From 9ea5766552fa083753a1775e5b43df80b8956a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:14:57 +0800 Subject: [PATCH 1/5] feat(inspector): pick the zoom level from a row of buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Zoom Level select with six always-visible buttons. Keyboard stays local to the row: every level is a Tab stop, Enter/Space activate, and arrows step from the focused button with both ends clamped. Rapid clicks share a small zoom-pane write chain so 3 → 4 → 5 lands in order. Each request has a generation, so late settlement, a region switch, or a failed save can still retry. Fixes #670. --- .../ai-edition/v4/FloatingInspector.tsx | 181 +++++++- .../ai-edition/v4/ZoomLevelControl.test.tsx | 391 ++++++++++++++++++ .../store/documentWriteAudit.test.ts | 5 +- src/lib/ai-edition/store/useTimeline.test.ts | 69 ++++ src/lib/ai-edition/store/useTimeline.ts | 85 ++-- tests/e2e/v4-shell.spec.ts | 88 +++- 6 files changed, 751 insertions(+), 68 deletions(-) create mode 100644 src/components/ai-edition/v4/ZoomLevelControl.test.tsx diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 949e307d0..5c0cc6867 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -36,6 +36,7 @@ import { MOVING_ROTATION_3D_PRESETS, type Rotation3DPreset, ZOOM_DEPTH_SCALES, + type ZoomDepth, } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import { @@ -428,7 +429,142 @@ function convertAnnotationKind( return { ...parked, type: next, content: restored }; } -const ZOOM_DEPTHS = [1, 2, 3, 4, 5, 6] as const; +const ZOOM_DEPTHS: readonly ZoomDepth[] = [1, 2, 3, 4, 5, 6]; + +/** + * The six zoom levels as one row of buttons, so a level is one click away instead of two + * (open the select, then pick). Six short labels fit the 300px pane on their own line, which + * is why this is a stacked label/row rather than a `paneRow`. + * + * `aria-pressed` buttons inside a labelled `role="group"` is `TranscriptLaneSwitch`'s pattern + * (the facet rail is the same buttons without the wrapper, since its own label carries), so + * every level stays in the Tab order and reads like its neighbours. Arrow keys step through + * the levels, which is what the ` - void tl.updateZoomDepth(region.id, Number(e.target.value) as 1 | 2 | 3 | 4 | 5 | 6) - } - className={shell.control} - > - {ZOOM_DEPTHS.map((d) => ( - - ))} - , - )} +
{paneRow( ts("zoom.camera.title"), @@ -1095,6 +1214,30 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void } * slab outshouted every setting above it. */ const PANE_BUTTON = `${shell.btn} ${shell.btnSecondary}`; +// Six of these share the pane's 266px of content width, so each gets ~41px: enough for +// "1.25×" at 12px with room either side, and no horizontal padding to lose. +const zoomLevelBtnStyle: React.CSSProperties = { + flex: "1 1 0", + minWidth: 0, + height: 28, + padding: 0, + borderRadius: 8, + border: "1px solid var(--border)", + background: "var(--surface)", + color: "var(--fg-2)", + font: "500 12px var(--font-display)", + cursor: "pointer", +}; + +// Same signal as the pressed facet-rail button: accent text on the soft accent fill. +const zoomLevelPressedStyle: React.CSSProperties = { + ...zoomLevelBtnStyle, + border: "1px solid var(--accent)", + background: "var(--accent-soft)", + color: "var(--accent)", + fontWeight: 600, +}; + function FacetBody({ facet, onCollapse, diff --git a/src/components/ai-edition/v4/ZoomLevelControl.test.tsx b/src/components/ai-edition/v4/ZoomLevelControl.test.tsx new file mode 100644 index 000000000..eb18eba1d --- /dev/null +++ b/src/components/ai-edition/v4/ZoomLevelControl.test.tsx @@ -0,0 +1,391 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { ZOOM_DEPTH_SCALES, type ZoomDepth } from "@/components/video-editor/types"; + +// The pane is only reachable with a project open and a zoom region selected, so drive the +// control directly. The translator echoes keys, as in `SpeedControl.test.tsx`. +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string) => key, +})); + +import { ZoomLevelControl } from "./FloatingInspector"; + +function renderControl(depth: ZoomDepth) { + const updateZoomDepth = vi.fn(async () => true); + render(); + const group = screen.getByRole("group", { name: "zoom.level" }); + const buttons = screen.getAllByRole("button"); + return { updateZoomDepth, group, buttons }; +} + +/** + * The same control with the pane's half of the loop in place: in the editor `updateZoomDepth` + * writes the region and the pane re-renders with the new `depth`, so the pressed button moves + * under the keyboard. Stepping is only coherent if that feedback exists — with a frozen prop + * every arrow would keep counting from the level the control opened on. + */ +function renderControlled(initial: ZoomDepth) { + const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); + function Harness() { + const [depth, setDepth] = useState(initial); + return ( + { + // Synchronously, so the re-render lands inside the `fireEvent` that caused it: + // the next keystroke in a test then sees the same DOM a user's would. + updateZoomDepth(id, next); + setDepth(next); + return Promise.resolve(true); + }, + }} + /> + ); + } + render(); + const group = screen.getByRole("group", { name: "zoom.level" }); + const buttons = screen.getAllByRole("button"); + const focusLevel = (depth: ZoomDepth) => (buttons[depth - 1] as HTMLButtonElement).focus(); + return { updateZoomDepth, group, buttons, focusLevel }; +} + +describe("ZoomLevelControl", () => { + it("renders one button per depth, labelled with the table value, current one pressed", () => { + const { buttons } = renderControl(3); + expect(buttons).toHaveLength(6); + expect(buttons.map((b) => b.textContent)).toEqual( + ([1, 2, 3, 4, 5, 6] as const).map((d) => `${ZOOM_DEPTH_SCALES[d]}×`), + ); + expect(buttons.map((b) => b.getAttribute("aria-pressed"))).toEqual([ + "false", + "false", + "true", + "false", + "false", + "false", + ]); + }); + + it("commits a level in one click", () => { + const { updateZoomDepth, buttons } = renderControl(3); + fireEvent.click(buttons[4] as HTMLButtonElement); + expect(updateZoomDepth).toHaveBeenCalledTimes(1); + expect(updateZoomDepth).toHaveBeenCalledWith("z1", 5); + }); + + it("does not write when the current level is clicked again", () => { + // A no-op edit would still land a save and an undo entry. + const { updateZoomDepth, buttons } = renderControl(3); + fireEvent.click(buttons[2] as HTMLButtonElement); + expect(updateZoomDepth).not.toHaveBeenCalled(); + }); + + // The other half of that guard: undo/redo and the agent write the region without going + // through this control, so a request of ours must never outlive the prop. The moment the + // region says something else, that is the level to compare against. + it("follows the region when the level is changed from elsewhere", async () => { + const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); + const { rerender } = render( + , + ); + fireEvent.click(screen.getAllByRole("button")[4] as HTMLButtonElement); + expect(updateZoomDepth).toHaveBeenCalledWith("z1", 5); + await act(async () => { + // Let the request settle so the follow-effect is allowed to copy the prop. + }); + + // An undo lands on 2 instead of the 5 this control asked for. + rerender(); + fireEvent.click(screen.getAllByRole("button")[1] as HTMLButtonElement); + expect(updateZoomDepth).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getAllByRole("button")[4] as HTMLButtonElement); + expect(updateZoomDepth).toHaveBeenCalledTimes(2); + expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5); + }); + + it("steps to the neighbouring level with the arrow keys and moves focus with it", () => { + const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(3); + focusLevel(3); + fireEvent.keyDown(group, { key: "ArrowRight" }); + expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 4); + expect(buttons[3]).toHaveFocus(); + fireEvent.keyDown(group, { key: "ArrowDown" }); + expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5); + expect(buttons[4]).toHaveFocus(); + fireEvent.keyDown(group, { key: "ArrowLeft" }); + expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 4); + fireEvent.keyDown(group, { key: "ArrowUp" }); + expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 3); + expect(buttons[2]).toHaveFocus(); + expect(updateZoomDepth).toHaveBeenCalledTimes(4); + expect(buttons[2]).toHaveAttribute("aria-pressed", "true"); + }); + + // Every level is a Tab stop, so focus can sit on a level that is not the selected one. + // Counting from the selection there moved focus the wrong way across the row. + it("steps from the button that has focus, not from the selected level", () => { + const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(2); + focusLevel(5); + fireEvent.keyDown(group, { key: "ArrowRight" }); + expect(buttons[5]).toHaveFocus(); + expect(updateZoomDepth).toHaveBeenCalledWith("z1", 6); + }); + + // `updateZoomDepth` writes the document, so the pressed state only catches up a tick later. + // Focus moves in the keystroke itself, which is why holding an arrow down keeps advancing + // instead of re-applying the same step against a `depth` prop that has not landed yet. + it("keeps stepping while the write is still in flight", () => { + const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); + function Harness() { + const [depth, setDepth] = useState(3); + return ( + { + await updateZoomDepth(id, next); + setDepth(next); + return true; + }, + }} + /> + ); + } + render(); + const group = screen.getByRole("group", { name: "zoom.level" }); + const buttons = screen.getAllByRole("button"); + (buttons[2] as HTMLButtonElement).focus(); + fireEvent.keyDown(group, { key: "ArrowRight" }); + fireEvent.keyDown(group, { key: "ArrowRight" }); + expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5]); + expect(buttons[4]).toHaveFocus(); + }); + + // The mirror image of the test above, and the one that catches a stale read: stepping + // BACK to where the region started. The guard that makes re-pressing the current level a + // no-op has to compare against what was last asked for, not against a `depth` prop that + // still says 3 because the first write has not landed -- or the user's second keystroke + // is dropped and the level stays on 4. + it("does not drop a step back while the first write is still in flight", () => { + const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); + function Harness() { + const [depth, setDepth] = useState(3); + return ( + { + await updateZoomDepth(id, next); + setDepth(next); + return true; + }, + }} + /> + ); + } + render(); + const group = screen.getByRole("group", { name: "zoom.level" }); + const buttons = screen.getAllByRole("button"); + (buttons[2] as HTMLButtonElement).focus(); + fireEvent.keyDown(group, { key: "ArrowRight" }); + fireEvent.keyDown(group, { key: "ArrowLeft" }); + expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 3]); + expect(buttons[2]).toHaveFocus(); + }); + + // `saveDocument` writes the returned document into the store and only then + // resolves, so an earlier request can echo back while a later one is still in + // flight. Copying that echo into the no-op guard made ArrowLeft look like a + // re-press of the current level: 3 → 4 → 5, 4 lands, ArrowLeft dropped, and + // the level stayed on 5 with focus on 4. + it("does not treat an earlier in-flight write as the latest request", async () => { + const resolvers: Array<() => void> = []; + const updateZoomDepth = vi.fn((_id: string, _depth: ZoomDepth) => { + return new Promise((resolve) => { + resolvers.push(() => resolve(true)); + }); + }); + function Harness() { + const [depth, setDepth] = useState(3); + return ( + { + const pending = updateZoomDepth(id, next); + void pending.then(() => setDepth(next)); + return pending; + }, + }} + /> + ); + } + render(); + const group = screen.getByRole("group", { name: "zoom.level" }); + const buttons = screen.getAllByRole("button"); + (buttons[2] as HTMLButtonElement).focus(); + fireEvent.keyDown(group, { key: "ArrowRight" }); + fireEvent.keyDown(group, { key: "ArrowRight" }); + expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5]); + expect(resolvers).toHaveLength(2); + + await act(async () => { + resolvers[0]!(); + }); + expect(buttons[3]).toHaveAttribute("aria-pressed", "true"); + expect(buttons[4]).toHaveFocus(); + + fireEvent.keyDown(group, { key: "ArrowLeft" }); + expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5, 4]); + expect(buttons[3]).toHaveFocus(); + }); + + // Depth values repeat, so an older request landing on 4 must not look like + // the later request for 4 has confirmed, or the 5 in between is treated as + // an external write and the latest 4 is lost. + it("does not treat an older request for the same depth as the latest one", async () => { + const resolvers: Array<() => void> = []; + const updateZoomDepth = vi.fn((_id: string, _depth: ZoomDepth) => { + return new Promise((resolve) => { + resolvers.push(() => resolve(true)); + }); + }); + function Harness() { + const [depth, setDepth] = useState(3); + return ( + { + const pending = updateZoomDepth(id, next); + void pending.then(() => setDepth(next)); + return pending; + }, + }} + /> + ); + } + render(); + const group = screen.getByRole("group", { name: "zoom.level" }); + const buttons = screen.getAllByRole("button"); + (buttons[2] as HTMLButtonElement).focus(); + fireEvent.keyDown(group, { key: "ArrowRight" }); + fireEvent.keyDown(group, { key: "ArrowRight" }); + fireEvent.keyDown(group, { key: "ArrowLeft" }); + expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5, 4]); + + await act(async () => { + resolvers[0]!(); + }); + await act(async () => { + resolvers[1]!(); + }); + fireEvent.click(buttons[4] as HTMLButtonElement); + expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5, 4, 5]); + }); + + it("does not leak a pending request onto a different zoom region", async () => { + const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); + const { rerender } = render( + , + ); + fireEvent.click(screen.getAllByRole("button")[4] as HTMLButtonElement); + expect(updateZoomDepth).toHaveBeenCalledWith("A", 5); + + rerender(); + fireEvent.click(screen.getAllByRole("button")[4] as HTMLButtonElement); + expect(updateZoomDepth).toHaveBeenLastCalledWith("B", 5); + expect(updateZoomDepth).toHaveBeenCalledTimes(2); + }); + + it("follows an undo after rapid steps have all settled", async () => { + const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); + const { rerender } = render( + , + ); + const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[3] as HTMLButtonElement); + fireEvent.click(buttons[4] as HTMLButtonElement); + expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5]); + await act(async () => { + // both generations must drain, or the follow-effect stays blocked + }); + + rerender(); + fireEvent.click(buttons[4] as HTMLButtonElement); + expect(updateZoomDepth).toHaveBeenCalledTimes(3); + expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5); + }); + + it("retries the same level after a failed save", async () => { + const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => false); + render(); + const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[4] as HTMLButtonElement); + await act(async () => { + // settle the failed write so the same target is not stuck as current + }); + fireEvent.click(buttons[4] as HTMLButtonElement); + expect(updateZoomDepth).toHaveBeenCalledTimes(2); + expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5); + }); + + it("clamps at the lowest level instead of wrapping", () => { + const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(1); + focusLevel(1); + fireEvent.keyDown(group, { key: "ArrowLeft" }); + expect(updateZoomDepth).not.toHaveBeenCalled(); + expect(buttons[0]).toHaveFocus(); + }); + + it("clamps at the highest level instead of wrapping", () => { + const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(6); + focusLevel(6); + fireEvent.keyDown(group, { key: "ArrowRight" }); + expect(updateZoomDepth).not.toHaveBeenCalled(); + expect(buttons[5]).toHaveFocus(); + }); + + it("keeps its own keys off the window listener, and lets every other key through", () => { + // The editor shell listens on WINDOW, above React's root container: ArrowLeft/ArrowRight + // seek the playhead there and Space is play/pause. Space matters most — the shell + // `preventDefault()`s it, which cancels the button's own activation, so an unstopped + // Space changed no level and started playback instead. + // + // What this pins is the propagation rule, which is where the bug was. jsdom does not + // dispatch a button's native activation for Space at all, so "Space commits the focused + // level" is only provable in a browser, where it was checked against the running editor. + const onWindowKey = vi.fn(); + window.addEventListener("keydown", onWindowKey); + try { + const { group } = renderControlled(3); + for (const key of ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", " ", "Enter"]) { + fireEvent.keyDown(group, { key }); + } + expect(onWindowKey).not.toHaveBeenCalled(); + + // Keys the group ignores still get there, or the editor shortcuts would be dead. + fireEvent.keyDown(group, { key: "z" }); + fireEvent.keyDown(group, { key: "Tab" }); + expect(onWindowKey).toHaveBeenCalledTimes(2); + } finally { + window.removeEventListener("keydown", onWindowKey); + } + }); + + // `preventDefault()` on the activation keys would cancel the button's own click, which is + // exactly how the shell broke Space in the first place. + it("does not cancel the default action of the activation keys", () => { + const { group } = renderControlled(3); + for (const key of ["Enter", " "]) { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + group.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + } + }); +}); diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index 0d054eb83..0d3a3fd73 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -285,6 +285,7 @@ const DECLARED: WritePath[] = [ w("src/lib/ai-edition/store/useTimeline.ts", "removeClip", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "removeRegion", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "removeRegions", "save", "gesture"), + w("src/lib/ai-edition/store/useTimeline.ts", "saveZoomPatch", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "setTrimEntries", "save", "gesture"), // The live halves of the two drags. w("src/lib/ai-edition/store/useTimeline.ts", "updateAnnotationLive", "set", "automatic"), @@ -294,11 +295,7 @@ const DECLARED: WritePath[] = [ w("src/lib/ai-edition/store/useTimeline.ts", "updateSpeedValue", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateTrim", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomClickImpact", "save", "gesture"), - w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomDepth", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomFocusLive", "set", "automatic"), - w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomFocusMode", "save", "gesture"), - w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomHideCursor", "save", "gesture"), - w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomRotation", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomSpan", "save", "gesture"), // Source-dimension backfill for assets a migration left unprobed. On load, for // every project, whether or not the user touches anything. diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 797f39eda..ed2a10478 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -1197,6 +1197,75 @@ describe("useTimeline undo history", () => { expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(2); }); + // Holds the first document save until the test releases it; every later one lands at once. + const gateFirstSave = () => { + const gate: { release?: () => void } = {}; + let calls = 0; + bridgeMocks.save.mockImplementation(async (doc: AxcutDocument) => { + calls += 1; + if (calls === 1) { + await new Promise((resolve) => { + gate.release = resolve; + }); + } + return { success: true, document: doc }; + }); + return gate; + }; + + it("lands rapid zoom-level steps in order, one undo step each", async () => { + seed(docWithZoom); + const gate = gateFirstSave(); + const { result } = renderTimeline(); + + const p4 = result.current.updateZoomDepth("zoom_a", 4); + const p5 = result.current.updateZoomDepth("zoom_a", 5); + await waitFor(() => expect(gate.release).toEqual(expect.any(Function))); + await act(async () => { + gate.release?.(); + await Promise.all([p4, p5]); + }); + + expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(5); + act(() => { + expect(undo()).toBe(true); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(4); + }); + + it("keeps a pending zoom level when the 3D tilt is changed before it lands", async () => { + seed(docWithZoom); + const gate = gateFirstSave(); + const { result } = renderTimeline(); + + const pDepth = result.current.updateZoomDepth("zoom_a", 4); + const pRotation = result.current.updateZoomRotation("zoom_a", "iso"); + await waitFor(() => expect(gate.release).toEqual(expect.any(Function))); + await act(async () => { + gate.release?.(); + await Promise.all([pDepth, pRotation]); + }); + + expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ + depth: 4, + rotationPreset: "iso", + }); + }); + + it("resolves a zoom-level write with whether the save took effect", async () => { + seed(docWithZoom); + const { result } = renderTimeline(); + bridgeMocks.save.mockResolvedValueOnce({ success: false, error: "read-only" }); + + let ok: boolean | undefined; + await act(async () => { + ok = await result.current.updateZoomDepth("zoom_a", 4); + }); + + expect(ok).toBe(false); + expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(3); + }); + it("leaves no undo step behind a focus drag whose commit failed", async () => { // The drag used to push its pre-drag document from the FIRST `setDocument`. When // the commit then failed, `commitZoomFocus` restored that same document through diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 72a074f53..73a3a87a5 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -44,6 +44,7 @@ import { import { dropTrimPillsByIds, resolveTimelineSpanToTrim } from "../timeline/trim-mapping"; import type { AutoZoomSuggestion } from "../timeline/zoom-suggestions"; import { useProjectStore, waitForDocumentSaves } from "./projectStore"; +import { useSequentialTimelineOps } from "./useSequentialTimelineOps"; // How long a region lasts when the caller doesn't say. The timeline's toolbar // passes its own duration instead, derived from the current zoom so the new pill @@ -705,21 +706,42 @@ export function useTimeline() { } }, [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. 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. + const saveZoomPatch = useCallback( + (id: string, patch: Partial) => + enqueueZoomWrite(async () => { + const doc = useProjectStore.getState().document; + if (!doc) return false; + return saveDocument( + { + ...doc, + zoomRanges: patchPillById(doc.zoomRanges, id, patch) as AxcutDocument["zoomRanges"], + }, + { history: true }, + ); + }), + [enqueueZoomWrite, saveDocument], + ); + // Zoom-level control for the region-settings panel (1-6, matches // zoomRegionSchema's depth literal union — 1.0x..3.5x in 0.5x steps per // the `depth/2 + 0.5` label formula used throughout the timeline UI). const updateZoomDepth = useCallback( - async (id: string, depth: 1 | 2 | 3 | 4 | 5 | 6) => { - if (!document) return; - const next: AxcutDocument = { - ...document, - zoomRanges: patchPillById(document.zoomRanges, id, { - depth, - }) as AxcutDocument["zoomRanges"], - }; - await saveDocument(next, { history: true }); - }, - [document, saveDocument], + (id: string, depth: 1 | 2 | 3 | 4 | 5 | 6) => saveZoomPatch(id, { depth }), + [saveZoomPatch], ); // Same story as `focusMode` below: the 3D tilt was implemented end to end — schema @@ -728,17 +750,9 @@ export function useTimeline() { // `undefined` clears the preset back to a flat frame; `migrate.ts` already drops the field // when it is falsy, so absent and "no rotation" are the same state. const updateZoomRotation = useCallback( - async (id: string, rotationPreset: Rotation3DPreset | undefined) => { - if (!document) return; - const next: AxcutDocument = { - ...document, - zoomRanges: patchPillById(document.zoomRanges, id, { - rotationPreset, - }) as AxcutDocument["zoomRanges"], - }; - await saveDocument(next, { history: true }); - }, - [document, saveDocument], + (id: string, rotationPreset: Rotation3DPreset | undefined) => + saveZoomPatch(id, { rotationPreset }), + [saveZoomPatch], ); // Nothing could set `focusMode`: "auto" only ever arrived from the automatic suggestion pass @@ -750,31 +764,14 @@ export function useTimeline() { // Writing "manual" explicitly is safe even though `migrate.ts` only persists "auto": an absent // field MEANS manual, so both forms resolve identically. const updateZoomFocusMode = useCallback( - async (id: string, focusMode: "manual" | "auto") => { - if (!document) return; - const next: AxcutDocument = { - ...document, - zoomRanges: patchPillById(document.zoomRanges, id, { - focusMode, - }) as AxcutDocument["zoomRanges"], - }; - await saveDocument(next, { history: true }); - }, - [document, saveDocument], + (id: string, focusMode: "manual" | "auto") => saveZoomPatch(id, { focusMode }), + [saveZoomPatch], ); const updateZoomHideCursor = useCallback( - async (id: string, hideCursor: boolean | undefined) => { - if (!document) return; - const next: AxcutDocument = { - ...document, - zoomRanges: patchPillById(document.zoomRanges, id, { - hideCursor: hideCursor ? true : undefined, - }) as AxcutDocument["zoomRanges"], - }; - await saveDocument(next, { history: true }); - }, - [document, saveDocument], + (id: string, hideCursor: boolean | undefined) => + saveZoomPatch(id, { hideCursor: hideCursor ? true : undefined }), + [saveZoomPatch], ); // Per-region, like the preset it animates. `undefined` rather than `false` so the document diff --git a/tests/e2e/v4-shell.spec.ts b/tests/e2e/v4-shell.spec.ts index 1df64a83d..79c1dd4b4 100644 --- a/tests/e2e/v4-shell.spec.ts +++ b/tests/e2e/v4-shell.spec.ts @@ -14,6 +14,18 @@ const EDITOR_URL = `${BASE_URL}/?windowType=editor`; // 300 MB exactly, so MediaStage's formatSize renders "300 MB". const SIZED_BYTES = 314_572_800; +// Only what a zoom region needs to survive `documentSchema` and reach the timeline. +interface ZoomFixture { + id: string; + startMs: number; + endMs: number; + clipId: string; + sourceStartSec: number; + sourceEndSec: number; + depth: 1 | 2 | 3 | 4 | 5 | 6; + focus: { cx: number; cy: number }; +} + function makeAsset(id: string, label: string, sizeBytes?: number) { return { id, @@ -64,7 +76,7 @@ function makeDoc() { captionRanges: [], }, annotations: [], - zoomRanges: [], + zoomRanges: [] as ZoomFixture[], legacyEditor: null, agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, preview: { strategy: "seek" as const, revision: 0 }, @@ -73,6 +85,26 @@ function makeDoc() { }; } +// Same fixture with one zoom region on the only clip, so the inspector's zoom pane — +// and the level row inside it — has something to select. Depth 3 is the editor's default, +// and `ZOOM_DEPTH_SCALES` renders it as the "1.80×" the pill is addressed by below. +function makeZoomDoc(): ReturnType { + const doc = makeDoc(); + doc.zoomRanges = [ + { + id: "zoom_e2e", + startMs: 60_000, + endMs: 180_000, + clipId: "clip_e2e", + sourceStartSec: 60, + sourceEndSec: 180, + depth: 3, + focus: { cx: 0.5, cy: 0.5 }, + }, + ]; + return doc; +} + // Same fixture, split into two clips: FloatingInspector's "Edit clip" button // only renders its picker popover past one clip (clips.length === 1 jumps // straight to onEditClip instead), so testing the popover needs a second clip. @@ -243,6 +275,60 @@ test.describe("v4 editor shell", () => { expect(await storeTimeSec()).toBeGreaterThan(400); }); + // The zoom levels are buttons rather than a `` this replaces did once focused — and the one - * keyboard path that survives the editor shell's Tab binding (it cycles annotations whenever - * any exist, from any focused element). + * The control speaks in scales, not depths: a preset and a typed level are the same kind of + * value, and a typed level the table has (1.8, or 1.25 which is not in the row) is written as + * its depth, so a document keeps naming its presets. Anything else is a `customScale`. */ export function ZoomLevelControl({ region, tl, }: { - region: { id: string; depth: ZoomDepth }; - tl: Pick; + region: { id: string; depth: ZoomDepth; customScale?: number }; + tl: Pick; }) { const ts = useScopedT("settings"); - const buttonsRef = useRef>([]); - // Last depth this instance asked for, and the generation of that request. - // Depth values repeat (only 1–6), so a Set of depths cannot tell "our older - // 4 landed" from "the latest request is 4" or from an undo that happens to - // land on 4. Each click/key gets a new gen. Every gen belonging to this - // region epoch is removed from `pending` when it settles — a superseded 4 - // must still drain, or `pending` stays non-empty and undo/redo can never - // overwrite `requestedRef`. Only the latest gen may change `requestedRef`. - const requestedRef = useRef(region.depth); + const current = effectiveZoomScale(region); + // Last level this instance asked for, and the generation of that request. Levels repeat, + // so a set of levels cannot tell "our older 2.2 landed" from "the latest request is 2.2" or + // from an undo that happens to land on 2.2. Each click/key gets a new gen. Every gen + // belonging to this region epoch is removed from `pending` when it settles: a superseded + // request must still drain, or `pending` stays non-empty and undo/redo can never overwrite + // the request. Only the latest gen may change it. + // + // The row shows the request, not the document: its no-op guard compares against the value + // it is given, and against a document still saying 1.8 while a 2.2 is in flight, stepping + // back to 1.8 would be dropped as a re-press. + const requestedRef = useRef(current); + const [requested, setRequested] = useState(current); const genRef = useRef(0); const pendingRef = useRef(new Set()); - const regionRef = useRef(region); - regionRef.current = region; + const currentRef = useRef(current); + currentRef.current = current; + // "" means the field is idle and shows the live level as its placeholder. + const [draft, setDraft] = useState(""); - // biome-ignore lint/correctness/useExhaustiveDependencies: region.id is the trigger, not a read — the body resets request state; depth is taken from the render's ref so a same-depth other pill still clears the previous pill's pending gen. + // biome-ignore lint/correctness/useExhaustiveDependencies: region.id is the trigger, not a read — the body resets request state; the level is taken from the render's ref so a same-level other pill still clears the previous pill's pending gen. useEffect(() => { genRef.current += 1; pendingRef.current.clear(); - requestedRef.current = regionRef.current.depth; + requestedRef.current = currentRef.current; + setRequested(currentRef.current); }, [region.id]); useEffect(() => { if (pendingRef.current.size > 0) return; - requestedRef.current = region.depth; - }, [region.depth]); + requestedRef.current = current; + setRequested(current); + }, [current]); - const setDepth = (depth: ZoomDepth) => { - // Re-pressing the current level is not an edit: no save, no undo entry. - if (depth === requestedRef.current) return; - requestedRef.current = depth; + const setScale = (scale: number) => { + // Re-choosing the current level is not an edit: no save, no undo entry. + if (scale === requestedRef.current) return; + requestedRef.current = scale; + setRequested(scale); const gen = ++genRef.current; pendingRef.current.add(gen); - const regionId = region.id; - void Promise.resolve(tl.updateZoomDepth(regionId, depth)).then( - (ok) => { - pendingRef.current.delete(gen); - if (gen !== genRef.current) return; - if (ok === false) requestedRef.current = regionRef.current.depth; - }, - () => { - pendingRef.current.delete(gen); - if (gen !== genRef.current) return; - requestedRef.current = regionRef.current.depth; - }, + const depth = ZOOM_DEPTHS.find((d) => ZOOM_DEPTH_SCALES[d] === scale); + const write = + depth === undefined + ? tl.updateZoomCustomScale(region.id, scale) + : tl.updateZoomDepth(region.id, depth); + // A refused write hands the level back to the document, so the same one can be retried. + const settle = (ok: boolean) => { + pendingRef.current.delete(gen); + if (ok || gen !== genRef.current) return; + requestedRef.current = currentRef.current; + setRequested(currentRef.current); + }; + void Promise.resolve(write).then( + (ok) => settle(ok !== false), + () => settle(false), ); }; + const commitDraft = () => { + const text = draft + .trim() + .replace(",", ".") + .replace(/\s*[×x]$/i, ""); + setDraft(""); + // Empty or unparseable reverts to the live level rather than guessing at an intent. + if (text === "" || !Number.isFinite(Number(text))) return; + const scale = Math.round(Number(text) * 100) / 100; + if (scale < MIN_ZOOM_SCALE || scale > MAX_ZOOM_SCALE) { + toast.error(ts("zoom.customScaleRange", { min: MIN_ZOOM_SCALE, max: MAX_ZOOM_SCALE })); + return; + } + setScale(scale); + }; + return ( -
- - {ts("zoom.level")} - -
{ - // A button activates on Enter and Space by itself — but the shell's play/pause - // shortcut is Space on WINDOW, and it `preventDefault()`s the keydown, which - // cancels that activation. Unstopped, Space on a level changed nothing and - // started playback instead. Stop the keystroke here so the button keeps its own - // key, and do NOT `preventDefault()` it, or the activation dies the same way. - if (e.key === "Enter" || e.key === " ") { - e.nativeEvent.stopPropagation(); - return; - } - const step = - e.key === "ArrowRight" || e.key === "ArrowDown" - ? 1 - : e.key === "ArrowLeft" || e.key === "ArrowUp" - ? -1 - : 0; - if (step === 0) return; - // `nativeEvent.stopPropagation()`, not just the synthetic one: the editor shell - // listens on WINDOW, above React's root container, and ArrowLeft/ArrowRight seek - // the playhead there. Same reason as the pill's own keydown in V4Timeline. - e.preventDefault(); - e.nativeEvent.stopPropagation(); - // Step from the FOCUSED button, not from the selected level. Every level is a Tab - // stop, so the two can part company — and stepping from the selection then threw - // focus across the row (ArrowRight on the last button landed it in the middle). - // Focus moves one place and the level follows it; at either end neither moves. - const focused = buttonsRef.current.findIndex((b) => b === document.activeElement); - const from = focused >= 0 ? focused : ZOOM_DEPTHS.indexOf(requestedRef.current); - const next = ZOOM_DEPTHS[from + step]; - if (next === undefined) return; - buttonsRef.current[next - 1]?.focus(); - setDepth(next); - }} - > - {ZOOM_DEPTHS.map((d) => { - const pressed = d === region.depth; - return ( - - ); - })} -
-
+ <> + {/* A level outside the row presses no button; the field below shows it. */} + {paneStack( + ts("zoom.level"), + + label={ts("zoom.level")} + options={ZOOM_PRESETS} + value={requested} + onChange={setScale} + />, + )} + {paneRow( + ts("zoom.customScale"), + setDraft(e.target.value)} + onBlur={commitDraft} + // Enter blurs, and the blur handler commits: one path, so a keyboard commit + // can't apply the same draft twice. + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + }} + className={shell.control} + style={{ width: 84, textAlign: "right" }} + />, + )} + ); } @@ -1214,30 +1220,6 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void } * slab outshouted every setting above it. */ const PANE_BUTTON = `${shell.btn} ${shell.btnSecondary}`; -// Six of these share the pane's 266px of content width, so each gets ~41px: enough for -// "1.25×" at 12px with room either side, and no horizontal padding to lose. -const zoomLevelBtnStyle: React.CSSProperties = { - flex: "1 1 0", - minWidth: 0, - height: 28, - padding: 0, - borderRadius: 8, - border: "1px solid var(--border)", - background: "var(--surface)", - color: "var(--fg-2)", - font: "500 12px var(--font-display)", - cursor: "pointer", -}; - -// Same signal as the pressed facet-rail button: accent text on the soft accent fill. -const zoomLevelPressedStyle: React.CSSProperties = { - ...zoomLevelBtnStyle, - border: "1px solid var(--accent)", - background: "var(--accent-soft)", - color: "var(--accent)", - fontWeight: 600, -}; - function FacetBody({ facet, onCollapse, diff --git a/src/components/ai-edition/v4/ZoomLevelControl.test.tsx b/src/components/ai-edition/v4/ZoomLevelControl.test.tsx index eb18eba1d..7b76a663b 100644 --- a/src/components/ai-edition/v4/ZoomLevelControl.test.tsx +++ b/src/components/ai-edition/v4/ZoomLevelControl.test.tsx @@ -2,23 +2,36 @@ import "@testing-library/jest-dom"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { useState } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { ZOOM_DEPTH_SCALES, type ZoomDepth } from "@/components/video-editor/types"; // The pane is only reachable with a project open and a zoom region selected, so drive the // control directly. The translator echoes keys, as in `SpeedControl.test.tsx`. vi.mock("@/contexts/I18nContext", () => ({ - useScopedT: () => (key: string) => key, + useScopedT: () => (key: string, vars?: Record) => + vars ? `${key}:${Object.values(vars).join(",")}` : key, })); +const toastError = vi.fn(); +vi.mock("sonner", () => ({ toast: { error: (message: string) => toastError(message) } })); + import { ZoomLevelControl } from "./FloatingInspector"; -function renderControl(depth: ZoomDepth) { - const updateZoomDepth = vi.fn(async () => true); - render(); +// Never reached by the preset tests: a scale the table has is written as its depth. +const updateZoomCustomScale = vi.fn(async (_id: string, _scale: number) => true); + +function renderControl(depth: ZoomDepth, customScale?: number) { + const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); + render( + , + ); const group = screen.getByRole("group", { name: "zoom.level" }); const buttons = screen.getAllByRole("button"); - return { updateZoomDepth, group, buttons }; + const field = screen.getByRole("textbox", { name: "zoom.customScale" }); + return { updateZoomDepth, group, buttons, field }; } /** @@ -35,6 +48,7 @@ function renderControlled(initial: ZoomDepth) { { // Synchronously, so the re-render lands inside the `fireEvent` that caused it: // the next keystroke in a test then sees the same DOM a user's would. @@ -49,30 +63,78 @@ function renderControlled(initial: ZoomDepth) { render(); const group = screen.getByRole("group", { name: "zoom.level" }); const buttons = screen.getAllByRole("button"); - const focusLevel = (depth: ZoomDepth) => (buttons[depth - 1] as HTMLButtonElement).focus(); + const focusLevel = (depth: ZoomDepth) => (buttons[depth - 2] as HTMLButtonElement).focus(); return { updateZoomDepth, group, buttons, focusLevel }; } describe("ZoomLevelControl", () => { - it("renders one button per depth, labelled with the table value, current one pressed", () => { - const { buttons } = renderControl(3); - expect(buttons).toHaveLength(6); + beforeEach(() => { + toastError.mockClear(); + updateZoomCustomScale.mockClear(); + }); + + it("renders four presets, labelled with the table value, current one pressed", () => { + const { buttons, field } = renderControl(3); expect(buttons.map((b) => b.textContent)).toEqual( - ([1, 2, 3, 4, 5, 6] as const).map((d) => `${ZOOM_DEPTH_SCALES[d]}×`), + ([2, 3, 4, 5] as const).map((d) => `${ZOOM_DEPTH_SCALES[d]}×`), ); expect(buttons.map((b) => b.getAttribute("aria-pressed"))).toEqual([ - "false", "false", "true", "false", "false", - "false", ]); + expect(field).toHaveAttribute("placeholder", "1.8×"); + }); + + // The ends of the table and every custom level live in the field, not the row. + it("presses no preset for a level outside the row, and shows it in the field", () => { + const { buttons, field } = renderControl(3, 2.75); + expect(buttons.every((b) => b.getAttribute("aria-pressed") === "false")).toBe(true); + expect(field).toHaveAttribute("placeholder", "2.75×"); + }); + + it("commits a typed level as a custom scale, on Enter or blur and only once", () => { + const { updateZoomDepth, field } = renderControl(3); + fireEvent.change(field, { target: { value: "2,5×" } }); + fireEvent.keyDown(field, { key: "Enter" }); + fireEvent.blur(field); + expect(updateZoomCustomScale).toHaveBeenCalledTimes(1); + expect(updateZoomCustomScale).toHaveBeenCalledWith("z1", 2.5); + expect(updateZoomDepth).not.toHaveBeenCalled(); + expect(field).toHaveValue(""); + }); + + // A document keeps naming its presets, whether they were clicked or typed. + it("writes a typed level the table has as its depth", () => { + const { updateZoomDepth, field } = renderControl(3); + fireEvent.change(field, { target: { value: "1.25" } }); + fireEvent.blur(field); + expect(updateZoomDepth).toHaveBeenCalledWith("z1", 1); + expect(updateZoomCustomScale).not.toHaveBeenCalled(); + }); + + it("refuses a level outside the renderer's range and says so", () => { + const { updateZoomDepth, field } = renderControl(3); + fireEvent.change(field, { target: { value: "8" } }); + fireEvent.blur(field); + expect(toastError).toHaveBeenCalledWith("zoom.customScaleRange:1,5"); + expect(updateZoomCustomScale).not.toHaveBeenCalled(); + expect(updateZoomDepth).not.toHaveBeenCalled(); + }); + + it("ignores an unparseable draft without touching the region", () => { + const { updateZoomDepth, field } = renderControl(3); + fireEvent.change(field, { target: { value: "abc" } }); + fireEvent.blur(field); + expect(toastError).not.toHaveBeenCalled(); + expect(updateZoomCustomScale).not.toHaveBeenCalled(); + expect(updateZoomDepth).not.toHaveBeenCalled(); }); it("commits a level in one click", () => { const { updateZoomDepth, buttons } = renderControl(3); - fireEvent.click(buttons[4] as HTMLButtonElement); + fireEvent.click(buttons[3] as HTMLButtonElement); expect(updateZoomDepth).toHaveBeenCalledTimes(1); expect(updateZoomDepth).toHaveBeenCalledWith("z1", 5); }); @@ -80,7 +142,7 @@ describe("ZoomLevelControl", () => { it("does not write when the current level is clicked again", () => { // A no-op edit would still land a save and an undo entry. const { updateZoomDepth, buttons } = renderControl(3); - fireEvent.click(buttons[2] as HTMLButtonElement); + fireEvent.click(buttons[1] as HTMLButtonElement); expect(updateZoomDepth).not.toHaveBeenCalled(); }); @@ -90,20 +152,28 @@ describe("ZoomLevelControl", () => { it("follows the region when the level is changed from elsewhere", async () => { const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); const { rerender } = render( - , + , ); - fireEvent.click(screen.getAllByRole("button")[4] as HTMLButtonElement); + fireEvent.click(screen.getAllByRole("button")[3] as HTMLButtonElement); expect(updateZoomDepth).toHaveBeenCalledWith("z1", 5); await act(async () => { // Let the request settle so the follow-effect is allowed to copy the prop. }); // An undo lands on 2 instead of the 5 this control asked for. - rerender(); - fireEvent.click(screen.getAllByRole("button")[1] as HTMLButtonElement); + rerender( + , + ); + fireEvent.click(screen.getAllByRole("button")[0] as HTMLButtonElement); expect(updateZoomDepth).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getAllByRole("button")[4] as HTMLButtonElement); + fireEvent.click(screen.getAllByRole("button")[3] as HTMLButtonElement); expect(updateZoomDepth).toHaveBeenCalledTimes(2); expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5); }); @@ -113,27 +183,27 @@ describe("ZoomLevelControl", () => { focusLevel(3); fireEvent.keyDown(group, { key: "ArrowRight" }); expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 4); - expect(buttons[3]).toHaveFocus(); + expect(buttons[2]).toHaveFocus(); fireEvent.keyDown(group, { key: "ArrowDown" }); expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5); - expect(buttons[4]).toHaveFocus(); + expect(buttons[3]).toHaveFocus(); fireEvent.keyDown(group, { key: "ArrowLeft" }); expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 4); fireEvent.keyDown(group, { key: "ArrowUp" }); expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 3); - expect(buttons[2]).toHaveFocus(); + expect(buttons[1]).toHaveFocus(); expect(updateZoomDepth).toHaveBeenCalledTimes(4); - expect(buttons[2]).toHaveAttribute("aria-pressed", "true"); + expect(buttons[1]).toHaveAttribute("aria-pressed", "true"); }); // Every level is a Tab stop, so focus can sit on a level that is not the selected one. // Counting from the selection there moved focus the wrong way across the row. it("steps from the button that has focus, not from the selected level", () => { const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(2); - focusLevel(5); + focusLevel(4); fireEvent.keyDown(group, { key: "ArrowRight" }); - expect(buttons[5]).toHaveFocus(); - expect(updateZoomDepth).toHaveBeenCalledWith("z1", 6); + expect(buttons[3]).toHaveFocus(); + expect(updateZoomDepth).toHaveBeenCalledWith("z1", 5); }); // `updateZoomDepth` writes the document, so the pressed state only catches up a tick later. @@ -147,6 +217,7 @@ describe("ZoomLevelControl", () => { { await updateZoomDepth(id, next); setDepth(next); @@ -159,11 +230,11 @@ describe("ZoomLevelControl", () => { render(); const group = screen.getByRole("group", { name: "zoom.level" }); const buttons = screen.getAllByRole("button"); - (buttons[2] as HTMLButtonElement).focus(); + (buttons[1] as HTMLButtonElement).focus(); fireEvent.keyDown(group, { key: "ArrowRight" }); fireEvent.keyDown(group, { key: "ArrowRight" }); expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5]); - expect(buttons[4]).toHaveFocus(); + expect(buttons[3]).toHaveFocus(); }); // The mirror image of the test above, and the one that catches a stale read: stepping @@ -179,6 +250,7 @@ describe("ZoomLevelControl", () => { { await updateZoomDepth(id, next); setDepth(next); @@ -191,11 +263,11 @@ describe("ZoomLevelControl", () => { render(); const group = screen.getByRole("group", { name: "zoom.level" }); const buttons = screen.getAllByRole("button"); - (buttons[2] as HTMLButtonElement).focus(); + (buttons[1] as HTMLButtonElement).focus(); fireEvent.keyDown(group, { key: "ArrowRight" }); fireEvent.keyDown(group, { key: "ArrowLeft" }); expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 3]); - expect(buttons[2]).toHaveFocus(); + expect(buttons[1]).toHaveFocus(); }); // `saveDocument` writes the returned document into the store and only then @@ -216,6 +288,7 @@ describe("ZoomLevelControl", () => { { const pending = updateZoomDepth(id, next); void pending.then(() => setDepth(next)); @@ -228,7 +301,7 @@ describe("ZoomLevelControl", () => { render(); const group = screen.getByRole("group", { name: "zoom.level" }); const buttons = screen.getAllByRole("button"); - (buttons[2] as HTMLButtonElement).focus(); + (buttons[1] as HTMLButtonElement).focus(); fireEvent.keyDown(group, { key: "ArrowRight" }); fireEvent.keyDown(group, { key: "ArrowRight" }); expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5]); @@ -237,12 +310,13 @@ describe("ZoomLevelControl", () => { await act(async () => { resolvers[0]!(); }); + // The row shows the latest request, not the echo of the older one. expect(buttons[3]).toHaveAttribute("aria-pressed", "true"); - expect(buttons[4]).toHaveFocus(); + expect(buttons[3]).toHaveFocus(); fireEvent.keyDown(group, { key: "ArrowLeft" }); expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5, 4]); - expect(buttons[3]).toHaveFocus(); + expect(buttons[2]).toHaveFocus(); }); // Depth values repeat, so an older request landing on 4 must not look like @@ -261,6 +335,7 @@ describe("ZoomLevelControl", () => { { const pending = updateZoomDepth(id, next); void pending.then(() => setDepth(next)); @@ -273,7 +348,7 @@ describe("ZoomLevelControl", () => { render(); const group = screen.getByRole("group", { name: "zoom.level" }); const buttons = screen.getAllByRole("button"); - (buttons[2] as HTMLButtonElement).focus(); + (buttons[1] as HTMLButtonElement).focus(); fireEvent.keyDown(group, { key: "ArrowRight" }); fireEvent.keyDown(group, { key: "ArrowRight" }); fireEvent.keyDown(group, { key: "ArrowLeft" }); @@ -285,20 +360,28 @@ describe("ZoomLevelControl", () => { await act(async () => { resolvers[1]!(); }); - fireEvent.click(buttons[4] as HTMLButtonElement); + fireEvent.click(buttons[3] as HTMLButtonElement); expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5, 4, 5]); }); it("does not leak a pending request onto a different zoom region", async () => { const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); const { rerender } = render( - , + , ); - fireEvent.click(screen.getAllByRole("button")[4] as HTMLButtonElement); + fireEvent.click(screen.getAllByRole("button")[3] as HTMLButtonElement); expect(updateZoomDepth).toHaveBeenCalledWith("A", 5); - rerender(); - fireEvent.click(screen.getAllByRole("button")[4] as HTMLButtonElement); + rerender( + , + ); + fireEvent.click(screen.getAllByRole("button")[3] as HTMLButtonElement); expect(updateZoomDepth).toHaveBeenLastCalledWith("B", 5); expect(updateZoomDepth).toHaveBeenCalledTimes(2); }); @@ -306,49 +389,64 @@ describe("ZoomLevelControl", () => { it("follows an undo after rapid steps have all settled", async () => { const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => true); const { rerender } = render( - , + , ); const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[2] as HTMLButtonElement); fireEvent.click(buttons[3] as HTMLButtonElement); - fireEvent.click(buttons[4] as HTMLButtonElement); expect(updateZoomDepth.mock.calls.map(([, depth]) => depth)).toEqual([4, 5]); await act(async () => { // both generations must drain, or the follow-effect stays blocked }); - rerender(); - fireEvent.click(buttons[4] as HTMLButtonElement); + rerender( + , + ); + fireEvent.click(buttons[3] as HTMLButtonElement); expect(updateZoomDepth).toHaveBeenCalledTimes(3); expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5); }); it("retries the same level after a failed save", async () => { const updateZoomDepth = vi.fn(async (_id: string, _depth: ZoomDepth) => false); - render(); + render( + , + ); const buttons = screen.getAllByRole("button"); - fireEvent.click(buttons[4] as HTMLButtonElement); + fireEvent.click(buttons[3] as HTMLButtonElement); await act(async () => { // settle the failed write so the same target is not stuck as current }); - fireEvent.click(buttons[4] as HTMLButtonElement); + // The row hands the level back to the document while nothing is pending. + expect(buttons[1]).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(buttons[3] as HTMLButtonElement); expect(updateZoomDepth).toHaveBeenCalledTimes(2); expect(updateZoomDepth).toHaveBeenLastCalledWith("z1", 5); }); it("clamps at the lowest level instead of wrapping", () => { - const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(1); - focusLevel(1); + const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(2); + focusLevel(2); fireEvent.keyDown(group, { key: "ArrowLeft" }); expect(updateZoomDepth).not.toHaveBeenCalled(); expect(buttons[0]).toHaveFocus(); }); it("clamps at the highest level instead of wrapping", () => { - const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(6); - focusLevel(6); + const { updateZoomDepth, group, buttons, focusLevel } = renderControlled(5); + focusLevel(5); fireEvent.keyDown(group, { key: "ArrowRight" }); expect(updateZoomDepth).not.toHaveBeenCalled(); - expect(buttons[5]).toHaveFocus(); + expect(buttons[3]).toHaveFocus(); }); it("keeps its own keys off the window listener, and lets every other key through", () => { diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 100be30b1..88a9a32b9 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -384,6 +384,7 @@ "level": "مستوى التكبير", "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير", "customScale": "تكبير مخصص", + "customScaleRange": "يتراوح التكبير بين {{min}}× و{{max}}×", "selectRegion": "حدد منطقة التكبير للتعديل", "cursor": { "title": "المؤشر", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index e5e1be7f6..701c74a11 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -384,6 +384,7 @@ "level": "Úroveň přiblížení", "previewHold": "Podržte pro náhled efektu přiblížení", "customScale": "Vlastní přiblížení", + "customScaleRange": "Přiblížení může být od {{min}}× do {{max}}×", "selectRegion": "Vyberte oblast přiblížení pro úpravu", "cursor": { "title": "Kurzor", diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index a2479ad90..4b7e19ad9 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -384,6 +384,7 @@ "level": "Zoomstufe", "previewHold": "Gedrückt halten für die Zoom-Vorschau", "customScale": "Eigener Zoom", + "customScaleRange": "Der Zoom reicht von {{min}}× bis {{max}}×", "selectRegion": "Wähle einen Zoom-Bereich zum Anpassen", "cursor": { "title": "Cursor", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index e4957152a..297b712ed 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -383,7 +383,8 @@ }, "level": "Zoom level", "previewHold": "Hold to preview zoom effect", - "customScale": "Custom Zoom", + "customScale": "Custom zoom", + "customScaleRange": "Zoom goes from {{min}}× to {{max}}×", "selectRegion": "Select a zoom region to adjust", "cursor": { "title": "Cursor", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 16f5c7a79..890b41440 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -384,6 +384,7 @@ "level": "Nivel de zoom", "previewHold": "Mantener para previsualizar el efecto de zoom", "customScale": "Zoom personalizado", + "customScaleRange": "El zoom va de {{min}}× a {{max}}×", "selectRegion": "Selecciona una región de zoom para ajustar", "cursor": { "title": "Cursor", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index b8cb772a2..68e0e2233 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -384,6 +384,7 @@ "level": "Niveau de zoom", "previewHold": "Maintenir pour prévisualiser l'effet de zoom", "customScale": "Zoom personnalisé", + "customScaleRange": "Le zoom va de {{min}}× à {{max}}×", "selectRegion": "Sélectionnez une région de zoom à ajuster", "cursor": { "title": "Curseur", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 23ad7a651..a0c13e5aa 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -384,6 +384,7 @@ "level": "Livello zoom", "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom", "customScale": "Zoom personalizzato", + "customScaleRange": "Lo zoom va da {{min}}× a {{max}}×", "selectRegion": "Seleziona una regione zoom da regolare", "cursor": { "title": "Cursore", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 8e3a4dfdb..af5e3d177 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -384,6 +384,7 @@ "level": "ズーム倍率", "previewHold": "押している間ズーム効果をプレビュー", "customScale": "カスタムズーム", + "customScaleRange": "ズーム倍率は{{min}}×から{{max}}×までです", "selectRegion": "ズーム範囲を選択して調整", "cursor": { "title": "カーソル", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 6a46098fc..7c556eb97 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -384,6 +384,7 @@ "level": "줌 레벨", "previewHold": "누르고 있으면 줌 효과 미리보기", "customScale": "커스텀 줌", + "customScaleRange": "줌은 {{min}}×에서 {{max}}×까지 가능합니다", "selectRegion": "조정할 줌 구간을 선택하세요", "cursor": { "title": "커서", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 9cf5dc48e..398fa2839 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -384,6 +384,7 @@ "level": "Nível de Zoom", "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom", "customScale": "Zoom Personalizado", + "customScaleRange": "O zoom vai de {{min}}× a {{max}}×", "selectRegion": "Selecione uma região de zoom para ajustar", "cursor": { "title": "Cursor", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 89359ce7b..625a53605 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -384,6 +384,7 @@ "level": "Уровень масштабирования", "previewHold": "Удерживайте для предпросмотра эффекта зума", "customScale": "Пользовательский масштаб", + "customScaleRange": "Масштаб может быть от {{min}}× до {{max}}×", "selectRegion": "Выберите область масштабирования для настройки", "cursor": { "title": "Курсор", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 46d43abec..206b2b7ca 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -384,6 +384,7 @@ "level": "Yakınlaştırma Seviyesi", "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun", "customScale": "Özel Yakınlaştırma", + "customScaleRange": "Yakınlaştırma {{min}}× ile {{max}}× arasında olabilir", "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin", "cursor": { "title": "İmleç", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 11c4ac50e..1aa15e103 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -384,6 +384,7 @@ "level": "Mức độ thu phóng", "previewHold": "Giữ để xem trước hiệu ứng phóng to", "customScale": "Thu phóng tùy chỉnh", + "customScaleRange": "Mức thu phóng từ {{min}}× đến {{max}}×", "selectRegion": "Chọn vùng thu phóng để điều chỉnh", "cursor": { "title": "Con trỏ", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index d18fd8d46..b34912793 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -384,6 +384,7 @@ "level": "缩放级别", "previewHold": "按住预览放大效果", "customScale": "自定义缩放", + "customScaleRange": "缩放范围为 {{min}}× 到 {{max}}×", "selectRegion": "选择要调整的缩放区域", "cursor": { "title": "光标", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 66b592ed5..d8b84fafc 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -384,6 +384,7 @@ "level": "縮放級別", "previewHold": "按住預覽放大效果", "customScale": "自訂縮放", + "customScaleRange": "縮放範圍為 {{min}}× 到 {{max}}×", "selectRegion": "選擇要調整的縮放區域", "cursor": { "title": "游標", diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 5fd041c42..3848b5ff1 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -1233,6 +1233,25 @@ describe("useTimeline undo history", () => { expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(4); }); + // A custom scale overrides the depth, so a preset that left it in place would change a + // field the render never reads. + it("clamps a custom zoom level to the renderer's range, and a preset clears it", async () => { + seed(docWithZoom); + const { result } = renderTimeline(); + + await act(async () => { + await result.current.updateZoomCustomScale("zoom_a", 9); + }); + expect(useProjectStore.getState().document?.zoomRanges[0]?.customScale).toBe(5); + + await act(async () => { + await result.current.updateZoomDepth("zoom_a", 4); + }); + const zoom = useProjectStore.getState().document?.zoomRanges[0]; + expect(zoom?.depth).toBe(4); + expect(zoom?.customScale).toBeUndefined(); + }); + it("keeps a pending zoom level when the 3D tilt is changed before it lands", async () => { seed(docWithZoom); const gate = gateFirstSave(); diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 37796941c..d47d1a4a4 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -42,6 +42,7 @@ import { resolvePillIds, } from "../timeline/timelineMap"; import { dropTrimPillsByIds, resolveTimelineSpanToTrim } from "../timeline/trim-mapping"; +import { MAX_ZOOM_SCALE, MIN_ZOOM_SCALE } from "../timeline/zoom-scale"; import type { AutoZoomSuggestion } from "../timeline/zoom-suggestions"; import { saveWithDeadline, useProjectStore, waitForDocumentSaves } from "./projectStore"; import { currentWriteEpoch } from "./undoStack"; @@ -775,11 +776,19 @@ export function useTimeline() { [enqueueZoomWrite, saveDocument], ); - // Zoom-level control for the region-settings panel (1-6, matches - // zoomRegionSchema's depth literal union — 1.0x..3.5x in 0.5x steps per - // the `depth/2 + 0.5` label formula used throughout the timeline UI). + // 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. const updateZoomDepth = useCallback( - (id: string, depth: 1 | 2 | 3 | 4 | 5 | 6) => saveZoomPatch(id, { depth }), + (id: string, depth: 1 | 2 | 3 | 4 | 5 | 6) => + saveZoomPatch(id, { depth, customScale: undefined }), + [saveZoomPatch], + ); + + // Any other level, from the pane's free field. Clamped to the renderer's range here, so no + // caller can store a scale that `effectiveZoomScale` would then read differently. + const updateZoomCustomScale = useCallback( + (id: string, scale: number) => + saveZoomPatch(id, { customScale: Math.min(MAX_ZOOM_SCALE, Math.max(MIN_ZOOM_SCALE, scale)) }), [saveZoomPatch], ); @@ -1569,6 +1578,7 @@ export function useTimeline() { updateZoomFocusLive, commitZoomFocus, updateZoomDepth, + updateZoomCustomScale, updateZoomRotation, updateZoomFocusMode, updateZoomHideCursor, diff --git a/tests/e2e/v4-shell.spec.ts b/tests/e2e/v4-shell.spec.ts index 79c1dd4b4..5b060112d 100644 --- a/tests/e2e/v4-shell.spec.ts +++ b/tests/e2e/v4-shell.spec.ts @@ -287,7 +287,7 @@ test.describe("v4 editor shell", () => { await page.locator('[class*="lanePill"][title="1.80×"]').first().click(); const levels = page.getByRole("group", { name: "Zoom Level" }).getByRole("button"); - await expect(levels).toHaveCount(6); + await expect(levels).toHaveCount(4); // One row inside the 300px pane, with every label intact: the reason this control // stacks its own label instead of sitting in a `paneRow` like its neighbours. @@ -312,21 +312,28 @@ test.describe("v4 editor shell", () => { ); expect(await depth()).toBe(3); - await levels.nth(3).focus(); // 2.2×, depth 4 + await levels.nth(2).focus(); // 2.2×, depth 4 await page.keyboard.press("Space"); await expect.poll(depth).toBe(4); - await expect(levels.nth(3)).toBeFocused(); + await expect(levels.nth(2)).toBeFocused(); // Arrows step from the focused level, not from the selected one: every level is a - // Tab stop, so ArrowRight on the last button has nowhere to go — and must not throw + // Tab stop, so ArrowLeft on the first button has nowhere to go — and must not throw // focus back across the row to wherever the selection happens to be. - await levels.nth(5).focus(); - await page.keyboard.press("ArrowRight"); - await expect(levels.nth(5)).toBeFocused(); - expect(await depth()).toBe(4); + await levels.nth(0).focus(); await page.keyboard.press("ArrowLeft"); - await expect.poll(depth).toBe(5); - await expect(levels.nth(4)).toBeFocused(); + await expect(levels.nth(0)).toBeFocused(); + expect(await depth()).toBe(4); + await page.keyboard.press("ArrowRight"); + await expect.poll(depth).toBe(3); + await expect(levels.nth(1)).toBeFocused(); + + // Any other level goes through the free field, and then no preset is pressed. + const custom = page.getByRole("textbox", { name: "Custom zoom" }); + await custom.fill("2.5"); + await custom.press("Enter"); + await expect(page.locator('[class*="lanePill"][title="2.50×"]')).toHaveCount(1); + await expect(levels.and(page.locator('[aria-pressed="true"]'))).toHaveCount(0); }); test("clicking outside the clip picker popover closes it", async ({ page }) => {