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; /** 自定义点击区域配置 */ clickableAreas?: Array<{ /** 区域名称 */ name: string; /** 相对于图片的位置和大小(百分比) */ position: { left: number; top: number; width: number; height: number }; /** 点击回调 */ onClick: () => void; }>; } type Point = { x: number; y: number }; /** * 展品通用遮罩层 * - 背景蒙版:components/蒙版遮罩.png * - 返回按钮:components/返回.png * - 传入展品图片地址进行展示 * - 支持缩放、拖拽、双击重置 * - 使用屏幕坐标系统,简单可靠 */ const ExhibitOverlay = ({ src, onClose, visible = true, className, description, autoPlayDescription = false, clickableAreas, }: ExhibitOverlayProps) => { const maskSrc = getStaticImage("components/蒙版遮罩"); const backSrc = getStaticImage("components/关闭"); // 获取缩放参数,用于视觉对齐 const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale(); const [mounted, setMounted] = useState(false); // SSR 兼容:只在客户端挂载 useEffect(() => { setMounted(true); }, []); // 允许传入相对路径或绝对 URL const exhibitSrc = src.startsWith("http://") || src.startsWith("https://") || src.startsWith("/") ? src : getStaticImage(src); const handleClose = (e: MouseEvent) => { e.stopPropagation(); onClose?.(); }; // ====== 缩放 / 拖拽状态 ====== const containerRef = useRef(null); const viewElRef = useRef(null); const frameRef = useRef(null); const pointers = useRef>(new Map()); const lastDistance = useRef(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; // 释放所有指针捕获 if (containerRef.current) { pointers.current.forEach((_, pointerId) => { try { containerRef.current?.releasePointerCapture(pointerId); } catch { // 忽略捕获释放错误 } }); } applyTransformStyle(); }, [applyTransformStyle]); // 关闭或切换时重置 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) => { 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) => { 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 }, }; } 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) => { 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) => { 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) => { // 释放指针捕获 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; } }, [] ); // 双击重置 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( () => ({ position: "fixed", inset: 0, zIndex: 9999, pointerEvents: "none", }), [] ); const scaledContainerStyle = useMemo( () => ({ 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 = ( {visible && (
{/* 背景蒙版 */} {maskSrc && ( )} {/* 返回按钮 */} {onClose && ( 返回 )}
{/* 展品描述播报 - 固定在左侧 */} {description && ( )} {/* 展品内容 - 在剩余空间中居中 */}
展品 {/* 可点击区域 */} {clickableAreas && clickableAreas.map((area, index) => (
{ e.stopPropagation(); area.onClick(); }} onPointerDown={(e) => { // 阻止拖拽事件 e.stopPropagation(); }} /> ))}
)} ); // 直接使用 Portal 渲染到 document.body return createPortal(content, document.body); }; export default ExhibitOverlay;