diff --git a/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx b/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx index 93e44dd6..903b13db 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 } 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 + // 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( + , + ); + 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( + , + ); + 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'); + }); }); diff --git a/apps/readest-app/src/app/reader/components/ImageViewer.tsx b/apps/readest-app/src/app/reader/components/ImageViewer.tsx index 4af6df17..b2fdbe0c 100644 --- a/apps/readest-app/src/app/reader/components/ImageViewer.tsx +++ b/apps/readest-app/src/app/reader/components/ImageViewer.tsx @@ -196,23 +196,29 @@ const ImageViewer: React.FC = ({ 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 ) 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 = ({ 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 = ({ 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, }} />