Skip to content

Commit 2ac69cd

Browse files
authored
Merge pull request #5608 from nodetool-ai/claude/macos-timeline-zoom-gestures-cd0z6g
feat(timeline): zoom on a macOS trackpad pinch in WebKit
2 parents 5d7714f + 1f9e037 commit 2ac69cd

5 files changed

Lines changed: 324 additions & 2 deletions

File tree

web/src/components/timeline/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ a keyboard layout the user picks in the shortcut sheet (`?`):
2828
asset, then Append, Insert or Overwrite it at the playhead.
2929
- **Playback**: J/K/L shuttle, I/O mark a loop range shown on the ruler, M
3030
adds a marker, Up/Down step between cuts.
31+
- **Zoom and pan**: Ctrl/Cmd+wheel zooms at the cursor, and so does a macOS
32+
trackpad pinch — Chromium reports one as a synthetic ctrlKey wheel, Safari as
33+
WebKit gesture events, and both land on the same anchored zoom. A two-finger
34+
horizontal swipe or Shift+wheel pans the lanes.
3135

3236
## Phone layout
3337

web/src/components/timeline/Tracks/TracksRegion.tsx

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
* ../TimelineShortcutsDialog.tsx — keep the two in sync when a shortcut
3434
* changes.
3535
*
36-
* Zoom: Ctrl/Cmd+wheel (or a trackpad pinch) on the lane area changes msPerPx,
37-
* anchored at the cursor.
36+
* Zoom: Ctrl/Cmd+wheel on the lane area changes msPerPx, anchored at the
37+
* cursor. A macOS trackpad pinch arrives as that same synthetic ctrlKey
38+
* wheel in Chromium and as WebKit gesture events in Safari; both routes land
39+
* on the same anchored zoom.
3840
* Horizontal scroll: a trackpad two-finger horizontal swipe or Shift+wheel
3941
* scrolls the lanes left/right. The handler takes the gesture over so the
4042
* browser's back/forward swipe never fires at the scroll edges; a plain
@@ -101,6 +103,11 @@ import { deserializeDragData } from "../../../lib/dragdrop";
101103
import { assetMediaType } from "../dnd/assetToClipAdapter";
102104
import { buildTypedIndexMap } from "./trackVisuals";
103105
import { partitionTimelineWheel, normalizeWheelDeltaPx } from "./timelineWheel";
106+
import {
107+
pinchMsPerPx,
108+
supportsWebKitGestures,
109+
type WebKitGestureEvent
110+
} from "./timelineGesture";
104111
import { resolveTimelineAction } from "../timelineKeymap";
105112
import { performSourceEdit } from "../sourceEdit";
106113
import {
@@ -483,6 +490,9 @@ export const TracksRegion: React.FC<TracksRegionProps> = memo(
483490
const pendingZoomFactorRef = useRef(1);
484491
const pendingZoomClientXRef = useRef(0);
485492
const zoomRafIdRef = useRef<number | null>(null);
493+
// Set while a WebKit pinch is in flight so a stray ctrlKey wheel can't
494+
// apply a second, compounding zoom on top of the gesture's own scale.
495+
const webkitGestureActiveRef = useRef(false);
486496

487497
useEffect(() => {
488498
const el = scrollableRef.current;
@@ -520,6 +530,7 @@ export const TracksRegion: React.FC<TracksRegionProps> = memo(
520530

521531
if (e.ctrlKey || e.metaKey) {
522532
e.preventDefault();
533+
if (webkitGestureActiveRef.current) return;
523534
pendingZoomFactorRef.current *= 1 + zoomDelta * ZOOM_SENSITIVITY;
524535
pendingZoomClientXRef.current = e.clientX;
525536
if (zoomRafIdRef.current === null) {
@@ -560,6 +571,86 @@ export const TracksRegion: React.FC<TracksRegionProps> = memo(
560571
};
561572
}, [uiStoreApi]);
562573

574+
// Pinch-to-zoom (macOS trackpad, WebKit). Chromium turns a pinch into the
575+
// ctrlKey wheel the handler above already routes to zoom; Safari instead
576+
// fires gesture events with a cumulative scale and no wheel at all, so
577+
// without this a pinch over the timeline zooms the page. Feeds the same
578+
// setZoom + `zoomAnchorRef` path, so the time under the fingers stays put.
579+
useEffect(() => {
580+
const el = scrollableRef.current;
581+
if (!el || !supportsWebKitGestures(window)) return;
582+
583+
let startMsPerPx = 0;
584+
let pendingScale = 1;
585+
let pendingClientX = 0;
586+
let rafId: number | null = null;
587+
588+
const applyGesture = () => {
589+
rafId = null;
590+
if (startMsPerPx === 0) return;
591+
const current = uiStoreApi.getState().msPerPx;
592+
const next = pinchMsPerPx(
593+
startMsPerPx,
594+
pendingScale,
595+
MIN_MS_PER_PX,
596+
MAX_MS_PER_PX
597+
);
598+
if (next === current) return;
599+
600+
const rect = el.getBoundingClientRect();
601+
const cursorPx = pendingClientX - rect.left;
602+
zoomAnchorRef.current = {
603+
timeMs: (el.scrollLeft + cursorPx) * current,
604+
cursorPx
605+
};
606+
uiStoreApi.getState().setZoom(next);
607+
};
608+
609+
const onGestureStart = (event: Event) => {
610+
const e = event as WebKitGestureEvent;
611+
// Claim the pinch before Safari applies its own page zoom.
612+
e.preventDefault();
613+
webkitGestureActiveRef.current = true;
614+
startMsPerPx = uiStoreApi.getState().msPerPx;
615+
pendingScale = 1;
616+
pendingClientX = e.clientX;
617+
};
618+
619+
const onGestureChange = (event: Event) => {
620+
const e = event as WebKitGestureEvent;
621+
e.preventDefault();
622+
if (startMsPerPx === 0) return;
623+
pendingScale = e.scale;
624+
pendingClientX = e.clientX;
625+
// A pinch delivers several events per frame; batch to one setZoom per
626+
// frame so the lanes/clips/ruler re-render once.
627+
if (rafId === null) rafId = requestAnimationFrame(applyGesture);
628+
};
629+
630+
const endGesture = (event: Event) => {
631+
event.preventDefault();
632+
webkitGestureActiveRef.current = false;
633+
startMsPerPx = 0;
634+
if (rafId !== null) {
635+
cancelAnimationFrame(rafId);
636+
rafId = null;
637+
}
638+
};
639+
640+
// Non-passive so preventDefault() actually suppresses Safari's own page
641+
// zoom.
642+
el.addEventListener("gesturestart", onGestureStart, { passive: false });
643+
el.addEventListener("gesturechange", onGestureChange, { passive: false });
644+
el.addEventListener("gestureend", endGesture, { passive: false });
645+
return () => {
646+
el.removeEventListener("gesturestart", onGestureStart);
647+
el.removeEventListener("gesturechange", onGestureChange);
648+
el.removeEventListener("gestureend", endGesture);
649+
webkitGestureActiveRef.current = false;
650+
if (rafId !== null) cancelAnimationFrame(rafId);
651+
};
652+
}, [uiStoreApi]);
653+
563654
// Pinch-to-zoom (touch). The desktop route to zoom is Ctrl+wheel, which a
564655
// phone has no way to produce; without this the only zoom on a phone is the
565656
// status-bar buttons, and trimming to a frame needs a scale you can reach
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* TracksRegion pinch-to-zoom over WebKit gesture events (macOS trackpad in
3+
* Safari). jsdom has no GestureEvent, so the events are synthesized with the
4+
* fields WebKit sends: a cumulative `scale` and the gesture centroid.
5+
*/
6+
import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals";
7+
import { act, render } from "@testing-library/react";
8+
import { ThemeProvider } from "@mui/material/styles";
9+
10+
import mockTheme from "../../../../__mocks__/themeMock";
11+
import { TracksRegion } from "../TracksRegion";
12+
import { TimelineProvider } from "../../../../stores/timeline/TimelineInstance";
13+
import { useTimelineUIStore } from "../../../../stores/timeline/TimelineUIStore";
14+
15+
jest.mock("../../../../lib/rest-fetch", () => ({
16+
restFetch: jest.fn()
17+
}));
18+
19+
type GestureInit = { scale: number; clientX: number };
20+
21+
const gesture = (type: string, init: GestureInit): Event => {
22+
const event = new Event(type, { bubbles: true, cancelable: true });
23+
Object.assign(event, { ...init, clientY: 0, rotation: 0 });
24+
return event;
25+
};
26+
27+
/** Run the pending rAF callback the zoom batches into. */
28+
const flushFrame = (frames: Array<FrameRequestCallback>) => {
29+
const pending = frames.splice(0, frames.length);
30+
act(() => {
31+
for (const frame of pending) frame(0);
32+
});
33+
};
34+
35+
describe("TracksRegion WebKit pinch zoom", () => {
36+
let frames: Array<FrameRequestCallback>;
37+
let rafSpy: jest.SpiedFunction<typeof window.requestAnimationFrame>;
38+
39+
beforeEach(() => {
40+
frames = [];
41+
// Feature detection: only a browser that dispatches gesture events (Safari)
42+
// gets the listeners.
43+
(window as unknown as { ongesturechange: unknown }).ongesturechange = null;
44+
rafSpy = jest
45+
.spyOn(window, "requestAnimationFrame")
46+
.mockImplementation((cb: FrameRequestCallback) => {
47+
frames.push(cb);
48+
return frames.length;
49+
});
50+
});
51+
52+
afterEach(() => {
53+
rafSpy.mockRestore();
54+
delete (window as unknown as { ongesturechange?: unknown }).ongesturechange;
55+
});
56+
57+
const setup = () => {
58+
const result = render(
59+
<ThemeProvider theme={mockTheme}>
60+
<TimelineProvider>
61+
<TracksRegion heightPx={400} />
62+
</TimelineProvider>
63+
</ThemeProvider>
64+
);
65+
act(() => {
66+
useTimelineUIStore.getState().setZoom(10);
67+
});
68+
const el = result.getByTestId("tracks-scroll-area");
69+
return el;
70+
};
71+
72+
it("zooms in as the fingers move apart and out as they close", () => {
73+
const el = setup();
74+
75+
act(() => {
76+
el.dispatchEvent(gesture("gesturestart", { scale: 1, clientX: 100 }));
77+
el.dispatchEvent(gesture("gesturechange", { scale: 2, clientX: 100 }));
78+
});
79+
flushFrame(frames);
80+
expect(useTimelineUIStore.getState().msPerPx).toBe(5);
81+
82+
// Still the same gesture: `scale` is cumulative, so 0.5 is half the scale
83+
// it started at, not half of the last frame.
84+
act(() => {
85+
el.dispatchEvent(gesture("gesturechange", { scale: 0.5, clientX: 100 }));
86+
});
87+
flushFrame(frames);
88+
expect(useTimelineUIStore.getState().msPerPx).toBe(20);
89+
});
90+
91+
it("takes the gesture over so Safari does not zoom the page", () => {
92+
const el = setup();
93+
const start = gesture("gesturestart", { scale: 1, clientX: 100 });
94+
const change = gesture("gesturechange", { scale: 1.5, clientX: 100 });
95+
96+
act(() => {
97+
el.dispatchEvent(start);
98+
el.dispatchEvent(change);
99+
});
100+
expect(start.defaultPrevented).toBe(true);
101+
expect(change.defaultPrevented).toBe(true);
102+
});
103+
104+
it("ignores a gesturechange after the gesture ended", () => {
105+
const el = setup();
106+
107+
act(() => {
108+
el.dispatchEvent(gesture("gesturestart", { scale: 1, clientX: 100 }));
109+
el.dispatchEvent(gesture("gestureend", { scale: 2, clientX: 100 }));
110+
el.dispatchEvent(gesture("gesturechange", { scale: 4, clientX: 100 }));
111+
});
112+
flushFrame(frames);
113+
expect(useTimelineUIStore.getState().msPerPx).toBe(10);
114+
});
115+
116+
it("leaves the zoom to the wheel route where gesture events don't exist", () => {
117+
delete (window as unknown as { ongesturechange?: unknown }).ongesturechange;
118+
const el = setup();
119+
120+
act(() => {
121+
el.dispatchEvent(gesture("gesturestart", { scale: 1, clientX: 100 }));
122+
el.dispatchEvent(gesture("gesturechange", { scale: 2, clientX: 100 }));
123+
});
124+
flushFrame(frames);
125+
expect(useTimelineUIStore.getState().msPerPx).toBe(10);
126+
});
127+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* macOS trackpad pinch, WebKit route.
3+
*
4+
* Pins the pinch scale math and the feature detection that keeps the gesture
5+
* listeners off browsers that report a pinch as a ctrlKey wheel instead.
6+
*/
7+
8+
import { pinchMsPerPx, supportsWebKitGestures } from "../timelineGesture";
9+
10+
const MIN = 0.5;
11+
const MAX = 500;
12+
13+
describe("pinchMsPerPx", () => {
14+
it("zooms in when the fingers move apart (scale > 1)", () => {
15+
expect(pinchMsPerPx(10, 2, MIN, MAX)).toBe(5);
16+
});
17+
18+
it("zooms out when the fingers move together (scale < 1)", () => {
19+
expect(pinchMsPerPx(10, 0.5, MIN, MAX)).toBe(20);
20+
});
21+
22+
it("holds the scale at a gesture that has not moved yet", () => {
23+
expect(pinchMsPerPx(10, 1, MIN, MAX)).toBe(10);
24+
});
25+
26+
it("clamps to the zoom bounds", () => {
27+
expect(pinchMsPerPx(10, 1000, MIN, MAX)).toBe(MIN);
28+
expect(pinchMsPerPx(10, 0.001, MIN, MAX)).toBe(MAX);
29+
});
30+
31+
it("falls back to the start scale for a zero or non-finite scale", () => {
32+
expect(pinchMsPerPx(10, 0, MIN, MAX)).toBe(10);
33+
expect(pinchMsPerPx(10, -1, MIN, MAX)).toBe(10);
34+
expect(pinchMsPerPx(10, Number.NaN, MIN, MAX)).toBe(10);
35+
});
36+
37+
it("is cumulative, not incremental — the same scale gives the same result", () => {
38+
expect(pinchMsPerPx(10, 1.5, MIN, MAX)).toBe(pinchMsPerPx(10, 1.5, MIN, MAX));
39+
});
40+
});
41+
42+
describe("supportsWebKitGestures", () => {
43+
it("detects a browser that dispatches gesture events", () => {
44+
expect(
45+
supportsWebKitGestures({ ongesturechange: null } as unknown as Window)
46+
).toBe(true);
47+
});
48+
49+
it("reports false where a pinch arrives as a ctrlKey wheel instead", () => {
50+
expect(supportsWebKitGestures({} as unknown as Window)).toBe(false);
51+
});
52+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* macOS trackpad pinch, WebKit route.
3+
*
4+
* Chromium reports a trackpad pinch as a synthetic ctrlKey wheel, which
5+
* `partitionTimelineWheel` already routes to zoom. WebKit does not: Safari
6+
* fires `gesturestart` / `gesturechange` / `gestureend` carrying a cumulative
7+
* `scale` and emits no wheel event at all, so without this route a pinch over
8+
* the timeline zooms the page instead of the lanes.
9+
*
10+
* The math is pure so it can be tested without a WebKit-only event.
11+
*/
12+
13+
/**
14+
* WebKit's non-standard GestureEvent. Not in lib.dom, so it is declared here.
15+
* `scale` is cumulative since `gesturestart` (1 = unchanged, >1 = fingers
16+
* apart).
17+
*/
18+
export interface WebKitGestureEvent extends UIEvent {
19+
readonly scale: number;
20+
readonly rotation: number;
21+
readonly clientX: number;
22+
readonly clientY: number;
23+
}
24+
25+
/** True when the browser dispatches WebKit gesture events (Safari). */
26+
export function supportsWebKitGestures(win: Window): boolean {
27+
return "ongesturechange" in win;
28+
}
29+
30+
/**
31+
* Scale (ms per pixel) for a cumulative pinch scale, clamped to the zoom
32+
* bounds. Fingers apart (scale > 1) zooms in, so msPerPx shrinks.
33+
*/
34+
export function pinchMsPerPx(
35+
startMsPerPx: number,
36+
scale: number,
37+
minMsPerPx: number,
38+
maxMsPerPx: number
39+
): number {
40+
const clamp = (value: number) =>
41+
Math.min(maxMsPerPx, Math.max(minMsPerPx, value));
42+
// A zero or non-finite scale would blow up the division; Safari sends 0 for
43+
// a gesture that ends the instant it starts.
44+
if (!Number.isFinite(scale) || scale <= 0) {
45+
return clamp(startMsPerPx);
46+
}
47+
return clamp(startMsPerPx / scale);
48+
}

0 commit comments

Comments
 (0)