ProseMirror - 模块化的富文本编辑框架

关于富文本编辑器,不少同窗没用过也听过了。是你们都不想去踩的坑。到底有多坑呢?javascript

我这里摘了一部分一位大哥在知乎上的回答,若是有兴趣,能够去看看。 要让一款编辑器达到商业级质量,从目前接触到主要的例子来看,独立开发时间太长:java

  • Quill编辑器Quill 从 2012 年收到第一个 Issue 到 2016 年发布 1.0 版本,已通过去了四年。
  • Prosemirror编辑器Prosemirror 做者在 2015 年正式开源前筹款维护时已经开发了半年,而到发布 1.0 版本时,已通过去了接近三年。
  • Slate 从开源到接近两年时,仍然有一堆边边角角用起来莫名其妙的 bug 。

上面这几个单人主导的编辑器项目要达到稳定质量,时间是以年为单位来计算的。考虑到目前互联网“下周上线”的节奏,动辄几年的时间是不划算的。因此在人力,时间合理性各方面的约束下,使用开源框架是最好的选择。node

想要一款配置性强,模块化的编辑器,这就决定了这不是一个开箱即用的应用,而Quill集成了许多样式和交互逻辑,已经算是一个应用了,有时一些制定需求不能彻底知足。Slate是基于的React视图层的,咱们的技术栈是Vue,就不作考虑了,之后有机会能够研究一下,因此最后选择了prosemirror,但另外两款依然是很强大值得去学习的编辑器框架。git

因为prosemirror目前使用搜索引擎能搜出来的中文资料几乎没有,遇到问题也只能去论坛issue里面搜,或者向做者提问。如下的内容是从官网,加上本身在使用过程当中对它的理解简化出来的。但愿看完后,能让你对prosemirror产生兴趣,并从做者的设计思路中,学到东西,一块儿分享。github

ProseMirror简介

A toolkit for building rich-text editors on the webweb

prosemirror 的做者 Marijncodemirror 编辑器和 acorn 解释器的做者,前者已经在 ChromeFirefox 自带的调试工具里使用了,后者则是 babel 的依赖。正则表达式

prosemirror不是一个大而全的框架, 它是由无数个小的模块组成,它就像乐高同样是一个堆叠出来的编辑器。数组

它的核心库有:浏览器

  • prosemirror-model: 定义编辑器的文档模型,用来描述编辑器内容的数据结构
  • prosemirror-state: 提供描述编辑器整个状态的数据结构,包括selection(选择),以及从一个状态到下一个状态的transaction(事务)
  • prosemirror-view: 实现一个在浏览器中将给定编辑器状态显示为可编辑元素,而且处理用户交互的用户界面组件
  • prosemirror-transform: 包括以记录和重放的方式修改文档的功能,这是state模块中transaction(事务)的基础,而且它使得撤销和协做编辑成为可能。

此外,prosemirror还提供了许多的模块,如prosemirror-commands基本编辑命令,prosemirror-keymap键绑定,prosemirror-history历史记录,prosemirror-inputrules输入宏,prosemirror-collab协做编辑,prosemirror-schema-basic简单文档模式等。bash

如今你应该大概了解了它们各自的做用,它们是整个编辑器的基础。

实现一个编辑器demo

import { schema } from "prosemirror-schema-basic"
import { EditorState } from "prosemirror-state"
import { EditorView } from "prosemirror-view"

let state = EditorState.create({ schema })
let view = new EditorView(document.body, { state })
复制代码

咱们来看看上面的代码干了什么事,从第一行开始。prosemirror要求指定一个文档符合的模式。因此从prosemirror-schema-basic引入了一个基本的schema。那么这个schema是什么呢?

由于prosemirror定义了本身的数据结构来表示文档内容。在prosemirror结构HTML的Dom结构之间,须要一次解析与转化,这二者间相互转化的桥梁,就是咱们的schema,因此要先了解一下prosemirror的文档结构。

prosemirror文档结构

prosemirror的文档是一个Node,它包含零个或多个child NodesFragment(片断)

