@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,244 @@
1
+ import { useComputed, useQuickReactor } from '@tldraw/state-react'
2
+ import { createComputedCache } from '@tldraw/store'
3
+ import { TLShape, TLShapeId } from '@tldraw/tlschema'
4
+ import { dedupe, isEqual } from '@tldraw/utils'
5
+ import { memo, useEffect, useRef } from 'react'
6
+ import { Editor } from '../../editor/Editor'
7
+ import { TLIndicatorPath } from '../../editor/shapes/ShapeUtil'
8
+ import { useEditor } from '../../hooks/useEditor'
9
+ import { useIsDarkMode } from '../../hooks/useIsDarkMode'
10
+ import { useActivePeerIds$ } from '../../hooks/usePeerIds'
11
+
12
+ interface CollaboratorIndicatorData {
13
+ color: string
14
+ shapeIds: TLShapeId[]
15
+ }
16
+
17
+ const indicatorPathCache = createComputedCache(
18
+ 'indicatorPath',
19
+ (editor: Editor, shape: TLShape) => {
20
+ const util = editor.getShapeUtil(shape)
21
+ return util.getIndicatorPath(shape)
22
+ }
23
+ )
24
+
25
+ const getIndicatorPath = (editor: Editor, shape: TLShape) => {
26
+ return indicatorPathCache.get(editor, shape.id)
27
+ }
28
+
29
+ function renderShapeIndicator(
30
+ ctx: CanvasRenderingContext2D,
31
+ editor: Editor,
32
+ shapeId: TLShapeId,
33
+ renderingShapeIds: Set<TLShapeId>
34
+ ): boolean {
35
+ if (!renderingShapeIds.has(shapeId)) return false
36
+
37
+ const shape = editor.getShape(shapeId)
38
+ if (!shape || shape.isLocked) return false
39
+
40
+ const pageTransform = editor.getShapePageTransform(shape)
41
+ if (!pageTransform) return false
42
+
43
+ const indicatorPath = getIndicatorPath(editor, shape)
44
+ if (!indicatorPath) return false
45
+
46
+ ctx.save()
47
+ ctx.transform(
48
+ pageTransform.a,
49
+ pageTransform.b,
50
+ pageTransform.c,
51
+ pageTransform.d,
52
+ pageTransform.e,
53
+ pageTransform.f
54
+ )
55
+ renderIndicatorPath(ctx, indicatorPath)
56
+ ctx.restore()
57
+
58
+ return true
59
+ }
60
+
61
+ function renderIndicatorPath(ctx: CanvasRenderingContext2D, indicatorPath: TLIndicatorPath) {
62
+ if (indicatorPath instanceof Path2D) {
63
+ ctx.stroke(indicatorPath)
64
+ } else {
65
+ const { path, clipPath, additionalPaths } = indicatorPath
66
+
67
+ if (clipPath) {
68
+ ctx.save()
69
+ ctx.clip(clipPath, 'evenodd')
70
+ ctx.stroke(path)
71
+ ctx.restore()
72
+ } else {
73
+ ctx.stroke(path)
74
+ }
75
+
76
+ if (additionalPaths) {
77
+ for (const additionalPath of additionalPaths) {
78
+ ctx.stroke(additionalPath)
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ /** @internal @react */
85
+ export const CanvasShapeIndicators = memo(function CanvasShapeIndicators() {
86
+ const editor = useEditor()
87
+ const canvasRef = useRef<HTMLCanvasElement>(null)
88
+
89
+ // Cache the selected color to avoid getComputedStyle on every render
90
+ const rSelectedColor = useRef<string | null>(null)
91
+ const isDarkMode = useIsDarkMode()
92
+
93
+ useEffect(() => {
94
+ const timer = editor.timers.setTimeout(() => {
95
+ rSelectedColor.current = null
96
+ }, 0)
97
+ return () => clearTimeout(timer)
98
+ }, [isDarkMode, editor])
99
+
100
+ // Get active peer IDs (already handles time-based state transitions)
101
+ const activePeerIds$ = useActivePeerIds$()
102
+
103
+ const $renderData = useComputed(
104
+ 'indicator render data',
105
+ () => {
106
+ const renderingShapeIds = new Set(editor.getRenderingShapes().map((s) => s.id))
107
+
108
+ // Compute ids to display for selected/hovered shapes
109
+ const idsToDisplay = new Set<TLShapeId>()
110
+ const instanceState = editor.getInstanceState()
111
+ const isChangingStyle = instanceState.isChangingStyle
112
+ const isIdleOrEditing = editor.isInAny('select.idle', 'select.editing_shape')
113
+ const isInSelectState = editor.isInAny(
114
+ 'select.brushing',
115
+ 'select.scribble_brushing',
116
+ 'select.pointing_shape',
117
+ 'select.pointing_selection',
118
+ 'select.pointing_handle'
119
+ )
120
+
121
+ if (!isChangingStyle && (isIdleOrEditing || isInSelectState)) {
122
+ for (const id of editor.getSelectedShapeIds()) {
123
+ idsToDisplay.add(id)
124
+ }
125
+ if (isIdleOrEditing && instanceState.isHoveringCanvas && !instanceState.isCoarsePointer) {
126
+ const hovered = editor.getHoveredShapeId()
127
+ if (hovered) idsToDisplay.add(hovered)
128
+ }
129
+ }
130
+
131
+ // Compute hinting shape ids
132
+ const hintingShapeIds = dedupe(editor.getHintingShapeIds())
133
+
134
+ // Compute collaborator indicators
135
+ const collaboratorIndicators: CollaboratorIndicatorData[] = []
136
+ const currentPageId = editor.getCurrentPageId()
137
+ const activePeerIds = activePeerIds$.get()
138
+
139
+ const collaborators = editor.getCollaborators()
140
+ for (const peerId of activePeerIds.values()) {
141
+ // Skip collaborators on different pages
142
+ const presence = collaborators.find((c) => c.userId === peerId)
143
+ if (!presence || presence.currentPageId !== currentPageId) continue
144
+
145
+ // Filter to shapes that are visible and on the current rendering set
146
+ const visibleShapeIds = presence.selectedShapeIds.filter(
147
+ (id) => renderingShapeIds.has(id) && !editor.isShapeHidden(id)
148
+ )
149
+
150
+ if (visibleShapeIds.length > 0) {
151
+ collaboratorIndicators.push({
152
+ color: presence.color,
153
+ shapeIds: visibleShapeIds,
154
+ })
155
+ }
156
+ }
157
+
158
+ return {
159
+ idsToDisplay,
160
+ renderingShapeIds,
161
+ hintingShapeIds,
162
+ collaboratorIndicators,
163
+ }
164
+ },
165
+ { isEqual: isEqual },
166
+ [editor, activePeerIds$]
167
+ )
168
+
169
+ useQuickReactor(
170
+ 'canvas indicators render',
171
+ () => {
172
+ const canvas = canvasRef.current
173
+ if (!canvas) return
174
+
175
+ const ctx = canvas.getContext('2d')
176
+ if (!ctx) return
177
+
178
+ const { idsToDisplay, renderingShapeIds, hintingShapeIds, collaboratorIndicators } =
179
+ $renderData.get()
180
+
181
+ const { w, h } = editor.getViewportScreenBounds()
182
+ const dpr = window.devicePixelRatio || 1
183
+ const { x: cx, y: cy, z: zoom } = editor.getCamera()
184
+
185
+ const canvasWidth = Math.ceil(w * dpr)
186
+ const canvasHeight = Math.ceil(h * dpr)
187
+
188
+ if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
189
+ canvas.width = canvasWidth
190
+ canvas.height = canvasHeight
191
+ canvas.style.width = `${w}px`
192
+ canvas.style.height = `${h}px`
193
+ }
194
+
195
+ ctx.resetTransform()
196
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
197
+
198
+ ctx.scale(dpr, dpr)
199
+ ctx.scale(zoom, zoom)
200
+ ctx.translate(cx, cy)
201
+
202
+ ctx.lineCap = 'round'
203
+ ctx.lineJoin = 'round'
204
+
205
+ // Draw collaborator indicators first (underneath local indicators)
206
+ // Use 0.5 opacity to match the original SVG-based collaborator indicators
207
+ ctx.lineWidth = 1.5 / zoom
208
+ for (const collaborator of collaboratorIndicators) {
209
+ ctx.strokeStyle = collaborator.color
210
+ ctx.globalAlpha = 0.7
211
+ for (const shapeId of collaborator.shapeIds) {
212
+ renderShapeIndicator(ctx, editor, shapeId, renderingShapeIds)
213
+ }
214
+ }
215
+
216
+ // Reset alpha for local indicators
217
+ ctx.globalAlpha = 1.0
218
+
219
+ // Use cached color, only call getComputedStyle when cache is empty
220
+ if (!rSelectedColor.current) {
221
+ rSelectedColor.current = getComputedStyle(canvas).getPropertyValue('--tl-color-selected')
222
+ }
223
+
224
+ ctx.strokeStyle = rSelectedColor.current
225
+
226
+ // Draw selected/hovered indicators (1.5px stroke)
227
+ ctx.lineWidth = 1.5 / zoom
228
+ for (const shapeId of idsToDisplay) {
229
+ renderShapeIndicator(ctx, editor, shapeId, renderingShapeIds)
230
+ }
231
+
232
+ // Draw hinted indicators with a thicker stroke (2.5px)
233
+ if (hintingShapeIds.length > 0) {
234
+ ctx.lineWidth = 2.5 / zoom
235
+ for (const shapeId of hintingShapeIds) {
236
+ renderShapeIndicator(ctx, editor, shapeId, renderingShapeIds)
237
+ }
238
+ }
239
+ },
240
+ [editor, $renderData]
241
+ )
242
+
243
+ return <canvas ref={canvasRef} className="tl-canvas-indicators" />
244
+ })
@@ -26,6 +26,7 @@ import { GeometryDebuggingView } from '../GeometryDebuggingView'
26
26
  import { LiveCollaborators } from '../LiveCollaborators'
27
27
  import { MenuClickCapture } from '../MenuClickCapture'
28
28
  import { Shape } from '../Shape'
29
+ import { CanvasShapeIndicators } from './CanvasShapeIndicators'
29
30
 
30
31
  /** @public */
31
32
  export interface TLCanvasComponentProps {
@@ -159,6 +160,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
159
160
  {hideShapes ? null : debugSvg ? <ShapesWithSVGs /> : <ShapesToDisplay />}
160
161
  </div>
161
162
  <div className="tl-overlays">
163
+ <CanvasShapeIndicators />
162
164
  <div ref={rHtmlLayer2} className="tl-html-layer">
163
165
  {debugGeometry ? <GeometryDebuggingView /> : null}
164
166
  <BrushWrapper />
@@ -412,11 +414,7 @@ function ReflowIfNeeded() {
412
414
  'reflow for culled shapes',
413
415
  () => {
414
416
  const culledShapes = editor.getCulledShapes()
415
- if (
416
- culledShapesRef.current.size === culledShapes.size &&
417
- [...culledShapes].every((id) => culledShapesRef.current.has(id))
418
- )
419
- return
417
+ if (culledShapesRef.current === culledShapes) return
420
418
 
421
419
  culledShapesRef.current = culledShapes
422
420
  const canvas = document.getElementsByClassName('tl-canvas')
@@ -449,7 +447,19 @@ function HintedShapeIndicator() {
449
447
  const editor = useEditor()
450
448
  const { ShapeIndicator } = useEditorComponents()
451
449
 
452
- const ids = useValue('hinting shape ids', () => dedupe(editor.getHintingShapeIds()), [editor])
450
+ const ids = useValue(
451
+ 'hinting shape ids without canvas indicator',
452
+ () => {
453
+ // Filter to only shapes that use legacy SVG indicators
454
+ return dedupe(editor.getHintingShapeIds()).filter((id) => {
455
+ const shape = editor.getShape(id)
456
+ if (!shape) return false
457
+ const util = editor.getShapeUtil(shape)
458
+ return util.useLegacyIndicator()
459
+ })
460
+ },
461
+ [editor]
462
+ )
453
463
 
454
464
  if (!ids.length) return null
455
465
  if (!ShapeIndicator) return null
@@ -32,6 +32,11 @@ const InnerIndicator = memo(({ editor, id }: { editor: Editor; id: TLShapeId })
32
32
 
33
33
  if (!shape || shape.isLocked) return null
34
34
 
35
+ const util = editor.getShapeUtil(shape)
36
+
37
+ // If the shape uses canvas indicators, it will be rendered by CanvasShapeIndicators
38
+ if (!util.useLegacyIndicator()) return null
39
+
35
40
  return (
36
41
  <OptionalErrorBoundary
37
42
  fallback={ShapeIndicatorErrorFallback}
@@ -39,7 +44,7 @@ const InnerIndicator = memo(({ editor, id }: { editor: Editor; id: TLShapeId })
39
44
  editor.annotateError(error, { origin: 'react.shapeIndicator', willCrashApp: false })
40
45
  }
41
46
  >
42
- <EvenInnererIndicator key={shape.id} shape={shape} util={editor.getShapeUtil(shape)} />
47
+ <EvenInnererIndicator key={shape.id} shape={shape} util={util} />
43
48
  </OptionalErrorBoundary>
44
49
  )
45
50
  })
@@ -89,9 +89,24 @@ export const DefaultShapeIndicators = memo(function DefaultShapeIndicators({
89
89
  const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor])
90
90
 
91
91
  const { ShapeIndicator } = useEditorComponents()
92
+
93
+ // Filter out shapes that have canvas indicator support - only render shapes that use legacy SVG indicators
94
+ const shapesToRender = useValue(
95
+ 'shapes to render for svg indicators',
96
+ () => {
97
+ return renderingShapes.filter(({ id }) => {
98
+ const shape = editor.getShape(id)
99
+ if (!shape) return false
100
+ const util = editor.getShapeUtil(shape)
101
+ return util.useLegacyIndicator()
102
+ })
103
+ },
104
+ [editor, renderingShapes]
105
+ )
106
+
92
107
  if (!ShapeIndicator) return null
93
108
 
94
- return renderingShapes.map(({ id }) => (
109
+ return shapesToRender.map(({ id }) => (
95
110
  <ShapeIndicator
96
111
  key={id + '_indicator'}
97
112
  shapeId={id}
@@ -21,6 +21,7 @@ describe('TLUserPreferences consistency', () => {
21
21
  'isPasteAtCursorMode',
22
22
  'enhancedA11yMode',
23
23
  'inputMode',
24
+ 'isZoomDirectionInverted',
24
25
  ] as const
25
26
 
26
27
  it('defaultUserPreferences contains all TLUserPreferences keys (except id)', () => {
@@ -26,6 +26,7 @@ export interface TLUserPreferences {
26
26
  isPasteAtCursorMode?: boolean | null
27
27
  enhancedA11yMode?: boolean | null
28
28
  inputMode?: 'trackpad' | 'mouse' | null
29
+ isZoomDirectionInverted?: boolean | null
29
30
  }
30
31
 
31
32
  interface UserDataSnapshot {
@@ -56,6 +57,7 @@ export const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUser
56
57
  isPasteAtCursorMode: T.boolean.nullable().optional(),
57
58
  enhancedA11yMode: T.boolean.nullable().optional(),
58
59
  inputMode: T.literalEnum('trackpad', 'mouse').nullable().optional(),
60
+ isZoomDirectionInverted: T.boolean.nullable().optional(),
59
61
  })
60
62
 
61
63
  const Versions = {
@@ -71,6 +73,7 @@ const Versions = {
71
73
  AddShowUiLabels: 10,
72
74
  AddPointerPeripheral: 11,
73
75
  RenameShowUiLabelsToEnhancedA11yMode: 12,
76
+ AddZoomDirectionInverted: 13,
74
77
  } as const
75
78
 
76
79
  const CURRENT_VERSION = Math.max(...Object.values(Versions))
@@ -121,6 +124,10 @@ function migrateSnapshot(data: { version: number; user: any }) {
121
124
  data.user.inputMode = null
122
125
  }
123
126
 
127
+ if (data.version < Versions.AddZoomDirectionInverted) {
128
+ data.user.isZoomDirectionInverted = false
129
+ }
130
+
124
131
  // finally
125
132
  data.version = CURRENT_VERSION
126
133
  }
@@ -171,6 +178,7 @@ export const defaultUserPreferences = Object.freeze({
171
178
  enhancedA11yMode: false,
172
179
  colorScheme: 'light',
173
180
  inputMode: null,
181
+ isZoomDirectionInverted: false,
174
182
  }) satisfies Readonly<Omit<TLUserPreferences, 'id'>>
175
183
 
176
184
  /** @public */
@@ -149,6 +149,7 @@ import { HistoryManager } from './managers/HistoryManager/HistoryManager'
149
149
  import { InputsManager } from './managers/InputsManager/InputsManager'
150
150
  import { ScribbleManager } from './managers/ScribbleManager/ScribbleManager'
151
151
  import { SnapManager } from './managers/SnapManager/SnapManager'
152
+ import { SpatialIndexManager } from './managers/SpatialIndexManager/SpatialIndexManager'
152
153
  import { TextManager } from './managers/TextManager/TextManager'
153
154
  import { TickManager } from './managers/TickManager/TickManager'
154
155
  import { UserPreferencesManager } from './managers/UserPreferencesManager/UserPreferencesManager'
@@ -308,6 +309,9 @@ export class Editor extends EventEmitter<TLEventMap> {
308
309
 
309
310
  this.snaps = new SnapManager(this)
310
311
 
312
+ this._spatialIndex = new SpatialIndexManager(this)
313
+ this.disposables.add(() => this._spatialIndex.dispose())
314
+
311
315
  this.disposables.add(this.timers.dispose)
312
316
 
313
317
  this._cameraOptions.set({ ...DEFAULT_CAMERA_OPTIONS, ...cameraOptions })
@@ -895,6 +899,8 @@ export class Editor extends EventEmitter<TLEventMap> {
895
899
  */
896
900
  readonly snaps: SnapManager
897
901
 
902
+ private readonly _spatialIndex: SpatialIndexManager
903
+
898
904
  /**
899
905
  * A manager for the any asynchronous events and making sure they're
900
906
  * cleaned up upon disposal.
@@ -5135,6 +5141,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5135
5141
  }
5136
5142
 
5137
5143
  private _notVisibleShapes = notVisibleShapes(this)
5144
+ private _culledShapesCache: Set<TLShapeId> | null = null
5138
5145
 
5139
5146
  /**
5140
5147
  * Get culled shapes (those that should not render), taking into account which shapes are selected or editing.
@@ -5146,16 +5153,41 @@ export class Editor extends EventEmitter<TLEventMap> {
5146
5153
  const notVisibleShapes = this.getNotVisibleShapes()
5147
5154
  const selectedShapeIds = this.getSelectedShapeIds()
5148
5155
  const editingId = this.getEditingShapeId()
5149
- const culledShapes = new Set<TLShapeId>(notVisibleShapes)
5156
+ const nextValue = new Set<TLShapeId>(notVisibleShapes)
5150
5157
  // we don't cull the shape we are editing
5151
5158
  if (editingId) {
5152
- culledShapes.delete(editingId)
5159
+ nextValue.delete(editingId)
5153
5160
  }
5154
5161
  // we also don't cull selected shapes
5155
5162
  selectedShapeIds.forEach((id) => {
5156
- culledShapes.delete(id)
5163
+ nextValue.delete(id)
5157
5164
  })
5158
- return culledShapes
5165
+
5166
+ // Cache optimization: return same Set object if contents unchanged
5167
+ // This allows consumers to use === comparison and prevents unnecessary re-renders
5168
+ const prevValue = this._culledShapesCache
5169
+ if (prevValue) {
5170
+ // If sizes differ, contents must differ
5171
+ if (prevValue.size !== nextValue.size) {
5172
+ this._culledShapesCache = nextValue
5173
+ return nextValue
5174
+ }
5175
+
5176
+ // Check if all elements are the same
5177
+ for (const id of prevValue) {
5178
+ if (!nextValue.has(id)) {
5179
+ // Found a difference, update cache and return new set
5180
+ this._culledShapesCache = nextValue
5181
+ return nextValue
5182
+ }
5183
+ }
5184
+
5185
+ // Loop completed without finding differences - contents identical
5186
+ return prevValue
5187
+ }
5188
+
5189
+ this._culledShapesCache = nextValue
5190
+ return nextValue
5159
5191
  }
5160
5192
 
5161
5193
  /**
@@ -5222,11 +5254,18 @@ export class Editor extends EventEmitter<TLEventMap> {
5222
5254
  let inMarginClosestToEdgeDistance = Infinity
5223
5255
  let inMarginClosestToEdgeHit: TLShape | null = null
5224
5256
 
5257
+ // Use larger margin for spatial search to account for edge distance checks
5258
+ const searchMargin = Math.max(innerMargin, outerMargin, this.options.hitTestMargin / zoomLevel)
5259
+ const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, searchMargin)
5260
+
5225
5261
  const shapesToCheck = (
5226
5262
  opts.renderingOnly
5227
5263
  ? this.getCurrentPageRenderingShapesSorted()
5228
5264
  : this.getCurrentPageShapesSorted()
5229
5265
  ).filter((shape) => {
5266
+ // Frames have labels positioned above the shape (outside bounds), so always include them
5267
+ if (!candidateIds.has(shape.id) && !this.isShapeOfType(shape, 'frame')) return false
5268
+
5230
5269
  if (
5231
5270
  (shape.isLocked && !hitLocked) ||
5232
5271
  this.isShapeHidden(shape) ||
@@ -5412,11 +5451,41 @@ export class Editor extends EventEmitter<TLEventMap> {
5412
5451
  point: VecLike,
5413
5452
  opts = {} as { margin?: number; hitInside?: boolean }
5414
5453
  ): TLShape[] {
5454
+ const margin = opts.margin ?? 0
5455
+ const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, margin)
5456
+
5457
+ // Get all page shapes in z-index order and filter to candidates that pass isPointInShape
5458
+ // Frames are always checked because their labels can be outside their bounds
5415
5459
  return this.getCurrentPageShapesSorted()
5416
- .filter((shape) => !this.isShapeHidden(shape) && this.isPointInShape(shape, point, opts))
5460
+ .filter((shape) => {
5461
+ if (this.isShapeHidden(shape)) return false
5462
+ if (!candidateIds.has(shape.id) && !this.isShapeOfType(shape, 'frame')) return false
5463
+ return this.isPointInShape(shape, point, opts)
5464
+ })
5417
5465
  .reverse()
5418
5466
  }
5419
5467
 
5468
+ /**
5469
+ * Get shape IDs within the given bounds.
5470
+ *
5471
+ * Note: Uses shape page bounds only. Frames with labels outside their bounds
5472
+ * may not be included even if the label is within the search bounds.
5473
+ *
5474
+ * Note: Results are unordered. If you need z-order, combine with sorted shapes:
5475
+ * ```ts
5476
+ * const candidates = editor.getShapeIdsInsideBounds(bounds)
5477
+ * const sorted = editor.getCurrentPageShapesSorted().filter(s => candidates.has(s.id))
5478
+ * ```
5479
+ *
5480
+ * @param bounds - The bounds to search within.
5481
+ * @returns Unordered set of shape IDs within the given bounds.
5482
+ *
5483
+ * @internal
5484
+ */
5485
+ getShapeIdsInsideBounds(bounds: Box): Set<TLShapeId> {
5486
+ return this._spatialIndex.getShapeIdsInsideBounds(bounds)
5487
+ }
5488
+
5420
5489
  /**
5421
5490
  * Test whether a point (in the current page space) will will a shape. This method takes into account masks,
5422
5491
  * such as when a shape is the child of a frame and is partially clipped by the frame.
@@ -10475,7 +10544,16 @@ export class Editor extends EventEmitter<TLEventMap> {
10475
10544
  }
10476
10545
  }
10477
10546
 
10478
- const zoom = cz + (delta ?? 0) * zoomSpeed * cz
10547
+ // because we can't for sure detect whether a user is using a mouse or a trackpad,
10548
+ // we need to check the input mode preference, and only invert the zoom direction
10549
+ // if the user has specifically set it to a mouse.
10550
+ const isZoomDirectionInverted =
10551
+ (this.user.getUserPreferences().isZoomDirectionInverted && inputMode === 'mouse') ??
10552
+ false
10553
+ const deltaValue = delta ?? 0
10554
+ const finalDelta = isZoomDirectionInverted ? -deltaValue : deltaValue
10555
+
10556
+ const zoom = cz + finalDelta * zoomSpeed * cz
10479
10557
  this._setCamera(new Vec(cx + x / zoom - x / cz, cy + y / zoom - y / cz, zoom), {
10480
10558
  immediate: true,
10481
10559
  })
@@ -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
  }