前端图形编辑器的多选框架:从单选到框选到 Shift 连选

做过图形编辑器的同学都知道一个扎心的事实:单选的实现大概占 10% 的工时,多选和框选占 40%,而剩下的 50% 全部花在发现你没吃透的那一篇交互细节上。

不信?我们来数数一个"正常"的多选系统到底要搞定多少东西:

  • 鼠标单击选中/取消选中
  • Ctrl/Cmd + 点击追加/取消选中单个节点
  • 按住鼠标在空白处拖拽,矩形框选
  • Shift + 点击两个节点,选中它们之间的所有节点(拓扑图特有)
  • 全选(Ctrl+A)
  • 选中的节点组一起移动、缩放、旋转
  • 多选后属性面板要显示交集还是并集?
  • 右键菜单在多个选中节点上的行为

今天这篇文章就聊聊:如果你要自己实现(或者深度定制)一个图形编辑器的多选系统,架构上应该怎么设计,每一层有哪些坑。


一、先搞懂你在跟什么打交道

Canvas 图形编辑器跟 DOM 编辑器有一个本质区别:DOM 有天然的树形结构和事件冒泡,Canvas 上你只有一堆像素。

在 Canvas 中,一个"图形节点"通常是这样定义的:

css 复制代码
interface GraphNode {
  id: string;
  type: 'rect' | 'circle' | 'line' | 'image' | 'text' | 'custom';
  x: number;
  y: number;
  width: number;
  height: number;
  rotation: number;       // 旋转角度(弧度)
  scaleX: number;
  scaleY: number;
  zIndex: number;         // 层级
  children?: GraphNode[]; // 嵌套子节点(少数引擎支持)
}

而"命中检测"就是回答:用户在 (mouseX, mouseY) 点了一下,他到底点中了哪个节点?

这里有一个巨大的陷阱:如果你用遍历所有节点、逐个判断点是否在多边形内部的方式来做命中检测,当画布上有几千个节点时,每次点击都要卡一下。这个问题我会在下一节专门讲。


二、命中检测:多选系统的地基

2.1 朴素方案:遍历 + isPointInPath

arduino 复制代码
function hitTestNaive(nodes: GraphNode[], point: Point): GraphNode | null {
  // 从上层往下遍历(zIndex 高的先命中)
  for (let i = nodes.length - 1; i >= 0; i--) {
    const node = nodes[i];
    if (node.type === 'rect') {
      if (
        point.x >= node.x &&
        point.x <= node.x + node.width &&
        point.y >= node.y &&
        point.y <= node.y + node.height
      ) {
        return node;
      }
    }
    // 圆形、多边形、路径...每种都要写一遍判断
  }
  return null;
}

问题在哪?

  1. O(n) 复杂度:节点多了就慢
  2. 旋转后失效:上面的 AABB 判断假设矩形没旋转
  3. 不规则形状:Path 类型节点不能用简单矩形判断

2.2 进阶方案:空间索引 + 变换矩阵

这是目前主流引擎都在用的方案,分三步走:

第一步:空间索引(R-Tree / Quadtree)

不要去遍历所有节点,先用空间索引把搜索范围缩小到鼠标附近的那几个:

php 复制代码
// 假设你已经构建了一个 Quadtree
const candidates = quadtree.query({
  x: mouseX - tolerance,
  y: mouseY - tolerance,
  width: tolerance * 2,
  height: tolerance * 2,
});
// candidates 已经从几千个缩到十几个

第二步:逆变换检测

对于旋转节点,正确的做法是把鼠标坐标"逆变换"到节点的本地坐标系中,然后在本地坐标系里做简单的 AABB 判断:

arduino 复制代码
function hitTestTransformed(node: GraphNode, point: Point): boolean {
  // 将全局坐标转换到节点的本地坐标系
  const localPoint = transformToLocal(node, point);
  
  // 在本地坐标系中,节点始终是"不旋转"的
  return (
    localPoint.x >= 0 &&
    localPoint.x <= node.width &&
    localPoint.y >= 0 &&
    localPoint.y <= node.height
  );
}
​
function transformToLocal(node: GraphNode, globalPoint: Point): Point {
  // 先平移到节点中心
  const cx = node.x + node.width / 2;
  const cy = node.y + node.height / 2;
  
  let dx = globalPoint.x - cx;
  let dy = globalPoint.y - cy;
  
  // 逆旋转(用旋转矩阵的逆矩阵)
  const cos = Math.cos(-node.rotation);
  const sin = Math.sin(-node.rotation);
  const localX = dx * cos - dy * sin;
  const localY = dx * sin + dy * cos;
  
  // 考虑缩放
  return {
    x: localX / node.scaleX + node.width / 2,
    y: localY / node.scaleY + node.height / 2,
  };
}

⚠️ 注意:如果节点还嵌套在 Group 里(有父节点的变换),你需要沿着父链一直做逆变换。Fabric.js 的 calcTransformMatrix()containsPoint() 就是这样处理的。

第三步:缓存命中结果

如果用户在拖拽选择框的过程中频繁做命中检测,可以把结果缓存起来(根据节点变化做增量更新),避免每帧都重新计算。


