Motrix 2.0 桌面组件库 Desktop Kit 实战:构建支持虚拟滚动、框选与全键盘操作的通用列表
【免费下载链接】MotrixA full-featured download manager.项目地址: https://gitcode.com/GitHub_Trending/mo/Motrix
导读
desktop-kit/README.md 是 Motrix 2.0 的桌面交互组件库说明文档。它面向“数据量很大、又需要像系统文件管理器一样交互”的列表场景,把**虚拟滚动(Virtual Scrolling)、框选(Marquee Selection)与多选(Multi-selection)**拆成三个可独立复用的模块,再用一个胶水 Hook 组合成开箱即用的能力。下载管理器的任务列表、文件浏览面板这类需要渲染成千上万行、并支持鼠标拖选 + 键盘全选的界面,正是它的典型用武之地。读完本文,你将掌握 Desktop Kit 的分层架构、每个模块的完整 API、鼠标/键盘交互语义,并能对照源码理解框选在虚拟列表上“无死角”工作的底层原理。
一、设计起点:桌面级列表交互为什么值得单独做一个组件库
传统 Web 列表组件通常只解决“渲染与展示”,而桌面软件的列表交互有几个硬需求:
- 海量数据的流畅渲染:仅渲染视口内可见行(虚拟化),否则几千个下载任务会让 DOM 崩溃。
- 框选(Marquee):按住鼠标在列表区域拖出一个矩形,覆盖的行全部选中——这是文件管理器(Finder/资源管理器)的标准操作。
- 全键盘可达:
↑/↓移动焦点、Space切换、Shift扩展选择、Ctrl/Cmd+A全选、Escape清空,让重度用户不必碰鼠标。 - 表头全选 Checkbox,且支持“部分选中”的 indeterminate 态。
Desktop Kit 的价值在于:这三类需求与业务数据完全解耦,因此设计成了任意数据类型都能适配的通用层,而不是只服务下载任务。
二、架构:三个独立模块 + 一个胶水 Hook
文档给出的架构图可以用一句话概括:SelectionEngine 管状态、VirtualList 管渲染、MarqueeOverlay 管手势,useSelectableList 把三者接线。
使用侧代码 | useSelectableList (胶水 hook) / | \ VirtualList MarqueeOverlay SelectionEngine | | | @tanstack/virtual DOM events Zustand store (纯逻辑,零 DOM)对应到源码目录 src/renderer/components/desktop-kit 下的实际结构:
- SelectionEngine(selection/create-selection-store.ts)—— 一个纯逻辑的 Zustand store 工厂,接口类型定义在 selection/types.ts。不依赖 React、不碰 DOM,因此可以用纯单测覆盖(create-selection-store.test.ts)。
- VirtualList(virtual-list/virtual-list.tsx)—— 对
@tanstack/react-virtual的泛型封装,只负责“渲染哪些行”,完全不感知选择逻辑。 - MarqueeOverlay(marquee-selection/marquee-overlay.tsx)—— 框选的 UI 覆盖层,负责监听拖拽手势,最终只向外部输出一个“索引范围”。
- useSelectableList(hooks/use-selectable-list.ts)—— 唯一的组合点,把三者按固定约定接线,是绝大多数使用方唯一需要 import 的入口。
值得注意的边界划分:MarqueeOverlay不知道“哪些数据被选中”,它只算出行号区间交给回调;VirtualList不知道“选择”是什么概念,它只负责滚动与渲染。选择语义全部沉淀在纯逻辑 store 里。这样任何一层都能被单独替换或测试,例如在 虚拟列表组件测试 与 框选组件测试 中,都能在没有完整业务环境的前提下验证各自行为。
三、快速开始:最小可用示例
在数据驱动类界面(如“下载任务”列表)中接入只需要一次 Hook 调用:
import { useSelectableList } from './hooks/use-selectable-list' import { VirtualList } from './virtual-list/virtual-list' import { MarqueeOverlay } from './marquee-selection/marquee-overlay' interface Task { id: string name: string } function TaskList({ tasks }: { tasks: Task[] }) { const { listRef, listProps, marqueeProps, getRowProps, headerCheckbox, onKeyDown, } = useSelectableList({ items: tasks, getId: (t) => t.id, rowHeight: 40, }) return ( <div onKeyDown={onKeyDown} tabIndex={0} style={{ position: 'relative' }}> <VirtualList ref={listRef} {...listProps} style={{ height: 500 }} renderHeader={() => ( <div> <input type="checkbox" checked={headerCheckbox.checked} ref={(el) => { if (el) el.indeterminate = headerCheckbox.indeterminate }} onChange={headerCheckbox.onChange} /> Name </div> )} renderRow={({ item, index }) => { const rp = getRowProps(index) return ( <div style={{ background: rp.selected ? '#dbeafe' : 'transparent' }} onClick={rp.onClick} > <input type="checkbox" checked={rp.selected} onChange={() => {}} onClick={(e) => { e.stopPropagation() rp.onCheckboxChange() }} /> {item.name} </div> ) }} /> <MarqueeOverlay {...marqueeProps} /> </div> ) }四个必须遵守的接线约定(对照源码可验证):
- 外层容器必须
position: relative,MarqueeOverlay绘制的 SVG 覆盖层才能与滚动容器对齐(见 marquee-overlay.tsx 中zIndex: 10的全覆盖<svg>)。 - 外层容器必须能接收键盘事件:
tabIndex={0}+onKeyDown,否则↑/↓、Space等按键永远不会命中 use-selectable-list.ts 的onKeyDown分发逻辑。 VirtualList必须显式设置height:虚拟化依赖一个有确定高度的滚动容器。- 行内 Checkbox 要
stopPropagation:单击勾选框只做 toggle,不能冒泡成行点击触发的“单选清空”。
四、API 参考(逐项对照源码)
4.1useSelectableList<T>(options)—— 胶水 Hook
Options
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
items | T[] | 必填 | 列表数据 |
getId | (item: T) => string | 必填 | 从数据项提取唯一 ID |
rowHeight | number | 必填 | 固定行高(px) |
headerHeight | number | 0 | 表头高度(框选坐标偏移用) |
marquee | boolean | true | 是否启用框选 |
源码中选项接口定义在 hooks/use-selectable-list.ts 的UseSelectableListOptions。值得注意它还接受一个可选store:当组件树中多个列表需要共享同一份选择状态(例如“任务列表”与“详情预览”联动高亮)时,可传入外部创建的 store。
返回值
| 字段 | 类型 | 说明 |
|---|---|---|
listRef | RefObject<VirtualListHandle> | 传给VirtualList的 ref |
listProps | { items, getId, rowHeight, scrollRef } | 展开传给VirtualList |
marqueeProps | MarqueeOverlayProps | 展开传给MarqueeOverlay |
selection | SelectionStore<T> | Zustand store,可细粒度订阅 |
getRowProps(index) | (index: number) => RowProps | 行级交互 props |
headerCheckbox | HeaderCheckboxState | 表头全选 Checkbox 状态 |
onKeyDown | (e: KeyboardEvent) => void | 绑定到列表容器 |
从实现细节看,Hook 内部还做了两件“看不见但重要”的事:
- 同步数据源:
useEffect里每次items变化就调用store.getState().setItems(items),确保 store 里永远是最新数据。 - 焦点行自动滚动:订阅 store,一旦
focusedIndex变化就通过listRef.current?.scrollToIndex(focusedIndex)让键盘导航永远把焦点行滚进视口。
4.2getRowProps(index)返回值
| 字段 | 说明 |
|---|---|
selected | 当前行是否选中 |
focused | 当前行是否获得键盘焦点 |
onClick | 行点击处理(支持 Ctrl/Cmd/Shift 修饰键) |
onCheckboxChange | Checkbox toggle(不影响其他选中项) |
onClick的修饰键语义与shiftKey/ctrlKey/metaKey一一对应,直接落到 store 的不同命令:
onClick: (e) => { const state = store.getState() if (e.shiftKey) state.rangeSelect(index) // Shift:范围选择 else if (e.ctrlKey || e.metaKey) state.toggle(id) // Ctrl/Cmd:切换 else state.select(id) // 普通:单选 }4.3headerCheckbox返回值
| 字段 | 说明 |
|---|---|
checked | 全部选中时为true |
indeterminate | 部分选中时为true |
onChange | 全选/全不选切换 |
计算规则在源码中非常直白:checked = total > 0 && count === total、indeterminate = count > 0 && count < total;onChange里若已有选中项则清空,否则全选。渲染时把checked与indeterminate分别赋给原生 Checkbox 的checked与 DOM 属性el.indeterminate,即可得到标准的“半选”表头。
4.4VirtualList<T>—— 泛型虚拟滚动列表
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
items | T[] | 必填 | 列表数据 |
getId | (item: T) => string | 必填 | 唯一 ID 提取 |
rowHeight | number | 必填 | 固定行高 |
overscan | number | 5 | 视口外预渲染行数 |
scrollRef | RefObject<HTMLDivElement> | — | 外部滚动容器 ref(useSelectableList自动传入) |
renderRow | (props: RowRenderProps<T>) => ReactNode | 必填 | 行渲染函数 |
renderHeader | () => ReactNode | — | 表头插槽 |
renderEmpty | () => ReactNode | — | 空状态插槽 |
className | string | — | 容器 CSS 类名 |
style | CSSProperties | — | 容器内联样式(必须设置 height) |
实现上(virtual-list.tsx)要点如下:
- 通过
useVirtualizer({ count, getScrollElement, estimateSize: () => rowHeight, overscan })驱动;固定行高让estimateSize变成一个常量函数。 - 每个虚拟行是绝对定位的 div,用
transform: translateY(virtualRow.start)放置,行高直接height: rowHeight,因此不需要逐行测量 DOM。 scrollRef允许把滚动容器交给外部(useSelectableList会把内部containerRef同时传给 VirtualList 与 MarqueeOverlay,保证两者操作的是同一个滚动容器)。- 列表为空且提供了
renderEmpty时,会单独渲染“表头 + 空状态”分支。
Ref Handle(VirtualListHandle)
| 方法 | 说明 |
|---|---|
scrollToIndex(index) | 滚动到指定行 |
getScrollOffset() | 当前滚动偏移量 |
getContainerRef() | 滚动容器 DOM 引用 |
4.5MarqueeOverlay—— 框选覆盖层
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
containerRef | RefObject<HTMLDivElement> | 必填 | 滚动容器引用 |
rowHeight | number | 必填 | 行高 |
totalCount | number | 必填 | 总行数 |
headerHeight | number | 0 | 表头偏移 |
enabled | boolean | true | 启用/禁用 |
minDragDistance | number | 5 | 最小拖拽距离(px) |
scrollGutter | number | 100 | 自动滚动触发区域(px) |
scrollMaxSpeed | number | 15 | 最大滚动速度(px/16ms) |
onSelectionChange | (start, end) => void | 必填 | 拖拽中的索引范围回调 |
onSelectionEnd | () => void | 必填 | 拖拽结束回调 |
组件内部渲染一个aria-hidden的全覆盖 SVG,用<rect>绘制半透明选框,并把选框的 zIndex 固定为 10。样式采用 CSScolor-mix基于主题变量生成填充与描边色,视觉上能与深/浅主题自洽。
需要理解的关键点:
- 事件挂载策略:
mousedown/scroll挂在滚动容器上,mousemove/mouseup挂在window上(capture 阶段)。这意味着鼠标拖出容器甚至拖出窗口,框选都不会丢。 - 拖出容器边缘自动滚动:靠 use-auto-scroll.ts 实现——当指针进入距容器上下边缘
scrollGutter(默认 100px)的条带时,速度随距离线性增长到maxSpeed(默认 15px/16ms),并用requestAnimationFrame驱动container.scrollBy,帧率由FRAME_DURATION_MS = 1000/60归一化。 - 框选不“丢选区”:拖拽中不断回调
onSelectionChange(start, end);拖拽结束回调onSelectionEnd,由 store 侧做最终提交。 - 多一种边界保障:通过
ResizeObserver监听容器尺寸变化;窗口 resize 或滚动时,都会修正拖拽起点/当前点坐标与容器几何信息,避免容器布局变化导致选框错位。
4.6createSelectionStore<T>(getId)—— 纯逻辑选择状态工厂
适合需要自定义选择逻辑、或不使用useSelectableList的场景,例如某些只需要“选中状态”而不需要框选 UI 的迷你列表:
const store = createSelectionStore<MyItem>((item) => item.id) store.getState().setItems(myItems) store.getState().select('item-1')Store State
| 字段 | 类型 | 说明 |
|---|---|---|
items | T[] | 当前数据源 |
selectedIds | Set<string> | 选中项 ID 集合 |
focusedIndex | number \| null | 键盘焦点行索引 |
lastActionIndex | number \| null | 上次操作行索引(Shift 范围选择锚点) |
需要补充的是,selection/types.ts 中还有两个状态字段承担更精细的职责:
committedSelectedIds—— “最近一次已提交用户操作后的选择”,而selectedIds在框选拖拽过程中会包含进行中的预览选中,二者分离才能让Escape或后续操作准确回退到“框选前”的状态。preservedIds—— 在范围/框选操作前被保留的 ID 集合,用于实现Shift/框选与已有选区的合并语义。
Store Methods
| 方法 | 说明 |
|---|---|
select(id) | 单选,清除其他 |
toggle(id) | 切换选中,不影响其他 |
rangeSelect(toIndex) | 从lastActionIndex到目标行范围选中 |
selectAll() | 全选 |
clearSelection() | 清空选择 |
setItems(items) | 更新数据源(自动剪除无效选中) |
marqueeSelect(start, end) | 框选预览(合并preservedIds) |
marqueeEnd() | 框选确认 |
moveFocus(delta) | 移动键盘焦点(+1/-1) |
focusedSelect() | 切换焦点行选中 |
shiftMoveFocus(delta) | 移动焦点 + 扩展选择 |
isSelected(id) | 查询是否选中 |
selectedCount() | 选中数量 |
五、交互语义一览
鼠标
| 操作 | 行为 |
|---|---|
| 单击行 | 单选(清除其他) |
Ctrl/Cmd + 单击 | Toggle(不影响其他) |
Shift + 单击 | 范围选择(从上次操作到当前行) |
| 单击 Checkbox | Toggle(不影响其他) |
| 表头 Checkbox | 全选/全不选 |
| 拖拽框选 | 框中的行全部选中 |
键盘
| 按键 | 行为 |
|---|---|
↑/↓ | 移动焦点(不改变选中) |
Space | 切换焦点行选中 |
Shift + ↑/↓ | 移动焦点 + 扩展选择 |
Ctrl/Cmd + A | 全选 |
Escape | 清空选择 |
这些语义并不是文档空谈,而是直接编码在 use-selectable-list.ts 的onKeyDown键盘分发中:ArrowDown/ArrowUp(带 Shift 走shiftMoveFocus)、空格、Ctrl/Cmd+A、Escape一一映射到对应 store 命令。
六、源码级原理:框选如何做到“虚拟化无死角”
这是 Desktop Kit 最值得细读的一处设计。普通表格实现框选往往遍历真实 DOM 或用getBoundingClientRect逐行判断“哪个元素被框住了”,但在虚拟列表中,视口外根本没有 DOM 可查——传统做法直接失效。
Desktop Kit 的做法是纯数学计算:固定行高 + 已知滚动偏移 + 已知表头高度,一行公式就能把鼠标像素坐标换算成行号区间:
const absTop = Math.min(y1, y2) + scrollTop - headerHeight const absBottom = Math.max(y1, y2) + scrollTop - headerHeight const startIndex = Math.max(0, Math.floor(absTop / rowHeight)) const endIndex = Math.min(totalCount - 1, Math.floor(absBottom / rowHeight))这段代码来自 marquee-overlay.tsx 的computeIndices。因为行高固定,所以:
- 索引计算是O(1),无论列表是 10 行还是 10 万行;
- 视口外的“隐形行”也能被正确框选,因为选中与渲染完全解耦——store 只记录 ID,不需要 DOM 存在;
- 拖拽过程中伴随自动滚动时,用“拖拽起点在旧滚动位置的偏移”与“当前滚动位置”动态换算,选框和命中范围始终同步。
同理,store 内部也用 Map 维护id → index(indexById,在setItems时重建),select/toggle的判定均为 O(1) 而非对数组线性扫描。此外,store 在数据刷新时有非常细致的自我保护逻辑(见 create-selection-store.ts 的setItems):
- 按 ID 剪除无效选中:已不存在的行自动从
selectedIds/committedSelectedIds中剔除; - 焦点按 ID 重定位:内部用
focusedId锚定焦点行,即使列表重排(排序变化)焦点仍跟随同一行;若该行被删除,则回退为“原索引 clamp 到新范围内”,呈现 Finder 风格的视觉连续性; - 范围锚点按 ID 重定位:
lastActionIndex由lastActionId解析而来,若锚点行被移除则清空,避免下一次Shift + 单击静默框选错误的任务。
七、设计原则
Desktop Kit 的五条设计原则,与源码可以逐条互证:
- 三层解耦—— SelectionEngine 不依赖 DOM,VirtualList 不感知选择,MarqueeOverlay 只输出索引。
- 泛型适配——
<T>+getId函数适配任意数据结构(任务、文件、会话记录均可,只要提供一个稳定的字符串 ID)。 - 数学计算替代 DOM 查询—— 框选通过
Math.floor(offset / rowHeight)计算索引,不查询 DOM,虚拟化无死角。 - 固定行高—— 所有列表场景统一固定行高,使索引计算为 O(1)。
- O(1) ID 查找—— 内部维护
Map<id, index>,select/toggle 不做线性扫描。
八、测试覆盖:三层各自独立验证
组件库通过分层解耦获得了清晰的测试金字塔,测试文件均与实现同目录:
- create-selection-store.test.ts —— 32 个单元测试,纯逻辑覆盖单选/toggle/范围/全选/框选预览与提交、
setItems剪除无效选中、按 ID 重定位焦点等状态机行为; - virtual-list.test.tsx —— 4 个组件测试,覆盖渲染与空状态;
- marquee-overlay.test.tsx —— 4 个组件测试,覆盖框选绘制与索引回调(其相邻的 use-auto-scroll.test.tsx 验证边缘自动滚动);
- use-selectable-list.test.tsx —— 12 个集成测试,验证胶水 Hook 把三者接线后的整体行为。
九、运行 Demo 与测试
Demo 入口位于仓库根目录 package.json 声明的开发脚本,应用启动后即可在渲染进程中体验三类演示列表:
- File Browser—— 500 个文件,多列表头,框选 + 键盘导航;
- Download Manager—— 200 个下载任务,进度条 + 状态色;
- Minimal List—— 10 个项目,空状态切换。
pnpm start # 启动应用查看 Demo pnpm test # 运行一次组件库测试 pnpm run test:watch # 监听模式注意仓库采用 pnpm 工作区(pnpm-workspace.yaml),执行前需先完成依赖安装。
十、总结
Desktop Kit 的价值在于把“桌面级列表交互”做成与业务无关的通用基础设施:纯逻辑的 SelectionEngine 让复杂选择状态(框选预览、Shift 合并、数据刷新后的 ID 级纠偏)可以被数百个测试独立守护;固定行高 + 数学换算让框选在十万级虚拟列表上依然精准;一个 useSelectableList 调用即可把渲染、手势、状态三者接成一体。如果你正在 Motrix 内实现下载任务列表或文件浏览面板这类界面,这就是可以直接复用的现成底座;即便在别的项目里,这套“状态层纯逻辑化、手势层只输出索引、渲染层不感知选择”的分层思路,也是设计高性能可交互长列表的可靠参考。
【免费下载链接】MotrixA full-featured download manager.项目地址: https://gitcode.com/GitHub_Trending/mo/Motrix
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考