@tldraw/editor 3.9.0-internal.7f0e15f4f7d9 → 3.9.0

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 (118) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +1 -1
  3. package/dist-cjs/index.d.ts +36 -229
  4. package/dist-cjs/index.js +1 -9
  5. package/dist-cjs/index.js.map +2 -2
  6. package/dist-cjs/lib/TldrawEditor.js +6 -33
  7. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  8. package/dist-cjs/lib/components/Shape.js +0 -7
  9. package/dist-cjs/lib/components/Shape.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
  11. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
  12. package/dist-cjs/lib/editor/Editor.js +435 -308
  13. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  14. package/dist-cjs/lib/editor/managers/TextManager.js +17 -23
  15. package/dist-cjs/lib/editor/managers/TextManager.js.map +2 -2
  16. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +7 -13
  17. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  18. package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
  19. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  20. package/dist-cjs/lib/exports/FontEmbedder.js +2 -7
  21. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  22. package/dist-cjs/lib/exports/StyleEmbedder.js +1 -1
  23. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  24. package/dist-cjs/lib/exports/exportToSvg.js +2 -3
  25. package/dist-cjs/lib/exports/exportToSvg.js.map +2 -2
  26. package/dist-cjs/lib/exports/getSvgJsx.js +1 -18
  27. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  28. package/dist-cjs/lib/exports/parseCss.js +0 -1
  29. package/dist-cjs/lib/exports/parseCss.js.map +2 -2
  30. package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -2
  31. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  32. package/dist-cjs/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.js +1 -1
  33. package/dist-cjs/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.js.map +2 -2
  34. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  35. package/dist-cjs/lib/options.js +1 -2
  36. package/dist-cjs/lib/options.js.map +2 -2
  37. package/dist-cjs/lib/utils/dom.js +1 -1
  38. package/dist-cjs/lib/utils/dom.js.map +2 -2
  39. package/dist-cjs/version.js +3 -3
  40. package/dist-cjs/version.js.map +1 -1
  41. package/dist-esm/index.d.mts +36 -229
  42. package/dist-esm/index.mjs +1 -13
  43. package/dist-esm/index.mjs.map +2 -2
  44. package/dist-esm/lib/TldrawEditor.mjs +7 -34
  45. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  46. package/dist-esm/lib/components/Shape.mjs +1 -8
  47. package/dist-esm/lib/components/Shape.mjs.map +2 -2
  48. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
  49. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
  50. package/dist-esm/lib/editor/Editor.mjs +432 -312
  51. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  52. package/dist-esm/lib/editor/managers/TextManager.mjs +17 -23
  53. package/dist-esm/lib/editor/managers/TextManager.mjs.map +2 -2
  54. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +7 -13
  55. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  56. package/dist-esm/lib/exports/FontEmbedder.mjs +2 -7
  57. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  58. package/dist-esm/lib/exports/StyleEmbedder.mjs +1 -1
  59. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  60. package/dist-esm/lib/exports/exportToSvg.mjs +2 -3
  61. package/dist-esm/lib/exports/exportToSvg.mjs.map +2 -2
  62. package/dist-esm/lib/exports/getSvgJsx.mjs +2 -19
  63. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  64. package/dist-esm/lib/exports/parseCss.mjs +0 -1
  65. package/dist-esm/lib/exports/parseCss.mjs.map +2 -2
  66. package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -2
  67. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  68. package/dist-esm/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.mjs +1 -1
  69. package/dist-esm/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.mjs.map +2 -2
  70. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  71. package/dist-esm/lib/options.mjs +1 -2
  72. package/dist-esm/lib/options.mjs.map +2 -2
  73. package/dist-esm/lib/utils/dom.mjs +1 -1
  74. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  75. package/dist-esm/version.mjs +3 -3
  76. package/dist-esm/version.mjs.map +1 -1
  77. package/editor.css +13 -127
  78. package/package.json +7 -10
  79. package/src/index.ts +2 -15
  80. package/src/lib/TldrawEditor.tsx +4 -52
  81. package/src/lib/components/Shape.tsx +1 -9
  82. package/src/lib/components/default-components/DefaultErrorFallback.tsx +5 -3
  83. package/src/lib/editor/Editor.ts +561 -362
  84. package/src/lib/editor/managers/TextManager.ts +17 -42
  85. package/src/lib/editor/shapes/ShapeUtil.ts +32 -18
  86. package/src/lib/editor/types/emit-types.ts +0 -1
  87. package/src/lib/editor/types/external-content.ts +0 -1
  88. package/src/lib/exports/FontEmbedder.ts +1 -13
  89. package/src/lib/exports/StyleEmbedder.ts +1 -1
  90. package/src/lib/exports/exportToSvg.tsx +3 -4
  91. package/src/lib/exports/getSvgJsx.tsx +3 -22
  92. package/src/lib/exports/parseCss.ts +0 -1
  93. package/src/lib/hooks/useCanvasEvents.ts +1 -2
  94. package/src/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.ts +0 -1
  95. package/src/lib/hooks/usePassThroughWheelEvents.ts +1 -0
  96. package/src/lib/options.ts +0 -7
  97. package/src/lib/utils/dom.ts +1 -1
  98. package/src/version.ts +3 -3
  99. package/dist-cjs/lib/editor/managers/FontManager.js +0 -167
  100. package/dist-cjs/lib/editor/managers/FontManager.js.map +0 -7
  101. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js +0 -48
  102. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js.map +0 -7
  103. package/dist-cjs/lib/hooks/useViewportHeight.js +0 -56
  104. package/dist-cjs/lib/hooks/useViewportHeight.js.map +0 -7
  105. package/dist-cjs/lib/utils/richText.js +0 -46
  106. package/dist-cjs/lib/utils/richText.js.map +0 -7
  107. package/dist-esm/lib/editor/managers/FontManager.mjs +0 -153
  108. package/dist-esm/lib/editor/managers/FontManager.mjs.map +0 -7
  109. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs +0 -28
  110. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs.map +0 -7
  111. package/dist-esm/lib/hooks/useViewportHeight.mjs +0 -36
  112. package/dist-esm/lib/hooks/useViewportHeight.mjs.map +0 -7
  113. package/dist-esm/lib/utils/richText.mjs +0 -26
  114. package/dist-esm/lib/utils/richText.mjs.map +0 -7
  115. package/src/lib/editor/managers/FontManager.ts +0 -252
  116. package/src/lib/hooks/usePassThroughMouseOverEvents.ts +0 -29
  117. package/src/lib/hooks/useViewportHeight.ts +0 -37
  118. package/src/lib/utils/richText.ts +0 -72
