@tldraw/editor 5.4.1 → 5.5.0-canary.003bf5951274

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 (200) hide show
  1. package/dist-cjs/index.d.ts +37 -14
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +16 -12
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
  11. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
  12. package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
  13. package/dist-cjs/lib/constants.js +2 -0
  14. package/dist-cjs/lib/constants.js.map +2 -2
  15. package/dist-cjs/lib/editor/Editor.js +147 -157
  16. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  17. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  18. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +10 -6
  20. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  22. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
  24. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  26. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  27. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  28. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  29. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  30. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  31. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  32. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  33. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  34. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  35. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  36. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  37. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  38. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  39. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  40. package/dist-cjs/lib/exports/parseCss.js +1 -1
  41. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  42. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  43. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  44. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  45. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  46. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  47. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  48. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  49. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  50. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  51. package/dist-cjs/lib/primitives/geometry/Arc2d.js +1 -1
  52. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  53. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
  54. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  55. package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
  56. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  57. package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
  58. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  59. package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
  60. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  61. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  62. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  63. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  64. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  65. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +22 -24
  66. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  67. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
  68. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  69. package/dist-cjs/version.js +3 -3
  70. package/dist-cjs/version.js.map +1 -1
  71. package/dist-esm/index.d.mts +37 -14
  72. package/dist-esm/index.mjs +4 -2
  73. package/dist-esm/index.mjs.map +2 -2
  74. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  75. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  76. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  77. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  78. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  79. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  80. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  81. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  82. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  83. package/dist-esm/lib/constants.mjs +2 -0
  84. package/dist-esm/lib/constants.mjs.map +2 -2
  85. package/dist-esm/lib/editor/Editor.mjs +148 -157
  86. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  87. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  88. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  89. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
  90. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  91. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  92. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
  94. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +2 -2
  95. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  96. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  97. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  98. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  99. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  100. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  101. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  102. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  103. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  104. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  105. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  106. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  107. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  108. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  109. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  110. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  111. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  112. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  113. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  114. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  115. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  116. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  117. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  118. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  119. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  120. package/dist-esm/lib/primitives/geometry/Arc2d.mjs +1 -1
  121. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  122. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
  123. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  124. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
  125. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  126. package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
  127. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  128. package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
  129. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  130. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  131. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  132. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  133. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  134. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +22 -24
  135. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  136. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
  137. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  138. package/dist-esm/version.mjs +3 -3
  139. package/dist-esm/version.mjs.map +1 -1
  140. package/editor.css +9 -1
  141. package/package.json +8 -11
  142. package/src/index.ts +5 -1
  143. package/src/lib/TldrawEditor.tsx +35 -21
  144. package/src/lib/components/MenuClickCapture.tsx +55 -4
  145. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  146. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  147. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  148. package/src/lib/constants.ts +8 -0
  149. package/src/lib/editor/Editor.test.ts +20 -0
  150. package/src/lib/editor/Editor.ts +228 -262
  151. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  152. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
  153. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
  154. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  155. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  156. package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
  157. package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
  158. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  159. package/src/lib/editor/types/external-content.ts +5 -0
  160. package/src/lib/exports/ExportDelay.tsx +16 -4
  161. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  162. package/src/lib/exports/FontEmbedder.ts +32 -11
  163. package/src/lib/exports/StyleEmbedder.ts +13 -4
  164. package/src/lib/exports/fetchCache.test.ts +22 -0
  165. package/src/lib/exports/fetchCache.ts +4 -0
  166. package/src/lib/exports/getSvgAsImage.ts +13 -5
  167. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  168. package/src/lib/exports/getSvgJsx.tsx +28 -16
  169. package/src/lib/exports/parseCss.test.ts +4 -0
  170. package/src/lib/exports/parseCss.ts +2 -2
  171. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  172. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  173. package/src/lib/hooks/useIdentity.tsx +12 -1
  174. package/src/lib/license/LicenseManager.ts +13 -1
  175. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  176. package/src/lib/license/LicenseProvider.tsx +19 -5
  177. package/src/lib/primitives/geometry/Arc2d.test.ts +9 -0
  178. package/src/lib/primitives/geometry/Arc2d.ts +1 -1
  179. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
  180. package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
  181. package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
  182. package/src/lib/primitives/geometry/Edge2d.ts +1 -1
  183. package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
  184. package/src/lib/primitives/geometry/Group2d.ts +18 -7
  185. package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
  186. package/src/lib/primitives/geometry/Point2d.ts +1 -1
  187. package/src/lib/test/preserveFocus.test.tsx +81 -0
  188. package/src/lib/utils/debug-flags.ts +0 -6
  189. package/src/lib/utils/deepLinks.test.ts +15 -0
  190. package/src/lib/utils/deepLinks.ts +8 -3
  191. package/src/lib/utils/sync/LocalIndexedDb.test.ts +95 -0
  192. package/src/lib/utils/sync/LocalIndexedDb.ts +31 -27
  193. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
  194. package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
  195. package/src/version.ts +3 -3
  196. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  197. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  198. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  199. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  200. package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
