指针状态机与捕获
Canvas 上的拖拽不是 mousedown + mousemove。要处理:移出元素、多指、触控取消、触控笔 hover、按钮中途抬起。没有状态机就会出现「粘手」和「丢拖」。
1. 状态
type PointerKind = "mouse" | "pen" | "touch";
type DragPhase =
| { kind: "idle" }
| {
kind: "pending";
pointerId: number;
pointerType: PointerKind;
originView: { x: number; y: number };
}
| {
kind: "drag";
pointerId: number;
pointerType: PointerKind;
originView: { x: number; y: number };
lastView: { x: number; y: number };
}
| {
kind: "pinch";
a: number;
b: number;
};
const SLACK_PX = 4;
pending → drag 用位移阈值,避免把点击当拖拽。框选、平移、节点拖是三种 工具,不要写成三个并行 mousemove 监听互相改同一个 camera。
2. 捕获
export function bindDrag(
canvas: HTMLCanvasElement,
options: {
onClick: (view: { x: number; y: number }, event: PointerEvent) => void;
onDragStart: (view: { x: number; y: number }, event: PointerEvent) => void;
onDrag: (view: { x: number; y: number }, event: PointerEvent) => void;
onDragEnd: (view: { x: number; y: number }, event: PointerEvent) => void;
onCancel: () => void;
},
): () => void {
let phase: DragPhase = { kind: "idle" };
const viewOf = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
};
const onDown = (event: PointerEvent): void => {
if (phase.kind !== "idle") return;
if (event.button !== 0 && event.pointerType === "mouse") return;
canvas.setPointerCapture(event.pointerId);
phase = {
kind: "pending",
pointerId: event.pointerId,
pointerType: event.pointerType as PointerKind,
originView: viewOf(event),
};
};
const onMove = (event: PointerEvent): void => {
if (phase.kind === "pending" && event.pointerId === phase.pointerId) {
const view = viewOf(event);
const dx = view.x - phase.originView.x;
const dy = view.y - phase.originView.y;
if (dx * dx + dy * dy >= SLACK_PX * SLACK_PX) {
options.onDragStart(phase.originView, event);
phase = { ...phase, kind: "drag", lastView: view };
options.onDrag(view, event);
}
return;
}
if (phase.kind === "drag" && event.pointerId === phase.pointerId) {
const view = viewOf(event);
phase.lastView = view;
options.onDrag(view, event);
}
};
const finish = (event: PointerEvent, cancelled: boolean): void => {
if (phase.kind === "pending" && event.pointerId === phase.pointerId) {
if (!cancelled) options.onClick(phase.originView, event);
phase = { kind: "idle" };
return;
}
if (phase.kind === "drag" && event.pointerId === phase.pointerId) {
if (cancelled) options.onCancel();
else options.onDragEnd(viewOf(event), event);
phase = { kind: "idle" };
}
};
const onUp = (event: PointerEvent): void => finish(event, false);
const onCancel = (event: PointerEvent): void => finish(event, true);
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onCancel);
canvas.addEventListener("lostpointercapture", onCancel);
return () => {
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onCancel);
canvas.removeEventListener("lostpointercapture", onCancel);
};
}
setPointerCapture 后即使移出 canvas 仍能收到 move/up。不 capture 的典型 bug:拖到工具栏松开,节点永远 dragging=true。
触控滚动:touch-action: none 写在 canvas 上,否则浏览器抢垂直平移。不要全局禁掉整个页面。
3. 多指捏合
第二根手指 down 时,若已在 pending/drag,升级为 pinch:记录两指 view 距离与中点世界坐标。move 时用距离比当 zoomAt 的 factor,中点当锚点。任一指 up 则结束手势,不要把剩下那指接着当拖拽——状态会跳。
笔 + 手掌:event.pointerType === "touch" 且已有 pen drag 时忽略 touch(掌误触)。width/height 很大的 touch 也可当 palm。
4. coalesced events
笔刷和墨迹用 event.getCoalescedEvents(),否则 120Hz 笔在 60Hz rAF 下会丢点。见 白板笔刷。普通拖节点不需要,用最后一点即可。
5. 失败形态
| 症状 | 原因 |
|---|---|
| 拖出画布就卡在拖拽态 | 没 capture / 没听 pointercancel |
| 点击也变成拖 | 没有 pending 阈值 |
| 移动端拖画布变成滚页面 | 没 touch-action: none |
| 双指缩放后乱飞 | pinch 结束把单指当 drag 续上 |
| 笔在边缘点稀 | 没用 coalesced |
权威资料
核对日期:2026-08-26