@tldraw/editor 4.3.0 → 4.4.0-canary.0791272556f5

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 (114) hide show
  1. package/README.md +1 -1
  2. package/dist-cjs/index.d.ts +185 -16
  3. package/dist-cjs/index.js +3 -6
  4. package/dist-cjs/index.js.map +3 -3
  5. package/dist-cjs/lib/TldrawEditor.js.map +1 -1
  6. package/dist-cjs/lib/components/LiveCollaborators.js +14 -24
  7. package/dist-cjs/lib/components/LiveCollaborators.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js +201 -0
  9. package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js.map +7 -0
  10. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +30 -16
  11. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  12. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js +3 -1
  13. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js.map +2 -2
  14. package/dist-cjs/lib/components/default-components/DefaultShapeIndicators.js +13 -1
  15. package/dist-cjs/lib/components/default-components/DefaultShapeIndicators.js.map +2 -2
  16. package/dist-cjs/lib/config/TLUserPreferences.js +9 -3
  17. package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
  18. package/dist-cjs/lib/config/createTLStore.js.map +1 -1
  19. package/dist-cjs/lib/editor/Editor.js +70 -16
  20. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  21. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js +13 -21
  22. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js +13 -38
  24. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js.map +3 -3
  25. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +378 -89
  26. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  27. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +144 -0
  28. package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +7 -0
  29. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +180 -0
  30. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +7 -0
  31. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +8 -3
  32. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  33. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +29 -0
  34. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  35. package/dist-cjs/lib/editor/tools/RootState.js +0 -13
  36. package/dist-cjs/lib/editor/tools/RootState.js.map +2 -2
  37. package/dist-cjs/lib/hooks/usePeerIds.js +29 -0
  38. package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
  39. package/dist-cjs/lib/options.js +1 -0
  40. package/dist-cjs/lib/options.js.map +2 -2
  41. package/dist-cjs/lib/utils/collaboratorState.js +42 -0
  42. package/dist-cjs/lib/utils/collaboratorState.js.map +7 -0
  43. package/dist-cjs/version.js +3 -3
  44. package/dist-cjs/version.js.map +1 -1
  45. package/dist-esm/index.d.mts +185 -16
  46. package/dist-esm/index.mjs +3 -6
  47. package/dist-esm/index.mjs.map +2 -2
  48. package/dist-esm/lib/TldrawEditor.mjs.map +1 -1
  49. package/dist-esm/lib/components/LiveCollaborators.mjs +17 -24
  50. package/dist-esm/lib/components/LiveCollaborators.mjs.map +2 -2
  51. package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs +181 -0
  52. package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs.map +7 -0
  53. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +30 -16
  54. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  55. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs +3 -1
  56. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs.map +2 -2
  57. package/dist-esm/lib/components/default-components/DefaultShapeIndicators.mjs +13 -1
  58. package/dist-esm/lib/components/default-components/DefaultShapeIndicators.mjs.map +2 -2
  59. package/dist-esm/lib/config/TLUserPreferences.mjs +9 -3
  60. package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
  61. package/dist-esm/lib/config/createTLStore.mjs.map +1 -1
  62. package/dist-esm/lib/editor/Editor.mjs +70 -16
  63. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  64. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs +13 -21
  65. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs.map +2 -2
  66. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs +14 -39
  67. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs.map +2 -2
  68. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +378 -89
  69. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  70. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +114 -0
  71. package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +7 -0
  72. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +160 -0
  73. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +7 -0
  74. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +8 -3
  75. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  76. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +29 -0
  77. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  78. package/dist-esm/lib/editor/tools/RootState.mjs +0 -13
  79. package/dist-esm/lib/editor/tools/RootState.mjs.map +2 -2
  80. package/dist-esm/lib/hooks/usePeerIds.mjs +33 -1
  81. package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
  82. package/dist-esm/lib/options.mjs +1 -0
  83. package/dist-esm/lib/options.mjs.map +2 -2
  84. package/dist-esm/lib/utils/collaboratorState.mjs +22 -0
  85. package/dist-esm/lib/utils/collaboratorState.mjs.map +7 -0
  86. package/dist-esm/version.mjs +3 -3
  87. package/dist-esm/version.mjs.map +1 -1
  88. package/editor.css +11 -9
  89. package/package.json +10 -10
  90. package/src/index.ts +3 -5
  91. package/src/lib/TldrawEditor.tsx +1 -1
  92. package/src/lib/components/LiveCollaborators.tsx +26 -37
  93. package/src/lib/components/default-components/CanvasShapeIndicators.tsx +244 -0
  94. package/src/lib/components/default-components/DefaultCanvas.tsx +16 -6
  95. package/src/lib/components/default-components/DefaultShapeIndicator.tsx +6 -1
  96. package/src/lib/components/default-components/DefaultShapeIndicators.tsx +16 -1
  97. package/src/lib/config/TLUserPreferences.test.ts +1 -0
  98. package/src/lib/config/TLUserPreferences.ts +8 -0
  99. package/src/lib/config/createTLStore.ts +1 -1
  100. package/src/lib/editor/Editor.ts +105 -17
  101. package/src/lib/editor/derivations/notVisibleShapes.ts +15 -41
  102. package/src/lib/editor/managers/HistoryManager/HistoryManager.ts +19 -47
  103. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +491 -106
  104. package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +144 -0
  105. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +214 -0
  106. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +24 -0
  107. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +8 -0
  108. package/src/lib/editor/shapes/ShapeUtil.ts +44 -0
  109. package/src/lib/editor/tools/RootState.ts +0 -16
  110. package/src/lib/hooks/usePeerIds.ts +46 -1
  111. package/src/lib/options.ts +7 -0
  112. package/src/lib/utils/collaboratorState.ts +54 -0
  113. package/src/version.ts +3 -3
  114. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.test.ts +0 -621
