From 7deba10705ee81b7e1926286718e06d45c6f97fb Mon Sep 17 00:00:00 2001 From: lh01217311 Date: Wed, 2 Sep 2026 16:52:13 +0800 Subject: [PATCH 1/2] feat: support `rebound` for image preview dragging Add a `rebound` option to `PreviewConfig` (default `true`, backward compatible). When set to `false`, the preview image keeps the dropped position after dragging instead of rebounding to the visible area, which is convenient for reading content underneath the dragged image. Co-Authored-By: Claude --- README.md | 1 + src/Preview/index.tsx | 5 ++++ src/hooks/useMouseEvent.ts | 4 +++ src/hooks/useTouchEvent.ts | 4 +++ tests/preview.test.tsx | 51 +++++++++++++++++++++++++++++++++++++ tests/previewTouch.test.tsx | 40 +++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+) diff --git a/README.md b/README.md index 98327cf8..8387e07c 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Native image attributes are also supported. | wheel | Enable mouse wheel zoom | boolean | true | | onOpenChange | Callback when preview open state changes | `(open: boolean) => void` | - | | onTransform | Callback when transform changes | `(info: { transform: TransformType; action: TransformAction }) => void` | - | +| rebound | Whether to rebound the image to the visible area after dragging ends | boolean | true | ### Image.PreviewGroup diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 03a43f47..2934dfc5 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -96,6 +96,8 @@ export interface InternalPreviewConfig { // Operation movable?: boolean; + /** Whether to rebound the image to the visible area after dragging. Default is true. */ + rebound?: boolean; icons?: OperationIcons; closeIcon?: React.ReactNode; @@ -176,6 +178,7 @@ const Preview: React.FC = props => { imageInfo, fallback, movable = true, + rebound = true, onClose, open, afterOpenChange, @@ -222,6 +225,7 @@ const Preview: React.FC = props => { const { isMoving, onMouseDown, onWheel } = useMouseEvent( imgRef, movable, + rebound, open, scaleStep, transform, @@ -232,6 +236,7 @@ const Preview: React.FC = props => { const { isTouching, onTouchStart, onTouchMove, onTouchEnd } = useTouchEvent( imgRef, movable, + rebound, open, minScale, transform, diff --git a/src/hooks/useMouseEvent.ts b/src/hooks/useMouseEvent.ts index 96b22b25..7a753f39 100644 --- a/src/hooks/useMouseEvent.ts +++ b/src/hooks/useMouseEvent.ts @@ -12,6 +12,7 @@ import type { export default function useMouseEvent( imgRef: React.MutableRefObject, movable: boolean, + rebound: boolean, open: boolean, scaleStep: number, transform: TransformType, @@ -64,6 +65,9 @@ export default function useMouseEvent( const hasChangedPosition = x !== transformX && y !== transformY; if (!hasChangedPosition) return; + // Keep the image at the dropped position when `rebound` is disabled + if (!rebound) return; + const width = imgRef.current.offsetWidth * scale; const height = imgRef.current.offsetHeight * scale; // eslint-disable-next-line @typescript-eslint/no-shadow diff --git a/src/hooks/useTouchEvent.ts b/src/hooks/useTouchEvent.ts index fd83cbce..d341c3bb 100644 --- a/src/hooks/useTouchEvent.ts +++ b/src/hooks/useTouchEvent.ts @@ -47,6 +47,7 @@ function getCenter(oldPoint1: Point, oldPoint2: Point, newPoint1: Point, newPoin export default function useTouchEvent( imgRef: React.MutableRefObject, movable: boolean, + rebound: boolean, open: boolean, minScale: number, transform: TransformType, @@ -144,6 +145,9 @@ export default function useTouchEvent( return updateTransform({ x: 0, y: 0, scale: minScale }, 'touchZoom'); } + // Keep the image at the dropped position when `rebound` is disabled + if (!rebound) return; + const width = imgRef.current.offsetWidth * scale; const height = imgRef.current.offsetHeight * scale; // eslint-disable-next-line @typescript-eslint/no-shadow diff --git a/tests/preview.test.tsx b/tests/preview.test.tsx index 2d48c43d..45be90b7 100644 --- a/tests/preview.test.tsx +++ b/tests/preview.test.tsx @@ -699,6 +699,57 @@ describe('Preview', () => { jest.restoreAllMocks(); }); + it('rebound disabled keeps the dropped position', () => { + const clientWidthMock = jest + .spyOn(document.documentElement, 'clientWidth', 'get') + .mockImplementation(() => 1080); + const clientHeightMock = jest + .spyOn(document.documentElement, 'clientHeight', 'get') + .mockImplementation(() => 760); + + const left = 0; + const top = 0; + + const imgEleMock = spyElementPrototypes(HTMLImageElement, { + offsetWidth: { get: () => 2000 }, + offsetHeight: { get: () => 1000 }, + getBoundingClientRect: () => ({ left, top }), + }); + + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-image')); + + // Drag the image out of the visible area + fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { + pageX: 0, + pageY: 0, + button: 0, + }); + fireMouseEvent('mouseMove', window, { pageX: 80, pageY: 60 }); + + expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ + transform: 'translate3d(80px, 60px, 0) scale3d(1, 1, 1) rotate(0deg)', + }); + + // Without rebound, the image stays where it was dropped + fireMouseEvent('mouseUp', window); + + expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ + transform: 'translate3d(80px, 60px, 0) scale3d(1, 1, 1) rotate(0deg)', + }); + + clientWidthMock.mockRestore(); + clientHeightMock.mockRestore(); + imgEleMock.mockRestore(); + jest.restoreAllMocks(); + }); + it('PreviewGroup render', () => { const { container } = render( { }); }); + it('touch move does not rebound when rebound is disabled', () => { + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-image')); + + const previewImgDom = document.querySelector('.rc-image-preview-img'); + + fireEvent.touchStart(previewImgDom, { + touches: [{ clientX: 0, clientY: 0 }], + }); + fireEvent.touchMove(previewImgDom, { + touches: [{ clientX: 50, clientY: 50 }], + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(previewImgDom).toHaveStyle({ + transform: 'translate3d(50px, 50px, 0) scale3d(1, 1, 1) rotate(0deg)', + transitionDuration: '0s', + }); + + fireEvent.touchEnd(previewImgDom); + + act(() => { + jest.runAllTimers(); + }); + + // Keep the dropped position instead of rebounding to the center + expect(previewImgDom).toHaveStyle({ + transform: 'translate3d(50px, 50px, 0) scale3d(1, 1, 1) rotate(0deg)', + }); + }); + it('touch zoom', () => { const { container } = render( , From b5e1c5c09310b09fb1beba6ff466e028ea6b7388 Mon Sep 17 00:00:00 2001 From: lh01217311 Date: Thu, 3 Sep 2026 10:37:35 +0800 Subject: [PATCH 2/2] fix: keep dropped position on zoom back when rebound disabled Address review feedback: - `useTouchEvent`: only skip the position correction on single-finger drag (`move`); two-finger `touchZoom` still corrects the image back into the visible area when `rebound` is disabled. - `useImageTransform`: when zooming back to the minimum scale (`scale` reaches 1 and the image fits the viewport), keep the dropped position instead of jumping to the center when `rebound` is disabled. This fixes zooming out after dragging the preview image off-center. Add regression tests for the touch-zoom correction, the zoom-back position, and the default (`rebound` true) behavior. Co-Authored-By: Claude --- src/Preview/index.tsx | 1 + src/hooks/useImageTransform.ts | 5 +- src/hooks/useTouchEvent.ts | 3 +- tests/preview.test.tsx | 122 +++++++++++++++++++++++++++++++-- tests/previewTouch.test.tsx | 54 +++++++++++++++ 5 files changed, 176 insertions(+), 9 deletions(-) diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 2934dfc5..c9f10d0f 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -220,6 +220,7 @@ const Preview: React.FC = props => { imgRef, minScale, maxScale, + rebound, onTransform, ); const { isMoving, onMouseDown, onWheel } = useMouseEvent( diff --git a/src/hooks/useImageTransform.ts b/src/hooks/useImageTransform.ts index ce8e2851..81243788 100644 --- a/src/hooks/useImageTransform.ts +++ b/src/hooks/useImageTransform.ts @@ -54,6 +54,7 @@ export default function useImageTransform( imgRef: React.MutableRefObject, minScale: number, maxScale: number, + rebound: boolean, onTransform: (info: { transform: TransformType; action: TransformAction }) => void, ) { const frame = useRef(null); @@ -134,7 +135,9 @@ export default function useImageTransform( const mergedWidth = offsetWidth * newScale; const mergedHeight = offsetHeight * newScale; const { width: clientWidth, height: clientHeight } = getClientSize(); - if (mergedWidth <= clientWidth && mergedHeight <= clientHeight) { + // Keep the dropped position when `rebound` is disabled, instead of + // resetting the image back to the viewport center. + if (mergedWidth <= clientWidth && mergedHeight <= clientHeight && rebound) { newX = 0; newY = 0; } diff --git a/src/hooks/useTouchEvent.ts b/src/hooks/useTouchEvent.ts index d341c3bb..3176c8a6 100644 --- a/src/hooks/useTouchEvent.ts +++ b/src/hooks/useTouchEvent.ts @@ -133,6 +133,7 @@ export default function useTouchEvent( const onTouchEnd = () => { if (!open) return; + const { eventType } = touchPointInfo.current; if (isTouching) { setIsTouching(false); @@ -146,7 +147,7 @@ export default function useTouchEvent( } // Keep the image at the dropped position when `rebound` is disabled - if (!rebound) return; + if (!rebound && eventType === 'move') return; const width = imgRef.current.offsetWidth * scale; const height = imgRef.current.offsetHeight * scale; diff --git a/tests/preview.test.tsx b/tests/preview.test.tsx index 45be90b7..8d72b9a2 100644 --- a/tests/preview.test.tsx +++ b/tests/preview.test.tsx @@ -707,13 +707,10 @@ describe('Preview', () => { .spyOn(document.documentElement, 'clientHeight', 'get') .mockImplementation(() => 760); - const left = 0; - const top = 0; - const imgEleMock = spyElementPrototypes(HTMLImageElement, { - offsetWidth: { get: () => 2000 }, - offsetHeight: { get: () => 1000 }, - getBoundingClientRect: () => ({ left, top }), + offsetWidth: { get: () => 200 }, + offsetHeight: { get: () => 100 }, + getBoundingClientRect: () => ({ left: 0, top: 0 }), }); const { container } = render( @@ -725,7 +722,8 @@ describe('Preview', () => { fireEvent.click(container.querySelector('.rc-image')); - // Drag the image out of the visible area + // Drag the image within the visible area (image is smaller than the + // viewport, so the default behaviour would rebound it back to the origin) fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { pageX: 0, pageY: 0, @@ -750,6 +748,116 @@ describe('Preview', () => { jest.restoreAllMocks(); }); + it('rebound defaults to true', () => { + const clientWidthMock = jest + .spyOn(document.documentElement, 'clientWidth', 'get') + .mockImplementation(() => 1080); + const clientHeightMock = jest + .spyOn(document.documentElement, 'clientHeight', 'get') + .mockImplementation(() => 760); + + const imgEleMock = spyElementPrototypes(HTMLImageElement, { + offsetWidth: { get: () => 200 }, + offsetHeight: { get: () => 100 }, + getBoundingClientRect: () => ({ left: 0, top: 0 }), + }); + + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-image')); + + fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { + pageX: 0, + pageY: 0, + button: 0, + }); + fireMouseEvent('mouseMove', window, { pageX: 80, pageY: 60 }); + + // When `rebound` is omitted, the default (true) still rebounds the image + fireMouseEvent('mouseUp', window); + + expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ + transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', + }); + + clientWidthMock.mockRestore(); + clientHeightMock.mockRestore(); + imgEleMock.mockRestore(); + jest.restoreAllMocks(); + }); + + it('rebound disabled keeps the position when zooming back to min scale', () => { + const { container } = render( + , + ); + fireEvent.click(container.querySelector('.rc-image')); + act(() => { + jest.runAllTimers(); + }); + const img = document.querySelector('.rc-image-preview-img'); + + // Zoom in to 1.5x + fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]); + act(() => { + jest.runAllTimers(); + }); + + // Drag the image to a non-centered position + fireMouseEvent('mouseDown', img, { pageX: 0, pageY: 0, button: 0 }); + fireMouseEvent('mouseMove', window, { pageX: 100, pageY: 80 }); + fireMouseEvent('mouseUp', window); + + // Zoom out back to the minimum scale (1x) + fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]); + act(() => { + jest.runAllTimers(); + }); + + // Without rebound, the image keeps the dropped position instead of + // jumping back to the viewport center + const transform = img.getAttribute('style') || ''; + expect(transform).toContain('scale3d(1, 1, 1)'); + expect(transform).not.toContain('translate3d(0px, 0px,'); + }); + + it('rebound enabled resets to center when zooming back to min scale', () => { + const { container } = render( + , + ); + fireEvent.click(container.querySelector('.rc-image')); + act(() => { + jest.runAllTimers(); + }); + const img = document.querySelector('.rc-image-preview-img'); + + // Zoom in to 1.5x + fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]); + act(() => { + jest.runAllTimers(); + }); + + // Drag the image to a non-centered position + fireMouseEvent('mouseDown', img, { pageX: 0, pageY: 0, button: 0 }); + fireMouseEvent('mouseMove', window, { pageX: 100, pageY: 80 }); + fireMouseEvent('mouseUp', window); + + // Zoom out back to the minimum scale (1x) + fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]); + act(() => { + jest.runAllTimers(); + }); + + // With rebound (default true), the image jumps back to the center + expect(img).toHaveStyle({ + transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', + }); + }); + it('PreviewGroup render', () => { const { container } = render( { }); }); + it('touch zoom still corrects position when rebound is disabled', () => { + const imgEleMock = spyElementPrototypes(HTMLImageElement, { + offsetWidth: { get: () => 2000 }, + offsetHeight: { get: () => 1000 }, + getBoundingClientRect: () => ({ left: 10, top: 10 }), + }); + + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-image')); + + const previewImgDom = document.querySelector('.rc-image-preview-img'); + + fireEvent.touchStart(previewImgDom, { + touches: [ + { clientX: 40, clientY: 40 }, + { clientX: 60, clientY: 60 }, + ], + }); + fireEvent.touchMove(previewImgDom, { + touches: [ + { clientX: 30, clientY: 30 }, + { clientX: 70, clientY: 70 }, + ], + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(previewImgDom).toHaveStyle({ + transform: 'translate3d(-50px, -50px, 0) scale3d(2, 2, 1) rotate(0deg)', + }); + + fireEvent.touchEnd(previewImgDom); + + act(() => { + jest.runAllTimers(); + }); + + // `rebound` only skips single-finger dragging; the two-finger zoom still + // corrects the image back into the visible area. + expect(previewImgDom).toHaveStyle({ + transform: 'translate3d(2000px, 616px, 0) scale3d(2, 2, 1) rotate(0deg)', + }); + + imgEleMock.mockRestore(); + }); + it('touch zoom', () => { const { container } = render( ,