@tldraw/editor 4.6.0-next.20de11b7e238 → 4.6.0-next.241e87d4700a

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 (156) hide show
  1. package/dist-cjs/index.d.ts +668 -96
  2. package/dist-cjs/index.js +16 -3
  3. package/dist-cjs/index.js.map +3 -3
  4. package/dist-cjs/lib/TldrawEditor.js +55 -12
  5. package/dist-cjs/lib/TldrawEditor.js.map +3 -3
  6. package/dist-cjs/lib/components/MenuClickCapture.js +16 -1
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js +3 -3
  9. package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js.map +2 -2
  10. package/dist-cjs/lib/config/createTLStore.js +7 -0
  11. package/dist-cjs/lib/config/createTLStore.js.map +2 -2
  12. package/dist-cjs/lib/config/defaultAssets.js +36 -0
  13. package/dist-cjs/lib/config/defaultAssets.js.map +7 -0
  14. package/dist-cjs/lib/editor/Editor.js +215 -5
  15. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  16. package/dist-cjs/lib/editor/assets/AssetUtil.js +66 -0
  17. package/dist-cjs/lib/editor/assets/AssetUtil.js.map +7 -0
  18. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.js +80 -0
  20. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.js.map +7 -0
  21. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +466 -0
  22. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +7 -0
  23. package/dist-cjs/lib/editor/managers/PerformanceManager/perf-types.js +17 -0
  24. package/dist-cjs/lib/editor/managers/PerformanceManager/perf-types.js.map +7 -0
  25. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +106 -0
  26. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js.map +7 -0
  27. package/dist-cjs/lib/editor/managers/ThemeManager/defaultThemes.js +586 -0
  28. package/dist-cjs/lib/editor/managers/ThemeManager/defaultThemes.js.map +7 -0
  29. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +6 -4
  30. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
  31. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +11 -2
  32. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  33. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +1 -1
  34. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
  35. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js +6 -0
  36. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
  37. package/dist-cjs/lib/editor/tools/StateNode.js +14 -17
  38. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  39. package/dist-cjs/lib/editor/types/SvgExportContext.js.map +2 -2
  40. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  41. package/dist-cjs/lib/exports/getSvgJsx.js +12 -7
  42. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  43. package/dist-cjs/lib/globals/environment.js +18 -1
  44. package/dist-cjs/lib/globals/environment.js.map +2 -2
  45. package/dist-cjs/lib/hooks/{useIsDarkMode.js → useColorMode.js} +14 -10
  46. package/dist-cjs/lib/hooks/useColorMode.js.map +7 -0
  47. package/dist-cjs/lib/hooks/useCursor.js +3 -7
  48. package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
  49. package/dist-cjs/lib/hooks/useDarkMode.js +4 -4
  50. package/dist-cjs/lib/hooks/useDarkMode.js.map +2 -2
  51. package/dist-cjs/lib/utils/reparenting.js +2 -1
  52. package/dist-cjs/lib/utils/reparenting.js.map +2 -2
  53. package/dist-cjs/lib/utils/richText.js.map +2 -2
  54. package/dist-cjs/lib/utils/runtime.js +2 -1
  55. package/dist-cjs/lib/utils/runtime.js.map +2 -2
  56. package/dist-cjs/lib/utils/sync/hardReset.js +0 -8
  57. package/dist-cjs/lib/utils/sync/hardReset.js.map +2 -2
  58. package/dist-cjs/version.js +3 -3
  59. package/dist-cjs/version.js.map +1 -1
  60. package/dist-esm/index.d.mts +668 -96
  61. package/dist-esm/index.mjs +17 -6
  62. package/dist-esm/index.mjs.map +2 -2
  63. package/dist-esm/lib/TldrawEditor.mjs +58 -12
  64. package/dist-esm/lib/TldrawEditor.mjs.map +3 -3
  65. package/dist-esm/lib/components/MenuClickCapture.mjs +16 -1
  66. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  67. package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs +3 -3
  68. package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs.map +2 -2
  69. package/dist-esm/lib/config/createTLStore.mjs +10 -1
  70. package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
  71. package/dist-esm/lib/config/defaultAssets.mjs +16 -0
  72. package/dist-esm/lib/config/defaultAssets.mjs.map +7 -0
  73. package/dist-esm/lib/editor/Editor.mjs +215 -5
  74. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  75. package/dist-esm/lib/editor/assets/AssetUtil.mjs +46 -0
  76. package/dist-esm/lib/editor/assets/AssetUtil.mjs.map +7 -0
  77. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  78. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.mjs +60 -0
  79. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.mjs.map +7 -0
  80. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +438 -0
  81. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +7 -0
  82. package/dist-esm/lib/editor/managers/PerformanceManager/perf-types.mjs +1 -0
  83. package/dist-esm/lib/editor/managers/PerformanceManager/perf-types.mjs.map +7 -0
  84. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +88 -0
  85. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +7 -0
  86. package/dist-esm/lib/editor/managers/ThemeManager/defaultThemes.mjs +568 -0
  87. package/dist-esm/lib/editor/managers/ThemeManager/defaultThemes.mjs.map +7 -0
  88. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +6 -4
  89. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
  90. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +11 -2
  91. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  92. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +1 -1
  93. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
  94. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs +6 -0
  95. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
  96. package/dist-esm/lib/editor/tools/StateNode.mjs +14 -17
  97. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  98. package/dist-esm/lib/editor/types/SvgExportContext.mjs.map +2 -2
  99. package/dist-esm/lib/exports/getSvgJsx.mjs +12 -10
  100. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  101. package/dist-esm/lib/globals/environment.mjs +18 -1
  102. package/dist-esm/lib/globals/environment.mjs.map +2 -2
  103. package/dist-esm/lib/hooks/useColorMode.mjs +19 -0
  104. package/dist-esm/lib/hooks/useColorMode.mjs.map +7 -0
  105. package/dist-esm/lib/hooks/useCursor.mjs +3 -7
  106. package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
  107. package/dist-esm/lib/hooks/useDarkMode.mjs +4 -4
  108. package/dist-esm/lib/hooks/useDarkMode.mjs.map +2 -2
  109. package/dist-esm/lib/utils/reparenting.mjs +2 -1
  110. package/dist-esm/lib/utils/reparenting.mjs.map +2 -2
  111. package/dist-esm/lib/utils/richText.mjs.map +2 -2
  112. package/dist-esm/lib/utils/runtime.mjs +2 -1
  113. package/dist-esm/lib/utils/runtime.mjs.map +2 -2
  114. package/dist-esm/lib/utils/sync/hardReset.mjs +0 -8
  115. package/dist-esm/lib/utils/sync/hardReset.mjs.map +2 -2
  116. package/dist-esm/version.mjs +3 -3
  117. package/dist-esm/version.mjs.map +1 -1
  118. package/editor.css +0 -33
  119. package/package.json +7 -7
  120. package/src/index.ts +23 -6
  121. package/src/lib/TldrawEditor.tsx +90 -13
  122. package/src/lib/components/MenuClickCapture.tsx +20 -0
  123. package/src/lib/components/default-components/CanvasShapeIndicators.tsx +3 -3
  124. package/src/lib/config/createTLStore.ts +22 -1
  125. package/src/lib/config/defaultAssets.ts +19 -0
  126. package/src/lib/editor/Editor.ts +301 -27
  127. package/src/lib/editor/assets/AssetUtil.ts +85 -0
  128. package/src/lib/editor/managers/FontManager/FontManager.test.ts +9 -2
  129. package/src/lib/editor/managers/FontManager/FontManager.ts +1 -67
  130. package/src/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.ts +82 -0
  131. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +522 -0
  132. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +583 -0
  133. package/src/lib/editor/managers/PerformanceManager/perf-types.ts +196 -0
  134. package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +116 -0
  135. package/src/lib/editor/managers/ThemeManager/defaultThemes.ts +605 -0
  136. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +23 -29
  137. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +5 -3
  138. package/src/lib/editor/shapes/ShapeUtil.ts +28 -3
  139. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +1 -1
  140. package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +7 -0
  141. package/src/lib/editor/tools/StateNode.ts +16 -18
  142. package/src/lib/editor/types/SvgExportContext.tsx +5 -0
  143. package/src/lib/editor/types/external-content.ts +1 -0
  144. package/src/lib/exports/getSvgJsx.tsx +21 -15
  145. package/src/lib/globals/environment.ts +18 -0
  146. package/src/lib/hooks/{useIsDarkMode.ts → useColorMode.ts} +9 -5
  147. package/src/lib/hooks/useCursor.ts +3 -7
  148. package/src/lib/hooks/useDarkMode.ts +4 -4
  149. package/src/lib/utils/reparenting.ts +6 -2
  150. package/src/lib/utils/richText.ts +1 -1
  151. package/src/lib/utils/runtime.ts +3 -1
  152. package/src/lib/utils/sync/hardReset.ts +0 -8
  153. package/src/version.ts +3 -3
  154. package/dist-cjs/lib/hooks/useIsDarkMode.js.map +0 -7
  155. package/dist-esm/lib/hooks/useIsDarkMode.mjs +0 -15
  156. package/dist-esm/lib/hooks/useIsDarkMode.mjs.map +0 -7