@@ -1,12 +1,4 @@
1
- import {
2
- Atom,
3
- EMPTY_ARRAY,
4
- atom,
5
- computed,
6
- react,
7
- transact,
8
- unsafe__withoutCapture,
9
- } from '@tldraw/state'
1
+ import { EMPTY_ARRAY, atom, computed, react, transact, unsafe__withoutCapture } from '@tldraw/state'
10
2
  import {
11
3
  ComputedCache,
12
4
  RecordType,
@@ -42,7 +34,6 @@ import {
42
34
  TLImageAsset,
43
35
  TLInstance,
44
36
  TLInstancePageState,
45
- TLNoteShape,
46
37
  TLPOINTER_ID,
47
38
  TLPage,
48
39
  TLPageId,
@@ -92,6 +83,7 @@ import {
92
83
  structuredClone,
93
84
  uniqueId,
94
85
  } from '@tldraw/utils'
86
+ import { Number } from 'core-js'
95
87
  import EventEmitter from 'eventemitter3'
96
88
  import {
97
89
  TLEditorSnapshot,
@@ -125,7 +117,7 @@ import { EASINGS } from '../primitives/easings'
125
117
  import { Geometry2d } from '../primitives/geometry/Geometry2d'
126
118
  import { Group2d } from '../primitives/geometry/Group2d'
127
119
  import { intersectPolygonPolygon } from '../primitives/intersect'
128
- import { PI2, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils'
120
+ import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils'
129
121
  import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap'
130
122
  import { dataUrlToFile } from '../utils/assets'
131
123
  import { debugFlags } from '../utils/debug-flags'
@@ -138,7 +130,6 @@ import {
138
130
  import { getIncrementedName } from '../utils/getIncrementedName'
139
131
  import { isAccelKey } from '../utils/keyboard'
140
132
  import { getReorderingShapesChanges } from '../utils/reorderShapes'
141
- import { TLTextOptions, TiptapEditor } from '../utils/richText'
142
133
  import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
143
134
  import { BindingOnDeleteOptions, BindingUtil } from './bindings/BindingUtil'
144
135
  import { bindingsIndex } from './derivations/bindingsIndex'
@@ -148,14 +139,13 @@ import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage
148
139
  import { ClickManager } from './managers/ClickManager'
149
140
  import { EdgeScrollManager } from './managers/EdgeScrollManager'
150
141
  import { FocusManager } from './managers/FocusManager'
151
- import { FontManager } from './managers/FontManager'
152
142
  import { HistoryManager } from './managers/HistoryManager'
153
143
  import { ScribbleManager } from './managers/ScribbleManager'
154
144
  import { SnapManager } from './managers/SnapManager/SnapManager'
155
145
  import { TextManager } from './managers/TextManager'
156
146
  import { TickManager } from './managers/TickManager'
157
147
  import { UserPreferencesManager } from './managers/UserPreferencesManager'
158
- import { ShapeUtil, TLResizeMode } from './shapes/ShapeUtil'
148
+ import { ShapeUtil, TLGeometryOpts, TLResizeMode } from './shapes/ShapeUtil'
159
149
  import { RootState } from './tools/RootState'
160
150
  import { StateNode, TLStateNodeConstructor } from './tools/StateNode'
161
151
  import { TLContent } from './types/clipboard-types'
@@ -235,10 +225,8 @@ export interface TLEditorOptions {
235
225
  * Options for the editor's camera.
236
226
  */
237
227
  cameraOptions?: Partial<TLCameraOptions>
238
- textOptions?: TLTextOptions
239
228
  options?: Partial<TldrawOptions>
240
229
  licenseKey?: string
241
- fontAssetUrls?: { [key: string]: string | undefined }
242
230
  /**
243
231
  * A predicate that should return true if the given shape should be hidden.
244
232
  * @param shape - The shape to check.
@@ -275,13 +263,11 @@ export class Editor extends EventEmitter<TLEventMap> {
275
263
  tools,
276
264
  getContainer,
277
265
  cameraOptions,
278
- textOptions,
279
266
  initialState,
280
267
  autoFocus,
281
268
  inferDarkMode,
282
269
  options,
283
270
  isShapeHidden,
284
- fontAssetUrls,
285
271
  }: TLEditorOptions) {
286
272
  super()
287
273
 
@@ -305,16 +291,12 @@ export class Editor extends EventEmitter<TLEventMap> {
305
291
 
306
292
  this._cameraOptions.set({ ...DEFAULT_CAMERA_OPTIONS, ...cameraOptions })
307
293
 
308
- this._textOptions = atom('text options', textOptions ?? null)
309
-
310
294
  this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false)
311
295
  this.disposables.add(() => this.user.dispose())
312
296
 
313
297
  this.getContainer = getContainer
314
298
 
315
299
  this.textMeasure = new TextManager(this)
316
- this.fonts = new FontManager(this, fontAssetUrls)
317
-
318
300
  this._tickManager = new TickManager(this)
319
301
 
320
302
  class NewRoot extends RootState {
@@ -853,13 +835,6 @@ export class Editor extends EventEmitter<TLEventMap> {
853
835
  */
854
836
  readonly textMeasure: TextManager
855
837
 
856
- /**
857
- * A utility for managing the set of fonts that should be rendered in the document.
858
- *
859
- * @public
860
- */
861
- readonly fonts: FontManager
862
-
863
838
  /**
864
839
  * A manager for the editor's environment.
865
840
  *
@@ -2048,7 +2023,6 @@ export class Editor extends EventEmitter<TLEventMap> {
2048
2023
  */
2049
2024
  setEditingShape(shape: TLShapeId | TLShape | null): this {
2050
2025
  const id = typeof shape === 'string' ? shape : (shape?.id ?? null)
2051
- this.setRichTextEditor(null)
2052
2026
  if (id !== this.getEditingShapeId()) {
2053
2027
  if (id) {
2054
2028
  const shape = this.getShape(id)
@@ -2067,7 +2041,6 @@ export class Editor extends EventEmitter<TLEventMap> {
2067
2041
  this.run(
2068
2042
  () => {
2069
2043
  this._updateCurrentPageState({ editingShapeId: null })
2070
- this._currentRichTextEditor.set(null)
2071
2044
  },
2072
2045
  { history: 'ignore' }
2073
2046
  )
@@ -2075,42 +2048,6 @@ export class Editor extends EventEmitter<TLEventMap> {
2075
2048
  return this
2076
2049
  }
2077
2050
 
2078
- // Rich text editor
2079
-
2080
- private _currentRichTextEditor = atom('rich text editor', null as TiptapEditor | null)
2081
-
2082
- /**
2083
- * The current editing shape's text editor.
2084
- *
2085
- * @public
2086
- */
2087
- @computed getRichTextEditor(): TiptapEditor | null {
2088
- return this._currentRichTextEditor.get()
2089
- }
2090
-
2091
- /**
2092
- * Set the current editing shape's rich text editor.
2093
- *
2094
- * @example
2095
- * ```ts
2096
- * editor.setRichTextEditor(richTextEditorView)
2097
- * ```
2098
- *
2099
- * @param textEditor - The text editor to set as the current editing shape's text editor.
2100
- *
2101
- * @public
2102
- */
2103
- setRichTextEditor(textEditor: TiptapEditor | null) {
2104
- // If the new editor is different from the current one, destroy the current one
2105
- const current = this._currentRichTextEditor.__unsafe__getWithoutCapture()
2106
- if (current !== textEditor) {
2107
- current?.destroy()
2108
- }
2109
-
2110
- this._currentRichTextEditor.set(textEditor)
2111
- return this
2112
- }
2113
-
2114
2051
  // Hovered
2115
2052
 
2116
2053
  /**
@@ -2167,7 +2104,6 @@ export class Editor extends EventEmitter<TLEventMap> {
2167
2104
  @computed getHintingShapeIds() {
2168
2105
  return this.getCurrentPageState().hintingShapeIds
2169
2106
  }
2170
-
2171
2107
  /**
2172
2108
  * The editor's current hinting shapes.
2173
2109
  *
@@ -2316,21 +2252,6 @@ export class Editor extends EventEmitter<TLEventMap> {
2316
2252
  return this
2317
2253
  }
2318
2254
 
2319
- private _textOptions: Atom<TLTextOptions | null>
2320
-
2321
- /**
2322
- * Get the current text options.
2323
- *
2324
- * @example
2325
- * ```ts
2326
- * editor.getTextOptions()
2327
- * ```
2328
- *
2329
- * @public */
2330
- getTextOptions() {
2331
- return assertExists(this._textOptions.get(), 'Cannot use text without setting textOptions')
2332
- }
2333
-
2334
2255
  /* --------------------- Camera --------------------- */
2335
2256
 
2336
2257
  /** @internal */
@@ -4283,17 +4204,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4283
4204
 
4284
4205
  /* --------------------- Shapes --------------------- */
4285
4206
 
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
- }
4207
+ private _shapeGeometryCaches: Record<string, ComputedCache<Geometry2d, TLShape>> = {}
4297
4208
 
4298
4209
  /**
4299
4210
  * Get the geometry of a shape.
@@ -4302,14 +4213,26 @@ export class Editor extends EventEmitter<TLEventMap> {
4302
4213
  * ```ts
4303
4214
  * editor.getShapeGeometry(myShape)
4304
4215
  * editor.getShapeGeometry(myShapeId)
4216
+ * editor.getShapeGeometry(myShapeId, { context: "arrow" })
4305
4217
  * ```
4306
4218
  *
4307
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}.
4308
4221
  *
4309
4222
  * @public
4310
4223
  */
4311
- getShapeGeometry<T extends Geometry2d>(shape: TLShape | TLShapeId): T {
4312
- 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
4313
4236
  }
4314
4237
 
4315
4238
  /** @internal */
@@ -4841,10 +4764,9 @@ export class Editor extends EventEmitter<TLEventMap> {
4841
4764
  // Check labels first
4842
4765
  if (
4843
4766
  this.isShapeOfType<TLFrameShape>(shape, 'frame') ||
4844
- (this.isShapeOfType<TLArrowShape>(shape, 'arrow') && shape.props.text.trim()) ||
4845
- ((this.isShapeOfType<TLNoteShape>(shape, 'note') ||
4767
+ ((this.isShapeOfType<TLArrowShape>(shape, 'arrow') ||
4846
4768
  (this.isShapeOfType<TLGeoShape>(shape, 'geo') && shape.props.fill === 'none')) &&
4847
- this.getShapeUtil(shape).getText(shape)?.trim())
4769
+ shape.props.text.trim())
4848
4770
  ) {
4849
4771
  for (const childGeometry of (geometry as Group2d).children) {
4850
4772
  if (childGeometry.isLabel && childGeometry.isPointInBounds(pointInShapeSpace)) {
@@ -5769,14 +5691,15 @@ export class Editor extends EventEmitter<TLEventMap> {
5769
5691
  return this
5770
5692
  }
5771
5693
 
5694
+ // Gets a shape partial that includes life cycle changes: on translate start, on translate, on translate end
5772
5695
  private getChangesToTranslateShape(initialShape: TLShape, newShapeCoords: VecLike): TLShape {
5773
5696
  let workingShape = initialShape
5774
5697
  const util = this.getShapeUtil(initialShape)
5775
5698
 
5776
- workingShape = applyPartialToRecordWithProps(
5777
- workingShape,
5778
- util.onTranslateStart?.(workingShape) ?? undefined
5779
- )
5699
+ const afterTranslateStart = util.onTranslateStart?.(workingShape)
5700
+ if (afterTranslateStart) {
5701
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslateStart)
5702
+ }
5780
5703
 
5781
5704
  workingShape = applyPartialToRecordWithProps(workingShape, {
5782
5705
  id: initialShape.id,
@@ -5785,15 +5708,15 @@ export class Editor extends EventEmitter<TLEventMap> {
5785
5708
  y: newShapeCoords.y,
5786
5709
  })
5787
5710
 
5788
- workingShape = applyPartialToRecordWithProps(
5789
- workingShape,
5790
- util.onTranslate?.(initialShape, workingShape) ?? undefined
5791
- )
5711
+ const afterTranslate = util.onTranslate?.(initialShape, workingShape)
5712
+ if (afterTranslate) {
5713
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslate)
5714
+ }
5792
5715
 
5793
- workingShape = applyPartialToRecordWithProps(
5794
- workingShape,
5795
- util.onTranslateEnd?.(initialShape, workingShape) ?? undefined
5796
- )
5716
+ const afterTranslateEnd = util.onTranslateEnd?.(initialShape, workingShape)
5717
+ if (afterTranslateEnd) {
5718
+ workingShape = applyPartialToRecordWithProps(workingShape, afterTranslateEnd)
5719
+ }
5797
5720
 
5798
5721
  return workingShape
5799
5722
  }
@@ -6192,6 +6115,37 @@ export class Editor extends EventEmitter<TLEventMap> {
6192
6115
  return this
6193
6116
  }
6194
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
+
6195
6149
  /**
6196
6150
  * Flip shape positions.
6197
6151
  *
@@ -6207,47 +6161,74 @@ export class Editor extends EventEmitter<TLEventMap> {
6207
6161
  * @public
6208
6162
  */
6209
6163
  flipShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
6164
+ if (this.getIsReadonly()) return this
6165
+
6210
6166
  const ids =
6211
6167
  typeof shapes[0] === 'string'
6212
6168
  ? (shapes as TLShapeId[])
6213
6169
  : (shapes as TLShape[]).map((s) => s.id)
6214
6170
 
6215
- if (this.getIsReadonly()) return this
6171
+ // Collect a greedy list of shapes to flip
6172
+ const shapesToFlipFirstPass = compact(ids.map((id) => this.getShape(id)))
6216
6173
 
6217
- 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
+ }
6218
6182
 
6219
- 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
+ }[] = []
6220
6190
 
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
- }
6191
+ const allBounds: Box[] = []
6227
6192
 
6228
- 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,
6229
6199
  })
6230
- .flat()
6231
- )
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
+ }
6232
6216
 
6233
- const scaleOriginPage = Box.Common(
6234
- compact(shapesToFlip.map((id) => this.getShapePageBounds(id)))
6235
- ).center
6217
+ if (!shapesToFlip.length) return this
6218
+
6219
+ const scaleOriginPage = Box.Common(allBounds).center
6236
6220
 
6237
6221
  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
6222
+ for (const { shape, localBounds, pageTransform, isAspectRatioLocked } of shapesToFlip) {
6242
6223
  this.resizeShape(
6243
6224
  shape.id,
6244
6225
  { x: operation === 'horizontal' ? -1 : 1, y: operation === 'vertical' ? -1 : 1 },
6245
6226
  {
6246
- initialBounds: bounds,
6247
- initialPageTransform,
6227
+ initialBounds: localBounds,
6228
+ initialPageTransform: pageTransform,
6248
6229
  initialShape: shape,
6230
+ isAspectRatioLocked,
6249
6231
  mode: 'scale_shape',
6250
- isAspectRatioLocked: this.getShapeUtil(shape).isAspectRatioLocked(shape),
6251
6232
  scaleOrigin: scaleOriginPage,
6252
6233
  scaleAxisRotation: 0,
6253
6234
  }
@@ -6284,21 +6265,58 @@ export class Editor extends EventEmitter<TLEventMap> {
6284
6265
  : (shapes as TLShape[]).map((s) => s.id)
6285
6266
  if (this.getIsReadonly()) return this
6286
6267
 
6287
- const shapesToStack = ids
6288
- .map((id) => this.getShape(id)) // always fresh shapes
6289
- .filter((shape): shape is TLShape => {
6290
- 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>()
6291
6279
 
6292
- 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,
6293
6305
  })
6294
6306
 
6295
- const len = shapesToStack.length
6307
+ const commonPageBounds = Box.Common(boundsOfShapesMovingTogether)
6308
+ if (!commonPageBounds) continue
6296
6309
 
6297
- if ((gap === 0 && len < 3) || len < 2) return this
6310
+ shapeClustersToStack.push({
6311
+ shapes: shapesMovingTogether,
6312
+ pageBounds: commonPageBounds,
6313
+ })
6298
6314
 
6299
- const pageBounds = Object.fromEntries(
6300
- shapesToStack.map((shape) => [shape.id, this.getShapePageBounds(shape)!])
6301
- )
6315
+ allBounds.push(commonPageBounds)
6316
+ }
6317
+
6318
+ const len = shapeClustersToStack.length
6319
+ if ((gap === 0 && len < 3) || len < 2) return this
6302
6320
 
6303
6321
  let val: 'x' | 'y'
6304
6322
  let min: 'minX' | 'minY'
@@ -6317,46 +6335,45 @@ export class Editor extends EventEmitter<TLEventMap> {
6317
6335
  dim = 'height'
6318
6336
  }
6319
6337
 
6320
- let shapeGap: number
6338
+ let shapeGap: number = 0
6321
6339
 
6322
6340
  if (gap === 0) {
6323
- const gaps: { gap: number; count: number }[] = []
6341
+ // note: this is not used in the current tldraw.com; there we use a specified stack
6324
6342
 
6325
- 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])
6326
6346
 
6327
6347
  // Collect all of the gaps between shapes. We want to find
6328
6348
  // patterns (equal gaps between shapes) and use the most common
6329
6349
  // one as the gap for all of the shapes.
6330
6350
  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 })
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
6345
6356
  }
6357
+ gaps[gap]++
6346
6358
  }
6347
6359
 
6348
6360
  // 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
6361
+ let maxCount = 1
6362
+ for (const [gap, count] of Object.entries(gaps)) {
6363
+ if (count > maxCount) {
6364
+ maxCount = count
6365
+ shapeGap = parseFloat(gap)
6354
6366
  }
6355
- })
6367
+ }
6356
6368
 
6357
6369
  // If there is no most-common gap, use the average gap.
6358
6370
  if (maxCount === 1) {
6359
- 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
6360
6377
  }
6361
6378
  } else {
6362
6379
  // If a gap was provided, then use that instead.
@@ -6365,36 +6382,30 @@ export class Editor extends EventEmitter<TLEventMap> {
6365
6382
 
6366
6383
  const changes: TLShapePartial[] = []
6367
6384
 
6368
- let v = pageBounds[shapesToStack[0].id][max]
6369
-
6370
- shapesToStack.forEach((shape, i) => {
6371
- if (i === 0) return
6385
+ let v = shapeClustersToStack[0].pageBounds[max]
6372
6386
 
6373
- const delta = { x: 0, y: 0 }
6374
- delta[val] = v + shapeGap - pageBounds[shape.id][val]
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]
6375
6391
 
6376
- const parent = this.getShapeParent(shape)
6377
- const localDelta = parent
6378
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation)
6379
- : delta
6392
+ for (const shape of shapes) {
6393
+ const shapeDelta = delta.clone()
6380
6394
 
6381
- 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
+ }
6382
6402
 
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
- )
6403
+ shapeDelta.add(shape) // add the shape's x and y to the delta
6404
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6405
+ }
6395
6406
 
