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.1KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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()
  111. Object.values(shapes).forEach((shape) => lShapes.set(shape.id, shape))
  112. Object.values(bindings).forEach((binding) => lBindings.set(binding.id, binding))
  113. }
  114. }
  115. // Save the version number for future migrations
  116. storage.root.set('version', 2)
  117. setLoading(false)
  118. }
  119. setupDocument()
  120. return () => {
  121. unsubs.forEach((unsub) => unsub())
  122. }
  123. }, [room, app])
  124. // Callbacks --------------
  125. // Put the state into the window, for debugging.
  126. const onMount = React.useCallback(
  127. (app: TldrawApp) => {
  128. app.loadRoom(roomId)
  129. app.pause() // Turn off the app's own undo / redo stack
  130. window.app = app
  131. setApp(app)
  132. },
  133. [roomId]
  134. )
  135. // Update the live shapes when the app's shapes change.
  136. const onChangePage = React.useCallback(
  137. (
  138. app: TldrawApp,
  139. shapes: Record<string, TDShape | undefined>,
  140. bindings: Record<string, TDBinding | undefined>
  141. ) => {
  142. room.batch(() => {
  143. const lShapes = rLiveShapes.current
  144. const lBindings = rLiveBindings.current
  145. if (!(lShapes && lBindings)) return
  146. Object.entries(shapes).forEach(([id, shape]) => {
  147. if (!shape) {
  148. lShapes.delete(id)
  149. } else {
  150. lShapes.set(shape.id, shape)
  151. }
  152. })
  153. Object.entries(bindings).forEach(([id, binding]) => {
  154. if (!binding) {
  155. lBindings.delete(id)
  156. } else {
  157. lBindings.set(binding.id, binding)
  158. }
  159. })
  160. rExpectingUpdate.current = true
  161. })
  162. },
  163. [room]
  164. )
  165. // Handle presence updates when the user's pointer / selection changes
  166. const onChangePresence = React.useCallback(
  167. (app: TldrawApp, user: TDUser) => {
  168. updateMyPresence({ id: app.room?.userId, user })
  169. },
  170. [updateMyPresence]
  171. )
  172. return {
  173. onUndo,
  174. onRedo,
  175. onMount,
  176. onChangePage,
  177. onChangePresence,
  178. error,
  179. loading,
  180. }
  181. }