有点相似浏览器DOM的递归和树形的结构。但它在存储内联内容方式上有所不同。

<p>This is <strong>strong text with <em>emphasis</em></strong></p>
复制代码

HTML中,是这样的树结构:

p //"this is "
  strong //"strong text with "
	em //"emphasis"
复制代码

prosemirror中,内联内容被建模为平面的序列,strong、em(Mark)做为paragraph(Node)的附加数据:

"paragraph(Node)"
// "this is " | "strong text with" | "emphasis"
                    "strong(Mark)"       "strong(Mark)", "em(Mark)"
复制代码

prosemirror的文档的对象结构以下

Node:
  type: NodeType //包含了Node的名字与属性等
  content: Fragment //包含多个Node
  attrs: Object //自定义属性,image能够用来存储src等。
  marks: [Mark, Mark...] // 包含一组Mark实例的数组,例如em和strong
复制代码
Mark:
  type: MarkType //包含Mark的名字与属性等
  attrs: Object //自定义属性
复制代码

prosemirror提供了两种类型的索引

  • 树类型,这个和dom结构类似,你能够利用child或者childCount等方法直接访问到子节点
  • 平坦的标记序列,它将标记序列中的索引做为文档的位置,它们是一种计数约定
    • 在整个文档开头,索引位置为0
    • 进入或离开一个不是叶节点的节点记为一个标记
    • 文本节点中的每一个节点都算一个标记
    • 没有内容的叶节点(例如image)也算一个标记

例若有一个HTML片断为

<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>
复制代码

则计数标记为

0   1 2 3 4    5
 <p> O n e </p>

5            6   7 8 9 10    11   12            13
 <blockquote> <p> T w o <img> </p> </blockquote>
复制代码

每一个节点都有一个nodeSize属性表示整个节点的大小。手动解析这些位置涉及到至关多的计数,prosemirror为咱们提供了Node.resolve方法来解析这些位置,而且可以获取关于这个位置更多的信息,例如父节点是什么,与父节点的偏移量,父节点的祖先是什么等一些其它信息。

了解了prosemirror的数据结构,知道了schema是两种文档间转化的模式,回到刚才的地方,咱们从prosemirror-schema-basic中引入了一个基本的schema,那么这个基本的schema长什么样呢?经过查看源码最后一行

export const schema = new Schema({nodes, marks})
复制代码

schemaSchema经过传入的nodes, marks生成的实例。 而在实例以前的代码,都是在定义nodesmarks,将代码折叠一下,发现nodes

{
  doc: {...} // 顶级文档
  blockquote: {...} //<blockquote>
  code_block: {...} //<pre>
  hard_break: {...} //<br>
  heading: {...} //<h1>..<h6>
  horizontal_rule: {...} //<hr>
  image: {...} //<img>
  paragraph: {...} //<p>
  text: {...} //文本
}
复制代码

marks

{
  em: {...} //<em>
  link: {...} //<a>
  strong: {...} //<strong>
  code: {...} //<code>
}
复制代码

它们表示编辑器中可能会出现的节点类型以及它们嵌套的方式。它们每一个都包含着一套规则,用来描述prosemirror文档Dom文档之间的关联,如何把Dom转化为Node或者Node转化为Dom。文档中的每一个节点都有一个对应的类型。 从最上面开始doc开始看:

doc: {
  content: "block+"
}
复制代码

每一个schema必须定义一个顶层节点,即doccontent控制子节点的哪些序列对此节点类型有效。 例如"paragraph"表示一个段落,"paragraph+"表示一个或多个段落,"paragraph*"表示零个或多个段落,你能够在名称后使用相似正则表达式的范围。同时你也能够用组合表达式例如"heading paragraph+""{paragraph | blockquote}+"。这里"block+"表示"(paragraph | blockquote)+"。 接着看看em:

em: {
  parseDOM: [
    { tag: "i" },
    { tag: "em" },
    { style: "font-style=italic" }
  ],
  toDOM: function() {
    return ["em"]
  }
}
复制代码