三、选择框:一行 CSS 搞不定的东西

3.1 用单独的 Canvas 层画选择框

不要在主画布上画选择框------你不想让选择框成为"可渲染可视化的数据"的一部分。正确做法:

xml 复制代码
<!-- 主画布:渲染所有图形节点 -->
<canvas id="main-canvas"></canvas>
​
<!-- 选择层:只渲染选择框、控制手柄等 -->
<canvas id="selection-canvas" style="position:absolute;top:0;left:0;pointer-events:none;"></canvas>

选择框的渲染很简单:

ini 复制代码
function drawSelectionBox(ctx: CanvasRenderingContext2D, rect: Rect) {
  // 半透明填充
  ctx.fillStyle = 'rgba(66, 133, 244, 0.1)';
  ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
  
  // 蓝色边框
  ctx.strokeStyle = '#4285F4';
  ctx.lineWidth = 1;
  ctx.setLineDash([4, 4]);
  ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
  ctx.setLineDash([]);
}

3.2 框选的碰撞检测

这是整个多选系统最频繁被调用的方法之一,必须高效。框选本质上是判断"拖拽出的矩形"和"每个候选节点的 AABB"是否相交:

javascript 复制代码
function isNodeInSelectionBox(node: GraphNode, box: Rect): boolean {
  // 把节点的包围盒(考虑旋转后的大 AABB)计算出来
  const aabb = getWorldAABB(node);
  
  // AABB 相交检测(最经典的 4 条件判断)
  return !(
    aabb.maxX < box.x ||
    aabb.minX > box.x + box.width ||
    aabb.maxY < box.y ||
    aabb.minY > box.y + box.height
  );
}
​
function getWorldAABB(node: GraphNode): AABB {
  // 如果节点没有旋转:
  if (node.rotation === 0) {
    return {
      minX: node.x,
      minY: node.y,
      maxX: node.x + node.width * node.scaleX,
      maxY: node.y + node.height * node.scaleY,
    };
  }
  
  // 旋转情况下,计算四个角点变换后的包围盒
  const corners = getRotatedCorners(node);
  const xs = corners.map(c => c.x);
  const ys = corners.map(c => c.y);
  return {
    minX: Math.min(...xs),
    minY: Math.min(...ys),
    maxX: Math.max(...xs),
    maxY: Math.max(...ys),
  };
}

💡 性能提示:如果你用 Quadtree,框选只需要查询与选择框相交的 Quadtree 节点,然后再对候选节点逐个做精确判断。不要每次拖拽都遍历全部节点。


四、选择状态管理:别把逻辑写在事件回调里

这是我看过最多人踩的坑。很多人上来就在 mouse 事件回调里写 if-else 判断各种选择模式,三周后发现变成了一个 500 行的怪物。

正确做法:用状态机管理选择流程。

css 复制代码
type SelectionState = 
  | { type: 'idle' }
  | { type: 'single_selected'; nodeIds: string[] }
  | { type: 'multi_selected'; nodeIds: string[] }
  | { type: 'box_selecting'; startPoint: Point; currentPoint: Point; initialSelected: string[] };
​
type SelectionEvent = 
  | { type: 'CLICK'; point: Point; ctrlKey: boolean; shiftKey: boolean }
  | { type: 'MOUSE_DOWN'; point: Point; ctrlKey: boolean; shiftKey: boolean }
  | { type: 'MOUSE_MOVE'; point: Point }
  | { type: 'MOUSE_UP'; point: Point }
  | { type: 'SELECT_ALL'; nodeIds: string[] }
  | { type: 'CLEAR' };

状态转换的核心逻辑:

csharp 复制代码
function selectionReducer(
  state: SelectionState, 
  event: SelectionEvent,
  hitNodeId: string | null  // 命中检测的结果
): SelectionState {
  switch (event.type) {
    case 'MOUSE_DOWN': {
      if (hitNodeId) {
        if (event.ctrlKey || event.shiftKey) {
          // Ctrl/Shift + 点击:toggle 选择
          const newIds = toggleInArray(getSelectedIds(state), hitNodeId);
          return { type: 'multi_selected', nodeIds: newIds };
        } else {
          // 普通点击:单选(如果已经选中则在移动时不做切换)
          return { type: 'single_selected', nodeIds: [hitNodeId] };
        }
      } else {
        // 点击空白处:开始框选
        return {
          type: 'box_selecting',
          startPoint: event.point,
          currentPoint: event.point,
          initialSelected: getSelectedIds(state),
        };
      }
    }
​
    case 'MOUSE_MOVE': {
      if (state.type === 'box_selecting') {
        return { ...state, currentPoint: event.point };
      }
      return state;
    }
​
    case 'MOUSE_UP': {
      if (state.type === 'box_selecting') {
        // 根据选择框内的节点完成框选
        const box = normalizeRect(state.startPoint, state.currentPoint);
        const selectedIds = getNodesInBox(box)
          .map(n => n.id)
          .filter(Boolean);
        return {
          type: selectedIds.length > 0 ? 'multi_selected' : 'idle',
          nodeIds: selectedIds,
        };
      }
      return state;
    }
    // ... 其他事件
  }
}

