fix(reader): stop zoomed image pan from flickering on desktop, closes #4451 (#4465)

The desktop mouse-drag handlers were bound to the moving <img>, so the
cursor crossing the (transition-lagged) image boundary fired onMouseLeave
and repeatedly aborted/restarted the drag — the flicker. Touch was fine
because it tracks on the full-screen container.

Track the drag on `window` while dragging (mirroring the touch path),
disable the transform transition during the drag so the pan is 1:1, and
set will-change: transform (the transform-gpu class is overridden by the
inline transform, so its GPU hint was lost).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-05 00:29:21 +08:00
committed by GitHub
parent b07c9eb631
commit 4d1205fdf5
2 changed files with 72 additions and 20 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import ImageViewer from '@/app/reader/components/ImageViewer';
@@ -36,4 +36,46 @@ describe('ImageViewer', () => {
const calloutSafeImage = container.querySelector('.no-context-menu img');
expect(calloutSafeImage).toBeTruthy();
});
// Desktop-only flicker (#4451): the drag pan must keep tracking the pointer
// even after it leaves the (moving) image, mirroring how the touch path
// tracks on the full-screen container. Binding the move/up handlers to the
// <img> meant the pointer crossing the image boundary aborted/restarted the
// drag, producing the flicker. The drag now tracks on `window`.
const zoomIn = (img: Element) => {
// Double-click on a fresh viewer zooms to scale=2 so panning is enabled.
fireEvent.doubleClick(img);
};
it('keeps panning when the pointer leaves the image (tracks on window)', () => {
const { container } = render(
<ImageViewer src='blob:test-image' onClose={vi.fn()} gridInsets={gridInsets} />,
);
const img = container.querySelector('img')!;
zoomIn(img);
fireEvent.mouseDown(img, { clientX: 100, clientY: 100 });
// Pointer moves while no longer over the image element — handled on window.
fireEvent.mouseMove(window, { clientX: 160, clientY: 130 });
// position = (60, 30); transform divides the translate by scale (2).
expect(img.style.transform).toContain('scale(2)');
expect(img.style.transform).toContain('translate(30px, 15px)');
});
it('disables the transform transition while dragging to avoid lag flicker', () => {
const { container } = render(
<ImageViewer src='blob:test-image' onClose={vi.fn()} gridInsets={gridInsets} />,
);
const img = container.querySelector('img')!;
zoomIn(img);
expect(img.style.transition).not.toBe('none');
fireEvent.mouseDown(img, { clientX: 100, clientY: 100 });
expect(img.style.transition).toBe('none');
fireEvent.mouseUp(window);
expect(img.style.transition).not.toBe('none');
});
});
@@ -196,23 +196,29 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y };
};
const handleImageMouseMove = (e: React.MouseEvent) => {
if (!isDragging || scale <= 1) return;
e.preventDefault();
// Track the drag on `window` (not the moving <img>) so the pan keeps
// following the pointer even when it crosses the image boundary. Binding the
// move/up handlers to the image meant the cursor leaving the lagging image
// aborted and restarted the drag, which flickered on desktop (#4451). This
// mirrors the touch path, which tracks on the full-screen container.
useEffect(() => {
if (!isDragging) return;
wasDragging.current = true;
const newX = e.clientX - dragStart.current.x;
const newY = e.clientY - dragStart.current.y;
const handleMouseMove = (e: MouseEvent) => {
wasDragging.current = true;
setPosition({ x: e.clientX - dragStart.current.x, y: e.clientY - dragStart.current.y });
};
const handleMouseUp = () => {
setIsDragging(false);
};
setPosition({ x: newX, y: newY });
};
const handleImageMouseUp = (e: React.MouseEvent) => {
if (isDragging) {
e.stopPropagation();
}
setIsDragging(false);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
const onTouchStart = (e: React.TouchEvent) => {
const touches = e.touches;
@@ -445,9 +451,6 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
sizes='100vw'
onClick={handleImageClick}
onMouseDown={handleImageMouseDown}
onMouseMove={handleImageMouseMove}
onMouseUp={handleImageMouseUp}
onMouseLeave={handleImageMouseUp}
onDoubleClick={onDoubleClick}
style={{
width: 'auto',
@@ -455,7 +458,14 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
maxWidth: '100%',
maxHeight: '100%',
transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`,
transition: 'transform 0.05s ease-out',
// 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',
// 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).
willChange: 'transform',
cursor: cursorStyle,
}}
/>