From f9842b1f664bd206c2edc84ec527f16a9468608b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 22 Aug 2026 11:39:09 +0200 Subject: [PATCH 1/2] fix(editor): zoom/pan the whole timeline pane on scroll, not just the lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wheel listener lived on .tlTracks alone, so Ctrl/Shift+scrolling over the ruler, the hint labels, or the nav bar did nothing — only scrolling over the lanes zoomed or panned. --- .../v4/V4Timeline.geometry.test.tsx | 24 +++++++++++++++---- src/components/ai-edition/v4/V4Timeline.tsx | 24 ++++++++++++++----- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index f3a067bca..aa895d5c1 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -122,13 +122,17 @@ function dragHandle(handle: Element, dxPx: number) { window.dispatchEvent(new MouseEvent("pointerup", { clientX: dxPx })); } -/** Ctrl+wheel up = zoom in; the handler is a native listener, so dispatch real events. */ -function zoomIn(notches: number) { - const canvas = document.querySelector("[class*=tlTracks]") as HTMLElement; +/** Ctrl+wheel up = zoom in; the handler is a native listener, so dispatch real events. + * Takes the target element so a test can prove the listener isn't confined to the + * lanes — it fires from wherever in the pane the cursor happens to be. */ +function wheelZoomOn(el: HTMLElement, notches: number) { for (let i = 0; i < notches; i++) { - fireEvent.wheel(canvas, { ctrlKey: true, deltaY: -100, clientX: 0 }); + fireEvent.wheel(el, { ctrlKey: true, deltaY: -100, clientX: 0 }); } } +function zoomIn(notches: number) { + wheelZoomOn(document.querySelector("[class*=tlTracks]") as HTMLElement, notches); +} describe("V4Timeline lane pills", () => { it("draws a pill exactly as wide as its region, at any zoom", () => { @@ -212,6 +216,18 @@ describe("V4Timeline create-from-toolbar", () => { expect(durationOf(tl)).toBeCloseTo(3.84, 3); }); + it("zooms from a wheel over the ruler too, not just the lanes", () => { + // The ruler row is pinned above .tlTracks (so its ticks don't scroll away + // with the lanes) and isn't a descendant of it. The wheel listener used to + // live on .tlTracks alone, so Ctrl/Shift+scrolling anywhere else in the + // pane — the ruler included — silently did nothing. + const { tl } = renderTimeline(); + const ruler = document.querySelector("[class*=tlRulerRow]") as HTMLElement; + wheelZoomOn(ruler, 40); + fireEvent.click(screen.getByTitle("buttons.addZoom")); + expect(durationOf(tl)).toBeCloseTo(3.84, 3); + }); + it("never asks for a slice too short to be worth creating", () => { // Past ~30x on a short timeline the pixels are worth hundredths of a // second; the region would be born unusable, so the duration floors. diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 493f17d5c..f6c94e2ca 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -374,6 +374,10 @@ export function V4Timeline({ // The camera lane borrows the Layout pane's "No Webcam" wording when there is no // camera to grow, so the two surfaces say the same thing about the same project. const ts = useScopedT("settings"); + // Wheel zoom/pan listens on the whole pane (toolbar down through the nav bar), + // not just the lanes — a user scrolling over the ruler or the hint labels + // expects the same zoom/pan the lanes give, not silence. + const panelRef = useRef(null); const tracksRef = useRef(null); // The transformed canvas is the true timeline coordinate frame — clips, pills // and the playhead are all positioned inside it. Time↔x math must measure THIS @@ -815,14 +819,22 @@ export function V4Timeline({ // React marks wheel handlers passive by default, so e.preventDefault() // there silently no-ops and the browser/OS still intercepts Ctrl+wheel as // a page-zoom gesture. + // Listens on the whole panel (ref below) so the ruler, the hint labels and + // the nav bar all zoom/pan too — only .tlTracks scrolls natively, but the + // gesture shouldn't be confined to wherever that scroll happens to live. + // The rect stays tracksRef regardless of which descendant the wheel fired + // on: ruler + tracks share one horizontal padding (see the width effect + // below), so tracksRef reads the same left/width either way, and it's the + // one guaranteed to exist whenever showLanes is true. useEffect(() => { - const el = tracksRef.current; - if (!el) return; + const panel = panelRef.current; + const tracks = tracksRef.current; + if (!panel || !tracks) return; // Media shows no zoom window, so leave the wheel alone there: a zoom with // no control to undo it and no ruler reading to explain it is a trap. if (!showLanes) return; const onWheelNative = (e: WheelEvent) => { - const r = el.getBoundingClientRect(); + const r = tracks.getBoundingClientRect(); const viewportPct = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); if (e.shiftKey) { e.preventDefault(); @@ -852,8 +864,8 @@ export function V4Timeline({ } // Otherwise let the native vertical scroll of .tlTracks run (no preventDefault). }; - el.addEventListener("wheel", onWheelNative, { passive: false }); - return () => el.removeEventListener("wheel", onWheelNative); + panel.addEventListener("wheel", onWheelNative, { passive: false }); + return () => panel.removeEventListener("wheel", onWheelNative); }, [showLanes]); // Track the tracks' content width for the ruler. .tlTracks and .tlRulerRow @@ -1256,7 +1268,7 @@ export function V4Timeline({ }; return ( -
+
{showLanes ? (
From 5b69a2f3da9498c7f2b2735f66786d79c8e4df93 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 22 Aug 2026 11:46:20 +0200 Subject: [PATCH 2/2] test(editor): cover Shift+wheel pan from the ruler too CodeRabbit flagged that the panel-wide wheel fix only had regression coverage for Ctrl+wheel zoom from the ruler, not Shift+wheel pan. --- .../ai-edition/v4/V4Timeline.geometry.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index aa895d5c1..8e515568c 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -228,6 +228,20 @@ describe("V4Timeline create-from-toolbar", () => { expect(durationOf(tl)).toBeCloseTo(3.84, 3); }); + it("pans from a wheel over the ruler too, not just the lanes", () => { + // Same gap as the zoom case above, but for the Shift+wheel pan path. + // Panning is a no-op fully zoomed out (nav already spans the whole + // timeline, so there is nowhere to pan to), so zoom in first — from the + // ruler too — to open up room to pan within. + renderTimeline(); + const ruler = document.querySelector("[class*=tlRulerRow]") as HTMLElement; + wheelZoomOn(ruler, 40); + const navWindow = document.querySelector("[class*=tlNavWindow]") as HTMLElement; + const before = navWindow.style.left; + fireEvent.wheel(ruler, { shiftKey: true, deltaY: 100, clientX: 0 }); + expect(navWindow.style.left).not.toBe(before); + }); + it("never asks for a slice too short to be worth creating", () => { // Past ~30x on a short timeline the pixels are worth hundredths of a // second; the region would be born unusable, so the duration floors.