@tldraw/editor 5.4.2 → 5.5.0-canary.0241f11f2e99

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 (135) hide show
  1. package/dist-cjs/index.d.ts +7 -2
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/constants.js +2 -0
  4. package/dist-cjs/lib/constants.js.map +2 -2
  5. package/dist-cjs/lib/editor/Editor.js +131 -151
  6. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  7. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  8. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  9. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +10 -6
  10. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
  12. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js.map +2 -2
  13. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  14. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  15. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  16. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  17. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  18. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  19. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  20. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  21. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  22. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  23. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  24. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  25. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  26. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  27. package/dist-cjs/lib/exports/parseCss.js +1 -1
  28. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  29. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  30. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  31. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  32. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  33. package/dist-cjs/lib/primitives/geometry/Arc2d.js +1 -1
  34. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  35. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
  36. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  37. package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
  38. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  39. package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
  40. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  41. package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
  42. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  43. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +13 -17
  44. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  45. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +78 -40
  46. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  47. package/dist-cjs/version.js +3 -3
  48. package/dist-cjs/version.js.map +1 -1
  49. package/dist-esm/index.d.mts +7 -2
  50. package/dist-esm/index.mjs +1 -1
  51. package/dist-esm/lib/constants.mjs +2 -0
  52. package/dist-esm/lib/constants.mjs.map +2 -2
  53. package/dist-esm/lib/editor/Editor.mjs +132 -151
  54. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  55. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  56. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  57. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
  58. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  59. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
  60. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +2 -2
  61. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  62. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  63. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  64. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  65. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  66. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  67. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  68. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  69. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  70. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  71. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  72. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  73. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  74. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  75. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  76. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  77. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  78. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  79. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  80. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  81. package/dist-esm/lib/primitives/geometry/Arc2d.mjs +1 -1
  82. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  83. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
  84. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  85. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
  86. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  87. package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
  88. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  89. package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
  90. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  91. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +13 -17
  92. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  93. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +78 -40
  94. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  95. package/dist-esm/version.mjs +3 -3
  96. package/dist-esm/version.mjs.map +1 -1
  97. package/package.json +7 -7
  98. package/src/lib/constants.ts +8 -0
  99. package/src/lib/editor/Editor.test.ts +20 -0
  100. package/src/lib/editor/Editor.ts +202 -255
  101. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  102. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
  103. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
  104. package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
  105. package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
  106. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  107. package/src/lib/exports/ExportDelay.tsx +16 -4
  108. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  109. package/src/lib/exports/FontEmbedder.ts +32 -11
  110. package/src/lib/exports/StyleEmbedder.ts +13 -4
  111. package/src/lib/exports/fetchCache.test.ts +22 -0
  112. package/src/lib/exports/fetchCache.ts +4 -0
  113. package/src/lib/exports/getSvgAsImage.ts +13 -5
  114. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  115. package/src/lib/exports/getSvgJsx.tsx +28 -16
  116. package/src/lib/exports/parseCss.test.ts +4 -0
  117. package/src/lib/exports/parseCss.ts +2 -2
  118. package/src/lib/license/LicenseManager.ts +13 -1
  119. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  120. package/src/lib/license/LicenseProvider.tsx +19 -5
  121. package/src/lib/primitives/geometry/Arc2d.test.ts +9 -0
  122. package/src/lib/primitives/geometry/Arc2d.ts +1 -1
  123. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
  124. package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
  125. package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
  126. package/src/lib/primitives/geometry/Edge2d.ts +1 -1
  127. package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
  128. package/src/lib/primitives/geometry/Group2d.ts +18 -7
  129. package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
  130. package/src/lib/primitives/geometry/Point2d.ts +1 -1
  131. package/src/lib/utils/sync/LocalIndexedDb.test.ts +28 -0
  132. package/src/lib/utils/sync/LocalIndexedDb.ts +18 -20
  133. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +236 -0
  134. package/src/lib/utils/sync/TLLocalSyncClient.ts +106 -54
  135. package/src/version.ts +3 -3
