最近在优化个人项目,打算使用 tiptap 替换掉原来自己实现的 markdown 编辑组件。遇到的问题有两个:
- 由于在我的项目里保存的是原始的 markdown 文本,而 tiptap 仅支持导出 JSON 或者 html,因此需要增加一步将 html 反转回 markdown 的步骤;
- 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>
);
}
其中 NodeViewWrapper
和 NodeViewContent
是 tiptap 提供的辅助组件,NodeViewContent
用于渲染该节点内部的其他子节点。在自定义的 View 中,可以通过 props 获取到 node
、editor
等实例,在上面的代码中我们通过使用 Node 中的属性对不同类型 Admonition 的样式和 Label 进行了区分。