Skip to content

Commit 2bee99f

Browse files
fix(studio): undo no longer snaps back a box you started dragging again (#5039)
* fix(studio): undo no longer snaps back a box you started dragging again * fix(studio): undo reloads the preview after the drag ends, and the bench checks it * test(studio): leave the edit bench sequence for the resize-then-drag fix * fix(studio): the undo reload after a drag also refreshes the restored scenes
1 parent cd82ce0 commit 2bee99f

2 files changed

Lines changed: 109 additions & 4 deletions

File tree

‎packages/studio/src/hooks/usePreviewPersistence.history.test.tsx‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import React, { act } from "react";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import { usePlayerStore } from "../player/store/playerStore";
66
import type { TimelineElement } from "../player/store/timelineElement";
7+
import {
8+
beginStudioManualEditGesture,
9+
endStudioManualEditGesture,
10+
} from "../components/editor/manualEdits";
711
import { mountReactHarness } from "./domSelectionTestHarness";
812
import { usePreviewPersistence } from "./usePreviewPersistence";
913

@@ -132,3 +136,82 @@ describe("undo that re-runs the top-level script over an element of a nested com
132136
expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
133137
});
134138
});
139+
140+
describe("undo that lands while a newer gesture holds an element", () => {
141+
const page = (left: number) =>
142+
`<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>`;
143+
const files = { "index.html": { previous: page(0), restored: page(200) } };
144+
145+
function heldPreview(held = true) {
146+
const doc = document.implementation.createHTMLDocument("");
147+
doc.head.innerHTML = `<meta name="hf-scene-parts" content='{"shared":"s1","scenes":{}}'>`;
148+
doc.body.innerHTML = `<div id="root" data-composition-id="root"><div id="box" data-hf-id="hf-b" style="left: 90px"></div></div>`;
149+
const box = doc.getElementById("box")!;
150+
if (held) beginStudioManualEditGesture(box, "move");
151+
const contentWindow = {
152+
__hfForceTimelineRebind: vi.fn(),
153+
__timelines: {},
154+
__player: { getTime: () => 1, seek: vi.fn() },
155+
__hfStudioManualEditsApply: vi.fn(),
156+
};
157+
const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
158+
const reloadPreview = vi.fn();
159+
let hook: ReturnType<typeof usePreviewPersistence> | null = null;
160+
function Harness() {
161+
hook = usePreviewPersistence({
162+
showToast: () => {},
163+
readOptionalProjectFile: async () => "",
164+
writeProjectFile: async () => {},
165+
recordEdit: async () => {},
166+
previewIframeRef: { current: iframe },
167+
activeCompPathRef: { current: "index.html" },
168+
reloadPreview,
169+
});
170+
return null;
171+
}
172+
mountReactHarness(<Harness />);
173+
usePlayerStore.getState().setSelectedElementId("box");
174+
const sharedPart = () =>
175+
JSON.parse(doc.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]')!.content).shared;
176+
return { box, win: contentWindow, reloadPreview, sharedPart, hook: () => hook! };
177+
}
178+
179+
it("leaves the held element where the gesture drew it and reloads the preview once it ends", async () => {
180+
const { box, win, reloadPreview, sharedPart, hook } = heldPreview();
181+
182+
await act(async () => hook().syncHistoryPreviewAfterApply({ paths: ["index.html"], files }));
183+
184+
expect(box.style.left).toBe("90px");
185+
expect(win.__player.seek).not.toHaveBeenCalled();
186+
expect(win.__hfStudioManualEditsApply).not.toHaveBeenCalled();
187+
expect(reloadPreview).not.toHaveBeenCalled();
188+
endStudioManualEditGesture(box);
189+
expect(reloadPreview).toHaveBeenCalledTimes(1);
190+
// The scene swap must load the restored file again, not keep the part it showed before the undo.
191+
expect(sharedPart()).toBe("");
192+
expect(usePlayerStore.getState().selectedElementId).toBe("box");
193+
});
194+
195+
it("does not paint a predicted undo under the gesture", () => {
196+
const { box, win, hook } = heldPreview();
197+
198+
expect(hook().showHistoryRestoreNow(files)).toBeNull();
199+
expect(box.style.left).toBe("90px");
200+
expect(win.__player.seek).not.toHaveBeenCalled();
201+
});
202+
203+
it("puts a refused predicted undo back through the preview reload once a gesture took the element", async () => {
204+
const { box, win, reloadPreview, hook } = heldPreview(false);
205+
const putBack = hook().showHistoryRestoreNow(files)!;
206+
beginStudioManualEditGesture(box, "move");
207+
win.__player.seek.mockClear();
208+
209+
putBack();
210+
await act(async () => {});
211+
expect(reloadPreview).not.toHaveBeenCalled();
212+
endStudioManualEditGesture(box);
213+
214+
expect(reloadPreview).toHaveBeenCalledTimes(1);
215+
expect(win.__player.seek).not.toHaveBeenCalled();
216+
});
217+
});

