关于前端:还在写业务代码高级程序员已经开始写自己的编辑器了

35次阅读

共计 9253 个字符,预计需要花费 24 分钟才能阅读完成。

ProseMiror 作为 JS 老鹰书作者的编辑器开山之作,充沛集成了 Unix 零碎设计的精髓,践行稳固、高性能内核设计 + 灵便的插件扩大机制,是继 CodeMirror 代码编辑器后,又一优良的前端富文本编辑器开源我的项目,业界很多出名产品应用 Prosemirror 作为期内容编写外围工具,如 Confluence 等。

本文为带你实现一个生产级别的富文本编辑器系列教程的开篇,次要为对 Prosemirror 文档的提炼,解说 Prosemirror 的外围概念,以及如何开发一个最简的编辑器。

特点

  • Prosemirror 提供了一系列工具、概念来构建富文本编辑器,应用基于 WYSIWYG 的图形界面,然而试图防止这类编辑的陷阱
  • 通过代码(code)齐全管制文档(document),以及在文档上能够做的事件,文档是自定义的数据结构,蕴含你让它能有的内容,所有的更新都会通过一个中介点,你能够辨认出这些更新(inspect),并作出对应的反馈(react)。
  • 外围库不是一个很容易应用的组件 — 优先思考模块性、定制性,因为简略性,将来可能有其余基于 Prosemirror 封装的更高层的编辑器呈现,而 Prosemirror 更像是一个 Lego 而不是一个很匹配的汽车。
  • 四个外围模块,在可能解决任何模式的编辑时是必要的,以及由外围团队保护的一系列扩大(extensions)库,相似于第三方模块,提供了十分有用的性能,然而你能够疏忽它们,或是代替他们来实现相似的性能。

四个外围模块如下:

  • prosemirror-model:定义了编辑器的“文档模型”(document model),“数据结构”— 用于形容编辑器的内容
  • prosemirror-state:提供数据结构 — 形容整个编辑器的状态(whole state),蕴含光标(selection)、一个合约零碎(transaction system)– 用于将一个状态变为下一个状态
  • prosemirror-view:实现了用户界面组件(user interface component),接管给定的编辑器状态作为浏览器中的一个可编辑元素,解决这个元素的用户交互
  • prosemirror-transform:蕴含以一种可记录、可重放的形式来批改文档的性能,是 state 模块外面“合约零碎”的根底,使得重做(undo)历史、合作编辑性能变为可能

除此之外还有一些扩大库:

  • 根底编辑命令(basic editing commands):prosemirror-commands
  • 绑定键盘键(binding keys):prosemirror-keymap
  • 重做历史(undo history):prosemirror-history
  • 输出宏(input macros):prosemirror-inputrules
  • 合作编辑(collaborative editing):prosemirror-collab
  • 文档模式(document schema):prosemirror-schema-basic
  • 解决列表模式:prosemirror-schema-list
  • 解决拖放光标:prosemirror-gapcursor

Prosemirror 并不是以一个浏览器可加载的繁多脚本文件来散发的,而是提供了一系列模块,在应用时须要借助打包工具来应用。

第一个编辑器

创立一个 Prosemirror 编辑器须要如下几个流程:

  1. 定义一个文档须要遵循的模式(Schema)
  2. 基于此模式创立编辑器的初始状态(State),这个过程会创立一个遵循上述模式的空文档(Empty document)以及一个置于文档开始地位的默认光标(Selection)
  3. 基于上述状态创立视图(View),并将此视图挂载到 DOM 中,这个过程会将文档状态渲染成一个可编辑的 DOM 节点,当用户打字时,会基于用户的操作生成对应的“合约”(Transaction),合约会去更新文档状态(State),最终反馈到视图(View)

根据上述的流程咱们来看一个最简略的 Prosemirror 编辑器代码:

import {schema} from "prosemirror-schema-basic"

import {EditorState} from "prosemirror-state"

import {EditorView} from "prosemirror-view"



