Tolaria 的 Neighborhood 模式:基于 entity 选区的笔记关系浏览如何实现
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
Tolaria 是一个基于 Markdown 文件库(Vault)的桌面知识管理应用,其侧边栏笔记列表除了常规筛选外,还内置了一种关系浏览模式:选定一篇笔记后,列表会围绕它展示所有关联分组。本文基于架构决策记录 0069:Neighborhood mode for note-list relationship browsing,结合仓库中的类型定义、Hook 与工具函数源码,讲清楚 Neighborhood 模式的产品定义、状态模型、键盘/指针交互语义,以及关系分组的构建与渲染实现,读完后你可以理解“从源码结构看”这套关系浏览是如何以最小的状态改动嵌入现有选区体系的。
背景:模糊的 entity 选区与不匹配的关系浏览
在正式化之前,Tolaria 已经存在一个关系浏览状态,隐藏在SidebarSelection.kind === 'entity'这一内部判别值之后,但产品语言与交互模型并不清晰。ADR 中记录了三个具体问题:
- 作为“源笔记”(source note)的选中项被渲染成一张特殊卡片,而不是普通笔记行;
- 分组后的关系结果在多个 section 之间做了去重,掩盖了“一篇笔记合法地属于多个分组”这一图谱事实;
- Cmd-click 的行为更像遗留的“在别处打开”,而不是一次明确的图导航动作。
新的笔记列表流程需要一个显式的产品概念——围绕某篇源笔记浏览其“邻居”(Neighborhood),并且键盘语义要与鼠标流程保持一致。团队还明确要求:当一篇笔记确实属于多个分组时,列表要保留图谱真相,而不是把重叠关系折叠掉。
决策:将 entity 选区正式定义为 Neighborhood 模式
ADR 的核心决策可以概括为两点。
第一,Tolaria 将SidebarSelection.kind === 'entity'正式定义为 Neighborhood 模式。笔记列表把当前选中的笔记视为邻域源,并使用“标准激活笔记行的样式”将其置顶,不再使用特殊卡片;关系分组先展示 outgoing(直接关系)分组,再展示 inverse/backlink 分组;空分组保持可见、计数为0;同一篇笔记在多条关系同时成立时允许出现在多个分组中。
第二,Neighborhood 导航是一个独立的“转向”(pivot)动作。普通点击与普通Enter只是打开聚焦的笔记,不会替换当前邻域;而 Cmd/Ctrl-click 与 Cmd/Ctrl-Enter会打开笔记并把整个列表转向(pivot)到那篇笔记自己的 Neighborhood。
为什么复用 entity 而不是新增 neighborhood 变体
ADR 列出了三个候选方案,最终选择复用现有entity选区:
| 方案 | 评估 |
|---|---|
复用现有entity选区作为 Neighborhood 模式(选定) | 状态模型保持局部化,避免再造一个几乎相同的笔记列表模式,且侧边栏选中任意其他目标即可退出 Neighborhood。代价:内部代码仍沿用历史性的entity命名 |
新增neighborhood选区变体 | 内部命名更清晰,但会复制同一份源笔记负载,并迫使整个应用的选区处理大范围改动,产品收益却很小 |
| 保留旧的隐式 entity 浏览行为 | 短期工程量最低,但产品术语不一致、去重分组与非 pivot 的 Cmd-click 等交互问题会一直保留 |
从源码结构看,这个“局部化”的取舍直接体现在选区类型的定义上。src/types.ts#L291-L296 中的SidebarSelection是一个五分支联合类型:
export type SidebarSelection = | { kind: 'filter'; filter: SidebarFilter } | { kind: 'sectionGroup'; type: string } | { kind: 'folder'; path: string; rootPath?: string } | { kind: 'entity'; entry: VaultEntry } | { kind: 'view'; filename: string; rootPath?: string }Neighborhood 模式就是其中{ kind: 'entity'; entry: VaultEntry }这一分支——载荷是完整的VaultEntry,因此不需要额外的状态容器。ADR 也在后果部分明确提示:内部代码仍使用entity判别值,未来的重构应把 “entity selection” 和 “Neighborhood mode” 视为同一概念,除非更大规模的导航重设计有理由引入新的选区形态。
pivot 的三种动作:enter / switch / exit
Neighborhood 的进入逻辑集中在纯函数 resolveNeighborhoodSelection 中:
export function resolveNeighborhoodSelection( currentSelection: SidebarSelection, entry: VaultEntry, ): NeighborhoodSelectionUpdate { const nextSelection: SidebarSelection = { kind: 'entity', entry } if (selectionsEqual(currentSelection, nextSelection)) { return { action: 'exit', selection: { kind: 'filter', filter: 'all' } } } return { action: currentSelection.kind === 'entity' ? 'switch' : 'enter', selection: nextSelection, } }从源码结构看,一次 pivot 会被归约为三种动作:
enter:当前不是 entity 选区,首次进入 Neighborhood;switch:当前已是某篇笔记的 Neighborhood,转向另一篇笔记(嵌套邻域);exit:对当前邻域源再次 pivot,则退出模式并回到all筛选。
与动作配套的是一层选区历史栈。pushNeighborhoodHistory 在选区发生实际变化时把“当前选区”压栈(selectionsEqual判断当前与目标相同则不压栈),popNeighborhoodHistory 做 LIFO 弹出。这使得连续 pivot 多篇笔记后可以逐层回退,回退目标是“转向前的那个选区”——它可能是一个folder、filter或另一个entity。
Hook 层的封装
React 侧由 src/hooks/useNeighborhoodSelection.ts 提供四个 Hook 完成接线:
useNeighborhoodEntry(L56-L81):pivot 的统一入口。它先调用resolveNeighborhoodSelection,发出neighborhood_mode_toggled遥测事件(携带action),再按动作分支:exit时弹出历史栈并恢复之前的选区;否则把当前选区压入历史并切换到新的 entity 选区。两次setSelection都带preserveNeighborhoodHistory: true选项,避免历史栈被后续清洗逻辑误清。useSelectionSanitizer(L83-L104):当外部(如侧边栏)把选区改成非 entity 形态时,清空neighborhoodHistoryRef并把列表筛选重置为'open'。这正是 ADR 所说“侧边栏导航是退出 Neighborhood 的路径”的实现依据。useNeighborhoodHistoryBack(L106-L119):弹栈回退,无历史时返回false。useNeighborhoodEscape(L121-L148):把键盘语义对齐到鼠标流程。
Escape 的触发条件由 shouldProcessNeighborhoodEscape 精确定义:键为Escape、未带 meta/ctrl/alt 修饰键、未被默认处理、当前选区kind === 'entity',且未被阻断。处理顺序上有一个细节值得注意:如果焦点落在编辑器表面(.editor__blocknote-container或.cm-editor),Escape 只负责失焦并把焦点拉回笔记列表容器([data-testid="note-list-container"]),不消费历史栈;焦点在其他可编辑元素时直接忽略;只有列表本身持有焦点时,Escape 才执行onBack()回退一层邻域。这保证了“普通按键保持当前邻域、修饰键组合才转向”的键盘语义不会与编辑器的快捷键冲突。
指针侧的 pivot 判定
鼠标侧的修饰键判定在 src/components/note-list/noteListUtils.ts:
function usesCommandModifier(event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey'>): boolean { return event.metaKey || event.ctrlKey }即 macOS 的 Cmd 与 Windows/Linux 的 Ctrl 走同一条分支,与 ADR 中 “Cmd/Ctrl-click” 的表述一致;不带修饰键的普通点击只打开笔记,不触发 pivot。
关系分组的构建:outgoing 优先、inverse 在后、重叠保留
列表内容来自 buildRelationshipGroups,它以entity(邻域源)和全库allEntries为输入,构建RelationshipGroup[]:
export function buildRelationshipGroups( entity: VaultEntry, allEntries: VaultEntry[], ): RelationshipGroup[] { const b = new GroupBuilder(entity.path, allEntries) const rels = entity.relationships if (entity.isA === 'Type') { b.filterAndAdd('Instances', (e) => e.isA === entity.title) } // Direct relationships first — all keys from entity.relationships take // priority so that reverse/computed groups (Children, Events, Referenced by) // only show *additional* entries not already covered by a direct property. Object.keys(rels) .filter((k) => k.toLowerCase() !== 'type') .sort((a, b) => a.localeCompare(b)) .forEach((key) => { b.addFromRefs(key, (Reflect.get(rels, key) as string[] | undefined) ?? []) }) for (const group of collectInverseRelationshipGroups(entity, allEntries)) { b.add(group.label, group.entries) } b.add('Backlinks', findBacklinks(entity, allEntries).sort(sortByModified)) return b.groups }从源码结构看,分组顺序与 ADR 的 “outgoing first, inverse/backlink after” 完全对应:
- Type 特例:如果邻域源是一篇 Type 文档,先加入
Instances分组(所有isA等于其标题的笔记); - 直接关系(outgoing):取
entity.relationships的全部键(排除type),按字母序加入。这些键来自笔记 frontmatter 中的属性引用,是“主动声明”的关系; - 逆关系(inverse):
collectInverseRelationshipGroups扫描全库反查,产出诸如Children、Events、Referenced by以及Belongs to、Related to等计算型分组; - Backlinks:最后追加
findBacklinks找到的反向链接组,按修改时间排序。
去重策略也在这里体现得比较微妙:注释明确说明直接关系的键拥有优先权,逆关系/计算型分组只展示“尚未被直接属性覆盖的额外条目”——也就是说去重只发生在计算组与直接组之间。没有被直接属性覆盖的条目,仍然会同时出现在多个计算型分组中(例如一篇笔记既出现在Referenced by又出现在Children)。这正是 ADR 所坚持的“保留图谱真相”:一篇笔记合法属于多个分组时,同一笔记会在多个分组中各出现一次,而不是被去重到只剩一处。
分组渲染:空组可见、计数恒显、组内独立排序
分组的 UI 由 RelationshipGroupSection 渲染:每个分组是一个可折叠的 section,头部左侧是分组名与group.entries.length计数,右侧是排序下拉。由于该组件对“非折叠状态下的空数组”不做额外隐藏,空分组依然渲染出标题行并显示计数0,与 ADR “keeps empty groups visible with count 0” 的决策一致。组内排序默认按modified降序,且每个分组拥有独立的SortConfig(sortPrefs[group.label] ?? { option: 'modified', direction: 'desc' }),还可通过extractSortableProperties支持按笔记的自定义属性排序。
邻域源本身的置顶也回归了常规渲染路径:ADR 的 Context 批评了旧的“特殊卡片”做法,决策改为“使用标准激活笔记行样式置顶”。列表侧的行渲染通过renderItem回调统一产出,置顶项与分组内项共用同一套行组件,差别只是选中态样式。
退出路径与整体状态流转
综合上面的源码,一次完整的 Neighborhood 使用闭环是:
- 在侧边栏某笔记上 Cmd/Ctrl-click(或 Cmd/Ctrl-
Enter)→useNeighborhoodEntry解析出enter或switch,当前选区压入历史栈,选区变为{ kind: 'entity', entry }; - 列表以该笔记为邻域源,按
buildRelationshipGroups渲染分组,可在分组间继续 pivot 形成多层邻域; - Escape(列表持焦时)逐层回退;或再次 pivot 当前邻域源触发
exit回all筛选; - 点击侧边栏任意其他目标(filter/section/folder/view)→
useSelectionSanitizer判定新选区非 entity,清空历史栈并重置列表筛选,模式随之退出。
第 4 步是 ADR “Options considered” 中选中方案的关键收益:由于模式完全建立在既有选区联合之上,退出不需要专门的状态位或命令,任何侧边栏导航天然就是退出动作。
验证与回归:测试覆盖点
该模式的测试分布在三层,均可在仓库中直接查证:
- src/hooks/useNeighborhoodSelection.test.ts:验证 enter/switch/exit 分支、历史栈行为与 Escape 路由;
- src/utils/neighborhoodHistory.test.ts:对
resolveNeighborhoodSelection、pushNeighborhoodHistory、popNeighborhoodHistory、selectionsEqual等纯函数的单测; - src/components/NoteList.keyboard.test.tsx 与 src/components/note-list/RelationshipGroupSection.test.tsx:覆盖键盘流与分组渲染(含计数与折叠)的组件级行为。
结论:一个“零新增状态”的产品概念
ADR 0069 的价值在于把一个既有的、命名混乱的选区分支升级为产品一等概念,同时把交互语义补齐:产品、测试与文档现在统一以 Neighborhood 指代这种笔记列表浏览模式;列表保留重叠的关系证据;键盘浏览(方向键 + Enter 保持邻域、Cmd/Ctrl-Enter 转向)与指针流程一致;侧边栏导航保留为唯一的退出路径。实现上它没有引入第二套状态——SidebarSelection联合类型、选区历史栈和纯函数式的分组构建器(src/utils/neighborhoodHistory.ts、src/utils/noteListHelpers.ts)共同支撑了 ADR 中 “keeps the state model localized” 的承诺,也留下了一个明确的遗留约定:内部entity判别值与产品术语 “Neighborhood mode” 长期共存,除非导航体系被整体重设计,否则二者应视为同一概念。
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考