@@ -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) {
@@ -4086,8 +4103,8 @@ export class Editor extends EventEmitter<TLEventMap> {
4086
4103
  newCy += center.y / newCz - center.y / cz
4087
4104
  }
4088
4105
 
4089
- // Apply friction
4090
- currentSpeed *= 1 - friction
4106
+ // Apply friction per unit of elapsed time, not per tick, or a 120 Hz display decays twice as fast
4107
+ currentSpeed *= (1 - friction) ** (elapsed / FRAME_MS_60HZ)
4091
4108
  if (currentSpeed < speedThreshold) {
4092
4109
  cancel()
4093
4110
  } else {
@@ -4130,6 +4147,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4130
4147
  // If we're not on the same page, move to the page they're on
4131
4148
  const isOnSamePage = presence.currentPageId === this.getCurrentPageId()
4132
4149
  if (!isOnSamePage) {
4150
+ this.markHistoryStoppingPoint('change-page')
4133
4151
  this.setCurrentPage(presence.currentPageId)
4134
4152
  }
4135
4153
 
@@ -5227,7 +5245,10 @@ export class Editor extends EventEmitter<TLEventMap> {
5227
5245
 
5228
5246
  this.run(
5229
5247
  () => {
5230
- this.store.props.assets.remove?.(ids)
5248
+ // the asset store's remove is async; surface failures instead of leaving an unhandled rejection
5249
+ Promise.resolve(this.store.props.assets.remove?.(ids)).catch((err) =>
5250
+ console.error('Error while removing assets from the asset store:', err)
5251
+ )
5231
5252
  this.store.remove(ids)
5232
5253
  },
5233
5254
  { history: 'ignore' }
@@ -5689,10 +5710,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5689
5710
  return
5690
5711
  }
5691
5712
 
5692
- const ids =
5693
- typeof shapes[0] === 'string'
5694
- ? (shapes as TLShapeId[])
5695
- : (shapes as TLShape[]).map((s) => s.id)
5713
+ const ids = toShapeIds(shapes)
5696
5714
  const freshShapes = compact(ids.map((id) => this.getShape(id)))
5697
5715
 
5698
5716
  if (freshShapes.length === 1) {
@@ -6423,8 +6441,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6423
6441
  * @public
6424
6442
  */
6425
6443
  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)
6444
+ const ids = toShapeIds(shapes)
6428
6445
  if (ids.length === 0) return this
6429
6446
 
6430
6447
  const changes: TLShapePartial[] = []
@@ -6773,6 +6790,8 @@ export class Editor extends EventEmitter<TLEventMap> {
6773
6790
  * binding, but the `type`, `toId`, and `fromId` must all be provided.
6774
6791
  */
6775
6792
  createBindings<B extends TLBinding = TLBinding>(partials: TLBindingCreate<B>[]) {
6793
+ if (this.getIsReadonly()) return this
6794
+
6776
6795
  const bindings: TLBinding[] = []
6777
6796
  for (const partial of partials) {
6778
6797
  const fromShape = this.getShape(partial.fromId)
@@ -6812,6 +6831,8 @@ export class Editor extends EventEmitter<TLEventMap> {
6812
6831
  * binding is skipped. The changes from the partial are merged into the existing record.
6813
6832
  */
6814
6833
  updateBindings(partials: (TLBindingUpdate | null | undefined)[]) {
6834
+ if (this.getIsReadonly()) return this
6835
+
6815
6836
  const updated: TLBinding[] = []
6816
6837
 
6817
6838
  for (const partial of partials) {
@@ -6849,6 +6870,17 @@ export class Editor extends EventEmitter<TLEventMap> {
6849
6870
  * Delete several bindings by their IDs. If a binding ID doesn't exist, it's ignored.
6850
6871
  */
6851
6872
  deleteBindings(bindings: (TLBinding | TLBindingId)[], { isolateShapes = false } = {}) {
6873
+ if (this.getIsReadonly()) return this
6874
+ return this._deleteBindings(bindings, { isolateShapes })
6875
+ }
6876
+
6877
+ /**
6878
+ * Unguarded so that withIsolatedShapes can transiently isolate shapes for copy and export in
6879
+ * readonly mode; the public deleteBindings is what readonly blocks.
6880
+ *
6881
+ * @internal
6882
+ */
6883
+ _deleteBindings(bindings: (TLBinding | TLBindingId)[], { isolateShapes = false } = {}) {
6852
6884
  const ids = bindings.map((binding) => (typeof binding === 'string' ? binding : binding.id))
6853
6885
  if (isolateShapes) {
6854
6886
  this.store.atomic(() => {
@@ -6923,10 +6955,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6923
6955
  delta: number,
6924
6956
  opts?: { center?: VecLike }
6925
6957
  ): this {
6926
- const ids =
6927
- typeof shapes[0] === 'string'
6928
- ? (shapes as TLShapeId[])
6929
- : (shapes as TLShape[]).map((s) => s.id)
6958
+ const ids = toShapeIds(shapes)
6930
6959
 
6931
6960
  if (ids.length <= 0) return this
6932
6961
 
@@ -6973,6 +7002,15 @@ export class Editor extends EventEmitter<TLEventMap> {
6973
7002
  return workingShape
6974
7003
  }
6975
7004
 
7005
+ // Counter-rotates the page-space delta into the parent's space; without that a child of a rotated
7006
+ // frame or group would move along its parent's axes instead of the page's.
7007
+ // todo: a shape laid out together with its own parent moves twice, once with the parent and once
7008
+ // on its own; the layout commands should skip shapes whose ancestors are also being laid out
7009
+ private getChangesToTranslateShapeByPageDelta(shape: TLShape, pageDelta: VecLike): TLShape {
7010
+ const localDelta = Vec.From(pageDelta).rot(-this.getShapeParentTransform(shape).rotation())
7011
+ return this.getChangesToTranslateShape(shape, localDelta.add(shape))
7012
+ }
7013
+
6976
7014
  /**
6977
7015
  * Move shapes by a delta.
6978
7016
  *
@@ -6985,20 +7023,14 @@ export class Editor extends EventEmitter<TLEventMap> {
6985
7023
  * @param offset - The offset to apply to the shapes.
6986
7024
  */
6987
7025
  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)
7026
+ const ids = toShapeIds(shapes)
6992
7027
 
6993
7028
  if (ids.length <= 0) return this
6994
7029
  const changes: TLShapePartial[] = []
6995
7030
 
6996
7031
  for (const id of ids) {
6997
7032
  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)))
7033
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, offset))
7002
7034
  }
7003
7035
 
7004
7036
  this.updateShapes(changes)
@@ -7022,10 +7054,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7022
7054
  */
7023
7055
  duplicateShapes(shapes: TLShapeId[] | TLShape[], offset?: VecLike): this {
7024
7056
  this.run(() => {
7025
- const _ids =
7026
- typeof shapes[0] === 'string'
7027
- ? (shapes as TLShapeId[])
7028
- : (shapes as TLShape[]).map((s) => s.id)
7057
+ const _ids = toShapeIds(shapes)
7029
7058
 
7030
7059
  const ids = this._shouldIgnoreShapeLock ? _ids : this._getUnlockedShapeIds(_ids)
7031
7060
  if (ids.length <= 0) return this
@@ -7167,10 +7196,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7167
7196
  * @public
7168
7197
  */
7169
7198
  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)
7199
+ const ids = toShapeIds(shapes)
7174
7200
 
7175
7201
  if (ids.length === 0) return this
7176
7202
  if (this.getIsReadonly()) return this
@@ -7231,10 +7257,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7231
7257
  * @public
7232
7258
  */
7233
7259
  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)
7260
+ const ids = toShapeIds(shapes)
7238
7261
 
7239
7262
  if (this.getIsReadonly() || ids.length === 0) return this
7240
7263
 
@@ -7277,11 +7300,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7277
7300
  * @public
7278
7301
  */
7279
7302
  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[], {
7303
+ const ids = toShapeIds(shapes)
7304
+ const changes = getReorderingShapesChanges(this, 'toBack', ids, {
7285
7305
  considerAllShapes: true,
7286
7306
  })
7287
7307
  if (changes) this.updateShapes(changes)
@@ -7311,11 +7331,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7311
7331
  * @public
7312
7332
  */
7313
7333
  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)
7334
+ const ids = toShapeIds(shapes)
7335
+ const changes = getReorderingShapesChanges(this, 'backward', ids, opts)
7319
7336
  if (changes) this.updateShapes(changes)
7320
7337
  return this
7321
7338
  }
@@ -7343,11 +7360,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7343
7360
  * @public
7344
7361
  */
7345
7362
  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)
7363
+ const ids = toShapeIds(shapes)
7364
+ const changes = getReorderingShapesChanges(this, 'forward', ids, opts)
7351
7365
  if (changes) this.updateShapes(changes)
7352
7366
  return this
7353
7367
  }
@@ -7366,11 +7380,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7366
7380
  * @public
7367
7381
  */
7368
7382
  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[])
7383
+ const ids = toShapeIds(shapes)
7384
+ const changes = getReorderingShapesChanges(this, 'toFront', ids)
7374
7385
  if (changes) this.updateShapes(changes)
7375
7386
  return this
7376
7387
  }
@@ -7386,10 +7397,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7386
7397
  type: TLShapeUtilCanBeLaidOutOpts['type'],
7387
7398
  opts?: { filterAxisAligned?: boolean }
7388
7399
  ): { 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)
7400
+ const ids = toShapeIds(shapes)
7393
7401
 
7394
7402
  // always fresh shapes
7395
7403
  let freshShapes = compact(ids.map((id) => this.getShape(id)))
@@ -7427,8 +7435,10 @@ export class Editor extends EventEmitter<TLEventMap> {
7427
7435
  const shapesMovingTogether = [shape]
7428
7436
  const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
7429
7437
 
7438
+ // Seed with bindings in both directions, otherwise an arrow visited before the shapes it
7439
+ // binds ends up in a cluster of its own and the result depends on input order
7430
7440
  this.collectShapesViaArrowBindings({
7431
- bindings: this.getBindingsToShape(shape.id, 'arrow'),
7441
+ bindings: this.getBindingsInvolvingShape(shape.id, 'arrow'),
7432
7442
  initialShapes: freshShapes,
7433
7443
  resultShapes: shapesMovingTogether,
7434
7444
  resultBounds: boundsOfShapesMovingTogether,
@@ -7497,10 +7507,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7497
7507
  flipShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
7498
7508
  if (this.getIsReadonly()) return this
7499
7509
 
7500
- const ids =
7501
- typeof shapes[0] === 'string'
7502
- ? (shapes as TLShapeId[])
7503
- : (shapes as TLShape[]).map((s) => s.id)
7510
+ const ids = toShapeIds(shapes)
7504
7511
 
7505
7512
  // Collect a greedy list of shapes to flip
7506
7513
  const shapesToFlipFirstPass = compact(ids.map((id) => this.getShape(id)))
@@ -7603,22 +7610,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7603
7610
  const len = shapeClustersToStack.length
7604
7611
  if ((_gap === 0 && len < 3) || len < 2) return this
7605
7612
 
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
- }
7613
+ const { val, min, max, dim } = AXIS[operation]
7622
7614
 
7623
7615
  let shapeGap: number = 0
7624
7616
 
@@ -7676,17 +7668,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7676
7668
  delta[val] = v + shapeGap - pageBounds[val]
7677
7669
 
7678
7670
  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))
7671
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
7690
7672
  }
7691
7673
 
7692
7674
  v += pageBounds[dim] + shapeGap
@@ -7802,15 +7784,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7802
7784
  const delta = Vec.Sub(nextPageBounds.point, pageBounds.point).add(centerDelta)
7803
7785
 
7804
7786
  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))
