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..c9f10d0f 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, @@ -217,11 +220,13 @@ const Preview: React.FC = props => { imgRef, minScale, maxScale, + rebound, onTransform, ); const { isMoving, onMouseDown, onWheel } = useMouseEvent( imgRef, movable, + rebound, open, scaleStep, transform, @@ -232,6 +237,7 @@ const Preview: React.FC = props => { const { isTouching, onTouchStart, onTouchMove, onTouchEnd } = useTouchEvent( imgRef, movable, + rebound, open, minScale, transform, 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/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..3176c8a6 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, @@ -132,6 +133,7 @@ export default function useTouchEvent( const onTouchEnd = () => { if (!open) return; + const { eventType } = touchPointInfo.current; if (isTouching) { setIsTouching(false); @@ -144,6 +146,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 && eventType === 'move') 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..8d72b9a2 100644 --- a/tests/preview.test.tsx +++ b/tests/preview.test.tsx @@ -699,6 +699,165 @@ 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 imgEleMock = spyElementPrototypes(HTMLImageElement, { + offsetWidth: { get: () => 200 }, + offsetHeight: { get: () => 100 }, + getBoundingClientRect: () => ({ left: 0, top: 0 }), + }); + + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-image')); + + // 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, + 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('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 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 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( ,