@tldraw/editor 4.3.0 → 4.4.0-canary.09e80a09d230

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 (98) hide show
  1. package/README.md +1 -1
  2. package/dist-cjs/index.d.ts +180 -11
  3. package/dist-cjs/index.js +3 -1
  4. package/dist-cjs/index.js.map +2 -2
  5. package/dist-cjs/lib/components/LiveCollaborators.js +14 -24
  6. package/dist-cjs/lib/components/LiveCollaborators.js.map +2 -2
  7. package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js +201 -0
  8. package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js.map +7 -0
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +30 -16
  10. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  11. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js +3 -1
  12. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js.map +2 -2
  13. package/dist-cjs/lib/components/default-components/DefaultShapeIndicators.js +13 -1
  14. package/dist-cjs/lib/components/default-components/DefaultShapeIndicators.js.map +2 -2
  15. package/dist-cjs/lib/config/TLUserPreferences.js +9 -3
  16. package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
  17. package/dist-cjs/lib/editor/Editor.js +58 -6
  18. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  19. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js +13 -21
  20. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +378 -89
  22. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +144 -0
  24. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +7 -0
  25. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +180 -0
  26. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +7 -0
  27. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +8 -3
  28. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  29. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +29 -0
  30. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  31. package/dist-cjs/lib/hooks/usePeerIds.js +29 -0
  32. package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
  33. package/dist-cjs/lib/options.js +1 -0
  34. package/dist-cjs/lib/options.js.map +2 -2
  35. package/dist-cjs/lib/utils/collaboratorState.js +42 -0
  36. package/dist-cjs/lib/utils/collaboratorState.js.map +7 -0
  37. package/dist-cjs/version.js +3 -3
  38. package/dist-cjs/version.js.map +1 -1
  39. package/dist-esm/index.d.mts +180 -11
  40. package/dist-esm/index.mjs +3 -1
  41. package/dist-esm/index.mjs.map +2 -2
  42. package/dist-esm/lib/components/LiveCollaborators.mjs +17 -24
  43. package/dist-esm/lib/components/LiveCollaborators.mjs.map +2 -2
  44. package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs +181 -0
  45. package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs.map +7 -0
  46. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +30 -16
  47. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  48. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs +3 -1
  49. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs.map +2 -2
  50. package/dist-esm/lib/components/default-components/DefaultShapeIndicators.mjs +13 -1
  51. package/dist-esm/lib/components/default-components/DefaultShapeIndicators.mjs.map +2 -2
  52. package/dist-esm/lib/config/TLUserPreferences.mjs +9 -3
  53. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  54. package/dist-esm/lib/editor/Editor.mjs +58 -6
  55. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  56. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs +13 -21
  57. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs.map +2 -2
  58. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +378 -89
  59. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  60. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +114 -0
  61. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +7 -0
  62. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +160 -0
  63. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +7 -0
  64. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +8 -3
  65. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  66. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +29 -0
  67. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  68. package/dist-esm/lib/hooks/usePeerIds.mjs +33 -1
  69. package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
  70. package/dist-esm/lib/options.mjs +1 -0
  71. package/dist-esm/lib/options.mjs.map +2 -2
  72. package/dist-esm/lib/utils/collaboratorState.mjs +22 -0
  73. package/dist-esm/lib/utils/collaboratorState.mjs.map +7 -0
  74. package/dist-esm/version.mjs +3 -3
  75. package/dist-esm/version.mjs.map +1 -1
  76. package/editor.css +6 -0
  77. package/package.json +10 -8
  78. package/src/index.ts +3 -0
  79. package/src/lib/components/LiveCollaborators.tsx +26 -37
  80. package/src/lib/components/default-components/CanvasShapeIndicators.tsx +244 -0
  81. package/src/lib/components/default-components/DefaultCanvas.tsx +16 -6
  82. package/src/lib/components/default-components/DefaultShapeIndicator.tsx +6 -1
  83. package/src/lib/components/default-components/DefaultShapeIndicators.tsx +16 -1
  84. package/src/lib/config/TLUserPreferences.test.ts +1 -0
  85. package/src/lib/config/TLUserPreferences.ts +8 -0
  86. package/src/lib/editor/Editor.ts +84 -6
  87. package/src/lib/editor/derivations/notVisibleShapes.ts +15 -41
  88. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +491 -106
  89. package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +144 -0
  90. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +214 -0
  91. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +24 -0
  92. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +8 -0
  93. package/src/lib/editor/shapes/ShapeUtil.ts +44 -0
  94. package/src/lib/hooks/usePeerIds.ts +46 -1
  95. package/src/lib/options.ts +7 -0
  96. package/src/lib/utils/collaboratorState.ts +54 -0
  97. package/src/version.ts +3 -3
  98. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.test.ts +0 -621
