@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
@@ -1,6 +1,6 @@
1
1
  import { computed, EMPTY_ARRAY, transact } from '@tldraw/state'
2
2
  import { AtomMap } from '@tldraw/store'
3
- import { TLShape, TLShapeId } from '@tldraw/tlschema'
3
+ import { TLFontFace, TLShape, TLShapeId } from '@tldraw/tlschema'
4
4
  import {
5
5
  areArraysShallowEqual,
6
6
  compact,
@@ -10,72 +10,6 @@ import {
10
10
  } from '@tldraw/utils'
11
11
  import type { Editor } from '../../Editor'
12
12
 
13
- /**
14
- * Represents the `src` property of a {@link TLFontFace}.
15
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src | `src`} for details of the properties here.
16
- * @public
17
- */
18
- export interface TLFontFaceSource {
19
- /**
20
- * A URL from which to load the font. If the value here is a key in
21
- * {@link tldraw#TLEditorAssetUrls.fonts}, the value from there will be used instead.
22
- */
23
- url: string
24
- format?: string
25
- tech?: string
26
- }
27
-
28
- /**
29
- * A font face that can be used in the editor. The properties of this are largely the same as the
30
- * ones in the
31
- * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face | css `@font-face` rule}.
32
- * @public
33
- */
34
- export interface TLFontFace {
35
- /**
36
- * How this font can be referred to in CSS.
37
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-family | `font-family`}.
38
- */
39
- readonly family: string
40
- /**
41
- * The source of the font. This
42
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src | `src`}.
43
- */
44
- readonly src: TLFontFaceSource
45
- /**
46
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/ascent-override | `ascent-override`}.
47
- */
48
- readonly ascentOverride?: string
49
- /**
50
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/descent-override | `descent-override`}.
51
- */
52
- readonly descentOverride?: string
53
- /**
54
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-stretch | `font-stretch`}.
55
- */
56
- readonly stretch?: string
57
- /**
58
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-style | `font-style`}.
59
- */
60
- readonly style?: string
61
- /**
62
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-weight | `font-weight`}.
63
- */
64
- readonly weight?: string
65
- /**
66
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-feature-settings | `font-feature-settings`}.
67
- */
68
- readonly featureSettings?: string
69
- /**
70
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/line-gap-override | `line-gap-override`}.
71
- */
72
- readonly lineGapOverride?: string
73
- /**
74
- * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range | `unicode-range`}.
75
- */
76
- readonly unicodeRange?: string
77
- }
78
-
79
13
  interface FontState {
80
14
  readonly state: 'loading' | 'ready' | 'error'
81
15
  readonly instance: FontFace
@@ -0,0 +1,82 @@
1
+ import type { PerformanceManager } from './PerformanceManager'
2
+
3
+ /** Wrap performance.mark with try/catch — `detail` option may throw in Safari/Firefox. */
4
+ function safeMark(name: string, detail?: Record<string, unknown>) {
5
+ try {
6
+ performance.mark(name, detail ? { detail } : undefined)
7
+ } catch {
8
+ performance.mark(name)
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Optional adapter that pipes PerformanceManager events into browser
14
+ * `performance.mark()` / `performance.measure()` for DevTools integration.
15
+ *
16
+ * Tree-shakeable — only included if imported.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const adapter = new PerformanceApiAdapter(editor.performance)
21
+ * // ... later
22
+ * adapter.dispose()
23
+ * ```
24
+ *
25
+ * @public
26
+ */
27
+ export class PerformanceApiAdapter {
28
+ private cleanups: (() => void)[] = []
29
+
30
+ constructor(perfManager: PerformanceManager) {
31
+ this.cleanups.push(
32
+ perfManager.on('interaction-start', (event) => {
33
+ safeMark(`tldraw:interaction:${event.name}:start`, { path: event.path })
34
+ })
35
+ )
36
+
37
+ this.cleanups.push(
38
+ perfManager.on('interaction-end', (event) => {
39
+ const startMark = `tldraw:interaction:${event.name}:start`
40
+ const endMark = `tldraw:interaction:${event.name}:end`
41
+ safeMark(endMark, {
42
+ path: event.path,
43
+ fps: event.fps,
44
+ frameCount: event.frameCount,
45
+ shapeCount: event.shapeCount,
46
+ })
47
+ try {
48
+ performance.measure(`tldraw:interaction:${event.name}`, startMark, endMark)
49
+ } catch {
50
+ // start mark may not exist if adapter attached mid-interaction
51
+ }
52
+ })
53
+ )
54
+
55
+ this.cleanups.push(
56
+ perfManager.on('camera-start', (event) => {
57
+ safeMark(`tldraw:camera:${event.type}:start`)
58
+ })
59
+ )
60
+
61
+ this.cleanups.push(
62
+ perfManager.on('camera-end', (event) => {
63
+ const startMark = `tldraw:camera:${event.type}:start`
64
+ const endMark = `tldraw:camera:${event.type}:end`
65
+ safeMark(endMark, { fps: event.fps, shapeCount: event.shapeCount })
66
+ try {
67
+ performance.measure(`tldraw:camera:${event.type}`, startMark, endMark)
68
+ } catch {
69
+ // start mark may not exist
70
+ }
71
+ })
72
+ )
73
+ }
74
+
75
+ /** Remove all listeners and stop piping events. @public */
76
+ dispose() {
77
+ for (const cleanup of this.cleanups) {
78
+ cleanup()
79
+ }
80
+ this.cleanups.length = 0
81
+ }
82
+ }
@@ -0,0 +1,522 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { PerformanceManager } from './PerformanceManager'
3
+
4
+ function createMockEditor() {
5
+ const listeners: Record<string, ((...args: any[]) => void)[]> = {}
6
+ return {
7
+ on(event: string, handler: (...args: any[]) => void) {
8
+ ;(listeners[event] ??= []).push(handler)
9
+ },
10
+ off(event: string, handler: (...args: any[]) => void) {
11
+ const arr = listeners[event]
12
+ if (arr) {
13
+ const idx = arr.indexOf(handler)
14
+ if (idx !== -1) arr.splice(idx, 1)
15
+ }
16
+ },
17
+ emit(event: string, ...args: any[]) {
18
+ for (const handler of listeners[event] ?? []) {
19
+ handler(...args)
20
+ }
21
+ },
22
+ getSelectedShapes: () => [
23
+ { type: 'geo', id: '1' },
24
+ { type: 'geo', id: '2' },
25
+ { type: 'draw', id: '3' },
26
+ ],
27
+ getCurrentPageShapeIds: () => ({ size: 10 }),
28
+ getCulledShapes: () => ({ size: 3 }),
29
+ getCamera: () => ({ x: 0, y: 0, z: 1 }),
30
+ getShape: (id: string) => {
31
+ const shapes: Record<string, { type: string }> = {
32
+ 'shape:1': { type: 'geo' },
33
+ 'shape:2': { type: 'draw' },
34
+ 'shape:3': { type: 'geo' },
35
+ }
36
+ return shapes[id]
37
+ },
38
+ getViewportScreenBounds: () => ({ w: 1920, h: 1080 }),
39
+ timers: {
40
+ setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms),
41
+ },
42
+ _listeners: listeners,
43
+ } as any
44
+ }
45
+
46
+ describe('PerformanceManager', () => {
47
+ describe('listener management', () => {
48
+ it('returns an unsubscribe function from on()', () => {
49
+ const editor = createMockEditor()
50
+ const pm = new PerformanceManager(editor)
51
+ const fn = vi.fn()
52
+ const unsub = pm.on('interaction-end', fn)
53
+ expect(typeof unsub).toBe('function')
54
+ unsub()
55
+ })
56
+
57
+ it('attaches frame listener lazily when interaction listener subscribes', () => {
58
+ const editor = createMockEditor()
59
+ const pm = new PerformanceManager(editor)
60
+ expect(editor._listeners['frame'] ?? []).toHaveLength(0)
61
+
62
+ const unsub = pm.on('interaction-end', vi.fn())
63
+ expect(editor._listeners['frame']).toHaveLength(1)
64
+
65
+ unsub()
66
+ expect(editor._listeners['frame'] ?? []).toHaveLength(0)
67
+ })
68
+
69
+ it('attaches shape listeners lazily', () => {
70
+ const editor = createMockEditor()
71
+ const pm = new PerformanceManager(editor)
72
+
73
+ const unsub = pm.on('shapes-created', vi.fn())
74
+ expect(editor._listeners['created-shapes']).toHaveLength(1)
75
+
76
+ unsub()
77
+ expect(editor._listeners['created-shapes'] ?? []).toHaveLength(0)
78
+ })
79
+
80
+ it('does not double-attach frame listener', () => {
81
+ const editor = createMockEditor()
82
+ const pm = new PerformanceManager(editor)
83
+
84
+ const unsub1 = pm.on('interaction-end', vi.fn())
85
+ const unsub2 = pm.on('camera-end', vi.fn())
86
+ expect(editor._listeners['frame']).toHaveLength(1)
87
+
88
+ unsub1()
89
+ // still one camera-end listener
90
+ expect(editor._listeners['frame']).toHaveLength(1)
91
+
92
+ unsub2()
93
+ expect(editor._listeners['frame'] ?? []).toHaveLength(0)
94
+ })
95
+ })
96
+
97
+ describe('interaction tracking', () => {
98
+ it('emits interaction-start when notified', () => {
99
+ const editor = createMockEditor()
100
+ const pm = new PerformanceManager(editor)
101
+ const fn = vi.fn()
102
+ pm.on('interaction-start', fn)
103
+
104
+ pm._notifyInteractionStart('translating', 'select.translating')
105
+
106
+ expect(fn).toHaveBeenCalledTimes(1)
107
+ expect(fn.mock.calls[0][0]).toMatchObject({
108
+ name: 'translating',
109
+ path: 'select.translating',
110
+ })
111
+ expect(fn.mock.calls[0][0].timestamp).toBeGreaterThan(0)
112
+ })
113
+
114
+ it('emits interaction-end with frame stats', () => {
115
+ const editor = createMockEditor()
116
+ const pm = new PerformanceManager(editor)
117
+ const fn = vi.fn()
118
+ pm.on('interaction-end', fn)
119
+
120
+ pm._notifyInteractionStart('resizing', 'select.resizing')
121
+
122
+ // Simulate frames
123
+ editor.emit('frame', 16)
124
+ editor.emit('frame', 16)
125
+ editor.emit('frame', 32)
126
+ editor.emit('frame', 16)
127
+ editor.emit('frame', 50)
128
+
129
+ pm._notifyInteractionEnd()
130
+
131
+ expect(fn).toHaveBeenCalledTimes(1)
132
+ const event = fn.mock.calls[0][0]
133
+ expect(event.name).toBe('resizing')
134
+ expect(event.path).toBe('select.resizing')
135
+ expect(event.frameCount).toBe(5)
136
+ expect(event.frameTimes).toEqual([16, 16, 32, 16, 50])
137
+ expect(event.shapeCount).toBe(10)
138
+ expect(event.selectedShapeTypes).toEqual({ geo: 2, draw: 1 })
139
+ expect(event.avgFrameTime).toBe(26)
140
+ expect(event.duration).toBeGreaterThan(0)
141
+ })
142
+
143
+ it('does not emit when no listeners', () => {
144
+ const editor = createMockEditor()
145
+ const pm = new PerformanceManager(editor)
146
+
147
+ // Should not throw
148
+ pm._notifyInteractionStart('resizing', 'select.resizing')
149
+ pm._notifyInteractionEnd()
150
+ })
151
+
152
+ it('handles end without start gracefully', () => {
153
+ const editor = createMockEditor()
154
+ const pm = new PerformanceManager(editor)
155
+ const fn = vi.fn()
156
+ pm.on('interaction-end', fn)
157
+
158
+ pm._notifyInteractionEnd()
159
+ expect(fn).not.toHaveBeenCalled()
160
+ })
161
+ })
162
+
163
+ describe('camera tracking', () => {
164
+ it('emits camera-start and camera-end', () => {
165
+ vi.useFakeTimers()
166
+ const editor = createMockEditor()
167
+ // Override timers.setTimeout to use fake timers
168
+ editor.timers.setTimeout = (fn: () => void, ms: number) => setTimeout(fn, ms) as any
169
+ const pm = new PerformanceManager(editor)
170
+ const startFn = vi.fn()
171
+ const endFn = vi.fn()
172
+ pm.on('camera-start', startFn)
173
+ pm.on('camera-end', endFn)
174
+
175
+ pm._notifyCameraOperation('panning')
176
+ expect(startFn).toHaveBeenCalledTimes(1)
177
+ expect(startFn.mock.calls[0][0].type).toBe('panning')
178
+
179
+ // Simulate frames during camera operation
180
+ editor.emit('frame', 16)
181
+ editor.emit('frame', 16)
182
+
183
+ // Wait for debounce timeout
184
+ vi.advanceTimersByTime(100)
185
+
186
+ expect(endFn).toHaveBeenCalledTimes(1)
187
+ const event = endFn.mock.calls[0][0]
188
+ expect(event.type).toBe('panning')
189
+ expect(event.frameCount).toBe(2)
190
+ expect(event.viewportWidth).toBe(1920)
191
+ expect(event.viewportHeight).toBe(1080)
192
+
193
+ vi.useRealTimers()
194
+ })
195
+
196
+ it('extends camera session on repeated operations', () => {
197
+ vi.useFakeTimers()
198
+ const editor = createMockEditor()
199
+ editor.timers.setTimeout = (fn: () => void, ms: number) => setTimeout(fn, ms) as any
200
+ const pm = new PerformanceManager(editor)
201
+ const endFn = vi.fn()
202
+ pm.on('camera-end', endFn)
203
+
204
+ pm._notifyCameraOperation('panning')
205
+ editor.emit('frame', 16)
206
+ vi.advanceTimersByTime(30)
207
+
208
+ pm._notifyCameraOperation('panning')
209
+ editor.emit('frame', 16)
210
+ vi.advanceTimersByTime(30)
211
+
212
+ // Should not have ended yet
213
+ expect(endFn).not.toHaveBeenCalled()
214
+
215
+ vi.advanceTimersByTime(100)
216
+ expect(endFn).toHaveBeenCalledTimes(1)
217
+ expect(endFn.mock.calls[0][0].frameCount).toBe(2)
218
+
219
+ vi.useRealTimers()
220
+ })
221
+
222
+ it('ends old session and starts new on type change', () => {
223
+ vi.useFakeTimers()
224
+ const editor = createMockEditor()
225
+ editor.timers.setTimeout = (fn: () => void, ms: number) => setTimeout(fn, ms) as any
226
+ const pm = new PerformanceManager(editor)
227
+ const startFn = vi.fn()
228
+ const endFn = vi.fn()
229
+ pm.on('camera-start', startFn)
230
+ pm.on('camera-end', endFn)
231
+
232
+ pm._notifyCameraOperation('panning')
233
+ editor.emit('frame', 16)
234
+
235
+ pm._notifyCameraOperation('zooming')
236
+ // Panning session should have ended
237
+ expect(endFn).toHaveBeenCalledTimes(1)
238
+ expect(endFn.mock.calls[0][0].type).toBe('panning')
239
+ // Zooming session should have started
240
+ expect(startFn).toHaveBeenCalledTimes(2)
241
+
242
+ vi.advanceTimersByTime(100)
243
+ expect(endFn).toHaveBeenCalledTimes(2)
244
+ expect(endFn.mock.calls[1][0].type).toBe('zooming')
245
+
246
+ vi.useRealTimers()
247
+ })
248
+ })
249
+
250
+ describe('frame time stats', () => {
251
+ it('computes correct percentiles', () => {
252
+ const editor = createMockEditor()
253
+ const pm = new PerformanceManager(editor)
254
+ const fn = vi.fn()
255
+ pm.on('interaction-end', fn)
256
+
257
+ pm._notifyInteractionStart('translating', 'select.translating')
258
+
259
+ // 10 frames: 10, 11, 12, 13, 14, 15, 16, 17, 18, 100
260
+ for (let i = 10; i <= 18; i++) editor.emit('frame', i)
261
+ editor.emit('frame', 100)
262
+
263
+ pm._notifyInteractionEnd()
264
+
265
+ const event = fn.mock.calls[0][0]
266
+ expect(event.minFrameTime).toBe(10)
267
+ expect(event.maxFrameTime).toBe(100)
268
+ expect(event.medianFrameTime).toBe(14)
269
+ })
270
+
271
+ it('handles single frame', () => {
272
+ const editor = createMockEditor()
273
+ const pm = new PerformanceManager(editor)
274
+ const fn = vi.fn()
275
+ pm.on('interaction-end', fn)
276
+
277
+ pm._notifyInteractionStart('translating', 'select.translating')
278
+ editor.emit('frame', 42)
279
+ pm._notifyInteractionEnd()
280
+
281
+ const event = fn.mock.calls[0][0]
282
+ expect(event.avgFrameTime).toBe(42)
283
+ expect(event.medianFrameTime).toBe(42)
284
+ expect(event.p95FrameTime).toBe(42)
285
+ expect(event.p99FrameTime).toBe(42)
286
+ expect(event.minFrameTime).toBe(42)
287
+ expect(event.maxFrameTime).toBe(42)
288
+ })
289
+
290
+ it('handles zero frames', () => {
291
+ const editor = createMockEditor()
292
+ const pm = new PerformanceManager(editor)
293
+ const fn = vi.fn()
294
+ pm.on('interaction-end', fn)
295
+
296
+ pm._notifyInteractionStart('translating', 'select.translating')
297
+ pm._notifyInteractionEnd()
298
+
299
+ const event = fn.mock.calls[0][0]
300
+ expect(event.avgFrameTime).toBe(0)
301
+ expect(event.frameCount).toBe(0)
302
+ })
303
+ })
304
+
305
+ describe('undo/redo tracking', () => {
306
+ it('emits undo events', () => {
307
+ const editor = createMockEditor()
308
+ const pm = new PerformanceManager(editor)
309
+ const fn = vi.fn()
310
+ pm.on('undo', fn)
311
+
312
+ pm._notifyUndoRedo('undo', 5, 2)
313
+
314
+ expect(fn).toHaveBeenCalledWith({ type: 'undo', undoDepth: 5, redoDepth: 2 })
315
+ })
316
+
317
+ it('emits redo events', () => {
318
+ const editor = createMockEditor()
319
+ const pm = new PerformanceManager(editor)
320
+ const fn = vi.fn()
321
+ pm.on('redo', fn)
322
+
323
+ pm._notifyUndoRedo('redo', 4, 3)
324
+
325
+ expect(fn).toHaveBeenCalledWith({ type: 'redo', undoDepth: 4, redoDepth: 3 })
326
+ })
327
+
328
+ it('skips when no listeners', () => {
329
+ const editor = createMockEditor()
330
+ const pm = new PerformanceManager(editor)
331
+ // Should not throw
332
+ pm._notifyUndoRedo('undo', 1, 0)
333
+ })
334
+ })
335
+
336
+ describe('shape operation tracking', () => {
337
+ it('emits shapes-created events', () => {
338
+ const editor = createMockEditor()
339
+ const pm = new PerformanceManager(editor)
340
+ const fn = vi.fn()
341
+ pm.on('shapes-created', fn)
342
+
343
+ editor.emit('created-shapes', [
344
+ { typeName: 'shape', type: 'geo' },
345
+ { typeName: 'shape', type: 'geo' },
346
+ { typeName: 'shape', type: 'draw' },
347
+ ])
348
+
349
+ expect(fn).toHaveBeenCalledTimes(1)
350
+ expect(fn.mock.calls[0][0]).toMatchObject({
351
+ operation: 'create',
352
+ count: 3,
353
+ shapeTypes: { geo: 2, draw: 1 },
354
+ })
355
+ })
356
+
357
+ it('ignores non-shape records', () => {
358
+ const editor = createMockEditor()
359
+ const pm = new PerformanceManager(editor)
360
+ const fn = vi.fn()
361
+ pm.on('shapes-created', fn)
362
+
363
+ editor.emit('created-shapes', [{ typeName: 'page', type: 'page' }])
364
+
365
+ expect(fn).not.toHaveBeenCalled()
366
+ })
367
+
368
+ it('emits shapes-deleted with shape types', () => {
369
+ const editor = createMockEditor()
370
+ const pm = new PerformanceManager(editor)
371
+ const fn = vi.fn()
372
+ pm.on('shapes-deleted', fn)
373
+
374
+ editor.emit('deleted-shapes', ['shape:1', 'shape:2'])
375
+
376
+ expect(fn).toHaveBeenCalledTimes(1)
377
+ expect(fn.mock.calls[0][0]).toMatchObject({
378
+ operation: 'delete',
379
+ count: 2,
380
+ shapeTypes: { geo: 1, draw: 1 },
381
+ })
382
+ })
383
+ })
384
+
385
+ describe('frame event', () => {
386
+ it('emits frame events with shape counts', () => {
387
+ const editor = createMockEditor()
388
+ const pm = new PerformanceManager(editor)
389
+ const fn = vi.fn()
390
+ pm.on('frame', fn)
391
+
392
+ editor.emit('frame', 16)
393
+
394
+ expect(fn).toHaveBeenCalledWith({
395
+ elapsed: 16,
396
+ shapeCount: 10,
397
+ culledShapeCount: 3,
398
+ visibleShapeCount: 7,
399
+ })
400
+ })
401
+ })
402
+
403
+ describe('once()', () => {
404
+ it('fires callback only once', () => {
405
+ const editor = createMockEditor()
406
+ const pm = new PerformanceManager(editor)
407
+ const fn = vi.fn()
408
+ pm.once('interaction-start', fn)
409
+
410
+ pm._notifyInteractionStart('resizing', 'select.resizing')
411
+ pm._notifyInteractionEnd()
412
+ pm._notifyInteractionStart('resizing', 'select.resizing')
413
+
414
+ expect(fn).toHaveBeenCalledTimes(1)
415
+ })
416
+
417
+ it('detaches frame listener after once fires', () => {
418
+ const editor = createMockEditor()
419
+ const pm = new PerformanceManager(editor)
420
+
421
+ pm.once('interaction-start', vi.fn())
422
+ expect(editor._listeners['frame']).toHaveLength(1)
423
+
424
+ pm._notifyInteractionStart('resizing', 'select.resizing')
425
+ // frame listener should be detached after once fires
426
+ expect(editor._listeners['frame'] ?? []).toHaveLength(0)
427
+ })
428
+ })
429
+
430
+ describe('LoAF integration', () => {
431
+ it('attaches LoAF entries to interaction-end events', () => {
432
+ let observerCallback: ((list: any) => void) | null = null
433
+ const mockDisconnect = vi.fn()
434
+
435
+ const origPO = globalThis.PerformanceObserver
436
+ globalThis.PerformanceObserver = class MockPO {
437
+ constructor(cb: (list: any) => void) {
438
+ observerCallback = cb
439
+ }
440
+ observe() {}
441
+ disconnect = mockDisconnect
442
+ static supportedEntryTypes = ['long-animation-frame']
443
+ } as any
444
+
445
+ const editor = createMockEditor()
446
+ const pm = new PerformanceManager(editor)
447
+ const fn = vi.fn()
448
+ pm.on('interaction-end', fn)
449
+
450
+ pm._notifyInteractionStart('resizing', 'select.resizing')
451
+ editor.emit('frame', 16)
452
+
453
+ // Simulate a LoAF entry
454
+ observerCallback!({
455
+ getEntries: () => [
456
+ {
457
+ startTime: 100,
458
+ duration: 80,
459
+ blockingDuration: 60,
460
+ scripts: [{ sourceURL: 'app.js', invoker: 'onPointerMove', duration: 55 }],
461
+ },
462
+ ],
463
+ })
464
+
465
+ editor.emit('frame', 16)
466
+ pm._notifyInteractionEnd()
467
+
468
+ expect(fn).toHaveBeenCalledTimes(1)
469
+ const event = fn.mock.calls[0][0]
470
+ expect(event.longAnimationFrames).toHaveLength(1)
471
+ expect(event.longAnimationFrames[0]).toEqual({
472
+ startTime: 100,
473
+ duration: 80,
474
+ blockingDuration: 60,
475
+ scripts: [{ sourceURL: 'app.js', invoker: 'onPointerMove', duration: 55 }],
476
+ })
477
+
478
+ // Cleanup
479
+ pm.on('interaction-end', vi.fn())() // subscribe + unsub to trigger detach check
480
+ globalThis.PerformanceObserver = origPO
481
+ })
482
+
483
+ it('omits longAnimationFrames when none collected', () => {
484
+ const editor = createMockEditor()
485
+ const pm = new PerformanceManager(editor)
486
+ const fn = vi.fn()
487
+ pm.on('interaction-end', fn)
488
+
489
+ pm._notifyInteractionStart('resizing', 'select.resizing')
490
+ editor.emit('frame', 16)
491
+ pm._notifyInteractionEnd()
492
+
493
+ expect(fn.mock.calls[0][0].longAnimationFrames).toBeUndefined()
494
+ })
495
+
496
+ it('starts LoAF observer only when interaction-end or camera-end listeners exist', () => {
497
+ const origPO = globalThis.PerformanceObserver
498
+ const mockObserve = vi.fn()
499
+ globalThis.PerformanceObserver = class MockPO {
500
+ constructor(_cb: any) {}
501
+ observe = mockObserve
502
+ disconnect = vi.fn()
503
+ static supportedEntryTypes = ['long-animation-frame']
504
+ } as any
505
+
506
+ const editor = createMockEditor()
507
+ const pm = new PerformanceManager(editor)
508
+
509
+ // interaction-start alone should NOT start LoAF observer
510
+ const unsub1 = pm.on('interaction-start', vi.fn())
511
+ expect(mockObserve).not.toHaveBeenCalled()
512
+ unsub1()
513
+
514
+ // interaction-end should start LoAF observer
515
+ const unsub2 = pm.on('interaction-end', vi.fn())
516
+ expect(mockObserve).toHaveBeenCalledTimes(1)
517
+ unsub2()
518
+
519
+ globalThis.PerformanceObserver = origPO
520
+ })
521
+ })
522
+ })