feat: 更新 ESLint 和 Prettier 配置,增强代码质量管理
- 在 .gitignore 中添加 public/audio/* 以忽略音频文件。 - 更新 bun.lock,添加 eslint-config-prettier 和 eslint-plugin-prettier 依赖,确保 ESLint 与 Prettier 的兼容性。 - 修改 eslint.config.ts,整合 Prettier 规则,优化代码风格一致性。 - 在 package.json 中添加 lint:fix 和 format 脚本,简化代码格式化和修复流程。 - 更新 tailwind.config.ts 和其他组件文件,统一代码风格,提升可读性。
This commit is contained in:
1 parent
5d00adc1a5
commit
76c67b492e
216 files changed
+12454
-13976
No files matched your search
@@ -1,21 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { cn, getBackgroundImage } from "@/lib/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
import { useEffect } from 'react'
|
||||
import { cn, getBackgroundImage } from '@/lib/utils'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useAudioManager } from '@/hooks/useAudioManager'
|
||||
|
||||
export interface ExhibitDescriptionNarrationProps {
|
||||
/** 描述文本 */
|
||||
text?: string;
|
||||
text?: string
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 是否自动播放音频(默认 true) */
|
||||
autoPlay?: boolean;
|
||||
autoPlay?: boolean
|
||||
/** 音频文件地址 */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
/** 额外的自定义类 */
|
||||
className?: string;
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,30 +34,27 @@ const ExhibitDescriptionNarration = ({
|
||||
}: ExhibitDescriptionNarrationProps) => {
|
||||
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
|
||||
const { currentSrc, isPlaying, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? { mode: "channel", channelId: audioChannelId }
|
||||
: { mode: "global" }
|
||||
);
|
||||
audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
|
||||
)
|
||||
|
||||
// 当前组件对应的音频是否正在播放
|
||||
const isCurrentAudioPlaying =
|
||||
audioSrc && currentSrc === audioSrc && isPlaying;
|
||||
const isCurrentAudioPlaying = audioSrc && currentSrc === audioSrc && isPlaying
|
||||
|
||||
// 自动播放音频
|
||||
useEffect(() => {
|
||||
if (visible && autoPlay && audioSrc) {
|
||||
play(audioSrc);
|
||||
play(audioSrc)
|
||||
}
|
||||
}, [visible, autoPlay, audioSrc, play]);
|
||||
}, [visible, autoPlay, audioSrc, play])
|
||||
|
||||
// 组件不可见时停止音频(仅当当前播放的是该组件的 audioSrc)
|
||||
useEffect(() => {
|
||||
if (!visible && audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
stop()
|
||||
}
|
||||
}, [visible, audioSrc, currentSrc, stop]);
|
||||
}, [visible, audioSrc, currentSrc, stop])
|
||||
|
||||
if (!text) return null;
|
||||
if (!text) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -68,9 +65,9 @@ const ExhibitDescriptionNarration = ({
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={cn(
|
||||
"max-w-[580px] px-12 py-14",
|
||||
"shadow-black/60",
|
||||
"relative overflow-hidden",
|
||||
'max-w-[580px] px-12 py-14',
|
||||
'shadow-black/60',
|
||||
'relative overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -84,12 +81,10 @@ const ExhibitDescriptionNarration = ({
|
||||
className="absolute inset-0 pointer-events-none box-border"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
border: "40px solid transparent",
|
||||
borderImageSource: getBackgroundImage(
|
||||
"components/narration/b.png"
|
||||
),
|
||||
borderImageSlice: "40 fill",
|
||||
borderImageRepeat: "stretch round",
|
||||
border: '40px solid transparent',
|
||||
borderImageSource: getBackgroundImage('components/narration/b.png'),
|
||||
borderImageSlice: '40 fill',
|
||||
borderImageRepeat: 'stretch round',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col gap-4">
|
||||
@@ -107,11 +102,7 @@ const ExhibitDescriptionNarration = ({
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
|
||||
aria-label="播放"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -122,11 +113,7 @@ const ExhibitDescriptionNarration = ({
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
|
||||
aria-label="停止"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5.25 3A2.25 2.25 0 003 5.25v9.5A2.25 2.25 0 005.25 17h9.5A2.25 2.25 0 0017 14.75v-9.5A2.25 2.25 0 0014.75 3h-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -137,7 +124,7 @@ const ExhibitDescriptionNarration = ({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ExhibitDescriptionNarration;
|
||||
export default ExhibitDescriptionNarration
|
||||
@@ -1,42 +1,35 @@
|
||||
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";
|
||||
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;
|
||||
src: string
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
onClose?: () => void
|
||||
/** 是否可见,默认 true */
|
||||
visible?: boolean;
|
||||
visible?: boolean
|
||||
/** 额外的自定义类 */
|
||||
className?: string;
|
||||
className?: string
|
||||
/** 展品描述文本 */
|
||||
description?: string;
|
||||
description?: string
|
||||
/** 是否自动播放描述语音,默认 false */
|
||||
autoPlayDescription?: boolean;
|
||||
autoPlayDescription?: boolean
|
||||
/** 展品描述对应的音频地址(可选) */
|
||||
audioSrc?: string;
|
||||
audioSrc?: string
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
audioChannelId?: string
|
||||
/** 点击展示 */
|
||||
onClick?: () => void;
|
||||
onClick?: () => void
|
||||
/** 打开回调 */
|
||||
onOpen?: () => void;
|
||||
onOpen?: () => void
|
||||
}
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
/**
|
||||
* 展品通用遮罩层
|
||||
@@ -58,215 +51,211 @@ const ExhibitOverlay = ({
|
||||
onClick,
|
||||
onOpen,
|
||||
}: ExhibitOverlayProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/close");
|
||||
const maskSrc = getStaticImage('components/蒙版遮罩')
|
||||
const backSrc = getStaticImage('components/close')
|
||||
|
||||
// 获取缩放参数,用于视觉对齐
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// 允许传入相对路径或绝对 URL
|
||||
const exhibitSrc =
|
||||
src.startsWith("http://") ||
|
||||
src.startsWith("https://") ||
|
||||
src.startsWith("/")
|
||||
src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/')
|
||||
? src
|
||||
: getStaticImage(src);
|
||||
: getStaticImage(src)
|
||||
|
||||
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
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);
|
||||
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 });
|
||||
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;
|
||||
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;
|
||||
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";
|
||||
}, []);
|
||||
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;
|
||||
if (frameRef.current) return
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
applyTransformStyle();
|
||||
frameRef.current = null;
|
||||
});
|
||||
}, [applyTransformStyle]);
|
||||
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);
|
||||
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);
|
||||
containerRef.current?.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
applyTransformStyle();
|
||||
}, [applyTransformStyle]);
|
||||
applyTransformStyle()
|
||||
}, [applyTransformStyle])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
onOpen?.();
|
||||
onOpen?.()
|
||||
}
|
||||
}, [visible, onOpen]);
|
||||
}, [visible, onOpen])
|
||||
|
||||
// 关闭或切换时重置
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
resetTransform();
|
||||
resetTransform()
|
||||
} else {
|
||||
// 不可见时清理所有状态和指针捕获
|
||||
transformRef.current = { scale: 1, x: 0, y: 0 };
|
||||
pointers.current.clear();
|
||||
lastDistance.current = null;
|
||||
dragStartRef.current = null;
|
||||
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);
|
||||
containerRef.current?.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
// 取消待处理的动画帧
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, src]);
|
||||
}, [visible, src])
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
const containerEl = containerRef.current;
|
||||
const pointersMap = pointers.current;
|
||||
const frameId = frameRef.current;
|
||||
const containerEl = containerRef.current
|
||||
const pointersMap = pointers.current
|
||||
const frameId = frameRef.current
|
||||
|
||||
return () => {
|
||||
// 清理所有指针捕获
|
||||
if (containerEl) {
|
||||
pointersMap.forEach((_, pointerId) => {
|
||||
try {
|
||||
containerEl.releasePointerCapture(pointerId);
|
||||
containerEl.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
// 取消待处理的动画帧
|
||||
if (frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
cancelAnimationFrame(frameId)
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 缩放函数:origin 是屏幕坐标,需要转换为相对于 viewElRef 的坐标
|
||||
const applyZoom = useCallback(
|
||||
(factor: number, origin: Point) => {
|
||||
if (!viewElRef.current || !containerRef.current) return;
|
||||
if (!viewElRef.current || !containerRef.current) return
|
||||
|
||||
const prev = transformRef.current;
|
||||
const nextScale = clampScale(prev.scale * factor);
|
||||
const prev = transformRef.current
|
||||
const nextScale = clampScale(prev.scale * factor)
|
||||
// 边界检查:避免除零
|
||||
if (prev.scale === 0) return;
|
||||
if (prev.scale === 0) return
|
||||
|
||||
// 获取 viewElRef 相对于视口的位置
|
||||
const viewRect = viewElRef.current.getBoundingClientRect();
|
||||
const viewRect = viewElRef.current.getBoundingClientRect()
|
||||
// 将屏幕坐标转换为相对于 viewElRef 的坐标
|
||||
const relativeOrigin = {
|
||||
x: origin.x - viewRect.left,
|
||||
y: origin.y - viewRect.top,
|
||||
};
|
||||
}
|
||||
|
||||
const scaleChange = nextScale / prev.scale;
|
||||
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();
|
||||
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;
|
||||
const delta = e.deltaY
|
||||
const factor = delta < 0 ? 1.05 : 0.95
|
||||
// 直接使用屏幕坐标作为缩放中心
|
||||
applyZoom(factor, { x: e.clientX, y: e.clientY });
|
||||
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 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;
|
||||
if (!containerRef.current || !viewElRef.current) return
|
||||
try {
|
||||
containerRef.current.setPointerCapture(e.pointerId);
|
||||
containerRef.current.setPointerCapture(e.pointerId)
|
||||
} catch (err) {
|
||||
console.warn("Failed to capture pointer:", err);
|
||||
console.warn('Failed to capture pointer:', err)
|
||||
}
|
||||
|
||||
// 记录屏幕坐标
|
||||
const point = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
pointers.current.set(e.pointerId, point);
|
||||
}
|
||||
pointers.current.set(e.pointerId, point)
|
||||
|
||||
// 单指拖拽:记录初始状态
|
||||
if (pointers.current.size === 1) {
|
||||
@@ -275,22 +264,22 @@ const ExhibitOverlay = ({
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startTransform: { ...transformRef.current },
|
||||
};
|
||||
setIsDragging(true);
|
||||
}
|
||||
setIsDragging(true)
|
||||
} else if (pointers.current.size === 2) {
|
||||
// 双指缩放:记录初始距离
|
||||
const values = Array.from(pointers.current.values());
|
||||
const values = Array.from(pointers.current.values())
|
||||
if (values.length === 2) {
|
||||
const [p1, p2] = values;
|
||||
const [p1, p2] = values
|
||||
if (p1 && p2) {
|
||||
lastDistance.current = getDistance(p1, p2);
|
||||
dragStartRef.current = null;
|
||||
lastDistance.current = getDistance(p1, p2)
|
||||
dragStartRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[getDistance]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 单指拖拽处理
|
||||
@@ -299,29 +288,29 @@ const ExhibitOverlay = ({
|
||||
*/
|
||||
const handleSingleDrag = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragStart = dragStartRef.current;
|
||||
if (!dragStart) return;
|
||||
const dragStart = dragStartRef.current
|
||||
if (!dragStart) return
|
||||
|
||||
// 1. 计算屏幕坐标系下的位移
|
||||
const dxScreen = e.clientX - dragStart.startX;
|
||||
const dyScreen = e.clientY - dragStart.startY;
|
||||
const dxScreen = e.clientX - dragStart.startX
|
||||
const dyScreen = e.clientY - dragStart.startY
|
||||
|
||||
// 2. 映射到设计稿坐标系(补偿全局 scale)
|
||||
const dx = dxScreen / scale;
|
||||
const dy = dyScreen / scale;
|
||||
const dx = dxScreen / scale
|
||||
const dy = dyScreen / scale
|
||||
|
||||
// 3. 应用到 transform(设计稿坐标)
|
||||
const nextX = dragStart.startTransform.x + dx;
|
||||
const nextY = dragStart.startTransform.y + dy;
|
||||
const nextX = dragStart.startTransform.x + dx
|
||||
const nextY = dragStart.startTransform.y + dy
|
||||
|
||||
transformRef.current.x = nextX;
|
||||
transformRef.current.y = nextY;
|
||||
transformRef.current.x = nextX
|
||||
transformRef.current.y = nextY
|
||||
|
||||
// 统一交给 rAF 的 scheduleUpdate,减少直接 DOM 操作次数
|
||||
scheduleUpdate();
|
||||
scheduleUpdate()
|
||||
},
|
||||
[scale, scheduleUpdate]
|
||||
);
|
||||
)
|
||||
|
||||
/**
|
||||
* 双指缩放处理
|
||||
@@ -329,73 +318,72 @@ const ExhibitOverlay = ({
|
||||
* - 使用两指中点作为缩放中心
|
||||
*/
|
||||
const handlePinchZoom = useCallback(() => {
|
||||
if (pointers.current.size !== 2) return;
|
||||
const values = Array.from(pointers.current.values());
|
||||
const [p1, p2] = values;
|
||||
if (!p1 || !p2) return;
|
||||
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);
|
||||
const distance = getDistance(p1, p2)
|
||||
if (!lastDistance.current || lastDistance.current <= 0) {
|
||||
lastDistance.current = distance;
|
||||
return;
|
||||
lastDistance.current = distance
|
||||
return
|
||||
}
|
||||
|
||||
const factor = distance / lastDistance.current;
|
||||
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]);
|
||||
applyZoom(factor, center)
|
||||
lastDistance.current = distance
|
||||
}, [getDistance, applyZoom])
|
||||
|
||||
// 指针移动:更新指针位置,然后按当前手势类型分发给拖拽 / 缩放处理
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!pointers.current.has(e.pointerId)) return;
|
||||
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;
|
||||
handlePinchZoom()
|
||||
return
|
||||
}
|
||||
|
||||
// 单指拖拽
|
||||
if (pointers.current.size === 1) {
|
||||
handleSingleDrag(e);
|
||||
handleSingleDrag(e)
|
||||
}
|
||||
},
|
||||
[handlePinchZoom, handleSingleDrag]
|
||||
);
|
||||
)
|
||||
|
||||
// 指针抬起
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragStart = dragStartRef.current;
|
||||
const dragStart = dragStartRef.current
|
||||
|
||||
// 检测是否是点击(移动距离很小)
|
||||
if (dragStart && dragStart.pointerId === e.pointerId && onClick) {
|
||||
// 计算从按下到抬起的移动距离
|
||||
const dx = e.clientX - dragStart.startX;
|
||||
const dy = e.clientY - dragStart.startY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const dx = e.clientX - dragStart.startX
|
||||
const dy = e.clientY - dragStart.startY
|
||||
const distance = Math.hypot(dx, dy)
|
||||
// 如果移动距离小于 5px,认为是点击而不是拖拽
|
||||
if (distance < 5) {
|
||||
// 检查 transform 是否改变,如果改变了说明是拖拽
|
||||
const transformChanged =
|
||||
Math.abs(transformRef.current.x - dragStart.startTransform.x) >
|
||||
0.1 ||
|
||||
Math.abs(transformRef.current.y - dragStart.startTransform.y) > 0.1;
|
||||
Math.abs(transformRef.current.x - dragStart.startTransform.x) > 0.1 ||
|
||||
Math.abs(transformRef.current.y - dragStart.startTransform.y) > 0.1
|
||||
|
||||
if (!transformChanged) {
|
||||
onClick();
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,76 +391,76 @@ const ExhibitOverlay = ({
|
||||
// 释放指针捕获
|
||||
if (containerRef.current) {
|
||||
try {
|
||||
containerRef.current.releasePointerCapture(e.pointerId);
|
||||
containerRef.current.releasePointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// 忽略捕获释放错误
|
||||
}
|
||||
}
|
||||
|
||||
pointers.current.delete(e.pointerId);
|
||||
pointers.current.delete(e.pointerId)
|
||||
|
||||
if (pointers.current.size < 2) {
|
||||
lastDistance.current = null;
|
||||
lastDistance.current = null
|
||||
}
|
||||
|
||||
// 如果剩余单指,重新记录拖拽初始状态
|
||||
if (pointers.current.size === 1) {
|
||||
const remaining = Array.from(pointers.current.values())[0];
|
||||
const remainingId = Array.from(pointers.current.keys())[0];
|
||||
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;
|
||||
dragStartRef.current = null
|
||||
}
|
||||
|
||||
setIsDragging(pointers.current.size > 0);
|
||||
setIsDragging(pointers.current.size > 0)
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
)
|
||||
|
||||
// 双击重置
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
resetTransform();
|
||||
}, [resetTransform]);
|
||||
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",
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
)
|
||||
|
||||
const scaledContainerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
overflow: "hidden",
|
||||
transformOrigin: 'top left',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
|
||||
);
|
||||
)
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!mounted) return null
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
@@ -483,16 +471,16 @@ const ExhibitOverlay = ({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<div style={scaledContainerStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
"bg-black/70",
|
||||
'absolute inset-0 flex items-center justify-center',
|
||||
'bg-black/70',
|
||||
className
|
||||
)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
{/* 背景蒙版 */}
|
||||
{maskSrc && (
|
||||
@@ -532,11 +520,11 @@ const ExhibitOverlay = ({
|
||||
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",
|
||||
left: description ? '520px' : '0', // 30 + 450 + 30
|
||||
right: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -545,9 +533,9 @@ const ExhibitOverlay = ({
|
||||
style={{
|
||||
maxWidth: containerSize,
|
||||
maxHeight: containerSize,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -560,16 +548,16 @@ const ExhibitOverlay = ({
|
||||
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",
|
||||
transform: 'translate3d(0px, 0px, 0) scale(1)',
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
// iOS 微信浏览器优化
|
||||
WebkitTransform: "translate3d(0px, 0px, 0) scale(1)",
|
||||
WebkitBackfaceVisibility: "hidden",
|
||||
backfaceVisibility: "hidden",
|
||||
WebkitTransform: 'translate3d(0px, 0px, 0) scale(1)',
|
||||
WebkitBackfaceVisibility: 'hidden',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 强制硬件加速
|
||||
WebkitPerspective: "1000",
|
||||
perspective: "1000",
|
||||
WebkitPerspective: '1000',
|
||||
perspective: '1000',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
@@ -586,10 +574,10 @@ const ExhibitOverlay = ({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
)
|
||||
|
||||
// 直接使用 Portal 渲染到 document.body
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
export default ExhibitOverlay;
|
||||
export default ExhibitOverlay
|
||||
@@ -1,4 +1,4 @@
|
||||
export { default as ExhibitOverlay } from "./ExhibitOverlay";
|
||||
export type { ExhibitOverlayProps } from "./ExhibitOverlay";
|
||||
export { default as ExhibitDescriptionNarration } from "./ExhibitDescriptionNarration";
|
||||
export type { ExhibitDescriptionNarrationProps } from "./ExhibitDescriptionNarration";
|
||||
export { default as ExhibitOverlay } from './ExhibitOverlay'
|
||||
export type { ExhibitOverlayProps } from './ExhibitOverlay'
|
||||
export { default as ExhibitDescriptionNarration } from './ExhibitDescriptionNarration'
|
||||
export type { ExhibitDescriptionNarrationProps } from './ExhibitDescriptionNarration'
|
||||
Reference in new issue
Block a user