@tldraw/editor 5.5.0-next.f9308d324e1c → 5.5.0

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 (192) hide show
  1. package/dist-cjs/index.d.ts +49 -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 +16 -12
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
  11. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
  12. package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
  13. package/dist-cjs/lib/editor/Editor.js +168 -181
  14. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  15. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  16. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -2
  18. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/EditorManager.js +11 -2
  20. package/dist-cjs/lib/editor/managers/EditorManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +49 -63
  22. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +1 -1
  24. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +24 -24
  26. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  27. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js +7 -1
  28. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
  29. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js +4 -3
  30. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js.map +2 -2
  31. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  32. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  33. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  34. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  35. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  36. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  37. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  38. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  39. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  40. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  41. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  42. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  43. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  44. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  45. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  46. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  47. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  48. package/dist-cjs/lib/exports/parseCss.js +1 -1
  49. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  50. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  51. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  52. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  53. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  54. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  55. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  56. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  57. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  58. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  59. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  60. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  61. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  62. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  63. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +32 -25
  64. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  65. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
  66. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  67. package/dist-cjs/version.js +3 -3
  68. package/dist-cjs/version.js.map +1 -1
  69. package/dist-esm/index.d.mts +49 -23
  70. package/dist-esm/index.mjs +4 -2
  71. package/dist-esm/index.mjs.map +2 -2
  72. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  73. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  74. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  75. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  76. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  77. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  78. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  79. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  80. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  81. package/dist-esm/lib/editor/Editor.mjs +168 -181
  82. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  83. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  84. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  85. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -2
  86. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  87. package/dist-esm/lib/editor/managers/EditorManager.mjs +11 -2
  88. package/dist-esm/lib/editor/managers/EditorManager.mjs.map +2 -2
  89. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +49 -63
  90. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +2 -2
  91. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +1 -1
  92. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +24 -24
  94. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  95. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs +7 -1
  96. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
  97. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs +4 -3
  98. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs.map +2 -2
  99. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  100. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  101. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  102. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  103. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  104. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  105. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  106. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  107. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  108. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  109. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  110. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  111. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  112. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  113. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  114. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  115. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  116. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  117. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  118. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  119. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  120. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  121. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  122. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  123. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  124. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  125. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  126. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  127. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  128. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  129. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  130. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +32 -25
  131. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  132. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
  133. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  134. package/dist-esm/version.mjs +3 -3
  135. package/dist-esm/version.mjs.map +1 -1
  136. package/editor.css +9 -1
  137. package/package.json +8 -11
  138. package/src/index.ts +5 -1
  139. package/src/lib/TldrawEditor.tsx +35 -21
  140. package/src/lib/components/MenuClickCapture.tsx +55 -4
  141. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  142. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  143. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  144. package/src/lib/editor/Editor.test.ts +65 -0
  145. package/src/lib/editor/Editor.ts +261 -294
  146. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  147. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +1 -0
  148. package/src/lib/editor/managers/ClickManager/ClickManager.ts +3 -2
  149. package/src/lib/editor/managers/EditorManager.test.ts +32 -1
  150. package/src/lib/editor/managers/EditorManager.ts +15 -3
  151. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +80 -0
  152. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +54 -81
  153. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +1 -1
  154. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +32 -36
  155. package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +13 -2
  156. package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +13 -0
  157. package/src/lib/editor/managers/SnapManager/SnapManager.ts +6 -4
  158. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  159. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  160. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  161. package/src/lib/editor/types/external-content.ts +5 -0
  162. package/src/lib/exports/ExportDelay.tsx +16 -4
  163. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  164. package/src/lib/exports/FontEmbedder.ts +32 -11
  165. package/src/lib/exports/StyleEmbedder.ts +13 -4
  166. package/src/lib/exports/fetchCache.test.ts +22 -0
  167. package/src/lib/exports/fetchCache.ts +4 -0
  168. package/src/lib/exports/getSvgAsImage.ts +13 -5
  169. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  170. package/src/lib/exports/getSvgJsx.tsx +28 -16
  171. package/src/lib/exports/parseCss.test.ts +4 -0
  172. package/src/lib/exports/parseCss.ts +2 -2
  173. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  174. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  175. package/src/lib/hooks/useIdentity.tsx +12 -1
  176. package/src/lib/license/LicenseManager.ts +13 -1
  177. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  178. package/src/lib/license/LicenseProvider.tsx +19 -5
  179. package/src/lib/test/preserveFocus.test.tsx +81 -0
  180. package/src/lib/utils/debug-flags.ts +0 -6
  181. package/src/lib/utils/deepLinks.test.ts +15 -0
  182. package/src/lib/utils/deepLinks.ts +8 -3
  183. package/src/lib/utils/sync/LocalIndexedDb.test.ts +121 -1
  184. package/src/lib/utils/sync/LocalIndexedDb.ts +41 -28
  185. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
  186. package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
  187. package/src/version.ts +3 -3
  188. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  189. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  190. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  191. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  192. package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
