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:
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user