@@ -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 */
@@ -29,7 +29,7 @@ export interface TLStoreBaseOptions {
29
29
  /** How should this store upload & resolve assets? */
30
30
  assets?: TLAssetStore
31
31
 
32
- /** Called when the store is connected to an {@link Editor}. */
32
+ /** Called when the store is connected to an {@link @tldraw/editor#Editor}. */
33
33
  onMount?(editor: Editor): void | (() => void)
34
34
  }
35
35
 
@@ -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 })
@@ -439,6 +443,7 @@ export class Editor extends EventEmitter<TLEventMap> {
439
443
  const deletedShapeIds = new Set<TLShapeId>()
440
444
  const invalidParents = new Set<TLShapeId>()
441
445
  let invalidBindingTypes = new Set<TLBinding['type']>()
446
+
442
447
  this.disposables.add(
443
448
  this.sideEffects.registerOperationCompleteHandler(() => {
444
449
  // this needs to be cleared here because further effects may delete more shapes
@@ -895,6 +900,8 @@ export class Editor extends EventEmitter<TLEventMap> {
895
900
  */
896
901
  readonly snaps: SnapManager
897
902
 
903
+ private readonly _spatialIndex: SpatialIndexManager
904
+
898
905
  /**
899
906
  * A manager for the any asynchronous events and making sure they're
900
907
  * cleaned up upon disposal.
@@ -2766,7 +2773,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2766
2773
  }
2767
2774
 
2768
2775
  /**
2769
- * Get the efficient zoom level. This returns the current zoom level if there are less than 300 shapes on the page,
2776
+ * Get the efficient zoom level. This returns the current zoom level if there are less than a certain number of shapes on the page,
2770
2777
  * otherwise it returns the debounced zoom level. This can be used to avoid expensive re-renders during camera movements.
2771
2778
  *
2772
2779
  * @public
@@ -3219,7 +3226,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3219
3226
  * @public
3220
3227
  */
3221
3228
  zoomToFit(opts?: TLCameraMoveOptions): this {
3222
- const ids = [...this.getCurrentPageShapeIds()]
3229
+ const ids = [...this.getCurrentPageShapeIds()].filter((id) => !this.isShapeHidden(id))
3223
3230
  if (ids.length <= 0) return this
3224
3231
  const pageBounds = Box.Common(compact(ids.map((id) => this.getShapePageBounds(id))))
3225
3232
  this.zoomToBounds(pageBounds, opts)
@@ -4174,23 +4181,25 @@ export class Editor extends EventEmitter<TLEventMap> {
4174
4181
  // unmount / remount in the DOM, which is expensive; and computing visibility is
4175
4182
  // also expensive in large projects. For this reason, we use a second bounding
4176
4183
  // box just for rendering, and we only update after the camera stops moving.
4177
- private _cameraState = atom('camera state', 'idle' as 'idle' | 'moving')
4178
4184
  private _cameraStateTimeoutRemaining = 0
4179
4185
  private _decayCameraStateTimeout(elapsed: number) {
4180
4186
  this._cameraStateTimeoutRemaining -= elapsed
4181
4187
  if (this._cameraStateTimeoutRemaining > 0) return
4182
4188
  this.off('tick', this._decayCameraStateTimeout)
4183
- this._cameraState.set('idle')
4189
+ this._setCameraState('idle')
4184
4190
  }
4185
4191
  private _tickCameraState() {
4186
4192
  // always reset the timeout
4187
4193
  this._cameraStateTimeoutRemaining = this.options.cameraMovingTimeoutMs
4188
4194
  // If the state is idle, then start the tick
4189
- if (this._cameraState.__unsafe__getWithoutCapture() !== 'idle') return
4190
- this._cameraState.set('moving')
4195
+ if (this.getInstanceState().cameraState !== 'idle') return
4196
+ this._setCameraState('moving')
4191
4197
  this._debouncedZoomLevel.set(unsafe__withoutCapture(() => this.getCamera().z))
4192
4198
  this.on('tick', this._decayCameraStateTimeout)
4193
4199
  }
4200
+ private _setCameraState(cameraState: 'idle' | 'moving') {
4201
+ this.updateInstanceState({ cameraState }, { history: 'ignore' })
4202
+ }
4194
4203
 
4195
4204
  /**
4196
4205
  * Whether the camera is moving or idle.
@@ -4203,7 +4212,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4203
4212
  * @public
4204
4213
  */
4205
4214
  getCameraState() {
4206
- return this._cameraState.get()
4215
+ return this.getInstanceState().cameraState
4207
4216
  }
4208
4217
 
4209
4218
  /**
@@ -5135,6 +5144,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5135
5144
  }
5136
5145
 
5137
5146
  private _notVisibleShapes = notVisibleShapes(this)
5147
+ private _culledShapesCache: Set<TLShapeId> | null = null
5138
5148
 
5139
5149
  /**
5140
5150
  * Get culled shapes (those that should not render), taking into account which shapes are selected or editing.
@@ -5146,16 +5156,41 @@ export class Editor extends EventEmitter<TLEventMap> {
5146
5156
  const notVisibleShapes = this.getNotVisibleShapes()
5147
5157
  const selectedShapeIds = this.getSelectedShapeIds()
5148
5158
  const editingId = this.getEditingShapeId()
5149
- const culledShapes = new Set<TLShapeId>(notVisibleShapes)
5159
+ const nextValue = new Set<TLShapeId>(notVisibleShapes)
5150
5160
  // we don't cull the shape we are editing
5151
5161
  if (editingId) {
5152
- culledShapes.delete(editingId)
5162
+ nextValue.delete(editingId)
5153
5163
  }
5154
5164
  // we also don't cull selected shapes
5155
5165
  selectedShapeIds.forEach((id) => {
5156
- culledShapes.delete(id)
5166
+ nextValue.delete(id)
5157
5167
  })
5158
- return culledShapes
5168
+
5169
+ // Cache optimization: return same Set object if contents unchanged
5170
+ // This allows consumers to use === comparison and prevents unnecessary re-renders
5171
+ const prevValue = this._culledShapesCache
5172
+ if (prevValue) {
5173
+ // If sizes differ, contents must differ
5174
+ if (prevValue.size !== nextValue.size) {
5175
+ this._culledShapesCache = nextValue
5176
+ return nextValue
5177
+ }
5178
+
5179
+ // Check if all elements are the same
5180
+ for (const id of prevValue) {
5181
+ if (!nextValue.has(id)) {
5182
+ // Found a difference, update cache and return new set
5183
+ this._culledShapesCache = nextValue
5184
+ return nextValue
5185
+ }
5186
+ }
5187
+
5188
+ // Loop completed without finding differences - contents identical
5189
+ return prevValue
5190
+ }
5191
+
5192
+ this._culledShapesCache = nextValue
5193
+ return nextValue
5159
5194
  }
5160
5195
 
5161
5196
  /**
@@ -5167,6 +5202,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5167
5202
  let commonBounds: Box | undefined
5168
5203
 
5169
5204
  this.getCurrentPageShapeIdsSorted().forEach((shapeId) => {
5205
+ if (this.isShapeHidden(shapeId)) return
5170
5206
  const bounds = this.getShapeMaskedPageBounds(shapeId)
5171
5207
  if (!bounds) return
5172
5208
  if (!commonBounds) {
@@ -5222,11 +5258,18 @@ export class Editor extends EventEmitter<TLEventMap> {
5222
5258
  let inMarginClosestToEdgeDistance = Infinity
5223
5259
  let inMarginClosestToEdgeHit: TLShape | null = null
5224
5260
 
5261
+ // Use larger margin for spatial search to account for edge distance checks
5262
+ const searchMargin = Math.max(innerMargin, outerMargin, this.options.hitTestMargin / zoomLevel)
5263
+ const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, searchMargin)
5264
+
5225
5265
  const shapesToCheck = (
5226
5266
  opts.renderingOnly
5227
5267
  ? this.getCurrentPageRenderingShapesSorted()
5228
5268
  : this.getCurrentPageShapesSorted()
5229
5269
  ).filter((shape) => {
5270
+ // Frames have labels positioned above the shape (outside bounds), so always include them
5271
+ if (!candidateIds.has(shape.id) && !this.isShapeOfType(shape, 'frame')) return false
5272
+
5230
5273
  if (
5231
5274
  (shape.isLocked && !hitLocked) ||
5232
5275
  this.isShapeHidden(shape) ||
@@ -5412,11 +5455,41 @@ export class Editor extends EventEmitter<TLEventMap> {
5412
5455
  point: VecLike,
5413
5456
  opts = {} as { margin?: number; hitInside?: boolean }
5414
5457
  ): TLShape[] {
5458
+ const margin = opts.margin ?? 0
5459
+ const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, margin)
5460
+
5461
+ // Get all page shapes in z-index order and filter to candidates that pass isPointInShape
5462
+ // Frames are always checked because their labels can be outside their bounds
5415
5463
  return this.getCurrentPageShapesSorted()
5416
- .filter((shape) => !this.isShapeHidden(shape) && this.isPointInShape(shape, point, opts))
5464
+ .filter((shape) => {
5465
+ if (this.isShapeHidden(shape)) return false
5466
+ if (!candidateIds.has(shape.id) && !this.isShapeOfType(shape, 'frame')) return false
5467
+ return this.isPointInShape(shape, point, opts)
5468
+ })
5417
5469
  .reverse()
5418
5470
  }
5419
5471
 
5472
+ /**
5473
+ * Get shape IDs within the given bounds.
5474
+ *
5475
+ * Note: Uses shape page bounds only. Frames with labels outside their bounds
5476
+ * may not be included even if the label is within the search bounds.
5477
+ *
5478
+ * Note: Results are unordered. If you need z-order, combine with sorted shapes:
5479
+ * ```ts
5480
+ * const candidates = editor.getShapeIdsInsideBounds(bounds)
5481
+ * const sorted = editor.getCurrentPageShapesSorted().filter(s => candidates.has(s.id))
5482
+ * ```
5483
+ *
5484
+ * @param bounds - The bounds to search within.
5485
+ * @returns Unordered set of shape IDs within the given bounds.
5486
+ *
5487
+ * @internal
5488
+ */
5489
+ getShapeIdsInsideBounds(bounds: Box): Set<TLShapeId> {
5490
+ return this._spatialIndex.getShapeIdsInsideBounds(bounds)
5491
+ }
5492
+
5420
5493
  /**
5421
5494
  * Test whether a point (in the current page space) will will a shape. This method takes into account masks,
5422
5495
  * such as when a shape is the child of a frame and is partially clipped by the frame.
@@ -9892,7 +9965,7 @@ export class Editor extends EventEmitter<TLEventMap> {
9892
9965
  /**
9893
9966
  * Handles navigating to the content specified by the query param in the given URL.
9894
9967
  *
9895
- * Use {@link Editor#createDeepLink} to create a URL with a deep link query param.
9968
+ * Use {@link Editor.createDeepLink} to create a URL with a deep link query param.
9896
9969
  *
9897
9970
  * If no URL is provided, it will look for the param in the current `window.location.href`.
9898
9971
  *
@@ -10351,9 +10424,10 @@ export class Editor extends EventEmitter<TLEventMap> {
10351
10424
  if (inputs.getIsPinching()) return
10352
10425
 
10353
10426
  if (!inputs.getIsEditing()) {
10354
- if (!this._selectedShapeIdsAtPointerDown.length) {
10355
- this._selectedShapeIdsAtPointerDown = [...pageState.selectedShapeIds]
10356
- }
10427
+ // Always capture the current selection when pinch starts.
10428
+ // This ensures Safari (which uses gesture events instead of wheel)
10429
+ // doesn't restore a stale selection from an earlier pointer_down.
10430
+ this._selectedShapeIdsAtPointerDown = [...pageState.selectedShapeIds]
10357
10431
 
10358
10432
  this._didPinch = true
10359
10433
 
@@ -10475,7 +10549,16 @@ export class Editor extends EventEmitter<TLEventMap> {
10475
10549
  }
10476
10550
  }
10477
10551
 
10478
- const zoom = cz + (delta ?? 0) * zoomSpeed * cz
10552
+ // because we can't for sure detect whether a user is using a mouse or a trackpad,
10553
+ // we need to check the input mode preference, and only invert the zoom direction
10554
+ // if the user has specifically set it to a mouse.
10555
+ const isZoomDirectionInverted =
10556
+ (this.user.getUserPreferences().isZoomDirectionInverted && inputMode === 'mouse') ??
10557
+ false
10558
+ const deltaValue = delta ?? 0
10559
+ const finalDelta = isZoomDirectionInverted ? -deltaValue : deltaValue
10560
+
10561
+ const zoom = cz + finalDelta * zoomSpeed * cz
10479
10562
  this._setCamera(new Vec(cx + x / zoom - x / cz, cy + y / zoom - y / cz, zoom), {
10480
10563
  immediate: true,
10481
10564
  })
@@ -10655,6 +10738,11 @@ export class Editor extends EventEmitter<TLEventMap> {
10655
10738
  this.setCurrentTool(this._restoreToolId)
10656
10739
  }
10657
10740
  }
10741
+
10742
+ // Clear the stashed selection so the next pinch captures fresh state.
10743
+ // This fixes Safari pinch zoom restoring outdated selections.
10744
+ this._selectedShapeIdsAtPointerDown = []
10745
+
10658
10746
  break
10659
10747
  }
10660
10748
  }
@@ -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
  }
@@ -1,12 +1,12 @@
1
- import { atom, transact } from '@tldraw/state'
1
+ import { atom, EMPTY_ARRAY, transact } from '@tldraw/state'
2
2
  import {
3
- RecordsDiff,
4
- Store,
5
- UnknownRecord,
6
3
  createEmptyRecordsDiff,
7
4
  isRecordsDiffEmpty,
5
+ RecordsDiff,
8
6
  reverseRecordsDiff,
9
7
  squashRecordDiffsMutable,
8
+ Store,
9
+ UnknownRecord,
10
10
  } from '@tldraw/store'
11
11
  import { exhaustiveSwitchError, noop } from '@tldraw/utils'
12
12
  import { TLHistoryBatchOptions, TLHistoryEntry } from '../../types/history-types'
@@ -312,8 +312,8 @@ export class HistoryManager<R extends UnknownRecord> {
312
312
  debug() {
313
313
  const { undos, redos } = this.stacks.get()
314
314
  return {
315
- undos: undos.toArray(),
316
- redos: redos.toArray(),
315
+ undos: stackToArray(undos),
316
+ redos: stackToArray(redos),
317
317
  pendingDiff: this.pendingDiff.debug(),
318
318
  state: this.state as string,
319
319
  }
@@ -351,22 +351,13 @@ class PendingDiff<R extends UnknownRecord> {
351
351
  }
352
352
  }
353
353
 
354
- import { EMPTY_ARRAY } from '@tldraw/state'
354
+ type Stack<T> = StackItem<T> | EmptyStackItem<T>
355
355
 
356
- export type Stack<T> = StackItem<T> | EmptyStackItem<T>
357
-
358
- export function stack<T>(items?: Array<T>): Stack<T> {
359
- if (items) {
360
- let result = EMPTY_STACK_ITEM as Stack<T>
361
- while (items.length) {
362
- result = result.push(items.pop()!)
363
- }
364
- return result
365
- }
356
+ function stack<T>(): Stack<T> {
366
357
  return EMPTY_STACK_ITEM as any
367
358
  }
368
359
 
369
- class EmptyStackItem<T> implements Iterable<T> {
360
+ class EmptyStackItem<T> {
370
361
  readonly length = 0
371
362
  readonly head = null
372
363
  readonly tail: Stack<T> = this
@@ -374,23 +365,11 @@ class EmptyStackItem<T> implements Iterable<T> {
374
365
  push(head: T): Stack<T> {
375
366
  return new StackItem<T>(head, this)
376
367
  }
377
-
378
- toArray() {
379
- return EMPTY_ARRAY
380
- }
381
-
382
- [Symbol.iterator]() {
383
- return {
384
- next() {
385
- return { value: undefined, done: true as const }
386
- },
387
- }
388
- }
389
368
  }
390
369
 
391
370
  const EMPTY_STACK_ITEM = new EmptyStackItem()
392
371
 
393
- class StackItem<T> implements Iterable<T> {
372
+ class StackItem<T> {
394
373
  length: number
395
374
  constructor(
396
375
  public readonly head: T,
@@ -402,23 +381,16 @@ class StackItem<T> implements Iterable<T> {
402
381
  push(head: T): Stack<T> {
403
382
  return new StackItem(head, this)
404
383
  }
384
+ }
405
385
 
406
- toArray() {
407
- return Array.from(this)
386
+ function stackToArray<T>(stack: Stack<T>) {
387
+ if (!stack.length) {
388
+ return EMPTY_ARRAY
408
389
  }
409
-
410
- [Symbol.iterator]() {
411
- let stack = this as Stack<T>
412
- return {
413
- next() {
414
- if (stack.length) {
415
- const value = stack.head!
416
- stack = stack.tail
417
- return { value, done: false as const }
418
- } else {
419
- return { value: undefined, done: true as const }
420
- }
421
- },
422
- }
390
+ const arr: T[] = []
391
+ while (stack.length) {
392
+ arr.push(stack.head!)
393
+ stack = stack.tail
423
394
  }
395
+ return arr
424
396
  }