0


富文本编辑器 从 Prosemirror 再到 Tiptap

简介

虽然大家都知道富文本是基于

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 编辑器相对于其他富文本编辑器的一些优势:

  1. 基于 ProseMirror:- ProseMirror 是一个强大的编辑框架,提供了可靠的文档模型和编辑功能。- Tiptap 继承了 ProseMirror 的强大功能,同时简化了其使用和配置。
  2. 可扩展性:- Tiptap 提供了丰富的插件系统,可以根据需要添加或移除功能。- 用户可以轻松地创建自定义扩展和插件,以满足特定需求。
  3. 易于定制:- Tiptap 的配置和定制非常灵活,可以根据需求调整编辑器的外观和功能。- 提供了丰富的 API 接口,方便开发者进行二次开发。
  4. 社区支持:- 其代码库维护良好,文档详尽,易于上手。- Tiptap 拥有活跃的社区和开发团队,提供了及时的支持和更新。- 有丰富的示例和教程,帮助用户快速上手和解决问题。
  5. 丰富的功能:- 支持多种文本格式和样式,如粗体、斜体、下划线、列表、表格、图片、链接等。- 提供了 Markdown 支持,可以在编辑器中直接使用 Markdown 语法。
  6. 支持 Vue 和 React:- Tiptap 提供了对 Vue 和 React 框架的良好支持,方便在这些框架中集成和使用。- 提供了 Vue 和 React 的封装组件,减少了集成的复杂性。
  7. 实时协作:- 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富文本编辑器。


本文转载自: https://blog.csdn.net/weixin_47339511/article/details/140851442
版权归原作者 Lsx~ 所有, 如有侵权,请联系我们删除。

“富文本编辑器 从 Prosemirror 再到 Tiptap”的评论:

还没有评论