@tldraw/editor 3.9.0-canary.d799df28e99e → 3.9.0-canary.d81de7fd0bea

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.
@@ -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'
@@ -5690,14 +5691,15 @@ export class Editor extends EventEmitter<TLEventMap> {
5690
5691
  return this
5691
5692
  }
5692
5693
 
5694
+ // Gets a shape partial that includes life cycle changes: on translate start, on translate, on translate end
5693
5695
  private getChangesToTranslateShape(initialShape: TLShape, newShapeCoords: VecLike): TLShape {
5694
5696
  let workingShape = initialShape
5695
5697
  const util = this.getShapeUtil(initialShape)
5696
5698
 
5697
- workingShape = applyPartialToRecordWithProps(
5698
- workingShape,
5699
- util.onTranslateStart?.(workingShape) ?? undefined
5700
- )
5699
+ const afterTranslateStart = util.onTranslateStart?.(workingShape)
5700
+ if (afterTranslateStart) {
5701
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslateStart)
5702
+ }
5701
5703
 
5702
5704
  workingShape = applyPartialToRecordWithProps(workingShape, {
5703
5705
  id: initialShape.id,
@@ -5706,15 +5708,15 @@ export class Editor extends EventEmitter<TLEventMap> {
5706
5708
  y: newShapeCoords.y,
5707
5709
  })
5708
5710
 
5709
- workingShape = applyPartialToRecordWithProps(
5710
- workingShape,
5711
- util.onTranslate?.(initialShape, workingShape) ?? undefined
5712
- )
5711
+ const afterTranslate = util.onTranslate?.(initialShape, workingShape)
5712
+ if (afterTranslate) {
5713
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslate)
5714
+ }
5713
5715
 
5714
- workingShape = applyPartialToRecordWithProps(
5715
- workingShape,
5716
- util.onTranslateEnd?.(initialShape, workingShape) ?? undefined
5717
- )
5716
+ const afterTranslateEnd = util.onTranslateEnd?.(initialShape, workingShape)
5717
+ if (afterTranslateEnd) {
5718
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslateEnd)
5719
+ }
5718
5720
 
5719
5721
  return workingShape
5720
5722
  }
@@ -6113,6 +6115,37 @@ export class Editor extends EventEmitter<TLEventMap> {
6113
6115
  return this
6114
6116
  }
6115
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
+
6116
6149
  /**
6117
6150
  * Flip shape positions.
6118
6151
  *
@@ -6128,47 +6161,74 @@ export class Editor extends EventEmitter<TLEventMap> {
6128
6161
  * @public
6129
6162
  */
6130
6163
  flipShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
6164
+ if (this.getIsReadonly()) return this
6165
+
6131
6166
  const ids =
6132
6167
  typeof shapes[0] === 'string'
6133
6168
  ? (shapes as TLShapeId[])
6134
6169
  : (shapes as TLShape[]).map((s) => s.id)
6135
6170
 
6136
- if (this.getIsReadonly()) return this
6171
+ // Collect a greedy list of shapes to flip
6172
+ const shapesToFlipFirstPass = compact(ids.map((id) => this.getShape(id)))
6137
6173
 
6138
- 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
+ }
6139
6182
 
6140
- 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
+ }[] = []
6141
6190
 
6142
- shapesToFlip = compact(
6143
- shapesToFlip
6144
- .map((shape) => {
6145
- if (this.isShapeOfType<TLGroupShape>(shape, 'group')) {
6146
- return this.getSortedChildIdsForParent(shape.id).map((id) => this.getShape(id))
6147
- }
6191
+ const allBounds: Box[] = []
6148
6192
 
6149
- 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,
6150
6199
  })
6151
- .flat()
6152
- )
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
6153
6218
 
6154
- const scaleOriginPage = Box.Common(
6155
- compact(shapesToFlip.map((id) => this.getShapePageBounds(id)))
6156
- ).center
6219
+ const scaleOriginPage = Box.Common(allBounds).center
6157
6220
 
