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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user