6396
- v += pageBounds[shape.id][dim] + shapeGap
6397
- })
6407
+ v += pageBounds[dim] + shapeGap
6408
+ }
6398
6409
 
6399
6410
  this.updateShapes(changes)
6400
6411
  return this
@@ -6414,42 +6425,79 @@ export class Editor extends EventEmitter<TLEventMap> {
6414
6425
  * @param gap - The padding to apply to the packed shapes. Defaults to 16.
6415
6426
  */
6416
6427
  packShapes(shapes: TLShapeId[] | TLShape[], gap: number): this {
6428
+ if (this.getIsReadonly()) return this
6429
+
6417
6430
  const ids =
6418
6431
  typeof shapes[0] === 'string'
6419
6432
  ? (shapes as TLShapeId[])
6420
6433
  : (shapes as TLShape[]).map((s) => s.id)
6421
6434
 
6422
- if (this.getIsReadonly()) return this
6423
- 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)
6424
6450
 
6425
- const shapesToPack = ids
6426
- .map((id) => this.getShape(id)) // always fresh shapes
6427
- .filter((shape): shape is TLShape => {
6428
- if (!shape) return false
6451
+ const shapePageBounds = this.getShapePageBounds(shape)
6452
+ if (!shapePageBounds) continue
6429
6453
 
6430
- 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,
6431
6472
  })
6432
- const shapePageBounds: Record<string, Box> = {}
6433
- const nextShapePageBounds: Record<string, Box> = {}
6434
6473
 
6435
- let shape: TLShape,
6436
- bounds: Box,
6437
- 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
+ })
6438
6482
 
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
6483
+ allBounds.push(commonPageBounds)
6445
6484
  }
