本文建立在命令式状态模型与虚拟滚动之上:前者负责将输入归约为选择状态,后者负责只挂载可见窗口中的节点。文件列表支持网格(grid)和列表(list)两种视图,并需要处理框选、Shift 范围选择、Ctrl/Meta 单项切换和键盘导航。关键是让命中计算不依赖“所有 item 都在 DOM 中”的前提。
数据模型
先把文件列表抽象成一份有序数组,不关心每一项里具体有什么字段:
interface FileItem {
id: string;
name: string;
// ...
}
const items: FileItem[] = [
{ id: "f1", name: "report.doc" },
{ id: "f2", name: "snapshot.png" },
// ...
];选择状态本身维护一个 Set<string>,key 是 item id。所有交互的终点都是对这个集合做增删。单独拎一个概念出来:
- anchor:当前选择范围的起始点,由最后一次「非范围」点击设定。
- focus:当前键盘焦点所在的 item,随方向键移动而变化,视觉上有 focus ring。
anchor 决定 Shift 范围的起点,focus 决定键盘操作的起点。两者经常重合,但并不等同。本文与配套 Demo 的约定是:Ctrl/Meta 单击会同时更新 anchor 和 focus;若产品希望 Ctrl/Meta 单击保留 anchor,也应在 reducer 中显式定义。
Hit Testing
鼠标点下时,先要确定 clientX/clientY 落在哪个 item 上。未虚拟化列表可遍历已挂载节点,用 getBoundingClientRect 逐项判断:
interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}
function hitTest(rects: Map<string, Rect>, x: number, y: number): string | null {
for (const [id, r] of rects) {
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
return id;
}
}
return null;
}这里的 rects 可在布局稳定后缓存,并在 scroll 或 resize 时更新。列表布局下每项占一行;网格布局虽然一行多个,也可使用同一套矩形判断。
虚拟滚动下,窗口外的 item 没有对应 DOM rect,不能遍历全部节点。配套 Demo 根据固定的行高、单元格尺寸、列数和滚动坐标直接计算候选下标,再只对这些候选项做矩形相交判断。
框选(Rubber-Band Selection)
框选流程:在空白处按下鼠标 → 拖出矩形 → 释放时选中所有与矩形相交的 item。
矩形由起点和当前点得出:
interface Point {
x: number;
y: number;
}
function makeRect(a: Point, b: Point): Rect {
return {
left: Math.min(a.x, b.x),
top: Math.min(a.y, b.y),
right: Math.max(a.x, b.x),
bottom: Math.max(a.y, b.y),
};
}
function intersects(a: Rect, b: Rect): boolean {
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
}拖拽过程中用 intersects 判断选择矩形与候选 item 的几何区域是否相交,并用矩形本身提供视觉反馈。鼠标释放后才派发 BOX_SELECT command 更新选择状态。虚拟窗口中,候选集应从布局公式计算,而不是从已挂载节点收集。
Grid 与 List 下的索引映射
框选解决的是矩形相交问题,键盘操作和 Shift 多选依赖「数组下标」。两种布局的关键差异在于:列表布局下数组顺序 = 视觉顺序,网格布局则不然。
网格一行放 N 个,数组的 [i, i+1, ..., i+N-1] 是同一行;[i+N, ...] 是下一行。因此方向键的“上/下”不是 index ± 1,而是 index ± cols(cols 是当前容器宽度下每行能放的列数)。
function getCols(containerEl: HTMLElement, itemEl: HTMLElement): number {
const containerWidth = containerEl.clientWidth;
const itemWidth = itemEl.offsetWidth; // 含 margin,要先量好
return Math.max(1, Math.floor(containerWidth / itemWidth));
}
function moveIndex(
index: number,
cols: number,
total: number,
direction: "up" | "down" | "left" | "right"
): number {
switch (direction) {
case "left":
return Math.max(0, index - 1);
case "right":
return Math.min(total - 1, index + 1);
case "up":
return Math.max(0, index - cols);
case "down":
return Math.min(total - 1, index + cols);
}
}实际列数放在容器 resize 和布局切换时重新算一次,缓存起来,不需要每次按键都算。列表布局下 cols === 1,四个方向退化成左右 = 上/下 = 相邻项,逻辑统一。
Shift 多选:范围选
Shift + 点击选的是「从 anchor 到当前项」之间的所有项。这个区间是数组上的闭区间:
function rangeSelection(
anchorIndex: number,
focusIndex: number,
items: FileItem[]
): Set<string> {
const next = new Set<string>();
const start = Math.min(anchorIndex, focusIndex);
const end = Math.max(anchorIndex, focusIndex);
for (let i = start; i <= end; i++) {
next.add(items[i].id);
}
return next;
}new Set<string>() 从空集合构造新的范围,避免就地修改已提交的选择集合。每次 command 产生新的集合后交给渲染层。若采用范围追加策略,才从 new Set(selected) 开始。
Shift + 方向键同理,区间是 [anchorIndex, newFocusIndex],按下 Shift 时 focus 先随方向移动再算区间。
Ctrl / Meta 切换
Ctrl(Windows/Linux)或 Meta(macOS)加点击时,点击项已选中则移除,未选中则添加;配套 Demo 同时将该项设为新的 anchor 与 focus。
function toggleSelection(
selected: Set<string>,
clickedId: string
): Set<string> {
const next = new Set(selected);
if (next.has(clickedId)) {
next.delete(clickedId);
} else {
next.add(clickedId);
}
return next;
}检测按键用 event.ctrlKey || event.metaKey,这样在 Mac 上用 Cmd、在 Windows 上用 Ctrl 都能正确触发。平台差异只在这一行,其余逻辑不必感知系统。
Anchor 与 Focus 的行为约定
总结一下各操作对 anchor / focus 的影响:
| 操作 | anchor | focus | 选择集 |
|---|---|---|---|
| 普通点击 | 移到该项 | 移到该项 | 只留该项 |
| Shift 点击 | 不变 | 移到该项 | 替换为 anchor 至该项的范围 |
| Ctrl/Meta 点击 | 移到该项 | 移到该项 | 该项 toggle |
| 方向键 | 移到该项 | 移到该项 | 替换为当前 focus 项(Demo 约定) |
| Shift + 方向键 | 不变 | 方向移动 | 替换为新的 anchor 范围 |
| 框选 | 不变 | 移到命中项中的最后一项(无命中则不变) | 与矩形相交项 |
配套 Demo 中,anchor 在普通点击和 Ctrl/Meta 点击后更新。先点击 A、再 Shift 点击 C 时,选择范围为 A–C;若中间 Ctrl/Meta 点击 B,anchor 更新为 B,随后 Shift 点击 D 时选择范围为 B–D。
拖拽:预览与提交
选完一批文件后拖拽到文件夹,常见实现有两阶段:
- 预览阶段(drag start 到 drop 之前):鼠标移动过程中持续更新 drag ghost(那个跟随光标的小图标和计数 badge),但不修改 selection 本身。
- 提交阶段(drop 之后):根据 drop 目标位置执行真正的移动/复制,然后刷新列表。
// 伪代码:一个 drag 会话
let dragPreview: { ids: string[]; target: string | null } | null = null;
function onDragStart(e: DragEvent, ids: string[]) {
dragPreview = { ids, target: null };
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", ids.join("\n"));
}
}
function onDragOverFolder(folderId: string) {
if (dragPreview) {
dragPreview.target = folderId; // 仅更新预览,不提交
}
}
function onDrop() {
if (!dragPreview || !dragPreview.target) return;
// 这里才真正提交:调 API,成功后更新数据
api.moveFiles(dragPreview.ids, dragPreview.target).then(refresh);
dragPreview = null;
}关键是 selection 和 drag 彼此独立:拖拽开始时的选择快照记在 dragPreview.ids 里,拖拽过程中用户改了 selection(例如 Ctrl/Meta 点击移除某项)不应影响已经开始拖拽的这批文件。这是把「当前选中」和「正在拖拽的」拆成两个状态的原因。
输入冲突处理
几种容易冲突的场景:
单击空白处 vs. 框选起点。 按下鼠标时先不释放「这是框选还是点击空白」的判断,看移动距离是否超过阈值(比如 4 px)。没超过就当点空取消选择,超过就进框选模式,画布上出现矩形框。
function isDrag(start: Point, current: Point): boolean {
const dx = current.x - start.x;
const dy = current.y - start.y;
return dx * dx + dy * dy > 16; // 4px 阈值
}Ctrl/Meta + 点击避免误触框选。 如果按下时已经按住 Ctrl/Meta 或 Shift,配套 Demo 将这次手势保留给范围选择或 toggle,不进入框选。
文本输入。 当重命名输入框(<input>)获得焦点时,所有快捷键(Delete、方向键、Cmd+A 等)都不应冒泡到文件列表。输入框自己的 blur 事件交出控制权。实现上用一个「当前焦点区域」的枚举就能区分:"list" | "rename-input" | "search-box",不同区域下同一快捷键走不同分支。
Cmd/Ctrl + A 全选。 只在焦点在列表内时拦截;搜索框获得焦点时 Cmd+A 仍应选中文本。
串起来
一次交互的数据流为:DOM 事件与固定布局参数计算命中项和修饰键;适配层组装 CLICK_ITEM、BOX 或 NAVIGATE command;reducer 产生新的 selection、anchor 和 focus;虚拟滚动窗口根据新状态渲染当前可见节点。
每条路径都返回新的选择集合,已提交状态不被就地修改。框选过程只保留选择矩形,鼠标释放时再提交命中项;取消手势时无需回滚 selection。
command 对象不必对应复杂的 Command 类层级。对这个场景,包含类型和必要参数的普通对象已经能让 reducer 成为选择状态的唯一入口;如需撤销,可额外保存状态快照或可逆操作记录。
几个容易漏的边角
- 滚动容器变化时坐标系要同步。 未虚拟化实现可在 scroll 后更新已挂载 rect 缓存;固定尺寸虚拟列表应使用
scrollTop和布局公式计算位置,不依赖窗口外节点的 rect。 - 缩放 / 系统 DPI。
clientX/clientY是 CSS 像素,getBoundingClientRect返回的也是 CSS 像素,两套数值在同一坐标系下,不需要做 DPI 换算。但如果将来接 canvas 渲染,就要小心了。 - item 在拖拽中被重排或删除。 如果拖拽期间数据刷新了(比如后台同步进新文件),
items数组顺序可能改变。应基于 id 而不是下标去追踪被拖拽的项。 - 大量文件(几千项)时的性能。 虚拟滚动已经在前文建立。框选时不要遍历所有 DOM rect:由选择矩形推导涉及的行和列,检查这个有限候选集,再将结果作为
BOXcommand 的 ids。

