From 266dc8789a9a032230552a21eaa0ad130b5cf7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 19:18:25 -0400 Subject: [PATCH 1/4] fix(studio): undo no longer snaps back a box you started dragging again --- .../usePreviewPersistence.history.test.tsx | 70 +++++++++++++++++++ .../studio/src/hooks/usePreviewPersistence.ts | 22 ++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx b/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx index 72c85a3367..fdd2b281c6 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx +++ b/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx @@ -4,6 +4,7 @@ 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 } from "../components/editor/manualEdits"; import { mountReactHarness } from "./domSelectionTestHarness"; import { usePreviewPersistence } from "./usePreviewPersistence"; @@ -132,3 +133,72 @@ 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) => + `
`; + const files = { "index.html": { previous: page(0), restored: page(200) } }; + + function heldPreview(held = true) { + const doc = document.implementation.createHTMLDocument(""); + doc.body.innerHTML = `
`; + 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 | null = null; + function Harness() { + hook = usePreviewPersistence({ + showToast: () => {}, + readOptionalProjectFile: async () => "", + writeProjectFile: async () => {}, + recordEdit: async () => {}, + previewIframeRef: { current: iframe }, + activeCompPathRef: { current: "index.html" }, + reloadPreview, + }); + return null; + } + mountReactHarness(); + usePlayerStore.getState().setSelectedElementId("box"); + return { box, win: contentWindow, reloadPreview, hook: () => hook! }; + } + + it("leaves the held element where the gesture drew it and hands the refresh to the preview reload", async () => { + const { box, win, reloadPreview, 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).toHaveBeenCalledTimes(1); + 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 vi.waitFor(() => expect(reloadPreview).toHaveBeenCalledTimes(1)); + expect(win.__player.seek).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index c749ddbc98..aa15110074 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -4,6 +4,7 @@ import { installStudioManualEditSeekReapply, reapplyPositionEditsAfterSeek, } from "../components/editor/manualEdits"; +import { isStudioManualEditGestureLiveIn } from "../components/editor/manualEditsDom"; import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue"; import { @@ -199,6 +200,12 @@ export function usePreviewPersistence({ // ── Sync preview after undo/redo ── + // Undo never repaints under a live gesture: the preview reload waits for it and loads the file after its save. + const gestureHoldsPreview = useCallback(() => { + const doc = previewIframeRef.current?.contentDocument; + return !!doc && isStudioManualEditGestureLiveIn(doc); + }, [previewIframeRef]); + const syncHistoryPreviewAfterApply = useCallback( async (restore: HistoryPreviewRestore) => { // Prefer an in-place soft reload for a soft-reloadable restore (the change @@ -220,6 +227,7 @@ export function usePreviewPersistence({ : Promise.reject(new Error("No project is open to read nested files from.")), ), ); + if (gestureHoldsPreview()) return reloadPreview(); const strategy = applyUndoRestoreToPreview( previewIframeRef.current, activeCompPathRef.current, @@ -238,24 +246,30 @@ 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, gestureHoldsPreview], ); // 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() || + gestureHoldsPreview() + ) + 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 (!gestureHoldsPreview() && putBack(now())) + syncStoredAutomationFromPreview(iframe?.contentDocument ?? null); else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) }); }; }, - [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply], + [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply, gestureHoldsPreview], ); // ── Migrate legacy studio-motion.json ── From ba7eb636b05be585cb59c19da1ea515faf711345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 19:41:02 -0400 Subject: [PATCH 2/4] fix(studio): undo reloads the preview after the drag ends, and the bench checks it --- .../usePreviewPersistence.history.test.tsx | 12 +++++++-- .../studio/src/hooks/usePreviewPersistence.ts | 26 +++++++++---------- .../studio/tests/e2e/edit-accuracy/drags.mjs | 20 +++++++++----- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx b/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx index fdd2b281c6..744ea85915 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx +++ b/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx @@ -4,7 +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 } from "../components/editor/manualEdits"; +import { + beginStudioManualEditGesture, + endStudioManualEditGesture, +} from "../components/editor/manualEdits"; import { mountReactHarness } from "./domSelectionTestHarness"; import { usePreviewPersistence } from "./usePreviewPersistence"; @@ -170,7 +173,7 @@ describe("undo that lands while a newer gesture holds an element", () => { return { box, win: contentWindow, reloadPreview, hook: () => hook! }; } - it("leaves the held element where the gesture drew it and hands the refresh to the preview reload", async () => { + it("leaves the held element where the gesture drew it and reloads the preview once it ends", async () => { const { box, win, reloadPreview, hook } = heldPreview(); await act(async () => hook().syncHistoryPreviewAfterApply({ paths: ["index.html"], files })); @@ -178,6 +181,8 @@ describe("undo that lands while a newer gesture holds an element", () => { 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); expect(usePlayerStore.getState().selectedElementId).toBe("box"); }); @@ -197,6 +202,9 @@ describe("undo that lands while a newer gesture holds an element", () => { win.__player.seek.mockClear(); putBack(); + await act(async () => {}); + expect(reloadPreview).not.toHaveBeenCalled(); + endStudioManualEditGesture(box); await vi.waitFor(() => expect(reloadPreview).toHaveBeenCalledTimes(1)); expect(win.__player.seek).not.toHaveBeenCalled(); diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index aa15110074..43978853f0 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -4,7 +4,10 @@ import { installStudioManualEditSeekReapply, reapplyPositionEditsAfterSeek, } from "../components/editor/manualEdits"; -import { isStudioManualEditGestureLiveIn } from "../components/editor/manualEditsDom"; +import { + afterStudioManualEditGestures, + isStudioManualEditGestureLiveIn, +} from "../components/editor/manualEditsDom"; import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue"; import { @@ -200,10 +203,10 @@ export function usePreviewPersistence({ // ── Sync preview after undo/redo ── - // Undo never repaints under a live gesture: the preview reload waits for it and loads the file after its save. - const gestureHoldsPreview = useCallback(() => { + // 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); + return doc && isStudioManualEditGestureLiveIn(doc) ? doc : null; }, [previewIframeRef]); const syncHistoryPreviewAfterApply = useCallback( @@ -227,7 +230,8 @@ export function usePreviewPersistence({ : Promise.reject(new Error("No project is open to read nested files from.")), ), ); - if (gestureHoldsPreview()) return reloadPreview(); + const held = heldPreviewDoc(); + if (held) return void afterStudioManualEditGestures(held, reloadPreview); const strategy = applyUndoRestoreToPreview( previewIframeRef.current, activeCompPathRef.current, @@ -246,30 +250,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, gestureHoldsPreview], + [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() || - gestureHoldsPreview() - ) + 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 (!gestureHoldsPreview() && putBack(now())) + if (!heldPreviewDoc() && putBack(now())) syncStoredAutomationFromPreview(iframe?.contentDocument ?? null); else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) }); }; }, - [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply, gestureHoldsPreview], + [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply, heldPreviewDoc], ); // ── Migrate legacy studio-motion.json ── diff --git a/packages/studio/tests/e2e/edit-accuracy/drags.mjs b/packages/studio/tests/e2e/edit-accuracy/drags.mjs index 76b18a6449..ccb906bf98 100644 --- a/packages/studio/tests/e2e/edit-accuracy/drags.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/drags.mjs @@ -1,5 +1,5 @@ -// Drag paths (one long move) and sequences (several edits, no settle between), gsap none. -// Ids keep the grid's scheme with a letters-only gesture token, so `^[a-z]+-none-` selects them. +// Drag paths (one long move) and sequences (several edits, no settle between), gsap none but for GSAP_SEQUENCES. +// Ids keep the grid's scheme with a letters-only gesture token, so `^[a-z]+-none-` selects the plain ones. const MOVE = { do: "drag", gesture: "move", by: [90, 60] }; const BACK = { do: "drag", gesture: "move", by: [-70, 50] }; @@ -26,16 +26,21 @@ const SEQUENCES = { ], }; +/** On a GSAP-tweened box: the undo is still landing while the next drag holds the box, which must not move. */ +const GSAP_SEQUENCES = { + resizeundodrag: [{ do: "drag", gesture: "resize" }, { do: "undo" }, MOVE], +}; + /** Text in place: a double press opens it, Enter commits; `select` first double-clicks a word to replace. */ const TEXT = { edit: [{ do: "text", word: "Teleport" }], select: [{ do: "text", word: "Teleport", select: "accuracy" }], }; -const row = (gesture, c, steps) => ({ - id: [gesture, "none", c.placement, `r${c.rotation}`, c.nesting, `z${c.zoom}`].join("-"), +const row = (gesture, c, steps, gsap = "none") => ({ + id: [gesture, gsap, c.placement, `r${c.rotation}`, c.nesting, `z${c.zoom}`].join("-"), gesture, - gsap: "none", + gsap, ...c, steps, other: Boolean(steps?.some((s) => s.element === "B")), @@ -57,6 +62,9 @@ export function dragCases() { const sequences = Object.entries(SEQUENCES).flatMap(([name, steps]) => everyPlacement.map((c) => row(`seq${name}`, c, steps)), ); + const gsapSequences = Object.entries(GSAP_SEQUENCES).map(([name, steps]) => + row(`seq${name}`, pxRoot(), steps, "tween"), + ); const texts = Object.entries(TEXT).flatMap(([name, steps]) => ["root", "nested"].map((nesting) => ({ ...row(`text${name}`, { ...base, placement: "px", nesting }, steps), @@ -71,5 +79,5 @@ export function dragCases() { other: false, })), ); - return [...paths, ...sequences, ...texts, ...centred]; + return [...paths, ...sequences, ...gsapSequences, ...texts, ...centred]; } From 91a6032f514fa65bad873693c9eef70abb8e21eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 20:11:16 -0400 Subject: [PATCH 3/4] test(studio): leave the edit bench sequence for the resize-then-drag fix --- .../studio/tests/e2e/edit-accuracy/drags.mjs | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/studio/tests/e2e/edit-accuracy/drags.mjs b/packages/studio/tests/e2e/edit-accuracy/drags.mjs index ccb906bf98..76b18a6449 100644 --- a/packages/studio/tests/e2e/edit-accuracy/drags.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/drags.mjs @@ -1,5 +1,5 @@ -// Drag paths (one long move) and sequences (several edits, no settle between), gsap none but for GSAP_SEQUENCES. -// Ids keep the grid's scheme with a letters-only gesture token, so `^[a-z]+-none-` selects the plain ones. +// Drag paths (one long move) and sequences (several edits, no settle between), gsap none. +// Ids keep the grid's scheme with a letters-only gesture token, so `^[a-z]+-none-` selects them. const MOVE = { do: "drag", gesture: "move", by: [90, 60] }; const BACK = { do: "drag", gesture: "move", by: [-70, 50] }; @@ -26,21 +26,16 @@ const SEQUENCES = { ], }; -/** On a GSAP-tweened box: the undo is still landing while the next drag holds the box, which must not move. */ -const GSAP_SEQUENCES = { - resizeundodrag: [{ do: "drag", gesture: "resize" }, { do: "undo" }, MOVE], -}; - /** Text in place: a double press opens it, Enter commits; `select` first double-clicks a word to replace. */ const TEXT = { edit: [{ do: "text", word: "Teleport" }], select: [{ do: "text", word: "Teleport", select: "accuracy" }], }; -const row = (gesture, c, steps, gsap = "none") => ({ - id: [gesture, gsap, c.placement, `r${c.rotation}`, c.nesting, `z${c.zoom}`].join("-"), +const row = (gesture, c, steps) => ({ + id: [gesture, "none", c.placement, `r${c.rotation}`, c.nesting, `z${c.zoom}`].join("-"), gesture, - gsap, + gsap: "none", ...c, steps, other: Boolean(steps?.some((s) => s.element === "B")), @@ -62,9 +57,6 @@ export function dragCases() { const sequences = Object.entries(SEQUENCES).flatMap(([name, steps]) => everyPlacement.map((c) => row(`seq${name}`, c, steps)), ); - const gsapSequences = Object.entries(GSAP_SEQUENCES).map(([name, steps]) => - row(`seq${name}`, pxRoot(), steps, "tween"), - ); const texts = Object.entries(TEXT).flatMap(([name, steps]) => ["root", "nested"].map((nesting) => ({ ...row(`text${name}`, { ...base, placement: "px", nesting }, steps), @@ -79,5 +71,5 @@ export function dragCases() { other: false, })), ); - return [...paths, ...sequences, ...gsapSequences, ...texts, ...centred]; + return [...paths, ...sequences, ...texts, ...centred]; } From e9c4cc127bebe85a914811913bdd3c7d2bda1649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 4 Oct 2026 20:51:23 -0400 Subject: [PATCH 4/4] fix(studio): the undo reload after a drag also refreshes the restored scenes --- .../src/hooks/usePreviewPersistence.history.test.tsx | 11 ++++++++--- packages/studio/src/hooks/usePreviewPersistence.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx b/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx index 744ea85915..c199235714 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx +++ b/packages/studio/src/hooks/usePreviewPersistence.history.test.tsx @@ -144,6 +144,7 @@ describe("undo that lands while a newer gesture holds an element", () => { function heldPreview(held = true) { const doc = document.implementation.createHTMLDocument(""); + doc.head.innerHTML = ``; doc.body.innerHTML = `
`; const box = doc.getElementById("box")!; if (held) beginStudioManualEditGesture(box, "move"); @@ -170,11 +171,13 @@ describe("undo that lands while a newer gesture holds an element", () => { } mountReactHarness(); usePlayerStore.getState().setSelectedElementId("box"); - return { box, win: contentWindow, reloadPreview, hook: () => hook! }; + const sharedPart = () => + JSON.parse(doc.querySelector('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, hook } = heldPreview(); + const { box, win, reloadPreview, sharedPart, hook } = heldPreview(); await act(async () => hook().syncHistoryPreviewAfterApply({ paths: ["index.html"], files })); @@ -184,6 +187,8 @@ describe("undo that lands while a newer gesture holds an element", () => { 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"); }); @@ -206,7 +211,7 @@ describe("undo that lands while a newer gesture holds an element", () => { expect(reloadPreview).not.toHaveBeenCalled(); endStudioManualEditGesture(box); - await vi.waitFor(() => expect(reloadPreview).toHaveBeenCalledTimes(1)); + expect(reloadPreview).toHaveBeenCalledTimes(1); expect(win.__player.seek).not.toHaveBeenCalled(); }); }); diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index 43978853f0..e77006882d 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -9,6 +9,7 @@ import { 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, @@ -231,7 +232,14 @@ export function usePreviewPersistence({ ), ); const held = heldPreviewDoc(); - if (held) return void afterStudioManualEditGestures(held, reloadPreview); + 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,