@@ -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,214 @@
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 [, to] of objectMapValues(changes.updated) as [TLShape, TLShape][]) {
121
+ if (!isShape(to)) continue
122
+ processedShapeIds.add(to.id)
123
+
124
+ const isOnPage = this.editor.getAncestorPageId(to) === this.lastPageId
125
+
126
+ if (isOnPage) {
127
+ const bounds = this.editor.getShapePageBounds(to.id)
128
+ if (bounds) {
129
+ this.rbush.upsert(to.id, bounds)
130
+ }
131
+ } else {
132
+ this.rbush.remove(to.id)
133
+ }
134
+ }
135
+ }
136
+
137
+ // 2. Check remaining shapes in index for bounds changes
138
+ // This handles shapes with computed bounds (arrows bound to moved shapes, groups with moved children, etc.)
139
+ const allShapeIds = this.rbush.getAllShapeIds()
140
+
141
+ for (const shapeId of allShapeIds) {
142
+ if (processedShapeIds.has(shapeId)) continue
143
+
144
+ const currentBounds = this.editor.getShapePageBounds(shapeId)
145
+ const indexedBounds = this.rbush.getBounds(shapeId)
146
+
147
+ if (!this.areBoundsEqual(currentBounds, indexedBounds)) {
148
+ if (currentBounds) {
149
+ this.rbush.upsert(shapeId, currentBounds)
150
+ } else {
151
+ this.rbush.remove(shapeId)
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ private areBoundsEqual(a: Box | undefined, b: Box | undefined): boolean {
158
+ if (!a && !b) return true
159
+ if (!a || !b) return false
160
+ return a.minX === b.minX && a.minY === b.minY && a.maxX === b.maxX && a.maxY === b.maxY
161
+ }
162
+
163
+ /**
164
+ * Get shape IDs within the given bounds.
165
+ * Optimized for viewport culling queries.
166
+ *
167
+ * Note: Results are unordered. If you need z-order, combine with sorted shapes:
168
+ * ```ts
169
+ * const candidates = editor.spatialIndex.getShapeIdsInsideBounds(bounds)
170
+ * const sorted = editor.getCurrentPageShapesSorted().filter(s => candidates.has(s.id))
171
+ * ```
172
+ *
173
+ * @param bounds - The bounds to search within
174
+ * @returns Unordered set of shape IDs within the bounds
175
+ *
176
+ * @public
177
+ */
178
+ getShapeIdsInsideBounds(bounds: Box): Set<TLShapeId> {
179
+ this.spatialIndexComputed.get()
180
+ return this.rbush.search(bounds)
181
+ }
182
+
183
+ /**
184
+ * Get shape IDs at a point (with optional margin).
185
+ * Creates a small bounding box around the point and searches the spatial index.
186
+ *
187
+ * Note: Results are unordered. If you need z-order, combine with sorted shapes:
188
+ * ```ts
189
+ * const candidates = editor.spatialIndex.getShapeIdsAtPoint(point, margin)
190
+ * const sorted = editor.getCurrentPageShapesSorted().filter(s => candidates.has(s.id))
191
+ * ```
192
+ *
193
+ * @param point - The point to search at
194
+ * @param margin - The margin around the point to search (default: 0)
195
+ * @returns Unordered set of shape IDs that could potentially contain the point
196
+ *
197
+ * @public
198
+ */
199
+ getShapeIdsAtPoint(point: { x: number; y: number }, margin = 0): Set<TLShapeId> {
200
+ this.spatialIndexComputed.get()
201
+ return this.rbush.search(new Box(point.x - margin, point.y - margin, margin * 2, margin * 2))
202
+ }
203
+
204
+ /**
205
+ * Dispose of the spatial index manager.
206
+ * Clears the R-tree to prevent memory leaks.
207
+ *
208
+ * @public
209
+ */
210
+ dispose(): void {
211
+ this.rbush.dispose()
212
+ this.lastPageId = null
213
+ }
214
+ }
@@ -31,6 +31,7 @@ describe('UserPreferencesManager', () => {
31
31
  isDynamicSizeMode: false,
32
32
  isPasteAtCursorMode: false,
33
33
  inputMode: null,
34
+ isZoomDirectionInverted: false,
34
35
  ...overrides,
35
36
  })
36
37
 
@@ -235,6 +236,7 @@ describe('UserPreferencesManager', () => {
235
236
  isWrapMode: mockUserPreferences.isWrapMode,
236
237
  isDynamicResizeMode: mockUserPreferences.isDynamicSizeMode,
237
238
  inputMode: mockUserPreferences.inputMode,
239
+ isZoomDirectionInverted: mockUserPreferences.isZoomDirectionInverted,
238
240
  })
239
241
  })
