@tldraw/editor 5.2.4 → 5.3.0-canary.00f14de1f81d

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 (100) hide show
  1. package/dist-cjs/index.d.ts +76 -10
  2. package/dist-cjs/index.js +3 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +6 -1
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/editor/Editor.js +67 -15
  7. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  8. package/dist-cjs/lib/editor/managers/EditorManager.js +52 -0
  9. package/dist-cjs/lib/editor/managers/EditorManager.js.map +7 -0
  10. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +9 -11
  11. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
  12. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +9 -0
  13. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  14. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +5 -9
  15. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  16. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +18 -29
  17. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +2 -2
  18. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +96 -54
  19. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  20. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +27 -19
  21. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  22. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +4 -7
  23. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  24. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -0
  25. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
  26. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +1 -1
  27. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -2
  28. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  29. package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
  30. package/dist-cjs/lib/license/LicenseManager.js +14 -5
  31. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  32. package/dist-cjs/lib/options.js +2 -1
  33. package/dist-cjs/lib/options.js.map +2 -2
  34. package/dist-cjs/version.js +3 -3
  35. package/dist-cjs/version.js.map +1 -1
  36. package/dist-esm/index.d.mts +76 -10
  37. package/dist-esm/index.mjs +3 -3
  38. package/dist-esm/index.mjs.map +2 -2
  39. package/dist-esm/lib/TldrawEditor.mjs +6 -1
  40. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  41. package/dist-esm/lib/editor/Editor.mjs +67 -15
  42. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  43. package/dist-esm/lib/editor/managers/EditorManager.mjs +32 -0
  44. package/dist-esm/lib/editor/managers/EditorManager.mjs.map +7 -0
  45. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +9 -11
  46. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
  47. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +9 -0
  48. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  49. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +5 -9
  50. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  51. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +18 -29
  52. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +103 -56
  54. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  55. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +27 -19
  56. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  57. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +4 -7
  58. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  59. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -0
  60. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
  61. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +1 -1
  62. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -2
  63. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  64. package/dist-esm/lib/license/LicenseManager.mjs +14 -5
  65. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  66. package/dist-esm/lib/options.mjs +2 -1
  67. package/dist-esm/lib/options.mjs.map +2 -2
  68. package/dist-esm/version.mjs +3 -3
  69. package/dist-esm/version.mjs.map +1 -1
  70. package/editor.css +10 -0
  71. package/package.json +7 -7
  72. package/src/index.ts +1 -1
  73. package/src/lib/TldrawEditor.tsx +10 -1
  74. package/src/lib/editor/Editor.ts +101 -20
  75. package/src/lib/editor/managers/EditorManager.test.ts +51 -0
  76. package/src/lib/editor/managers/EditorManager.ts +60 -0
  77. package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +2 -1
  78. package/src/lib/editor/managers/FocusManager/FocusManager.ts +10 -14
  79. package/src/lib/editor/managers/FontManager/FontManager.test.ts +66 -0
  80. package/src/lib/editor/managers/FontManager/FontManager.ts +14 -0
  81. package/src/lib/editor/managers/InputsManager/InputsManager.ts +6 -9
  82. package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +19 -33
  83. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +173 -86
  84. package/src/lib/editor/managers/TextManager/TextManager.test.ts +61 -1
  85. package/src/lib/editor/managers/TextManager/TextManager.ts +36 -19
  86. package/src/lib/editor/managers/TickManager/TickManager.test.ts +0 -10
  87. package/src/lib/editor/managers/TickManager/TickManager.ts +5 -6
  88. package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +6 -1
  89. package/src/lib/editor/shapes/ShapeUtil.ts +1 -0
  90. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +2 -2
  91. package/src/lib/editor/types/emit-types.ts +1 -0
  92. package/src/lib/license/LicenseManager.test.ts +99 -29
  93. package/src/lib/license/LicenseManager.ts +16 -4
  94. package/src/lib/options.ts +3 -1
  95. package/src/version.ts +3 -3
  96. package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js +0 -28
  97. package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js.map +0 -7
  98. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs +0 -8
  99. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs.map +0 -7
  100. package/src/lib/editor/tools/cancelShapeCreationOnLongPress.ts +0 -31
