diff --git a/src/Operations.tsx b/src/Operations.tsx index a3ca2ae..0f88e08 100644 --- a/src/Operations.tsx +++ b/src/Operations.tsx @@ -119,7 +119,7 @@ const Operations: React.FC = props => { icon: zoomOut, onClick: onZoomOut, type: 'zoomOut', - disabled: scale === minScale, + disabled: scale <= minScale, }, { icon: zoomIn, diff --git a/src/Preview.tsx b/src/Preview.tsx index 3a73590..7e46aea 100644 --- a/src/Preview.tsx +++ b/src/Preview.tsx @@ -3,15 +3,15 @@ import type { DialogProps as IDialogPropTypes } from 'rc-dialog'; import Dialog from 'rc-dialog'; import addEventListener from 'rc-util/lib/Dom/addEventListener'; import KeyCode from 'rc-util/lib/KeyCode'; -import { warning } from 'rc-util/lib/warning'; import React, { useContext, useEffect, useRef, useState } from 'react'; import { PreviewGroupContext } from './context'; -import getFixScaleEleTransPosition from './getFixScaleEleTransPosition'; import type { TransformAction, TransformType } from './hooks/useImageTransform'; import useImageTransform from './hooks/useImageTransform'; +import useMouseEvent from './hooks/useMouseEvent'; +import useTouchEvent from './hooks/useTouchEvent'; import useStatus from './hooks/useStatus'; import Operations from './Operations'; -import { BASE_SCALE_RATIO, WHEEL_MAX_SCALE_RATIO } from './previewConfig'; +import { BASE_SCALE_RATIO } from './previewConfig'; export type ToolbarRenderInfoType = { icons: { @@ -126,24 +126,35 @@ const Preview: React.FC = props => { } = props; const imgRef = useRef(); - const downPositionRef = useRef({ - deltaX: 0, - deltaY: 0, - transformX: 0, - transformY: 0, - }); - const [isMoving, setMoving] = useState(false); const groupContext = useContext(PreviewGroupContext); const showLeftOrRightSwitches = groupContext && count > 1; const showOperationsProgress = groupContext && count >= 1; + const [enableTransition, setEnableTransition] = useState(true); const { transform, resetTransform, updateTransform, dispatchZoomChange } = useImageTransform( imgRef, minScale, maxScale, onTransform, ); - const [enableTransition, setEnableTransition] = useState(true); - const { rotate, scale, x, y } = transform; + const { isMoving, onMouseDown, onWheel } = useMouseEvent( + imgRef, + movable, + visible, + scaleStep, + transform, + updateTransform, + dispatchZoomChange, + ); + const { isTouching, onTouchStart, onTouchMove, onTouchEnd } = useTouchEvent( + imgRef, + movable, + visible, + minScale, + transform, + updateTransform, + dispatchZoomChange, + ); + const { rotate, scale } = transform; const wrapClassName = classnames({ [`${prefixCls}-moving`]: isMoving, @@ -203,75 +214,6 @@ const Preview: React.FC = props => { } }; - const onMouseUp: React.MouseEventHandler = () => { - if (visible && isMoving) { - setMoving(false); - /** No need to restore the position when the picture is not moved, So as not to interfere with the click */ - const { transformX, transformY } = downPositionRef.current; - const hasChangedPosition = x !== transformX && y !== transformY; - if (!hasChangedPosition) { - return; - } - - const width = imgRef.current.offsetWidth * scale; - const height = imgRef.current.offsetHeight * scale; - // eslint-disable-next-line @typescript-eslint/no-shadow - const { left, top } = imgRef.current.getBoundingClientRect(); - const isRotate = rotate % 180 !== 0; - - const fixState = getFixScaleEleTransPosition( - isRotate ? height : width, - isRotate ? width : height, - left, - top, - ); - - if (fixState) { - updateTransform({ ...fixState }, 'dragRebound'); - } - } - }; - - const onMouseDown: React.MouseEventHandler = event => { - // Only allow main button - if (!movable || event.button !== 0) return; - event.preventDefault(); - event.stopPropagation(); - downPositionRef.current = { - deltaX: event.pageX - transform.x, - deltaY: event.pageY - transform.y, - transformX: transform.x, - transformY: transform.y, - }; - setMoving(true); - }; - - const onMouseMove: React.MouseEventHandler = event => { - if (visible && isMoving) { - updateTransform( - { - x: event.pageX - downPositionRef.current.deltaX, - y: event.pageY - downPositionRef.current.deltaY, - }, - 'move', - ); - } - }; - - const onWheel = (event: React.WheelEvent) => { - if (!visible || event.deltaY == 0) return; - // Scale ratio depends on the deltaY size - const scaleRatio = Math.abs(event.deltaY / 100); - // Limit the maximum scale ratio - const mergedScaleRatio = Math.min(scaleRatio, WHEEL_MAX_SCALE_RATIO); - // Scale the ratio each time - let ratio = BASE_SCALE_RATIO + mergedScaleRatio * scaleStep; - if (event.deltaY > 0) { - ratio = BASE_SCALE_RATIO / ratio; - } - dispatchZoomChange(ratio, 'wheel', event.clientX, event.clientY); - }; - const onKeyDown = (event: KeyboardEvent) => { if (!visible || !showLeftOrRightSwitches) return; @@ -297,39 +239,6 @@ const Preview: React.FC = props => { } }; - useEffect(() => { - let onTopMouseUpListener; - let onTopMouseMoveListener; - let onMouseUpListener; - let onMouseMoveListener; - - if (movable) { - onMouseUpListener = addEventListener(window, 'mouseup', onMouseUp, false); - onMouseMoveListener = addEventListener(window, 'mousemove', onMouseMove, false); - - try { - // Resolve if in iframe lost event - /* istanbul ignore next */ - if (window.top !== window.self) { - onTopMouseUpListener = addEventListener(window.top, 'mouseup', onMouseUp, false); - onTopMouseMoveListener = addEventListener(window.top, 'mousemove', onMouseMove, false); - } - } catch (error) { - /* istanbul ignore next */ - warning(false, `[rc-image] ${error}`); - } - } - - return () => { - onMouseUpListener?.remove(); - onMouseMoveListener?.remove(); - /* istanbul ignore next */ - onTopMouseUpListener?.remove(); - /* istanbul ignore next */ - onTopMouseMoveListener?.remove(); - }; - }, [visible, isMoving, x, y, rotate, movable]); - useEffect(() => { const onKeyDownListener = addEventListener(window, 'keydown', onKeyDown, false); @@ -350,13 +259,17 @@ const Preview: React.FC = props => { transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale3d(${ transform.flipX ? '-' : '' }${scale}, ${transform.flipY ? '-' : ''}${scale}, 1) rotate(${rotate}deg)`, - transitionDuration: !enableTransition && '0s', + transitionDuration: (!enableTransition || isTouching) && '0s', }} fallback={fallback} src={src} onWheel={onWheel} onMouseDown={onMouseDown} onDoubleClick={onDoubleClick} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} + onTouchCancel={onTouchEnd} /> ); diff --git a/src/hooks/useImageTransform.ts b/src/hooks/useImageTransform.ts index d8e0266..dcc24d6 100644 --- a/src/hooks/useImageTransform.ts +++ b/src/hooks/useImageTransform.ts @@ -25,7 +25,21 @@ export type TransformAction = | 'wheel' | 'doubleClick' | 'move' - | 'dragRebound'; + | 'dragRebound' + | 'touchZoom'; + +export type UpdateTransformFunc = ( + newTransform: Partial, + action: TransformAction, +) => void; + +export type DispatchZoomChangeFunc = ( + ratio: number, + action: TransformAction, + centerX?: number, + centerY?: number, + isTouch?: boolean, +) => void; const initialTransform = { x: 0, @@ -54,7 +68,7 @@ export default function useImageTransform( }; /** Direct update transform */ - const updateTransform = (newTransform: Partial, action: TransformAction) => { + const updateTransform: UpdateTransformFunc = (newTransform, action) => { if (frame.current === null) { queue.current = []; frame.current = raf(() => { @@ -76,36 +90,32 @@ export default function useImageTransform( }); }; - /** Scale according to the position of clientX and clientY */ - const dispatchZoomChange = ( - ratio: number, - action: TransformAction, - clientX?: number, - clientY?: number, - ) => { + /** Scale according to the position of centerX and centerY */ + const dispatchZoomChange: DispatchZoomChangeFunc = (ratio, action, centerX?, centerY?, isTouch?) => { const { width, height, offsetWidth, offsetHeight, offsetLeft, offsetTop } = imgRef.current; let newRatio = ratio; let newScale = transform.scale * ratio; if (newScale > maxScale) { - newRatio = maxScale / transform.scale; newScale = maxScale; + newRatio = maxScale / transform.scale; } else if (newScale < minScale) { - newRatio = minScale / transform.scale; - newScale = minScale; + // For mobile interactions, allow scaling down to the minimum scale. + newScale = isTouch ? newScale : minScale; + newRatio = newScale / transform.scale; } /** Default center point scaling */ - const mergedClientX = clientX ?? innerWidth / 2; - const mergedClientY = clientY ?? innerHeight / 2; + const mergedCenterX = centerX ?? innerWidth / 2; + const mergedCenterY = centerY ?? innerHeight / 2; const diffRatio = newRatio - 1; /** Deviation calculated from image size */ const diffImgX = diffRatio * width * 0.5; const diffImgY = diffRatio * height * 0.5; /** The difference between the click position and the edge of the document */ - const diffOffsetLeft = diffRatio * (mergedClientX - transform.x - offsetLeft); - const diffOffsetTop = diffRatio * (mergedClientY - transform.y - offsetTop); + const diffOffsetLeft = diffRatio * (mergedCenterX - transform.x - offsetLeft); + const diffOffsetTop = diffRatio * (mergedCenterY - transform.y - offsetTop); /** Final positioning */ let newX = transform.x - (diffOffsetLeft - diffImgX); let newY = transform.y - (diffOffsetTop - diffImgY); diff --git a/src/hooks/useMouseEvent.ts b/src/hooks/useMouseEvent.ts new file mode 100644 index 0000000..e152f33 --- /dev/null +++ b/src/hooks/useMouseEvent.ts @@ -0,0 +1,136 @@ +import type React from 'react'; +import { useState, useRef, useEffect } from 'react'; +import addEventListener from 'rc-util/lib/Dom/addEventListener'; +import { warning } from 'rc-util/lib/warning'; +import getFixScaleEleTransPosition from '../getFixScaleEleTransPosition'; +import { BASE_SCALE_RATIO, WHEEL_MAX_SCALE_RATIO } from '../previewConfig'; +import type { TransformType, UpdateTransformFunc, DispatchZoomChangeFunc } from './useImageTransform'; + +export default function useMouseEvent( + imgRef: React.MutableRefObject, + movable: boolean, + visible: boolean, + scaleStep: number, + transform: TransformType, + updateTransform: UpdateTransformFunc, + dispatchZoomChange: DispatchZoomChangeFunc, +) { + const { rotate, scale, x, y } = transform; + + const [isMoving, setMoving] = useState(false); + const startPositionInfo = useRef({ + diffX: 0, + diffY: 0, + transformX: 0, + transformY: 0, + }); + + const onMouseDown: React.MouseEventHandler = (event) => { + // Only allow main button + if (!movable || event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + startPositionInfo.current = { + diffX: event.pageX - x, + diffY: event.pageY - y, + transformX: x, + transformY: y, + }; + setMoving(true); + }; + + const onMouseMove: React.MouseEventHandler = (event) => { + if (visible && isMoving) { + updateTransform( + { + x: event.pageX - startPositionInfo.current.diffX, + y: event.pageY - startPositionInfo.current.diffY, + }, + 'move', + ); + } + }; + + const onMouseUp: React.MouseEventHandler = () => { + if (visible && isMoving) { + setMoving(false); + + /** No need to restore the position when the picture is not moved, So as not to interfere with the click */ + const { transformX, transformY } = startPositionInfo.current; + const hasChangedPosition = x !== transformX && y !== transformY; + if (!hasChangedPosition) return; + + const width = imgRef.current.offsetWidth * scale; + const height = imgRef.current.offsetHeight * scale; + // eslint-disable-next-line @typescript-eslint/no-shadow + const { left, top } = imgRef.current.getBoundingClientRect(); + const isRotate = rotate % 180 !== 0; + + const fixState = getFixScaleEleTransPosition( + isRotate ? height : width, + isRotate ? width : height, + left, + top, + ); + + if (fixState) { + updateTransform({ ...fixState }, 'dragRebound'); + } + } + }; + + const onWheel = (event: React.WheelEvent) => { + if (!visible || event.deltaY == 0) return; + // Scale ratio depends on the deltaY size + const scaleRatio = Math.abs(event.deltaY / 100); + // Limit the maximum scale ratio + const mergedScaleRatio = Math.min(scaleRatio, WHEEL_MAX_SCALE_RATIO); + // Scale the ratio each time + let ratio = BASE_SCALE_RATIO + mergedScaleRatio * scaleStep; + if (event.deltaY > 0) { + ratio = BASE_SCALE_RATIO / ratio; + } + dispatchZoomChange(ratio, 'wheel', event.clientX, event.clientY); + }; + + useEffect(() => { + let onTopMouseUpListener; + let onTopMouseMoveListener; + let onMouseUpListener; + let onMouseMoveListener; + + if (movable) { + onMouseUpListener = addEventListener(window, 'mouseup', onMouseUp, false); + onMouseMoveListener = addEventListener(window, 'mousemove', onMouseMove, false); + + try { + // Resolve if in iframe lost event + /* istanbul ignore next */ + if (window.top !== window.self) { + onTopMouseUpListener = addEventListener(window.top, 'mouseup', onMouseUp, false); + onTopMouseMoveListener = addEventListener(window.top, 'mousemove', onMouseMove, false); + } + } catch (error) { + /* istanbul ignore next */ + warning(false, `[rc-image] ${error}`); + } + } + + return () => { + onMouseUpListener?.remove(); + onMouseMoveListener?.remove(); + /* istanbul ignore next */ + onTopMouseUpListener?.remove(); + /* istanbul ignore next */ + onTopMouseMoveListener?.remove(); + }; + }, [visible, isMoving, x, y, rotate, movable]); + + return { + isMoving, + onMouseDown, + onMouseMove, + onMouseUp, + onWheel, + } +}; diff --git a/src/hooks/useTouchEvent.ts b/src/hooks/useTouchEvent.ts new file mode 100644 index 0000000..1de9632 --- /dev/null +++ b/src/hooks/useTouchEvent.ts @@ -0,0 +1,178 @@ +import type React from 'react'; +import { useState, useRef, useEffect } from 'react'; +import addEventListener from 'rc-util/lib/Dom/addEventListener'; +import getFixScaleEleTransPosition from '../getFixScaleEleTransPosition'; +import type { TransformType, UpdateTransformFunc, DispatchZoomChangeFunc } from './useImageTransform'; + +type Point = { + x: number; + y: number; +}; + +type TouchPointInfoType = { + point1: Point; + point2: Point; + eventType: string; +}; + +function getDistance(a: Point, b: Point) { + const x = a.x - b.x; + const y = a.y - b.y; + return Math.hypot(x, y); +} + +function getCenter(oldPoint1: Point, oldPoint2: Point, newPoint1: Point, newPoint2: Point) { + // Calculate the distance each point has moved + const distance1 = getDistance(oldPoint1, newPoint1); + const distance2 = getDistance(oldPoint2, newPoint2); + + // If both distances are 0, return the original points + if (distance1 === 0 && distance2 === 0) { + return [oldPoint1.x, oldPoint1.y]; + } + + // Calculate the ratio of the distances + const ratio = distance1 / (distance1 + distance2); + + // Calculate the new center point based on the ratio + const x = oldPoint1.x + ratio * (oldPoint2.x - oldPoint1.x); + const y = oldPoint1.y + ratio * (oldPoint2.y - oldPoint1.y); + + return [x, y]; +} + +export default function useTouchEvent( + imgRef: React.MutableRefObject, + movable: boolean, + visible: boolean, + minScale: number, + transform: TransformType, + updateTransform: UpdateTransformFunc, + dispatchZoomChange: DispatchZoomChangeFunc, +) { + const { rotate, scale, x, y } = transform; + + const [isTouching, setIsTouching] = useState(false); + const touchPointInfo = useRef({ + point1: { x: 0, y: 0 }, + point2: { x: 0, y: 0 }, + eventType: 'none', + }); + + const updateTouchPointInfo = (values: Partial) => { + touchPointInfo.current = { + ...touchPointInfo.current, + ...values, + }; + }; + + const onTouchStart = (event: React.TouchEvent) => { + if (!movable) return; + event.stopPropagation(); + setIsTouching(true); + + const { touches = [] } = event; + if (touches.length > 1) { + // touch zoom + updateTouchPointInfo({ + point1: { x: touches[0].clientX, y: touches[0].clientY }, + point2: { x: touches[1].clientX, y: touches[1].clientY }, + eventType: 'touchZoom' + }) + } else { + // touch move + updateTouchPointInfo({ + point1: { + x: touches[0].clientX - x, + y: touches[0].clientY - y + }, + eventType: 'move' + }) + } + }; + + const onTouchMove = (event: React.TouchEvent) => { + const { touches = [] } = event; + const { point1, point2, eventType } = touchPointInfo.current; + + if (touches.length > 1 && eventType === 'touchZoom') { + // touch zoom + const newPoint1 = { + x: touches[0].clientX, + y: touches[0].clientY + }; + const newPoint2 = { + x: touches[1].clientX, + y: touches[1].clientY + }; + const [centerX, centerY] = getCenter(point1, point2, newPoint1, newPoint2); + const ratio = getDistance(newPoint1, newPoint2) / getDistance(point1, point2); + + dispatchZoomChange(ratio, 'touchZoom', centerX, centerY, true); + updateTouchPointInfo({ + point1: newPoint1, + point2: newPoint2, + eventType: 'touchZoom' + }); + } else if (eventType === 'move') { + // touch move + updateTransform( + { + x: touches[0].clientX - point1.x, + y: touches[0].clientY - point1.y, + }, + 'move', + ); + updateTouchPointInfo({ eventType: 'move' }); + } + }; + + const onTouchEnd = () => { + if (!visible) return; + + if (isTouching) { + setIsTouching(false); + } + + updateTouchPointInfo({ eventType: 'none' }); + + if (minScale > scale) { + /** When the scaling ratio is less than the minimum scaling ratio, reset the scaling ratio */ + return updateTransform({ x: 0, y: 0, scale: minScale }, 'touchZoom'); + } + + const width = imgRef.current.offsetWidth * scale; + const height = imgRef.current.offsetHeight * scale; + // eslint-disable-next-line @typescript-eslint/no-shadow + const { left, top } = imgRef.current.getBoundingClientRect(); + const isRotate = rotate % 180 !== 0; + + const fixState = getFixScaleEleTransPosition( + isRotate ? height : width, + isRotate ? width : height, + left, + top, + ); + + if (fixState) { + updateTransform({ ...fixState }, 'dragRebound'); + } + }; + + useEffect(() => { + let onTouchMoveListener; + if (visible && movable) { + onTouchMoveListener = addEventListener(window, 'touchmove', (e) => e.preventDefault(), { passive: false }); + } + return () => { + onTouchMoveListener?.remove(); + } + }, [visible, movable]); + + return { + isTouching, + onTouchStart, + onTouchMove, + onTouchEnd, + } +}; diff --git a/tests/previewTouch.test.tsx b/tests/previewTouch.test.tsx new file mode 100644 index 0000000..4edee9a --- /dev/null +++ b/tests/previewTouch.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; +import Image from '../src'; + +describe('Touch Events', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('touch move', () => { + 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)', + // Disable transition during image movement + transitionDuration: '0s', + }); + + fireEvent.touchEnd(previewImgDom); + + act(() => { + jest.runAllTimers(); + }); + + // Correct the position when the image moves out of the current window + expect(previewImgDom).toHaveStyle({ + transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', + transitionDuration: undefined + }); + }); + + it('touch zoom', () => { + 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)', + // Disable transition during image zooming + transitionDuration: '0s', + }); + + fireEvent.touchEnd(previewImgDom); + + expect(previewImgDom).toHaveStyle({ + transform: 'translate3d(-50px, -50px, 0) scale3d(2, 2, 1) rotate(0deg)', + transitionDuration: undefined + }); + }); + + it('Calculation of the center point during image scaling', () => { + const imgEleMock = spyElementPrototypes(HTMLImageElement, { + width: { get: () => 375 }, + height: { get: () => 368 }, + offsetWidth: { get: () => 375 }, + offsetHeight: { get: () => 368 }, + offsetLeft: { get: () => 0 }, + offsetTop: { get: () => 149 }, + }); + + 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: 10, clientY: 10 }, + { clientX: 70, clientY: 70 }, + ] + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(previewImgDom).toHaveStyle({ + transform: 'translate3d(265px, 556px, 0) scale3d(3, 3, 1) rotate(0deg)', + }); + + // Cover the test when the movement distance of both points is 0 + fireEvent.touchMove(previewImgDom, { + touches: [ + { clientX: 10, clientY: 10 }, + { clientX: 70, clientY: 70 }, + ] + }); + + imgEleMock.mockRestore(); + }); + + it('The scale needs to be reset when the image is scaled to less than minScale', () => { + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-image')); + + const previewImgDom = document.querySelector('.rc-image-preview-img'); + + // The scale needs to be reset when the image is scaled to less than minScale + fireEvent.touchStart(previewImgDom, { + touches: [ + { clientX: 20, clientY: 40 }, + { clientX: 20, clientY: 60 }, + ] + }); + fireEvent.touchMove(previewImgDom, { + touches: [ + { clientX: 20, clientY: 45 }, + { clientX: 20, clientY: 55 }, + ] + }); + + act(() => { + jest.runAllTimers(); + }); + + fireEvent.touchEnd(previewImgDom); + + act(() => { + jest.runAllTimers(); + }); + + expect(previewImgDom).toHaveStyle({ + transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', + }); + }); +});