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;