const doc = schema.node("doc", null, [

  schema.node("paragraph", null, [schema.text("hello", [schema.mark("em")]),

  ]),

  schema.node("horizontal_rule"),

  schema.node("paragraph", null, [schema.text("world")]),

]);



let state = EditorState.create({schema})

let view = new EditorView(document.body, { state})

上述代码的展现成果如下:

上述的代码里很好的论述的上述图示的流程,然而咱们无奈很直观的理解到状态(State)、合约(Transaction)等概念,同时当初生成的编辑器也没法解决换行之类的内容,因为 Prosemirror 的外围库是无观点的,你必须通过设置适当的条件来告诉外围库解决对应的换行,后续将通过更加欠缺一点的例子来补齐欠缺的流程。

解决“合约”

每当用户输出内容或以其余形式与视图(View)进行交互时,会生成对应的“状态合约”(State Transaction),这意味着不是间接批改文档,而后费解的更新状态,而是每次批改都会创立一个 合约(Transaction)– 形容对状态(State)的改变,能够被利用来创立新的状态,而后新的状态被用于更新视图。

上述的过程都是在底层默认会产生的,然而你能够通过编写插件来配置视图以拦挡这个过程,例如在创立视图时增加 dispatchTransaction 的办法属性,每当合约被创立时,这个办法都会调用:

let state = EditorState.create({schema});

let view = new EditorView(document.body, {

  state,

  dispatchTransaction(transaction) {

    console.log(

      "文档的长度从",

      transaction.before.content.size,

      "变成",

      transaction.doc.content.size

    );

    let newState = view.state.apply(transaction);

    view.updateState(newState);

  },

});

每次状态(State)更新时,都须要通过 updateState 办法,每个根底的编辑更新都会通过发动一个合约的模式来进行(dispatching a transaction),对应的展现成果如下:

插件

插件用于以各种各样的形式扩大编辑器的行为或编辑器的状态,有些插件的行为很简略,如 keymap 插件用于给键盘输入绑定动作,有些则比较复杂,例如 undo history 插件,实现了历史回退的性能,通过监听合约,而后存储一个合约的反置,以便在用户想要重做时可能实现回退。

让咱们尝试将 undo/redo 插件退出到咱们的编辑器中来取得回退 / 重做的性能:

import {undo, redo, history} from "prosemirror-history";

import {keymap} from "prosemirror-keymap";



let state = EditorState.create({

  schema,

  plugins: [history(), keymap({"Mod-z": undo, "Mod-y": redo})],

});



let view = new EditorView(document.body, { state});

因为插件须要获取到“状态合约”(State Transaction),所以在创立状态时,注册对应的插件,当通过关上了“历史”性能的状态创立视图之后,你就能够通过快捷键“Ctrl-Z”(Mac 上 Command-Z)来回退你上次的批改。

对应的成果如下:

命令

下面提到的绑定了对应键盘键(Ctrl-Z/Mod-Z)的 undo/redo 值函数是一类被称之为命令(commands) 的非凡函数。大部分的编辑操作都能通过编写对应的命令来解决,这些命令能够绑定对应的键盘键,能够挂载到菜单栏上,或是裸露给用户应用来管制编辑器的行为。

prosemirror-commands 包提供了一系列根底的编辑命令,蕴含一个最小的“键映射”性能,解决包含你可能想要的 Enter 或 Delete 键按下须要的相应性能。

import {baseKeymap} from "prosemirror-commands";



let state = EditorState.create({

  schema,

  plugins: [history(),

    keymap({"Mod-z": undo, "Mod-y": redo}),

    keymap(baseKeymap),

  ],

});



let view = new EditorView(document.body, { state});

通过增加下面的“键映射”插件,当初就能够解决各种 Enter、Delete 等键你预期能达到的性能了,如按 Enter 键,编辑器就会插入一个换行符或插入一个新的节点。

为了增加菜单栏,或者为了特定模式的键绑定性能,你可能须要 prosemirror-example-setup 包来帮忙,这个包提供了一系列插件来配置你的编辑器,正如它的名字一样,更多地只是利用为一个欠缺的例子而非生产级别的库。对于在真实世界的部署编辑器,你可能须要将它的局部替换成自定义的代码来设定你理论须要的内容。

