From 36aedb549ee77099ed8d7bdf7bd1379f31fe9435 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] 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 | 84 ++-- tests/e2e/v4-shell.spec.ts | 88 +++- 6 files changed, 750 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 2a4a4904c..049306c7c 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -20,6 +20,7 @@ import { MAX_PLAYBACK_SPEED, SPEED_OPTIONS, ZOOM_DEPTH_SCALES, + type ZoomDepth, } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import { @@ -358,7 +359,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) - } - style={selectStyle} - > - {ZOOM_DEPTHS.map((d) => ( - - ))} - , - )} + {paneRow( ts("zoom.threeD.title"), ` (issue #670), which puts them + // under the shell's WINDOW key handling: Space there is play/pause and it + // `preventDefault()`s the keydown, which cancels a button's own activation outright. + // jsdom dispatches no native activation for Space at all, so a browser is the only + // place that can hold the line that Space still commits the focused level. + test("the zoom level row commits the focused level on Space and keeps focus in place", async ({ + page, + }) => { + await seedAndOpen(page, makeZoomDoc()); + 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); + + // 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. + const tops = await levels.evaluateAll((els) => + els.map((el) => Math.round(el.getBoundingClientRect().top)), + ); + expect(new Set(tops).size).toBe(1); + expect( + await levels.evaluateAll((els) => els.every((el) => el.scrollWidth <= el.clientWidth + 1)), + ).toBe(true); + + const depth = () => + page.evaluate( + () => + ( + window as unknown as { + __osProjectStore: { + getState: () => { document: { zoomRanges: Array<{ depth: number }> } | null }; + }; + } + ).__osProjectStore.getState().document?.zoomRanges[0]?.depth ?? null, + ); + expect(await depth()).toBe(3); + + await levels.nth(3).focus(); // 2.2×, depth 4 + await page.keyboard.press("Space"); + await expect.poll(depth).toBe(4); + await expect(levels.nth(3)).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 + // 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 page.keyboard.press("ArrowLeft"); + await expect.poll(depth).toBe(5); + await expect(levels.nth(4)).toBeFocused(); + }); + test("clicking outside the clip picker popover closes it", async ({ page }) => { await seedAndOpen(page, makeTwoClipDoc());