Tolaria 的 Neighborhood 模式:基于 entity 选区的笔记关系浏览如何实现
2026/9/13 21:11:27 网站建设 项目流程

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 多篇笔记后可以逐层回退,回退目标是“转向前的那个选区”——它可能是一个folderfilter或另一个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” 完全对应:

  1. Type 特例:如果邻域源是一篇 Type 文档,先加入Instances分组(所有isA等于其标题的笔记);
  2. 直接关系(outgoing):取entity.relationships的全部键(排除type),按字母序加入。这些键来自笔记 frontmatter 中的属性引用,是“主动声明”的关系;
  3. 逆关系(inverse)collectInverseRelationshipGroups扫描全库反查,产出诸如ChildrenEventsReferenced by以及Belongs toRelated to等计算型分组;
  4. Backlinks:最后追加findBacklinks找到的反向链接组,按修改时间排序。

去重策略也在这里体现得比较微妙:注释明确说明直接关系的键拥有优先权,逆关系/计算型分组只展示“尚未被直接属性覆盖的额外条目”——也就是说去重只发生在计算组与直接组之间。没有被直接属性覆盖的条目,仍然会同时出现在多个计算型分组中(例如一篇笔记既出现在Referenced by又出现在Children)。这正是 ADR 所坚持的“保留图谱真相”:一篇笔记合法属于多个分组时,同一笔记会在多个分组中各出现一次,而不是被去重到只剩一处。

分组渲染:空组可见、计数恒显、组内独立排序

分组的 UI 由 RelationshipGroupSection 渲染:每个分组是一个可折叠的 section,头部左侧是分组名与group.entries.length计数,右侧是排序下拉。由于该组件对“非折叠状态下的空数组”不做额外隐藏,空分组依然渲染出标题行并显示计数0,与 ADR “keeps empty groups visible with count 0” 的决策一致。组内排序默认按modified降序,且每个分组拥有独立的SortConfigsortPrefs[group.label] ?? { option: 'modified', direction: 'desc' }),还可通过extractSortableProperties支持按笔记的自定义属性排序。

邻域源本身的置顶也回归了常规渲染路径:ADR 的 Context 批评了旧的“特殊卡片”做法,决策改为“使用标准激活笔记行样式置顶”。列表侧的行渲染通过renderItem回调统一产出,置顶项与分组内项共用同一套行组件,差别只是选中态样式。

退出路径与整体状态流转

综合上面的源码,一次完整的 Neighborhood 使用闭环是:

  1. 在侧边栏某笔记上 Cmd/Ctrl-click(或 Cmd/Ctrl-Enter)→useNeighborhoodEntry解析出enterswitch,当前选区压入历史栈,选区变为{ kind: 'entity', entry }
  2. 列表以该笔记为邻域源,按buildRelationshipGroups渲染分组,可在分组间继续 pivot 形成多层邻域;
  3. Escape(列表持焦时)逐层回退;或再次 pivot 当前邻域源触发exitall筛选;
  4. 点击侧边栏任意其他目标(filter/section/folder/view)→useSelectionSanitizer判定新选区非 entity,清空历史栈并重置列表筛选,模式随之退出。

第 4 步是 ADR “Options considered” 中选中方案的关键收益:由于模式完全建立在既有选区联合之上,退出不需要专门的状态位或命令,任何侧边栏导航天然就是退出动作。

验证与回归:测试覆盖点

该模式的测试分布在三层,均可在仓库中直接查证:

  • src/hooks/useNeighborhoodSelection.test.ts:验证 enter/switch/exit 分支、历史栈行为与 Escape 路由;
  • src/utils/neighborhoodHistory.test.ts:对resolveNeighborhoodSelectionpushNeighborhoodHistorypopNeighborhoodHistoryselectionsEqual等纯函数的单测;
  • 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询