Browse Source

[improvement] refactor multiplayer (#336)

* Move rko into library, improve multiplayer example

* Add presence layer

* extract to a hook

* Migrate old documents to new structures

* Update repo-map.tldr

* More improvements

* Fix bug on deleted shapes

* Update MultiplayerEditor.tsx
main
Steve Ruiz 3 years ago
parent
commit
5e6a6c9967
No account linked to committer's email address

+ 6
- 134
apps/www/components/MultiplayerEditor.tsx View File

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

+ 217
- 0
apps/www/hooks/useMultiplayerState.ts View File

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

+ 2
- 2
apps/www/package.json View File

@@ -18,8 +18,8 @@
18 18
     "lint": "next lint"
19 19
   },
20 20
   "dependencies": {
21
-    "@liveblocks/client": "^0.12.3",
22
-    "@liveblocks/react": "^0.12.3",
21
+    "@liveblocks/client": "^0.13.0-beta.1",
22
+    "@liveblocks/react": "^0.13.0-beta.1",
23 23
     "@sentry/integrations": "^6.13.2",
24 24
     "@sentry/node": "^6.13.2",
25 25
     "@sentry/react": "^6.13.2",

+ 6
- 8
examples/tldraw-example/package.json View File

@@ -15,8 +15,8 @@
15 15
     "build": "node scripts/build.mjs"
16 16
   },
17 17
   "devDependencies": {
18
-    "@liveblocks/client": "^0.12.3",
19
-    "@liveblocks/react": "^0.12.3",
18
+    "@liveblocks/client": "^0.13.0-beta.1",
19
+    "@liveblocks/react": "0.13.0-beta.1",
20 20
     "@tldraw/tldraw": "^1.1.2",
21 21
     "@types/node": "^14.14.35",
22 22
     "@types/react": "^16.9.55",
@@ -31,11 +31,9 @@
31 31
     "react-router": "^5.2.1",
32 32
     "react-router-dom": "^5.3.0",
33 33
     "rimraf": "3.0.2",
34
-    "typescript": "4.2.3"
35
-  },
36
-  "gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6",
37
-  "dependencies": {
34
+    "typescript": "4.2.3",
38 35
     "react": "^17.0.2",
39 36
     "react-dom": "^17.0.2"
40
-  }
41
-}
37
+  },
38
+  "gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6"
39
+}

+ 8
- 133
examples/tldraw-example/src/multiplayer/multiplayer.tsx View File

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

+ 217
- 0
examples/tldraw-example/src/multiplayer/useMultiplayerState.ts View File

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

+ 3
- 2
packages/tldraw/package.json View File

@@ -52,10 +52,11 @@
52 52
     "@tldraw/core": "^1.1.2",
53 53
     "@tldraw/intersect": "latest",
54 54
     "@tldraw/vec": "latest",
55
+    "idb-keyval": "^6.0.3",
55 56
     "perfect-freehand": "^1.0.16",
56 57
     "react-hotkeys-hook": "^3.4.0",
57
-    "rko": "^0.6.5",
58
-    "tslib": "^2.3.1"
58
+    "tslib": "^2.3.1",
59
+    "zustand": "^3.6.5"
59 60
   },
