Plate Footnote Combobox UI:以 Obsidian 式轻量交互重构脚注插入与定义编辑体验
2026/9/16 8:31:49 网站建设 项目流程

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 部分):

  1. 脚注定义从盒状卡片改为紧凑编号行,并带一个可见的 "Back to reference" 返回控件;
  2. 在 live docs 编辑器的空行输入[^会打开 combobox,第一个候选项就是下一个空闲编号
  3. 选中 "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 章节):

选项类型默认值说明
triggerRegExp \| string[] \| string'^'打开脚注 combobox 的字符
triggerPreviousCharPatternRegExp/^\[$/仅当前一个字符匹配时才触发,避免正文中裸^打开 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)):

参数类型默认值说明
identifierstringapi.footnote.nextId()复用已有编号
focusDefinitionbooleantrue插入后是否聚焦定义正文
...optionsInsertNodesOptions透传给引用插入的标准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...作为第一项,且只有查询为空或查询本身就是纯数字时才显示它 —— 即用户输入12这类数字时可以精确指定编号;
  • 检索已有脚注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-gfm
import { 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),仅供参考

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

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

立即咨询