Tiptap RubyText 扩展完全指南:在富文本编辑器中实现 CJK 汉字注音与 Ruby 标注

📅 发布时间:2026/9/10 15:14:43
Tiptap RubyText 扩展完全指南:在富文本编辑器中实现 CJK 汉字注音与 Ruby 标注
Tiptap RubyText 扩展完全指南在富文本编辑器中实现 CJK 汉字注音与 Ruby 标注【免费下载链接】tiptapThe headless rich text editor framework for web artisans.项目地址: https://gitcode.com/GitHub_Trending/ti/tiptapRubyText是 Tiptap 官方提供的一个 Mark 扩展用于在编辑器内渲染 HTMLruby注音标记汉字上方或旁边的假名 / 拼音阅读辅助。本文以tiptap/extension-ruby-text的发布记录为主线结合该包源码、测试与仓库内的 React / Vue 演示完整讲解其数据模型、setRubyText/toggleRubyText/unsetRubyText命令、点击编辑行为、renderAnnotationEditor自定义编辑器方案以及底层基于 Decoration 的实现原理帮助你在一开始就选对配置、写出可直接运行的注音编辑功能。一、扩展定位为 CJK 文本提供 Reading GuidesHTML 的ruby、rt、rb、rp元素长期以来被用来在 CJK中日韩文本旁展示注音阅读指南日语中称为Furigana振り仮名。tiptap/extension-ruby-text将这一能力带进 Tiptap 编辑器其包描述即为 HTML ruby text annotation extension for tiptap关键词列表也明确标注了cjk、furigana、ruby-text。值得先澄清的一点它和 Ruby 编程语言没有任何关系。README 中专门用一行强调 It is not related to the Ruby programming language。根据仓库内 packages/extension-ruby-text/README.mdTiptap 本身是对 ProseMirror 的无头headless封装本包则是在这一封装之上注册了一种新的Mark标记使选区文本能够携带一段注音并在文档中以标准的ruby/rb/rt结构序列化。从 CHANGELOG 反映的发布历程看该扩展经历了两个关键阶段3.27.3Minor首次加入官方 RubyText 扩展特性为 directly editable annotations and reliable cursor navigation即注音可直接编辑、光标导航可靠。3.29.0Minor设计定稿为 HTML ruby text annotations withnon-editable annotationsandmark-based document storage并新增通过renderAnnotationOption中的renderAnnotationEditor选项以自定义元素替换默认的点击即编辑注音编辑器。后续 3.29.x 至 3.30.3 均为对tiptap/core、tiptap/pm的常规依赖同步Patch ChangesAPI 保持稳定。也就是说仓库当前根目录 packages/extension-ruby-text/CHANGELOG.md 所记录的这条演进路径实际上就是本扩展的技术规格说明书。二、安装与最小接入RubyText 作为独立官方包发布仓库内位于 packages/extension-ruby-text其 package.json 显示名称为tiptap/extension-ruby-text当前版本 3.30.3MIT 协议。它声明tiptap/core与tiptap/pm为 peerDependencies使用时需自行安装二者。npm install tiptap/core tiptap/pm tiptap/extension-ruby-text # 或使用 pnpm / yarn pnpm add tiptap/core tiptap/pm tiptap/extension-ruby-text接入方式是把它作为一个普通扩展加入extensions数组。仓库演示 demos/src/Marks/RubyText/Vue/index.vue 展示了最小编辑器import Document from tiptap/extension-document import Paragraph from tiptap/extension-paragraph import RubyText from tiptap/extension-ruby-text import Text from tiptap/extension-text import { Editor, EditorContent } from tiptap/vue-3 new Editor({ element, extensions: [Document, Paragraph, Text, RubyText], content: pruby東京rtとうきょう/rt/rubyは日本の首都です。/p pruby漢字rtかんじ/rt/rubyの上にルビを表示できます。/p , })React 侧对应实现见 demos/src/Marks/RubyText/React/index.jsx使用useEditor与tiptap/react的EditorContent写法完全一致。包入口 packages/extension-ruby-text/src/index.ts 同时导出具名RubyText与 default 导出因此两种导入风格都可用import { RubyText } from tiptap/extension-ruby-text // 具名导入 import RubyText from tiptap/extension-ruby-text // default 导入演示里还给出了建议的样式基座为ruby设置ruby-position: over注音显示在上方并放开rt的指针事件以便点击进入编辑详见下文点击即编辑.tiptap { ruby { ruby-position: over; } rt { cursor: text; pointer-events: auto; input { font: inherit; margin: 0; } } }三、数据模型正文存文档、注音存 Mark 属性RubyText 的实现思路与单独插入一个 ruby 节点不同——它被设计成一种Mark注册于 src/ruby-text.tsexport const RubyText Mark.createRubyTextOptions({ name: rubyText, inclusive: false, // 光标移动到标记末尾后不再继续携带该标记 ... })其唯一属性rt存注音文本声明如下export interface RubyTextAttributes { /** The ruby text annotation rendered in the HTML rt element. */ rt: string | null }因此正文文字仍是普通文本注音只作为该文本的 Mark 属性保存在文档中。这带来两个好处文档结构干净——漢字两个字符是一个携带rubyText标记的文本节点不引入额外节点类型任何 ProseMirror / Tiptap 的文本级操作选区、删除、拼写、协作对注音标记天然友好。仓库测试 packages/extension-ruby-text/tests/ruby-text.spec.ts 的第一条用例精确锁定了这一数据模型——editor.commands.setRubyText({ rt: かんじ })后getJSON()的输出为{ type: doc, content: [ { type: paragraph, content: [ { type: text, text: 漢字, marks: [{ type: rubyText, attrs: { rt: かんじ } }] } ] } ] }rt的取值有几种边界情况测试也逐一验证见ruby-text.spec.tsrt值renderHTML行为测试断言かんじ渲染rt contenteditablefalseかんじ/rt输出包含rt contenteditablefalseかんじ/rt空串渲染空的rt contenteditablefalse/rt且不输出data-rt输出不含data-rt但含空rtnull完全不渲染rt元素输出为prubyrb漢字/rb/ruby/p在 renderHTML 中标记渲染为ruby包裹的rb与可选rtrenderHTML({ HTMLAttributes, mark }) { return [ ruby, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), [rb, 0], // 0 内容占位 ...(mark.attrs.rt null ? [] : [[rt, { contenteditable: false }, mark.attrs.rt]]), ] }注意rt被显式设置为contenteditablefalse——这就是 3.29.0 版本说明中 non-editable annotations 的含义注音文本本身不可直接键入修改修改统一走点击唤出编辑框的交互见第五节从而保证光标在正文间的导航不会因为多出一个可编辑层而错乱。四、HTML 解析兼容有 / 无rb与rp的写法parseHTML负责把粘贴或初始化时遇到的ruby结构还原成 Mark。它的匹配规则src/ruby-text.ts是标签为rubycontentElement优先取子元素rb作为内容载体若不存在rb则把除RT、RP之外的子节点克隆到一个span载体中覆盖ruby東京rt…/rt/ruby这类省略rb的原生写法getAttrs在ruby内查找rt将其textContent作为rt属性若没有rt返回false即不解析为 rubyText 标记。仓库测试分别验证了三种粘贴场景// 带 rb正确还原 prubyrb漢字/rbrtかんじ/rt/ruby/p // 无 rb原生省略写法漢字 被标记后续は日本の首都です。保持普通文本 pruby東京rtとうきょう/rt/rubyは日本の首都です。/p // 无 rt整个 ruby 被降级为纯文本不产生标记 pruby漢字/ruby/prpruby fallback parenthesis为不支持 ruby 的浏览器准备的括号在解析时被主动剔除不会进入文档内容。五、三个命令与工具栏接入命令在 declare module tiptap/core 中声明并委托给 Tiptap 内建的标记命令实现addCommands命令签名底层委托说明setRubyText(attrs: { rt: string \| null }) …commands.setMark给当前选区套上 rubyText 标记并写入注音toggleRubyText(attrs: { rt: string \| null }) …commands.toggleMark(…, { extendEmptyMarkRange: true })有则移除、无则添加unsetRubyText() …commands.unsetMark(…, { extendEmptyMarkRange: true })移除标记正文不受影响其中toggleRubyText与unsetRubyText都带extendEmptyMarkRange: true当光标停在被标记文本的边界上时选区为空命令会把范围扩展到整个相邻标记保证一键撤销整段注音符合直觉。测试ruby-text.spec.ts对三命令做了完整往返验证set → isActive(rubyText) truetoggle → falseunset → getHTML() p漢字/p。工具栏接线React参考 demos/src/Marks/RubyText/React/index.jsx通过editor.getAttributes(rubyText).rt回显当前选中文本的注音用chain().focus().setRubyText({ rt }).run()提交const [rt, setRt] React.useState() const editor useEditor({ extensions: [Document, Paragraph, Text, RubyText], content: pruby漢字rtかんじ/rt/ruby/p, onSelectionUpdate: ({ editor }) { setRt(editor.getAttributes(rubyText).rt ?? ) // 选区变化时同步输入框 }, }) // 表单提交 editor.chain().focus().setRubyText({ rt }).run() // 移除按钮仅在选区命中标记时可点 editor.chain().focus().unsetRubyText().run() // 按钮可用态 !editor.isActive(rubyText) // 选区为空时禁用设置按钮 editor.state.selection.empty工具栏接线Vue同逻辑的 Vue 写法见 demos/src/Marks/RubyText/Vue/index.vue通过onSelectionUpdate更新data.rt表单以submit.preventeditor.chain().focus().setRubyText({ rt }).run()提交editor-content :editoreditor /承载编辑器视图并在beforeUnmount中调用editor.destroy()。更新已存在的注音setRubyText对已带标记的文本再次调用即可覆盖旧注音无需先 unseteditor.commands.setRubyText({ rt: かんじ }) // 旧 editor.commands.setRubyText({ rt: かんじ新 }) // 覆盖测试 updates the annotation on an existing mark 验证输出 HTML 含かんじ新。六、扩展选项从只读展示到自定义编辑 UIRubyTextOptions只有三个配置项定义在 src/ruby-text.ts选项类型默认值作用HTMLAttributesRecordstring, any{}附加到ruby元素上的 HTML 属性如class、lang。allowClickToEditbooleantrue点击注音是否弹出内联编辑框。false时注音仅展示、不可编辑可用于只读回显场景。renderAnnotationEditor(props) HTMLElementundefined自定义点击即编辑的编辑器元素不设置时默认渲染纯文本input。配置示例测试用例RubyText.configure({ HTMLAttributes: { class: ruby-text }, // ruby classruby-text… allowClickToEdit: true, // renderAnnotationEditor: myEditor, })HTMLAttributes同时作用于两个渲染出口静态序列化的renderHTML以及编辑器内 Mark 视图addMarkView测试确认editor.view.dom.querySelector(ruby)?.className ruby-text。关闭点击编辑 / 只读场景allowClickToEdit: false或editor.setEditable(false)时点击rt不会唤起编辑框注音保持静态展示。底层由装饰插件的stopEvent与view.editable双重守卫控制见第七节对应测试分别覆盖了allowClickToEdit: false与setEditable(false)两条路径。自定义注音编辑器这是 3.29.0 引入的核心扩展点。renderAnnotationEditor接收的 props 定义于 src/ruby-text-decoration-plugin.tsexport interface RubyTextAnnotationEditorProps { /** 当前注音值标记无注音或为空时是 */ annotation: string /** 提交新值并关闭编辑器重复调用或 dismiss 后调用是空操作 */ submit: (value: string) void /** 关闭编辑器且不改动注音重复调用或 submit 后调用是空操作 */ dismiss: () void /** Tiptap 编辑器实例 */ editor: Editor }返回的HTMLElement会被挂载到rt内部若元素内含带autofocus属性的后代焦点会落到该后代上否则聚焦元素本身源码注释说明浏览器对插入节点的 autofocus 不可靠因此手动 focus。一个弹出式输入框 预设按钮的自定义实现可以是import type { RubyTextAnnotationEditorProps } from tiptap/extension-ruby-text RubyText.configure({ renderAnnotationEditor({ annotation, submit, dismiss }) { const wrapper document.createElement(span) wrapper.className annotation-editor const input document.createElement(input) input.autofocus true // 挂载后自动聚焦 input.value annotation input.addEventListener(keydown, e { if (e.key Enter) submit(input.value) if (e.key Escape) dismiss() }) const confirm document.createElement(button) confirm.textContent 确定 confirm.addEventListener(click, () submit(input.value)) const cancel document.createElement(button) cancel.textContent 取消 cancel.addEventListener(click, () dismiss()) wrapper.append(input, confirm, cancel) return wrapper }, })仓库测试对该扩展点验证得非常细致能接收annotation与editor实例、能通过submit(value)更新注音此后编辑器被卸载、dismiss()恢复原注音、编辑器关闭后再次submit/dismiss为无操作、编辑过程中文档被外部改动后旧submit会被忽略防止把陈旧选区写坏文档。自定义编辑器返回的元素中若含[autofocus]后代也会被正确聚焦。默认编辑器的交互契约未配置renderAnnotationEditor时使用默认纯文本输入框defaultRenderAnnotationEditor交互契约如下Entersubmit(input.value)写入新注音并关闭Escapedismiss()放弃修改失焦blurdismiss()自动附带aria-labelRuby text annotation输入框size随内容伸缩。七、底层原理Decoration 渲染不可编辑rt保证光标导航可靠要理解为何注音显示在文本之后却能正确对齐、为何光标不会钻进不可编辑区需要看扩展的插件部分。RubyText的addProseMirrorPlugins返回一个由 src/ruby-text-decoration-plugin.ts 导出的RubyTextDecorationPlugin其核心策略是扫描文档getRubyTextRanges用一次doc.descendants遍历把相邻且属性相同的rubyText文本合并成一段段RubyTextRange { from, to, mark }L214-L224。生成 Widget Decoration为每个 range 在to位置正文末尾插入一个Decoration.widgetkey 为ruby-text-${from}-${to}-${annotation}、side: 1、并绑定该段的markscreateDecorations。Widget DOM 由createRtElement产出一个contentEditable false的rt。因为rt属于 Decoration 而非文档内容它不占用文档位置、不出现在序列化结果中——这正是 3.29.0 mark-based document storage 的另一半含义真正的注音数据存在 mark 属性里DOM 上的rt只是运行时装饰视图。这样设计带来的直接收益就是 CHANGELOG 中反复强调的reliable cursor navigation由于rt对 ProseMirror 而言不可编辑、仅是一个挂在文档末尾的 widget光标始终在正文文本节点上移动不会出现注音里多出一个可编辑文本节点导致的导航分裂或位置错乱。State 同步插件state.apply在docChanged时重建整棵 DecorationSet否则用事务映射把旧装饰平移到新位置L284-L290。事件隔离stopEvent在allowClickToEdit开启时拦截编辑器范围内几乎所有指针 / 键盘 / 剪贴板 / 合成事件类型STOPPED_EVENT_TYPES确保操作集中在input而不被 ProseMirror 抢走。会话防呆editSessions是WeakMapNode, () void记录当前编辑会话的清理函数当 widget 因文档变化被销毁时触发destroy把editing置回false防止后续对已卸载编辑器的过期submit/dismiss生效源码注释 Ends a widgets edit session on destroy, so stale submit/dismiss calls do nothing。submit内部若发现值未变化则直接走dismiss——因为不产生 doc 变更时 widget 不会重渲染、编辑框会滞留L124-L129。编辑器被切到非编辑态view.editable false后submit降级为dismiss不写入任何内容。IME 友好isImeEvent判断event.isComposing或 Safari 旧式keyCode 229在输入法组合期间忽略 Enter避免拼音选字回车误提交注音对应的专门测试用例也存在于测试文件中。八、版本发布记录速览与升级建议CHANGELOG 完整记录了该扩展的版本迭代packages/extension-ruby-text/CHANGELOG.md版本变更类型内容3.29.0Minor正式发布官方 RubyText 扩展不可编辑注音 基于 Mark 的文档存储新增renderAnnotationEditor以自定义点击编辑 UI3.29.1 ~ 3.30.3Patch随tiptap/core/tiptap/pm例行升级并同步依赖如果你的项目正从早期3.27.3 时代自研方案迁移或准备引入注音编辑两点现实建议与 tiptap 主版本保持同一系列RubyText 与tiptap/core、tiptap/pm版本强绑定Patch 记录几乎全部是依赖同步升级扩展时应同步升级这三个包注音数据只信 mark 属性解析回填与序列化都以rt属性为准DOM 里的rt只是装饰不要在外部逻辑里试图直接改写它——要改注音请通过setRubyText或编辑器内点击编辑完成否则 widget 重建后修改会丢失。如需深入源码推荐依次阅读 packages/extension-ruby-text/src/ruby-text.tsMark 定义与命令、packages/extension-ruby-text/src/ruby-text-decoration-plugin.tsDecoration 与编辑会话与 packages/extension-ruby-text/tests/ruby-text.spec.ts全部行为契约共 20 余条用例两端框架的完整演示见 demos/src/Marks/RubyText/React/index.jsx 与 demos/src/Marks/RubyText/Vue/index.vue。【免费下载链接】tiptapThe headless rich text editor framework for web artisans.项目地址: https://gitcode.com/GitHub_Trending/ti/tiptap创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考