@tldraw/editor 5.3.0-canary.263f9dbe88bb → 5.3.0-canary.2deebf21bdcd

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 (48) hide show
  1. package/dist-cjs/index.d.ts +31 -2
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/TldrawEditor.js +6 -1
  4. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  5. package/dist-cjs/lib/editor/Editor.js +65 -13
  6. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  7. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +9 -0
  8. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  9. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +18 -29
  10. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +96 -54
  12. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  13. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -0
  14. package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
  15. package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
  16. package/dist-cjs/lib/options.js +2 -1
  17. package/dist-cjs/lib/options.js.map +2 -2
  18. package/dist-cjs/version.js +3 -3
  19. package/dist-cjs/version.js.map +1 -1
  20. package/dist-esm/index.d.mts +31 -2
  21. package/dist-esm/index.mjs +1 -1
  22. package/dist-esm/lib/TldrawEditor.mjs +6 -1
  23. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  24. package/dist-esm/lib/editor/Editor.mjs +65 -13
  25. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  26. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +9 -0
  27. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  28. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +18 -29
  29. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +2 -2
  30. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +103 -56
  31. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  32. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -0
  33. package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
  34. package/dist-esm/lib/options.mjs +2 -1
  35. package/dist-esm/lib/options.mjs.map +2 -2
  36. package/dist-esm/version.mjs +3 -3
  37. package/dist-esm/version.mjs.map +1 -1
  38. package/package.json +7 -7
  39. package/src/lib/TldrawEditor.tsx +10 -1
  40. package/src/lib/editor/Editor.ts +97 -18
  41. package/src/lib/editor/managers/FontManager/FontManager.test.ts +66 -0
  42. package/src/lib/editor/managers/FontManager/FontManager.ts +14 -0
  43. package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +19 -33
  44. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +173 -86
  45. package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +6 -1
  46. package/src/lib/editor/types/emit-types.ts +1 -0
  47. package/src/lib/options.ts +3 -1
  48. package/src/version.ts +3 -3
@@ -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(
@@ -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[],
@@ -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: []
@@ -86,6 +86,7 @@ export interface TldrawOptions {
86
86
  readonly collaboratorCheckIntervalMs: number
87
87
  readonly cameraMovingTimeoutMs: number
88
88
  readonly hitTestMargin: number
89
+ readonly coarseHitTestMargin: number
89
90
  readonly edgeScrollDelay: number
90
91
  readonly edgeScrollEaseDuration: number
91
92
  readonly edgeScrollSpeed: number
@@ -312,7 +313,8 @@ export const defaultTldrawOptions = {
312
313
  collaboratorIdleTimeoutMs: 3000,
313
314
  collaboratorCheckIntervalMs: 1200,
314
315
  cameraMovingTimeoutMs: 64,
315
- hitTestMargin: 8,
316
+ hitTestMargin: 3,
317
+ coarseHitTestMargin: 4,
316
318
  edgeScrollDelay: 200,
317
319
  edgeScrollEaseDuration: 200,
318
320
  edgeScrollSpeed: 25,
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.3.0-canary.263f9dbe88bb'
4
+ export const version = '5.3.0-canary.2deebf21bdcd'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-07-01T09:30:40.438Z',
8
- patch: '2026-07-01T09:30:40.438Z',
7
+ minor: '2026-07-09T17:39:43.688Z',
8
+ patch: '2026-07-09T17:39:43.688Z',
9
9
  }