@@ -1,6 +1,12 @@
1
- import { Computed, RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
1
+ import {
2
+ Computed,
3
+ RESET_VALUE,
4
+ computed,
5
+ isUninitialized,
6
+ unsafe__withoutCapture,
7
+ } from '@tldraw/state'
2
8
  import type { RecordsDiff } from '@tldraw/store'
3
- import { TLPageId, TLShape, TLShapeId, isShape } from '@tldraw/tlschema'
9
+ import { TLPageId, TLShape, TLShapeId, isBinding, isPageId, isShape } from '@tldraw/tlschema'
4
10
  import type { TLRecord } from '@tldraw/tlschema'
5
11
  import { objectMapValues } from '@tldraw/utils'
6
12
  import { Box } from '../../../primitives/Box'
@@ -11,16 +17,24 @@ import { RBushIndex, type SpatialElement } from './RBushIndex'
11
17
  * Manages spatial indexing for efficient shape location queries.
12
18
  *
13
19
  * Uses an R-tree (via RBush) to enable O(log n) spatial queries instead of O(n) iteration.
14
- * Handles shapes with computed bounds (arrows, groups, custom shapes) by checking all shapes'
15
- * bounds on each update using the reactive bounds cache.
20
+ * Derived-bounds changes (arrows following bound shapes, groups resizing with children) are
21
+ * tracked structurally: each record diff is expanded into a dirty set covering the changed
22
+ * shapes' descendants, ancestors, and binding partners, and only those shapes' bounds are
23
+ * rechecked — O(affected) per update instead of O(page).
16
24
  *
17
25
  * Key features:
18
- * - Incremental updates using filterHistory pattern
19
- * - Leverages existing bounds cache reactivity for dependency tracking
20
- * - Works with any custom shape type with computed bounds
26
+ * - Incremental updates using filterHistory pattern (shape and binding history)
27
+ * - Dirty-set expansion via parent/child and binding relationships
21
28
  * - Per-page index (rebuilds on page change)
22
29
  * - Optimized for viewport culling queries
23
30
  *
31
+ * Known limitation: a custom shape whose geometry reads other shapes outside the
32
+ * parent/child and binding relationships is not rechecked when those shapes change.
33
+ * Core shapes only depend on other shapes via bindings (arrows) and parent/child
34
+ * relationships (groups), which are both covered. This narrows the previous
35
+ * approach, which rechecked every indexed shape on every update and so happened to
36
+ * cover such shapes; the structural expansion trades that for O(affected) updates.
37
+ *
24
38
  * @internal
25
39
  */
26
40
  export class SpatialIndexManager {
@@ -52,27 +66,28 @@ export class SpatialIndexManager {
52
66
  const bindingHistory = this.editor.store.query.filterHistory('binding')
53
67
 
54
68
  return computed<number>('spatialIndex', (_prevValue, lastComputedEpoch) => {
55
- if (isUninitialized(_prevValue)) {
56
- return this.rebuildAndBumpEpoch()
57
- }
58
-
69
+ // These three reads are the computed's only tracked dependencies.
70
+ // Every input to a shape's page bounds is a shape or binding record,
71
+ // so the two histories cover all invalidation; the rebuild/update
72
+ // paths below run without capture to avoid registering a dependency
73
+ // per checked shape on every update (and ~page-size dependencies on
74
+ // every rebuild).
59
75
  const shapeDiff = shapeHistory.getDiffSince(lastComputedEpoch)
60
76
  const bindingDiff = bindingHistory.getDiffSince(lastComputedEpoch)
61
-
62
- if (shapeDiff === RESET_VALUE || bindingDiff === RESET_VALUE) {
63
- return this.rebuildAndBumpEpoch()
64
- }
65
-
66
77
  const currentPageId = this.editor.getCurrentPageId()
67
- if (this.lastPageId !== currentPageId) {
68
- return this.rebuildAndBumpEpoch()
78
+
79
+ if (
80
+ isUninitialized(_prevValue) ||
81
+ shapeDiff === RESET_VALUE ||
82
+ bindingDiff === RESET_VALUE ||
83
+ this.lastPageId !== currentPageId
84
+ ) {
85
+ return unsafe__withoutCapture(() => this.rebuildAndBumpEpoch())
69
86
  }
70
87
 
71
88
  if (shapeDiff.length === 0 && bindingDiff.length === 0) return this._boundsEpoch
72
89
 
73
- // A binding-only diff passes an empty shape diff: step 1 is a no-op
74
- // and the step-2 sweep re-checks the indexed bounds of every shape.
75
- if (this.processIncrementalUpdate(shapeDiff)) {
90
+ if (unsafe__withoutCapture(() => this.processIncrementalUpdate(shapeDiff, bindingDiff))) {
76
91
  this._boundsEpoch++
77
92
  }
78
93
  return this._boundsEpoch
@@ -101,88 +116,160 @@ export class SpatialIndexManager {
101
116
  this.rbush.bulkLoad(elements)
102
117
  }
103
118
 
104
- private processIncrementalUpdate(shapeDiff: RecordsDiff<TLRecord>[]): boolean {
105
- const processedShapeIds = new Set<TLShapeId>()
106
- let changed = false
119
+ private processIncrementalUpdate(
120
+ shapeDiff: RecordsDiff<TLRecord>[],
121
+ bindingDiff: RecordsDiff<TLRecord>[]
122
+ ): boolean {
123
+ // Rbush mutations are collected and applied as one batch at the end so
124
+ // large updates can use a bulk rebuild instead of per-item tree ops.
125
+ const pendingRemoves = new Set<TLShapeId>()
126
+ const pendingUpserts: SpatialElement[] = []
127
+
128
+ // Shapes whose page transform may have moved (their record was added or
129
+ // updated). Their descendants' transforms move with them.
130
+ const transformChanged = new Set<TLShapeId>()
131
+ // Shapes whose page bounds need rechecking against the index.
132
+ const dirty = new Set<TLShapeId>()
133
+ // Subset of `dirty` not yet expanded into ancestors/binding partners.
134
+ const queue: TLShapeId[] = []
107
135
 
108
- // Step 1: apply diff entries directly. `changed` flips only on real
109
- // rbush mutations, so prop-only updates and no-op removes (e.g. shapes
110
- // from other pages, or never-indexed shapes with invalid bounds) don't
111
- // bump the epoch.
136
+ const markDirty = (id: TLShapeId) => {
137
+ if (!dirty.has(id)) {
138
+ dirty.add(id)
139
+ queue.push(id)
140
+ }
141
+ }
142
+
143
+ // Step 1: seed the dirty set from the record diffs.
112
144
  for (const changes of shapeDiff) {
113
145
  for (const shape of objectMapValues(changes.added) as TLShape[]) {
114
- if (isShape(shape) && this.editor.getAncestorPageId(shape) === this.lastPageId) {
115
- const bounds = this.editor.getShapePageBounds(shape.id)
116
- if (bounds && bounds.isValid()) {
117
- this.rbush.upsert(shape.id, bounds)
118
- changed = true
119
- }
120
- processedShapeIds.add(shape.id)
121
- }
146
+ if (!isShape(shape)) continue
147
+ transformChanged.add(shape.id)
148
+ markDirty(shape.id)
122
149
  }
123
150
 
124
151
  for (const shape of objectMapValues(changes.removed) as TLShape[]) {
125
- if (isShape(shape)) {
126
- if (this.rbush.has(shape.id)) {
127
- this.rbush.remove(shape.id)
128
- changed = true
129
- }
130
- processedShapeIds.add(shape.id)
152
+ if (!isShape(shape)) continue
153
+ if (this.rbush.has(shape.id)) {
154
+ pendingRemoves.add(shape.id)
131
155
  }
156
+ // The old parent's derived bounds (groups) may shrink. Bindings
157
+ // involving the shape are deleted in the same transaction, so the
158
+ // binding diff below dirties the other ends.
159
+ if (!isPageId(shape.parentId)) markDirty(shape.parentId)
132
160
  }
133
161
 
134
- for (const [, to] of objectMapValues(changes.updated) as [TLShape, TLShape][]) {
162
+ for (const [from, to] of objectMapValues(changes.updated) as [TLShape, TLShape][]) {
135
163
  if (!isShape(to)) continue
136
- processedShapeIds.add(to.id)
137
-
138
- const isOnPage = this.editor.getAncestorPageId(to) === this.lastPageId
139
-
140
- if (isOnPage) {
141
- const bounds = this.editor.getShapePageBounds(to.id)
142
- if (bounds && bounds.isValid()) {
143
- const indexedElement = this.rbush.getElement(to.id)
144
- if (!this.areBoundsEqualToSpatialElement(bounds, indexedElement)) {
145
- this.rbush.upsert(to.id, bounds)
146
- changed = true
147
- }
148
- } else if (this.rbush.has(to.id)) {
149
- this.rbush.remove(to.id)
150
- changed = true
151
- }
152
- } else if (this.rbush.has(to.id)) {
153
- this.rbush.remove(to.id)
154
- changed = true
164
+ transformChanged.add(to.id)
165
+ markDirty(to.id)
166
+ // On reparent, the old ancestor chain needs rechecking too
167
+ if (isShape(from) && from.parentId !== to.parentId && !isPageId(from.parentId)) {
168
+ markDirty(from.parentId)
169
+ }
170
+ }
171
+ }
172
+
173
+ for (const changes of bindingDiff) {
174
+ for (const binding of objectMapValues(changes.added)) {
175
+ if (!isBinding(binding)) continue
176
+ markDirty(binding.fromId)
177
+ markDirty(binding.toId)
178
+ }
179
+ for (const binding of objectMapValues(changes.removed)) {
180
+ if (!isBinding(binding)) continue
181
+ markDirty(binding.fromId)
182
+ markDirty(binding.toId)
183
+ }
184
+ for (const [from, to] of objectMapValues(changes.updated)) {
185
+ if (!isBinding(to)) continue
186
+ markDirty(to.fromId)
187
+ markDirty(to.toId)
188
+ // A reassigned binding leaves its old endpoints needing a
189
+ // recheck too (mirrors the reparent case for shapes above).
190
+ if (isBinding(from) && (from.fromId !== to.fromId || from.toId !== to.toId)) {
191
+ markDirty(from.fromId)
192
+ markDirty(from.toId)
193
+ }
194
+ }
195
+ }
196
+
197
+ // Step 2: a moved record moves the page transforms of all its
198
+ // descendants, so their bounds need rechecking even though their
199
+ // records never changed. Expand before the fixpoint below so each
200
+ // descendant also gets its ancestors and binding partners visited.
201
+ const transformQueue = [...transformChanged]
202
+ while (transformQueue.length) {
203
+ const id = transformQueue.pop()!
204
+ for (const childId of this.editor.getSortedChildIdsForParent(id)) {
205
+ if (!transformChanged.has(childId)) {
206
+ transformChanged.add(childId)
207
+ transformQueue.push(childId)
155
208
  }
209
+ markDirty(childId)
210
+ }
211
+ }
212
+
213
+ // Step 3: expand to a fixpoint. Any shape whose bounds may have changed
214
+ // can resize its ancestor groups and move arrows bound to it. A derived
215
+ // bounds change does not move the shape's own transform, so descendants
216
+ // are not affected by this expansion (only by step 2).
217
+ while (queue.length) {
218
+ const id = queue.pop()!
219
+
220
+ const record = this.editor.store.unsafeGetWithoutCapture(id)
221
+ if (record && isShape(record) && !isPageId(record.parentId)) {
222
+ markDirty(record.parentId)
223
+ }
224
+
225
+ for (const binding of this.editor.getBindingsInvolvingShape(id)) {
226
+ markDirty(binding.fromId)
227
+ markDirty(binding.toId)
156
228
  }
157
229
  }
158
230
 
159
- // Step 2: must always run. Diff entries can dirty derived bounds —
160
- // arrows bound to moved shapes, groups with moved children — without
161
- // touching any record visited in step 1. Also catches outline-only
162
- // changes (e.g. geo rectangle→ellipse at the same w/h) that shift a
163
- // bound arrow's intersection points: step 1 sees the geo's
164
- // axis-aligned bounds unchanged and skips, but the dependent arrow's
165
- // bounds have moved.
166
- //
167
- // Iterating the rbush's element map directly avoids allocating a
168
- // shape-id array per pointer move. Mutation here is limited to
169
- // upserts of existing keys and deletions, both safe during Map
170
- // iteration.
171
- for (const [shapeId, indexedElement] of this.rbush.entries()) {
172
- if (processedShapeIds.has(shapeId)) continue
173
-
174
- const currentBounds = this.editor.getShapePageBounds(shapeId)
175
- if (this.areBoundsEqualToSpatialElement(currentBounds, indexedElement)) continue
176
-
177
- if (currentBounds && currentBounds.isValid()) {
178
- this.rbush.upsert(shapeId, currentBounds)
179
- } else {
180
- this.rbush.remove(shapeId)
231
+ // Step 4: recheck only the dirty shapes' bounds. The batch stays empty
232
+ // on prop-only updates with unchanged bounds, so they don't bump the
233
+ // epoch.
234
+ for (const id of dirty) {
235
+ // A shape removed in an earlier diff entry may have been re-added in
236
+ // a later one; its pending removal means the indexed bounds no
237
+ // longer count.
238
+ const indexedElement = pendingRemoves.has(id) ? undefined : this.rbush.getElement(id)
239
+ const record = this.editor.store.unsafeGetWithoutCapture(id)
240
+
241
+ if (
242
+ !record ||
243
+ !isShape(record) ||
244
+ this.editor.getAncestorPageId(record) !== this.lastPageId
245
+ ) {
246
+ if (this.rbush.has(id)) {
247
+ pendingRemoves.add(id)
248
+ }
249
+ continue
250
+ }
251
+
252
+ const bounds = this.editor.getShapePageBounds(id)
253
+ if (bounds && bounds.isValid()) {
254
+ if (!this.areBoundsEqualToSpatialElement(bounds, indexedElement)) {
255
+ pendingRemoves.delete(id)
256
+ pendingUpserts.push({
257
+ minX: bounds.minX,
258
+ minY: bounds.minY,
259
+ maxX: bounds.maxX,
260
+ maxY: bounds.maxY,
261
+ id,
262
+ })
263
+ }
264
+ } else if (this.rbush.has(id)) {
265
+ pendingRemoves.add(id)
181
266
  }
182
- changed = true
183
267
  }
184
268
 
185
- return changed
269
+ if (pendingRemoves.size === 0 && pendingUpserts.length === 0) return false
270
+
271
+ this.rbush.applyBatch(pendingRemoves, pendingUpserts)
272
+ return true
186
273
  }
187
274
 
188
275
  private areBoundsEqualToSpatialElement(
@@ -110,7 +110,6 @@ describe('TextManager', () => {
110
110
  describe('constructor', () => {
111
111
  it('should create a TextManager instance', () => {
112
112
  expect(textManager).toBeInstanceOf(TextManager)
113
- expect(textManager.editor).toBe(mockEditor)
114
113
  })
115
114
  })
116
115
 
@@ -247,6 +246,67 @@ describe('TextManager', () => {
247
246
  expect(result.spans).toHaveLength(0)
248
247
  })
249
248
 
249
+ it('should skip characters that produce no layout rectangles', () => {
250
+ const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
251
+ RangeMock.mockImplementationOnce(function () {
252
+ return {
253
+ setStart: vi.fn(),
254
+ setEnd: vi.fn(),
255
+ // simulate a browser returning no rects for a measured character,
256
+ // which previously crashed with "Cannot read properties of
257
+ // undefined (reading 'top')". See issue #9112.
258
+ getClientRects: vi.fn(() => []),
259
+ }
260
+ })
261
+
262
+ const mockTextNode = {
263
+ nodeType: 3, // TEXT_NODE
264
+ textContent: 'Hello',
265
+ }
266
+
267
+ const mockElementWithText = {
268
+ childNodes: [mockTextNode],
269
+ getBoundingClientRect: () => ({ left: 0, top: 0 }),
270
+ }
271
+
272
+ let result
273
+ expect(() => {
274
+ result = textManager.measureElementTextNodeSpans(mockElementWithText as any)
275
+ }).not.toThrow()
276
+
277
+ expect(result!.spans).toHaveLength(0)
278
+ expect(result!.didTruncate).toBe(false)
279
+ })
280
+
281
+ it('should measure grapheme clusters as a single unit', () => {
282
+ const startOffsets: number[] = []
283
+ const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
284
+ RangeMock.mockImplementationOnce(function () {
285
+ return {
286
+ setStart: vi.fn((_node: any, offset: number) => startOffsets.push(offset)),
287
+ setEnd: vi.fn(),
288
+ getClientRects: vi.fn(() => [
289
+ { width: 10, height: 16, left: 0, top: 0, right: 10, bottom: 16 },
290
+ ]),
291
+ }
292
+ })
293
+
294
+ // 👨‍👩‍👧 is 5 code points (8 UTF-16 units) joined by zero-width joiners
295
+ const family = '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}'
296
+ const mockTextNode = { nodeType: 3, textContent: `a${family}b` }
297
+ const mockElementWithText = {
298
+ childNodes: [mockTextNode],
299
+ getBoundingClientRect: () => ({ left: 0, top: 0 }),
300
+ }
301
+
302
+ const result = textManager.measureElementTextNodeSpans(mockElementWithText as any)
303
+
304
+ // the emoji is measured as one grapheme, so the index jumps over all 8 units
305
+ expect(startOffsets).toEqual([0, 1, 9])
306
+ // and its code points are kept together in the span text
307
+ expect(result.spans[0].text).toBe(`a${family}b`)
308
+ })
309
+
250
310
  it('should handle truncation option', () => {
251
311
  const mockTextNode = {
252
312
  nodeType: 3, // TEXT_NODE
@@ -1,6 +1,7 @@
1
1
  import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
2
2
  import { objectMapKeys } from '@tldraw/utils'
3
3
  import type { Editor } from '../../Editor'
4
+ import { EditorManager } from '../EditorManager'
4
5
 
5
6
  /**
6
7
  * The whole-pixel line-height for a given font size and tldraw's unitless line-height
@@ -93,6 +94,10 @@ export interface TLMeasureTextSpanOpts {
93
94
 
94
95
  const spaceCharacterRegex = /\s/
95
96
 
97
+ // Iterate by grapheme cluster (e.g. emoji sequences, flags, accented letters)
98
+ // rather than by code point, so a single glyph is measured as one unit.
99
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
100
+
96
101
  const initialDefaultStyles = Object.freeze({
97
102
  'overflow-wrap': 'break-word',
98
103
  'word-break': 'auto',
@@ -103,13 +108,21 @@ const initialDefaultStyles = Object.freeze({
103
108
  })
104
109
 
105
110
  /** @public */
106
- export class TextManager {
111
+ export class TextManager extends EditorManager {
107
112
  private elm: HTMLDivElement
108
113
  private poolElms: PoolItem[] = []
109
114
 
110
- constructor(public editor: Editor) {
115
+ constructor(editor: Editor) {
116
+ super(editor)
111
117
  this.elm = this.createMeasurementEl()
112
118
  this.editor.getContainer().appendChild(this.elm)
119
+ this.register(() => {
120
+ this.elm.remove()
121
+ for (const { el } of this.poolElms) {
122
+ el.remove()
123
+ }
124
+ this.poolElms.length = 0
125
+ })
113
126
  }
114
127
 
115
128
  private createMeasurementEl(): HTMLDivElement {
@@ -170,6 +183,9 @@ export class TextManager {
170
183
  'font-weight': opts.fontWeight,
171
184
  'font-size': opts.fontSize + 'px',
172
185
  'line-height': `${resolveLineHeightPx(opts.fontSize, opts.lineHeight)}px`,
186
+ // Unitless multiplier consumed by the .tl-rich-text h1–h6 rule (see editor.css),
187
+ // so heading measurement matches on-canvas rendering.
188
+ '--tl-rich-text-heading-line-height': `${opts.lineHeight}`,
173
189
  padding: opts.padding,
174
190
  'max-width': opts.maxWidth ? opts.maxWidth + 'px' : undefined,
175
191
  'min-width': opts.minWidth ? opts.minWidth + 'px' : undefined,
@@ -178,14 +194,6 @@ export class TextManager {
178
194
  }
179
195
  }
180
196
 
181
- dispose() {
182
- this.elm.remove()
183
- for (const { el } of this.poolElms) {
184
- el.remove()
185
- }
186
- this.poolElms.length = 0
187
- }
188
-
189
197
  private ensurePoolSize(size: number) {
190
198
  if (this.poolElms.length >= size) return
191
199
 
@@ -301,15 +309,24 @@ export class TextManager {
301
309
  for (const childNode of element.childNodes) {
302
310
  if (childNode.nodeType !== Node.TEXT_NODE) continue
303
311
 
304
- for (const char of childNode.textContent ?? '') {
305
- // place the range around the characters we're interested in
312
+ for (const { segment } of graphemeSegmenter.segment(childNode.textContent ?? '')) {
313
+ // place the range around the grapheme we're interested in
306
314
  range.setStart(textNode, idx)
307
- range.setEnd(textNode, idx + char.length)
315
+ range.setEnd(textNode, idx + segment.length)
308
316
  // measure the range. some browsers return multiple rects for the
309
317
  // first char in a new line - one for the line break, and one for
310
318
  // the character itself. we're only interested in the character.
311
319
  const rects = range.getClientRects()
312
- const rect = rects[rects.length - 1]!
320
+ const rect = rects[rects.length - 1]
321
+
322
+ // some graphemes produce no layout rectangle (e.g. zero-width
323
+ // spaces or newlines). skip them rather than crashing, but keep
324
+ // advancing the index so later graphemes stay aligned.
325
+ // See https://github.com/tldraw/tldraw/issues/9112.
326
+ if (!rect) {
327
+ idx += segment.length
328
+ continue
329
+ }
313
330
 
314
331
  // calculate the position of the character relative to the element
315
332
  const top = rect.top + offsetY
@@ -317,7 +334,7 @@ export class TextManager {
317
334
  const right = rect.right + offsetX
318
335
  const isRTL = left < prevCharLeftForRTLTest
319
336
 
320
- const isSpaceCharacter = spaceCharacterRegex.test(char)
337
+ const isSpaceCharacter = spaceCharacterRegex.test(segment)
321
338
  if (
322
339
  // If we're at a word boundary...
323
340
  isSpaceCharacter !== prevCharWasSpaceCharacter ||
@@ -341,7 +358,7 @@ export class TextManager {
341
358
  // start a new span
342
359
  currentSpan = {
343
360
  box: { x: left, y: top, w: rect.width, h: rect.height },
344
- text: char,
361
+ text: segment,
345
362
  }
346
363
  prevCharLeftForRTLTest = left
347
364
  } else {
@@ -352,16 +369,16 @@ export class TextManager {
352
369
 
353
370
  // otherwise we just need to extend the current span with the next character
354
371
  currentSpan.box.w = isRTL ? currentSpan.box.w + rect.width : right - currentSpan.box.x
355
- currentSpan.text += char
372
+ currentSpan.text += segment
356
373
  }
357
374
 
358
- if (char === '\n') {
375
+ if (segment === '\n') {
359
376
  prevCharLeftForRTLTest = 0
360
377
  }
361
378
 
362
379
  prevCharWasSpaceCharacter = isSpaceCharacter
363
380
  prevCharTop = top
364
- idx += char.length
381
+ idx += segment.length
365
382
  }
366
383
  }
367
384
 
@@ -19,7 +19,6 @@ describe('TickManager', () => {
19
19
  let editor: Mocked<Editor>
20
20
  let tickManager: TickManager
21
21
  let mockEmit: Mock
22
- let mockDisposablesAdd: Mock
23
22
 
24
23
  beforeEach(() => {
25
24
  vi.clearAllMocks()
@@ -38,13 +37,9 @@ describe('TickManager', () => {
38
37
  mockCancelAnimationFrame.mockImplementation(() => {})
39
38
 
40
39
  mockEmit = vi.fn()
41
- mockDisposablesAdd = vi.fn()
42
40
 
43
41
  editor = {
44
42
  emit: mockEmit,
45
- disposables: {
46
- add: mockDisposablesAdd,
47
- },
48
43
  } as unknown as Mocked<Editor>
49
44
 
50
45
  tickManager = new TickManager(editor)
@@ -58,15 +53,10 @@ describe('TickManager', () => {
58
53
 
59
54
  describe('constructor and initialization', () => {
60
55
  it('should initialize with correct default values', () => {
61
- expect(tickManager.editor).toBe(editor)
62
56
  expect(tickManager.isPaused).toBe(false)
63
57
  expect(tickManager.now).toBe(1000)
64
58
  })
65
59
 
66
- it('should add dispose method to editor disposables', () => {
67
- expect(mockDisposablesAdd).toHaveBeenCalledWith(tickManager.dispose)
68
- })
69
-
70
60
  it('should start the tick loop on construction', () => {
71
61
  expect(mockRequestAnimationFrame).toHaveBeenCalled()
72
62
  })
@@ -1,5 +1,6 @@
1
1
  import { throttleToNextFrame as _throttleToNextFrame, bind } from '@tldraw/utils'
2
2
  import type { Editor } from '../../Editor'
3
+ import { EditorManager } from '../EditorManager'
3
4
 
4
5
  const throttleToNextFrame =
5
6
  typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
@@ -13,9 +14,9 @@ const throttleToNextFrame =
13
14
  : _throttleToNextFrame
14
15
 
15
16
  /** @internal */
16
- export class TickManager {
17
- constructor(public editor: Editor) {
18
- this.editor.disposables.add(this.dispose)
17
+ export class TickManager extends EditorManager {
18
+ constructor(editor: Editor) {
19
+ super(editor)
19
20
  this.start()
20
21
  }
21
22
 
@@ -45,11 +46,9 @@ export class TickManager {
45
46
  this.cancelRaf = throttleToNextFrame(this.tick)
46
47
  }
47
48
 
48
- // Clear the listener
49
- @bind
50
49
  dispose() {
51
50
  this.isPaused = true
52
-
53
51
  this.cancelRaf?.()
52
+ super.dispose()
54
53
  }
55
54
  }
@@ -5,7 +5,7 @@ import { BaseBoxShapeUtil, TLBaseBoxShape } from './BaseBoxShapeUtil'
5
5
  import { TLDragShapesInInfo, TLDragShapesOutInfo } from './ShapeUtil'
6
6
 
7
7
  /**
8
- * A base class for frame-like shapes — containers that clip their children,
8
+ * A base class for frame-like shapes — containers that clip their children except arrows,
9
9
  * require full-brush selection, block erasure from inside, and support
10
10
  * drag-and-drop reparenting.
11
11
  *
@@ -17,6 +17,7 @@ import { TLDragShapesInInfo, TLDragShapesOutInfo } from './ShapeUtil'
17
17
  * - `canReceiveNewChildrenOfType()` returns `true` unless the container is locked
18
18
  * - `canRemoveChildrenOfType()` returns `true` unless the container is locked
19
19
  * - `getClipPath()` returns the shape geometry's vertices
20
+ * - `shouldClipChild()` clips all children except arrows
20
21
  * - `onDragShapesIn()` reparents shapes into the frame (with index restoration)
21
22
  * - `onDragShapesOut()` reparents shapes back to the page
22
23
  *
@@ -69,6 +70,10 @@ export abstract class BaseFrameLikeShapeUtil<
69
70
  return this.editor.getShapeGeometry(shape.id).vertices
70
71
  }
71
72
 
73
+ override shouldClipChild(child: TLShape): boolean {
74
+ return child.type !== 'arrow'
75
+ }
76
+
72
77
  override onDragShapesIn(
73
78
  shape: Shape,
74
79
  draggingShapes: TLShape[],
@@ -1039,6 +1039,7 @@ export interface TLCropInfo<T extends TLShape> {
1039
1039
  uncroppedSize: { w: number; h: number }
1040
1040
  initialShape: T
1041
1041
  aspectRatioLocked?: boolean
1042
+ isResizingFromCenter?: boolean
1042
1043
  }
1043
1044
 
1044
1045
  /** @public */
@@ -4,7 +4,6 @@ import { Vec } from '../../../../primitives/Vec'
4
4
  import type { Editor } from '../../../Editor'
5
5
  import { TLBaseBoxShape } from '../../../shapes/BaseBoxShapeUtil'
6
6
  import { TLPointerEventInfo } from '../../../types/event-types'
7
- import { cancelShapeCreationOnLongPress } from '../../cancelShapeCreationOnLongPress'
8
7
  import { StateNode } from '../../StateNode'
9
8
  import type { BaseBoxShapeTool } from '../BaseBoxShapeTool'
10
9
 
@@ -67,7 +66,8 @@ export class Pointing extends StateNode {
67
66
  }
68
67
 
69
68
  override onLongPress() {
70
- cancelShapeCreationOnLongPress(this.editor, () => this.cancel())
69
+ // On a touch (coarse pointer) long-press, cancel the pending shape so it leaves nothing behind.
70
+ if (this.editor.getInstanceState().isCoarsePointer) this.cancel()
71
71
  }
72
72
 
73
73
  override onCancel() {
@@ -6,6 +6,7 @@ import { TLEventInfo } from './event-types'
6
6
  export interface TLEventMap {
7
7
  // Lifecycle / Internal
8
8
  mount: []
9
+ unmount: []
9
10
  'max-shapes': [{ name: string; pageId: TLPageId; count: number }]
10
11
  change: [HistoryEntry<TLRecord>]
11
12
  update: []