TipTap 双模编辑器:富文本与 Markdown 实时切换的实现
系列位置:本文是「项目架构总览」系列的第 3/10 篇。上一篇我们聊了为什么选 React 19 + Tauri 2 这对技术 CP,这次我们把镜头怼到编辑器的内核——看看 InkWise 如何用 TipTap 3 和 ProseMirror 的底层魔法,让富文本和 Markdown 两种排版流派在同一个编辑框里和平共处,甚至还能实时切换、光标不迷路。
古有「Vim 与 Emacs 哪个是真神」的圣战,今有「Markdown 与富文本谁更香」的日常互怼。Markdown 硬核党表示:纯文本才是真·程序员浪漫,所见即所得都是邪教。富文本视觉党反手甩出一张带格式的截图:你写一上午的 ** 和 ##,我点两下鼠标就搞定了。
InkWise 的选择很叛逆:我全都要。
而且不是那种「我开两个标签页各用各的」的假全都要——同一个编辑器,同一份文档,点一下按钮就从 WYSIWYG 切到 Markdown 源码,再从源码切回来,光标位置几乎纹丝不动(程序员能看懂的「几乎」)。本文就带你拆开这个「双模人格分裂」编辑器的每一颗螺丝。

1. 基石:ProseMirror 与 TipTap 这对好基友
在开搞双模切换之前,得先搞清楚我们脚下踩的是谁的地盘。
1.1 1.1 ProseMirror 的文档模型:像个 DOM 但更有文化
ProseMirror 的核心不是「把 HTML 塞进 contenteditable 然后听天由命」,而是搞了一套结构化文档模型。每一个文档都是一棵由「节点」和「标记」组成的树,格式不靠 CSS 硬撑,而是靠 Schema 定义:只有 Schema 允许的结构才能出现在文档里。
这就好比在 Word 里你不可能把一张图片塞进一个字符中间还期望它表现得像字符——但在原生 contenteditable 里,这种事每天都在发生。ProseMirror 说:不行,得守规矩。
1.2 1.2 TipTap 扩展生态:把 ProseMirror 包成 React 好朋友
ProseMirror 的原生 API……怎么说呢,属于那种「写过的人都觉得优雅,但第一次接触的人只想砸键盘」的水平。TipTap 就是那个翻译官,把 ProseMirror 的底层操作包装成 useEditor() hook 和一串声明式的扩展。
打开 src/components/editor/EditorContent.tsx,可以看到 InkWise 的编辑器扩展配置:
const editor = useEditor({ immediatelyRender: false, extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3, 4, 5, 6] }, codeBlock: false, link: false, underline: false, }), Underline, MermaidNode, CodeBlockLowlight.configure({ lowlight: createLowlight(common), }), Placeholder.configure({ placeholder: mode === "markdown" ? "使用 Markdown 语法写作…" : "开始写作…", }), Markdown, // ← 这就是双模切换的幕后黑手 Link.configure({ openOnClick: false, HTMLAttributes: { class: "tiptap-link" }, }), TaskList, TaskItem.configure({ nested: true }), Highlight.configure({ multicolor: true }), TextAlign.configure({ types: ["heading", "paragraph"], }), Image.configure({ inline: false, allowBase64: true, }), ], // ...});
注意那个 Markdown——它来自 @tiptap/markdown,是整个双模切换的灵魂插件。这个扩展做了两件大事:
-
1. 解析(Parse):把 Markdown 字符串变成 ProseMirror 的 JSON 文档结构
-
2. 序列化(Serialize):把 ProseMirror 文档转回 Markdown 字符串
有了它,ProseMirror 就不再只是个富文本编辑器了——它成了一个双向转化的管道。你输入 Markdown,它给你结构化文档;你编辑文档,它吐出干净的 Markdown。
1.3 1.3 扩展也可以自定义:Mermaid 节点的 Markdown 集成
说到自定义节点,咱就得聊聊 MermaidNode(src/extensions/editor/MermaidNode.ts)。这不是 TipTap 官方扩展,是 InkWise 自己写的——它实现了两端的 Markdown 对接:
// 解析:拦截 ```mermaid 代码块parseMarkdown(token: any, helpers: any) { if (token.type !== "code" || token.lang !== "mermaid") { return false; } return helpers.createNode( "mermaid", { language: "mermaid" }, token.text ? [helpers.createTextNode(token.text)] : [], );},// 序列化:输出为 ```mermaid 代码块renderMarkdown(node: any, helpers: any) { const lines = ["```mermaid"]; if (node.textContent) { lines.push(node.textContent); } lines.push("```"); return lines.join("\n");},
这保证了:不管在富文本模式还是 Markdown 模式,Mermaid 图表都不会神秘失踪。
2. 双模切换实战:让编辑风格在指尖花式切换
好,地基打好了,现在来看核心问题:怎么让用户在富文本和 Markdown 之间反复横跳,还不丢失光标位置和内容?
2.1 2.1 单实例 vs 双实例:为什么选「单实例拥抱状态」
有一种做法是在两个编辑器实例之间来回切换:一个 TipTap 实例伺候富文本,一个 CodeMirror 实例伺候 Markdown,切换时相互转换内容。听起来合理,但问题是——状态割裂。撤销历史、选区记忆、扩展状态……这些东西在两个实例之间同步起来,比让前男友和前女友在婚礼上坐一桌还尴尬。
InkWise 走的是单实例 + 渲染切换路线:TipTap 实例始终活着,但渲染的内容在「ProseMirror 视图」和「Textarea」之间切换。当用户切到 Markdown 模式时,ProseMirror 视图隐藏,Textarea 出现并显示当前 Markdown 内容;切回富文本时,Textarea 的 Markdown 文本被重新灌入 ProseMirror。核心编辑器引擎只有一个,不存在状态分裂。
2.2 2.2 切换时的渲染逻辑拆解
关键代码就在 EditorContent.tsx 的这段 useEffect 里:
useEffect(() => { if (!editor || mode === editorModeRef.current) return; editorModeRef.current = mode; if (mode === "rich") { // 从 Markdown → 富文本:推入内容 const mdContent = rawMd || content || ""; if (isHTML(mdContent)) { editor.commands.setContent(mdContent); } else { editor.commands.setContent(mdContent, { contentType: "markdown" as any }); } } else { // 从富文本 → Markdown:同步到 textarea const currentMd = editor.getMarkdown(); setRawMd(currentMd); prevMdRef.current = currentMd; onChange?.(currentMd, "markdown"); }}, [editor, mode, rawMd, content, onChange]);
注意那行 editor.getMarkdown()——这就是 @tiptap/markdown 扩展注入的方法,它把当前 ProseMirror 文档的所有节点遍历一遍,然后调用每个节点的 renderMarkdown 方法,拼接成一串干净整洁的 Markdown。反过来,setContent 配合 contentType: "markdown" 则让 TipTap 用 Markdown 扩展的解析器反方向走一遭。
用一句话总结:切到 Markdown 时拉取序列化结果,切回富文本时重新解析,ProseMirror 永远是真·真相。
2.3 2.3 渲染层的双视图
实际的 UI 渲染在 JSX 里:
return (<main className="editor-main" id="editorMain"> {mode === "rich" ? ( /* 富文本模式:TipTap 的 WYSIWYG 视图 */ <div className="editor-container"> <TipTapEditorContent editor={editor} /> <InlineGhostText /> </div> ) : ( /* Markdown 模式:纯文本 textarea */ <div className="editor-container editor-container--markdown"> <textarea className="editor-markdown-source" value={rawMd} onChange={handleRawMdChange} spellCheck={true} /> </div> )} {/* AI 响应提示等等 */} </main>);
TipTapEditorContent 渲染 ProseMirror 编辑视图,textarea 渲染 Markdown 源码。两者互斥,但数据同源。
2.4 2.4 工具栏上的「一键换装」
切换动作的触发点在 Toolbar.tsx 里那个不起眼的按钮:
<button className={`mode-switch-btn${editorMode === "markdown" ? " mode-switch-btn--active" : ""}`} title="切换 Markdown / 富文本" onClick={() => onModeSwitch(editorMode === "markdown" ? "rich" : "markdown")}> {editorMode === "markdown" ?<span style={{ fontFamily: "var(--mono)", fontSize: 11 }}><></span> :<Type size={13} />}</button>
而 onModeSwitch 在 EditorPane.tsx 里被绑定到 editorStore 的 setFormat:
// editorStore.ts (Zustand)export const useEditorStore = create<EditorState & EditorActions>((set) => ({ format: "rich", setFormat: (format) => set({ format }), // ...}));
每次点击,模式切换的涟漪从按钮 → Toolbar → EditorPane → EditorContent,最后触发前面那个 useEffect。
3. 进阶体验优化:让切换顺滑如德芙
基础的切换逻辑已经能跑了,但要让用户体验达到「哇哦」级别,还需要处理几个魔鬼细节。
3.1 3.1 防闪烁与样式化保留
快速切模式时最怕什么?闪白,内容乱跳,或者 placeholder 不对。 InkWise 的处理很朴素但有效:
-
• 模式切换时不销毁 Editor 实例,只切换显示层——ProseMirror 的 DOM 还活着,所以没有重建开销。
-
• Placeholder 文字随模式联动: Placeholder.configure({ placeholder: mode === "markdown" ? "使用 Markdown 语法写作…" : "开始写作…" })
-
• 样式通过 useEffect+<style>标签动态注入,切换模式时不重新加载 CSS,只做增量更新。
3.2 3.2 光标位置映射:一个没人提但很要命的问题
假设你在富文本模式下写了一段文字,光标停在某个 粗体 词中间,然后你切到 Markdown 模式——理想情况下,光标应该落在 粗体 那六个星号后面的某个位置。但实际上,富文本中的from/to位置和 Markdown 文本中的字符索引完全是两套坐标系。
InkWise 目前的做法是:切换时不强制还原光标位置。切到 Markdown 时,textarea 会获得焦点,但光标默认在文本末尾。这是一个「有意的取舍」:
-
• 如果花大力气做位置映射(需要遍历 ProseMirror 节点树、计算偏移量、匹配 Markdown 语法标记),复杂度将成倍增加,且边缘情况(表格、代码块)极难处理。 -
• 大多数用户的使用场景是:在富文本里写完一段 → 切到 Markdown 微调格式 → 切回富文本看效果。这个场景下,光标到末尾是可接受的。
但编辑器通过 onSelectionUpdate 把当前的 { from, to } 存在了 window.__lastEditorSelection 上——为将来实现更精确的光标映射埋了个伏笔。
3.3 3.3 事务钩子:两种模式的统一出口
无论用户在哪种模式编辑,最终的变更都要回馈到父组件。InkWise 用 handleTransaction 作为统一出口:
const handleTransaction = useCallback(() => { if (!onChange) return; const editor = window.editorInstance?.editor; if (!editor) return; const md = editor.getMarkdown(); if (md !== prevMdRef.current) { prevMdRef.current = md; onChange(md, editorModeRef.current); }}, [onChange]);
这个回调注册在 TipTap 的 onTransaction 和 onUpdate 上,每次 ProseMirror 文档有变化就触发,拿到序列化后的 Markdown 传给父组件。父组件拿到 Markdown 后,会触发自动保存、大纲重新解析等连锁反应。
所以在 InkWise 的整个数据流里,Markdown 是「通用语」——无论用户用什么模式编辑,最终对外输出的都是 Markdown。富文本只是 Markdown 的一个「可视化皮肤」。
4. 总结与延展:双模编辑器的星辰大海
双模编辑器的本质不是什么黑科技,而是在一个坚实的文档模型上,架设两个不同粒度的编辑界面,让用户根据场景自由选择。ProseMirror 提供结构化文档,TipTap 提供 React 友好的操作 API,@tiptap/markdown 扩展提供 Markdown ↔ 文档模型的双向翻译——这三者缺一不可。
在 InkWise 的生态中,双模编辑器只是一个起点:
-
• AI 技能系统读取的是 Markdown(用 editor.getMarkdown()获取),AI 返回的内容也以 Markdown 格式插入回编辑器——无论用户在什么模式。 -
• Blueprint 写作蓝图生成的大纲和章节内容全是 Markdown,编辑器只需要把它当普通内容解析即可。 -
• 多平台发布(公众号、头条等)导出时也以 Markdown 为中间格式,再编译为各平台兼容的 HTML。
如果要扩展更多标记语言——比如 AsciiDoc、Org-mode——思路是一样的:写一个新的解析器/序列化器,注册到 TipTap 扩展里,然后在切换时调用不同的格式化管道。但那是 2.0 之后的故事了。
系列导航
上一篇:为什么选择 React 19 + Tauri 2:InkWise 的技术选型剖析
下一篇:AI 辅助写作的架构设计:多提供商支持与流式生成文档已更新完毕。
夜雨聆风