最近在优化个人项目,打算使用 tiptap 替换掉原来自己实现的 markdown 编辑组件。遇到的问题有两个:

  1. 由于在我的项目里保存的是原始的 markdown 文本,而 tiptap 仅支持导出 JSON 或者 html,因此需要增加一步将 html 反转回 markdown 的步骤;
  2. tiptap 不支持 Admonition,不能渲染 Admonition html,显示为空,并且获取到的 html 也为空,导致内容丢失。

在我的项目里使用 unified 将 markdown 转换为 html,并且通过 react-admonition 插件增加了对 Admonition 的支持。为了解决上面这两个问题,需要:

  • 更新 unified 配置从而实现 markdown 和 html 中 Admonition 的双向转换
  • 实现一个 tiptap Node 拓展用于渲染 unified 转换得到的 Admonition html

配置 unified 以便实现双向转换

使用 unified 将包含 Admonition 标记的 markdown 转换为 html 可以直接 react-admonition 插件实现,例如:

// yarn add unified remark-parse remark-directive remark-admonition remark-rehype rehype-stringify rehype-remark import { remarkAdmonition } from 'remark-admonition' import { unified } from 'unified' import remarkParse from 'remark-parse' import remarkDirective from 'remark-directive' import remarkRehype from 'remark-rehype' import rehypeStringify from 'rehype-stringify' const md2html = unified() .use(remarkParse) // 解析 markdown .use(remarkDirective) // 解析 directive .use(remarkAdmonition, {}) // 将 directive 解析为 admonition .use(remarkRehype) // 将 markdown ast 转换为 html ast .use(rehypeStringify) // 输出 html string const md = `:::note Be careful folks! :::` const html = md2html.processSync(md); console.log(String(html)); /** * <div data-admonition-name="note" data-admonition-label="Note"> * <p>Be careful folks!</p> * </div> */

更多配置请查阅文档,关于 Markdown 中的 Directive 说明可以参考这里

如果要将插件生成的 html 转换为 markdown 则需要对 rehype-remark 进行一些配置,参考下面的代码:

import { remarkAdmonition } from 'remark-admonition' import { unified } from 'unified' import remarkParse from 'remark-parse' import remarkDirective from 'remark-directive' import remarkRehype from 'remark-rehype' import rehypeStringify from 'rehype-stringify' import { toMdast, type Handle } from "hast-util-to-mdast"; // 自定义方法处理 Admonition 节点 const AdmonitionHandle: Handle = (state, node) => { const name = node.properties.dataAdmonitionName as string; if (!name) return; const result = { type: "containerDirective", name, // @ts-ignore children: node.children.map(toMdast), }; // @ts-ignore state.patch(node, result); return result as RootContent; }; const html2md = unified() .use(rehypeParse) // 解析 html .use(rehypeRemark, { handlers: { div: AdmonitionHandle }, }) // 将 html ast 转换为 markdown ast,后面的插件和 `md2html` 中的无二 .use(remarkDirective) .use(remarkAdmonition, {}) .use(remarkRehype) .use(remarkStringify) const html = `<div data-admonition-name="note" data-admonition-label="Note"> <p>Be careful folks!</p> </div>` const md = html2md.processSync(html); console.log(String(md)); /** * :::note * Be careful folks! * ::: */

重点看 AdmonitionHandle 方法,当通过 rehypeParse 解析得到 html 抽象语法树以后,需要对其中的节点进行转换,以便后面的 remark 插件能够正确的识别 markdown 节点。在 remark-admonition 插件中,主要是处理 remark-directive 转换后的 markdown 节点(源码),由于无法直接将 html 节点转换为 markdown 中的 directive 节点,因此需要通过增加 AdmonitionHandle 进行转换,转换的依据是 html 节点属性中是否包含 data-admonition-name 属性。

为 tiptap 实现 Admonition 拓展

当完成 md 和 html 的相互转换后,还需要在 tiptap 中实现拓展才能正常渲染 Admontion 的 html。在 tiptap 中有三种拓展(Extension)

  • Node:可渲染的 html,例如 Paragraph、Heading;
  • Mark:调整 Node 样式,例如 Bold、Italic;
  • Extension:增加拓展性的功能。

我们的目标是希望渲染 unified 转换出来的 Admonition html,因此要实现的是 Node 拓展。Node 拓展由两部分组成:Node 定义和 Node 视图。Node 定义根据用于声明一个节点的模式和能力;Node 视图则是确定节点的具体展示。下面先看具体的 Node 定义:

