Skip to content

Commit e076628

Browse files
committed
fix(studio): undo and redo of an edit inside a nested composition apply in place
1 parent da0dbc2 commit e076628

2 files changed

Lines changed: 94 additions & 52 deletions

File tree

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,35 @@ describe("applyUndoRestoreToPreview", () => {
366366
expect(meta.content).toBe(content);
367367
});
368368

369+
const SUB = "compositions/sub.html";
370+
const sub = (style: string, rootAttrs = "") =>
371+
`<template id="sub-template"><div data-hf-id="hf-root" id="sub" data-composition-id="sub"${rootAttrs}><div ${style} data-hf-id="hf-t" id="target"></div></div></template>`;
372+
const host = (style: string) =>
373+
`<div data-composition-file="${SUB}" data-hf-id="hf-host"><div data-hf-inner-root="true" data-hf-authored-id="sub" data-hf-id="hf-root"><div data-hf-id="hf-t" id="target" ${style}></div></div></div>`;
374+
375+
it("restores a sub-composition file in place, on every host that inlines it", () => {
376+
const { iframe, doc } = buildLiveIframe(
377+
host(`style="clip-path: inset(0px 40px 0px 0px);"`) +
378+
host(`style="clip-path: inset(0px 40px 0px 0px);"`),
379+
);
380+
const reloadPreview = vi.fn();
381+
const files = {
382+
[SUB]: { previous: sub(`style="clip-path: inset(0px 40px 0px 0px)"`), restored: sub("") },
383+
};
384+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
385+
expect(reloadPreview).not.toHaveBeenCalled();
386+
const targets = [...doc.querySelectorAll('[data-hf-id="hf-t"]')];
387+
expect(targets.map((el) => el.getAttribute("style"))).toEqual([null, null]);
388+
});
389+
390+
it("full-reloads a sub-composition restore that changes the file's own root", () => {
391+
const { iframe } = buildLiveIframe(host(""));
392+
const reloadPreview = vi.fn();
393+
const files = { [SUB]: { previous: sub("", ' data-width="10"'), restored: sub("") } };
394+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
395+
expect(reloadPreview).toHaveBeenCalledTimes(1);
396+
});
397+
369398
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
370399
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
371400
const reloadPreview = vi.fn();

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

