@tldraw/editor 5.5.0-next.b6a9f7cd7be2 → 5.5.0-next.c91b2de0a423

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 (96) hide show
  1. package/dist-cjs/index.d.ts +47 -23
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  5. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
  6. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  7. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
  8. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
  9. package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
  10. package/dist-cjs/lib/editor/Editor.js +24 -26
  11. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  12. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -2
  13. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  14. package/dist-cjs/lib/editor/managers/EditorManager.js +11 -2
  15. package/dist-cjs/lib/editor/managers/EditorManager.js.map +2 -2
  16. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +49 -63
  17. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +2 -2
  18. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +1 -1
  19. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  20. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +24 -24
  21. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  22. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js +7 -1
  23. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
  24. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js +4 -3
  25. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js.map +2 -2
  26. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  27. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  28. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  29. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  30. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +19 -8
  31. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  32. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +3 -0
  33. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  34. package/dist-cjs/version.js +3 -3
  35. package/dist-cjs/version.js.map +1 -1
  36. package/dist-esm/index.d.mts +47 -23
  37. package/dist-esm/index.mjs +4 -2
  38. package/dist-esm/index.mjs.map +2 -2
  39. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  40. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  41. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  42. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  43. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  44. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  45. package/dist-esm/lib/editor/Editor.mjs +24 -26
  46. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  47. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -2
  48. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  49. package/dist-esm/lib/editor/managers/EditorManager.mjs +11 -2
  50. package/dist-esm/lib/editor/managers/EditorManager.mjs.map +2 -2
  51. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +49 -63
  52. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +1 -1
  54. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  55. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +24 -24
  56. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  57. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs +7 -1
  58. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
  59. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs +4 -3
  60. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs.map +2 -2
  61. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  62. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  63. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  64. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +19 -8
  65. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  66. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +3 -0
  67. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  68. package/dist-esm/version.mjs +3 -3
  69. package/dist-esm/version.mjs.map +1 -1
  70. package/package.json +7 -7
  71. package/src/index.ts +5 -1
  72. package/src/lib/TldrawEditor.tsx +8 -6
  73. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  74. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  75. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  76. package/src/lib/editor/Editor.test.ts +45 -0
  77. package/src/lib/editor/Editor.ts +40 -35
  78. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +1 -0
  79. package/src/lib/editor/managers/ClickManager/ClickManager.ts +3 -2
  80. package/src/lib/editor/managers/EditorManager.test.ts +32 -1
  81. package/src/lib/editor/managers/EditorManager.ts +15 -3
  82. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +80 -0
  83. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +54 -81
  84. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +1 -1
  85. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +32 -36
  86. package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +13 -2
  87. package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +13 -0
  88. package/src/lib/editor/managers/SnapManager/SnapManager.ts +6 -4
  89. package/src/lib/editor/types/external-content.ts +5 -0
  90. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  91. package/src/lib/utils/debug-flags.ts +0 -6
  92. package/src/lib/utils/sync/LocalIndexedDb.test.ts +93 -1
  93. package/src/lib/utils/sync/LocalIndexedDb.ts +23 -8
  94. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +21 -1
  95. package/src/lib/utils/sync/TLLocalSyncClient.ts +6 -0
  96. package/src/version.ts +3 -3
@@ -2013,7 +2013,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2013
2013
  this._updateInstanceState(partial, { history: 'ignore', ...historyOptions })
2014
2014
 
