diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index a026d797f5..38d728b393 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -155,6 +155,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St previewPersistence, pendingTimelineEditPathRef, reloadPreview, + onOutsideChange: editHistory.noteOutsideChange, }); const invalidateGsapCacheRef = useRef<() => void>(() => {}); const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []); @@ -254,6 +255,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St writeProjectFile: fileManager.writeProjectFile, showToast, syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply, + showHistoryRestoreNow: previewPersistence.showHistoryRestoreNow, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, handleCopy, handlePaste, diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 47fa2260ef..3895a5b2f4 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -86,6 +86,7 @@ interface UseAppHotkeysParams { writeProjectFile: (path: string, content: string) => Promise; showToast: (message: string, tone?: "error" | "info") => void; syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"]; + showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"]; waitForPendingDomEditSaves: () => Promise; handleCopy: () => boolean; handlePaste: () => Promise; @@ -124,6 +125,7 @@ export function useAppHotkeys({ writeProjectFile, showToast, syncHistoryPreviewAfterApply, + showHistoryRestoreNow, waitForPendingDomEditSaves, handleCopy, handlePaste, @@ -150,6 +152,7 @@ export function useAppHotkeys({ writeProjectFile, showToast, syncHistoryPreviewAfterApply, + showHistoryRestoreNow, waitForPendingDomEditSaves, onAfterUndoRedo, activeCompPath, diff --git a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx new file mode 100644 index 0000000000..de1fc622d6 --- /dev/null +++ b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + createStudioApi, + openProjectHistory, + type StudioApiAdapter, +} from "@hyperframes/studio-server"; +import { useEditHistoryActions } from "./useEditHistoryActions"; +import { usePersistentEditHistory } from "./usePersistentEditHistory"; +import { usePreviewPersistence } from "./usePreviewPersistence"; + +const page = (left: string, top = "0px") => + `
`; +const BEFORE = page("10px"); +const AFTER = page("50px"); +const OUTSIDE = page("50px", "99px"); + +const cleanup: Array<() => unknown> = []; +let scratch = ""; +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), "hf-undo-paint-")); +}); +afterEach(async () => { + for (const step of cleanup.splice(0).reverse()) await step(); + rmSync(scratch, { recursive: true, force: true }); + vi.unstubAllGlobals(); +}); + +/** Studio's undo wired as App.tsx wires it, over the real history engine, with a live preview document. */ +async function studio() { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const dir = join(scratch, "project"); + mkdirSync(dir); + const path = join(dir, "index.html"); + writeFileSync(path, BEFORE); + const engine = await openProjectHistory({ + projectDir: dir, + historyRoot: join(scratch, "history"), + }); + cleanup.push(() => engine.close()); + const api = createStudioApi({ + listProjects: () => [], + resolveProject: (id: string) => (id === "demo" ? { id, dir } : null), + history: () => engine, + } as unknown as StudioApiAdapter); + vi.stubGlobal("fetch", (url: string, init?: RequestInit) => + api.request(url.replace(/^\/api/, ""), init), + ); + const iframe = document.createElement("iframe"); + document.body.append(iframe); + cleanup.push(() => iframe.remove()); + const reloads = vi.fn(); + const readFile = async (p: string) => readFileSync(join(dir, p), "utf8"); + let history!: ReturnType; + let persistence!: ReturnType; + let actions!: ReturnType; + function Harness() { + history = usePersistentEditHistory({ projectId: "demo" }); + persistence = usePreviewPersistence({ + showToast: () => {}, + readOptionalProjectFile: async () => "", + writeProjectFile: async () => {}, + recordEdit: async () => {}, + previewIframeRef: { current: iframe }, + activeCompPathRef: { current: "index.html" }, + reloadPreview: reloads, + }); + actions = useEditHistoryActions({ + editHistory: history, + readOptionalProjectFile: async () => "", + readProjectFile: readFile, + writeProjectFile: async (p, content) => writeFileSync(join(dir, p), content), + showToast: () => {}, + syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply, + showHistoryRestoreNow: persistence.showHistoryRestoreNow, + waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves, + }); + return null; + } + const root = createRoot(document.createElement("div")); + await act(async () => root.render(createElement(Harness))); + cleanup.push(() => act(() => root.unmount())); + const box = () => iframe.contentDocument!.getElementById("box")!.style.left; + const other = () => iframe.contentDocument!.getElementById("other")!.style.top; + const show = (html: string) => { + iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString( + html, + "text/html", + ).documentElement.innerHTML; + }; + /** A move Studio saved: the live preview already shows it, then the file and the history claim land. */ + const edit = async () => { + show(AFTER); + writeFileSync(path, AFTER); + await act(() => + history.recordEdit({ + label: "Move layer", + files: { "index.html": { before: BEFORE, after: AFTER } }, + }), + ); + await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer")); + }; + return { + history: () => history, + persistence: () => persistence, + actions: () => actions, + box, + other, + show, + path, + edit, + file: () => readFileSync(path, "utf8"), + reloads, + }; +} + +it("undo and redo of a style edit paint in the key's own task, before the server answers", async () => { + const s = await studio(); + await s.edit(); + + const undone = s.actions().undo(); + expect(s.box()).toBe("10px"); + await act(() => undone); + expect(s.file()).toBe(BEFORE); + expect(s.box()).toBe("10px"); + + await vi.waitFor(() => expect(s.history().redoLabel).toBeTruthy()); + const redone = s.actions().redo(); + expect(s.box()).toBe("50px"); + await act(() => redone); + expect(s.file()).toBe(AFTER); + expect(s.box()).toBe("50px"); + expect(s.reloads).not.toHaveBeenCalled(); +}); + +it("an undo pressed while a save is still running waits for the server instead of guessing", async () => { + const s = await studio(); + await s.edit(); + let finish!: () => void; + const saving = new Promise((resolve) => (finish = resolve)); + void s.persistence().queueDomEditSave(() => saving); + + const undone = s.actions().undo(); + expect(s.box()).toBe("50px"); + finish(); + await act(() => undone); + expect(s.file()).toBe(BEFORE); + expect(s.box()).toBe("10px"); +}); + +it("an undo after an outside write ends with the preview showing the file the server restored", async () => { + const s = await studio(); + await s.edit(); + writeFileSync(s.path, OUTSIDE); + s.show(OUTSIDE); + + await act(() => s.actions().undo()); + expect(s.file()).toBe(AFTER); + expect(s.other()).toBe("0px"); + expect(s.box()).toBe("50px"); +}); diff --git a/packages/studio/src/hooks/useEditHistoryActions.test.tsx b/packages/studio/src/hooks/useEditHistoryActions.test.tsx index 93fd878ee0..402242c4b7 100644 --- a/packages/studio/src/hooks/useEditHistoryActions.test.tsx +++ b/packages/studio/src/hooks/useEditHistoryActions.test.tsx @@ -11,24 +11,36 @@ import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryA let root: Root | null = null; afterEach(() => act(() => root?.unmount())); -function mount(result: { - ok: boolean; - reason?: string; - message?: string; - label?: string; - paths?: string[]; -}) { +type RestoreFiles = Record; +type Prediction = { id: string; files: RestoreFiles }; + +function mount( + result: { + ok: boolean; + reason?: string; + message?: string; + label?: string; + paths?: string[]; + undoes?: string; + files?: RestoreFiles; + }, + predicted?: Prediction, +) { const editHistory = { undo: vi.fn(async () => result), redo: vi.fn(async () => result), + predict: () => predicted ?? null, }; + const putBack = vi.fn(); const deps = { editHistory, readOptionalProjectFile: vi.fn(async () => ""), readProjectFile: vi.fn(async () => ""), writeProjectFile: vi.fn(async () => undefined), showToast: vi.fn(), - syncHistoryPreviewAfterApply: vi.fn(async () => undefined), + syncHistoryPreviewAfterApply: vi.fn(async (_restore: unknown) => undefined), + putBack, + showHistoryRestoreNow: vi.fn((_files: RestoreFiles) => putBack), waitForPendingDomEditSaves: vi.fn(async () => undefined), onAfterUndoRedo: vi.fn(), activeCompPath: "index.html", @@ -44,7 +56,53 @@ function mount(result: { return { deps, actions }; } +const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored: "A" } } }; +const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } }; + describe("useEditHistoryActions", () => { + it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => { + const { deps, actions } = mount( + { ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES }, + PREDICTED, + ); + await act(() => actions.undo()); + expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files); + expect(deps.putBack).not.toHaveBeenCalled(); + expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({ + paths: ["index.html"], + files: { "index.html": { previous: "A", restored: "A2" } }, + }); + }); + + it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => { + const { deps, actions } = mount( + { + ok: true, + label: "Undid: Outside", + paths: ["index.html"], + undoes: "e0", + files: SERVER_FILES, + }, + PREDICTED, + ); + await act(() => actions.undo()); + expect(deps.putBack).toHaveBeenCalledTimes(1); + expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({ + paths: ["index.html"], + files: SERVER_FILES, + }); + }); + + it("puts a shown step back when the server refuses it", async () => { + const { deps, actions } = mount( + { ok: false, reason: "failed", message: "disk full" }, + PREDICTED, + ); + await act(() => actions.undo()); + expect(deps.putBack).toHaveBeenCalledTimes(1); + expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled(); + }); + it("undo resyncs the preview and toasts the step as the history names it", async () => { const { deps, actions } = mount({ ok: true, label: "Undid: Move clip", paths: ["index.html"] }); await act(() => actions.undo()); @@ -80,7 +138,7 @@ describe("useEditHistoryActions", () => { expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled(); }); - it("says why when the history could not take the step", async () => { + it("says why when the history could not take the step, with nothing shown to take back", async () => { const { deps, actions } = mount({ ok: false, reason: "failed", message: "disk full" }); await act(() => actions.undo()); expect(deps.showToast).toHaveBeenCalledWith("Undo failed: disk full", "error"); diff --git a/packages/studio/src/hooks/useEditHistoryActions.ts b/packages/studio/src/hooks/useEditHistoryActions.ts index d54051f67c..d2a82c3609 100644 --- a/packages/studio/src/hooks/useEditHistoryActions.ts +++ b/packages/studio/src/hooks/useEditHistoryActions.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react"; import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator"; +import type { RestoreFiles } from "../utils/gsapUndoRestore"; interface HistoryResult { ok: boolean; @@ -9,8 +10,9 @@ interface HistoryResult { message?: string; label?: string; paths?: string[]; + undoes?: string; /** Per-file restored/previous content, used to soft-apply the preview. */ - files?: Record; + files?: RestoreFiles; } interface HistoryFileCallbacks { readFile: (path: string) => Promise; @@ -19,6 +21,7 @@ interface HistoryFileCallbacks { export interface EditHistoryHandle { undo: (cb: HistoryFileCallbacks) => Promise; redo: (cb: HistoryFileCallbacks) => Promise; + predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null; state: { undo: ReadonlyArray<{ createdAt: number }>; redo: ReadonlyArray<{ createdAt: number }>; @@ -26,12 +29,13 @@ export interface EditHistoryHandle { } export interface UseEditHistoryActionsOptions { - editHistory: Pick; + editHistory: Pick; readOptionalProjectFile: (path: string) => Promise; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; showToast: (message: string, tone?: "error" | "info") => void; syncHistoryPreviewAfterApply: (restore: Pick) => Promise; + showHistoryRestoreNow?: (files: RestoreFiles) => (() => void) | null; waitForPendingDomEditSaves: () => Promise; onAfterUndoRedo?: (restore: Pick) => void; /** Active composition path — decides whether undo/redo must resync the SDK session. */ @@ -48,6 +52,7 @@ export function useEditHistoryActions({ writeProjectFile, showToast, syncHistoryPreviewAfterApply, + showHistoryRestoreNow, waitForPendingDomEditSaves, onAfterUndoRedo, activeCompPath, @@ -67,11 +72,22 @@ export function useEditHistoryActions({ const apply = useCallback( async (direction: "undo" | "redo") => { const noun = direction === "undo" ? "Undo" : "Redo"; - await waitForPendingDomEditSaves(); - const result = await editHistory[direction]({ - readFile: readHistoryFile, - serialize: serializeHistoryFiles, - }); + // Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects. + const predicted = editHistory.predict?.(direction) ?? null; + const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null; + let result: HistoryResult = { ok: false, reason: "failed" }; + let serverSteppedShown = false; + try { + await waitForPendingDomEditSaves(); + result = await editHistory[direction]({ + readFile: readHistoryFile, + serialize: serializeHistoryFiles, + }); + serverSteppedShown = + Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id; + } finally { + if (putBack && !serverSteppedShown) putBack(); + } if (!result.ok && result.reason === "content-mismatch") { showToast( `Can't ${direction}: ${result.paths?.join(", ")} changed since that edit.`, @@ -84,7 +100,8 @@ export function useEditHistoryActions({ return; } if (result.ok && result.label) { - const restore = { paths: result.paths, files: result.files }; + const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files; + const restore = { paths: result.paths, files }; onAfterUndoRedo?.(restore); if (activeCompPath && result.paths?.includes(activeCompPath)) { forceReloadSdkSession?.(); @@ -98,6 +115,7 @@ export function useEditHistoryActions({ readHistoryFile, showToast, syncHistoryPreviewAfterApply, + showHistoryRestoreNow, waitForPendingDomEditSaves, serializeHistoryFiles, onAfterUndoRedo, @@ -110,3 +128,13 @@ export function useEditHistoryActions({ const redo = useCallback(() => apply("redo"), [apply]); return useMemo(() => ({ undo, redo }), [undo, redo]); } + +function fromShown(files: RestoreFiles | undefined, shown: RestoreFiles) { + if (!files) return files; + return Object.fromEntries( + Object.entries(files).map(([path, f]) => [ + path, + { previous: shown[path]?.restored ?? f.previous, restored: f.restored }, + ]), + ); +} diff --git a/packages/studio/src/hooks/usePersistentEditHistory.test.ts b/packages/studio/src/hooks/usePersistentEditHistory.test.ts index 61a59d7510..2dda96b983 100644 --- a/packages/studio/src/hooks/usePersistentEditHistory.test.ts +++ b/packages/studio/src/hooks/usePersistentEditHistory.test.ts @@ -76,6 +76,7 @@ it("an edit Studio saved is undone and redone by the project's history, with the expect(undone).toEqual({ ok: true, label: "Undid: Moved Title", + undoes: expect.any(String), paths: ["index.html"], files: { "index.html": { previous: "B", restored: "A" } }, }); @@ -241,3 +242,48 @@ it("a step that cannot reach the server says so", async () => { message: "Studio could not reach its server.", }); }); + +it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => { + const { hook, save, readFile } = await studio(); + save("B"); + const claim = hook().recordEdit({ + label: "Moved Title", + files: { "index.html": { before: "A", after: "B" } }, + }); + expect(hook().predict("undo")).toBeNull(); + await act(() => claim); + const predicted = await vi.waitFor(() => { + const next = hook().predict("undo"); + expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } }); + return next!; + }); + + hook().noteOutsideChange(); + expect(hook().predict("undo")).toBeNull(); + await vi.waitFor(() => expect(hook().predict("undo")?.id).toBe(predicted.id)); + + const undone = hook().undo({ readFile }); + expect(hook().predict("undo")).toBeNull(); + expect((await act(() => undone)).undoes).toBe(predicted.id); + await vi.waitFor(() => + expect(hook().predict("redo")?.files).toEqual({ + "index.html": { previous: "A", restored: "B" }, + }), + ); +}); + +it("an undo taken before the view caught up with the edit still reports the preview's before and after", async () => { + const { hook, save, readFile } = await studio(); + save("B"); + const record = hook().recordEdit({ + label: "Moved Title", + files: { "index.html": { before: "A", after: "B" } }, + }); + const undo = hook().undo; + await act(() => record); + const undone = await act(() => undo({ readFile })); + expect(undone).toMatchObject({ + ok: true, + files: { "index.html": { previous: "B", restored: "A" } }, + }); +}); diff --git a/packages/studio/src/hooks/usePersistentEditHistory.ts b/packages/studio/src/hooks/usePersistentEditHistory.ts index 8dc61f68cb..8675b5c0bd 100644 --- a/packages/studio/src/hooks/usePersistentEditHistory.ts +++ b/packages/studio/src/hooks/usePersistentEditHistory.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server"; import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion"; +import type { RestoreFiles } from "../utils/gsapUndoRestore"; interface RecordEditInput { label: string; @@ -18,11 +19,6 @@ export interface UsePersistentEditHistoryOptions { projectId: string | null; } -interface ApplyRestoredFile { - previous: string; - restored: string; -} - interface ApplyResult { ok: boolean; /** content-mismatch: `paths` changed after the step's entry. failed: `message` says why. */ @@ -30,7 +26,8 @@ interface ApplyResult { message?: string; label?: string; paths?: string[]; - files?: Record; + undoes?: string; + files?: RestoreFiles; } interface NextStep { @@ -47,6 +44,69 @@ interface HistoryView { } const EMPTY: HistoryView = { entries: [], back: null, forward: null }; +/** ponytail: the newest entries' content is kept; older ones step through the server only. */ +const OWN_ENTRIES_KEPT = 100; + +type OwnFiles = Record; + +function createOwnHistory() { + const own = new Map(); + let next: Record<"undo" | "redo", NextStep | null> | null = null; + let changes = 0; + const remember = (id: string, files: OwnFiles) => { + const known = own.get(id) ?? {}; + for (const [path, { before, after }] of Object.entries(files)) { + known[path] = { before: known[path]?.before ?? before, after }; + } + own.delete(id); + own.set(id, known); + if (own.size > OWN_ENTRIES_KEPT) own.delete(own.keys().next().value!); + }; + return { + remember, + overtake: () => { + changes += 1; + next = null; + return changes; + }, + offered: (seen: number, view: HistoryView) => { + if (seen === changes) next = { undo: view.back, redo: view.forward }; + }, + changes: () => changes, + stepped: (entry: { id: string; undoes?: string }) => { + const undone = entry.undoes ? own.get(entry.undoes) : undefined; + if (!undone) return; + const swapped = Object.entries(undone).map(([path, f]) => [ + path, + { before: f.after, after: f.before }, + ]); + remember(entry.id, Object.fromEntries(swapped)); + }, + afterOf: (id: string | undefined): Record => + Object.fromEntries(Object.entries((id && own.get(id)) || {}).map(([p, f]) => [p, f.after])), + predict: (direction: "undo" | "redo"): { id: string; files: RestoreFiles } | null => { + const step = next?.[direction]; + const files = step ? own.get(step.id) : undefined; + const paths = files ? Object.keys(files) : []; + if ( + !files || + paths.length !== step!.paths.length || + !paths.every((p) => step!.paths.includes(p)) + ) + return null; + const restore = paths.map((path) => [ + path, + { previous: files[path]!.after, restored: files[path]!.before }, + ]); + return { id: step!.id, files: Object.fromEntries(restore) }; + }, + clear: () => { + own.clear(); + next = null; + changes += 1; + }, + }; +} const DEFAULT_COALESCE_MS = 300; function historyUrl(projectId: string, path = ""): string { @@ -71,12 +131,13 @@ async function post( return { ok: false, status: response.status, error: reply?.error ?? `HTTP ${response.status}` }; } -/** Whether a drag's claim is held open; a failed one is logged (its write lands as an outside change; 404: none). */ -function claimHeld(reply: Awaited>, label: string): boolean { - if (reply.ok) return Boolean((reply.body as { claimed: { id: string } | null } | null)?.claimed); +/** The entry id a claim took, or null; a failed one is logged (its write lands as an outside change; 404: none). */ +function claimHeld(reply: Awaited>, label: string): string | null { + if (reply.ok) + return (reply.body as { claimed: { id: string } | null } | null)?.claimed?.id ?? null; if (reply.status !== 404) console.error(`"${label}" was not recorded as your edit: ${reply.error}`); - return false; + return null; } async function overwroteVersions(files: RecordEditInput["files"]): Promise> { @@ -106,7 +167,7 @@ async function restoredFiles( paths: readonly string[], previous: Record | null, readFile: (path: string) => Promise, -): Promise | undefined> { +): Promise { if (!previous || paths.some((path) => !(path in previous))) return undefined; const restored = await readAll(paths, readFile); if (!restored) return undefined; @@ -128,36 +189,43 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory const heldClaimRef = useRef<{ paths: string[]; at: number } | null>(null); const projectIdRef = useRef(projectId); projectIdRef.current = projectId; + const [own] = useState(createOwnHistory); const refresh = useCallback(async () => { if (!projectId) return; + const seen = own.changes(); const response = await fetch(historyUrl(projectId)).catch(() => null); const next = response?.ok ? ((await response.json()) as HistoryView) : EMPTY; - if (projectIdRef.current === projectId) setView(next); - }, [projectId]); + if (projectIdRef.current !== projectId) return; + setView(next); + own.offered(seen, next); + }, [projectId, own]); useEffect(() => { setView(EMPTY); setLoaded(false); heldClaimRef.current = null; + own.clear(); void refresh().finally(() => setLoaded(true)); - }, [refresh]); + }, [refresh, own]); const recordEdit = useCallback( async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => { if (!projectId) return; const paths = Object.keys(files); + own.overtake(); const reply = await post(historyUrl(projectId, "/claim"), { label, paths, overwrote: await overwroteVersions(files), ...(coalesceKey && { coalesceKey, idleMs: coalesceMs ?? DEFAULT_COALESCE_MS }), }); - heldClaimRef.current = - claimHeld(reply, label) && coalesceKey ? { paths, at: Date.now() } : null; + const claimed = claimHeld(reply, label); + if (claimed) own.remember(claimed, files); + heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null; void refresh(); }, - [projectId, refresh], + [projectId, refresh, own], ); const step = useCallback( @@ -165,6 +233,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory if (!projectId) return { ok: false, reason: "empty" }; const next = direction === "undo" ? view.back : view.forward; const paths = [...new Set([...(next?.paths ?? []), ...(heldClaimRef.current?.paths ?? [])])]; + own.overtake(); const run = async (): Promise => { const previous = await readAll(paths, callbacks.readFile); const posted = await post( @@ -183,19 +252,30 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory return { ok: false, reason: "content-mismatch", paths: files }; } if (!reply.entry) return { ok: false, reason: "empty" }; + own.stepped(reply.entry); const changed = reply.entry.files.map((file) => file.path); return { ok: true, label: reply.entry.label, + undoes: reply.entry.undoes, paths: changed, - files: await restoredFiles(changed, previous, callbacks.readFile), + files: await restoredFiles( + changed, + { ...own.afterOf(reply.entry.undoes), ...previous }, + callbacks.readFile, + ), }; }; return callbacks.serialize ? callbacks.serialize(paths, run) : run(); }, - [projectId, view, refresh], + [projectId, view, refresh, own], ); + const noteOutsideChange = useCallback(() => { + own.overtake(); + void refresh(); + }, [own, refresh]); + const undo = useCallback((callbacks: ApplyCallbacks) => step("undo", callbacks), [step]); const redo = useCallback((callbacks: ApplyCallbacks) => step("redo", callbacks), [step]); @@ -220,5 +300,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory recordEdit, undo, redo, + predict: own.predict, + noteOutsideChange, }; } diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index cf32cc505f..913614a31b 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -8,17 +8,22 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue"; import { flushStudioPendingEdits, + hasStudioPendingEdits, type StudioPendingEditsDrainResult, } from "../utils/studioPendingEdits"; import { trackStudioEvent } from "../utils/studioTelemetry"; -import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore"; +import { + applyUndoRestoreToPreview, + showRestoreInPlace, + type RestoreFiles, +} from "../utils/gsapUndoRestore"; import { usePlayerStore } from "../player"; import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync"; /** The restore payload the undo/redo preview-sync consumes (from the history store). */ interface HistoryPreviewRestore { paths?: string[]; - files?: Record; + files?: RestoreFiles; } // ── Types ── @@ -217,6 +222,23 @@ export function usePreviewPersistence({ [previewIframeRef, activeCompPathRef, reloadPreview], ); + // 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; + 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); + else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) }); + }; + }, + [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply], + ); + // ── Migrate legacy studio-motion.json ── // Projects that used the old JSON-file approach may still have a populated // `.hyperframes/studio-motion.json`. The studio no longer reads from it, but @@ -239,5 +261,6 @@ export function usePreviewPersistence({ applyCurrentStudioManualEditsToPreview, applyStudioManualEditsToPreview, syncHistoryPreviewAfterApply, + showHistoryRestoreNow, }; } diff --git a/packages/studio/src/hooks/useStudioExternalFileChanges.ts b/packages/studio/src/hooks/useStudioExternalFileChanges.ts index 5e42cc9602..208915a941 100644 --- a/packages/studio/src/hooks/useStudioExternalFileChanges.ts +++ b/packages/studio/src/hooks/useStudioExternalFileChanges.ts @@ -36,6 +36,7 @@ interface UseStudioExternalFileChangesOptions { previewPersistence: PreviewPersistence; pendingTimelineEditPathRef: MutableRefObject>; reloadPreview: () => void; + onOutsideChange?: () => void; } /** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */ @@ -47,14 +48,17 @@ export function useStudioExternalFileChanges({ previewPersistence, pendingTimelineEditPathRef, reloadPreview, + onOutsideChange, }: UseStudioExternalFileChangesOptions) { const { flushPendingSourceSave, discardPendingSourceSave } = fileManager; const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence; const bumpThumbnailRevisions = usePlayerStore((state) => state.bumpThumbnailRevisions); const onAcceptedPersistedFileChange = useCallback( - (_path: string, affectedCompositions: readonly string[] | null) => - bumpThumbnailRevisions(affectedCompositions), - [bumpThumbnailRevisions], + (_path: string, affectedCompositions: readonly string[] | null) => { + onOutsideChange?.(); + bumpThumbnailRevisions(affectedCompositions); + }, + [bumpThumbnailRevisions, onOutsideChange], ); const drainPendingChanges = useCallback(async () => { const source = await flushPendingSourceSave(); diff --git a/packages/studio/src/utils/domEditSaveQueue.ts b/packages/studio/src/utils/domEditSaveQueue.ts index eb9ad868c7..f1d8035449 100644 --- a/packages/studio/src/utils/domEditSaveQueue.ts +++ b/packages/studio/src/utils/domEditSaveQueue.ts @@ -20,6 +20,7 @@ interface DomEditSaveQueueOptions { export interface DomEditSaveQueue { enqueue: (save: () => Promise) => Promise; waitForIdle: () => Promise; + isIdle: () => boolean; reset: () => void; destroy: () => void; } @@ -39,6 +40,7 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD; let tail = Promise.resolve(); + let queuedCount = 0; let consecutiveFailures = 0; let breakerOpen = false; let drainError: unknown = null; @@ -81,6 +83,7 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D return { enqueue(save) { if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError()); + queuedCount += 1; const queued = tail .catch(() => undefined) .then(() => { @@ -88,8 +91,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D return run(save); }); tail = queued.then( - () => undefined, - () => undefined, + () => void (queuedCount -= 1), + () => void (queuedCount -= 1), ); return queued; }, @@ -103,6 +106,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D return { status: "clean" }; }, + isIdle: () => queuedCount === 0, + reset, destroy() { diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index a9094a0224..6e1006d78b 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -91,7 +91,7 @@ function isGsapScript(text: string): boolean { ); } -export function findGsapScriptElements(doc: Document): HTMLScriptElement[] { +export function findGsapScriptElements(doc: ParentNode): HTMLScriptElement[] { const results: HTMLScriptElement[] = []; const scripts = doc.querySelectorAll("script:not([src])"); for (const script of scripts) { diff --git a/packages/studio/src/utils/gsapUndoRestore.test.ts b/packages/studio/src/utils/gsapUndoRestore.test.ts index 387fbac227..83b93aed8e 100644 --- a/packages/studio/src/utils/gsapUndoRestore.test.ts +++ b/packages/studio/src/utils/gsapUndoRestore.test.ts @@ -366,6 +366,52 @@ describe("applyUndoRestoreToPreview", () => { expect(meta.content).toBe(content); }); + const SUB = "compositions/sub.html"; + const sub = (style: string, rootAttrs = "") => + ``; + const host = (style: string) => + `
`; + + it("restores a sub-composition file in place, on every host that inlines it", () => { + const { iframe, doc } = buildLiveIframe( + host(`style="clip-path: inset(0px 40px 0px 0px);"`) + + host(`style="clip-path: inset(0px 40px 0px 0px);"`), + ); + const reloadPreview = vi.fn(); + const files = { + [SUB]: { previous: sub(`style="clip-path: inset(0px 40px 0px 0px)"`), restored: sub("") }, + }; + expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft"); + expect(reloadPreview).not.toHaveBeenCalled(); + const targets = [...doc.querySelectorAll('[data-hf-id="hf-t"]')]; + expect(targets.map((el) => el.getAttribute("style"))).toEqual([null, null]); + }); + + it("full-reloads a sub-composition restore that changes the file's own root", () => { + const { iframe } = buildLiveIframe(host("")); + const reloadPreview = vi.fn(); + const files = { [SUB]: { previous: sub("", ' data-width="10"'), restored: sub("") } }; + expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full"); + expect(reloadPreview).toHaveBeenCalledTimes(1); + }); + + it("full-reloads a sub-composition restore whose file has a GSAP script", () => { + const { iframe, doc } = buildLiveIframe(host(`style="clip-path: inset(0px 40px 0px 0px);"`)); + const reloadPreview = vi.fn(); + const script = ``; + const scripted = (style: string) => + sub(style).replace("", `${script}`); + const files = { + [SUB]: { + previous: scripted(`style="clip-path: inset(0px 40px 0px 0px)"`), + restored: scripted(""), + }, + }; + expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full"); + expect(reloadPreview).toHaveBeenCalledTimes(1); + expect(doc.querySelector('[data-hf-id="hf-t"]')?.getAttribute("style")).toContain("clip-path"); + }); + it("full-reloads when the restore touches a sub-comp, not the active comp", () => { const { iframe } = buildLiveIframe(`
t
`); const reloadPreview = vi.fn(); diff --git a/packages/studio/src/utils/gsapUndoRestore.ts b/packages/studio/src/utils/gsapUndoRestore.ts index 78174abd80..e8fa92ad2f 100644 --- a/packages/studio/src/utils/gsapUndoRestore.ts +++ b/packages/studio/src/utils/gsapUndoRestore.ts @@ -8,6 +8,7 @@ import { extractGsapScriptText, findGsapScriptElements, } from "./gsapSoftReload"; +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"; @@ -22,6 +23,7 @@ export interface UndoRestoreFile { previous: string; restored: string; } +export type RestoreFiles = Record; /** * Identity for the soft diff: `data-hf-id` when present, else `id`. Nearly @@ -40,9 +42,9 @@ function elementIdentityKey(el: Element): string | null { const IDENTITY_SELECTOR = "[id], [data-hf-id]"; -function identityElementMap(doc: Document): Map | null { +function identityElementMap(root: ParentNode): Map | null { const map = new Map(); - for (const el of doc.querySelectorAll(IDENTITY_SELECTOR)) { + for (const el of root.querySelectorAll(IDENTITY_SELECTOR)) { const key = elementIdentityKey(el); if (!key) continue; // Ambiguous identity must full-reload; silently overwriting would restore @@ -53,14 +55,26 @@ function identityElementMap(doc: Document): Map | null { return map; } +// A sub-composition file wraps its markup in a template; the preview inlines that markup into its host. +function parseRestoreSource(html: string): Document { + const doc = new DOMParser().parseFromString(html, "text/html"); + for (const template of Array.from(doc.querySelectorAll("template")).filter( + isCompositionTemplate, + )) { + template.replaceWith(template.content); + } + return doc; +} + // Strip identified elements to their bare identity attributes and blank GSAP // scripts, in place: docs that differ only in identified-element attributes/ // inline-style/script text normalize equal; any residual difference is beyond // soft-reload's reach → caller full-reloads. Both identity attributes are // KEPT, so a change to `id`/`data-hf-id` themselves stays a residual // (structural) difference. -function normalizeSoftResidual(doc: Document): void { - for (const el of doc.querySelectorAll(IDENTITY_SELECTOR)) { +function normalizeSoftResidual(root: Element): void { + const self = root.matches(IDENTITY_SELECTOR) ? [root] : []; + for (const el of [...self, ...root.querySelectorAll(IDENTITY_SELECTOR)]) { const id = el.getAttribute("id"); const hfId = el.getAttribute("data-hf-id"); for (const name of [...el.getAttributeNames()]) { @@ -69,7 +83,7 @@ function normalizeSoftResidual(doc: Document): void { if (id) el.setAttribute("id", id); if (hfId) el.setAttribute("data-hf-id", hfId); } - for (const script of findGsapScriptElements(doc)) script.textContent = ""; + for (const script of findGsapScriptElements(root)) script.textContent = ""; } /** Same attribute set with identical values (order-insensitive). */ @@ -100,14 +114,11 @@ export function diffSoftReloadableRestore( previous: string, restored: string, ): { changedElementKeys: string[] } | null { - let prevDoc: Document; - let nextDoc: Document; - try { - prevDoc = new DOMParser().parseFromString(previous, "text/html"); - nextDoc = new DOMParser().parseFromString(restored, "text/html"); - } catch { - return null; - } + const keys = diffRestoreDocs(parseRestoreSource(previous), parseRestoreSource(restored)); + return keys && { changedElementKeys: keys }; +} + +function diffRestoreDocs(prevDoc: Document, nextDoc: Document): string[] | null { const prevByKey = identityElementMap(prevDoc); const nextByKey = identityElementMap(nextDoc); if (!prevByKey || !nextByKey) return null; @@ -121,10 +132,12 @@ export function diffSoftReloadableRestore( if (!attributesEqual(prevEl, nextEl)) changedElementKeys.push(key); } // Confirm nothing OUTSIDE identified-element attributes and GSAP scripts changed. - normalizeSoftResidual(prevDoc); - normalizeSoftResidual(nextDoc); - if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null; - return { changedElementKeys }; + const [prevRest, nextRest] = [prevDoc, nextDoc].map((doc) => { + const root = doc.documentElement.cloneNode(true) as Element; + normalizeSoftResidual(root); + return root.outerHTML; + }); + return prevRest === nextRest ? changedElementKeys : null; } /** Copy every attribute from `source` onto the live `target`, dropping extras. */ @@ -148,14 +161,10 @@ function syncStaleEditMarks(doc: Document, restored: string): void { } } -function readGsapScriptTexts(html: string): string[] { - const doc = new DOMParser().parseFromString(html, "text/html"); - return findGsapScriptElements(doc).map((script) => script.textContent ?? ""); -} - -function hasAmbiguousGsapScriptChange(previous: string, restored: string): boolean { - const previousScripts = readGsapScriptTexts(previous); - const restoredScripts = readGsapScriptTexts(restored); +function hasAmbiguousGsapScriptChange( + previousScripts: string[], + restoredScripts: string[], +): boolean { if (previousScripts.length <= 1 && restoredScripts.length <= 1) return false; return ( previousScripts.length !== restoredScripts.length || @@ -163,11 +172,124 @@ function hasAmbiguousGsapScriptChange(previous: string, restored: string): boole ); } +type RestoreTarget = { live: Element; restored: 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. */ +function restoreScripted(prevDoc: Document, nextDoc: Document, isActive: boolean): boolean | null { + const [before, after] = [prevDoc, nextDoc].map((doc) => + findGsapScriptElements(doc).map((script) => script.textContent ?? ""), + ); + const scripted = before!.length > 0 || after!.length > 0; + // Only the active document's GSAP script can be re-run in place. + if (scripted && !isActive) return null; + return hasAmbiguousGsapScriptChange(before!, after!) ? null : scripted; +} + +// The active document is the whole preview; a sub-composition lives in each host that inlines it. +function liveScopes(doc: Document, path: string, isActive: boolean): ParentNode[] { + if (isActive) return [doc]; + return Array.from(doc.querySelectorAll(`[data-composition-file="${CSS.escape(path)}"]`)); +} + +function scopeTargets( + scope: ParentNode, + keys: string[], + restoredByKey: Map, +): RestoreTarget[] | null { + const liveByKey = identityElementMap(scope); + if (!liveByKey) return null; + const targets: RestoreTarget[] = []; + for (const key of keys) { + const live = liveByKey.get(key); + 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 }); + } + return targets; +} + +function fileTargets( + doc: Document, + path: string, + isActive: boolean, + file: UndoRestoreFile, +): RestorePlan | null { + const prevDoc = parseRestoreSource(file.previous); + const nextDoc = parseRestoreSource(file.restored); + const scripted = restoreScripted(prevDoc, nextDoc, isActive); + if (scripted === null) return null; + const keys = diffRestoreDocs(prevDoc, nextDoc); + const restoredByKey = identityElementMap(nextDoc); + if (!keys || !restoredByKey) return null; + const found = liveScopes(doc, path, isActive).map((scope) => + scopeTargets(scope, keys, restoredByKey), + ); + if (!found.length || found.includes(null)) return null; + return { targets: (found as RestoreTarget[][]).flat(), scripted }; +} + +/** Every live element a restore changes with its restored markup, or null when it needs a reload. */ +function planRestoreTargets( + doc: Document, + activeDocPath: string, + files: RestoreFiles, +): RestorePlan | null { + const plan: RestorePlan = { targets: [], scripted: false }; + for (const [path, file] of Object.entries(files)) { + const found = fileTargets(doc, path, path === activeDocPath, file); + if (!found) return null; + plan.targets.push(...found.targets); + plan.scripted ||= found.scripted; + } + return plan; +} + +// Rebind-only finalization (no script run); plain seek + manual reapply when the runtime has no rebind hook. +function finalizeInPlace( + iframe: HTMLIFrameElement, + win: PreviewWindow, + currentTime: number, +): boolean { + if (applySoftReloadFinalization(iframe, currentTime)) return true; + try { + win.__player?.seek?.(currentTime); + win.__hfStudioManualEditsApply?.(); + return true; + } catch { + return false; + } +} + +/** Shows a restore without a GSAP script in place, or nothing; returns what puts the shown elements back. */ +export function showRestoreInPlace( + iframe: HTMLIFrameElement | null, + activeCompPath: string | null, + files: RestoreFiles, + currentTime: number, +): ((currentTime: number) => boolean) | null { + const doc = iframe?.contentDocument; + 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 putBack = (time: number) => { + if (iframe.contentDocument !== doc) return false; + for (const [live, attributes] of before) syncElementAttributes(live, attributes); + return finalizeInPlace(iframe, win, time); + }; + if (finalizeInPlace(iframe, win, currentTime)) return putBack; + putBack(currentTime); + return null; +} + /** * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe - * remount (which blanks the frame black and re-flashes the WebGL context). Only - * the active composition — the document living in the root iframe — is eligible; - * a sub-comp or multi-file restore falls back to `reloadPreview`. + * remount (which blanks the frame black and re-flashes the WebGL context). Eligible + * files are the active composition and any sub-composition the preview inlines; + * a file the preview does not show falls back to `reloadPreview`. * * The restore is soft-applied when its only differences are identified-element * (id / data-hf-id) attributes / inline-style and/or the GSAP script (see @@ -184,10 +306,7 @@ function hasAmbiguousGsapScriptChange(previous: string, restored: string): boole * lane move, timing shift, style tweak) → NO script execution — the * blink-free finalization only (seek + __hfForceTimelineRebind + manual * reapply, exactly the rebindPreviewTiming path), so timing-attribute - * reverts refresh their visibility windows. Re-running an unchanged - * script here used to be the biggest undo blink source: it tore down - * and rebuilt live timelines (and full-reloaded whenever the script - * couldn't be scoped) for restores that never touched it. + * reverts refresh their visibility windows. * * Returns "soft" when applied in place, "full" when it escalated to reloadPreview * (ineligible restore, missing target, or a permanent soft-reload failure). @@ -196,7 +315,7 @@ function hasAmbiguousGsapScriptChange(previous: string, restored: string): boole export function applyUndoRestoreToPreview( iframe: HTMLIFrameElement | null, activeCompPath: string | null, - files: Record | undefined, + files: RestoreFiles | undefined, currentTime: number, reload: () => void, ): "soft" | "full" { @@ -210,62 +329,26 @@ export function applyUndoRestoreToPreview( markScenesStale(iframe, paths); reload(); }; - // Soft path only covers the single active-comp document in the root iframe. - if (!iframe || !files || paths.length !== 1 || paths[0] !== activeDocPath) { + const doc = iframe?.contentDocument; + const win = iframe?.contentWindow as PreviewWindow | null; + const plan = doc && files && win ? planRestoreTargets(doc, activeDocPath, files) : null; + if (!iframe || !doc || !files || !win || !plan) { reloadPreview(); return "full"; } - const doc = iframe.contentDocument; - const win = iframe.contentWindow as PreviewWindow | null; - if (!doc || !win) { - reloadPreview(); - return "full"; - } - const { previous, restored } = files[activeDocPath]!; - const diff = diffSoftReloadableRestore(previous, restored); - if (!diff) { - reloadPreview(); - return "full"; - } - // A serialized snapshot cannot identify which of several GSAP scripts owns a - // rewrite. Keep attribute-only restores soft when every script byte is equal, - // but fail closed before touching the live DOM when an ambiguous script changed. - if (hasAmbiguousGsapScriptChange(previous, restored)) { - reloadPreview(); - return "full"; - } - - // Resolve every changed pair BEFORE touching the live DOM. A missing target - // makes the soft restore incomplete, so escalate without leaving a partially - // restored preview behind. - const liveByKey = identityElementMap(doc); - const restoredByKey = identityElementMap(new DOMParser().parseFromString(restored, "text/html")); - if (!liveByKey || !restoredByKey) { - reloadPreview(); - return "full"; - } - const changedTargets: Array<{ live: Element; restored: Element }> = []; - for (const key of diff.changedElementKeys) { - const liveEl = liveByKey.get(key); - const restoredEl = restoredByKey.get(key); - if (!liveEl || !restoredEl) { - reloadPreview(); - return "full"; - } - changedTargets.push({ live: liveEl, restored: restoredEl }); - } // 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 changedTargets) syncElementAttributes(target.live, target.restored); + for (const target of plan.targets) syncElementAttributes(target.live, target.restored); - const restoredScript = extractGsapScriptText(restored); - const previousScript = extractGsapScriptText(previous); + const active = files[activeDocPath]; + const restoredScript = active ? extractGsapScriptText(active.restored) : null; + const previousScript = active ? extractGsapScriptText(active.previous) : null; if (restoredScript && restoredScript !== previousScript) { - syncStaleEditMarks(doc, restored); + syncStaleEditMarks(doc, active.restored); const result = applySoftReload(iframe, restoredScript, { onAsyncFailure: reloadPreview, currentTimeOverride: currentTime, - authoredHtml: restored, + authoredHtml: active.restored, }); if (result === "cannot-soft-reload") { reloadPreview(); @@ -274,16 +357,8 @@ export function applyUndoRestoreToPreview( return "soft"; } // Script unchanged or absent — the live timelines are still valid; only the - // synced attributes need to take effect. Rebind-only finalization (zero - // script execution); plain seek + manual reapply as a degraded fallback when - // the runtime rebind hook is unavailable. - if (applySoftReloadFinalization(iframe, currentTime)) return "soft"; - try { - win.__player?.seek?.(currentTime); - win.__hfStudioManualEditsApply?.(); - } catch { - reloadPreview(); - return "full"; - } - return "soft"; + // synced attributes need to take effect. + if (finalizeInPlace(iframe, win, currentTime)) return "soft"; + reloadPreview(); + return "full"; } diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index 876ce7e432..ea3940f589 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -27,6 +27,18 @@ function inspectDrainFailures(results: PromiseSettledResult[]): { return { firstFailure }; } +function focusedField(): HTMLElement | null { + const active = document.activeElement; + return active instanceof HTMLElement && + active.matches('input, textarea, select, [contenteditable="true"], [role="textbox"]') + ? active + : null; +} + +export function hasStudioPendingEdits(): boolean { + return pendingEditPromises.size > 0 || focusedField() !== null; +} + export function trackStudioPendingEdit( result: Promise | unknown, ): Promise | undefined { @@ -41,11 +53,8 @@ export function trackStudioPendingEdit( } export async function flushStudioPendingEdits(): Promise { - const active = document.activeElement; - if ( - active instanceof HTMLElement && - active.matches('input, textarea, select, [contenteditable="true"], [role="textbox"]') - ) { + const active = focusedField(); + if (active) { active.blur(); // ponytail: Preserve synchronous/microtask blur commits, then cross one task boundary // so React effects triggered by the blur can register their flush listener.