fix: restore smooth PDF touchpad and wheel zoom

Enable WebView2 pinch gestures by allowing zoom hotkeys at the native layer, while keeping app-level zoom shortcuts guarded in the frontend.

Rework PDF preview zoom handling to distinguish touchpad pinch from Ctrl/Cmd mouse-wheel zoom, preserve the cursor anchor, and make mouse wheel zoom respond on the first notch.
This commit is contained in:
Gerard-Devlin 2026-06-11 15:31:57 +08:00
parent a814c2779e
commit aa55622ecc
4 changed files with 262 additions and 62 deletions

View file

@ -187,7 +187,7 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
.title("ClaudePrism")
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.zoom_hotkeys_enabled(false)
.zoom_hotkeys_enabled(true)
.visible(false);
#[cfg(target_os = "macos")]
@ -461,7 +461,7 @@ fn open_debug_window(app: tauri::AppHandle) -> Result<(), String> {
.title("ClaudePrism — Debug")
.inner_size(560.0, 700.0)
.min_inner_size(400.0, 400.0)
.zoom_hotkeys_enabled(false)
.zoom_hotkeys_enabled(true)
.visible(true)
.build()
.map_err(|e| format!("Failed to create debug window: {}", e))?;

View file

@ -23,7 +23,7 @@
"visible": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"zoomHotkeysEnabled": false,
"zoomHotkeysEnabled": true,
"trafficLightPosition": {
"x": 12,
"y": 12

View file

@ -1,4 +1,5 @@
import { useCallback, useRef, useEffect, useState } from "react";
import type { MutableRefObject } from "react";
import { flushSync } from "react-dom";
import { LoaderIcon } from "lucide-react";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
@ -17,8 +18,17 @@ const log = createLogger("pdf-viewer");
const MIN_PDF_SCALE = 0.25;
const MAX_PDF_SCALE = 4;
const WHEEL_ZOOM_SENSITIVITY = 0.006;
const MAX_WHEEL_DELTA_PER_FRAME = 420;
const MOUSE_WHEEL_ZOOM_STEP = 1.12;
const TRACKPAD_PINCH_MIN_FACTOR = 0.9;
const TRACKPAD_PINCH_MAX_FACTOR = 1.1;
const PIXELS_PER_WHEEL_TICK = 120;
const MIN_WHEEL_TICKS_PER_ZOOM_STEP = 0.5;
type WebKitGestureEvent = Event & {
scale?: number;
clientX?: number;
clientY?: number;
};
function clampPdfScale(value: number): number {
return Math.max(MIN_PDF_SCALE, Math.min(MAX_PDF_SCALE, value));
@ -28,12 +38,23 @@ function isModifiedZoomWheel(event: WheelEvent): boolean {
return (event.metaKey || event.ctrlKey) && !event.altKey;
}
function isWheelInsideElement(event: WheelEvent, element: HTMLElement): boolean {
if (event.target instanceof Node && element.contains(event.target)) {
function isWheelInsidePdfViewer(
event: WheelEvent,
container: HTMLElement,
): boolean {
if (event.target instanceof Node && container.contains(event.target)) {
return true;
}
const rect = element.getBoundingClientRect();
if (
event
.composedPath()
.some((target) => target instanceof Node && container.contains(target))
) {
return true;
}
const rect = container.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
@ -42,6 +63,94 @@ function isWheelInsideElement(event: WheelEvent, element: HTMLElement): boolean
);
}
function isTrackpadPinchWheel(
event: WheelEvent,
isCtrlKeyDown: boolean,
isMetaKeyDown: boolean,
): boolean {
return (
event.ctrlKey &&
!event.metaKey &&
!isCtrlKeyDown &&
!isMetaKeyDown &&
event.deltaMode === WheelEvent.DOM_DELTA_PIXEL &&
Math.abs(event.deltaX) < 1 &&
event.deltaZ === 0
);
}
function normalizeWheelEventDirection(event: WheelEvent): number {
let delta = Math.hypot(event.deltaX, event.deltaY);
const angle = Math.atan2(event.deltaY, event.deltaX);
if (-0.25 * Math.PI < angle && angle < 0.75 * Math.PI) {
delta = -delta;
}
return delta;
}
function normalizeMouseWheelTicks(event: WheelEvent): number {
if (event.deltaY === 0) return 0;
if (
event.deltaMode === WheelEvent.DOM_DELTA_LINE ||
event.deltaMode === WheelEvent.DOM_DELTA_PAGE
) {
return Math.sign(normalizeWheelEventDirection(event));
}
return normalizeWheelEventDirection(event) / PIXELS_PER_WHEEL_TICK;
}
function accumulateWheelTicks(
ticksRef: MutableRefObject<number>,
ticks: number,
): number {
if (
(ticksRef.current > 0 && ticks < 0) ||
(ticksRef.current < 0 && ticks > 0)
) {
ticksRef.current = 0;
}
ticksRef.current += ticks;
if (Math.abs(ticksRef.current) < MIN_WHEEL_TICKS_PER_ZOOM_STEP) {
return 0;
}
const zoomTicks = Math.sign(ticksRef.current);
ticksRef.current -= zoomTicks;
return zoomTicks;
}
function accumulatePinchFactor(
factorRef: MutableRefObject<number>,
previousScale: number,
factor: number,
): number {
if (factor === 1) return 1;
if (
(factorRef.current > 1 && factor < 1) ||
(factorRef.current < 1 && factor > 1)
) {
factorRef.current = 1;
}
const nextFactor =
Math.floor(previousScale * factor * factorRef.current * 100) /
(100 * previousScale);
factorRef.current = factor / nextFactor;
return nextFactor;
}
function getTrackpadPinchFactor(event: WheelEvent): number {
const factor = Math.exp(-event.deltaY / 100);
return Math.max(
TRACKPAD_PINCH_MIN_FACTOR,
Math.min(TRACKPAD_PINCH_MAX_FACTOR, factor),
);
}
interface PageZoomAnchor {
pageNumber: number;
pdfX: number;
@ -66,7 +175,10 @@ function findPageZoomAnchor(
const distance =
clientY >= rect.top && clientY <= rect.bottom
? 0
: Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom));
: Math.min(
Math.abs(clientY - rect.top),
Math.abs(clientY - rect.bottom),
);
if (distance < bestDistance) {
bestDistance = distance;
@ -164,15 +276,17 @@ export function PdfViewer({
const scaleRef = useRef(scale);
scaleRef.current = scale;
const wheelRafRef = useRef<number | null>(null);
const wheelDeltaRef = useRef(0);
const wheelPointRef = useRef<{ x: number; y: number } | null>(null);
const ctrlKeyDownRef = useRef(false);
const metaKeyDownRef = useRef(false);
const wheelUnusedTicksRef = useRef(0);
const wheelUnusedFactorRef = useRef(1);
const touchPinchRef = useRef<{
distance: number;
scale: number;
x: number;
y: number;
} | null>(null);
const gesturePinchRef = useRef<{ scale: number } | null>(null);
const synctexClickRef = useRef(onSynctexClick);
synctexClickRef.current = onSynctexClick;
const textSelectRef = useRef(onTextSelect);
@ -224,6 +338,30 @@ export function PdfViewer({
const numPages = pageSizes.length;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Control") ctrlKeyDownRef.current = true;
if (event.key === "Meta") metaKeyDownRef.current = true;
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === "Control") ctrlKeyDownRef.current = false;
if (event.key === "Meta") metaKeyDownRef.current = false;
};
const resetModifierState = () => {
ctrlKeyDownRef.current = false;
metaKeyDownRef.current = false;
};
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", resetModifierState);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", resetModifierState);
};
}, []);
function getVisiblePage(): number {
const container = containerRef.current;
if (!container) return 1;
@ -616,8 +754,10 @@ export function PdfViewer({
}
const containerRect = container.getBoundingClientRect();
const anchorClientX = clientX ?? containerRect.left + containerRect.width / 2;
const anchorClientY = clientY ?? containerRect.top + containerRect.height / 2;
const anchorClientX =
clientX ?? containerRect.left + containerRect.width / 2;
const anchorClientY =
clientY ?? containerRect.top + containerRect.height / 2;
const pageAnchor = findPageZoomAnchor(
container,
anchorClientX,
@ -658,65 +798,101 @@ export function PdfViewer({
[onScaleChange],
);
const getWheelDeltaPixels = useCallback((event: WheelEvent) => {
const container = containerRef.current;
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16;
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return event.deltaY * (container?.clientHeight || window.innerHeight);
}
return event.deltaY;
}, []);
// Ctrl/Cmd + wheel zoom. Windows precision touchpad pinch arrives here too.
useEffect(() => {
const container = containerRef.current;
if (!container || !onScaleChange) return;
const flushWheelZoom = () => {
wheelRafRef.current = null;
const delta = wheelDeltaRef.current;
const point = wheelPointRef.current;
wheelDeltaRef.current = 0;
wheelPointRef.current = null;
if (!point || delta === 0) return;
const cappedDelta = Math.max(
-MAX_WHEEL_DELTA_PER_FRAME,
Math.min(MAX_WHEEL_DELTA_PER_FRAME, delta),
);
const factor = Math.exp(-cappedDelta * WHEEL_ZOOM_SENSITIVITY);
zoomAtPoint(scaleRef.current * factor, point.x, point.y);
};
const handleWheel = (e: WheelEvent) => {
if (!isModifiedZoomWheel(e) || !isWheelInsideElement(e, container)) {
if (!isModifiedZoomWheel(e) || !isWheelInsidePdfViewer(e, container)) {
return;
}
e.preventDefault();
e.stopPropagation();
wheelDeltaRef.current += getWheelDeltaPixels(e);
wheelPointRef.current = { x: e.clientX, y: e.clientY };
if (wheelRafRef.current === null) {
wheelRafRef.current = window.requestAnimationFrame(flushWheelZoom);
if (
isTrackpadPinchWheel(e, ctrlKeyDownRef.current, metaKeyDownRef.current)
) {
const previousScale = scaleRef.current;
const factor = accumulatePinchFactor(
wheelUnusedFactorRef,
previousScale,
getTrackpadPinchFactor(e),
);
if (factor !== 1) {
wheelUnusedTicksRef.current = 0;
zoomAtPoint(previousScale * factor, e.clientX, e.clientY);
}
return;
}
const ticks = accumulateWheelTicks(
wheelUnusedTicksRef,
normalizeMouseWheelTicks(e),
);
if (ticks === 0) return;
wheelUnusedFactorRef.current = 1;
const factor = MOUSE_WHEEL_ZOOM_STEP ** ticks;
zoomAtPoint(scaleRef.current * factor, e.clientX, e.clientY);
};
document.addEventListener("wheel", handleWheel, {
capture: true,
window.addEventListener("wheel", handleWheel, {
passive: false,
});
return () => {
document.removeEventListener("wheel", handleWheel, true);
if (wheelRafRef.current !== null) {
window.cancelAnimationFrame(wheelRafRef.current);
wheelRafRef.current = null;
}
wheelDeltaRef.current = 0;
wheelPointRef.current = null;
window.removeEventListener("wheel", handleWheel);
wheelUnusedTicksRef.current = 0;
wheelUnusedFactorRef.current = 1;
};
}, [getWheelDeltaPixels, onScaleChange, zoomAtPoint]);
}, [onScaleChange, zoomAtPoint]);
// Some WebViews report trackpad pinch through non-standard WebKit gesture
// events instead of Ctrl/Cmd + wheel.
useEffect(() => {
const container = containerRef.current;
if (!container || !onScaleChange) return;
const readGestureScale = (event: WebKitGestureEvent) =>
typeof event.scale === "number" && Number.isFinite(event.scale)
? event.scale
: 1;
const handleGestureStart: EventListener = (event) => {
event.preventDefault();
gesturePinchRef.current = { scale: scaleRef.current };
};
const handleGestureChange: EventListener = (event) => {
const gesture = event as WebKitGestureEvent;
const start = gesturePinchRef.current;
if (!start) return;
event.preventDefault();
const factor =
(start.scale * readGestureScale(gesture)) / scaleRef.current;
zoomAtPoint(scaleRef.current * factor, gesture.clientX, gesture.clientY);
};
const handleGestureEnd: EventListener = () => {
gesturePinchRef.current = null;
};
container.addEventListener("gesturestart", handleGestureStart, {
passive: false,
});
container.addEventListener("gesturechange", handleGestureChange, {
passive: false,
});
container.addEventListener("gestureend", handleGestureEnd);
return () => {
container.removeEventListener("gesturestart", handleGestureStart);
container.removeEventListener("gesturechange", handleGestureChange);
container.removeEventListener("gestureend", handleGestureEnd);
gesturePinchRef.current = null;
};
}, [onScaleChange, zoomAtPoint]);
// Two-finger touch pinch for touch-capable screens. Trackpads usually arrive
// through the wheel path above.
@ -753,11 +929,9 @@ export function PdfViewer({
const pinch = readTouchPinch(event.touches);
if (!start || !pinch || start.distance <= 0) return;
event.preventDefault();
zoomAtPoint(
start.scale * (pinch.distance / start.distance),
pinch.x,
pinch.y,
);
const factor =
(start.scale * (pinch.distance / start.distance)) / scaleRef.current;
zoomAtPoint(scaleRef.current * factor, pinch.x, pinch.y);
};
const handleTouchEnd = () => {
@ -1006,7 +1180,7 @@ export function PdfViewer({
className="min-h-0 flex-1 overflow-auto outline-none"
style={{
cursor: captureMode ? "crosshair" : undefined,
touchAction: captureMode ? "none" : "pan-x pan-y pinch-zoom",
touchAction: captureMode ? "none" : "pan-x pan-y",
}}
onMouseDownCapture={() => containerRef.current?.focus()}
onMouseDown={handleCaptureMouseDown}

View file

@ -107,6 +107,32 @@ export function shouldHandleAppZoomShortcut(
return !target.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`);
}
function hasLocalZoomSurfaceAtPoint(event: WheelEvent): boolean {
if (typeof document.elementsFromPoint !== "function") return false;
return document
.elementsFromPoint(event.clientX, event.clientY)
.some((element) => element.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`));
}
function hasLocalZoomSurfaceInPath(event: WheelEvent): boolean {
return event
.composedPath()
.some(
(target) =>
target instanceof Element &&
!!target.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`),
);
}
function shouldHandleNativeWheelZoom(event: WheelEvent): boolean {
return (
shouldHandleAppZoomShortcut(event.target) &&
!hasLocalZoomSurfaceInPath(event) &&
!hasLocalZoomSurfaceAtPoint(event)
);
}
export function installNativeWheelZoomGuard(): void {
if (nativeWheelZoomGuardInstalled || typeof document === "undefined") return;
nativeWheelZoomGuardInstalled = true;
@ -115,9 +141,9 @@ export function installNativeWheelZoomGuard(): void {
"wheel",
(event) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
if (!shouldHandleAppZoomShortcut(event.target)) return;
if (!shouldHandleNativeWheelZoom(event)) return;
event.preventDefault();
},
{ capture: true, passive: false },
{ passive: false },
);
}