@hyperframes/studio 0.8.32 → 0.8.34
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/assets/{hyperframes-player-CroojEG1.js → hyperframes-player-D4aSRr_M.js} +1 -1
- package/dist/assets/{index-PVOehm7h.js → index-CkxPQxLJ.js} +182 -172
- package/dist/assets/{index-CSyaU1m2.js → index-aNyQBoJv.js} +1 -1
- package/dist/assets/{index-D0AjUHNT.js → index-hch4ktcC.js} +1 -1
- package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
- package/dist/chunk-7BP32G62.js.map +1 -0
- package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +354 -206
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -4
- package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
- package/src/captions/components/CaptionOverlay.tsx +1 -0
- package/src/captions/generator.test.ts +30 -0
- package/src/captions/generator.ts +5 -3
- package/src/captions/hooks/useCaptionSync.ts +3 -2
- package/src/components/MediaPreview.tsx +2 -1
- package/src/components/editor/AnimationCard.test.tsx +3 -3
- package/src/components/editor/PropertyPanel.test.tsx +18 -14
- package/src/components/editor/SnapGuideOverlay.tsx +2 -6
- package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
- package/src/components/editor/domEditLayerWalkCache.ts +327 -0
- package/src/components/editor/domEditOverlayBasis.ts +72 -0
- package/src/components/editor/domEditOverlayGeometry.ts +78 -83
- package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
- package/src/components/editor/domEditOverlayTransform.ts +47 -9
- package/src/components/editor/domEditingDom.ts +63 -14
- package/src/components/editor/domEditingElement.ts +56 -14
- package/src/components/editor/domEditingLayers.test.ts +48 -0
- package/src/components/editor/domEditingLayers.ts +22 -35
- package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
- package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
- package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
- package/src/components/editor/overlayFrameLoop.test.ts +246 -0
- package/src/components/editor/overlayFrameLoop.ts +204 -0
- package/src/components/editor/probeSourceElement.ts +29 -0
- package/src/components/editor/propertyPanelFill.tsx +2 -1
- package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
- package/src/components/editor/useDomEditCompositionRect.ts +2 -4
- package/src/components/editor/useDomEditOverlayRects.ts +15 -9
- package/src/components/editor/useMotionPathData.ts +2 -4
- package/src/components/feedback/projectProvenance.ts +4 -1
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/PreviewOverlays.tsx +1 -1
- package/src/components/nle/useCompositionStack.ts +11 -4
- package/src/components/renders/RenderQueue.test.tsx +2 -2
- package/src/components/renders/RenderQueueItem.tsx +2 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/CompositionsTab.tsx +2 -1
- package/src/components/storyboard/FramePoster.tsx +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useCompositionContentLoader.ts +3 -1
- package/src/hooks/useCompositionDimensions.test.tsx +47 -0
- package/src/hooks/useCompositionDimensions.ts +3 -2
- package/src/hooks/useDomEditCommits.ts +5 -4
- package/src/hooks/useElementLifecycleOps.ts +5 -1
- package/src/hooks/useFileTree.ts +5 -4
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/useRenderClipContent.ts +6 -5
- package/src/hooks/useTimelineDeleteOps.ts +5 -1
- package/src/player/components/Player.tsx +6 -4
- package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
- package/src/player/components/thumbnailUtils.ts +6 -2
- package/src/player/components/timelineDragDrop.test.tsx +3 -3
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -1
- package/src/test-setup.ts +8 -0
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/projectRouting.test.ts +9 -0
- package/src/utils/projectRouting.ts +18 -1
- package/src/utils/razorSplitTransaction.test.ts +3 -3
- package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
- package/src/utils/sourceScopedSelectorIndex.ts +64 -6
- package/src/utils/studioFileHistory.ts +2 -1
- package/src/utils/studioHelpers.test.ts +5 -1
- package/src/utils/studioHelpers.ts +3 -2
- package/src/utils/timelineCompositionInsert.test.ts +3 -3
- package/dist/chunk-A7S5SGNA.js.map +0 -1
- /package/dist/{domEditingLayers-JMMVOGTO.js.map → domEditingLayers-UKMVYXT6.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.34",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"gsap": "^3.13.0",
|
|
49
49
|
"marked": "^14.1.4",
|
|
50
50
|
"mediabunny": "^1.45.3",
|
|
51
|
-
"@hyperframes/core": "0.8.
|
|
52
|
-
"@hyperframes/parsers": "0.8.
|
|
53
|
-
"@hyperframes/player": "0.8.
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/
|
|
51
|
+
"@hyperframes/core": "0.8.34",
|
|
52
|
+
"@hyperframes/parsers": "0.8.34",
|
|
53
|
+
"@hyperframes/player": "0.8.34",
|
|
54
|
+
"@hyperframes/sdk": "0.8.34",
|
|
55
|
+
"@hyperframes/studio-server": "0.8.34"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "19",
|
|
@@ -67,9 +67,9 @@
|
|
|
67
67
|
"tsup": "^8.0.0",
|
|
68
68
|
"typescript": "^5.0.0",
|
|
69
69
|
"vite": "^6.4.2",
|
|
70
|
-
"vitest": "^
|
|
70
|
+
"vitest": "^4.1.11",
|
|
71
71
|
"zustand": "^5.0.0",
|
|
72
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.34"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "./utils/projectRouting";
|
|
1
2
|
import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
|
|
2
3
|
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
|
|
3
4
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
@@ -326,12 +327,13 @@ export function StudioApp() {
|
|
|
326
327
|
const renderClipContent = useRenderClipContent({
|
|
327
328
|
projectIdRef: fileManager.projectIdRef,
|
|
328
329
|
compIdToSrc,
|
|
329
|
-
activePreviewUrl:
|
|
330
|
-
|
|
331
|
-
|
|
330
|
+
activePreviewUrl:
|
|
331
|
+
activeCompPath && projectId
|
|
332
|
+
? buildProjectApiPath(projectId, `/preview/comp/${activeCompPath}`)
|
|
333
|
+
: null,
|
|
332
334
|
effectiveTimelineDuration,
|
|
333
335
|
});
|
|
334
|
-
const compositionDimensions = useCompositionDimensions();
|
|
336
|
+
const compositionDimensions = useCompositionDimensions(previewIframeRef);
|
|
335
337
|
const { lintModal, linting, handleLint, closeLintModal, findingsByFile } = useLintModal(
|
|
336
338
|
projectId,
|
|
337
339
|
refreshKey,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
import { CaptionOverlay } from "./CaptionOverlay";
|
|
6
|
+
import { useCaptionStore } from "../store";
|
|
7
|
+
|
|
8
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
9
|
+
|
|
10
|
+
it("only schedules caption layout for messages from its preview", () => {
|
|
11
|
+
const host = document.createElement("div");
|
|
12
|
+
const preview = document.createElement("iframe");
|
|
13
|
+
const foreign = document.createElement("iframe");
|
|
14
|
+
document.body.append(host, preview, foreign);
|
|
15
|
+
const root = createRoot(host);
|
|
16
|
+
const schedule = vi.fn(() => 1);
|
|
17
|
+
vi.stubGlobal("requestAnimationFrame", schedule);
|
|
18
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
19
|
+
vi.stubGlobal(
|
|
20
|
+
"ResizeObserver",
|
|
21
|
+
class {
|
|
22
|
+
observe() {}
|
|
23
|
+
disconnect() {}
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
useCaptionStore.getState().setEditMode(true);
|
|
27
|
+
const send = (source: MessageEventSource | null) =>
|
|
28
|
+
act(() => {
|
|
29
|
+
window.dispatchEvent(new MessageEvent("message", { source, data: { source: "hf-preview" } }));
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
act(() => root.render(<CaptionOverlay iframeRef={{ current: preview }} />));
|
|
33
|
+
schedule.mockClear();
|
|
34
|
+
send(foreign.contentWindow);
|
|
35
|
+
send(null);
|
|
36
|
+
expect(schedule).not.toHaveBeenCalled();
|
|
37
|
+
send(preview.contentWindow);
|
|
38
|
+
expect(schedule).toHaveBeenCalledOnce();
|
|
39
|
+
} finally {
|
|
40
|
+
act(() => root.unmount());
|
|
41
|
+
useCaptionStore.getState().reset();
|
|
42
|
+
host.remove();
|
|
43
|
+
preview.remove();
|
|
44
|
+
foreign.remove();
|
|
45
|
+
vi.unstubAllGlobals();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -116,6 +116,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
|
|
116
116
|
// messages, element resize, window resize.
|
|
117
117
|
const unsubPlayer = usePlayerStore.subscribe(scheduleTick);
|
|
118
118
|
const handleMessage = (e: MessageEvent) => {
|
|
119
|
+
if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
|
|
119
120
|
const data = e.data;
|
|
120
121
|
if (data?.source === "hf-preview") scheduleTick();
|
|
121
122
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @vitest-environment node
|
|
2
2
|
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { Window } from "happy-dom";
|
|
3
4
|
import { generateCaptionHtml } from "./generator";
|
|
4
5
|
import { buildCaptionModel, TranscriptWord } from "./parser";
|
|
5
6
|
|
|
@@ -31,6 +32,35 @@ function buildTestModel(wordsPerGroup = 5) {
|
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
|
|
33
34
|
describe("generateCaptionHtml", () => {
|
|
35
|
+
it("keeps script-closing text and word IDs as data when HTML is parsed", () => {
|
|
36
|
+
const text = '</ScRiPt><img src=x onerror="alert(1)"><!-- <script> & end';
|
|
37
|
+
const id = '</script><svg onload="alert(2)">';
|
|
38
|
+
const model = buildCaptionModel([{ text, id, start: 0, end: 1 }], {
|
|
39
|
+
width: 1920,
|
|
40
|
+
height: 1080,
|
|
41
|
+
duration: 2,
|
|
42
|
+
wordsPerGroup: 5,
|
|
43
|
+
});
|
|
44
|
+
const window = new Window();
|
|
45
|
+
const template = window.document.createElement("template");
|
|
46
|
+
template.innerHTML = generateCaptionHtml(model);
|
|
47
|
+
const nested = template.content.querySelector("template");
|
|
48
|
+
expect(nested).not.toBeNull();
|
|
49
|
+
const content = nested!.content;
|
|
50
|
+
expect(content.querySelector("img, svg")).toBeNull();
|
|
51
|
+
const scripts = content.querySelectorAll("script");
|
|
52
|
+
expect(scripts).toHaveLength(1);
|
|
53
|
+
const script = scripts[0].textContent;
|
|
54
|
+
const transcript = script.match(/const TRANSCRIPT = ([\s\S]*?);/);
|
|
55
|
+
expect(transcript).not.toBeNull();
|
|
56
|
+
expect(JSON.parse(transcript![1])).toEqual([{ text, id, start: 0, end: 1 }]);
|
|
57
|
+
const wordText = script.match(/\.textContent = (.*);/);
|
|
58
|
+
const wordId = script.match(/\.id = (.*);/);
|
|
59
|
+
expect(JSON.parse(wordText![1])).toBe(text);
|
|
60
|
+
expect(JSON.parse(wordId![1])).toBe(id);
|
|
61
|
+
window.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
34
64
|
describe("HTML structure", () => {
|
|
35
65
|
it("wraps output in a <template id='captions-template'>", () => {
|
|
36
66
|
const model = buildTestModel();
|
|
@@ -277,7 +277,7 @@ function generateJs(model: CaptionModel): string {
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
const transcriptJson = JSON.stringify(allSegments, null, 2);
|
|
280
|
+
const transcriptJson = JSON.stringify(allSegments, null, 2).replace(/</g, "\\u003c");
|
|
281
281
|
|
|
282
282
|
const groupBlocks: string[] = [];
|
|
283
283
|
|
|
@@ -303,9 +303,11 @@ function generateJs(model: CaptionModel): string {
|
|
|
303
303
|
|
|
304
304
|
// Build word spans
|
|
305
305
|
const wordLines: string[] = groupSegments.map((seg) => {
|
|
306
|
-
const escaped = JSON.stringify(seg.text);
|
|
306
|
+
const escaped = JSON.stringify(seg.text).replace(/</g, "\\u003c");
|
|
307
307
|
const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
308
|
-
const idLine = seg.wordId
|
|
308
|
+
const idLine = seg.wordId
|
|
309
|
+
? `\n ${segVar}.id = ${JSON.stringify(seg.wordId).replace(/</g, "\\u003c")};`
|
|
310
|
+
: "";
|
|
309
311
|
return (
|
|
310
312
|
` const ${segVar} = document.createElement('span');` +
|
|
311
313
|
`\n ${segVar}.className = 'word clip';` +
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
1
2
|
import { useCallback, useRef } from "react";
|
|
2
3
|
import { useCaptionStore } from "../store";
|
|
3
4
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
@@ -92,7 +93,7 @@ export function useCaptionSync(projectId: string | null) {
|
|
|
92
93
|
const seqAtSave = editSeqRef.current;
|
|
93
94
|
const overrides = buildOverrides(state.model);
|
|
94
95
|
|
|
95
|
-
fetch(`/
|
|
96
|
+
fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), {
|
|
96
97
|
method: "PUT",
|
|
97
98
|
headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
|
|
98
99
|
body: JSON.stringify(overrides, null, 2),
|
|
@@ -171,7 +172,7 @@ export function useCaptionSync(projectId: string | null) {
|
|
|
171
172
|
let data: { content?: string };
|
|
172
173
|
try {
|
|
173
174
|
const res = await fetch(
|
|
174
|
-
`/
|
|
175
|
+
buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`),
|
|
175
176
|
);
|
|
176
177
|
if (!res.ok) return; // no overrides file yet — normal
|
|
177
178
|
data = await res.json();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
1
2
|
import { useState } from "react";
|
|
2
3
|
import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes";
|
|
3
4
|
|
|
@@ -28,7 +29,7 @@ function MediaErrorPanel({ name, filePath }: { name: string; filePath: string })
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
|
|
31
|
-
const serveUrl = `/
|
|
32
|
+
const serveUrl = buildProjectApiPath(projectId, `/preview/${filePath}`);
|
|
32
33
|
const name = filePath.split("/").pop() ?? filePath;
|
|
33
34
|
// Keyed by path so switching to another file clears a previous failure.
|
|
34
35
|
const [failedPath, setFailedPath] = useState<string | null>(null);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
7
7
|
import { AnimationCard } from "./AnimationCard";
|
|
8
8
|
import { EASE_PRESETS } from "./easePresetLibrary";
|
|
9
9
|
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
@@ -118,8 +118,8 @@ function renderCard({
|
|
|
118
118
|
animation?: GsapAnimation;
|
|
119
119
|
defaultExpanded?: boolean;
|
|
120
120
|
flat?: boolean;
|
|
121
|
-
onUpdateMeta?:
|
|
122
|
-
onUpdateKeyframeEase?:
|
|
121
|
+
onUpdateMeta?: Mock;
|
|
122
|
+
onUpdateKeyframeEase?: Mock;
|
|
123
123
|
onDeleteAnimation?: (id: string) => void;
|
|
124
124
|
} = {}) {
|
|
125
125
|
const host = document.createElement("div");
|
|
@@ -1001,20 +1001,24 @@ describe("PropertyPanel — Motion is for things that move", () => {
|
|
|
1001
1001
|
return element;
|
|
1002
1002
|
},
|
|
1003
1003
|
],
|
|
1004
|
-
])(
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
host
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1004
|
+
])(
|
|
1005
|
+
"recognizes %s through the shared audio predicate",
|
|
1006
|
+
async (_label, makeElement) => {
|
|
1007
|
+
const fixture = {
|
|
1008
|
+
...audioClipElement(),
|
|
1009
|
+
element: makeElement(),
|
|
1010
|
+
tagName: "div",
|
|
1011
|
+
};
|
|
1012
|
+
const { host, root } = await renderPanel(true, fixture);
|
|
1013
|
+
const titles = Array.from(
|
|
1014
|
+
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
|
|
1015
|
+
).map((node) => node.textContent ?? "");
|
|
1016
|
+
expect(titles.some((title) => title.includes("Motion"))).toBe(false);
|
|
1017
|
+
expect(titles.some((title) => title.includes("Timing"))).toBe(true);
|
|
1018
|
+
act(() => root.unmount());
|
|
1019
|
+
},
|
|
1020
|
+
RENDER_TIMEOUT_MS,
|
|
1021
|
+
);
|
|
1018
1022
|
|
|
1019
1023
|
it(
|
|
1020
1024
|
"calls the section Timing on an audio clip, and offers no tween editor",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useRef, type RefObject } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { resolveGuideLineRect, type SnapGuide, type SpacingGuide } from "./snapEngine";
|
|
4
|
+
import { subscribeOverlayFrame } from "./overlayFrameLoop";
|
|
4
5
|
|
|
5
6
|
export interface SnapGuidesState {
|
|
6
7
|
guides: SnapGuide[];
|
|
@@ -47,12 +48,8 @@ export const SnapGuideOverlay = memo(function SnapGuideOverlay({
|
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
useMountEffect(() => {
|
|
50
|
-
let frame = 0;
|
|
51
|
-
|
|
52
51
|
// fallow-ignore-next-line complexity
|
|
53
52
|
const update = () => {
|
|
54
|
-
frame = requestAnimationFrame(update);
|
|
55
|
-
|
|
56
53
|
const state = snapGuidesRef.current;
|
|
57
54
|
const guides = state?.guides ?? [];
|
|
58
55
|
const spacingGuides = state?.spacingGuides ?? [];
|
|
@@ -116,8 +113,7 @@ export const SnapGuideOverlay = memo(function SnapGuideOverlay({
|
|
|
116
113
|
}
|
|
117
114
|
};
|
|
118
115
|
|
|
119
|
-
|
|
120
|
-
return () => cancelAnimationFrame(frame);
|
|
116
|
+
return subscribeOverlayFrame(update);
|
|
121
117
|
});
|
|
122
118
|
|
|
123
119
|
return (
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
DOM_EDIT_LAYER_OBSERVER_INIT,
|
|
5
|
+
type DomEditLayerWalkCache,
|
|
6
|
+
createDomEditLayerWalkCache,
|
|
7
|
+
drainPendingLayerMutations,
|
|
8
|
+
} from "./domEditLayerWalkCache";
|
|
9
|
+
import { collectDomEditLayerItems } from "./domEditingLayers";
|
|
10
|
+
import type { DomEditLayerItem } from "./domEditingTypes";
|
|
11
|
+
|
|
12
|
+
const opts = { activeCompositionPath: "index.html", isMasterView: true };
|
|
13
|
+
|
|
14
|
+
/** Attached, not detached: the occurrence index is resolved with a whole-document
|
|
15
|
+
* query, so a detached fixture would number every element as a miss. */
|
|
16
|
+
function mountPreview(cardCount: number): HTMLElement {
|
|
17
|
+
const cards = Array.from(
|
|
18
|
+
{ length: cardCount },
|
|
19
|
+
(_unused, i) =>
|
|
20
|
+
`<div class="box"><span class="label">card ${i}</span><b class="tag">t${i}</b></div>`,
|
|
21
|
+
).join("");
|
|
22
|
+
document.body.innerHTML = `<div data-composition-id="index.html">${cards}</div>`;
|
|
23
|
+
return document.body.firstElementChild as HTMLElement;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Everything a caller can observe about a walk. Compared field by field so a
|
|
31
|
+
* divergence names itself instead of failing on object identity. */
|
|
32
|
+
function shape(items: DomEditLayerItem[]): unknown {
|
|
33
|
+
return items.map((item) => ({
|
|
34
|
+
key: item.key,
|
|
35
|
+
label: item.label,
|
|
36
|
+
tagName: item.tagName,
|
|
37
|
+
depth: item.depth,
|
|
38
|
+
childCount: item.childCount,
|
|
39
|
+
id: item.id,
|
|
40
|
+
hfId: item.hfId,
|
|
41
|
+
selector: item.selector,
|
|
42
|
+
selectorIndex: item.selectorIndex,
|
|
43
|
+
sourceFile: item.sourceFile,
|
|
44
|
+
element: item.element,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Count `getComputedStyle` calls, which is the per-element cost this cache
|
|
50
|
+
* exists to avoid: `isInspectableLayerElement` makes one for the element and
|
|
51
|
+
* `getDirectLayerChildren` one more for each direct child. A count that does not
|
|
52
|
+
* grow with the document is the whole claim.
|
|
53
|
+
*/
|
|
54
|
+
function countingComputedStyle(): { calls: () => number; restore: () => void } {
|
|
55
|
+
const real = window.getComputedStyle.bind(window);
|
|
56
|
+
let calls = 0;
|
|
57
|
+
window.getComputedStyle = ((el: Element, pseudo?: string | null) => {
|
|
58
|
+
calls += 1;
|
|
59
|
+
return real(el, pseudo ?? undefined);
|
|
60
|
+
}) as typeof window.getComputedStyle;
|
|
61
|
+
return { calls: () => calls, restore: () => (window.getComputedStyle = real) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface WarmWalk {
|
|
65
|
+
root: HTMLElement;
|
|
66
|
+
cache: DomEditLayerWalkCache;
|
|
67
|
+
observer: MutationObserver;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A mounted preview whose cache has already done one full walk, with the
|
|
72
|
+
* observer live. Every test here needs exactly this before it can measure what
|
|
73
|
+
* a SECOND walk costs, so it lives in one place.
|
|
74
|
+
*/
|
|
75
|
+
function withWarmWalk<T>(cardCount: number, run: (ctx: WarmWalk) => T): T {
|
|
76
|
+
const root = mountPreview(cardCount);
|
|
77
|
+
const cache = createDomEditLayerWalkCache();
|
|
78
|
+
const observer = new MutationObserver(() => {});
|
|
79
|
+
observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
|
|
80
|
+
try {
|
|
81
|
+
collectDomEditLayerItems(root, opts, undefined, cache);
|
|
82
|
+
return run({ root, cache, observer });
|
|
83
|
+
} finally {
|
|
84
|
+
observer.disconnect();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("collectDomEditLayerItems incremental rebuild", () => {
|
|
89
|
+
/**
|
|
90
|
+
* The load-bearing assertion, and it is INVARIANCE, not a threshold: a
|
|
91
|
+
* threshold ("under 20 style reads") passes on a small fixture and still
|
|
92
|
+
* degrades linearly on a real preview. Quadrupling the card count must not
|
|
93
|
+
* change what one style edit costs at all.
|
|
94
|
+
*
|
|
95
|
+
* Before the cache both numbers tracked the document size (every element and
|
|
96
|
+
* every direct child re-derived on every mutation, whatever the mutation was).
|
|
97
|
+
*/
|
|
98
|
+
function styleEditRebuildCost(cardCount: number): number {
|
|
99
|
+
return withWarmWalk(cardCount, ({ root, cache, observer }) => {
|
|
100
|
+
// What animation writes on a frame: one element's transform, nothing that
|
|
101
|
+
// can change any element's selector, membership or label.
|
|
102
|
+
root.querySelector<HTMLElement>(".box")!.style.transform = "translateX(4px)";
|
|
103
|
+
cache.ingest(observer.takeRecords());
|
|
104
|
+
|
|
105
|
+
const probe = countingComputedStyle();
|
|
106
|
+
try {
|
|
107
|
+
collectDomEditLayerItems(root, opts, undefined, cache);
|
|
108
|
+
return probe.calls();
|
|
109
|
+
} finally {
|
|
110
|
+
probe.restore();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it("costs the same for one style edit at n cards and at 4n", () => {
|
|
116
|
+
const small = styleEditRebuildCost(12);
|
|
117
|
+
const large = styleEditRebuildCost(48);
|
|
118
|
+
|
|
119
|
+
expect(large).toBe(small);
|
|
120
|
+
// A ceiling as well, so a future change that reintroduces a per-element term
|
|
121
|
+
// fails here even if it happens to be document-size-flat. The edited element,
|
|
122
|
+
// its parent, and each of their direct children is the whole budget.
|
|
123
|
+
expect(small).toBeLessThanOrEqual(12);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("performs no document query at all for an edit that touches no selector", () => {
|
|
127
|
+
withWarmWalk(12, ({ root, cache, observer }) => {
|
|
128
|
+
root.querySelector<HTMLElement>(".tag")!.style.opacity = "0.5";
|
|
129
|
+
cache.ingest(observer.takeRecords());
|
|
130
|
+
|
|
131
|
+
const real = document.querySelectorAll.bind(document);
|
|
132
|
+
let queries = 0;
|
|
133
|
+
Object.defineProperty(document, "querySelectorAll", {
|
|
134
|
+
configurable: true,
|
|
135
|
+
value: (selector: string) => {
|
|
136
|
+
queries += 1;
|
|
137
|
+
return real(selector);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
collectDomEditLayerItems(root, opts, undefined, cache);
|
|
142
|
+
} finally {
|
|
143
|
+
delete (document as Partial<Document>).querySelectorAll;
|
|
144
|
+
}
|
|
145
|
+
// The only document query in the walk resolves a selector's occurrence
|
|
146
|
+
// index, and no cached selector was invalidated.
|
|
147
|
+
expect(queries).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The equivalence half, and the one that would catch a wrong optimisation
|
|
153
|
+
* rather than a missing one. Every mutation KIND the invalidation reasons
|
|
154
|
+
* about is applied in turn, and after each the incremental walk must equal a
|
|
155
|
+
* walk with no cache at all — which is what every other caller still gets.
|
|
156
|
+
*/
|
|
157
|
+
it("matches an uncached walk after every kind of mutation it reasons about", () => {
|
|
158
|
+
const root = mountPreview(6);
|
|
159
|
+
const cache = createDomEditLayerWalkCache();
|
|
160
|
+
const observer = new MutationObserver(() => {});
|
|
161
|
+
observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
|
|
162
|
+
// Captured up front: later cases change class and id, so re-querying `.box`
|
|
163
|
+
// would hand the next case a different element than it names.
|
|
164
|
+
const cards = Array.from(root.querySelectorAll<HTMLElement>(".box"));
|
|
165
|
+
const box = (i: number) => cards[i]!;
|
|
166
|
+
|
|
167
|
+
const mutations: Array<[string, () => void]> = [
|
|
168
|
+
["a style write that cannot change membership", () => (box(0).style.opacity = "0.5")],
|
|
169
|
+
// Inherited: the subtree's computed visibility moves with it.
|
|
170
|
+
["a style write that hides a subtree", () => (box(1).style.visibility = "hidden")],
|
|
171
|
+
["clearing that same subtree hide", () => (box(1).style.visibility = "")],
|
|
172
|
+
// display:none removes the element from the list and shifts its
|
|
173
|
+
// descendants' depth by one, without touching their own computed display.
|
|
174
|
+
["display:none on a card", () => (box(2).style.display = "none")],
|
|
175
|
+
// Renumbers `.box` for every card after it, and relabels this one.
|
|
176
|
+
["a class change that renumbers a shared selector", () => (box(3).className = "crate")],
|
|
177
|
+
["an id, which outranks the class selector", () => (box(4).id = "hero")],
|
|
178
|
+
["edited text, which is a label input", () => (box(5).firstChild!.textContent = "renamed")],
|
|
179
|
+
// The unattributable case: the documented full-rebuild fallback.
|
|
180
|
+
["a node inserted mid-document", () => box(0).append(document.createElement("i"))],
|
|
181
|
+
["a node removed", () => box(0).querySelector("b")!.remove()],
|
|
182
|
+
["a data attribute nothing derives from", () => box(0).setAttribute("data-x", "1")],
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
collectDomEditLayerItems(root, opts, undefined, cache);
|
|
187
|
+
for (const [what, mutate] of mutations) {
|
|
188
|
+
mutate();
|
|
189
|
+
cache.ingest(observer.takeRecords());
|
|
190
|
+
expect(
|
|
191
|
+
shape(collectDomEditLayerItems(root, opts, undefined, cache)),
|
|
192
|
+
`incremental walk diverged after ${what}`,
|
|
193
|
+
).toEqual(shape(collectDomEditLayerItems(root, opts)));
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
observer.disconnect();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The subtree drop is the one expensive branch in the ingest path, and it runs
|
|
202
|
+
* in the raw observer callback — not behind the rebuild throttle. GSAP's
|
|
203
|
+
* `autoAlpha` writes `visibility: visible` into inline style ONCE on fade-in
|
|
204
|
+
* and leaves it there, so a rule that fires whenever the style attribute
|
|
205
|
+
* mentions visibility fires on every transform tick for the rest of the clip:
|
|
206
|
+
* a whole-subtree walk per animation frame, for the exact elements a
|
|
207
|
+
* composition animates most.
|
|
208
|
+
*/
|
|
209
|
+
it("does not re-walk a subtree when a faded-in element merely animates", () => {
|
|
210
|
+
const root = mountPreview(6);
|
|
211
|
+
const cache = createDomEditLayerWalkCache();
|
|
212
|
+
const observer = new MutationObserver(() => {});
|
|
213
|
+
observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
|
|
214
|
+
const card = root.querySelector<HTMLElement>(".box")!;
|
|
215
|
+
try {
|
|
216
|
+
collectDomEditLayerItems(root, opts, undefined, cache);
|
|
217
|
+
// The fade-in itself. This one legitimately invalidates the subtree.
|
|
218
|
+
card.style.visibility = "visible";
|
|
219
|
+
cache.ingest(observer.takeRecords());
|
|
220
|
+
|
|
221
|
+
let subtreeWalks = 0;
|
|
222
|
+
const real = Element.prototype.querySelectorAll;
|
|
223
|
+
Element.prototype.querySelectorAll = function (this: Element, selector: string) {
|
|
224
|
+
if (selector === "*") subtreeWalks += 1;
|
|
225
|
+
return real.call(this, selector);
|
|
226
|
+
} as typeof Element.prototype.querySelectorAll;
|
|
227
|
+
try {
|
|
228
|
+
// Ordinary animation frames. `visibility: visible` is still sitting in
|
|
229
|
+
// the attribute, but the value has not moved.
|
|
230
|
+
card.style.transform = "translateX(1px)";
|
|
231
|
+
cache.ingest(observer.takeRecords());
|
|
232
|
+
card.style.transform = "translateX(2px)";
|
|
233
|
+
cache.ingest(observer.takeRecords());
|
|
234
|
+
} finally {
|
|
235
|
+
Element.prototype.querySelectorAll = real;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
expect(subtreeWalks).toBe(0);
|
|
239
|
+
} finally {
|
|
240
|
+
observer.disconnect();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("drops everything when the walk is re-scoped to a different composition", () => {
|
|
245
|
+
const root = mountPreview(3);
|
|
246
|
+
const cache = createDomEditLayerWalkCache();
|
|
247
|
+
collectDomEditLayerItems(root, opts, undefined, cache);
|
|
248
|
+
|
|
249
|
+
const rescoped = { ...opts, activeCompositionPath: "scenes/two.html" };
|
|
250
|
+
expect(shape(collectDomEditLayerItems(root, rescoped, undefined, cache))).toEqual(
|
|
251
|
+
shape(collectDomEditLayerItems(root, rescoped)),
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("drainPendingLayerMutations", () => {
|
|
257
|
+
it("applies records the observer has not delivered yet", () => {
|
|
258
|
+
withWarmWalk(6, ({ root, cache, observer }) => {
|
|
259
|
+
const card = root.querySelector<HTMLElement>(".box")!;
|
|
260
|
+
// Not delivered: the callback runs in a microtask, and this test never
|
|
261
|
+
// yields to one.
|
|
262
|
+
card.id = "renamed";
|
|
263
|
+
|
|
264
|
+
drainPendingLayerMutations(observer, cache);
|
|
265
|
+
|
|
266
|
+
// An identity attribute drops the whole cache, so the walk after the drain
|
|
267
|
+
// must agree with an uncached walk.
|
|
268
|
+
expect(shape(collectDomEditLayerItems(root, opts, undefined, cache))).toEqual(
|
|
269
|
+
shape(collectDomEditLayerItems(root, opts)),
|
|
270
|
+
);
|
|
271
|
+
// And the records really were consumed by the drain, not still queued.
|
|
272
|
+
expect(observer.takeRecords()).toHaveLength(0);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("is a no-op when no observer is attached yet", () => {
|
|
277
|
+
withWarmWalk(6, ({ root, cache }) => {
|
|
278
|
+
expect(() => drainPendingLayerMutations(null, cache)).not.toThrow();
|
|
279
|
+
expect(collectDomEditLayerItems(root, opts, undefined, cache)).toHaveLength(
|
|
280
|
+
collectDomEditLayerItems(root, opts).length,
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|