@tldraw/editor 5.2.1 → 5.3.0-canary.7d00ddf70f63

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 (70) hide show
  1. package/dist-cjs/index.d.ts +45 -8
  2. package/dist-cjs/index.js +3 -3
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/editor/Editor.js +12 -2
  5. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  6. package/dist-cjs/lib/editor/managers/EditorManager.js +52 -0
  7. package/dist-cjs/lib/editor/managers/EditorManager.js.map +7 -0
  8. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +9 -11
  9. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
  10. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +5 -9
  11. package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
  12. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +18 -29
  13. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +2 -2
  14. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +96 -54
  15. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  16. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +24 -19
  17. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  18. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +4 -7
  19. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  20. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +1 -1
  21. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -2
  22. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
  23. package/dist-cjs/version.js +3 -3
  24. package/dist-cjs/version.js.map +1 -1
  25. package/dist-esm/index.d.mts +45 -8
  26. package/dist-esm/index.mjs +3 -3
  27. package/dist-esm/index.mjs.map +2 -2
  28. package/dist-esm/lib/editor/Editor.mjs +12 -2
  29. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  30. package/dist-esm/lib/editor/managers/EditorManager.mjs +32 -0
  31. package/dist-esm/lib/editor/managers/EditorManager.mjs.map +7 -0
  32. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +9 -11
  33. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
  34. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +5 -9
  35. package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
  36. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +18 -29
  37. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +2 -2
  38. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +103 -56
  39. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  40. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +24 -19
  41. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  42. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +4 -7
  43. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  44. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +1 -1
  45. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -2
  46. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
  47. package/dist-esm/version.mjs +3 -3
  48. package/dist-esm/version.mjs.map +1 -1
  49. package/package.json +7 -7
  50. package/src/index.ts +1 -1
  51. package/src/lib/editor/Editor.ts +15 -2
  52. package/src/lib/editor/managers/EditorManager.test.ts +51 -0
  53. package/src/lib/editor/managers/EditorManager.ts +60 -0
  54. package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +2 -1
  55. package/src/lib/editor/managers/FocusManager/FocusManager.ts +10 -14
  56. package/src/lib/editor/managers/InputsManager/InputsManager.ts +6 -9
  57. package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +19 -33
  58. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +173 -86
  59. package/src/lib/editor/managers/TextManager/TextManager.test.ts +61 -1
  60. package/src/lib/editor/managers/TextManager/TextManager.ts +33 -19
  61. package/src/lib/editor/managers/TickManager/TickManager.test.ts +0 -10
  62. package/src/lib/editor/managers/TickManager/TickManager.ts +5 -6
  63. package/src/lib/editor/shapes/ShapeUtil.ts +1 -0
  64. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +2 -2
  65. package/src/version.ts +3 -3
  66. package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js +0 -28
  67. package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js.map +0 -7
  68. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs +0 -8
  69. package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs.map +0 -7
  70. 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 {
@@ -178,14 +191,6 @@ export class TextManager {
178
191
  }
179
192
  }