内容

文档(Document)的状态通常保留在它对应的 doc 属性下,状态通常是一个只读的数据结构,通过一堆层级节点来代表文档,相似浏览器端的 DOM。一个简略的文档(Document)可能蕴含一个 doc,而后这个 doc 蕴含两个 paragraph 节点,每个 paragraph 节点又各自蕴含一个 text 节点。

当初始化状态时,你能够给予它一个初始的文档(Document),在这种状况下,schema 字段就为可选的,因为能从这个初始的文档中提取出对应的 schema

// html

<div id="content" style="display: none">hello world</div>



import {DOMParser} from "prosemirror-model"

import {EditorState} from "prosemirror-state"

import {schema} from "prosemirror-schema-basic"



let content = document.getElementById("content")

let state = EditorState.create({doc: DOMParser.fromSchema(schema).parse(content)

})

let view = new EditorView(document.getElementById("root"), {state});

上述代码通过解析 idcontent 的 DOM 内容来初始化编辑器的状态,接管 schema 提供的信息,通知 DOMParser 在解析时,对应的 DOM 节点如何映射为 Schema 外面的元素,最终会解析成一颗相似如下的节点树:

{

  "type": "doc",

  {},

  [

    { 

      "type": "paragraph", 

      {}, 

      [

        { 

          "type": "text", 

          {"text": "hello world"} 

        }

      ]

    }

  ]

}

上述代码运行的成果如下,行将一个暗藏的 DOM 节点的内容解析为了编辑器的初始内容“Hello World”。

文档

Prosemirror 通过一套自定义的数据结构来代表内容文档,因为文档是接下来要解说的编辑器其余组成部分的的基石内容,所以理解文档是如何工作的有利于了解接下来的内容。

构造

一个 Promseirror 文档是一个蕴含一个或多个子节点的节点,相似浏览器的 DOM,所以它也是类树状的,可递归的,然而 Prosemirror 的文档和 DOM 在存“内联”内容时是不太一样的。

在 HTML 中,一个段落 p 标签被示意为一棵树,相似如下这样:

<p>This is <strong>strong text with <em>emphasis</em></strong></p>

上述 HTML 的蕴含关系为如下层级:

而在 Prosemirror 外面,内联内容被建模为一个扁平序列,标签被作为元属性附加在节点上,形如如下关系:

这种形式更像咱们冀望操作文本的形式,它使得咱们通过在字符串中的偏移量来示意地位而非树中的层级门路,使得拆分文本、批改内容的款式操作变得更容易,而不须要执行顺当的树操作。

这意味着每个文档只有一个无效示意,具备雷同“标记”(Mark)的相邻文本节点总会被合并,同时不容许空文本节点存在,“标记”呈现的程序由模式(Schema)决定。

所以一个 Prosemirror 文档就是一个块节点树,大多数的叶子节点为文本块 — 蕴含文本的块节点,叶子块也能够是空节点,例如一个 <hr /> 或者 <video />

节点(Node)对象蕴含一系列属性,代表它们在文档(Document)中承当的角色:

  • isBlockisInline 办法能够通知你给定的某个节点是 块(block)还是内联(inline)节点
  • isInlineContent 是如果冀望内联节点作为内容,返回 true
  • isTextblock 则针对块节点蕴含内联内容时,返回 true
  • isLeaf 代表某个节点不能蕴含任何内容

所以一个传统的 paragraph 节点会返回文本块 textblockblockquote 可能会返回一个蕴含其余 blockblock,而 text<br />images 都是 leaf nodes<hr /> 则是一个块级别的叶子节点。

“模式”(schema)能够为文档指定更加精确的限度,如即使一个节点容许块内容存在,然而不代表所有类型的块都能够作为其内容存在。

身份与长久化

