feat:init
This commit is contained in:
1 parent
53d0eeac6b
commit
ae0c050010
494 files changed
+17701
-2318
No files matched your search
@@ -1,9 +1,14 @@
|
||||
export { useDragAndDrop } from "./useDragAndDrop";
|
||||
export { usePageScale } from "./usePageScale";
|
||||
export { useTouch } from "./useTouch";
|
||||
export { useZoomAndDrag } from "./useZoomAndDrag";
|
||||
export type {
|
||||
UseTouchOptions,
|
||||
UseTouchReturn,
|
||||
TouchEventData,
|
||||
SwipeDirection,
|
||||
} from "./useTouch";
|
||||
export type {
|
||||
UseZoomAndDragOptions,
|
||||
UseZoomAndDragReturn,
|
||||
} from "./useZoomAndDrag";
|
||||
@@ -13,12 +13,19 @@ export function usePageScale() {
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
|
||||
|
||||
// 是否移动端竖屏(需要旋转横屏展示)
|
||||
const isMobilePortrait = useMemo(() => {
|
||||
const ua = window.navigator.userAgent.toLowerCase();
|
||||
const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua);
|
||||
return isMobile && windowHeight > windowWidth;
|
||||
}, [windowHeight, windowWidth]);
|
||||
|
||||
// 缩放比例
|
||||
const scale = useMemo(() => {
|
||||
const scaleX = windowWidth / DESIGN_WIDTH;
|
||||
const scaleY = windowHeight / DESIGN_HEIGHT;
|
||||
|
||||
// 取较小的缩放比例,确保内容完全显示在视窗内
|
||||
const baseWidth = DESIGN_WIDTH;
|
||||
const baseHeight = DESIGN_HEIGHT;
|
||||
const scaleX = windowWidth / baseWidth;
|
||||
const scaleY = windowHeight / baseHeight;
|
||||
return Math.min(scaleX, scaleY);
|
||||
}, [windowWidth, windowHeight]);
|
||||
|
||||
@@ -39,13 +46,13 @@ export function usePageScale() {
|
||||
// 缩放样式对象
|
||||
const scaleStyle = useMemo(
|
||||
() => ({
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
position: "absolute" as const,
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
position: "absolute" as const,
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
}),
|
||||
[scale, offsetX, offsetY]
|
||||
);
|
||||
@@ -78,6 +85,7 @@ export function usePageScale() {
|
||||
|
||||
// 样式对象
|
||||
scaleStyle,
|
||||
isMobilePortrait,
|
||||
|
||||
// 常量
|
||||
DESIGN_WIDTH,
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
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<HTMLDivElement>;
|
||||
/** 视图元素 ref */
|
||||
viewElRef: React.RefObject<HTMLDivElement>;
|
||||
/** 重置变换 */
|
||||
resetTransform: () => void;
|
||||
/** 滚轮事件处理 */
|
||||
handleWheel: (e: React.WheelEvent<HTMLDivElement>) => void;
|
||||
/** 指针按下事件处理 */
|
||||
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
/** 指针移动事件处理 */
|
||||
handlePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
/** 指针抬起事件处理 */
|
||||
handlePointerUp: (e: React.PointerEvent<HTMLDivElement>) => 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<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 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<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 },
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 双击重置
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
resetTransform();
|
||||
}, [resetTransform]);
|
||||
|
||||
return {
|
||||
containerRef: containerRef as React.RefObject<HTMLDivElement>,
|
||||
viewElRef: viewElRef as React.RefObject<HTMLDivElement>,
|
||||
resetTransform,
|
||||
handleWheel,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleDoubleClick,
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user