@tldraw/editor 4.3.0 → 4.4.0-canary.6f91153ede5e

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 (39) hide show
  1. package/README.md +1 -1
  2. package/dist-cjs/index.d.ts +5 -0
  3. package/dist-cjs/index.js +3 -1
  4. package/dist-cjs/index.js.map +2 -2
  5. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +1 -2
  6. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  7. package/dist-cjs/lib/editor/Editor.js +54 -5
  8. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  9. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js +13 -21
  10. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +144 -0
  12. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +7 -0
  13. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +181 -0
  14. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +7 -0
  15. package/dist-cjs/version.js +3 -3
  16. package/dist-cjs/version.js.map +1 -1
  17. package/dist-esm/index.d.mts +5 -0
  18. package/dist-esm/index.mjs +3 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +1 -2
  21. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  22. package/dist-esm/lib/editor/Editor.mjs +54 -5
  23. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  24. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs +13 -21
  25. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs.map +2 -2
  26. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +114 -0
  27. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +7 -0
  28. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +161 -0
  29. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +7 -0
  30. package/dist-esm/version.mjs +3 -3
  31. package/dist-esm/version.mjs.map +1 -1
  32. package/package.json +10 -8
  33. package/src/index.ts +1 -0
  34. package/src/lib/components/default-components/DefaultCanvas.tsx +1 -5
  35. package/src/lib/editor/Editor.ts +74 -5
  36. package/src/lib/editor/derivations/notVisibleShapes.ts +15 -41
  37. package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +144 -0
  38. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +215 -0
  39. package/src/version.ts +3 -3
@@ -9,60 +9,34 @@ import { Editor } from '../Editor'
9
9
  * @returns Incremental derivation of non visible shapes.
10
10
  */