180
193
 
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
194
  private ensurePoolSize(size: number) {
190
195
  if (this.poolElms.length >= size) return
191
196
 
@@ -301,15 +306,24 @@ export class TextManager {
301
306
  for (const childNode of element.childNodes) {
302
307
  if (childNode.nodeType !== Node.TEXT_NODE) continue
303
308
 
304
- for (const char of childNode.textContent ?? '') {
305
- // place the range around the characters we're interested in
309
+ for (const { segment } of graphemeSegmenter.segment(childNode.textContent ?? '')) {
310
+ // place the range around the grapheme we're interested in
306
311
  range.setStart(textNode, idx)
307
- range.setEnd(textNode, idx + char.length)
312
+ range.setEnd(textNode, idx + segment.length)
308
313
  // measure the range. some browsers return multiple rects for the
309
314
  // first char in a new line - one for the line break, and one for
310
315
  // the character itself. we're only interested in the character.
311
316
  const rects = range.getClientRects()
312
- const rect = rects[rects.length - 1]!
317
+ const rect = rects[rects.length - 1]
318
+
319
+ // some graphemes produce no layout rectangle (e.g. zero-width
320
+ // spaces or newlines). skip them rather than crashing, but keep
321
+ // advancing the index so later graphemes stay aligned.
322
+ // See https://github.com/tldraw/tldraw/issues/9112.
323
+ if (!rect) {
324
+ idx += segment.length
325
+ continue
326
+ }
313
327
 
314
328
  // calculate the position of the character relative to the element
315
329
  const top = rect.top + offsetY
@@ -317,7 +331,7 @@ export class TextManager {
317
331
  const right = rect.right + offsetX
318
332
  const isRTL = left < prevCharLeftForRTLTest
319
333
 
320
- const isSpaceCharacter = spaceCharacterRegex.test(char)
334
+ const isSpaceCharacter = spaceCharacterRegex.test(segment)
321
335
  if (
322
336
  // If we're at a word boundary...
323
337
  isSpaceCharacter !== prevCharWasSpaceCharacter ||
@@ -341,7 +355,7 @@ export class TextManager {
341
355
  // start a new span
342
356
  currentSpan = {
343
357
  box: { x: left, y: top, w: rect.width, h: rect.height },
344
- text: char,
358
+ text: segment,
345
359
  }
346
360
  prevCharLeftForRTLTest = left
347
361
  } else {
@@ -352,16 +366,16 @@ export class TextManager {
352
366
 
353
367
  // otherwise we just need to extend the current span with the next character
354
368
  currentSpan.box.w = isRTL ? currentSpan.box.w + rect.width : right - currentSpan.box.x
355
- currentSpan.text += char
369
+ currentSpan.text += segment
356
370
  }
357
371
 
358
- if (char === '\n') {
372
+ if (segment === '\n') {
359
373
  prevCharLeftForRTLTest = 0
360
374
  }
361
375
 
362
376
  prevCharWasSpaceCharacter = isSpaceCharacter
363
377
  prevCharTop = top
364
- idx += char.length
378
+ idx += segment.length
365
379
  }
366
380
  }
367
381
 
@@ -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
  }
@@ -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() {
package/src/version.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '5.2.1'
4
+ export const version = '5.3.0-canary.7d00ddf70f63'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-07-01T09:20:01.735Z',
8
- patch: '2026-07-01T10:28:54.350Z',
7
+ minor: '2026-07-01T14:32:31.738Z',
8
+ patch: '2026-07-01T14:32:31.738Z',
9
9
  }
@@ -1,28 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
- var cancelShapeCreationOnLongPress_exports = {};
20
- __export(cancelShapeCreationOnLongPress_exports, {
21
- cancelShapeCreationOnLongPress: () => cancelShapeCreationOnLongPress
22
- });
23
- module.exports = __toCommonJS(cancelShapeCreationOnLongPress_exports);
24
- function cancelShapeCreationOnLongPress(editor, cancelPendingCreation) {
25
- if (!editor.getInstanceState().isCoarsePointer) return;
26
- cancelPendingCreation();
27
- }
28
- //# sourceMappingURL=cancelShapeCreationOnLongPress.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/lib/editor/tools/cancelShapeCreationOnLongPress.ts"],
4
- "sourcesContent": ["import type { Editor } from '../Editor'\n\n/**\n * Shared handler for a long-press in a discrete shape-creation tool's pointing\n * state (geo, note, line, text, arrow, frame). On a coarse pointer this cancels\n * any pending creation so the long-press leaves no shape behind (issue #8277).\n * It does not open a context menu \u2014 that is a select-tool surface, reached on\n * touch only from the select tool, and on desktop from a right-click (which the\n * editor routes through the select tool).\n *\n * Gated on `isCoarsePointer`: on a fine pointer (mouse) the long-press timer\n * powers the deliberate \"pause before drag to start a precise arrow\" gesture,\n * which we must not disturb. The bug only manifests on coarse pointers, because\n * that is also where the browser fires `contextmenu` at the long-press mark.\n *\n * The `cancelPendingCreation` callback is the tool's own cleanup \u2014 it knows\n * whether it needs to `bailToMark` a shape it created early or simply transition\n * back to idle. Continuous-gesture tools (draw, highlight, eraser, laser) are\n * excluded: their press is their own action, so they handle (or ignore) the\n * long-press themselves.\n *\n * @internal\n */\nexport function cancelShapeCreationOnLongPress(\n\teditor: Editor,\n\tcancelPendingCreation: () => void\n): void {\n\tif (!editor.getInstanceState().isCoarsePointer) return\n\n\tcancelPendingCreation()\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAuBO,SAAS,+BACf,QACA,uBACO;AACP,MAAI,CAAC,OAAO,iBAAiB,EAAE,gBAAiB;AAEhD,wBAAsB;AACvB;",
6
- "names": []
7
- }