|
33 | 33 | * ../TimelineShortcutsDialog.tsx — keep the two in sync when a shortcut |
34 | 34 | * changes. |
35 | 35 | * |
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. |
38 | 40 | * Horizontal scroll: a trackpad two-finger horizontal swipe or Shift+wheel |
39 | 41 | * scrolls the lanes left/right. The handler takes the gesture over so the |
40 | 42 | * browser's back/forward swipe never fires at the scroll edges; a plain |
@@ -101,6 +103,11 @@ import { deserializeDragData } from "../../../lib/dragdrop"; |
101 | 103 | import { assetMediaType } from "../dnd/assetToClipAdapter"; |
102 | 104 | import { buildTypedIndexMap } from "./trackVisuals"; |
103 | 105 | import { partitionTimelineWheel, normalizeWheelDeltaPx } from "./timelineWheel"; |
| 106 | +import { |
| 107 | + pinchMsPerPx, |
| 108 | + supportsWebKitGestures, |
| 109 | + type WebKitGestureEvent |
| 110 | +} from "./timelineGesture"; |
104 | 111 | import { resolveTimelineAction } from "../timelineKeymap"; |
105 | 112 | import { performSourceEdit } from "../sourceEdit"; |
106 | 113 | import { |
@@ -483,6 +490,9 @@ export const TracksRegion: React.FC<TracksRegionProps> = memo( |
483 | 490 | const pendingZoomFactorRef = useRef(1); |
484 | 491 | const pendingZoomClientXRef = useRef(0); |
485 | 492 | 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); |
486 | 496 |
|
487 | 497 | useEffect(() => { |
488 | 498 | const el = scrollableRef.current; |
@@ -520,6 +530,7 @@ export const TracksRegion: React.FC<TracksRegionProps> = memo( |
520 | 530 |
|
521 | 531 | if (e.ctrlKey || e.metaKey) { |
522 | 532 | e.preventDefault(); |
| 533 | + if (webkitGestureActiveRef.current) return; |
523 | 534 | pendingZoomFactorRef.current *= 1 + zoomDelta * ZOOM_SENSITIVITY; |
524 | 535 | pendingZoomClientXRef.current = e.clientX; |
525 | 536 | if (zoomRafIdRef.current === null) { |
@@ -560,6 +571,86 @@ export const TracksRegion: React.FC<TracksRegionProps> = memo( |
560 | 571 | }; |
561 | 572 | }, [uiStoreApi]); |
562 | 573 |
|
| 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 | + |
563 | 654 | // Pinch-to-zoom (touch). The desktop route to zoom is Ctrl+wheel, which a |
564 | 655 | // phone has no way to produce; without this the only zoom on a phone is the |
565 | 656 | // status-bar buttons, and trimming to a frame needs a scale you can reach |
|
0 commit comments