2015
2015
  if (partial.isChangingStyle !== undefined) {
2016
- clearTimeout(this._isChangingStyleTimeout)
2016
+ this.timers.clearTimeout(this._isChangingStyleTimeout)
2017
2017
  if (partial.isChangingStyle === true) {
2018
2018
  // If we've set to true, set a new reset timeout to change the value back to false after 1 seconds
2019
2019
  this._isChangingStyleTimeout = this.timers.setTimeout(() => {
@@ -10797,22 +10797,22 @@ export class Editor extends EventEmitter<TLEventMap> {
10797
10797
  const releaseMeta = this._metaKeyTimeout !== -1
10798
10798
 
10799
10799
  if (releaseShift) {
10800
- clearTimeout(this._shiftKeyTimeout)
10800
+ this.timers.clearTimeout(this._shiftKeyTimeout)
10801
10801
  this._shiftKeyTimeout = -1
10802
10802
  this.inputs.setShiftKey(false)
10803
10803
  }
10804
10804
  if (releaseAlt) {
10805
- clearTimeout(this._altKeyTimeout)
10805
+ this.timers.clearTimeout(this._altKeyTimeout)
10806
10806
  this._altKeyTimeout = -1
10807
10807
  this.inputs.setAltKey(false)
10808
10808
  }
10809
10809
  if (releaseCtrl) {
10810
- clearTimeout(this._ctrlKeyTimeout)
10810
+ this.timers.clearTimeout(this._ctrlKeyTimeout)
10811
10811
  this._ctrlKeyTimeout = -1
10812
10812
  this.inputs.setCtrlKey(false)
10813
10813
  }
10814
10814
  if (releaseMeta) {
10815
- clearTimeout(this._metaKeyTimeout)
10815
+ this.timers.clearTimeout(this._metaKeyTimeout)
10816
10816
  this._metaKeyTimeout = -1
10817
10817
  this.inputs.setMetaKey(false)
10818
10818
  }
@@ -10962,7 +10962,7 @@ export class Editor extends EventEmitter<TLEventMap> {
10962
10962
  }
10963
10963
 
10964
10964
  if (info.shiftKey) {
10965
- clearTimeout(this._shiftKeyTimeout)
10965
+ this.timers.clearTimeout(this._shiftKeyTimeout)
10966
10966
  this._shiftKeyTimeout = -1
10967
10967
  inputs.setShiftKey(true)
10968
10968
  } else if (!info.shiftKey && inputs.getShiftKey() && this._shiftKeyTimeout === -1) {
@@ -10970,7 +10970,7 @@ export class Editor extends EventEmitter<TLEventMap> {
10970
10970
  }
10971
10971
 
10972
10972
  if (info.altKey) {
10973
- clearTimeout(this._altKeyTimeout)
10973
+ this.timers.clearTimeout(this._altKeyTimeout)
10974
10974
  this._altKeyTimeout = -1
10975
10975
  inputs.setAltKey(true)
10976
10976
  } else if (!info.altKey && inputs.getAltKey() && this._altKeyTimeout === -1) {
@@ -10978,7 +10978,7 @@ export class Editor extends EventEmitter<TLEventMap> {
10978
10978
  }
10979
10979
 
10980
10980
  if (info.ctrlKey) {
10981
- clearTimeout(this._ctrlKeyTimeout)
10981
+ this.timers.clearTimeout(this._ctrlKeyTimeout)
10982
10982
  this._ctrlKeyTimeout = -1
10983
10983
  inputs.setCtrlKey(true)
10984
10984
  } else if (!info.ctrlKey && inputs.getCtrlKey() && this._ctrlKeyTimeout === -1) {
@@ -10988,7 +10988,7 @@ export class Editor extends EventEmitter<TLEventMap> {
10988
10988
  if (info.metaKey && info.name !== 'key_up') {
10989
10989
  // Unlike the other modifiers, the native metaKey property is still true on keyup.
10990
10990
  // If we don't have this guard, then the metakey will be left true without the timeout.
10991
- clearTimeout(this._metaKeyTimeout)
10991
+ this.timers.clearTimeout(this._metaKeyTimeout)
10992
10992
  this._metaKeyTimeout = -1
10993
10993
  inputs.setMetaKey(true)
10994
10994
  } else if (!info.metaKey && inputs.getMetaKey() && this._metaKeyTimeout === -1) {
@@ -11005,8 +11005,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11005
11005
 
11006
11006
  switch (type) {
11007
11007
  case 'pinch': {
11008
- if (cameraOptions.isLocked) return
11009
- clearTimeout(this._longPressTimeout)
11008
+ this.timers.clearTimeout(this._longPressTimeout)
11010
11009
  this.inputs.updateFromEvent(info)
11011
11010
 
11012
11011
  switch (info.name) {
@@ -11043,6 +11042,9 @@ export class Editor extends EventEmitter<TLEventMap> {
11043
11042
  }
11044
11043
  case 'pinch': {
11045
11044
  if (!inputs.getIsPinching()) return
11045
+ // Lock the zoom, not the gesture: skipping pinch_start would send the fingers'
11046
+ // pointer events to the tool, and skipping pinch_end would leave isPinching stuck.
11047
+ if (cameraOptions.isLocked) return
11046
11048
 
11047
11049
  const {
11048
11050
  point: { z = 1 },
@@ -11116,8 +11118,9 @@ export class Editor extends EventEmitter<TLEventMap> {
11116
11118
  let wheelBehavior = cameraOptions.wheelBehavior
11117
11119
  const inputMode = this.user.getUserPreferences().inputMode
11118
11120
 
11119
- // If the user has set their input mode preference, then use that to determine the wheel behavior
11120
- if (inputMode !== null) {
11121
+ // The user's input mode preference picks between pan and zoom, but it must not
11122
+ // re-enable a wheel the app disabled with `wheelBehavior: 'none'`.
11123
+ if (inputMode !== null && wheelBehavior !== 'none') {
11121
11124
  wheelBehavior = inputMode === 'trackpad' ? 'pan' : 'zoom'
11122
11125
  }
11123
11126
 
@@ -11191,15 +11194,18 @@ export class Editor extends EventEmitter<TLEventMap> {
11191
11194
  // Ignore pointer events while we're pinching
11192
11195
  if (inputs.getIsPinching()) return
11193
11196
 
11194
- this.inputs.updateFromEvent(info)
11195
11197
  const { isPen } = info
11196
11198
  const { isPenMode } = instanceState
11197
11199
 
11200
+ // In pen mode, reject non-pen input (a resting palm) before it touches any
11201
+ // state: otherwise updateFromEvent moves the origin point out from under the
11202
+ // pen's drag, and a palm pointer_up clears the pen's isPointing/isDragging.
11203
+ if (isPenMode && !isPen) return
11204
+
11205
+ this.inputs.updateFromEvent(info)
11206
+
11198
11207
  switch (info.name) {
11199
11208
  case 'pointer_down': {
11200
- // If we're in pen mode and the input is not a pen type, then stop here
11201
- if (isPenMode && !isPen) return
11202
-
11203
11209
  // A pointer down starts a new interaction, so flush any modifier that's
11204
11210
  // still lingering in its release-debounce window: treat it as released now.
11205
11211
  this._releaseDebouncedModifiers()
@@ -11255,8 +11261,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11255
11261
  this.interrupt()
11256
11262
  }
11257
11263
 
11258
- // On devices with erasers (like the Surface Pen or Wacom Pen), button 5 is the eraser
11259
- if (info.button === STYLUS_ERASER_BUTTON) {
11264
+ // On devices with erasers (like the Surface Pen or Wacom Pen), button 5 is the eraser.
11265
+ // Guarded on the eraser tool existing: the bare editor can be configured without
11266
+ // it, and an unguarded transition would throw and crash the editor.
11267
+ if (info.button === STYLUS_ERASER_BUTTON && this.getStateDescendant('eraser')) {
11260
11268
  this._restoreToolId = this.getCurrentToolId()
11261
11269
  this.complete()
11262
11270
  this.setCurrentTool('eraser')
@@ -11266,10 +11274,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11266
11274
  this._prevCursor = this.getInstanceState().cursor.type
11267
11275
  }
11268
11276
  this.inputs.setIsPanning(true)
11269
- clearTimeout(this._longPressTimeout)
11277
+ this.timers.clearTimeout(this._longPressTimeout)
11270
11278
  } else if (info.button === RIGHT_MOUSE_BUTTON && this.options.rightClickPanning) {
11271
11279
  this.inputs.setIsRightPointing(true)
11272
- clearTimeout(this._longPressTimeout)
11280
+ this.timers.clearTimeout(this._longPressTimeout)
11273
11281
  return this
11274
11282
  }
11275
11283
 
@@ -11284,9 +11292,6 @@ export class Editor extends EventEmitter<TLEventMap> {
11284
11292
  break
11285
11293
  }
11286
11294
  case 'pointer_move': {
11287
- // If the user is in pen mode, but the pointer is not a pen, stop here.
11288
- if (!isPen && isPenMode) return
11289
-
11290
11295
  const { x: cx, y: cy, z: cz } = unsafe__withoutCapture(() => this.getCamera())
11291
11296
 
11292
11297
  // Right-click pointing: waiting to see if this becomes a drag
@@ -11337,7 +11342,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11337
11342
  ) {
11338
11343
  // Start dragging
11339
11344
  inputs.setIsDragging(true)
11340
- clearTimeout(this._longPressTimeout)
11345
+ this.timers.clearTimeout(this._longPressTimeout)
11341
11346
  }
11342
11347
  break
11343
11348
  }
@@ -11345,13 +11350,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11345
11350
  // Stop dragging / pointing
11346
11351
  inputs.setIsDragging(false)
11347
11352
  inputs.setIsPointing(false)
11348
- clearTimeout(this._longPressTimeout)
11353
+ this.timers.clearTimeout(this._longPressTimeout)
11349
11354
  // Remove the button from the buttons set
11350
11355
  inputs.buttons.delete(info.button)
11351
11356
 
11352
- // If we're in pen mode and we're not using a pen, stop here
11353
- if (instanceState.isPenMode && !isPen) return
11354
-
11355
11357
  // Right-click pointing ended without dragging—this is a static
11356
11358
  // right-click, so let it through to the state chart as right_click.
11357
11359
  // Check isPanning first: if we transitioned to panning, isRightPointing
@@ -11421,7 +11423,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11421
11423
  })
11422
11424
  }
11423
11425
  } else {
11424
- if (info.button === STYLUS_ERASER_BUTTON) {
11426
+ if (info.button === STYLUS_ERASER_BUTTON && this.getStateDescendant('eraser')) {
11425
11427
  // If we were erasing with a stylus button, restore the tool we were using before we started erasing
11426
11428
  this.complete()
11427
11429
  this.setCurrentTool(this._restoreToolId)
@@ -11461,7 +11463,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11461
11463
 
11462
11464
  this.inputs.setIsPanning(true)
11463
11465
  this.inputs.setIsSpacebarPanning(true)
11464
- clearTimeout(this._longPressTimeout)
11466
+ this.timers.clearTimeout(this._longPressTimeout)
11465
11467
  this.setCursor({
11466
11468
  type: this.inputs.getIsPointing() ? 'grabbing' : 'grab',
11467
11469
  rotation: 0,
@@ -11489,7 +11491,8 @@ export class Editor extends EventEmitter<TLEventMap> {
11489
11491
  }
11490
11492
  }
11491
11493
 
11492
- if (offset) {
11494
+ // `_animateToViewport` bypasses `setCamera`, so honor the lock here.
11495
+ if (offset && !cameraOptions.isLocked) {
11493
11496
  const bounds = this.getViewportPageBounds()
11494
11497
  const next = bounds.clone().translate(offset.mulV({ x: bounds.w, y: bounds.h }))
11495
11498
  this._animateToViewport(next, { animation: { duration: 320 } })
@@ -11551,9 +11554,11 @@ export class Editor extends EventEmitter<TLEventMap> {
11551
11554
  this.setCurrentTool('select')
11552
11555
  }
11553
11556
 
11554
- // If a left click pointer event, send the event to the click manager.
11557
+ // Send the event to the click manager. In pen mode only pen clicks count; otherwise
11558
+ // every pointer does, including an indirect tablet stylus (a pen that never enables
11559
+ // pen mode), which would otherwise never produce double-click events.
11555
11560
  const { isPenMode } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
11556
- if (info.isPen === isPenMode) {
11561
+ if (!isPenMode || info.isPen) {
11557
11562
  // The click manager may return a new event, i.e. a double click event
11558
11563
  // depending on the event coming in and its own state. If the event has
11559
11564
  // changed then hand both events to the statechart
@@ -11585,7 +11590,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11585
11590
  private maybeTrackPerformance(name: string) {
11586
11591
  if (debugFlags.measurePerformance.get()) {
11587
11592
  if (this.performanceTracker.isStarted()) {
11588
- clearTimeout(this.performanceTrackerTimeout)
11593
+ this.timers.clearTimeout(this.performanceTrackerTimeout)
11589
11594
  } else {
11590
11595
  this.performanceTracker.start(name)
11591
11596
  }
@@ -34,6 +34,7 @@ describe('ClickManager', () => {
34
34
  vi.useFakeTimers()
35
35
  mockTimers = {
36
36
  setTimeout: vi.fn((fn, delay) => setTimeout(fn, delay)),
37
+ clearTimeout: vi.fn((id) => clearTimeout(id)),
37
38
  }
38
39
 
39
40
  const mockCurrentScreenPoint = new Vec(0, 0)
@@ -25,7 +25,7 @@ export class ClickManager {
25
25
  @bind
26
26
  _getClickTimeout(state: TLClickState, id = uniqueId()) {
27
27
  this._clickId = id
28
- clearTimeout(this._clickTimeout)
28
+ this.editor.timers.clearTimeout(this._clickTimeout)
29
29
  this._clickTimeout = this.editor.timers.setTimeout(
30
30
  () => {
31
31
  if (this._clickState === state && this._clickId === id) {
@@ -166,7 +166,8 @@ export class ClickManager {
166
166
  */
167
167
  @bind
168
168
  cancelDoubleClickTimeout() {
169
- this._clickTimeout = clearTimeout(this._clickTimeout)
169
+ this.editor.timers.clearTimeout(this._clickTimeout)
170
+ this._clickTimeout = undefined
170
171
  this._clickState = 'idle'
171
172
  // when a double click is cancelled, we are no longer pending any further
172
173
  // clicks, so we set this to false even if the user is still pressing
@@ -4,16 +4,24 @@ import { EditorManager } from './EditorManager'
4
4
 
5
5
  class TestManager extends EditorManager {
6
6
  onTestEvent = vi.fn()
7
+ removeTestEvent: () => void
7
8
 
8
9
  constructor(editor: Editor) {
9
10
  super(editor)
10
- this.addEditorEvent('frame', this.onTestEvent)
11
+ this.removeTestEvent = this.addEditorEvent('frame', this.onTestEvent)
12
+ }
13
+
14
+ detachTestEvent() {
15
+ this.unregister(this.removeTestEvent)
11
16
  }
12
17
 
13
18
  cancelRaf = vi.fn()
14
19
  startRaf() {
15
20
  this.register(this.cancelRaf)
16
21
  }
22
+ stopRaf() {
23
+ this.unregister(this.cancelRaf)
24
+ }
17
25
  }
18
26
 
19
27
  describe('EditorManager', () => {
@@ -48,4 +56,27 @@ describe('EditorManager', () => {
48
56
  manager.dispose()
49
57
  expect(() => manager.dispose()).not.toThrow()
50
58
  })
59
+
60
+ it('unregister runs the disposable now and drops it from dispose', () => {
61
+ manager.startRaf()
62
+ manager.stopRaf()
63
+ expect(manager.cancelRaf).toHaveBeenCalledTimes(1)
64
+ manager.dispose()
65
+ expect(manager.cancelRaf).toHaveBeenCalledTimes(1)
66
+ })
67
+
68
+ it('unregister ignores a disposable that already ran', () => {
69
+ manager.startRaf()
70
+ manager.dispose()
71
+ manager.stopRaf()
72
+ expect(manager.cancelRaf).toHaveBeenCalledTimes(1)
73
+ })
74
+
75
+ it('addEditorEvent returns the registered unsubscribe so it can be detached early', () => {
76
+ manager.detachTestEvent()
77
+ expect(off).toHaveBeenCalledTimes(1)
78
+ expect(off).toHaveBeenCalledWith('frame', manager.onTestEvent)
79
+ manager.dispose()
80
+ expect(off).toHaveBeenCalledTimes(1)
81
+ })
51
82
  })
@@ -16,6 +16,8 @@ import type { TLEventMap } from '../types/emit-types'
16
16
  * - Reaction (`react(...)` from `@tldraw/state`): `this.register(react(...))`
17
17
  * - DOM listener: `this.register(() => el.removeEventListener(...))`
18
18
  * - Other resource (cache, index, child manager): `this.register(() => x.dispose())`
19
+ * - Resource held only part of the time (a listener attached while subscribers exist):
20
+ * `this.register(...)` on attach and `this.unregister(...)` on detach, see `PerformanceManager`
19
21
  *
20
22
  * For timeouts, intervals, and animation frames prefer `editor.timers` (context-grouped
21
23
  * and auto-disposed) over raw `setTimeout`. For cleanup on the editor itself rather than a
@@ -40,16 +42,26 @@ export abstract class EditorManager {
40
42
  return dispose
41
43
  }
42
44
 
45
+ /**
46
+ * Run a registered teardown now instead of at `dispose()`. A teardown that is no longer
47
+ * registered (already run by `dispose()`, or never registered) is skipped, so nothing is
48
+ * torn down twice.
49
+ */
50
+ protected unregister(dispose: () => void): void {
51
+ if (this.disposables.delete(dispose)) dispose()
52
+ }
53
+
43
54
  /**
44
55
  * Subscribe to an editor bus event and register the matching unsubscribe, so the listener
45
- * is removed automatically on `dispose()`.
56
+ * is removed automatically on `dispose()`. Returns that unsubscribe, for passing to
57
+ * {@link EditorManager.unregister} to detach it before `dispose()`.
46
58
  */
47
59
  protected addEditorEvent<E extends keyof TLEventMap>(
48
60
  event: E,
49
61
  fn: (...args: TLEventMap[E]) => void
50
- ): void {
62
+ ): () => void {
51
63
  this.editor.on(event, fn as any)
52
- this.register(() => this.editor.off(event, fn as any))
64
+ return this.register(() => this.editor.off(event, fn as any))
53
65
  }
54
66
 
55
67
  /** @internal */
@@ -38,6 +38,7 @@ function createMockEditor() {
38
38
  getViewportScreenBounds: () => ({ w: 1920, h: 1080 }),
39
39
  timers: {
40
40
  setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms),
41
+ clearTimeout: (id: number) => clearTimeout(id),
41
42
  },
42
43
  _listeners: listeners,
43
44
  } as any
@@ -94,6 +95,85 @@ describe('PerformanceManager', () => {
94
95
  })
95
96
  })
96
97
 
98
+ describe('dispose()', () => {
99
+ it('removes every lazily attached editor listener', () => {
100
+ const editor = createMockEditor()
101
+ const pm = new PerformanceManager(editor)
102
+ pm.on('interaction-end', vi.fn())
103
+ pm.on('shapes-created', vi.fn())
104
+ pm.on('shapes-updated', vi.fn())
105
+ pm.on('shapes-deleted', vi.fn())
106
+ expect(editor._listeners['frame']).toHaveLength(1)
107
+ expect(editor._listeners['created-shapes']).toHaveLength(1)
108
+ expect(editor._listeners['edited-shapes']).toHaveLength(1)
109
+ expect(editor._listeners['deleted-shapes']).toHaveLength(1)
110
+
111
+ pm.dispose()
112
+
113
+ expect(editor._listeners).toEqual({
114
+ frame: [],
115
+ 'created-shapes': [],
116
+ 'edited-shapes': [],
117
+ 'deleted-shapes': [],
118
+ })
119
+ })
120
+
121
+ it('disconnects the LoAF observer', () => {
122
+ const origPO = globalThis.PerformanceObserver
123
+ const mockDisconnect = vi.fn()
124
+ globalThis.PerformanceObserver = class MockPO {
125
+ constructor(_cb: any) {}
126
+ observe() {}
127
+ disconnect = mockDisconnect
128
+ static supportedEntryTypes = ['long-animation-frame']
129
+ } as any
130
+
131
+ const editor = createMockEditor()
132
+ const pm = new PerformanceManager(editor)
133
+ pm.on('interaction-end', vi.fn())
134
+ expect(mockDisconnect).not.toHaveBeenCalled()
135
+
136
+ pm.dispose()
137
+ expect(mockDisconnect).toHaveBeenCalledTimes(1)
138
+
139
+ globalThis.PerformanceObserver = origPO
140
+ })
141
+
142
+ it('does not tear down a listener that was already detached', () => {
143
+ const editor = createMockEditor()
144
+ const off = vi.spyOn(editor, 'off')
145
+ const pm = new PerformanceManager(editor)
146
+ pm.on('shapes-created', vi.fn())()
147
+ expect(off).toHaveBeenCalledTimes(1)
148
+
149
+ pm.dispose()
150
+ expect(off).toHaveBeenCalledTimes(1)
151
+ })
152
+
153
+ it('makes a stale unsubscribe a no-op after dispose', () => {
154
+ const editor = createMockEditor()
155
+ const off = vi.spyOn(editor, 'off')
156
+ const pm = new PerformanceManager(editor)
157
+ const unsub = pm.on('interaction-end', vi.fn())
158
+
159
+ pm.dispose()
160
+ expect(off).toHaveBeenCalledTimes(1)
161
+ unsub()
162
+ expect(off).toHaveBeenCalledTimes(1)
163
+ })
164
+
165
+ it('attaches again for a subscriber added after dispose', () => {
166
+ const editor = createMockEditor()
167
+ const pm = new PerformanceManager(editor)
168
+ pm.on('shapes-created', vi.fn())
169
+ pm.dispose()
170
+ expect(editor._listeners['created-shapes']).toHaveLength(0)
171
+
172
+ pm.on('shapes-created', vi.fn())
173
+ expect(editor._listeners['created-shapes']).toHaveLength(1)
174
+ })
175
+ })
176
+
97
177
  describe('interaction tracking', () => {
98
178
  it('emits interaction-start when notified', () => {
99
179
  const editor = createMockEditor()