60 61
   "devDependencies": {
61 62
     "@swc-node/jest": "^1.3.3",

+ 22
- 12
packages/tldraw/src/Tldraw.tsx View File

@@ -2,7 +2,7 @@ import * as React from 'react'
2 2
 import { IdProvider } from '@radix-ui/react-id'
3 3
 import { Renderer } from '@tldraw/core'
4 4
 import { styled, dark } from '~styles'
5
-import { TDDocument, TDStatus, TDUser } from '~types'
5
+import { TDDocument, TDShape, TDBinding, TDStatus, TDUser } from '~types'
6 6
 import { TldrawApp, TDCallbacks } from '~state'
7 7
 import { TldrawContext, useStylesheet, useKeyboardShortcuts, useTldrawApp } from '~hooks'
8 8
 import { shapeUtils } from '~state/shapes'
@@ -116,7 +116,7 @@ export interface TldrawProps extends TDCallbacks {
116 116
   /**
117 117
    * (optional) A callback to run when the user creates a new project.
118 118
    */
119
-  onUserChange?: (state: TldrawApp, user: TDUser) => void
119
+  onChangePresence?: (state: TldrawApp, user: TDUser) => void
120 120
   /**
121 121
    * (optional) A callback to run when the component's state changes.
122 122
    */
@@ -141,6 +141,12 @@ export interface TldrawProps extends TDCallbacks {
141 141
    * (optional) A callback to run when the user redos.
142 142
    */
143 143
   onRedo?: (state: TldrawApp) => void
144
+
145
+  onChangePage?: (
146
+    app: TldrawApp,
147
+    shapes: Record<string, TDShape | undefined>,
148
+    bindings: Record<string, TDBinding | undefined>
149
+  ) => void
144 150
 }
145 151
 
146 152
 export function Tldraw({
@@ -159,7 +165,7 @@ export function Tldraw({
159 165
   showSponsorLink = false,
160 166
   onMount,
161 167
   onChange,
162
-  onUserChange,
168
+  onChangePresence,
163 169
   onNewProject,
164 170
   onSaveProject,
165 171
   onSaveProjectAs,
@@ -171,6 +177,7 @@ export function Tldraw({
171 177
   onPersist,
172 178
   onPatch,
173 179
   onCommand,
180
+  onChangePage,
174 181
 }: TldrawProps) {
175 182
   const [sId, setSId] = React.useState(id)
176 183
 
@@ -180,7 +187,7 @@ export function Tldraw({
180 187
       new TldrawApp(id, {
181 188
         onMount,
182 189
         onChange,
183
-        onUserChange,
190
+        onChangePresence,
184 191
         onNewProject,
185 192
         onSaveProject,
186 193
         onSaveProjectAs,
@@ -189,9 +196,10 @@ export function Tldraw({
189 196
         onSignIn,
190 197
         onUndo,
191 198
         onRedo,
199
+        onPersist,
192 200
         onPatch,
193 201
         onCommand,
194
-        onPersist,
202
+        onChangePage,
195 203
       })
196 204
   )
197 205
 
@@ -202,7 +210,7 @@ export function Tldraw({
202 210
     const newApp = new TldrawApp(id, {
203 211
       onMount,
204 212
       onChange,
205
-      onUserChange,
213
+      onChangePresence,
206 214
       onNewProject,
207 215
       onSaveProject,
208 216
       onSaveProjectAs,
@@ -211,9 +219,10 @@ export function Tldraw({
211 219
       onSignIn,
212 220
       onUndo,
213 221
       onRedo,
222
+      onPersist,
214 223
       onPatch,
215 224
       onCommand,
216
-      onPersist,
225
+      onChangePage,
217 226
     })
218 227
 
219 228
     setSId(id)
@@ -256,7 +265,7 @@ export function Tldraw({
256 265
     app.callbacks = {
257 266
       onMount,
258 267
       onChange,
259
-      onUserChange,
268
+      onChangePresence,
260 269
       onNewProject,
261 270
       onSaveProject,
262 271
       onSaveProjectAs,
@@ -265,15 +274,15 @@ export function Tldraw({
265 274
       onSignIn,
266 275
       onUndo,
267 276
       onRedo,
277
+      onPersist,
268 278
       onPatch,
269 279
       onCommand,
270
-      onPersist,
280
+      onChangePage,
271 281
     }
272 282
   }, [
273
-    app,
274 283
     onMount,
275 284
     onChange,
276
-    onUserChange,
285
+    onChangePresence,
277 286
     onNewProject,
278 287
     onSaveProject,
279 288
     onSaveProjectAs,
@@ -282,9 +291,10 @@ export function Tldraw({
282 291
     onSignIn,
283 292
     onUndo,
284 293
     onRedo,
294
+    onPersist,
285 295
     onPatch,
286 296
     onCommand,
287
-    onPersist,
297
+    onChangePage,
288 298
   ])
289 299
 
290 300
   // Use the `key` to ensure that new selector hooks are made when the id changes

+ 419
- 0
packages/tldraw/src/state/StateManager/StateManager.ts View File

@@ -0,0 +1,419 @@
1
+import createVanilla, { StoreApi } from 'zustand/vanilla'
2
+import create, { UseBoundStore } from 'zustand'
3
+import * as idb from 'idb-keyval'
4
+import { deepCopy } from './copy'
5
+import { merge } from './merge'
6
+import type { Patch, Command } from '../../types'
7
+
8
+export class StateManager<T extends Record<string, any>> {
9
+  /**
10
+   * An ID used to persist state in indexdb.
11
+   */
12
+  protected _idbId?: string
13
+
14
+  /**
15
+   * The initial state.
16
+   */
17
+  private initialState: T
18
+
19
+  /**
20
+   * A zustand store that also holds the state.
21
+   */
22
+  private store: StoreApi<T>
23
+
24
+  /**
25
+   * The index of the current command.
26
+   */
27
+  protected pointer = -1
28
+
29
+  /**
30
+   * The current state.
31
+   */
32
+  private _state: T
33
+
34
+  /**
35
+   * The state manager's current status, with regard to restoring persisted state.
36
+   */
37
+  private _status: 'loading' | 'ready' = 'loading'
38
+
39
+  /**
40
+   * A stack of commands used for history (undo and redo).
41
+   */
42
+  protected stack: Command<T>[] = []
43
+
44
+  /**
45
+   * A snapshot of the current state.
46
+   */
47
+  protected _snapshot: T
48
+
49
+  /**
50
+   * A React hook for accessing the zustand store.
51
+   */
52
+  public readonly useStore: UseBoundStore<T>
53
+
54
+  /**
55
+   * A promise that will resolve when the state manager has loaded any peristed state.
56
+   */
57
+  public ready: Promise<'none' | 'restored' | 'migrated'>
58
+
59
+  public isPaused = false
60
+
61
+  constructor(
62
+    initialState: T,
63
+    id?: string,
64
+    version?: number,
65
+    update?: (prev: T, next: T, prevVersion: number) => T
66
+  ) {
67
+    this._idbId = id
68
+    this._state = deepCopy(initialState)
69
+    this._snapshot = deepCopy(initialState)
70
+    this.initialState = deepCopy(initialState)
71
+    this.store = createVanilla(() => this._state)
72
+    this.useStore = create(this.store)
73
+
74
+    this.ready = new Promise<'none' | 'restored' | 'migrated'>((resolve) => {
75
+      let message: 'none' | 'restored' | 'migrated' = 'none'
76
+
77
+      if (this._idbId) {
78
+        message = 'restored'
79
+
80
+        idb
81
+          .get(this._idbId)
82
+          .then(async (saved) => {
83
+            if (saved) {
84
+              let next = saved
85
+
86
+              if (version) {
87
+                const savedVersion = await idb.get<number>(id + '_version')
88
+
89
+                if (savedVersion && savedVersion < version) {
90
+                  next = update ? update(saved, initialState, savedVersion) : initialState
91
+
92
+                  message = 'migrated'
93
+                }
94
+              }
95
+
96
+              await idb.set(id + '_version', version || -1)
97
+
98
+              this._state = deepCopy(next)
99
+              this._snapshot = deepCopy(next)
100
+              this.store.setState(this._state, true)
101
+            } else {
102
+              await idb.set(id + '_version', version || -1)
103
+            }
104
+            this._status = 'ready'
105
+            resolve(message)
106
+          })
107
+          .catch((e) => console.error(e))
108
+      } else {
109
+        // We need to wait for any override to `onReady` to take effect.
110
+        this._status = 'ready'
111
+        resolve(message)
112
+      }
113
+
114
+      resolve(message)
115
+    }).then((message) => {
116
+      if (this.onReady) this.onReady(message)
117
+      return message
118
+    })
119
+  }
120
+
121
+  /**
122
+   * Save the current state to indexdb.
123
+   */
124
+  protected persist = (id?: string): void | Promise<void> => {
125
+    if (this.onPersist) {
126
+      this.onPersist(this._state, id)
127
+    }
128
+
129
+    if (this._idbId) {
130
+      return idb.set(this._idbId, this._state).catch((e) => console.error(e))
131
+    }
132
+  }
133
+
134
+  /**
135
+   * Apply a patch to the current state.
136
+   * This does not effect the undo/redo stack.
137
+   * This does not persist the state.
138
+   * @param patch The patch to apply.
139
+   * @param id (optional) An id for the patch.
140
+   */
141
+  private applyPatch = (patch: Patch<T>, id?: string) => {
142
+    const prev = this._state
143
+    const next = merge(this._state, patch)
144
+    const final = this.cleanup(next, prev, patch, id)
145
+    if (this.onStateWillChange) {
146
+      this.onStateWillChange(final, id)
147
+    }
148
+    this._state = final
149
+    this.store.setState(this._state, true)
150
+    if (this.onStateDidChange) {
151
+      this.onStateDidChange(this._state, id)
152
+    }
153
+    return this
154
+  }
155
+
156
+  // Internal API ---------------------------------
157
+
158
+  /**
159
+   * Perform any last changes to the state before updating.
160
+   * Override this on your extending class.
161
+   * @param nextState The next state.
162
+   * @param prevState The previous state.
163
+   * @param patch The patch that was just applied.
164
+   * @param id (optional) An id for the just-applied patch.
165
+   * @returns The final new state to apply.
166
+   */
167
+  protected cleanup = (nextState: T, prevState: T, patch: Patch<T>, id?: string): T => nextState
168
+
169
+  /**
170
+   * A life-cycle method called when the state is about to change.
171
+   * @param state The next state.
172
+   * @param id An id for the change.
173
+   */
174
+  protected onStateWillChange?: (state: T, id?: string) => void
175
+
176
+  /**
177
+   * A life-cycle method called when the state has changed.
178
+   * @param state The next state.
179
+   * @param id An id for the change.
180
+   */
181
+  protected onStateDidChange?: (state: T, id?: string) => void
182
+
183
+  /**
184
+   * Apply a patch to the current state.
185
+   * This does not effect the undo/redo stack.
186
+   * This does not persist the state.
187
+   * @param patch The patch to apply.
188
+   * @param id (optional) An id for this patch.
189
+   */
190
+  protected patchState = (patch: Patch<T>, id?: string): this => {
191
+    this.applyPatch(patch, id)
192
+    if (this.onPatch) {
193
+      this.onPatch(this._state, id)
194
+    }
195
+    return this
196
+  }
197
+
198
+  /**
199
+   * Replace the current state.
200
+   * This does not effect the undo/redo stack.
201
+   * This does not persist the state.
202
+   * @param state The new state.
203
+   * @param id An id for this change.
204
+   */
205
+  protected replaceState = (state: T, id?: string): this => {
206
+    const final = this.cleanup(state, this._state, state, id)
207
+    if (this.onStateWillChange) {
208
+      this.onStateWillChange(final, 'replace')
209
+    }
210
+    this._state = final
211
+    this.store.setState(this._state, true)
212
+    if (this.onStateDidChange) {
213
+      this.onStateDidChange(this._state, 'replace')
214
+    }
215
+    return this
216
+  }
217
+
218
+  /**
219
+   * Update the state using a Command.
220
+   * This effects the undo/redo stack.
221
+   * This persists the state.
222
+   * @param command The command to apply and add to the undo/redo stack.
223
+   * @param id (optional) An id for this command.
224
+   */
225
+  protected setState = (command: Command<T>, id = command.id) => {
226
+    if (this.pointer < this.stack.length - 1) {
227
+      this.stack = this.stack.slice(0, this.pointer + 1)
228
+    }
229
+    this.stack.push({ ...command, id })
230
+    this.pointer = this.stack.length - 1
231
+    this.applyPatch(command.after, id)
232
+    if (this.onCommand) this.onCommand(this._state, id)
233
+    this.persist(id)
234
+    return this
235
+  }
236
+
237
+  // Public API ---------------------------------
238
+
239
+  public pause() {
240
+    this.isPaused = true
241
+  }
242
+
243
+  public resume() {
244
+    this.isPaused = false
245
+  }
246
+
247
+  /**
248
+   * A callback fired when the constructor finishes loading any
249
+   * persisted data.
250
+   */
251
+  protected onReady?: (message: 'none' | 'restored' | 'migrated') => void
252
+
253
+  /**
254
+   * A callback fired when a patch is applied.
255
+   */
256
+  public onPatch?: (state: T, id?: string) => void
257
+
258
+  /**
259
+   * A callback fired when a patch is applied.
260
+   */
261
+  public onCommand?: (state: T, id?: string) => void
262
+
263
+  /**
264
+   * A callback fired when the state is persisted.
265
+   */
266
+  public onPersist?: (state: T, id?: string) => void
267
+
268
+  /**
269
+   * A callback fired when the state is replaced.
270
+   */
271
+  public onReplace?: (state: T) => void
272
+
273
+  /**
274
+   * A callback fired when the state is reset.
275
+   */
276
+  public onReset?: (state: T) => void
277
+
278
+  /**
279
+   * A callback fired when the history is reset.
280
+   */
281
+  public onResetHistory?: (state: T) => void
282
+
283
+  /**
284
+   * A callback fired when a command is undone.
285
+   */
286
+  public onUndo?: (state: T) => void
287
+
288
+  /**
289
+   * A callback fired when a command is redone.
290
+   */
291
+  public onRedo?: (state: T) => void
292
+
293
+  /**
294
+   * Reset the state to the initial state and reset history.
295
+   */
296
+  public reset = () => {
297
+    if (this.onStateWillChange) {
298
+      this.onStateWillChange(this.initialState, 'reset')
299
+    }
300
+    this._state = this.initialState
301
+    this.store.setState(this._state, true)
302
+    this.resetHistory()
303
+    this.persist('reset')
304
+    if (this.onStateDidChange) {
305
+      this.onStateDidChange(this._state, 'reset')
306
+    }
307
+    if (this.onReset) {
308
+      this.onReset(this._state)
309
+    }
310
+    return this
311
+  }
312
+
313
+  /**
314
+   * Force replace a new undo/redo history. It's your responsibility
315
+   * to make sure that this is compatible with the current state!
316
+   * @param history The new array of commands.
317
+   * @param pointer (optional) The new pointer position.
318
+   */
319
+  public replaceHistory = (history: Command<T>[], pointer = history.length - 1): this => {
320
+    this.stack = history
321
+    this.pointer = pointer
322
+    if (this.onReplace) {
323
+      this.onReplace(this._state)
324
+    }
325
+    return this
326
+  }
327
+
328
+  /**
329
+   * Reset the history stack (without resetting the state).
330
+   */
331
+  public resetHistory = (): this => {
332
+    this.stack = []
333
+    this.pointer = -1
334
+    if (this.onResetHistory) {
335
+      this.onResetHistory(this._state)
336
+    }
337
+    return this
338
+  }
339
+
340
+  /**
341
+   * Move backward in the undo/redo stack.
342
+   */
343
+  public undo = (): this => {
344
+    if (!this.isPaused) {
345
+      if (!this.canUndo) return this
346
+      const command = this.stack[this.pointer]
347
+      this.pointer--
348
+      this.applyPatch(command.before, `undo`)
349
+      this.persist('undo')
350
+    }
351
+    if (this.onUndo) this.onUndo(this._state)
352
+    return this
353
+  }
354
+
355
+  /**
356
+   * Move forward in the undo/redo stack.
357
+   */
358
+  public redo = (): this => {
359
+    if (!this.isPaused) {
360
+      if (!this.canRedo) return this
361
+      this.pointer++
362
+      const command = this.stack[this.pointer]
363
+      this.applyPatch(command.after, 'redo')
364
+      this.persist('undo')
365
+    }
366
+    if (this.onRedo) this.onRedo(this._state)
367
+    return this
368
+  }
369
+
370
+  /**
371
+   * Save a snapshot of the current state, accessible at `this.snapshot`.
372
+   */
373
+  public setSnapshot = (): this => {
374
+    this._snapshot = { ...this._state }
375
+    return this
376
+  }
377
+
378
+  /**
379
+   * Force the zustand state to update.
380
+   */
381
+  public forceUpdate = () => {
382
+    this.store.setState(this._state, true)
383
+  }
384
+
385
+  /**
386
+   * Get whether the state manager can undo.
387
+   */
388
+  public get canUndo(): boolean {
389
+    return this.pointer > -1
390
+  }
391
+
392
+  /**
393
+   * Get whether the state manager can redo.
394
+   */
395
+  public get canRedo(): boolean {
396
+    return this.pointer < this.stack.length - 1
397
+  }
398
+
399
+  /**
400
+   * The current state.
401
+   */
402
+  public get state(): T {
403
+    return this._state
404
+  }
405
+
406
+  /**
407
+   * The current status.
408
+   */
409
+  public get status(): string {
410
+    return this._status
411
+  }
412
+
413
+  /**
414
+   * The most-recent snapshot.
415
+   */
416
+  protected get snapshot(): T {
417
+    return this._snapshot
418
+  }
419
+}

+ 41
- 0
packages/tldraw/src/state/StateManager/copy.ts View File

@@ -0,0 +1,41 @@
1
+/**
2
+ * Deep copy function for TypeScript.
3
+ * @param T Generic type of target/copied value.
4
+ * @param target Target value to be copied.
5
+ * @see Source project, ts-deeply https://github.com/ykdr2017/ts-deepcopy
6
+ * @see Code pen https://codepen.io/erikvullings/pen/ejyBYg
7
+ */
8
+export function deepCopy<T>(target: T): T {
9
+  if (target === null) {
10
+    return target
11
+  }
12
+  if (target instanceof Date) {
13
+    return new Date(target.getTime()) as any
14
+  }
15
+
16
+  // First part is for array and second part is for Realm.Collection
17
+  // if (target instanceof Array || typeof (target as any).type === 'string') {
18
+  if (typeof target === 'object') {
19
+    if (typeof target[Symbol.iterator as keyof T] === 'function') {
20
+      const cp = [] as any[]
21
+      if ((target as any as any[]).length > 0) {
22
+        for (const arrayMember of target as any as any[]) {
23
+          cp.push(deepCopy(arrayMember))
24
+        }
25
+      }
26
+      return cp as any as T
27
+    } else {
28
+      const targetKeys = Object.keys(target)
29
+      const cp = {} as T
30
+      if (targetKeys.length > 0) {
31
+        for (const key of targetKeys) {
32
+          cp[key as keyof T] = deepCopy(target[key as keyof T])
33
+        }
34
+      }
35
+      return cp
36
+    }
37
+  }
38
+
39
+  // Means that object is atomic
40
+  return target
41
+}

+ 1
- 0
packages/tldraw/src/state/StateManager/index.ts View File

@@ -0,0 +1 @@
1
+export * from './StateManager'

+ 19
- 0
packages/tldraw/src/state/StateManager/merge.ts View File

@@ -0,0 +1,19 @@
1
+import type { Patch } from '../../types'
2
+
3
+/**
4
+ * Recursively merge an object with a deep partial of the same type.
5
+ * @param target The original complete object.
6
+ * @param patch The deep partial to merge with the original object.
7
+ */
8
+
9
+export function merge<T>(target: T, patch: Patch<T>): T {
10
+  const result: T = { ...target }
11
+
12
+  const entries = Object.entries(patch) as [keyof T, T[keyof T]][]
13
+
14
+  for (const [key, value] of entries)
15
+    result[key] =
16
+      value === Object(value) && !Array.isArray(value) ? merge(result[key], value) : value
17
+
18
+  return result
19
+}

+ 142
- 51
packages/tldraw/src/state/TldrawApp.ts View File

@@ -1,7 +1,6 @@
1 1
 /* eslint-disable @typescript-eslint/no-explicit-any */
2 2
 /* eslint-disable @typescript-eslint/ban-ts-comment */
3 3
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
4
-import { StateManager } from 'rko'
5 4
 import { Vec } from '@tldraw/vec'
6 5
 import {
7 6
   TLBoundsEventHandler,
@@ -59,6 +58,7 @@ import { RectangleTool } from './tools/RectangleTool'
59 58
 import { LineTool } from './tools/LineTool'
60 59
 import { ArrowTool } from './tools/ArrowTool'
61 60
 import { StickyTool } from './tools/StickyTool'
61
+import { StateManager } from './StateManager'
62 62
 
63 63
 const uuid = Utils.uniqueId()
64 64
 
@@ -95,10 +95,6 @@ export interface TDCallbacks {
95 95
    * (optional) A callback to run when the user signs out via the menu.
96 96
    */
97 97
   onSignOut?: (state: TldrawApp) => void
98
-  /**
99
-   * (optional) A callback to run when the user creates a new project.
100
-   */
101
-  onUserChange?: (state: TldrawApp, user: TDUser) => void
102 98
   /**
103 99
    * (optional) A callback to run when the state is patched.
104 100
    */
@@ -119,6 +115,18 @@ export interface TDCallbacks {
119 115
    * (optional) A callback to run when the user redos.
120 116
    */
121 117
   onRedo?: (state: TldrawApp) => void
118
+  /**
119
+   * (optional) A callback to run when the user changes the current page's shapes.
120
+   */
121
+  onChangePage?: (
122
+    app: TldrawApp,
123
+    shapes: Record<string, TDShape | undefined>,
124
+    bindings: Record<string, TDBinding | undefined>
125
+  ) => void
126
+  /**
127
+   * (optional) A callback to run when the user creates a new project.
128
+   */
129
+  onChangePresence?: (state: TldrawApp, user: TDUser) => void
122 130
 }
123 131
 
124 132
 export class TldrawApp extends StateManager<TDSnapshot> {
@@ -264,6 +272,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
264 272
 
265 273
         const prevPage = prev.document.pages[pageId]
266 274
 
275
+        const changedShapes: Record<string, TDShape | undefined> = {}
276
+
267 277
         if (!prevPage || page.shapes !== prevPage.shapes || page.bindings !== prevPage.bindings) {
268 278
           page.shapes = { ...page.shapes }
269 279
           page.bindings = { ...page.bindings }
@@ -275,12 +285,18 @@ export class TldrawApp extends StateManager<TDSnapshot> {
275 285
             let parentId: string
276 286
 
277 287
             if (!shape) {
278
-              parentId = prevPage.shapes[id]?.parentId
288
+              parentId = prevPage?.shapes[id]?.parentId
279 289
               delete page.shapes[id]
280 290
             } else {
281 291
               parentId = shape.parentId
282 292
             }
283 293
 
294
+            if (page.id === next.appState.currentPageId) {
295
+              if (prevPage?.shapes[id] !== shape) {
296
+                changedShapes[id] = shape
297
+              }
298
+            }
299
+
284 300
             // If the shape is the child of a group, then update the group
285 301
             // (unless the group is being deleted too)
286 302
             if (parentId && parentId !== pageId) {
@@ -298,15 +314,15 @@ export class TldrawApp extends StateManager<TDSnapshot> {
298 314
             }
299 315
           })
300 316
 
301
-          // Find which shapes have changed
302
-          const changedShapeIds = Object.values(page.shapes)
303
-            .filter((shape) => prevPage?.shapes[shape.id] !== shape)
304
-            .map((shape) => shape.id)
305
-
306 317
           next.document.pages[pageId] = page
307 318
 
319
+          // Find which shapes have changed
320
+          // const changedShapes = Object.entries(page.shapes).filter(
321
+          //   ([id, shape]) => prevPage?.shapes[shape.id] !== shape
322
+          // )
323
+
308 324
           // Get bindings related to the changed shapes
309
-          const bindingsToUpdate = TLDR.getRelatedBindings(next, changedShapeIds, pageId)
325
+          const bindingsToUpdate = TLDR.getRelatedBindings(next, Object.keys(changedShapes), pageId)
310 326
 
311 327
           // Update all of the bindings we've just collected
312 328
           bindingsToUpdate.forEach((binding) => {
@@ -453,9 +469,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
453 469
   }
454 470
 
455 471
   onPersist = () => {
456
-    this.callbacks.onPersist?.(this)
472
+    this.broadcastPageChanges()
457 473
   }
458 474
 
475
+  private prevSelectedIds = this.selectedIds
476
+
459 477
   /**
460 478
    * Clear the selection history after each new command, undo or redo.
461 479
    * @param state
@@ -463,21 +481,118 @@ export class TldrawApp extends StateManager<TDSnapshot> {
463 481
    */
464 482
   protected onStateDidChange = (_state: TDSnapshot, id?: string): void => {
465 483
     this.callbacks.onChange?.(this, id)
484
+
485
+    if (this.room && this.selectedIds !== this.prevSelectedIds) {
486
+      this.callbacks.onChangePresence?.(this, {
487
+        ...this.room.users[this.room.userId],
488
+        selectedIds: this.selectedIds,
489
+      })
490
+      this.prevSelectedIds = this.selectedIds
491
+    }
492
+  }
493
+
494
+  /* ----------- Managing Multiplayer State ----------- */
495
+
496
+  private prevShapes = this.page.shapes
497
+  private prevBindings = this.page.bindings
498
+
499
+  private broadcastPageChanges = () => {
500
+    const visited = new Set<string>()
501
+
502
+    const changedShapes: Record<string, TDShape | undefined> = {}
503
+    const changedBindings: Record<string, TDBinding | undefined> = {}
504
+
505
+    this.shapes.forEach((shape) => {
506
+      visited.add(shape.id)
507
+      if (this.prevShapes[shape.id] !== shape) {
508
+        changedShapes[shape.id] = shape
509
+      }
510
+    })
511
+
512
+    Object.keys(this.prevShapes)
513
+      .filter((id) => !visited.has(id))
514
+      .forEach((id) => {
515
+        changedShapes[id] = undefined
516
+      })
517
+
518
+    this.bindings.forEach((binding) => {
519
+      visited.add(binding.id)
520
+      if (this.prevBindings[binding.id] !== binding) {
521
+        changedBindings[binding.id] = binding
522
+      }
523
+    })
524
+
525
+    Object.keys(this.prevShapes)
526
+      .filter((id) => !visited.has(id))
527
+      .forEach((id) => {
528
+        changedBindings[id] = undefined
529
+      })
530
+
531
+    this.callbacks.onChangePage?.(this, changedShapes, changedBindings)
532
+
533
+    this.callbacks.onPersist?.(this)
534
+    this.prevShapes = this.page.shapes
535
+    this.prevBindings = this.page.bindings
466 536
   }
467 537
 
468
-  // if (id && !id.startsWith('patch')) {
469
-  //   if (!id.startsWith('replace')) {
470
-  //     // If we've changed the undo stack, then the file is out of
471
-  //     // sync with any saved version on the file system.
472
-  //     this.isDirty = true
473
-  //   }
474
-  //   this.clearSelectHistory()
475
-  // }
476
-  // if (id.startsWith('undo') || id.startsWith('redo')) {
477
-  //   Session.cache.selectedIds = [...this.selectedIds]
478
-  // }
479
-  // this.onChange?.(this, id)
480
-  // }
538
+  /**
539
+   * Manually patch a set of shapes.
540
+   * @param shapes An array of shape partials, containing the changes to be made to each shape.
541
+   * @command
542
+   */
543
+  public replacePageContent = (
544
+    shapes: Record<string, TDShape>,
545
+    bindings: Record<string, TDBinding>,
546
+    pageId = this.currentPageId
547
+  ): this => {
548
+    this.useStore.setState((current) => {
549
+      const { hoveredId, editingId, bindingId, selectedIds } = current.document.pageStates[pageId]
550
+
551
+      const next = {
552
+        ...current,
553
+        document: {
554
+          ...current.document,
555
+          pages: {
556
+            [pageId]: {
557
+              ...current.document.pages[pageId],
558
+              shapes,
559
+              bindings,
560
+            },
561
+          },
562
+          pageStates: {
563
+            ...current.document.pageStates,
564
+            [pageId]: {
565
+              ...current.document.pageStates[pageId],
566
+              selectedIds: selectedIds.filter((id) => shapes[id] !== undefined),
567
+              hoveredId: hoveredId
568
+                ? shapes[hoveredId] === undefined
569
+                  ? undefined
570
+                  : hoveredId
571
+                : undefined,
572
+              editingId: editingId
573
+                ? shapes[editingId] === undefined
574
+                  ? undefined
575
+                  : hoveredId
576
+                : undefined,
577
+              bindingId: bindingId
578
+                ? bindings[bindingId] === undefined
579
+                  ? undefined
580
+                  : bindingId
581
+                : undefined,
582
+            },
583
+          },
584
+        },
585
+      }
586
+
587
+      this.state.document = next.document
588
+      this.prevShapes = next.document.pages[this.currentPageId].shapes
589
+      this.prevBindings = next.document.pages[this.currentPageId].bindings
590
+
591
+      return next
592
+    }, true)
593
+
594
+    return this
595
+  }
481 596
 
482 597
   /**
483 598
    * Set the current status.
@@ -1763,15 +1878,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
1763 1878
   private setSelectedIds = (ids: string[], push = false): this => {
1764 1879
     const nextIds = push ? [...this.pageState.selectedIds, ...ids] : [...ids]
1765 1880
 
1766
-    if (this.state.room) {
1767
-      const { users, userId } = this.state.room
1768
-
1769
-      this.callbacks.onUserChange?.(this, {
1770
-        ...users[userId],
1771
-        selectedIds: nextIds,
1772
-      })
1773
-    }
1774
-
1775 1881
     return this.patchState(
1776 1882
       {
1777 1883
         appState: {
@@ -2065,21 +2171,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
2065 2171
     )
2066 2172
   }
2067 2173
 
2068
-  /**
2069
-   * Manually patch a set of shapes.
2070
-   * @param shapes An array of shape partials, containing the changes to be made to each shape.
2071
-   * @command
2072
-   */
2073
-  patchShapes = (...shapes: ({ id: string } & Partial<TDShape>)[]): this => {
2074
-    const pageShapes = this.document.pages[this.currentPageId].shapes
2075
-    const shapesToUpdate = shapes.filter((shape) => pageShapes[shape.id])
2076
-    if (shapesToUpdate.length === 0) return this
2077
-    return this.patchState(
2078
-      Commands.updateShapes(this, shapesToUpdate, this.currentPageId).after,
2079
-      'updated_shapes'
2080
-    )
2081
-  }
2082
-
2083 2174
   createTextShapeAtPoint(point: number[], id?: string): this {
2084 2175
     const {
2085 2176
       shapes,
@@ -2521,7 +2612,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
2521 2612
     if (this.state.room) {
2522 2613
       const { users, userId } = this.state.room
2523 2614
 
2524
-      this.callbacks.onUserChange?.(this, {
2615
+      this.callbacks.onChangePresence?.(this, {
2525 2616
         ...users[userId],
2526 2617
         point: this.getPagePoint(info.point),
2527 2618
       })

+ 1
- 2
packages/tldraw/src/state/commands/createShapes/createShapes.ts View File

@@ -1,5 +1,4 @@
1
-import type { Patch } from 'rko'
2
-import type { TDShape, TldrawCommand, TDBinding } from '~types'
1
+import type { Patch, TDShape, TldrawCommand, TDBinding } from '~types'
3 2
 import type { TldrawApp } from '../../internal'
4 3
 
5 4
 export function createShapes(

+ 1
- 2
packages/tldraw/src/state/commands/groupShapes/groupShapes.ts View File

@@ -1,7 +1,6 @@
1 1
 import { TDShape, TDShapeType } from '~types'
2 2
 import { Utils } from '@tldraw/core'
3
-import type { TDSnapshot, TldrawCommand, TDBinding } from '~types'
4
-import type { Patch } from 'rko'
3
+import type { Patch, TldrawCommand, TDBinding } from '~types'
5 4
 import type { TldrawApp } from '../../internal'
6 5
 import { TLDR } from '~state/TLDR'
7 6
 

+ 2
- 3
packages/tldraw/src/state/commands/styleShapes/styleShapes.ts View File

@@ -1,7 +1,6 @@
1
-import { ShapeStyles, TldrawCommand, TDShape, TDShapeType, TextShape } from '~types'
1
+import { Patch, ShapeStyles, TldrawCommand, TDShape, TDShapeType, TextShape } from '~types'
2 2
 import { TLDR } from '~state/TLDR'
3
-import Vec from '@tldraw/vec'
4
-import type { Patch } from 'rko'
3
+import { Vec } from '@tldraw/vec'
5 4
 import type { TldrawApp } from '../../internal'
6 5
 
7 6
 export function styleShapes(

+ 1
- 2
packages/tldraw/src/state/commands/toggleShapesDecoration/toggleShapesDecoration.ts View File

@@ -1,6 +1,5 @@
1 1
 import { Decoration } from '~types'
2
-import type { ArrowShape, TldrawCommand } from '~types'
3
-import type { Patch } from 'rko'
2
+import type { Patch, ArrowShape, TldrawCommand } from '~types'
4 3
 import type { TldrawApp } from '../../internal'
5 4
 
6 5
 export function toggleShapesDecoration(

+ 1
- 2
packages/tldraw/src/state/commands/ungroupShapes/ungroupShapes.ts View File

@@ -1,7 +1,6 @@
1 1
 import { TLDR } from '~state/TLDR'
2
-import type { GroupShape, TDBinding, TDShape } from '~types'
2
+import type { Patch, GroupShape, TDBinding, TDShape } from '~types'
3 3
 import type { TldrawCommand } from '~types'
4
-import type { Patch } from 'rko'
5 4
 import type { TldrawApp } from '../../internal'
6 5
 
7 6
 export function ungroupShapes(

+ 9
- 2
packages/tldraw/src/state/sessions/GridSession/GridSession.ts View File

@@ -1,8 +1,15 @@
1 1
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
2 2
 import { TLPageState, TLBounds, Utils } from '@tldraw/core'
3 3
 import { Vec } from '@tldraw/vec'
4
-import { TDShape, TDStatus, SessionType, TDShapeType, TldrawPatch, TldrawCommand } from '~types'
5
-import type { Patch } from 'rko'
4
+import {
5
+  Patch,
6
+  TDShape,
7
+  TDStatus,
8
+  SessionType,
9
+  TDShapeType,
10
+  TldrawPatch,
11
+  TldrawCommand,
12
+} from '~types'
6 13
 import { BaseSession } from '../BaseSession'
7 14
 import type { TldrawApp } from '../../internal'
8 15
 

+ 1
- 1
packages/tldraw/src/state/sessions/TranslateSession/TranslateSession.ts View File

@@ -7,6 +7,7 @@ import {
7 7
   TldrawCommand,
8 8
   TDStatus,
9 9
   ArrowShape,
10
+  Patch,
10 11
   GroupShape,
11 12
   SessionType,
12 13
   ArrowBinding,
@@ -14,7 +15,6 @@ import {
14 15
 } from '~types'
15 16
 import { SLOW_SPEED, SNAP_DISTANCE } from '~constants'
16 17
 import { TLDR } from '~state/TLDR'
17
-import type { Patch } from 'rko'
18 18
 import { BaseSession } from '../BaseSession'
19 19
 import type { TldrawApp } from '../../internal'
20 20
 

+ 8
- 1
packages/tldraw/src/types.ts View File

@@ -1,7 +1,6 @@
1 1
 /* eslint-disable @typescript-eslint/no-explicit-any */
2 2
 /* eslint-disable @typescript-eslint/ban-types */
3 3
 import type { TLPage, TLUser, TLPageState } from '@tldraw/core'
4
-import type { Command, Patch } from 'rko'
5 4
 import type { FileSystemHandle } from '~state/data/browser-fs-access'
6 5
 import type {
7 6
   TLBinding,
@@ -463,3 +462,11 @@ export type MappedByType<U extends string, T extends { type: U }> = {
463 462
 }
464 463
 
465 464
 export type ShapesWithProp<U> = MembersWithRequiredKey<MappedByType<TDShapeType, TDShape>, U>
465
+
466
+export type Patch<T> = Partial<{ [P in keyof T]: Patch<T[P]> }>
467
+
468
+export interface Command<T extends { [key: string]: any }> {
469
+  id?: string
470
+  before: Patch<T>
471
+  after: Patch<T>
472
+}

+ 38
- 38
repo-map.tldr View File

@@ -491,7 +491,7 @@
491 491
             "parentId": "page",
492 492
             "childIndex": 1.6666666666666665,
493 493
             "point": [
494
-              2320.57,
494
+              2286.01,
495 495
               528.32
496 496
             ],
497 497
             "rotation": 0,
@@ -512,7 +512,7 @@
512 512
             "parentId": "page",
513 513
             "childIndex": 1.3333333333333333,
514 514
             "point": [
515
-              2359.33,
515
+              2324.77,
516 516
               583.12
517 517
             ],
518 518
             "rotation": 0,
@@ -533,7 +533,7 @@
533 533
             "parentId": "page",
534 534
             "childIndex": 1,
535 535
             "point": [
536
-              2206.49,
536
+              2171.93,
537 537
               508.61
538 538
             ],
539 539
             "size": [
@@ -556,7 +556,7 @@
556 556
             "parentId": "page",
557 557
             "childIndex": 1.8333333333333333,
558 558
             "point": [
559
-              1773.27,
559
+              1738.71,
560 560
               534.63
561 561
             ],
562 562
             "rotation": 0,
@@ -577,7 +577,7 @@
577 577
             "parentId": "page",
578 578
             "childIndex": 1.5,
579 579
             "point": [
580
-              1859.03,
580
+              1824.47,
581 581
               589.43
582 582
             ],
583 583
             "rotation": 0,
@@ -598,7 +598,7 @@
598 598
             "parentId": "page",
599 599
             "childIndex": 1.1666666666666665,
600 600
             "point": [
601
-              1713.69,
601
+              1679.13,
602 602
               514.92
603 603
             ],
604 604
             "size": [
@@ -621,7 +621,7 @@
621 621
             "parentId": "page",
622 622
             "childIndex": 1.5,
623 623
             "point": [
624
-              1833.77,
624
+              1799.21,
625 625
               734.99
626 626
             ],
627 627
             "rotation": 0,
@@ -642,7 +642,7 @@
642 642
             "parentId": "page",
643 643
             "childIndex": 1.8333333333333333,
644 644
             "point": [
645
-              1862.75,
645
+              1828.19,
646 646
               684.94
647 647
             ],
648 648
             "rotation": 0,
@@ -663,7 +663,7 @@
663 663
             "parentId": "page",
664 664
             "childIndex": 1.1666666666666665,
665 665
             "point": [
666
-              1745.74,
666
+              1711.18,
667 667
               674.08
668 668
             ],
669 669
             "size": [
@@ -686,7 +686,7 @@
686 686
             "parentId": "page",
687 687
             "childIndex": 1.6666666666666665,
688 688
             "point": [
689
-              1818.77,
689
+              1784.21,
690 690
               874.35
691 691
             ],
692 692
             "rotation": 0,
@@ -707,7 +707,7 @@
707 707
             "parentId": "page",
708 708
             "childIndex": 2,
709 709
             "point": [
710
-              1835.75,
710
+              1801.19,
711 711
               824.3
712 712
             ],
713 713
             "rotation": 0,
@@ -728,7 +728,7 @@
728 728
             "parentId": "page",
729 729
             "childIndex": 1.3333333333333333,
730 730
             "point": [
731
-              1745.74,
731
+              1711.18,
732 732
               813.44
733 733
             ],
734 734
             "size": [
@@ -751,7 +751,7 @@
751 751
             "parentId": "page",
752 752
             "childIndex": 8,
753 753
             "point": [
754
-              1997.85,
754
+              1989.03,
755 755
               350.84
756 756
             ],
757 757
             "rotation": 0,
@@ -771,7 +771,7 @@
771 771
                 "id": "end",
772 772
                 "index": 1,
773 773
                 "point": [
774
-                  206.62,
774
+                  192.38,
775 775
                   141.77
776 776
                 ],
777 777
                 "canBind": true,
@@ -781,7 +781,7 @@
781 781
                 "id": "bend",
782 782
                 "index": 2,
783 783
                 "point": [
784
-                  103.31,
784
+                  96.19,
785 785
                   70.89
786 786
                 ]
787 787
               }
@@ -806,7 +806,7 @@
806 806
             "parentId": "page",
807 807
             "childIndex": 9,
808 808
             "point": [
809
-              1869.85,
809
+              1871.84,
810 810
               350.84
811 811
             ],
812 812
             "rotation": 0,
@@ -816,7 +816,7 @@
816 816
                 "id": "start",
817 817
                 "index": 0,
818 818
                 "point": [
819
-                  0.11,
819
+                  0,
820 820
                   0
821 821
                 ],
822 822
                 "canBind": true,
@@ -826,17 +826,17 @@
826 826
                 "id": "end",
827 827
                 "index": 1,
828 828
                 "point": [
829
-                  0,
829
+                  3.08,
830 830
                   148.08
831 831
                 ],
832 832
                 "canBind": true,
833
-                "bindingId": "7a0098b7-cf3f-4c08-0e0a-5e12f660e748"
833
+                "bindingId": "4ab25c0d-2c56-4a2d-2c28-b3f1ef83367a"
834 834
               },
835 835
               "bend": {
836 836
                 "id": "bend",
837 837
                 "index": 2,
838 838
                 "point": [
839
-                  0.06,
839
+                  1.54,
840 840
                   74.04
841 841
                 ]
842 842
               }
@@ -861,7 +861,7 @@
861 861
             "parentId": "page",
862 862
             "childIndex": 1.6666666666666665,
863 863
             "point": [
864
-              1345.38,
864
+              1310.82,
865 865
               534.62
866 866
             ],
867 867
             "rotation": 0,
@@ -882,7 +882,7 @@
882 882
             "parentId": "page",
883 883
             "childIndex": 1.3333333333333333,
884 884
             "point": [
885
-              1381.64,
885
+              1347.08,
886 886
               589.4
887 887
             ],
888 888
             "rotation": 0,
@@ -903,7 +903,7 @@
903 903
             "parentId": "page",
904 904
             "childIndex": 1.1666666666666665,
905 905
             "point": [
906
-              1243.3,
906
+              1208.74,
907 907
               514.91
908 908
             ],
909 909
             "size": [
@@ -926,7 +926,7 @@
926 926
             "parentId": "page",
927 927
             "childIndex": 10,
928 928
             "point": [
929
-              1523.16,
929
+              1498.4,
930 930
               350.84
931 931
             ],
932 932
             "rotation": 0,
@@ -936,7 +936,7 @@
936 936
                 "id": "start",
937 937
                 "index": 0,
938 938
                 "point": [
939
-                  217.83,
939
+                  233.38,
940 940
                   0
941 941
                 ],
942 942
                 "canBind": true,
@@ -956,7 +956,7 @@
956 956
                 "id": "bend",
957 957
                 "index": 2,
958 958
                 "point": [
959
-                  108.92,
959
+                  116.69,
960 960
                   74.03
961 961
                 ]
962 962
               }
@@ -1394,18 +1394,6 @@
1394 1394
             ],
1395 1395
             "distance": 16
1396 1396
           },
1397
-          "7a0098b7-cf3f-4c08-0e0a-5e12f660e748": {
1398
-            "id": "7a0098b7-cf3f-4c08-0e0a-5e12f660e748",
1399
-            "type": "arrow",
1400
-            "fromId": "751c4107-ec6d-4a01-2af2-fefc3f7d89b5",
1401
-            "toId": "7b6f1ad1-fe5a-4007-3a1d-a8661aeacdc3",
1402
-            "handleId": "end",
1403
-            "point": [
1404
-              0.4,
1405
-              0.75
1406
-            ],
1407
-            "distance": 16
1408
-          },
1409 1397
           "ab6ad0f8-76eb-43ee-2079-60a95a564847": {
1410 1398
             "id": "ab6ad0f8-76eb-43ee-2079-60a95a564847",
1411 1399
             "type": "arrow",
@@ -1477,6 +1465,18 @@
1477 1465
               0.5
1478 1466
             ],
1479 1467
             "distance": 16
1468
+          },
1469
+          "4ab25c0d-2c56-4a2d-2c28-b3f1ef83367a": {
1470
+            "id": "4ab25c0d-2c56-4a2d-2c28-b3f1ef83367a",
1471
+            "type": "arrow",
1472
+            "fromId": "751c4107-ec6d-4a01-2af2-fefc3f7d89b5",
1473
+            "toId": "7b6f1ad1-fe5a-4007-3a1d-a8661aeacdc3",
1474
+            "handleId": "end",
1475
+            "point": [
1476
+              0.5,
1477
+              0.5
1478
+            ],
1479
+            "distance": 16
1480 1480
           }
1481 1481
         }
1482 1482
       }

+ 9
- 17
yarn.lock View File

@@ -1999,15 +1999,15 @@
1999 1999
     npmlog "^4.1.2"
2000 2000
     write-file-atomic "^2.3.0"
2001 2001
 
2002
-"@liveblocks/client@^0.12.3":
2003
-  version "0.12.3"
2004
-  resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.12.3.tgz#03b957ccc7a6a5dc7474d224fe12c32e065e9c9c"
2005
-  integrity sha512-n82Ymngpvt4EiZEU3LWnEq7EjDmcd2wb2kjGz4m/4L7wYEd4RygAYi7bp7w5JOD1rt3Srhrwbq9Rz7TikbUheg==
2002
+"@liveblocks/client@^0.13.0-beta.1":
2003
+  version "0.13.0-beta.1"
2004
+  resolved "https://registry.yarnpkg.com/@liveblocks/client/-/client-0.13.0-beta.1.tgz#baee31dbefb7c40c954ab61b8c421562a85f729e"
2005
+  integrity sha512-LW1CygndCQeITYFsnaEZgbe2qqIZKo4iVH/qGYEXVLptc/1PP0nzEi8Hr2lfw4JOUw003FTeQ+BSI/raP22mgg==
2006 2006
 
2007
-"@liveblocks/react@^0.12.3":
2008
-  version "0.12.3"
2009
-  resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.12.3.tgz#82d93a9a3a96401258f6c87c1150026dd9d63504"
2010
-  integrity sha512-3mHRiEwZ/s1lbGS4/bblUpLCNCBFMzEiUHHfBH3zO9+IKrH40lVdky0OujgF5zEacYcqUnVW7jT4ZvHCchvsYA==
2007
+"@liveblocks/react@0.13.0-beta.1", "@liveblocks/react@^0.13.0-beta.1":
2008
+  version "0.13.0-beta.1"
2009
+  resolved "https://registry.yarnpkg.com/@liveblocks/react/-/react-0.13.0-beta.1.tgz#e71bc47511480967c2a11042aa920399674b5c3d"
2010
+  integrity sha512-odOO5WCVfV3B70Yy8k/11XFY/5dVSBpIPKnx+ZDxZkw/yzrA39NqS+GH7go/RvVAGSeHbg9phknOtg4X9gziAQ==
2011 2011
 
2012 2012
 "@malept/cross-spawn-promise@^1.1.0":
2013 2013
   version "1.1.1"
@@ -12677,14 +12677,6 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
12677 12677
     hash-base "^3.0.0"
12678 12678
     inherits "^2.0.1"
12679 12679
 
12680
-rko@^0.6.5:
12681
-  version "0.6.5"
12682
-  resolved "https://registry.yarnpkg.com/rko/-/rko-0.6.5.tgz#48069a97bc3ae96c86da2502e909247c6c25f861"
12683
-  integrity sha512-0cYMs8iYJY2J7IuxSzGxWoelxkghvvvT3fWCwi/942uy6ORAYaJpQ73s7DIRR87W6jHNJvtcCzyZVfmtkoQzmg==
12684
-  dependencies:
12685
-    idb-keyval "^6.0.3"
12686
-    zustand "^3.6.4"
12687
-
12688 12680
 roarr@^2.15.3:
12689 12681
   version "2.15.4"
12690 12682
   resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.4.tgz#f5fe795b7b838ccfe35dc608e0282b9eba2e7afd"
@@ -15204,7 +15196,7 @@ zen-observable@0.8.15:
15204 15196
   resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"
15205 15197
   integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==
15206 15198
 
15207
-zustand@^3.6.4:
15199
+zustand@^3.6.5:
15208 15200
   version "3.6.5"
15209 15201
   resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.6.5.tgz#42a459397907d6bf0e2375351394733b2f83ee44"
15210 15202
   integrity sha512-/WfLJuXiEJimt61KGMHebrFBwckkCHGhAgVXTgPQHl6IMzjqm6MREb1OnDSnCRiSmRdhgdFCctceg6tSm79hiw==

Loading…
Cancel
Save