指针状态机与捕获
SVG 上的拖拽不是 mousedown + mousemove。要处理移出元素、多指、触控取消、中途抬起。没有状态机就会「粘手」。坐标一律 client → inverse(getScreenCTM),见 getScreenCTM。
1. 状态
type PointerKind = "mouse" | "pen" | "touch";
type DragPhase =
| { kind: "idle" }
| { kind: "pending" | "drag"; pointerId: number; pointerType: PointerKind; originClient: { x: number; y: number } }
| { kind: "pinch"; a: number; b: number };
const SLACK_PX = 4;
pending → drag 用 屏幕像素 阈值,避免把点击当拖拽。框选、平移相机、拖节点是三种工具,不要三个监听一起改同一份 camera。
2. 捕获与 pointerId
export function bindSvgDrag(
svg: SVGSVGElement,
options: {
onClick: (event: PointerEvent) => void;
onDragStart: (event: PointerEvent) => void;
onDrag: (event: PointerEvent) => void;
onDragEnd: (event: PointerEvent) => void;
onCancel: () => void;
},
): () => void {
let phase: DragPhase = { kind: "idle" };
const onDown = (event: PointerEvent): void => {
if (phase.kind !== "idle") return;
if (event.button !== 0 && event.pointerType === "mouse") return;
svg.setPointerCapture(event.pointerId);
phase = {
kind: "pending",
pointerId: event.pointerId,
pointerType: event.pointerType as PointerKind,
originClient: { x: event.clientX, y: event.clientY },
};
};
const onMove = (event: PointerEvent): void => {
if (phase.kind === "pending" && event.pointerId === phase.pointerId) {
const dx = event.clientX - phase.originClient.x;
const dy = event.clientY - phase.originClient.y;
if (dx * dx + dy * dy >= SLACK_PX * SLACK_PX) {
options.onDragStart(event);
phase = { ...phase, kind: "drag" };
options.onDrag(event);
}
return;
}
if (phase.kind === "drag" && event.pointerId === phase.pointerId) {
options.onDrag(event);
}
};
const finish = (event: PointerEvent, cancelled: boolean): void => {
if (phase.kind === "pending" && event.pointerId === phase.pointerId) {
if (!cancelled) options.onClick(event);
phase = { kind: "idle" };
return;
}
if (phase.kind === "drag" && event.pointerId === phase.pointerId) {
if (cancelled) options.onCancel();
else options.onDragEnd(event);
phase = { kind: "idle" };
}
};
const onUp = (event: PointerEvent): void => finish(event, false);
const onCancel = (event: PointerEvent): void => finish(event, true);
svg.addEventListener("pointerdown", onDown);
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onCancel);
svg.addEventListener("lostpointercapture", onCancel);
return () => {
svg.removeEventListener("pointerdown", onDown);
svg.removeEventListener("pointermove", onMove);
svg.removeEventListener("pointerup", onUp);
svg.removeEventListener("pointercancel", onCancel);
svg.removeEventListener("lostpointercapture", onCancel);
};
}
setPointerCapture 后移出 SVG、经过 overlay,仍能收到 move/up。不 capture 的典型 bug:拖到工具栏松开,节点永远 dragging。
只认绑定的 pointerId。第二根手指不要续上第一根的 drag。多指捏合升级为 pinch,任一指抬起就结束手势,不要把剩下那指接着当拖节点。
touch-action: none 写在 这棵 svg(或画布容器)上,不要全局禁页面滚动。
3. 不要用 movementX 累加
movementX/Y 在浏览器缩放、外接屏 DPI、pointer lock、部分 Safari 上会漂。位移用:
- 屏幕空间差:
clientX - lastClientX(橡皮筋、阈值) - 用户空间差:两次
clientToLocal相减(改x/transform)
把每帧的局部点写成 绝对值(按下时记录 local - nodeOrigin 作为 grab offset),不要 node.x += movement。后者在丢帧、coalesced 合并后会积分误差。
笔刷采点用 getCoalescedEvents();拖矩形不需要。
4. 失败形态
| 症状 | 原因 |
|---|---|
| 拖出 svg 就粘住 | 没 setPointerCapture / 没听 pointercancel |
| 点击变成拖 | 没有 pending 像素阈值 |
| 节点越拖越偏 | 用 movementX 累加,或每帧叠乘 transform |
| 双指缩放后乱飞 | pinch 结束把单指当 drag 续上 |
| 移动端拖图变成滚页面 | 没 touch-action: none |
权威资料
核对日期:2026-08-26