From 4d1205fdf5297f9841e52e97ddae658d696ab008 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 5 Jun 2026 00:29:21 +0800 Subject: [PATCH] fix(reader): stop zoomed image pan from flickering on desktop, closes #4451 (#4465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop mouse-drag handlers were bound to the moving , 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) --- .../__tests__/components/ImageViewer.test.tsx | 44 ++++++++++++++++- .../src/app/reader/components/ImageViewer.tsx | 48 +++++++++++-------- 2 files changed, 72 insertions(+), 20 deletions(-) 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, }} />