@@ -0,0 +1,583 @@
1
+ import type { TLRecord, TLShapeId } from '@tldraw/tlschema'
2
+ import { bind } from '@tldraw/utils'
3
+ import EventEmitter from 'eventemitter3'
4
+ import type { Editor } from '../../Editor'
5
+ import type {
6
+ TLCameraEndPerfEvent,
7
+ TLCameraStartPerfEvent,
8
+ TLFramePerfEvent,
9
+ TLInteractionEndPerfEvent,
10
+ TLInteractionStartPerfEvent,
11
+ TLPerfEventMap,
12
+ TLPerfLongAnimationFrame,
13
+ TLShapeOperationPerfEvent,
14
+ TLUndoRedoPerfEvent,
15
+ } from './perf-types'
16
+
17
+ function percentile(sorted: number[], p: number): number {
18
+ const idx = Math.ceil(p * sorted.length) - 1
19
+ return sorted[Math.max(0, idx)]
20
+ }
21
+
22
+ function computeFrameTimeStats(frameTimes: number[]) {
23
+ if (frameTimes.length === 0) return { avg: 0, median: 0, p95: 0, p99: 0, min: 0, max: 0 }
24
+ const sorted = [...frameTimes].sort((a, b) => a - b)
25
+ const sum = sorted.reduce((a, b) => a + b, 0)
26
+ return {
27
+ avg: sum / sorted.length,
28
+ median: percentile(sorted, 0.5),
29
+ p95: percentile(sorted, 0.95),
30
+ p99: percentile(sorted, 0.99),
31
+ min: sorted[0],
32
+ max: sorted[sorted.length - 1],
33
+ }
34
+ }
35
+
36
+ function toLoafEntry(entry: PerformanceEntry): TLPerfLongAnimationFrame | null {
37
+ // LoAF entries have these properties but TypeScript doesn't know about them yet
38
+ const e = entry as PerformanceEntry & {
39
+ blockingDuration?: number
40
+ scripts?: ReadonlyArray<{
41
+ sourceURL?: string
42
+ invoker?: string
43
+ duration?: number
44
+ }>
45
+ }
46
+ if (typeof e.duration !== 'number') return null
47
+ return {
48
+ startTime: e.startTime,
49
+ duration: e.duration,
50
+ blockingDuration: e.blockingDuration ?? 0,
51
+ scripts: (e.scripts ?? []).map((s) => ({
52
+ sourceURL: s.sourceURL ?? '',
53
+ invoker: s.invoker ?? '',
54
+ duration: s.duration ?? 0,
55
+ })),
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Manages performance event subscriptions for the editor. Available as `editor.performance`.
61
+ *
62
+ * Listeners are lazy — internal editor hooks (frame, shape events) are only attached while
63
+ * at least one subscriber exists, so there is zero overhead when unused.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * const unsub = editor.performance.on('interaction-end', (event) => {
68
+ * console.log(`${event.name}: ${event.fps.toFixed(1)} fps, p95=${event.p95FrameTime.toFixed(1)}ms`)
69
+ * })
70
+ * ```
71
+ *
72
+ * @public
73
+ */
74
+ export class PerformanceManager {
75
+ /** @internal */
76
+ readonly emitter = new EventEmitter<TLPerfEventMap>()
77
+
78
+ private editor: Editor
79
+
80
+ // Active interaction tracking
81
+ private activeInteraction: {
82
+ name: string
83
+ path: string
84
+ startTime: number
85
+ frameTimes: number[]
86
+ selectedShapeTypes: Record<string, number>
87
+ loafEntries: TLPerfLongAnimationFrame[]
88
+ } | null = null
89
+
90
+ // Active camera tracking
91
+ private activeCamera: {
92
+ type: 'panning' | 'zooming'
93
+ startTime: number
94
+ frameTimes: number[]
95
+ timeout: number | null
96
+ loafEntries: TLPerfLongAnimationFrame[]
97
+ } | null = null
98
+
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
+ }
111
+
112
+ /**
113
+ * Subscribe to a performance event. Returns an unsubscribe function.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * const unsub = editor.performance.on('interaction-end', (event) => {
118
+ * sendToAnalytics({ name: event.name, fps: event.fps, p95: event.p95FrameTime })
119
+ * })
120
+ * // later: unsub()
121
+ * ```
122
+ *
123
+ * @public
124
+ */
125
+ on<K extends keyof TLPerfEventMap>(
126
+ event: K,
127
+ fn: (...args: TLPerfEventMap[K]) => void
128
+ ): () => void {
129
+ this.emitter.on(event, fn as any)
130
+ this._maybeAttachLazyListeners(event)
131
+ return () => {
132
+ this.emitter.off(event, fn as any)
133
+ this._maybeDetachLazyListeners(event)
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Subscribe to a performance event once. The listener is removed after the first invocation.
139
+ * Returns an unsubscribe function for early removal.
140
+ *
141
+ * @public
142
+ */
143
+ once<K extends keyof TLPerfEventMap>(
144
+ event: K,
145
+ fn: (...args: TLPerfEventMap[K]) => void
146
+ ): () => void {
147
+ const wrapped = (...args: TLPerfEventMap[K]) => {
148
+ ;(fn as any)(...args)
149
+ this._maybeDetachLazyListeners(event)
150
+ }
151
+ this.emitter.once(event, wrapped as any)
152
+ this._maybeAttachLazyListeners(event)
153
+ return () => {
154
+ this.emitter.off(event, wrapped as any)
155
+ this._maybeDetachLazyListeners(event)
156
+ }
157
+ }
158
+
159
+ /** @internal */
160
+ dispose() {
161
+ if (this.activeCamera?.timeout) clearTimeout(this.activeCamera.timeout)
162
+ this.activeInteraction = null
163
+ 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()
173
+ this.emitter.removeAllListeners()
174
+ }
175
+
176
+ // --- Internal notification methods ---
177
+
178
+ /** @internal */
179
+ _notifyInteractionStart(name: string, path: string) {
180
+ if (
181
+ this.emitter.listenerCount('interaction-start') === 0 &&
182
+ this.emitter.listenerCount('interaction-end') === 0
183
+ ) {
184
+ return
185
+ }
186
+
187
+ if (this.activeInteraction) {
188
+ console.warn(
189
+ `[tldraw] New interaction '${name}' started while '${this.activeInteraction.name}' was still active`
190
+ )
191
+ }
192
+
193
+ // Capture selected shape types at start
194
+ const selectedShapeTypes: Record<string, number> = {}
195
+ for (const shape of this.editor.getSelectedShapes()) {
196
+ selectedShapeTypes[shape.type] = (selectedShapeTypes[shape.type] || 0) + 1
197
+ }
198
+
199
+ this.activeInteraction = {
200
+ name,
201
+ path,
202
+ startTime: performance.now(),
203
+ frameTimes: [],
204
+ selectedShapeTypes,
205
+ loafEntries: [],
206
+ }
207
+
208
+ const event: TLInteractionStartPerfEvent = {
209
+ name,
210
+ path,
211
+ timestamp: performance.now(),
212
+ }
213
+ this.emitter.emit('interaction-start', event)
214
+ }
215
+
216
+ /** @internal */
217
+ _notifyInteractionEnd() {
218
+ const interaction = this.activeInteraction
219
+ if (!interaction) return
220
+ this.activeInteraction = null
221
+
222
+ if (this.emitter.listenerCount('interaction-end') === 0) return
223
+
224
+ const duration = performance.now() - interaction.startTime
225
+ const stats = computeFrameTimeStats(interaction.frameTimes)
226
+
227
+ const event: TLInteractionEndPerfEvent = {
228
+ name: interaction.name,
229
+ path: interaction.path,
230
+ duration,
231
+ fps:
232
+ interaction.frameTimes.length > 0 ? (interaction.frameTimes.length / duration) * 1000 : 0,
233
+ frameCount: interaction.frameTimes.length,
234
+ avgFrameTime: stats.avg,
235
+ medianFrameTime: stats.median,
236
+ p95FrameTime: stats.p95,
237
+ p99FrameTime: stats.p99,
238
+ minFrameTime: stats.min,
239
+ maxFrameTime: stats.max,
240
+ frameTimes: interaction.frameTimes,
241
+ shapeCount: this.editor.getCurrentPageShapeIds().size,
242
+ selectedShapeTypes: interaction.selectedShapeTypes,
243
+ longAnimationFrames: interaction.loafEntries.length > 0 ? interaction.loafEntries : undefined,
244
+ zoomLevel: this.editor.getCamera().z,
245
+ timestamp: performance.now(),
246
+ }
247
+ this.emitter.emit('interaction-end', event)
248
+ }
249
+
250
+ /** @internal */
251
+ _notifyCameraOperation(type: 'panning' | 'zooming') {
252
+ if (
253
+ this.emitter.listenerCount('camera-start') === 0 &&
254
+ this.emitter.listenerCount('camera-end') === 0
255
+ ) {
256
+ return
257
+ }
258
+
259
+ if (this.activeCamera) {
260
+ // Extend existing camera session
261
+ if (this.activeCamera.timeout) {
262
+ clearTimeout(this.activeCamera.timeout)
263
+ }
264
+ // If type changed, end old and start new
265
+ if (this.activeCamera.type !== type) {
266
+ this._endCameraSession()
267
+ this._startCameraSession(type)
268
+ } else {
269
+ // Reset timeout
270
+ this.activeCamera.timeout = this.editor.timers.setTimeout(
271
+ () => this._endCameraSession(),
272
+ 50
273
+ )
274
+ }
275
+ } else {
276
+ this._startCameraSession(type)
277
+ }
278
+ }
279
+
280
+ /** @internal */
281
+ _notifyUndoRedo(type: 'undo' | 'redo', undoDepth: number, redoDepth: number) {
282
+ if (this.emitter.listenerCount(type) === 0) return
283
+
284
+ const event: TLUndoRedoPerfEvent = {
285
+ type,
286
+ undoDepth,
287
+ redoDepth,
288
+ }
289
+ this.emitter.emit(type, event)
290
+ }
291
+
292
+ // --- Private helpers ---
293
+
294
+ private _startCameraSession(type: 'panning' | 'zooming') {
295
+ this.activeCamera = {
296
+ type,
297
+ startTime: performance.now(),
298
+ frameTimes: [],
299
+ timeout: this.editor.timers.setTimeout(() => this._endCameraSession(), 50),
300
+ loafEntries: [],
301
+ }
302
+
303
+ if (this.emitter.listenerCount('camera-start') > 0) {
304
+ const event: TLCameraStartPerfEvent = {
305
+ type,
306
+ timestamp: performance.now(),
307
+ }
308
+ this.emitter.emit('camera-start', event)
309
+ }
310
+ }
311
+
312
+ private _endCameraSession() {
313
+ const camera = this.activeCamera
314
+ if (!camera) return
315
+ this.activeCamera = null
316
+ if (camera.timeout) clearTimeout(camera.timeout)
317
+
318
+ if (this.emitter.listenerCount('camera-end') === 0) return
319
+
320
+ const duration = performance.now() - camera.startTime
321
+ const stats = computeFrameTimeStats(camera.frameTimes)
322
+ const viewportBounds = this.editor.getViewportScreenBounds()
323
+ const totalShapes = this.editor.getCurrentPageShapeIds().size
324
+ const culledShapeCount = this.editor.getCulledShapes().size
325
+
326
+ const event: TLCameraEndPerfEvent = {
327
+ type: camera.type,
328
+ duration,
329
+ fps: camera.frameTimes.length > 0 ? (camera.frameTimes.length / duration) * 1000 : 0,
330
+ frameCount: camera.frameTimes.length,
331
+ avgFrameTime: stats.avg,
332
+ medianFrameTime: stats.median,
333
+ p95FrameTime: stats.p95,
334
+ p99FrameTime: stats.p99,
335
+ minFrameTime: stats.min,
336
+ maxFrameTime: stats.max,
337
+ frameTimes: camera.frameTimes,
338
+ shapeCount: totalShapes,
339
+ viewportWidth: viewportBounds.w,
340
+ viewportHeight: viewportBounds.h,
341
+ longAnimationFrames: camera.loafEntries.length > 0 ? camera.loafEntries : undefined,
342
+ visibleShapeCount: totalShapes - culledShapeCount,
343
+ culledShapeCount,
344
+ zoomLevel: this.editor.getCamera().z,
345
+ timestamp: performance.now(),
346
+ }
347
+ this.emitter.emit('camera-end', event)
348
+ }
349
+
350
+ @bind
351
+ private _onFrame(elapsed: number) {
352
+ // Record frame time for active interaction/camera
353
+ if (this.activeInteraction) {
354
+ this.activeInteraction.frameTimes.push(elapsed)
355
+ }
356
+ if (this.activeCamera) {
357
+ this.activeCamera.frameTimes.push(elapsed)
358
+ }
359
+
360
+ // Emit standalone frame event if listeners exist
361
+ if (this.emitter.listenerCount('frame') > 0) {
362
+ const totalShapes = this.editor.getCurrentPageShapeIds().size
363
+ const culledShapes = this.editor.getCulledShapes()
364
+ const culledCount = culledShapes.size
365
+ const event: TLFramePerfEvent = {
366
+ elapsed,
367
+ shapeCount: totalShapes,
368
+ culledShapeCount: culledCount,
369
+ visibleShapeCount: totalShapes - culledCount,
370
+ }
371
+ this.emitter.emit('frame', event)
372
+ }
373
+ }
374
+
375
+ @bind
376
+ private _onShapesCreated(records: TLRecord[]) {
377
+ if (this.emitter.listenerCount('shapes-created') === 0) return
378
+ const shapeTypes: Record<string, number> = {}
379
+ for (const record of records) {
380
+ if (record.typeName === 'shape') {
381
+ shapeTypes[record.type] = (shapeTypes[record.type] || 0) + 1
382
+ }
383
+ }
384
+ const count = Object.values(shapeTypes).reduce((a, b) => a + b, 0)
385
+ if (count === 0) return
386
+ const event: TLShapeOperationPerfEvent = {
387
+ operation: 'create',
388
+ count,
389
+ shapeTypes,
390
+ timestamp: performance.now(),
391
+ }
392
+ this.emitter.emit('shapes-created', event)
393
+ }
394
+
395
+ @bind
396
+ private _onShapesEdited(records: TLRecord[]) {
397
+ if (this.emitter.listenerCount('shapes-updated') === 0) return
398
+ const shapeTypes: Record<string, number> = {}
399
+ for (const record of records) {
400
+ if (record.typeName === 'shape') {
401
+ shapeTypes[record.type] = (shapeTypes[record.type] || 0) + 1
402
+ }
403
+ }
404
+ const count = Object.values(shapeTypes).reduce((a, b) => a + b, 0)
405
+ if (count === 0) return
406
+ const event: TLShapeOperationPerfEvent = {
407
+ operation: 'update',
408
+ count,
409
+ shapeTypes,
410
+ timestamp: performance.now(),
411
+ }
412
+ this.emitter.emit('shapes-updated', event)
413
+ }
414
+
415
+ @bind
416
+ private _onShapesDeleted(ids: TLShapeId[]) {
417
+ if (this.emitter.listenerCount('shapes-deleted') === 0) return
418
+ const shapeTypes: Record<string, number> = {}
419
+ for (const id of ids) {
420
+ // Works because 'deleted-shapes' fires before store.remove() in Editor.deleteShapes
421
+ const shape = this.editor.getShape(id)
422
+ if (shape) {
423
+ shapeTypes[shape.type] = (shapeTypes[shape.type] || 0) + 1
424
+ }
425
+ }
426
+ const event: TLShapeOperationPerfEvent = {
427
+ operation: 'delete',
428
+ count: ids.length,
429
+ shapeTypes,
430
+ timestamp: performance.now(),
431
+ }
432
+ this.emitter.emit('shapes-deleted', event)
433
+ }
434
+
435
+ // --- LoAF observer ---
436
+
437
+ private _startLoafObserver() {
438
+ if (typeof PerformanceObserver === 'undefined') return
439
+
440
+ try {
441
+ const supported = PerformanceObserver.supportedEntryTypes
442
+ if (!supported?.includes('long-animation-frame')) return
443
+ } catch {
444
+ return
445
+ }
446
+
447
+ this.loafObserver = new PerformanceObserver((list) => {
448
+ const isInteractionActive = this.activeInteraction !== null
449
+ const isCameraActive = this.activeCamera !== null
450
+
451
+ if (!isInteractionActive && !isCameraActive) return
452
+
453
+ for (const entry of list.getEntries()) {
454
+ const loaf = toLoafEntry(entry)
455
+ if (!loaf) continue
456
+
457
+ if (isInteractionActive) {
458
+ this.activeInteraction!.loafEntries.push(loaf)
459
+ }
460
+ if (isCameraActive) {
461
+ this.activeCamera!.loafEntries.push(loaf)
462
+ }
463
+ }
464
+ })
465
+
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
+ }
474
+ }
475
+
476
+ // --- Lazy listener management ---
477
+
478
+ private _needsFrameListener(): boolean {
479
+ return (
480
+ this.emitter.listenerCount('frame') > 0 ||
481
+ this.emitter.listenerCount('interaction-start') > 0 ||
482
+ this.emitter.listenerCount('interaction-end') > 0 ||
483
+ this.emitter.listenerCount('camera-start') > 0 ||
484
+ this.emitter.listenerCount('camera-end') > 0
485
+ )
486
+ }
487
+
488
+ private _needsLoafObserver(): boolean {
489
+ return (
490
+ this.emitter.listenerCount('interaction-end') > 0 ||
491
+ this.emitter.listenerCount('camera-end') > 0
492
+ )
493
+ }
494
+
495
+ private _maybeAttachLazyListeners(event: keyof TLPerfEventMap) {
496
+ // Frame listener needed for frame event + interaction/camera frame time tracking
497
+ if (
498
+ !this.frameCleanup &&
499
+ (event === 'frame' ||
500
+ event === 'interaction-start' ||
501
+ event === 'interaction-end' ||
502
+ event === 'camera-start' ||
503
+ event === 'camera-end')
504
+ ) {
505
+ if (this._needsFrameListener()) {
506
+ this.editor.on('frame', this._onFrame)
507
+ this.frameCleanup = () => this.editor.off('frame', this._onFrame)
508
+ }
509
+ }
510
+
511
+ // LoAF observer needed when interaction-end or camera-end listeners exist
512
+ if (!this.loafObserver && (event === 'interaction-end' || event === 'camera-end')) {
513
+ if (this._needsLoafObserver()) {
514
+ this._startLoafObserver()
515
+ }
516
+ }
517
+
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)
521
+ }
522
+
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)
526
+ }
527
+
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)
531
+ }
532
+ }
533
+
534
+ private _maybeDetachLazyListeners(event: keyof TLPerfEventMap) {
535
+ if (
536
+ this.frameCleanup &&
537
+ (event === 'frame' ||
538
+ event === 'interaction-start' ||
539
+ event === 'interaction-end' ||
540
+ event === 'camera-start' ||
541
+ event === 'camera-end')
542
+ ) {
543
+ if (!this._needsFrameListener()) {
544
+ this.frameCleanup()
545
+ this.frameCleanup = null
546
+ }
547
+ }
548
+
549
+ // 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
+ }
554
+ }
555
+
556
+ if (
557
+ this.shapeCreatedCleanup &&
558
+ event === 'shapes-created' &&
559
+ this.emitter.listenerCount('shapes-created') === 0
560
+ ) {
561
+ this.shapeCreatedCleanup()
562
+ this.shapeCreatedCleanup = null
563
+ }
564
+
565
+ if (
566
+ this.shapeEditedCleanup &&
567
+ event === 'shapes-updated' &&
568
+ this.emitter.listenerCount('shapes-updated') === 0
569
+ ) {
570
+ this.shapeEditedCleanup()
571
+ this.shapeEditedCleanup = null
572
+ }
573
+
574
+ if (
575
+ this.shapeDeletedCleanup &&
576
+ event === 'shapes-deleted' &&
577
+ this.emitter.listenerCount('shapes-deleted') === 0
578
+ ) {
579
+ this.shapeDeletedCleanup()
580
+ this.shapeDeletedCleanup = null
581
+ }
582
+ }
583
+ }