跳到主要内容

指针状态机与捕获

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