---
title: 云盘文件选择交互实现
date: "2015-10-24T10:00:00.000Z"
series: cloud-drive
published: true
description: "建立在命令式状态模型与虚拟滚动之上，说明框选、Shift 范围选择、Ctrl/Meta 切换在 grid 与 list 布局中的几何计算和状态更新。"
---

本文建立在[命令式状态模型](./10-10-多选交互的命令模式与单向数据流.md)与[虚拟滚动](./10-24-文件管理器虚拟滚动从零实现列表与缩略图双模式.md)之上：前者负责将输入归约为选择状态，后者负责只挂载可见窗口中的节点。文件列表支持网格（grid）和列表（list）两种视图，并需要处理框选、Shift 范围选择、Ctrl/Meta 单项切换和键盘导航。关键是让命中计算不依赖“所有 item 都在 DOM 中”的前提。

## 数据模型

先把文件列表抽象成一份有序数组，不关心每一项里具体有什么字段：

```typescript
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` 逐项判断：

```typescript
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。

矩形由起点和当前点得出：

```typescript
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 是当前容器宽度下每行能放的列数）。

```typescript
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 到当前项」之间的所有项。这个区间是数组上的闭区间：

```typescript
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。

```typescript
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。

## 拖拽：预览与提交

选完一批文件后拖拽到文件夹，常见实现有两阶段：

1. **预览阶段**（drag start 到 drop 之前）：鼠标移动过程中持续更新 drag ghost（那个跟随光标的小图标和计数 badge），但不修改 selection 本身。
2. **提交阶段**（drop 之后）：根据 drop 目标位置执行真正的移动/复制，然后刷新列表。

```typescript
// 伪代码：一个 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）。没超过就当点空取消选择，超过就进框选模式，画布上出现矩形框。

```typescript
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 而不是下标去追踪被拖拽的项。
- **大量文件（几千项）时的性能。** [虚拟滚动](./10-24-文件管理器虚拟滚动从零实现列表与缩略图双模式.md)已经在前文建立。框选时不要遍历所有 DOM rect：由选择矩形推导涉及的行和列，检查这个有限候选集，再将结果作为 `BOX` command 的 ids。
