做过图形编辑器的同学都知道一个扎心的事实:单选的实现大概占 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;
}
问题在哪?
- O(n) 复杂度:节点多了就慢
- 旋转后失效:上面的 AABB 判断假设矩形没旋转
- 不规则形状: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、插件系统时,好处会越来越明显。