在思维导图类的可视化工具里,用户往往希望像在桌面文件管理器中那样,按住鼠标左键拉出一个矩形,把范围内的节点一次性选中。这个过程涉及鼠标按下、移动、抬起三个阶段,以及被框住的节点集合的变化。如果只用零散的number变量去记录左上角和右下角,代码很快就会变得难以读懂,也更难排查为什么某些节点没被选进去。用TypeScript定义一套清晰的矩形选区域类型,可以让拖拽和多选逻辑在写代码阶段就受到保护。

矩形选区域的核心类型设计
框选本质上是一个屏幕坐标系下的轴对齐矩形。无论用户从哪个方向拉框,最终都能归一化为拥有最小x、最小y、宽度和高度的矩形。在TypeScript中,我们可以先定义一个基础的Rect类型,用来描述任意矩形区域。这个类型不仅仅服务于选框,也可以复用到节点的包围盒上,保证两者使用同一套几何语言。
为了区分“正在拖拽中的临时选框”和“已经确定的选区”,可以使用联合类型或者带状态字段的接口。下面的例子用SelectionRect接口表达选框,并用status字段标记当前是未开始、进行中还是已完成。这样在渲染层就能根据状态决定是否绘制半透明矩形,而类型系统会提醒开发者处理每一种状态。
// 基础矩形,所有坐标基于画布像素
interface Rect {
x: number; // 左边界
y: number; // 上边界
width: number; // 宽度,恒为非负
height: number; // 高度,恒为非负
}
// 框选区域类型,包含起止点与状态
interface SelectionRect extends Rect {
startX: number;
startY: number;
status: 'idle' | 'dragging' | 'finished';
}
// 将任意对角点归一化为标准Rect
function normalizeRect(x1: number, y1: number, x2: number, y2: number): Rect {
const x = Math.min(x1, x2);
const y = Math.min(y1, y2);
const width = Math.abs(x1 - x2);
const height = Math.abs(y1 - y2);
return { x, y, width, height };
}
上面的normalizeRect函数非常关键。因为用户可能从左下拖到右上,也可能从右下拖到左上,如果不做归一化,后面写交集判断时就要写四种分支。归一化之后,所有选框都变成“左上角加宽高”的标准形式,后续逻辑大幅简化。这也是类型设计与几何计算配合的一个典型好处。
节点命中检测与多选集合的类型建模
思维导图中的每个节点自身也有一个Rect包围盒,通常来自布局算法。判断节点是否被框选住,就是判断两个矩形是否相交,且相交面积大于零,或者至少节点中心落在选框内。不同产品对“选中”的宽松程度不同:有的要求完全包含,有的只要碰到就算。我们可以用TypeScript的类型来约束命中策略。
定义一个HitTestStrategy类型别名,它指向一个接收选框和节点矩形、返回布尔值的函数。这样在拖拽过程中,替换策略不需要改动调用方代码。同时,被选中节点的ID集合可以定义为Set<string>,相比数组更方便增删和去重。以下示例展示了策略定义与命中函数:
type HitTestStrategy = (sel: Rect, node: Rect) => boolean;
// 策略一:节点中心在选框内即选中
const hitByCenter: HitTestStrategy = (sel, node) => {
const cx = node.x + node.width / 2;
const cy = node.y + node.height / 2;
return cx >= sel.x && cx <= sel.x + sel.width
&& cy >= sel.y && cy <= sel.y + sel.height;
};
// 策略二:两矩形有交集即选中
const hitByIntersect: HitTestStrategy = (sel, node) => {
return !(node.x > sel.x + sel.width
|| node.x + node.width < sel.x
|| node.y > sel.y + sel.height
|| node.y + node.height < sel.y);
};
function selectNodes(selection: Rect, nodes: Map<string, Rect>, strategy: HitTestStrategy): Set<string> {
const result = new Set<string>();
nodes.forEach((rect, id) => {
if (strategy(selection, rect)) {
result.add(id);
}
});
return result;
}
从类型角度看,selectNodes的返回值是明确的Set<string>,调用方不可能误把它当成数组去用push。而在拖拽事件里,我们只需在mousemove时重新调用normalizeRect生成新选框,再跑一次selectNodes,就能实时更新多选结果。由于所有坐标都收敛到Rect结构,调试时打印出来的对象也一目了然。
将选框类型接入拖拽事件与状态管理
真正在思维导图里用起来,还要把SelectionRect接到鼠标事件上。我们通常会有一个全局的拖拽状态机,记录当前是否处于框选模式。用TypeScript的可辨识联合(discriminated union)来描述“无操作”“平移画布”“框选节点”三种模式,可以让switch语句覆盖完整,编译器会检查是否漏掉某种情况。
下面代码演示了如何把鼠标事件转成选框状态,并在抬起鼠标时定型为finished。注意pointerdown时如果命中空白区域才进入框选,命中节点则是单拖。类型系统帮助我们把“当前模式”和“对应数据”绑在一起,避免拿到框选坐标却处在平移模式的错误。
type DragState =
| { mode: 'none' }
| { mode: 'pan'; lastX: number; lastY: number }
| { mode: 'select'; rect: SelectionRect };
let state: DragState = { mode: 'none' };
function onPointerDown(e: MouseEvent, hitNode: boolean) {
if (hitNode) {
state = { mode: 'pan', lastX: e.clientX, lastY: e.clientY };
} else {
const rect: SelectionRect = {
x: e.clientX, y: e.clientY, width: 0, height: 0,
startX: e.clientX, startY: e.clientY, status: 'dragging'
};
state = { mode: 'select', rect };
}
}
function onPointerMove(e: MouseEvent) {
if (state.mode === 'select') {
state.rect = {
...state.rect,
...normalizeRect(state.rect.startX, state.rect.startY, e.clientX, e.clientY),
status: 'dragging'
};
}
}
function onPointerUp() {
if (state.mode === 'select') {
state.rect.status = 'finished';
}
}
把这套类型放进项目后,新来的开发者看DragState就能明白交互分支有哪些,不需要通读事件处理函数。当产品经理想增加“框选后按Shift追加”的功能时,也只需在selectNodes外面包一层旧集合的合并,核心矩形类型完全不用动。这种可扩展性正是TypeScript定义专属选框类型的最大价值。
TypeScript思维导图矩形选框修改时间:2026-08-16 03:22:38