@tldraw/editor 5.4.0-canary.042da660fa66 → 5.4.0-canary.059d049864ed

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist-cjs/index.d.ts +31 -1
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/components/MenuClickCapture.js +6 -8
  4. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  5. package/dist-cjs/lib/components/default-components/CanvasOverlays.js +1 -3
  6. package/dist-cjs/lib/components/default-components/CanvasOverlays.js.map +2 -2
  7. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
  8. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +1 -1
  9. package/dist-cjs/lib/config/TLUserPreferences.js +6 -2
  10. package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
  11. package/dist-cjs/lib/config/createTLStore.js +1 -1
  12. package/dist-cjs/lib/config/createTLStore.js.map +2 -2
  13. package/dist-cjs/lib/editor/Editor.js +139 -268
  14. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  15. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +8 -8
  16. package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +2 -1
  18. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +79 -6
  20. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +0 -2
  22. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +1 -0
  24. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/overlays/OverlayManager.js +3 -7
  26. package/dist-cjs/lib/editor/overlays/OverlayManager.js.map +2 -2
  27. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +0 -1
  28. package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +2 -2
  29. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -1
  30. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
  31. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js +1 -0
  32. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
  33. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -1
  34. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  35. package/dist-cjs/lib/editor/tools/StateNode.js +3 -2
  36. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  37. package/dist-cjs/lib/globals/menus.js +9 -4
  38. package/dist-cjs/lib/globals/menus.js.map +2 -2
  39. package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -0
  40. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  41. package/dist-cjs/lib/hooks/useDocumentEvents.js +9 -7
  42. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  43. package/dist-cjs/lib/hooks/useLocalStore.js +10 -6
  44. package/dist-cjs/lib/hooks/useLocalStore.js.map +2 -2
  45. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +1 -0
  46. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  47. package/dist-cjs/lib/hooks/usePeerIds.js +2 -1
  48. package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
  49. package/dist-cjs/lib/primitives/Box.js +2 -1
  50. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  51. package/dist-cjs/lib/primitives/Vec.js +4 -3
  52. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  53. package/dist-cjs/lib/primitives/utils.js +2 -1
  54. package/dist-cjs/lib/primitives/utils.js.map +2 -2
  55. package/dist-cjs/lib/utils/rotation.js +1 -9
  56. package/dist-cjs/lib/utils/rotation.js.map +2 -2
  57. package/dist-cjs/version.js +3 -3
  58. package/dist-cjs/version.js.map +1 -1
  59. package/dist-esm/index.d.mts +31 -1
  60. package/dist-esm/index.mjs +1 -1
  61. package/dist-esm/lib/components/MenuClickCapture.mjs +6 -8
  62. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  63. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs +1 -3
  64. package/dist-esm/lib/components/default-components/CanvasOverlays.mjs.map +2 -2
  65. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  66. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +1 -1
  67. package/dist-esm/lib/config/TLUserPreferences.mjs +6 -2
  68. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  69. package/dist-esm/lib/config/createTLStore.mjs +1 -1
  70. package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
  71. package/dist-esm/lib/editor/Editor.mjs +147 -269
  72. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  73. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +9 -9
  74. package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
  75. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +2 -1
  76. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  77. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +81 -8
  78. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  79. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +0 -2
  80. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  81. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +1 -0
  82. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  83. package/dist-esm/lib/editor/overlays/OverlayManager.mjs +3 -7
  84. package/dist-esm/lib/editor/overlays/OverlayManager.mjs.map +2 -2
  85. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +0 -1
  86. package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +2 -2
  87. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -1
  88. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
  89. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs +1 -0
  90. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
  91. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -1
  92. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  93. package/dist-esm/lib/editor/tools/StateNode.mjs +3 -2
  94. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  95. package/dist-esm/lib/globals/menus.mjs +9 -4
  96. package/dist-esm/lib/globals/menus.mjs.map +2 -2
  97. package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -0
  98. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  99. package/dist-esm/lib/hooks/useDocumentEvents.mjs +9 -7
  100. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  101. package/dist-esm/lib/hooks/useLocalStore.mjs +11 -7
  102. package/dist-esm/lib/hooks/useLocalStore.mjs.map +2 -2
  103. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +1 -0
  104. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  105. package/dist-esm/lib/hooks/usePeerIds.mjs +2 -1
  106. package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
  107. package/dist-esm/lib/primitives/Box.mjs +2 -1
  108. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  109. package/dist-esm/lib/primitives/Vec.mjs +4 -3
  110. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  111. package/dist-esm/lib/primitives/utils.mjs +2 -1
  112. package/dist-esm/lib/primitives/utils.mjs.map +2 -2
  113. package/dist-esm/lib/utils/rotation.mjs +1 -9
  114. package/dist-esm/lib/utils/rotation.mjs.map +2 -2
  115. package/dist-esm/version.mjs +3 -3
  116. package/dist-esm/version.mjs.map +1 -1
  117. package/package.json +7 -7
  118. package/src/lib/components/MenuClickCapture.tsx +19 -18
  119. package/src/lib/components/default-components/CanvasOverlays.tsx +1 -3
  120. package/src/lib/components/default-components/DefaultErrorFallback.tsx +1 -1
  121. package/src/lib/config/TLUserPreferences.test.ts +18 -1
  122. package/src/lib/config/TLUserPreferences.ts +8 -4
  123. package/src/lib/config/createTLStore.ts +1 -1
  124. package/src/lib/editor/Editor.ts +196 -341
  125. package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +9 -9
  126. package/src/lib/editor/managers/FontManager/FontManager.ts +2 -1
  127. package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +199 -3
  128. package/src/lib/editor/managers/InputsManager/InputsManager.ts +128 -9
  129. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +0 -2
  130. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +1 -0
  131. package/src/lib/editor/overlays/OverlayManager.ts +3 -7
  132. package/src/lib/editor/overlays/strokeShapeIndicators.ts +0 -1
  133. package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +5 -1
  134. package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +3 -0
  135. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +4 -1
  136. package/src/lib/editor/tools/StateNode.test.ts +64 -0
  137. package/src/lib/editor/tools/StateNode.ts +12 -2
  138. package/src/lib/globals/menus.test.ts +33 -0
  139. package/src/lib/globals/menus.ts +11 -4
  140. package/src/lib/hooks/useCanvasEvents.ts +2 -0
  141. package/src/lib/hooks/useDocumentEvents.ts +14 -7
  142. package/src/lib/hooks/useLocalStore.ts +16 -8
  143. package/src/lib/hooks/usePassThroughWheelEvents.ts +3 -0
  144. package/src/lib/hooks/usePeerIds.ts +2 -1
  145. package/src/lib/primitives/Box.test.ts +9 -1
  146. package/src/lib/primitives/Box.ts +3 -1
  147. package/src/lib/primitives/Vec.test.ts +10 -0
  148. package/src/lib/primitives/Vec.ts +4 -4
  149. package/src/lib/primitives/utils.test.ts +10 -1
  150. package/src/lib/primitives/utils.ts +4 -1
  151. package/src/lib/test/useCanvasEvents.test.tsx +74 -0
  152. package/src/lib/test/useDocumentEvents.test.tsx +31 -0
  153. package/src/lib/test/usePassThroughWheelEvents.test.tsx +25 -2
  154. package/src/lib/utils/rotation.ts +4 -12
  155. package/src/version.ts +3 -3
