
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/CmdA全选、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 下的实际结构SelectionEngineselection/create-selection-store.ts—— 一个纯逻辑的 Zustand store 工厂接口类型定义在 selection/types.ts。不依赖 React、不碰 DOM因此可以用纯单测覆盖create-selection-store.test.ts。VirtualListvirtual-list/virtual-list.tsx—— 对tanstack/react-virtual的泛型封装只负责“渲染哪些行”完全不感知选择逻辑。MarqueeOverlaymarquee-selection/marquee-overlay.tsx—— 框选的 UI 覆盖层负责监听拖拽手势最终只向外部输出一个“索引范围”。useSelectableListhooks/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 typecheckbox 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 typecheckbox checked{rp.selected} onChange{() {}} onClick{(e) { e.stopPropagation() rp.onCheckboxChange() }} / {item.name} /div ) }} / MarqueeOverlay {...marqueeProps} / /div ) }四个必须遵守的接线约定对照源码可验证外层容器必须position: relativeMarqueeOverlay绘制的 SVG 覆盖层才能与滚动容器对齐见 marquee-overlay.tsx 中zIndex: 10的全覆盖svg。外层容器必须能接收键盘事件tabIndex{0}onKeyDown否则↑/↓、Space等按键永远不会命中 use-selectable-list.ts 的onKeyDown分发逻辑。VirtualList必须显式设置height虚拟化依赖一个有确定高度的滚动容器。行内 Checkbox 要stopPropagation单击勾选框只做 toggle不能冒泡成行点击触发的“单选清空”。四、API 参考逐项对照源码4.1useSelectableListT(options)—— 胶水 HookOptions参数类型默认值说明itemsT[]必填列表数据getId(item: T) string必填从数据项提取唯一 IDrowHeightnumber必填固定行高pxheaderHeightnumber0表头高度框选坐标偏移用marqueebooleantrue是否启用框选源码中选项接口定义在 hooks/use-selectable-list.ts 的UseSelectableListOptions。值得注意它还接受一个可选store当组件树中多个列表需要共享同一份选择状态例如“任务列表”与“详情预览”联动高亮时可传入外部创建的 store。返回值字段类型说明listRefRefObjectVirtualListHandle传给VirtualList的 reflistProps{ items, getId, rowHeight, scrollRef }展开传给VirtualListmarqueePropsMarqueeOverlayProps展开传给MarqueeOverlayselectionSelectionStoreTZustand store可细粒度订阅getRowProps(index)(index: number) RowProps行级交互 propsheaderCheckboxHeaderCheckboxState表头全选 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 修饰键onCheckboxChangeCheckbox 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全部选中时为trueindeterminate部分选中时为trueonChange全选/全不选切换计算规则在源码中非常直白checked total 0 count total、indeterminate count 0 count totalonChange里若已有选中项则清空否则全选。渲染时把checked与indeterminate分别赋给原生 Checkbox 的checked与 DOM 属性el.indeterminate即可得到标准的“半选”表头。4.4VirtualListT—— 泛型虚拟滚动列表参数类型默认值说明itemsT[]必填列表数据getId(item: T) string必填唯一 ID 提取rowHeightnumber必填固定行高overscannumber5视口外预渲染行数scrollRefRefObjectHTMLDivElement—外部滚动容器 refuseSelectableList自动传入renderRow(props: RowRenderPropsT) ReactNode必填行渲染函数renderHeader() ReactNode—表头插槽renderEmpty() ReactNode—空状态插槽classNamestring—容器 CSS 类名styleCSSProperties—容器内联样式必须设置 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 HandleVirtualListHandle方法说明scrollToIndex(index)滚动到指定行getScrollOffset()当前滚动偏移量getContainerRef()滚动容器 DOM 引用4.5MarqueeOverlay—— 框选覆盖层参数类型默认值说明containerRefRefObjectHTMLDivElement必填滚动容器引用rowHeightnumber必填行高totalCountnumber必填总行数headerHeightnumber0表头偏移enabledbooleantrue启用/禁用minDragDistancenumber5最小拖拽距离pxscrollGutternumber100自动滚动触发区域pxscrollMaxSpeednumber15最大滚动速度px/16msonSelectionChange(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.6createSelectionStoreT(getId)—— 纯逻辑选择状态工厂适合需要自定义选择逻辑、或不使用useSelectableList的场景例如某些只需要“选中状态”而不需要框选 UI 的迷你列表const store createSelectionStoreMyItem((item) item.id) store.getState().setItems(myItems) store.getState().select(item-1)Store State字段类型说明itemsT[]当前数据源selectedIdsSetstring选中项 ID 集合focusedIndexnumber \| null键盘焦点行索引lastActionIndexnumber \| 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)框选预览合并preservedIdsmarqueeEnd()框选确认moveFocus(delta)移动键盘焦点1/-1focusedSelect()切换焦点行选中shiftMoveFocus(delta)移动焦点 扩展选择isSelected(id)查询是否选中selectedCount()选中数量五、交互语义一览鼠标操作行为单击行单选清除其他Ctrl/Cmd 单击Toggle不影响其他Shift 单击范围选择从上次操作到当前行单击 CheckboxToggle不影响其他表头 Checkbox全选/全不选拖拽框选框中的行全部选中键盘按键行为↑/↓移动焦点不改变选中Space切换焦点行选中Shift ↑/↓移动焦点 扩展选择Ctrl/Cmd A全选Escape清空选择这些语义并不是文档空谈而是直接编码在 use-selectable-list.ts 的onKeyDown键盘分发中ArrowDown/ArrowUp带 Shift 走shiftMoveFocus、空格、Ctrl/CmdA、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 → indexindexById在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 不依赖 DOMVirtualList 不感知选择MarqueeOverlay 只输出索引。泛型适配——TgetId函数适配任意数据结构任务、文件、会话记录均可只要提供一个稳定的字符串 ID。数学计算替代 DOM 查询—— 框选通过Math.floor(offset / rowHeight)计算索引不查询 DOM虚拟化无死角。固定行高—— 所有列表场景统一固定行高使索引计算为 O(1)。O(1) ID 查找—— 内部维护Mapid, indexselect/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),仅供参考