另一个 Prosemirror 文档与 DOM 树不同的点在于 — 文档对象代表节点行为的形式,在 DOM 中,节点是可渐变的对象,通过一个身份来示意,这代表一个节点只能呈现在一个父节点上面,在内容更新时,此节点对象被渐变了。

在 Prosemirror 中,另一方面,节点是简略的值,就像数字 3 就是数字 3 一样,3 能够同时呈现在很多数据结构中,它没有一个指向以后所属父节点的链接,如果你给它加 1,你就取得了一个新的数字 4,而没有扭转之前的 3。

所以,Prosemirror 文档也是如此,它们不会扭转,然而能用于初始值来计算被批改的文档片段,它们也不晓得以后所属那份数据结构中,然而能同时属于多份数据结构,或是在一份数据结构中呈现屡次,它们是值,不是有状态的对象。

这意味着,每次你更新一份文档,你取得一份新的文档值,这份新文档会共享原文档所有没有扭转的子节点,这使得创立新文档绝对便宜,不会很耗时。

这带来了很多长处,它让编辑器在更新时处于一个中间状态变得不可能,因为带有新文档的新状态可能立刻替换,同时使得能够更容易的以某种数学形式推理文档,如果值在不受管制的一直变动,那么上述推理是很难的。这同时使得合作编辑变为可能,通过比拟它上次绘制在屏幕上的文档与以后的文档,使得 Prosemirror 可能运行一个十分高效的 DOM 更新算法。

因为节点是以一般 JS 对象示意的,明确 解冻(freezing)对象的属性会影响性能,所以实际上也能够批改这些对象,然而这样做是不被反对的,会使得编辑器解体,因为在多份数据结构中总是有共享的局部,所以要当心别这样做!同时也要留神不要批改节点对象中的对象或数组,如对象用于存储节点属性,对象用于搁置孩子节点,

数据结构

文档的对象构造相似如下模式:

每个节点都通过 Node 类的实例来示意,通过 type 标记,通过 type 理解节点的名称,非法的属性,等等。Node 的 type(或 Mark 的 type)每份 Schema 创立一次,并理解它们属于那份 Schema。

节点的内容(content)以 Fragment 的实例存储,Fragment 蕴含一系列 Node,及其节点没有、或不容许有内容,这个字段都会以共享的空 fragment 来填充。

有些节点还须要蕴含属性(attrs),是存储在节点外面的额定值,例如,一个图片节点可能通过这个字段存储 alt 文本或图片的 url

除此之外,内联节点还蕴含一系列的标记(mark),比方加粗(em)或者成为一个连贯(link),这些都被放在一个 Mark 实例的数组里。

一个残缺的文档(Document)就是一个节点(Node),文档的内容通过顶层节点的孩子节点示意,一般来说,文档蕴含一系列块节点,有一些蕴含内联内容的文本块,然而顶层节点也可能是文本块自身,这时文档就只蕴含内联内容。

什么中央容许什么样的节点都是由文档的模式(schema)决定的,为了创立节点,你必须恪守模式,例如用 nodetext 的办法来创立节点时:

import {schema} from "prosemirror-schema-basic"



// (The null arguments are where you can specify attributes, if necessary.)

let doc = schema.node("doc", null, [schema.node("paragraph", null, [schema.text("One.")]),

  schema.node("horizontal_rule"),

  schema.node("paragraph", null, [schema.text("Two!")])

])

索引

Prosemirror 节点反对两类索引 — 能够被当做树解决,应用偏移来获取到某个节点,或者被当做一个扁平的 token 序列解决。

对于第一种形式你能够做相似对繁多节点交互时对 DOM 能够做的操作,通过 childchildCount 间接获取孩子节点,编写递归函数来扫描整份文档,如果你只想看所有的节点,那么用 descendantsnodesBetween 就能够了。

