【Canvas 2D】day48-interaction-manager

作者:mario 发布时间: 2026-09-02 阅读量:3 评论数:0

Canvas 图元交互内核 — 命中优先级、拖拽、框选,编辑器的交互引擎

前面积累的所有零件今天组装成一个模块:InteractionManager(交互管理器)。它统一处理编辑器类产品的全部交互:点击选中(谁在上面选中谁——命中优先级)、hover 悬停(光标反馈 + 高亮)、拖拽移动(含 viewport 缩放后的坐标逆变换——Day 38 的知识在这里落地)、框选(拖出的矩形与图元求交——Day 45 的 AABB 再就业)。这个模块是明天 BOSS 战编辑器交互层的全部——今天做扎实,明天只管组装。


目录


一、交互内核要解决的五个问题

① 点击 → 选中哪个图元?(命中 + 优先级)
② 悬停 → 哪个图元在鼠标下?(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 分支)

九、自测挑战

  1. 推导题:viewport 为 zoom=2, panX=50, panY=100。鼠标画布 CSS 坐标 (250, 300),求场景坐标。图元 A 的 box 为 (80, 90),拖拽偏移应是多少?
  2. 实现题:给 InteractionManager 加"多选拖拽":框选中多个图元后拖动其中一个,所有选中图元一起移动(提示:记录每个图元的初始位置 + 鼠标总位移)。
  3. 改错题:下面的框选代码有两个 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);
});
  1. 设计题:加"画线模式"(toolbar 按钮切换):模式激活后,mousedown 在节点 A 的锚点 → 拖动出一条跟随鼠标的贝塞尔线 → mouseup 在节点 B 上则建立连线。设计状态机扩展(新状态 + 转移条件),写出关键代码框架。
  2. 思考题:为什么"绘制顺序 = 命中优先顺序"是必须遵守的一致性?(如果违反,会出现什么视觉/交互矛盾?)
  3. 进阶题:实现拖拽的"网格吸附":图元位置松手时吸附到 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)。做完它,"工业组态编辑器"从名词变成你的作品集条目。

评论