@tldraw/editor 4.4.0-next.55b4db2171bd → 4.4.0-next.f181afb0ab39

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 (87) hide show
  1. package/dist-cjs/index.d.ts +175 -11
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/components/LiveCollaborators.js +14 -24
  5. package/dist-cjs/lib/components/LiveCollaborators.js.map +2 -2
  6. package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js +201 -0
  7. package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js.map +7 -0
  8. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +29 -14
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js +3 -1
  11. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js.map +2 -2
  12. package/dist-cjs/lib/components/default-components/DefaultShapeIndicators.js +13 -1
  13. package/dist-cjs/lib/components/default-components/DefaultShapeIndicators.js.map +2 -2
  14. package/dist-cjs/lib/config/TLUserPreferences.js +9 -3
  15. package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
  16. package/dist-cjs/lib/editor/Editor.js +4 -1
  17. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  18. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +378 -89
  19. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  20. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +2 -3
  21. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  22. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +8 -3
  23. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  24. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +29 -0
  25. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  26. package/dist-cjs/lib/hooks/usePeerIds.js +29 -0
  27. package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
  28. package/dist-cjs/lib/options.js +1 -0
  29. package/dist-cjs/lib/options.js.map +2 -2
  30. package/dist-cjs/lib/utils/collaboratorState.js +42 -0
  31. package/dist-cjs/lib/utils/collaboratorState.js.map +7 -0
  32. package/dist-cjs/version.js +3 -3
  33. package/dist-cjs/version.js.map +1 -1
  34. package/dist-esm/index.d.mts +175 -11
  35. package/dist-esm/index.mjs +1 -1
  36. package/dist-esm/index.mjs.map +2 -2
  37. package/dist-esm/lib/components/LiveCollaborators.mjs +17 -24
  38. package/dist-esm/lib/components/LiveCollaborators.mjs.map +2 -2
  39. package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs +181 -0
  40. package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs.map +7 -0
  41. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +29 -14
  42. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  43. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs +3 -1
  44. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs.map +2 -2
  45. package/dist-esm/lib/components/default-components/DefaultShapeIndicators.mjs +13 -1
  46. package/dist-esm/lib/components/default-components/DefaultShapeIndicators.mjs.map +2 -2
  47. package/dist-esm/lib/config/TLUserPreferences.mjs +9 -3
  48. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  49. package/dist-esm/lib/editor/Editor.mjs +4 -1
  50. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  51. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +378 -89
  52. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +2 -3
  54. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  55. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +8 -3
  56. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  57. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +29 -0
  58. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  59. package/dist-esm/lib/hooks/usePeerIds.mjs +33 -1
  60. package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
  61. package/dist-esm/lib/options.mjs +1 -0
  62. package/dist-esm/lib/options.mjs.map +2 -2
  63. package/dist-esm/lib/utils/collaboratorState.mjs +22 -0
  64. package/dist-esm/lib/utils/collaboratorState.mjs.map +7 -0
  65. package/dist-esm/version.mjs +3 -3
  66. package/dist-esm/version.mjs.map +1 -1
  67. package/editor.css +6 -0
  68. package/package.json +7 -7
  69. package/src/index.ts +2 -0
  70. package/src/lib/components/LiveCollaborators.tsx +26 -37
  71. package/src/lib/components/default-components/CanvasShapeIndicators.tsx +244 -0
  72. package/src/lib/components/default-components/DefaultCanvas.tsx +15 -1
  73. package/src/lib/components/default-components/DefaultShapeIndicator.tsx +6 -1
  74. package/src/lib/components/default-components/DefaultShapeIndicators.tsx +16 -1
  75. package/src/lib/config/TLUserPreferences.test.ts +1 -0
  76. package/src/lib/config/TLUserPreferences.ts +8 -0
  77. package/src/lib/editor/Editor.ts +10 -1
  78. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +491 -106
  79. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +2 -3
  80. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +24 -0
  81. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +8 -0
  82. package/src/lib/editor/shapes/ShapeUtil.ts +44 -0
  83. package/src/lib/hooks/usePeerIds.ts +46 -1
  84. package/src/lib/options.ts +7 -0
  85. package/src/lib/utils/collaboratorState.ts +54 -0
  86. package/src/version.ts +3 -3
  87. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.test.ts +0 -621
@@ -6,6 +6,11 @@ import { useEditor } from '../hooks/useEditor'
6
6
  import { useEditorComponents } from '../hooks/useEditorComponents'
7
7
  import { usePeerIds } from '../hooks/usePeerIds'
8
8
  import { usePresence } from '../hooks/usePresence'
9
+ import {
10
+ CollaboratorState,
11
+ getCollaboratorStateFromElapsedTime,
12
+ shouldShowCollaborator,
13
+ } from '../utils/collaboratorState'
9
14
 
