@tldraw/editor 3.9.0-internal.7f0e15f4f7d9 → 3.10.0-canary.12c0cb0549ca

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 (74) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +1 -1
  3. package/dist-cjs/index.d.ts +54 -9
  4. package/dist-cjs/index.js +1 -1
  5. package/dist-cjs/index.js.map +2 -2
  6. package/dist-cjs/lib/TldrawEditor.js +2 -3
  7. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  8. package/dist-cjs/lib/components/LiveCollaborators.js +5 -0
  9. package/dist-cjs/lib/components/LiveCollaborators.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultBrush.js.map +2 -2
  11. package/dist-cjs/lib/components/default-components/DefaultCollaboratorHint.js.map +2 -2
  12. package/dist-cjs/lib/components/default-components/DefaultCursor.js.map +2 -2
  13. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
  14. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
  15. package/dist-cjs/lib/components/default-components/DefaultScribble.js.map +2 -2
  16. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js.map +2 -2
  17. package/dist-cjs/lib/editor/Editor.js +430 -248
  18. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  19. package/dist-cjs/lib/editor/managers/FontManager.js +25 -26
  20. package/dist-cjs/lib/editor/managers/FontManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +7 -2
  22. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  23. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  24. package/dist-cjs/lib/hooks/usePeerIds.js.map +1 -1
  25. package/dist-cjs/lib/hooks/usePresence.js.map +1 -1
  26. package/dist-cjs/lib/utils/browserCanvasMaxSize.js +5 -0
  27. package/dist-cjs/lib/utils/browserCanvasMaxSize.js.map +2 -2
  28. package/dist-cjs/version.js +3 -3
  29. package/dist-cjs/version.js.map +1 -1
  30. package/dist-esm/index.d.mts +54 -9
  31. package/dist-esm/index.mjs +1 -1
  32. package/dist-esm/index.mjs.map +2 -2
  33. package/dist-esm/lib/TldrawEditor.mjs +2 -3
  34. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  35. package/dist-esm/lib/components/LiveCollaborators.mjs +5 -0
  36. package/dist-esm/lib/components/LiveCollaborators.mjs.map +2 -2
  37. package/dist-esm/lib/components/default-components/DefaultBrush.mjs.map +2 -2
  38. package/dist-esm/lib/components/default-components/DefaultCollaboratorHint.mjs.map +2 -2
  39. package/dist-esm/lib/components/default-components/DefaultCursor.mjs.map +2 -2
  40. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  41. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
  42. package/dist-esm/lib/components/default-components/DefaultScribble.mjs.map +2 -2
  43. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs.map +2 -2
  44. package/dist-esm/lib/editor/Editor.mjs +431 -249
  45. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  46. package/dist-esm/lib/editor/managers/FontManager.mjs +26 -27
  47. package/dist-esm/lib/editor/managers/FontManager.mjs.map +2 -2
  48. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +7 -2
  49. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  50. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  51. package/dist-esm/lib/hooks/usePeerIds.mjs.map +1 -1
  52. package/dist-esm/lib/hooks/usePresence.mjs.map +1 -1
  53. package/dist-esm/lib/utils/browserCanvasMaxSize.mjs +5 -0
  54. package/dist-esm/lib/utils/browserCanvasMaxSize.mjs.map +2 -2
  55. package/dist-esm/version.mjs +3 -3
  56. package/dist-esm/version.mjs.map +1 -1
  57. package/package.json +7 -7
  58. package/src/index.ts +2 -0
  59. package/src/lib/TldrawEditor.tsx +3 -3
  60. package/src/lib/components/LiveCollaborators.tsx +5 -0
  61. package/src/lib/components/default-components/DefaultBrush.tsx +1 -0
  62. package/src/lib/components/default-components/DefaultCollaboratorHint.tsx +1 -0
  63. package/src/lib/components/default-components/DefaultCursor.tsx +1 -0
  64. package/src/lib/components/default-components/DefaultErrorFallback.tsx +5 -3
  65. package/src/lib/components/default-components/DefaultScribble.tsx +1 -0
  66. package/src/lib/components/default-components/DefaultShapeIndicator.tsx +1 -0
  67. package/src/lib/editor/Editor.ts +560 -277
  68. package/src/lib/editor/managers/FontManager.ts +26 -27
  69. package/src/lib/editor/shapes/ShapeUtil.ts +32 -5
  70. package/src/lib/exports/getSvgJsx.tsx +1 -0
  71. package/src/lib/hooks/usePeerIds.ts +1 -1
  72. package/src/lib/hooks/usePresence.ts +2 -2
  73. package/src/lib/utils/browserCanvasMaxSize.ts +5 -3
  74. package/src/version.ts +3 -3
@@ -125,7 +125,7 @@ import { EASINGS } from '../primitives/easings'
125
125
  import { Geometry2d } from '../primitives/geometry/Geometry2d'
126
126
  import { Group2d } from '../primitives/geometry/Group2d'
127
127
  import { intersectPolygonPolygon } from '../primitives/intersect'
