@tldraw/editor 3.9.0-internal.7f0e15f4f7d9 → 3.10.0-canary.3bf31007c5a7

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