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).