第二种形式在解决文档中特定地位时十分有用,它使得文档中任意地位都能够通过数字示意 — token 序列中的索引,这些 token 不理论作为对象存储在内存中,它们仅仅是一个计算约定,然而文档的树形构造,以及每个节点晓得其理论的节点大小,所以通过地位获取变得老本很低。

  • 文档的开始,在第一个内容之前,地位为 0
  • 进入或来到一个非叶子节点(例如:蕴含内容)被计算为一个 token,所以一个以 paragraph 结尾的文档,paragraph 的结尾被记为地位 1
  • 文本节点外面的每个字符被计算为一个 token,如果文档结尾的 paragraph 蕴含单词 hih 后的地位为 2,在 h 之后,i 后的地位是 3,而后整个 paragraph 完结之后的地位为 4
  • 不可蕴含内容的叶子节点如 images,也算作一个 token

所以,如果你有一个如下的文档,通过 HTML 示意相似这样:

<p>One</p>

<blockquote><p>Two<img src="..."></p></blockquote>

带上地位的 token 序列图相似如下这样:

每个节点有一个 nodeSize 属性,来通知你整个节点的大小,你也能够通过 .content.size 获取节点内容的大小。须要留神的是,对于最内部的文档节点,开闭 token 不会当做文档的一部分解决,因为你无奈将光标放在文档外,所以文档的大小应用 doc.content.size 示意,而不是 doc.nodeSize 示意。

手动的解释上述的地位须要很多的计算,你能够通过调用 Node.resolve 来获取此地位更多形容的数据结构,这个数据结构将会通知你这个地位的父节点是什么,它在父节点外面的偏移量,父节点有哪些先人节点,以及一些其余的信息。

留神对于每个 childCount 属性,都要辨别孩子索引、文档方面的地位、以及节点的本地偏移量(劣势也用于在递归函数中示意一个以后正在被解决的节点中的地位)。

切片

为了解决 复制 - 粘贴,或者 拖拽 - 搁置 等性能,聊一聊文档的切片就很有必要了 — 例如两个地位之间的内容,这样的切片与一个残缺的节点或片段不同,在切片中,有些节点的 start 和 end 地位是“开”着的。

例如,如果你抉择的内容从一段两头到另一段两头,你抉择的这个切片就有两段内容在外面,第一段内容在开始地位是“开”着的,第二段内容在完结地位也是“开”着的,然而如果你选中整个 paragraph 节点,你会选中一个“闭”节点。这种状况下,“开”节点外面的内容就违反了“模式”限度,如果将其作为节点的全部内容看待,然而有些必要的节点落在了“切片”之外。

Slice 数据结构被用于代表上述切片,它存储在两侧都为“开深度”的片段,你能够在节点上应用 slice 办法来从文档中切断一段“切片”。

 // doc 蕴含两段内容,蕴含文本 "a" 和 "b"

let slice1 = doc.slice(0, 3); // 第一段内容

console.log(slice1.openStart, slice1.openEnd); // → 0 0

let slice2 = doc.slice(1, 5); // 从第一段开始到第二段完结

console.log(slice2.openStart, slice2.openEnd); // → 1 1

批改

因为节点和片段都是长久化的,你应该永远都不要渐变它们。如果你解决了一份文档(一个节点、或一个片段),这个文档对应的对象将会放弃不变。

大多数状况下,应用 transformation 来更新文档,而不会间接与节点打交道,这也会带来对于改变的记录,这对于文档是整个编辑器状态的一部分来说是很有必要的。

如果你想手动的更新一份文档,在 Node 和 Fragment 类型上也有一些便当的帮忙办法,为了创立和更新整个文档的版本,你会常应用到 Node.replace 办法,它会将给定范畴的文档用一个新内容的切片来替换。为了浅更新文档,你能够应用 copy 办法,这会创立带有新内容的类似节点。Fragment 也有很多更新办法,如 replaceChild、和 append

❤️ 谢谢反对

以上便是本次分享的全部内容,心愿对你有所帮忙 ^_^

喜爱的话别忘了 分享、点赞、珍藏 三连哦~。

欢送关注公众号 程序员巴士 ,一辆乏味、有范儿、有温度的程序员巴士,涉猎大厂面经、程序员生存、实战教程、技术前沿等内容,关注我,交个敌人。

正文完
 0