Plate Footnote Combobox UI:以 Obsidian 式轻量交互重构脚注插入与定义编辑体验
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
Plate(README)的 footnote 能力经历了从"功能齐全但沉重"到"轻量、贴近书写直觉"的交互演进。本文以 2026-04-05-footnote-combobox-ui 计划 为核心,完整讲解这次重构的目标、节点模型、[^内联 combobox 的触发与插入链路、紧凑定义行的渲染方案,以及如何把「下一个空闲编号优先」的分配策略落地到真实编辑器中。读完你可以掌握 Plate footnote 插件的完整接入方式,并理解其底层查询 API、变换(transforms)与惰性注册表(registry)是如何协同工作的。
重构动机:定义编辑块太重,引用插入不够内联
在重构之前,footnote 交互有两个明确的痛点(计划文档 Notes 部分):
- "Editor Definition" 块过于沉重:定义编辑区域以盒状卡片呈现,视觉与操作成本偏高;
- 插入引用不够轻、不够内联:新增一条脚注引用需要经过较重的中转,打断了书写流。
重构目标因此被定为:接近 Obsidian 的脚注体验—— 定义以紧凑的编号行呈现,引用通过内联 combobox 插入,且复用项目已有的 emoji/combobox 基础设施,而不是另起炉灶发明一个新的弹层组件。计划同时强调范围约束:"只针对当前 Plate footnote 表面,不做幻想中的新 note-link 系统"。
重构完成后的可见结果(计划的 Outcome 部分):
- 脚注定义从盒状卡片改为紧凑编号行,并带一个可见的 "Back to reference" 返回控件;
- 在 live docs 编辑器的空行输入
[^会打开 combobox,第一个候选项就是下一个空闲编号; - 选中 "new-footnote" 行后,编辑器在原地插入
[4]引用,并在文档底部创建一条紧凑的4定义行,全程不跳入沉重的定义卡片。
三个节点:引用、定义与 combobox 输入
这一交互建立在三个 Slate 节点之上,分别由三个插件声明(源码见 packages/footnote/src/lib):
| 节点 | 插件 | 节点特性 | 渲染 |
|---|---|---|---|
| 引用(reference) | BaseFootnoteReferencePlugin | 元素、内联、void | <sup>[1]</sup> |
| 定义(definition) | BaseFootnoteDefinitionPlugin | 元素(块级),位于文档底部 | 紧凑编号行 |
| combobox 输入 | BaseFootnoteInputPlugin | 元素、内联、void,editOnly | 内联输入框 |
其中 combobox 输入节点定义最简单,见 BaseFootnoteInputPlugin.ts:
import { createSlatePlugin, KEYS } from 'platejs'; /** Enables support for inline footnote combobox inputs. */ export const BaseFootnoteInputPlugin = createSlatePlugin({ key: KEYS.footnoteInput, editOnly: true, node: { isElement: true, isInline: true, isVoid: true }, });它只在编辑态存在(editOnly: true),是用户在^之后输入查询文本时的"活"输入载体;一旦选择完成,它会被真实的引用节点替换。官方文档明确指出:FootnoteReferencePlugin会自动把FootnoteInputPlugin拉进来(见 [footnote.mdx/(elements)/footnote.mdx) 的 Manual Usage 一节),只有当你完全自己渲染 combobox 时才需要手动单独注册它。
[^触发链路:复用 combobox 基础设施
引用插件是整套能力的枢纽。它在 BaseFootnoteReferencePlugin.ts 中通过createTSlatePlugin声明了 combobox 的触发选项:
export const BaseFootnoteReferencePlugin = createTSlatePlugin<FootnoteConfig>({ key: KEYS.footnoteReference, options: { createComboboxInput: () => ({ children: [{ text: '' }], type: KEYS.footnoteInput, }), trigger: '^', triggerPreviousCharPattern: /^\[$/, }, node: { isElement: true, isInline: true, isVoid: true }, plugins: [BaseFootnoteInputPlugin], render: { as: 'sup' }, })这些选项由withTriggerCombobox消费(实现见 withTriggerCombobox.ts)。其核心逻辑是重写编辑器的insertText:当输入文本命中trigger(这里是^),且**上一个字符匹配triggerPreviousCharPattern(默认要求是 ``)**时,才在光标处插入一个 combobox 输入节点;否则退化为普通文本插入。
const matchesTrigger = (text: string) => { const { trigger } = getOptions(); if (trigger instanceof RegExp) return trigger.test(text); if (Array.isArray(trigger)) return trigger.includes(text); return text === trigger; };这就是"空行输入[^才打开 combobox、普通文本里的裸^不触发"的底层保证。值得一提的细节:withTriggerCombobox在创建输入节点时会写入userId(当editor.meta.userId存在时),这样在 Yjs 协作场景中只有发起者能看到自己的 combobox,其他协作者不会被弹层干扰。此外还支持可选的triggerQuery谓词,用于在特定选区抑制触发。
组合触发相关选项如下(引用自 [footnote.mdx/(elements)/footnote.mdx) 的 Plugins 章节):
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
trigger | RegExp \| string[] \| string | '^' | 打开脚注 combobox 的字符 |
triggerPreviousCharPattern | RegExp | /^\[$/ | 仅当前一个字符匹配时才触发,避免正文中裸^打开 combobox |
createComboboxInput | (trigger: string) => TElement | 生成footnoteInput元素 | 打开 combobox 时插入的节点工厂 |
triggerQuery | (editor) => boolean | 无 | 额外的触发门控谓词,返回false则抑制触发 |
下一个空闲编号优先:nextId 的分配逻辑
combobox 的第一个候选项是"下一个空闲编号",这依赖api.footnote.nextId。其实现位于 getNextFootnoteIdentifier.ts:
export const getNextFootnoteIdentifier = (editor: SlateEditor) => { const used = new Set<number>(); const registry = ensureFootnoteRegistry(editor); for (const identifier of registry.definitionsByIdentifier.keys()) { if (NUMERIC_IDENTIFIER_REGEX.test(identifier)) { used.add(Number.parseInt(identifier, 10)); } } for (const identifier of registry.referencesByIdentifier.keys()) { if (NUMERIC_IDENTIFIER_REGEX.test(identifier)) { used.add(Number.parseInt(identifier, 10)); } } let next = 1; while (used.has(next)) { next += 1; } return `${next}`; };该函数同时扫描已占用的定义编号与引用编号(NUMERIC_IDENTIFIER_REGEX = /^\d+$/),从 1 开始递增找到第一个空闲数字。也就是说,即使某个编号只有引用没有定义(例如从外部粘贴而来),nextId也不会重复分配它。
插入链路:一次变换完成「引用 + 定义」
从 combobox 中选择候选项后,前端调用的是insertTransforms.footnote({ focusDefinition: false, identifier })(见 footnote-node.tsx 的insertSelectedFootnote)。底层实现在 insertFootnote.ts:
export const insertFootnote = (editor, { focusDefinition = true, identifier, ...options } = {}) => { if (!editor.selection) return; const selectionBefore = structuredClone(editor.selection); const nextIdentifier = identifier ?? getNextFootnoteIdentifier(editor); const fragment = editor.api.isExpanded() ? (editor.api.fragment(editor.selection) as TNode[]) : undefined; const referenceType = editor.getType(KEYS.footnoteReference); editor.tf.withoutNormalizing(() => { // 1) 原地插入内联引用节点 editor.tf.insertNodes<TElement>({ children: [{ text: '' }], identifier: nextIdentifier, type: referenceType }, options); // 2) 在文档底部创建匹配的定义节点 createFootnoteDefinition(editor, { focus: false, fragment, identifier: nextIdentifier }); }); // 3) 默认把光标聚焦到定义正文 if (shouldFocusDefinition) { focusFootnoteDefinition(editor, { identifier: nextIdentifier }); return; } // focusDefinition: false 时,把光标放回引用之后 if (referencePath) { const point = getFootnoteReferenceSelectionPoint(editor, referencePath); if (point) editor.tf.select({ anchor: point, focus: point }); } };关键设计:
- 一次变换完成两件事:插入引用 + 在文档末尾追加定义,二者通过
withoutNormalizing包裹,避免中间态被 normalization 打断; - 选区种子(fragment):如果当前选区是展开的(选中了一段文本),选中的内容会被克隆并作为定义正文的种子,实现"选中文字一键转脚注"(
createFootnoteDefinition会逐个把块包装为段落,见 createFootnoteDefinition.ts); focusDefinition开关:默认true(插入后跳进定义正文开始书写);combobox 场景传false,光标停留在内联引用之后,定义行安静地出现在底部 —— 这正是 Outcome 中"插入[4]且不跳入定义卡片"的实现来源;- 幂等性:
createFootnoteDefinition会先查询该编号是否已有定义,存在则复用,不重复创建。
tf.insert.footnote的完整参数如下(引用自官方文档 footnote.mdx/(elements)/footnote.mdx)):
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
identifier | string | api.footnote.nextId() | 复用已有编号 |
focusDefinition | boolean | true | 插入后是否聚焦定义正文 |
...options | InsertNodesOptions | — | 透传给引用插入的标准at/select等选项 |
combobox 渲染:next id 优先 + 已存在脚注可检索
combobox 的 UI 实现在 footnote-node.tsx 的FootnoteInputElement中,基于项目的InlineCombobox组件族构建。核心状态逻辑:
const identifiers = footnoteApi.identifiers?.() ?? []; const nextIdentifier = footnoteApi.nextId?.() ?? '1'; const query = search.trim(); const numericQuery = NUMERIC_FOOTNOTE_QUERY.test(query) ? query : ''; const proposedIdentifier = numericQuery || nextIdentifier; const showCreateOption = !identifiers.includes(proposedIdentifier);要点:
- 新建项置顶:只要
proposedIdentifier(默认就是 nextId)尚未被定义,就渲染[^{id}] : New footnote...作为第一项,且只有查询为空或查询本身就是纯数字时才显示它 —— 即用户输入1、2这类数字时可以精确指定编号; - 检索已有脚注:
filteredIdentifiers同时匹配编号与定义正文(definitionText不区分大小写),这样可以直接从已有脚注中挑选复用,而不是每次都新建; - 选择即插入:
insertSelectedFootnote会先检查光标前是否为[,是则deleteBackward('character')删掉它,再以focusDefinition: false调用插入变换,把[+^完整替换为引用节点; - 空结果时显示
No footnotes空态。
同时,FootnoteReferenceElement提供了引用侧的交互:<sup>内的[id]是一个按钮,悬停时通过 HoverCard 展示定义正文预览(definitionText实时读取,48 字符截断),Ctrl/Cmd + 点击可直接跳到定义;未解析的引用(只有引用、没有定义)会提供 "Create definition for [^id]" 按钮一键补全。
紧凑定义行:从卡片到编号行
定义节点在静态渲染与客户端渲染中都是"紧凑编号行"形态。静态版本见 footnote-node-static.tsx:
export function FootnoteDefinitionElementStatic(props) { return ( <SlateElement {...props} as="div" className="mt-2 flex items-start gap-2"> <div className="mt-0.5 min-w-4 text-muted-foreground text-sm tabular-nums"> {element.identifier ?? ''} </div> <div className="min-w-0 flex-1">{props.children}</div> </SlateElement> ); }编号以等宽数字(tabular-nums)单独成列,正文占据剩余空间 —— 没有边框、没有卡片背景,视觉上就是一条轻量的脚注行。客户端版本FootnoteDefinitionElement在此基础上增加了:
- "Back to reference":编号本身是按钮,点击调用
focusReference跳回引用;当同一编号被多处引用时,弹出 Popover(基于Command)列出所有引用位置的上下文标签,可选择跳转到具体某一条(focusReference({ identifier, index })); - 重复定义修复:当检测到
isDuplicateDefinition(同一编号有多个定义)时,该行以琥珀色高亮并渲染 "Renumber to [^{nextId}]" 按钮,调用normalizeDuplicateDefinition把后出现的重复定义重排为下一个空闲编号,首个定义保持权威(详见 getFootnoteDefinition.ts 与 footnote.mdx/(elements)/footnote.mdx) 的 Duplicate Definitions 章节)。
注册表:查询廉价的关键
api.footnote.definition / definitions / definitionText / references / identifiers / isResolved / nextId等查询都走一个按编辑器惰性构建的注册表(registry.ts):
type FootnoteRegistry = { definitionsByIdentifier: Map<string, PathRef[]>; referencesByIdentifier: Map<string, PathRef[]>; dirty: boolean; };注册表以WeakMap<SlateEditor, FootnoteRegistry>挂在编辑器上,用PathRef保存路径(随文档变换自动更新),并通过shouldInvalidateFootnoteRegistry/invalidateFootnoteRegistry在 footnote 相关操作(apply 阶段)把dirty置位,下一次查询时再重建。这样 hover 预览、导航这类高频读操作不会每次都全树扫描,即使一条定义被多处引用也能保持查询廉价 —— 官方文档在 API 章节对此有明确说明(见 footnote.mdx/(elements)/footnote.mdx))。
在项目中使用
方式一:直接使用默认 UI Kit
仓库在 footnote-kit.tsx 中预组装了三个插件与默认渲染组件:
export const FootnoteKit = [ FootnoteInputPlugin.withComponent(FootnoteInputElement), FootnoteReferencePlugin.withComponent(FootnoteReferenceElement), FootnoteDefinitionPlugin.withComponent(FootnoteDefinitionElement), ];配合MarkdownKit使用即可获得上述全部交互(参考 footnote.mdx/(elements)/footnote.mdx) 的 Kit Usage 章节)。
方式二:手动接入插件
npm install @platejs/footnote @platejs/markdown remark-gfmimport { FootnoteDefinitionPlugin, FootnoteReferencePlugin } from '@platejs/footnote/react'; import { MarkdownPlugin } from '@platejs/markdown'; import { createPlateEditor } from 'platejs/react'; import remarkGfm from 'remark-gfm'; const editor = createPlateEditor({ plugins: [ FootnoteReferencePlugin, FootnoteDefinitionPlugin, MarkdownPlugin.configure({ options: { remarkPlugins: [remarkGfm] }, }), ], });配remark-gfm是为了让[^1]/[^1]: text在 markdown 往返中保持为真正的 GFM 脚注语法而不是退化为纯文本(安装与使用细节另见 footnote 包 README)。
程序化插入与导航
editor.tf.insert.footnote(); // 插入引用 + 定义并聚焦定义正文 editor.tf.insert.footnote({ focusDefinition: false }); // 只插入引用,光标留在行内 editor.tf.footnote.createDefinition({ identifier: '3' }); // 为未解析引用补建定义 editor.tf.footnote.focusDefinition({ identifier: '3' }); // 跳到定义 editor.tf.footnote.focusReference({ identifier: '3', index: 1 }); // 跳到第 2 处引用小结
这次 footnote combobox UI 重构给出了一个清晰的取舍模板:交互要轻、要内联,能力复用要优先于新造组件。底层依旧是一套扎实的节点模型(引用 / 定义 / combobox 输入)、一个withTriggerCombobox触发协议、一组幂等的插入与导航变换,以及一个惰性注册表保证查询性能;变化的只是表层 —— 定义从盒状卡片收敛为紧凑编号行,引用插入从显式操作收敛为^即触发的内联选择。若需完整 API 参考与参数说明,可直接查阅 [footnote.mdx/(elements)/footnote.mdx) 与 footnote 包 README。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考