Просмотр исходного кода

[fix] account for "draft" shapes when preserving selection state during replacePageContent (#427)

* account for "virtual" shapes when preserving appState

* rewrite merge logic

* More work on multiplayer

* Update TldrawApp.ts

* Improve logic around when to replace page content

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
main
Braden 3 лет назад
Родитель
Сommit
522baf5b61
Аккаунт пользователя с таким Email не найден

+ 77
- 81
examples/tldraw-example/src/multiplayer/useMultiplayerState.ts Просмотреть файл

@@ -4,6 +4,7 @@ import * as React from 'react'
4 4
 import type { TldrawApp, TDUser, TDShape, TDBinding, TDDocument } from '@tldraw/tldraw'
5 5
 import { useRedo, useUndo, useRoom, useUpdateMyPresence } from '@liveblocks/react'
6 6
 import { LiveMap, LiveObject } from '@liveblocks/client'
7
+import { Utils } from '@tldraw/core'
7 8
 
8 9
 declare const window: Window & { app: TldrawApp }
9 10
 
@@ -11,21 +12,73 @@ export function useMultiplayerState(roomId: string) {
11 12
   const [app, setApp] = React.useState<TldrawApp>()
12 13
   const [error, setError] = React.useState<Error>()
13 14
   const [loading, setLoading] = React.useState(true)
14
-  const rExpectingUpdate = React.useRef(false)
15 15
 
16 16
   const room = useRoom()
17 17
   const onUndo = useUndo()
18 18
   const onRedo = useRedo()
19 19
   const updateMyPresence = useUpdateMyPresence()
20 20
 
21
-  // Document Changes --------
22
-
23 21
   const rLiveShapes = React.useRef<LiveMap<string, TDShape>>()
24 22
   const rLiveBindings = React.useRef<LiveMap<string, TDBinding>>()
25 23
 
24
+  // Callbacks --------------
25
+
26
+  // Put the state into the window, for debugging.
27
+  const onMount = React.useCallback(
28
+    (app: TldrawApp) => {
29
+      app.loadRoom(roomId)
30
+      app.pause() // Turn off the app's own undo / redo stack
31
+      window.app = app
32
+      setApp(app)
33
+    },
34
+    [roomId]
35
+  )
36
+
37
+  // Update the live shapes when the app's shapes change.
38
+  const onChangePage = React.useCallback(
39
+    (
40
+      app: TldrawApp,
41
+      shapes: Record<string, TDShape | undefined>,
42
+      bindings: Record<string, TDBinding | undefined>
43
+    ) => {
44
+      room.batch(() => {
45
+        const lShapes = rLiveShapes.current
46
+        const lBindings = rLiveBindings.current
47
+
48
+        if (!(lShapes && lBindings)) return
49
+
50
+        Object.entries(shapes).forEach(([id, shape]) => {
51
+          if (!shape) {
52
+            lShapes.delete(id)
53
+          } else {
54
+            lShapes.set(shape.id, shape)
55
+          }
56
+        })
57
+
58
+        Object.entries(bindings).forEach(([id, binding]) => {
59
+          if (!binding) {
60
+            lBindings.delete(id)
61
+          } else {
62
+            lBindings.set(binding.id, binding)
63
+          }
64
+        })
65
+      })
66
+    },
67
+    [room]
68
+  )
69
+
70
+  // Handle presence updates when the user's pointer / selection changes
71
+  const onChangePresence = React.useCallback(
72
+    (app: TldrawApp, user: TDUser) => {
73
+      updateMyPresence({ id: app.room?.userId, user })
74
+    },
75
+    [updateMyPresence]
76
+  )
77
+
78
+  // Document Changes --------
79
+
26 80
   React.useEffect(() => {
27 81
     const unsubs: (() => void)[] = []
28
-
29 82
     if (!(app && room)) return
30 83
     // Handle errors
31 84
     unsubs.push(room.subscribe('error', (error) => setError(error)))
@@ -67,6 +120,8 @@ export function useMultiplayerState(roomId: string) {
67 120
     window.addEventListener('beforeunload', handleExit)
68 121
     unsubs.push(() => window.removeEventListener('beforeunload', handleExit))
69 122
 
123
+    let stillAlive = true
124
+
70 125
     // Setup the document's storage and subscriptions
71 126
     async function setupDocument() {
72 127
       const storage = await room.getStorage<any>()
@@ -87,25 +142,6 @@ export function useMultiplayerState(roomId: string) {
87 142
       }
88 143
       rLiveBindings.current = lBindings
89 144
 
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 145
       // Migrate previous versions
110 146
       const version = storage.root.get('version')
111 147
 
@@ -139,71 +175,31 @@ export function useMultiplayerState(roomId: string) {
139 175
       // Save the version number for future migrations
140 176
       storage.root.set('version', 2)
141 177
 
142
-      setLoading(false)
178
+      // Subscribe to changes
179
+      const handleChanges = () => {
180
+        app?.replacePageContent(
181
+          Object.fromEntries(lShapes.entries()),
182
+          Object.fromEntries(lBindings.entries())
183
+        )
184
+      }
185
+
186
+      if (stillAlive) {
187
+        unsubs.push(room.subscribe(lShapes, handleChanges))
188
+
189
+        // Update the document with initial content
190
+        handleChanges()
191
+
192
+        setLoading(false)
193
+      }
143 194
     }
144 195
 
145 196
     setupDocument()
146 197
 
147 198
     return () => {
199
+      stillAlive = false
148 200
       unsubs.forEach((unsub) => unsub())
149 201
     }
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
-  )
202
+  }, [app])
207 203
 
208 204
   return {
209 205
     onUndo,

+ 212
- 22
packages/tldraw/src/state/TldrawApp.ts Просмотреть файл

@@ -469,7 +469,10 @@ export class TldrawApp extends StateManager<TDSnapshot> {
469 469
   }
470 470
 
471 471
   onPersist = () => {
472
-    this.broadcastPageChanges()
472
+    // If we are part of a room, send our changes to the server
473
+    if (this.callbacks.onChangePage) {
474
+      this.broadcastPageChanges()
475
+    }
473 476
   }
474 477
 
475 478
   private prevSelectedIds = this.selectedIds
@@ -493,6 +496,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
493 496
 
494 497
   /* ----------- Managing Multiplayer State ----------- */
495 498
 
499
+  private justSent = false
496 500
   private prevShapes = this.page.shapes
497 501
   private prevBindings = this.page.bindings
498 502
 
@@ -502,6 +506,26 @@ export class TldrawApp extends StateManager<TDSnapshot> {
502 506
     const changedShapes: Record<string, TDShape | undefined> = {}
503 507
     const changedBindings: Record<string, TDBinding | undefined> = {}
504 508
 
509
+    // const visitedIds = new Set<string>()
510
+    // const shapesToVisit = this.shapes
511
+
512
+    // while (shapesToVisit.length > 0) {
513
+    //   const shape = shapesToVisit.pop()
514
+    //   if (!shape) break
515
+    //   visitedIds.add(shape.id)
516
+    //   if (this.prevShapes[shape.id] !== shape) {
517
+    //     changedShapes[shape.id] = shape
518
+
519
+    //     if (shape.parentId !== this.currentPageId) {
520
+    //       shapesToVisit.push(this.page.shapes[shape.parentId])
521
+    //     }
522
+
523
+    //     if (shape.children) {
524
+
525
+    //     }
526
+    //   }
527
+    // }
528
+
505 529
     this.shapes.forEach((shape) => {
506 530
       visited.add(shape.id)
507 531
       if (this.prevShapes[shape.id] !== shape) {
@@ -512,6 +536,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
512 536
     Object.keys(this.prevShapes)
513 537
       .filter((id) => !visited.has(id))
514 538
       .forEach((id) => {
539
+        // After visiting all the current shapes, if we haven't visited a
540
+        // previously present shape, then it was deleted
515 541
         changedShapes[id] = undefined
516 542
       })
517 543
 
@@ -525,16 +551,73 @@ export class TldrawApp extends StateManager<TDSnapshot> {
525 551
     Object.keys(this.prevBindings)
526 552
       .filter((id) => !visited.has(id))
527 553
       .forEach((id) => {
554
+        // After visiting all the current bindings, if we haven't visited a
555
+        // previously present shape, then it was deleted
528 556
         changedBindings[id] = undefined
529 557
       })
530 558
 
559
+    this.justSent = true
531 560
     this.callbacks.onChangePage?.(this, changedShapes, changedBindings)
532
-
533 561
     this.callbacks.onPersist?.(this)
534 562
     this.prevShapes = this.page.shapes
535 563
     this.prevBindings = this.page.bindings
536 564
   }
537 565
 
566
+  getReservedContent = (ids: string[], pageId = this.currentPageId) => {
567
+    const { bindings } = this.document.pages[pageId]
568
+
569
+    // We want to know which shapes we need to
570
+    const reservedShapes: Record<string, TDShape> = {}
571
+    const reservedBindings: Record<string, TDBinding> = {}
572
+
573
+    // Quick lookup maps for bindings
574
+    const bindingsArr = Object.values(bindings)
575
+    const boundTos = new Map(bindingsArr.map((binding) => [binding.toId, binding]))
576
+    const boundFroms = new Map(bindingsArr.map((binding) => [binding.fromId, binding]))
577
+    const bindingMaps = [boundTos, boundFroms]
578
+
579
+    // Unique set of shape ids that are going to be reserved
580
+    const reservedShapeIds: string[] = []
581
+
582
+    if (this.session) ids.forEach((id) => reservedShapeIds.push(id))
583
+
584
+    const strongReservedShapeIds = new Set(reservedShapeIds)
585
+
586
+    // Which shape ids have we already visited?
587
+    const visited = new Set<string>()
588
+
589
+    // Time to visit every reserved shape and every related shape and binding.
590
+    while (reservedShapeIds.length > 0) {
591
+      const id = reservedShapeIds.pop()
592
+      if (!id) break
593
+      if (visited.has(id)) continue
594
+
595
+      // Add to set so that we don't process this id a second time
596
+      visited.add(id)
597
+
598
+      // Get the shape and reserve it
599
+      const shape = this.getShape(id)
600
+      reservedShapes[id] = shape
601
+
602
+      if (shape.parentId !== pageId) reservedShapeIds.push(shape.parentId)
603
+
604
+      // If the shape has children, add the shape's children to the list of ids to process
605
+      if (shape.children) reservedShapeIds.push(...shape.children)
606
+
607
+      // If there are binding for this shape, reserve the bindings and
608
+      // add its related shapes to the list of ids to process
609
+      bindingMaps
610
+        .map((map) => map.get(shape.id)!)
611
+        .filter(Boolean)
612
+        .forEach((binding) => {
613
+          reservedBindings[binding.id] = binding
614
+          reservedShapeIds.push(binding.toId, binding.fromId)
615
+        })
616
+    }
617
+
618
+    return { reservedShapes, reservedBindings, strongReservedShapeIds }
619
+  }
620
+
538 621
   /**
539 622
    * Manually patch a set of shapes.
540 623
    * @param shapes An array of shape partials, containing the changes to be made to each shape.
@@ -545,19 +628,75 @@ export class TldrawApp extends StateManager<TDSnapshot> {
545 628
     bindings: Record<string, TDBinding>,
546 629
     pageId = this.currentPageId
547 630
   ): this => {
631
+    // This will be called a few times: once by our own change,
632
+    // once by the change to shapes, and once by the change to bindings
633
+
634
+    if (this.justSent) {
635
+      this.justSent = false
636
+      return this
637
+    }
638
+
548 639
     this.useStore.setState((current) => {
549 640
       const { hoveredId, editingId, bindingId, selectedIds } = current.document.pageStates[pageId]
550 641
 
551
-      const keepShapes: Record<string, TDShape> = {}
552
-      const keepBindings: Record<string, TDBinding> = {}
642
+      const coreReservedIds = [...selectedIds]
553 643
 
554
-      if (this.session) {
555
-        selectedIds.forEach((id) => (keepShapes[id] = this.getShape(id)))
556
-        Object.assign(keepBindings, this.bindings) // ROUGH
644
+      if (editingId) coreReservedIds.push(editingId)
645
+
646
+      const { reservedShapes, reservedBindings, strongReservedShapeIds } = this.getReservedContent(
647
+        coreReservedIds,
648
+        this.currentPageId
649
+      )
650
+
651
+      // Merge in certain changes to reserved shapes
652
+      Object.values(reservedShapes)
653
+        // Don't merge updates to shapes with text (Text or Sticky)
654
+        .filter((reservedShape) => !('text' in reservedShape))
655
+        .forEach((reservedShape) => {
656
+          const incomingShape = shapes[reservedShape.id]
657
+          if (!incomingShape) return
658
+
659
+          // If the shape isn't "strongly reserved", then use the incoming shape;
660
+          // note that this is only if the incoming shape exists! If the shape was
661
+          // deleted in the incoming shapes, then we'll keep out reserved shape.
662
+          // This logic would need more work for arrows, because the incoming shape
663
+          // include a binding change that we'll need to resolve with our reserved bindings.
664
+          if (
665
+            !(
666
+              reservedShape.type === TDShapeType.Arrow ||
667
+              strongReservedShapeIds.has(reservedShape.id)
668
+            )
669
+          ) {
670
+            reservedShapes[reservedShape.id] = incomingShape
671
+            return
672
+          }
673
+
674
+          // Only allow certain merges.
675
+
676
+          // Allow decorations (of an arrow) to be changed
677
+          if ('decorations' in incomingShape && 'decorations' in reservedShape) {
678
+            reservedShape.decorations = incomingShape.decorations
679
+          }
680
+
681
+          // Allow the shape's style to be changed
682
+          reservedShape.style = incomingShape.style
683
+        })
684
+
685
+      // Use the incoming shapes / bindings as comparisons for what
686
+      // will have changed. This is important because we want to restore
687
+      // related shapes that may not have changed on our side, but which
688
+      // were deleted on the server.
689
+      this.prevShapes = shapes
690
+      this.prevBindings = bindings
691
+
692
+      const nextShapes = {
693
+        ...shapes,
694
+        ...reservedShapes,
557 695
       }
558 696
 
559
-      if (editingId) {
560
-        keepShapes[editingId] = this.getShape(editingId)
697
+      const nextBindings = {
698
+        ...bindings,
699
+        ...reservedBindings,
561 700
       }
562 701
 
563 702
       const next: TDSnapshot = {
@@ -567,29 +706,23 @@ export class TldrawApp extends StateManager<TDSnapshot> {
567 706
           pages: {
568 707
             [pageId]: {
569 708
               ...current.document.pages[pageId],
570
-              shapes: {
571
-                ...shapes,
572
-                ...keepShapes,
573
-              },
574
-              bindings: {
575
-                ...bindings,
576
-                ...keepBindings,
577
-              },
709
+              shapes: nextShapes,
710
+              bindings: nextBindings,
578 711
             },
579 712
           },
580 713
           pageStates: {
581 714
             ...current.document.pageStates,
582 715
             [pageId]: {
583 716
               ...current.document.pageStates[pageId],
584
-              selectedIds: selectedIds.filter((id) => shapes[id] !== undefined),
717
+              selectedIds: selectedIds.filter((id) => nextShapes[id] !== undefined),
585 718
               hoveredId: hoveredId
586
-                ? shapes[hoveredId] === undefined
719
+                ? nextShapes[hoveredId] === undefined
587 720
                   ? undefined
588 721
                   : hoveredId
589 722
                 : undefined,
590 723
               editingId: editingId,
591 724
               bindingId: bindingId
592
-                ? bindings[bindingId] === undefined
725
+                ? nextBindings[bindingId] === undefined
593 726
                   ? undefined
594 727
                   : bindingId
595 728
                 : undefined,
@@ -598,9 +731,66 @@ export class TldrawApp extends StateManager<TDSnapshot> {
598 731
         },
599 732
       }
600 733
 
734
+      // Get bindings related to the changed shapes
735
+      const bindingsToUpdate = TLDR.getRelatedBindings(next, Object.keys(nextShapes), pageId)
736
+
737
+      const page = next.document.pages[pageId]
738
+
739
+      // Update all of the bindings we've just collected
740
+      bindingsToUpdate.forEach((binding) => {
741
+        if (!page.bindings[binding.id]) {
742
+          return
743
+        }
744
+
745
+        const toShape = page.shapes[binding.toId]
746
+        const fromShape = page.shapes[binding.fromId]
747
+
748
+        const toUtils = TLDR.getShapeUtil(toShape)
749
+
750
+        const fromUtils = TLDR.getShapeUtil(fromShape)
751
+
752
+        // We only need to update the binding's "from" shape
753
+        const fromDelta = fromUtils.onBindingChange?.(
754
+          fromShape,
755
+          binding,
756
+          toShape,
757
+          toUtils.getBounds(toShape),
758
+          toUtils.getCenter(toShape)
759
+        )
760
+
761
+        if (fromDelta) {
762
+          const nextShape = {
763
+            ...fromShape,
764
+            ...fromDelta,
765
+          } as TDShape
766
+
767
+          page.shapes[fromShape.id] = nextShape
768
+        }
769
+      })
770
+
771
+      Object.values(nextShapes).forEach((shape) => {
772
+        if (shape.type !== TDShapeType.Group) return
773
+
774
+        const children = shape.children.filter((id) => page.shapes[id] !== undefined)
775
+
776
+        const commonBounds = Utils.getCommonBounds(
777
+          children
778
+            .map((id) => page.shapes[id])
779
+            .filter(Boolean)
780
+            .map((shape) => TLDR.getRotatedBounds(shape))
781
+        )
782
+
783
+        page.shapes[shape.id] = {
784
+          ...shape,
785
+          point: [commonBounds.minX, commonBounds.minY],
786
+          size: [commonBounds.width, commonBounds.height],
787
+          children,
788
+        }
789
+      })
790
+
601 791
       this.state.document = next.document
602
-      this.prevShapes = next.document.pages[this.currentPageId].shapes
603
-      this.prevBindings = next.document.pages[this.currentPageId].bindings
792
+      // this.prevShapes = nextShapes
793
+      // this.prevBindings = nextBindings
604 794
 
605 795
       return next
606 796
     }, true)

Загрузка…
Отмена
Сохранить