7787
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
7814
7788
  }
7815
7789
  }
7816
7790
 
@@ -7890,17 +7864,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7890
7864
  }
7891
7865
 
7892
7866
  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))
7867
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
7904
7868
  }
7905
7869
  })
7906
7870
 
@@ -7929,22 +7893,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7929
7893
 
7930
7894
  if (shapeClustersToDistribute.length < 3) return this
7931
7895
 
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
- }
7896
+ const { val, min, max, dim } = AXIS[operation]
7948
7897
  const changes: TLShapePartial[] = []
7949
7898
 
7950
7899
  const first = shapeClustersToDistribute.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])[0]
@@ -7953,10 +7902,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7953
7902
  // If the first shape group is also the last shape group, distribute without it
7954
7903
  if (first === last) {
7955
7904
  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)
7905
+ const ids = toShapeIds(shapes)
7960
7906
  return this.distributeShapes(
7961
7907
  ids.filter((id) => !excludedShapeIds.has(id)),
7962
7908
  operation
@@ -7990,17 +7936,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7990
7936
  }
7991
7937
 
7992
7938
  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))
7939
+ changes.push(this.getChangesToTranslateShapeByPageDelta(shape, delta))
8004
7940
  }
8005
7941
 
8006
7942
  v += pageBounds[dim] + gap