import { Node } from "@tiptap/core"; import { ReactNodeViewRenderer, mergeAttributes, wrappingInputRule, } from "@tiptap/react"; import { AdmonitionView } from "./View"; // ts 声明 declare module "@tiptap/core" { interface Commands<ReturnType> { CardNode: { createAdmonitionNode: () => ReturnType; }; } } export const inputRegex = /^\s*:::(note|tip|warn|error)\s$/; const NodeName = "admonitionNode"; export const AdmonitionNode = Node.create({ name: NodeName, group: "block", content: "block*", gapCursor: false, addAttributes() { return { name: { default: "note" }, label: { default: "Note" }, }; }, parseHTML() { return [ { tag: "div", getAttrs: (elem) => { if (!elem.hasAttribute("data-admonition-name")) return false; if (!elem.hasAttribute("data-admonition-label")) return false; return { name: elem.getAttribute("data-admonition-name"), label: elem.getAttribute("data-admonition-label"), }; }, }, ]; }, renderHTML({ HTMLAttributes, node, ...other }) { return [ "div", mergeAttributes(HTMLAttributes, { "data-admonition-name": node.attrs.name, "data-admonition-label": node.attrs.label, }), 0, ]; }, addCommands() { return { createAdmonitionNode: () => { return ({ commands }) => { return commands.insertContent({ type: this.name, attrs: { name: "note", label: "Note" }, }); }; }, }; }, addNodeView() { return ReactNodeViewRenderer(AdmonitionView); }, addInputRules() { return [ wrappingInputRule({ find: inputRegex, type: this.type, keepAttributes: true, getAttributes: (match) => ({ name: match[1], label: `${match[1][0].toUpperCase()}${match[1].slice(1)}`, }), }), ]; }, });

重点看 Node.create 方法的参数:

  • name:表示每个节点类型的唯一标识符,用于在同一模式 schema 内区分不同的节点类型;
  • group:定义了节点类型所属的类别或集合,可以在模式的内容表达式中引用。可选值有:block(块)、list(列表)和 inline(行内);
  • content:在 tiptap 的语境中指的是“内容表达式”,用于确定该节点类型中允许哪些子节点序列。例如 paragraph+ 表示该节点内部可以包含一个或多个 paragraph 类型的子节点 heading list 表示该节点内部只能有两个子节点:先是一个 heading 节点,然后跟随一个 list 节点。表达式的语法有点类似 glob ,理解起来并不复杂;
  • parseHTML:该方法定义了 tiptap 如何将 html 转换为节点定义,注意要和 md2html 中生成的 html 匹配;
  • renderHTML:该方法定义了 tiptap 如何将节点定义转换为 html,返回值是一个 DOMOutputSpec
  • addCommands:该方法为 Editor 实例定义了新的方法;
  • addAttributes:该方法定义了节点支持的属性,注意,只有那些在该方法中返回的属性才能被正常读取,否则会被忽略;
  • addNodeView:tiptap 提供的辅助方法,用于将 Node 定义和 Node 视图绑定;
  • addInputRules :通过定义该方法,可以让 tiptap 监听用户输入,当输入满足特定模式的时候自动插入一个节点。例如根据上面的定义,当用户输入 :::note 然后按下回车的时,就会自动插入一个 Admonition 节点,并且使用 "note" 生成属性。

完成 Node 定义后可以据此实现 Node 视图,具体代码如下:

import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; import classNames from "classnames"; export function AdmonitionView({ ...props }) { const name = props.node.attrs.name as string; const border_color = { tip: "border-blue-400", note: "border-yellow-400", warn: "border-orange-400", error: "border-red-400", }[name] || "border-yellow-400"; const bg_color = { tip: "bg-blue-400", note: "bg-yellow-400", warn: "bg-orange-400", error: "bg-red-400", }[name] || "border-blue-400"; return ( <NodeViewWrapper> <div className={classNames( "px-3 w-fit text-white rounded-t-md cursor-default", bg_color, )} contentEditable={false} > {props.node.attrs.label} </div> <div className={classNames("py-2 px-4 border-2", border_color)}> <NodeViewContent></NodeViewContent> </div> </NodeViewWrapper> ); }

其中 NodeViewWrapperNodeViewContent 是 tiptap 提供的辅助组件,NodeViewContent 用于渲染该节点内部的其他子节点。在自定义的 View 中,可以通过 props 获取到 nodeeditor 等实例,在上面的代码中我们通过使用 Node 中的属性对不同类型 Admonition 的样式和 Label 进行了区分。