‎packages/studio/src/hooks/usePreviewPersistence.ts‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import {
44
installStudioManualEditSeekReapply,
55
reapplyPositionEditsAfterSeek,
66
} from "../components/editor/manualEdits";
7+
import {
8+
afterStudioManualEditGestures,
9+
isStudioManualEditGestureLiveIn,
10+
} from "../components/editor/manualEditsDom";
711
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
12+
import { markScenesStale } from "../player/sceneSwap";
813
import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
914
import {
1015
flushStudioPendingEdits,
@@ -199,6 +204,12 @@ export function usePreviewPersistence({
199204

200205
// ── Sync preview after undo/redo ──
201206

207+
// Undo never repaints under a live gesture; the preview reloads once the last one ends.
208+
const heldPreviewDoc = useCallback(() => {
209+
const doc = previewIframeRef.current?.contentDocument;
210+
return doc && isStudioManualEditGestureLiveIn(doc) ? doc : null;
211+
}, [previewIframeRef]);
212+
202213
const syncHistoryPreviewAfterApply = useCallback(
203214
async (restore: HistoryPreviewRestore) => {
204215
// Prefer an in-place soft reload for a soft-reloadable restore (the change
@@ -220,6 +231,15 @@ export function usePreviewPersistence({
220231
: Promise.reject(new Error("No project is open to read nested files from.")),
221232
),
222233
);
234+
const held = heldPreviewDoc();
235+
if (held) {
236+
const paths = restore.paths ?? Object.keys(restore.files ?? {});
237+
afterStudioManualEditGestures(held, () => {
238+
markScenesStale(previewIframeRef.current, paths);
239+
reloadPreview();
240+
});
241+
return;
242+
}
223243
const strategy = applyUndoRestoreToPreview(
224244
previewIframeRef.current,
225245
activeCompPathRef.current,
@@ -238,24 +258,26 @@ export function usePreviewPersistence({
238258
// reload. The full path above waits for the reloaded preview to report instead.
239259
syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
240260
},
241-
[previewIframeRef, activeCompPathRef, reloadPreview],
261+
[previewIframeRef, activeCompPathRef, reloadPreview, heldPreviewDoc],
242262
);
243263

244264
// A restore the server has not confirmed yet: in place now, or not at all. A GSAP script re-run is not
245265
// synchronous, and a pending save would land under it.
246266
const showHistoryRestoreNow = useCallback(
247267
(files: RestoreFiles): (() => void) | null => {
248-
if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits()) return null;
268+
if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits() || heldPreviewDoc())
269+
return null;
249270
const iframe = previewIframeRef.current;
250271
const now = () => usePlayerStore.getState().currentTime;
251272
const putBack = showRestoreInPlace(iframe, activeCompPathRef.current, files, now());
252273
if (!putBack) return null;
253274
return () => {
254-
if (putBack(now())) syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
275+
if (!heldPreviewDoc() && putBack(now()))
276+
syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
255277
else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) });
256278
};
257279
},
258-
[previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply],
280+
[previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply, heldPreviewDoc],
259281
);
260282

261283
// ── Migrate legacy studio-motion.json ──

0 commit comments

Comments
 (0)