@tldraw/editor 5.5.0-next.f9308d324e1c → 5.5.0

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 (192) hide show
  1. package/dist-cjs/index.d.ts +49 -23
  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/editor/Editor.js +168 -181
  14. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  15. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  16. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -2
  18. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/EditorManager.js +11 -2
  20. package/dist-cjs/lib/editor/managers/EditorManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +49 -63
  22. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +1 -1
  24. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +24 -24
  26. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  27. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js +7 -1
  28. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
  29. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js +4 -3
  30. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js.map +2 -2
  31. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  32. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  33. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  34. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  35. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  36. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  37. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  38. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  39. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  40. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  41. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  42. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  43. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  44. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  45. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  46. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  47. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  48. package/dist-cjs/lib/exports/parseCss.js +1 -1
  49. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  50. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  51. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  52. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  53. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  54. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  55. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  56. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  57. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  58. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  59. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  60. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  61. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  62. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  63. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +32 -25
  64. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  65. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
  66. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  67. package/dist-cjs/version.js +3 -3
  68. package/dist-cjs/version.js.map +1 -1
  69. package/dist-esm/index.d.mts +49 -23
  70. package/dist-esm/index.mjs +4 -2
  71. package/dist-esm/index.mjs.map +2 -2
  72. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  73. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  74. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  75. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  76. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  77. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  78. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  79. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  80. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  81. package/dist-esm/lib/editor/Editor.mjs +168 -181
  82. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  83. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  84. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  85. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -2
  86. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  87. package/dist-esm/lib/editor/managers/EditorManager.mjs +11 -2
  88. package/dist-esm/lib/editor/managers/EditorManager.mjs.map +2 -2
  89. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +49 -63
  90. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +2 -2
  91. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +1 -1
  92. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +24 -24
  94. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  95. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs +7 -1
  96. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
  97. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs +4 -3
  98. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs.map +2 -2
  99. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  100. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  101. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  102. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  103. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  104. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  105. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  106. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  107. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  108. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  109. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  110. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  111. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  112. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  113. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  114. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  115. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  116. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  117. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  118. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  119. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  120. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  121. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  122. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  123. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  124. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  125. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  126. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  127. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  128. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  129. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  130. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +32 -25
  131. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  132. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
  133. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  134. package/dist-esm/version.mjs +3 -3
  135. package/dist-esm/version.mjs.map +1 -1
  136. package/editor.css +9 -1
  137. package/package.json +8 -11
  138. package/src/index.ts +5 -1
  139. package/src/lib/TldrawEditor.tsx +35 -21
  140. package/src/lib/components/MenuClickCapture.tsx +55 -4
  141. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  142. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  143. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  144. package/src/lib/editor/Editor.test.ts +65 -0
  145. package/src/lib/editor/Editor.ts +261 -294
  146. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  147. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +1 -0
  148. package/src/lib/editor/managers/ClickManager/ClickManager.ts +3 -2
  149. package/src/lib/editor/managers/EditorManager.test.ts +32 -1
  150. package/src/lib/editor/managers/EditorManager.ts +15 -3
  151. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +80 -0
  152. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +54 -81
  153. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +1 -1
  154. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +32 -36
  155. package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +13 -2
  156. package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +13 -0
  157. package/src/lib/editor/managers/SnapManager/SnapManager.ts +6 -4
  158. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  159. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  160. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  161. package/src/lib/editor/types/external-content.ts +5 -0
  162. package/src/lib/exports/ExportDelay.tsx +16 -4
  163. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  164. package/src/lib/exports/FontEmbedder.ts +32 -11
  165. package/src/lib/exports/StyleEmbedder.ts +13 -4
  166. package/src/lib/exports/fetchCache.test.ts +22 -0
  167. package/src/lib/exports/fetchCache.ts +4 -0
  168. package/src/lib/exports/getSvgAsImage.ts +13 -5
  169. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  170. package/src/lib/exports/getSvgJsx.tsx +28 -16
  171. package/src/lib/exports/parseCss.test.ts +4 -0
  172. package/src/lib/exports/parseCss.ts +2 -2
  173. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  174. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  175. package/src/lib/hooks/useIdentity.tsx +12 -1
  176. package/src/lib/license/LicenseManager.ts +13 -1
  177. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  178. package/src/lib/license/LicenseProvider.tsx +19 -5
  179. package/src/lib/test/preserveFocus.test.tsx +81 -0
  180. package/src/lib/utils/debug-flags.ts +0 -6
  181. package/src/lib/utils/deepLinks.test.ts +15 -0
  182. package/src/lib/utils/deepLinks.ts +8 -3
  183. package/src/lib/utils/sync/LocalIndexedDb.test.ts +121 -1
  184. package/src/lib/utils/sync/LocalIndexedDb.ts +41 -28
  185. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
  186. package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
  187. package/src/version.ts +3 -3
  188. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  189. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  190. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  191. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  192. package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
@@ -341,6 +341,11 @@ export interface TLRenderingShape {
341
341
 
342
342
  const RENDERING_SHAPES_SORT_CACHE_THRESHOLD = 100
343
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
+
344
349
  /** @public */
345
350
  export class Editor extends EventEmitter<TLEventMap> {
346
351
  readonly id = uniqueId()
@@ -753,7 +758,9 @@ export class Editor extends EventEmitter<TLEventMap> {
753
758
  }
754
759
 
755
760
  if (deleteBindingIds.length) {
756
- 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)
757
764
  }
758
765
  },
759
766
  },
