mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio): undo puts a resized, rotated or moved element back on screen (#4740)
* fix(studio): undo puts a resized, rotated or moved element back on screen * fix(studio): undo no longer drifts an element with a Studio offset and a GSAP tween A soft reload killed the old timeline but kept its children, and the runtime seeks that captured timeline until the rebind swaps it. The old tween painted its transform after the authored-transform restore, and the new tween folded it into its start. Clear the timeline before killing it. The restore also covers SVG elements now, and the save path has a test.
This commit is contained in:
@@ -67,7 +67,11 @@ function reapplyRotations(doc: Document): void {
|
||||
}
|
||||
|
||||
// Every mark a reapply below acts on, the legacy double-prefixed ones included.
|
||||
const STUDIO_EDIT_ATTRS = [STUDIO_PATH_OFFSET_ATTR, STUDIO_BOX_SIZE_ATTR, STUDIO_ROTATION_ATTR]
|
||||
export const STUDIO_EDIT_ATTRS = [
|
||||
STUDIO_PATH_OFFSET_ATTR,
|
||||
STUDIO_BOX_SIZE_ATTR,
|
||||
STUDIO_ROTATION_ATTR,
|
||||
]
|
||||
.flatMap((attr) => [attr, `data-${attr}`])
|
||||
.concat(STUDIO_MOTION_ATTR);
|
||||
// One selector each: Chrome answers a lone attribute selector without walking the DOM, a comma list it walks.
|
||||
|
||||
@@ -426,8 +426,8 @@ describe("ensureMotionPathPluginLoaded", () => {
|
||||
// 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> = {}) {
|
||||
describe("applySoftReload authored-style restore", () => {
|
||||
function buildIframeWithTarget(el: Element, overrides: Record<string, unknown> = {}) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent =
|
||||
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
|
||||
@@ -532,4 +532,53 @@ describe("applySoftReload authored-opacity restore", () => {
|
||||
|
||||
expect(restoreOpacity(el)).toBe("");
|
||||
});
|
||||
|
||||
it("restores the file's inline translate, rotate and scale and drops GSAP's transform", () => {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("data-hf-id", "hf-1");
|
||||
el.style.cssText =
|
||||
"left: 10px; translate: none; rotate: none; scale: none; transform: translate(9px, 9px)";
|
||||
const { iframe } = buildIframeWithTarget(el);
|
||||
const authoredHtml = `<html><body><div data-hf-id="hf-1" style="translate: 60px 40px; rotate: 15deg; scale: 1.5"></div></body></html>`;
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml })).toBe("applied");
|
||||
const read = (prop: string) => el.style.getPropertyValue(prop);
|
||||
expect(["translate", "rotate", "scale", "transform"].map(read)).toEqual([
|
||||
"60px 40px",
|
||||
"15deg",
|
||||
"1.5",
|
||||
"",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an SVG child's authored inline transform", () => {
|
||||
const el = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
el.setAttribute("data-hf-id", "hf-1");
|
||||
el.setAttribute("style", "transform: translate(9px, 9px) rotate(20deg)");
|
||||
const { iframe } = buildIframeWithTarget(el);
|
||||
const authoredHtml = `<html><body><svg><rect data-hf-id="hf-1" style="transform: rotate(20deg)"></rect></svg></body></html>`;
|
||||
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml });
|
||||
expect(el.style.transform).toBe("rotate(20deg)");
|
||||
});
|
||||
|
||||
it("the finalize seek cannot paint the killed timeline over the restored transform", () => {
|
||||
const el = document.createElement("div");
|
||||
el.style.cssText = "translate: none; transform: translate3d(77.5px, 40px, 0px)";
|
||||
const children = [{ targets: () => [el] }];
|
||||
const killed = {
|
||||
kill: vi.fn(),
|
||||
getChildren: () => children,
|
||||
clear: () => void children.splice(0),
|
||||
};
|
||||
// The runtime still seeks the timeline it captured at load until the rebind swaps it.
|
||||
const seek = () =>
|
||||
children.forEach(() => (el.style.transform = "translate3d(77.5px, 0px, 0px)"));
|
||||
let transformAtRebind: string | null = null;
|
||||
const { iframe } = buildIframeWithTarget(el, {
|
||||
__timelines: { root: killed },
|
||||
__player: { getTime: () => 1, seek },
|
||||
__hfForceTimelineRebind: () => (transformAtRebind = el.style.transform),
|
||||
});
|
||||
applySoftReload(iframe, SCRIPT_TEXT);
|
||||
expect(transformAtRebind).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,18 @@ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): bool
|
||||
return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
|
||||
}
|
||||
|
||||
// GSAP masks a folded CSS translate/rotate/scale with `none`; a fresh load has only what the file authors.
|
||||
function restoreAuthoredTransforms(
|
||||
style: CSSStyleDeclaration,
|
||||
authored: CSSStyleDeclaration | null,
|
||||
) {
|
||||
style.transform = authored?.transform ?? "";
|
||||
if (!authored) return;
|
||||
for (const prop of ["translate", "rotate", "scale"]) {
|
||||
style.setProperty(prop, authored.getPropertyValue(prop));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a soft-reload attempt. Callers must distinguish PERMANENT failures
|
||||
* (the preview genuinely can't be soft-updated — escalate to a full reload) from
|
||||
@@ -181,7 +193,7 @@ export interface SoftReloadOptions {
|
||||
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. */
|
||||
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
|
||||
authoredHtml?: string;
|
||||
}
|
||||
|
||||
@@ -299,7 +311,7 @@ export function applySoftReload(
|
||||
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
|
||||
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
|
||||
let authoredDoc: Document | null | undefined;
|
||||
const findAuthoredSource = (el: HTMLElement): Element | null => {
|
||||
const findAuthoredStyle = (el: HTMLElement): CSSStyleDeclaration | null => {
|
||||
if (authoredDoc === undefined) {
|
||||
try {
|
||||
authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
|
||||
@@ -307,13 +319,12 @@ export function applySoftReload(
|
||||
authoredDoc = null;
|
||||
}
|
||||
}
|
||||
return authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
|
||||
};
|
||||
const readAuthoredOpacity = (el: HTMLElement): string | null => {
|
||||
const source = findAuthoredSource(el);
|
||||
if (source instanceof HTMLElement) return source.style.opacity;
|
||||
return readStampedAuthoredOpacity(el);
|
||||
const source = authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
|
||||
// The parsed file lives in this realm, so instanceof holds here, unlike for the iframe nodes below.
|
||||
return source instanceof HTMLElement || source instanceof SVGElement ? source.style : null;
|
||||
};
|
||||
const readAuthoredOpacity = (el: HTMLElement): string | null =>
|
||||
findAuthoredStyle(el)?.opacity ?? readStampedAuthoredOpacity(el);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const doReload = () => {
|
||||
@@ -327,6 +338,7 @@ export function applySoftReload(
|
||||
const tl = timelines[key] as
|
||||
| {
|
||||
kill?: () => void;
|
||||
clear?: () => void;
|
||||
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
||||
}
|
||||
| undefined;
|
||||
@@ -341,6 +353,8 @@ export function applySoftReload(
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
// kill() keeps the children, and the finalize seek renders this timeline until the rebind swaps it.
|
||||
tl.clear?.();
|
||||
tl.kill?.();
|
||||
} catch {}
|
||||
delete timelines[key];
|
||||
@@ -369,7 +383,7 @@ export function applySoftReload(
|
||||
// Reset GSAP's internal transform cache so from() tweens don't read stale
|
||||
// end values. `clearProps: "all"` is needed to flush the cache, but it also
|
||||
// nukes the element's CSS base (position, width, height, etc.) from the
|
||||
// HTML `style=""` attribute. Save → clear → restore → strip `transform`.
|
||||
// HTML `style=""` attribute. Save → clear → restore → authored transform props.
|
||||
if (allTargets.length > 0 && win.gsap?.set) {
|
||||
const saved: Array<[HTMLElement, string]> = [];
|
||||
for (const el of allTargets) {
|
||||
@@ -384,7 +398,7 @@ export function applySoftReload(
|
||||
for (const [el, css] of saved) {
|
||||
const s = el.style;
|
||||
s.cssText = css;
|
||||
s.removeProperty("transform");
|
||||
restoreAuthoredTransforms(s, findAuthoredStyle(el));
|
||||
// The restored cssText carries RUNTIME opacity, not authored opacity:
|
||||
// a mid-flight tween's interpolated value, or the color-grading hide
|
||||
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
|
||||
|
||||
@@ -161,6 +161,55 @@ describe("applyUndoRestoreToPreview", () => {
|
||||
},
|
||||
);
|
||||
|
||||
describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
|
||||
const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
|
||||
const undoScriptEdit = (live: string, authored: string, edit: string) => {
|
||||
const { iframe, contentWindow, doc } = buildLiveIframe(
|
||||
`${live}<script>${script(edit)}</script>`,
|
||||
);
|
||||
const clearProps = (targets: HTMLElement[]) =>
|
||||
targets.forEach((t) => t.removeAttribute("style"));
|
||||
Object.assign(contentWindow.gsap, { set: clearProps });
|
||||
for (const el of doc.querySelectorAll("[style*=transform]")) Object.assign(el, { _gsap: {} });
|
||||
const files = {
|
||||
[ROOT]: {
|
||||
previous: wrap(`${authored}<script>${script(edit)}</script>`),
|
||||
restored: wrap(`${authored}<script>${script("")}</script>`),
|
||||
},
|
||||
};
|
||||
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
|
||||
return doc;
|
||||
};
|
||||
|
||||
it("drops the size marks a resize left on the live element", () => {
|
||||
const doc = undoScriptEdit(
|
||||
`<div id="a" data-hf-studio-box-size="true" style="width: 502px; --hf-studio-width: 502px; --hf-studio-height: 334px">t</div>`,
|
||||
`<div id="a" style="width: 300px">t</div>`,
|
||||
`tl.set("#a",{width:502,height:334},0);`,
|
||||
);
|
||||
expect(doc.getElementById("a")!.hasAttribute("data-hf-studio-box-size")).toBe(false);
|
||||
expect(doc.getElementById("a")!.getAttribute("style")).toBe("width: 300px");
|
||||
});
|
||||
|
||||
it("keeps the authored inline rotation of an element GSAP moved", () => {
|
||||
const doc = undoScriptEdit(
|
||||
`<div id="r" style="left: 10px; transform: translate(116px, 72px) rotate(20deg)">t</div>`,
|
||||
`<div id="r" style="left: 10px; transform: rotate(20deg)">t</div>`,
|
||||
`gsap.set("#r",{x:116,y:72});`,
|
||||
);
|
||||
expect(doc.getElementById("r")!.style.transform).toBe("rotate(20deg)");
|
||||
});
|
||||
|
||||
it("drops the translate mask GSAP wrote over a stylesheet translate", () => {
|
||||
const doc = undoScriptEdit(
|
||||
`<h1 id="t" style="opacity: 0.5; translate: none; transform: translate(116px, -113px)">t</h1>`,
|
||||
`<h1 id="t">t</h1>`,
|
||||
`gsap.set("#t",{x:116,y:-113});`,
|
||||
);
|
||||
expect(doc.getElementById("t")!.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("full-reloads without partially restoring when any changed live target is missing", () => {
|
||||
const { iframe, doc } = buildLiveIframe(`<div id="a" style="z-index: 8">a</div>`);
|
||||
const reloadPreview = vi.fn();
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
extractGsapScriptText,
|
||||
findGsapScriptElements,
|
||||
} from "./gsapSoftReload";
|
||||
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
|
||||
import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
|
||||
import { markScenesStale } from "../player/sceneSwap";
|
||||
|
||||
type PreviewWindow = Window & {
|
||||
@@ -135,6 +137,17 @@ function syncElementAttributes(target: Element, source: Element): void {
|
||||
}
|
||||
}
|
||||
|
||||
// A gesture folded into the script leaves marks on the live element that every seek would re-impose.
|
||||
function syncStaleEditMarks(doc: Document, restored: string): void {
|
||||
const restoredDoc = parseSavedSource(restored);
|
||||
for (const live of doc.querySelectorAll(STUDIO_EDIT_ATTRS.map((attr) => `[${attr}]`).join())) {
|
||||
const source = findAuthoredElement(restoredDoc, live);
|
||||
if (source && STUDIO_EDIT_ATTRS.some((a) => live.getAttribute(a) !== source.getAttribute(a))) {
|
||||
syncElementAttributes(live, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readGsapScriptTexts(html: string): string[] {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return findGsapScriptElements(doc).map((script) => script.textContent ?? "");
|
||||
@@ -248,9 +261,11 @@ export function applyUndoRestoreToPreview(
|
||||
const restoredScript = extractGsapScriptText(restored);
|
||||
const previousScript = extractGsapScriptText(previous);
|
||||
if (restoredScript && restoredScript !== previousScript) {
|
||||
syncStaleEditMarks(doc, restored);
|
||||
const result = applySoftReload(iframe, restoredScript, {
|
||||
onAsyncFailure: reloadPreview,
|
||||
currentTimeOverride: currentTime,
|
||||
authoredHtml: restored,
|
||||
});
|
||||
if (result === "cannot-soft-reload") {
|
||||
reloadPreview();
|
||||
|
||||
Reference in New Issue
Block a user