parseDOMtoDOM表示文档间的相互转化,上面的代码有三条解析规则:

  • <i>标签
  • <em>标签
  • font-style=italic的样式

当匹配到一条规则时,就呈现为HTML<em>结构。

同理,咱们能够实现一个下划线的mark

underline: {
  parseDOM: [
    { tag: 'u' },
    { style: 'text-decoration:underline' }
  ],
  toDOM: function() {
    return ['span', { style: 'text-decoration:underline' }]
  }
}
复制代码

NodeMark均可以使用attrs来存储自定义属性,好比image,能够在attrs中存储srcalttitle

回到刚才

import { schema } from "prosemirror-schema-basic"
import { EditorState } from "prosemirror-state"
import { EditorView } from "prosemirror-view"

let state = EditorState.create({ schema })
let view = new EditorView(document.body, { state })
复制代码

咱们使用EditorState.create经过基础规则schema建立了编辑器的状态state。接着,为状态state建立了编辑器的视图,并附加到了document.body。这会将咱们的状态state呈现为可编辑的dom节点,并在用户键入时产生transaction

Transaction

当用户键入或者其余方式与视图交互时,都会产生transaction。描述对state所作的更改,而且能够用来建立新的state,而后更新视图。

下图是prosemirror简单的循环数据流data flow:编辑器视图显示给定的state,当发生某些event时,它会建立一个transactionbroadcast它。而后,此transaction一般用于建立新state,该state使用updateState方法提供给视图 。

DOM event
        ↗            ↘
EditorView           Transaction
        ↖            ↙
        new EditorState
复制代码

默认状况下,state的更新都发生在底层,可是,你能够编写插件plugin或者配置视图来实现。例如咱们修改下上面建立视图的代码:

// (Imports omitted)
let state = EditorState.create({schema})
let view = new EditorView(document.body, {
  state,
  dispatchTransaction(transaction) {
    console.log("create new transaction")
    let newState = view.state.apply(transaction)
    view.updateState(newState)
  }
})
复制代码

EditorView添加了一个dispatchTransactionprop,每次建立了一个transaction,就会调用该函数。 这样写的话,每一个state更新都必须手动调用updateState

Immutable

prosemirror的数据结构是immutable的,不可变的,你不能直接去赋值它,你只能经过相应的API去建立新的引用。可是在不一样的引用之间,相同的部分是共享的。这就比如,有一颗基于immutable的嵌套复杂很深的文档树,即便你只改变了某个地方的叶子节点,也会生成一棵新树,但这棵新树,除了刚才更改的叶子节点外,其他部分和原有树是共享的。有了immutable,当每次键入编辑器都会产生新的state,你在每种不一样的state之间来回切换,就能实现撤销重作操做。同时,更新state重绘文档也变得更高效了。

State

是什么构成了prosemirrorstate呢?state有三个主要组成部分:你的文档doc, 当前选择selection和当前存储的markstoredMarks

初始化state时,你能够经过doc属性为其提供要使用的初始文档。这里咱们可使用idcontent下的 dom结构做为编辑器的初始文档。Dom解析器Dom结构经过咱们的解析模式schema将其转化为prosemirror结构

import { DOMParser } from "prosemirror-model"
import { EditorState } from "prosemirror-state"
import { schema } from "prosemirror-schema-basic"

let state = EditorState.create({
  doc: DOMParser.fromSchema(schema).parse(document.querySelector("#content"))
})
复制代码

prosemirror支持多种类型的selection(并容许第三方代码定义新的选择类型,注:任何一个新的类型都须要继承自Selection)。selection与文档和其余与state相关的值同样,也是immutable的 ,更改selection,就要建立新的selection和保持它的新stateselection至少具备fromto指向当前文档的位置来表示选择的范围。最多见的选择类型是TextSelection,用于游标或选定文本。prosemirror还支持NodeSelection,例如,当你按ctrl / cmd单击某个Node时。会选择范围从节点以前的位置到其后的位置。 storedMarks则表示须要应用于下一次输入时的一组Mark

Plugins