10
15
  export const LiveCollaborators = track(function Collaborators() {
11
16
  const peerIds = usePeerIds()
@@ -26,30 +31,8 @@ const CollaboratorGuard = track(function CollaboratorGuard({
26
31
  return null
27
32
  }
28
33
 
29
- switch (collaboratorState) {
30
- case 'inactive': {
31
- const { followingUserId, highlightedUserIds } = editor.getInstanceState()
32
- // If they're inactive and unless we're following them or they're highlighted, hide them
33
- if (!(followingUserId === presence.userId || highlightedUserIds.includes(presence.userId))) {
34
- return null
35
- }
36
- break
37
- }
38
- case 'idle': {
39
- const { highlightedUserIds } = editor.getInstanceState()
40
- // If they're idle and following us and unless they have a chat message or are highlighted, hide them
41
- if (
42
- presence.followingUserId === editor.user.getId() &&
43
- !(presence.chatMessage || highlightedUserIds.includes(presence.userId))
44
- ) {
45
- return null
46
- }
47
- break
48
- }
49
- case 'active': {
50
- // If they're active, show them
51
- break
52
- }
34
+ if (!shouldShowCollaborator(editor, presence, collaboratorState)) {
35
+ return null
53
36
  }
54
37
 
55
38
  return <Collaborator latestPresence={presence} />
@@ -137,7 +120,16 @@ const Collaborator = track(function Collaborator({
137
120
  ) : null}
138
121
  {CollaboratorShapeIndicator &&
139
122
  selectedShapeIds
140
- .filter((id) => !editor.isShapeHidden(id))
123
+ .filter((id) => {
124
+ // Skip hidden shapes
125
+ if (editor.isShapeHidden(id)) return false
126
+ // Only render SVG indicators for shapes that use legacy indicators
127
+ // Canvas-based indicators are handled by CanvasShapeIndicators
128
+ const shape = editor.getShape(id)
129
+ if (!shape) return false
130
+ const util = editor.getShapeUtil(shape)
131
+ return util.useLegacyIndicator()
132
+ })
141
133
  .map((shapeId) => (
142
134
  <CollaboratorShapeIndicator
143
135
  className="tl-collaborator__shape-indicator"
@@ -152,24 +144,21 @@ const Collaborator = track(function Collaborator({
152
144
  )
153
145
  })
154
146
 
155
- function getStateFromElapsedTime(editor: Editor, elapsed: number) {
156
- return elapsed > editor.options.collaboratorInactiveTimeoutMs
157
- ? 'inactive'
158
- : elapsed > editor.options.collaboratorIdleTimeoutMs
159
- ? 'idle'
160
- : 'active'
161
- }
162
-
163
- function useCollaboratorState(editor: Editor, latestPresence: TLInstancePresence | null) {
147
+ function useCollaboratorState(
148
+ editor: Editor,
149
+ latestPresence: TLInstancePresence | null
150
+ ): CollaboratorState {
164
151
  const rLastActivityTimestamp = useRef(latestPresence?.lastActivityTimestamp ?? -1)
165
152
 
166
- const [state, setState] = useState<'active' | 'idle' | 'inactive'>(() =>
167
- getStateFromElapsedTime(editor, Date.now() - rLastActivityTimestamp.current)
153
+ const [state, setState] = useState<CollaboratorState>(() =>
154
+ getCollaboratorStateFromElapsedTime(editor, Date.now() - rLastActivityTimestamp.current)
168
155
  )
169
156
 
170
157
  useEffect(() => {
171
158
  const interval = editor.timers.setInterval(() => {
172
- setState(getStateFromElapsedTime(editor, Date.now() - rLastActivityTimestamp.current))
159
+ setState(
160
+ getCollaboratorStateFromElapsedTime(editor, Date.now() - rLastActivityTimestamp.current)
161
+ )
173
162
  }, editor.options.collaboratorCheckIntervalMs)
174
163
 
175
164
  return () => clearInterval(interval)
@@ -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 />
@@ -445,7 +447,19 @@ function HintedShapeIndicator() {
445
447
  const editor = useEditor()
446
448
  const { ShapeIndicator } = useEditorComponents()
447
449
 
448
- 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
+ )
449
463
 
450
464
  if (!ids.length) return null
451
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 */
@@ -10544,7 +10544,16 @@ export class Editor extends EventEmitter<TLEventMap> {
10544
10544
  }
10545
10545
  }
10546
10546
 
10547
- 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
10548
10557
  this._setCamera(new Vec(cx + x / zoom - x / cz, cy + y / zoom - y / cz, zoom), {
10549
10558
  immediate: true,
10550
10559
  })