@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.
- package/dist-cjs/index.d.ts +668 -96
- package/dist-cjs/index.js +16 -3
- package/dist-cjs/index.js.map +3 -3
- package/dist-cjs/lib/TldrawEditor.js +55 -12
- package/dist-cjs/lib/TldrawEditor.js.map +3 -3
- package/dist-cjs/lib/components/MenuClickCapture.js +16 -1
- package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
- package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js +3 -3
- package/dist-cjs/lib/components/default-components/CanvasShapeIndicators.js.map +2 -2
- package/dist-cjs/lib/config/createTLStore.js +7 -0
- package/dist-cjs/lib/config/createTLStore.js.map +2 -2
- package/dist-cjs/lib/config/defaultAssets.js +36 -0
- package/dist-cjs/lib/config/defaultAssets.js.map +7 -0
- package/dist-cjs/lib/editor/Editor.js +215 -5
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/assets/AssetUtil.js +66 -0
- package/dist-cjs/lib/editor/assets/AssetUtil.js.map +7 -0
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.js +80 -0
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.js.map +7 -0
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +466 -0
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +7 -0
- package/dist-cjs/lib/editor/managers/PerformanceManager/perf-types.js +17 -0
- package/dist-cjs/lib/editor/managers/PerformanceManager/perf-types.js.map +7 -0
- package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +106 -0
- package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js.map +7 -0
- package/dist-cjs/lib/editor/managers/ThemeManager/defaultThemes.js +586 -0
- package/dist-cjs/lib/editor/managers/ThemeManager/defaultThemes.js.map +7 -0
- package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +6 -4
- package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js +11 -2
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +1 -1
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js +6 -0
- package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
- package/dist-cjs/lib/editor/tools/StateNode.js +14 -17
- package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
- package/dist-cjs/lib/editor/types/SvgExportContext.js.map +2 -2
- package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
- package/dist-cjs/lib/exports/getSvgJsx.js +12 -7
- package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
- package/dist-cjs/lib/globals/environment.js +18 -1
- package/dist-cjs/lib/globals/environment.js.map +2 -2
- package/dist-cjs/lib/hooks/{useIsDarkMode.js → useColorMode.js} +14 -10
- package/dist-cjs/lib/hooks/useColorMode.js.map +7 -0
- package/dist-cjs/lib/hooks/useCursor.js +3 -7
- package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
- package/dist-cjs/lib/hooks/useDarkMode.js +4 -4
- package/dist-cjs/lib/hooks/useDarkMode.js.map +2 -2
- package/dist-cjs/lib/utils/reparenting.js +2 -1
- package/dist-cjs/lib/utils/reparenting.js.map +2 -2
- package/dist-cjs/lib/utils/richText.js.map +2 -2
- package/dist-cjs/lib/utils/runtime.js +2 -1
- package/dist-cjs/lib/utils/runtime.js.map +2 -2
- package/dist-cjs/lib/utils/sync/hardReset.js +0 -8
- package/dist-cjs/lib/utils/sync/hardReset.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +668 -96
- package/dist-esm/index.mjs +17 -6
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +58 -12
- package/dist-esm/lib/TldrawEditor.mjs.map +3 -3
- package/dist-esm/lib/components/MenuClickCapture.mjs +16 -1
- package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs +3 -3
- package/dist-esm/lib/components/default-components/CanvasShapeIndicators.mjs.map +2 -2
- package/dist-esm/lib/config/createTLStore.mjs +10 -1
- package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
- package/dist-esm/lib/config/defaultAssets.mjs +16 -0
- package/dist-esm/lib/config/defaultAssets.mjs.map +7 -0
- package/dist-esm/lib/editor/Editor.mjs +215 -5
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/assets/AssetUtil.mjs +46 -0
- package/dist-esm/lib/editor/assets/AssetUtil.mjs.map +7 -0
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.mjs +60 -0
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.mjs.map +7 -0
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +438 -0
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +7 -0
- package/dist-esm/lib/editor/managers/PerformanceManager/perf-types.mjs +1 -0
- package/dist-esm/lib/editor/managers/PerformanceManager/perf-types.mjs.map +7 -0
- package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +88 -0
- package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +7 -0
- package/dist-esm/lib/editor/managers/ThemeManager/defaultThemes.mjs +568 -0
- package/dist-esm/lib/editor/managers/ThemeManager/defaultThemes.mjs.map +7 -0
- package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +6 -4
- package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +11 -2
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +1 -1
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs +6 -0
- package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
- package/dist-esm/lib/editor/tools/StateNode.mjs +14 -17
- package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
- package/dist-esm/lib/editor/types/SvgExportContext.mjs.map +2 -2
- package/dist-esm/lib/exports/getSvgJsx.mjs +12 -10
- package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
- package/dist-esm/lib/globals/environment.mjs +18 -1
- package/dist-esm/lib/globals/environment.mjs.map +2 -2
- package/dist-esm/lib/hooks/useColorMode.mjs +19 -0
- package/dist-esm/lib/hooks/useColorMode.mjs.map +7 -0
- package/dist-esm/lib/hooks/useCursor.mjs +3 -7
- package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
- package/dist-esm/lib/hooks/useDarkMode.mjs +4 -4
- package/dist-esm/lib/hooks/useDarkMode.mjs.map +2 -2
- package/dist-esm/lib/utils/reparenting.mjs +2 -1
- package/dist-esm/lib/utils/reparenting.mjs.map +2 -2
- package/dist-esm/lib/utils/richText.mjs.map +2 -2
- package/dist-esm/lib/utils/runtime.mjs +2 -1
- package/dist-esm/lib/utils/runtime.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/hardReset.mjs +0 -8
- package/dist-esm/lib/utils/sync/hardReset.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +0 -33
- package/package.json +7 -7
- package/src/index.ts +23 -6
- package/src/lib/TldrawEditor.tsx +90 -13
- package/src/lib/components/MenuClickCapture.tsx +20 -0
- package/src/lib/components/default-components/CanvasShapeIndicators.tsx +3 -3
- package/src/lib/config/createTLStore.ts +22 -1
- package/src/lib/config/defaultAssets.ts +19 -0
- package/src/lib/editor/Editor.ts +301 -27
- package/src/lib/editor/assets/AssetUtil.ts +85 -0
- package/src/lib/editor/managers/FontManager/FontManager.test.ts +9 -2
- package/src/lib/editor/managers/FontManager/FontManager.ts +1 -67
- package/src/lib/editor/managers/PerformanceManager/PerformanceApiAdapter.ts +82 -0
- package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +522 -0
- package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +583 -0
- package/src/lib/editor/managers/PerformanceManager/perf-types.ts +196 -0
- package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +116 -0
- package/src/lib/editor/managers/ThemeManager/defaultThemes.ts +605 -0
- package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +23 -29
- package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +5 -3
- package/src/lib/editor/shapes/ShapeUtil.ts +28 -3
- package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +1 -1
- package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +7 -0
- package/src/lib/editor/tools/StateNode.ts +16 -18
- package/src/lib/editor/types/SvgExportContext.tsx +5 -0
- package/src/lib/editor/types/external-content.ts +1 -0
- package/src/lib/exports/getSvgJsx.tsx +21 -15
- package/src/lib/globals/environment.ts +18 -0
- package/src/lib/hooks/{useIsDarkMode.ts → useColorMode.ts} +9 -5
- package/src/lib/hooks/useCursor.ts +3 -7
- package/src/lib/hooks/useDarkMode.ts +4 -4
- package/src/lib/utils/reparenting.ts +6 -2
- package/src/lib/utils/richText.ts +1 -1
- package/src/lib/utils/runtime.ts +3 -1
- package/src/lib/utils/sync/hardReset.ts +0 -8
- package/src/version.ts +3 -3
- package/dist-cjs/lib/hooks/useIsDarkMode.js.map +0 -7
- package/dist-esm/lib/hooks/useIsDarkMode.mjs +0 -15
- 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
|
+
})
|