这样设计后,UI 层只需要负责两件事:① 收集事件传给 reducer ② 根据 state 渲染选择框和高亮。核心逻辑完全可测试。


五、Shift 连选:拓扑图的特有难题

Shift 连选常见于流程图/拓扑图编辑器:当你按住 Shift 先后点击节点 A 和节点 B,你期望选中 A 到 B 之间路径上的所有节点

这个需求的难点不在 UI,在图算法。你需要找到两个节点之间的所有可能路径,然后取"最短路径"还是"所有路径的并集"?不同场景的选择不一样。

对于大多数拓扑场景(网络拓扑、电力接线图),使用 BFS(广度优先搜索) 取最短路径是最合理的:

ini 复制代码
function findPathBetween(
  adjacencyList: Map<string, string[]>,
  fromId: string,
  toId: string
): string[] {
  if (fromId === toId) return [fromId];
  
  const queue: [string, string[]][] = [[fromId, [fromId]]];
  const visited = new Set<string>();
  visited.add(fromId);
  
  while (queue.length > 0) {
    const [currentId, path] = queue.shift()!;
    
    for (const neighborId of adjacencyList.get(currentId) || []) {
      if (neighborId === toId) {
        return [...path, toId];
      }
      if (!visited.has(neighborId)) {
        visited.add(neighborId);
        queue.push([neighborId, [...path, neighborId]]);
      }
    }
  }
  
  return []; // 不可达
}

提醒一句:Shift 连选的前提是你要维护好图的邻接表(从连线数据中实时构建)。连线的新增和删除都要同步更新邻接表。


六、被低估的细节:多选后的属性面板

当选了 5 个矩形后打开属性面板, "宽度"字段应该显示什么?

  • 如果 5 个矩形的宽度分别是 [100, 100, 150, 80, 100],显示为"Mixed"还是"100"?
  • 如果用户修改宽度为 200,是全部设成 200,还是按比例缩放?

这里没有一个"正确"答案,取决于你的产品定位:

策略 宽度字段显示 用户输入 200 后的行为
交集模式 只有全部相同时才显示值,否则显示"Mixed" 全部设为 200
绝对设置 显示第一个选中节点的值 全部设为 200
比例缩放 显示"Mixed" 200 / 原宽度 的比例缩放每个

我个人推荐交集模式 + 绝对设置的组合:显示时取交集,设置时全部写入。用户在视觉上能看到哪些属性在节点间不一致,修改时也只影响同一个属性名下的值。这个交互在各设计工具里已经被广泛验证。


七、一个完整的架构分层

把以上讨论串起来,一个生产级的多选系统应该这样分层:

复制代码
┌─────────────────────────────────────────┐
│  UI 层:选择框渲染、控制手柄、高亮效果    │
├─────────────────────────────────────────┤
│  交互层:鼠标/键盘事件→事件规范化         │
├─────────────────────────────────────────┤
│  状态机:selectionReducer(纯函数)       │
├─────────────────────────────────────────┤
│  命中检测层:Quadtree/R-Tree + 逆变换     │
├─────────────────────────────────────────┤
│  数据层:节点数据 + 邻接表(用于 Shift 连选)│
└─────────────────────────────────────────┘

每一层都有清晰的职责边界:

  • 数据层只管节点的增删改查和图的拓扑关系
  • 命中检测层不关心业务逻辑,只回答"这个点在哪个节点上"
  • 状态机是纯函数,接收事件 + 命中结果,输出新状态------完全可以单元测试覆盖
  • 交互层 负责把原生事件规范化,转换成状态机理解的 SelectionEvent
  • UI 层响应状态变化,画选择框、高亮、手柄

这样的分层在需要对接多人协同编辑、Undo/Redo、插件系统时,好处会越来越明显。

相关推荐
3秒一个大1 小时前
JS 数组去重全方案:从基础到通用万能去重函数
前端·javascript
阿黎梨梨1 小时前
读懂一个 Next.js 全栈笔记应用
前端
古茗前端团队1 小时前
代码越改越乱?来试试前端领域模型驱动设计(DDD)
前端·agent·ai编程
Hilaku1 小时前
为什么跳槽涨薪 30% 的时代彻底结束了?聊聊 2026 前端薪资的天花板
前端·javascript·程序员
愚公搬代码2 小时前
【愚公系列】《Web应用安全》006-HackBar插件的使用
前端·安全
超爱吃香菜的菜鸟2 小时前
关于pnpm 部分使用(一)
前端·vue.js
Vuji2 小时前
Pi 插件解剖|git-checkpoint.ts:只用 53 行,让 fork 恢复代码状态
前端·agent
大家的林语冰2 小时前
✌️ 让 Rust 再次伟大,pnpm 12 抛弃 TypeScript,移植 Rust 原地起飞!
前端·javascript·node.js
XPoet3 小时前
AI 编程工程化:实战——从 0 到 1 搭建 AI 编程工作流
前端·后端·ai编程