6446
6485
 
6447
- 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)
6448
6494
 
6449
6495
  const maxWidth = commonBounds.width
6450
6496
 
6451
- // sort the shapes by height, descending
6452
- 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)
6453
6501
 
6454
6502
  // Start with is (sort of) the square of the area
6455
6503
  const startWidth = Math.max(Math.ceil(Math.sqrt(area / 0.95)), maxWidth)
@@ -6462,85 +6510,69 @@ export class Editor extends EventEmitter<TLEventMap> {
6462
6510
  let space: Box
6463
6511
  let last: Box
6464
6512
 
6465
- for (let i = 0; i < shapesToPack.length; i++) {
6466
- shape = shapesToPack[i]
6467
- bounds = nextShapePageBounds[shape.id]
6468
-
6513
+ for (const { nextPageBounds } of shapeClustersToPack) {
6469
6514
  // starting at the back (smaller shapes)
6470
6515
  for (let i = spaces.length - 1; i >= 0; i--) {
6471
6516
  space = spaces[i]
6472
6517
 
6473
6518
  // find a space that is big enough to contain the shape
6474
- if (bounds.width > space.width || bounds.height > space.height) continue
6519
+ if (nextPageBounds.width > space.width || nextPageBounds.height > space.height) continue
6475
6520
 
6476
6521
  // add the shape to its top-left corner
6477
- bounds.x = space.x
6478
- bounds.y = space.y
6522
+ nextPageBounds.x = space.x
6523
+ nextPageBounds.y = space.y
6479
6524
 
6480
- height = Math.max(height, bounds.maxY)
6481
- width = Math.max(width, bounds.maxX)
6525
+ height = Math.max(height, nextPageBounds.maxY)
6526
+ width = Math.max(width, nextPageBounds.maxX)
6482
6527
 
6483
- if (bounds.width === space.width && bounds.height === space.height) {
6528
+ if (nextPageBounds.width === space.width && nextPageBounds.height === space.height) {
6484
6529
  // remove the space on a perfect fit
6485
6530
  last = spaces.pop()!
6486
6531
  if (i < spaces.length) spaces[i] = last
6487
- } else if (bounds.height === space.height) {
6532
+ } else if (nextPageBounds.height === space.height) {
6488
6533
  // 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) {
6534
+ space.x += nextPageBounds.width + gap
6535
+ space.width -= nextPageBounds.width + gap
6536
+ } else if (nextPageBounds.width === space.width) {
6492
6537
  // fit the shape into the space (height)
6493
- space.y += bounds.height + gap
6494
- space.height -= bounds.height + gap
6538
+ space.y += nextPageBounds.height + gap
6539
+ space.height -= nextPageBounds.height + gap
6495
6540
  } else {
6496
6541
  // split the space into two spaces
6497
6542
  spaces.push(
6498
6543
  new Box(
6499
- space.x + (bounds.width + gap),
6544
+ space.x + (nextPageBounds.width + gap),
6500
6545
  space.y,
6501
- space.width - (bounds.width + gap),
6502
- bounds.height
6546
+ space.width - (nextPageBounds.width + gap),
6547
+ nextPageBounds.height
6503
6548
  )
6504
6549
  )
6505
- space.y += bounds.height + gap
6506
- space.height -= bounds.height + gap
6550
+ space.y += nextPageBounds.height + gap
6551
+ space.height -= nextPageBounds.height + gap
6507
6552
  }
6508
6553
  break
6509
6554
  }
6510
6555
  }
6511
6556
 
6512
- const commonAfter = Box.Common(Object.values(nextShapePageBounds))
6557
+ const commonAfter = Box.Common(shapeClustersToPack.map((s) => s.nextPageBounds))
6513
6558
  const centerDelta = Vec.Sub(commonBounds.center, commonAfter.center)
6514
6559
 
6515
- let nextBounds: Box
6516
-
6517
6560
  const changes: TLShapePartial<any>[] = []
6518
6561
 
6519
- for (let i = 0; i < shapesToPack.length; i++) {
6520
- shape = shapesToPack[i]
6521
- bounds = shapePageBounds[shape.id]
6522
- nextBounds = nextShapePageBounds[shape.id]
6523
-
6524
- const delta = Vec.Sub(nextBounds.point, bounds.point).add(centerDelta)
6525
- const parentTransform = this.getShapeParentTransform(shape)
6526
- if (parentTransform) delta.rot(-parentTransform.rotation())
6562
+ for (const { shapes, pageBounds, nextPageBounds } of shapeClustersToPack) {
6563
+ const delta = Vec.Sub(nextPageBounds.point, pageBounds.point).add(centerDelta)
6527
6564
 
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
- }
6565
+ for (const shape of shapes) {
6566
+ const shapeDelta = delta.clone()
6534
6567
 
6535
- const translateStartChange = this.getShapeUtil(shape).onTranslateStart?.({
6536
- ...shape,
6537
- ...change,
6538
- })
6568
+ const parent = this.getShapeParent(shape)
6569
+ if (parent) {
6570
+ const parentTransform = this.getShapeParentTransform(shape)
6571
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6572
+ }
6539
6573
 
6540
- if (translateStartChange) {
6541
- changes.push({ ...change, ...translateStartChange })
6542
- } else {
6543
- changes.push(change)
6574
+ shapeDelta.add(shape)
6575
+ changes.push(this.getChangesToTranslateShape(shape, shapeDelta))
6544
6576
  }
6545
6577
  }
6546
6578
 
@@ -6565,32 +6597,78 @@ export class Editor extends EventEmitter<TLEventMap> {
6565
6597
  *
6566
6598
  * @public
6567
6599
  */
6568
-
6569
6600
  alignShapes(
6570
6601
  shapes: TLShapeId[] | TLShape[],
6571
6602
  operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom'
6572
6603
  ): this {
6604
+ if (this.getIsReadonly()) return this
6605
+
6573
6606
  const ids =
6574
6607
  typeof shapes[0] === 'string'
6575
6608
  ? (shapes as TLShapeId[])
6576
6609
  : (shapes as TLShape[]).map((s) => s.id)
6577
6610
 
6578
- if (this.getIsReadonly()) return this
6579
- if (ids.length < 2) return this
6611
+ // Always get fresh shapes
6612
+ const shapesToAlignFirstPass = compact(ids.map((id) => this.getShape(id)))
6580
6613
 
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)))
6614
+ const shapeClustersToAlign: {
6615
+ shapes: TLShape[]
6616
+ pageBounds: Box
6617
+ }[] = []
6618
+ const allBounds: Box[] = []
6619
+ const visited = new Set<TLShapeId>()
6586
6620
 
6587
- const changes: TLShapePartial[] = []
6621
+ for (const shape of shapesToAlignFirstPass) {
6622
+ if (visited.has(shape.id)) continue
6623
+ visited.add(shape.id)
6588
6624
 
6589
- shapesToAlign.forEach((shape) => {
6590
- const pageBounds = shapePageBounds[shape.id]
6591
- if (!pageBounds) return
6625
+ const shapePageBounds = this.getShapePageBounds(shape)
6626
+ if (!shapePageBounds) continue
6592
6627
 
6593
- const delta = { x: 0, y: 0 }
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[] = []
6669
+
6670
+ shapeClustersToAlign.forEach(({ shapes, pageBounds }) => {
6671
+ const delta = new Vec()
6594
6672
 
6595
6673
  switch (operation) {
6596
6674
  case 'top': {
@@ -6619,12 +6697,20 @@ export class Editor extends EventEmitter<TLEventMap> {
6619
6697
  }
6620
6698
  }
6621
6699
 
6622
- const parent = this.getShapeParent(shape)
6623
- const localDelta = parent
6624
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation)
6625
- : 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
+ }
6626
6710
 
6627
- 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
+ }
6628
6714
  })
6629
6715
 
6630
6716
  this.updateShapes(changes)
@@ -6646,65 +6732,137 @@ export class Editor extends EventEmitter<TLEventMap> {
6646
6732
  * @public
6647
6733
  */
6648
6734
  distributeShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this {
6735
+ if (this.getIsReadonly()) return this
6736
+
6649
6737
  const ids =
6650
6738
  typeof shapes[0] === 'string'
6651
6739
  ? (shapes as TLShapeId[])
6652
6740
  : (shapes as TLShape[]).map((s) => s.id)
6653
6741
 
6654
- if (this.getIsReadonly()) return this
6655
- if (ids.length < 3) return this
6742
+ // always fresh shapes
6743
+ const shapesToDistributeFirstPass = compact(ids.map((id) => this.getShape(id)))
6656
6744
 
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
- )
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
6662
6792
 
6663
6793
  let val: 'x' | 'y'
6664
6794
  let min: 'minX' | 'minY'
6665
6795
  let max: 'maxX' | 'maxY'
6666
- let mid: 'midX' | 'midY'
6667
6796
  let dim: 'width' | 'height'
6668
6797
 
6669
6798
  if (operation === 'horizontal') {
6670
6799
  val = 'x'
6671
6800
  min = 'minX'
6672
6801
  max = 'maxX'
6673
- mid = 'midX'
6674
6802
  dim = 'width'
6675
6803
  } else {
6676
6804
  val = 'y'
6677
6805
  min = 'minY'
6678
6806
  max = 'maxY'
6679
- mid = 'midY'
6680
6807
  dim = 'height'
6681
6808
  }
6682
6809
  const changes: TLShapePartial[] = []
6683
6810
 
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]
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]
6689
6813
 
6690
- const midFirst = pageBounds[first.id][mid]
6691
- const step = (pageBounds[last.id][mid] - midFirst) / (len - 1)
6692
- 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
+ }
6693
6822
 
6694
- shapesToDistribute
6823
+ const shapeClustersToMove = shapeClustersToDistribute
6695
6824
  .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]
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)
6700
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()
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
6701
6854
  const parent = this.getShapeParent(shape)
