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.

useMultiplayerState.ts 6.3KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  3. import * as React from 'react'
  4. import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
  5. import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
  6. import { LiveMap, LiveObject } from '@liveblocks/client'
  7. declare const window: Window & { app: TldrawApp }
  8. export function useMultiplayerState(roomId: string) {
  9. const [app, setApp] = React.useState<TldrawApp>()
  10. const [error, setError] = React.useState<Error>()
  11. const [loading, setLoading] = React.useState(true)
  12. const rExpectingUpdate = React.useRef(false)
  13. const room = useRoom()
  14. const onUndo = useUndo()
  15. const onRedo = useRedo()
  16. const updateMyPresence = useUpdateMyPresence()
  17. // Document Changes --------
  18. const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
  19. const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
  20. React.useEffect(() => {
  21. const unsubs: (() => void)[] = []
  22. if (!(app && room)) return
  23. // Handle errors
  24. unsubs.push(room.subscribe('error', (error) => setError(error)))
  25. // Handle changes to other users' presence
  26. unsubs.push(
  27. room.subscribe('others', (others) => {
  28. app.updateUsers(
  29. others
  30. .toArray()
  31. .filter((other) => other.presence)
  32. .map((other) => other.presence!.user)
  33. .filter(Boolean)
  34. )
  35. })
  36. )
  37. // Handle events from the room
  38. unsubs.push(
  39. room.subscribe(
  40. 'event',
  41. (e: { connectionId: number; event: { name: string; userId: string } }) => {
  42. switch (e.event.name) {
  43. case 'exit': {
  44. app?.removeUser(e.event.userId)
  45. break
  46. }
  47. }
  48. }
  49. )
  50. )
  51. // Send the exit event when the tab closes
  52. function handleExit() {
  53. if (!(room && app?.room)) return
  54. room?.broadcastEvent({ name: 'exit', userId: app.room.userId })
  55. }
  56. window.addEventListener('beforeunload', handleExit)
  57. unsubs.push(() => window.removeEventListener('beforeunload', handleExit))
  58. // Setup the document's storage and subscriptions
  59. async function setupDocument() {
  60. const storage = await room.getStorage<any>()
  61. // Initialize (get or create) shapes and bindings maps
  62. let lShapes: LiveMap<string, TDShape> = storage.root.get('shapes')
  63. if (!lShapes) {
  64. storage.root.set('shapes', new LiveMap<string, TDShape>())
  65. lShapes = storage.root.get('shapes')
  66. }
  67. rLiveShapes.current = lShapes
  68. let lBindings: LiveMap<string, TDBinding> = storage.root.get('bindings')
  69. if (!lBindings) {
  70. storage.root.set('bindings', new LiveMap<string, TDBinding>())
  71. lBindings = storage.root.get('bindings')
  72. }
  73. rLiveBindings.current = lBindings
  74. // Subscribe to changes
  75. function handleChanges() {
  76. if (rExpectingUpdate.current) {
  77. rExpectingUpdate.current = false
  78. return
  79. }
  80. app?.replacePageContent(
  81. Object.fromEntries(lShapes.entries()),
  82. Object.fromEntries(lBindings.entries())
  83. )
  84. }
  85. unsubs.push(room.subscribe(lShapes, handleChanges))
  86. unsubs.push(room.subscribe(lBindings, handleChanges))
  87. // Update the document with initial content
  88. handleChanges()
  89. // Migrate previous versions
  90. const version = storage.root.get('version')
  91. if (!version) {
  92. // The doc object will only be present if the document was created
  93. // prior to the current multiplayer implementation. At this time, the
  94. // document was a single LiveObject named 'doc'. If we find a doc,
  95. // then we need to move the shapes and bindings over to the new structures
  96. // and then mark the doc as migrated.
  97. const doc = storage.root.get('doc') as LiveObject<{
  98. uuid: string
  99. document: TDDocument
  100. migrated?: boolean
  101. }>
  102. // No doc? No problem. This was likely
  103. if (doc) {
  104. const {
  105. document: {
  106. pages: {
  107. page: { shapes, bindings },
  108. },
  109. },
  110. } = doc.toObject() as { document: TDDocument }
  111. for (const key in shapes) {
  112. const shape = shapes[key]
  113. lShapes.set(shape.id, shape)
  114. }
  115. for (const key in bindings) {
  116. const binding = bindings[key]
  117. lBindings.set(binding.id, binding)
  118. }
  119. }
  120. }
  121. // Save the version number for future migrations
  122. storage.root.set('version', 2)
  123. setLoading(false)
  124. }
  125. setupDocument()
  126. return () => {
  127. unsubs.forEach((unsub) => unsub())
  128. }
  129. }, [room, app])
  130. // Callbacks --------------
  131. // Put the state into the window, for debugging.
  132. const onMount = React.useCallback(
  133. (app: TldrawApp) => {
  134. app.loadRoom(roomId)
  135. app.pause() // Turn off the app's own undo / redo stack
  136. window.app = app
  137. setApp(app)
  138. },
  139. [roomId]
  140. )
  141. // Update the live shapes when the app's shapes change.
  142. const onChangePage = React.useCallback(
  143. (
  144. app: TldrawApp,
  145. shapes: Record<string, TDShape | undefined>,
  146. bindings: Record<string, TDBinding | undefined>
  147. ) => {
  148. room.batch(() => {
  149. const lShapes = rLiveShapes.current
  150. const lBindings = rLiveBindings.current
  151. if (!(lShapes && lBindings)) return
  152. for (const id in shapes) {
  153. const shape = shapes[id]
  154. if (!shape) {
  155. lShapes.delete(id)
  156. } else {
  157. lShapes.set(shape.id, shape)
  158. }
  159. }
  160. for (const id in bindings) {
  161. const binding = bindings[id]
  162. if (!binding) {
  163. lBindings.delete(id)
  164. } else {
  165. lBindings.set(binding.id, binding)
  166. }
  167. }
  168. rExpectingUpdate.current = true
  169. })
  170. },
  171. [room]
  172. )
  173. // Handle presence updates when the user's pointer / selection changes
  174. const onChangePresence = React.useCallback(
  175. (app: TldrawApp, user: TDUser) => {
  176. updateMyPresence({ id: app.room?.userId, user })
  177. },
  178. [updateMyPresence]
  179. )
  180. return {
  181. onUndo,
  182. onRedo,
  183. onMount,
  184. onChangePage,
  185. onChangePresence,
  186. error,
  187. loading,
  188. }
  189. }