@@ -72,6 +72,7 @@ import {
72
72
  Result,
73
73
  ZERO_INDEX_KEY,
74
74
  annotateError,
75
+ areArraysShallowEqual,
75
76
  assert,
76
77
  assertExists,
77
78
  bind,
@@ -129,7 +130,13 @@ import { Geometry2d } from '../primitives/geometry/Geometry2d'
129
130
  import { Group2d } from '../primitives/geometry/Group2d'
130
131
  import { intersectPolygonPolygon } from '../primitives/intersect'
131
132
  import { Mat, MatLike } from '../primitives/Mat'
132
- import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils'
133
+ import {
134
+ HALF_PI,
135
+ approximately,
136
+ areAnglesCompatible,
137
+ clamp,
138
+ pointInPolygon,
139
+ } from '../primitives/utils'
133
140
  import { Vec, VecLike } from '../primitives/Vec'
134
141
  import { areShapesContentEqual } from '../utils/areShapesContentEqual'
135
142
  import { dataUrlToFile } from '../utils/assets'
@@ -187,6 +194,7 @@ import { TLHistoryBatchOptions } from './types/history-types'
187
194
  import {
188
195
  OptionalKeys,
189
196
  RequiredKeys,
197
+ TLCameraConstraints,
190
198
  TLCameraMoveOptions,
191
199
  TLCameraOptions,
192
200
  TLGetShapeAtPointOptions,
@@ -545,6 +553,14 @@ export class Editor extends EventEmitter<TLEventMap> {
545
553
  nextPageState.editingShapeId = null
546
554
  }
547
555
 
556
+ if (
557
+ prevPageState.croppingShapeId &&
558
+ shapesNoLongerInPage.has(prevPageState.croppingShapeId)
559
+ ) {
560
+ if (!nextPageState) nextPageState = { ...prevPageState }
561
+ nextPageState.croppingShapeId = null
562
+ }
563
+
548
564
  const hintingShapeIds = prevPageState.hintingShapeIds.filter(
549
565
  (id) => !shapesNoLongerInPage.has(id)
550
566
  )
@@ -830,10 +846,11 @@ export class Editor extends EventEmitter<TLEventMap> {
830
846
 
831
847
  if (prev?.selectedShapeIds !== next?.selectedShapeIds) {
832
848
  // ensure that descendants and ancestors are not selected at the same time
849
+ const selectedShapeIds = new Set(next.selectedShapeIds)
833
850
  const filtered = next.selectedShapeIds.filter((id) => {
834
851
  let parentId = this.getShape(id)?.parentId
835
852
  while (isShapeId(parentId)) {
836
- if (next.selectedShapeIds.includes(parentId)) {
853
+ if (selectedShapeIds.has(parentId)) {
837
854
  return false
838
855
  }
839
856
  parentId = this.getShape(parentId)?.parentId
@@ -938,7 +955,11 @@ export class Editor extends EventEmitter<TLEventMap> {
938
955
  const mode = this.store.props.collaboration.mode
939
956
  this.disposables.add(
940
957
  react('update collaboration mode', () => {
941
- this.store.put([{ ...this.getInstanceState(), isReadonly: mode.get() === 'readonly' }])
958
+ const isReadonly = mode.get() === 'readonly'
959
+ // only track `mode`, and keep the sync out of the user's undo history
960
+ unsafe__withoutCapture(() =>
961
+ this._updateInstanceState({ isReadonly }, { history: 'ignore' })
962
+ )
942
963
  })
943
964
  )
944
965
  }
@@ -1390,7 +1411,8 @@ export class Editor extends EventEmitter<TLEventMap> {
1390
1411
  getShapeUtil(arg: string | { type: string }) {
1391
1412
  const type = typeof arg === 'string' ? arg : arg.type
1392
1413
  const shapeUtil = getOwnProperty(this.shapeUtils, type)
1393
- assert(shapeUtil, `No shape util found for type "${type}"`)
1414
+ // hot path: avoid building the message (and the assert wrapper) on every successful call
1415
+ if (!shapeUtil) throw new Error(`No shape util found for type "${type}"`)
1394
1416
  return shapeUtil
1395
1417
  }
1396
1418
 
@@ -2467,9 +2489,9 @@ export class Editor extends EventEmitter<TLEventMap> {
2467
2489
  const selectedShapes = this.getSelectedShapes()
2468
2490
  if (!selectedShapes.length) return
2469
2491
  const selectedShape = selectedShapes[0]
2470
- const children = this.getSortedChildIdsForParent(selectedShape.id)
2471
- .map((id) => this.getShape(id))
2472
- .filter((i) => i) as TLShape[]
2492
+ const children = compact(
2493
+ this.getSortedChildIdsForParent(selectedShape.id).map((id) => this.getShape(id))
2494
+ )
2473
2495
  const sortedChildren = this._getShapesInReadingOrder(children)
2474
2496
  if (sortedChildren.length === 0) return
2475
2497
  this._selectShapesAndZoom([sortedChildren[0].id])
@@ -2570,20 +2592,14 @@ export class Editor extends EventEmitter<TLEventMap> {
2570
2592
  * @internal
2571
2593
  */
2572
2594
  getShapesSharedRotation(shapeIds: TLShapeId[]) {
2573
- let foundFirst = false // annoying but we can't use an i===0 check because we need to skip over undefineds
2574
2595
  let rotation = 0
2575
2596
  for (let i = 0, n = shapeIds.length; i < n; i++) {
2576
- const pageTransform = this.getShapePageTransform(shapeIds[i])
2577
- if (!pageTransform) continue
2578
- if (foundFirst) {
2579
- if (pageTransform.rotation() !== rotation) {
2580
- // There are at least 2 different rotations, so the common rotation is zero
2581
- return 0
2582
- }
2583
- } else {
2584
- // First rotation found
2585
- foundFirst = true
2586
- rotation = pageTransform.rotation()
2597
+ const pageRotation = this.getShapePageTransform(shapeIds[i]).rotation()
2598
+ if (i === 0) {
2599
+ rotation = pageRotation
2600
+ } else if (pageRotation !== rotation) {
2601
+ // There are at least 2 different rotations, so the common rotation is zero
2602
+ return 0
2587
2603
  }
2588
2604
  }
2589
2605
 
@@ -2615,7 +2631,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2615
2631
 
2616
2632
  if (shapeIds.length === 1) {
2617
2633
  const bounds = this.getShapeGeometry(shapeIds[0]).bounds.clone()
2618
- const pageTransform = this.getShapePageTransform(shapeIds[0])!
2634
+ const pageTransform = this.getShapePageTransform(shapeIds[0])
2619
2635
  bounds.point = pageTransform.applyToPoint(bounds.point)
2620
2636
  return bounds
2621
2637
  }
@@ -2623,11 +2639,9 @@ export class Editor extends EventEmitter<TLEventMap> {
2623
2639
  // need to 'un-rotate' all the outlines of the existing nodes so we can fit them inside a box
2624
2640
  const boxFromRotatedVertices = Box.FromPoints(
2625
2641
  shapeIds
2626
- .flatMap((id) => {
2627
- const pageTransform = this.getShapePageTransform(id)
2628
- if (!pageTransform) return []
2629
- return pageTransform.applyToPoints(this.getShapeGeometry(id).bounds.corners)
2630
- })
2642
+ .flatMap((id) =>
2643
+ this.getShapePageTransform(id).applyToPoints(this.getShapeGeometry(id).bounds.corners)
2644
+ )
2631
2645
  .map((p) => p.rot(-selectionRotation))
2632
2646
  )
2633
2647
  // now position box so that it's top-left corner is in the right place
@@ -2654,9 +2668,16 @@ export class Editor extends EventEmitter<TLEventMap> {
2654
2668
  @computed getSelectionRotatedScreenBounds(): Box | undefined {
2655
2669
  const bounds = this.getSelectionRotatedPageBounds()
2656
2670
  if (!bounds) return undefined
2657
- const { x, y } = this.pageToScreen(bounds.point)
2658
- const zoom = this.getZoomLevel()
2659
- return new Box(x, y, bounds.width * zoom, bounds.height * zoom)
2671
+ // Don't use pageToScreen here: it reads the screen bounds without capturing them, so this
2672
+ // computed would never invalidate when the container moves
2673
+ const screenBounds = this.getViewportScreenBounds()
2674
+ const { x: cx, y: cy, z: zoom } = this.getCamera()
2675
+ return new Box(
2676
+ (bounds.x + cx) * zoom + screenBounds.x,
2677
+ (bounds.y + cy) * zoom + screenBounds.y,
2678
+ bounds.width * zoom,
2679
+ bounds.height * zoom
2680
+ )
2660
2681
  }
2661
2682
 
2662
2683
  // Focus Group
@@ -2796,57 +2817,38 @@ export class Editor extends EventEmitter<TLEventMap> {
2796
2817
  setEditingShape(shape: TLShapeId | TLShape | null): this {
2797
2818
  const id = typeof shape === 'string' ? shape : (shape?.id ?? null)
2798
2819
 
2799
- if (!id) {
2800
- // setting the editing shape to null
2820
+ // id was provided but the next editing shape was not editable or didn't exist, so do nothing
2821
+ if (id && !this.canEditShape(id)) return this
2822
+
2823
+ this.run(() => {
2824
+ // Clean up the previous editing shape. This runs outside the history-ignored batch below,
2825
+ // otherwise document changes made by onEditEnd (e.g. deleting an empty text shape) are
2826
+ // never recorded and leave a phantom undo entry whose redo resurrects the shape.
2827
+ const prevEditingShapeId = this.getEditingShapeId()
2828
+ if (prevEditingShapeId) {
2829
+ const prevEditingShape = this.getShape(prevEditingShapeId)
2830
+ if (prevEditingShape) {
2831
+ this.getShapeUtil(prevEditingShape).onEditEnd?.(prevEditingShape)
2832
+ }
2833
+ }
2834
+
2801
2835
  this.run(
2802
2836
  () => {
2803
- // Clean up the previous editing shape
2804
- const prevEditingShapeId = this.getEditingShapeId()
2805
- if (prevEditingShapeId) {
2806
- const prevEditingShape = this.getShape(prevEditingShapeId)
2807
- if (prevEditingShape) {
2808
- this.getShapeUtil(prevEditingShape).onEditEnd?.(prevEditingShape)
2809
- }
2810
- }
2811
-
2812
2837
  // Clean up the editing shape state and rich text editor
2813
2838
  this._updateCurrentPageState({ editingShapeId: null })
2814
2839
  this._currentRichTextEditor.set(null)
2815
- },
2816
- { history: 'ignore' }
2817
- )
2818
-
2819
- return this
2820
- }
2821
2840
 
2822
- // id was provided but the next editing shape was not editable or didn't exist, so do nothing
2823
- if (!this.canEditShape(id)) return this
2824
-
2825
- // id was provided and the next editing shape is editable, so set the rich text editor to null
2826
- this.run(
2827
- () => {
2828
- // Clean up the previous editing shape
2829
- const prevEditingShapeId = this.getEditingShapeId()
2830
- if (prevEditingShapeId) {
2831
- const prevEditingShape = this.getShape(prevEditingShapeId)
2832
- if (prevEditingShape) {
2833
- this.getShapeUtil(prevEditingShape).onEditEnd?.(prevEditingShape)
2834
- }
2835
- }
2836
-
2837
- // Clean up the editing shape state and rich text editor
2838
- this._updateCurrentPageState({ editingShapeId: null })
2839
- this._currentRichTextEditor.set(null)
2841
+ if (!id) return
2840
2842
 
2841
- // Set the new editing shape
2842
- this.select(id)
2843
- this._updateCurrentPageState({ editingShapeId: id })
2843
+ this.select(id)
2844
+ this._updateCurrentPageState({ editingShapeId: id })
2844
2845
 
2845
- const nextEditingShape = this.getShape(id)! // shape should be there because canEditShape checked it. Possible small chance that onEditEnd deleted it?
2846
- this.getShapeUtil(nextEditingShape).onEditStart?.(nextEditingShape)
2847
- },
2848
- { history: 'ignore' }
2849
- )
2846
+ const nextEditingShape = this.getShape(id)! // shape should be there because canEditShape checked it. Possible small chance that onEditEnd deleted it?
2847
+ this.getShapeUtil(nextEditingShape).onEditStart?.(nextEditingShape)
2848
+ },
2849
+ { history: 'ignore' }
2850
+ )
2851
+ })
2850
2852
 
2851
2853
  return this
2852
2854
  }
@@ -3011,26 +3013,17 @@ export class Editor extends EventEmitter<TLEventMap> {
3011
3013
  * @public
3012
3014
  */
3013
3015
  setErasingShapes(shapes: TLShapeId[] | TLShape[]): this {
3016
+ // copy before sorting: the caller may pass a store-owned (frozen) array
3014
3017
  const ids =
3015
3018
  typeof shapes[0] === 'string'
3016
- ? (shapes as TLShapeId[])
3019
+ ? (shapes as TLShapeId[]).slice()
3017
3020
  : (shapes as TLShape[]).map((shape) => shape.id)
3018
3021
  ids.sort() // sort the incoming ids
3019
3022
  const erasingShapeIds = this.getErasingShapeIds()
3020
3023
  this.run(
3021
3024
  () => {
3022
- if (ids.length === erasingShapeIds.length) {
3023
- // if the new ids are the same length as the current ids, they might be the same.
3024
- // presuming the current ids are also sorted, check each item to see if it's the same;
3025
- // if we find any unequal, then we know the new ids are different.
3026
- for (let i = 0; i < ids.length; i++) {
3027
- if (ids[i] !== erasingShapeIds[i]) {
3028
- this._updateCurrentPageState({ erasingShapeIds: ids })
3029
- break
3030
- }
3031
- }
3032
- } else {
3033
- // if the ids are a different length, then we know they're different.
3025
+ // the current ids are also sorted, so a shallow comparison tells us whether they changed
3026
+ if (!areArraysShallowEqual(ids, erasingShapeIds)) {
3034
3027
  this._updateCurrentPageState({ erasingShapeIds: ids })
3035
3028
  }
3036
3029
  },
@@ -3067,6 +3060,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3067
3060
  if (!_shape) return false
3068
3061
  const util = this.getShapeUtil(_shape)
3069
3062
  if (!util.canCrop(_shape)) return false
3063
+ if (this.getIsReadonly()) return false
3070
3064
  if (this.isShapeOrAncestorLocked(_shape)) return false
3071
3065
  return true
3072
3066
  }
@@ -3146,11 +3140,13 @@ export class Editor extends EventEmitter<TLEventMap> {
3146
3140
  const collaborators = this.getCollaborators()
3147
3141
  let leaderPresence = null as null | TLInstancePresence
3148
3142
  while (targetUserId && !visited.includes(targetUserId)) {
3149
- leaderPresence = collaborators.find((c) => c.userId === targetUserId) ?? null
3150
- targetUserId = leaderPresence?.followingUserId ?? null
3151
- if (leaderPresence) {
3152
- visited.push(leaderPresence.userId)
3153
- }
3143
+ const nextPresence = collaborators.find((c) => c.userId === targetUserId)
3144
+ // Stop at the last resolvable presence, otherwise a leader whose own leader has left
3145
+ // (or whose presence hasn't arrived yet) can't be followed at all
3146
+ if (!nextPresence) break
3147
+ leaderPresence = nextPresence
3148
+ targetUserId = nextPresence.followingUserId ?? null
3149
+ visited.push(nextPresence.userId)
3154
3150
  }
3155
3151
  return leaderPresence
3156
3152
  }
@@ -3268,44 +3264,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3268
3264
  *
3269
3265
  * @public */
3270
3266
  getInitialZoom() {
3271
- const cameraOptions = this.getCameraOptions()
3272
- // If no camera constraints are provided, the default zoom is 100%
3273
- if (!cameraOptions.constraints) return 1
3274
-
3275
- // When defaultZoom is default, the default zoom is 100%
3276
- if (cameraOptions.constraints.initialZoom === 'default') return 1
3277
-
3278
- const { zx, zy } = getCameraFitXFitY(this, cameraOptions)
3279
-
3280
- switch (cameraOptions.constraints.initialZoom) {
3281
- case 'fit-min': {
3282
- return Math.max(zx, zy)
3283
- }
3284
- case 'fit-max': {
3285
- return Math.min(zx, zy)
3286
- }
3287
- case 'fit-x': {
3288
- return zx
3289
- }
3290
- case 'fit-y': {
3291
- return zy
3292
- }
3293
- case 'fit-min-100': {
3294
- return Math.min(1, Math.max(zx, zy))
3295
- }
3296
- case 'fit-max-100': {
3297
- return Math.min(1, Math.min(zx, zy))
3298
- }
3299
- case 'fit-x-100': {
3300
- return Math.min(1, zx)
3301
- }
3302
- case 'fit-y-100': {
3303
- return Math.min(1, zy)
3304
- }
3305
- default: {
3306
- throw exhaustiveSwitchError(cameraOptions.constraints.initialZoom)
3307
- }
3308
- }
3267
+ return this._getFitZoom(this.getCameraOptions().constraints?.initialZoom ?? 'default')
3309
3268
  }
3310
3269
 
3311
3270
  /**
@@ -3318,16 +3277,16 @@ export class Editor extends EventEmitter<TLEventMap> {
3318
3277
  *
3319
3278
  * @public */
3320
3279
  getBaseZoom() {
3321
- const cameraOptions = this.getCameraOptions()
3322
- // If no camera constraints are provided, the default zoom is 100%
3323
- if (!cameraOptions.constraints) return 1
3280
+ return this._getFitZoom(this.getCameraOptions().constraints?.baseZoom ?? 'default')
3281
+ }
3324
3282
 
3325
- // When defaultZoom is default, the default zoom is 100%
3326
- if (cameraOptions.constraints.baseZoom === 'default') return 1
3283
+ private _getFitZoom(fit: TLCameraConstraints['initialZoom']): number {
3284
+ const cameraOptions = this.getCameraOptions()
3285
+ if (!cameraOptions.constraints || fit === 'default') return 1
3327
3286
 
3328
3287
  const { zx, zy } = getCameraFitXFitY(this, cameraOptions)
3329
3288
 
3330
- switch (cameraOptions.constraints.baseZoom) {
3289
+ switch (fit) {
3331
3290
  case 'fit-min': {
3332
3291
  return Math.max(zx, zy)
3333
3292
  }
@@ -3353,7 +3312,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3353
3312
  return Math.min(1, zy)
3354
3313
  }
3355
3314
  default: {
3356
- throw exhaustiveSwitchError(cameraOptions.constraints.baseZoom)
3315
+ throw exhaustiveSwitchError(fit)
3357
3316
  }
3358
3317
  }
3359
3318
  }
@@ -3390,7 +3349,9 @@ export class Editor extends EventEmitter<TLEventMap> {
3390
3349
  ...this._cameraOptions.__unsafe__getWithoutCapture(),
3391
3350
  ...opts,
3392
3351
  })
3393
- if (next.zoomSteps?.length < 1) next.zoomSteps = [1]
3352
+ // `undefined < 1` is false, so an explicit `zoomSteps: undefined` would otherwise get through
3353
+ // and make every later camera move throw
3354
+ if (!next.zoomSteps || next.zoomSteps.length < 1) next.zoomSteps = [1]
3394
3355
  this._cameraOptions.set(next)
3395
3356
  this.setCamera(this.getCamera())
3396
3357
  return this
@@ -3998,9 +3959,9 @@ export class Editor extends EventEmitter<TLEventMap> {
3998
3959
  const remaining = duration - elapsed
3999
3960
  const t = easing(1 - remaining / duration)
4000
3961
 
4001
- const left = start.minX + (end.minX - start.minX) * t
4002
- const top = start.minY + (end.minY - start.minY) * t
4003
- const right = start.maxX + (end.maxX - start.maxX) * t
3962
+ const left = lerp(start.minX, end.minX, t)
3963
+ const top = lerp(start.minY, end.minY, t)
3964
+ const right = lerp(start.maxX, end.maxX, t)
4004
3965
 
4005
3966
  this._setCamera(new Vec(-left, -top, this.getViewportScreenBounds().width / (right - left)), {
4006
3967
  force: true,
@@ -4115,9 +4076,10 @@ export class Editor extends EventEmitter<TLEventMap> {
4115
4076
  let newCy = cy + dy
4116
4077
  let newCz = cz
4117
4078
 
4118
- // animate zoom if z direction is passed in
4079
+ // animate zoom if z direction is passed in. Use an exponential factor so that a single
4080
+ // long frame can't drive the zoom to zero or negative (which would give NaN coordinates)
4119
4081
  if (dirZ !== 0) {
4120
- newCz = cz * (1 + dirZ * currentSpeed * elapsed)
4082
+ newCz = cz * Math.exp(dirZ * currentSpeed * elapsed)
4121
4083
  // Adjust x/y to keep the viewport center fixed while zooming
4122
4084
  const center = this.getViewportScreenCenter()
4123
4085
  newCx += center.x / newCz - center.x / cz
@@ -4702,7 +4664,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4702
4664
  let nextIndex = this.options.maxShapesPerPage * 2
4703
4665
  let nextBackgroundIndex = this.options.maxShapesPerPage
4704
4666
 
4705
- const erasingShapeIds = this.getErasingShapeIds()
4667
+ const erasingShapeIds = new Set(this.getErasingShapeIds())
4706
4668
 
4707
4669
  const addShapeById = (id: TLShapeId, opacity: number, isAncestorErasing: boolean) => {
4708
4670
  const shape = this.getShape(id)
@@ -4710,7 +4672,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4710
4672
 
4711
4673
  if (this.isShapeHidden(shape)) {
4712
4674
  // process children just in case they are overriding the hidden state
4713
- const isErasing = isAncestorErasing || erasingShapeIds.includes(id)
4675
+ const isErasing = isAncestorErasing || erasingShapeIds.has(id)
4714
4676
  for (const childId of this.getSortedChildIdsForParent(id)) {
4715
4677
  addShapeById(childId, opacity, isErasing)
4716
4678
  }
@@ -4722,7 +4684,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4722
4684
  const util = this.getShapeUtil(shape)
4723
4685
 
4724
4686
  if (useEditorState) {
4725
- isShapeErasing = !isAncestorErasing && erasingShapeIds.includes(id)
4687
+ isShapeErasing = !isAncestorErasing && erasingShapeIds.has(id)
4726
4688
  if (isShapeErasing) {
4727
4689
  opacity *= 0.32
4728
4690
  }
@@ -5481,11 +5443,8 @@ export class Editor extends EventEmitter<TLEventMap> {
5481
5443
  /** @internal */
5482
5444
  @computed private _getShapePageBoundsCache(): ComputedCache<Box, TLShape> {
5483
5445
  return this.store.createComputedCache<Box, TLShape>('pageBoundsCache', (shape) => {
5484
- const pageTransform = this.getShapePageTransform(shape)
5485
- if (!pageTransform) return undefined
5486
-
5487
5446
  return Box.FromPoints(
5488
- pageTransform.applyToPoints(this.getShapeGeometry(shape).boundsVertices)
5447
+ this.getShapePageTransform(shape).applyToPoints(this.getShapeGeometry(shape).boundsVertices)
5489
5448
  )
5490
5449
  })
5491
5450
  }
@@ -6239,9 +6198,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6239
6198
  if (!freshShape) return new Vec(0, 0)
6240
6199
  if (isPageId(freshShape.parentId)) return Vec.From(point)
6241
6200
 
6242
- const parentTransform = this.getShapePageTransform(freshShape.parentId)
6243
- if (!parentTransform) return Vec.From(point)
6244
- return parentTransform.clone().invert().applyToPoint(point)
6201
+ return this.getShapePageTransform(freshShape.parentId).clone().invert().applyToPoint(point)
6245
6202
  }
6246
6203
 
6247
6204
  /**
@@ -6408,26 +6365,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6408
6365
  * @public
6409
6366
  */
6410
6367
  isShapeInPage(shape: TLShape | TLShapeId, pageId = this.getCurrentPageId()): boolean {
6411
- const id = typeof shape === 'string' ? shape : shape.id
6412
- const shapeToCheck = this.getShape(id)
6413
- if (!shapeToCheck) return false
6414
-
6415
- let shapeIsInPage = false
6416
-
6417
- if (shapeToCheck.parentId === pageId) {
6418
- shapeIsInPage = true
6419
- } else {
6420
- let parent = this.getShape(shapeToCheck.parentId)
6421
- isInPageSearch: while (parent) {
6422
- if (parent.parentId === pageId) {
6423
- shapeIsInPage = true
6424
- break isInPageSearch
6425
- }
6426
- parent = this.getShape(parent.parentId)
6427
- }
6428
- }
6429
-
6430
- return shapeIsInPage
6368
+ return this.getAncestorPageId(shape) === pageId
6431
6369
  }
6432
6370
 
6433
6371
  /**
@@ -6538,13 +6476,8 @@ export class Editor extends EventEmitter<TLEventMap> {
6538
6476
  for (let i = 0; i < shapesToReparent.length; i++) {
6539
6477
  const shape = shapesToReparent[i]
6540
6478
 
6541
- const pageTransform = this.getShapePageTransform(shape)!
6542
- if (!pageTransform) continue
6543
-
6544
- const pagePoint = pageTransform.point()
6545
- if (!pagePoint) continue
6546
-
6547
- const newPoint = invertedParentTransform.applyToPoint(pagePoint)
6479
+ const pageTransform = this.getShapePageTransform(shape)
6480
+ const newPoint = invertedParentTransform.applyToPoint(pageTransform.point())
6548
6481
  const newRotation = pageTransform.rotation() - parentPageRotation
6549
6482
 
6550
6483
  if (shape.id === parentId) {
@@ -6645,7 +6578,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6645
6578
  */
6646
6579
  getShapeAndDescendantIds(ids: TLShapeId[]): Set<TLShapeId> {
6647
6580
  const shapeIds = new Set<TLShapeId>()
6648
- for (const shape of ids.map((id) => this.getShape(id)!).sort(sortByIndex)) {
6581
+ for (const shape of compact(ids.map((id) => this.getShape(id))).sort(sortByIndex)) {
6649
6582
  shapeIds.add(shape.id)
6650
6583
  this.visitDescendants(shape, (descendantId) => {
6651
6584
  shapeIds.add(descendantId)
@@ -6669,6 +6602,9 @@ export class Editor extends EventEmitter<TLEventMap> {
6669
6602
  const draggingShapes = compact(droppingShapes.map((s) => this.getShape(s))).filter(
6670
6603
  (s) => !s.isLocked && !this.isShapeHidden(s)
6671
6604
  )
6605
+ // Descendants of the dragged shapes can't be the target, otherwise dragging a frame out of
6606
+ // its parent reports a nested child as the target and the parent never sees the drag leave
6607
+ const excludedIds = this.getShapeAndDescendantIds(draggingShapes.map((s) => s.id))
6672
6608
 
6673
6609
  const maybeDraggingOverShapes = this.getShapesAtPoint(point, {
6674
6610
  hitInside: true,
@@ -6678,7 +6614,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6678
6614
  !droppingShapes.includes(s) &&
6679
6615
  !s.isLocked &&
6680
6616
  !this.isShapeHidden(s) &&
6681
- !draggingShapes.includes(s)
6617
+ !excludedIds.has(s.id)
6682
6618
  )
6683
6619
 
6684
6620
  for (const maybeDraggingOverShape of maybeDraggingOverShapes) {
@@ -7052,8 +6988,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7052
6988
  for (const id of ids) {
7053
6989
  const shape = this.getShape(id)!
7054
6990
  const localDelta = Vec.From(offset)
7055
- const parentTransform = this.getShapeParentTransform(shape)
7056
- if (parentTransform) localDelta.rot(-parentTransform.rotation())
6991
+ localDelta.rot(-this.getShapeParentTransform(shape).rotation())
7057
6992
 
7058
6993
  changes.push(this.getChangesToTranslateShape(shape, localDelta.add(shape)))
7059
6994
  }
@@ -7123,7 +7058,13 @@ export class Editor extends EventEmitter<TLEventMap> {
7123
7058
  let ox = 0
7124
7059
  let oy = 0
7125
7060
 
7126
- if (offset && initialIds.has(originalId)) {
7061
+ // Only offset the roots of the duplicated tree: a descendant that was also passed in
7062
+ // follows its duplicated parent, so offsetting it too would move it twice
7063
+ if (
7064
+ offset &&
7065
+ initialIds.has(originalId) &&
7066
+ !shapeIdSet.has(originalShape.parentId as TLShapeId)
7067
+ ) {
7127
7068
  const parentTransform = this.getShapeParentTransform(originalShape)
7128
7069
  const vec = new Vec(offset.x, offset.y).rot(-parentTransform!.rotation())
7129
7070
  ox = vec.x
@@ -7289,27 +7230,17 @@ export class Editor extends EventEmitter<TLEventMap> {
7289
7230
 
7290
7231
  if (this.getIsReadonly() || ids.length === 0) return this
7291
7232
 
7292
- let allLocked = true,
7293
- allUnlocked = true
7233
+ let allLocked = true
7294
7234
  const shapesToToggle: TLShape[] = []
7295
7235
  for (const id of ids) {
7296
7236
  const shape = this.getShape(id)
7297
7237
  if (shape) {
7298
7238
  shapesToToggle.push(shape)
7299
- if (shape.isLocked) {
7300
- allUnlocked = false
7301
- } else {
7302
- allLocked = false
7303
- }
7239
+ if (!shape.isLocked) allLocked = false
7304
7240
  }
7305
7241
  }
7306
7242
  this.run(() => {
7307
- if (allUnlocked) {
7308
- this.updateShapes(
7309
- shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true }))
7310
- )
7311
- this.setSelectedShapes([])
7312
- } else if (allLocked) {
7243
+ if (allLocked) {
7313
7244
  this.updateShapes(
7314
7245
  shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false }))
7315
7246
  )
@@ -7317,6 +7248,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7317
7248
  this.updateShapes(
7318
7249
  shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true }))
7319
7250
  )
7251
+ this.setSelectedShapes([])
7320
7252
  }
7321
7253
  })
7322
7254
 
@@ -7456,9 +7388,12 @@ export class Editor extends EventEmitter<TLEventMap> {
7456
7388
 
7457
7389
  // optionally filter to axis-aligned shapes (rotation is a multiple of 90 degrees)
7458
7390
  if (opts?.filterAxisAligned) {
7459
- freshShapes = freshShapes.filter(
7460
- (s) => this.getShapePageTransform(s)?.rotation() % (PI / 2) === 0
7461
- )
7391
+ freshShapes = freshShapes.filter((s) => {
7392
+ // Page rotations come back through atan2 on a composed matrix, so a 270° shape can land
7393
+ // a hair either side of the multiple; an exact === 0 check drops it
7394
+ const remainder = Math.abs(this.getShapePageTransform(s).rotation() % HALF_PI)
7395
+ return Math.min(remainder, HALF_PI - remainder) < 1e-9
7396
+ })
7462
7397
  }
7463
7398
 
7464
7399
  const clusters: { shapes: TLShape[]; pageBounds: Box }[] = []
@@ -7679,13 +7614,14 @@ export class Editor extends EventEmitter<TLEventMap> {
7679
7614
 
7680
7615
  let shapeGap: number = 0
7681
7616
 
7617
+ // Stack in spatial order rather than input (z) order, otherwise shapes swap places
7618
+ shapeClustersToStack.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])
7619
+
7682
7620
  if (_gap === 0) {
7683
7621
  // note: this is not used in the current tldraw.com; there we use a specified stack
7684
7622
 
7685
7623
  const gaps: Record<number, number> = {}
7686
7624
 
7687
- shapeClustersToStack.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])
7688
-
7689
7625
  // Collect all of the gaps between shapes. We want to find
7690
7626
  // patterns (equal gaps between shapes) and use the most common
7691
7627
  // one as the gap for all of the shapes.
@@ -7738,8 +7674,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7738
7674
  // todo: ensure that the parent isn't being aligned together with its children
7739
7675
  const parent = this.getShapeParent(shape)
7740
7676
  if (parent) {
7741
- const parentTransform = this.getShapePageTransform(parent)
7742
- if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
7677
+ shapeDelta.rot(-this.getShapePageTransform(parent).rotation())
7743
7678
  }
7744
7679
 
7745
7680
  shapeDelta.add(shape) // add the shape's x and y to the delta
@@ -7789,10 +7724,11 @@ export class Editor extends EventEmitter<TLEventMap> {
7789
7724
 
7790
7725
  const maxWidth = commonBounds.width
7791
7726
 
7792
- // sort the shape clusters by width and then height, descending
7727
+ // sort the shape clusters by width and then height, descending: potpack fills each row's
7728
+ // right-hand space with the shapes that follow, so they must be no taller than the row
7793
7729
  shapeClustersToPack
7794
- .sort((a, b) => a.pageBounds.width - b.pageBounds.width)
7795
- .sort((a, b) => a.pageBounds.height - b.pageBounds.height)
7730
+ .sort((a, b) => b.pageBounds.width - a.pageBounds.width)
7731
+ .sort((a, b) => b.pageBounds.height - a.pageBounds.height)
7796
7732
 
7797
7733
  // Start with is (sort of) the square of the area
7798
7734
  const startWidth = Math.max(Math.ceil(Math.sqrt(area / 0.95)), maxWidth)
@@ -7862,8 +7798,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7862
7798
 
7863
7799
  const parent = this.getShapeParent(shape)
7864
7800
  if (parent) {
7865
- const parentTransform = this.getShapeParentTransform(shape)
7866
- if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
7801
+ shapeDelta.rot(-this.getShapeParentTransform(shape).rotation())
7867
7802
  }
7868
7803
 
7869
7804
  shapeDelta.add(shape)
@@ -7953,8 +7888,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7953
7888
  // todo: ensure that the parent isn't being aligned together with its children
7954
7889
  const parent = this.getShapeParent(shape)
7955
7890
  if (parent) {
7956
- const parentTransform = this.getShapePageTransform(parent)
7957
- if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
7891
+ shapeDelta.rot(-this.getShapePageTransform(parent).rotation())
7958
7892
  }
7959
7893
 
7960
7894
  shapeDelta.add(shape) // add the shape's x and y to the delta
@@ -8054,8 +7988,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8054
7988
  // todo: ensure that the parent isn't being aligned together with its children
8055
7989
  const parent = this.getShapeParent(shape)
8056
7990
  if (parent) {
8057
- const parentTransform = this.getShapePageTransform(parent)
8058
- if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
7991
+ shapeDelta.rot(-this.getShapePageTransform(parent).rotation())
8059
7992
  }
8060
7993
 
8061
7994
  shapeDelta.add(shape) // add the shape's x and y to the delta
@@ -8123,8 +8056,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8123
8056
  for (const shape of shapes) {
8124
8057
  // First translate
8125
8058
  const shapeLocalOffset = localOffset.clone()
8126
- const parentTransform = this.getShapeParentTransform(shape)
8127
- if (parentTransform) shapeLocalOffset.rot(-parentTransform.rotation())
8059
+ shapeLocalOffset.rot(-this.getShapeParentTransform(shape).rotation())
8128
8060
  shapeLocalOffset.add(shape)
8129
8061
  const changes = this.getChangesToTranslateShape(shape, shapeLocalOffset)
8130
8062
  this.updateShape(changes)
@@ -8195,8 +8127,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8195
8127
  for (const shape of shapes) {
8196
8128
  // First translate
8197
8129
  const shapeLocalOffset = localOffset.clone()
8198
- const parentTransform = this.getShapeParentTransform(shape)
8199
- if (parentTransform) shapeLocalOffset.rot(-parentTransform.rotation())
8130
+ shapeLocalOffset.rot(-this.getShapeParentTransform(shape).rotation())
8200
8131
  shapeLocalOffset.add(shape)
8201
8132
  const changes = this.getChangesToTranslateShape(shape, shapeLocalOffset)
8202
8133
  this.updateShape(changes)
@@ -8260,12 +8191,9 @@ export class Editor extends EventEmitter<TLEventMap> {
8260
8191
  const pageTransform = opts.initialPageTransform
8261
8192
  ? Mat.Cast(opts.initialPageTransform)
8262
8193
  : this.getShapePageTransform(id)
8263
- if (!pageTransform) return null
8264
8194
 
8265
8195
  const pageRotation = pageTransform.rotation()
8266
8196
 
8267
- if (pageRotation == null) return null
8268
-
8269
8197
  const scaleAxisRotation = opts.scaleAxisRotation ?? pageRotation
8270
8198
 
8271
8199
  const initialBounds = opts.initialBounds ?? this.getShapeGeometry(id).bounds
@@ -8502,7 +8430,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8502
8430
  )
8503
8431
 
8504
8432
  // now calculate how far away the shape is from where it needs to be
8505
- const pageTransform = this.getShapePageTransform(id)!
8433
+ const pageTransform = this.getShapePageTransform(id)
8506
8434
  // We need to use the local bounds center transformed to page space, not the axis-aligned
8507
8435
  // page bounds center. This is because the page bounds are axis-aligned and their center
8508
8436
  // changes when the rotation changes, but we want to use the same reference point as
@@ -8510,7 +8438,6 @@ export class Editor extends EventEmitter<TLEventMap> {
8510
8438
  const currentLocalBounds = this.getShapeGeometry(id).bounds
8511
8439
  const currentPageCenter = Mat.applyToPoint(pageTransform, currentLocalBounds.center)
8512
8440
  const shapePageTransformOrigin = pageTransform.point()
8513
- if (!currentPageCenter || !shapePageTransformOrigin) return this
8514
8441
  const pageDelta = Vec.Sub(postScaleShapePageCenter, currentPageCenter)
8515
8442
 
8516
8443
  // and finally figure out what the shape's new position should be
@@ -8898,10 +8825,10 @@ export class Editor extends EventEmitter<TLEventMap> {
8898
8825
 
8899
8826
  updates.push({
8900
8827
  ...end,
8901
- x: start.x + (end.x - start.x) * t,
8902
- y: start.y + (end.y - start.y) * t,
8903
- opacity: start.opacity + (end.opacity - start.opacity) * t,
8904
- rotation: start.rotation + (end.rotation - start.rotation) * t,
8828
+ x: lerp(start.x, end.x, t),
8829
+ y: lerp(start.y, end.y, t),
8830
+ opacity: lerp(start.opacity, end.opacity, t),
8831
+ rotation: lerp(start.rotation, end.rotation, t),
8905
8832
  props: this.getShapeUtil(end).getInterpolatedProps?.(start, end, t) ?? end.props,
8906
8833
  })
8907
8834
  }
@@ -9117,7 +9044,7 @@ export class Editor extends EventEmitter<TLEventMap> {
9117
9044
  * @public
9118
9045
  */
9119
9046
  updateShapes<T extends TLShape>(partials: (TLShapePartial<T> | null | undefined)[]) {
9120
- const compactedPartials: TLShapePartial<T>[] = Array(partials.length)
9047
+ const compactedPartials: TLShapePartial<T>[] = []
9121
9048
 
9122
9049
  for (let i = 0, n = partials.length; i < n; i++) {
9123
9050
  const partial = partials[i]
@@ -9263,26 +9190,25 @@ export class Editor extends EventEmitter<TLEventMap> {
9263
9190
  /* --------------------- Styles --------------------- */
9264
9191
 
9265
9192
  /**
9266
- * Get all the current styles among the users selected shapes
9193
+ * Groups have no styles of their own: a style read or write on a selection applies to the
9194
+ * non-group shapes beneath each group, however deeply nested. Returns those shapes.
9267
9195
  *
9268
9196
  * @internal
9269
9197
  */
9270
- private _extractSharedStyles(shape: TLShape, sharedStyleMap: SharedStyleMap) {
9271
- if (this.isShapeOfType(shape, 'group')) {
9272
- // For groups, ignore the styles of the group shape and instead include the styles of the
9273
- // group's children. These are the shapes that would have their styles changed if the
9274
- // user called `setStyle` on the current selection.
9275
- const childIds = this._parentIdsToChildIds.get()[shape.id]
9276
- if (!childIds) return
9277
-
9278
- for (let i = 0, n = childIds.length; i < n; i++) {
9279
- this._extractSharedStyles(this.getShape(childIds[i])!, sharedStyleMap)
9280
- }
9281
- } else {
9282
- for (const [style, propKey] of this.styleProps[shape.type]) {
9283
- sharedStyleMap.applyValue(style, getOwnProperty(shape.props, propKey))
9198
+ private _getStyleableShapes(shapes: TLShape[]): TLShape[] {
9199
+ const result: TLShape[] = []
9200
+ const visit = (shape: TLShape) => {
9201
+ if (this.isShapeOfType(shape, 'group')) {
9202
+ for (const childId of this.getSortedChildIdsForParent(shape.id)) {
9203
+ const child = this.getShape(childId)
9204
+ if (child) visit(child)
9205
+ }
9206
+ } else {
9207
+ result.push(shape)
9284
9208
  }
9285
9209
  }
9210
+ for (const shape of shapes) visit(shape)
9211
+ return result
9286
9212
  }
9287
9213
 
9288
9214
  /**
@@ -9292,11 +9218,11 @@ export class Editor extends EventEmitter<TLEventMap> {
9292
9218
  */
9293
9219
  @computed
9294
9220
  private _getSelectionSharedStyles(): ReadonlySharedStyleMap {
9295
- const selectedShapes = this.getSelectedShapes()
9296
-
9297
9221
  const sharedStyles = new SharedStyleMap()
9298
- for (const selectedShape of selectedShapes) {
9299
- this._extractSharedStyles(selectedShape, sharedStyles)
9222
+ for (const shape of this._getStyleableShapes(this.getSelectedShapes())) {
9223
+ for (const [style, propKey] of this.styleProps[shape.type]) {
9224
+ sharedStyles.applyValue(style, getOwnProperty(shape.props, propKey))
9225
+ }
9300
9226
  }
9301
9227
 
9302
9228
  return sharedStyles
@@ -9354,18 +9280,8 @@ export class Editor extends EventEmitter<TLEventMap> {
9354
9280
  if (!currentTool) return styles
9355
9281
 
9356
9282
  if (currentTool.shapeType) {
9357
- if (
9358
- currentTool.shapeType === 'frame' &&
9359
- !(this.getShapeUtil('frame')!.options as any).showColors
9360
- ) {
9361
- for (const style of this.styleProps[currentTool.shapeType].keys()) {
9362
- if (style.id === 'tldraw:color') continue
9363
- styles.applyValue(style, this.getStyleForNextShape(style))
9364
- }
9365
- } else {
9366
- for (const style of this.styleProps[currentTool.shapeType].keys()) {
9367
- styles.applyValue(style, this.getStyleForNextShape(style))
9368
- }
9283
+ for (const style of this.styleProps[currentTool.shapeType].keys()) {
9284
+ styles.applyValue(style, this.getStyleForNextShape(style))
9369
9285
  }
9370
9286
  }
9371
9287
 
@@ -9381,27 +9297,8 @@ export class Editor extends EventEmitter<TLEventMap> {
9381
9297
  */
9382
9298
  @computed getSharedOpacity(): SharedStyle<number> {
9383
9299
  if (this.isIn('select') && this.getSelectedShapeIds().length > 0) {
9384
- const shapesToCheck: TLShape[] = []
9385
- const addShape = (shapeId: TLShapeId) => {
9386
- const shape = this.getShape(shapeId)
9387
- if (!shape) return
9388
- // For groups, ignore the opacity of the group shape and instead include
9389
- // the opacity of the group's children. These are the shapes that would have
9390
- // their opacity changed if the user called `setOpacity` on the current selection.
9391
- if (this.isShapeOfType(shape, 'group')) {
9392
- for (const childId of this.getSortedChildIdsForParent(shape.id)) {
9393
- addShape(childId)
9394
- }
9395
- } else {
9396
- shapesToCheck.push(shape)
9397
- }
9398
- }
9399
- for (const shapeId of this.getSelectedShapeIds()) {
9400
- addShape(shapeId)
9401
- }
9402
-
9403
9300
  let opacity: number | null = null
9404
- for (const shape of shapesToCheck) {
9301
+ for (const shape of this._getStyleableShapes(this.getSelectedShapes())) {
9405
9302
  if (opacity === null) {
9406
9303
  opacity = shape.opacity
9407
9304
  } else if (opacity !== shape.opacity) {
@@ -9444,33 +9341,12 @@ export class Editor extends EventEmitter<TLEventMap> {
9444
9341
  const selectedShapes = this.getSelectedShapes()
9445
9342
 
9446
9343
  if (selectedShapes.length > 0) {
9447
- const shapesToUpdate: TLShape[] = []
9448
-
9449
- // We can have many deep levels of grouped shape
9450
- // Making a recursive function to look through all the levels
9451
- const addShapeById = (shape: TLShape) => {
9452
- if (this.isShapeOfType(shape, 'group')) {
9453
- const childIds = this.getSortedChildIdsForParent(shape)
9454
- for (const childId of childIds) {
9455
- addShapeById(this.getShape(childId)!)
9456
- }
9457
- } else {
9458
- shapesToUpdate.push(shape)
9459
- }
9460
- }
9461
-
9462
- for (const id of selectedShapes) {
9463
- addShapeById(id)
9464
- }
9465
-
9466
9344
  this.updateShapes(
9467
- shapesToUpdate.map((shape) => {
9468
- return {
9469
- id: shape.id,
9470
- type: shape.type,
9471
- opacity,
9472
- }
9473
- })
9345
+ this._getStyleableShapes(selectedShapes).map((shape) => ({
9346
+ id: shape.id,
9347
+ type: shape.type,
9348
+ opacity,
9349
+ }))
9474
9350
  )
9475
9351
  }
9476
9352
 
@@ -9524,43 +9400,19 @@ export class Editor extends EventEmitter<TLEventMap> {
9524
9400
  const selectedShapes = this.getSelectedShapes()
9525
9401
 
9526
9402
  if (selectedShapes.length > 0) {
9527
- const updates: {
9528
- util: ShapeUtil
9529
- originalShape: TLShape
9530
- updatePartial: TLShapePartial
9531
- }[] = []
9532
-
9533
- // We can have many deep levels of grouped shape
9534
- // Making a recursive function to look through all the levels
9535
- const addShapeById = (shape: TLShape) => {
9536
- if (this.isShapeOfType(shape, 'group')) {
9537
- const childIds = this.getSortedChildIdsForParent(shape.id)
9538
- for (const childId of childIds) {
9539
- addShapeById(this.getShape(childId)!)
9540
- }
9541
- } else {
9542
- const util = this.getShapeUtil(shape)
9543
- const stylePropKey = this.styleProps[shape.type].get(style)
9544
- if (stylePropKey) {
9545
- const shapePartial: TLShapePartial = {
9546
- id: shape.id,
9547
- type: shape.type,
9548
- props: { [stylePropKey]: value },
9549
- }
9550
- updates.push({
9551
- util,
9552
- originalShape: shape,
9553
- updatePartial: shapePartial,
9554
- })
9555
- }
9403
+ const updates: TLShapePartial[] = []
9404
+ for (const shape of this._getStyleableShapes(selectedShapes)) {
9405
+ const stylePropKey = this.styleProps[shape.type].get(style)
9406
+ if (stylePropKey) {
9407
+ updates.push({
9408
+ id: shape.id,
9409
+ type: shape.type,
9410
+ props: { [stylePropKey]: value },
9411
+ })
9556
9412
  }
9557
9413
  }
9558
9414
 
9559
- for (const shape of selectedShapes) {
9560
- addShapeById(shape)
9561
- }
9562
-
9563
- this.updateShapes(updates.map(({ updatePartial }) => updatePartial))
9415
+ this.updateShapes(updates)
9564
9416
  }
9565
9417
 
9566
9418
  return this
@@ -10092,7 +9944,6 @@ export class Editor extends EventEmitter<TLEventMap> {
10092
9944
  if (shapeIdMap.has(newShape.parentId)) {
10093
9945
  newShape.parentId = shapeIdMap.get(oldShape.parentId)!
10094
9946
  } else {
10095
- rootShapeIds.push(newShape.id)
10096
9947
  // newShape.parentId = pasteParentId
10097
9948
  newShape.index = index
10098
9949
  index = getIndexAbove(index)
@@ -10135,10 +9986,14 @@ export class Editor extends EventEmitter<TLEventMap> {
10135
9986
  (asset.type === 'video' && asset.props.src?.startsWith('data:video'))
10136
9987
  ) {
10137
9988
  // it's src is a base64 image or video; we need to create a new asset without the src,
10138
- // then create a new asset from the original src. So we save a copy of the original asset,
10139
- // then delete the src from the original asset.
10140
- assetsToUpdate.push(structuredClone(asset as TLImageAsset | TLVideoAsset))
10141
- asset.props.src = null
9989
+ // then create a new asset from the original src. So we keep the original asset for the
9990
+ // upload and create a copy with its src removed. Copy rather than mutate: when no
9991
+ // migration applies, migrateStoreSnapshot hands back the caller's own record objects.
9992
+ assetsToUpdate.push(asset as TLImageAsset | TLVideoAsset)
9993
+ const assetWithoutSrc = structuredClone(asset as TLImageAsset | TLVideoAsset)
9994
+ assetWithoutSrc.props.src = null
9995
+ assetsToCreate.push(assetWithoutSrc)
9996
+ continue
10142
9997
  }
10143
9998
 
10144
9999
  // Add the asset to the list of assets to create