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

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