6158
6221
  this.run(() => {
6159
- for (const shape of shapesToFlip) {
6160
- const bounds = this.getShapeGeometry(shape).bounds
6161
- const initialPageTransform = this.getShapePageTransform(shape.id)
6162
- if (!initialPageTransform) continue
6222
+ for (const { shape, localBounds, pageTransform, isAspectRatioLocked } of shapesToFlip) {
6163
6223
  this.resizeShape(
6164
6224
  shape.id,
6165
6225
  { x: operation === 'horizontal' ? -1 : 1, y: operation === 'vertical' ? -1 : 1 },
6166
6226
  {
6167
- initialBounds: bounds,
6168
- initialPageTransform,
6227
+ initialBounds: localBounds,
6228
+ initialPageTransform: pageTransform,
6169
6229
  initialShape: shape,
6230
+ isAspectRatioLocked,
6170
6231
  mode: 'scale_shape',
6171
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6172
6232
  scaleOrigin: scaleOriginPage,
6173
6233
  scaleAxisRotation: 0,
6174
6234
  }
@@ -6205,21 +6265,58 @@ export class Editor extends EventEmitter<TLEventMap> {
6205
6265
  : (shapes as TLShape[]).map((s) => s.id)
6206
6266
  if (this.getIsReadonly()) return this
6207
6267
 
6208
- const shapesToStack = ids
6209
- .map((id) => this.getShape(id)) // always fresh shapes
6210
- .filter((shape): shape is TLShape => {
6211
- 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>()
6212
6279
 
6213
- return this.getShapeUtil(shape).canBeLaidOut(shape)
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]
6298
+
6299
+ this.collectShapesViaArrowBindings({
6300
+ bindings: this.getBindingsToShape(shape.id, 'arrow'),
6301
+ initialShapes: shapesToStackFirstPass,
6302
+ resultShapes: shapesMovingTogether,
6303
+ resultBounds: boundsOfShapesMovingTogether,
6304
+ visited,
6214
6305
  })
6215
6306
 
6216
- const len = shapesToStack.length
6307
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6308
+ if (!commonPageBounds) continue
6217
6309
 
6218
- if ((gap === 0 && len < 3) || len < 2) return this
6310
+ shapeClustersToStack.push({
6311
+ shapes: shapesMovingTogether,
6312
+ pageBounds: commonPageBounds,
6313
+ })
6219
6314
 
6220
- const pageBounds = Object.fromEntries(
6221
- shapesToStack.map((shape) => [shape.id, this.getShapePageBounds(shape)!])
6222
- )
6315
+ allBounds.push(commonPageBounds)
6316
+ }
6317
+
6318
+ const len = shapeClustersToStack.length
6319
+ if ((gap === 0 && len < 3) || len < 2) return this
6223
6320
 
6224
6321
  let val: 'x' | 'y'
6225
6322
  let min: 'minX' | 'minY'
@@ -6238,46 +6335,45 @@ export class Editor extends EventEmitter<TLEventMap> {
6238
6335
  dim = 'height'
6239
6336
  }
6240
6337
 
6241
- let shapeGap: number
6338
+ let shapeGap: number = 0
6242
6339
 
6243
6340
  if (gap === 0) {
6244
- const gaps: { gap: number; count: number }[] = []
6341
+ // note: this is not used in the current tldraw.com; there we use a specified stack
6245
6342
 
6246
- shapesToStack.sort((a, b) => pageBounds[a.id][min] - pageBounds[b.id][min])
6343
+ const gaps: Record<number, number> = {}
6344
+
6345
+ shapeClustersToStack.sort((a, b) => a.pageBounds[min] - b.pageBounds[min])
6247
6346
 
6248
6347
  // Collect all of the gaps between shapes. We want to find
6249
6348
  // patterns (equal gaps between shapes) and use the most common
6250
6349
  // one as the gap for all of the shapes.
6251
6350
  for (let i = 0; i < len - 1; i++) {
6252
- const shape = shapesToStack[i]
6253
- const nextShape = shapesToStack[i + 1]
6254
-
6255
- const bounds = pageBounds[shape.id]
6256
- const nextBounds = pageBounds[nextShape.id]
6257
-
6258
- const gap = nextBounds[min] - bounds[max]
6259
-
6260
- const current = gaps.find((g) => g.gap === gap)
6261
-
6262
- if (current) {
6263
- current.count++
6264
- } else {
6265
- 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
6266
6356
  }
6357
+ gaps[gap]++
6267
6358
  }
6268
6359
 
6269
6360
  // Which gap is the most common?
6270
- let maxCount = 0
6271
- gaps.forEach((g) => {
6272
- if (g.count > maxCount) {
6273
- maxCount = g.count
6274
- 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)
6275
6366
  }
6276
- })
6367
+ }
6277
6368
 
6278
6369
  // If there is no most-common gap, use the average gap.
6279
6370
  if (maxCount === 1) {
6280
- 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
6281
6377
  }
6282
6378
  } else {
6283
6379
  // If a gap was provided, then use that instead.
@@ -6286,36 +6382,30 @@ export class Editor extends EventEmitter<TLEventMap> {
6286
6382
 
6287
6383
  const changes: TLShapePartial[] = []
6288
6384
 
6289
- let v = pageBounds[shapesToStack[0].id][max]
6385
+ let v = shapeClustersToStack[0].pageBounds[max]
6290
6386
 
6291
- shapesToStack.forEach((shape, i) => {
6292
- 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]
6293
6391
 
6294
- const delta = { x: 0, y: 0 }
6295
- delta[val] = v + shapeGap - pageBounds[shape.id][val]
6296
-
6297
- const parent = this.getShapeParent(shape)
6298
- const localDelta = parent
6299
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation)
6300
- : delta
6392
+ for (const shape of shapes) {
6393
+ const shapeDelta = delta.clone()
6301
6394
 
6302
- 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
+ }
6303
6402
 
6304
- changes.push(
6305
- translateStartChanges
6306
- ? {
6307
- ...translateStartChanges,
6308
- [val]: shape[val] + localDelta[val],
6309
- }
6310
- : {
6311
- id: shape.id as any,
6312
- type: shape.type,
6313
- [val]: shape[val] + localDelta[val],
6314
- }
6315
- )
6403
+ shapeDelta.add(shape) // add the shape's x and y to the delta
6404
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6405
+ }
6316
6406
 
6317
- v += pageBounds[shape.id][dim] + shapeGap
6318
- })
6407
+ v += pageBounds[dim] + shapeGap
6408
+ }
6319
6409
 
6320
6410
  this.updateShapes(changes)
6321
6411
  return this
@@ -6335,42 +6425,79 @@ export class Editor extends EventEmitter<TLEventMap> {
6335
6425
  * @param gap - The padding to apply to the packed shapes. Defaults to 16.
6336
6426
  */
6337
6427
  packShapes(shapes: TLShapeId[] | TLShape[], gap: number): this {
6428
+ if (this.getIsReadonly()) return this
6429
+
6338
6430
  const ids =
6339
6431
  typeof shapes[0] === 'string'
6340
6432
  ? (shapes as TLShapeId[])
6341
6433
  : (shapes as TLShape[]).map((s) => s.id)
6342
6434
 
6343
- if (this.getIsReadonly()) return this
6344
- if (ids.length < 2) return this
6435
+ // Always fresh shapes
6436
+ const shapesToPackFirstPass = compact(ids.map((id) => this.getShape(id)))
6437
+
6438
+ const shapeClustersToPack: {
6439
+ shapes: TLShape[]
6440
+ pageBounds: Box
6441
+ nextPageBounds: Box
6442
+ }[] = []
6443
+
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)
6345
6450
 
6346
- const shapesToPack = ids
6347
- .map((id) => this.getShape(id)) // always fresh shapes
6348
- .filter((shape): shape is TLShape => {
6349
- if (!shape) return false
6451
+ const shapePageBounds = this.getShapePageBounds(shape)
6452
+ if (!shapePageBounds) continue
6350
6453
 
6351
- return this.getShapeUtil(shape).canBeLaidOut(shape)
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,
6352
6472
  })
6353
- const shapePageBounds: Record<string, Box> = {}
6354
- const nextShapePageBounds: Record<string, Box> = {}
6355
6473
 
6356
- let shape: TLShape,
6357
- bounds: Box,
6358
- area = 0
6474
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6475
+ if (!commonPageBounds) continue
6359
6476
 
6360
- for (let i = 0; i < shapesToPack.length; i++) {
6361
- shape = shapesToPack[i]
6362
- bounds = this.getShapePageBounds(shape)!
6363
- shapePageBounds[shape.id] = bounds
6364
- nextShapePageBounds[shape.id] = bounds.clone()
6365
- area += bounds.width * bounds.height
6477
+ shapeClustersToPack.push({
6478
+ shapes: shapesMovingTogether,
6479
+ pageBounds: commonPageBounds,
6480
+ nextPageBounds: commonPageBounds.clone(),
6481
+ })
6482
+
6483
+ allBounds.push(commonPageBounds)
6366
6484
  }
6367
6485
 
6368
- const commonBounds = Box.Common(compact(Object.values(shapePageBounds)))
6486
+ if (shapeClustersToPack.length < 2) return this
6487
+
6488
+ let area = 0
6489
+ for (const { pageBounds } of shapeClustersToPack) {
6490
+ area += pageBounds.width * pageBounds.height
6491
+ }
6492
+
6493
+ const commonBounds = Box.Common(allBounds)
6369
6494
 
6370
6495
  const maxWidth = commonBounds.width
6371
6496
 
6372
- // sort the shapes by height, descending
6373
- 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)
6374
6501
 
6375
6502
  // Start with is (sort of) the square of the area
6376
6503
  const startWidth = Math.max(Math.ceil(Math.sqrt(area / 0.95)), maxWidth)
@@ -6383,85 +6510,69 @@ export class Editor extends EventEmitter<TLEventMap> {
6383
6510
  let space: Box
6384
6511
  let last: Box
6385
6512
 
6386
- for (let i = 0; i < shapesToPack.length; i++) {
6387
- shape = shapesToPack[i]
6388
- bounds = nextShapePageBounds[shape.id]
6389
-
6513
+ for (const { nextPageBounds } of shapeClustersToPack) {
6390
6514
  // starting at the back (smaller shapes)
6391
6515
  for (let i = spaces.length - 1; i >= 0; i--) {
6392
6516
  space = spaces[i]
6393
6517
 
6394
6518
  // find a space that is big enough to contain the shape
6395
- if (bounds.width > space.width || bounds.height > space.height) continue
6519
+ if (nextPageBounds.width > space.width || nextPageBounds.height > space.height) continue
6396
6520
 
6397
6521
  // add the shape to its top-left corner
6398
- bounds.x = space.x
6399
- bounds.y = space.y
6522
+ nextPageBounds.x = space.x
6523
+ nextPageBounds.y = space.y
6400
6524
 
6401
- height = Math.max(height, bounds.maxY)
6402
- width = Math.max(width, bounds.maxX)
6525
+ height = Math.max(height, nextPageBounds.maxY)
6526
+ width = Math.max(width, nextPageBounds.maxX)
6403
6527
 
6404
- if (bounds.width === space.width && bounds.height === space.height) {
6528
+ if (nextPageBounds.width === space.width && nextPageBounds.height === space.height) {
6405
6529
  // remove the space on a perfect fit
6406
6530
  last = spaces.pop()!
6407
6531
  if (i < spaces.length) spaces[i] = last
6408
- } else if (bounds.height === space.height) {
6532
+ } else if (nextPageBounds.height === space.height) {
6409
6533
  // fit the shape into the space (width)
6410
- space.x += bounds.width + gap
6411
- space.width -= bounds.width + gap
6412
- } 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) {
6413
6537
  // fit the shape into the space (height)
6414
- space.y += bounds.height + gap
6415
- space.height -= bounds.height + gap
6538
+ space.y += nextPageBounds.height + gap
6539
+ space.height -= nextPageBounds.height + gap
6416
6540
  } else {
6417
6541
  // split the space into two spaces
6418
6542
  spaces.push(
6419
6543
  new Box(
6420
- space.x + (bounds.width + gap),
6544
+ space.x + (nextPageBounds.width + gap),
6421
6545
  space.y,
6422
- space.width - (bounds.width + gap),
6423
- bounds.height
6546
+ space.width - (nextPageBounds.width + gap),
6547
+ nextPageBounds.height
6424
6548
  )
6425
6549
  )
6426
- space.y += bounds.height + gap
6427
- space.height -= bounds.height + gap
6550
+ space.y += nextPageBounds.height + gap
6551
+ space.height -= nextPageBounds.height + gap
6428
6552
  }
6429
6553
  break
6430
6554
  }
6431
6555
  }
6432
6556
 
6433
- const commonAfter = Box.Common(Object.values(nextShapePageBounds))
6557
+ const commonAfter = Box.Common(shapeClustersToPack.map((s) => s.nextPageBounds))
6434
6558
  const centerDelta = Vec.Sub(commonBounds.center, commonAfter.center)
6435
6559
 
6436
- let nextBounds: Box
6437
-
6438
6560
  const changes: TLShapePartial<any>[] = []
6439
6561
 
6440
- for (let i = 0; i < shapesToPack.length; i++) {
6441
- shape = shapesToPack[i]
6442
- bounds = shapePageBounds[shape.id]
6443
- nextBounds = nextShapePageBounds[shape.id]
6562
+ for (const { shapes, pageBounds, nextPageBounds } of shapeClustersToPack) {
6563
+ const delta = Vec.Sub(nextPageBounds.point, pageBounds.point).add(centerDelta)
6444
6564
 
6445
- const delta = Vec.Sub(nextBounds.point, bounds.point).add(centerDelta)
6446
- const parentTransform = this.getShapeParentTransform(shape)
6447
- if (parentTransform) delta.rot(-parentTransform.rotation())
6448
-
6449
- const change: TLShapePartial = {
6450
- id: shape.id,
6451
- type: shape.type,
6452
- x: shape.x + delta.x,
6453
- y: shape.y + delta.y,
6454
- }
6565
+ for (const shape of shapes) {
6566
+ const shapeDelta = delta.clone()
6455
6567
 
6456
- const translateStartChange = this.getShapeUtil(shape).onTranslateStart?.({
6457
- ...shape,
6458
- ...change,
6459
- })
6568
+ const parent = this.getShapeParent(shape)
6569
+ if (parent) {
6570
+ const parentTransform = this.getShapeParentTransform(shape)
6571
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6572
+ }
6460
6573
 
6461
- if (translateStartChange) {
6462
- changes.push({ ...change, ...translateStartChange })
6463
- } else {
6464
- changes.push(change)
6574
+ shapeDelta.add(shape)
6575
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6465
6576
  }
6466
6577
  }
6467
6578
 
@@ -6486,32 +6597,78 @@ export class Editor extends EventEmitter<TLEventMap> {
6486
6597
  *
6487
6598
  * @public
6488
6599
  */
6489
-
6490
6600
  alignShapes(
6491
6601
  shapes: TLShapeId[] | TLShape[],
6492
6602
  operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom'
6493
6603
  ): this {
6604
+ if (this.getIsReadonly()) return this
6605
+
6494
6606
  const ids =
6495
6607
  typeof shapes[0] === 'string'
6496
6608
  ? (shapes as TLShapeId[])
6497
6609
  : (shapes as TLShape[]).map((s) => s.id)
6498
6610
 
6499
- if (this.getIsReadonly()) return this
6500
- if (ids.length < 2) return this
6611
+ // Always get fresh shapes
6612
+ const shapesToAlignFirstPass = compact(ids.map((id) => this.getShape(id)))
6501
6613
 
6502
- const shapesToAlign = compact(ids.map((id) => this.getShape(id))) // always fresh shapes
6503
- const shapePageBounds = Object.fromEntries(
6504
- shapesToAlign.map((shape) => [shape.id, this.getShapePageBounds(shape)])
6505
- )
6506
- 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>()
6507
6620
 
6508
- const changes: TLShapePartial[] = []
6621
+ for (const shape of shapesToAlignFirstPass) {
6622
+ if (visited.has(shape.id)) continue
6623
+ visited.add(shape.id)
6509
6624
 
6510
- shapesToAlign.forEach((shape) => {
6511
- const pageBounds = shapePageBounds[shape.id]
6512
- 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
+ })
6652
+
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[] = []
6513
6669
 
6514
- const delta = { x: 0, y: 0 }
6670
+ shapeClustersToAlign.forEach(({ shapes, pageBounds }) => {
6671
+ const delta = new Vec()
6515
6672
 
6516
6673
  switch (operation) {
6517
6674
  case 'top': {
@@ -6540,12 +6697,20 @@ export class Editor extends EventEmitter<TLEventMap> {
6540
6697
  }
6541
6698
  }
6542
6699
 
6543
- const parent = this.getShapeParent(shape)
6544
- const localDelta = parent
6545
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation)
6546
- : delta
6700
+ for (const shape of shapes) {
6701
+ const shapeDelta = delta.clone()
6702
+
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
+ }
6547
6710
 
6548
- changes.push(this.getChangesToTranslateShape(shape, Vec.Add(shape, localDelta)))
6711
+ shapeDelta.add(shape) // add the shape's x and y to the delta
6712
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6713
+ }
6549
6714
  })
6550
6715
 
6551
6716
  this.updateShapes(changes)
@@ -6567,65 +6732,137 @@ export class Editor extends EventEmitter<TLEventMap> {
6567
6732
  * @public
6568
6733
  */
6569
6734
  distributeShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
6735
+ if (this.getIsReadonly()) return this
6736
+
6570
6737
  const ids =
6571
6738
  typeof shapes[0] === 'string'
6572
6739
  ? (shapes as TLShapeId[])
6573
6740
  : (shapes as TLShape[]).map((s) => s.id)
6574
6741
 
6575
- if (this.getIsReadonly()) return this
6576
- if (ids.length < 3) return this
6742
+ // always fresh shapes
6743
+ const shapesToDistributeFirstPass = compact(ids.map((id) => this.getShape(id)))
6577
6744
 
6578
- const len = ids.length
6579
- const shapesToDistribute = compact(ids.map((id) => this.getShape(id))) // always fresh shapes
6580
- const pageBounds = Object.fromEntries(
6581
- shapesToDistribute.map((shape) => [shape.id, this.getShapePageBounds(shape)!])
6582
- )
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
6583
6792
 
6584
6793
  let val: 'x' | 'y'
6585
6794
  let min: 'minX' | 'minY'
6586
6795
  let max: 'maxX' | 'maxY'
6587
- let mid: 'midX' | 'midY'
6588
6796
  let dim: 'width' | 'height'
6589
6797
 
6590
6798
  if (operation === 'horizontal') {
6591
6799
  val = 'x'
6592
6800
  min = 'minX'
6593
6801
  max = 'maxX'
6594
- mid = 'midX'
6595
6802
  dim = 'width'
6596
6803
  } else {
6597
6804
  val = 'y'
6598
6805
  min = 'minY'
6599
6806
  max = 'maxY'
6600
- mid = 'midY'
6601
6807
  dim = 'height'
6602
6808
  }
6603
6809
  const changes: TLShapePartial[] = []
6604
6810
 
6605
- // Clustered
6606
- const first = shapesToDistribute.sort(
6607
- (a, b) => pageBounds[a.id][min] - pageBounds[b.id][min]
6608
- )[0]
6609
- 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]
6610
6813
 
6611
- const midFirst = pageBounds[first.id][mid]
6612
- const step = (pageBounds[last.id][mid] - midFirst) / (len - 1)
6613
- 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
+ }
6614
6822
 
6615
- shapesToDistribute
6823
+ const shapeClustersToMove = shapeClustersToDistribute
6616
6824
  .filter((shape) => shape !== first && shape !== last)
6617
- .sort((a, b) => pageBounds[a.id][mid] - pageBounds[b.id][mid])
6618
- .forEach((shape, i) => {
6619
- const delta = { x: 0, y: 0 }
6620
- 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]
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()
6621
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
6622
6854
  const parent = this.getShapeParent(shape)
6623
- const localDelta = parent
6624
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.rotation())
6625
- : delta
6855
+ if (parent) {
6856
+ const parentTransform = this.getShapePageTransform(parent)
6857
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6858
+ }
6626
6859
 
6627
- changes.push(this.getChangesToTranslateShape(shape, Vec.Add(shape, localDelta)))
6628
- })
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
+ }
6629
6866
 
6630
6867
  this.updateShapes(changes)
6631
6868
  return this
@@ -6652,65 +6889,106 @@ export class Editor extends EventEmitter<TLEventMap> {
6652
6889
  : (shapes as TLShape[]).map((s) => s.id)
6653
6890
 
6654
6891
  if (this.getIsReadonly()) return this
6655
- if (ids.length < 2) return this
6656
-
6657
- const shapesToStretch = compact(ids.map((id) => this.getShape(id))) // always fresh shapes
6658
- const shapeBounds = Object.fromEntries(ids.map((id) => [id, this.getShapeGeometry(id).bounds]))
6659
- const shapePageBounds = Object.fromEntries(ids.map((id) => [id, this.getShapePageBounds(id)!]))
6660
- const commonBounds = Box.Common(compact(Object.values(shapePageBounds)))
6661
-
6662
- switch (operation) {
6663
- case 'vertical': {
6664
- this.run(() => {
6665
- for (const shape of shapesToStretch) {
6666
- const pageRotation = this.getShapePageTransform(shape)!.rotation()
6667
- if (pageRotation % PI2) continue
6668
- const bounds = shapeBounds[shape.id]
6669
- const pageBounds = shapePageBounds[shape.id]
6670
- const localOffset = new Vec(0, commonBounds.minY - pageBounds.minY)
6671
- const parentTransform = this.getShapeParentTransform(shape)
6672
- if (parentTransform) localOffset.rot(-parentTransform.rotation())
6673
-
6674
- const { x, y } = Vec.Add(localOffset, shape)
6675
- this.updateShapes([{ id: shape.id, type: shape.type, x, y }])
6676
- const scale = new Vec(1, commonBounds.height / pageBounds.height)
6677
- this.resizeShape(shape.id, scale, {
6678
- initialBounds: bounds,
6679
- scaleOrigin: new Vec(pageBounds.center.x, commonBounds.minY),
6680
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6681
- scaleAxisRotation: 0,
6682
- })
6683
- }
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,
6684
6920
  })
6685
- break
6921
+ ) {
6922
+ continue
6686
6923
  }
6687
- case 'horizontal': {
6688
- this.run(() => {
6689
- for (const shape of shapesToStretch) {
6690
- const bounds = shapeBounds[shape.id]
6691
- const pageBounds = shapePageBounds[shape.id]
6692
- const pageRotation = this.getShapePageTransform(shape)!.rotation()
6693
- if (pageRotation % PI2) continue
6694
- const localOffset = new Vec(commonBounds.minX - pageBounds.minX, 0)
6695
- const parentTransform = this.getShapeParentTransform(shape)
6696
- if (parentTransform) localOffset.rot(-parentTransform.rotation())
6697
-
6698
- const { x, y } = Vec.Add(localOffset, shape)
6699
- this.updateShapes([{ id: shape.id, type: shape.type, x, y }])
6700
- const scale = new Vec(commonBounds.width / pageBounds.width, 1)
6701
- this.resizeShape(shape.id, scale, {
6702
- initialBounds: bounds,
6703
- scaleOrigin: new Vec(commonBounds.minX, pageBounds.center.y),
6704
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6705
- scaleAxisRotation: 0,
6706
- })
6707
- }
6708
- })
6709
6924
 
6710
- break
6711
- }
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)
6712
6942
  }
6713
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'
6959
+ }
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
+
6714
6992
  return this
6715
6993
  }
6716
6994