Lines changed: 65 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
extractGsapScriptText,
99
findGsapScriptElements,
1010
} from "./gsapSoftReload";
11+
import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
1112
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
1213
import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
1314
import { markScenesStale } from "../player/sceneSwap";
@@ -40,9 +41,9 @@ function elementIdentityKey(el: Element): string | null {
4041

4142
const IDENTITY_SELECTOR = "[id], [data-hf-id]";
4243

43-
function identityElementMap(doc: Document): Map<string, Element> | null {
44+
function identityElementMap(root: ParentNode): Map<string, Element> | null {
4445
const map = new Map<string, Element>();
45-
for (const el of doc.querySelectorAll(IDENTITY_SELECTOR)) {
46+
for (const el of root.querySelectorAll(IDENTITY_SELECTOR)) {
4647
const key = elementIdentityKey(el);
4748
if (!key) continue;
4849
// Ambiguous identity must full-reload; silently overwriting would restore
@@ -53,6 +54,17 @@ function identityElementMap(doc: Document): Map<string, Element> | null {
5354
return map;
5455
}
5556

57+
// A sub-composition file wraps its markup in a template; the preview inlines that markup into its host.
58+
function parseRestoreSource(html: string): Document {
59+
const doc = new DOMParser().parseFromString(html, "text/html");
60+
for (const template of Array.from(doc.querySelectorAll("template")).filter(
61+
isCompositionTemplate,
62+
)) {
63+
template.replaceWith(template.content);
64+
}
65+
return doc;
66+
}
67+
5668
// Strip identified elements to their bare identity attributes and blank GSAP
5769
// scripts, in place: docs that differ only in identified-element attributes/
5870
// inline-style/script text normalize equal; any residual difference is beyond
@@ -103,8 +115,8 @@ export function diffSoftReloadableRestore(
103115
let prevDoc: Document;
104116
let nextDoc: Document;
105117
try {
106-
prevDoc = new DOMParser().parseFromString(previous, "text/html");
107-
nextDoc = new DOMParser().parseFromString(restored, "text/html");
118+
prevDoc = parseRestoreSource(previous);
119+
nextDoc = parseRestoreSource(restored);
108120
} catch {
109121
return null;
110122
}
@@ -163,11 +175,48 @@ function hasAmbiguousGsapScriptChange(previous: string, restored: string): boole
163175
);
164176
}
165177

178+
/**
179+
* Every live element a restore changes, paired with its restored markup, or null when the restore is
180+
* beyond an attribute sync. A file is the active document or a sub-composition the live preview
181+
* inlines into each `[data-composition-file]` host; only the active document's GSAP script may change.
182+
*/
183+
function planRestoreTargets(
184+
doc: Document,
185+
activeDocPath: string,
186+
files: Record<string, UndoRestoreFile>,
187+
): Array<{ live: Element; restored: Element }> | null {
188+
const targets: Array<{ live: Element; restored: Element }> = [];
189+
for (const [path, { previous, restored }] of Object.entries(files)) {
190+
const isActive = path === activeDocPath;
191+
const scopes: ParentNode[] = isActive
192+
? [doc]
193+
: Array.from(doc.querySelectorAll(`[data-composition-file="${CSS.escape(path)}"]`));
194+
const diff = diffSoftReloadableRestore(previous, restored);
195+
if (!scopes.length || !diff || hasAmbiguousGsapScriptChange(previous, restored)) return null;
196+
if (!isActive && extractGsapScriptText(previous) !== extractGsapScriptText(restored))
197+
return null;
198+
const restoredByKey = identityElementMap(parseRestoreSource(restored));
199+
if (!restoredByKey) return null;
200+
for (const scope of scopes) {
201+
const liveByKey = identityElementMap(scope);
202+
if (!liveByKey) return null;
203+
for (const key of diff.changedElementKeys) {
204+
const live = liveByKey.get(key);
205+
const restoredEl = restoredByKey.get(key);
206+
// The preview rewrites a sub-composition's own root, so its attributes are not the file's.
207+
if (!live || !restoredEl || live.hasAttribute("data-hf-inner-root")) return null;
208+
targets.push({ live, restored: restoredEl });
209+
}
210+
}
211+
}
212+
return targets;
213+
}
214+
166215
/**
167216
* Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
168-
* remount (which blanks the frame black and re-flashes the WebGL context). Only
169-
* the active composition — the document living in the root iframe — is eligible;
170-
* a sub-comp or multi-file restore falls back to `reloadPreview`.
217+
* remount (which blanks the frame black and re-flashes the WebGL context). Eligible
218+
* files are the active composition and any sub-composition the preview inlines;
219+
* a file the preview does not show falls back to `reloadPreview`.
171220
*
172221
* The restore is soft-applied when its only differences are identified-element
173222
* (id / data-hf-id) attributes / inline-style and/or the GSAP script (see
@@ -210,62 +259,26 @@ export function applyUndoRestoreToPreview(
210259
markScenesStale(iframe, paths);
211260
reload();
212261
};
213-
// Soft path only covers the single active-comp document in the root iframe.
214-
if (!iframe || !files || paths.length !== 1 || paths[0] !== activeDocPath) {
215-
reloadPreview();
216-
return "full";
217-
}
218-
const doc = iframe.contentDocument;
219-
const win = iframe.contentWindow as PreviewWindow | null;
220-
if (!doc || !win) {
262+
const doc = iframe?.contentDocument;
263+
const win = iframe?.contentWindow as PreviewWindow | null;
264+
const changedTargets = doc && files && win ? planRestoreTargets(doc, activeDocPath, files) : null;
265+
if (!doc || !files || !win || !changedTargets) {
221266
reloadPreview();
222267
return "full";
223268
}
224-
const { previous, restored } = files[activeDocPath]!;
225-
const diff = diffSoftReloadableRestore(previous, restored);
226-
if (!diff) {
227-
reloadPreview();
228-
return "full";
229-
}
230-
// A serialized snapshot cannot identify which of several GSAP scripts owns a
231-
// rewrite. Keep attribute-only restores soft when every script byte is equal,
232-
// but fail closed before touching the live DOM when an ambiguous script changed.
233-
if (hasAmbiguousGsapScriptChange(previous, restored)) {
234-
reloadPreview();
235-
return "full";
236-
}
237-
238-
// Resolve every changed pair BEFORE touching the live DOM. A missing target
239-
// makes the soft restore incomplete, so escalate without leaving a partially
240-
// restored preview behind.
241-
const liveByKey = identityElementMap(doc);
242-
const restoredByKey = identityElementMap(new DOMParser().parseFromString(restored, "text/html"));
243-
if (!liveByKey || !restoredByKey) {
244-
reloadPreview();
245-
return "full";
246-
}
247-
const changedTargets: Array<{ live: Element; restored: Element }> = [];
248-
for (const key of diff.changedElementKeys) {
249-
const liveEl = liveByKey.get(key);
250-
const restoredEl = restoredByKey.get(key);
251-
if (!liveEl || !restoredEl) {
252-
reloadPreview();
253-
return "full";
254-
}
255-
changedTargets.push({ live: liveEl, restored: restoredEl });
256-
}
257269
// Sync each changed element's attributes onto the live DOM from the restored
258270
// markup, so the runtime's seek-reapply reads the reverted values.
259271
for (const target of changedTargets) syncElementAttributes(target.live, target.restored);
260272

261-
const restoredScript = extractGsapScriptText(restored);
262-
const previousScript = extractGsapScriptText(previous);
273+
const active = files[activeDocPath];
274+
const restoredScript = active ? extractGsapScriptText(active.restored) : null;
275+
const previousScript = active ? extractGsapScriptText(active.previous) : null;
263276
if (restoredScript && restoredScript !== previousScript) {
264-
syncStaleEditMarks(doc, restored);
277+
syncStaleEditMarks(doc, active.restored);
265278
const result = applySoftReload(iframe, restoredScript, {
266279
onAsyncFailure: reloadPreview,
267280
currentTimeOverride: currentTime,
268-
authoredHtml: restored,
281+
authoredHtml: active.restored,
269282
});
270283
if (result === "cannot-soft-reload") {
271284
reloadPreview();

0 commit comments

Comments
 (0)