Files
leonpan-assets/src/component/Viewers/ImageViewer/react-photo-view/PhotoBox.tsx

517 lines
15 KiB
TypeScript
Raw Normal View History

2025-10-19 13:31:11 +00:00
import { grey } from "@mui/material/colors";
import React, { useEffect, useRef } from "react";
import { useInView } from "react-intersection-observer";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { loadMorePages } from "../../../../redux/thunks/filemanager.ts";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
import { FileManagerIndex } from "../../../FileManager/FileManager.tsx";
import useAnimationPosition from "./hooks/useAnimationPosition";
import useContinuousTap from "./hooks/useContinuousTap";
import useDebounceCallback from "./hooks/useDebounceCallback";
import useEventListener from "./hooks/useEventListener";
import useIsomorphicLayoutEffect from "./hooks/useIsomorphicLayoutEffect";
import useMethods from "./hooks/useMethods";
import useMountedRef from "./hooks/useMountedRef";
import useScrollPosition from "./hooks/useScrollPosition";
import useSetState from "./hooks/useSetState";
import type { IPhotoLoadedParams } from "./Photo";
import Photo from "./Photo";
import "./PhotoBox.less";
import type {
BrokenElementParams,
DataType,
ExposedProperties,
PhotoTapFunction,
ReachFunction,
ReachMoveFunction,
ReachType,
TouchStartType,
} from "./types";
import { computePositionEdge, getReachType } from "./utils/edgeHandle";
import getMultipleTouchPosition from "./utils/getMultipleTouchPosition";
import getPositionOnMoveOrScale from "./utils/getPositionOnMoveOrScale";
import getRotateSize from "./utils/getRotateSize";
import getSuitableImageSize from "./utils/getSuitableImageSize";
import isTouchDevice from "./utils/isTouchDevice";
import { limitScale } from "./utils/limitTarget";
import { minStartTouchOffset, scaleBuffer } from "./variables";
export interface PhotoBoxProps {
// 图片信息
item: DataType;
// 是否可见
visible: boolean;
// 动画时间
speed: number;
// 动画函数
easing: string;
// 容器类名
wrapClassName?: string;
// 图片类名
className?: string;
// style
style?: object;
// 自定义 loading
loadingElement?: JSX.Element;
// 加载失败 Element
brokenElement?: JSX.Element | ((photoProps: BrokenElementParams) => JSX.Element);
// Photo 点击事件
onPhotoTap: PhotoTapFunction;
// Mask 点击事件
onMaskTap: PhotoTapFunction;
// 到达边缘滑动事件
onReachMove: ReachMoveFunction;
// 触摸解除事件
onReachUp: ReachFunction;
// Resize 事件
onPhotoResize: () => void;
// 向父组件导出属性
expose: (state: ExposedProperties) => void;
// 是否在当前操作中
isActive: boolean;
}
const initialState = {
// 真实宽度
naturalWidth: undefined as number | undefined,
// 真实高度
naturalHeight: undefined as number | undefined,
// 宽度
width: undefined as number | undefined,
// 高度
height: undefined as number | undefined,
// 加载成功状态
loaded: undefined as boolean | undefined,
// 破碎状态
broken: false,
// 图片 X 偏移量
x: 0,
// 图片 y 偏移量
y: 0,
// 图片处于触摸的状态
touched: false,
// 背景处于触摸状态
maskTouched: false,
// 旋转状态
rotate: 0,
// 放大缩小
scale: 1,
// 触摸开始时 x 原始坐标
CX: 0,
// 触摸开始时 y 原始坐标
CY: 0,
// 触摸开始时图片 x 偏移量
lastX: 0,
// 触摸开始时图片 y 偏移量
lastY: 0,
// 上一个触摸状态 x 原始坐标
lastCX: 0,
// 上一个触摸状态 y 原始坐标
lastCY: 0,
// 上一个触摸状态的 scale
lastScale: 1,
// 触摸开始时时间
touchTime: 0,
// 多指触控间距
touchLength: 0,
// 是否暂停 transition
pause: true,
// 停止 Raf
stopRaf: true,
// 当前边缘触发状态
reach: undefined as ReachType,
};
export default function PhotoBox({
item: {
render,
file,
version,
width: customWidth = 0,
height: customHeight = 0,
originRef,
loadMorePlaceholder,
key,
},
visible,
speed,
easing,
wrapClassName,
className,
style,
loadingElement,
brokenElement,
onPhotoTap,
onMaskTap,
onReachMove,
onReachUp,
onPhotoResize,
isActive,
expose,
}: PhotoBoxProps) {
const [state, updateState] = useSetState(initialState);
const initialTouchRef = useRef<TouchStartType>(0);
const mounted = useMountedRef();
const {
naturalWidth = customWidth,
naturalHeight = customHeight,
width = customWidth,
height = customHeight,
loaded = !file,
broken,
x,
y,
touched,
stopRaf,
maskTouched,
rotate,
scale,
CX,
CY,
lastX,
lastY,
lastCX,
lastCY,
lastScale,
touchTime,
touchLength,
pause,
reach,
} = state;
const sideBarOpen = useAppSelector((state) => state.globalState.sidebarOpen);
const dynamicInnerWidth = sideBarOpen ? window.innerWidth - 300 : window.innerWidth;
const fn = useMethods({
onScale: (current: number) => onScale(limitScale(current)),
onRotate(current: number) {
if (rotate !== current) {
expose({ rotate: current });
updateState({
rotate: current,
...getSuitableImageSize(naturalWidth, naturalHeight, current, dynamicInnerWidth),
});
}
},
});
// 默认为屏幕中心缩放
function onScale(current: number, clientX?: number, clientY?: number) {
if (scale !== current) {
expose({ scale: current });
updateState({
scale: current,
...getPositionOnMoveOrScale(x, y, width, height, scale, current, clientX, clientY),
...(current <= 1 && { x: 0, y: 0 }),
});
}
}
const handleMove = useDebounceCallback(
(nextClientX: number, nextClientY: number, currentTouchLength: number = 0) => {
if ((touched || maskTouched) && isActive) {
// 通过旋转调换宽高
const [currentWidth, currentHeight] = getRotateSize(rotate, width, height);
// 单指最小缩放下,以初始移动距离来判断意图
if (currentTouchLength === 0 && initialTouchRef.current === 0) {
const isStillX = Math.abs(nextClientX - CX) <= minStartTouchOffset;
const isStillY = Math.abs(nextClientY - CY) <= minStartTouchOffset;
// 初始移动距离不足
if (isStillX && isStillY) {
// 方向记录上次移动距离,以便平滑过渡
updateState({ lastCX: nextClientX, lastCY: nextClientY });
return;
}
// 设置响应状态
initialTouchRef.current = !isStillX ? 1 : nextClientY > CY ? 3 : 2;
}
const offsetX = nextClientX - lastCX;
const offsetY = nextClientY - lastCY;
// 边缘触发状态
let currentReach: ReachType;
if (currentTouchLength === 0) {
// 边缘超出状态
const [horizontalCloseEdge] = computePositionEdge(offsetX + lastX, scale, currentWidth, dynamicInnerWidth);
const [verticalCloseEdge] = computePositionEdge(offsetY + lastY, scale, currentHeight, innerHeight);
// 边缘触发检测
currentReach = getReachType(initialTouchRef.current, horizontalCloseEdge, verticalCloseEdge, reach);
// 接触边缘
if (currentReach !== undefined) {
onReachMove(currentReach, nextClientX, nextClientY, scale);
}
}
// 横向边缘触发、背景触发禁用当前滑动
if (currentReach === "x" || maskTouched) {
updateState({ reach: "x" });
return;
}
// 目标倍数
const toScale = limitScale(
scale + ((currentTouchLength - touchLength) / 100 / 2) * scale,
naturalWidth / width,
scaleBuffer,
);
// 导出变量
expose({ scale: toScale });
updateState({
touchLength: currentTouchLength,
reach: currentReach,
scale: toScale,
...getPositionOnMoveOrScale(x, y, width, height, scale, toScale, nextClientX, nextClientY, offsetX, offsetY),
});
}
},
{
maxWait: 8,
},
);
function updateRaf(position: { x?: number; y?: number }) {
if (stopRaf || touched) {
return false;
}
if (mounted.current) {
// 下拉关闭时可以有动画
updateState({ ...position, pause: visible });
}
return mounted.current;
}
const slideToPosition = useScrollPosition(
(nextX) => updateRaf({ x: nextX }),
(nextY) => updateRaf({ y: nextY }),
(nextScale) => {
if (mounted.current) {
expose({ scale: nextScale });
updateState({ scale: nextScale });
}
return !touched && mounted.current;
},
dynamicInnerWidth,
);
const handlePhotoTap = useContinuousTap(onPhotoTap, (currentClientX: number, currentClientY: number) => {
if (!reach) {
// 若图片足够大,则放大适应的倍数
const endScale = scale !== 1 ? 1 : Math.max(2, naturalWidth / width);
onScale(endScale, currentClientX, currentClientY);
}
});
function handleUp(nextClientX: number, nextClientY: number) {
// 重置响应状态
initialTouchRef.current = 0;
if ((touched || maskTouched) && isActive) {
updateState({
touched: false,
maskTouched: false,
pause: false,
stopRaf: false,
reach: undefined,
});
const safeScale = limitScale(scale, naturalWidth / width);
// Go
slideToPosition(x, y, lastX, lastY, width, height, scale, safeScale, lastScale, rotate, touchTime);
onReachUp(nextClientX, nextClientY);
// 触发 Tap 事件
if (CX === nextClientX && CY === nextClientY) {
if (touched) {
handlePhotoTap(nextClientX, nextClientY);
return;
}
if (maskTouched) {
onMaskTap(nextClientX, nextClientY);
}
}
}
}
useEventListener(isTouchDevice ? undefined : "mousemove", (e) => {
handleMove(e.clientX, e.clientY);
});
useEventListener(isTouchDevice ? undefined : "mouseup", (e) => {
handleUp(e.clientX, e.clientY);
});
useEventListener(
isTouchDevice ? "touchmove" : undefined,
(e) => {
const position = getMultipleTouchPosition(e);
handleMove(...position);
},
{ passive: false },
);
useEventListener(
isTouchDevice ? "touchend" : undefined,
({ changedTouches }) => {
const touch = changedTouches[0];
handleUp(touch.clientX, touch.clientY);
},
{ passive: false },
);
useEventListener(
"resize",
useDebounceCallback(
() => {
if (loaded && !touched) {
updateState(getSuitableImageSize(naturalWidth, naturalHeight, rotate, dynamicInnerWidth));
onPhotoResize();
}
},
{ maxWait: 8 },
),
);
useIsomorphicLayoutEffect(() => {
if (isActive) {
expose({ scale, rotate, ...fn });
}
}, [isActive]);
function handlePhotoLoad(params: IPhotoLoadedParams) {
updateState({
...params,
...(params.loaded &&
getSuitableImageSize(params.naturalWidth || 0, params.naturalHeight || 0, rotate, dynamicInnerWidth)),
});
}
function handleStart(currentClientX: number, currentClientY: number, currentTouchLength: number = 0) {
updateState({
touched: true,
CX: currentClientX,
CY: currentClientY,
lastCX: currentClientX,
lastCY: currentClientY,
lastX: x,
lastY: y,
lastScale: scale,
touchLength: currentTouchLength,
touchTime: Date.now(),
});
}
function handleWheel(e: React.WheelEvent) {
if (!reach) {
// 限制最大倍数和最小倍数
const toScale = limitScale(scale - e.deltaY / 100 / 2, naturalWidth / width);
updateState({ stopRaf: true });
onScale(toScale, e.clientX, e.clientY);
}
}
function handleMaskStart(e: { clientX: number; clientY: number }) {
updateState({
maskTouched: true,
CX: e.clientX,
CY: e.clientY,
lastX: x,
lastY: y,
});
}
function handleTouchStart(e: React.TouchEvent) {
e.stopPropagation();
handleStart(...getMultipleTouchPosition(e));
}
function handleMouseDown(e: React.MouseEvent) {
e.stopPropagation();
if (e.button === 0) {
handleStart(e.clientX, e.clientY, 0);
}
}
// 计算位置
const [translateX, translateY, currentWidth, currentHeight, currentScale, opacity, easingMode, FIT] =
useAnimationPosition(
dynamicInnerWidth,
visible,
originRef,
loaded,
x,
y,
width,
height,
scale,
speed,
(isPause: boolean) => updateState({ pause: isPause }),
);
// 图片 objectFit 渐变时间
const transitionLayoutTime = easingMode < 4 ? speed / 2 : easingMode > 4 ? speed : 0;
const transitionCSS = `transform ${speed}ms ${easing}`;
const attrs = {
className,
onMouseDown: isTouchDevice ? undefined : handleMouseDown,
onTouchStart: isTouchDevice ? handleTouchStart : undefined,
onWheel: handleWheel,
style: {
width: currentWidth,
height: currentHeight,
opacity,
objectFit: easingMode === 4 ? undefined : FIT,
transform: rotate ? `rotate(${rotate}deg)` : undefined,
transition:
// 初始状态无渐变
easingMode > 2
? `${transitionCSS}, opacity ${speed}ms ease, height ${transitionLayoutTime}ms ${easing}`
: undefined,
},
};
return (
<div
className={`PhotoView__PhotoWrap${wrapClassName ? ` ${wrapClassName}` : ""}`}
style={style}
onMouseDown={!isTouchDevice && isActive ? handleMaskStart : undefined}
onTouchStart={isTouchDevice && isActive ? (e) => handleMaskStart(e.touches[0]) : undefined}
>
<div
className="PhotoView__PhotoBox"
style={{
transform: `matrix(${currentScale}, 0, 0, ${currentScale}, ${translateX}, ${translateY})`,
transition: touched || pause ? undefined : transitionCSS,
willChange: isActive ? "transform" : undefined,
}}
>
{file ? (
<Photo
file={file}
version={version}
loaded={loaded}
broken={broken}
{...attrs}
onPhotoLoad={handlePhotoLoad}
loadingElement={loadingElement}
brokenElement={brokenElement}
/>
) : (
render && render({ attrs, scale: currentScale, rotate })
)}
{loadMorePlaceholder && <LoadMorePlaceholder key={key} />}
</div>
</div>
);
}
const LoadMorePlaceholder = () => {
const dispatch = useAppDispatch();
const { ref, inView } = useInView({ triggerOnce: true });
useEffect(() => {
if (inView) {
dispatch(loadMorePages(FileManagerIndex.main));
}
}, [inView]);
return <FacebookCircularProgress ref={ref} fgColor={"#fff"} bgColor={grey[800]} />;
};