UNPKG

react-image-previewer

Version:
1 lines 106 kB
{"version":3,"sources":["../src/PhotoProvider.tsx","../src/hooks/useMethods.ts","../src/hooks/useSetState.ts","../src/photo-context.ts","../src/PhotoSlider.tsx","../src/variables.ts","../src/utils/limitTarget.ts","../src/hooks/useIsomorphicLayoutEffect.ts","../src/hooks/useAdjacentImages.ts","../src/hooks/useEventListener.ts","../src/hooks/useAnimationVisible.tsx","../src/hooks/useForkedVariable.ts","../src/components/SlidePortal.tsx","../src/components/PreventScroll.tsx","../src/PhotoBox.tsx","../src/utils/getMultipleTouchPosition.ts","../src/utils/edgeHandle.ts","../src/utils/getPositionOnMoveOrScale.ts","../src/utils/getRotateSize.ts","../src/utils/getSuitableImageSize.ts","../src/hooks/useDebounceCallback.ts","../src/hooks/useContinuousTap.ts","../src/hooks/useScrollPosition.ts","../src/hooks/useAnimationOrigin.tsx","../src/hooks/useTargetScale.ts","../src/hooks/useAnimationPosition.ts","../src/hooks/useMountedRef.ts","../src/Photo.tsx","../src/components/Spinner.tsx","../src/PhotoView.tsx","../src/hooks/useInitial.ts"],"sourcesContent":["import React, { forwardRef, useMemo, useRef } from 'react'\nimport type { DataType, PhotoProviderBase } from './types'\nimport useMethods from './hooks/useMethods'\nimport useSetState from './hooks/useSetState'\nimport PhotoContext from './photo-context'\nimport PhotoSlider from './PhotoSlider'\nimport { SlidePortalRef } from './components/SlidePortal'\n\nexport interface PhotoProviderProps extends PhotoProviderBase {\n children: React.ReactNode\n onIndexChange?: (index: number, state: PhotoProviderState) => void\n onVisibleChange?: (visible: boolean, index: number, state: PhotoProviderState) => void\n}\n\ntype PhotoProviderState = {\n images: DataType[]\n visible: boolean\n index: number\n}\n\nconst initialState: PhotoProviderState = {\n images: [],\n visible: false,\n index: 0,\n}\n\nconst PhotoProvider = forwardRef<SlidePortalRef, PhotoProviderProps>(\n ({ children, onIndexChange, onVisibleChange, ...restProps }, ref) => {\n const [state, updateState] = useSetState(initialState)\n const uniqueIdRef = useRef(0)\n const { images, visible, index } = state\n\n const methods = useMethods({\n nextId() {\n return (uniqueIdRef.current += 1)\n },\n update(imageItem: DataType) {\n const currentIndex = images.findIndex(n => n.key === imageItem.key)\n if (currentIndex > -1) {\n const nextImages = images.slice()\n nextImages.splice(currentIndex, 1, imageItem)\n updateState({\n images: nextImages,\n })\n return\n }\n updateState(prev => ({\n images: prev.images.concat(imageItem),\n }))\n },\n remove(key: number) {\n updateState(prev => {\n const nextImages = prev.images.filter(item => item.key !== key)\n const nextEndIndex = nextImages.length - 1\n return {\n images: nextImages,\n index: Math.min(nextEndIndex, index),\n }\n })\n },\n show(key: number) {\n const currentIndex = images.findIndex(item => item.key === key)\n updateState({\n visible: true,\n index: currentIndex,\n })\n if (onVisibleChange) {\n onVisibleChange(true, currentIndex, state)\n }\n },\n })\n\n const fn = useMethods({\n close() {\n updateState({\n visible: false,\n })\n\n if (onVisibleChange) {\n onVisibleChange(false, index, state)\n }\n },\n changeIndex(nextIndex: number) {\n updateState({\n index: nextIndex,\n })\n\n if (onIndexChange) {\n onIndexChange(nextIndex, state)\n }\n },\n })\n\n const value = useMemo(() => ({ ...state, ...methods }), [state, methods])\n\n return (\n <PhotoContext.Provider value={value}>\n {children}\n <PhotoSlider\n ref={ref}\n images={images}\n visible={visible}\n index={index}\n onIndexChange={fn.changeIndex}\n onClose={fn.close}\n {...restProps}\n />\n </PhotoContext.Provider>\n )\n },\n)\nPhotoProvider.displayName = 'PhotoProvider'\nexport default PhotoProvider\n","import { useRef } from 'react'\n\n/**\n * Hook of persistent methods\n */\nexport default function useMethods<T extends Record<string, (...args: any[]) => any>>(fn: T) {\n const { current } = useRef({\n fn,\n curr: undefined as T | undefined,\n })\n current.fn = fn\n\n if (!current.curr) {\n const curr = Object.create(null)\n Object.keys(fn).forEach(key => {\n curr[key] = (...args: unknown[]) => current.fn[key].call(current.fn, ...args)\n })\n current.curr = curr\n }\n\n return current.curr as T\n}\n","import { useReducer } from 'react'\n\nexport default function useSetState<S extends Record<string, any>>(initialState: S) {\n return useReducer(\n (state: S, action: Partial<S> | ((state: S) => Partial<S>)) => ({\n ...state,\n ...(typeof action === 'function' ? action(state) : action),\n }),\n initialState,\n )\n}\n","import { createContext } from 'react'\nimport type { DataType } from './types'\n\nexport type UpdateItemType = (dataType: DataType) => void\n\nexport interface PhotoContextType {\n show: (key: number) => void\n update: UpdateItemType\n remove: (key: number) => void\n nextId: () => number\n}\n\nexport default createContext<PhotoContextType>(undefined as unknown as PhotoContextType)\n","import _styled2styled_components from 'styled-components';\n const _styled2 = typeof _styled2styled_components.default === 'undefined' ? _styled2styled_components : _styled2styled_components.default;;\nimport _styledstyled_components from 'styled-components';\n const _styled = typeof _styledstyled_components.default === 'undefined' ? _styledstyled_components : _styledstyled_components.default;;\nimport { css as _css } from \"styled-components\";\nimport React, { forwardRef, useMemo, useRef, useState } from 'react';\nimport { defaultEasing, defaultSpeed, defaultOpacity, horizontalOffset, maxMoveOffset, defaultDragEasing } from './variables';\nimport isTouchDevice from './utils/isTouchDevice';\nimport { limitNumber } from './utils/limitTarget';\nimport useIsomorphicLayoutEffect from './hooks/useIsomorphicLayoutEffect';\nimport useAdjacentImages from './hooks/useAdjacentImages';\nimport useSetState from './hooks/useSetState';\nimport useEventListener from './hooks/useEventListener';\nimport useAnimationVisible from './hooks/useAnimationVisible';\nimport useMethods from './hooks/useMethods';\nimport SlidePortal from './components/SlidePortal';\nimport PreventScroll from './components/PreventScroll';\nimport PhotoBox from './PhotoBox';\nimport { keyframes } from 'styled-components';\nconst initialState = {\n loading: false,\n x: 0,\n y: 0,\n touched: false,\n pause: false,\n lastCX: undefined,\n lastCY: undefined,\n bg: undefined,\n lastBg: undefined,\n overlay: true,\n minimal: true,\n scale: 1,\n rotate: 0\n};\nconst fade = keyframes([\"from{opacity:0;}to{opacity:1;}\"]);\nconst PhotoSlider = forwardRef((props, ref) => {\n const {\n mode: modeProp = 'slide',\n loop = 3,\n speed: speedFn,\n easing: easingFn,\n photoClosable,\n maskClosable = true,\n maskOpacity = defaultOpacity,\n pullClosable = true,\n overlayRender,\n className,\n maskClassName,\n photoClassName,\n photoWrapClassName,\n loadingElement,\n brokenElement,\n images,\n index: controlledIndex = 0,\n onIndexChange: controlledIndexChange,\n visible,\n onClose,\n afterClose,\n portalContainer,\n enableMouseZoom = true\n } = props;\n const isDragMode = useMemo(() => {\n if (isTouchDevice) return false;\n return modeProp === 'drag';\n }, [modeProp]);\n const [state, updateState] = useSetState(initialState);\n const [innerIndex, updateInnerIndex] = useState(0);\n const {\n loading,\n x,\n y,\n touched,\n pause,\n lastCX,\n lastCY,\n bg = maskOpacity,\n lastBg,\n overlay,\n minimal,\n scale,\n rotate,\n onScale,\n onRotate\n } = state;\n // 受控 index\n const isControlled = props.hasOwnProperty('index');\n const index = isControlled ? controlledIndex : innerIndex;\n const onIndexChange = isControlled ? controlledIndexChange : updateInnerIndex;\n // 内部虚拟 index\n const virtualIndexRef = useRef(index);\n\n // 当前图片\n const imageLength = images.length;\n const currentImage = images[index];\n\n // 是否开启\n // noinspection SuspiciousTypeOfGuard\n const enableLoop = typeof loop === 'boolean' ? loop : imageLength > loop;\n\n // 显示动画处理\n const [realVisible, activeAnimation, onAnimationEnd] = useAnimationVisible(visible, afterClose);\n useIsomorphicLayoutEffect(() => {\n // 显示弹出层,修正正确的指向\n if (realVisible) {\n updateState({\n pause: true,\n x: index * -(innerWidth + horizontalOffset)\n });\n virtualIndexRef.current = index;\n return;\n }\n // 关闭后清空状态\n updateState(initialState);\n }, [realVisible]);\n const {\n close,\n changeIndex\n } = useMethods({\n close(evt) {\n if (onRotate) {\n onRotate(0);\n }\n updateState({\n overlay: true,\n // 记录当前关闭时的透明度\n lastBg: bg\n });\n onClose(evt);\n },\n changeIndex(nextIndex, isPause = false) {\n // 当前索引\n const currentIndex = enableLoop ? virtualIndexRef.current + (nextIndex - index) : nextIndex;\n const max = imageLength - 1;\n // 虚拟 index\n // 非循环模式,限制区间\n const limitIndex = limitNumber(currentIndex, 0, max);\n const nextVirtualIndex = enableLoop ? currentIndex : limitIndex;\n // 单个屏幕宽度\n const singlePageWidth = innerWidth + horizontalOffset;\n updateState({\n touched: false,\n lastCX: undefined,\n lastCY: undefined,\n x: -singlePageWidth * nextVirtualIndex,\n pause: isPause\n });\n virtualIndexRef.current = nextVirtualIndex;\n // 更新真实的 index\n const realLoopIndex = nextIndex < 0 ? max : nextIndex > max ? 0 : nextIndex;\n if (onIndexChange) {\n onIndexChange(enableLoop ? realLoopIndex : limitIndex);\n }\n }\n });\n useEventListener('keydown', evt => {\n if (visible) {\n switch (evt.key) {\n case 'ArrowLeft':\n changeIndex(index - 1, true);\n break;\n case 'ArrowRight':\n changeIndex(index + 1, true);\n break;\n case 'Escape':\n close();\n break;\n }\n }\n });\n function handlePhotoTap(closeable) {\n return closeable ? close() : updateState({\n overlay: !overlay\n });\n }\n function handleResize() {\n updateState({\n x: -(innerWidth + horizontalOffset) * index,\n lastCX: undefined,\n lastCY: undefined,\n pause: isDragMode ? false : true\n });\n virtualIndexRef.current = index;\n }\n function handleReachVerticalMove(clientY, nextScale) {\n if (lastCY === undefined) {\n updateState({\n touched: true,\n lastCY: clientY,\n bg,\n minimal: true\n });\n return;\n }\n const opacity = maskOpacity === null ? null : limitNumber(maskOpacity, 0.01, maskOpacity - Math.abs(clientY - lastCY) / 100 / 4);\n updateState({\n touched: true,\n lastCY,\n bg: nextScale === 1 && !isDragMode ? opacity : maskOpacity,\n minimal: nextScale === 1\n });\n }\n function handleReachHorizontalMove(clientX) {\n if (lastCX === undefined) {\n updateState({\n touched: true,\n lastCX: clientX,\n x,\n pause: false\n });\n return;\n }\n const originOffsetClientX = clientX - lastCX;\n let offsetClientX = originOffsetClientX;\n\n // 第一张和最后一张超出距离减半\n if (!enableLoop && (index === 0 && originOffsetClientX > 0 || index === imageLength - 1 && originOffsetClientX < 0)) {\n offsetClientX = originOffsetClientX / 2;\n }\n updateState({\n touched: true,\n lastCX: lastCX,\n x: -(innerWidth + horizontalOffset) * virtualIndexRef.current + offsetClientX,\n pause: false\n });\n }\n function handleReachMove(reachPosition, clientX, clientY, nextScale) {\n if (isDragMode) {\n // handleReachDragMove\n } else if (reachPosition === 'x') {\n handleReachHorizontalMove(clientX);\n } else if (reachPosition === 'y') {\n handleReachVerticalMove(clientY, nextScale);\n }\n }\n function handleReachUp(clientX, clientY) {\n const offsetClientX = clientX - (lastCX ?? clientX);\n const offsetClientY = clientY - (lastCY ?? clientY);\n if (isDragMode) {\n updateState({\n touched: false\n });\n return;\n }\n let willClose = false;\n // 下一张\n if (offsetClientX < -maxMoveOffset) {\n changeIndex(index + 1);\n return;\n }\n // 上一张\n if (offsetClientX > maxMoveOffset) {\n changeIndex(index - 1);\n return;\n }\n const singlePageWidth = innerWidth + horizontalOffset;\n // 当前偏移\n const currentTranslateX = -singlePageWidth * virtualIndexRef.current;\n if (Math.abs(offsetClientY) > 100 && minimal && pullClosable) {\n willClose = true;\n close();\n }\n updateState({\n touched: false,\n x: currentTranslateX,\n lastCX: undefined,\n lastCY: undefined,\n bg: maskOpacity,\n overlay: willClose ? true : overlay\n });\n }\n // 截取相邻的图片\n const adjacentImages = useAdjacentImages(images, index, enableLoop);\n if (!realVisible) {\n return null;\n }\n const currentOverlayVisible = overlay && !activeAnimation;\n // 关闭过程中使用下拉保存的透明度\n const currentOpacity = visible ? bg : lastBg;\n // 覆盖物参数\n const overlayParams = onScale && onRotate && {\n loading,\n loop: enableLoop,\n mode: isDragMode ? 'drag' : 'slide',\n images,\n index,\n visible,\n onClose: close,\n onIndexChange: changeIndex,\n overlayVisible: currentOverlayVisible,\n overlay: currentImage && currentImage.overlay,\n scale,\n rotate,\n onScale,\n onRotate\n };\n // 动画时间\n const currentSpeed = speedFn ? speedFn(activeAnimation) : defaultSpeed;\n const currentEasing = easingFn ? easingFn(activeAnimation) : isDragMode ? defaultDragEasing : defaultEasing;\n const slideSpeed = speedFn ? speedFn(3) : defaultSpeed + 200;\n const slideEasing = easingFn ? easingFn(3) : defaultEasing;\n return <SlidePortal ref={ref} className={className} role=\"dialog\" onClick={e => e.stopPropagation()} container={portalContainer}>\n {visible && <PreventScroll />}\n <_StyledDiv className={maskClassName} style={{\n background: currentOpacity ? `rgba(0, 0, 0, ${currentOpacity})` : undefined,\n transitionTimingFunction: currentEasing,\n transitionDuration: `${touched ? 0 : currentSpeed}ms`,\n animationDuration: `${currentSpeed}ms`\n }} onAnimationEnd={onAnimationEnd} $_css2={[{\n \"position\": \"absolute\",\n \"left\": \"0px\",\n \"top\": \"0px\",\n \"zIndex\": \"-1\",\n \"height\": \"100%\",\n \"width\": \"100%\",\n \"--tw-bg-opacity\": \"1\",\n \"backgroundColor\": \"rgb(0 0 0 / var(--tw-bg-opacity))\",\n \"transitionProperty\": \"background-color\",\n \"transitionTimingFunction\": \"cubic-bezier(0.4, 0, 0.2, 1)\",\n \"transitionDuration\": \"150ms\"\n }, (activeAnimation === 1 || activeAnimation === 2) && {\n \"opacity\": \"0\"\n }, activeAnimation === 1 && _css([\"animation:\", \" linear both;\"], fade), activeAnimation === 2 && _css([\"animation:\", \" linear both reverse;\"], fade)]} />\n {adjacentImages.map((item, currentIndex) => {\n // 截取之前的索引位置\n const nextIndex = !enableLoop && index === 0 ? index + currentIndex : virtualIndexRef.current - 1 + currentIndex;\n const isActive = !item.isCloned && (currentImage && currentImage.key) === item.key;\n return <PhotoBox key={enableLoop ? `${item.key}/${item.src}/${nextIndex}` : item.key} item={item} speed={currentSpeed} easing={currentEasing} visible={visible} onReachMove={handleReachMove} onReachUp={handleReachUp} onPhotoTap={() => handlePhotoTap(photoClosable)} onMaskTap={() => handlePhotoTap(maskClosable)} isDragMode={isDragMode} wrapClassName={photoWrapClassName} className={photoClassName} style={{\n opacity: isDragMode ? isActive ? 1 : 0 : undefined,\n left: `${(innerWidth + horizontalOffset) * nextIndex}px`,\n transform: `translate3d(${x}px, ${y}px, 0)`,\n transition: touched || pause ? undefined : `transform ${slideSpeed}ms ${slideEasing}`\n }} loadingElement={loadingElement} brokenElement={brokenElement} onPhotoResize={handleResize} isActive={isActive} expose={updateState} enableMouseZoom={enableMouseZoom} />;\n })}\n {overlayRender && overlayParams && <_StyledDiv2 $_css3={[!currentOverlayVisible && {\n \"opacity\": \"0\"\n }]}>{overlayRender(overlayParams)}</_StyledDiv2>}\n </SlidePortal>;\n});\nPhotoSlider.displayName = 'PhotoSlider';\nexport default PhotoSlider;\nvar _StyledDiv = _styled(\"div\").withConfig({\n componentId: \"sc-12i02f7-0\"\n})([\"\", \"\"], p => p.$_css2);\nvar _StyledDiv2 = _styled(\"div\").withConfig({\n componentId: \"sc-12i02f7-1\"\n})([\"\", \"\"], p => p.$_css3);","/**\n * 最大触摸时间\n */\nexport const maxTouchTime = 200\n\n/**\n * 默认动画速度\n */\nexport const defaultSpeed = 400\n\n/**\n * 默认动画函数\n */\nexport const defaultEasing = 'cubic-bezier(0.25, 0.8, 0.25, 1)'\n\n/**\n * 默认拖拽动画函数\n */\nexport const defaultDragEasing = 'ease'\n\n/**\n * 最大滑动切换图片距离\n */\nexport const maxMoveOffset = 40\n\n/**\n * 图片的间隔\n */\nexport const horizontalOffset = 20\n\n/**\n * 最小初始响应距离\n */\nexport const minStartTouchOffset = 20\n\n/**\n * 默认背景透明度\n */\nexport const defaultOpacity = 1\n\n/**\n * 最小缩放度\n */\nexport const minScale = 1\n\n/**\n * 拖拽模式最小缩放度\n */\nexport const minDragScale = 0.1\n\n/**\n * 最大缩放度(若图片足够大,则会超出)\n */\nexport const maxScale = 6\n\n/**\n * 最小长图模式比例\n */\nexport const longModeRatio = 3\n\n/**\n * 缩放弹性缓冲\n */\nexport const scaleBuffer = 0.2\n\n/**\n * 最大等待动画时间\n */\nexport const maxWaitAnimationTime = 250\n","import { maxScale, minDragScale, minScale } from '../variables'\n\nexport const limitNumber = (value: number, min: number, max: number) => {\n return Math.max(Math.min(value, max), min)\n}\n\n/**\n * 限制最大/最小缩放\n */\nexport const limitScale = (\n scale: number,\n isDragMode = false,\n max: number = 0,\n buffer: number = 0,\n) => {\n return limitNumber(\n scale,\n isDragMode ? minDragScale : minScale * (1 - buffer),\n Math.max(maxScale, max) * (1 + buffer),\n )\n}\n","import { useEffect, useLayoutEffect } from 'react'\n\nconst isSSR =\n typeof window === 'undefined' || /ServerSideRendering/.test(navigator && navigator.userAgent)\n\nexport default isSSR ? useEffect : useLayoutEffect\n","import { useMemo } from 'react'\nimport type { DataType } from '../types'\n\ninterface AdjacentDataType extends DataType {\n /**\n * 是否是克隆的\n */\n isCloned?: boolean\n}\n\n/**\n * 截取相邻三张图片\n */\nexport default function useAdjacentImages(\n images: DataType[],\n index: number,\n loop: boolean,\n): AdjacentDataType[] {\n return useMemo(() => {\n const imageLength = images.length\n if (loop) {\n const connected = images.concat(images).concat(images)\n const currentImage = connected[imageLength + index]\n const sliceImages: AdjacentDataType[] = connected.slice(\n imageLength + index - 1,\n imageLength + index + 2,\n )\n\n const adjacentImages: AdjacentDataType[] = []\n\n for (let i = 0; i < sliceImages.length; i++) {\n const image = sliceImages[i]\n if (i !== 1 && image.key === currentImage.key) {\n adjacentImages.push({ ...image, isCloned: true })\n } else {\n adjacentImages.push(image)\n }\n }\n return adjacentImages\n }\n return images.slice(Math.max(index - 1, 0), Math.min(index + 2, imageLength + 1))\n }, [images, index, loop])\n}\n","import { useEffect, useRef } from 'react'\n\nexport default function useEventListener<K extends keyof WindowEventMap>(\n type: K | undefined,\n fn: (evt: WindowEventMap[K]) => void,\n options?: AddEventListenerOptions,\n) {\n const latest = useRef(fn)\n latest.current = fn\n\n useEffect(() => {\n function wrapper(evt: WindowEventMap[K]) {\n latest.current(evt)\n }\n if (type) {\n window.addEventListener(type, wrapper, options)\n }\n return () => {\n if (type) {\n window.removeEventListener(type, wrapper)\n }\n }\n }, [type])\n}\n","import { useReducer, useRef } from 'react'\nimport type { ActiveAnimationType } from '../types'\nimport useForkedVariable from './useForkedVariable'\n\n/**\n * 动画关闭处理真实关闭状态\n * 通过 onAnimationEnd 回调实现 leaveCallback\n */\nexport default function useAnimationVisible(\n visible: boolean | undefined,\n afterClose?: () => void,\n): [\n realVisible: boolean | undefined,\n activeAnimation: ActiveAnimationType,\n onAnimationEnd: () => void,\n] {\n const [, handleRender] = useReducer(c => !c, false)\n\n const activeAnimation = useRef<ActiveAnimationType>(0)\n\n // 可见状态分支\n const [realVisible, modifyRealVisible] = useForkedVariable(visible, modify => {\n // 可见状态:设置进入动画\n if (visible) {\n modify(visible)\n activeAnimation.current = 1\n } else {\n activeAnimation.current = 2\n }\n })\n\n function onAnimationEnd() {\n // 动画结束后触发渲染\n handleRender()\n // 结束动画:设置隐藏状态\n if (activeAnimation.current === 2) {\n modifyRealVisible(false)\n // 触发隐藏回调\n if (afterClose) {\n afterClose()\n }\n }\n // 重置状态\n activeAnimation.current = 0\n }\n\n return [\n /**\n * 真实可见状态\n */\n realVisible,\n /**\n * 正在进行的动画\n */\n activeAnimation.current,\n /**\n * 动画结束后回调\n */\n onAnimationEnd,\n ]\n}\n","import { useRef, useMemo } from 'react'\n\n/**\n * 逻辑分叉变量处理\n * 此 hook 不触发额外渲染\n */\nexport default function useForkedVariable<T>(\n initial: T,\n updater: (modify: (variable: T) => void) => void,\n) {\n // 初始分叉变量\n const forkedRef = useRef(initial)\n\n function modify(next: T) {\n forkedRef.current = next\n }\n\n useMemo(() => {\n // 参数变化之后同步内部分叉变量\n updater(modify)\n }, [initial])\n\n return [forkedRef.current, modify] as const\n}\n","import _styledstyled_components from 'styled-components';\n const _styled = typeof _styledstyled_components.default === 'undefined' ? _styledstyled_components : _styledstyled_components.default;;\nimport React, { forwardRef } from 'react';\nimport { createPortal } from 'react-dom';\nconst SlidePortal = forwardRef(({\n container = document.body,\n ...rest\n}, ref) => {\n return createPortal(<_StyledDiv ref={ref} {...rest} />, container);\n});\nSlidePortal.displayName = 'SlidePortal';\nexport default SlidePortal;\nvar _StyledDiv = _styled(\"div\").withConfig({\n componentId: \"sc-1ab9qfp-0\"\n})({\n \"position\": \"fixed\",\n \"top\": \"0px\",\n \"left\": \"0px\",\n \"zIndex\": \"50\",\n \"height\": \"100%\",\n \"width\": \"100%\",\n \"touchAction\": \"none\",\n \"overflow\": \"hidden\"\n});","import { useEffect } from 'react'\n\nexport default function PreventScroll() {\n useEffect(() => {\n const { style } = document.body\n const lastOverflow = style.overflow\n style.overflow = 'hidden'\n\n return () => {\n style.overflow = lastOverflow\n }\n }, [])\n\n return null\n}\n","import _styledstyled_components from 'styled-components';\n const _styled = typeof _styledstyled_components.default === 'undefined' ? _styledstyled_components : _styledstyled_components.default;;\nimport React, { useRef } from 'react';\nimport isTouchDevice from './utils/isTouchDevice';\nimport getMultipleTouchPosition from './utils/getMultipleTouchPosition';\nimport getPositionOnMoveOrScale from './utils/getPositionOnMoveOrScale';\nimport { getReachType, computePositionEdge } from './utils/edgeHandle';\nimport getRotateSize from './utils/getRotateSize';\nimport { limitScale } from './utils/limitTarget';\nimport getSuitableImageSize from './utils/getSuitableImageSize';\nimport useIsomorphicLayoutEffect from './hooks/useIsomorphicLayoutEffect';\nimport { minDragScale, minScale, minStartTouchOffset, scaleBuffer } from './variables';\nimport useSetState from './hooks/useSetState';\nimport useMethods from './hooks/useMethods';\nimport useDebounceCallback from './hooks/useDebounceCallback';\nimport useEventListener from './hooks/useEventListener';\nimport useContinuousTap from './hooks/useContinuousTap';\nimport useScrollPosition from './hooks/useScrollPosition';\nimport useAnimationPosition from './hooks/useAnimationPosition';\nimport useMountedRef from './hooks/useMountedRef';\nimport Photo from './Photo';\nconst initialState = {\n // 真实宽度\n naturalWidth: undefined,\n // 真实高度\n naturalHeight: undefined,\n // 宽度\n width: undefined,\n // 高度\n height: undefined,\n // 加载成功状态\n loaded: undefined,\n // 破碎状态\n broken: false,\n // 图片 X 偏移量\n x: 0,\n // 图片 y 偏移量\n y: 0,\n // 图片处于触摸的状态\n touched: false,\n // 背景处于触摸状态\n maskTouched: false,\n // 旋转状态\n rotate: 0,\n // 放大缩小\n scale: 1,\n // 触摸开始时 x 原始坐标\n CX: 0,\n // 触摸开始时 y 原始坐标\n CY: 0,\n // 触摸开始时图片 x 偏移量\n lastX: 0,\n // 触摸开始时图片 y 偏移量\n lastY: 0,\n // 上一个触摸状态 x 原始坐标\n lastCX: 0,\n // 上一个触摸状态 y 原始坐标\n lastCY: 0,\n // 上一个触摸状态的 scale\n lastScale: 1,\n // 触摸开始时时间\n touchTime: 0,\n // 多指触控间距\n touchLength: 0,\n // 是否暂停 transition\n pause: true,\n // 停止 Raf\n stopRaf: true,\n // 当前边缘触发状态\n reach: undefined\n};\nconst StyledView = _styled.div.withConfig({\n componentId: \"sc-1mzwirp-0\"\n})([\"\", \" direction:ltr\"], {\n \"position\": \"absolute\",\n \"top\": \"0px\",\n \"right\": \"0px\",\n \"bottom\": \"0px\",\n \"left\": \"0px\",\n \"width\": \"100%\",\n \"touchAction\": \"none\"\n});\nconst StyledWrap = _styled(StyledView).withConfig({\n componentId: \"sc-1mzwirp-1\"\n})(({\n isDragMode\n}) => [{\n \"zIndex\": \"10\"\n}, !isDragMode && {\n \"overflow\": \"hidden\"\n}]);\nconst StyledBox = _styled(StyledView).withConfig({\n componentId: \"sc-1mzwirp-2\"\n})([\"transform-origin:left top;\"]);\nexport default function PhotoBox({\n isDragMode = false,\n item: {\n src,\n render,\n width: customWidth = 0,\n height: customHeight = 0,\n originRef\n },\n visible,\n speed,\n easing,\n wrapClassName,\n className,\n style,\n loadingElement,\n brokenElement,\n enableMouseZoom = true,\n onPhotoTap,\n onMaskTap,\n onReachMove,\n onReachUp,\n onPhotoResize,\n isActive,\n expose\n}) {\n const [state, updateState] = useSetState(initialState);\n const initialTouchRef = useRef(0);\n const mounted = useMountedRef();\n const {\n naturalWidth = customWidth,\n naturalHeight = customHeight,\n width = customWidth,\n height = customHeight,\n loaded = !src,\n broken,\n x,\n y,\n touched,\n stopRaf,\n maskTouched,\n rotate,\n scale,\n CX,\n CY,\n lastX,\n lastY,\n lastCX,\n lastCY,\n lastScale,\n touchTime,\n touchLength,\n pause,\n reach\n } = state;\n const fn = useMethods({\n onScale: current => onScale(limitScale(current, isDragMode)),\n onRotate(current) {\n if (rotate !== current) {\n expose({\n rotate: current\n });\n updateState({\n rotate: current,\n ...getSuitableImageSize(isDragMode, naturalWidth, naturalHeight, current),\n scale\n });\n }\n }\n });\n\n // 默认为屏幕中心缩放\n function onScale(current, clientX, clientY) {\n if (scale !== current) {\n expose({\n scale: current\n });\n updateState({\n scale: current,\n ...getPositionOnMoveOrScale(isDragMode, x, y, width, height, naturalWidth, naturalHeight, scale, current, clientX, clientY),\n ...((isDragMode ? current < minDragScale : current <= minScale) ? {\n x: 0,\n y: 0\n } : {})\n });\n }\n }\n const handleMove = useDebounceCallback((nextClientX, nextClientY, currentTouchLength = 0) => {\n if ((touched || maskTouched) && isActive) {\n // 通过旋转调换宽高\n const [currentWidth, currentHeight] = getRotateSize(rotate, width, height);\n // 单指最小缩放下,以初始移动距离来判断意图\n if (currentTouchLength === 0 && initialTouchRef.current === 0) {\n const isStillX = Math.abs(nextClientX - CX) <= minStartTouchOffset;\n const isStillY = Math.abs(nextClientY - CY) <= minStartTouchOffset;\n // 初始移动距离不足\n if (isStillX && isStillY) {\n // 方向记录上次移动距离,以便平滑过渡\n updateState({\n lastCX: nextClientX,\n lastCY: nextClientY\n });\n return;\n }\n // 设置响应状态\n initialTouchRef.current = !isStillX ? 1 : nextClientY > CY ? 3 : 2;\n }\n const offsetX = nextClientX - lastCX;\n const offsetY = nextClientY - lastCY;\n // 边缘触发状态\n let currentReach = undefined;\n if (currentTouchLength === 0) {\n // 边缘超出状态\n const [horizontalCloseEdge] = computePositionEdge(offsetX + lastX, scale, currentWidth, innerWidth);\n const [verticalCloseEdge] = computePositionEdge(offsetY + lastY, scale, currentHeight, innerHeight);\n // 边缘触发检测\n currentReach = getReachType(initialTouchRef.current, horizontalCloseEdge, verticalCloseEdge, reach);\n // 接触边缘\n if (currentReach !== undefined) {\n onReachMove(currentReach, nextClientX, nextClientY, scale);\n }\n }\n // 横向边缘触发、背景触发禁用当前滑动\n if (currentReach === 'x' || maskTouched || isDragMode) {\n const state = {\n reach: 'x'\n };\n if (isDragMode) {\n state.x = lastX + offsetX;\n state.y = lastY + offsetY;\n state.pause = false;\n }\n updateState(state);\n return;\n }\n // 目标倍数\n const toScale = limitScale(scale + (currentTouchLength - touchLength) / 100 / 2 * scale, isDragMode, naturalWidth / width, scaleBuffer);\n // 导出变量\n expose({\n scale: toScale\n });\n updateState({\n touchLength: currentTouchLength,\n reach: currentReach,\n scale: toScale,\n ...getPositionOnMoveOrScale(isDragMode, x, y, width, height, naturalWidth, naturalHeight, scale, toScale, nextClientX, nextClientY, offsetX, offsetY)\n });\n }\n }, {\n maxWait: 8\n });\n function updateRaf(position) {\n if (stopRaf || touched) {\n return false;\n }\n if (mounted.current) {\n // 下拉关闭时可以有动画\n updateState({\n ...position,\n pause: visible\n });\n }\n return mounted.current;\n }\n const slideToPosition = useScrollPosition(nextX => updateRaf({\n x: nextX\n }), nextY => updateRaf({\n y: nextY\n }), nextScale => {\n if (mounted.current) {\n expose({\n scale: nextScale\n });\n updateState({\n scale: nextScale\n });\n }\n return !touched && mounted.current;\n });\n const handlePhotoTap = useContinuousTap(onPhotoTap, (currentClientX, currentClientY) => {\n if (!reach && !isDragMode) {\n // 若图片足够大,则放大适应的倍数\n const endScale = scale !== 1 ? 1 : Math.max(2, naturalWidth / width);\n onScale(endScale, currentClientX, currentClientY);\n }\n });\n function handleUp(nextClientX, nextClientY) {\n // 重置响应状态\n initialTouchRef.current = 0;\n if ((touched || maskTouched) && isActive) {\n updateState({\n touched: false,\n maskTouched: false,\n pause: false,\n stopRaf: false,\n reach: undefined\n });\n const safeScale = limitScale(scale, isDragMode, naturalWidth / width);\n // Go\n if (!isDragMode) {\n slideToPosition(x, y, lastX, lastY, width, height, naturalWidth, naturalHeight, scale, safeScale, lastScale, rotate, touchTime);\n }\n onReachUp(nextClientX, nextClientY);\n // 触发 Tap 事件\n if (CX === nextClientX && CY === nextClientY) {\n if (touched) {\n handlePhotoTap(nextClientX, nextClientY);\n return;\n }\n if (maskTouched) {\n onMaskTap(nextClientX, nextClientY);\n }\n }\n }\n }\n useEventListener(isTouchDevice ? undefined : 'mousemove', e => {\n e.preventDefault();\n handleMove(e.clientX, e.clientY);\n });\n useEventListener(isTouchDevice ? undefined : 'mouseup', e => {\n handleUp(e.clientX, e.clientY);\n });\n useEventListener(isTouchDevice ? 'touchmove' : undefined, e => {\n e.preventDefault();\n const position = getMultipleTouchPosition(e);\n handleMove(...position);\n }, {\n passive: false\n });\n useEventListener(isTouchDevice ? 'touchend' : undefined, ({\n changedTouches\n }) => {\n const touch = changedTouches[0];\n handleUp(touch.clientX, touch.clientY);\n }, {\n passive: false\n });\n useEventListener('resize', useDebounceCallback(() => {\n if (loaded && !touched) {\n if (isDragMode) {\n updateState({\n x: 0,\n y: 0\n });\n } else updateState(getSuitableImageSize(isDragMode, naturalWidth, naturalHeight, rotate));\n onPhotoResize();\n }\n }, {\n maxWait: 8\n }));\n useIsomorphicLayoutEffect(() => {\n if (isActive) {\n expose({\n scale,\n rotate,\n loading: !loaded,\n ...fn\n });\n }\n }, [isActive]);\n function handlePhotoLoad(params) {\n const state = params.loaded ? getSuitableImageSize(isDragMode, params.naturalWidth || 0, params.naturalHeight || 0, rotate) : {};\n if (isDragMode && params.loaded && isActive) expose({\n scale: state.scale,\n loading: false\n });\n updateState({\n ...params,\n ...state\n });\n }\n function handleStart(currentClientX, currentClientY, currentTouchLength = 0) {\n updateState({\n touched: true,\n CX: currentClientX,\n CY: currentClientY,\n lastCX: currentClientX,\n lastCY: currentClientY,\n lastX: x,\n lastY: y,\n lastScale: scale,\n touchLength: currentTouchLength,\n touchTime: Date.now()\n });\n }\n function handleWheel(e) {\n if (!reach && enableMouseZoom) {\n // 限制最大倍数和最小倍数\n const delta = isDragMode ? e.deltaY / 100 / 16 : e.deltaY / 100 / 2;\n const toScale = limitScale(scale - delta, isDragMode, naturalWidth / width);\n updateState({\n stopRaf: true\n });\n onScale(toScale, e.clientX, e.clientY);\n }\n }\n function handleMaskStart(e) {\n updateState({\n maskTouched: true,\n CX: e.clientX,\n CY: e.clientY,\n lastX: x,\n lastY: y\n });\n }\n function handleTouchStart(e) {\n e.stopPropagation();\n handleStart(...getMultipleTouchPosition(e));\n }\n function handleMouseDown(e) {\n e.stopPropagation();\n if (e.button === 0) {\n handleStart(e.clientX, e.clientY, 0);\n }\n }\n\n // 计算位置\n const [translateX, translateY, currentWidth, currentHeight, currentScale, opacity, easingMode, FIT] = useAnimationPosition(isDragMode, visible, originRef, loaded, x, y, width, height, naturalWidth, naturalHeight, scale, speed, isPause => updateState({\n pause: isPause\n }));\n // 图片 objectFit 渐变时间\n const transitionLayoutTime = easingMode < 4 ? speed / 2 : easingMode > 4 ? speed : 0;\n const transitionCSS = `transform ${speed}ms ${easing}`;\n const attrs = {\n className,\n onMouseDown: isTouchDevice ? undefined : handleMouseDown,\n onTouchStart: isTouchDevice ? handleTouchStart : undefined,\n onWheel: handleWheel,\n style: {\n width: currentWidth,\n height: currentHeight,\n opacity,\n objectFit: easingMode === 4 ? undefined : FIT,\n transform: rotate ? `rotate(${rotate}deg)` : undefined,\n transition:\n // 初始状态无渐变\n easingMode > 2 ? isDragMode ? `${transitionCSS}, width ${transitionLayoutTime}ms ${easing}, height ${transitionLayoutTime}ms ${easing}` : `${transitionCSS}, opacity ${speed}ms ease, height ${transitionLayoutTime}ms ${easing}` : undefined\n }\n };\n return <StyledWrap isDragMode={isDragMode} className={wrapClassName} style={style} onMouseDown={!isTouchDevice && isActive ? handleMaskStart : undefined} onTouchStart={isTouchDevice && isActive ? e => handleMaskStart(e.touches[0]) : undefined}>\n <StyledBox style={{\n transform: isDragMode ? undefined : `matrix(${currentScale}, 0, 0, ${currentScale}, ${translateX}, ${translateY})`,\n left: isDragMode ? translateX : undefined,\n top: isDragMode ? translateY : undefined,\n transition: touched || pause ? undefined : isDragMode ? `left ${transitionLayoutTime}ms ${easing}, top ${transitionLayoutTime}ms ${easing}` : transitionCSS,\n willChange: isActive ? 'transform' : undefined\n }}>\n {src ? <Photo src={src} loaded={loaded} broken={broken} {...attrs} onPhotoLoad={handlePhotoLoad} loadingElement={loadingElement} brokenElement={brokenElement} /> : render && render({\n attrs,\n scale: currentScale,\n rotate\n })}\n </StyledBox>\n </StyledWrap>;\n}","import type React from 'react'\n\n/**\n * 从 Touch 事件中获取两个触控中心位置\n */\nexport default function getMultipleTouchPosition(\n evt: TouchEvent | React.TouchEvent,\n): [clientX: number, clientY: number, touchLength: number] {\n const { clientX, clientY } = evt.touches[0]\n if (evt.touches.length >= 2) {\n const { clientX: nextClientX, clientY: nextClientY } = evt.touches[1]\n return [\n (clientX + nextClientX) / 2,\n (clientY + nextClientY) / 2,\n Math.sqrt((nextClientX - clientX) ** 2 + (nextClientY - clientY) ** 2),\n ]\n }\n return [clientX, clientY, 0]\n}\n","import type { CloseEdgeType } from '../types'\nimport type { ReachType, TouchStartType } from '../types'\n\n/**\n * 获取接触边缘类型\n */\nexport const getReachType = (\n initialTouchState: TouchStartType,\n horizontalCloseEdge: CloseEdgeType,\n verticalCloseEdge: CloseEdgeType,\n reachPosition: ReachType,\n): ReachType => {\n if ((horizontalCloseEdge && initialTouchState === 1) || reachPosition === 'x') {\n return 'x'\n } else if ((verticalCloseEdge && initialTouchState > 1) || reachPosition === 'y') {\n return 'y'\n }\n return undefined\n}\n\n/**\n * 计算接触边缘位置\n * @param position - x/y\n * @param scale\n * @param size - width/height\n * @param innerSize - innerWidth/innerHeight\n * @return [CloseEdgeType, position]\n */\nexport const computePositionEdge = (\n position: number,\n scale: number,\n size: number,\n innerSize: number,\n) => {\n const currentWidth = size * scale\n // 图片超出的宽度\n const outOffset = (currentWidth - innerSize) / 2\n let closedEdge: CloseEdgeType = undefined\n\n let current = position\n if (currentWidth <= innerSize) {\n closedEdge = 1\n current = 0\n } else if (position > 0 && outOffset - position <= 0) {\n closedEdge = 2\n current = outOffset\n } else if (position < 0 && outOffset + position <= 0) {\n closedEdge = 3\n current = -outOffset\n }\n\n return [closedEdge, current] as const\n}\n","import { longModeRatio } from '../variables'\nimport { computePositionEdge } from './edgeHandle'\n\n/**\n * 获取移动或缩放之后的中心点\n */\nexport default function getPositionOnMoveOrScale(\n isDragMode: boolean,\n x: number,\n y: number,\n width: number,\n height: number,\n naturalWidth: number,\n naturalHeight: number,\n scale: number,\n toScale: number,\n clientX: number = innerWidth / 2,\n clientY: number = innerHeight / 2,\n offsetX: number = 0,\n offsetY: number = 0,\n) {\n // 是否接触边缘\n const [closedEdgeX] = computePositionEdge(\n x,\n toScale,\n isDragMode ? naturalWidth : width,\n innerWidth,\n )\n const [closedEdgeY] = computePositionEdge(\n y,\n toScale,\n isDragMode ? naturalHeight : height,\n innerHeight,\n )\n\n const centerClientX = innerWidth / 2\n const centerClientY = innerHeight / 2\n\n // 坐标偏移\n const lastPositionX = centerClientX + x\n const lastPositionY = centerClientY + y\n\n // 偏移位置\n const originX = clientX - (clientX - lastPositionX) * (toScale / scale) - centerClientX\n const originY = clientY - (clientY - lastPositionY) * (toScale / scale) - centerClientY\n // 长图模式无左右反馈\n const longModeEdge =\n (isDragMode ? naturalHeight / naturalWidth : height / width) >= longModeRatio &&\n (isDragMode ? naturalWidth : width) * toScale === innerWidth\n // 超出边缘距离减半\n return {\n x: originX + (longModeEdge ? 0 : closedEdgeX ? offsetX / 2 : offsetX),\n y: originY + (closedEdgeY ? offsetY / 2 : offsetY),\n lastCX: clientX,\n lastCY: clientY,\n }\n}\n","/**\n * 获取旋转后的宽高\n */\nexport default function getRotateSize(rotate: number, width: number, height: number) {\n const isVertical = rotate % 180 !== 0\n\n // 若图片不是水平则调换属性\n if (isVertical) {\n return [height, width, isVertical] as const\n }\n\n return [width, height, isVertical] as const\n}\n","import { longModeRatio } from '../variables'\nimport getRotateSize from './getRotateSize'\n\n/**\n * 获取图片合适的大小\n */\nexport default function getSuitableImageSize(\n isDragMode: boolean,\n naturalWidth: number,\n naturalHeight: number,\n rotate: number,\n margin = 0,\n) {\n const [currentWidth, currentHeight, isVertical] = getRotateSize(rotate, innerWidth, innerHeight)\n\n let y = margin\n let width = currentWidth - margin * 2\n let height = currentHeight - margin * 2\n\n // 自适应宽高\n const autoWidth = (naturalWidth / naturalHeight) * currentHeight\n const autoHeight = (naturalHeight / naturalWidth) * currentWidth\n\n if (naturalWidth < currentWidth && naturalHeight < currentHeight) {\n width = naturalWidth\n height = naturalHeight\n } else if (naturalWidth < currentWidth && naturalHeight >= currentHeight) {\n width = autoWidth\n } else if (naturalWidth >= currentWidth && naturalHeight < currentHeight) {\n height = autoHeight\n } else if (naturalWidth / naturalHeight > currentWidth / currentHeight) {\n height = autoHeight\n }\n // 长图模式\n else if (naturalHeight / naturalWidth >= longModeRatio && !isVertical) {\n height = autoHeight\n y = (height - currentHeight) / 2\n } else {\n width = autoWidth\n }\n const state: Record<string, number | boolean> = {\n width,\n height,\n x: margin,\n y,\n pause: isDragMode ? false : true,\n }\n if (isDragMode) state.scale = Math.min(height / naturalHeight, width / naturalWidth, 1)\n return state\n}\n","import { useCallback, useRef } from 'react'\n\ninterface DebounceCallback<CallbackArguments extends any[]> {\n (...args: CallbackArguments): void\n cancel: () => void\n}\n\nexport default function useDebounceCallback<CallbackArguments extends any[]>(\n callback: (...args: CallbackArguments) => void,\n {\n leading = false,\n maxWait,\n wait = maxWait || 0,\n }: {\n leading?: boolean\n maxWait?: number\n wait?: number\n },\n): DebounceCallback<CallbackArguments> {\n const callbackRef = useRef(callback)\n callbackRef.current = callback\n\n const prev = useRef(0)\n const trailingTimeout = useRef<ReturnType<typeof setTimeout>>()\n const clearTrailing = () => trailingTimeout.current && clearTimeout(trailingTimeout.current)\n\n const fn = useCallback(\n (...args: CallbackArguments) => {\n const now = Date.now()\n\n function call() {\n prev.current = now\n clearTrailing()\n callbackRef.current.apply(null, args)\n }\n const last = prev.current\n const offset = now - last\n // leading\n if (last === 0) {\n if (leading) {\n call()\n }\n prev.current = now\n }\n\n // body\n if (maxWait !== undefined) {\n if (offset > maxWait) {\n call()\n return\n }\n } else if (offset < wait) {\n prev.current = now\n }\n\n // trailing\n clearTrailing()\n trailingTimeout.current = setTimeout(() => {\n call()\n prev.current = 0\n }, wait)\n },\n [wait, maxWait, leading],\n )\n ;(fn as DebounceCallback<CallbackArguments>).cancel = clearTrailing\n\n return fn as DebounceCallback<CallbackArguments>\n}\n","import { useRef } from 'react'\nimport useDebounceCallback from './useDebounceCallback'\n\nexport type TapFuncType<T> = (...args: T[]) => void\n\n/**\n * 单击和双击事件处理\n * @param singleTap - 单击事件\n * @param doubleTap - 双击事件\n * @return invokeTap\n */\nexport default function useContinuousTap<T>(\n singleTap: TapFuncType<T>,\n doubleTap: TapFuncType<T>,\n): TapFuncType<T> {\n // 当前连续点击次数\n const continuousClick = useRef(0)\n\n const debounceTap = useDebounceCallback(\n (...args) => {\n continuousClick.current = 0\n singleTap(...args)\n },\n {\n wait: 300,\n },\n )\n\n return function invokeTap(...args) {\n continuousClick.current += 1\n debounceTap(...args)\n // 双击\n if (continuousClick.current >= 2) {\n debounceTap.cancel()\n continuousClick.current = 0\n doubleTap(...args)\n }\n }\n}\n","import { computePositionEdge } from '../utils/edgeHandle'\nimport getPositionOnMoveOrScale from '../utils/getPositionOnMoveOrScale'\nimport getRotateSize from '../utils/getRotateSize'\nimport { defaultSpeed, maxTouchTime } from '../variables'\nimport useMethods from './useMethods'\n\n// 触边运动反馈\nconst rebound = (start: number, bound: number, callback: (spatial: number) => boolean) =>\n easeOutMove(\n start,\n bound,\n callback,\n defaultSpeed / 4,\n t => t,\n () => easeOutMove(bound, start, callback),\n )\n\n/**\n * 物理滚动到具体位置\n */\nexport default function useScrollPosition<C extends (spatial: number) => boolean>(\n callbackX: C,\n callbackY: C,\n callbackS: C,\n) {\n const callback = useMethods({\n X: (spatial: number) => callbackX(spatial),\n Y: (spatial: number) => callbackY(spatial),\n S: (spatial: number) => callbackS(spatial),\n })\n\n return (\n x: number,\n y: number,\n lastX: number,\n lastY: number,\n width: number,\n height: number,\n naturalWidth: number,\n naturalHeight: number,\n scale: number,\n safeScale: number,\n lastScale: number,\n rotate: number,\n touchedTime: number,\n ) => {\n const [currentWidth, currentHeight] = getRotateSize(rotate, width, height)\n // 开始状态下边缘触发状态\n const [beginEdgeX, beginX] = computePositionEdge(x, safeScale, currentWidth, innerWidth)\n const [beginEdgeY, beginY] = computePositionEdge(y, safeScale, currentHeight, innerHeight)\n const moveTime = Date.now() - touchedTime\n\n // 时间过长、超出安全范围的情况下不执行滚动逻辑,恢复安全范围\n if (moveTime >= maxTouchTime || safeScale != scale || Math.abs(lastScale - scale) > 1) {\n // 计算中心缩放点\n const { x: nextX, y: nextY } = getPositionOnMoveOrScale(\n false,\n x,\n y,\n width,\n height,\n naturalWidth,\n naturalHeight,\n scale,\n safeScale,\n )\n const targetX = beginEdgeX ? beginX : nextX !== x ? nextX : null\n const targetY = beginEdgeY ? beginY : nextY !== y ? nextY : null\n\n if (targetX !== null) {\n easeOutMove(x, targetX, callback.X)\n }\n if (targetY !== null) {\n easeOutMove(y, targetY, callback.Y)\n }\n if (safeScale != scale) {\n easeOutMove(scale, safeScale, callback.S)\n }\n return\n }\n\n // 初始速度\n const speedX = (x - lastX) / moveTime\n const speedY = (y - lastY) / moveTime\n const speedT = Math.sqrt(speedX ** 2 + speedY ** 2)\n // 是否接触到边缘\n let edgeX = false\n let edgeY = false\n\n scrollMove(speedT, spatial => {\n const nextX = x + spatial * (speedX / speedT)\n const nextY = y + spatial * (speedY / speedT)\n\n const [isEdgeX, currentX] = computePositionEdge(nextX, scale, currentWidth, innerWidth)\n const [isEdgeY, currentY] = computePositionEdge(nextY, scale, currentHeight, innerHeight)\n\n if (isEdgeX && !edgeX) {\n edgeX = true\n if (beginEdgeX) {\n easeOutMove(nextX, currentX, callback.X)\n } else {\n rebound(currentX, nextX + (nextX - currentX), callback.X)\n }\n }\n\n if (isEdgeY && !edgeY) {\n edgeY = true\n if (beginEdgeY) {\n easeOutMove(nextY, currentY, callback.Y)\n } else {\n rebound(currentY, nextY + (nextY - currentY), callback.Y)\n }\n }\n // 同时接触边缘的情况下停止滚动\n if (edgeX && edgeY) {\n return false\n }\n\n const resultX = edgeX || callback.X(currentX)\n const resultY = edgeY || callback.Y(currentY)\n return resultX && resultY\n })\n }\n}\n\n// 加速度\nconst acceleration = -0.001\n// 阻力\nconst resistance = 0.0002\n\n/**\n * 通过速度滚动到停止\n */\nfunction scrollMove(initialSpeed: number, callback: (spatial: number) => boolean) {\n let v = initialSpeed\n let s = 0\n let lastTime: number | undefined = undefined\n let frameId = 0\n\n const calcMove = (now: number) => {\n if (!lastTime) {\n lastTime = now\n }\n const dt = now - lastTime\n const direction = Math.sign(initialSpeed)\n const a = direction * acceleration\n const f = Math.sign(-v) * v ** 2 * resistance\n const ds = v * dt + ((a + f) * dt ** 2) / 2\n v = v + (a + f) * dt\n\n s = s + ds\n // move to s\n lastTime = now\n\n if (direction * v <= 0) {\n caf()\n return\n }\n\n if (callback(s)) {\n raf()\n return\n }\n caf()\n }\n raf()\n\n function raf() {\n frameId = requestAnimationFrame(calcMove)\n }\n function caf() {\n cancelAnimationFrame(frameId)\n }\n}\n\n/**\n * 缓动函数\n */\ncons