@@ -1970,6 +1977,7 @@ export class Editor extends EventEmitter<TLEventMap> {
1970
1977
  * @public
1971
1978
  **/
1972
1979
  updateDocumentSettings(settings: Partial<TLDocument>): this {
1980
+ if (this.getIsReadonly()) return this
1973
1981
  this.run(
1974
1982
  () => {
1975
1983
  this.store.put([{ ...this.getDocumentSettings(), ...settings }])
@@ -2005,7 +2013,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2005
2013
  this._updateInstanceState(partial, { history: 'ignore', ...historyOptions })
2006
2014
 
2007
2015
  if (partial.isChangingStyle !== undefined) {
2008
- clearTimeout(this._isChangingStyleTimeout)
2016
+ this.timers.clearTimeout(this._isChangingStyleTimeout)
2009
2017
  if (partial.isChangingStyle === true) {
2010
2018
  // If we've set to true, set a new reset timeout to change the value back to false after 1 seconds
2011
2019
  this._isChangingStyleTimeout = this.timers.setTimeout(() => {
@@ -2197,10 +2205,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2197
2205
  * @public
2198
2206
  */
2199
2207
  select(...shapes: TLShapeId[] | TLShape[]): this {
2200
- const ids =
2201
- typeof shapes[0] === 'string'
2202
- ? (shapes as TLShapeId[])
2203
- : (shapes as TLShape[]).map((shape) => shape.id)
2208
+ const ids = toShapeIds(shapes)
2204
2209
  this.setSelectedShapes(ids)
2205
2210
  return this
2206
2211
  }
@@ -2216,10 +2221,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2216
2221
  * @public
2217
2222
  */
2218
2223
  deselect(...shapes: TLShapeId[] | TLShape[]): this {
2219
- const ids =
2220
- typeof shapes[0] === 'string'
2221
- ? (shapes as TLShapeId[])
2222
- : (shapes as TLShape[]).map((shape) => shape.id)
2224
+ const ids = toShapeIds(shapes)
2223
2225
  const selectedShapeIds = this.getSelectedShapeIds()
2224
2226
  if (selectedShapeIds.length > 0 && ids.length > 0) {
2225
2227
  this.setSelectedShapes(selectedShapeIds.filter((id) => !ids.includes(id)))
@@ -2264,10 +2266,11 @@ export class Editor extends EventEmitter<TLEventMap> {
2264
2266
  parentToSelectWithinId = this.getCurrentPageId()
2265
2267
  }
2266
2268
 
2267
- // 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.
2268
2271
  const ids = this.getSortedChildIdsForParent(parentToSelectWithinId)
2269
2272
  if (ids.length <= 0) return this
2270
- this.setSelectedShapes(this._getUnlockedShapeIds(ids))
2273
+ this.setSelectedShapes(ids.filter((id) => !this.getShape(id)?.isLocked))
2271
2274
  return this
2272
2275
  }
2273
2276
 
@@ -2288,12 +2291,14 @@ export class Editor extends EventEmitter<TLEventMap> {
2288
2291
  firstParentId &&
2289
2292
  selectedShapeIds.every((shapeId) => this.getShape(shapeId)?.parentId === firstParentId) &&
2290
2293
  !isPageId(firstParentId)
2291
- const filteredShapes = isSelectedWithinContainer
2292
- ? this.getCurrentPageShapes().filter((shape) => shape.parentId === firstParentId)
2293
- : this.getCurrentPageShapes().filter((shape) => isPageId(shape.parentId))
2294
- const readingOrderShapes = isSelectedWithinContainer
2295
- ? this._getShapesInReadingOrder(filteredShapes)
2296
- : 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)
2297
2302
  const currentShapeId: TLShapeId | undefined =
2298
2303
  selectedShapeIds.length === 1
2299
2304
  ? selectedShapeIds[0]
@@ -2302,6 +2307,9 @@ export class Editor extends EventEmitter<TLEventMap> {
2302
2307
  let adjacentShapeId: TLShapeId
2303
2308
  if (direction === 'next' || direction === 'prev') {
2304
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
2305
2313
 
2306
2314
  const currentIndex = currentShapeId ? shapeIds.indexOf(currentShapeId) : -1
2307
2315
  // With no current index, stepping from -1 makes 'prev' land one shape
@@ -2450,8 +2458,9 @@ export class Editor extends EventEmitter<TLEventMap> {
2450
2458
 
2451
2459
  // Ok, now score that subset of shapes.
2452
2460
  const lowestScoringShape = minBy(shapesInDirection, ({ center }) => {
2453
- // Distance is the primary weighting factor.
2454
- 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)
2455
2464
 
2456
2465
  // Distance along the primary axis.
2457
2466
  const dirProp = ['left', 'right'].includes(direction) ? 'x' : 'y'
@@ -2461,9 +2470,11 @@ export class Editor extends EventEmitter<TLEventMap> {
2461
2470
  const offProp = ['left', 'right'].includes(direction) ? 'y' : 'x'
2462
2471
  const offAxisDeviation = Math.abs(center[offProp] - currentCenter[offProp])
2463
2472
 
2464
- // Angle in degrees
2465
- const angle = Math.abs(Vec.Angle(currentCenter, center) * (180 / Math.PI))
2466
- 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)
2467
2478
 
2468
2479
  // Calculate final score (lower is better).
2469
2480
  // Weight factors to prioritize:
@@ -2968,10 +2979,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2968
2979
  * @public
2969
2980
  */
2970
2981
  setHintingShapes(shapes: TLShapeId[] | TLShape[]): this {
2971
- const ids =
2972
- typeof shapes[0] === 'string'
2973
- ? (shapes as TLShapeId[])
2974
- : (shapes as TLShape[]).map((shape) => shape.id)
2982
+ const ids = toShapeIds(shapes)
2975
2983
  // always ephemeral
2976
2984
  this.run(
2977
2985
  () => {
@@ -3018,10 +3026,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3018
3026
  */
3019
3027
  setErasingShapes(shapes: TLShapeId[] | TLShape[]): this {
3020
3028
  // copy before sorting: the caller may pass a store-owned (frozen) array
3021
- const ids =
3022
- typeof shapes[0] === 'string'
3023
- ? (shapes as TLShapeId[]).slice()
3024
- : (shapes as TLShape[]).map((shape) => shape.id)
3029
+ const ids = toShapeIds(shapes).slice()
3025
3030
  ids.sort() // sort the incoming ids
3026
3031
  const erasingShapeIds = this.getErasingShapeIds()
3027
3032
  this.run(
@@ -3595,6 +3600,20 @@ export class Editor extends EventEmitter<TLEventMap> {
3595
3600
  const { isLocked } = this._cameraOptions.__unsafe__getWithoutCapture()
3596
3601
  if (isLocked && !opts?.force) return this
3597
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
+
3598
3617
  // Stop any camera animations
3599
3618
  this.stopCameraAnimation()
3600
3619
 
@@ -3603,12 +3622,6 @@ export class Editor extends EventEmitter<TLEventMap> {
3603
3622
  this.stopFollowingUser()
3604
3623
  }
3605
3624
 
3606
- const _point = Vec.Cast(point)
3607
-
3608
- if (!Number.isFinite(_point.x)) _point.x = 0
3609
- if (!Number.isFinite(_point.y)) _point.y = 0
3610
- if (_point.z === undefined || !Number.isFinite(_point.z)) point.z = this.getZoomLevel()
3611
-
3612
3625
  const camera = this.getConstrainedCamera(_point, opts)
3613
3626
 
3614
3627
  if (opts?.animation) {
@@ -3943,6 +3956,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3943
3956
  easing(t: number): number
3944
3957
  start: Box
3945
3958
  end: Box
3959
+ opts: TLCameraMoveOptions
3946
3960
  }
3947
3961
 
3948
3962
  /** @internal */
@@ -3951,12 +3965,17 @@ export class Editor extends EventEmitter<TLEventMap> {
3951
3965
 
3952
3966
  this._viewportAnimation.elapsed += ms
3953
3967
 
3954
- const { elapsed, easing, duration, start, end } = this._viewportAnimation
3968
+ const { elapsed, easing, duration, start, end, opts } = this._viewportAnimation
3955
3969
 
3956
3970
  if (elapsed > duration) {
3957
3971
  this.off('tick', this._animateViewport)
3958
3972
  this._viewportAnimation = null
3959
- 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
+ )
3960
3979
  return
3961
3980
  }
3962
3981
 
@@ -4010,6 +4029,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4010
4029
  easing,
4011
4030
  start: viewportPageBounds.clone(),
4012
4031
  end: targetViewportPage.clone(),
4032
+ opts: rest,
4013
4033
  }
4014
4034
 
4015
4035
  // If we ever get a "stop-camera-animation" event, we stop
@@ -4134,6 +4154,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4134
4154
  // If we're not on the same page, move to the page they're on
4135
4155
  const isOnSamePage = presence.currentPageId === this.getCurrentPageId()
4136
4156
  if (!isOnSamePage) {
4157
+ this.markHistoryStoppingPoint('change-page')
4137
4158
  this.setCurrentPage(presence.currentPageId)
4138
4159
  }
4139
4160
 
@@ -5231,7 +5252,10 @@ export class Editor extends EventEmitter<TLEventMap> {
5231
5252
 
5232
5253
  this.run(
5233
5254
  () => {
5234
- 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
+ )
5235
5259
  this.store.remove(ids)
5236
5260
  },
5237
5261
  { history: 'ignore' }
@@ -5693,10 +5717,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5693
5717
  return
5694
5718
  }
5695
5719
 
5696
- const ids =
5697
- typeof shapes[0] === 'string'
5698
- ? (shapes as TLShapeId[])
5699
- : (shapes as TLShape[]).map((s) => s.id)
5720
+ const ids = toShapeIds(shapes)
5700
5721
  const freshShapes = compact(ids.map((id) => this.getShape(id)))
5701
5722
 
5702
5723
  if (freshShapes.length === 1) {
@@ -5844,14 +5865,16 @@ export class Editor extends EventEmitter<TLEventMap> {
5844
5865
  */
5845
5866
  getSelectedShapeAtPoint(point: VecLike): TLShape | undefined {
5846
5867
  const selectedShapeIds = this.getSelectedShapeIds()
5847
- 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()
5848
5871
  const sortedShapes = this.getCurrentPageShapesSorted()
5849
5872
 
5850
5873
  // iterate from the top (highest z-index) to find the top-most matching shape
5851
5874
  for (let i = sortedShapes.length - 1; i >= 0; i--) {
5852
5875
  const shape = sortedShapes[i]
5853
5876
  if (shape.type === 'group') continue
5854
- if (!selectedShapeIds.includes(shape.id)) continue
5877
+ if (!selectedShapeIdSet.has(shape.id)) continue
5855
5878
  if (
5856
5879
  this.getShapeGeometry(shape).hitTestPoint(
5857
5880
  this.getPointInShapeSpace(shape, point),
@@ -6022,9 +6045,6 @@ export class Editor extends EventEmitter<TLEventMap> {
6022
6045
  // other hits would be occluded by the shape.
6023
6046
  return inMarginClosestToEdgeHit || shape
6024
6047
  } else {
6025
- // If the shape is bigger than the viewport, then skip it.
6026
- if (this.getShapePageBounds(shape)!.contains(viewportPageBounds)) continue
6027
-
6028
6048
  // If we're close to the edge of the shape, and if it's the closest edge among
6029
6049
  // all the edges that we've gotten close to so far, then we will want to hit the
6030
6050
  // shape unless we hit something else or closer in later iterations.
@@ -6045,6 +6065,10 @@ export class Editor extends EventEmitter<TLEventMap> {
6045
6065
  inMarginClosestToEdgeHit = shape
6046
6066
  }
6047
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
+
6048
6072
  // If we're not within margin distance to any edge, and if the
6049
6073
  // shape is hollow, then we want to hit the shape with the
6050
6074
  // smallest area. (There's a bug here with self-intersecting
@@ -6063,6 +6087,11 @@ export class Editor extends EventEmitter<TLEventMap> {
6063
6087
  // If the distance is less than the margin, return the shape as the hit.
6064
6088
  // Use the editor's configurable hit test margin.
6065
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
+ }
6066
6095
  return shape
6067
6096
  }
6068
6097
  }
@@ -6427,8 +6456,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6427
6456
  * @public
6428
6457
  */
6429
6458
  reparentShapes(shapes: TLShapeId[] | TLShape[], parentId: TLParentId, insertIndex?: IndexKey) {
6430
- const ids =
6431
- typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : shapes.map((s) => (s as TLShape).id)
6459
+ const ids = toShapeIds(shapes)
6432
6460
  if (ids.length === 0) return this
6433
6461
 
6434
6462
  const changes: TLShapePartial[] = []
@@ -6777,6 +6805,8 @@ export class Editor extends EventEmitter<TLEventMap> {
6777
6805
  * binding, but the `type`, `toId`, and `fromId` must all be provided.
6778
6806
  */
6779
6807
  createBindings<B extends TLBinding = TLBinding>(partials: TLBindingCreate<B>[]) {
6808
+ if (this.getIsReadonly()) return this
6809
+
6780
6810
  const bindings: TLBinding[] = []
6781
6811
  for (const partial of partials) {
6782
6812
  const fromShape = this.getShape(partial.fromId)
@@ -6816,6 +6846,8 @@ export class Editor extends EventEmitter<TLEventMap> {
6816
6846
  * binding is skipped. The changes from the partial are merged into the existing record.
6817
6847
  */
6818
6848
  updateBindings(partials: (TLBindingUpdate | null | undefined)[]) {
6849
+ if (this.getIsReadonly()) return this
6850
+
6819
6851
  const updated: TLBinding[] = []
6820
6852
 
6821
6853
  for (const partial of partials) {
@@ -6853,6 +6885,17 @@ export class Editor extends EventEmitter<TLEventMap> {
6853
6885
  * Delete several bindings by their IDs. If a binding ID doesn't exist, it's ignored.
6854
6886
  */
6855
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 } = {}) {
6856
6899
  const ids = bindings.map((binding) => (typeof binding === 'string' ? binding : binding.id))
6857
6900
  if (isolateShapes) {
6858
6901
  this.store.atomic(() => {
@@ -6927,10 +6970,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6927
6970
  delta: number,
6928
6971
  opts?: { center?: VecLike }
6929
6972
  ): this {
6930
- const ids =
6931
- typeof shapes[0] === 'string'
6932
- ? (shapes as TLShapeId[])
6933
- : (shapes as TLShape[]).map((s) => s.id)
6973
+ const ids = toShapeIds(shapes)
6934
6974
 
6935
6975
  if (ids.length <= 0) return this
6936
6976
 
@@ -6977,6 +7017,15 @@ export class Editor extends EventEmitter<TLEventMap> {
6977
7017
  return workingShape
6978
7018
  }
6979
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
+
6980
7029
  /**
6981
7030
  * Move shapes by a delta.
6982
7031
  *
@@ -6989,20 +7038,14 @@ export class Editor extends EventEmitter<TLEventMap> {
6989
7038
  * @param offset - The offset to apply to the shapes.
6990
7039
  */
6991
7040
  nudgeShapes(shapes: TLShapeId[] | TLShape[], offset: VecLike): this {
6992
- const ids =
6993
- typeof shapes[0] === 'string'
6994
- ? (shapes as TLShapeId[])
6995
- : (shapes as TLShape[]).map((s) => s.id)
7041
+ const ids = toShapeIds(shapes)
6996
7042
 
6997
7043
  if (ids.length <= 0) return this
6998
7044
  const changes: TLShapePartial[] = []
6999
7045
 
7000
7046
  for (const id of ids) {
7001
7047
  const shape = this.getShape(id)!
7002
- const localDelta = Vec.From(offset)
7003
- localDelta.rot(-this.getShapeParentTransform(shape).rotation())
7004
-
7005
- changes.push(this.getChangesToTranslateShape(shape, localDelta.add(shape)))
7048
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, offset))
7006
7049
  }
7007
7050
 
7008
7051
  this.updateShapes(changes)
@@ -7026,10 +7069,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7026
7069
  */
7027
7070
  duplicateShapes(shapes: TLShapeId[] | TLShape[], offset?: VecLike): this {
7028
7071
  this.run(() => {
7029
- const _ids =
7030
- typeof shapes[0] === 'string'
7031
- ? (shapes as TLShapeId[])
7032
- : (shapes as TLShape[]).map((s) => s.id)
7072
+ const _ids = toShapeIds(shapes)
7033
7073
 
7034
7074
  const ids = this._shouldIgnoreShapeLock ? _ids : this._getUnlockedShapeIds(_ids)
7035
7075
  if (ids.length <= 0) return this
@@ -7171,10 +7211,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7171
7211
  * @public
7172
7212
  */
7173
7213
  moveShapesToPage(shapes: TLShapeId[] | TLShape[], pageId: TLPageId): this {
7174
- const ids =
7175
- typeof shapes[0] === 'string'
7176
- ? (shapes as TLShapeId[])
7177
- : (shapes as TLShape[]).map((s) => s.id)
7214
+ const ids = toShapeIds(shapes)
7178
7215
 
7179
7216
  if (ids.length === 0) return this
7180
7217
  if (this.getIsReadonly()) return this
@@ -7235,10 +7272,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7235
7272
  * @public
7236
7273
  */
7237
7274
  toggleLock(shapes: TLShapeId[] | TLShape[]): this {
7238
- const ids =
7239
- typeof shapes[0] === 'string'
7240
- ? (shapes as TLShapeId[])
7241
- : (shapes as TLShape[]).map((s) => s.id)
7275
+ const ids = toShapeIds(shapes)
7242
7276
 
7243
7277
  if (this.getIsReadonly() || ids.length === 0) return this
7244
7278
 
@@ -7281,11 +7315,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7281
7315
  * @public
7282
7316
  */
7283
7317
  sendToBack(shapes: TLShapeId[] | TLShape[]): this {
7284
- const ids =
7285
- typeof shapes[0] === 'string'
7286
- ? (shapes as TLShapeId[])
7287
- : (shapes as TLShape[]).map((s) => s.id)
7288
- const changes = getReorderingShapesChanges(this, 'toBack', ids as TLShapeId[], {
7318
+ const ids = toShapeIds(shapes)
7319
+ const changes = getReorderingShapesChanges(this, 'toBack', ids, {
7289
7320
  considerAllShapes: true,
7290
7321
  })
7291
7322
  if (changes) this.updateShapes(changes)
@@ -7315,11 +7346,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7315
7346
  * @public
7316
7347
  */
7317
7348
  sendBackward(shapes: TLShapeId[] | TLShape[], opts: { considerAllShapes?: boolean } = {}): this {
7318
- const ids =
7319
- typeof shapes[0] === 'string'
7320
- ? (shapes as TLShapeId[])
7321
- : (shapes as TLShape[]).map((s) => s.id)
7322
- const changes = getReorderingShapesChanges(this, 'backward', ids as TLShapeId[], opts)
7349
+ const ids = toShapeIds(shapes)
7350
+ const changes = getReorderingShapesChanges(this, 'backward', ids, opts)
7323
7351
  if (changes) this.updateShapes(changes)
7324
7352
  return this
7325
7353
  }
@@ -7347,11 +7375,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7347
7375
  * @public
7348
7376
  */
7349
7377
  bringForward(shapes: TLShapeId[] | TLShape[], opts: { considerAllShapes?: boolean } = {}): this {
7350
- const ids =
7351
- typeof shapes[0] === 'string'
7352
- ? (shapes as TLShapeId[])
7353
- : (shapes as TLShape[]).map((s) => s.id)
7354
- const changes = getReorderingShapesChanges(this, 'forward', ids as TLShapeId[], opts)
7378
+ const ids = toShapeIds(shapes)
7379
+ const changes = getReorderingShapesChanges(this, 'forward', ids, opts)
7355
7380
  if (changes) this.updateShapes(changes)
7356
7381
  return this
7357
7382
  }
@@ -7370,11 +7395,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7370
7395
  * @public
7371
7396
  */
7372
7397
  bringToFront(shapes: TLShapeId[] | TLShape[]): this {
7373
- const ids =
7374
- typeof shapes[0] === 'string'
7375
- ? (shapes as TLShapeId[])
7376
- : (shapes as TLShape[]).map((s) => s.id)
7377
- const changes = getReorderingShapesChanges(this, 'toFront', ids as TLShapeId[])
7398
+ const ids = toShapeIds(shapes)
7399
+ const changes = getReorderingShapesChanges(this, 'toFront', ids)
7378
7400
  if (changes) this.updateShapes(changes)
7379
7401
  return this
7380
7402
  }
@@ -7390,10 +7412,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7390
7412
  type: TLShapeUtilCanBeLaidOutOpts['type'],
7391
7413
  opts?: { filterAxisAligned?: boolean }
7392
7414
  ): { clusters: { shapes: TLShape[]; pageBounds: Box }[]; allBounds: Box[] } {
7393
- const ids =
7394
- typeof shapes[0] === 'string'
7395
- ? (shapes as TLShapeId[])
7396
- : (shapes as TLShape[]).map((s) => s.id)
7415
+ const ids = toShapeIds(shapes)
7397
7416
 
7398
7417
  // always fresh shapes
7399
7418
  let freshShapes = compact(ids.map((id) => this.getShape(id)))
@@ -7431,8 +7450,10 @@ export class Editor extends EventEmitter<TLEventMap> {
7431
7450
  const shapesMovingTogether = [shape]
7432
7451
  const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
7433
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
7434
7455
  this.collectShapesViaArrowBindings({
7435
- bindings: this.getBindingsToShape(shape.id, 'arrow'),
7456
+ bindings: this.getBindingsInvolvingShape(shape.id, 'arrow'),
7436
7457
  initialShapes: freshShapes,
7437
7458
  resultShapes: shapesMovingTogether,
7438
7459
  resultBounds: boundsOfShapesMovingTogether,
@@ -7501,10 +7522,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7501
7522
  flipShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
7502
7523
  if (this.getIsReadonly()) return this
7503
7524
 
7504
- const ids =
7505
- typeof shapes[0] === 'string'
7506
- ? (shapes as TLShapeId[])
7507
- : (shapes as TLShape[]).map((s) => s.id)
7525
+ const ids = toShapeIds(shapes)
7508
7526
 
7509
7527
  // Collect a greedy list of shapes to flip
7510
7528
  const shapesToFlipFirstPass = compact(ids.map((id) => this.getShape(id)))
@@ -7607,22 +7625,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7607
7625
  const len = shapeClustersToStack.length
7608
7626
  if ((_gap === 0 && len < 3) || len < 2) return this
7609
7627
 
7610
- let val: 'x' | 'y'
7611
- let min: 'minX' | 'minY'
7612
- let max: 'maxX' | 'maxY'
7613
- let dim: 'width' | 'height'
7614
-
7615
- if (operation === 'horizontal') {
7616
- val = 'x'
7617
- min = 'minX'
7618
- max = 'maxX'
7619
- dim = 'width'
7620
- } else {
7621
- val = 'y'
7622
- min = 'minY'
7623
- max = 'maxY'
7624
- dim = 'height'
7625
- }
7628
+ const { val, min, max, dim } = AXIS[operation]
7626
7629
 
7627
7630
  let shapeGap: number = 0
7628
7631
 
@@ -7680,17 +7683,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7680
7683
  delta[val] = v + shapeGap - pageBounds[val]
7681
7684
 
7682
7685
  for (const shape of shapes) {
7683
- const shapeDelta = delta.clone()
7684
-
7685
- // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
7686
- // todo: ensure that the parent isn't being aligned together with its children
7687
- const parent = this.getShapeParent(shape)
7688
- if (parent) {
7689
- shapeDelta.rot(-this.getShapePageTransform(parent).rotation())
7690
- }
7691
-
7692
- shapeDelta.add(shape) // add the shape's x and y to the delta
7693
- changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
7686
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
7694
7687
  }
7695
7688
 
7696
7689
  v += pageBounds[dim] + shapeGap
@@ -7806,15 +7799,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7806
7799
  const delta = Vec.Sub(nextPageBounds.point, pageBounds.point).add(centerDelta)
7807
7800
 
7808
7801
  for (const shape of shapes) {
7809
- const shapeDelta = delta.clone()
7810
-
7811
- const parent = this.getShapeParent(shape)
7812
- if (parent) {
7813
- shapeDelta.rot(-this.getShapeParentTransform(shape).rotation())
7814
- }
7815
-
7816
- shapeDelta.add(shape)
7817
- changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
7802
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
7818
7803
  }
7819
7804
  }
7820
7805
 
@@ -7894,17 +7879,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7894
7879
  }
7895
7880
 
7896
7881
  for (const shape of shapes) {
7897
- const shapeDelta = delta.clone()
7898
-
7899
- // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
7900
- // todo: ensure that the parent isn't being aligned together with its children
7901
- const parent = this.getShapeParent(shape)
7902
- if (parent) {
7903
- shapeDelta.rot(-this.getShapePageTransform(parent).rotation())
7904
- }
7905
-
7906
- shapeDelta.add(shape) // add the shape's x and y to the delta
7907
- changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
7882
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
7908
7883
  }
7909
7884
  })
7910
7885
 
@@ -7933,22 +7908,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7933
7908
 
7934
7909
  if (shapeClustersToDistribute.length < 3) return this
7935
7910
 
7936
- let val: 'x' | 'y'
7937
- let min: 'minX' | 'minY'
7938
- let max: 'maxX' | 'maxY'
7939
- let dim: 'width' | 'height'
7940
-
7941
- if (operation === 'horizontal') {
7942
- val = 'x'
7943
- min = 'minX'
7944
- max = 'maxX'
7945
- dim = 'width'
7946
- } else {
7947
- val = 'y'
7948
- min = 'minY'
7949
- max = 'maxY'
7950
- dim = 'height'
7951
- }
7911
+ const { val, min, max, dim } = AXIS[operation]
7952
7912
  const changes: TLShapePartial[] = []
7953
7913
 
7954
7914
  const first = shapeClustersToDistribute.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])[0]
@@ -7957,10 +7917,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7957
7917
  // If the first shape group is also the last shape group, distribute without it
7958
7918
  if (first === last) {
7959
7919
  const excludedShapeIds = new Set(first.shapes.map((s) => s.id))
7960
- const ids =
7961
- typeof shapes[0] === 'string'
7962
- ? (shapes as TLShapeId[])
7963
- : (shapes as TLShape[]).map((s) => s.id)
7920
+ const ids = toShapeIds(shapes)
7964
7921
  return this.distributeShapes(
7965
7922
  ids.filter((id) => !excludedShapeIds.has(id)),
7966
7923
  operation
@@ -7994,17 +7951,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7994
7951
  }
7995
7952
 
7996
7953
  for (const shape of shapes) {
7997
- const shapeDelta = delta.clone()
7998
-
7999
- // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
8000
- // todo: ensure that the parent isn't being aligned together with its children
8001
- const parent = this.getShapeParent(shape)
8002
- if (parent) {
8003
- shapeDelta.rot(-this.getShapePageTransform(parent).rotation())
8004
- }
8005
-
8006
- shapeDelta.add(shape) // add the shape's x and y to the delta
8007
- changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
7954
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
8008
7955
  }
8009
7956
 
8010
7957
  v += pageBounds[dim] + gap
@@ -8040,24 +7987,12 @@ export class Editor extends EventEmitter<TLEventMap> {
8040
7987
  if (shapeClustersToStretch.length < 2) return this
8041
7988
 
8042
7989
  const commonBounds = Box.Common(allBounds)
8043
- let val: 'x' | 'y'
8044
- let min: 'minX' | 'minY'
8045
- let dim: 'width' | 'height'
8046
-
8047
- if (operation === 'horizontal') {
8048
- val = 'x'
8049
- min = 'minX'
8050
- dim = 'width'
8051
- } else {
8052
- val = 'y'
8053
- min = 'minY'
8054
- dim = 'height'
8055
- }
7990
+ const { val, min, dim } = AXIS[operation]
8056
7991
 
8057
7992
  this.run(() => {
8058
7993
  shapeClustersToStretch.forEach(({ shapes, pageBounds }) => {
8059
- const localOffset = new Vec()
8060
- localOffset[val] = commonBounds[min] - pageBounds[min]
7994
+ const pageOffset = new Vec()
7995
+ pageOffset[val] = commonBounds[min] - pageBounds[min]
8061
7996
 
8062
7997
  const scaleOrigin = pageBounds.center.clone()
8063
7998
  scaleOrigin[val] = commonBounds[min]
@@ -8067,11 +8002,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8067
8002
 
8068
8003
  for (const shape of shapes) {
8069
8004
  // First translate
8070
- const shapeLocalOffset = localOffset.clone()
8071
- shapeLocalOffset.rot(-this.getShapeParentTransform(shape).rotation())
8072
- shapeLocalOffset.add(shape)
8073
- const changes = this.getChangesToTranslateShape(shape, shapeLocalOffset)
8074
- this.updateShape(changes)
8005
+ this.updateShape(this.getChangesToTranslateShapeByPageDelta(shape, pageOffset))
8075
8006
 
8076
8007
  // Then resize
8077
8008
  this.resizeShape(shape.id, scale, {
@@ -8124,7 +8055,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8124
8055
  const scale = new Vec(scaleX, scaleY)
8125
8056
 
8126
8057
  shapeClusters.forEach(({ shapes, pageBounds }) => {
8127
- const localOffset = new Vec(
8058
+ const pageOffset = new Vec(
8128
8059
  targetBounds.minX -
8129
8060
  commonBounds.minX +
8130
8061
  (pageBounds.minX - commonBounds.minX) * (scaleX - 1),
@@ -8138,11 +8069,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8138
8069
 
8139
8070
  for (const shape of shapes) {
8140
8071
  // First translate
8141
- const shapeLocalOffset = localOffset.clone()
8142
- shapeLocalOffset.rot(-this.getShapeParentTransform(shape).rotation())
8143
- shapeLocalOffset.add(shape)
8144
- const changes = this.getChangesToTranslateShape(shape, shapeLocalOffset)
8145
- this.updateShape(changes)
8072
+ this.updateShape(this.getChangesToTranslateShapeByPageDelta(shape, pageOffset))
8146
8073
 
8147
8074
  // Then resize
8148
8075
  this.resizeShape(shape.id, scale, {
@@ -8787,6 +8714,11 @@ export class Editor extends EventEmitter<TLEventMap> {
8787
8714
 
8788
8715
  const animations: ShapeAnimation[] = []
8789
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
+
8790
8722
  let partial: TLShapePartial | null | undefined, result: ShapeAnimation
8791
8723
  for (let i = 0, n = partials.length; i < n; i++) {
8792
8724
  partial = partials[i]
@@ -8795,6 +8727,14 @@ export class Editor extends EventEmitter<TLEventMap> {
8795
8727
  const shape = this.getShape(partial.id)!
8796
8728
  if (!shape) continue
8797
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
+
8798
8738
  result = {
8799
8739
  start: structuredClone(shape),
8800
8740
  end: applyPartialToRecordWithProps(structuredClone(shape), partial),
@@ -8815,7 +8755,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8815
8755
  if (partialsToUpdate.length) {
8816
8756
  // the regular update shapes also removes the shape from
8817
8757
  // the animating shapes set
8818
- this.updateShapes(partialsToUpdate)
8758
+ this.run(() => this.updateShapes(partialsToUpdate), { ignoreShapeLock })
8819
8759
  }
8820
8760
 
8821
8761
  this.off('tick', handleTick)
@@ -8882,18 +8822,16 @@ export class Editor extends EventEmitter<TLEventMap> {
8882
8822
  }
8883
8823
  if (this.getIsReadonly()) return this
8884
8824
 
8885
- const ids =
8886
- typeof shapes[0] === 'string'
8887
- ? (shapes as TLShapeId[])
8888
- : (shapes.map((s) => (s as TLShape).id) as TLShapeId[])
8889
-
8890
- if (ids.length <= 1) return this
8825
+ const ids = toShapeIds(shapes)
8891
8826
 
8892
8827
  const shapesToGroup = compact(
8893
8828
  (this._shouldIgnoreShapeLock ? ids : this._getUnlockedShapeIds(ids)).map((id) =>
8894
8829
  this.getShape(id)
8895
8830
  )
8896
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
+
8897
8835
  const sortedShapeIds = shapesToGroup.sort(sortByIndex).map((s) => s.id)
8898
8836
  const childBounds = compact(shapesToGroup.map((shape) => this.getShapePageBounds(shape)))
8899
8837
  const pageBounds = Box.Common(childBounds)
@@ -8906,11 +8844,15 @@ export class Editor extends EventEmitter<TLEventMap> {
8906
8844
 
8907
8845
  const parentId = this.findCommonAncestor(shapesToGroup) ?? this.getCurrentPageId()
8908
8846
 
8909
- // Only group when the select tool is active
8910
- 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
+ }
8911
8853
 
8912
- // If not already in idle, cancel the current interaction (get back to idle)
8913
- 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')) {
8914
8856
  this.cancel()
8915
8857
  }
8916
8858
 
@@ -8964,10 +8906,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8964
8906
  if (this.getIsReadonly()) return this
8965
8907
 
8966
8908
  const { select = true } = opts
8967
- const ids =
8968
- typeof shapes[0] === 'string'
8969
- ? (shapes as TLShapeId[])
8970
- : (shapes as TLShape[]).map((s) => s.id)
8909
+ const ids = toShapeIds(shapes)
8971
8910
 
8972
8911
  const shapesToUngroup = compact(
8973
8912
  (this._shouldIgnoreShapeLock ? ids : this._getUnlockedShapeIds(ids)).map((id) =>
@@ -8977,9 +8916,8 @@ export class Editor extends EventEmitter<TLEventMap> {
8977
8916
 
8978
8917
  if (shapesToUngroup.length === 0) return this
8979
8918
 
8980
- // todo: the editor shouldn't know about the select tool, move to group / ungroup actions
8981
- if (this.getCurrentToolId() !== 'select') return this
8982
- 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')) {
8983
8921
  this.cancel()
8984
8922
  }
8985
8923
 
@@ -9002,10 +8940,12 @@ export class Editor extends EventEmitter<TLEventMap> {
9002
8940
  if (groups.length === 0) return this
9003
8941
 
9004
8942
  this.run(() => {
9005
- let group: TLGroupShape
9006
-
9007
8943
  for (let i = 0, n = groups.length; i < n; i++) {
9008
- 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
9009
8949
  const childIds = this.getSortedChildIdsForParent(group.id)
9010
8950
 
9011
8951
  for (let j = 0, n = childIds.length; j < n; j++) {
@@ -9132,7 +9072,9 @@ export class Editor extends EventEmitter<TLEventMap> {
9132
9072
 
9133
9073
  /** @internal */
9134
9074
  private _getUnlockedShapeIds(ids: TLShapeId[]): TLShapeId[] {
9135
- 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))
9136
9078
  }
9137
9079
 
9138
9080
  /**
@@ -9156,8 +9098,7 @@ export class Editor extends EventEmitter<TLEventMap> {
9156
9098
  throw Error('Editor.deleteShapes: must provide an array of shapes or shapeIds')
9157
9099
  }
9158
9100
 
9159
- const shapeIds =
9160
- typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id)
9101
+ const shapeIds = toShapeIds(_ids)
9161
9102
 
9162
9103
  // Normally we don't want to delete locked shapes, but if the force option is set, we'll delete them anyway
9163
9104
  const shapeIdsToDelete = this._shouldIgnoreShapeLock
@@ -9631,10 +9572,7 @@ export class Editor extends EventEmitter<TLEventMap> {
9631
9572
  */
9632
9573
  getContentFromCurrentPage(shapes: TLShapeId[] | TLShape[]): TLContent | undefined {
9633
9574
  // todo: make this work with any page, not just the current page
9634
- const ids =
9635
- typeof shapes[0] === 'string'
9636
- ? (shapes as TLShapeId[])
9637
- : (shapes as TLShape[]).map((s) => s.id)
9575
+ const ids = toShapeIds(shapes)
9638
9576
 
9639
9577
  if (!ids) return
9640
9578
  if (ids.length === 0) return
@@ -9721,21 +9659,34 @@ export class Editor extends EventEmitter<TLEventMap> {
9721
9659
  !asset.props.src?.startsWith('data:video') &&
9722
9660
  !asset.props.src?.startsWith('http')
9723
9661
  ) {
9724
- const assetWithDataUrl = structuredClone(asset as TLImageAsset | TLVideoAsset)
9725
- const objectUrl = await this.store.props.assets.resolve(asset, {
9726
- screenScale: 1,
9727
- steppedScreenScale: 1,
9728
- dpr: 1,
9729
- networkEffectiveType: null,
9730
- shouldResolveToOriginal: true,
9731
- })
9732
- assetWithDataUrl.props.src = await FileHelpers.blobToDataUrl(
9733
- await fetch(objectUrl!).then((r) => r.blob())
9734
- )
9735
- assets.push(assetWithDataUrl)
9736
- } else {
9737
- 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
+ }
9738
9688
  }
9689
+ assets.push(asset)
9739
9690
  })
9740
9691
  )
9741
9692
  content.assets = assets
@@ -10157,12 +10108,7 @@ export class Editor extends EventEmitter<TLEventMap> {
10157
10108
  * @public
10158
10109
  */
10159
10110
  async getSvgElement(shapes: TLShapeId[] | TLShape[], opts: TLSvgExportOptions = {}) {
10160
- const ids =
10161
- shapes.length === 0
10162
- ? this.getCurrentPageShapeIdsSorted()
10163
- : typeof shapes[0] === 'string'
10164
- ? (shapes as TLShapeId[])
10165
- : (shapes as TLShape[]).map((s) => s.id)
10111
+ const ids = shapes.length === 0 ? this.getCurrentPageShapeIdsSorted() : toShapeIds(shapes)
10166
10112
 
10167
10113
  if (ids.length === 0) return undefined
10168
10114
 
@@ -10851,22 +10797,22 @@ export class Editor extends EventEmitter<TLEventMap> {
10851
10797
  const releaseMeta = this._metaKeyTimeout !== -1
10852
10798
 
10853
10799
  if (releaseShift) {
10854
- clearTimeout(this._shiftKeyTimeout)
10800
+ this.timers.clearTimeout(this._shiftKeyTimeout)
10855
10801
  this._shiftKeyTimeout = -1
10856
10802
  this.inputs.setShiftKey(false)
10857
10803
  }
10858
10804
  if (releaseAlt) {
10859
- clearTimeout(this._altKeyTimeout)
10805
+ this.timers.clearTimeout(this._altKeyTimeout)
10860
10806
  this._altKeyTimeout = -1
10861
10807
  this.inputs.setAltKey(false)
10862
10808
  }
10863
10809
  if (releaseCtrl) {
10864
- clearTimeout(this._ctrlKeyTimeout)
10810
+ this.timers.clearTimeout(this._ctrlKeyTimeout)
10865
10811
  this._ctrlKeyTimeout = -1
10866
10812
  this.inputs.setCtrlKey(false)
10867
10813
  }
10868
10814
  if (releaseMeta) {
10869
- clearTimeout(this._metaKeyTimeout)
10815
+ this.timers.clearTimeout(this._metaKeyTimeout)
10870
10816
  this._metaKeyTimeout = -1
10871
10817
  this.inputs.setMetaKey(false)
10872
10818
  }
@@ -11016,7 +10962,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11016
10962
  }
11017
10963
 
11018
10964
  if (info.shiftKey) {
11019
- clearTimeout(this._shiftKeyTimeout)
10965
+ this.timers.clearTimeout(this._shiftKeyTimeout)
11020
10966
  this._shiftKeyTimeout = -1
11021
10967
  inputs.setShiftKey(true)
11022
10968
  } else if (!info.shiftKey && inputs.getShiftKey() && this._shiftKeyTimeout === -1) {
@@ -11024,7 +10970,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11024
10970
  }
11025
10971
 
11026
10972
  if (info.altKey) {
11027
- clearTimeout(this._altKeyTimeout)
10973
+ this.timers.clearTimeout(this._altKeyTimeout)
11028
10974
  this._altKeyTimeout = -1
11029
10975
  inputs.setAltKey(true)
11030
10976
  } else if (!info.altKey && inputs.getAltKey() && this._altKeyTimeout === -1) {
@@ -11032,7 +10978,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11032
10978
  }
11033
10979
 
11034
10980
  if (info.ctrlKey) {
11035
- clearTimeout(this._ctrlKeyTimeout)
10981
+ this.timers.clearTimeout(this._ctrlKeyTimeout)
11036
10982
  this._ctrlKeyTimeout = -1
11037
10983
  inputs.setCtrlKey(true)
11038
10984
  } else if (!info.ctrlKey && inputs.getCtrlKey() && this._ctrlKeyTimeout === -1) {
@@ -11042,7 +10988,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11042
10988
  if (info.metaKey && info.name !== 'key_up') {
11043
10989
  // Unlike the other modifiers, the native metaKey property is still true on keyup.
11044
10990
  // If we don't have this guard, then the metakey will be left true without the timeout.
11045
- clearTimeout(this._metaKeyTimeout)
10991
+ this.timers.clearTimeout(this._metaKeyTimeout)
11046
10992
  this._metaKeyTimeout = -1
11047
10993
  inputs.setMetaKey(true)
11048
10994
  } else if (!info.metaKey && inputs.getMetaKey() && this._metaKeyTimeout === -1) {
@@ -11059,8 +11005,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11059
11005
 
11060
11006
  switch (type) {
11061
11007
  case 'pinch': {
11062
- if (cameraOptions.isLocked) return
11063
- clearTimeout(this._longPressTimeout)
11008
+ this.timers.clearTimeout(this._longPressTimeout)
11064
11009
  this.inputs.updateFromEvent(info)
11065
11010
 
11066
11011
  switch (info.name) {
@@ -11097,6 +11042,9 @@ export class Editor extends EventEmitter<TLEventMap> {
11097
11042
  }
11098
11043
  case 'pinch': {
11099
11044
  if (!inputs.getIsPinching()) return
11045
+ // Lock the zoom, not the gesture: skipping pinch_start would send the fingers'
11046
+ // pointer events to the tool, and skipping pinch_end would leave isPinching stuck.
11047
+ if (cameraOptions.isLocked) return
11100
11048
 
11101
11049
  const {
11102
11050
  point: { z = 1 },
@@ -11170,8 +11118,9 @@ export class Editor extends EventEmitter<TLEventMap> {
11170
11118
  let wheelBehavior = cameraOptions.wheelBehavior
11171
11119
  const inputMode = this.user.getUserPreferences().inputMode
11172
11120
 
11173
- // If the user has set their input mode preference, then use that to determine the wheel behavior
11174
- if (inputMode !== null) {
11121
+ // The user's input mode preference picks between pan and zoom, but it must not
11122
+ // re-enable a wheel the app disabled with `wheelBehavior: 'none'`.
11123
+ if (inputMode !== null && wheelBehavior !== 'none') {
11175
11124
  wheelBehavior = inputMode === 'trackpad' ? 'pan' : 'zoom'
11176
11125
  }
11177
11126
 
@@ -11245,15 +11194,18 @@ export class Editor extends EventEmitter<TLEventMap> {
11245
11194
  // Ignore pointer events while we're pinching
11246
11195
  if (inputs.getIsPinching()) return
11247
11196
 
11248
- this.inputs.updateFromEvent(info)
11249
11197
  const { isPen } = info
11250
11198
  const { isPenMode } = instanceState
11251
11199
 
11200
+ // In pen mode, reject non-pen input (a resting palm) before it touches any
11201
+ // state: otherwise updateFromEvent moves the origin point out from under the
11202
+ // pen's drag, and a palm pointer_up clears the pen's isPointing/isDragging.
11203
+ if (isPenMode && !isPen) return
11204
+
11205
+ this.inputs.updateFromEvent(info)
11206
+
11252
11207
  switch (info.name) {
11253
11208
  case 'pointer_down': {
11254
- // If we're in pen mode and the input is not a pen type, then stop here
11255
- if (isPenMode && !isPen) return
11256
-
11257
11209
  // A pointer down starts a new interaction, so flush any modifier that's
11258
11210
  // still lingering in its release-debounce window: treat it as released now.
11259
11211
  this._releaseDebouncedModifiers()
@@ -11295,6 +11247,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11295
11247
  inputs.setIsPointing(true)
11296
11248
  inputs.setIsDragging(false)
11297
11249
 
11250
+ // A camera still animating under a held pointer would shift the page
11251
+ // point past the drag threshold and turn a click into a drag (#10706)
11252
+ this.stopCameraAnimation()
11253
+
11298
11254
  // If pen mode is off, turn it on for direct-display pen input only (e.g. Apple
11299
11255
  // Pencil on an iPad or a Surface Pen on a touchscreen). Indirect desktop tablet
11300
11256
  // styluses still draw as pens, but should not auto-enable pen mode.
@@ -11305,8 +11261,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11305
11261
  this.interrupt()
11306
11262
  }
11307
11263
 
11308
- // On devices with erasers (like the Surface Pen or Wacom Pen), button 5 is the eraser
11309
- if (info.button === STYLUS_ERASER_BUTTON) {
11264
+ // On devices with erasers (like the Surface Pen or Wacom Pen), button 5 is the eraser.
11265
+ // Guarded on the eraser tool existing: the bare editor can be configured without
11266
+ // it, and an unguarded transition would throw and crash the editor.
11267
+ if (info.button === STYLUS_ERASER_BUTTON && this.getStateDescendant('eraser')) {
11310
11268
  this._restoreToolId = this.getCurrentToolId()
11311
11269
  this.complete()
11312
11270
  this.setCurrentTool('eraser')
@@ -11316,10 +11274,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11316
11274
  this._prevCursor = this.getInstanceState().cursor.type
11317
11275
  }
11318
11276
  this.inputs.setIsPanning(true)
11319
- clearTimeout(this._longPressTimeout)
11277
+ this.timers.clearTimeout(this._longPressTimeout)
11320
11278
  } else if (info.button === RIGHT_MOUSE_BUTTON && this.options.rightClickPanning) {
11321
11279
  this.inputs.setIsRightPointing(true)
11322
- clearTimeout(this._longPressTimeout)
11280
+ this.timers.clearTimeout(this._longPressTimeout)
11323
11281
  return this
11324
11282
  }
11325
11283
 
@@ -11334,9 +11292,6 @@ export class Editor extends EventEmitter<TLEventMap> {
11334
11292
  break
11335
11293
  }
11336
11294
  case 'pointer_move': {
11337
- // If the user is in pen mode, but the pointer is not a pen, stop here.
11338
- if (!isPen && isPenMode) return
11339
-
11340
11295
  const { x: cx, y: cy, z: cz } = unsafe__withoutCapture(() => this.getCamera())
11341
11296
 
11342
11297
  // Right-click pointing: waiting to see if this becomes a drag
@@ -11387,7 +11342,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11387
11342
  ) {
11388
11343
  // Start dragging
11389
11344
  inputs.setIsDragging(true)
11390
- clearTimeout(this._longPressTimeout)
11345
+ this.timers.clearTimeout(this._longPressTimeout)
11391
11346
  }
11392
11347
  break
11393
11348
  }
@@ -11395,13 +11350,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11395
11350
  // Stop dragging / pointing
11396
11351
  inputs.setIsDragging(false)
11397
11352
  inputs.setIsPointing(false)
11398
- clearTimeout(this._longPressTimeout)
11353
+ this.timers.clearTimeout(this._longPressTimeout)
11399
11354
  // Remove the button from the buttons set
11400
11355
  inputs.buttons.delete(info.button)
11401
11356
 
11402
- // If we're in pen mode and we're not using a pen, stop here
11403
- if (instanceState.isPenMode && !isPen) return
11404
-
11405
11357
  // Right-click pointing ended without dragging—this is a static
11406
11358
  // right-click, so let it through to the state chart as right_click.
11407
11359
  // Check isPanning first: if we transitioned to panning, isRightPointing
@@ -11471,7 +11423,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11471
11423
  })
11472
11424
  }
11473
11425
  } else {
11474
- if (info.button === STYLUS_ERASER_BUTTON) {
11426
+ if (info.button === STYLUS_ERASER_BUTTON && this.getStateDescendant('eraser')) {
11475
11427
  // If we were erasing with a stylus button, restore the tool we were using before we started erasing
11476
11428
  this.complete()
11477
11429
  this.setCurrentTool(this._restoreToolId)
@@ -11489,9 +11441,11 @@ export class Editor extends EventEmitter<TLEventMap> {
11489
11441
  break
11490
11442
  }
11491
11443
  case 'keyboard': {
11492
- // please, please
11493
- if (info.key === 'ShiftRight') info.key = 'ShiftLeft'
11494
- if (info.key === 'AltRight') info.key = 'AltLeft'
11444
+ // Left and right modifier keys are the same key to us. `inputs.keys` stores
11445
+ // `code`, so normalize that: a `ShiftRight` left as-is would never match the
11446
+ // `ShiftLeft` that nudging checks or that `_releaseShiftKey` clears.
11447
+ if (info.code === 'ShiftRight') info.code = 'ShiftLeft'
11448
+ if (info.code === 'AltRight') info.code = 'AltLeft'
11495
11449
  if (info.code === 'ControlRight') info.code = 'ControlLeft'
11496
11450
  if (info.code === 'MetaRight') info.code = 'MetaLeft'
11497
11451
 
@@ -11509,7 +11463,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11509
11463
 
11510
11464
  this.inputs.setIsPanning(true)
11511
11465
  this.inputs.setIsSpacebarPanning(true)
11512
- clearTimeout(this._longPressTimeout)
11466
+ this.timers.clearTimeout(this._longPressTimeout)
11513
11467
  this.setCursor({
11514
11468
  type: this.inputs.getIsPointing() ? 'grabbing' : 'grab',
11515
11469
  rotation: 0,
@@ -11537,7 +11491,8 @@ export class Editor extends EventEmitter<TLEventMap> {
11537
11491
  }
11538
11492
  }
11539
11493
 
11540
- if (offset) {
11494
+ // `_animateToViewport` bypasses `setCamera`, so honor the lock here.
11495
+ if (offset && !cameraOptions.isLocked) {
11541
11496
  const bounds = this.getViewportPageBounds()
11542
11497
  const next = bounds.clone().translate(offset.mulV({ x: bounds.w, y: bounds.h }))
11543
11498
  this._animateToViewport(next, { animation: { duration: 320 } })
@@ -11599,9 +11554,11 @@ export class Editor extends EventEmitter<TLEventMap> {
11599
11554
  this.setCurrentTool('select')
11600
11555
  }
11601
11556
 
11602
- // If a left click pointer event, send the event to the click manager.
11557
+ // Send the event to the click manager. In pen mode only pen clicks count; otherwise
11558
+ // every pointer does, including an indirect tablet stylus (a pen that never enables
11559
+ // pen mode), which would otherwise never produce double-click events.
11603
11560
  const { isPenMode } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
11604
- if (info.isPen === isPenMode) {
11561
+ if (!isPenMode || info.isPen) {
11605
11562
  // The click manager may return a new event, i.e. a double click event
11606
11563
  // depending on the event coming in and its own state. If the event has
11607
11564
  // changed then hand both events to the statechart
@@ -11633,7 +11590,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11633
11590
  private maybeTrackPerformance(name: string) {
11634
11591
  if (debugFlags.measurePerformance.get()) {
11635
11592
  if (this.performanceTracker.isStarted()) {
11636
- clearTimeout(this.performanceTrackerTimeout)
11593
+ this.timers.clearTimeout(this.performanceTrackerTimeout)
11637
11594
  } else {
11638
11595
  this.performanceTracker.start(name)
11639
11596
  }
@@ -11697,6 +11654,16 @@ function pushShapeWithDescendants(editor: Editor, id: TLShapeId, result: TLShape
11697
11654
  }
11698
11655
  }
11699
11656
 
11657
+ /**
11658
+ * When `shapes` already holds ids this returns that same array, not a copy, so a caller that
11659
+ * mutates the result (for example to sort it) must copy it first.
11660
+ */
11661
+ function toShapeIds(shapes: TLShapeId[] | TLShape[]): TLShapeId[] {
11662
+ return typeof shapes[0] === 'string'
11663
+ ? (shapes as TLShapeId[])
11664
+ : (shapes as TLShape[]).map((shape) => shape.id)
11665
+ }
11666
+
11700
11667
  /**
11701
11668
  * Run `callback` in a world where all bindings from the shapes in `shapeIds` to shapes not in
11702
11669
  * `shapeIds` are removed. This is useful when you want to duplicate/copy shapes without worrying
@@ -11734,7 +11701,7 @@ function withIsolatedShapes<T>(
11734
11701
  }
11735
11702
  }
11736
11703
 
11737
- editor.deleteBindings([...bindingsToRemove], { isolateShapes: true })
11704
+ editor._deleteBindings([...bindingsToRemove], { isolateShapes: true })
11738
11705
 
11739
11706
  try {
11740
11707
  result = Result.ok(callback(bindingsWithBoth))