Canvas 图元交互内核 — 命中优先级、拖拽、框选,编辑器的交互引擎
前面积累的所有零件今天组装成一个模块:InteractionManager(交互管理器)。它统一处理编辑器类产品的全部交互:点击选中(谁在上面选中谁——命中优先级)、hover 悬停(光标反馈 + 高亮)、拖拽移动(含 viewport 缩放后的坐标逆变换——Day 38 的知识在这里落地)、框选(拖出的矩形与图元求交——Day 45 的 AABB 再就业)。这个模块是明天 BOSS 战编辑器交互层的全部——今天做扎实,明天只管组装。
目录
- 一、交互内核要解决的五个问题
- 二、命中优先级:谁在上面选中谁
- 三、hover 与光标反馈
- 四、拖拽:从屏幕坐标到场景坐标
- 五、框选:矩形求交与批量选中
- 六、实战:InteractionManager 完整实现
- 七、交互的状态机视角
- 八、常见坑点与最佳实践
- 九、自测挑战
- 十、总结与知识图谱
一、交互内核要解决的五个问题
① 点击 → 选中哪个图元?(命中 + 优先级)
② 悬停 → 哪个图元在鼠标下?(hover + 光标形状)
③ 拖动 → 图元怎么跟着鼠标走?(拖拽 + 坐标换算)
④ 空白处拖动 → 框选哪些图元?(框选 + 矩形求交)
⑤ 这些动作之间怎么互斥/流转?(状态机)
本周知识的"零件清单"
Day 34:坐标换算(eventToCanvas) → 一切交互的入口
Day 38:viewport 逆变换(screenToScene) → 缩放场景里的拖拽
Day 45:AABB 相交判定 → 命中检测 + 框选求交
Day 46:z 序纪律(数组序 = 绘制序) → 命中优先级
Day 47:多层架构的 ui 层 → 交互反馈的画布
二、命中优先级:谁在上面选中谁
2.1 z 序与命中序的一致性
/**
* 命中检测的铁律:绘制顺序 = 层次顺序 = 命中的优先顺序
*
* items 数组的【末尾】画得最晚 → 显示在最上层 → 点击时最先被命中
* → 命中检测【倒序遍历】数组
*/
function hitTest(items: Item[], p: { x: number; y: number }): Item | null {
for (let i = items.length - 1; i >= 0; i--) { // 倒序:最上层优先
const it = items[i];
if (pointInBox(p, it.box)) return it; // Day 45 的点-矩形包含
}
return null;
}
interface Item {
id: string;
box: { x: number; y: number; w: number; h: number }; // 场景坐标
}
2.2 “选中置顶”:动态调整 z 序
/**
* 用户点了一个图元 → 通常希望它"浮到最上层"(否则总被别人挡)
* 编辑器的标准行为:选中即置顶
*/
function bringToFront(items: Item[], target: Item): void {
const idx = items.indexOf(target);
if (idx < 0) return;
items.splice(idx, 1); // 从原位置摘出
items.push(target); // 放到数组尾 = 最上层
}
// ⚠ 副作用:取消选中后图元不回原层级(大部分编辑器接受这个行为)
// 想保持原层级:给 Item 加 z: number 字段,命中时按 z 排序的视图遍历
2.3 多类型图元的命中差异
/**
* 不同形状的图元用不同命中算法(Day 45 全家福):
* 圆形图元 → 点-圆(距离平方比较,比 AABB 包围盒更贴合视觉)
*/
type Shape =
| { kind: "rect"; x: number; y: number; w: number; h: number }
| { kind: "circle"; x: number; y: number; r: number };
function hitShape(p: { x: number; y: number }, s: Shape): boolean {
switch (s.kind) {
case "rect":
return p.x >= s.x && p.x <= s.x + s.w && p.y >= s.y && p.y <= s.y + s.h;
case "circle": {
const dx = p.x - s.x, dy = p.y - s.y;
return dx * dx + dy * dy < s.r * s.r;
}
}
}
// 可辨识联合 + switch + narrow —— TS 阶段的老朋友了
三、hover 与光标反馈
3.1 hover 检测:mousemove 的轻量用途
/**
* hover 的性能要点:
* ① mousemove 触发频率极高(高刷鼠标 1000Hz)→ 检测必须便宜
* ② hover 结果通常不变(还在同一个图元上)→ 变了才触发重绘
*/
let hoveredId: string | null = null;
function onMouseMove(p: { x: number; y: number }): void {
const hit = hitTest(items, p);
const newHovered = hit?.id ?? null;
if (newHovered !== hoveredId) { // ⭐ 只有变化才干活
hoveredId = newHovered;
updateCursor(hit);
renderUI(); // 重绘高亮(只画 ui 层)
}
}
3.2 光标形状映射
/**
* 光标反馈:告诉用户"这里能干嘛"
* canvas 的 CSS cursor 属性直接设置
*/
function updateCursor(hit: Item | null): void {
if (dragState.active) {
canvas.style.cursor = "grabbing"; // 拖拽中:抓手
} else if (hit) {
canvas.style.cursor = hit.resizable ? "nwse-resize" : "move"; // 图元上:移动/缩放
} else if (selectionBox.active) {
canvas.style.cursor = "crosshair"; // 框选中:十字
} else {
canvas.style.cursor = "default"; // 空白:默认
}
}
// ⚠ 光标更新是 CSS 层面的事,与画布内容无关——别用画布画光标!
3.3 常用光标速查
default 默认箭头
move 四向移动(悬停在可拖对象上)
grab 抓手(可拖的画布/面板)
grabbing 抓取中(正在拖)
crosshair 十字(框选/画线起点)
nwse-resize / nesw-resize 斜向缩放(角落控制点)
pointer 手指(可点击)—— 原生给链接用的,画布场景少用
四、拖拽:从屏幕坐标到场景坐标
4.1 拖拽三阶段(Day 34/35 的深化)
// mousedown:记录"抓取点相对图元的偏移"(offset)
// ⭐ 为什么不直接把图元中心贴到鼠标?——那样图元会"跳"一下
offset = {
dx: sceneP.x - item.box.x, // 鼠标(场景坐标) - 图元左上角
dy: sceneP.y - item.box.y,
};
// mousemove:新位置 = 鼠标(场景坐标) - offset
item.box.x = sceneP.x - offset.dx;
item.box.y = sceneP.y - offset.dy;
// mouseup(在 window 上监听!Day 35 的老坑):结束拖拽
4.2 viewport 场景里的拖拽(Day 38 落地)
/**
* ⭐ 本节核心:画面缩放/平移过,鼠标的屏幕坐标必须先转成场景坐标
*
* 错误示范(没缩放时对,缩放后图元"跟不上"鼠标):
* item.x = 屏幕坐标.x - offset.dx; // 拿屏幕坐标当场景坐标
* 缩放 2 倍时:鼠标动 10px,图元视觉动 20px(item 也应该只动 5)
*/
const view = { zoom: 1, panX: 0, panY: 0 }; // Day 38 的 viewport
function screenToScene(sx: number, sy: number): { x: number; y: number } {
return {
x: (sx - view.panX) / view.zoom, // Day 38 推导过的逆变换
y: (sy - view.panY) / view.zoom,
};
}
// 拖拽的正确打开方式:
canvas.addEventListener("mousemove", (e) => {
if (!dragState.active) return;
const screen = eventToCanvas(canvas, e); // ① 视口 → 画布 CSS 坐标
const scene = screenToScene(screen.x, screen.y); // ② CSS → 场景坐标 ⭐
dragState.item.box.x = scene.x - dragState.dx; // ③ 场景坐标里更新图元
dragState.item.box.y = scene.y - dragState.dy;
renderScene();
});
4.3 拖拽的坐标系换算链(全景图)
鼠标事件 e.clientX/e.clientY(视口坐标)
│ eventToCanvas(getBoundingClientRect)
▼
画布 CSS 坐标(屏幕像素)
│ screenToScene(viewport 逆变换)
▼
场景坐标(图元 box 生活的世界)
│ renderScene 时 ctx.transform(zoom, 0, 0, zoom, panX, panY)
▼
又被变换回屏幕显示 —— 一个闭环
记住这个链条,任何"图元跟不上鼠标"的 bug 都能定位:
在哪一环拿错了坐标系?
五、框选:矩形求交与批量选中
5.1 框选的交互定义
空白处按下 → 拖动 → 出现虚线选框 → 松开 → 框住的图元全部选中
┌────────────────────┐
│ ╔════════╗ │ ╔═╗ = 框选矩形(拖动中实时更新)
│ ║ ┌──┐ ║ ┌──┐ │ ┌──┐ = 图元
│ ║ │① │ ║ │② │ │ ① 被框住(选)
│ ║ └──┘▓▓▓║ └──┘ │ ▓▓▓ 与选框相交但没被完全框住(看策略)
│ ╚════════╝ │ ② 框外(不选)
└────────────────────┘
5.2 框选的两种语义
/**
* 完全包含(containment):图元完全在选框内才选中
* 适用:精细操作(流程图编辑器常用)
*
* 相交即可(intersection):碰着就算选中
* 适用:粗放圈选(图标桌面常用)
*/
function selectByBox(box: Rect, mode: "contain" | "intersect"): Item[] {
return items.filter((it) =>
mode === "contain" ? boxContains(box, it.box) : rectsIntersect(box, it.box)
);
}
/** box 完全包含 inner */
function boxContains(box: Rect, inner: Rect): boolean {
return inner.x >= box.x
&& inner.y >= box.y
&& inner.x + inner.w <= box.x + box.w
&& inner.y + inner.h <= box.y + box.h;
}
// rectsIntersect 是 Day 45/46 反复用的老朋友,直接复用
5.3 框选矩形的方向问题
/**
* ⭐ 经典坑:往左上方向拖动选框
* 起点 (300,200),拖到 (100,80) → naive 的 rect = (300,200,w=100,h=80)?❌
* w/h 算出负数或矩形位置错误!
*
* 标准化:min/max 重算左上角和宽高
*/
function normalizeRect(a: { x: number; y: number }, b: { x: number; y: number }): Rect {
return {
x: Math.min(a.x, b.x),
y: Math.min(a.y, b.y),
w: Math.abs(a.x - b.x),
h: Math.abs(a.y - b.y),
};
}
5.4 框选期间的实时预览
// mousemove 中(框选激活时):
// ① 更新选框(normalizeRect)
// ② ui 层画虚线选框
// ③ 实时高亮"将要被选中"的图元(体验加分项)
function renderMarquee(a: { x: number; y: number }, b: { x: number; y: number }): void {
const rect = normalizeRect(a, b);
uiCtx.clearRect(0, 0, W, H);
uiCtx.strokeStyle = "#00c6ff";
uiCtx.fillStyle = "rgba(0,198,255,0.08)";
uiCtx.lineWidth = 1;
uiCtx.setLineDash([6, 4]);
uiCtx.fillRect(rect.x, rect.y, rect.w, rect.h); // 淡底
uiCtx.strokeRect(rect.x, rect.y, rect.w, rect.h); // 虚线框
uiCtx.setLineDash([]);
// 预高亮
for (const it of selectByBox(rect, "contain")) {
uiCtx.strokeStyle = "rgba(0,255,136,0.8)";
uiCtx.strokeRect(it.box.x - 2, it.box.y - 2, it.box.w + 4, it.box.h + 4);
}
}
六、实战:InteractionManager 完整实现
把今天所有内容组装成一个可复用的交互管理器。
6.1 完整代码
// day48-interaction.ts —— InteractionManager 交互内核
// (多层画布的 ui 层负责反馈渲染;本模块管理交互状态与图元更新)
interface Rect { x: number; y: number; w: number; h: number }
export interface InteractiveItem {
id: string;
box: Rect; // 场景坐标
selected: boolean;
}
/** 交互模式(同一时刻只处于一种——第七节状态机) */
type Mode =
| { kind: "idle" } // 待命
| { kind: "hover"; itemId: string } // 悬停某图元
| { kind: "drag"; itemId: string; dx: number; dy: number } // 拖拽(dx/dy=抓取偏移,场景坐标)
| { kind: "marquee"; origin: { x: number; y: number } }; // 框选(origin=起点,场景坐标)
export class InteractionManager {
private mode: Mode = { kind: "idle" };
constructor(
private items: InteractiveItem[], // 图元数组(数组序 = z 序)
private view: { zoom: number; panX: number; panY: number }, // viewport 引用
private hooks: {
renderUI: () => void; // 反馈重绘(ui 层)
renderScene: () => void; // 图元重绘(scene 层)
onSelectionChange: (ids: string[]) => void; // 选中集变化的回调
},
) {}
// ===== 坐标换算(Day 34/38)=====
private toScene(e: MouseEvent): { x: number; y: number } {
const rect = (e.target as HTMLElement).getBoundingClientRect();
const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
return {
x: (sx - this.view.panX) / this.view.zoom,
y: (sy - this.view.panY) / this.view.zoom,
};
}
// ===== 命中(第二节)=====
private hitTest(p: { x: number; y: number }): InteractiveItem | null {
for (let i = this.items.length - 1; i >= 0; i--) {
const b = this.items[i].box;
if (p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h) {
return this.items[i];
}
}
return null;
}
// ===== 事件入口 =====
onMouseDown(e: MouseEvent): void {
const p = this.toScene(e);
const hit = this.hitTest(p);
if (e.shiftKey) { // Shift:追加选择模式
if (hit) { hit.selected = !hit.selected; this.emitSelection(); }
return;
}
if (hit) {
// 点中图元:进入拖拽模式
this.clearSelection();
hit.selected = true;
this.emitSelection();
this.mode = {
kind: "drag",
itemId: hit.id,
dx: p.x - hit.box.x, // 抓取偏移(场景坐标)
dy: p.y - hit.box.y,
};
this.hooks.renderUI();
} else {
// 空白:进入框选模式
this.clearSelection();
this.mode = { kind: "marquee", origin: p };
}
}
onMouseMove(e: MouseEvent): void {
const p = this.toScene(e);
switch (this.mode.kind) {
case "idle": {
// hover 检测(第三节:变了才干活)
const hit = this.hitTest(p);
const newMode: Mode = hit ? { kind: "hover", itemId: hit.id } : { kind: "idle" };
if (newMode.kind !== this.mode.kind || (newMode.kind === "hover" && newMode.itemId !== (this.mode as {itemId?: string}).itemId)) {
this.mode = newMode;
this.updateCursor(e, hit !== null);
this.hooks.renderUI();
}
break;
}
case "drag": {
// 拖拽(第四节:场景坐标 - 偏移)
const item = this.items.find((i) => i.id === this.mode.kind && false || i.id === (this.mode as { itemId: string }).itemId);
if (item) {
item.box.x = p.x - (this.mode as { dx: number }).dx;
item.box.y = p.y - (this.mode as { dy: number }).dy;
this.hooks.renderScene();
}
break;
}
case "marquee": {
// 框选预览(第五节)
this.renderMarqueePreview(this.mode.origin, p);
break;
}
}
}
onMouseUp(e: MouseEvent): void {
if (this.mode.kind === "marquee") {
const p = this.toScene(e);
const box = this.normalizeRect(this.mode.origin, p);
// 完全包含语义的批量选中
for (const it of this.items) {
it.selected = this.boxContains(box, it.box);
}
this.emitSelection();
}
this.mode = { kind: "idle" };
this.updateCursor(e, false);
this.hooks.renderUI();
}
// ===== 工具方法 =====
private clearSelection(): void {
for (const it of this.items) it.selected = false;
}
private emitSelection(): void {
this.hooks.onSelectionChange(this.items.filter((i) => i.selected).map((i) => i.id));
}
private normalizeRect(a: { x: number; y: number }, b: { x: number; y: number }): Rect {
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(a.x - b.x), h: Math.abs(a.y - b.y) };
}
private boxContains(box: Rect, inner: Rect): boolean {
return inner.x >= box.x && inner.y >= box.y
&& inner.x + inner.w <= box.x + box.w && inner.y + inner.h <= box.y + box.h;
}
private updateCursor(_e: MouseEvent, overItem: boolean): void {
const el = _e.target as HTMLElement;
if (this.mode.kind === "drag") el.style.cursor = "grabbing";
else if (this.mode.kind === "marquee") el.style.cursor = "crosshair";
else el.style.cursor = overItem ? "move" : "default";
}
private marqueeBox: Rect | null = null;
private renderMarqueePreview(a: { x: number; y: number }, b: { x: number; y: number }): void {
this.marqueeBox = this.normalizeRect(a, b);
// 实时高亮预选中的图元(第五节 5.4 的逻辑)
for (const it of this.items) it.selected = this.boxContains(this.marqueeBox, it.box);
this.hooks.renderUI();
}
}
/**
* ⚠ 以上 mode 的类型 narrow 写法为教学展开示意(实际项目建议把
* drag/marquee 的字段访问拆成独立私有方法,避免 as 断言)——
* 这是"可辨识联合"在复杂分支下的工程化处理课题。
*/
6.2 使用方式
// 组装(与 Day 47 多层架构配合):
const im = new InteractionManager(devices, view, {
renderUI, // ui 层:画选中框/框选虚线
renderScene, // scene 层:画图元
onSelectionChange: (ids) => statusbar.setText(`已选 ${ids.length} 项`),
});
uiCanvas.addEventListener("mousedown", (e) => im.onMouseDown(e));
uiCanvas.addEventListener("mousemove", (e) => im.onMouseMove(e));
window.addEventListener("mouseup", (e) => im.onMouseUp(e)); // window 兜底(Day 35)
七、交互的状态机视角
7.1 把交互画成状态机
点中图元
┌─────────┐ ────────────→ ┌─────────┐
│ idle │ │ drag │
│ (待命) │ ←──────────── │ (拖拽中) │
└─────────┘ 松开鼠标 └─────────┘
│ ↑ │
悬停图元│ │移出图元 │拖到空白
↓ │ ↓ (不可能:mousedown 已定模式)
┌─────────┐ ┌──────────┐
│ hover │ 空白按下 │ marquee │
│ (悬停) │ ────────────→ │ (框选中) │──松开──→ 回 idle + 批量选中
└─────────┘ └──────────┘
7.2 状态机带来的纪律
① 同一时刻只在一个模式 → 拖拽时不可能触发框选(天然互斥)
② 模式切换点明确 → 每次切换做清理(如 marquee 结束清掉虚线)
③ 新增交互 = 新状态 → 加"画线模式"(BOSS 战要用)只需加一个分支
回顾 TS 阶段的可辨识联合:Mode 类型就是状态机的类型化表达,
漏处理任何状态的分支,编译器替你查岗(Day 34 粒子状态机的同款思路)。
八、常见坑点与最佳实践
| # | 坑 | 症状 | 解法 |
|---|---|---|---|
| 1 | 命中检测正序遍历 | 重叠图元总选中最底下的 | 倒序(数组尾 = 最上层) |
| 2 | 缩放场景里拿屏幕坐标更新图元 | 图元跟不上鼠标/速度异常 | screenToScene 逆变换 |
| 3 | 框选只支持右下方向拖 | 往左上拖出负宽高矩形 | normalizeRect 归一化 |
| 4 | mouseup 监听在 canvas 上 | 拖出画布松手,模式卡死 | window 监听(Day 35 铁律) |
| 5 | hover 每次 mousemove 都重绘 | 不动也刷帧,白耗性能 | 变了才渲染(diff 判断) |
| 6 | 拖拽没记录抓取偏移 | mousedown 瞬间图元"跳"到鼠标 | offset = 场景点 - 图元角 |
| 7 | 框选语义不明确 | 用户疑惑"碰着怎么没选中" | 明确 contain/intersect 并保持一致 |
| 8 | Shift 多选把单选逻辑搞乱 | 追加选择变成替换 | 模式入口先判断修饰键 |
| 9 | 光标状态互相覆盖 | 抓手变箭头闪来闪去 | updateCursor 单一出口 |
| 10 | 拖拽期间还触发 hover 逻辑 | 拖着拖着出现高亮闪烁 | 状态机互斥(mode 分支) |
九、自测挑战
- 推导题:viewport 为 zoom=2, panX=50, panY=100。鼠标画布 CSS 坐标 (250, 300),求场景坐标。图元 A 的 box 为 (80, 90),拖拽偏移应是多少?
- 实现题:给 InteractionManager 加"多选拖拽":框选中多个图元后拖动其中一个,所有选中图元一起移动(提示:记录每个图元的初始位置 + 鼠标总位移)。
- 改错题:下面的框选代码有两个 bug,找出来:
canvas.addEventListener("mousemove", (e) => {
if (!marqueeStart) return;
const rect = canvas.getBoundingClientRect();
marqueeRect = {
x: marqueeStart.x,
y: marqueeStart.y,
w: e.clientX - rect.left - marqueeStart.x,
h: e.clientY - rect.top - marqueeStart.y,
};
renderMarquee(marqueeRect);
});
- 设计题:加"画线模式"(toolbar 按钮切换):模式激活后,mousedown 在节点 A 的锚点 → 拖动出一条跟随鼠标的贝塞尔线 → mouseup 在节点 B 上则建立连线。设计状态机扩展(新状态 + 转移条件),写出关键代码框架。
- 思考题:为什么"绘制顺序 = 命中优先顺序"是必须遵守的一致性?(如果违反,会出现什么视觉/交互矛盾?)
- 进阶题:实现拖拽的"网格吸附":图元位置松手时吸附到 10px 网格(提示:Math.round(x/10)*10),并加一个开关。想想吸附应该在 mousemove 还是 mouseup 生效?两种选择的手感差异。
十、总结与知识图谱
Day 48 图元交互内核
├── 命中优先级
│ ├── z 序 = 数组序 = 命中序(倒序遍历)
│ ├── 选中置顶(bringToFront)
│ └── 多形状命中(可辨识联合 + switch)
├── hover 反馈
│ ├── 变了才渲染(diff)
│ └── 光标映射(move/grabbing/crosshair)
├── 拖拽
│ ├── 抓取偏移(防"跳一下")
│ ├── viewport 逆变换(屏幕→场景)
│ └── 坐标换算闭环(视口→CSS→场景)
├── 框选
│ ├── contain vs intersect 语义
│ ├── normalizeRect(方向归一化)
│ └── 实时预览 + 预高亮
├── 状态机
│ └── idle/hover/drag/marquee 互斥流转
└── 实战
└── InteractionManager(BOSS 战直接复用)
明天预告(Day 49 · BOSS 战):流程图/拓扑图编辑器——本周零件的总装日:数据模型(nodes + edges 图结构)+ 节点渲染(圆角矩形 + Day 43 排版)+ 贝塞尔连线(Day 30 + 锚点方位)+ 交互(今天的 InteractionManager)+ 多层渲染(Day 47)+ 性能(Day 46)。做完它,"工业组态编辑器"从名词变成你的作品集条目。