You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

MultiplayerEditor.tsx 4.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import { Tldraw, TldrawApp, TDDocument, TDUser, useFileSystem } from '@tldraw/tldraw'
  3. import * as React from 'react'
  4. import { createClient, Presence } from '@liveblocks/client'
  5. import { LiveblocksProvider, RoomProvider, useObject, useErrorListener } from '@liveblocks/react'
  6. import { Utils } from '@tldraw/core'
  7. import { useAccountHandlers } from '-hooks/useAccountHandlers'
  8. import { styled } from '-styles'
  9. declare const window: Window & { app: TldrawApp }
  10. interface TDUserPresence extends Presence {
  11. user: TDUser
  12. }
  13. const client = createClient({
  14. publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY || '',
  15. throttle: 80,
  16. })
  17. export default function MultiplayerEditor({
  18. roomId,
  19. isUser = false,
  20. isSponsor = false,
  21. }: {
  22. roomId: string
  23. isUser: boolean
  24. isSponsor: boolean
  25. }) {
  26. return (
  27. <LiveblocksProvider client={client}>
  28. <RoomProvider id={roomId} defaultStorageRoot={TldrawApp.defaultDocument}>
  29. <Editor roomId={roomId} isSponsor={isSponsor} isUser={isUser} />
  30. </RoomProvider>
  31. </LiveblocksProvider>
  32. )
  33. }
  34. // Inner Editor
  35. function Editor({
  36. roomId,
  37. isUser,
  38. isSponsor,
  39. }: {
  40. roomId: string
  41. isUser: boolean
  42. isSponsor: boolean
  43. }) {
  44. const [docId] = React.useState(() => Utils.uniqueId())
  45. const [app, setApp] = React.useState<TldrawApp>()
  46. const [error, setError] = React.useState<Error>()
  47. useErrorListener((err) => setError(err))
  48. // Setup document
  49. const doc = useObject<{ uuid: string; document: TDDocument }>('doc', {
  50. uuid: docId,
  51. document: {
  52. ...TldrawApp.defaultDocument,
  53. id: roomId,
  54. },
  55. })
  56. // Put the state into the window, for debugging.
  57. const handleMount = React.useCallback((app: TldrawApp) => {
  58. window.app = app
  59. setApp(app)
  60. }, [])
  61. // Setup client
  62. React.useEffect(() => {
  63. const room = client.getRoom(roomId)
  64. if (!room) return
  65. if (!doc) return
  66. if (!app) return
  67. app.loadRoom(roomId)
  68. // Subscribe to presence changes; when others change, update the state
  69. room.subscribe<TDUserPresence>('others', (others) => {
  70. app.updateUsers(
  71. others
  72. .toArray()
  73. .filter((other) => other.presence)
  74. .map((other) => other.presence!.user)
  75. .filter(Boolean)
  76. )
  77. })
  78. room.subscribe('event', (event) => {
  79. if (event.event?.name === 'exit') {
  80. app.removeUser(event.event.userId)
  81. }
  82. })
  83. function handleDocumentUpdates() {
  84. if (!doc) return
  85. if (!app?.room) return
  86. const docObject = doc.toObject()
  87. // Only merge the change if it caused by someone else
  88. if (docObject.uuid !== docId) {
  89. app.mergeDocument(docObject.document)
  90. } else {
  91. app.updateUsers(
  92. Object.values(app.room.users).map((user) => {
  93. return {
  94. ...user,
  95. selectedIds: user.selectedIds,
  96. }
  97. })
  98. )
  99. }
  100. }
  101. function handleExit() {
  102. if (!app?.room) return
  103. room?.broadcastEvent({ name: 'exit', userId: app.room.userId })
  104. }
  105. window.addEventListener('beforeunload', handleExit)
  106. // When the shared document changes, update the state
  107. doc.subscribe(handleDocumentUpdates)
  108. // Load the shared document
  109. const newDocument = doc.toObject().document
  110. if (newDocument) {
  111. app.loadDocument(newDocument)
  112. app.loadRoom(roomId)
  113. // Update the user's presence with the user from state
  114. if (app.state.room) {
  115. const { users, userId } = app.state.room
  116. room.updatePresence({ id: userId, user: users[userId] })
  117. }
  118. }
  119. return () => {
  120. window.removeEventListener('beforeunload', handleExit)
  121. doc.unsubscribe(handleDocumentUpdates)
  122. }
  123. }, [doc, docId, app, roomId])
  124. const handlePersist = React.useCallback(
  125. (app: TldrawApp) => {
  126. doc?.update({ uuid: docId, document: app.document })
  127. },
  128. [docId, doc]
  129. )
  130. const handleUserChange = React.useCallback(
  131. (app: TldrawApp, user: TDUser) => {
  132. const room = client.getRoom(roomId)
  133. room?.updatePresence({ id: app.room?.userId, user })
  134. },
  135. [roomId]
  136. )
  137. const fileSystemEvents = useFileSystem()
  138. const { onSignIn, onSignOut } = useAccountHandlers()
  139. if (error) return <LoadingScreen>Error: {error.message}</LoadingScreen>
  140. if (doc === null) return <LoadingScreen>Loading...</LoadingScreen>
  141. return (
  142. <div className="tldraw">
  143. <Tldraw
  144. autofocus
  145. onMount={handleMount}
  146. onPersist={handlePersist}
  147. onUserChange={handleUserChange}
  148. showPages={false}
  149. showSponsorLink={isSponsor}
  150. onSignIn={isSponsor ? undefined : onSignIn}
  151. onSignOut={isUser ? onSignOut : undefined}
  152. {...fileSystemEvents}
  153. />
  154. </div>
  155. )
  156. }
  157. const LoadingScreen = styled('div', {
  158. position: 'absolute',
  159. top: 0,
  160. left: 0,
  161. width: '100%',
  162. height: '100%',
  163. display: 'flex',
  164. alignItems: 'center',
  165. justifyContent: 'center',
  166. })