云盘文件选择交互实现

1 分钟阅读

建立在命令式状态模型与虚拟滚动之上,说明框选、Shift 范围选择、Ctrl/Meta 切换在 grid 与 list 布局中的几何计算和状态更新。

本文建立在命令式状态模型虚拟滚动之上:前者负责将输入归约为选择状态,后者负责只挂载可见窗口中的节点。文件列表支持网格(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。

拖拽:预览与提交

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

  1. 预览阶段(drag start 到 drop 之前):鼠标移动过程中持续更新 drag ghost(那个跟随光标的小图标和计数 badge),但不修改 selection 本身。
  2. 提交阶段(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_ITEMBOXNAVIGATE 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:由选择矩形推导涉及的行和列,检查这个有限候选集,再将结果作为 BOX command 的 ids。

473 字 · 50 段落
ximing

Written by ximingFollow onGitHub

相关文章