import { useRef, useCallback, useEffect } from "react"; import { useScale } from "@/contexts/ScaleContext"; type Point = { x: number; y: number }; export interface UseZoomAndDragOptions { /** 最小缩放比例,默认 0.8 */ minScale?: number; /** 最大缩放比例,默认 3 */ maxScale?: number; /** 是否可见,用于重置状态 */ visible?: boolean; } export interface UseZoomAndDragReturn { /** 容器 ref */ containerRef: React.RefObject; /** 视图元素 ref */ viewElRef: React.RefObject; /** 重置变换 */ resetTransform: () => void; /** 滚轮事件处理 */ handleWheel: (e: React.WheelEvent) => void; /** 指针按下事件处理 */ handlePointerDown: (e: React.PointerEvent) => void; /** 指针移动事件处理 */ handlePointerMove: (e: React.PointerEvent) => void; /** 指针抬起事件处理 */ handlePointerUp: (e: React.PointerEvent) => void; /** 双击事件处理 */ handleDoubleClick: () => void; } /** * 缩放和拖拽 Hook * 提供图片/内容的缩放、拖拽、双击重置功能 */ export const useZoomAndDrag = ( options: UseZoomAndDragOptions = {} ): UseZoomAndDragReturn => { const { minScale = 0.8, maxScale = 3, visible = true } = options; const { scale } = useScale(); const containerRef = useRef(null); const viewElRef = useRef(null); const frameRef = useRef(null); const pointers = useRef>(new Map()); const lastDistance = useRef(null); 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 }); // 针对当前 scale 预计算 clamp const clampScale = useCallback( (next: number) => Math.min(maxScale, Math.max(minScale, next)), [minScale, maxScale] ); 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]); // 组件卸载时清理资源 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]); return { containerRef: containerRef as React.RefObject, viewElRef: viewElRef as React.RefObject, resetTransform, handleWheel, handlePointerDown, handlePointerMove, handlePointerUp, handleDoubleClick, }; };