11
11
  export function notVisibleShapes(editor: Editor) {
12
- return computed<Set<TLShapeId>>('notVisibleShapes', function updateNotVisibleShapes(prevValue) {
13
- const shapeIds = editor.getCurrentPageShapeIds()
14
- const nextValue = new Set<TLShapeId>()
15
-
16
- // Extract viewport bounds once to avoid repeated property access
12
+ return computed<Set<TLShapeId>>('notVisibleShapes', function (prevValue) {
13
+ const allShapeIds = editor.getCurrentPageShapeIds()
17
14
  const viewportPageBounds = editor.getViewportPageBounds()
18
- const viewMinX = viewportPageBounds.minX
19
- const viewMinY = viewportPageBounds.minY
20
- const viewMaxX = viewportPageBounds.maxX
21
- const viewMaxY = viewportPageBounds.maxY
15
+ const visibleIds = editor.getShapeIdsInsideBounds(viewportPageBounds)
22
16
 
23
- for (const id of shapeIds) {
24
- const pageBounds = editor.getShapePageBounds(id)
25
-
26
- // Hybrid check: if bounds exist and shape overlaps viewport, it's visible.
27
- // This inlines Box.Collides to avoid function call overhead and the
28
- // redundant Contains check that Box.Includes was doing.
29
- if (
30
- pageBounds !== undefined &&
31
- pageBounds.maxX >= viewMinX &&
32
- pageBounds.minX <= viewMaxX &&
33
- pageBounds.maxY >= viewMinY &&
34
- pageBounds.minY <= viewMaxY
35
- ) {
36
- continue
37
- }
17
+ const nextValue = new Set<TLShapeId>()
38
18
 
39
- // Shape is outside viewport or has no bounds - check if it can be culled.
40
- // We defer getShape and canCull checks until here since most shapes are
41
- // typically visible and we can skip these calls for them.
42
- const shape = editor.getShape(id)
43
- if (!shape) continue
19
+ // Non-visible shapes are all shapes minus visible shapes
20
+ for (const id of allShapeIds) {
21
+ if (!visibleIds.has(id)) {
22
+ const shape = editor.getShape(id)
23
+ if (!shape) continue
44
24
 
45
- const canCull = editor.getShapeUtil(shape.type).canCull(shape)
46
- if (!canCull) continue
25
+ const canCull = editor.getShapeUtil(shape.type).canCull(shape)
26
+ if (!canCull) continue
47
27
 
48
- nextValue.add(id)
28
+ nextValue.add(id)
29
+ }
49
30
  }
50
31
 
51
- if (isUninitialized(prevValue)) {
32
+ if (isUninitialized(prevValue) || prevValue.size !== nextValue.size) {
52
33
  return nextValue
53
34
  }
54
35
 
55
- // If there are more or less shapes, we know there's a change
56
- if (prevValue.size !== nextValue.size) return nextValue
57
-
58
- // If any of the old shapes are not in the new set, we know there's a change
59
36
  for (const prev of prevValue) {
60
- if (!nextValue.has(prev)) {
61
- return nextValue
62
- }
37
+ if (!nextValue.has(prev)) return nextValue
63
38
  }
64
39
 
65
- // If we've made it here, we know that the set is the same
66
40
  return prevValue
67
41
  })
68
42
  }
@@ -0,0 +1,144 @@
1
+ import type { TLShapeId } from '@tldraw/tlschema'
2
+ import RBush from 'rbush'
3
+ import { Box } from '../../../primitives/Box'
4
+
5
+ /**
6
+ * Element stored in the R-tree spatial index.
7
+ * Contains bounds (minX, minY, maxX, maxY) and shape ID.
8
+ */
9
+ export interface SpatialElement {
10
+ minX: number
11
+ minY: number
12
+ maxX: number
13
+ maxY: number
14
+ id: TLShapeId
15
+ }
16
+
17
+ /**
18
+ * Custom RBush class for tldraw shapes.
19
+ */
20
+ class TldrawRBush extends RBush<SpatialElement> {}
21
+
22
+ /**
23
+ * Wrapper around RBush R-tree for efficient spatial queries.
24
+ * Maintains a map of elements currently in the tree for efficient updates.
25
+ */
26
+ export class RBushIndex {
27
+ private rBush: TldrawRBush
28
+ private elementsInTree: Map<TLShapeId, SpatialElement>
29
+
30
+ constructor() {
31
+ this.rBush = new TldrawRBush()
32
+ this.elementsInTree = new Map()
33
+ }
34
+
35
+ /**
36
+ * Search for shapes within the given bounds.
37
+ * Returns set of shape IDs that intersect with the bounds.
38
+ */
39
+ search(bounds: Box): Set<TLShapeId> {
40
+ const results = this.rBush.search({
41
+ minX: bounds.minX,
42
+ minY: bounds.minY,
43
+ maxX: bounds.maxX,
44
+ maxY: bounds.maxY,
45
+ })
46
+ return new Set(results.map((e: SpatialElement) => e.id))
47
+ }
48
+
49
+ /**
50
+ * Insert or update a shape in the spatial index.
51
+ * If the shape already exists, it will be removed first to prevent duplicates.
52
+ */
53
+ upsert(id: TLShapeId, bounds: Box): void {
54
+ // Remove existing entry to prevent map-tree desync
55
+ const existing = this.elementsInTree.get(id)
56
+ if (existing) {
57
+ this.rBush.remove(existing)
58
+ }
59
+
60
+ const element: SpatialElement = {
61
+ minX: bounds.minX,
62
+ minY: bounds.minY,
63
+ maxX: bounds.maxX,
64
+ maxY: bounds.maxY,
65
+ id,
66
+ }
67
+ this.rBush.insert(element)
68
+ this.elementsInTree.set(id, element)
69
+ }
70
+
71
+ /**
72
+ * Remove a shape from the spatial index.
73
+ */
74
+ remove(id: TLShapeId): void {
75
+ const element = this.elementsInTree.get(id)
76
+ if (element) {
77
+ this.rBush.remove(element)
78
+ this.elementsInTree.delete(id)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Bulk load elements into the spatial index.
84
+ * More efficient than individual inserts for initial loading.
85
+ */
86
+ bulkLoad(elements: SpatialElement[]): void {
87
+ this.rBush.load(elements)
88
+ for (const element of elements) {
89
+ this.elementsInTree.set(element.id, element)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Clear all elements from the spatial index.
95
+ */
96
+ clear(): void {
97
+ this.rBush.clear()
98
+ this.elementsInTree.clear()
99
+ }
100
+
101
+ /**
102
+ * Check if a shape is in the spatial index.
103
+ */
104
+ has(id: TLShapeId): boolean {
105
+ return this.elementsInTree.has(id)
106
+ }
107
+
108
+ /**
109
+ * Get the number of elements in the spatial index.
110
+ */
111
+ getSize(): number {
112
+ return this.elementsInTree.size
113
+ }
114
+
115
+ /**
116
+ * Get all shape IDs currently in the spatial index.
117
+ */
118
+ getAllShapeIds(): TLShapeId[] {
119
+ return Array.from(this.elementsInTree.keys())
120
+ }
121
+
122
+ /**
123
+ * Get the bounds currently stored in the spatial index for a shape.
124
+ * Returns undefined if the shape is not in the index.
125
+ */
126
+ getBounds(id: TLShapeId): Box | undefined {
127
+ const element = this.elementsInTree.get(id)
128
+ if (!element) return undefined
129
+ return new Box(
130
+ element.minX,
131
+ element.minY,
132
+ element.maxX - element.minX,
133
+ element.maxY - element.minY
134
+ )
135
+ }
136
+
137
+ /**
138
+ * Dispose of the spatial index.
139
+ * Clears all data structures to prevent memory leaks.
140
+ */
141
+ dispose(): void {
142
+ this.clear()
143
+ }
144
+ }
@@ -0,0 +1,215 @@
1
+ import { Computed, RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
2
+ import type { RecordsDiff } from '@tldraw/store'
3
+ import type { TLRecord } from '@tldraw/tlschema'
4
+ import { TLPageId, TLShape, TLShapeId, isShape } from '@tldraw/tlschema'
5
+ import { objectMapValues } from '@tldraw/utils'
6
+ import { Box } from '../../../primitives/Box'
7
+ import type { Editor } from '../../Editor'
8
+ import { RBushIndex, type SpatialElement } from './RBushIndex'
9
+
10
+ /**
11
+ * Manages spatial indexing for efficient shape location queries.
12
+ *
13
+ * 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.
16
+ *
17
+ * 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
21
+ * - Per-page index (rebuilds on page change)
22
+ * - Optimized for viewport culling queries
23
+ *
24
+ * @internal
25
+ */
26
+ export class SpatialIndexManager {
27
+ private rbush: RBushIndex
28
+ private spatialIndexComputed: Computed<number>
29
+ private lastPageId: TLPageId | null = null
30
+
31
+ constructor(public readonly editor: Editor) {
32
+ this.rbush = new RBushIndex()
33
+ this.spatialIndexComputed = this.createSpatialIndexComputed()
34
+ }
35
+
36
+ private createSpatialIndexComputed() {
37
+ const shapeHistory = this.editor.store.query.filterHistory('shape')
38
+
39
+ return computed<number>('spatialIndex', (_prevValue, lastComputedEpoch) => {
40
+ if (isUninitialized(_prevValue)) {
41
+ return this.buildFromScratch(lastComputedEpoch)
42
+ }
43
+
44
+ const shapeDiff = shapeHistory.getDiffSince(lastComputedEpoch)
45
+
46
+ if (shapeDiff === RESET_VALUE) {
47
+ return this.buildFromScratch(lastComputedEpoch)
48
+ }
49
+
50
+ const currentPageId = this.editor.getCurrentPageId()
51
+ if (this.lastPageId !== currentPageId) {
52
+ return this.buildFromScratch(lastComputedEpoch)
53
+ }
54
+
55
+ // No shape changes - index is already up to date
56
+ if (shapeDiff.length === 0) {
57
+ return lastComputedEpoch
58
+ }
59
+
60
+ // Process incremental updates
61
+ this.processIncrementalUpdate(shapeDiff)
62
+
63
+ return lastComputedEpoch
64
+ })
65
+ }
66
+
67
+ private buildFromScratch(epoch: number): number {
68
+ this.rbush.clear()
69
+ this.lastPageId = this.editor.getCurrentPageId()
70
+
71
+ const shapes = this.editor.getCurrentPageShapes()
72
+ const elements: SpatialElement[] = []
73
+
74
+ // Collect all shape elements for bulk loading
75
+ for (const shape of shapes) {
76
+ const bounds = this.editor.getShapePageBounds(shape.id)
77
+ if (bounds) {
78
+ elements.push({
79
+ minX: bounds.minX,
80
+ minY: bounds.minY,
81
+ maxX: bounds.maxX,
82
+ maxY: bounds.maxY,
83
+ id: shape.id,
84
+ })
85
+ }
86
+ }
87
+
88
+ // Bulk load for efficiency
89
+ this.rbush.bulkLoad(elements)
90
+
91
+ return epoch
92
+ }
93
+
94
+ private processIncrementalUpdate(shapeDiff: RecordsDiff<TLRecord>[]): void {
95
+ // Track shapes we've already processed from the diff
96
+ const processedShapeIds = new Set<TLShapeId>()
97
+
98
+ // 1. Process shape additions, removals, and updates from diff
99
+ for (const changes of shapeDiff) {
100
+ // Handle additions (only for shapes on current page)
101
+ for (const shape of objectMapValues(changes.added) as TLShape[]) {
102
+ if (isShape(shape) && this.editor.getAncestorPageId(shape) === this.lastPageId) {
103
+ const bounds = this.editor.getShapePageBounds(shape.id)
104
+ if (bounds) {
105
+ this.rbush.upsert(shape.id, bounds)
106
+ }
107
+ processedShapeIds.add(shape.id)
108
+ }
109
+ }
110
+
111
+ // Handle removals
112
+ for (const shape of objectMapValues(changes.removed) as TLShape[]) {
113
+ if (isShape(shape)) {
114
+ this.rbush.remove(shape.id)
115
+ processedShapeIds.add(shape.id)
116
+ }
117
+ }
118
+
119
+ // Handle updated shapes: page changes and bounds updates
120
+ for (const [from, to] of objectMapValues(changes.updated) as [TLShape, TLShape][]) {
121
+ if (!isShape(to)) continue
122
+ processedShapeIds.add(to.id)
123
+
124
+ const wasOnPage = this.editor.getAncestorPageId(from) === this.lastPageId
125
+ const isOnPage = this.editor.getAncestorPageId(to) === this.lastPageId
126
+
127
+ if (isOnPage) {
128
+ const bounds = this.editor.getShapePageBounds(to.id)
129
+ if (bounds) {
130
+ this.rbush.upsert(to.id, bounds)
131
+ }
132
+ } else if (wasOnPage) {
133
+ this.rbush.remove(to.id)
134
+ }
135
+ }
136
+ }
137
+
138
+ // 2. Check remaining shapes in index for bounds changes
139
+ // This handles shapes with computed bounds (arrows bound to moved shapes, groups with moved children, etc.)
140
+ const allShapeIds = this.rbush.getAllShapeIds()
141
+
142
+ for (const shapeId of allShapeIds) {
143
+ if (processedShapeIds.has(shapeId)) continue
144
+
145
+ const currentBounds = this.editor.getShapePageBounds(shapeId)
146
+ const indexedBounds = this.rbush.getBounds(shapeId)
147
+
148
+ if (!this.areBoundsEqual(currentBounds, indexedBounds)) {
149
+ if (currentBounds) {
150
+ this.rbush.upsert(shapeId, currentBounds)
151
+ } else {
152
+ this.rbush.remove(shapeId)
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ private areBoundsEqual(a: Box | undefined, b: Box | undefined): boolean {
159
+ if (!a && !b) return true
160
+ if (!a || !b) return false
161
+ return a.minX === b.minX && a.minY === b.minY && a.maxX === b.maxX && a.maxY === b.maxY
162
+ }
163
+
164
+ /**
165
+ * Get shape IDs within the given bounds.
166
+ * Optimized for viewport culling queries.
167
+ *
168
+ * Note: Results are unordered. If you need z-order, combine with sorted shapes:
169
+ * ```ts
170
+ * const candidates = editor.spatialIndex.getShapeIdsInsideBounds(bounds)
171
+ * const sorted = editor.getCurrentPageShapesSorted().filter(s => candidates.has(s.id))
172
+ * ```
173
+ *
174
+ * @param bounds - The bounds to search within
175
+ * @returns Unordered set of shape IDs within the bounds
176
+ *
177
+ * @public
178
+ */
179
+ getShapeIdsInsideBounds(bounds: Box): Set<TLShapeId> {
180
+ this.spatialIndexComputed.get()
181
+ return this.rbush.search(bounds)
182
+ }
183
+
184
+ /**
185
+ * Get shape IDs at a point (with optional margin).
186
+ * Creates a small bounding box around the point and searches the spatial index.
187
+ *
188
+ * Note: Results are unordered. If you need z-order, combine with sorted shapes:
189
+ * ```ts
190
+ * const candidates = editor.spatialIndex.getShapeIdsAtPoint(point, margin)
191
+ * const sorted = editor.getCurrentPageShapesSorted().filter(s => candidates.has(s.id))
192
+ * ```
193
+ *
194
+ * @param point - The point to search at
195
+ * @param margin - The margin around the point to search (default: 0)
196
+ * @returns Unordered set of shape IDs that could potentially contain the point
197
+ *
198
+ * @public
199
+ */
200
+ getShapeIdsAtPoint(point: { x: number; y: number }, margin = 0): Set<TLShapeId> {
201
+ this.spatialIndexComputed.get()
202
+ return this.rbush.search(new Box(point.x - margin, point.y - margin, margin * 2, margin * 2))
203
+ }
204
+
205
+ /**
206
+ * Dispose of the spatial index manager.
207
+ * Clears the R-tree to prevent memory leaks.
208
+ *
209
+ * @public
210
+ */
211
+ dispose(): void {
212
+ this.rbush.dispose()
213
+ this.lastPageId = null
214
+ }
215
+ }
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 = '4.3.0'
4
+ export const version = '4.4.0-canary.6f91153ede5e'
5
5
  export const publishDates = {
6
6
  major: '2025-09-18T14:39:22.803Z',
7
- minor: '2026-01-21T11:56:39.106Z',
8
- patch: '2026-01-21T11:56:39.106Z',
7
+ minor: '2026-01-21T12:39:00.559Z',
8
+ patch: '2026-01-21T12:39:00.559Z',
9
9
  }