Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/studio/src/hooks/usePreviewPersistence.history.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import React, { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../player/store/playerStore";
import type { TimelineElement } from "../player/store/timelineElement";
import {
beginStudioManualEditGesture,
endStudioManualEditGesture,
} from "../components/editor/manualEdits";
import { mountReactHarness } from "./domSelectionTestHarness";
import { usePreviewPersistence } from "./usePreviewPersistence";

Expand Down Expand Up @@ -132,3 +136,82 @@ describe("undo that re-runs the top-level script over an element of a nested com
expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
});
});

describe("undo that lands while a newer gesture holds an element", () => {
const page = (left: number) =>
`<html><body><div id="root" data-composition-id="root"><div id="box" data-hf-id="hf-b" style="left: ${left}px"></div></div></body></html>`;
const files = { "index.html": { previous: page(0), restored: page(200) } };

function heldPreview(held = true) {
const doc = document.implementation.createHTMLDocument("");
doc.head.innerHTML = `<meta name="hf-scene-parts" content='{"shared":"s1","scenes":{}}'>`;
doc.body.innerHTML = `<div id="root" data-composition-id="root"><div id="box" data-hf-id="hf-b" style="left: 90px"></div></div>`;
const box = doc.getElementById("box")!;
if (held) beginStudioManualEditGesture(box, "move");
const contentWindow = {
__hfForceTimelineRebind: vi.fn(),
__timelines: {},
__player: { getTime: () => 1, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
};
const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
const reloadPreview = vi.fn();
let hook: ReturnType<typeof usePreviewPersistence> | null = null;
function Harness() {
hook = usePreviewPersistence({
showToast: () => {},
readOptionalProjectFile: async () => "",
writeProjectFile: async () => {},
recordEdit: async () => {},
previewIframeRef: { current: iframe },
activeCompPathRef: { current: "index.html" },
reloadPreview,
});
return null;
}
mountReactHarness(<Harness />);
usePlayerStore.getState().setSelectedElementId("box");
const sharedPart = () =>
JSON.parse(doc.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]')!.content).shared;
return { box, win: contentWindow, reloadPreview, sharedPart, hook: () => hook! };
}

it("leaves the held element where the gesture drew it and reloads the preview once it ends", async () => {
const { box, win, reloadPreview, sharedPart, hook } = heldPreview();

await act(async () => hook().syncHistoryPreviewAfterApply({ paths: ["index.html"], files }));

expect(box.style.left).toBe("90px");
expect(win.__player.seek).not.toHaveBeenCalled();
expect(win.__hfStudioManualEditsApply).not.toHaveBeenCalled();
expect(reloadPreview).not.toHaveBeenCalled();
endStudioManualEditGesture(box);
expect(reloadPreview).toHaveBeenCalledTimes(1);
// The scene swap must load the restored file again, not keep the part it showed before the undo.
expect(sharedPart()).toBe("");
expect(usePlayerStore.getState().selectedElementId).toBe("box");
});

it("does not paint a predicted undo under the gesture", () => {
const { box, win, hook } = heldPreview();

expect(hook().showHistoryRestoreNow(files)).toBeNull();
expect(box.style.left).toBe("90px");
expect(win.__player.seek).not.toHaveBeenCalled();
});

it("puts a refused predicted undo back through the preview reload once a gesture took the element", async () => {
const { box, win, reloadPreview, hook } = heldPreview(false);
const putBack = hook().showHistoryRestoreNow(files)!;
beginStudioManualEditGesture(box, "move");
win.__player.seek.mockClear();

putBack();
await act(async () => {});
expect(reloadPreview).not.toHaveBeenCalled();
endStudioManualEditGesture(box);

expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(win.__player.seek).not.toHaveBeenCalled();
});
});
30 changes: 26 additions & 4 deletions packages/studio/src/hooks/usePreviewPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import {
installStudioManualEditSeekReapply,
reapplyPositionEditsAfterSeek,
} from "../components/editor/manualEdits";
import {
afterStudioManualEditGestures,
isStudioManualEditGestureLiveIn,
} from "../components/editor/manualEditsDom";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import { markScenesStale } from "../player/sceneSwap";
import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
import {
flushStudioPendingEdits,
Expand Down Expand Up @@ -199,6 +204,12 @@ export function usePreviewPersistence({

// ── Sync preview after undo/redo ──

// Undo never repaints under a live gesture; the preview reloads once the last one ends.
const heldPreviewDoc = useCallback(() => {
const doc = previewIframeRef.current?.contentDocument;
return doc && isStudioManualEditGestureLiveIn(doc) ? doc : null;
}, [previewIframeRef]);

const syncHistoryPreviewAfterApply = useCallback(
async (restore: HistoryPreviewRestore) => {
// Prefer an in-place soft reload for a soft-reloadable restore (the change
Expand All @@ -220,6 +231,15 @@ export function usePreviewPersistence({
: Promise.reject(new Error("No project is open to read nested files from.")),
),
);
const held = heldPreviewDoc();
if (held) {
const paths = restore.paths ?? Object.keys(restore.files ?? {});
afterStudioManualEditGestures(held, () => {
markScenesStale(previewIframeRef.current, paths);
reloadPreview();
});
return;
}
const strategy = applyUndoRestoreToPreview(
previewIframeRef.current,
activeCompPathRef.current,
Expand All @@ -238,24 +258,26 @@ export function usePreviewPersistence({
// reload. The full path above waits for the reloaded preview to report instead.
syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
},
[previewIframeRef, activeCompPathRef, reloadPreview],
[previewIframeRef, activeCompPathRef, reloadPreview, heldPreviewDoc],
);

// A restore the server has not confirmed yet: in place now, or not at all. A GSAP script re-run is not
// synchronous, and a pending save would land under it.
const showHistoryRestoreNow = useCallback(
(files: RestoreFiles): (() => void) | null => {
if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits()) return null;
if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits() || heldPreviewDoc())
return null;
const iframe = previewIframeRef.current;
const now = () => usePlayerStore.getState().currentTime;
const putBack = showRestoreInPlace(iframe, activeCompPathRef.current, files, now());
if (!putBack) return null;
return () => {
if (putBack(now())) syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
if (!heldPreviewDoc() && putBack(now()))
syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) });
};
},
[previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply],
[previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply, heldPreviewDoc],
);

// ── Migrate legacy studio-motion.json ──
Expand Down
Loading