fix(reader): stop trackpad pinch-zoom flicker on image viewer (#4742) (#4748)

On macOS a trackpad pinch-to-zoom is delivered as a rapid stream of
ctrl+wheel events. The zoomed image kept its 0.05s transform transition
during that stream, so each event restarted the in-flight transition
from its interpolated mid-point and the image lagged and flickered. This
is the same root cause as the #4451 pan flicker, which was fixed by
dropping the transition during the gesture for the pan and touch-pinch
paths; the wheel-zoom path was the only continuous gesture left with the
transition on.

Suppress the transition while a wheel-zoom gesture is streaming, cleared
on a short debounce since wheel has no explicit gesture-end event.
Discrete zoom (buttons, double-click, keyboard) keeps its smoothing.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-24 00:28:42 +08:00
committed by GitHub
parent 6301c620a8
commit 8810aa6db0
2 changed files with 59 additions and 5 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { render, cleanup, fireEvent, act } from '@testing-library/react';
import ImageViewer from '@/app/reader/components/ImageViewer';
@@ -83,4 +83,35 @@ describe('ImageViewer', () => {
fireEvent.mouseUp(window);
expect(img.style.transition).not.toBe('none');
});
// Trackpad pinch flicker (#4742): on macOS a trackpad pinch-to-zoom arrives
// as a rapid stream of ctrl+wheel events. With the 0.05s transition left on,
// each event restarts the in-flight transition from its interpolated
// mid-point, so the image lags and flickers — the same root cause as the
// #4451 pan flicker. The transition must be off while the wheel-zoom gesture
// is streaming, then return for discrete zoom once the gesture settles.
it('disables the transform transition during ctrl+wheel (trackpad pinch) zoom', () => {
vi.useFakeTimers();
try {
const { container } = render(
<ImageViewer src='blob:test-image' onClose={vi.fn()} gridInsets={gridInsets} />,
);
const img = container.querySelector('img')!;
expect(img.style.transition).not.toBe('none');
act(() => {
fireEvent.wheel(img, { deltaY: -50, ctrlKey: true, clientX: 100, clientY: 100 });
});
expect(img.style.transition).toBe('none');
// After the gesture settles the smoothing returns for discrete zoom.
act(() => {
vi.advanceTimersByTime(500);
});
expect(img.style.transition).not.toBe('none');
} finally {
vi.useRealTimers();
}
});
});
@@ -40,6 +40,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isWheelZooming, setIsWheelZooming] = useState(false);
const [showZoomLabel, setShowZoomLabel] = useState(true);
const lastTouchDistance = useRef<number>(0);
const dragStart = useRef({ x: 0, y: 0 });
@@ -47,10 +48,25 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wheelZoomEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Escape (desktop) and Android Back key → close the viewer.
useKeyDownActions({ onCancel: onClose });
// A macOS trackpad pinch arrives as a rapid stream of ctrl+wheel events.
// Flag the gesture as active so the transform transition is suppressed while
// it streams (see the transition note below), then clear it shortly after the
// last event since wheel has no explicit gesture-end.
const markWheelZooming = () => {
setIsWheelZooming(true);
if (wheelZoomEndTimeoutRef.current) {
clearTimeout(wheelZoomEndTimeoutRef.current);
}
wheelZoomEndTimeoutRef.current = setTimeout(() => {
setIsWheelZooming(false);
}, 200);
};
const hideZoomLabelAfterDelay = () => {
if (zoomLabelTimeoutRef.current) {
clearTimeout(zoomLabelTimeoutRef.current);
@@ -154,6 +170,9 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
if (zoomLabelTimeoutRef.current) {
clearTimeout(zoomLabelTimeoutRef.current);
}
if (wheelZoomEndTimeoutRef.current) {
clearTimeout(wheelZoomEndTimeoutRef.current);
}
};
}, []);
@@ -171,6 +190,8 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
markWheelZooming();
const delta = e.deltaY;
const newScale = Math.min(
Math.max(scale * Math.exp(-delta * WHEEL_SENSITIVITY), MIN_SCALE),
@@ -519,10 +540,12 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
maxWidth: '100%',
maxHeight: '100%',
transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`,
// No transition while dragging: the 0.05s ease made the image lag
// behind the cursor, which flickered on desktop (#4451). Keep the
// smoothing only for discrete zoom (buttons, double-click, wheel).
transition: isDragging ? 'none' : 'transform 0.05s ease-out',
// No transition during continuous gestures: the 0.05s ease made the
// image lag behind a moving pointer, which flickered on desktop
// (#4451). The same lag flickered a trackpad pinch (a rapid
// ctrl+wheel stream) on macOS (#4742). Keep the smoothing only for
// discrete zoom (buttons, double-click, keyboard).
transition: isDragging || isWheelZooming ? 'none' : 'transform 0.05s ease-out',
// Promote to a GPU layer so transform changes don't repaint the
// page (the `transform-gpu` class is overridden by this inline
// transform, so its hint is lost).