@@ -110,6 +110,7 @@ import {
110
110
  import {
111
111
  DEFAULT_ANIMATION_OPTIONS,
112
112
  DEFAULT_CAMERA_OPTIONS,
113
+ FRAME_MS_60HZ,
113
114
  INTERNAL_POINTER_IDS,
114
115
  LEFT_MOUSE_BUTTON,
115
116
  MIDDLE_MOUSE_BUTTON,
@@ -340,6 +341,11 @@ export interface TLRenderingShape {
340
341
 
341
342
  const RENDERING_SHAPES_SORT_CACHE_THRESHOLD = 100
342
343
 
344
+ const AXIS = {
345
+ horizontal: { val: 'x', min: 'minX', max: 'maxX', dim: 'width' },
346
+ vertical: { val: 'y', min: 'minY', max: 'maxY', dim: 'height' },
347
+ } as const
348
+
343
349
  /** @public */
344
350
  export class Editor extends EventEmitter<TLEventMap> {
345
351
  readonly id = uniqueId()
@@ -752,7 +758,9 @@ export class Editor extends EventEmitter<TLEventMap> {
752
758
  }
753
759
 
754
760
  if (deleteBindingIds.length) {
755
- this.deleteBindings(deleteBindingIds)
761
+ // straight to the store: this cleanup must run even when deleteBindings would
762
+ // refuse (readonly), e.g. for a deletion that arrived from a remote peer
763
+ this.store.remove(deleteBindingIds)
756
764
  }
757
765
  },
758
766
  },
@@ -1969,6 +1977,7 @@ export class Editor extends EventEmitter<TLEventMap> {
1969
1977
  * @public
1970
1978
  **/
1971
1979
  updateDocumentSettings(settings: Partial<TLDocument>): this {
1980
+ if (this.getIsReadonly()) return this
1972
1981
  this.run(
1973
1982
  () => {
1974
1983
  this.store.put([{ ...this.getDocumentSettings(), ...settings }])
@@ -2196,10 +2205,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2196
2205
  * @public
2197
2206
  */
2198
2207
  select(...shapes: TLShapeId[] | TLShape[]): this {
2199
- const ids =
2200
- typeof shapes[0] === 'string'
2201
- ? (shapes as TLShapeId[])
2202
- : (shapes as TLShape[]).map((shape) => shape.id)
2208
+ const ids = toShapeIds(shapes)
2203
2209
  this.setSelectedShapes(ids)
2204
2210
  return this
2205
2211
  }
@@ -2215,10 +2221,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2215
2221
  * @public
2216
2222
  */
2217
2223
  deselect(...shapes: TLShapeId[] | TLShape[]): this {
2218
- const ids =
2219
- typeof shapes[0] === 'string'
2220
- ? (shapes as TLShapeId[])
2221
- : (shapes as TLShape[]).map((shape) => shape.id)
2224
+ const ids = toShapeIds(shapes)
2222
2225
  const selectedShapeIds = this.getSelectedShapeIds()
2223
2226
  if (selectedShapeIds.length > 0 && ids.length > 0) {
2224
2227
  this.setSelectedShapes(selectedShapeIds.filter((id) => !ids.includes(id)))
@@ -2263,10 +2266,11 @@ export class Editor extends EventEmitter<TLEventMap> {
2263
2266
  parentToSelectWithinId = this.getCurrentPageId()
2264
2267
  }
2265
2268
 
2266
- // Select all the unlocked shapes within the parent
2269
+ // Select all the unlocked shapes within the parent. Only the shape's own lock matters here:
2270
+ // selecting inside a locked frame or group is allowed, mutating is not.
2267
2271
  const ids = this.getSortedChildIdsForParent(parentToSelectWithinId)
2268
2272
  if (ids.length <= 0) return this
2269
- this.setSelectedShapes(this._getUnlockedShapeIds(ids))
2273
+ this.setSelectedShapes(ids.filter((id) => !this.getShape(id)?.isLocked))
2270
2274
  return this
2271
2275
  }
2272
2276
 
@@ -2287,12 +2291,14 @@ export class Editor extends EventEmitter<TLEventMap> {
2287
2291
  firstParentId &&
2288
2292
  selectedShapeIds.every((shapeId) => this.getShape(shapeId)?.parentId === firstParentId) &&
2289
2293
  !isPageId(firstParentId)
2290
- const filteredShapes = isSelectedWithinContainer
2291
- ? this.getCurrentPageShapes().filter((shape) => shape.parentId === firstParentId)
2292
- : this.getCurrentPageShapes().filter((shape) => isPageId(shape.parentId))
2293
- const readingOrderShapes = isSelectedWithinContainer
2294
- ? this._getShapesInReadingOrder(filteredShapes)
2295
- : this.getCurrentPageShapesInReadingOrder()
2294
+ // Locked shapes (and children of locked containers) can't be selected by clicking or
2295
+ // select all, so traversal skips them too
2296
+ const filteredShapes = this.getCurrentPageShapes().filter(
2297
+ (shape) =>
2298
+ !this.isShapeOrAncestorLocked(shape) &&
2299
+ (isSelectedWithinContainer ? shape.parentId === firstParentId : isPageId(shape.parentId))
2300
+ )
2301
+ const readingOrderShapes = this._getShapesInReadingOrder(filteredShapes)
2296
2302
  const currentShapeId: TLShapeId | undefined =
2297
2303
  selectedShapeIds.length === 1
2298
2304
  ? selectedShapeIds[0]
@@ -2301,10 +2307,16 @@ export class Editor extends EventEmitter<TLEventMap> {
2301
2307
  let adjacentShapeId: TLShapeId
2302
2308
  if (direction === 'next' || direction === 'prev') {
2303
2309
  const shapeIds = readingOrderShapes.map((shape) => shape.id)
2310
+ // Every candidate can be filtered out (e.g. a locked shape is selected and nothing
2311
+ // else is unlocked); indexing an empty list would hand getShape undefined
2312
+ if (shapeIds.length === 0) return
2304
2313
 
2305
2314
  const currentIndex = currentShapeId ? shapeIds.indexOf(currentShapeId) : -1
2315
+ // With no current index, stepping from -1 makes 'prev' land one shape
2316
+ // before the last; seed it from 0 so it wraps to the last shape. See #10559.
2317
+ const startIndex = currentIndex === -1 && direction === 'prev' ? 0 : currentIndex
2306
2318
  const adjacentIndex =
2307
- (currentIndex + (direction === 'next' ? 1 : -1) + shapeIds.length) % shapeIds.length
2319
+ (startIndex + (direction === 'next' ? 1 : -1) + shapeIds.length) % shapeIds.length
2308
2320
  adjacentShapeId = shapeIds[adjacentIndex]
2309
2321
  } else {
2310
2322
  if (!currentShapeId) return
@@ -2446,8 +2458,9 @@ export class Editor extends EventEmitter<TLEventMap> {
2446
2458
 
2447
2459
  // Ok, now score that subset of shapes.
2448
2460
  const lowestScoringShape = minBy(shapesInDirection, ({ center }) => {
2449
- // Distance is the primary weighting factor.
2450
- const distance = Vec.Dist2(currentCenter, center)
2461
+ // Linear, not squared: the off-axis and diagonal penalties below are page units too, and
2462
+ // squared distance swamps them, so a nearer diagonal shape beats an aligned one.
2463
+ const distance = Vec.Dist(currentCenter, center)
2451
2464
 
2452
2465
  // Distance along the primary axis.
2453
2466
  const dirProp = ['left', 'right'].includes(direction) ? 'x' : 'y'
@@ -2457,9 +2470,11 @@ export class Editor extends EventEmitter<TLEventMap> {
2457
2470
  const offProp = ['left', 'right'].includes(direction) ? 'y' : 'x'
2458
2471
  const offAxisDeviation = Math.abs(center[offProp] - currentCenter[offProp])
2459
2472
 
2460
- // Angle in degrees
2461
- const angle = Math.abs(Vec.Angle(currentCenter, center) * (180 / Math.PI))
2462
- const angleDeviation = Math.abs(angle - directionToAngle[direction])
2473
+ // atan2 gives -180..180, so 'up' is -90; normalize to 0..360 to match 270, and wrap the
2474
+ // deviation so that 350 is 10 away from 'right' (0), not 350.
2475
+ const angle = (Vec.Angle(currentCenter, center) * (180 / Math.PI) + 360) % 360
2476
+ const rawAngleDeviation = Math.abs(angle - directionToAngle[direction])
2477
+ const angleDeviation = Math.min(rawAngleDeviation, 360 - rawAngleDeviation)
2463
2478
 
2464
2479
  // Calculate final score (lower is better).
2465
2480
  // Weight factors to prioritize:
@@ -2964,10 +2979,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2964
2979
  * @public
2965
2980
  */
2966
2981
  setHintingShapes(shapes: TLShapeId[] | TLShape[]): this {
2967
- const ids =
2968
- typeof shapes[0] === 'string'
2969
- ? (shapes as TLShapeId[])
2970
- : (shapes as TLShape[]).map((shape) => shape.id)
2982
+ const ids = toShapeIds(shapes)
2971
2983
  // always ephemeral
2972
2984
  this.run(
2973
2985
  () => {
@@ -3014,10 +3026,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3014
3026
  */
3015
3027
  setErasingShapes(shapes: TLShapeId[] | TLShape[]): this {
3016
3028
  // copy before sorting: the caller may pass a store-owned (frozen) array
3017
- const ids =
3018
- typeof shapes[0] === 'string'
3019
- ? (shapes as TLShapeId[]).slice()
3020
- : (shapes as TLShape[]).map((shape) => shape.id)
3029
+ const ids = toShapeIds(shapes).slice()
3021
3030
  ids.sort() // sort the incoming ids
3022
3031
  const erasingShapeIds = this.getErasingShapeIds()
3023
3032
  this.run(
@@ -3591,6 +3600,20 @@ export class Editor extends EventEmitter<TLEventMap> {
3591
3600
  const { isLocked } = this._cameraOptions.__unsafe__getWithoutCapture()
3592
3601
  if (isLocked && !opts?.force) return this
3593
3602
 
3603
+ // Resolve the zoom before building the Vec: Vec.Cast would default a missing z to 1,
3604
+ // and a missing z should keep the current zoom level instead
3605
+ const _point = new Vec(point.x, point.y, point.z ?? this.getZoomLevel())
3606
+
3607
+ // Reject non-finite values before anything else, so the call is a no-op rather than a
3608
+ // partial one. An animated move writes the camera from a 'tick' listener, and a listener
3609
+ // that throws stops TickManager scheduling the next frame, which kills every frame-driven
3610
+ // behavior for the rest of the session instead of surfacing the error to the caller.
3611
+ if (!Number.isFinite(_point.x) || !Number.isFinite(_point.y) || !Number.isFinite(_point.z)) {
3612
+ throw Error(
3613
+ `Editor.setCamera: expected finite values, got (${_point.x}, ${_point.y}, ${_point.z}).`
3614
+ )
3615
+ }
3616
+
3594
3617
  // Stop any camera animations
3595
3618
  this.stopCameraAnimation()
3596
3619
 
@@ -3599,12 +3622,6 @@ export class Editor extends EventEmitter<TLEventMap> {
3599
3622
  this.stopFollowingUser()
3600
3623
  }
3601
3624
 
3602
- const _point = Vec.Cast(point)
3603
-
3604
- if (!Number.isFinite(_point.x)) _point.x = 0
3605
- if (!Number.isFinite(_point.y)) _point.y = 0
3606
- if (_point.z === undefined || !Number.isFinite(_point.z)) point.z = this.getZoomLevel()
3607
-
3608
3625
  const camera = this.getConstrainedCamera(_point, opts)
3609
3626
 
3610
3627
  if (opts?.animation) {
@@ -3939,6 +3956,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3939
3956
  easing(t: number): number
3940
3957
  start: Box
3941
3958
  end: Box
3959
+ opts: TLCameraMoveOptions
3942
3960
  }
3943
3961
 
3944
3962
  /** @internal */
@@ -3947,12 +3965,17 @@ export class Editor extends EventEmitter<TLEventMap> {
3947
3965
 
3948
3966
  this._viewportAnimation.elapsed += ms
3949
3967
 
3950
- const { elapsed, easing, duration, start, end } = this._viewportAnimation
3968
+ const { elapsed, easing, duration, start, end, opts } = this._viewportAnimation
3951
3969
 
3952
3970
  if (elapsed > duration) {
3953
3971
  this.off('tick', this._animateViewport)
3954
3972
  this._viewportAnimation = null
3955
- this._setCamera(new Vec(-end.x, -end.y, this.getViewportScreenBounds().width / end.width))
3973
+ // Forward the caller's options, otherwise a forced move to a position outside the
3974
+ // constraints animates there and then snaps back on this last frame
3975
+ this._setCamera(
3976
+ new Vec(-end.x, -end.y, this.getViewportScreenBounds().width / end.width),
3977
+ opts
3978
+ )
3956
3979
  return
3957
3980
  }
3958
3981
 
@@ -4006,6 +4029,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4006
4029
  easing,
4007
4030
  start: viewportPageBounds.clone(),
4008
4031
  end: targetViewportPage.clone(),
4032
+ opts: rest,
4009
4033
  }
4010
4034
 
4011
4035
  // If we ever get a "stop-camera-animation" event, we stop
@@ -4086,8 +4110,8 @@ export class Editor extends EventEmitter<TLEventMap> {
4086
4110
  newCy += center.y / newCz - center.y / cz
4087
4111
  }
4088
4112
 
4089
- // Apply friction
4090
- currentSpeed *= 1 - friction
4113
+ // Apply friction per unit of elapsed time, not per tick, or a 120 Hz display decays twice as fast
4114
+ currentSpeed *= (1 - friction) ** (elapsed / FRAME_MS_60HZ)
4091
4115
  if (currentSpeed < speedThreshold) {
4092
4116
  cancel()
4093
4117
  } else {
@@ -4130,6 +4154,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4130
4154
  // If we're not on the same page, move to the page they're on
4131
4155
  const isOnSamePage = presence.currentPageId === this.getCurrentPageId()
4132
4156
  if (!isOnSamePage) {
4157
+ this.markHistoryStoppingPoint('change-page')
4133
4158
  this.setCurrentPage(presence.currentPageId)
4134
4159
  }
4135
4160
 
@@ -5227,7 +5252,10 @@ export class Editor extends EventEmitter<TLEventMap> {
5227
5252
 
5228
5253
  this.run(
5229
5254
  () => {
5230
- this.store.props.assets.remove?.(ids)
5255
+ // the asset store's remove is async; surface failures instead of leaving an unhandled rejection
5256
+ Promise.resolve(this.store.props.assets.remove?.(ids)).catch((err) =>
5257
+ console.error('Error while removing assets from the asset store:', err)
5258
+ )
5231
5259
  this.store.remove(ids)
5232
5260
  },
5233
5261
  { history: 'ignore' }
@@ -5689,10 +5717,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5689
5717
  return
5690
5718
  }
5691
5719
 
5692
- const ids =
5693
- typeof shapes[0] === 'string'
5694
- ? (shapes as TLShapeId[])
5695
- : (shapes as TLShape[]).map((s) => s.id)
5720
+ const ids = toShapeIds(shapes)
5696
5721
  const freshShapes = compact(ids.map((id) => this.getShape(id)))
5697
5722
 
5698
5723
  if (freshShapes.length === 1) {
@@ -5840,14 +5865,16 @@ export class Editor extends EventEmitter<TLEventMap> {
5840
5865
  */
5841
5866
  getSelectedShapeAtPoint(point: VecLike): TLShape | undefined {
5842
5867
  const selectedShapeIds = this.getSelectedShapeIds()
5843
- const margin = this.options.hitTestMargin / this.getZoomLevel()
5868
+ if (selectedShapeIds.length === 0) return undefined
5869
+ const selectedShapeIdSet = new Set(selectedShapeIds)
5870
+ const margin = this.getHitTestMargin()
5844
5871
  const sortedShapes = this.getCurrentPageShapesSorted()
5845
5872
 
5846
5873
  // iterate from the top (highest z-index) to find the top-most matching shape
5847
5874
  for (let i = sortedShapes.length - 1; i >= 0; i--) {
5848
5875
  const shape = sortedShapes[i]
5849
5876
  if (shape.type === 'group') continue
5850
- if (!selectedShapeIds.includes(shape.id)) continue
5877
+ if (!selectedShapeIdSet.has(shape.id)) continue
5851
5878
  if (
5852
5879
  this.getShapeGeometry(shape).hitTestPoint(
5853
5880
  this.getPointInShapeSpace(shape, point),
@@ -6018,9 +6045,6 @@ export class Editor extends EventEmitter<TLEventMap> {
6018
6045
  // other hits would be occluded by the shape.
6019
6046
  return inMarginClosestToEdgeHit || shape
6020
6047
  } else {
6021
- // If the shape is bigger than the viewport, then skip it.
6022
- if (this.getShapePageBounds(shape)!.contains(viewportPageBounds)) continue
6023
-
6024
6048
  // If we're close to the edge of the shape, and if it's the closest edge among
6025
6049
  // all the edges that we've gotten close to so far, then we will want to hit the
6026
6050
  // shape unless we hit something else or closer in later iterations.
@@ -6041,6 +6065,10 @@ export class Editor extends EventEmitter<TLEventMap> {
6041
6065
  inMarginClosestToEdgeHit = shape
6042
6066
  }
6043
6067
  } else if (!inMarginClosestToEdgeHit) {
6068
+ // If the shape is bigger than the viewport, then skip it. (Only here: its
6069
+ // edges should still be hittable within the margin.)
6070
+ if (this.getShapePageBounds(shape)!.contains(viewportPageBounds)) continue
6071
+
6044
6072
  // If we're not within margin distance to any edge, and if the
6045
6073
  // shape is hollow, then we want to hit the shape with the
6046
6074
  // smallest area. (There's a bug here with self-intersecting
@@ -6059,6 +6087,11 @@ export class Editor extends EventEmitter<TLEventMap> {
6059
6087
  // If the distance is less than the margin, return the shape as the hit.
6060
6088
  // Use the editor's configurable hit test margin.
6061
6089
  if (distance < this.getHitTestMargin()) {
6090
+ // An edge we already hit (above this shape) that is at least as close still wins,
6091
+ // matching the closest-edge rule used for hollow shapes
6092
+ if (inMarginClosestToEdgeHit && inMarginClosestToEdgeDistance <= distance) {
6093
+ return inMarginClosestToEdgeHit
6094
+ }
6062
6095
  return shape
6063
6096
  }
6064
6097
  }
@@ -6423,8 +6456,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6423
6456
  * @public
6424
6457
  */
6425
6458
  reparentShapes(shapes: TLShapeId[] | TLShape[], parentId: TLParentId, insertIndex?: IndexKey) {
6426
- const ids =
6427
- typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : shapes.map((s) => (s as TLShape).id)
6459
+ const ids = toShapeIds(shapes)
6428
6460
  if (ids.length === 0) return this
6429
6461
 
6430
6462
  const changes: TLShapePartial[] = []
@@ -6773,6 +6805,8 @@ export class Editor extends EventEmitter<TLEventMap> {
6773
6805
  * binding, but the `type`, `toId`, and `fromId` must all be provided.
6774
6806
  */
6775
6807
  createBindings<B extends TLBinding = TLBinding>(partials: TLBindingCreate<B>[]) {
6808
+ if (this.getIsReadonly()) return this
6809
+
6776
6810
  const bindings: TLBinding[] = []
6777
6811
  for (const partial of partials) {
6778
6812
  const fromShape = this.getShape(partial.fromId)
@@ -6812,6 +6846,8 @@ export class Editor extends EventEmitter<TLEventMap> {
6812
6846
  * binding is skipped. The changes from the partial are merged into the existing record.
6813
6847
  */
6814
6848
  updateBindings(partials: (TLBindingUpdate | null | undefined)[]) {
6849
+ if (this.getIsReadonly()) return this
6850
+
6815
6851
  const updated: TLBinding[] = []
6816
6852
 
6817
6853
  for (const partial of partials) {
@@ -6849,6 +6885,17 @@ export class Editor extends EventEmitter<TLEventMap> {
6849
6885
  * Delete several bindings by their IDs. If a binding ID doesn't exist, it's ignored.
6850
6886
  */
6851
6887
  deleteBindings(bindings: (TLBinding | TLBindingId)[], { isolateShapes = false } = {}) {
6888
+ if (this.getIsReadonly()) return this
6889
+ return this._deleteBindings(bindings, { isolateShapes })
6890
+ }
6891
+
6892
+ /**
6893
+ * Unguarded so that withIsolatedShapes can transiently isolate shapes for copy and export in
6894
+ * readonly mode; the public deleteBindings is what readonly blocks.
6895
+ *
6896
+ * @internal
6897
+ */
6898
+ _deleteBindings(bindings: (TLBinding | TLBindingId)[], { isolateShapes = false } = {}) {
6852
6899
  const ids = bindings.map((binding) => (typeof binding === 'string' ? binding : binding.id))
6853
6900
  if (isolateShapes) {
6854
6901
  this.store.atomic(() => {
@@ -6923,10 +6970,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6923
6970
  delta: number,
6924
6971
  opts?: { center?: VecLike }
6925
6972
  ): this {
6926
- const ids =
6927
- typeof shapes[0] === 'string'
6928
- ? (shapes as TLShapeId[])
6929
- : (shapes as TLShape[]).map((s) => s.id)
6973
+ const ids = toShapeIds(shapes)
6930
6974
 
6931
6975
  if (ids.length <= 0) return this
6932
6976
 
@@ -6973,6 +7017,15 @@ export class Editor extends EventEmitter<TLEventMap> {
6973
7017
  return workingShape
6974
7018
  }
6975
7019
 
7020
+ // Counter-rotates the page-space delta into the parent's space; without that a child of a rotated
7021
+ // frame or group would move along its parent's axes instead of the page's.
7022
+ // todo: a shape laid out together with its own parent moves twice, once with the parent and once
7023
+ // on its own; the layout commands should skip shapes whose ancestors are also being laid out
7024
+ private getChangesToTranslateShapeByPageDelta(shape: TLShape, pageDelta: VecLike): TLShape {
7025
+ const localDelta = Vec.From(pageDelta).rot(-this.getShapeParentTransform(shape).rotation())
7026
+ return this.getChangesToTranslateShape(shape, localDelta.add(shape))
7027
+ }
7028
+
6976
7029
  /**
6977
7030
  * Move shapes by a delta.
6978
7031
  *
@@ -6985,20 +7038,14 @@ export class Editor extends EventEmitter<TLEventMap> {
6985
7038
  * @param offset - The offset to apply to the shapes.
6986
7039
  */
6987
7040
  nudgeShapes(shapes: TLShapeId[] | TLShape[], offset: VecLike): this {
6988
- const ids =
6989
- typeof shapes[0] === 'string'
6990
- ? (shapes as TLShapeId[])
6991
- : (shapes as TLShape[]).map((s) => s.id)
7041
+ const ids = toShapeIds(shapes)
6992
7042
 
6993
7043
  if (ids.length <= 0) return this
6994
7044
  const changes: TLShapePartial[] = []
6995
7045
 
6996
7046
  for (const id of ids) {
6997
7047
  const shape = this.getShape(id)!
6998
- const localDelta = Vec.From(offset)
6999
- localDelta.rot(-this.getShapeParentTransform(shape).rotation())
7000
-
7001
- changes.push(this.getChangesToTranslateShape(shape, localDelta.add(shape)))
7048
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, offset))
7002
7049
  }
7003
7050
 
7004
7051
  this.updateShapes(changes)
@@ -7022,10 +7069,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7022
7069
  */
7023
7070
  duplicateShapes(shapes: TLShapeId[] | TLShape[], offset?: VecLike): this {
7024
7071
  this.run(() => {
7025
- const _ids =
7026
- typeof shapes[0] === 'string'
7027
- ? (shapes as TLShapeId[])
7028
- : (shapes as TLShape[]).map((s) => s.id)
7072
+ const _ids = toShapeIds(shapes)
7029
7073
 
7030
7074
  const ids = this._shouldIgnoreShapeLock ? _ids : this._getUnlockedShapeIds(_ids)
7031
7075
  if (ids.length <= 0) return this
@@ -7167,10 +7211,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7167
7211
  * @public
7168
7212
  */
7169
7213
  moveShapesToPage(shapes: TLShapeId[] | TLShape[], pageId: TLPageId): this {
7170
- const ids =
7171
- typeof shapes[0] === 'string'
7172
- ? (shapes as TLShapeId[])
7173
- : (shapes as TLShape[]).map((s) => s.id)
7214
+ const ids = toShapeIds(shapes)
7174
7215
 
7175
7216
  if (ids.length === 0) return this
7176
7217
  if (this.getIsReadonly()) return this
@@ -7231,10 +7272,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7231
7272
  * @public
7232
7273
  */
7233
7274
  toggleLock(shapes: TLShapeId[] | TLShape[]): this {
7234
- const ids =
7235
- typeof shapes[0] === 'string'
7236
- ? (shapes as TLShapeId[])
7237
- : (shapes as TLShape[]).map((s) => s.id)
7275
+ const ids = toShapeIds(shapes)
7238
7276
 
7239
7277
  if (this.getIsReadonly() || ids.length === 0) return this
7240
7278
 
@@ -7277,11 +7315,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7277
7315
  * @public
7278
7316
  */
7279
7317
  sendToBack(shapes: TLShapeId[] | TLShape[]): this {
7280
- const ids =
7281
- typeof shapes[0] === 'string'
7282
- ? (shapes as TLShapeId[])
7283
- : (shapes as TLShape[]).map((s) => s.id)
7284
- const changes = getReorderingShapesChanges(this, 'toBack', ids as TLShapeId[], {
7318
+ const ids = toShapeIds(shapes)
7319
+ const changes = getReorderingShapesChanges(this, 'toBack', ids, {
7285
7320
  considerAllShapes: true,
7286
7321
  })
7287
7322
  if (changes) this.updateShapes(changes)
@@ -7311,11 +7346,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7311
7346
  * @public
7312
7347
  */
7313
7348
  sendBackward(shapes: TLShapeId[] | TLShape[], opts: { considerAllShapes?: boolean } = {}): this {
7314
- const ids =
7315
- typeof shapes[0] === 'string'
7316
- ? (shapes as TLShapeId[])
7317
- : (shapes as TLShape[]).map((s) => s.id)
7318
- const changes = getReorderingShapesChanges(this, 'backward', ids as TLShapeId[], opts)
7349
+ const ids = toShapeIds(shapes)
7350
+ const changes = getReorderingShapesChanges(this, 'backward', ids, opts)
7319
7351
  if (changes) this.updateShapes(changes)
7320
7352
  return this
7321
7353
  }
@@ -7343,11 +7375,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7343
7375
  * @public
7344
7376
  */
7345
7377
  bringForward(shapes: TLShapeId[] | TLShape[], opts: { considerAllShapes?: boolean } = {}): this {
7346
- const ids =
7347
- typeof shapes[0] === 'string'
7348
- ? (shapes as TLShapeId[])
7349
- : (shapes as TLShape[]).map((s) => s.id)
7350
- const changes = getReorderingShapesChanges(this, 'forward', ids as TLShapeId[], opts)
7378
+ const ids = toShapeIds(shapes)
7379
+ const changes = getReorderingShapesChanges(this, 'forward', ids, opts)
7351
7380
  if (changes) this.updateShapes(changes)
7352
7381
  return this
7353
7382
  }
@@ -7366,11 +7395,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7366
7395
  * @public
7367
7396
  */
7368
7397
  bringToFront(shapes: TLShapeId[] | TLShape[]): this {
7369
- const ids =
7370
- typeof shapes[0] === 'string'
7371
- ? (shapes as TLShapeId[])
7372
- : (shapes as TLShape[]).map((s) => s.id)
7373
- const changes = getReorderingShapesChanges(this, 'toFront', ids as TLShapeId[])
7398
+ const ids = toShapeIds(shapes)
7399
+ const changes = getReorderingShapesChanges(this, 'toFront', ids)
7374
7400
  if (changes) this.updateShapes(changes)
7375
7401
  return this
7376
7402
  }
@@ -7386,10 +7412,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7386
7412
  type: TLShapeUtilCanBeLaidOutOpts['type'],
7387
7413
  opts?: { filterAxisAligned?: boolean }
7388
7414
  ): { clusters: { shapes: TLShape[]; pageBounds: Box }[]; allBounds: Box[] } {
7389
- const ids =
7390
- typeof shapes[0] === 'string'
7391
- ? (shapes as TLShapeId[])
7392
- : (shapes as TLShape[]).map((s) => s.id)
7415
+ const ids = toShapeIds(shapes)
7393
7416
 
7394
7417
  // always fresh shapes
7395
7418
  let freshShapes = compact(ids.map((id) => this.getShape(id)))
@@ -7427,8 +7450,10 @@ export class Editor extends EventEmitter<TLEventMap> {
7427
7450
  const shapesMovingTogether = [shape]
7428
7451
  const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
7429
7452
 
7453
+ // Seed with bindings in both directions, otherwise an arrow visited before the shapes it
7454
+ // binds ends up in a cluster of its own and the result depends on input order
7430
7455
  this.collectShapesViaArrowBindings({
7431
- bindings: this.getBindingsToShape(shape.id, 'arrow'),
7456
+ bindings: this.getBindingsInvolvingShape(shape.id, 'arrow'),
7432
7457
  initialShapes: freshShapes,
7433
7458
  resultShapes: shapesMovingTogether,
7434
7459
  resultBounds: boundsOfShapesMovingTogether,
@@ -7497,10 +7522,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7497
7522
  flipShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
7498
7523
  if (this.getIsReadonly()) return this
7499
7524
 
7500
- const ids =
7501
- typeof shapes[0] === 'string'
7502
- ? (shapes as TLShapeId[])
7503
- : (shapes as TLShape[]).map((s) => s.id)
7525
+ const ids = toShapeIds(shapes)
7504
7526
 
7505
7527
  // Collect a greedy list of shapes to flip
7506
7528
  const shapesToFlipFirstPass = compact(ids.map((id) => this.getShape(id)))
@@ -7603,22 +7625,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7603
7625
  const len = shapeClustersToStack.length
7604
7626
  if ((_gap === 0 && len < 3) || len < 2) return this
7605
7627
 
7606
- let val: 'x' | 'y'
7607
- let min: 'minX' | 'minY'
7608
- let max: 'maxX' | 'maxY'
7609
- let dim: 'width' | 'height'
7610
-
7611
- if (operation === 'horizontal') {
7612
- val = 'x'
7613
- min = 'minX'
7614
- max = 'maxX'
7615
- dim = 'width'
7616
- } else {
7617
- val = 'y'
7618
- min = 'minY'
7619
- max = 'maxY'
7620
- dim = 'height'
7621
- }
7628
+ const { val, min, max, dim } = AXIS[operation]
7622
7629
 
7623
7630
  let shapeGap: number = 0
7624
7631
 
@@ -7676,17 +7683,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7676
7683
  delta[val] = v + shapeGap - pageBounds[val]
7677
7684
 
7678
7685
  for (const shape of shapes) {
7679
- const shapeDelta = delta.clone()
7680
-
7681
- // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
7682
- // todo: ensure that the parent isn't being aligned together with its children
7683
- const parent = this.getShapeParent(shape)
7684
- if (parent) {
7685
- shapeDelta.rot(-this.getShapePageTransform(parent).rotation())
7686
- }
7687
-
7688
- shapeDelta.add(shape) // add the shape's x and y to the delta
7689
- changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
7686
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
7690
7687
  }
7691
7688
 
7692
7689
  v += pageBounds[dim] + shapeGap
@@ -7802,15 +7799,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7802
7799
  const delta = Vec.Sub(nextPageBounds.point, pageBounds.point).add(centerDelta)
7803
7800
 
7804
7801
  for (const shape of shapes) {
7805
- const shapeDelta = delta.clone()
7806
-
7807
- const parent = this.getShapeParent(shape)
7808
- if (parent) {
7809
- shapeDelta.rot(-this.getShapeParentTransform(shape).rotation())
7810
- }
7811
-
7812
- shapeDelta.add(shape)
7813
- changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
7802
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
7814
7803
  }
7815
7804
  }
7816
7805
 
@@ -7890,17 +7879,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7890
7879
  }
7891
7880
 
7892
7881
  for (const shape of shapes) {
7893
- const shapeDelta = delta.clone()
7894
-
7895
- // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
7896
- // todo: ensure that the parent isn't being aligned together with its children
7897
- const parent = this.getShapeParent(shape)
7898
- if (parent) {
7899
- shapeDelta.rot(-this.getShapePageTransform(parent).rotation())
7900
- }
7901
-
7902
- shapeDelta.add(shape) // add the shape's x and y to the delta
7903
- changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
7882
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
7904
7883
  }
7905
7884
  })
7906
7885
 
@@ -7929,22 +7908,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7929
7908
 
7930
7909
  if (shapeClustersToDistribute.length < 3) return this
7931
7910
 
7932
- let val: 'x' | 'y'
7933
- let min: 'minX' | 'minY'
7934
- let max: 'maxX' | 'maxY'
7935
- let dim: 'width' | 'height'
7936
-
7937
- if (operation === 'horizontal') {
7938
- val = 'x'
7939
- min = 'minX'
7940
- max = 'maxX'
7941
- dim = 'width'
7942
- } else {
7943
- val = 'y'
7944
- min = 'minY'
7945
- max = 'maxY'
7946
- dim = 'height'
7947
- }
7911
+ const { val, min, max, dim } = AXIS[operation]
7948
7912
  const changes: TLShapePartial[] = []
7949
7913
 
7950
7914
  const first = shapeClustersToDistribute.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])[0]
@@ -7953,10 +7917,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7953
7917
  // If the first shape group is also the last shape group, distribute without it
7954
7918
  if (first === last) {
7955
7919
  const excludedShapeIds = new Set(first.shapes.map((s) => s.id))
7956
- const ids =
7957
- typeof shapes[0] === 'string'
7958
- ? (shapes as TLShapeId[])
7959
- : (shapes as TLShape[]).map((s) => s.id)
7920
+ const ids = toShapeIds(shapes)
7960
7921
  return this.distributeShapes(
7961
7922
  ids.filter((id) => !excludedShapeIds.has(id)),
7962
7923
  operation
@@ -7990,17 +7951,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7990
7951
  }
7991
7952
 
7992
7953
  for (const shape of shapes) {
7993
- const shapeDelta = delta.clone()
7994
-
7995
- // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
7996
- // todo: ensure that the parent isn't being aligned together with its children
7997
- const parent = this.getShapeParent(shape)
7998
- if (parent) {
7999
- shapeDelta.rot(-this.getShapePageTransform(parent).rotation())
8000
- }
8001
-
8002
- shapeDelta.add(shape) // add the shape's x and y to the delta
8003
- changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
7954
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
8004
7955
  }
8005
7956
 
8006
7957
  v += pageBounds[dim] + gap
@@ -8036,24 +7987,12 @@ export class Editor extends EventEmitter<TLEventMap> {
8036
7987
  if (shapeClustersToStretch.length < 2) return this
8037
7988
 
8038
7989
  const commonBounds = Box.Common(allBounds)
8039
- let val: 'x' | 'y'
8040
- let min: 'minX' | 'minY'
8041
- let dim: 'width' | 'height'
8042
-
8043
- if (operation === 'horizontal') {
8044
- val = 'x'
8045
- min = 'minX'
8046
- dim = 'width'
8047
- } else {
8048
- val = 'y'
8049
- min = 'minY'
8050
- dim = 'height'
8051
- }
7990
+ const { val, min, dim } = AXIS[operation]
8052
7991
 
8053
7992
  this.run(() => {
8054
7993
  shapeClustersToStretch.forEach(({ shapes, pageBounds }) => {
8055
- const localOffset = new Vec()
8056
- localOffset[val] = commonBounds[min] - pageBounds[min]
7994
+ const pageOffset = new Vec()
7995
+ pageOffset[val] = commonBounds[min] - pageBounds[min]
8057
7996
 
8058
7997
  const scaleOrigin = pageBounds.center.clone()
8059
7998
  scaleOrigin[val] = commonBounds[min]
@@ -8063,11 +8002,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8063
8002
 
8064
8003
  for (const shape of shapes) {
8065
8004
  // First translate
8066
- const shapeLocalOffset = localOffset.clone()
8067
- shapeLocalOffset.rot(-this.getShapeParentTransform(shape).rotation())
8068
- shapeLocalOffset.add(shape)
8069
- const changes = this.getChangesToTranslateShape(shape, shapeLocalOffset)
8070
- this.updateShape(changes)
8005
+ this.updateShape(this.getChangesToTranslateShapeByPageDelta(shape, pageOffset))
8071
8006
 
8072
8007
  // Then resize
8073
8008
  this.resizeShape(shape.id, scale, {
@@ -8120,7 +8055,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8120
8055
  const scale = new Vec(scaleX, scaleY)
8121
8056
 
8122
8057
  shapeClusters.forEach(({ shapes, pageBounds }) => {
8123
- const localOffset = new Vec(
8058
+ const pageOffset = new Vec(
8124
8059
  targetBounds.minX -
8125
8060
  commonBounds.minX +
8126
8061
  (pageBounds.minX - commonBounds.minX) * (scaleX - 1),
@@ -8134,11 +8069,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8134
8069
 
8135
8070
  for (const shape of shapes) {
8136
8071
  // First translate
8137
- const shapeLocalOffset = localOffset.clone()
8138
- shapeLocalOffset.rot(-this.getShapeParentTransform(shape).rotation())
8139
- shapeLocalOffset.add(shape)
8140
- const changes = this.getChangesToTranslateShape(shape, shapeLocalOffset)
8141
- this.updateShape(changes)
8072
+ this.updateShape(this.getChangesToTranslateShapeByPageDelta(shape, pageOffset))
8142
8073
 
8143
8074
  // Then resize
8144
8075
  this.resizeShape(shape.id, scale, {
@@ -8783,6 +8714,11 @@ export class Editor extends EventEmitter<TLEventMap> {
8783
8714
 
8784
8715
  const animations: ShapeAnimation[] = []
8785
8716
 
8717
+ // Snapshot the lock override now: when this animation is started inside
8718
+ // editor.run(..., { ignoreShapeLock: true }), run() restores the flag before any tick
8719
+ // fires, so the final updateShapes below would refuse the locked shape and strand it
8720
+ const ignoreShapeLock = this._shouldIgnoreShapeLock
8721
+
8786
8722
  let partial: TLShapePartial | null | undefined, result: ShapeAnimation
8787
8723
  for (let i = 0, n = partials.length; i < n; i++) {
8788
8724
  partial = partials[i]
@@ -8791,6 +8727,14 @@ export class Editor extends EventEmitter<TLEventMap> {
8791
8727
  const shape = this.getShape(partial.id)!
8792
8728
  if (!shape) continue
8793
8729
 
8730
+ // Apply the same lock rule as updateShapes up front: the intermediate frames go through
8731
+ // _updateShapes, which doesn't check locks, so a locked shape would otherwise be moved by
8732
+ // every frame but the last and end up stranded at the penultimate one
8733
+ const unlocks = shape.isLocked && Object.hasOwn(partial, 'isLocked') && !partial.isLocked
8734
+ if (!ignoreShapeLock && !unlocks && this.isShapeOrAncestorLocked(shape)) {
8735
+ continue
8736
+ }
8737
+
8794
8738
  result = {
8795
8739
  start: structuredClone(shape),
8796
8740
  end: applyPartialToRecordWithProps(structuredClone(shape), partial),
@@ -8811,7 +8755,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8811
8755
  if (partialsToUpdate.length) {
8812
8756
  // the regular update shapes also removes the shape from
8813
8757
  // the animating shapes set
8814
- this.updateShapes(partialsToUpdate)
8758
+ this.run(() => this.updateShapes(partialsToUpdate), { ignoreShapeLock })
8815
8759
  }
8816
8760
 
8817
8761
  this.off('tick', handleTick)
@@ -8878,18 +8822,16 @@ export class Editor extends EventEmitter<TLEventMap> {
8878
8822
  }
8879
8823
  if (this.getIsReadonly()) return this
8880
8824
 
8881
- const ids =
8882
- typeof shapes[0] === 'string'
8883
- ? (shapes as TLShapeId[])
8884
- : (shapes.map((s) => (s as TLShape).id) as TLShapeId[])
8885
-
8886
- if (ids.length <= 1) return this
8825
+ const ids = toShapeIds(shapes)
8887
8826
 
8888
8827
  const shapesToGroup = compact(
8889
8828
  (this._shouldIgnoreShapeLock ? ids : this._getUnlockedShapeIds(ids)).map((id) =>
8890
8829
  this.getShape(id)
8891
8830
  )
8892
8831
  )
8832
+ // Re-check after the lock filter: Box.Common of nothing is not a valid box and would throw
8833
+ if (shapesToGroup.length <= 1) return this
8834
+
8893
8835
  const sortedShapeIds = shapesToGroup.sort(sortByIndex).map((s) => s.id)
8894
8836
  const childBounds = compact(shapesToGroup.map((shape) => this.getShapePageBounds(shape)))
8895
8837
  const pageBounds = Box.Common(childBounds)
@@ -8902,11 +8844,15 @@ export class Editor extends EventEmitter<TLEventMap> {
8902
8844
 
8903
8845
  const parentId = this.findCommonAncestor(shapesToGroup) ?? this.getCurrentPageId()
8904
8846
 
8905
- // Only group when the select tool is active
8906
- if (this.getCurrentToolId() !== 'select') return this
8847
+ // createShapes bails out when the page is full, so check first; otherwise the shapes get
8848
+ // reparented into a group that was never created and vanish from the page
8849
+ if (!this.canCreateShapes([groupId])) {
8850
+ alertMaxShapes(this)
8851
+ return this
8852
+ }
8907
8853
 
8908
- // If not already in idle, cancel the current interaction (get back to idle)
8909
- if (!this.isIn('select.idle')) {
8854
+ // If the select tool is mid-interaction, cancel it (get back to idle) before grouping
8855
+ if (this.isIn('select') && !this.isIn('select.idle')) {
8910
8856
  this.cancel()
8911
8857
  }
8912
8858
 
@@ -8960,10 +8906,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8960
8906
  if (this.getIsReadonly()) return this
8961
8907
 
8962
8908
  const { select = true } = opts
8963
- const ids =
8964
- typeof shapes[0] === 'string'
8965
- ? (shapes as TLShapeId[])
8966
- : (shapes as TLShape[]).map((s) => s.id)
8909
+ const ids = toShapeIds(shapes)
8967
8910
 
8968
8911
  const shapesToUngroup = compact(
8969
8912
  (this._shouldIgnoreShapeLock ? ids : this._getUnlockedShapeIds(ids)).map((id) =>
@@ -8973,9 +8916,8 @@ export class Editor extends EventEmitter<TLEventMap> {
8973
8916
 
8974
8917
  if (shapesToUngroup.length === 0) return this
8975
8918
 
8976
- // todo: the editor shouldn't know about the select tool, move to group / ungroup actions
8977
- if (this.getCurrentToolId() !== 'select') return this
8978
- if (!this.isIn('select.idle')) {
8919
+ // If the select tool is mid-interaction, cancel it (get back to idle) before ungrouping
8920
+ if (this.isIn('select') && !this.isIn('select.idle')) {
8979
8921
  this.cancel()
8980
8922
  }
8981
8923
 
@@ -8998,10 +8940,12 @@ export class Editor extends EventEmitter<TLEventMap> {
8998
8940
  if (groups.length === 0) return this
8999
8941
 
9000
8942
  this.run(() => {
9001
- let group: TLGroupShape
9002
-
9003
8943
  for (let i = 0, n = groups.length; i < n; i++) {
9004
- group = groups[i]
8944
+ // Re-read the group: ungrouping an outer group earlier in this loop reparents the
8945
+ // inner ones, and the stale parentId would send their children into a group that's
8946
+ // about to be deleted
8947
+ const group = this.getShape<TLGroupShape>(groups[i].id)
8948
+ if (!group) continue
9005
8949
  const childIds = this.getSortedChildIdsForParent(group.id)
9006
8950
 
9007
8951
  for (let j = 0, n = childIds.length; j < n; j++) {
@@ -9128,7 +9072,9 @@ export class Editor extends EventEmitter<TLEventMap> {
9128
9072
 
9129
9073
  /** @internal */
9130
9074
  private _getUnlockedShapeIds(ids: TLShapeId[]): TLShapeId[] {
9131
- return ids.filter((id) => !this.getShape(id)?.isLocked)
9075
+ // Match updateShapes, which also refuses shapes under a locked ancestor; otherwise a child
9076
+ // of a locked frame can't be moved but can still be deleted or duplicated
9077
+ return ids.filter((id) => !this.isShapeOrAncestorLocked(id))
9132
9078
  }
9133
9079
 
9134
9080
  /**
@@ -9152,8 +9098,7 @@ export class Editor extends EventEmitter<TLEventMap> {
9152
9098
  throw Error('Editor.deleteShapes: must provide an array of shapes or shapeIds')
9153
9099
  }
9154
9100
 
9155
- const shapeIds =
9156
- typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id)
9101
+ const shapeIds = toShapeIds(_ids)
9157
9102
 
9158
9103
  // Normally we don't want to delete locked shapes, but if the force option is set, we'll delete them anyway
9159
9104
  const shapeIdsToDelete = this._shouldIgnoreShapeLock
@@ -9627,10 +9572,7 @@ export class Editor extends EventEmitter<TLEventMap> {
9627
9572
  */
9628
9573
  getContentFromCurrentPage(shapes: TLShapeId[] | TLShape[]): TLContent | undefined {
9629
9574
  // todo: make this work with any page, not just the current page
9630
- const ids =
9631
- typeof shapes[0] === 'string'
9632
- ? (shapes as TLShapeId[])
9633
- : (shapes as TLShape[]).map((s) => s.id)
9575
+ const ids = toShapeIds(shapes)
9634
9576
 
9635
9577
  if (!ids) return
9636
9578
  if (ids.length === 0) return
@@ -9717,21 +9659,34 @@ export class Editor extends EventEmitter<TLEventMap> {
9717
9659
  !asset.props.src?.startsWith('data:video') &&
9718
9660
  !asset.props.src?.startsWith('http')
9719
9661
  ) {
9720
- const assetWithDataUrl = structuredClone(asset as TLImageAsset | TLVideoAsset)
9721
- const objectUrl = await this.store.props.assets.resolve(asset, {
9722
- screenScale: 1,
9723
- steppedScreenScale: 1,
9724
- dpr: 1,
9725
- networkEffectiveType: null,
9726
- shouldResolveToOriginal: true,
9727
- })
9728
- assetWithDataUrl.props.src = await FileHelpers.blobToDataUrl(
9729
- await fetch(objectUrl!).then((r) => r.blob())
9730
- )
9731
- assets.push(assetWithDataUrl)
9732
- } else {
9733
- assets.push(asset)
9662
+ // If the asset can't be inlined (unresolvable src, fetch failure), fall through and
9663
+ // keep the original record; dropping it leaves the pasted shapes pointing at an
9664
+ // asset that doesn't exist
9665
+ try {
9666
+ const objectUrl = await this.store.props.assets.resolve(asset, {
9667
+ screenScale: 1,
9668
+ steppedScreenScale: 1,
9669
+ dpr: 1,
9670
+ networkEffectiveType: null,
9671
+ shouldResolveToOriginal: true,
9672
+ })
9673
+ if (objectUrl) {
9674
+ // fetch resolves on 4xx/5xx, so without this check a 404 error page would be
9675
+ // inlined as a data:text/html src
9676
+ const response = await fetch(objectUrl)
9677
+ if (response.ok) {
9678
+ const assetWithDataUrl = structuredClone(asset as TLImageAsset | TLVideoAsset)
9679
+ assetWithDataUrl.props.src = await FileHelpers.blobToDataUrl(await response.blob())
9680
+ assets.push(assetWithDataUrl)
9681
+ return
9682
+ }
9683
+ console.warn(`Could not inline asset ${asset.id}: fetch returned ${response.status}`)
9684
+ }
9685
+ } catch (err) {
9686
+ console.warn(`Could not inline asset ${asset.id}`, err)
9687
+ }
9734
9688
  }
9689
+ assets.push(asset)
9735
9690
  })
9736
9691
  )
9737
9692
  content.assets = assets
@@ -10153,12 +10108,7 @@ export class Editor extends EventEmitter<TLEventMap> {
10153
10108
  * @public
10154
10109
  */
10155
10110
  async getSvgElement(shapes: TLShapeId[] | TLShape[], opts: TLSvgExportOptions = {}) {
10156
- const ids =
10157
- shapes.length === 0
10158
- ? this.getCurrentPageShapeIdsSorted()
10159
- : typeof shapes[0] === 'string'
10160
- ? (shapes as TLShapeId[])
10161
- : (shapes as TLShape[]).map((s) => s.id)
10111
+ const ids = shapes.length === 0 ? this.getCurrentPageShapeIdsSorted() : toShapeIds(shapes)
10162
10112
 
10163
10113
  if (ids.length === 0) return undefined
10164
10114
 
@@ -11291,6 +11241,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11291
11241
  inputs.setIsPointing(true)
11292
11242
  inputs.setIsDragging(false)
11293
11243
 
11244
+ // A camera still animating under a held pointer would shift the page
11245
+ // point past the drag threshold and turn a click into a drag (#10706)
11246
+ this.stopCameraAnimation()
11247
+
11294
11248
  // If pen mode is off, turn it on for direct-display pen input only (e.g. Apple
11295
11249
  // Pencil on an iPad or a Surface Pen on a touchscreen). Indirect desktop tablet
11296
11250
  // styluses still draw as pens, but should not auto-enable pen mode.
@@ -11485,9 +11439,11 @@ export class Editor extends EventEmitter<TLEventMap> {
11485
11439
  break
11486
11440
  }
11487
11441
  case 'keyboard': {
11488
- // please, please
11489
- if (info.key === 'ShiftRight') info.key = 'ShiftLeft'
11490
- if (info.key === 'AltRight') info.key = 'AltLeft'
11442
+ // Left and right modifier keys are the same key to us. `inputs.keys` stores
11443
+ // `code`, so normalize that: a `ShiftRight` left as-is would never match the
11444
+ // `ShiftLeft` that nudging checks or that `_releaseShiftKey` clears.
11445
+ if (info.code === 'ShiftRight') info.code = 'ShiftLeft'
11446
+ if (info.code === 'AltRight') info.code = 'AltLeft'
11491
11447
  if (info.code === 'ControlRight') info.code = 'ControlLeft'
11492
11448
  if (info.code === 'MetaRight') info.code = 'MetaLeft'
11493
11449
 
@@ -11693,6 +11649,16 @@ function pushShapeWithDescendants(editor: Editor, id: TLShapeId, result: TLShape
11693
11649
  }
11694
11650
  }
11695
11651
 
11652
+ /**
11653
+ * When `shapes` already holds ids this returns that same array, not a copy, so a caller that
11654
+ * mutates the result (for example to sort it) must copy it first.
11655
+ */
11656
+ function toShapeIds(shapes: TLShapeId[] | TLShape[]): TLShapeId[] {
11657
+ return typeof shapes[0] === 'string'
11658
+ ? (shapes as TLShapeId[])
11659
+ : (shapes as TLShape[]).map((shape) => shape.id)
11660
+ }
11661
+
11696
11662
  /**
11697
11663
  * Run `callback` in a world where all bindings from the shapes in `shapeIds` to shapes not in
11698
11664
  * `shapeIds` are removed. This is useful when you want to duplicate/copy shapes without worrying
@@ -11730,7 +11696,7 @@ function withIsolatedShapes<T>(
11730
11696
  }
11731
11697
  }
11732
11698
 
11733
- editor.deleteBindings([...bindingsToRemove], { isolateShapes: true })
11699
+ editor._deleteBindings([...bindingsToRemove], { isolateShapes: true })
11734
11700
 
11735
11701
  try {
11736
11702
  result = Result.ok(callback(bindingsWithBoth))