Plate Footnote Combobox UI:以 Obsidian 式轻量交互重构脚注插入与定义编辑体验

发布时间:2026/9/16 8:31:52
Plate Footnote Combobox UI:以 Obsidian 式轻量交互重构脚注插入与定义编辑体验 Plate Footnote Combobox UI以 Obsidian 式轻量交互重构脚注插入与定义编辑体验【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/platePlateREADME的 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节点插件节点特性渲染引用referenceBaseFootnoteReferencePlugin元素、内联、voidsup[1]/sup定义definitionBaseFootnoteDefinitionPlugin元素块级位于文档底部紧凑编号行combobox 输入BaseFootnoteInputPlugin元素、内联、voideditOnly内联输入框其中 combobox 输入节点定义最简单见 BaseFootnoteInputPlugin.tsimport { 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 createTSlatePluginFootnoteConfig({ 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/^\[$/仅当前一个字符匹配时才触发避免正文中裸^打开 comboboxcreateComboboxInput(trigger: string) TElement生成footnoteInput元素打开 combobox 时插入的节点工厂triggerQuery(editor) boolean无额外的触发门控谓词返回false则抑制触发下一个空闲编号优先nextId 的分配逻辑combobox 的第一个候选项是下一个空闲编号这依赖api.footnote.nextId。其实现位于 getNextFootnoteIdentifier.tsexport const getNextFootnoteIdentifier (editor: SlateEditor) { const used new Setnumber(); 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.tsexport 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.insertNodesTElement({ 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.tsfocusDefinition开关默认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...作为第一项且只有查询为空或查询本身就是纯数字时才显示它 —— 即用户输入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.tsxexport function FootnoteDefinitionElementStatic(props) { return ( SlateElement {...props} asdiv classNamemt-2 flex items-start gap-2 div classNamemt-0.5 min-w-4 text-muted-foreground text-sm tabular-nums {element.identifier ?? } /div div classNamemin-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.tstype FootnoteRegistry { definitionsByIdentifier: Mapstring, PathRef[]; referencesByIdentifier: Mapstring, PathRef[]; dirty: boolean; };注册表以WeakMapSlateEditor, 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),仅供参考