Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -212,6 +216,32 @@ 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("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.
Expand Down
24 changes: 18 additions & 6 deletions src/components/ai-edition/v4/V4Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement | null>(null);
const tracksRef = useRef<HTMLDivElement | null>(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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1256,7 +1268,7 @@ export function V4Timeline({
};

return (
<div className={styles.tl}>
<div className={styles.tl} ref={panelRef}>
<div className={styles.tlToolbar}>
{showLanes ? (
<div className={styles.tlTools} role="toolbar" aria-label={t("toolbar.timelineTools")}>
Expand Down
Loading