diff --git a/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx b/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx index aa80bcb6..47c51ad8 100644 --- a/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx +++ b/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx @@ -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( + , + ); + 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(); + } + }); }); diff --git a/apps/readest-app/src/app/reader/components/ImageViewer.tsx b/apps/readest-app/src/app/reader/components/ImageViewer.tsx index 149c19ff..b576c5b4 100644 --- a/apps/readest-app/src/app/reader/components/ImageViewer.tsx +++ b/apps/readest-app/src/app/reader/components/ImageViewer.tsx @@ -40,6 +40,7 @@ const ImageViewer: React.FC = ({ 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(0); const dragStart = useRef({ x: 0, y: 0 }); @@ -47,10 +48,25 @@ const ImageViewer: React.FC = ({ const containerRef = useRef(null); const imageRef = useRef(null); const zoomLabelTimeoutRef = useRef | null>(null); + const wheelZoomEndTimeoutRef = useRef | 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 = ({ if (zoomLabelTimeoutRef.current) { clearTimeout(zoomLabelTimeoutRef.current); } + if (wheelZoomEndTimeoutRef.current) { + clearTimeout(wheelZoomEndTimeoutRef.current); + } }; }, []); @@ -171,6 +190,8 @@ const ImageViewer: React.FC = ({ 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 = ({ 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).