6702
- const localDelta = parent
6703
- ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.rotation())
6704
- : delta
6855
+ if (parent) {
6856
+ const parentTransform = this.getShapePageTransform(parent)
6857
+ if (parentTransform) shapeDelta.rot(-parentTransform.rotation())
6858
+ }
6705
6859
 
6706
- changes.push(this.getChangesToTranslateShape(shape, Vec.Add(shape, localDelta)))
6707
- })
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
+ }
6708
6866
 
6709
6867
  this.updateShapes(changes)
6710
6868
  return this
@@ -6731,65 +6889,106 @@ export class Editor extends EventEmitter<TLEventMap> {
6731
6889
  : (shapes as TLShape[]).map((s) => s.id)
6732
6890
 
6733
6891
  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
- }
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,
6763
6920
  })
6764
- break
6921
+ ) {
6922
+ continue
6765
6923
  }
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
6924
 
6789
- break
6790
- }
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)
6791
6942
  }
6792
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
+
6793
6992
  return this
6794
6993
  }
6795
6994
 
@@ -7091,7 +7290,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7091
7290
  * @example
7092
7291
  * ```ts
7093
7292
  * editor.createShape(myShape)
7094
- * editor.createShape({ id: 'box1', type: 'text', props: { richText: toRichText("ok") } })
7293
+ * editor.createShape({ id: 'box1', type: 'text', props: { text: "ok" } })
7095
7294
  * ```
7096
7295
  *
7097
7296
  * @param shape - The shape (or shape partial) to create.
@@ -7109,7 +7308,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7109
7308
  * @example
7110
7309
  * ```ts
7111
7310
  * editor.createShapes([myShape])
7112
- * editor.createShapes([{ id: 'box1', type: 'text', props: { richText: toRichText("ok") } }])
7311
+ * editor.createShapes([{ id: 'box1', type: 'text', props: { text: "ok" } }])
7113
7312
  * ```
7114
7313
  *
7115
7314
  * @param shapes - The shapes (or shape partials) to create.
@@ -10057,7 +10256,7 @@ function withIsolatedShapes<T>(
10057
10256
  }
10058
10257
  })
10059
10258
 
10060
- editor.store.applyDiff(reverseRecordsDiff(changes))
10259
+ editor.store.applyDiff(reverseRecordsDiff(changes), { runCallbacks: false })
10061
10260
  },
10062
10261
  { history: 'ignore' }
10063
10262
  )