基于 tiptap 实现微信文章编辑器
Tiptap 编辑器是一款无头、与框架无关的富文本编辑器,可通过 extension
进行定制和扩展。
它的特点包括:
-
headless:Tiptap 是无头的,意味着它没有固定的用户界面,无任何预设样式。允许开发者打造任何样式的编辑器。
-
基于 ProseMirror:Tiptap 是基于 ProseMirror 构建的,ProseMirror 是一个强大的编辑器框架,这使得 tiptap 简单易用的同时具备强大潜力。
-
可扩展:Tiptap 可以通过 extension 进行定制和扩展,你可以在编辑器中定义任何内容特性和编辑器行为。官方提供了一些常用的 extension,如链接、列表、表格等。还包括 AI 增强集成、基于 Y.js 的多人协作等功能
extension
;
官方网站:www.tiptap.dev
api 文档:tiptap.dev/docs/editor...
github: github.com/ueberdosis/...
前面的话
本文以使用 tiptap 实现微信文章编辑器为例,逐步介绍 tiptap 使用与核心概念。
项目地址:github.com/KID-1912/ti...
在线示例:kid-1912.github.io/tiptap-appm...
为什么选择 tiptap
?
由于需要完全自定义编辑界面,诸如 wangEditor 等需要覆盖默认样式的富文本编辑器很难实现; 最开始尝试使用 quill,一个轻便的富文本编辑器; quill 基于 Parchment 的文档模型,不允许直接插入/修改 dom,只能通过声明新的 Parchment Blots 实现输入解析规则与输出渲染规则(基于 ProseMirror 的 tiptap 与之相似) 但 quill 的 Blots 声明项繁杂且相关文档说明很少,很难为编辑器添加新的内容特性。而 tiptap 通过 extension 可以很好的实现这一点。
开始搭建
构造 editor 实例
js
import { Editor } from "https://esm.sh/@tiptap/core"; // @tiptap/core
import StarterKit from "https://esm.sh/@tiptap/starter-kit"; // @tiptap/starter-kit
const editor = new Editor({
element: document.querySelector("#editor"),
extensions: [StarterKit],
});
Editor
是 tiptap 的核心类,用于创建编辑器实例。element
选项指定编辑器的容器元素,extensions
选项指定编辑器的扩展。
StarterKit
是 tiptap 提供的入门套件 extension,它包含了所有常用的编辑器功能
由于 tiptap editor 默认在页面无任何样式,添加一些自定义样式后,预览效果看这里
核心概念
Command
通过 extension
提供的指令,可以实现快速对内容执行预定义操作
以用于加粗的 @tiptap/extension-bold
extension 为例,由于已包含在入门套件,直接使用命令即可
js
const handleBold = () => {
editor.commands.toggleBold(); // setBold unsetBold
};
页面新增加粗按钮,绑定加粗处理到点击事件,预览效果看这里
你可以在官方文档的 Nodes、Marks、Extensions 3 个章节查看其它 extension 各自提供的 command
,为你的编辑器添加新功能
同时可以参阅 微信文章编辑器 toolbar.js 查看编辑器工具栏功能的实现
editor 命令
除了 extension 封装过的命令,编辑器(editor)提供了大量基础命令,允许添加、更改内容或改变选区。见文档 (command
)[tiptap.dev/docs/editor...] 章节
其中 insertContent
,updateAttributes
是常用基础命令
如官方提供用于插入图片内容的 @tiptap/extension-image
拓展,提供 setImage
命令插入图片:
js
editor.commands.setImage({ src: "https://example.com/logo.png" }); // 插入图片
setImage
命令源代码实现:
js
// addCommand选项: extension向外暴露的命令
addCommands() {
return {
setImage: options => ({ commands }) => {
return commands.insertContent({ // 内部依旧调用 insertContent 基础命令
type: this.name,
attrs: options,
})
},
}
},
因此,下面两行代码互相等价
js
editor.commands.setImage({ src: "https://example.com/logo.png" });
// 等于
editor.commands.insertContent({
type: "image", // @tiptap/extension-image `name` 选项值
attrs: { src: "https://example.com/logo.png" },
});
insertContent
支持一次插入嵌套的内容、支持插入 HTML 形式内容,详见 insert-content
链式调用
editor.chain()
命令提供命令链调用
js
editor
.chain() // 开启链式命令
.focus() // 聚焦编辑区,保留选区选中样式
.toggleBold() // 若干命令链接
...
.run() // 运行
extension
extension
是 tiptap 的核心概念,它是一种可扩展的编辑器功能,可添加新的节点、标记、插件、指令等
在我们编写 extension 时,建议反复查阅文档 Custom Extensions 章节
extension 分类
根据 extension 的作用,大致分为这 3 种类型拓展
Node
创建一个新节点类型,即文档支持一个新的内容类型
js
import { Node } from "@tiptap/core";
const Video = Node.create({
type: "video",
renderHTML(){ ... },
parseHTML(){ ... }
})
Mark
可以对节点应用一个或多个标记,常见为文本添加内联样式(textStyle)
js
import { Mark } from "@tiptap/core";
const FontSize = Mark.create({
name: "fontSize",
...
})
Extension
以上 2 种类型都基于 Extension
基础类,通过定义基础的 extension 添加全局特性
新增内容浮动(float)特性
js
import { Extension } from "@tiptap/core";
const Float = Extension.create({
name: "float",
addGlobalAttributes() { ... }
...
})
extension 核心选项
name
扩展名称,代表内容类型/特性唯一名称
js
// Node类型extension
editor.commands.insertContent({
type: "image", // 'image' 即 @tiptap/extension-image中name选项值
attrs: { src: "https://example.com/logo.png" },
});
// Mark类型extension
editor.commands.setMark("bold"); // 'bold' 即 @tiptap/extension-bold中name选项值
// 基础extension
editor.commands.updateAttributes(
"paragraph",
{ textAlign: alignment } // textAlign 即 @tiptap/extension-text-align中name选项值
);
group
定义节点所属的内容组,值可以是 block/inline/有效type值
,供 content
选项引用
content
定义节点可以包含的内容类型。不符合的内容会被丢弃
js
// 必须一个或多个内容块(group选项值为block)
content: 'block+',
// 必须零个或多个区块
content: 'block*',
// 允许所有内联内容(group选项值为inline)
content: 'inline*',
// 仅文本内容
content: 'text*',
// 可以有一个或多个段落,或列表(如果使用列表)
content: '(paragraph|list?)+',
// 顶部必须有一个标题,下面必须有一个或多个区块
content: 'heading block+'
inline
节点是否内联显示。为 true 时,节点会与文本一起并列行呈现。
addOptions
声明 extension 使用时配置项,供拓展使用者控制 extension 行为
如 @tiptap/extension-image
的 addOptions
选项:
js
addOptions() {
return {
inline: false,
allowBase64: false,
HTMLAttributes: {},
}
},
// 其它选项内通过 `this.options` 访问参数值,进行不同处理
group() {
return this.options.inline ? 'inline' : 'block'
},
parseHTML() {
return [
{
tag: this.options.allowBase64
? 'img[src]'
: 'img[src]:not([src^="data:"])',
},
]
},
@tiptap/extension-image
配置项说明 使用时可以自行配置拓展 options 默认值,
js
import Image from "@tiptap/extension-image";
const editor = new Editor({
element: document.querySelector(".editor"),
extensions: [Image.configure({ inline: true, allowBase64: true })],
});
addAttributes
设置节点/标记状态,注意到它返回一个函数,即为每个节点/标记实例添加独立状态
js
// `@tiptap/extension-image`
addAttributes() {
return {
src: { // image 节点新增 src 属性
default: null,
},
alt: { // image 节点新增 alt 属性
default: null,
},
title: { // image 节点新增 title 属性
default: null,
},
}
},
默认未添加额外声明时,tiptap 节点属性(attributes)会作为 DOM HTMLAttributes,渲染到 DOM 节点上。
同时,你也可以通过 renderHTML
如何消费你声明的属性,自定义渲染输出;也可以通过 parseHTML
定义外部输入时(向 editor 插入 HTML 或粘贴)如何解析出属性值。
js
// @tiptap/extension-highlight 文字高亮
addAttributes() {
return {
color: {
default: null,
// 当外部内容时检查 data-color 或 样式背景颜色 解析为节点color属性
parseHTML: element => element.getAttribute('data-color') || element.style.backgroundColor,
// 消费节点color属性
renderHTML: attributes => {
if (!attributes.color) {
return {}
}
return {
'data-color': attributes.color, // 作为DOM节点 data-color HTMLAttributes
style: `background-color: ${attributes.color}; color: inherit`, // 作为DOM节点 背景色样式
}
},
},
}
},
如果只想新增一个单纯状态,避免默认作为 DOM HTMLAttributes,设置 rendered: false
即可
js
// @tiptap/extension-heading
addAttributes() {
return {
level: {
default: 1,
rendered: false, // level 不出现在DOM节点上
},
}
},
还记得前面提到的 editor 基础命令 updateAttributes
吗?它就是用来更新节点属性的
js
// 更换图片的src地址
editor.commands.updateAttributes("image", {
src: "https://example.com/logo.png",
});
// 切换标题级别
editor.commands.updateAttributes("heading", { level: 2 });
...
addGlobalAttributes
在 Mark/Node 类型 extension 中 addAttributes
是常见的,它为新增的节点类型声明自己的属性;
但大部分情况我们要基于已有的 Mark/Node 增加新特性。如为段落(paragraph)添加行高支持,为图片(image)和视频(video)支持浮动;
addGlobalAttributes
选项为全局 extension 添加属性,供指定节点/标记使用
js
// tiptap-extension-line-height
import { Extension } from "@tiptap/core";
export default Extension.create({
name: "lineHeight",
addGlobalAttributes() {
return [
{
types: ["paragraph"], // 仅为段落添加行高
attributes: {
lineHeight: {
default: null,
parseHTML: (element) => element.style.lineHeight,
renderHTML: (attributes) => {
if (!attributes.lineHeight) {
return {};
}
return { style: `line-height: ${attributes.lineHeight}` };
},
},
},
},
];
},
addCommands() {
return {
setLineHeight:
(lineHeight) =>
({ commands }) => {
return commands.updateAttributes("paragraph", {
lineHeight,
});
},
};
},
});
renderHTML
通过 renderHTML 函数,您可以控制如何将扩展渲染为 HTML,同时也影响 editor.getHTML()
返回值
这与 addAttributes
内的 renderHTML 选项不同,后者用于如何消费 node 属性(attribute),前者用于渲染节点/标记的容器,且此时 DOM 的 HTMLAttribute 已被计算。
js
// node 渲染为 strong 标签,并携带默认计算的HTMLAttributes
renderHTML({ HTMLAttributes }) {
return ['strong', HTMLAttributes, 0] // HTMLAttributes 即tiptap计算后的DOM属性
},
renderHTML 返回一个数组,第一个值是 HTML 标签名; 如果第二个元素是一个对象,它将被解释为一组属性; 第三个参数 0 用于表示内容应插入的位置;
通过自定义 renderHTML 逻辑,我们可以额外的 HTMLAttributes
js
import { mergeAttributes } from '@tiptap/core'
// 渲染为 a 标签,且额外添加 rel 属性,值来自addOptions配置
renderHTML({ HTMLAttributes }) {
return ['a', mergeAttributes(HTMLAttributes, { rel: this.options.rel }), 0]
},
mergeAttributes
用于合并 2 个表示 HTMLAttributes 的对象,返回一个新的对象
如下,甚至可以将 addOptions
传入自定义的 HTMLAttributes
补充到 renderHTML 中,常见的做法
js
renderHTML({ HTMLAttributes }) {
return ['strong', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}
// 使用者
const editor = new Editor({
element: document.querySelector('.editor'),
extensions: [Bold.configure({ HTMLAttributes: { 'data-format': 'bold' } })],
})
extension 的 DOM 输出不限于简单单个容器,可以是嵌套的内容
js
// @tiptap/extension-code-block
renderHTML({ node, HTMLAttributes }) {
return [
'pre',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
[
'code',
{
class: node.attrs.language
? this.options.languageClassPrefix + node.attrs.language
: null,
},
0,
],
]
}
parseHTML
parseHTML
选项用于定义外部 HTML 字符串解析为 Node 的方法,HTML 字符串的未匹配并解析内容将无法插入编辑器
js
// @tiptap/extension-code-block
parseHTML() {
return [
{
tag: 'pre', // 将pre标签作为code-block
preserveWhitespace: 'full',
},
]
},
// @tiptap/extension-bold
parseHTML() {
// 将满足以下任一条件作为bold
return [
{
tag: 'strong',
},
{
tag: 'b',
getAttrs: node => (node as HTMLElement).style.fontWeight !== 'normal' && null,
},
{
style: 'font-weight',
getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null,
},
]
},
Events
@tiptap/core
提供了一些事件,供 extension 监听并处理
常用事件如下
transaction
transaction
事件在每次编辑器状态(state)变更时触发,可以监听编辑器的状态变化,如内容变更、选区变更等
js
editor.on("transaction", ({ editor, transaction }) => {
// 通过transaction获取编辑器状态变更信息
console.log(transaction);
});
update
update
事件在编辑器内容变更时触发,可以监听编辑器内容变更
js
editor.on("update", () => {
// 字数实时统计
const wordCount = editor.getText().length;
});
extension 继承
如前面在 addGlobalAttributes
说到,"大部分情况我们要基于已有的 Mark/Node 增加新特性";
如果只是简单为某些 type extension 新增特性是适合的,但又是我们需要针对某一个extension进行添加特性升值修改部分逻辑(如renderHTML),tiptap提供 Node.extend
以 extension 继承实现
如下为 @tiptap/extension-bullet-list
新增 listStyleType
特性,打造一个支持修改无序列表 list-style
的新 bullet list extension
js
// tiptap-extension-bullet-list
import BulletList from "@tiptap/extension-bullet-list";
export default BulletList.extend({
addAttributes() {
return {
...this.parent?.(), // 沿用BulletList attributes,类似ES6 class中super作用
listStyleType: {
default: "disc",
parseHTML: (element) => {
const listStyleType = element.style["list-style-type"];
return { listStyleType: listStyleType || "disc" };
},
renderHTML: (attributes) => {
return { style: `list-style-type: ${attributes.listStyleType}` };
},
},
};
},
});
this.parent()
可以在 addOptions
,addStorage
,addAttributes
等获取到父extension对应配置
如我们有当html 内容中img元素被插入时,将携带的style作为baseStyle attribute存放,然后 mergeStyles处理后最终渲染,
避免大部分携带的style由于未声明解析规则而被 blocked
js
import Image from "@tiptap/extension-image";
import { mergeAttributes } from "@tiptap/core";
export default Image.extend({
name: "image",
addAttributes() {
return {
...this.parent?.(),
baseStyle: {
default: "",
rendered: false,
parseHTML: (element) => element.getAttribute("style"),
},
};
},
renderHTML({ node, HTMLAttributes }) {
const baseStyle = node.attrs.baseStyle;
const style = HTMLAttributes.style || "";
if (style || baseStyle) {
HTMLAttributes.style = mergeStyles(baseStyle, HTMLAttributes.style);
}
return [
"img",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
];
},
});
addNodeView
通过添加节点视图,为编辑器添加了交互的或内嵌内容类型
addNodeView
作为一个extension配置,它 renderHTML
有共同点,都能控制节点最终在编辑区渲染结果;
renderHTML 最核心作用是 editor.getHTML
如何将节点转换为html文本用于存储,编辑器默认将renderHTML作为编辑区渲染依据
但节点视图支持开发者自定义一个type node在编辑区上dom内容,如 @tiptap/extension-task-item
实现代码
js
addNodeView() {
return ({
node, HTMLAttributes, getPos, editor,
}) => {
const listItem = document.createElement('li')
const checkboxWrapper = document.createElement('label')
const checkboxStyler = document.createElement('span')
const checkbox = document.createElement('input')
const content = document.createElement('div')
checkboxWrapper.contentEditable = 'false'
checkbox.type = 'checkbox'
checkbox.addEventListener('change', event => { ... }) // 绑定交互事件
Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
listItem.setAttribute(key, value)
})
listItem.dataset.checked = node.attrs.checked
if (node.attrs.checked) {
checkbox.setAttribute('checked', 'checked')
}
checkboxWrapper.append(checkbox, checkboxStyler)
listItem.append(checkboxWrapper, content)
Object.entries(HTMLAttributes).forEach(([key, value]) => {
listItem.setAttribute(key, value)
})
return {
dom: listItem,
contentDOM: content,
update: updatedNode => {
if (updatedNode.type !== this.type) {
return false
}
listItem.dataset.checked = updatedNode.attrs.checked
if (updatedNode.attrs.checked) {
checkbox.setAttribute('checked', 'checked')
} else {
checkbox.removeAttribute('checked')
}
return true
},
}
}
},
诸如此类,如实现类似微信文章编辑器中的地图卡片、微信公众号卡片等内嵌内容效果,就可以通过这个特性实现(微信采用Web Components)