乐于分享
好东西不私藏

TipTap 双模编辑器:富文本与 Markdown 实时切换的实现

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. 1. 解析(Parse):把 Markdown 字符串变成 ProseMirror 的 JSON 文档结构
  1. 2. 序列化(Serialize):把 ProseMirror 文档转回 Markdown 字符串

有了它,ProseMirror 就不再只是个富文本编辑器了——它成了一个双向转化的管道。你输入 Markdown,它给你结构化文档;你编辑文档,它吐出干净的 Markdown。

1.3 1.3 扩展也可以自定义:Mermaid 节点的 Markdown 集成

说到自定义节点,咱就得聊聊 MermaidNodesrc/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 }}>&lt;&gt;</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 辅助写作的架构设计:多提供商支持与流式生成文档已更新完毕。