mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
fix(studio): an undo that lands mid-drag keeps the box under the pointer
This commit is contained in:
@@ -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 = `<div id="a" style="width: 120px; translate: 90px 60px" data-start="2" data-hf-studio-original-inline-translate="">t</div>`;
|
||||
const restored = wrap(`<div id="a" style="width: 100px" data-start="1">t</div>`);
|
||||
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)]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, Element>,
|
||||
previousByKey: Map<string, Element>,
|
||||
): 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;
|
||||
|
||||
Reference in New Issue
Block a user