@hyperframes/studio 0.8.116 → 0.8.118
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-mGYvPVpw.js → hyperframes-player-CnpIuNRl.js} +1 -1
- package/dist/assets/{index-DYNaXPVf.js → index-B4SbAADe.js} +196 -194
- package/dist/assets/{index-Dr3hNpvS.js → index-BZ23-h1_.js} +1 -1
- package/dist/assets/{index-BWHt0-ir.js → index-FeRU6G3G.js} +1 -1
- package/dist/index.d.ts +29 -19
- package/dist/index.html +1 -1
- package/dist/index.js +2366 -2305
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/StudioRightPanels.tsx +3 -6
- package/src/components/TimelineToolbar.test.tsx +14 -0
- package/src/components/TimelineToolbar.tsx +4 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +149 -25
- package/src/components/editor/PropertyPanel.tsx +1 -12
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +2 -3
- package/src/components/editor/PropertyPanelEmptyState.tsx +3 -5
- package/src/components/editor/PropertyPanelFlat.tsx +9 -22
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +7 -4
- package/src/components/editor/PropertyPanelFlatHeader.tsx +5 -3
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/manualOffsetDrag.ts +3 -3
- package/src/components/editor/plainTranslate.ts +5 -3
- package/src/components/editor/propertyPanelHelpers.ts +2 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
- package/src/components/editor/rotationDraft.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
- package/src/components/editor/usePanelHiddenToggle.ts +44 -0
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
- package/src/hooks/gsapScriptCommitTypes.ts +1 -1
- package/src/hooks/plainRotation.ts +5 -1
- package/src/hooks/timelineTrackVisibility.ts +7 -17
- package/src/hooks/useDomEditPersist.test.tsx +335 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
- package/src/hooks/useDomGeometryCommit.ts +6 -9
- package/src/hooks/useFreezeFrame.test.ts +39 -3
- package/src/hooks/useFreezeFrame.ts +4 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +25 -0
- package/src/hooks/useGsapAwareEditing.ts +12 -8
- package/src/hooks/useGsapAwareGroupMove.ts +2 -2
- package/src/hooks/useGsapScriptCommits.test.tsx +69 -1
- package/src/hooks/useGsapScriptCommits.ts +9 -7
- package/src/hooks/usePersistentEditHistory.test.ts +25 -2
- package/src/hooks/usePersistentEditHistory.ts +3 -9
- package/src/player/components/Timeline.test.ts +48 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +14 -22
- package/src/player/components/TimelineTrackHeader.tsx +4 -2
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +17 -31
- package/src/player/components/TimelineTrackPlainHeader.tsx +9 -32
- package/src/player/components/hiddenToggle.tsx +20 -0
- package/src/utils/gsapSoftReload.test.ts +51 -2
- package/src/utils/gsapSoftReload.ts +11 -21
- package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
- package/src/utils/softReloadTargets.ts +4 -3
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/timelineInspector.ts +10 -5
|
@@ -1,39 +1,28 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
-
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
|
3
2
|
import { Music } from "../../icons/SystemIcons";
|
|
4
3
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
5
4
|
import { TrackClipCount } from "./TrackClipCount";
|
|
6
5
|
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
7
|
-
|
|
8
|
-
// Hide, plainly. The speaker variant was the mute presentation; with mute gone
|
|
9
|
-
// this is the visibility eye it always was, and audio rows do not render it.
|
|
10
|
-
function visibilityButtonLabel(hidden: boolean, suffix: string): string {
|
|
11
|
-
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function visibilityButtonIcon(hidden: boolean) {
|
|
15
|
-
const Icon = hidden ? EyeSlash : Eye;
|
|
16
|
-
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
|
17
|
-
}
|
|
6
|
+
import { HiddenToggleIcon, hiddenToggleVerb } from "./hiddenToggle";
|
|
18
7
|
|
|
19
8
|
export function VisibilityButton({
|
|
20
9
|
hidden,
|
|
21
10
|
trackNumber,
|
|
22
11
|
trackDisplayNumber,
|
|
23
|
-
|
|
12
|
+
asMute,
|
|
24
13
|
onToggle,
|
|
25
14
|
}: {
|
|
26
15
|
hidden: boolean;
|
|
27
16
|
trackNumber: number;
|
|
28
17
|
trackDisplayNumber: number | null;
|
|
29
|
-
|
|
18
|
+
/** `data-hidden` silences an audio-only track, so its toggle is a mute. */
|
|
19
|
+
asMute: boolean;
|
|
30
20
|
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
31
21
|
}) {
|
|
32
|
-
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
33
22
|
// Display number in the text, real key in the callback. The two must not be
|
|
34
23
|
// conflated in either direction.
|
|
35
24
|
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
36
|
-
const label =
|
|
25
|
+
const label = `${hiddenToggleVerb(asMute, hidden)} track${suffix}`;
|
|
37
26
|
return (
|
|
38
27
|
<button
|
|
39
28
|
type="button"
|
|
@@ -52,7 +41,7 @@ export function VisibilityButton({
|
|
|
52
41
|
void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
|
|
53
42
|
}}
|
|
54
43
|
>
|
|
55
|
-
{
|
|
44
|
+
<HiddenToggleIcon asMute={asMute} hidden={hidden} size={14} />
|
|
56
45
|
</button>
|
|
57
46
|
);
|
|
58
47
|
}
|
|
@@ -67,6 +56,7 @@ export function PlainTrackHeader({
|
|
|
67
56
|
showTrackLabel,
|
|
68
57
|
isTrackHidden,
|
|
69
58
|
isAudioTrack,
|
|
59
|
+
isAudioOnly,
|
|
70
60
|
onToggleTrackHidden,
|
|
71
61
|
trailing,
|
|
72
62
|
}: {
|
|
@@ -76,6 +66,7 @@ export function PlainTrackHeader({
|
|
|
76
66
|
clipCount: number;
|
|
77
67
|
isTrackHidden: boolean;
|
|
78
68
|
isAudioTrack: boolean;
|
|
69
|
+
isAudioOnly: boolean;
|
|
79
70
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
80
71
|
showTrackLabel: boolean;
|
|
81
72
|
/** Trailing controls that belong on the control line — the FX entry points,
|
|
@@ -111,25 +102,11 @@ export function PlainTrackHeader({
|
|
|
111
102
|
truncating name leaves, so the controls sit on the edge whatever the
|
|
112
103
|
name's length. */}
|
|
113
104
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
114
|
-
{/* Not on an audio track. The control is the old visibility eye, and
|
|
115
|
-
on audio it silences rather than hides — but a row that already says
|
|
116
|
-
what it is with a speaker does not also need the hide affordance
|
|
117
|
-
sitting in the eye's slot. `visible={false}` rather than omitting the
|
|
118
|
-
element, so the spacer keeps every row's control columns aligned.
|
|
119
|
-
|
|
120
|
-
EXCEPT when the audio track is ALREADY hidden. Withholding the
|
|
121
|
-
control unconditionally withheld the only way back: `data-hidden`
|
|
122
|
-
silences the clip in preview and drops it from the render, the
|
|
123
|
-
panel's "Muted" is the unrelated HTML `muted` attribute, and nothing
|
|
124
|
-
else writes it — so a track hidden before this rule (or by "Hide
|
|
125
|
-
all", or by hand) was silent with no control anywhere to restore it.
|
|
126
|
-
Offering the eye only in that state keeps the affordance off a normal
|
|
127
|
-
audio row while leaving the door open from the inside. */}
|
|
128
105
|
<VisibilityButton
|
|
129
106
|
hidden={isTrackHidden}
|
|
130
107
|
trackNumber={trackNumber}
|
|
131
108
|
trackDisplayNumber={trackDisplayNumber}
|
|
132
|
-
|
|
109
|
+
asMute={isAudioOnly}
|
|
133
110
|
onToggle={onToggleTrackHidden}
|
|
134
111
|
/>
|
|
135
112
|
{trailing}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
|
2
|
+
|
|
3
|
+
/** The action a hide toggle offers from the current `hidden` state; on audio `data-hidden` is a mute. */
|
|
4
|
+
export function hiddenToggleVerb(asMute: boolean, hidden: boolean): string {
|
|
5
|
+
if (asMute) return hidden ? "Unmute" : "Mute";
|
|
6
|
+
return hidden ? "Show" : "Hide";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function HiddenToggleIcon({
|
|
10
|
+
asMute,
|
|
11
|
+
hidden,
|
|
12
|
+
size,
|
|
13
|
+
}: {
|
|
14
|
+
asMute: boolean;
|
|
15
|
+
hidden: boolean;
|
|
16
|
+
size: number;
|
|
17
|
+
}) {
|
|
18
|
+
const Icon = asMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
|
|
19
|
+
return <Icon size={size} weight="bold" aria-hidden="true" />;
|
|
20
|
+
}
|
|
@@ -249,7 +249,7 @@ describe("applySoftReload", () => {
|
|
|
249
249
|
// gsap present but MotionPathPlugin unset → async load path.
|
|
250
250
|
const { iframe, contentWindow } = buildMockIframe({
|
|
251
251
|
MotionPathPlugin: undefined,
|
|
252
|
-
gsap: { timeline: vi.fn(), registerPlugin: vi.fn() },
|
|
252
|
+
gsap: { timeline: vi.fn(), registerPlugin: vi.fn(), version: "3.14.2" },
|
|
253
253
|
});
|
|
254
254
|
(iframe.contentDocument as unknown as { head: unknown }).head = head;
|
|
255
255
|
|
|
@@ -260,7 +260,7 @@ describe("applySoftReload", () => {
|
|
|
260
260
|
// script has NOT executed yet, so the timeline isn't rebound synchronously.
|
|
261
261
|
expect(result).toBe("applied");
|
|
262
262
|
expect(appendedScripts).toHaveLength(1);
|
|
263
|
-
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
|
|
263
|
+
expect(appendedScripts[0]!.src).toContain("gsap@3.14.2/dist/MotionPathPlugin");
|
|
264
264
|
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
|
|
265
265
|
|
|
266
266
|
// onerror must NOT run the script (that would reference a missing plugin) —
|
|
@@ -384,6 +384,16 @@ describe("ensureMotionPathPluginLoaded", () => {
|
|
|
384
384
|
expect(appendedScripts).toHaveLength(0);
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
it("loads the plugin at the composition's own gsap version", () => {
|
|
388
|
+
const { iframe, appendedScripts } = buildBootstrapIframe({
|
|
389
|
+
gsap: { version: "3.14.2", registerPlugin: vi.fn() },
|
|
390
|
+
});
|
|
391
|
+
ensureMotionPathPluginLoaded(iframe);
|
|
392
|
+
expect(appendedScripts[0]!.src).toBe(
|
|
393
|
+
"https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js",
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
387
397
|
it("appends the plugin script once and registers it on load", () => {
|
|
388
398
|
const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
|
|
389
399
|
ensureMotionPathPluginLoaded(iframe);
|
|
@@ -481,6 +491,45 @@ describe("applySoftReload authored-style restore", () => {
|
|
|
481
491
|
return el.style.getPropertyValue("opacity");
|
|
482
492
|
}
|
|
483
493
|
|
|
494
|
+
it("flushes only elements when a tween targets a plain object (the runtime's duration filler)", () => {
|
|
495
|
+
const el = document.createElement("div");
|
|
496
|
+
// GSAP's clearProps writes target.style.cssText, which throws on a plain object.
|
|
497
|
+
const set = vi.fn((targets: Array<{ style: CSSStyleDeclaration }>) => {
|
|
498
|
+
for (const t of targets) t.style.cssText = "";
|
|
499
|
+
});
|
|
500
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
501
|
+
gsap: { timeline: vi.fn(), set },
|
|
502
|
+
__timelines: {
|
|
503
|
+
root: {
|
|
504
|
+
kill: vi.fn(),
|
|
505
|
+
getChildren: () => [{ targets: () => [el] }, { targets: () => [{}] }],
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
|
|
511
|
+
expect(set).toHaveBeenCalledWith([el], { clearProps: "all" });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("falls back to a full reload, and says why, when the flush throws", () => {
|
|
515
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
516
|
+
const { iframe } = buildIframeWithTarget(document.createElement("div"), {
|
|
517
|
+
gsap: {
|
|
518
|
+
timeline: vi.fn(),
|
|
519
|
+
set: vi.fn(() => {
|
|
520
|
+
throw new Error("flush failed");
|
|
521
|
+
}),
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
|
526
|
+
expect(error).toHaveBeenCalledWith(
|
|
527
|
+
expect.stringContaining("soft reload threw"),
|
|
528
|
+
expect.any(Error),
|
|
529
|
+
);
|
|
530
|
+
error.mockRestore();
|
|
531
|
+
});
|
|
532
|
+
|
|
484
533
|
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
|
|
485
534
|
const el = document.createElement("img");
|
|
486
535
|
el.setAttribute("data-hf-id", "hf-1");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
|
2
|
+
import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
|
|
2
3
|
import { findAuthoredElement } from "./authoredSource";
|
|
3
4
|
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
|
4
5
|
import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
|
|
@@ -14,6 +15,7 @@ type IframeWindow = Window & {
|
|
|
14
15
|
// re-flash the iframe. Cleared once the plugin loads or errors.
|
|
15
16
|
__hfMotionPathPluginLoading?: boolean;
|
|
16
17
|
gsap?: {
|
|
18
|
+
version?: string;
|
|
17
19
|
timeline?: (...args: unknown[]) => unknown;
|
|
18
20
|
registerPlugin?: (...plugins: unknown[]) => unknown;
|
|
19
21
|
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
|
|
@@ -22,14 +24,6 @@ type IframeWindow = Window & {
|
|
|
22
24
|
MotionPathPlugin?: unknown;
|
|
23
25
|
};
|
|
24
26
|
|
|
25
|
-
/**
|
|
26
|
-
* CDN URL for the GSAP MotionPathPlugin. Shared between the one-time preview
|
|
27
|
-
* bootstrap (ensureMotionPathPluginLoaded) and the soft-reload fallback so the
|
|
28
|
-
* version is pinned in a single place.
|
|
29
|
-
*/
|
|
30
|
-
const MOTION_PATH_PLUGIN_CDN =
|
|
31
|
-
"https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
|
32
|
-
|
|
33
27
|
/**
|
|
34
28
|
* Pre-load + register MotionPathPlugin ONCE in the preview iframe so
|
|
35
29
|
* `win.MotionPathPlugin` is reliably set before any studio edit. Called from the
|
|
@@ -66,7 +60,7 @@ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null):
|
|
|
66
60
|
try {
|
|
67
61
|
win.__hfMotionPathPluginLoading = true;
|
|
68
62
|
const pluginScript = doc.createElement("script");
|
|
69
|
-
pluginScript.src =
|
|
63
|
+
pluginScript.src = motionPathPluginUrl(win.gsap?.version);
|
|
70
64
|
const finalize = () => {
|
|
71
65
|
win.__hfMotionPathPluginLoading = false;
|
|
72
66
|
try {
|
|
@@ -152,7 +146,8 @@ function runSuppressed(win: IframeWindow, reload: () => void): boolean {
|
|
|
152
146
|
if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
|
|
153
147
|
else reload();
|
|
154
148
|
return true;
|
|
155
|
-
} catch {
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error("[Studio] GSAP soft reload threw; falling back to a full reload", error);
|
|
156
151
|
return false;
|
|
157
152
|
}
|
|
158
153
|
}
|
|
@@ -369,16 +364,11 @@ export function applySoftReload(
|
|
|
369
364
|
// HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
|
|
370
365
|
const allTargets = [...targets.keys()];
|
|
371
366
|
if (allTargets.length > 0 && win.gsap?.set) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
|
|
378
|
-
}
|
|
379
|
-
try {
|
|
380
|
-
win.gsap.set(allTargets, { clearProps: "all" });
|
|
381
|
-
} catch {}
|
|
367
|
+
// Iframe-realm nodes: instanceof HTMLElement fails across realms, so style access is duck-typed.
|
|
368
|
+
const saved = allTargets.map(
|
|
369
|
+
(el) => [el as HTMLElement, (el as HTMLElement).style.cssText] as const,
|
|
370
|
+
);
|
|
371
|
+
win.gsap.set(allTargets, { clearProps: "all" });
|
|
382
372
|
for (const [el, css] of saved) {
|
|
383
373
|
const s = el.style;
|
|
384
374
|
s.cssText = css;
|
|
@@ -449,7 +439,7 @@ export function applySoftReload(
|
|
|
449
439
|
}
|
|
450
440
|
win.__hfMotionPathPluginLoading = true;
|
|
451
441
|
const pluginScript = doc.createElement("script");
|
|
452
|
-
pluginScript.src =
|
|
442
|
+
pluginScript.src = motionPathPluginUrl(win.gsap?.version);
|
|
453
443
|
pluginScript.onload = () => {
|
|
454
444
|
win.__hfMotionPathPluginLoading = false;
|
|
455
445
|
executeScript();
|
|
@@ -16,11 +16,9 @@ const CROP = "inset(0px 40px 0px 0px)";
|
|
|
16
16
|
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
|
|
17
17
|
let browser: Browser;
|
|
18
18
|
let undoBundle: string;
|
|
19
|
+
let softReloadBundle: string;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
const executablePath = findSystemChrome();
|
|
22
|
-
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
23
|
-
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
21
|
+
async function bundle(file: string, name: string): Promise<string> {
|
|
24
22
|
const out = await build({
|
|
25
23
|
configFile: false,
|
|
26
24
|
logLevel: "silent",
|
|
@@ -30,68 +28,85 @@ beforeAll(async () => {
|
|
|
30
28
|
build: {
|
|
31
29
|
write: false,
|
|
32
30
|
minify: false,
|
|
33
|
-
lib: {
|
|
34
|
-
entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
|
|
35
|
-
formats: ["iife"],
|
|
36
|
-
name: "hfUndo",
|
|
37
|
-
},
|
|
31
|
+
lib: { entry: fileURLToPath(new URL(file, import.meta.url)), formats: ["iife"], name },
|
|
38
32
|
},
|
|
39
33
|
});
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
return (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0].code;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
const executablePath = findSystemChrome();
|
|
39
|
+
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
40
|
+
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
41
|
+
undoBundle = await bundle("./gsapUndoRestore.ts", "hfUndo");
|
|
42
|
+
softReloadBundle = await bundle("./gsapSoftReload.ts", "hfSoftReload");
|
|
42
43
|
});
|
|
43
44
|
|
|
44
45
|
afterAll(() => browser?.close());
|
|
45
46
|
|
|
47
|
+
type Spec = { gsap: string; placement: string; rotation: number; time: number };
|
|
48
|
+
|
|
49
|
+
/** The bench's root crop fixture, shown at `spec.time` with the module under test loaded. */
|
|
50
|
+
async function openCase(spec: Spec, moduleBundle: string) {
|
|
51
|
+
const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
|
|
52
|
+
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
|
|
53
|
+
const restored = readFileSync(join(dir, "index.html"), "utf8");
|
|
54
|
+
const page = await browser.newPage();
|
|
55
|
+
await page.setViewport({ width: 1920, height: 1080 });
|
|
56
|
+
await page.setRequestInterception(true);
|
|
57
|
+
// Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
|
|
58
|
+
page.on("request", (request) =>
|
|
59
|
+
request.url().endsWith("/gsap.min.js")
|
|
60
|
+
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
61
|
+
: request.abort("blockedbyclient"),
|
|
62
|
+
);
|
|
63
|
+
await page.setContent(restored, { waitUntil: "load" });
|
|
64
|
+
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
65
|
+
await page.waitForFunction(() => "__player" in window);
|
|
66
|
+
await page.evaluate(moduleBundle);
|
|
67
|
+
await page.evaluate(
|
|
68
|
+
(time) => (window as unknown as { __player: { seek(t: number): void } }).__player.seek(time),
|
|
69
|
+
spec.time,
|
|
70
|
+
);
|
|
71
|
+
const box = () =>
|
|
72
|
+
page.evaluate(() => {
|
|
73
|
+
const { left, top, width, height } = document
|
|
74
|
+
.getElementById("target")!
|
|
75
|
+
.getBoundingClientRect();
|
|
76
|
+
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
|
|
77
|
+
});
|
|
78
|
+
return { page, restored, box };
|
|
79
|
+
}
|
|
80
|
+
|
|
46
81
|
// The first case opens the cold browser's first page, which overran 5 s on a loaded CI runner.
|
|
47
82
|
it.each([
|
|
48
|
-
{ gsap: "hold", placement: "px", rotation: 0 },
|
|
49
|
-
{ gsap: "tween", placement: "px", rotation: 0 },
|
|
50
|
-
{ gsap: "tween", placement: "xpercent", rotation: 30 },
|
|
83
|
+
{ gsap: "hold", placement: "px", rotation: 0, time: 1 },
|
|
84
|
+
{ gsap: "tween", placement: "px", rotation: 0, time: 1 },
|
|
85
|
+
{ gsap: "tween", placement: "xpercent", rotation: 30, time: 1 },
|
|
86
|
+
{ gsap: "keys", placement: "px", rotation: 0, time: 1 },
|
|
87
|
+
{ gsap: "keys", placement: "px", rotation: 30, time: 2 },
|
|
88
|
+
{ gsap: "scale", placement: "px", rotation: 0, time: 1 },
|
|
89
|
+
{ gsap: "spin", placement: "px", rotation: 30, time: 1 },
|
|
90
|
+
{ gsap: "fromto", placement: "px", rotation: 0, time: 1 },
|
|
51
91
|
])(
|
|
52
|
-
"an undone crop leaves a $gsap $placement r$rotation layer where it was",
|
|
92
|
+
"an undone crop leaves a $gsap $placement r$rotation layer at t$time where it was",
|
|
53
93
|
async (spec) => {
|
|
54
|
-
const
|
|
55
|
-
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
|
|
56
|
-
const restored = readFileSync(join(dir, "index.html"), "utf8");
|
|
94
|
+
const { page, restored, box } = await openCase(spec, undoBundle);
|
|
57
95
|
const previous = restored.replace(
|
|
58
96
|
`<div id="target"`,
|
|
59
97
|
`<div id="target" style="clip-path: ${CROP}"`,
|
|
60
98
|
);
|
|
61
|
-
const page = await browser.newPage();
|
|
62
|
-
await page.setViewport({ width: 1920, height: 1080 });
|
|
63
|
-
await page.setRequestInterception(true);
|
|
64
|
-
// Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
|
|
65
|
-
page.on("request", (request) =>
|
|
66
|
-
request.url().endsWith("/gsap.min.js")
|
|
67
|
-
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
68
|
-
: request.abort("blockedbyclient"),
|
|
69
|
-
);
|
|
70
|
-
await page.setContent(restored, { waitUntil: "load" });
|
|
71
|
-
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
72
|
-
await page.waitForFunction(() => "__player" in window);
|
|
73
|
-
await page.evaluate(undoBundle);
|
|
74
|
-
|
|
75
|
-
const box = () =>
|
|
76
|
-
page.evaluate(() => {
|
|
77
|
-
const { left, top, width, height } = document
|
|
78
|
-
.getElementById("target")!
|
|
79
|
-
.getBoundingClientRect();
|
|
80
|
-
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
|
|
81
|
-
});
|
|
82
|
-
await page.evaluate(() =>
|
|
83
|
-
(window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
|
|
84
|
-
);
|
|
85
99
|
const before = await box();
|
|
86
100
|
const outcome = await page.evaluate(
|
|
87
|
-
(files, crop) => {
|
|
101
|
+
(files, crop, time) => {
|
|
88
102
|
document.getElementById("target")!.style.setProperty("clip-path", crop);
|
|
89
103
|
const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
|
|
90
104
|
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
91
|
-
return undo.applyUndoRestoreToPreview(iframe, "index.html", files,
|
|
105
|
+
return undo.applyUndoRestoreToPreview(iframe, "index.html", files, time, () => {});
|
|
92
106
|
},
|
|
93
107
|
{ "index.html": { previous, restored } },
|
|
94
108
|
CROP,
|
|
109
|
+
spec.time,
|
|
95
110
|
);
|
|
96
111
|
|
|
97
112
|
expect(outcome).toBe("soft");
|
|
@@ -100,3 +115,35 @@ it.each([
|
|
|
100
115
|
},
|
|
101
116
|
30_000,
|
|
102
117
|
);
|
|
118
|
+
|
|
119
|
+
// A drop saves the file and soft-reloads it; the re-run must place an animated layer where it was.
|
|
120
|
+
it.each([
|
|
121
|
+
{ gsap: "tween", placement: "px", rotation: 0, time: 1 },
|
|
122
|
+
{ gsap: "keys", placement: "px", rotation: 30, time: 2 },
|
|
123
|
+
{ gsap: "spin", placement: "px", rotation: 0, time: 1 },
|
|
124
|
+
])(
|
|
125
|
+
"a soft reload keeps a $gsap $placement r$rotation layer at t$time where it was",
|
|
126
|
+
async (spec) => {
|
|
127
|
+
const { page, restored, box } = await openCase(spec, softReloadBundle);
|
|
128
|
+
const before = await box();
|
|
129
|
+
const outcome = await page.evaluate(
|
|
130
|
+
(html, time) => {
|
|
131
|
+
const soft = (window as unknown as { hfSoftReload: typeof import("./gsapSoftReload") })
|
|
132
|
+
.hfSoftReload;
|
|
133
|
+
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
134
|
+
const script = soft.extractGsapScriptText(html)!;
|
|
135
|
+
return soft.applySoftReload(iframe, script, {
|
|
136
|
+
currentTimeOverride: time,
|
|
137
|
+
authoredHtml: html,
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
restored,
|
|
141
|
+
spec.time,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(outcome).toBe("applied");
|
|
145
|
+
expect(await box()).toEqual(before);
|
|
146
|
+
await page.close();
|
|
147
|
+
},
|
|
148
|
+
30_000,
|
|
149
|
+
);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each.
|
|
2
2
|
import { parseSavedSource } from "./authoredSource";
|
|
3
|
+
import { elementTargets } from "./elementGsap";
|
|
3
4
|
|
|
4
5
|
type TweenLike = {
|
|
5
|
-
targets?: () =>
|
|
6
|
+
targets?: () => unknown[];
|
|
6
7
|
vars?: Record<string, unknown>;
|
|
7
8
|
getChildren?: (deep: boolean) => TweenLike[];
|
|
8
9
|
};
|
|
@@ -36,7 +37,7 @@ export function authoringFile(el: Element): string | null {
|
|
|
36
37
|
|
|
37
38
|
function addTweenTargets(tween: TweenLike, targets: Map<Element, Set<string>>): void {
|
|
38
39
|
const props = tweenedProps(tween.vars);
|
|
39
|
-
for (const el of tween
|
|
40
|
+
for (const el of elementTargets(tween)) {
|
|
40
41
|
const seen = targets.get(el) ?? new Set<string>();
|
|
41
42
|
for (const prop of props) seen.add(prop);
|
|
42
43
|
targets.set(el, seen);
|
|
@@ -68,7 +69,7 @@ function tweenTargetsIn(timelines: Set<unknown>): Set<Element> {
|
|
|
68
69
|
for (const tl of timelines) {
|
|
69
70
|
try {
|
|
70
71
|
for (const tween of (tl as TweenLike | undefined)?.getChildren?.(true) ?? []) {
|
|
71
|
-
for (const el of tween
|
|
72
|
+
for (const el of elementTargets(tween)) els.add(el);
|
|
72
73
|
}
|
|
73
74
|
} catch {}
|
|
74
75
|
}
|
|
@@ -34,6 +34,13 @@ export function isAudioTimelineElement(
|
|
|
34
34
|
return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** A track whose hide toggle reads as mute: the header button and its undo entry. */
|
|
38
|
+
export function isAudioOnlyTrack(
|
|
39
|
+
elements: readonly Pick<TimelineElement, "tag" | "src">[],
|
|
40
|
+
): boolean {
|
|
41
|
+
return elements.length > 0 && elements.every(isAudioTimelineElement);
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
/** The two tags the property panel lets you put a volume automation lane on.
|
|
38
45
|
* Single owner: `groupAutomationLanes`, `automationLaneCountOf` and
|
|
39
46
|
* `TimelineAutomationLaneSlot`'s clip filter all have to agree on this set. */
|
|
@@ -108,11 +115,9 @@ export function resolveBeatSourceTrack(
|
|
|
108
115
|
* May this multi-selection be hidden as one action?
|
|
109
116
|
*
|
|
110
117
|
* Audio has no visual to hide, and `data-hidden` on an audio element is what
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* write on `audioSelection`. The multi-selection "Hide all" was the one path
|
|
115
|
-
* left back to it, on a control whose label promises visibility.
|
|
118
|
+
* mutes it: preview silences it and the render drops it from the mix. The timeline
|
|
119
|
+
* and the single-selection panel offer it as a mute; "Hide all" would reach it on a
|
|
120
|
+
* control whose label promises visibility.
|
|
116
121
|
*
|
|
117
122
|
* A shared predicate rather than a check in the handler so the panel's button
|
|
118
123
|
* and the handler's refusal cannot disagree — the button is not the only caller.
|