517 lines
15 KiB
TypeScript
Executable File
517 lines
15 KiB
TypeScript
Executable File
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]} />;
|
|
};
|