128
- import { PI2, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils'
128
+ import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils'
129
129
  import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap'
130
130
  import { dataUrlToFile } from '../utils/assets'
131
131
  import { debugFlags } from '../utils/debug-flags'
@@ -155,7 +155,7 @@ import { SnapManager } from './managers/SnapManager/SnapManager'
155
155
  import { TextManager } from './managers/TextManager'
156
156
  import { TickManager } from './managers/TickManager'
157
157
  import { UserPreferencesManager } from './managers/UserPreferencesManager'
158
- import { ShapeUtil, TLResizeMode } from './shapes/ShapeUtil'
158
+ import { ShapeUtil, TLGeometryOpts, TLResizeMode } from './shapes/ShapeUtil'
159
159
  import { RootState } from './tools/RootState'
160
160
  import { StateNode, TLStateNodeConstructor } from './tools/StateNode'
161
161
  import { TLContent } from './types/clipboard-types'
@@ -267,6 +267,7 @@ export interface TLRenderingShape {
267
267
 
268
268
  /** @public */
269
269
  export class Editor extends EventEmitter<TLEventMap> {
270
+ readonly id = uniqueId()
270
271
  constructor({
271
272
  store,
272
273
  user,
@@ -4283,17 +4284,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4283
4284
 
4284
4285
  /* --------------------- Shapes --------------------- */
4285
4286
 
4286
- @computed
4287
- private _getShapeGeometryCache(): ComputedCache<Geometry2d, TLShape> {
4288
- return this.store.createComputedCache(
4289
- 'bounds',
4290
- (shape) => {
4291
- this.fonts.trackFontsForShape(shape)
4292
- return this.getShapeUtil(shape).getGeometry(shape)
4293
- },
4294
- { areRecordsEqual: (a, b) => a.props === b.props }
4295
- )
4296
- }
4287
+ private _shapeGeometryCaches: Record<string, ComputedCache<Geometry2d, TLShape>> = {}
4297
4288
 
4298
4289
  /**
4299
4290
  * Get the geometry of a shape.
@@ -4302,14 +4293,29 @@ export class Editor extends EventEmitter<TLEventMap> {
4302
4293
  * ```ts
4303
4294
  * editor.getShapeGeometry(myShape)
4304
4295
  * editor.getShapeGeometry(myShapeId)
4296
+ * editor.getShapeGeometry(myShapeId, { context: "arrow" })
4305
4297
  * ```
4306
4298
  *
4307
4299
  * @param shape - The shape (or shape id) to get the geometry for.
4300
+ * @param opts - Additional options about the request for geometry. Passed to {@link ShapeUtil.getGeometry}.
4308
4301
  *
4309
4302
  * @public
4310
4303
  */
4311
- getShapeGeometry<T extends Geometry2d>(shape: TLShape | TLShapeId): T {
4312
- return this._getShapeGeometryCache().get(typeof shape === 'string' ? shape : shape.id)! as T
4304
+ getShapeGeometry<T extends Geometry2d>(shape: TLShape | TLShapeId, opts?: TLGeometryOpts): T {
4305
+ const context = opts?.context ?? 'none'
4306
+ if (!this._shapeGeometryCaches[context]) {
4307
+ this._shapeGeometryCaches[context] = this.store.createComputedCache(
4308
+ 'bounds',
4309
+ (shape) => {
4310
+ this.fonts.trackFontsForShape(shape)
4311
+ return this.getShapeUtil(shape).getGeometry(shape, opts)
4312
+ },
4313
+ { areRecordsEqual: (a, b) => a.props === b.props }
4314
+ )
4315
+ }
4316
+ return this._shapeGeometryCaches[context].get(
4317
+ typeof shape === 'string' ? shape : shape.id
4318
+ )! as T
4313
4319
  }
4314
4320
 
4315
4321
  /** @internal */
@@ -5336,7 +5342,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5336
5342
 
5337
5343
  const invertedParentTransform = parentTransform.clone().invert()
5338
5344
 
5339
- const shapesToReparent = compact(ids.map((id) => this.getShape(id)))
5345
+ const shapesToReparent = compact(ids.map((id) => this.getShape(id))).sort(sortByIndex)
5340
5346
 
5341
5347
  // Ignore locked shapes so that we can reparent locked shapes, for example
5342
5348
  // when a locked shape's parent is deleted.
@@ -5769,14 +5775,15 @@ export class Editor extends EventEmitter<TLEventMap> {
5769
5775
  return this
5770
5776
  }
5771
5777
 
5778
+ // Gets a shape partial that includes life cycle changes: on translate start, on translate, on translate end
5772
5779
  private getChangesToTranslateShape(initialShape: TLShape, newShapeCoords: VecLike): TLShape {
5773
5780
  let workingShape = initialShape
5774
5781
  const util = this.getShapeUtil(initialShape)
5775
5782
 
5776
- workingShape = applyPartialToRecordWithProps(
5777
- workingShape,
5778
- util.onTranslateStart?.(workingShape) ?? undefined
5779
- )
5783
+ const afterTranslateStart = util.onTranslateStart?.(workingShape)
5784
+ if (afterTranslateStart) {
5785
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslateStart)
5786
+ }
5780
5787
 
5781
5788
  workingShape = applyPartialToRecordWithProps(workingShape, {
5782
5789
  id: initialShape.id,
@@ -5785,15 +5792,15 @@ export class Editor extends EventEmitter<TLEventMap> {
5785
5792
  y: newShapeCoords.y,
5786
5793
  })
5787
5794
 
5788
- workingShape = applyPartialToRecordWithProps(
5789
- workingShape,
5790
- util.onTranslate?.(initialShape, workingShape) ?? undefined
5791
- )
5795
+ const afterTranslate = util.onTranslate?.(initialShape, workingShape)
5796
+ if (afterTranslate) {
5797
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslate)
5798
+ }
5792
5799
 
5793
- workingShape = applyPartialToRecordWithProps(
5794
- workingShape,
5795
- util.onTranslateEnd?.(initialShape, workingShape) ?? undefined
5796
- )
5800
+ const afterTranslateEnd = util.onTranslateEnd?.(initialShape, workingShape)
5801
+ if (afterTranslateEnd) {
5802
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslateEnd)
5803
+ }
5797
5804
 
5798
5805
  return workingShape
5799
5806
  }
@@ -6192,6 +6199,37 @@ export class Editor extends EventEmitter<TLEventMap> {
6192
6199
  return this
6193
6200
  }
6194
6201
 
6202
+ /**
6203
+ * @internal
6204
+ */
6205
+ private collectShapesViaArrowBindings(info: {
6206
+ initialShapes: TLShape[]
6207
+ resultShapes: TLShape[]
6208
+ resultBounds: Box[]
6209
+ bindings: TLBinding[]
6210
+ visited: Set<TLShapeId>
6211
+ }) {
6212
+ const { initialShapes, resultShapes, resultBounds, bindings, visited } = info
6213
+ for (const binding of bindings) {
6214
+ for (const id of [binding.fromId, binding.toId]) {
6215
+ if (!visited.has(id)) {
6216
+ const aligningShape = initialShapes.find((s) => s.id === id)
6217
+ if (aligningShape && !visited.has(aligningShape.id)) {
6218
+ visited.add(aligningShape.id)
6219
+ const shapePageBounds = this.getShapePageBounds(aligningShape)
6220
+ if (!shapePageBounds) continue
6221
+ resultShapes.push(aligningShape)
6222
+ resultBounds.push(shapePageBounds)
6223
+ this.collectShapesViaArrowBindings({
6224
+ ...info,
6225
+ bindings: this.getBindingsInvolvingShape(aligningShape, 'arrow'),
6226
+ })
6227
+ }
6228
+ }
6229
+ }
6230
+ }
6231
+ }
6232
+
6195
6233
  /**
6196
6234
  * Flip shape positions.
6197
6235
  *
@@ -6207,47 +6245,74 @@ export class Editor extends EventEmitter<TLEventMap> {
6207
6245
  * @public
6208
6246
  */
6209
6247
  flipShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
6248
+ if (this.getIsReadonly()) return this
6249
+
6210
6250
  const ids =
6211
6251
  typeof shapes[0] === 'string'
6212
6252
  ? (shapes as TLShapeId[])
6213
6253
  : (shapes as TLShape[]).map((s) => s.id)
6214
6254
 
6215
- if (this.getIsReadonly()) return this
6255
+ // Collect a greedy list of shapes to flip
6256
+ const shapesToFlipFirstPass = compact(ids.map((id) => this.getShape(id)))
6216
6257
 
6217
- let shapesToFlip = compact(ids.map((id) => this.getShape(id)))
6258
+ for (const shape of shapesToFlipFirstPass) {
6259
+ if (this.isShapeOfType<TLGroupShape>(shape, 'group')) {
6260
+ const childrenOfGroups = compact(
6261
+ this.getSortedChildIdsForParent(shape.id).map((id) => this.getShape(id))
6262
+ )
6263
+ shapesToFlipFirstPass.push(...childrenOfGroups)
6264
+ }
6265
+ }
6218
6266
 
6219
- if (!shapesToFlip.length) return this
6267
+ // exclude shapes that can't be flipped
6268
+ const shapesToFlip: {
6269
+ shape: TLShape
6270
+ localBounds: Box
6271
+ pageTransform: Mat
6272
+ isAspectRatioLocked: boolean
6273
+ }[] = []
6220
6274
 
6221
- shapesToFlip = compact(
6222
- shapesToFlip
6223
- .map((shape) => {
6224
- if (this.isShapeOfType<TLGroupShape>(shape, 'group')) {
6225
- return this.getSortedChildIdsForParent(shape.id).map((id) => this.getShape(id))
6226
- }
6275
+ const allBounds: Box[] = []
6227
6276
 
6228
- return shape
6277
+ for (const shape of shapesToFlipFirstPass) {
6278
+ const util = this.getShapeUtil(shape)
6279
+ if (
6280
+ !util.canBeLaidOut(shape, {
6281
+ type: 'flip',
6282
+ shapes: shapesToFlipFirstPass,
6229
6283
  })
6230
- .flat()
6231
- )
6284
+ ) {
6285
+ continue
6286
+ }
6287
+
6288
+ const pageBounds = this.getShapePageBounds(shape)
6289
+ const localBounds = this.getShapeGeometry(shape).bounds
6290
+ const pageTransform = this.getShapePageTransform(shape.id)
6291
+ if (!(pageBounds && localBounds && pageTransform)) continue
6292
+ shapesToFlip.push({
6293
+ shape,
6294
+ localBounds,
6295
+ pageTransform,
6296
+ isAspectRatioLocked: util.isAspectRatioLocked(shape),
6297
+ })
6298
+ allBounds.push(pageBounds)
6299
+ }
6300
+
6301
+ if (!shapesToFlip.length) return this
6232
6302
 
6233
- const scaleOriginPage = Box.Common(
6234
- compact(shapesToFlip.map((id) => this.getShapePageBounds(id)))
6235
- ).center
6303
+ const scaleOriginPage = Box.Common(allBounds).center
6236
6304
 
6237
6305
  this.run(() => {
6238
- for (const shape of shapesToFlip) {
6239
- const bounds = this.getShapeGeometry(shape).bounds
6240
- const initialPageTransform = this.getShapePageTransform(shape.id)
6241
- if (!initialPageTransform) continue
6306
+ for (const { shape, localBounds, pageTransform, isAspectRatioLocked } of shapesToFlip) {
6242
6307
  this.resizeShape(
6243
6308
  shape.id,
6244
6309
  { x: operation === 'horizontal' ? -1 : 1, y: operation === 'vertical' ? -1 : 1 },
6245
6310
  {
6246
- initialBounds: bounds,
6247
- initialPageTransform,
6311
+ initialBounds: localBounds,
6312
+ initialPageTransform: pageTransform,
6248
6313
  initialShape: shape,
6314
+ isAspectRatioLocked,
6249
6315
  mode: 'scale_shape',
6250
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6251
6316
  scaleOrigin: scaleOriginPage,
6252
6317
  scaleAxisRotation: 0,
6253
6318
  }
@@ -6284,21 +6349,58 @@ export class Editor extends EventEmitter<TLEventMap> {
6284
6349
  : (shapes as TLShape[]).map((s) => s.id)
6285
6350
  if (this.getIsReadonly()) return this
6286
6351
 
6287
- const shapesToStack = ids
6288
- .map((id) => this.getShape(id)) // always fresh shapes
6289
- .filter((shape): shape is TLShape => {
6290
- if (!shape) return false
6352
+ // todo: this has a lot of extra code to handle stacking with custom gaps or auto gaps or other things like that. I don't think anyone has ever used this stuff.
6353
+
6354
+ // always fresh shapes
6355
+ const shapesToStackFirstPass = compact(ids.map((id) => this.getShape(id)))
6356
+
6357
+ const shapeClustersToStack: {
6358
+ shapes: TLShape[]
6359
+ pageBounds: Box
6360
+ }[] = []
6361
+ const allBounds: Box[] = []
6362
+ const visited = new Set<TLShapeId>()
6363
+
6364
+ for (const shape of shapesToStackFirstPass) {
6365
+ if (visited.has(shape.id)) continue
6366
+ visited.add(shape.id)
6367
+
6368
+ const shapePageBounds = this.getShapePageBounds(shape)
6369
+ if (!shapePageBounds) continue
6370
+
6371
+ if (
6372
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
6373
+ type: 'stack',
6374
+ shapes: shapesToStackFirstPass,
6375
+ })
6376
+ ) {
6377
+ continue
6378
+ }
6379
+
6380
+ const shapesMovingTogether = [shape]
6381
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6291
6382
 
6292
- return this.getShapeUtil(shape).canBeLaidOut(shape)
6383
+ this.collectShapesViaArrowBindings({
6384
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6385
+ initialShapes: shapesToStackFirstPass,
6386
+ resultShapes: shapesMovingTogether,
6387
+ resultBounds: boundsOfShapesMovingTogether,
6388
+ visited,
6293
6389
  })
6294
6390
 
6295
- const len = shapesToStack.length
6391
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6392
+ if (!commonPageBounds) continue
6296
6393
 
6297
- if ((gap === 0 && len < 3) || len < 2) return this
6394
+ shapeClustersToStack.push({
6395
+ shapes: shapesMovingTogether,
6396
+ pageBounds: commonPageBounds,
6397
+ })
6298
6398
 
6299
- const pageBounds = Object.fromEntries(
6300
- shapesToStack.map((shape) => [shape.id, this.getShapePageBounds(shape)!])
6301
- )
6399
+ allBounds.push(commonPageBounds)
6400
+ }
6401
+
6402
+ const len = shapeClustersToStack.length
6403
+ if ((gap === 0 && len < 3) || len < 2) return this
6302
6404
 
6303
6405
  let val: 'x' | 'y'
6304
6406
  let min: 'minX' | 'minY'
@@ -6317,46 +6419,45 @@ export class Editor extends EventEmitter<TLEventMap> {
6317
6419
  dim = 'height'
6318
6420
  }
6319
6421
 
6320
- let shapeGap: number
6422
+ let shapeGap: number = 0
6321
6423
 
6322
6424
  if (gap === 0) {
6323
- const gaps: { gap: number; count: number }[] = []
6425
+ // note: this is not used in the current tldraw.com; there we use a specified stack
6426
+
6427
+ const gaps: Record<number, number> = {}
6324
6428
 
6325
- shapesToStack.sort((a, b) => pageBounds[a.id][min] - pageBounds[b.id][min])
6429
+ shapeClustersToStack.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])
6326
6430
 
6327
6431
  // Collect all of the gaps between shapes. We want to find
6328
6432
  // patterns (equal gaps between shapes) and use the most common
6329
6433
  // one as the gap for all of the shapes.
6330
6434
  for (let i = 0; i < len - 1; i++) {
6331
- const shape = shapesToStack[i]
6332
- const nextShape = shapesToStack[i + 1]
6333
-
6334
- const bounds = pageBounds[shape.id]
6335
- const nextBounds = pageBounds[nextShape.id]
6336
-
6337
- const gap = nextBounds[min] - bounds[max]
6338
-
6339
- const current = gaps.find((g) => g.gap === gap)
6340
-
6341
- if (current) {
6342
- current.count++
6343
- } else {
6344
- gaps.push({ gap, count: 1 })
6435
+ const currCluster = shapeClustersToStack[i]
6436
+ const nextCluster = shapeClustersToStack[i + 1]
6437
+ const gap = nextCluster.pageBounds[min] - currCluster.pageBounds[max]
6438
+ if (!gaps[gap]) {
6439
+ gaps[gap] = 0
6345
6440
  }
6441
+ gaps[gap]++
6346
6442
  }
6347
6443
 
6348
6444
  // Which gap is the most common?
6349
- let maxCount = 0
6350
- gaps.forEach((g) => {
6351
- if (g.count > maxCount) {
6352
- maxCount = g.count
6353
- shapeGap = g.gap
6445
+ let maxCount = 1
6446
+ for (const [gap, count] of Object.entries(gaps)) {
6447
+ if (count > maxCount) {
6448
+ maxCount = count
6449
+ shapeGap = parseFloat(gap)
6354
6450
  }
6355
- })
6451
+ }
6356
6452
 
6357
6453
  // If there is no most-common gap, use the average gap.
6358
6454
  if (maxCount === 1) {
6359
- shapeGap = Math.max(0, gaps.reduce((a, c) => a + c.gap * c.count, 0) / (len - 1))
6455
+ let totalCount = 0
6456
+ for (const [gap, count] of Object.entries(gaps)) {
6457
+ shapeGap += parseFloat(gap) * count
6458
+ totalCount += count
6459
+ }
6460
+ shapeGap /= totalCount
6360
6461
  }
6361
6462
  } else {
6362
6463
  // If a gap was provided, then use that instead.
@@ -6365,36 +6466,30 @@ export class Editor extends EventEmitter<TLEventMap> {
6365
6466
 
6366
6467
  const changes: TLShapePartial[] = []
6367
6468
 
6368
- let v = pageBounds[shapesToStack[0].id][max]
6369
-
6370
- shapesToStack.forEach((shape, i) => {
6371
- if (i === 0) return
6469
+ let v = shapeClustersToStack[0].pageBounds[max]
6372
6470
 
6373
- const delta = { x: 0, y: 0 }
6374
- delta[val] = v + shapeGap - pageBounds[shape.id][val]
6471
+ for (let i = 1; i < shapeClustersToStack.length; i++) {
6472
+ const { shapes, pageBounds } = shapeClustersToStack[i]
6473
+ const delta = new Vec()
6474
+ delta[val] = v + shapeGap - pageBounds[val]
6375
6475
 
6376
- const parent = this.getShapeParent(shape)
6377
- const localDelta = parent
6378
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation)
6379
- : delta
6476
+ for (const shape of shapes) {
6477
+ const shapeDelta = delta.clone()
6380
6478
 
6381
- const translateStartChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)
6479
+ // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
6480
+ // todo: ensure that the parent isn't being aligned together with its children
6481
+ const parent = this.getShapeParent(shape)
6482
+ if (parent) {
6483
+ const parentTransform = this.getShapePageTransform(parent)
6484
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6485
+ }
6382
6486
 
6383
- changes.push(
6384
- translateStartChanges
6385
- ? {
6386
- ...translateStartChanges,
6387
- [val]: shape[val] + localDelta[val],
6388
- }
6389
- : {
6390
- id: shape.id as any,
6391
- type: shape.type,
6392
- [val]: shape[val] + localDelta[val],
6393
- }
6394
- )
6487
+ shapeDelta.add(shape) // add the shape's x and y to the delta
6488
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6489
+ }
6395
6490
 
6396
- v += pageBounds[shape.id][dim] + shapeGap
6397
- })
6491
+ v += pageBounds[dim] + shapeGap
6492
+ }
6398
6493
 
6399
6494
  this.updateShapes(changes)
6400
6495
  return this
@@ -6414,42 +6509,79 @@ export class Editor extends EventEmitter<TLEventMap> {
6414
6509
  * @param gap - The padding to apply to the packed shapes. Defaults to 16.
6415
6510
  */
6416
6511
  packShapes(shapes: TLShapeId[] | TLShape[], gap: number): this {
6512
+ if (this.getIsReadonly()) return this
6513
+
6417
6514
  const ids =
6418
6515
  typeof shapes[0] === 'string'
6419
6516
  ? (shapes as TLShapeId[])
6420
6517
  : (shapes as TLShape[]).map((s) => s.id)
6421
6518
 
6422
- if (this.getIsReadonly()) return this
6423
- if (ids.length < 2) return this
6519
+ // Always fresh shapes
6520
+ const shapesToPackFirstPass = compact(ids.map((id) => this.getShape(id)))
6521
+
6522
+ const shapeClustersToPack: {
6523
+ shapes: TLShape[]
6524
+ pageBounds: Box
6525
+ nextPageBounds: Box
6526
+ }[] = []
6527
+
6528
+ const allBounds: Box[] = []
6529
+ const visited = new Set<TLShapeId>()
6530
+
6531
+ for (const shape of shapesToPackFirstPass) {
6532
+ if (visited.has(shape.id)) continue
6533
+ visited.add(shape.id)
6534
+
6535
+ const shapePageBounds = this.getShapePageBounds(shape)
6536
+ if (!shapePageBounds) continue
6537
+
6538
+ if (
6539
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
6540
+ type: 'pack',
6541
+ shapes: shapesToPackFirstPass,
6542
+ })
6543
+ ) {
6544
+ continue
6545
+ }
6546
+
6547
+ const shapesMovingTogether = [shape]
6548
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6549
+
6550
+ this.collectShapesViaArrowBindings({
6551
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6552
+ initialShapes: shapesToPackFirstPass,
6553
+ resultShapes: shapesMovingTogether,
6554
+ resultBounds: boundsOfShapesMovingTogether,
6555
+ visited,
6556
+ })
6424
6557
 
6425
- const shapesToPack = ids
6426
- .map((id) => this.getShape(id)) // always fresh shapes
6427
- .filter((shape): shape is TLShape => {
6428
- if (!shape) return false
6558
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6559
+ if (!commonPageBounds) continue
6429
6560
 
6430
- return this.getShapeUtil(shape).canBeLaidOut(shape)
6561
+ shapeClustersToPack.push({
6562
+ shapes: shapesMovingTogether,
6563
+ pageBounds: commonPageBounds,
6564
+ nextPageBounds: commonPageBounds.clone(),
6431
6565
  })
6432
- const shapePageBounds: Record<string, Box> = {}
6433
- const nextShapePageBounds: Record<string, Box> = {}
6434
6566
 
6435
- let shape: TLShape,
6436
- bounds: Box,
6437
- area = 0
6567
+ allBounds.push(commonPageBounds)
6568
+ }
6569
+
6570
+ if (shapeClustersToPack.length < 2) return this
6438
6571
 
6439
- for (let i = 0; i < shapesToPack.length; i++) {
6440
- shape = shapesToPack[i]
6441
- bounds = this.getShapePageBounds(shape)!
6442
- shapePageBounds[shape.id] = bounds
6443
- nextShapePageBounds[shape.id] = bounds.clone()
6444
- area += bounds.width * bounds.height
6572
+ let area = 0
6573
+ for (const { pageBounds } of shapeClustersToPack) {
6574
+ area += pageBounds.width * pageBounds.height
6445
6575
  }
6446
6576
 
6447
- const commonBounds = Box.Common(compact(Object.values(shapePageBounds)))
6577
+ const commonBounds = Box.Common(allBounds)
6448
6578
 
6449
6579
  const maxWidth = commonBounds.width
6450
6580
 
6451
- // sort the shapes by height, descending
6452
- shapesToPack.sort((a, b) => shapePageBounds[b.id].height - shapePageBounds[a.id].height)
6581
+ // sort the shape clusters by width and then height, descending
6582
+ shapeClustersToPack
6583
+ .sort((a, b) => a.pageBounds.width - b.pageBounds.width)
6584
+ .sort((a, b) => a.pageBounds.height - b.pageBounds.height)
6453
6585
 
6454
6586
  // Start with is (sort of) the square of the area
6455
6587
  const startWidth = Math.max(Math.ceil(Math.sqrt(area / 0.95)), maxWidth)
@@ -6462,85 +6594,69 @@ export class Editor extends EventEmitter<TLEventMap> {
6462
6594
  let space: Box
6463
6595
  let last: Box
6464
6596
 
6465
- for (let i = 0; i < shapesToPack.length; i++) {
6466
- shape = shapesToPack[i]
6467
- bounds = nextShapePageBounds[shape.id]
6468
-
6597
+ for (const { nextPageBounds } of shapeClustersToPack) {
6469
6598
  // starting at the back (smaller shapes)
6470
6599
  for (let i = spaces.length - 1; i >= 0; i--) {
6471
6600
  space = spaces[i]
6472
6601
 
6473
6602
  // find a space that is big enough to contain the shape
6474
- if (bounds.width > space.width || bounds.height > space.height) continue
6603
+ if (nextPageBounds.width > space.width || nextPageBounds.height > space.height) continue
6475
6604
 
6476
6605
  // add the shape to its top-left corner
6477
- bounds.x = space.x
6478
- bounds.y = space.y
6606
+ nextPageBounds.x = space.x
6607
+ nextPageBounds.y = space.y
6479
6608
 
6480
- height = Math.max(height, bounds.maxY)
6481
- width = Math.max(width, bounds.maxX)
6609
+ height = Math.max(height, nextPageBounds.maxY)
6610
+ width = Math.max(width, nextPageBounds.maxX)
6482
6611
 
6483
- if (bounds.width === space.width && bounds.height === space.height) {
6612
+ if (nextPageBounds.width === space.width && nextPageBounds.height === space.height) {
6484
6613
  // remove the space on a perfect fit
6485
6614
  last = spaces.pop()!
6486
6615
  if (i < spaces.length) spaces[i] = last
6487
- } else if (bounds.height === space.height) {
6616
+ } else if (nextPageBounds.height === space.height) {
6488
6617
  // fit the shape into the space (width)
6489
- space.x += bounds.width + gap
6490
- space.width -= bounds.width + gap
6491
- } else if (bounds.width === space.width) {
6618
+ space.x += nextPageBounds.width + gap
6619
+ space.width -= nextPageBounds.width + gap
6620
+ } else if (nextPageBounds.width === space.width) {
6492
6621
  // fit the shape into the space (height)
6493
- space.y += bounds.height + gap
6494
- space.height -= bounds.height + gap
6622
+ space.y += nextPageBounds.height + gap
6623
+ space.height -= nextPageBounds.height + gap
6495
6624
  } else {
6496
6625
  // split the space into two spaces
6497
6626
  spaces.push(
6498
6627
  new Box(
6499
- space.x + (bounds.width + gap),
6628
+ space.x + (nextPageBounds.width + gap),
6500
6629
  space.y,
6501
- space.width - (bounds.width + gap),
6502
- bounds.height
6630
+ space.width - (nextPageBounds.width + gap),
6631
+ nextPageBounds.height
6503
6632
  )
6504
6633
  )
6505
- space.y += bounds.height + gap
6506
- space.height -= bounds.height + gap
6634
+ space.y += nextPageBounds.height + gap
6635
+ space.height -= nextPageBounds.height + gap
6507
6636
  }
6508
6637
  break
6509
6638
  }
6510
6639
  }
6511
6640
 
6512
- const commonAfter = Box.Common(Object.values(nextShapePageBounds))
6641
+ const commonAfter = Box.Common(shapeClustersToPack.map((s) => s.nextPageBounds))
6513
6642
  const centerDelta = Vec.Sub(commonBounds.center, commonAfter.center)
6514
6643
 
6515
- let nextBounds: Box
6516
-
6517
6644
  const changes: TLShapePartial<any>[] = []
6518
6645
 
6519
- for (let i = 0; i < shapesToPack.length; i++) {
6520
- shape = shapesToPack[i]
6521
- bounds = shapePageBounds[shape.id]
6522
- nextBounds = nextShapePageBounds[shape.id]
6646
+ for (const { shapes, pageBounds, nextPageBounds } of shapeClustersToPack) {
6647
+ const delta = Vec.Sub(nextPageBounds.point, pageBounds.point).add(centerDelta)
6523
6648
 
6524
- const delta = Vec.Sub(nextBounds.point, bounds.point).add(centerDelta)
6525
- const parentTransform = this.getShapeParentTransform(shape)
6526
- if (parentTransform) delta.rot(-parentTransform.rotation())
6527
-
6528
- const change: TLShapePartial = {
6529
- id: shape.id,
6530
- type: shape.type,
6531
- x: shape.x + delta.x,
6532
- y: shape.y + delta.y,
6533
- }
6649
+ for (const shape of shapes) {
6650
+ const shapeDelta = delta.clone()
6534
6651
 
6535
- const translateStartChange = this.getShapeUtil(shape).onTranslateStart?.({
6536
- ...shape,
6537
- ...change,
6538
- })
6652
+ const parent = this.getShapeParent(shape)
6653
+ if (parent) {
6654
+ const parentTransform = this.getShapeParentTransform(shape)
6655
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6656
+ }
6539
6657
 
6540
- if (translateStartChange) {
6541
- changes.push({ ...change, ...translateStartChange })
6542
- } else {
6543
- changes.push(change)
6658
+ shapeDelta.add(shape)
6659
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6544
6660
  }
6545
6661
  }
6546
6662
 
@@ -6565,32 +6681,78 @@ export class Editor extends EventEmitter<TLEventMap> {
6565
6681
  *
6566
6682
  * @public
6567
6683
  */
6568
-
6569
6684
  alignShapes(
6570
6685
  shapes: TLShapeId[] | TLShape[],
6571
6686
  operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom'
6572
6687
  ): this {
6688
+ if (this.getIsReadonly()) return this
6689
+
6573
6690
  const ids =
6574
6691
  typeof shapes[0] === 'string'
6575
6692
  ? (shapes as TLShapeId[])
6576
6693
  : (shapes as TLShape[]).map((s) => s.id)
6577
6694
 
6578
- if (this.getIsReadonly()) return this
6579
- if (ids.length < 2) return this
6695
+ // Always get fresh shapes
6696
+ const shapesToAlignFirstPass = compact(ids.map((id) => this.getShape(id)))
6580
6697
 
6581
- const shapesToAlign = compact(ids.map((id) => this.getShape(id))) // always fresh shapes
6582
- const shapePageBounds = Object.fromEntries(
6583
- shapesToAlign.map((shape) => [shape.id, this.getShapePageBounds(shape)])
6584
- )
6585
- const commonBounds = Box.Common(compact(Object.values(shapePageBounds)))
6698
+ const shapeClustersToAlign: {
6699
+ shapes: TLShape[]
6700
+ pageBounds: Box
6701
+ }[] = []
6702
+ const allBounds: Box[] = []
6703
+ const visited = new Set<TLShapeId>()
6586
6704
 
6587
- const changes: TLShapePartial[] = []
6705
+ for (const shape of shapesToAlignFirstPass) {
6706
+ if (visited.has(shape.id)) continue
6707
+ visited.add(shape.id)
6588
6708
 
6589
- shapesToAlign.forEach((shape) => {
6590
- const pageBounds = shapePageBounds[shape.id]
6591
- if (!pageBounds) return
6709
+ const shapePageBounds = this.getShapePageBounds(shape)
6710
+ if (!shapePageBounds) continue
6711
+
6712
+ if (
6713
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
6714
+ type: 'align',
6715
+ shapes: shapesToAlignFirstPass,
6716
+ })
6717
+ ) {
6718
+ continue
6719
+ }
6720
+
6721
+ // In this implementation, we want to create psuedo-groups out of shapes that
6722
+ // are moving together. At the moment shapes only move together if they're connected
6723
+ // by arrows. So let's say A -> B -> C -> D and A, B, and C are selected. If we're
6724
+ // aligning A, B, and C, then we want these to move together as one unit.
6725
+
6726
+ const shapesMovingTogether = [shape]
6727
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6728
+
6729
+ this.collectShapesViaArrowBindings({
6730
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6731
+ initialShapes: shapesToAlignFirstPass,
6732
+ resultShapes: shapesMovingTogether,
6733
+ resultBounds: boundsOfShapesMovingTogether,
6734
+ visited,
6735
+ })
6592
6736
 
6593
- const delta = { x: 0, y: 0 }
6737
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6738
+ if (!commonPageBounds) continue
6739
+
6740
+ shapeClustersToAlign.push({
6741
+ shapes: shapesMovingTogether,
6742
+ pageBounds: commonPageBounds,
6743
+ })
6744
+
6745
+ allBounds.push(commonPageBounds)
6746
+ }
6747
+
6748
+ if (shapeClustersToAlign.length < 2) return this
6749
+
6750
+ const commonBounds = Box.Common(allBounds)
6751
+
6752
+ const changes: TLShapePartial[] = []
6753
+
6754
+ shapeClustersToAlign.forEach(({ shapes, pageBounds }) => {
6755
+ const delta = new Vec()
6594
6756
 
6595
6757
  switch (operation) {
6596
6758
  case 'top': {
@@ -6619,12 +6781,20 @@ export class Editor extends EventEmitter<TLEventMap> {
6619
6781
  }
6620
6782
  }
6621
6783
 
6622
- const parent = this.getShapeParent(shape)
6623
- const localDelta = parent
6624
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation)
6625
- : delta
6784
+ for (const shape of shapes) {
6785
+ const shapeDelta = delta.clone()
6626
6786
 
6627
- changes.push(this.getChangesToTranslateShape(shape, Vec.Add(shape, localDelta)))
6787
+ // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
6788
+ // todo: ensure that the parent isn't being aligned together with its children
6789
+ const parent = this.getShapeParent(shape)
6790
+ if (parent) {
6791
+ const parentTransform = this.getShapePageTransform(parent)
6792
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6793
+ }
6794
+
6795
+ shapeDelta.add(shape) // add the shape's x and y to the delta
6796
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6797
+ }
6628
6798
  })
6629
6799
 
6630
6800
  this.updateShapes(changes)
@@ -6646,65 +6816,137 @@ export class Editor extends EventEmitter<TLEventMap> {
6646
6816
  * @public
6647
6817
  */
6648
6818
  distributeShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
6819
+ if (this.getIsReadonly()) return this
6820
+
6649
6821
  const ids =
6650
6822
  typeof shapes[0] === 'string'
6651
6823
  ? (shapes as TLShapeId[])
6652
6824
  : (shapes as TLShape[]).map((s) => s.id)
6653
6825
 
6654
- if (this.getIsReadonly()) return this
6655
- if (ids.length < 3) return this
6826
+ // always fresh shapes
6827
+ const shapesToDistributeFirstPass = compact(ids.map((id) => this.getShape(id)))
6656
6828
 
6657
- const len = ids.length
6658
- const shapesToDistribute = compact(ids.map((id) => this.getShape(id))) // always fresh shapes
6659
- const pageBounds = Object.fromEntries(
6660
- shapesToDistribute.map((shape) => [shape.id, this.getShapePageBounds(shape)!])
6661
- )
6829
+ const shapeClustersToDistribute: {
6830
+ shapes: TLShape[]
6831
+ pageBounds: Box
6832
+ }[] = []
6833
+
6834
+ const allBounds: Box[] = []
6835
+ const visited = new Set<TLShapeId>()
6836
+
6837
+ for (const shape of shapesToDistributeFirstPass) {
6838
+ if (visited.has(shape.id)) continue
6839
+ visited.add(shape.id)
6840
+
6841
+ const shapePageBounds = this.getShapePageBounds(shape)
6842
+ if (!shapePageBounds) continue
6843
+
6844
+ if (
6845
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
6846
+ type: 'distribute',
6847
+ shapes: shapesToDistributeFirstPass,
6848
+ })
6849
+ ) {
6850
+ continue
6851
+ }
6852
+
6853
+ const shapesMovingTogether = [shape]
6854
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6855
+
6856
+ this.collectShapesViaArrowBindings({
6857
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6858
+ initialShapes: shapesToDistributeFirstPass,
6859
+ resultShapes: shapesMovingTogether,
6860
+ resultBounds: boundsOfShapesMovingTogether,
6861
+ visited,
6862
+ })
6863
+
6864
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6865
+ if (!commonPageBounds) continue
6866
+
6867
+ shapeClustersToDistribute.push({
6868
+ shapes: shapesMovingTogether,
6869
+ pageBounds: commonPageBounds,
6870
+ })
6871
+
6872
+ allBounds.push(commonPageBounds)
6873
+ }
6874
+
6875
+ if (shapeClustersToDistribute.length < 3) return this
6662
6876
 
6663
6877
  let val: 'x' | 'y'
6664
6878
  let min: 'minX' | 'minY'
6665
6879
  let max: 'maxX' | 'maxY'
6666
- let mid: 'midX' | 'midY'
6667
6880
  let dim: 'width' | 'height'
6668
6881
 
6669
6882
  if (operation === 'horizontal') {
6670
6883
  val = 'x'
6671
6884
  min = 'minX'
6672
6885
  max = 'maxX'
6673
- mid = 'midX'
6674
6886
  dim = 'width'
6675
6887
  } else {
6676
6888
  val = 'y'
6677
6889
  min = 'minY'
6678
6890
  max = 'maxY'
6679
- mid = 'midY'
6680
6891
  dim = 'height'
6681
6892
  }
6682
6893
  const changes: TLShapePartial[] = []
6683
6894
 
6684
- // Clustered
6685
- const first = shapesToDistribute.sort(
6686
- (a, b) => pageBounds[a.id][min] - pageBounds[b.id][min]
6687
- )[0]
6688
- const last = shapesToDistribute.sort((a, b) => pageBounds[b.id][max] - pageBounds[a.id][max])[0]
6895
+ const first = shapeClustersToDistribute.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])[0]
6896
+ const last = shapeClustersToDistribute.sort((a, b) => b.pageBounds[max] - a.pageBounds[max])[0]
6689
6897
 
6690
- const midFirst = pageBounds[first.id][mid]
6691
- const step = (pageBounds[last.id][mid] - midFirst) / (len - 1)
6692
- const v = midFirst + step
6898
+ // If the first shape group is also the last shape group, distribute without it
6899
+ if (first === last) {
6900
+ const excludedShapeIds = new Set(first.shapes.map((s) => s.id))
6901
+ return this.distributeShapes(
6902
+ ids.filter((id) => !excludedShapeIds.has(id)),
6903
+ operation
6904
+ )
6905
+ }
6693
6906
 
6694
- shapesToDistribute
6907
+ const shapeClustersToMove = shapeClustersToDistribute
6695
6908
  .filter((shape) => shape !== first && shape !== last)
6696
- .sort((a, b) => pageBounds[a.id][mid] - pageBounds[b.id][mid])
6697
- .forEach((shape, i) => {
6698
- const delta = { x: 0, y: 0 }
6699
- delta[val] = v + step * i - pageBounds[shape.id][dim] / 2 - pageBounds[shape.id][val]
6909
+ .sort((a, b) => {
6910
+ if (a.pageBounds[min] === b.pageBounds[min]) {
6911
+ return a.shapes[0].id < b.shapes[0].id ? -1 : 1
6912
+ }
6913
+ return a.pageBounds[min] - b.pageBounds[min]
6914
+ })
6915
+
6916
+ // The gap is the amount of space "left over" between the first and last shape. This can be a negative number if the shapes are overlapping.
6917
+ const maxFirst = first.pageBounds[max]
6918
+ const range = last.pageBounds[min] - maxFirst
6919
+ const summedShapeDimensions = shapeClustersToMove.reduce((acc, s) => acc + s.pageBounds[dim], 0)
6920
+ const gap = (range - summedShapeDimensions) / (shapeClustersToMove.length + 1)
6921
+
6922
+ for (let v = maxFirst + gap, i = 0; i < shapeClustersToMove.length; i++) {
6923
+ const { shapes, pageBounds } = shapeClustersToMove[i]
6924
+ const delta = new Vec()
6925
+ delta[val] = v - pageBounds[val]
6700
6926
 
6927
+ // If for some reason the new position would be more than the maximum, we need to adjust the delta
6928
+ // This will likely throw off some of the other placements but hey, it's better than changing the common bounds
6929
+ if (v + pageBounds[dim] > last.pageBounds[max] - 1) {
6930
+ delta[val] = last.pageBounds[max] - pageBounds[max] - 1
6931
+ }
6932
+
6933
+ for (const shape of shapes) {
6934
+ const shapeDelta = delta.clone()
6935
+
6936
+ // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
6937
+ // todo: ensure that the parent isn't being aligned together with its children
6701
6938
  const parent = this.getShapeParent(shape)
6702
- const localDelta = parent
6703
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.rotation())
6704
- : delta
6939
+ if (parent) {
6940
+ const parentTransform = this.getShapePageTransform(parent)
6941
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6942
+ }
6705
6943
 
6706
- changes.push(this.getChangesToTranslateShape(shape, Vec.Add(shape, localDelta)))
6707
- })
6944
+ shapeDelta.add(shape) // add the shape's x and y to the delta
6945
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6946
+ }
6947
+
6948
+ v += pageBounds[dim] + gap
6949
+ }
6708
6950
 
6709
6951
  this.updateShapes(changes)
6710
6952
  return this
@@ -6731,65 +6973,106 @@ export class Editor extends EventEmitter<TLEventMap> {
6731
6973
  : (shapes as TLShape[]).map((s) => s.id)
6732
6974
 
6733
6975
  if (this.getIsReadonly()) return this
6734
- if (ids.length < 2) return this
6735
-
6736
- const shapesToStretch = compact(ids.map((id) => this.getShape(id))) // always fresh shapes
6737
- const shapeBounds = Object.fromEntries(ids.map((id) => [id, this.getShapeGeometry(id).bounds]))
6738
- const shapePageBounds = Object.fromEntries(ids.map((id) => [id, this.getShapePageBounds(id)!]))
6739
- const commonBounds = Box.Common(compact(Object.values(shapePageBounds)))
6740
-
6741
- switch (operation) {
6742
- case 'vertical': {
6743
- this.run(() => {
6744
- for (const shape of shapesToStretch) {
6745
- const pageRotation = this.getShapePageTransform(shape)!.rotation()
6746
- if (pageRotation % PI2) continue
6747
- const bounds = shapeBounds[shape.id]
6748
- const pageBounds = shapePageBounds[shape.id]
6749
- const localOffset = new Vec(0, commonBounds.minY - pageBounds.minY)
6750
- const parentTransform = this.getShapeParentTransform(shape)
6751
- if (parentTransform) localOffset.rot(-parentTransform.rotation())
6752
-
6753
- const { x, y } = Vec.Add(localOffset, shape)
6754
- this.updateShapes([{ id: shape.id, type: shape.type, x, y }])
6755
- const scale = new Vec(1, commonBounds.height / pageBounds.height)
6756
- this.resizeShape(shape.id, scale, {
6757
- initialBounds: bounds,
6758
- scaleOrigin: new Vec(pageBounds.center.x, commonBounds.minY),
6759
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6760
- scaleAxisRotation: 0,
6761
- })
6762
- }
6976
+
6977
+ // always fresh shapes, skip anything that isn't rotated 90 deg
6978
+ const shapesToStretchFirstPass = compact(ids.map((id) => this.getShape(id))).filter(
6979
+ (s) => this.getShapePageTransform(s)?.rotation() % (PI / 2) === 0
6980
+ )
6981
+
6982
+ const shapeClustersToStretch: {
6983
+ shapes: TLShape[]
6984
+ pageBounds: Box
6985
+ }[] = []
6986
+
6987
+ const allBounds: Box[] = []
6988
+ const visited = new Set<TLShapeId>()
6989
+
6990
+ for (const shape of shapesToStretchFirstPass) {
6991
+ if (visited.has(shape.id)) continue
6992
+ visited.add(shape.id)
6993
+
6994
+ const shapePageBounds = this.getShapePageBounds(shape)
6995
+ if (!shapePageBounds) continue
6996
+
6997
+ const shapesMovingTogether = [shape]
6998
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6999
+
7000
+ if (
7001
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
7002
+ type: 'stretch',
7003
+ shapes: shapesToStretchFirstPass,
6763
7004
  })
6764
- break
7005
+ ) {
7006
+ continue
6765
7007
  }
6766
- case 'horizontal': {
6767
- this.run(() => {
6768
- for (const shape of shapesToStretch) {
6769
- const bounds = shapeBounds[shape.id]
6770
- const pageBounds = shapePageBounds[shape.id]
6771
- const pageRotation = this.getShapePageTransform(shape)!.rotation()
6772
- if (pageRotation % PI2) continue
6773
- const localOffset = new Vec(commonBounds.minX - pageBounds.minX, 0)
6774
- const parentTransform = this.getShapeParentTransform(shape)
6775
- if (parentTransform) localOffset.rot(-parentTransform.rotation())
6776
-
6777
- const { x, y } = Vec.Add(localOffset, shape)
6778
- this.updateShapes([{ id: shape.id, type: shape.type, x, y }])
6779
- const scale = new Vec(commonBounds.width / pageBounds.width, 1)
6780
- this.resizeShape(shape.id, scale, {
6781
- initialBounds: bounds,
6782
- scaleOrigin: new Vec(commonBounds.minX, pageBounds.center.y),
6783
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6784
- scaleAxisRotation: 0,
6785
- })
6786
- }
6787
- })
6788
7008
 
6789
- break
6790
- }
7009
+ this.collectShapesViaArrowBindings({
7010
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
7011
+ initialShapes: shapesToStretchFirstPass,
7012
+ resultShapes: shapesMovingTogether,
7013
+ resultBounds: boundsOfShapesMovingTogether,
7014
+ visited,
7015
+ })
7016
+
7017
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
7018
+ if (!commonPageBounds) continue
7019
+
7020
+ shapeClustersToStretch.push({
7021
+ shapes: shapesMovingTogether,
7022
+ pageBounds: commonPageBounds,
7023
+ })
7024
+
7025
+ allBounds.push(commonPageBounds)
7026
+ }
7027
+
7028
+ if (shapeClustersToStretch.length < 2) return this
7029
+
7030
+ const commonBounds = Box.Common(allBounds)
7031
+ let val: 'x' | 'y'
7032
+ let min: 'minX' | 'minY'
7033
+ let dim: 'width' | 'height'
7034
+
7035
+ if (operation === 'horizontal') {
7036
+ val = 'x'
7037
+ min = 'minX'
7038
+ dim = 'width'
7039
+ } else {
7040
+ val = 'y'
7041
+ min = 'minY'
7042
+ dim = 'height'
6791
7043
  }
6792
7044
 
7045
+ this.run(() => {
7046
+ shapeClustersToStretch.forEach(({ shapes, pageBounds }) => {
7047
+ const localOffset = new Vec()
7048
+ localOffset[val] = commonBounds[min] - pageBounds[min]
7049
+
7050
+ const scaleOrigin = pageBounds.center.clone()
7051
+ scaleOrigin[val] = commonBounds[min]
7052
+
7053
+ const scale = new Vec(1, 1)
7054
+ scale[val] = commonBounds[dim] / pageBounds[dim]
7055
+
7056
+ for (const shape of shapes) {
7057
+ // First translate
7058
+ const shapeLocalOffset = localOffset.clone()
7059
+ const parentTransform = this.getShapeParentTransform(shape)
7060
+ if (parentTransform) localOffset.rot(-parentTransform.rotation())
7061
+ shapeLocalOffset.add(shape)
7062
+ const changes = this.getChangesToTranslateShape(shape, shapeLocalOffset)
7063
+ this.updateShape(changes)
7064
+
7065
+ // Then resize
7066
+ this.resizeShape(shape.id, scale, {
7067
+ initialBounds: this.getShapeGeometry(shape).bounds,
7068
+ scaleOrigin,
7069
+ isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
7070
+ scaleAxisRotation: 0,
7071
+ })
7072
+ }
7073
+ })
7074
+ })
7075
+
6793
7076
  return this
6794
7077
  }
6795
7078
 
@@ -10057,7 +10340,7 @@ function withIsolatedShapes<T>(
10057
10340
  }
10058
10341
  })
10059
10342
 
10060
- editor.store.applyDiff(reverseRecordsDiff(changes))
10343
+ editor.store.applyDiff(reverseRecordsDiff(changes), { runCallbacks: false })
10061
10344
  },
10062
10345
  { history: 'ignore' }
10063
10346
  )