diff --git a/packages/studio/src/utils/gsapUndoRestore.test.ts b/packages/studio/src/utils/gsapUndoRestore.test.ts
index 83b93aed8e..1b0158b7fa 100644
--- a/packages/studio/src/utils/gsapUndoRestore.test.ts
+++ b/packages/studio/src/utils/gsapUndoRestore.test.ts
@@ -2,6 +2,9 @@
import { describe, it, expect, vi } from "vitest";
import { applyUndoRestoreToPreview, diffSoftReloadableRestore } from "./gsapUndoRestore";
+import { applyPatch } from "./sourcePatcher";
+import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
+import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
@@ -420,3 +423,50 @@ describe("applyUndoRestoreToPreview", () => {
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
});
+
+describe("an undo that lands while the layer is being dragged", () => {
+ const ROOT = "index.html";
+ const undone = `
t
`;
+ const restored = wrap(`t
`);
+ const files = { [ROOT]: { previous: wrap(undone), restored } };
+ const saveDrop = (el: HTMLElement, file: string) =>
+ writePlainMove(el, { x: 20, y: 110 }).reduce((html, op) => applyPatch(html, "a", op), file);
+
+ function dragging(drag: boolean) {
+ const { iframe, doc } = buildLiveIframe(undone);
+ const el = doc.getElementById("a")!;
+ if (drag) beginStudioManualEditGesture(el);
+ if (drag) writeTranslatePx(el, { x: 70, y: 110 });
+ applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
+ return el;
+ }
+
+ it("keeps the translate the drag is drawing and reverts the rest", () => {
+ const el = dragging(true);
+
+ expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
+ expect(el.style.getPropertyValue("width")).toBe("100px");
+ expect(el.getAttribute("data-start")).toBe("1");
+ expect(el.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
+ });
+
+ it("saves the drop onto the undone file, the same bytes as dragging the undone layer", () => {
+ const el = dragging(true);
+ const fresh = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;
+
+ const saved = saveDrop(el, restored);
+
+ expect(saved).toBe(saveDrop(fresh, restored));
+ expect(saved).toContain("width: 100px; translate: 20px 110px");
+ expect(saved).not.toContain("120px");
+ });
+
+ it("restores a layer no gesture is drawing exactly as before", () => {
+ const el = dragging(false);
+ const want = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;
+
+ expect(el.getAttributeNames().map((n) => [n, el.getAttribute(n)])).toEqual(
+ want.getAttributeNames().map((n) => [n, want.getAttribute(n)]),
+ );
+ });
+});
diff --git a/packages/studio/src/utils/gsapUndoRestore.ts b/packages/studio/src/utils/gsapUndoRestore.ts
index e8fa92ad2f..2b0a6ce165 100644
--- a/packages/studio/src/utils/gsapUndoRestore.ts
+++ b/packages/studio/src/utils/gsapUndoRestore.ts
@@ -12,6 +12,10 @@ import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
import { markScenesStale } from "../player/sceneSwap";
+import {
+ STUDIO_MANUAL_EDIT_GESTURE_ATTR,
+ STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
+} from "../components/editor/manualEditsTypes";
type PreviewWindow = Window & {
__player?: { seek?: (t: number) => void };
@@ -141,15 +145,38 @@ function diffRestoreDocs(prevDoc: Document, nextDoc: Document): string[] | null
}
/** Copy every attribute from `source` onto the live `target`, dropping extras. */
-function syncElementAttributes(target: Element, source: Element): void {
+function syncElementAttributes(target: Element, source: Element, base?: Element): void {
+ const merged =
+ base && target.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)
+ ? keepDrawn(target, source, base)
+ : source;
for (const name of [...target.getAttributeNames()]) {
- if (!source.hasAttribute(name)) target.removeAttribute(name);
+ if (!merged.hasAttribute(name)) target.removeAttribute(name);
}
- for (const name of source.getAttributeNames()) {
- target.setAttribute(name, source.getAttribute(name) ?? "");
+ for (const name of merged.getAttributeNames()) {
+ target.setAttribute(name, merged.getAttribute(name) ?? "");
}
}
+/** `source`, plus what a gesture in progress drew on `live` since `base`, and the record its draws need. */
+function keepDrawn(live: Element, source: Element, base: Element): Element {
+ const merged = source.cloneNode(false) as Element;
+ for (const name of live.getAttributeNames()) {
+ const value = live.getAttribute(name)!;
+ const own = name === STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR && !source.hasAttribute(name);
+ if (name !== "style" && (own || value !== base.getAttribute(name)))
+ merged.setAttribute(name, value);
+ }
+ const [liveStyle, baseStyle, style] = [live, base, merged].map((el) => (el as HTMLElement).style);
+ for (const prop of new Set([...Array.from(liveStyle), ...Array.from(baseStyle)])) {
+ const value = liveStyle.getPropertyValue(prop);
+ if (value === baseStyle.getPropertyValue(prop)) continue;
+ if (value) style.setProperty(prop, value, liveStyle.getPropertyPriority(prop));
+ else style.removeProperty(prop);
+ }
+ return merged;
+}
+
// 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);
@@ -172,7 +199,7 @@ function hasAmbiguousGsapScriptChange(
);
}
-type RestoreTarget = { live: Element; restored: Element };
+type RestoreTarget = { live: Element; restored: Element; previous?: Element };
type RestorePlan = { targets: RestoreTarget[]; scripted: boolean };
/** Whether either side has a GSAP script, or null when its scripts rule out an in-place restore. */
@@ -196,6 +223,7 @@ function scopeTargets(
scope: ParentNode,
keys: string[],
restoredByKey: Map,
+ previousByKey: Map,
): RestoreTarget[] | null {
const liveByKey = identityElementMap(scope);
if (!liveByKey) return null;
@@ -205,7 +233,7 @@ function scopeTargets(
const restored = restoredByKey.get(key);
// The preview rewrites a sub-composition's own root, so its attributes are not the file's.
if (!live || !restored || live.hasAttribute("data-hf-inner-root")) return null;
- targets.push({ live, restored });
+ targets.push({ live, restored, previous: previousByKey.get(key) });
}
return targets;
}
@@ -222,9 +250,10 @@ function fileTargets(
if (scripted === null) return null;
const keys = diffRestoreDocs(prevDoc, nextDoc);
const restoredByKey = identityElementMap(nextDoc);
- if (!keys || !restoredByKey) return null;
+ const previousByKey = identityElementMap(prevDoc);
+ if (!keys || !restoredByKey || !previousByKey) return null;
const found = liveScopes(doc, path, isActive).map((scope) =>
- scopeTargets(scope, keys, restoredByKey),
+ scopeTargets(scope, keys, restoredByKey, previousByKey),
);
if (!found.length || found.includes(null)) return null;
return { targets: (found as RestoreTarget[][]).flat(), scripted };
@@ -273,11 +302,14 @@ export function showRestoreInPlace(
const win = iframe?.contentWindow as PreviewWindow | null;
const plan = doc && win ? planRestoreTargets(doc, activeCompPath ?? "index.html", files) : null;
if (!iframe || !win || !plan || plan.scripted) return null;
- const before = plan.targets.map(({ live }) => [live, live.cloneNode(false) as Element] as const);
- for (const { live, restored } of plan.targets) syncElementAttributes(live, restored);
+ const before = plan.targets.map(
+ ({ live, restored }) => [live, live.cloneNode(false) as Element, restored] as const,
+ );
+ for (const { live, restored, previous } of plan.targets)
+ syncElementAttributes(live, restored, previous);
const putBack = (time: number) => {
if (iframe.contentDocument !== doc) return false;
- for (const [live, attributes] of before) syncElementAttributes(live, attributes);
+ for (const [live, attributes, shown] of before) syncElementAttributes(live, attributes, shown);
return finalizeInPlace(iframe, win, time);
};
if (finalizeInPlace(iframe, win, currentTime)) return putBack;
@@ -338,7 +370,8 @@ export function applyUndoRestoreToPreview(
}
// Sync each changed element's attributes onto the live DOM from the restored
// markup, so the runtime's seek-reapply reads the reverted values.
- for (const target of plan.targets) syncElementAttributes(target.live, target.restored);
+ for (const target of plan.targets)
+ syncElementAttributes(target.live, target.restored, target.previous);
const active = files[activeDocPath];
const restoredScript = active ? extractGsapScriptText(active.restored) : null;