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,
}}
/>