240
242
 
@@ -475,6 +477,24 @@ describe('UserPreferencesManager', () => {
475
477
  expect(userPreferencesManager.getInputMode()).toBe('mouse')
476
478
  })
477
479
  })
480
+
481
+ describe('getIsZoomDirectionInverted', () => {
482
+ it('should return user zoom direction inverted setting', () => {
483
+ expect(userPreferencesManager.getIsZoomDirectionInverted()).toBe(false)
484
+ })
485
+
486
+ it('should return default when null', () => {
487
+ userPreferencesAtom.set({ ...mockUserPreferences, isZoomDirectionInverted: null })
488
+ expect(userPreferencesManager.getIsZoomDirectionInverted()).toBe(
489
+ defaultUserPreferences.isZoomDirectionInverted
490
+ )
491
+ })
492
+
493
+ it('should return true when inverted', () => {
494
+ userPreferencesAtom.set({ ...mockUserPreferences, isZoomDirectionInverted: true })
495
+ expect(userPreferencesManager.getIsZoomDirectionInverted()).toBe(true)
496
+ })
497
+ })
478
498
  })
479
499
 
480
500
  describe('reactive behavior', () => {
@@ -536,6 +556,7 @@ describe('UserPreferencesManager', () => {
536
556
  isWrapMode: null,
537
557
  isDynamicSizeMode: null,
538
558
  isPasteAtCursorMode: null,
559
+ isZoomDirectionInverted: null,
539
560
  })
540
561
 
541
562
  userPreferencesAtom.set(nullPrefs)
@@ -558,6 +579,9 @@ describe('UserPreferencesManager', () => {
558
579
  expect(userPreferencesManager.getIsPasteAtCursorMode()).toBe(
559
580
  defaultUserPreferences.isPasteAtCursorMode
560
581
  )
582
+ expect(userPreferencesManager.getIsZoomDirectionInverted()).toBe(
583
+ defaultUserPreferences.isZoomDirectionInverted
584
+ )
561
585
  })
562
586
 
563
587
  it('should handle matchMedia with null response', () => {
@@ -51,6 +51,7 @@ export class UserPreferencesManager {
51
51
  isDynamicResizeMode: this.getIsDynamicResizeMode(),
52
52
  enhancedA11yMode: this.getEnhancedA11yMode(),
53
53
  inputMode: this.getInputMode(),
54
+ isZoomDirectionInverted: this.getIsZoomDirectionInverted(),
54
55
  }
55
56
  }
56
57
 
@@ -131,4 +132,11 @@ export class UserPreferencesManager {
131
132
  @computed getInputMode() {
132
133
  return this.user.userPreferences.get().inputMode ?? defaultUserPreferences.inputMode
133
134
  }
135
+
136
+ @computed getIsZoomDirectionInverted() {
137
+ return (
138
+ this.user.userPreferences.get().isZoomDirectionInverted ??
139
+ defaultUserPreferences.isZoomDirectionInverted
140
+ )
141
+ }
134
142
  }
@@ -75,6 +75,19 @@ export interface TLShapeUtilCanvasSvgDef {
75
75
  component: React.ComponentType
76
76
  }
77
77
 
78
+ /**
79
+ * Return type for {@link ShapeUtil.getIndicatorPath}. Can be either a simple Path2D
80
+ * or an object with additional rendering info like clip paths for complex indicators.
81
+ * @public
82
+ */
83
+ export type TLIndicatorPath =
84
+ | Path2D
85
+ | {
86
+ path: Path2D
87
+ clipPath?: Path2D
88
+ additionalPaths?: Path2D[]
89
+ }
90
+
78
91
  /** @public */
79
92
  export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
80
93
  /** Configure this shape utils {@link ShapeUtil.options | `options`}. */
@@ -173,6 +186,37 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
173
186
  */
174
187
  abstract indicator(shape: Shape): any
175
188
 
189
+ /**
190
+ * Whether to use the legacy React-based indicator rendering.
191
+ *
192
+ * Override this to return `false` if your shape implements {@link ShapeUtil.getIndicatorPath}
193
+ * for canvas-based indicator rendering.
194
+ *
195
+ * @returns `true` to use SVG indicators (default), `false` to use canvas indicators.
196
+ * @public
197
+ */
198
+ useLegacyIndicator(): boolean {
199
+ return true
200
+ }
201
+
202
+ /**
203
+ * Get a Path2D for rendering the shape's indicator on the canvas.
204
+ *
205
+ * When implemented, this is used instead of {@link ShapeUtil.indicator} for more
206
+ * efficient canvas-based indicator rendering. Shapes that return `undefined` will
207
+ * fall back to SVG-based rendering via {@link ShapeUtil.indicator}.
208
+ *
209
+ * For complex indicators that need clipping (e.g., arrows with labels), return an
210
+ * object with `path`, `clipPath`, and `additionalPaths` properties.
211
+ *
212
+ * @param shape - The shape.
213
+ * @returns A Path2D to stroke, or an object with clipping info, or undefined to use SVG fallback.
214
+ * @public
215
+ */
216
+ getIndicatorPath(shape: Shape): TLIndicatorPath | undefined {
217
+ return undefined
218
+ }
219
+
176
220
  /**
177
221
  * Get the font faces that should be rendered in the document in order for this shape to render
178
222
  * correctly.
@@ -1,4 +1,10 @@
1
- import { useComputed, useValue } from '@tldraw/state-react'
1
+ import { useAtom, useComputed, useValue } from '@tldraw/state-react'
2
+ import { isEqual } from '@tldraw/utils'
3
+ import { useEffect } from 'react'
4
+ import {
5
+ getCollaboratorStateFromElapsedTime,
6
+ shouldShowCollaborator,
7
+ } from '../utils/collaboratorState'
2
8
  import { uniq } from '../utils/uniq'
3
9
  import { useEditor } from './useEditor'
4
10
 
@@ -19,3 +25,42 @@ export function usePeerIds() {
19
25
 
20
26
  return useValue($userIds)
21
27
  }
28
+
29
+ /**
30
+ * Returns a computed signal of active peer user IDs that should be shown.
31
+ * Automatically re-evaluates on an interval to handle time-based state transitions
32
+ * (active -> idle -> inactive).
33
+ *
34
+ * @returns A computed signal containing a Set of active peer user IDs
35
+ * @internal
36
+ */
37
+ export function useActivePeerIds$() {
38
+ const $time = useAtom('peerIdsTime', Date.now())
39
+ const editor = useEditor()
40
+ useEffect(() => {
41
+ const interval = editor.timers.setInterval(() => {
42
+ $time.set(Date.now())
43
+ }, editor.options.collaboratorCheckIntervalMs)
44
+
45
+ return () => clearInterval(interval)
46
+ }, [editor, $time])
47
+
48
+ return useComputed(
49
+ 'activePeerIds',
50
+ () => {
51
+ const now = $time.get()
52
+ return new Set(
53
+ editor
54
+ .getCollaborators()
55
+ .filter((p) => {
56
+ const elapsed = Math.max(0, now - (p.lastActivityTimestamp ?? Infinity))
57
+ const state = getCollaboratorStateFromElapsedTime(editor, elapsed)
58
+ return shouldShowCollaborator(editor, p, state)
59
+ })
60
+ .map((p) => p.userId)
61
+ )
62
+ },
63
+ { isEqual },
64
+ [editor]
65
+ )
66
+ }
@@ -54,6 +54,12 @@ export interface TldrawOptions {
54
54
  readonly flattenImageBoundsExpand: number
55
55
  readonly flattenImageBoundsPadding: number
56
56
  readonly laserDelayMs: number
57
+ /**
58
+ * How long (in milliseconds) to fade all laser scribbles after the session ends.
59
+ * The total points across all scribbles will be removed proportionally over this duration.
60
+ * Defaults to 500ms (0.5 seconds).
61
+ */
62
+ readonly laserFadeoutMs: number
57
63
  readonly maxExportDelayMs: number
58
64
  readonly tooltipDelayMs: number
59
65
  /**
@@ -157,6 +163,7 @@ export const defaultTldrawOptions = {
157
163
  flattenImageBoundsExpand: 64,
158
164
  flattenImageBoundsPadding: 16,
159
165
  laserDelayMs: 1200,
166
+ laserFadeoutMs: 500,
160
167
  maxExportDelayMs: 5000,
161
168
  tooltipDelayMs: 700,
162
169
  temporaryAssetPreviewLifetimeMs: 180000,
@@ -0,0 +1,54 @@
1
+ import { TLInstancePresence } from '@tldraw/tlschema'
2
+ import { Editor } from '../editor/Editor'
3
+
4
+ /** The activity state of a collaborator */
5
+ export type CollaboratorState = 'active' | 'idle' | 'inactive'
6
+
7
+ /**
8
+ * Get the activity state of a collaborator based on elapsed time since their last activity.
9
+ *
10
+ * @param editor - The editor instance
11
+ * @param elapsed - Time in milliseconds since the collaborator's last activity
12
+ * @returns The collaborator's activity state
13
+ */
14
+ export function getCollaboratorStateFromElapsedTime(
15
+ editor: Editor,
16
+ elapsed: number
17
+ ): CollaboratorState {
18
+ return elapsed > editor.options.collaboratorInactiveTimeoutMs
19
+ ? 'inactive'
20
+ : elapsed > editor.options.collaboratorIdleTimeoutMs
21
+ ? 'idle'
22
+ : 'active'
23
+ }
24
+
25
+ /**
26
+ * Determine whether a collaborator should be shown based on their activity state
27
+ * and the current instance state (following, highlighted users, etc.).
28
+ *
29
+ * @param editor - The editor instance
30
+ * @param presence - The collaborator's presence data
31
+ * @param state - The collaborator's activity state
32
+ * @returns Whether the collaborator should be shown
33
+ */
34
+ export function shouldShowCollaborator(
35
+ editor: Editor,
36
+ presence: TLInstancePresence,
37
+ state: CollaboratorState
38
+ ): boolean {
39
+ const { followingUserId, highlightedUserIds } = editor.getInstanceState()
40
+
41
+ switch (state) {
42
+ case 'inactive':
43
+ // If they're inactive, only show if we're following them or they're highlighted
44
+ return followingUserId === presence.userId || highlightedUserIds.includes(presence.userId)
45
+ case 'idle':
46
+ // If they're idle and following us, hide them unless they have a chat message or are highlighted
47
+ if (presence.followingUserId === editor.user.getId()) {
48
+ return !!(presence.chatMessage || highlightedUserIds.includes(presence.userId))
49
+ }
50
+ return true
51
+ case 'active':
52
+ return true
53
+ }
54
+ }
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.09e80a09d230'
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-28T13:48:07.522Z',
8
+ patch: '2026-01-28T13:48:07.522Z',
9
9
  }