diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 3848b5ff1..0a671789a 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -1296,6 +1296,114 @@ describe("useTimeline undo history", () => { }); }); + // A resize built its save from the render's document, outside the zoom chain: whichever of + // the two saves landed last put the other's field back. + it("keeps a pending zoom level when the pill is resized before it lands", async () => { + seed(docWithZoom); + const gate = gateFirstSave(); + const { result } = renderTimeline(); + + const pDepth = result.current.updateZoomDepth("zoom_a", 4); + const pSpan = result.current.updateZoomSpan("zoom_a", 1500, 3500); + await waitFor(() => expect(gate.release).toEqual(expect.any(Function))); + await act(async () => { + gate.release?.(); + await Promise.all([pDepth, pSpan]); + }); + + expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ + depth: 4, + startMs: 1500, + endMs: 3500, + }); + }); + + // The inspector's "Reset focus point" is a live write and its commit in one click, next to + // the level buttons. The level landing first also takes the live focus off screen, so the + // commit has to put it back on top rather than save what it finds. + it("keeps a pending zoom level when a focus is committed before it lands", async () => { + seed(docWithZoom); + const gate = gateFirstSave(); + const { result } = renderTimeline(); + + const pDepth = result.current.updateZoomDepth("zoom_a", 4); + await waitFor(() => expect(gate.release).toEqual(expect.any(Function))); + act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 })); + const pCommit = result.current.commitZoomFocus(); + await act(async () => { + gate.release?.(); + await Promise.all([pDepth, pCommit]); + }); + + expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ + depth: 4, + focus: { cx: 0.8, cy: 0.2 }, + }); + // One undo step each, the focus first. + act(() => { + expect(undo()).toBe(true); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ + depth: 4, + focus: { cx: 0.5, cy: 0.5 }, + }); + act(() => { + expect(undo()).toBe(true); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(3); + }); + + // The commit's focus is put back only over a write of its own epoch. A drag abandoned + // (`ZoomFocusOverlay` unmounts when the focus mode flips to auto) and then undone must stay + // undone when a bare commit (`endDrag` with nothing in front of it) comes later. + it("does not put an abandoned drag's focus back after an undo", async () => { + seed(docWithZoom); + const { result } = renderTimeline(); + + await act(async () => { + await result.current.updateZoomDepth("zoom_a", 4); + }); + act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 })); + act(() => { + expect(undo()).toBe(true); + }); + expect(future).toHaveLength(1); + + await act(async () => { + await result.current.commitZoomFocus(); + }); + + expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({ + cx: 0.5, + cy: 0.5, + }); + expect(future).toHaveLength(1); + }); + + // A zoom write that runs while a focus drag is on screen saves the dragged focus too, so the + // commit finds nothing left to record. That write's undo step has to reach back to before + // the drag, or no Ctrl+Z could ever bring the old focus back. + it("undoes a focus drag with the zoom write that saved it", async () => { + seed(docWithZoom); + const { result } = renderTimeline(); + + act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 })); + await act(async () => { + await result.current.updateZoomDepth("zoom_a", 4); + }); + await act(async () => { + await result.current.commitZoomFocus(); + }); + + act(() => { + expect(undo()).toBe(true); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ + depth: 3, + focus: { cx: 0.5, cy: 0.5 }, + }); + }); + // Rebase-review finding (queued zoom writes vs. document replacement): a zoom write // queued behind a still-pending one starts AFTER an undo has restored the document, // and must not apply its stale patch to the replacement. The in-flight write itself @@ -1429,6 +1537,47 @@ describe("useTimeline undo history", () => { } }); + // A focus commit out of time leaves the zoom chain, but its save can still fail. The drag + // must come off screen then, as for any failed commit, or a later save persists it. + it("rolls a focus drag back when its commit fails after the deadline", async () => { + seed(docWithZoom); + vi.useFakeTimers(); + try { + let failHungSave: () => void; + bridgeMocks.save.mockImplementation( + () => + new Promise((resolve) => { + failHungSave = () => resolve({ success: false, error: "disk full" }); + }), + ); + const { result } = renderTimeline(); + + act(() => result.current.updateZoomFocusLive("zoom_a", { cx: 0.8, cy: 0.2 })); + act(() => { + void result.current.commitZoomFocus(); + }); + // No answer by the deadline: the save may still land, so the drag stays. + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({ + cx: 0.8, + cy: 0.2, + }); + + await act(async () => { + failHungSave(); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]?.focus).toEqual({ + cx: 0.5, + cy: 0.5, + }); + } finally { + vi.useRealTimers(); + } + }); + // Rebase-review follow-up (unknown save × replacement): the refusal must not outlive // its reason. Once an undo bumps the epoch, the stuck save can no longer install // anything (`saveDocument` drops it), so it must stop blocking zoom writes — even if diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index d47d1a4a4..a10af9ce5 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -135,6 +135,13 @@ export function useTimeline() { // was never written. const zoomFocusRollbackRef = useRef(null); const zoomFocusLiveRef = useRef(null); + // And the focus drag's last value, which its commit puts back if a zoom write lands over it, + // with the write epoch it was made in. + const zoomFocusEditRef = useRef<{ + id: string; + focus: { cx: number; cy: number }; + epoch: number; + } | null>(null); const annotationRollbackRef = useRef(null); const annotationLiveRef = useRef(null); @@ -148,6 +155,7 @@ export function useTimeline() { useEffect(() => { zoomFocusRollbackRef.current = null; zoomFocusLiveRef.current = null; + zoomFocusEditRef.current = null; annotationRollbackRef.current = null; annotationLiveRef.current = null; }, [projectId]); @@ -624,29 +632,119 @@ export function useTimeline() { [saveDocument], ); + // The zoom pane's own write chain -- see `queueZoomWrite`. Only `enqueue` is used, so + // there is no fallback document to hand it. + const { enqueue: enqueueZoomWrite } = useSequentialTimelineOps({ + fallbackDocument: null, + saveDocument, + }); + + // Every whole-document write to one zoom goes through this chain: the pane's one-field + // writes (level, 3D tilt, focus mode, cursor, click impact), a pill's span, and the focus + // commit. `write` runs INSIDE the chain, on the document the previous zoom write left, and + // returns its `saveDocument`. The level buttons step while the previous save is still out, + // and 3 -> 4 -> 5 built both saves from the render's depth-3 document: the main process does + // not order them, so the 4 could land last, and even in order one Ctrl+Z skipped a level. A + // neighbouring select changed, a pill resized or a focus committed while a level was pending + // rebuilt from that same document and put 3 back. Resolves `saveDocument`'s answer, so the + // level buttons can retry a failed write, or `"timeout"` when it did not come in time. + // + // Each request is bound to the project and write epoch it was asked against — the same + // pair `addAsset` samples: an undo bumps the epoch, a project switch swaps both, and a + // queued patch that only STARTS after such a replacement must not apply to the document + // that replaced its target. A save whose answer is unknown (`saveWithDeadline` timed out + // with the bridge still silent) may still land, so later zoom writes are refused until it + // settles instead of racing it — the same "a queued write racing a stuck one" the + // `waitForDocumentSaves` header calls out. The block is keyed to the save's own epoch: + // once a replacement moves the epoch, that save can no longer install anything + // (`saveDocument` drops it) and must stop blocking; a project switch does not move the + // epoch, so there the stuck save can still land and the block correctly stays. + const unknownZoomSavesRef = useRef>([]); + const queueZoomWrite = useCallback( + (write: (doc: AxcutDocument, historyBase: AxcutDocument | undefined) => Promise) => { + const epoch = currentWriteEpoch(); + const projectId = useProjectStore.getState().projectId; + return enqueueZoomWrite(async () => { + if (useProjectStore.getState().projectId !== projectId || currentWriteEpoch() !== epoch) { + return false; + } + if (unknownZoomSavesRef.current.filter((stuck) => stuck === epoch).length > 0) { + return false; + } + const doc = useProjectStore.getState().document; + if (!doc) return false; + // A focus drag not yet committed is on screen, so this write saves it too, and the + // commit will find nothing left to record. Its undo step reaches back to before + // the drag instead, or the drag could never be undone. + const historyBase = + doc === zoomFocusLiveRef.current + ? (zoomFocusRollbackRef.current ?? undefined) + : undefined; + const save = write(doc, historyBase); + const outcome = await saveWithDeadline(save); + if (outcome === "timeout") { + // Unknown, not failed: the write may still land. Refuse later writes into + // this same document generation until the save settles or the epoch moves + // past it. + unknownZoomSavesRef.current.push(epoch); + void save + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + unknownZoomSavesRef.current = unknownZoomSavesRef.current.filter( + (stuck) => stuck !== epoch, + ); + }); + } + return outcome; + }); + }, + [enqueueZoomWrite], + ); + + // Reports not-taken for an unknown answer too, so the buttons retry. + const saveZoomPatch = useCallback( + (id: string, patch: Partial) => + queueZoomWrite((doc, historyBase) => + saveDocument( + { + ...doc, + zoomRanges: patchPillById(doc.zoomRanges, id, patch) as AxcutDocument["zoomRanges"], + }, + { history: true, historyBase }, + ), + ).then((outcome) => outcome === true), + [queueZoomWrite, saveDocument], + ); + // Span edits are GROUP-AWARE: dragging/resizing a pill re-anchors every fragment // under the pill to the new ruler span, so an edit that crosses a clip // boundary re-splits and one dragged back inside a clip collapses — one user edit stays // one pill. See timelineMap.reanchorGroupSpan. const updateZoomSpan = useCallback( - async (id: string, startMs: number, endMs: number) => { - if (!document) return; + (id: string, startMs: number, endMs: number) => { const s = finiteMs(startMs); const e = finiteMs(endMs); - const next: AxcutDocument = { - ...document, - zoomRanges: replacePillSpan( - document.zoomRanges, - id, - Math.min(s, e), - Math.max(s, e), - document.timeline.clips, - () => createId("zoom"), - ) as AxcutDocument["zoomRanges"], - }; - await saveDocument(next, { history: true }); + return queueZoomWrite((doc, historyBase) => + saveDocument( + { + ...doc, + zoomRanges: replacePillSpan( + doc.zoomRanges, + id, + Math.min(s, e), + Math.max(s, e), + doc.timeline.clips, + () => createId("zoom"), + ) as AxcutDocument["zoomRanges"], + }, + { history: true, historyBase }, + ), + ); }, - [document, saveDocument], + [queueZoomWrite, saveDocument], ); // ponytail: the focus overlay drags at pointermove frequency (~60-120 Hz). @@ -666,115 +764,96 @@ export function useTimeline() { // behind when the commit failed and rolled the document back to that very // document — a Ctrl+Z that visibly did nothing, with `future` already wiped. if (zoomFocusLiveRef.current !== doc) zoomFocusRollbackRef.current = doc; + const edit = { + id, + focus: { cx: finiteFraction(focus.cx), cy: finiteFraction(focus.cy) }, + epoch: currentWriteEpoch(), + }; const next: AxcutDocument = { ...doc, zoomRanges: patchPillById(doc.zoomRanges, id, { - focus: { cx: finiteFraction(focus.cx), cy: finiteFraction(focus.cy) }, + focus: edit.focus, }) as AxcutDocument["zoomRanges"], }; setDocument(next, { history: false }); zoomFocusLiveRef.current = next; + zoomFocusEditRef.current = edit; }, [setDocument], ); + // On the zoom chain, like every zoom write: a level still being saved when the focus is + // committed would otherwise land first and be overwritten by this save, built before it. const commitZoomFocus = useCallback(async () => { - const doc = useProjectStore.getState().document; - if (!doc) return; - // The snapshot counts only while the document on screen is still the one this - // hook's last live write produced -- `updateZoomFocusLive`'s own identity test, - // read the other way round. Without it a commit that arrives with no live write - // in front of it picks up whatever an abandoned drag left behind, and every - // recording write since has already put the states in between on the stack: as a - // `historyBase` that makes one Ctrl+Z step over the lot, and on the failure path - // below it puts that buried document back on screen, silently dropping them. - // `handlePointerDown` sets `draggingRef` BEFORE its live write and that write - // returns early on a zero-size overlay rect, so `endDrag` can reach here bare. - const rollback = zoomFocusLiveRef.current === doc ? zoomFocusRollbackRef.current : null; + // What the drag left, taken now. While the commit waits its turn, a zoom write queued + // before the drag can land, and it installs ITS document, built before the drag, in + // place of the dragged one. + const live = zoomFocusLiveRef.current; + const rollback = zoomFocusRollbackRef.current; + const epoch = currentWriteEpoch(); + // A drag abandoned before an undo or a replacement is not this commit's to put back: the + // document it was made on is gone, and restoring its focus would undo the undo. + const pendingEdit = zoomFocusEditRef.current; + const edit = pendingEdit?.epoch === epoch ? pendingEdit : null; zoomFocusRollbackRef.current = null; zoomFocusLiveRef.current = null; - // `historyBase: rollback` — the pre-drag document, not the one the store holds - // (that is the dragged one, written live). `null` when no live write happened, - // which records nothing, which is right: nothing changed. - if (!(await saveDocument(doc, { history: true, historyBase: rollback })) && rollback) { + zoomFocusEditRef.current = null; + const pending: { save?: Promise } = {}; + const outcome = await queueZoomWrite((doc) => { + // The snapshot counts only while the document on screen is still the one this + // hook's last live write produced -- `updateZoomFocusLive`'s own identity test, + // read the other way round. Without it a commit that arrives with no live write + // in front of it picks up whatever an abandoned drag left behind, and every + // recording write since has already put the states in between on the stack: as a + // `historyBase` that makes one Ctrl+Z step over the lot, and on the failure path + // below it puts that buried document back on screen, silently dropping them. + // `handlePointerDown` sets `draggingRef` BEFORE its live write and that write + // returns early on a zero-size overlay rect, so `endDrag` can reach here bare. + // + // `historyBase` is the pre-drag document, not the one the store holds (that is the + // dragged one, written live); `null` when no live write happened, which records + // nothing, which is right: nothing changed. + let next = doc; + let historyBase = doc === live ? rollback : null; + const pill = edit ? doc.zoomRanges.find((zoom) => zoom.id === edit.id) : undefined; + if ( + doc !== live && + edit && + pill && + (pill.focus.cx !== edit.focus.cx || pill.focus.cy !== edit.focus.cy) + ) { + // A zoom write landed over the drag and took its focus off screen. The focus goes + // back on top of that write, so both stay, and one Ctrl+Z takes the focus off it + // again. When the writes since carried the focus along, there is nothing to add. + next = { + ...doc, + zoomRanges: patchPillById(doc.zoomRanges, edit.id, { + focus: edit.focus, + }) as AxcutDocument["zoomRanges"], + }; + historyBase = doc; + } + pending.save = saveDocument(next, { history: true, historyBase }); + return pending.save; + }); + // A save out of time has left the chain, but it can still fail: its answer, whenever it + // comes, decides the rollback, or the dragged focus stays on screen for a later save. + const saved = outcome === "timeout" && pending.save ? await pending.save : outcome; + if (saved === false && rollback) { useProjectStore.setState((state) => + // Only while the dragged document is still the one on screen, in the epoch it was + // committed in: anything else there was put by a write that landed, or by an undo. // `dirty` is deliberately NOT cleared. The rollback target is the last document // this drag started from, which is not the same as the last SAVED one: with two // commits in flight the first one's unsaved document is what we restore. Saying // "clean" there tells `beforeunload` and `setHasUnsavedChanges` there is nothing // to save, and the window closes on real work without prompting. - state.document === doc ? { document: rollback, revision: state.revision + 1 } : {}, + state.document === live && currentWriteEpoch() === epoch + ? { document: rollback, revision: state.revision + 1 } + : {}, ); } - }, [saveDocument]); - - // The zoom pane's own write chain -- see `saveZoomPatch`. Only `enqueue` is used, so - // there is no fallback document to hand it. - const { enqueue: enqueueZoomWrite } = useSequentialTimelineOps({ - fallbackDocument: null, - saveDocument, - }); - - // The zoom pane's one-field writes: level, 3D tilt, focus mode, cursor, click impact. Each - // is a whole-document save, so they share one chain and read the document INSIDE it. The - // level buttons step while the previous save is still out, and 3 -> 4 -> 5 built both saves - // from the render's depth-3 document: the main process does not order them, so the 4 could - // land last, and even in order one Ctrl+Z skipped a level. A neighbouring select changed - // while a level was pending rebuilt from that same document and put 3 back. Resolves - // `saveDocument`'s answer, so the level buttons can retry a failed write. - // - // Each request is bound to the project and write epoch it was asked against — the same - // pair `addAsset` samples: an undo bumps the epoch, a project switch swaps both, and a - // queued patch that only STARTS after such a replacement must not apply to the document - // that replaced its target. A save whose answer is unknown (`saveWithDeadline` timed out - // with the bridge still silent) may still land, so later zoom writes are refused until it - // settles instead of racing it — the same "a queued write racing a stuck one" the - // `waitForDocumentSaves` header calls out. The block is keyed to the save's own epoch: - // once a replacement moves the epoch, that save can no longer install anything - // (`saveDocument` drops it) and must stop blocking; a project switch does not move the - // epoch, so there the stuck save can still land and the block correctly stays. - const unknownZoomSavesRef = useRef>([]); - const saveZoomPatch = useCallback( - (id: string, patch: Partial) => { - const epoch = currentWriteEpoch(); - const projectId = useProjectStore.getState().projectId; - return enqueueZoomWrite(async () => { - if (useProjectStore.getState().projectId !== projectId || currentWriteEpoch() !== epoch) { - return false; - } - if (unknownZoomSavesRef.current.filter((stuck) => stuck === epoch).length > 0) { - return false; - } - const doc = useProjectStore.getState().document; - if (!doc) return false; - const save = saveDocument( - { - ...doc, - zoomRanges: patchPillById(doc.zoomRanges, id, patch) as AxcutDocument["zoomRanges"], - }, - { history: true }, - ); - const outcome = await saveWithDeadline(save); - if (outcome !== "timeout") return outcome === true; - // Unknown, not failed: the write may still land. Report not-taken (the - // buttons retry) and refuse later writes into this same document generation - // until the save settles or the epoch moves past it. - unknownZoomSavesRef.current.push(epoch); - void save - .then( - () => undefined, - () => undefined, - ) - .finally(() => { - unknownZoomSavesRef.current = unknownZoomSavesRef.current.filter( - (stuck) => stuck !== epoch, - ); - }); - return false; - }); - }, - [enqueueZoomWrite, saveDocument], - ); + }, [queueZoomWrite, saveDocument]); // A preset level (`ZOOM_DEPTH_SCALES`, 1.25×–5×). It clears any custom scale, which // would otherwise keep overriding the depth: the level picked is the level rendered.