plugin以各类方式扩展编辑器和编辑器state。当建立一个新的state,你能够向其提供一系列的plugin,这些将会保存在此state和由此state派生的任何state中。而且能够影响transaction的应用方式以及基于此state的编辑器的行为方式。 建立plugin时,会向其传递一个指定其行为的对象。

let myPlugin = new Plugin({
  props: {
    handleKeyDown(view, event) {
      //当收到keydown事件时调用
      console.log("A key was pressed!")
      return false // We did not handle this
    }
  }
})

let state = EditorState.create({schema, plugins: [myPlugin]})
复制代码

当插件须要本身的plugin state时,能够经过state属性来定义。

let transactionCounter = new Plugin({
  state: {
    init() { return 0 },
    apply(tr, value) { return value + 1 }
  }
})

function getTransactionCount(state) {
  return transactionCounter.getState(state)
}
复制代码

上面这个插件定义了一个简单的plugin state,它对已经应用于statetransaction进行计数。 下面有个辅助函数,它调用了plugingetState方法,从完整的编辑器的state中获取了pluginstate

由于编辑器的stateimmutable的,并且plugin state是该state的一部分,因此plugin state也是immutable的,即它们的apply方法必须返回一个新值,而不是修改旧值。 plugin一般能够给transaction添加一些额外信息metadata。例如,在撤销历史操做时,会标记生成的transaction,当plugin看到时,他不会向普通的transaction同样处理它,它会特殊处理它:从撤销堆栈顶部删除,将该transaction放入重作堆栈。

回到最初的例子,咱们能够将command绑定到键盘输入的keymap plugin,同时还有history plugin,其经过观察transaction来实现撤销和重作。

// (Omitted repeated imports)
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时会注册plugin,经过这个state建立的视图你将可以按Ctrl-Z(或OS X上的Cmd-Z)来撤消上次更改。

Commands

上面的undo, redo是一种command,大多数的编辑操做都被视为command。它能够绑定到菜单或者键上,或者其余方式暴露给用户。在prosemirror中,command是实现编辑操做的功能,它们大可能是采用编辑器statedispatch函数(EditorView.dispatch或者一些其余采用了transaction的函数)完成的。下面是一个简单的例子:

function deleteSelection(state, dispatch) {
  if (state.selection.empty) return false
  if (dispatch) dispatch(state.tr.deleteSelection())
  return true
}
复制代码

command不适用时,应该返回false或者什么也不作。若是适用,则须要dispatch一个transaction而后返回true,为了可以查询command是否适用于给定state而不实际执行它,dispatch参数是可选的,当没有传入dispatch时,command应该只返回true,而不执行任何操做,这个能够用来使你的菜单栏变灰来表示当前command不可执行。 一些command可能须要与dom交互,你能够为他传递第三个参数view,即整个编辑器的视图。 prosemirror-commands提供了许多的编辑command,从简单到复杂。还同时附带一个基础的keymap, 可以给编辑器使用的键绑定来使编辑器可以执行输入与删除等操做,它将许多与schema无关的command绑定到一般用于它们的键。它还导出了许多command的构造函数,例如toggleMark,它传入一个mark类型和自定义属性attrs,返回一个command函数,用于切换当前selection上的该mark类型。 要自定义编辑器,或容许用户与Node进行交互,你能够编写本身的command。 例如一个简单的清除样式的格式刷command

function clear(state, dispatch) {
  if (state.selection.empty) return false;
  const { $from, $to } = state.selection;
  if (dispatch) dispatch(state.tr.removeMark($from.pos, $to.pos, null));
  return true
}
复制代码

总结

上述介绍能够做为对prosemirror的一个简单的认识,了解了它的运做原理,避免你第一次接触它的时候,看到它的这么多库,不知道从哪上手。prosemirror除了上面介绍的概念之外,还有DecorationsNodeViews等,它们使你能够控制视图绘制文档的方式。若是你还想继续深刻的了解prosemirror,能够前往它的官网论坛,但愿你能成为它的贡献者。

相关文章
相关标签/搜索