@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.
- package/dist-cjs/index.d.ts +31 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TldrawEditor.js +6 -1
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +65 -13
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +9 -0
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +18 -29
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +96 -54
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -0
- package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
- package/dist-cjs/lib/options.js +2 -1
- package/dist-cjs/lib/options.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +31 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TldrawEditor.mjs +6 -1
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +65 -13
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +9 -0
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +18 -29
- package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +103 -56
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -0
- package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/options.mjs +2 -1
- package/dist-esm/lib/options.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/package.json +7 -7
- package/src/lib/TldrawEditor.tsx +10 -1
- package/src/lib/editor/Editor.ts +97 -18
- package/src/lib/editor/managers/FontManager/FontManager.test.ts +66 -0
- package/src/lib/editor/managers/FontManager/FontManager.ts +14 -0
- package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +19 -33
- package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +173 -86
- package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +6 -1
- package/src/lib/editor/types/emit-types.ts +1 -0
- package/src/lib/options.ts +3 -1
- package/src/version.ts +3 -3
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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(
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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)
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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[],
|
package/src/lib/options.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
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-
|
|
8
|
-
patch: '2026-07-
|
|
7
|
+
minor: '2026-07-09T17:39:43.688Z',
|
|
8
|
+
patch: '2026-07-09T17:39:43.688Z',
|
|
9
9
|
}
|