@hyperframes/studio 0.8.139 → 0.8.140
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-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
- package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.html +1 -1
- package/dist/index.js +6498 -5813
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +2 -2
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/nle/NLEPreview.test.ts +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { StyleSections } from "./propertyPanelStyleSections";
|
|
6
|
+
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function mount(
|
|
15
|
+
Panel: typeof StyleSections | typeof FlatStyleSection,
|
|
16
|
+
tag = "img",
|
|
17
|
+
sourceFile = "index.html",
|
|
18
|
+
src = "assets/old.png",
|
|
19
|
+
) {
|
|
20
|
+
const selected = document.createElement(tag);
|
|
21
|
+
selected.id = "picture";
|
|
22
|
+
selected.setAttribute("src", src);
|
|
23
|
+
document.body.append(selected);
|
|
24
|
+
const element: DomEditSelection = {
|
|
25
|
+
element: selected,
|
|
26
|
+
id: "picture",
|
|
27
|
+
selector: "#picture",
|
|
28
|
+
label: "Picture",
|
|
29
|
+
tagName: tag,
|
|
30
|
+
sourceFile,
|
|
31
|
+
compositionPath: sourceFile,
|
|
32
|
+
isCompositionHost: false,
|
|
33
|
+
isInsideLockedComposition: false,
|
|
34
|
+
boundingBox: { x: 0, y: 0, width: 320, height: 180 },
|
|
35
|
+
textContent: "",
|
|
36
|
+
dataAttributes: {},
|
|
37
|
+
inlineStyles: {},
|
|
38
|
+
computedStyles: {},
|
|
39
|
+
textFields: [],
|
|
40
|
+
capabilities: {
|
|
41
|
+
canSelect: true,
|
|
42
|
+
canEditStyles: true,
|
|
43
|
+
canCrop: true,
|
|
44
|
+
canMove: true,
|
|
45
|
+
canResize: true,
|
|
46
|
+
canApplyManualOffset: true,
|
|
47
|
+
canApplyManualSize: true,
|
|
48
|
+
canApplyManualRotation: true,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const host = document.createElement("div");
|
|
52
|
+
document.body.append(host);
|
|
53
|
+
const root = createRoot(host);
|
|
54
|
+
const onSetStyle = vi.fn((prop: string, value: string) =>
|
|
55
|
+
selected.style.setProperty(prop, value),
|
|
56
|
+
);
|
|
57
|
+
const onSetHtmlAttribute = vi.fn((attr: string, value: string | null) => {
|
|
58
|
+
if (value !== null) selected.setAttribute(attr, value);
|
|
59
|
+
});
|
|
60
|
+
act(() =>
|
|
61
|
+
root.render(
|
|
62
|
+
<Panel
|
|
63
|
+
projectId="fixture"
|
|
64
|
+
element={element}
|
|
65
|
+
styles={{}}
|
|
66
|
+
assets={["assets/old.png", "assets/new.png"]}
|
|
67
|
+
onSetStyle={onSetStyle}
|
|
68
|
+
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
69
|
+
/>,
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
const image = Array.from(host.querySelectorAll("button")).find(
|
|
73
|
+
(button) => button.textContent === "Image",
|
|
74
|
+
);
|
|
75
|
+
if (!image) throw new Error("Image control must exist");
|
|
76
|
+
act(() => image.click());
|
|
77
|
+
// The Image asset select is the one whose options include the fixture asset.
|
|
78
|
+
const assetSelect = Array.from(host.querySelectorAll("select")).find((node) =>
|
|
79
|
+
Array.from(node.options).some((option) => option.value === "assets/new.png"),
|
|
80
|
+
);
|
|
81
|
+
if (!assetSelect) throw new Error("Project asset control must exist");
|
|
82
|
+
function choose(value: string) {
|
|
83
|
+
const control = assetSelect;
|
|
84
|
+
if (!control) throw new Error("Asset control must exist");
|
|
85
|
+
act(() => {
|
|
86
|
+
control.value = value;
|
|
87
|
+
control.dispatchEvent(new Event("change", { bubbles: true }));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return { host, selected, root, choose, assetSelect, onSetStyle, onSetHtmlAttribute };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe.each([
|
|
94
|
+
["classic", StyleSections],
|
|
95
|
+
["flat", FlatStyleSection],
|
|
96
|
+
] as const)("%s Image fill", (_name, Panel) => {
|
|
97
|
+
it("shows the img source as the current project asset", () => {
|
|
98
|
+
const view = mount(Panel);
|
|
99
|
+
expect(view.assetSelect.value).toBe("assets/old.png");
|
|
100
|
+
act(() => view.root.unmount());
|
|
101
|
+
});
|
|
102
|
+
it.each([
|
|
103
|
+
"assets/100%.png",
|
|
104
|
+
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100%"></svg>',
|
|
105
|
+
])("shows an authored source containing a literal percent: %s", (src) => {
|
|
106
|
+
const view = mount(Panel, "img", "index.html", src);
|
|
107
|
+
const input = Array.from(view.host.querySelectorAll("label"))
|
|
108
|
+
.find((node) => node.querySelector("span")?.textContent === "External URL")
|
|
109
|
+
?.querySelector("input");
|
|
110
|
+
expect(input?.value).toBe(src);
|
|
111
|
+
act(() => view.root.unmount());
|
|
112
|
+
});
|
|
113
|
+
it("replaces the img source rather than writing a hidden background", () => {
|
|
114
|
+
const view = mount(Panel);
|
|
115
|
+
view.choose("assets/new.png");
|
|
116
|
+
expect(view.selected.getAttribute("src")).toBe("assets/new.png");
|
|
117
|
+
expect(view.onSetHtmlAttribute).toHaveBeenCalledExactlyOnceWith("src", "assets/new.png");
|
|
118
|
+
expect(view.onSetStyle).not.toHaveBeenCalled();
|
|
119
|
+
act(() => view.root.unmount());
|
|
120
|
+
});
|
|
121
|
+
it("replaces an img with an external URL without CSS wrapping", () => {
|
|
122
|
+
const view = mount(Panel);
|
|
123
|
+
const row = Array.from(view.host.querySelectorAll("label")).find(
|
|
124
|
+
(node) =>
|
|
125
|
+
node.querySelector("span")?.textContent === "External URL" && node.querySelector("input"),
|
|
126
|
+
);
|
|
127
|
+
const input = row?.querySelector("input");
|
|
128
|
+
if (!input) throw new Error("External URL input must exist");
|
|
129
|
+
act(() => {
|
|
130
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(
|
|
131
|
+
input,
|
|
132
|
+
"https://example.com/new.png",
|
|
133
|
+
);
|
|
134
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
135
|
+
});
|
|
136
|
+
act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
|
|
137
|
+
expect(view.selected.getAttribute("src")).toBe("https://example.com/new.png");
|
|
138
|
+
expect(view.onSetStyle).not.toHaveBeenCalled();
|
|
139
|
+
act(() => view.root.unmount());
|
|
140
|
+
});
|
|
141
|
+
it("keeps the img source when cleared", () => {
|
|
142
|
+
const view = mount(Panel);
|
|
143
|
+
view.choose("");
|
|
144
|
+
expect(view.selected.getAttribute("src")).toBe("assets/old.png");
|
|
145
|
+
expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
|
|
146
|
+
expect(view.onSetStyle).not.toHaveBeenCalled();
|
|
147
|
+
act(() => view.root.unmount());
|
|
148
|
+
});
|
|
149
|
+
it("uses source-relative image paths in nested compositions", () => {
|
|
150
|
+
const view = mount(Panel, "img", "compositions/scene.html");
|
|
151
|
+
view.choose("assets/new.png");
|
|
152
|
+
expect(view.selected.getAttribute("src")).toBe("../assets/new.png");
|
|
153
|
+
act(() => view.root.unmount());
|
|
154
|
+
});
|
|
155
|
+
it("keeps background-image replacement and clearing for ordinary elements", () => {
|
|
156
|
+
const view = mount(Panel, "div");
|
|
157
|
+
view.choose("assets/new.png");
|
|
158
|
+
expect(view.selected.style.backgroundImage).toBe('url("assets/new.png")');
|
|
159
|
+
view.choose("");
|
|
160
|
+
expect(view.selected.style.backgroundImage).toBe("none");
|
|
161
|
+
expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
|
|
162
|
+
act(() => view.root.unmount());
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -45,6 +45,7 @@ export function StyleSections({
|
|
|
45
45
|
styles,
|
|
46
46
|
assets,
|
|
47
47
|
onSetStyle,
|
|
48
|
+
onSetHtmlAttribute,
|
|
48
49
|
onImportAssets,
|
|
49
50
|
gsapBorderRadius,
|
|
50
51
|
hideFlex = false,
|
|
@@ -54,6 +55,7 @@ export function StyleSections({
|
|
|
54
55
|
styles: Record<string, string>;
|
|
55
56
|
assets: string[];
|
|
56
57
|
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
58
|
+
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
57
59
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
58
60
|
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
|
59
61
|
// When true, the Flex `Section` is suppressed. The flat inspector renders
|
|
@@ -108,12 +110,12 @@ export function StyleSections({
|
|
|
108
110
|
const backgroundImage = styles["background-image"] ?? "none";
|
|
109
111
|
const hasTextControls = isTextEditableSelection(element);
|
|
110
112
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
let fillMode = "Solid";
|
|
114
|
+
if (element.tagName === "img") {
|
|
115
|
+
fillMode = "Image";
|
|
116
|
+
} else if (backgroundImage && backgroundImage !== "none") {
|
|
117
|
+
fillMode = backgroundImage.includes("gradient") ? "Gradient" : "Image";
|
|
118
|
+
}
|
|
117
119
|
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
|
|
118
120
|
const imageUrl = extractBackgroundImageUrl(backgroundImage);
|
|
119
121
|
// Remember the last authored gradient so an exploratory Solid↔Gradient
|
|
@@ -488,7 +490,8 @@ export function StyleSections({
|
|
|
488
490
|
) : (
|
|
489
491
|
<ImageFillField
|
|
490
492
|
projectId={projectId}
|
|
491
|
-
|
|
493
|
+
element={element}
|
|
494
|
+
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
492
495
|
value={imageUrl}
|
|
493
496
|
assets={assets}
|
|
494
497
|
disabled={styleEditingDisabled}
|
|
@@ -12,6 +12,12 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
12
12
|
const playerMounts: string[] = [];
|
|
13
13
|
let livePlayerProps: { onReadyToShowChange?: (ready: boolean) => void } = {};
|
|
14
14
|
|
|
15
|
+
const measured = vi.hoisted(() => ({ size: null as { width: number; height: number } | null }));
|
|
16
|
+
vi.mock("../../utils/previewCompositionSize", async (original) => ({
|
|
17
|
+
...(await original<typeof import("../../utils/previewCompositionSize")>()),
|
|
18
|
+
readPreviewCompositionSize: () => measured.size,
|
|
19
|
+
}));
|
|
20
|
+
|
|
15
21
|
vi.mock("../../player", async () => {
|
|
16
22
|
const React = await import("react");
|
|
17
23
|
|
|
@@ -81,7 +87,12 @@ function renderPreview(
|
|
|
81
87
|
{
|
|
82
88
|
box = { width: 800, height: 600 },
|
|
83
89
|
fillBox,
|
|
84
|
-
|
|
90
|
+
compositionSizeHint,
|
|
91
|
+
}: {
|
|
92
|
+
box?: { width: number; height: number };
|
|
93
|
+
fillBox?: boolean;
|
|
94
|
+
compositionSizeHint?: { width: number; height: number };
|
|
95
|
+
} = {},
|
|
85
96
|
) {
|
|
86
97
|
resizeCallbacks = [];
|
|
87
98
|
const host = document.createElement("div");
|
|
@@ -103,6 +114,7 @@ function renderPreview(
|
|
|
103
114
|
setShadowIframeNode: () => {},
|
|
104
115
|
resetPreviewSlots: () => {},
|
|
105
116
|
fillBox,
|
|
117
|
+
compositionSizeHint,
|
|
106
118
|
}),
|
|
107
119
|
);
|
|
108
120
|
});
|
|
@@ -378,6 +390,37 @@ describe("NLEPreview", () => {
|
|
|
378
390
|
filled.cleanup();
|
|
379
391
|
});
|
|
380
392
|
|
|
393
|
+
it("takes the host's composition size as the stage's shape until the preview measures its own", () => {
|
|
394
|
+
const box = { width: 800, height: 600 };
|
|
395
|
+
const plain = renderPreview(undefined, { box });
|
|
396
|
+
expect([plain.stage.style.width, plain.stage.style.height]).toEqual(["784px", "441px"]);
|
|
397
|
+
plain.cleanup();
|
|
398
|
+
|
|
399
|
+
const hinted = renderPreview(undefined, {
|
|
400
|
+
box,
|
|
401
|
+
compositionSizeHint: { width: 1276, height: 1078 },
|
|
402
|
+
});
|
|
403
|
+
const shape = parseFloat(hinted.stage.style.width) / parseFloat(hinted.stage.style.height);
|
|
404
|
+
expect(shape).toBeCloseTo(1276 / 1078, 3);
|
|
405
|
+
expect(hinted.stage.style.height).toBe("584px");
|
|
406
|
+
hinted.cleanup();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("lets the size the preview measures win over the host's hint", () => {
|
|
410
|
+
measured.size = { width: 1920, height: 1080 };
|
|
411
|
+
try {
|
|
412
|
+
const box = { width: 800, height: 600 };
|
|
413
|
+
const hinted = renderPreview(undefined, {
|
|
414
|
+
box,
|
|
415
|
+
compositionSizeHint: { width: 1276, height: 1078 },
|
|
416
|
+
});
|
|
417
|
+
expect([hinted.stage.style.width, hinted.stage.style.height]).toEqual(["784px", "441px"]);
|
|
418
|
+
hinted.cleanup();
|
|
419
|
+
} finally {
|
|
420
|
+
measured.size = null;
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
381
424
|
it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
|
|
382
425
|
const view = renderPreview([
|
|
383
426
|
{ gen: 0, role: "live" },
|
|
@@ -50,6 +50,7 @@ interface NLEPreviewProps extends Pick<
|
|
|
50
50
|
onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
|
|
51
51
|
/** Draws the picture edge to edge in this box, without Studio's inset band. */
|
|
52
52
|
fillBox?: boolean;
|
|
53
|
+
compositionSizeHint?: PreviewCompositionSize;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export function getPreviewPlayerKey({
|
|
@@ -127,6 +128,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
127
128
|
onStageRef,
|
|
128
129
|
onCompositionSizeChange,
|
|
129
130
|
fillBox,
|
|
131
|
+
compositionSizeHint,
|
|
130
132
|
}: NLEPreviewProps) {
|
|
131
133
|
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
|
132
134
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
@@ -151,6 +153,8 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
151
153
|
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
|
|
152
154
|
const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
|
|
153
155
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
156
|
+
const hintWidth = compositionSizeHint?.width;
|
|
157
|
+
const hintHeight = compositionSizeHint?.height;
|
|
154
158
|
|
|
155
159
|
const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
|
|
156
160
|
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
|
|
@@ -183,7 +187,8 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
183
187
|
resolvePreviewStageSize(
|
|
184
188
|
rect.width,
|
|
185
189
|
rect.height,
|
|
186
|
-
compositionSize
|
|
190
|
+
compositionSize ??
|
|
191
|
+
(hintWidth && hintHeight ? { width: hintWidth, height: hintHeight } : null),
|
|
187
192
|
portrait,
|
|
188
193
|
gutterPx,
|
|
189
194
|
insetPx,
|
|
@@ -195,7 +200,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
195
200
|
const observer = new ResizeObserver(updateStageSize);
|
|
196
201
|
observer.observe(viewport);
|
|
197
202
|
return () => observer.disconnect();
|
|
198
|
-
}, [compositionSize, portrait, gutterPx, insetPx]);
|
|
203
|
+
}, [compositionSize, hintWidth, hintHeight, portrait, gutterPx, insetPx]);
|
|
199
204
|
|
|
200
205
|
const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
|
|
201
206
|
onCompositionSizeChangeRef.current = onCompositionSizeChange;
|
|
@@ -21,9 +21,30 @@ describe("external file-change subscription ownership", () => {
|
|
|
21
21
|
new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
|
|
22
22
|
"utf8",
|
|
23
23
|
);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
expect(coordinator.match(/sseFileChangeChannel\(handler, catchUp\)/g)).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("asks to catch up on every reconnect, since changes made while disconnected are never resent", () => {
|
|
28
|
+
const listeners = new Map<string, (event: unknown) => void>();
|
|
29
|
+
class FakeEventSource {
|
|
30
|
+
addEventListener(type: string, listener: (event: unknown) => void) {
|
|
31
|
+
listeners.set(type, listener);
|
|
32
|
+
}
|
|
33
|
+
close = vi.fn();
|
|
34
|
+
}
|
|
35
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
36
|
+
try {
|
|
37
|
+
const onReconnect = vi.fn();
|
|
38
|
+
sseFileChangeChannel(vi.fn(), onReconnect);
|
|
39
|
+
listeners.get("open")?.(new Event("open"));
|
|
40
|
+
expect(onReconnect).not.toHaveBeenCalled();
|
|
41
|
+
listeners.get("error")?.(new Event("error"));
|
|
42
|
+
listeners.get("open")?.(new Event("open"));
|
|
43
|
+
listeners.get("open")?.(new Event("open"));
|
|
44
|
+
expect(onReconnect).toHaveBeenCalledTimes(2);
|
|
45
|
+
} finally {
|
|
46
|
+
vi.unstubAllGlobals();
|
|
47
|
+
}
|
|
27
48
|
});
|
|
28
49
|
|
|
29
50
|
it("delivers the raw file-change event from EventSource to the handler and closes on cleanup", () => {
|
|
@@ -42,7 +63,7 @@ describe("external file-change subscription ownership", () => {
|
|
|
42
63
|
vi.stubGlobal("EventSource", FakeEventSource);
|
|
43
64
|
try {
|
|
44
65
|
const onDelivery = vi.fn();
|
|
45
|
-
const stop = sseFileChangeChannel(onDelivery);
|
|
66
|
+
const stop = sseFileChangeChannel(onDelivery, vi.fn());
|
|
46
67
|
const event = new MessageEvent("file-change", { data: JSON.stringify({ path: "a.html" }) });
|
|
47
68
|
listeners.get("file-change")?.(event);
|
|
48
69
|
expect(opened).toEqual(["/api/events"]);
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
// Real Chrome with the built runtime: its seek, not a plain GSAP seek, decides what a saved edit shows.
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { createRequire } from "node:module";
|
|
5
3
|
import { gsap } from "gsap";
|
|
6
4
|
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
|
|
7
5
|
import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
8
|
-
import puppeteer from "puppeteer-core";
|
|
9
6
|
import { expect, it, vi } from "vitest";
|
|
10
|
-
import {
|
|
7
|
+
import { launchTestChrome, showWithRuntime } from "../../tests/chromeTestUtils";
|
|
11
8
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
12
9
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
13
10
|
import { planValueEdit } from "./gsapValueAtPlayhead";
|
|
14
11
|
|
|
15
|
-
const require = createRequire(import.meta.url);
|
|
16
12
|
vi.setConfig({ testTimeout: 60_000 });
|
|
17
13
|
|
|
18
14
|
const SRC = `var tl = gsap.timeline({ paused: true });
|
|
@@ -49,25 +45,10 @@ const page = (script: string) => `<!doctype html><html><body>
|
|
|
49
45
|
|
|
50
46
|
it("a drag on a fromTo's start shows the dropped x there once the runtime seeks", async () => {
|
|
51
47
|
const written = writeDrag(90, 2);
|
|
52
|
-
const
|
|
53
|
-
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
54
|
-
const browser = await puppeteer.launch({
|
|
55
|
-
executablePath,
|
|
56
|
-
headless: true,
|
|
57
|
-
args: ["--no-sandbox"],
|
|
58
|
-
});
|
|
48
|
+
const browser = await launchTestChrome();
|
|
59
49
|
try {
|
|
60
50
|
const tab = await browser.newPage();
|
|
61
|
-
await tab
|
|
62
|
-
// Only the GSAP script is served; anything else is refused, never fetched.
|
|
63
|
-
tab.on("request", (request) =>
|
|
64
|
-
request.url().endsWith("/gsap.min.js")
|
|
65
|
-
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
66
|
-
: request.abort("blockedbyclient"),
|
|
67
|
-
);
|
|
68
|
-
await tab.setContent(page(written), { waitUntil: "load" });
|
|
69
|
-
await tab.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
70
|
-
await tab.waitForFunction(() => "__player" in window);
|
|
51
|
+
await showWithRuntime(tab, page(written));
|
|
71
52
|
const shown = await tab.evaluate(() => {
|
|
72
53
|
const w = window as unknown as {
|
|
73
54
|
__player: { seek(t: number): void };
|