fix(studio): address review findings on graded-element editing

Review follow-ups (both reviewers, all findings):

- resize captures scope to the resize group: convert-to-keyframes
  resolvedFromValues and the whole-offset backfill pass the group filter,
  so an opacity-touching intro tween can't ride into a converted scale
  tween (the rotation fix's contract, now uniform across intercepts)
- commitStaticSet resolves every group's target set BEFORE committing and
  coalesces groups landing on the same legacy mixed set into one commit —
  the second commit can no longer chase a stale group-derived id
- installAuthoredOpacityCapture also stamps an element the moment it GAINS
  data-color-grading at runtime (attributeFilter), not just at insertion
- both writer twins now share the same emitted-set dedupe shape
- applySoftReload's positional tail becomes a SoftReloadOptions object
- readAllAnimatedProperties builds the group-filtered key set immutably
  instead of deleting from the set mid-iteration
- applyAuthoredInlineOpacity documents the priority-lossy round-trip
- the marquee hit-test reads activeCompositionPathRef like its neighbors

New tests: resize intercept (scale route + group filter + non-uniform
longhands), after-write-HTML / stamp / empty-stamp opacity restore, the
no-op-commit-with-missed-instant-patch soft-reload contract, and the
runtime-gained-grading stamp.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-11 15:18:49 -04:00
parent 67cfae2587
commit 5d1cafff82
13 changed files with 367 additions and 88 deletions
@@ -643,4 +643,26 @@ describe("installAuthoredOpacityCapture", () => {
expect(el.getAttribute("data-hf-authored-opacity")).toBe("");
el.remove();
});
it("stamps an already-inserted element the moment it GAINS grading at runtime", async () => {
installAuthoredOpacityCapture();
const el = document.createElement("img");
el.style.opacity = "0.9";
document.body.appendChild(el);
await Promise.resolve();
expect(el.hasAttribute("data-hf-authored-opacity")).toBe(false);
// Studio applies a preset to a previously ungraded element — no re-insert.
el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.5 } }));
await Promise.resolve();
expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.9");
// Later attribute rewrites (preset tweaks) never overwrite the stamp,
// even if a transient is live by then.
el.style.opacity = "0";
el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.9 } }));
await Promise.resolve();
expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.9");
el.remove();
});
});
+14 -1
View File
@@ -239,8 +239,21 @@ export function installAuthoredOpacityCapture(): void {
new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) scan(node);
// An element can also GAIN grading at runtime (studio applies a preset to
// a previously ungraded element). Stamp at that moment — strictly earlier
// than the engine's hide, so the captured value can never be worse than
// the hide-time fallback, and after a soft reload's authored restore it
// IS the authored value. stamp() is idempotent: an existing stamp wins.
if (mutation.type === "attributes" && mutation.target instanceof Element) {
if (mutation.target.hasAttribute(HF_COLOR_GRADING_ATTR)) stamp(mutation.target);
}
}
}).observe(root, { childList: true, subtree: true });
}).observe(root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [HF_COLOR_GRADING_ATTR],
});
}
// Map insertion order gives us simple FIFO eviction for authoring sessions that cycle LUTs.
+6 -2
View File
@@ -57,11 +57,15 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
const emitted = new Set(Object.keys(props));
if (anim.extras) {
for (const [k, v] of Object.entries(anim.extras)) {
// A key carried by both properties and extras (a set's parsed
// `immediateRender: true`) must emit once — properties win.
if (!(k in props)) entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
// `immediateRender: true`) must emit once — properties win. Same
// dedupe shape as the recast twin (gsapParser.ts buildTweenStatementCode).
if (emitted.has(k)) continue;
emitted.add(k);
entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
}
}
const objCode = `{ ${entries.join(", ")} }`;
@@ -315,7 +315,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
const iframe = iframeRef.current;
const freshTarget = iframe
? getPreviewTargetFromPointer(iframe, event.clientX, event.clientY, activeCompositionPath)
? getPreviewTargetFromPointer(
iframe,
event.clientX,
event.clientY,
activeCompositionPathRef.current,
)
: null;
if (freshTarget) return;
const overlayEl = overlayRef.current;
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
afterEach(() => {
vi.restoreAllMocks();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
});
/**
* Scale-route resize: an element whose visual size is driven by a scale-group
* tween. The intercept must (a) route the commit through SCALE, never
* width/height, and (b) resolve convert-to-keyframes from-values through the
* group filter an opacity-touching intro tween on the same element must not
* ride into the converted keyframes (the disappearance bake class).
*/
function makeGradedElement(): HTMLElement {
const el = document.createElement("img");
el.id = "clip";
el.setAttribute("data-hf-studio-original-width", "640");
el.setAttribute("data-hf-studio-original-height", "360");
// Grading contract: source hidden, canvas carries effective opacity.
el.setAttribute("data-hf-color-grading-source-hidden", "");
const canvas = document.createElement("canvas");
canvas.id = "__hf_color_grading_clip";
canvas.style.opacity = "0.98";
document.body.append(el, canvas);
return el;
}
function fakeIframe(el: HTMLElement, gsapValues: Record<string, number>) {
// The element's OPACITY intro tween lives on the timeline: unfiltered
// capture would pick `opacity` up via the other-tween sweep.
const opacityIntro = { targets: () => [el], vars: { opacity: 0, duration: 0.8 } };
return {
contentWindow: {
__timelines: { main: { getChildren: () => [opacityIntro] } },
gsap: { getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0 },
},
contentDocument: document,
} as unknown as HTMLIFrameElement;
}
function scaleFromTween(): GsapAnimation {
return {
id: "#clip-from-200-scale",
targetSelector: "#clip",
propertyGroup: "scale",
method: "from",
properties: { scale: 0.9 },
position: 0.2,
resolvedStart: 0.2,
duration: 0.8,
} as unknown as GsapAnimation;
}
function keyframedScaleFixture(): GsapAnimation {
return {
...scaleFromTween(),
keyframes: {
keyframes: [
{ percentage: 0, properties: { scale: 0.9 } },
{ percentage: 100, properties: { scale: 1 } },
],
},
} as unknown as GsapAnimation;
}
/** Drive one resize through the intercept, returning every committed mutation. */
async function runResize(
el: HTMLElement,
iframe: HTMLIFrameElement,
size: { width: number; height: number },
): Promise<Array<Record<string, unknown>>> {
const selection = { id: "clip", selector: "#clip", element: el } as unknown as DomEditSelection;
usePlayerStore.setState({ currentTime: 0.5 }); // inside the tween's range
const committed: Array<Record<string, unknown>> = [];
const commitMutation = vi.fn(async (_sel: unknown, mutation: Record<string, unknown>) => {
committed.push(mutation);
});
const handled = await tryGsapResizeIntercept(
selection,
size,
[scaleFromTween()],
iframe,
commitMutation as never,
async () => [keyframedScaleFixture()],
);
expect(handled).toBe(true);
return committed;
}
it("scale-route resize converts via the group filter and commits scale, not width/height", async () => {
const el = makeGradedElement();
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0, rotation: 0 });
// uniform: 800/640 === 450/360
const committed = await runResize(el, iframe, { width: 800, height: 450 });
const convert = committed.find((m) => m.type === "convert-to-keyframes");
expect(convert).toBeDefined();
const fromValues = convert!.resolvedFromValues as Record<string, number>;
// Group filter: the opacity intro tween must NOT leak into the conversion.
expect(fromValues).not.toHaveProperty("opacity");
expect(fromValues).toHaveProperty("scale");
// Every committed property is scale-group — the resize never writes
// width/height for a scale-driven element (the double-apply bug class).
const allProps = committed.flatMap((m) => [
...Object.keys((m.properties as Record<string, unknown>) ?? {}),
...Object.keys((m.resolvedFromValues as Record<string, unknown>) ?? {}),
]);
expect(allProps).not.toContain("width");
expect(allProps).not.toContain("height");
expect(allProps.some((p) => p === "scale" || p === "scaleX")).toBe(true);
});
it("non-uniform drag commits scaleX/scaleY longhands", async () => {
const el = makeGradedElement();
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0 });
// scaleX 1.25 vs scaleY 1.0 → non-uniform
const committed = await runResize(el, iframe, { width: 800, height: 360 });
const serialized = JSON.stringify(committed);
expect(serialized).toContain("scaleX");
expect(serialized).toContain("scaleY");
});
@@ -106,7 +106,12 @@ export async function tryGsapResizeIntercept(
const coalesceKey = `gsap:resize:${anim.id}`;
const selector = selectorFromSelection(selection);
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
// Scope every capture to the resize group — same contract as the rotation
// intercept. Unfiltered, an opacity-touching intro tween on the element
// would ride into resize conversions/backfills (the Fix-2 bake class).
const runtimeProps = selector
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
: {};
let resizeProps: Record<string, number>;
let scaleDraftEl: HTMLElement | null = null;
@@ -254,7 +259,7 @@ export async function tryGsapResizeIntercept(
if (newId) anim = { ...anim, id: newId };
} else if (!anim.keyframes) {
const resolvedFromValues = selector
? readAllAnimatedProperties(iframe, selector, anim)
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
: undefined;
await commitMutation(
selection,
@@ -106,11 +106,9 @@ export function readAllAnimatedProperties(
// point of property-group tweens is that a rotation commit never carries
// opacity/rotationX/etc. captured from unrelated tweens on the element.
const inGroup = (p: string) => !group || classifyPropertyGroup(p) === group;
for (const p of propKeys) {
if (!inGroup(p)) propKeys.delete(p);
}
const groupedPropKeys = new Set([...propKeys].filter(inGroup));
for (const prop of propKeys) {
for (const prop of groupedPropKeys) {
const val = readLiveGsapValue(gsap, el, prop);
if (Number.isFinite(val)) {
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
@@ -142,7 +140,7 @@ export function readAllAnimatedProperties(
}
}
} catch {}
for (const p of propKeys) otherTweenProps.delete(p);
for (const p of groupedPropKeys) otherTweenProps.delete(p);
// Tier 1: Transform + visual properties with universal CSS defaults.
// Safe to compare against hardcoded values — these are always 0 or 1
@@ -174,7 +172,7 @@ export function readAllAnimatedProperties(
// Collect all properties that ANY tween on this element explicitly targets.
// Only capture baseline values for these — GSAP reports non-default values
// (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
const allTweenedProps = new Set([...propKeys, ...otherTweenProps]);
const allTweenedProps = new Set([...groupedPropKeys, ...otherTweenProps]);
for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
if (prop in result) continue;
if (!allTweenedProps.has(prop)) continue;
@@ -186,19 +186,40 @@ async function commitStaticSet(
byGroup.set(group, batch);
}
const sets = animations.filter((a) => a.method === "set" && a.targetSelector === selector);
// Resolve every group's target BEFORE committing anything, and coalesce
// groups that land on the SAME set into one commit: the `sets` snapshot is
// captured once, so if two groups resolved to one legacy mixed set, a first
// commit could re-shape it server-side and leave the second chasing a stale
// id (404 on legacy pre-split files).
const byTargetSet = new Map<GsapAnimation, [string, number | string][]>();
const newSetBatches: [string, number | string][][] = [];
for (const [group, batch] of byGroup) {
const existingSet =
// A set already dedicated to this group wins; else a mixed set that
// already carries a property of this group (merging same-group values
// there beats spawning a second writer for the same channel).
sets.find((a) => a.propertyGroup === group) ??
sets.find((a) => Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group));
const existingSet = findGroupOwningSet(sets, group);
if (existingSet) {
await commitSetProps(selection, existingSet, batch, selector, animations, commit);
byTargetSet.set(existingSet, [...(byTargetSet.get(existingSet) ?? []), ...batch]);
} else {
await addGlobalStaticSet(selection, batch, selector, commit);
newSetBatches.push(batch);
}
}
for (const [targetSet, batch] of byTargetSet) {
await commitSetProps(selection, targetSet, batch, selector, animations, commit);
}
// Fresh adds don't reshape existing sets, so their ids can't go stale.
for (const batch of newSetBatches) {
await addGlobalStaticSet(selection, batch, selector, commit);
}
}
/**
* The set that owns a property group: one already dedicated to the group wins;
* else a mixed set that already carries a property of the group (merging
* same-group values there beats spawning a second writer for the channel).
*/
function findGroupOwningSet(sets: GsapAnimation[], group: string): GsapAnimation | undefined {
return (
sets.find((a) => a.propertyGroup === group) ??
sets.find((a) => Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group))
);
}
/**
@@ -51,6 +51,14 @@ function syncDragPreview(res: MutationResult, reloadPreview: () => void) {
applyPreviewSync(FAKE_IFRAME, res, dragOptions(), reloadPreview);
}
function expectSoftReloadedWith(onAsyncFailure: unknown, authoredHtml: string | undefined) {
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", {
onAsyncFailure,
currentTimeOverride: 0,
authoredHtml,
});
}
describe("applyPreviewSync", () => {
beforeEach(() => {
patchRuntimeTweenInPlace.mockReset();
@@ -81,13 +89,7 @@ describe("applyPreviewSync", () => {
// reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
// CDN load failure escalates to a full reload — but it is NOT called eagerly.
expect(applySoftReload).toHaveBeenCalledWith(
FAKE_IFRAME,
"SCRIPT",
reloadPreview,
0,
undefined,
);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).not.toHaveBeenCalled();
// A successful instant patch is the fast path; here it missed → fallback event.
expect(trackStudioEvent).toHaveBeenCalledWith(
@@ -105,13 +107,7 @@ describe("applyPreviewSync", () => {
// U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
// is correct, so we must NOT escalate to a full reload.
expect(applySoftReload).toHaveBeenCalledWith(
FAKE_IFRAME,
"SCRIPT",
reloadPreview,
0,
undefined,
);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).not.toHaveBeenCalled();
// Telemetry records the suppressed transient (escalated: false).
expect(trackStudioEvent).toHaveBeenCalledWith(
@@ -132,13 +128,7 @@ describe("applyPreviewSync", () => {
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
// Structural failure: the preview is genuinely stale/broken → full reload.
expect(applySoftReload).toHaveBeenCalledWith(
FAKE_IFRAME,
"SCRIPT",
reloadPreview,
0,
undefined,
);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
@@ -162,13 +152,7 @@ describe("applyPreviewSync", () => {
);
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
expect(applySoftReload).toHaveBeenCalledWith(
FAKE_IFRAME,
"SCRIPT",
reloadPreview,
0,
undefined,
);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).not.toHaveBeenCalled();
// "applied" emits no telemetry (only the failure paths do).
expect(trackStudioEvent).not.toHaveBeenCalled();
@@ -186,13 +170,7 @@ describe("applyPreviewSync", () => {
);
// onAsyncFailure is wired, but the transient result does not trigger it.
expect(applySoftReload).toHaveBeenCalledWith(
FAKE_IFRAME,
"SCRIPT",
reloadPreview,
0,
undefined,
);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).not.toHaveBeenCalled();
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
@@ -211,13 +189,7 @@ describe("applyPreviewSync", () => {
reloadPreview,
);
expect(applySoftReload).toHaveBeenCalledWith(
FAKE_IFRAME,
"SCRIPT",
reloadPreview,
0,
undefined,
);
expectSoftReloadedWith(reloadPreview, undefined);
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenCalledWith(
"gsap_soft_reload_outcome",
@@ -330,6 +302,31 @@ describe("runCommit — instantPatch wiring", () => {
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
// Server contract: gsap-mutations returns scriptText on EVERY response,
// including changed:false — so the fallback re-runs the identical script
// ("applied") instead of escalating a genuine no-op to a full reload.
patchRuntimeTweenInPlace.mockReturnValue(false);
applySoftReload.mockReturnValue("applied");
mockFetchResult({ changed: false });
const deps = renderCommitHook();
await act(async () => {
await deps.api.commitMutation(
selection,
{ type: "update-property", property: "y", value: 311 },
{
label: "Move layer",
softReload: true,
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 485, y: 311 } } },
},
);
});
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
beforeEach(() => {
patchRuntimeTweenInPlace.mockReset();
applySoftReload.mockReset();
@@ -368,13 +365,7 @@ describe("runCommit — instantPatch wiring", () => {
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(applySoftReload).toHaveBeenCalledWith(
FAKE_IFRAME,
"SCRIPT",
deps.reloadPreview,
0,
"AFTER",
);
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
expect(deps.reloadPreview).not.toHaveBeenCalled();
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
});
@@ -389,13 +380,7 @@ describe("runCommit — instantPatch wiring", () => {
});
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
expect(applySoftReload).toHaveBeenCalledWith(
FAKE_IFRAME,
"SCRIPT",
deps.reloadPreview,
0,
"AFTER",
);
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
});
@@ -72,13 +72,11 @@ function softReloadOrEscalate(
// not the iframe's raw `__player.getTime()` — see the comment in
// applySoftReload for why the two can desync after a keyframe-node drag.
const currentTime = usePlayerStore.getState().currentTime;
const result: SoftReloadResult = applySoftReload(
iframe,
scriptText,
reloadPreview,
currentTime,
const result: SoftReloadResult = applySoftReload(iframe, scriptText, {
onAsyncFailure: reloadPreview,
currentTimeOverride: currentTime,
authoredHtml,
);
});
if (result === "applied") return;
trackStudioEvent("gsap_soft_reload_outcome", {
origin,
+8 -1
View File
@@ -21,7 +21,14 @@ export function readStampedAuthoredOpacity(element: AttributeReader): string | n
return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR);
}
/** Write an authored inline opacity back: "" removes the property, a value sets it. */
/**
* Write an authored inline opacity back: "" removes the property, a value sets
* it. Priority-lossy by design: the capture reads `style.opacity` (value only)
* and the write sets no priority, so an authored `opacity: X !important`
* round-trips as `opacity: X`. The only `!important` opacity in the pipeline
* is the color-grading runtime hide a transient this contract exists to
* discard and authored compositions don't `!important` their opacity.
*/
export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void {
if (authored === "") style.removeProperty("opacity");
else style.setProperty("opacity", authored);
@@ -107,7 +107,7 @@ describe("applySoftReload", () => {
// async commit resolves. The rebuilt timeline must re-seek to the caller's
// value, not the iframe's possibly-stale one.
const { iframe, contentWindow } = buildMockIframe();
const result = applySoftReload(iframe, SCRIPT_TEXT, undefined, 0);
const result = applySoftReload(iframe, SCRIPT_TEXT, { currentTimeOverride: 0 });
expect(result).toBe("applied");
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
});
@@ -244,7 +244,7 @@ describe("applySoftReload", () => {
(iframe.contentDocument as unknown as { head: unknown }).head = head;
const onAsyncFailure = vi.fn();
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, onAsyncFailure);
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure });
// Optimistically "applied" (script will run once the plugin loads) — and the
// script has NOT executed yet, so the timeline isn't rebound synchronously.
@@ -363,3 +363,87 @@ describe("ensureMotionPathPluginLoaded", () => {
expect(appendedScripts).toHaveLength(2);
});
});
// The authored-opacity restore: before the script re-runs (and its tweens
// re-capture bounds), every animated element's inline opacity must be put back
// to its AUTHORED value — from the after-write file HTML when provided, else
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
describe("applySoftReload authored-opacity restore", () => {
function buildIframeWithTarget(el: HTMLElement, overrides: Record<string, unknown> = {}) {
const scriptEl = document.createElement("script");
scriptEl.textContent =
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
const tl = {
kill: vi.fn(),
pause: vi.fn(),
getChildren: () => [{ targets: () => [el] }],
};
const contentWindow = {
gsap: { timeline: vi.fn(), set: vi.fn() },
__hfForceTimelineRebind: vi.fn(),
__timelines: { root: tl } as Record<string, unknown>,
__player: { getTime: () => 2.0, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
...overrides,
};
const container = document.createElement("div");
container.appendChild(scriptEl);
// Intercept only POST-SETUP appends: simulate the re-run script
// repopulating __timelines (as in buildMockIframe).
const realAppendChild = container.appendChild.bind(container);
container.appendChild = <T extends Node>(node: T): T => {
const result = realAppendChild(node);
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
contentWindow.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
}
return result;
};
const contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
return { iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement };
}
/** Run one restore cycle over `el` and return the final inline opacity. */
function restoreOpacity(el: HTMLElement, authoredHtml?: string): string {
const { iframe } = buildIframeWithTarget(el);
expect(applySoftReload(iframe, SCRIPT_TEXT, authoredHtml ? { authoredHtml } : {})).toBe(
"applied",
);
return el.style.getPropertyValue("opacity");
}
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-id", "hf-1");
el.style.setProperty("opacity", "0", "important"); // the grading hide
const opacity = restoreOpacity(
el,
'<html><body><img data-hf-id="hf-1" style="opacity: 0.98"></body></html>',
);
expect(opacity).toBe("0.98");
expect(el.style.getPropertyPriority("opacity")).toBe("");
});
it("falls back to the parse-time stamp when no after-write HTML is given", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-authored-opacity", "0.75");
el.style.opacity = "0.123"; // mid-flight tween transient
expect(restoreOpacity(el)).toBe("0.75");
});
it("an empty stamp (authored none) removes the inline opacity", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-authored-opacity", "");
el.style.opacity = "0";
expect(restoreOpacity(el)).toBe("");
});
});
+11 -3
View File
@@ -174,13 +174,21 @@ export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload
* caller should perform a full reload to recover. It never fires on the
* synchronous paths.
*/
export interface SoftReloadOptions {
/** Escalation for async plugin-load failures (e.g. MotionPath CDN error). */
onAsyncFailure?: () => void;
/** Seek target for the rebuilt timeline; defaults to the iframe player time. */
currentTimeOverride?: number;
/** After-write file HTML — the primary source for authored-opacity restore. */
authoredHtml?: string;
}
export function applySoftReload(
iframe: HTMLIFrameElement | null,
scriptText: string,
onAsyncFailure?: () => void,
currentTimeOverride?: number,
authoredHtml?: string,
options: SoftReloadOptions = {},
): SoftReloadResult {
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
if (!iframe || !scriptText) return "cannot-soft-reload";
const win = iframe.contentWindow as IframeWindow | null;