@@ -8036,24 +7972,12 @@ export class Editor extends EventEmitter<TLEventMap> {
8036
7972
  if (shapeClustersToStretch.length < 2) return this
8037
7973
 
8038
7974
  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
- }
7975
+ const { val, min, dim } = AXIS[operation]
8052
7976
 
8053
7977
  this.run(() => {
8054
7978
  shapeClustersToStretch.forEach(({ shapes, pageBounds }) => {
8055
- const localOffset = new Vec()
8056
- localOffset[val] = commonBounds[min] - pageBounds[min]
7979
+ const pageOffset = new Vec()
7980
+ pageOffset[val] = commonBounds[min] - pageBounds[min]
8057
7981
 
8058
7982
  const scaleOrigin = pageBounds.center.clone()
8059
7983
  scaleOrigin[val] = commonBounds[min]
@@ -8063,11 +7987,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8063
7987
 
8064
7988
  for (const shape of shapes) {
8065
7989
  // 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)
7990
+ this.updateShape(this.getChangesToTranslateShapeByPageDelta(shape, pageOffset))
8071
7991
 
8072
7992
  // Then resize
8073
7993
  this.resizeShape(shape.id, scale, {
@@ -8120,7 +8040,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8120
8040
  const scale = new Vec(scaleX, scaleY)
8121
8041
 
8122
8042
  shapeClusters.forEach(({ shapes, pageBounds }) => {
8123
- const localOffset = new Vec(
8043
+ const pageOffset = new Vec(
8124
8044
  targetBounds.minX -
8125
8045
  commonBounds.minX +
8126
8046
  (pageBounds.minX - commonBounds.minX) * (scaleX - 1),
@@ -8134,11 +8054,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8134
8054
 
8135
8055
  for (const shape of shapes) {
8136
8056
  // 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)
8057
+ this.updateShape(this.getChangesToTranslateShapeByPageDelta(shape, pageOffset))
8142
8058
 
8143
8059
  // Then resize
8144
8060
  this.resizeShape(shape.id, scale, {
@@ -8783,6 +8699,11 @@ export class Editor extends EventEmitter<TLEventMap> {
8783
8699
 
8784
8700
  const animations: ShapeAnimation[] = []
8785
8701
 
8702
+ // Snapshot the lock override now: when this animation is started inside
8703
+ // editor.run(..., { ignoreShapeLock: true }), run() restores the flag before any tick
8704
+ // fires, so the final updateShapes below would refuse the locked shape and strand it
8705
+ const ignoreShapeLock = this._shouldIgnoreShapeLock
8706
+
8786
8707
  let partial: TLShapePartial | null | undefined, result: ShapeAnimation
8787
8708
  for (let i = 0, n = partials.length; i < n; i++) {
8788
8709
  partial = partials[i]
@@ -8791,6 +8712,14 @@ export class Editor extends EventEmitter<TLEventMap> {
8791
8712
  const shape = this.getShape(partial.id)!
8792
8713
  if (!shape) continue
8793
8714
 
8715
+ // Apply the same lock rule as updateShapes up front: the intermediate frames go through
8716
+ // _updateShapes, which doesn't check locks, so a locked shape would otherwise be moved by
8717
+ // every frame but the last and end up stranded at the penultimate one
8718
+ const unlocks = shape.isLocked && Object.hasOwn(partial, 'isLocked') && !partial.isLocked
8719
+ if (!ignoreShapeLock && !unlocks && this.isShapeOrAncestorLocked(shape)) {
8720
+ continue
8721
+ }
8722
+
8794
8723
  result = {
8795
8724
  start: structuredClone(shape),
8796
8725
  end: applyPartialToRecordWithProps(structuredClone(shape), partial),
@@ -8811,7 +8740,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8811
8740
  if (partialsToUpdate.length) {
8812
8741
  // the regular update shapes also removes the shape from
8813
8742
  // the animating shapes set
8814
- this.updateShapes(partialsToUpdate)
8743
+ this.run(() => this.updateShapes(partialsToUpdate), { ignoreShapeLock })
8815
8744
  }
8816
8745
 
8817
8746
  this.off('tick', handleTick)
@@ -8878,18 +8807,16 @@ export class Editor extends EventEmitter<TLEventMap> {
8878
8807
  }
8879
8808
  if (this.getIsReadonly()) return this
8880
8809
 
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
8810
+ const ids = toShapeIds(shapes)
8887
8811
 
8888
8812
  const shapesToGroup = compact(
8889
8813
  (this._shouldIgnoreShapeLock ? ids : this._getUnlockedShapeIds(ids)).map((id) =>
8890
8814
  this.getShape(id)
8891
8815
  )
8892
8816
  )
8817
+ // Re-check after the lock filter: Box.Common of nothing is not a valid box and would throw
8818
+ if (shapesToGroup.length <= 1) return this
8819
+
8893
8820
  const sortedShapeIds = shapesToGroup.sort(sortByIndex).map((s) => s.id)
8894
8821
  const childBounds = compact(shapesToGroup.map((shape) => this.getShapePageBounds(shape)))
8895
8822
  const pageBounds = Box.Common(childBounds)
@@ -8902,11 +8829,15 @@ export class Editor extends EventEmitter<TLEventMap> {
8902
8829
 
8903
8830
  const parentId = this.findCommonAncestor(shapesToGroup) ?? this.getCurrentPageId()
8904
8831
 
8905
- // Only group when the select tool is active
8906
- if (this.getCurrentToolId() !== 'select') return this
8832
+ // createShapes bails out when the page is full, so check first; otherwise the shapes get
8833
+ // reparented into a group that was never created and vanish from the page
8834
+ if (!this.canCreateShapes([groupId])) {
8835
+ alertMaxShapes(this)
8836
+ return this
8837
+ }
8907
8838
 
8908
- // If not already in idle, cancel the current interaction (get back to idle)
8909
- if (!this.isIn('select.idle')) {
8839
+ // If the select tool is mid-interaction, cancel it (get back to idle) before grouping
8840
+ if (this.isIn('select') && !this.isIn('select.idle')) {
8910
8841
  this.cancel()
8911
8842
  }
8912
8843
 
@@ -8960,10 +8891,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8960
8891
  if (this.getIsReadonly()) return this
8961
8892
 
8962
8893
  const { select = true } = opts
8963
- const ids =
8964
- typeof shapes[0] === 'string'
8965
- ? (shapes as TLShapeId[])
8966
- : (shapes as TLShape[]).map((s) => s.id)
8894
+ const ids = toShapeIds(shapes)
8967
8895
 
8968
8896
  const shapesToUngroup = compact(
8969
8897
  (this._shouldIgnoreShapeLock ? ids : this._getUnlockedShapeIds(ids)).map((id) =>
@@ -8973,9 +8901,8 @@ export class Editor extends EventEmitter<TLEventMap> {
8973
8901
 
8974
8902
  if (shapesToUngroup.length === 0) return this
8975
8903
 
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')) {
8904
+ // If the select tool is mid-interaction, cancel it (get back to idle) before ungrouping
8905
+ if (this.isIn('select') && !this.isIn('select.idle')) {
8979
8906
  this.cancel()
8980
8907
  }
8981
8908
 
@@ -8998,10 +8925,12 @@ export class Editor extends EventEmitter<TLEventMap> {
8998
8925
  if (groups.length === 0) return this
8999
8926
 
9000
8927
  this.run(() => {
9001
- let group: TLGroupShape
9002
-
9003
8928
  for (let i = 0, n = groups.length; i < n; i++) {
9004
- group = groups[i]
8929
+ // Re-read the group: ungrouping an outer group earlier in this loop reparents the
8930
+ // inner ones, and the stale parentId would send their children into a group that's
8931
+ // about to be deleted
8932
+ const group = this.getShape<TLGroupShape>(groups[i].id)
8933
+ if (!group) continue
9005
8934
  const childIds = this.getSortedChildIdsForParent(group.id)
9006
8935
 
9007
8936
  for (let j = 0, n = childIds.length; j < n; j++) {
@@ -9128,7 +9057,9 @@ export class Editor extends EventEmitter<TLEventMap> {
9128
9057
 
9129
9058
  /** @internal */
9130
9059
  private _getUnlockedShapeIds(ids: TLShapeId[]): TLShapeId[] {
9131
- return ids.filter((id) => !this.getShape(id)?.isLocked)
9060
+ // Match updateShapes, which also refuses shapes under a locked ancestor; otherwise a child
9061
+ // of a locked frame can't be moved but can still be deleted or duplicated
9062
+ return ids.filter((id) => !this.isShapeOrAncestorLocked(id))
9132
9063
  }
9133
9064
 
9134
9065
  /**
@@ -9152,8 +9083,7 @@ export class Editor extends EventEmitter<TLEventMap> {
9152
9083
  throw Error('Editor.deleteShapes: must provide an array of shapes or shapeIds')
9153
9084
  }
9154
9085
 
9155
- const shapeIds =
9156
- typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id)
9086
+ const shapeIds = toShapeIds(_ids)
9157
9087
 
9158
9088
  // Normally we don't want to delete locked shapes, but if the force option is set, we'll delete them anyway
9159
9089
  const shapeIdsToDelete = this._shouldIgnoreShapeLock
@@ -9627,10 +9557,7 @@ export class Editor extends EventEmitter<TLEventMap> {
9627
9557
  */
9628
9558
  getContentFromCurrentPage(shapes: TLShapeId[] | TLShape[]): TLContent | undefined {
9629
9559
  // 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)
9560
+ const ids = toShapeIds(shapes)
9634
9561
 
9635
9562
  if (!ids) return
9636
9563
  if (ids.length === 0) return
@@ -9717,21 +9644,34 @@ export class Editor extends EventEmitter<TLEventMap> {
9717
9644
  !asset.props.src?.startsWith('data:video') &&
9718
9645
  !asset.props.src?.startsWith('http')
9719
9646
  ) {
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)
9647
+ // If the asset can't be inlined (unresolvable src, fetch failure), fall through and
9648
+ // keep the original record; dropping it leaves the pasted shapes pointing at an
9649
+ // asset that doesn't exist
9650
+ try {
9651
+ const objectUrl = await this.store.props.assets.resolve(asset, {
9652
+ screenScale: 1,
9653
+ steppedScreenScale: 1,
9654
+ dpr: 1,
9655
+ networkEffectiveType: null,
9656
+ shouldResolveToOriginal: true,
9657
+ })
9658
+ if (objectUrl) {
9659
+ // fetch resolves on 4xx/5xx, so without this check a 404 error page would be
9660
+ // inlined as a data:text/html src
9661
+ const response = await fetch(objectUrl)
9662
+ if (response.ok) {
9663
+ const assetWithDataUrl = structuredClone(asset as TLImageAsset | TLVideoAsset)
9664
+ assetWithDataUrl.props.src = await FileHelpers.blobToDataUrl(await response.blob())
9665
+ assets.push(assetWithDataUrl)
9666
+ return
9667
+ }
9668
+ console.warn(`Could not inline asset ${asset.id}: fetch returned ${response.status}`)
9669
+ }
9670
+ } catch (err) {
9671
+ console.warn(`Could not inline asset ${asset.id}`, err)
9672
+ }
9734
9673
  }
9674
+ assets.push(asset)
9735
9675
  })
9736
9676
  )
9737
9677
  content.assets = assets
@@ -10153,12 +10093,7 @@ export class Editor extends EventEmitter<TLEventMap> {
10153
10093
  * @public
10154
10094
  */
10155
10095
  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)
10096
+ const ids = shapes.length === 0 ? this.getCurrentPageShapeIdsSorted() : toShapeIds(shapes)
10162
10097
 
10163
10098
  if (ids.length === 0) return undefined
10164
10099
 
@@ -11485,9 +11420,11 @@ export class Editor extends EventEmitter<TLEventMap> {
11485
11420
  break
11486
11421
  }
11487
11422
  case 'keyboard': {
11488
- // please, please
11489
- if (info.key === 'ShiftRight') info.key = 'ShiftLeft'
11490
- if (info.key === 'AltRight') info.key = 'AltLeft'
11423
+ // Left and right modifier keys are the same key to us. `inputs.keys` stores
11424
+ // `code`, so normalize that: a `ShiftRight` left as-is would never match the
11425
+ // `ShiftLeft` that nudging checks or that `_releaseShiftKey` clears.
11426
+ if (info.code === 'ShiftRight') info.code = 'ShiftLeft'
11427
+ if (info.code === 'AltRight') info.code = 'AltLeft'
11491
11428
  if (info.code === 'ControlRight') info.code = 'ControlLeft'
11492
11429
  if (info.code === 'MetaRight') info.code = 'MetaLeft'
11493
11430
 
@@ -11693,6 +11630,16 @@ function pushShapeWithDescendants(editor: Editor, id: TLShapeId, result: TLShape
11693
11630
  }
11694
11631
  }
11695
11632
 
11633
+ /**
11634
+ * When `shapes` already holds ids this returns that same array, not a copy, so a caller that
11635
+ * mutates the result (for example to sort it) must copy it first.
11636
+ */
11637
+ function toShapeIds(shapes: TLShapeId[] | TLShape[]): TLShapeId[] {
11638
+ return typeof shapes[0] === 'string'
11639
+ ? (shapes as TLShapeId[])
11640
+ : (shapes as TLShape[]).map((shape) => shape.id)
11641
+ }
11642
+
11696
11643
  /**
11697
11644
  * Run `callback` in a world where all bindings from the shapes in `shapeIds` to shapes not in
11698
11645
  * `shapeIds` are removed. This is useful when you want to duplicate/copy shapes without worrying
@@ -11730,7 +11677,7 @@ function withIsolatedShapes<T>(
11730
11677
  }
11731
11678
  }
11732
11679
 
11733
- editor.deleteBindings([...bindingsToRemove], { isolateShapes: true })
11680
+ editor._deleteBindings([...bindingsToRemove], { isolateShapes: true })
11734
11681
 
11735
11682
  try {
11736
11683
  result = Result.ok(callback(bindingsWithBoth))