Skip to content

Commit 99f1934

Browse files
committed
fix(studio): undoing a crop leaves a GSAP-animated layer where its file puts it
1 parent ddf2bcc commit 99f1934

3 files changed

Lines changed: 110 additions & 6 deletions

File tree

‎packages/studio/src/utils/gsapSoftReload.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export interface SoftReloadOptions {
195195
currentTimeOverride?: number;
196196
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
197197
authoredHtml?: string;
198+
reparse?: Element[];
198199
}
199200

200201
/**
@@ -251,7 +252,7 @@ export function applySoftReload(
251252
scriptText: string,
252253
options: SoftReloadOptions = {},
253254
): SoftReloadResult {
254-
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
255+
const { onAsyncFailure, currentTimeOverride, authoredHtml, reparse = [] } = options;
255256
if (!iframe || !scriptText) return "cannot-soft-reload";
256257

257258
const win = iframe.contentWindow as IframeWindow | null;
@@ -329,7 +330,7 @@ export function applySoftReload(
329330
// fallow-ignore-next-line complexity
330331
const doReload = () => {
331332
const timelines = win.__timelines;
332-
const allTargets: Element[] = [];
333+
const allTargets: Element[] = [...reparse];
333334

334335
// Kill ONLY the target composition's timeline(s) — leaving every other
335336
// composition's timeline (and its children on the global timeline) intact.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Real Chrome: the box GSAP and a stylesheet translate produce together is what no DOM emulation computes.
2+
import { mkdtempSync, readFileSync } from "node:fs";
3+
import { createRequire } from "node:module";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
import puppeteer, { type Browser } from "puppeteer-core";
8+
import { build } from "vite";
9+
import { afterAll, beforeAll, expect, it } from "vitest";
10+
import { findSystemChrome } from "../../vite.browser";
11+
import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";
12+
13+
const require = createRequire(import.meta.url);
14+
const CROP = "inset(0px 40px 0px 0px)";
15+
let browser: Browser;
16+
let undoBundle: string;
17+
18+
beforeAll(async () => {
19+
const executablePath = findSystemChrome();
20+
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
21+
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
22+
const out = await build({
23+
configFile: false,
24+
logLevel: "silent",
25+
resolve: {
26+
alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
27+
},
28+
build: {
29+
write: false,
30+
minify: false,
31+
lib: {
32+
entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
33+
formats: ["iife"],
34+
name: "hfUndo",
35+
},
36+
},
37+
});
38+
undoBundle = (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0]
39+
.code;
40+
}, 60_000);
41+
42+
afterAll(() => browser?.close());
43+
44+
it.each([
45+
{ gsap: "hold", placement: "px", rotation: 0 },
46+
{ gsap: "tween", placement: "px", rotation: 0 },
47+
{ gsap: "tween", placement: "xpercent", rotation: 30 },
48+
])("an undone crop leaves a $gsap $placement r$rotation layer where it was", async (spec) => {
49+
const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
50+
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
51+
const restored = readFileSync(join(dir, "index.html"), "utf8");
52+
const previous = restored.replace(
53+
`<div id="target"`,
54+
`<div id="target" style="clip-path: ${CROP}"`,
55+
);
56+
const page = await browser.newPage();
57+
await page.setViewport({ width: 1920, height: 1080 });
58+
await page.setRequestInterception(true);
59+
page.on("request", (request) =>
60+
request.url().endsWith("/gsap.min.js")
61+
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
62+
: request.continue(),
63+
);
64+
await page.setContent(restored, { waitUntil: "load" });
65+
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
66+
await page.waitForFunction(() => "__player" in window);
67+
await page.evaluate(undoBundle);
68+
69+
const box = () =>
70+
page.evaluate(() => {
71+
const { left, top, width, height } = document
72+
.getElementById("target")!
73+
.getBoundingClientRect();
74+
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
75+
});
76+
await page.evaluate(() =>
77+
(window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
78+
);
79+
const before = await box();
80+
const outcome = await page.evaluate(
81+
(files, crop) => {
82+
document.getElementById("target")!.style.setProperty("clip-path", crop);
83+
const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
84+
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
85+
return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
86+
},
87+
{ "index.html": { previous, restored } },
88+
CROP,
89+
);
90+
91+
expect(outcome).toBe("soft");
92+
expect(await box()).toEqual(before);
93+
await page.close();
94+
});

‎packages/studio/src/utils/gsapUndoRestore.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,10 @@ export function showRestoreInPlace(
338338
* element — so a canvas-position revert lands on the live DOM the runtime's
339339
* seek-reapply reads from, not just on disk.
340340
* 2. The runtime refresh depends on what changed:
341-
* - GSAP script text actually CHANGED between previous and restored → the
342-
* restored script is re-run in place via applySoftReload (re-seeks to
343-
* `currentTime`, re-folds manual edits).
341+
* - GSAP script text actually CHANGED between previous and restored, or a
342+
* synced element was parsed by GSAP → the restored script is re-run in
343+
* place via applySoftReload (re-seeks to `currentTime`, re-folds manual
344+
* edits, re-parses those elements); several scripts can't, so it reloads.
344345
* - Script unchanged or absent (the overwhelmingly common undo: z-order,
345346
* lane move, timing shift, style tweak) → NO script execution — the
346347
* blink-free finalization only (seek + __hfForceTimelineRebind + manual
@@ -383,12 +384,20 @@ export function applyUndoRestoreToPreview(
383384
const active = files[activeDocPath];
384385
const restoredScript = active ? extractGsapScriptText(active.restored) : null;
385386
const previousScript = active ? extractGsapScriptText(active.previous) : null;
386-
if (restoredScript && restoredScript !== previousScript) {
387+
const reparse = plan.scripted
388+
? plan.targets.map(({ live }) => live).filter((el) => "_gsap" in el)
389+
: [];
390+
if (reparse.length && !restoredScript) {
391+
reloadPreview();
392+
return "full";
393+
}
394+
if (restoredScript && (restoredScript !== previousScript || reparse.length)) {
387395
syncStaleEditMarks(doc, active);
388396
const result = applySoftReload(iframe, restoredScript, {
389397
onAsyncFailure: reloadPreview,
390398
currentTimeOverride: currentTime,
391399
authoredHtml: active.restored,
400+
reparse,
392401
});
393402
if (result === "cannot-soft-reload") {
394403
reloadPreview();

0 commit comments

Comments
 (0)