@@ -18,6 +18,8 @@ function fromScratch(bindingsQuery: Computed<TLBinding[], unknown>) {
18
18
  } else {
19
19
  bindingsForFromShape.push(binding)
20
20
  }
21
+ // A binding from a shape to itself belongs in that shape's array once, not twice
22
+ if (toId === fromId) continue
21
23
  const bindingsForToShape = shapesToBindings.get(toId)
22
24
  if (!bindingsForToShape) {
23
25
  shapesToBindings.set(toId, [binding])
@@ -83,6 +85,7 @@ export function bindingsIndex(editor: Editor): Computed<TLBindingsIndex> {
83
85
 
84
86
  function addBinding(binding: TLBinding) {
85
87
  ensureNewArray(binding.fromId).push(binding)
88
+ if (binding.toId === binding.fromId) return
86
89
  ensureNewArray(binding.toId).push(binding)
87
90
  }
88
91
 
@@ -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()
@@ -1,7 +1,7 @@
1
1
  import type { TLRecord, TLShapeId } from '@tldraw/tlschema'
2
2
  import { bind } from '@tldraw/utils'
3
3
  import EventEmitter from 'eventemitter3'
4
- import type { Editor } from '../../Editor'
4
+ import { EditorManager } from '../EditorManager'
5
5
  import type {
6
6
  TLCameraEndPerfEvent,
7
7
  TLCameraStartPerfEvent,
@@ -14,6 +14,9 @@ import type {
14
14
  TLUndoRedoPerfEvent,
15
15
  } from './perf-types'
16
16
 
17
+ // The editor listeners and observer that are attached only while a subscriber needs them
18
+ type LazyListener = 'frame' | 'loaf' | 'shapes-created' | 'shapes-updated' | 'shapes-deleted'
19
+
17
20
  function percentile(sorted: number[], p: number): number {
18
21
  const idx = Math.ceil(p * sorted.length) - 1
19
22
  return sorted[Math.max(0, idx)]
@@ -71,12 +74,10 @@ function toLoafEntry(entry: PerformanceEntry): TLPerfLongAnimationFrame | null {
71
74
  *
72
75
  * @public
73
76
  */
74
- export class PerformanceManager {
77
+ export class PerformanceManager extends EditorManager {
75
78
  /** @internal */
76
79
  readonly emitter = new EventEmitter<TLPerfEventMap>()
77
80
 
78
- private editor: Editor
79
-
80
81
  // Active interaction tracking
81
82
  private activeInteraction: {
82
83
  name: string
@@ -96,18 +97,8 @@ export class PerformanceManager {
96
97
  loafEntries: TLPerfLongAnimationFrame[]
97
98
  } | null = null
98
99
 
99
- // Lazy listener cleanup functions
100
- private frameCleanup: (() => void) | null = null
101
- private shapeCreatedCleanup: (() => void) | null = null
102
- private shapeEditedCleanup: (() => void) | null = null
103
- private shapeDeletedCleanup: (() => void) | null = null
104
-
105
- // LoAF observer
106
- private loafObserver: PerformanceObserver | null = null
107
-
108
- constructor(editor: Editor) {
109
- this.editor = editor
110
- }
100
+ // Cleanups for whatever is currently attached, keyed so a detach unregisters only its own
101
+ private readonly lazyCleanups = new Map<LazyListener, () => void>()
111
102
 
112
103
  /**
113
104
  * Subscribe to a performance event. Returns an unsubscribe function.
@@ -158,19 +149,12 @@ export class PerformanceManager {
158
149
 
159
150
  /** @internal */
160
151
  dispose() {
161
- if (this.activeCamera?.timeout) clearTimeout(this.activeCamera.timeout)
152
+ if (this.activeCamera?.timeout) this.editor.timers.clearTimeout(this.activeCamera.timeout)
162
153
  this.activeInteraction = null
163
154
  this.activeCamera = null
164
- this.frameCleanup?.()
165
- this.frameCleanup = null
166
- this.shapeCreatedCleanup?.()
167
- this.shapeCreatedCleanup = null
168
- this.shapeEditedCleanup?.()
169
- this.shapeEditedCleanup = null
170
- this.shapeDeletedCleanup?.()
171
- this.shapeDeletedCleanup = null
172
- this._stopLoafObserver()
155
+ this.lazyCleanups.clear()
173
156
  this.emitter.removeAllListeners()
157
+ super.dispose()
174
158
  }
175
159
 
176
160
  // --- Internal notification methods ---
@@ -259,7 +243,7 @@ export class PerformanceManager {
259
243
  if (this.activeCamera) {
260
244
  // Extend existing camera session
261
245
  if (this.activeCamera.timeout) {
262
- clearTimeout(this.activeCamera.timeout)
246
+ this.editor.timers.clearTimeout(this.activeCamera.timeout)
263
247
  }
264
248
  // If type changed, end old and start new
265
249
  if (this.activeCamera.type !== type) {
@@ -313,7 +297,7 @@ export class PerformanceManager {
313
297
  const camera = this.activeCamera
314
298
  if (!camera) return
315
299
  this.activeCamera = null
316
- if (camera.timeout) clearTimeout(camera.timeout)
300
+ if (camera.timeout) this.editor.timers.clearTimeout(camera.timeout)
317
301
 
318
302
  if (this.emitter.listenerCount('camera-end') === 0) return
319
303
 
@@ -444,7 +428,7 @@ export class PerformanceManager {
444
428
  return
445
429
  }
446
430
 
447
- this.loafObserver = new PerformanceObserver((list) => {
431
+ const observer = new PerformanceObserver((list) => {
448
432
  const isInteractionActive = this.activeInteraction !== null
449
433
  const isCameraActive = this.activeCamera !== null
450
434
 
@@ -463,14 +447,11 @@ export class PerformanceManager {
463
447
  }
464
448
  })
465
449
 
466
- this.loafObserver.observe({ type: 'long-animation-frame', buffered: false })
467
- }
468
-
469
- private _stopLoafObserver() {
470
- if (this.loafObserver) {
471
- this.loafObserver.disconnect()
472
- this.loafObserver = null
473
- }
450
+ observer.observe({ type: 'long-animation-frame', buffered: false })
451
+ this.lazyCleanups.set(
452
+ 'loaf',
453
+ this.register(() => observer.disconnect())
454
+ )
474
455
  }
475
456
 
476
457
  // --- Lazy listener management ---
@@ -495,7 +476,7 @@ export class PerformanceManager {
495
476
  private _maybeAttachLazyListeners(event: keyof TLPerfEventMap) {
496
477
  // Frame listener needed for frame event + interaction/camera frame time tracking
497
478
  if (
498
- !this.frameCleanup &&
479
+ !this.lazyCleanups.has('frame') &&
499
480
  (event === 'frame' ||
500
481
  event === 'interaction-start' ||
501
482
  event === 'interaction-end' ||
@@ -503,81 +484,73 @@ export class PerformanceManager {
503
484
  event === 'camera-end')
504
485
  ) {
505
486
  if (this._needsFrameListener()) {
506
- this.editor.on('frame', this._onFrame)
507
- this.frameCleanup = () => this.editor.off('frame', this._onFrame)
487
+ this.lazyCleanups.set('frame', this.addEditorEvent('frame', this._onFrame))
508
488
  }
509
489
  }
510
490
 
511
491
  // LoAF observer needed when interaction-end or camera-end listeners exist
512
- if (!this.loafObserver && (event === 'interaction-end' || event === 'camera-end')) {
492
+ if (!this.lazyCleanups.has('loaf') && (event === 'interaction-end' || event === 'camera-end')) {
513
493
  if (this._needsLoafObserver()) {
514
494
  this._startLoafObserver()
515
495
  }
516
496
  }
517
497
 
518
- if (!this.shapeCreatedCleanup && event === 'shapes-created') {
519
- this.editor.on('created-shapes', this._onShapesCreated)
520
- this.shapeCreatedCleanup = () => this.editor.off('created-shapes', this._onShapesCreated)
498
+ if (!this.lazyCleanups.has('shapes-created') && event === 'shapes-created') {
499
+ this.lazyCleanups.set(
500
+ 'shapes-created',
501
+ this.addEditorEvent('created-shapes', this._onShapesCreated)
502
+ )
521
503
  }
522
504
 
523
- if (!this.shapeEditedCleanup && event === 'shapes-updated') {
524
- this.editor.on('edited-shapes', this._onShapesEdited)
525
- this.shapeEditedCleanup = () => this.editor.off('edited-shapes', this._onShapesEdited)
505
+ if (!this.lazyCleanups.has('shapes-updated') && event === 'shapes-updated') {
506
+ this.lazyCleanups.set(
507
+ 'shapes-updated',
508
+ this.addEditorEvent('edited-shapes', this._onShapesEdited)
509
+ )
526
510
  }
527
511
 
528
- if (!this.shapeDeletedCleanup && event === 'shapes-deleted') {
529
- this.editor.on('deleted-shapes', this._onShapesDeleted)
530
- this.shapeDeletedCleanup = () => this.editor.off('deleted-shapes', this._onShapesDeleted)
512
+ if (!this.lazyCleanups.has('shapes-deleted') && event === 'shapes-deleted') {
513
+ this.lazyCleanups.set(
514
+ 'shapes-deleted',
515
+ this.addEditorEvent('deleted-shapes', this._onShapesDeleted)
516
+ )
531
517
  }
532
518
  }
533
519
 
534
520
  private _maybeDetachLazyListeners(event: keyof TLPerfEventMap) {
535
521
  if (
536
- this.frameCleanup &&
537
522
  (event === 'frame' ||
538
523
  event === 'interaction-start' ||
539
524
  event === 'interaction-end' ||
540
525
  event === 'camera-start' ||
541
- event === 'camera-end')
526
+ event === 'camera-end') &&
527
+ !this._needsFrameListener()
542
528
  ) {
543
- if (!this._needsFrameListener()) {
544
- this.frameCleanup()
545
- this.frameCleanup = null
546
- }
529
+ this._detachLazy('frame')
547
530
  }
548
531
 
549
532
  // Stop LoAF observer when no longer needed
550
- if (this.loafObserver && (event === 'interaction-end' || event === 'camera-end')) {
551
- if (!this._needsLoafObserver()) {
552
- this._stopLoafObserver()
553
- }
533
+ if ((event === 'interaction-end' || event === 'camera-end') && !this._needsLoafObserver()) {
534
+ this._detachLazy('loaf')
554
535
  }
555
536
 
556
- if (
557
- this.shapeCreatedCleanup &&
558
- event === 'shapes-created' &&
559
- this.emitter.listenerCount('shapes-created') === 0
560
- ) {
561
- this.shapeCreatedCleanup()
562
- this.shapeCreatedCleanup = null
537
+ if (event === 'shapes-created' && this.emitter.listenerCount('shapes-created') === 0) {
538
+ this._detachLazy('shapes-created')
563
539
  }
564
540
 
565
- if (
566
- this.shapeEditedCleanup &&
567
- event === 'shapes-updated' &&
568
- this.emitter.listenerCount('shapes-updated') === 0
569
- ) {
570
- this.shapeEditedCleanup()
571
- this.shapeEditedCleanup = null
541
+ if (event === 'shapes-updated' && this.emitter.listenerCount('shapes-updated') === 0) {
542
+ this._detachLazy('shapes-updated')
572
543
  }
573
544
 
574
- if (
575
- this.shapeDeletedCleanup &&
576
- event === 'shapes-deleted' &&
577
- this.emitter.listenerCount('shapes-deleted') === 0
578
- ) {
579
- this.shapeDeletedCleanup()
580
- this.shapeDeletedCleanup = null
545
+ if (event === 'shapes-deleted' && this.emitter.listenerCount('shapes-deleted') === 0) {
546
+ this._detachLazy('shapes-deleted')
581
547
  }
582
548
  }
549
+
550
+ private _detachLazy(key: LazyListener) {
551
+ const cleanup = this.lazyCleanups.get(key)
552
+ if (!cleanup) return
553
+ this.unregister(cleanup)
554
+ this.lazyCleanups.delete(key)
555
+ }
583
556
  }
@@ -409,7 +409,7 @@ export class ScribbleManager {
409
409
 
410
410
  private clearIdleTimeout(session: Session): void {
411
411
  if (session.idleTimeoutHandle !== undefined) {
412
- clearTimeout(session.idleTimeoutHandle)
412
+ this.editor.timers.clearTimeout(session.idleTimeoutHandle)
413
413
  session.idleTimeoutHandle = undefined
414
414
  }
415
415
  }
@@ -159,6 +159,22 @@ function findAdjacentGaps(
159
159
  return matches
160
160
  }
161
161
 
162
+ // Adjacent evenly spaced items produce many center snaps whose gaps cover the
163
+ // same breadth; only the smallest such gap needs showing, so find the one a new
164
+ // gap would be redundant with. Touching breadths don't count as overlapping.
165
+ function findOverlappingCenterSnap(snaps: NearestSnap[], gap: Gap) {
166
+ return snaps.find(
167
+ (snap): snap is Extract<NearestSnap, { type: 'gap_center' }> =>
168
+ snap.type === 'gap_center' &&
169
+ rangesOverlap(
170
+ gap.breadthIntersection[0],
171
+ gap.breadthIntersection[1],
172
+ snap.gap.breadthIntersection[0],
173
+ snap.gap.breadthIntersection[1]
174
+ )
175
+ )
176
+ }
177
+
162
178
  function dedupeGapSnaps(snaps: Array<Extract<SnapIndicator, { type: 'gaps' }>>) {
163
179
  // sort by descending order of number of gaps
164
180
  snaps.sort((a, b) => b.gaps.length - a.gaps.length)
@@ -597,9 +613,8 @@ export class BoundsSnaps {
597
613
  // which are closest to it in each axis
598
614
  for (const thisSnapPoint of selectionSnapPoints) {
599
615
  for (const otherSnapPoint of otherNodeSnapPoints) {
600
- const offset = Vec.Sub(thisSnapPoint, otherSnapPoint)
601
- const offsetX = Math.abs(offset.x)
602
- const offsetY = Math.abs(offset.y)
616
+ const offsetX = Math.abs(thisSnapPoint.x - otherSnapPoint.x)
617
+ const offsetY = Math.abs(thisSnapPoint.y - otherSnapPoint.y)
603
618
 
604
619
  if (round(offsetX) <= round(minOffset.x)) {
605
620
  if (round(offsetX) < round(minOffset.x)) {
@@ -718,23 +733,14 @@ export class BoundsSnaps {
718
733
  // │ │
719
734
  // └─────────┘
720
735
 
721
- const otherCenterSnap = nearestSnapsX.find(({ type }) => type === 'gap_center') as
722
- | Extract<NearestSnap, { type: 'gap_center' }>
723
- | undefined
724
-
725
- const gapBreadthsOverlap =
726
- otherCenterSnap &&
727
- rangeIntersection(
728
- gap.breadthIntersection[0],
729
- gap.breadthIntersection[1],
730
- otherCenterSnap.gap.breadthIntersection[0],
731
- otherCenterSnap.gap.breadthIntersection[1]
732
- )
736
+ const overlappingCenterSnap = findOverlappingCenterSnap(nearestSnapsX, gap)
733
737
 
734
- // if there is another center snap and it's bigger than this one, and it overlaps with this one, replace it
735
- if (otherCenterSnap && otherCenterSnap.gap.length > gap.length && gapBreadthsOverlap) {
736
- nearestSnapsX[nearestSnapsX.indexOf(otherCenterSnap)] = snap
737
- } else if (!otherCenterSnap || !gapBreadthsOverlap) {
738
+ // if there is another center snap that overlaps with this one, keep only the smaller gap
739
+ if (overlappingCenterSnap) {
740
+ if (overlappingCenterSnap.gap.length > gap.length) {
741
+ nearestSnapsX[nearestSnapsX.indexOf(overlappingCenterSnap)] = snap
742
+ }
743
+ } else {
738
744
  nearestSnapsX.push(snap)
739
745
  }
740
746
  }
@@ -853,26 +859,16 @@ export class BoundsSnaps {
853
859
  // │ │
854
860
  // └─────────┘
855
861
 
856
- const otherCenterSnap = nearestSnapsY.find(({ type }) => type === 'gap_center') as
857
- | Extract<NearestSnap, { type: 'gap_center' }>
858
- | undefined
859
-
860
- const gapBreadthsOverlap =
861
- otherCenterSnap &&
862
- rangesOverlap(
863
- otherCenterSnap.gap.breadthIntersection[0],
864
- otherCenterSnap.gap.breadthIntersection[1],
865
- gap.breadthIntersection[0],
866
- gap.breadthIntersection[1]
867
- )
862
+ const overlappingCenterSnap = findOverlappingCenterSnap(nearestSnapsY, gap)
868
863
 
869
- // if there is another center snap and it's bigger than this one, and it overlaps with this one, replace it
870
- if (otherCenterSnap && otherCenterSnap.gap.length > gap.length && gapBreadthsOverlap) {
871
- nearestSnapsY[nearestSnapsY.indexOf(otherCenterSnap)] = snap
872
- } else if (!otherCenterSnap || !gapBreadthsOverlap) {
864
+ // if there is another center snap that overlaps with this one, keep only the smaller gap
865
+ if (overlappingCenterSnap) {
866
+ if (overlappingCenterSnap.gap.length > gap.length) {
867
+ nearestSnapsY[nearestSnapsY.indexOf(overlappingCenterSnap)] = snap
868
+ }
869
+ } else {
873
870
  nearestSnapsY.push(snap)
874
871
  }
875
- continue
876
872
  }
877
873
 
878
874
  // check for duplication top match
@@ -1,7 +1,8 @@
1
1
  import { computed } from '@tldraw/state'
2
2
  import { TLHandle, TLShape, TLShapeId, VecModel } from '@tldraw/tlschema'
3
3
  import { assertExists, uniqueId } from '@tldraw/utils'
4
- import { Geometry2d } from '../../../primitives/geometry/Geometry2d'
4
+ import { Geometry2d, Geometry2dFilters } from '../../../primitives/geometry/Geometry2d'
5
+ import { Group2d } from '../../../primitives/geometry/Group2d'
5
6
  import { Vec } from '../../../primitives/Vec'
6
7
  import type { Editor } from '../../Editor'
7
8
  import type { PointsSnapIndicator, SnapData, SnapManager } from './SnapManager'
@@ -156,11 +157,21 @@ export class HandleSnaps {
156
157
  let minDistanceForOutline = snapThreshold
157
158
  let nearestPointOnOutline: Vec | null = null
158
159
 
160
+ // Labels and internal detail (a note's label, a geo check mark) are not outlines
161
+ // to snap to. Group2d.nearestPoint throws when the filter leaves no child, so
162
+ // skip those groups.
163
+ const filters = Geometry2dFilters.EXCLUDE_NON_STANDARD
159
164
  for (const { shapeId, outline } of this.iterateSnapOutlines(currentShapeId, handle)) {
165
+ if (
166
+ outline instanceof Group2d &&
167
+ outline.children.every((child) => child.isExcludedByFilter(filters))
168
+ ) {
169
+ continue
170
+ }
160
171
  const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
161
172
  const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handleInPageSpace)
162
173
 
163
- const nearestShapePointInShapeSpace = outline.nearestPoint(pointInShapeSpace)
174
+ const nearestShapePointInShapeSpace = outline.nearestPoint(pointInShapeSpace, filters)
164
175
  const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace)
165
176
 
166
177
  if (Vec.DistMin(handleInPageSpace, nearestInPageSpace, minDistanceForOutline)) {
@@ -78,6 +78,7 @@ describe('SnapManager', () => {
78
78
  getShapePageBounds: vi.fn(),
79
79
  isShapeOfType: vi.fn(),
80
80
  isShapeFrameLike: vi.fn(() => false),
81
+ isShapeHidden: vi.fn(() => false),
81
82
  } as any
82
83
 
83
84
  editor.getShapeUtil.mockReturnValue({
@@ -337,6 +338,18 @@ describe('SnapManager', () => {
337
338
  expect(result.has(shapeId)).toBe(false)
338
339
  })
339
340
 
341
+ it('should exclude hidden shapes', () => {
342
+ const shapeId = createShapeId('shape1')
343
+
344
+ editor.getSortedChildIdsForParent.mockReturnValue([shapeId])
345
+ editor.getShape.mockReturnValue(createMockShape(shapeId) as any)
346
+ editor.getShapePageBounds.mockReturnValue(new Box(10, 10, 50, 50))
347
+ editor.isShapeHidden.mockReturnValue(true)
348
+
349
+ const result = snapManager.getSnappableShapes()
350
+ expect(result.has(shapeId)).toBe(false)
351
+ })
352
+
340
353
  it('should exclude shapes outside viewport bounds', () => {
341
354
  const shapeId = createShapeId('shape1')
342
355
  const shape = createMockShape(shapeId)