简介
虽然大家都知道富文本是基于
html
和
css
来渲染的,但是如何可视化的修改这些
html
以及
css
却是富文本编辑器需要解决的问题。
浏览器提供了
contenteditable
使得元素可以编辑,以及
document.execCommand
让
js
具备能力去改变元素。但直接用这两个能力去做富文本编辑器是很坑的。
所以一般富文本编辑器都采用如下的架构:
Prosemirror
prosemirror
核心有四个模块。
prosemirror-model
:定义编辑器的文档模型,用来描述编辑器内容的数据结构。prosemirror-state
:描述编辑器整体状态,包括文档数据、选择等。prosemirror-view
:UI组件,用于将编辑器状态展现为可编辑的元素,处理用户交互。prosemirror-transform
:修改文档的事务方法。
可以发现,
prosemirror
的核心模块和上述架构是完全对应得上的。因此本文就从
state
、
view
,
transform
三个方面来探索
prosemirror
的实现原理。
文档结构
HTML的文档结构是树状的,而
prosemirror
采用的是基于
inline + mark
的数据结构。每个文档就是一个
node
,
node
包含一个
fragment
,
fragment
包含一个或者多个子
node
。其中核心是
node
的数据结构。
对比如下(来自官网)。
在
prosemirror
中,
p
是一个节点,其有三个子节点
this is
,
string text with
以及
emphasis
。而类似
strong
,
em
这些非内容本身,仅仅是用来装饰内容的东西,就作为文本的
mark
存储在文本节点里面了。这样就从树状结构变成了
inline
的结构。
这里面有一个核心的好处是,如果是树状结构,我们对于一个既
strong
又
em
的文字,有如下两种描述方式。
<strong>
<em>
hello world
</em>
</strong>
和
<em>
<strong>
hello world
</strong>
</em>
显然,这样的话,文档数据就会不稳定,同样的展示将会对应不用的数据,问题很大。如果采用
prosemirror
的存储结构,类似上图的
emphasis
,只要保证各
mark
的排序是稳定的,其数据结构就是唯一的。
除了上述这个优点以外,针对富文本编辑这个场景,这种数据结构还有其他的优势。
更符合用户对文本操作的直观感受,可以通过偏移量来描述位置,更加轻易的做分割。
通过偏移量来操作性能上会比操作树要好很多。
state层
prosemirror
的
state
并不是固定的,而是可以扩展的,但其有基本的四个属性:
doc
、
selection
、
storedMarks
、
scrollToSelection
。不过其中最核心的应该是
doc
,也就是文档结构,里面存放的是文档数据。
view层
view
调用
updateState
(也就是根据
state
来更新视图)时,会调用节点的
toDOM
方法来创建
DOM
元素,从而渲染到浏览器上。
相应的还有
parseDOM
方法,可以根据
DOM
元素,序列化成文档数据。
每次初始化,或者有
state
有更新的时候,都会触发
updateState
方法,从而完成界面的更新。
transform层
在更新流程中,当
view
发生变化时,会构建
transaction
(其父类就是
transform
),来更新
state
。
Prosemirror 初始化流程
首先看一下
prosemirror
的初始化代码。
// 创建schemaconst demoSchema =newSchema({nodes:addListNodes(schema.spec.nodes,"paragraph block*","block"),marks: schema.spec.marks
})// 创建statelet state = EditorState.create({doc: DOMParser.fromSchema(demoSchema).parse(document.querySelector("#content")),plugins:exampleSetup({schema: demoSchema })})// 创建viewlet view =EditorView(document.querySelector('.full'),{ state })
初始化先是创建文档数据的规范标准,类似约定了数据模型。其次创建了
state
,
state
是需要满足
schema
规范的。最后根据
state
创建了
view
,
view
就是最终展现在用户面前的富文本编辑器UI。因为初始化的时候还没有用户操作的介入,所以并不涉及
command
也就是
transform
的引入。
编辑器初始化的详细流程图如下:
因为此类架构的富文本编辑器本质是
F(state) = view
,界面是由数据驱动的,而
contenteditable
的元素又是非受控的,所以保证状态和界面的一致性是非常重要的。
在上述创建状态的代码中,
DOMParser
解析了
id
为
content
的元素的内容,并将其传给了状态的工厂函数。
DOMParser
,顾名思义就是解析
DOM
元素的,其核心作用就是将元素内容同步到状态中,准确的说是
state
中的
doc
属性。
let state = EditorState.create({doc: DOMParser.fromSchema(demoSchema).parse(document.querySelector("#content")),plugins:exampleSetup({schema: demoSchema })})
Prosemirror 更新流程
当用户在编辑器里面输入一个字符的时候,会触发更新流程。详细的更新流程如下:
输入字符会触发
view
变化,继而更新
state
,保证
state
和
view
的一致性。如果我们输入的是自定义的元素,就会在触发
state
更新之后,再通过
updateState
方法更新
view
,展示自定义的元素。
Tiptap
Tiptap 是一个基于 ProseMirror 构建的富文本编辑器,因其灵活性和可扩展性而备受关注。以下是 Tiptap 编辑器相对于其他富文本编辑器的一些优势:
- 基于 ProseMirror:- ProseMirror 是一个强大的编辑框架,提供了可靠的文档模型和编辑功能。- Tiptap 继承了 ProseMirror 的强大功能,同时简化了其使用和配置。
- 可扩展性:- Tiptap 提供了丰富的插件系统,可以根据需要添加或移除功能。- 用户可以轻松地创建自定义扩展和插件,以满足特定需求。
- 易于定制:- Tiptap 的配置和定制非常灵活,可以根据需求调整编辑器的外观和功能。- 提供了丰富的 API 接口,方便开发者进行二次开发。
- 社区支持:- 其代码库维护良好,文档详尽,易于上手。- Tiptap 拥有活跃的社区和开发团队,提供了及时的支持和更新。- 有丰富的示例和教程,帮助用户快速上手和解决问题。
- 丰富的功能:- 支持多种文本格式和样式,如粗体、斜体、下划线、列表、表格、图片、链接等。- 提供了 Markdown 支持,可以在编辑器中直接使用 Markdown 语法。
- 支持 Vue 和 React:- Tiptap 提供了对 Vue 和 React 框架的良好支持,方便在这些框架中集成和使用。- 提供了 Vue 和 React 的封装组件,减少了集成的复杂性。
- 实时协作:- Tiptap 提供了对实时协作编辑的支持,可以方便地集成协作功能。- 通过 WebSocket 或其他实时通信技术,可以实现多人协作编辑。
构造 editor 实例
import{ useEditor, EditorContent }from'@tiptap/react';functionTiptapEditor({ content }:{ content?: string }){const editor =useEditor({extensions:[
StarterKit,// TaskList,// TaskItem,// UImage,// AIImage,// UHeading,// UAIGC,// UAIGCInline],
content,onUpdate(props){// editor.setEditable 会触发 onUpdate},});return(<div style={{position:'relative',height:'100%',overflow:'auto'}}><EditorContent editor={editor}/></div>);}
基于
@tiptap/react
的
useEditor
创建编辑器实例。
EditorContent组件
用于渲染编辑器,
extensions
选项指定编辑器的扩展。
StarterKit 是 tiptap 提供的入门套件 extension,它包含了所有常用的编辑器功能
核心概念
Command
以用于加粗的 @tiptap/extension-bold extension 为例,由于已包含在入门套件,直接使用命令即可
consthandleBold=()=>{
editor.commands.toggleBold();// setBold unsetBold};
editor 命令
其中
insertContent
,
updateAttributes
是常用基础命令
以插入图片为例:
editor.commands.setImage({src:"https://www.baidu.com/logo.png"});
setImage
命令源代码实现:
// addCommand为extension向外暴露的命令addCommands(){return{setImage:options=>({ commands })=>{return commands.insertContent({type:this.name,attrs: options,})},}},
可以看到
setImage
内部依旧调用 insertContent 基础命令。因此,下面两行代码互相等价
editor.commands.setImage({src:"https://www.baidu.com/logo.png"});// 等于
editor.commands.insertContent({type:"image",// @tiptap/extension-image `name` 选项值attrs:{src:"https://www.baidu.com/logo.png"},});
链式调用
editor.chain()
命令提供命令链调用
editor
.chain()// 开启链式命令.focus()// 聚焦编辑区,保留选区选中样式.toggleBold()// 若干命令链接....run()// 运行
extension
extension 分为这 3 种类型拓展:Node、Mark、Extension
Node
创建一个新节点类型
import{ Node }from"@tiptap/core";const Video = Node.create({type:"video",renderHTML(){...},parseHTML(){...}})
Mark
可以对节点应用一个或多个标记,例如为文本添加内联样式
import{ Mark }from"@tiptap/core";const FontSize = Mark.create({name:"fontSize",...})
Extension
以上 2 种类型都基于
Extension
基础类,通过定义基础的 extension 添加全局特性
import{ Extension }from"@tiptap/core";const Float = Extension.create({name:"uniqueId",addGlobalAttributes(){...}...})
extension 核心选项
name
扩展名称,代表内容类型/特性唯一名称
// Node类型extension
editor.commands.insertContent({type:"image",// 'image' 即 @tiptap/extension-image中name选项值attrs:{src:"https://www.baidu.com/logo.png"},});// Mark类型extension
editor.commands.setMark("bold");// 'bold' 即 @tiptap/extension-bold中name选项值
group
定义节点所属的内容组,值可以是
block/inline/有效type值
,供
content
选项引用
content
定义节点可以包含的内容类型。不符合的内容会被丢弃
// 必须一个或多个内容块(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
选项:
addOptions(){return{inline:false,allowBase64:false,HTMLAttributes:{},}},// 其它选项内通过 `this.options` 访问参数值,进行不同处理group(){returnthis.options.inline ?'inline':'block'},parseHTML(){return[{tag:this.options.allowBase64
?'img[src]':'img[src]:not([src^="data:"])',},]},
import Image from"@tiptap/extension-image";const editor =newEditor({element: document.querySelector(".editor"),extensions:[Image.configure({inline:true,allowBase64:true})],});
addAttributes
设置节点/标记状态,注意到它返回一个函数,即为每个节点/标记实例添加独立状态
// `@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 或粘贴)如何解析出属性值。
// @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 HTMLAttributesstyle:`background-color: ${attributes.color}; color: inherit`,// 作为DOM节点 背景色样式}},},}},
如果只想新增一个单纯状态,避免默认作为 DOM HTMLAttributes,设置
rendered: false
即可
// @tiptap/extension-headingaddAttributes(){return{level:{default:1,rendered:false,// level 不出现在DOM节点上},}},
editor 基础命令
updateAttributes
可以用来更新节点属性
// 切换标题级别
editor.commands.updateAttributes("heading",{level:2});...
renderHTML
通过 renderHTML 函数,您可以控制如何将扩展渲染为 HTML,同时也影响
editor.getHTML()
返回值
这与
addAttributes
内的 renderHTML 选项不同,后者用于如何消费 node 属性attribute,前者用于渲染节点/标记的容器,且此时 DOM 的 HTMLAttribute 已被计算。
// node 渲染为 strong 标签,并携带默认计算的HTMLAttributesrenderHTML({ HTMLAttributes }){return['strong', HTMLAttributes,0]// HTMLAttributes 即tiptap计算后的DOM属性},
renderHTML 返回一个数组,第一个值是 HTML 标签名; 如果第二个元素是一个对象,它将被解释为一组属性; 第三个参数 0 用于表示内容应插入的位置;
通过自定义 renderHTML 逻辑,可以额外的添加 HTMLAttributes
import{ mergeAttributes }from'@tiptap/core'// 渲染为 a 标签,且额外添加 rel 属性,值来自addOptions配置renderHTML({ HTMLAttributes }){return['a',mergeAttributes(HTMLAttributes,{rel:this.options.rel }),0]},
parseHTML
parseHTML
选项用于定义外部 HTML 字符串解析为 Node 的方法,HTML 字符串的未匹配并解析内容将无法插入编辑器
parseHTML(){// 将满足以下任一条件作为boldreturn[{tag:'strong',},{tag:'b',getAttrs:node=>(node as HTMLElement).style.fontWeight !=='normal'&&null,},]},
addNodeView
通过添加节点视图,为编辑器添加了交互的或内嵌内容类型
addNodeView
作为一个extension配置,它和
renderHTML
有共同点,都能控制节点最终在编辑区渲染结果;
renderHTML 最核心作用是
editor.getHTML
如何将节点转换为html文本用于存储,编辑器默认将renderHTML作为编辑区渲染依据
但节点视图支持开发者自定义一个类型Node在编辑区上的dom中。
extension 继承
如果针对某一个extension进行添加特性修改部分逻辑,tiptap提供
Node.extend
以 extension 继承实现
如下为
@tiptap/extension-bullet-list
新增
listStyleType
特性,打造一个支持修改无序列表
list-style
的新 bullet list extension
// tiptap-extension-bullet-listimport BulletList from"@tiptap/extension-bullet-list";exportdefault BulletList.extend({addAttributes(){return{...this.parent?.(),// 沿用BulletList attributeslistStyleType:{default:"disc",parseHTML:(element)=>{const listStyleType = element.style["list-style-type"];return{listStyleType: listStyleType ||"disc"};},renderHTML:(attributes)=>{return{style:`list-style-type: ${attributes.listStyleType}`};},},};},});
自定义 Node
如下代码定义了一个AIImage的节点,并渲染节点为AiImageView
import{ mergeAttributes, Node }from'@tiptap/core';import{ ReactNodeViewRenderer }from'@tiptap/react';import Image from'@tiptap/extension-image';import UImageComponent from'./u-image-component';import AiImageView from'./AiImageView';
declare module '@tiptap/core'{interfaceCommands<ReturnType>{uImage:{setImage:(options:{src: string;
alt?: string;
title?: string;
width?: string | number;
height?: string | number;
file?: File;})=> ReturnType;};aiImage:{setAiImage:(options:{pos: number; value?: string })=> ReturnType;};}}exportconst AIImage = Node.create({name:'aiImage',group:'block',atom:true,addAttributes(){return{value:{default:'',renderHTML(attributes){return{value: attributes.value,};},parseHTML(element){return element.getAttribute('value');},},};},renderHTML({ HTMLAttributes }){return['div',mergeAttributes(this.options.HTMLAttributes, HTMLAttributes,{'data-type':this.name,}),];},parseHTML(){return[{tag:`div[data-type="${this.name}"]`,},];},addNodeView(){returnReactNodeViewRenderer(AiImageView);},addCommands(){return{setAiImage:(options)=>({ commands })=>
commands.insertContentAt(options.pos,{type:this.name,attrs: options,}),};},});
NodeViewContent
NodeViewContent
其实就是个占位符,它会被替换成节点的实际内容。它确保这些内容能够正确地被渲染,并且可以在编辑器中进行编辑。
首先自定义节点:
import{ mergeAttributes, Node }from'@tiptap/core';import{ ReactNodeViewRenderer }from'@tiptap/react';import Component from'./card-component';exportconst Card = Node.create({name:'card',group:'block',content:'optionList',addAttributes(){return{title:{default:'',renderHTML(attributes){return{title: attributes.title,};},parseHTML(element){return element.getAttribute('title');},},};},parseHTML(){return[{tag:`div[data-type="${this.name}"]`,},];},renderHTML({ HTMLAttributes }){const attrs =mergeAttributes(HTMLAttributes,{'data-type':this.name,});return['div', attrs,0];},addNodeView(){returnReactNodeViewRenderer(Component);},});
比如编辑器的内容如下:
const editor =useEditor({extensions:[ StarterKit, Card,],content:`
<p>这是一个段落。</p>
<div data-type="card" title="Card Title">
<p>这是卡片的内容。</p>
</div>
`,})
那么就会在
CardComponent
的
NodeViewContent插槽
处显示
<p>这是卡片的内容。</p>
并且可以控制是否可编辑。
总结
笔者在日常的需求迭代中,已经在编辑器集成了AI写作、AI绘画等AIGC相关功能以及一些通用编辑功能。并且对该编辑器的灵活性、可扩展性、文档规范性等方面给予了很高的认可。
通过使用Tiptap编辑器的扩展继承、自定义扩展等功能,可以让我们构建出更为更为丰富的富文本编辑器。
Tiptap富文本编辑器的功能远不止这些,还有很多编辑器的方法没有介绍。不过笔者希望通过此篇文章可以帮助你更好的认识Prosemirror 和 Tiptap富文本编辑器。
版权归原作者 Lsx~ 所有, 如有侵权,请联系我们删除。