567 lines
17 KiB
TypeScript
567 lines
17 KiB
TypeScript
import { cn, getStaticImage } from "@/lib/utils";
|
|
import {
|
|
MouseEvent,
|
|
useEffect,
|
|
useRef,
|
|
useCallback,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Image } from "@/components";
|
|
import { useScale } from "@/contexts/ScaleContext";
|
|
import ExhibitDescriptionNarration from "./ExhibitDescriptionNarration";
|
|
|
|
export interface ExhibitOverlayProps {
|
|
/** 展品图片地址,可以是静态资源路径或完整 URL */
|
|
src: string;
|
|
/** 关闭回调 */
|
|
onClose?: () => void;
|
|
/** 是否可见,默认 true */
|
|
visible?: boolean;
|
|
/** 额外的自定义类 */
|
|
className?: string;
|
|
/** 展品描述文本 */
|
|
description?: string;
|
|
/** 是否自动播放描述语音,默认 false */
|
|
autoPlayDescription?: boolean;
|
|
/** 点击展示 */
|
|
onClick?: () => void;
|
|
/** 打开回调 */
|
|
onOpen?: () => void;
|
|
}
|
|
|
|
type Point = { x: number; y: number };
|
|
|
|
/**
|
|
* 展品通用遮罩层
|
|
* - 背景蒙版:components/蒙版遮罩.png
|
|
* - 返回按钮:components/返回.png
|
|
* - 传入展品图片地址进行展示
|
|
* - 支持缩放、拖拽、双击重置
|
|
* - 使用屏幕坐标系统,简单可靠
|
|
*/
|
|
const ExhibitOverlay = ({
|
|
src,
|
|
onClose,
|
|
visible = true,
|
|
className,
|
|
description,
|
|
autoPlayDescription = false,
|
|
onClick,
|
|
onOpen,
|
|
}: ExhibitOverlayProps) => {
|
|
const maskSrc = getStaticImage("components/蒙版遮罩");
|
|
const backSrc = getStaticImage("components/关闭");
|
|
|
|
// 获取缩放参数,用于视觉对齐
|
|
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
|
const [mounted, setMounted] = useState(false);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
// SSR 兼容:只在客户端挂载
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// 允许传入相对路径或绝对 URL
|
|
const exhibitSrc =
|
|
src.startsWith("http://") ||
|
|
src.startsWith("https://") ||
|
|
src.startsWith("/")
|
|
? src
|
|
: getStaticImage(src);
|
|
|
|
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
|
|
e.stopPropagation();
|
|
onClose?.();
|
|
};
|
|
|
|
// ====== 缩放 / 拖拽状态 ======
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const viewElRef = useRef<HTMLDivElement | null>(null);
|
|
const frameRef = useRef<number | null>(null);
|
|
const pointers = useRef<Map<number, Point>>(new Map());
|
|
const lastDistance = useRef<number | null>(null);
|
|
// 拖拽初始状态:记录 pointerDown 时的状态
|
|
const dragStartRef = useRef<{
|
|
pointerId: number;
|
|
startX: number;
|
|
startY: number;
|
|
startTransform: { scale: number; x: number; y: number };
|
|
} | null>(null);
|
|
const transformRef = useRef({ scale: 1, x: 0, y: 0 });
|
|
|
|
const MIN_SCALE = 0.8;
|
|
const MAX_SCALE = 3;
|
|
|
|
// 针对当前 scale 预计算 clamp
|
|
const clampScale = useCallback(
|
|
(next: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, next)),
|
|
[]
|
|
);
|
|
|
|
const applyTransformStyle = useCallback(() => {
|
|
const el = viewElRef.current;
|
|
if (!el) return;
|
|
const { scale, x, y } = transformRef.current;
|
|
// 使用 translate3d 触发 GPU 加速
|
|
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
|
el.style.transformOrigin = "0 0";
|
|
el.style.touchAction = "none";
|
|
}, []);
|
|
|
|
const scheduleUpdate = useCallback(() => {
|
|
if (frameRef.current) return;
|
|
frameRef.current = requestAnimationFrame(() => {
|
|
applyTransformStyle();
|
|
frameRef.current = null;
|
|
});
|
|
}, [applyTransformStyle]);
|
|
|
|
const resetTransform = useCallback(() => {
|
|
transformRef.current = { scale: 1, x: 0, y: 0 };
|
|
pointers.current.clear();
|
|
lastDistance.current = null;
|
|
dragStartRef.current = null;
|
|
setIsDragging(false);
|
|
// 释放所有指针捕获
|
|
if (containerRef.current) {
|
|
pointers.current.forEach((_, pointerId) => {
|
|
try {
|
|
containerRef.current?.releasePointerCapture(pointerId);
|
|
} catch {
|
|
// 忽略捕获释放错误
|
|
}
|
|
});
|
|
}
|
|
applyTransformStyle();
|
|
}, [applyTransformStyle]);
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
onOpen?.();
|
|
}
|
|
}, [visible, onOpen]);
|
|
|
|
// 关闭或切换时重置
|
|
useEffect(() => {
|
|
if (visible) {
|
|
resetTransform();
|
|
} else {
|
|
// 不可见时清理所有状态和指针捕获
|
|
transformRef.current = { scale: 1, x: 0, y: 0 };
|
|
pointers.current.clear();
|
|
lastDistance.current = null;
|
|
dragStartRef.current = null;
|
|
if (containerRef.current) {
|
|
pointers.current.forEach((_, pointerId) => {
|
|
try {
|
|
containerRef.current?.releasePointerCapture(pointerId);
|
|
} catch {
|
|
// 忽略捕获释放错误
|
|
}
|
|
});
|
|
}
|
|
// 取消待处理的动画帧
|
|
if (frameRef.current) {
|
|
cancelAnimationFrame(frameRef.current);
|
|
frameRef.current = null;
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [visible, src]);
|
|
|
|
// 组件卸载时清理资源
|
|
useEffect(() => {
|
|
const containerEl = containerRef.current;
|
|
const pointersMap = pointers.current;
|
|
const frameId = frameRef.current;
|
|
|
|
return () => {
|
|
// 清理所有指针捕获
|
|
if (containerEl) {
|
|
pointersMap.forEach((_, pointerId) => {
|
|
try {
|
|
containerEl.releasePointerCapture(pointerId);
|
|
} catch {
|
|
// 忽略捕获释放错误
|
|
}
|
|
});
|
|
}
|
|
// 取消待处理的动画帧
|
|
if (frameId) {
|
|
cancelAnimationFrame(frameId);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标
|
|
const applyZoom = useCallback(
|
|
(factor: number, origin: Point) => {
|
|
if (!viewElRef.current || !containerRef.current) return;
|
|
|
|
const prev = transformRef.current;
|
|
const nextScale = clampScale(prev.scale * factor);
|
|
// 边界检查:避免除零
|
|
if (prev.scale === 0) return;
|
|
|
|
// 获取 viewElRef 相对于视口的位置
|
|
const viewRect = viewElRef.current.getBoundingClientRect();
|
|
// 将屏幕坐标转换为相对于 viewElRef 的坐标
|
|
const relativeOrigin = {
|
|
x: origin.x - viewRect.left,
|
|
y: origin.y - viewRect.top,
|
|
};
|
|
|
|
const scaleChange = nextScale / prev.scale;
|
|
// 以相对坐标 origin 为基准,保持该点视觉位置不动
|
|
const nextX =
|
|
(prev.x - relativeOrigin.x) * scaleChange + relativeOrigin.x;
|
|
const nextY =
|
|
(prev.y - relativeOrigin.y) * scaleChange + relativeOrigin.y;
|
|
transformRef.current = { scale: nextScale, x: nextX, y: nextY };
|
|
scheduleUpdate();
|
|
},
|
|
[clampScale, scheduleUpdate]
|
|
);
|
|
|
|
// 滚轮缩放:直接使用屏幕坐标
|
|
const handleWheel = useCallback(
|
|
(e: React.WheelEvent<HTMLDivElement>) => {
|
|
const delta = e.deltaY;
|
|
const factor = delta < 0 ? 1.05 : 0.95;
|
|
// 直接使用屏幕坐标作为缩放中心
|
|
applyZoom(factor, { x: e.clientX, y: e.clientY });
|
|
},
|
|
[applyZoom]
|
|
);
|
|
|
|
// 计算两点距离
|
|
const getDistance = useCallback((p1: Point, p2: Point) => {
|
|
const dx = p1.x - p2.x;
|
|
const dy = p1.y - p2.y;
|
|
return Math.hypot(dx, dy);
|
|
}, []);
|
|
|
|
// 指针按下:记录初始状态
|
|
const handlePointerDown = useCallback(
|
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!containerRef.current || !viewElRef.current) return;
|
|
try {
|
|
containerRef.current.setPointerCapture(e.pointerId);
|
|
} catch (err) {
|
|
console.warn("Failed to capture pointer:", err);
|
|
}
|
|
|
|
// 记录屏幕坐标
|
|
const point = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
};
|
|
pointers.current.set(e.pointerId, point);
|
|
|
|
// 单指拖拽:记录初始状态
|
|
if (pointers.current.size === 1) {
|
|
dragStartRef.current = {
|
|
pointerId: e.pointerId,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
startTransform: { ...transformRef.current },
|
|
};
|
|
setIsDragging(true);
|
|
} else if (pointers.current.size === 2) {
|
|
// 双指缩放:记录初始距离
|
|
const values = Array.from(pointers.current.values());
|
|
if (values.length === 2) {
|
|
const [p1, p2] = values;
|
|
if (p1 && p2) {
|
|
lastDistance.current = getDistance(p1, p2);
|
|
dragStartRef.current = null;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[getDistance]
|
|
);
|
|
|
|
/**
|
|
* 单指拖拽处理
|
|
* - 只关心当前 pointer 相对于按下时的位移
|
|
* - 在这里统一做全局 scale 补偿,保持「手指移动 = 屏幕上移动」
|
|
*/
|
|
const handleSingleDrag = useCallback(
|
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
const dragStart = dragStartRef.current;
|
|
if (!dragStart) return;
|
|
|
|
// 1. 计算屏幕坐标系下的位移
|
|
const dxScreen = e.clientX - dragStart.startX;
|
|
const dyScreen = e.clientY - dragStart.startY;
|
|
|
|
// 2. 映射到设计稿坐标系(补偿全局 scale)
|
|
const dx = dxScreen / scale;
|
|
const dy = dyScreen / scale;
|
|
|
|
// 3. 应用到 transform(设计稿坐标)
|
|
const nextX = dragStart.startTransform.x + dx;
|
|
const nextY = dragStart.startTransform.y + dy;
|
|
|
|
transformRef.current.x = nextX;
|
|
transformRef.current.y = nextY;
|
|
|
|
// 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数
|
|
scheduleUpdate();
|
|
},
|
|
[scale, scheduleUpdate]
|
|
);
|
|
|
|
/**
|
|
* 双指缩放处理
|
|
* - 使用两指间距离变化计算缩放因子
|
|
* - 使用两指中点作为缩放中心
|
|
*/
|
|
const handlePinchZoom = useCallback(() => {
|
|
if (pointers.current.size !== 2) return;
|
|
const values = Array.from(pointers.current.values());
|
|
const [p1, p2] = values;
|
|
if (!p1 || !p2) return;
|
|
|
|
const distance = getDistance(p1, p2);
|
|
if (!lastDistance.current || lastDistance.current <= 0) {
|
|
lastDistance.current = distance;
|
|
return;
|
|
}
|
|
|
|
const factor = distance / lastDistance.current;
|
|
const center = {
|
|
x: (p1.x + p2.x) * 0.5,
|
|
y: (p1.y + p2.y) * 0.5,
|
|
};
|
|
|
|
applyZoom(factor, center);
|
|
lastDistance.current = distance;
|
|
}, [getDistance, applyZoom]);
|
|
|
|
// 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理
|
|
const handlePointerMove = useCallback(
|
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!pointers.current.has(e.pointerId)) return;
|
|
|
|
// 更新当前 pointer 的屏幕坐标
|
|
pointers.current.set(e.pointerId, {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
});
|
|
|
|
// 优先处理双指缩放
|
|
if (pointers.current.size === 2) {
|
|
handlePinchZoom();
|
|
return;
|
|
}
|
|
|
|
// 单指拖拽
|
|
if (pointers.current.size === 1) {
|
|
handleSingleDrag(e);
|
|
}
|
|
},
|
|
[handlePinchZoom, handleSingleDrag]
|
|
);
|
|
|
|
// 指针抬起
|
|
const handlePointerUp = useCallback(
|
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
// 释放指针捕获
|
|
if (containerRef.current) {
|
|
try {
|
|
containerRef.current.releasePointerCapture(e.pointerId);
|
|
} catch {
|
|
// 忽略捕获释放错误
|
|
}
|
|
}
|
|
|
|
pointers.current.delete(e.pointerId);
|
|
|
|
if (pointers.current.size < 2) {
|
|
lastDistance.current = null;
|
|
}
|
|
|
|
// 如果剩余单指,重新记录拖拽初始状态
|
|
if (pointers.current.size === 1) {
|
|
const remaining = Array.from(pointers.current.values())[0];
|
|
const remainingId = Array.from(pointers.current.keys())[0];
|
|
if (remaining && remainingId !== undefined) {
|
|
dragStartRef.current = {
|
|
pointerId: remainingId,
|
|
startX: remaining.x,
|
|
startY: remaining.y,
|
|
startTransform: { ...transformRef.current },
|
|
};
|
|
}
|
|
} else {
|
|
dragStartRef.current = null;
|
|
}
|
|
|
|
setIsDragging(pointers.current.size > 0);
|
|
},
|
|
[]
|
|
);
|
|
|
|
// 双击重置
|
|
const handleDoubleClick = useCallback(() => {
|
|
resetTransform();
|
|
}, [resetTransform]);
|
|
|
|
// 容器尺寸:基于设计稿尺寸的 90%
|
|
const containerSize = useMemo(
|
|
() => Math.min(DESIGN_WIDTH, DESIGN_HEIGHT) * 0.9,
|
|
[DESIGN_WIDTH, DESIGN_HEIGHT]
|
|
);
|
|
|
|
// 视觉对齐样式:确保与 App 内容对齐
|
|
const overlayStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: 9999,
|
|
pointerEvents: "none",
|
|
}),
|
|
[]
|
|
);
|
|
|
|
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
position: "absolute",
|
|
width: `${DESIGN_WIDTH}px`,
|
|
height: `${DESIGN_HEIGHT}px`,
|
|
left: `${offsetX}px`,
|
|
top: `${offsetY}px`,
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: "top left",
|
|
overflow: "hidden",
|
|
}),
|
|
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
|
);
|
|
|
|
if (!mounted) return null;
|
|
|
|
const content = (
|
|
<AnimatePresence>
|
|
{visible && (
|
|
<motion.div
|
|
key="exhibit-overlay"
|
|
style={overlayStyle}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: "easeInOut" }}
|
|
>
|
|
<div style={scaledContainerStyle}>
|
|
<div
|
|
className={cn(
|
|
"absolute inset-0 flex items-center justify-center",
|
|
"bg-black/70",
|
|
className
|
|
)}
|
|
style={{ pointerEvents: "auto" }}
|
|
>
|
|
{/* 背景蒙版 */}
|
|
{maskSrc && (
|
|
<Image
|
|
src={maskSrc}
|
|
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
|
|
{/* 返回按钮 */}
|
|
{onClose && (
|
|
<Image
|
|
src={backSrc}
|
|
onClick={handleClose}
|
|
alt="返回"
|
|
className="absolute top-5 right-5 z-20"
|
|
button
|
|
/>
|
|
)}
|
|
|
|
<div className="relative z-10 w-full h-full">
|
|
{/* 展品描述播报 - 固定在左侧 */}
|
|
{description && (
|
|
<ExhibitDescriptionNarration
|
|
text={description}
|
|
visible={visible}
|
|
autoPlay={autoPlayDescription}
|
|
className="absolute left-20 top-1/2 -translate-y-1/2"
|
|
/>
|
|
)}
|
|
|
|
{/* 展品内容 - 在剩余空间中居中 */}
|
|
<div
|
|
className="absolute top-1/2 -translate-y-1/2 pointer-events-none"
|
|
style={{
|
|
// 有描述时:从描述右侧30px开始,到右边界,在这个区域内居中
|
|
left: description ? "520px" : "0", // 30 + 450 + 30
|
|
right: "0",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<div
|
|
ref={containerRef}
|
|
className="relative flex items-center justify-center"
|
|
style={{
|
|
maxWidth: containerSize,
|
|
maxHeight: containerSize,
|
|
width: "100%",
|
|
height: "100%",
|
|
cursor: isDragging ? "grabbing" : "grab",
|
|
}}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerCancel={handlePointerUp}
|
|
onWheel={handleWheel}
|
|
onDoubleClick={handleDoubleClick}
|
|
>
|
|
<div
|
|
ref={viewElRef}
|
|
className="max-w-full max-h-full select-none relative pointer-events-auto"
|
|
style={{
|
|
transform: "translate3d(0px, 0px, 0) scale(1)",
|
|
transformOrigin: "0 0",
|
|
willChange: "transform",
|
|
// iOS 微信浏览器优化
|
|
WebkitTransform: "translate3d(0px, 0px, 0) scale(1)",
|
|
WebkitBackfaceVisibility: "hidden",
|
|
backfaceVisibility: "hidden",
|
|
// 强制硬件加速
|
|
WebkitPerspective: "1000",
|
|
perspective: "1000",
|
|
}}
|
|
onClick={onClick}
|
|
>
|
|
<Image
|
|
src={exhibitSrc}
|
|
alt="展品"
|
|
className="max-w-full max-h-full object-contain drop-shadow-2xl"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
|
|
// 直接使用 Portal 渲染到 document.body
|
|
return createPortal(content, document.body);
|
|
};
|
|
|
|
export default ExhibitOverlay;
|