@tldraw/editor 3.9.0-canary.81717556ec3d → 3.9.0-canary.8339b14b4397

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 (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/dist-cjs/index.d.ts +37 -7
  4. package/dist-cjs/index.js +1 -1
  5. package/dist-cjs/index.js.map +2 -2
  6. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
  7. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
  8. package/dist-cjs/lib/editor/Editor.js +431 -249
  9. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  10. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +7 -2
  11. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  12. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  13. package/dist-cjs/version.js +3 -3
  14. package/dist-cjs/version.js.map +1 -1
  15. package/dist-esm/index.d.mts +37 -7
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/index.mjs.map +2 -2
  18. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  19. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
  20. package/dist-esm/lib/editor/Editor.mjs +427 -245
  21. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  22. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +7 -2
  23. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  24. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  25. package/dist-esm/version.mjs +3 -3
  26. package/dist-esm/version.mjs.map +1 -1
  27. package/package.json +7 -7
  28. package/src/index.ts +2 -0
  29. package/src/lib/components/default-components/DefaultErrorFallback.tsx +5 -3
  30. package/src/lib/editor/Editor.ts +556 -273
  31. package/src/lib/editor/shapes/ShapeUtil.ts +32 -5
  32. package/src/lib/exports/getSvgJsx.tsx +1 -0
  33. package/src/version.ts +3 -3
@@ -83,6 +83,7 @@ import {
83
83
  structuredClone,
84
84
  uniqueId,
85
85
  } from '@tldraw/utils'
86
+ import { Number } from 'core-js'
86
87
  import EventEmitter from 'eventemitter3'
87
88
  import {
88
89
  TLEditorSnapshot,
@@ -116,7 +117,7 @@ import { EASINGS } from '../primitives/easings'
116
117
  import { Geometry2d } from '../primitives/geometry/Geometry2d'
117
118
  import { Group2d } from '../primitives/geometry/Group2d'
118
119
  import { intersectPolygonPolygon } from '../primitives/intersect'
119
- import { PI2, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils'
120
+ import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils'
120
121
  import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap'
121
122
  import { dataUrlToFile } from '../utils/assets'
122
123
  import { debugFlags } from '../utils/debug-flags'
@@ -144,7 +145,7 @@ import { SnapManager } from './managers/SnapManager/SnapManager'
144
145
  import { TextManager } from './managers/TextManager'
145
146
  import { TickManager } from './managers/TickManager'
146
147
  import { UserPreferencesManager } from './managers/UserPreferencesManager'
147
- import { ShapeUtil, TLResizeMode } from './shapes/ShapeUtil'
148
+ import { ShapeUtil, TLGeometryOpts, TLResizeMode } from './shapes/ShapeUtil'
148
149
  import { RootState } from './tools/RootState'
149
150
  import { StateNode, TLStateNodeConstructor } from './tools/StateNode'
150
151
  import { TLContent } from './types/clipboard-types'
@@ -4203,14 +4204,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4203
4204
 
4204
4205
  /* --------------------- Shapes --------------------- */
4205
4206
 
4206
- @computed
4207
- private _getShapeGeometryCache(): ComputedCache<Geometry2d, TLShape> {
4208
- return this.store.createComputedCache(
4209
- 'bounds',
4210
- (shape) => this.getShapeUtil(shape).getGeometry(shape),
4211
- (a, b) => a.props === b.props
4212
- )
4213
- }
4207
+ private _shapeGeometryCaches: Record<string, ComputedCache<Geometry2d, TLShape>> = {}
4214
4208
 
4215
4209
  /**
4216
4210
  * Get the geometry of a shape.
@@ -4219,14 +4213,26 @@ export class Editor extends EventEmitter<TLEventMap> {
4219
4213
  * ```ts
4220
4214
  * editor.getShapeGeometry(myShape)
4221
4215
  * editor.getShapeGeometry(myShapeId)
4216
+ * editor.getShapeGeometry(myShapeId, { context: "arrow" })
4222
4217
  * ```
4223
4218
  *
4224
4219
  * @param shape - The shape (or shape id) to get the geometry for.
4220
+ * @param opts - Additional options about the request for geometry. Passed to {@link ShapeUtil.getGeometry}.
4225
4221
  *
4226
4222
  * @public
4227
4223
  */
4228
- getShapeGeometry<T extends Geometry2d>(shape: TLShape | TLShapeId): T {
4229
- return this._getShapeGeometryCache().get(typeof shape === 'string' ? shape : shape.id)! as T
4224
+ getShapeGeometry<T extends Geometry2d>(shape: TLShape | TLShapeId, opts?: TLGeometryOpts): T {
4225
+ const context = opts?.context ?? 'none'
4226
+ if (!this._shapeGeometryCaches[context]) {
4227
+ this._shapeGeometryCaches[context] = this.store.createComputedCache(
4228
+ 'bounds',
4229
+ (shape) => this.getShapeUtil(shape).getGeometry(shape, opts),
4230
+ { areRecordsEqual: (a, b) => a.props === b.props }
4231
+ )
4232
+ }
4233
+ return this._shapeGeometryCaches[context].get(
4234
+ typeof shape === 'string' ? shape : shape.id
4235
+ )! as T
4230
4236
  }
4231
4237
 
4232
4238
  /** @internal */
@@ -5685,14 +5691,15 @@ export class Editor extends EventEmitter<TLEventMap> {
5685
5691
  return this
5686
5692
  }
5687
5693
 
5694
+ // Gets a shape partial that includes life cycle changes: on translate start, on translate, on translate end
5688
5695
  private getChangesToTranslateShape(initialShape: TLShape, newShapeCoords: VecLike): TLShape {
5689
5696
  let workingShape = initialShape
5690
5697
  const util = this.getShapeUtil(initialShape)
5691
5698
 
5692
- workingShape = applyPartialToRecordWithProps(
5693
- workingShape,
5694
- util.onTranslateStart?.(workingShape) ?? undefined
5695
- )
5699
+ const afterTranslateStart = util.onTranslateStart?.(workingShape)
5700
+ if (afterTranslateStart) {
5701
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslateStart)
5702
+ }
5696
5703
 
5697
5704
  workingShape = applyPartialToRecordWithProps(workingShape, {
5698
5705
  id: initialShape.id,
@@ -5701,15 +5708,15 @@ export class Editor extends EventEmitter<TLEventMap> {
5701
5708
  y: newShapeCoords.y,
5702
5709
  })
5703
5710
 
5704
- workingShape = applyPartialToRecordWithProps(
5705
- workingShape,
5706
- util.onTranslate?.(initialShape, workingShape) ?? undefined
5707
- )
5711
+ const afterTranslate = util.onTranslate?.(initialShape, workingShape)
5712
+ if (afterTranslate) {
5713
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslate)
5714
+ }
5708
5715
 
5709
- workingShape = applyPartialToRecordWithProps(
5710
- workingShape,
5711
- util.onTranslateEnd?.(initialShape, workingShape) ?? undefined
5712
- )
5716
+ const afterTranslateEnd = util.onTranslateEnd?.(initialShape, workingShape)
5717
+ if (afterTranslateEnd) {
5718
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslateEnd)
5719
+ }
5713
5720
 
5714
5721
  return workingShape
5715
5722
  }
@@ -6108,6 +6115,37 @@ export class Editor extends EventEmitter<TLEventMap> {
6108
6115
  return this
6109
6116
  }
6110
6117
 
6118
+ /**
6119
+ * @internal
6120
+ */
6121
+ private collectShapesViaArrowBindings(info: {
6122
+ initialShapes: TLShape[]
6123
+ resultShapes: TLShape[]
6124
+ resultBounds: Box[]
6125
+ bindings: TLBinding[]
6126
+ visited: Set<TLShapeId>
6127
+ }) {
6128
+ const { initialShapes, resultShapes, resultBounds, bindings, visited } = info
6129
+ for (const binding of bindings) {
6130
+ for (const id of [binding.fromId, binding.toId]) {
6131
+ if (!visited.has(id)) {
6132
+ const aligningShape = initialShapes.find((s) => s.id === id)
6133
+ if (aligningShape && !visited.has(aligningShape.id)) {
6134
+ visited.add(aligningShape.id)
6135
+ const shapePageBounds = this.getShapePageBounds(aligningShape)
6136
+ if (!shapePageBounds) continue
6137
+ resultShapes.push(aligningShape)
6138
+ resultBounds.push(shapePageBounds)
6139
+ this.collectShapesViaArrowBindings({
6140
+ ...info,
6141
+ bindings: this.getBindingsInvolvingShape(aligningShape, 'arrow'),
6142
+ })
6143
+ }
6144
+ }
6145
+ }
6146
+ }
6147
+ }
6148
+
6111
6149
  /**
6112
6150
  * Flip shape positions.
6113
6151
  *
@@ -6123,47 +6161,74 @@ export class Editor extends EventEmitter<TLEventMap> {
6123
6161
  * @public
6124
6162
  */
6125
6163
  flipShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
6164
+ if (this.getIsReadonly()) return this
6165
+
6126
6166
  const ids =
6127
6167
  typeof shapes[0] === 'string'
6128
6168
  ? (shapes as TLShapeId[])
6129
6169
  : (shapes as TLShape[]).map((s) => s.id)
6130
6170
 
6131
- if (this.getIsReadonly()) return this
6171
+ // Collect a greedy list of shapes to flip
6172
+ const shapesToFlipFirstPass = compact(ids.map((id) => this.getShape(id)))
6132
6173
 
6133
- let shapesToFlip = compact(ids.map((id) => this.getShape(id)))
6174
+ for (const shape of shapesToFlipFirstPass) {
6175
+ if (this.isShapeOfType<TLGroupShape>(shape, 'group')) {
6176
+ const childrenOfGroups = compact(
6177
+ this.getSortedChildIdsForParent(shape.id).map((id) => this.getShape(id))
6178
+ )
6179
+ shapesToFlipFirstPass.push(...childrenOfGroups)
6180
+ }
6181
+ }
6134
6182
 
6135
- if (!shapesToFlip.length) return this
6183
+ // exclude shapes that can't be flipped
6184
+ const shapesToFlip: {
6185
+ shape: TLShape
6186
+ localBounds: Box
6187
+ pageTransform: Mat
6188
+ isAspectRatioLocked: boolean
6189
+ }[] = []
6136
6190
 
6137
- shapesToFlip = compact(
6138
- shapesToFlip
6139
- .map((shape) => {
6140
- if (this.isShapeOfType<TLGroupShape>(shape, 'group')) {
6141
- return this.getSortedChildIdsForParent(shape.id).map((id) => this.getShape(id))
6142
- }
6191
+ const allBounds: Box[] = []
6143
6192
 
6144
- return shape
6193
+ for (const shape of shapesToFlipFirstPass) {
6194
+ const util = this.getShapeUtil(shape)
6195
+ if (
6196
+ !util.canBeLaidOut(shape, {
6197
+ type: 'flip',
6198
+ shapes: shapesToFlipFirstPass,
6145
6199
  })
6146
- .flat()
6147
- )
6200
+ ) {
6201
+ continue
6202
+ }
6203
+
6204
+ const pageBounds = this.getShapePageBounds(shape)
6205
+ const localBounds = this.getShapeGeometry(shape).bounds
6206
+ const pageTransform = this.getShapePageTransform(shape.id)
6207
+ if (!(pageBounds && localBounds && pageTransform)) continue
6208
+ shapesToFlip.push({
6209
+ shape,
6210
+ localBounds,
6211
+ pageTransform,
6212
+ isAspectRatioLocked: util.isAspectRatioLocked(shape),
6213
+ })
6214
+ allBounds.push(pageBounds)
6215
+ }
6216
+
6217
+ if (!shapesToFlip.length) return this
6148
6218
 
6149
- const scaleOriginPage = Box.Common(
6150
- compact(shapesToFlip.map((id) => this.getShapePageBounds(id)))
6151
- ).center
6219
+ const scaleOriginPage = Box.Common(allBounds).center
6152
6220
 
6153
6221
  this.run(() => {
6154
- for (const shape of shapesToFlip) {
6155
- const bounds = this.getShapeGeometry(shape).bounds
6156
- const initialPageTransform = this.getShapePageTransform(shape.id)
6157
- if (!initialPageTransform) continue
6222
+ for (const { shape, localBounds, pageTransform, isAspectRatioLocked } of shapesToFlip) {
6158
6223
  this.resizeShape(
6159
6224
  shape.id,
6160
6225
  { x: operation === 'horizontal' ? -1 : 1, y: operation === 'vertical' ? -1 : 1 },
6161
6226
  {
6162
- initialBounds: bounds,
6163
- initialPageTransform,
6227
+ initialBounds: localBounds,
6228
+ initialPageTransform: pageTransform,
6164
6229
  initialShape: shape,
6230
+ isAspectRatioLocked,
6165
6231
  mode: 'scale_shape',
6166
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6167
6232
  scaleOrigin: scaleOriginPage,
6168
6233
  scaleAxisRotation: 0,
6169
6234
  }
@@ -6200,21 +6265,58 @@ export class Editor extends EventEmitter<TLEventMap> {
6200
6265
  : (shapes as TLShape[]).map((s) => s.id)
6201
6266
  if (this.getIsReadonly()) return this
6202
6267
 
6203
- const shapesToStack = ids
6204
- .map((id) => this.getShape(id)) // always fresh shapes
6205
- .filter((shape): shape is TLShape => {
6206
- if (!shape) return false
6268
+ // 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.
6269
+
6270
+ // always fresh shapes
6271
+ const shapesToStackFirstPass = compact(ids.map((id) => this.getShape(id)))
6272
+
6273
+ const shapeClustersToStack: {
6274
+ shapes: TLShape[]
6275
+ pageBounds: Box
6276
+ }[] = []
6277
+ const allBounds: Box[] = []
6278
+ const visited = new Set<TLShapeId>()
6279
+
6280
+ for (const shape of shapesToStackFirstPass) {
6281
+ if (visited.has(shape.id)) continue
6282
+ visited.add(shape.id)
6283
+
6284
+ const shapePageBounds = this.getShapePageBounds(shape)
6285
+ if (!shapePageBounds) continue
6286
+
6287
+ if (
6288
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
6289
+ type: 'stack',
6290
+ shapes: shapesToStackFirstPass,
6291
+ })
6292
+ ) {
6293
+ continue
6294
+ }
6295
+
6296
+ const shapesMovingTogether = [shape]
6297
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6207
6298
 
6208
- return this.getShapeUtil(shape).canBeLaidOut(shape)
6299
+ this.collectShapesViaArrowBindings({
6300
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6301
+ initialShapes: shapesToStackFirstPass,
6302
+ resultShapes: shapesMovingTogether,
6303
+ resultBounds: boundsOfShapesMovingTogether,
6304
+ visited,
6209
6305
  })
6210
6306
 
6211
- const len = shapesToStack.length
6307
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6308
+ if (!commonPageBounds) continue
6212
6309
 
6213
- if ((gap === 0 && len < 3) || len < 2) return this
6310
+ shapeClustersToStack.push({
6311
+ shapes: shapesMovingTogether,
6312
+ pageBounds: commonPageBounds,
6313
+ })
6214
6314
 
6215
- const pageBounds = Object.fromEntries(
6216
- shapesToStack.map((shape) => [shape.id, this.getShapePageBounds(shape)!])
6217
- )
6315
+ allBounds.push(commonPageBounds)
6316
+ }
6317
+
6318
+ const len = shapeClustersToStack.length
6319
+ if ((gap === 0 && len < 3) || len < 2) return this
6218
6320
 
6219
6321
  let val: 'x' | 'y'
6220
6322
  let min: 'minX' | 'minY'
@@ -6233,46 +6335,45 @@ export class Editor extends EventEmitter<TLEventMap> {
6233
6335
  dim = 'height'
6234
6336
  }
6235
6337
 
6236
- let shapeGap: number
6338
+ let shapeGap: number = 0
6237
6339
 
6238
6340
  if (gap === 0) {
6239
- const gaps: { gap: number; count: number }[] = []
6341
+ // note: this is not used in the current tldraw.com; there we use a specified stack
6342
+
6343
+ const gaps: Record<number, number> = {}
6240
6344
 
6241
- shapesToStack.sort((a, b) => pageBounds[a.id][min] - pageBounds[b.id][min])
6345
+ shapeClustersToStack.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])
6242
6346
 
6243
6347
  // Collect all of the gaps between shapes. We want to find
6244
6348
  // patterns (equal gaps between shapes) and use the most common
6245
6349
  // one as the gap for all of the shapes.
6246
6350
  for (let i = 0; i < len - 1; i++) {
6247
- const shape = shapesToStack[i]
6248
- const nextShape = shapesToStack[i + 1]
6249
-
6250
- const bounds = pageBounds[shape.id]
6251
- const nextBounds = pageBounds[nextShape.id]
6252
-
6253
- const gap = nextBounds[min] - bounds[max]
6254
-
6255
- const current = gaps.find((g) => g.gap === gap)
6256
-
6257
- if (current) {
6258
- current.count++
6259
- } else {
6260
- gaps.push({ gap, count: 1 })
6351
+ const currCluster = shapeClustersToStack[i]
6352
+ const nextCluster = shapeClustersToStack[i + 1]
6353
+ const gap = nextCluster.pageBounds[min] - currCluster.pageBounds[max]
6354
+ if (!gaps[gap]) {
6355
+ gaps[gap] = 0
6261
6356
  }
6357
+ gaps[gap]++
6262
6358
  }
6263
6359
 
6264
6360
  // Which gap is the most common?
6265
- let maxCount = 0
6266
- gaps.forEach((g) => {
6267
- if (g.count > maxCount) {
6268
- maxCount = g.count
6269
- shapeGap = g.gap
6361
+ let maxCount = 1
6362
+ for (const [gap, count] of Object.entries(gaps)) {
6363
+ if (count > maxCount) {
6364
+ maxCount = count
6365
+ shapeGap = parseFloat(gap)
6270
6366
  }
6271
- })
6367
+ }
6272
6368
 
6273
6369
  // If there is no most-common gap, use the average gap.
6274
6370
  if (maxCount === 1) {
6275
- shapeGap = Math.max(0, gaps.reduce((a, c) => a + c.gap * c.count, 0) / (len - 1))
6371
+ let totalCount = 0
6372
+ for (const [gap, count] of Object.entries(gaps)) {
6373
+ shapeGap += parseFloat(gap) * count
6374
+ totalCount += count
6375
+ }
6376
+ shapeGap /= totalCount
6276
6377
  }
6277
6378
  } else {
6278
6379
  // If a gap was provided, then use that instead.
@@ -6281,36 +6382,30 @@ export class Editor extends EventEmitter<TLEventMap> {
6281
6382
 
6282
6383
  const changes: TLShapePartial[] = []
6283
6384
 
6284
- let v = pageBounds[shapesToStack[0].id][max]
6385
+ let v = shapeClustersToStack[0].pageBounds[max]
6285
6386
 
6286
- shapesToStack.forEach((shape, i) => {
6287
- if (i === 0) return
6387
+ for (let i = 1; i < shapeClustersToStack.length; i++) {
6388
+ const { shapes, pageBounds } = shapeClustersToStack[i]
6389
+ const delta = new Vec()
6390
+ delta[val] = v + shapeGap - pageBounds[val]
6288
6391
 
6289
- const delta = { x: 0, y: 0 }
6290
- delta[val] = v + shapeGap - pageBounds[shape.id][val]
6291
-
6292
- const parent = this.getShapeParent(shape)
6293
- const localDelta = parent
6294
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation)
6295
- : delta
6392
+ for (const shape of shapes) {
6393
+ const shapeDelta = delta.clone()
6296
6394
 
6297
- const translateStartChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)
6395
+ // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
6396
+ // todo: ensure that the parent isn't being aligned together with its children
6397
+ const parent = this.getShapeParent(shape)
6398
+ if (parent) {
6399
+ const parentTransform = this.getShapePageTransform(parent)
6400
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6401
+ }
6298
6402
 
6299
- changes.push(
6300
- translateStartChanges
6301
- ? {
6302
- ...translateStartChanges,
6303
- [val]: shape[val] + localDelta[val],
6304
- }
6305
- : {
6306
- id: shape.id as any,
6307
- type: shape.type,
6308
- [val]: shape[val] + localDelta[val],
6309
- }
6310
- )
6403
+ shapeDelta.add(shape) // add the shape's x and y to the delta
6404
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6405
+ }
6311
6406
 
6312
- v += pageBounds[shape.id][dim] + shapeGap
6313
- })
6407
+ v += pageBounds[dim] + shapeGap
6408
+ }
6314
6409
 
6315
6410
  this.updateShapes(changes)
6316
6411
  return this
@@ -6330,42 +6425,79 @@ export class Editor extends EventEmitter<TLEventMap> {
6330
6425
  * @param gap - The padding to apply to the packed shapes. Defaults to 16.
6331
6426
  */
6332
6427
  packShapes(shapes: TLShapeId[] | TLShape[], gap: number): this {
6428
+ if (this.getIsReadonly()) return this
6429
+
6333
6430
  const ids =
6334
6431
  typeof shapes[0] === 'string'
6335
6432
  ? (shapes as TLShapeId[])
6336
6433
  : (shapes as TLShape[]).map((s) => s.id)
6337
6434
 
6338
- if (this.getIsReadonly()) return this
6339
- if (ids.length < 2) return this
6435
+ // Always fresh shapes
6436
+ const shapesToPackFirstPass = compact(ids.map((id) => this.getShape(id)))
6340
6437
 
6341
- const shapesToPack = ids
6342
- .map((id) => this.getShape(id)) // always fresh shapes
6343
- .filter((shape): shape is TLShape => {
6344
- if (!shape) return false
6438
+ const shapeClustersToPack: {
6439
+ shapes: TLShape[]
6440
+ pageBounds: Box
6441
+ nextPageBounds: Box
6442
+ }[] = []
6345
6443
 
6346
- return this.getShapeUtil(shape).canBeLaidOut(shape)
6444
+ const allBounds: Box[] = []
6445
+ const visited = new Set<TLShapeId>()
6446
+
6447
+ for (const shape of shapesToPackFirstPass) {
6448
+ if (visited.has(shape.id)) continue
6449
+ visited.add(shape.id)
6450
+
6451
+ const shapePageBounds = this.getShapePageBounds(shape)
6452
+ if (!shapePageBounds) continue
6453
+
6454
+ if (
6455
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
6456
+ type: 'pack',
6457
+ shapes: shapesToPackFirstPass,
6458
+ })
6459
+ ) {
6460
+ continue
6461
+ }
6462
+
6463
+ const shapesMovingTogether = [shape]
6464
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6465
+
6466
+ this.collectShapesViaArrowBindings({
6467
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6468
+ initialShapes: shapesToPackFirstPass,
6469
+ resultShapes: shapesMovingTogether,
6470
+ resultBounds: boundsOfShapesMovingTogether,
6471
+ visited,
6347
6472
  })
6348
- const shapePageBounds: Record<string, Box> = {}
6349
- const nextShapePageBounds: Record<string, Box> = {}
6350
6473
 
6351
- let shape: TLShape,
6352
- bounds: Box,
6353
- area = 0
6474
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6475
+ if (!commonPageBounds) continue
6476
+
6477
+ shapeClustersToPack.push({
6478
+ shapes: shapesMovingTogether,
6479
+ pageBounds: commonPageBounds,
6480
+ nextPageBounds: commonPageBounds.clone(),
6481
+ })
6482
+
6483
+ allBounds.push(commonPageBounds)
6484
+ }
6485
+
6486
+ if (shapeClustersToPack.length < 2) return this
6354
6487
 
6355
- for (let i = 0; i < shapesToPack.length; i++) {
6356
- shape = shapesToPack[i]
6357
- bounds = this.getShapePageBounds(shape)!
6358
- shapePageBounds[shape.id] = bounds
6359
- nextShapePageBounds[shape.id] = bounds.clone()
6360
- area += bounds.width * bounds.height
6488
+ let area = 0
6489
+ for (const { pageBounds } of shapeClustersToPack) {
6490
+ area += pageBounds.width * pageBounds.height
6361
6491
  }
6362
6492
 
6363
- const commonBounds = Box.Common(compact(Object.values(shapePageBounds)))
6493
+ const commonBounds = Box.Common(allBounds)
6364
6494
 
6365
6495
  const maxWidth = commonBounds.width
6366
6496
 
6367
- // sort the shapes by height, descending
6368
- shapesToPack.sort((a, b) => shapePageBounds[b.id].height - shapePageBounds[a.id].height)
6497
+ // sort the shape clusters by width and then height, descending
6498
+ shapeClustersToPack
6499
+ .sort((a, b) => a.pageBounds.width - b.pageBounds.width)
6500
+ .sort((a, b) => a.pageBounds.height - b.pageBounds.height)
6369
6501
 
6370
6502
  // Start with is (sort of) the square of the area
6371
6503
  const startWidth = Math.max(Math.ceil(Math.sqrt(area / 0.95)), maxWidth)
@@ -6378,85 +6510,69 @@ export class Editor extends EventEmitter<TLEventMap> {
6378
6510
  let space: Box
6379
6511
  let last: Box
6380
6512
 
6381
- for (let i = 0; i < shapesToPack.length; i++) {
6382
- shape = shapesToPack[i]
6383
- bounds = nextShapePageBounds[shape.id]
6384
-
6513
+ for (const { nextPageBounds } of shapeClustersToPack) {
6385
6514
  // starting at the back (smaller shapes)
6386
6515
  for (let i = spaces.length - 1; i >= 0; i--) {
6387
6516
  space = spaces[i]
6388
6517
 
6389
6518
  // find a space that is big enough to contain the shape
6390
- if (bounds.width > space.width || bounds.height > space.height) continue
6519
+ if (nextPageBounds.width > space.width || nextPageBounds.height > space.height) continue
6391
6520
 
6392
6521
  // add the shape to its top-left corner
6393
- bounds.x = space.x
6394
- bounds.y = space.y
6522
+ nextPageBounds.x = space.x
6523
+ nextPageBounds.y = space.y
6395
6524
 
6396
- height = Math.max(height, bounds.maxY)
6397
- width = Math.max(width, bounds.maxX)
6525
+ height = Math.max(height, nextPageBounds.maxY)
6526
+ width = Math.max(width, nextPageBounds.maxX)
6398
6527
 
6399
- if (bounds.width === space.width && bounds.height === space.height) {
6528
+ if (nextPageBounds.width === space.width && nextPageBounds.height === space.height) {
6400
6529
  // remove the space on a perfect fit
6401
6530
  last = spaces.pop()!
6402
6531
  if (i < spaces.length) spaces[i] = last
6403
- } else if (bounds.height === space.height) {
6532
+ } else if (nextPageBounds.height === space.height) {
6404
6533
  // fit the shape into the space (width)
6405
- space.x += bounds.width + gap
6406
- space.width -= bounds.width + gap
6407
- } else if (bounds.width === space.width) {
6534
+ space.x += nextPageBounds.width + gap
6535
+ space.width -= nextPageBounds.width + gap
6536
+ } else if (nextPageBounds.width === space.width) {
6408
6537
  // fit the shape into the space (height)
6409
- space.y += bounds.height + gap
6410
- space.height -= bounds.height + gap
6538
+ space.y += nextPageBounds.height + gap
6539
+ space.height -= nextPageBounds.height + gap
6411
6540
  } else {
6412
6541
  // split the space into two spaces
6413
6542
  spaces.push(
6414
6543
  new Box(
6415
- space.x + (bounds.width + gap),
6544
+ space.x + (nextPageBounds.width + gap),
6416
6545
  space.y,
6417
- space.width - (bounds.width + gap),
6418
- bounds.height
6546
+ space.width - (nextPageBounds.width + gap),
6547
+ nextPageBounds.height
6419
6548
  )
6420
6549
  )
6421
- space.y += bounds.height + gap
6422
- space.height -= bounds.height + gap
6550
+ space.y += nextPageBounds.height + gap
6551
+ space.height -= nextPageBounds.height + gap
6423
6552
  }
6424
6553
  break
6425
6554
  }
6426
6555
  }
6427
6556
 
6428
- const commonAfter = Box.Common(Object.values(nextShapePageBounds))
6557
+ const commonAfter = Box.Common(shapeClustersToPack.map((s) => s.nextPageBounds))
6429
6558
  const centerDelta = Vec.Sub(commonBounds.center, commonAfter.center)
6430
6559
 
6431
- let nextBounds: Box
6432
-
6433
6560
  const changes: TLShapePartial<any>[] = []
6434
6561
 
6435
- for (let i = 0; i < shapesToPack.length; i++) {
6436
- shape = shapesToPack[i]
6437
- bounds = shapePageBounds[shape.id]
6438
- nextBounds = nextShapePageBounds[shape.id]
6562
+ for (const { shapes, pageBounds, nextPageBounds } of shapeClustersToPack) {
6563
+ const delta = Vec.Sub(nextPageBounds.point, pageBounds.point).add(centerDelta)
6439
6564
 
6440
- const delta = Vec.Sub(nextBounds.point, bounds.point).add(centerDelta)
6441
- const parentTransform = this.getShapeParentTransform(shape)
6442
- if (parentTransform) delta.rot(-parentTransform.rotation())
6443
-
6444
- const change: TLShapePartial = {
6445
- id: shape.id,
6446
- type: shape.type,
6447
- x: shape.x + delta.x,
6448
- y: shape.y + delta.y,
6449
- }
6565
+ for (const shape of shapes) {
6566
+ const shapeDelta = delta.clone()
6450
6567
 
6451
- const translateStartChange = this.getShapeUtil(shape).onTranslateStart?.({
6452
- ...shape,
6453
- ...change,
6454
- })
6568
+ const parent = this.getShapeParent(shape)
6569
+ if (parent) {
6570
+ const parentTransform = this.getShapeParentTransform(shape)
6571
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6572
+ }
6455
6573
 
6456
- if (translateStartChange) {
6457
- changes.push({ ...change, ...translateStartChange })
6458
- } else {
6459
- changes.push(change)
6574
+ shapeDelta.add(shape)
6575
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6460
6576
  }
6461
6577
  }
6462
6578
 
@@ -6481,32 +6597,78 @@ export class Editor extends EventEmitter<TLEventMap> {
6481
6597
  *
6482
6598
  * @public
6483
6599
  */
6484
-
6485
6600
  alignShapes(
6486
6601
  shapes: TLShapeId[] | TLShape[],
6487
6602
  operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom'
6488
6603
  ): this {
6604
+ if (this.getIsReadonly()) return this
6605
+
6489
6606
  const ids =
6490
6607
  typeof shapes[0] === 'string'
6491
6608
  ? (shapes as TLShapeId[])
6492
6609
  : (shapes as TLShape[]).map((s) => s.id)
6493
6610
 
6494
- if (this.getIsReadonly()) return this
6495
- if (ids.length < 2) return this
6611
+ // Always get fresh shapes
6612
+ const shapesToAlignFirstPass = compact(ids.map((id) => this.getShape(id)))
6496
6613
 
6497
- const shapesToAlign = compact(ids.map((id) => this.getShape(id))) // always fresh shapes
6498
- const shapePageBounds = Object.fromEntries(
6499
- shapesToAlign.map((shape) => [shape.id, this.getShapePageBounds(shape)])
6500
- )
6501
- const commonBounds = Box.Common(compact(Object.values(shapePageBounds)))
6614
+ const shapeClustersToAlign: {
6615
+ shapes: TLShape[]
6616
+ pageBounds: Box
6617
+ }[] = []
6618
+ const allBounds: Box[] = []
6619
+ const visited = new Set<TLShapeId>()
6502
6620
 
6503
- const changes: TLShapePartial[] = []
6621
+ for (const shape of shapesToAlignFirstPass) {
6622
+ if (visited.has(shape.id)) continue
6623
+ visited.add(shape.id)
6504
6624
 
6505
- shapesToAlign.forEach((shape) => {
6506
- const pageBounds = shapePageBounds[shape.id]
6507
- if (!pageBounds) return
6625
+ const shapePageBounds = this.getShapePageBounds(shape)
6626
+ if (!shapePageBounds) continue
6627
+
6628
+ if (
6629
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
6630
+ type: 'align',
6631
+ shapes: shapesToAlignFirstPass,
6632
+ })
6633
+ ) {
6634
+ continue
6635
+ }
6636
+
6637
+ // In this implementation, we want to create psuedo-groups out of shapes that
6638
+ // are moving together. At the moment shapes only move together if they're connected
6639
+ // by arrows. So let's say A -> B -> C -> D and A, B, and C are selected. If we're
6640
+ // aligning A, B, and C, then we want these to move together as one unit.
6641
+
6642
+ const shapesMovingTogether = [shape]
6643
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6644
+
6645
+ this.collectShapesViaArrowBindings({
6646
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6647
+ initialShapes: shapesToAlignFirstPass,
6648
+ resultShapes: shapesMovingTogether,
6649
+ resultBounds: boundsOfShapesMovingTogether,
6650
+ visited,
6651
+ })
6508
6652
 
6509
- const delta = { x: 0, y: 0 }
6653
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6654
+ if (!commonPageBounds) continue
6655
+
6656
+ shapeClustersToAlign.push({
6657
+ shapes: shapesMovingTogether,
6658
+ pageBounds: commonPageBounds,
6659
+ })
6660
+
6661
+ allBounds.push(commonPageBounds)
6662
+ }
6663
+
6664
+ if (shapeClustersToAlign.length < 2) return this
6665
+
6666
+ const commonBounds = Box.Common(allBounds)
6667
+
6668
+ const changes: TLShapePartial[] = []
6669
+
6670
+ shapeClustersToAlign.forEach(({ shapes, pageBounds }) => {
6671
+ const delta = new Vec()
6510
6672
 
6511
6673
  switch (operation) {
6512
6674
  case 'top': {
@@ -6535,12 +6697,20 @@ export class Editor extends EventEmitter<TLEventMap> {
6535
6697
  }
6536
6698
  }
6537
6699
 
6538
- const parent = this.getShapeParent(shape)
6539
- const localDelta = parent
6540
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation)
6541
- : delta
6700
+ for (const shape of shapes) {
6701
+ const shapeDelta = delta.clone()
6542
6702
 
6543
- changes.push(this.getChangesToTranslateShape(shape, Vec.Add(shape, localDelta)))
6703
+ // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
6704
+ // todo: ensure that the parent isn't being aligned together with its children
6705
+ const parent = this.getShapeParent(shape)
6706
+ if (parent) {
6707
+ const parentTransform = this.getShapePageTransform(parent)
6708
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6709
+ }
6710
+
6711
+ shapeDelta.add(shape) // add the shape's x and y to the delta
6712
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6713
+ }
6544
6714
  })
6545
6715
 
6546
6716
  this.updateShapes(changes)
@@ -6562,65 +6732,137 @@ export class Editor extends EventEmitter<TLEventMap> {
6562
6732
  * @public
6563
6733
  */
6564
6734
  distributeShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
6735
+ if (this.getIsReadonly()) return this
6736
+
6565
6737
  const ids =
6566
6738
  typeof shapes[0] === 'string'
6567
6739
  ? (shapes as TLShapeId[])
6568
6740
  : (shapes as TLShape[]).map((s) => s.id)
6569
6741
 
6570
- if (this.getIsReadonly()) return this
6571
- if (ids.length < 3) return this
6742
+ // always fresh shapes
6743
+ const shapesToDistributeFirstPass = compact(ids.map((id) => this.getShape(id)))
6572
6744
 
6573
- const len = ids.length
6574
- const shapesToDistribute = compact(ids.map((id) => this.getShape(id))) // always fresh shapes
6575
- const pageBounds = Object.fromEntries(
6576
- shapesToDistribute.map((shape) => [shape.id, this.getShapePageBounds(shape)!])
6577
- )
6745
+ const shapeClustersToDistribute: {
6746
+ shapes: TLShape[]
6747
+ pageBounds: Box
6748
+ }[] = []
6749
+
6750
+ const allBounds: Box[] = []
6751
+ const visited = new Set<TLShapeId>()
6752
+
6753
+ for (const shape of shapesToDistributeFirstPass) {
6754
+ if (visited.has(shape.id)) continue
6755
+ visited.add(shape.id)
6756
+
6757
+ const shapePageBounds = this.getShapePageBounds(shape)
6758
+ if (!shapePageBounds) continue
6759
+
6760
+ if (
6761
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
6762
+ type: 'distribute',
6763
+ shapes: shapesToDistributeFirstPass,
6764
+ })
6765
+ ) {
6766
+ continue
6767
+ }
6768
+
6769
+ const shapesMovingTogether = [shape]
6770
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6771
+
6772
+ this.collectShapesViaArrowBindings({
6773
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6774
+ initialShapes: shapesToDistributeFirstPass,
6775
+ resultShapes: shapesMovingTogether,
6776
+ resultBounds: boundsOfShapesMovingTogether,
6777
+ visited,
6778
+ })
6779
+
6780
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6781
+ if (!commonPageBounds) continue
6782
+
6783
+ shapeClustersToDistribute.push({
6784
+ shapes: shapesMovingTogether,
6785
+ pageBounds: commonPageBounds,
6786
+ })
6787
+
6788
+ allBounds.push(commonPageBounds)
6789
+ }
6790
+
6791
+ if (shapeClustersToDistribute.length < 3) return this
6578
6792
 
6579
6793
  let val: 'x' | 'y'
6580
6794
  let min: 'minX' | 'minY'
6581
6795
  let max: 'maxX' | 'maxY'
6582
- let mid: 'midX' | 'midY'
6583
6796
  let dim: 'width' | 'height'
6584
6797
 
6585
6798
  if (operation === 'horizontal') {
6586
6799
  val = 'x'
6587
6800
  min = 'minX'
6588
6801
  max = 'maxX'
6589
- mid = 'midX'
6590
6802
  dim = 'width'
6591
6803
  } else {
6592
6804
  val = 'y'
6593
6805
  min = 'minY'
6594
6806
  max = 'maxY'
6595
- mid = 'midY'
6596
6807
  dim = 'height'
6597
6808
  }
6598
6809
  const changes: TLShapePartial[] = []
6599
6810
 
6600
- // Clustered
6601
- const first = shapesToDistribute.sort(
6602
- (a, b) => pageBounds[a.id][min] - pageBounds[b.id][min]
6603
- )[0]
6604
- const last = shapesToDistribute.sort((a, b) => pageBounds[b.id][max] - pageBounds[a.id][max])[0]
6811
+ const first = shapeClustersToDistribute.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])[0]
6812
+ const last = shapeClustersToDistribute.sort((a, b) => b.pageBounds[max] - a.pageBounds[max])[0]
6605
6813
 
6606
- const midFirst = pageBounds[first.id][mid]
6607
- const step = (pageBounds[last.id][mid] - midFirst) / (len - 1)
6608
- const v = midFirst + step
6814
+ // If the first shape group is also the last shape group, distribute without it
6815
+ if (first === last) {
6816
+ const excludedShapeIds = new Set(first.shapes.map((s) => s.id))
6817
+ return this.distributeShapes(
6818
+ ids.filter((id) => !excludedShapeIds.has(id)),
6819
+ operation
6820
+ )
6821
+ }
6609
6822
 
6610
- shapesToDistribute
6823
+ const shapeClustersToMove = shapeClustersToDistribute
6611
6824
  .filter((shape) => shape !== first && shape !== last)
6612
- .sort((a, b) => pageBounds[a.id][mid] - pageBounds[b.id][mid])
6613
- .forEach((shape, i) => {
6614
- const delta = { x: 0, y: 0 }
6615
- delta[val] = v + step * i - pageBounds[shape.id][dim] / 2 - pageBounds[shape.id][val]
6825
+ .sort((a, b) => {
6826
+ if (a.pageBounds[min] === b.pageBounds[min]) {
6827
+ return a.shapes[0].id < b.shapes[0].id ? -1 : 1
6828
+ }
6829
+ return a.pageBounds[min] - b.pageBounds[min]
6830
+ })
6831
+
6832
+ // 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.
6833
+ const maxFirst = first.pageBounds[max]
6834
+ const range = last.pageBounds[min] - maxFirst
6835
+ const summedShapeDimensions = shapeClustersToMove.reduce((acc, s) => acc + s.pageBounds[dim], 0)
6836
+ const gap = (range - summedShapeDimensions) / (shapeClustersToMove.length + 1)
6837
+
6838
+ for (let v = maxFirst + gap, i = 0; i < shapeClustersToMove.length; i++) {
6839
+ const { shapes, pageBounds } = shapeClustersToMove[i]
6840
+ const delta = new Vec()
6841
+ delta[val] = v - pageBounds[val]
6616
6842
 
6843
+ // If for some reason the new position would be more than the maximum, we need to adjust the delta
6844
+ // This will likely throw off some of the other placements but hey, it's better than changing the common bounds
6845
+ if (v + pageBounds[dim] > last.pageBounds[max] - 1) {
6846
+ delta[val] = last.pageBounds[max] - pageBounds[max] - 1
6847
+ }
6848
+
6849
+ for (const shape of shapes) {
6850
+ const shapeDelta = delta.clone()
6851
+
6852
+ // If the shape has another shape as its parent, and if the parent has a rotation, we need to rotate the counter-rotate delta
6853
+ // todo: ensure that the parent isn't being aligned together with its children
6617
6854
  const parent = this.getShapeParent(shape)
6618
- const localDelta = parent
6619
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.rotation())
6620
- : delta
6855
+ if (parent) {
6856
+ const parentTransform = this.getShapePageTransform(parent)
6857
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6858
+ }
6621
6859
 
6622
- changes.push(this.getChangesToTranslateShape(shape, Vec.Add(shape, localDelta)))
6623
- })
6860
+ shapeDelta.add(shape) // add the shape's x and y to the delta
6861
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6862
+ }
6863
+
6864
+ v += pageBounds[dim] + gap
6865
+ }
6624
6866
 
6625
6867
  this.updateShapes(changes)
6626
6868
  return this
@@ -6647,65 +6889,106 @@ export class Editor extends EventEmitter<TLEventMap> {
6647
6889
  : (shapes as TLShape[]).map((s) => s.id)
6648
6890
 
6649
6891
  if (this.getIsReadonly()) return this
6650
- if (ids.length < 2) return this
6651
-
6652
- const shapesToStretch = compact(ids.map((id) => this.getShape(id))) // always fresh shapes
6653
- const shapeBounds = Object.fromEntries(ids.map((id) => [id, this.getShapeGeometry(id).bounds]))
6654
- const shapePageBounds = Object.fromEntries(ids.map((id) => [id, this.getShapePageBounds(id)!]))
6655
- const commonBounds = Box.Common(compact(Object.values(shapePageBounds)))
6656
-
6657
- switch (operation) {
6658
- case 'vertical': {
6659
- this.run(() => {
6660
- for (const shape of shapesToStretch) {
6661
- const pageRotation = this.getShapePageTransform(shape)!.rotation()
6662
- if (pageRotation % PI2) continue
6663
- const bounds = shapeBounds[shape.id]
6664
- const pageBounds = shapePageBounds[shape.id]
6665
- const localOffset = new Vec(0, commonBounds.minY - pageBounds.minY)
6666
- const parentTransform = this.getShapeParentTransform(shape)
6667
- if (parentTransform) localOffset.rot(-parentTransform.rotation())
6668
-
6669
- const { x, y } = Vec.Add(localOffset, shape)
6670
- this.updateShapes([{ id: shape.id, type: shape.type, x, y }])
6671
- const scale = new Vec(1, commonBounds.height / pageBounds.height)
6672
- this.resizeShape(shape.id, scale, {
6673
- initialBounds: bounds,
6674
- scaleOrigin: new Vec(pageBounds.center.x, commonBounds.minY),
6675
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6676
- scaleAxisRotation: 0,
6677
- })
6678
- }
6892
+
6893
+ // always fresh shapes, skip anything that isn't rotated 90 deg
6894
+ const shapesToStretchFirstPass = compact(ids.map((id) => this.getShape(id))).filter(
6895
+ (s) => this.getShapePageTransform(s)?.rotation() % (PI / 2) === 0
6896
+ )
6897
+
6898
+ const shapeClustersToStretch: {
6899
+ shapes: TLShape[]
6900
+ pageBounds: Box
6901
+ }[] = []
6902
+
6903
+ const allBounds: Box[] = []
6904
+ const visited = new Set<TLShapeId>()
6905
+
6906
+ for (const shape of shapesToStretchFirstPass) {
6907
+ if (visited.has(shape.id)) continue
6908
+ visited.add(shape.id)
6909
+
6910
+ const shapePageBounds = this.getShapePageBounds(shape)
6911
+ if (!shapePageBounds) continue
6912
+
6913
+ const shapesMovingTogether = [shape]
6914
+ const boundsOfShapesMovingTogether: Box[] = [shapePageBounds]
6915
+
6916
+ if (
6917
+ !this.getShapeUtil(shape).canBeLaidOut?.(shape, {
6918
+ type: 'stretch',
6919
+ shapes: shapesToStretchFirstPass,
6679
6920
  })
6680
- break
6921
+ ) {
6922
+ continue
6681
6923
  }
6682
- case 'horizontal': {
6683
- this.run(() => {
6684
- for (const shape of shapesToStretch) {
6685
- const bounds = shapeBounds[shape.id]
6686
- const pageBounds = shapePageBounds[shape.id]
6687
- const pageRotation = this.getShapePageTransform(shape)!.rotation()
6688
- if (pageRotation % PI2) continue
6689
- const localOffset = new Vec(commonBounds.minX - pageBounds.minX, 0)
6690
- const parentTransform = this.getShapeParentTransform(shape)
6691
- if (parentTransform) localOffset.rot(-parentTransform.rotation())
6692
-
6693
- const { x, y } = Vec.Add(localOffset, shape)
6694
- this.updateShapes([{ id: shape.id, type: shape.type, x, y }])
6695
- const scale = new Vec(commonBounds.width / pageBounds.width, 1)
6696
- this.resizeShape(shape.id, scale, {
6697
- initialBounds: bounds,
6698
- scaleOrigin: new Vec(commonBounds.minX, pageBounds.center.y),
6699
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6700
- scaleAxisRotation: 0,
6701
- })
6702
- }
6703
- })
6704
6924
 
6705
- break
6706
- }
6925
+ this.collectShapesViaArrowBindings({
6926
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6927
+ initialShapes: shapesToStretchFirstPass,
6928
+ resultShapes: shapesMovingTogether,
6929
+ resultBounds: boundsOfShapesMovingTogether,
6930
+ visited,
6931
+ })
6932
+
6933
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6934
+ if (!commonPageBounds) continue
6935
+
6936
+ shapeClustersToStretch.push({
6937
+ shapes: shapesMovingTogether,
6938
+ pageBounds: commonPageBounds,
6939
+ })
6940
+
6941
+ allBounds.push(commonPageBounds)
6942
+ }
6943
+
6944
+ if (shapeClustersToStretch.length < 2) return this
6945
+
6946
+ const commonBounds = Box.Common(allBounds)
6947
+ let val: 'x' | 'y'
6948
+ let min: 'minX' | 'minY'
6949
+ let dim: 'width' | 'height'
6950
+
6951
+ if (operation === 'horizontal') {
6952
+ val = 'x'
6953
+ min = 'minX'
6954
+ dim = 'width'
6955
+ } else {
6956
+ val = 'y'
6957
+ min = 'minY'
6958
+ dim = 'height'
6707
6959
  }
6708
6960
 
6961
+ this.run(() => {
6962
+ shapeClustersToStretch.forEach(({ shapes, pageBounds }) => {
6963
+ const localOffset = new Vec()
6964
+ localOffset[val] = commonBounds[min] - pageBounds[min]
6965
+
6966
+ const scaleOrigin = pageBounds.center.clone()
6967
+ scaleOrigin[val] = commonBounds[min]
6968
+
6969
+ const scale = new Vec(1, 1)
6970
+ scale[val] = commonBounds[dim] / pageBounds[dim]
6971
+
6972
+ for (const shape of shapes) {
6973
+ // First translate
6974
+ const shapeLocalOffset = localOffset.clone()
6975
+ const parentTransform = this.getShapeParentTransform(shape)
6976
+ if (parentTransform) localOffset.rot(-parentTransform.rotation())
6977
+ shapeLocalOffset.add(shape)
6978
+ const changes = this.getChangesToTranslateShape(shape, shapeLocalOffset)
6979
+ this.updateShape(changes)
6980
+
6981
+ // Then resize
6982
+ this.resizeShape(shape.id, scale, {
6983
+ initialBounds: this.getShapeGeometry(shape).bounds,
6984
+ scaleOrigin,
6985
+ isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6986
+ scaleAxisRotation: 0,
6987
+ })
6988
+ }
6989
+ })
6990
+ })
6991
+
6709
6992
  return this
6710
6993
  }
6711
6994
 
@@ -9973,7 +10256,7 @@ function withIsolatedShapes<T>(
9973
10256
  }
9974
10257
  })
9975
10258
 
9976
- editor.store.applyDiff(reverseRecordsDiff(changes))
10259
+ editor.store.applyDiff(reverseRecordsDiff(changes), { runCallbacks: false })
9977
10260
  },
9978
10261
  { history: 'ignore' }
9979
10262
  )