Skip to content

通过Prosemirror了解富文本编辑器 #48

@gnipbao

Description

@gnipbao

了解富文本编辑器基础

浏览器特性

  • contenteditable
  • document.execCommand 兼容性、安全性、可定制性差等原因被废弃

contentEditable之坑

  • 厂商实现差异
  • 不可预测的表现
  • 行内标签嵌套

视觉上等价DOM结构上不等价、生成DOM不总是符合预期

详细介绍:ContentEditable困境与破局

技术类型:

  • L0:早期的轻量级编辑器
    • 基于 contenteditable
    • 使⽤ document.execCommand
    • 代码量:⼏千—⼏万⾏代码
  • L1 :CKEditor、TinyMCE、Draft.js、Slate、prosemirror、石墨文档、腾讯文档
    • 基于 contenteditable
    • 不⽤ document.execCommand,⾃主实现
    • 架构大部分采用MVC模式、通过数据驱动视图与时俱进
    • 代码量:⼏万—⼏⼗万⾏
  • L2 :google docs、office word online 、 iCloud pages、wps在线版
    • 不⽤ contenteditable,⾃主实现
    • 不⽤ document.execCommand,⾃主实现
    • 自主实现光标和选区
    • 代码量:⼏⼗万⾏—⼏百万⾏

不同类型的优劣

类型 优势 劣势
L0 技术⻔槛低,短时间内快速研发 可定制的空间⾮常有限
L1 站在浏览器肩膀上,能够满⾜ 99% 业务场景 ⽆法突破浏览器本身的排版效
L2 技术都掌控在⾃⼰⼿中,⽀持个性化排版 技术难度相当于⾃研浏览器、数据库

Prosemirror

简介

ProseMirror是一个用于实现富文本编辑器的开源框架。它提供了一个可定制、可扩展的编辑器,能够适应多种编辑场景,如在线文本编辑、协作编辑、内容管理系统等。作者 Marijncodemirror 编辑器和 acorn 解释器的作者,前者已经在 ChromeFirefox 自带的调试工具里使用了,后者则是 babel 的依赖,他还撰写了一本广受欢迎的 JavaScript 编程入门书籍《Eloquent JavaScript》

实现原理

ProseMirror依赖contentEditable,不过非常厉害的是ProseMirror将主流的前端的架构理念应用到了编辑器的开发中,比如彻底使用纯JSON数据描述富文本内容,引入不可变数据以及Virtual DOM的概念,还有插件机制、分层、Schemas(范式)等等。

特点:

  • 模块化设计
  • 实时协作编辑
  • 拼写检查和样式格式化
  • 内置功能丰富(撤销/重做、拖拽/复制/粘贴、快捷键等)
  • 注重性能(高效更新)

核心模块

  • prosemirror-model 模型层: 定义编辑器的文档模型,用来描述编辑器内容的数据结构
  • prosemirror-state 状态层: 描述编辑器整体状态,包括文档数据、选区等
  • prosemirror-view 视图层: 用于将编辑器状态展现为可编辑的元素,处理用户交互
  • prosemirror-transform 事务层: 通过事务的方式修改文档,并支持修改记录、回放、重排等

其他模块

  • prosemirror-commands 基本编辑命令
  • prosemirror-keymap 键绑定
  • prosemirror-history 历史记录
  • prosemirror-inputrules 输入宏
  • prosemirror-collab 协作编辑
  • prosemirror-schema-basic 简单文档模式

使用

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 定义了它自己的数据结构来表示 document 内容. 因为 document 是构建一个编辑器的核心元素, 因此理解 document 是如何工作的很有必要. document 是一个 node 类型, 它含有一个 fragment对象, fragment 对象又包含了 0 个或更多子 node.

结构

在 HTML 中, 一个 paragraph 及其中包含的标记, 表现形式就像一个树, 比如有以下 HTML 结构:

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

Prosemirror 中, 内联元素被表示成一个扁平的模型, 他们的节点标记被作为 metadata 信息附加到相应 node 上

好处:可以使用字符串偏移量而不是树节点路径来表示在段落中的位置、spliting内容、改变内容style操作变的容易(树操作->数组下标)

Prosemirror document 就是一颗 block nodes 的树, 它的大多数 leaf nodes 是 textblock 类型, 该节点是包含 text 的 block nodes。Node 对象有一系列属性来表示它们在文档中的行为,如isBlock、isInline、inlineContent、isTextBlock、isLeaf ****等。

缺点:开发者重新学习它独有的描述DOM的范式。

数据结构

一个 document 的数据结构看起来像下面这样

不可变数据

ProseMirror document 和DOM树不同,它被设计成immutable,不可变的,nodes 仅仅是 values,它不跟当前数据结构绑定。这意味着每次你更新 document, 你就会得到一个新的 document。大多数情况下, 你需要使用 transform去更新 document 而不用直接修改 nodes。

优点:state 更新的时候编辑器始终可用,因为新的 state 就代表了新的 document, 新旧状态可以瞬间切换,这种机制使得协同编辑成为可能,新旧虚拟dom也可以通过diff算法实现高效的更新update DOM。

骨架schema

每个 Prosemirror document 都有一个与之相关的 schema. 这个 schema 描述了存在于 document 中的 nodes 类型, 和 nodes 们的嵌套关系. schema是骨架模版,nodes是不同类型的积木,通过组合搭积木的方式完成编辑器的组装。

const schema = new Schema({
  nodes: {
    doc: {content: "block+"},
    paragraph: {group: "block", content: "text*", marks: "_"}, // 允许所有marks
    heading: {group: "block", content: "text*", marks: ""}, // 0个多个 不允许使用marks
    blockquote: {group: "block", content: "block+"}, // 1个多个
    text: {inline: true}
  },
  marks: {
    strong: {},
    em: {}
  }
})
// 传入state
let state = EditorState.create({schema})
  • 内容表达式 content expressions
  • 标记 Marks
  • 序列化与解析 Serialization and Parsing node spec 中指定 toDOMparseDOM实现解析

状态层state

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

let state = EditorState.create({schema})
console.log(state.doc.toString()) // An empty paragraph
console.log(state.selection.from) // 1, the start of the paragraph

Transactions

let tr = state.tr
console.log(tr.doc.content.size) // 25
tr.insertText("hello") // Replaces selection with 'hello'
let newState = state.apply(tr)
console.log(tr.doc.content.size) // 30

Plugins

1.当新建一个 plugin 的时候, 你需要传递 一个对象 来指定它的行为:

let myPlugin = new Plugin({
  props: {
    handleKeyDown(view, event) {
      console.log("A key was pressed!")
      return false // We did not handle this
    }
  }
})

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

2.当一个 plugin 需要它自己的 state,它可以定义自己的 state 属性:

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

function getTransactionCount(state) {
  return transactionCounter.getState(state)
}

3.插件使用时候,可以在transaction 上增加一些meta数据这样插件可以在执行transaction根据meta数据做不同的表现,增强插件的灵活性。

let transactionCounter = new Plugin({
  state: {
    init() { return 0 },
    apply(tr, value) {
      if (tr.getMeta(transactionCounter)) return value
      else return value + 1
    }
  }
})
// set meta data
function markAsUncounted(tr) {
  tr.setMeta(transactionCounter, true)
}

事务层transform

Transform系统是 Prosemirror 的核心工作方式. 它是 transactions的基础, 其使得编辑历史跟踪和协同编辑成为可能。

why?

  1. 配合 Immutable 数据结构 可以使代码的保持清晰
  2. transform系统可以保留document更新的痕迹,便于实现undo history这种历史记录
  3. 为了实现协同编辑

Steps: 原子操作

一个编辑行为可能会产生一个或者多个 steps。例如: ReplaceStepAddMarkStep

console.log(myDoc.toString()) // → p("hello")
// 删除了 position 在 3-5 的 setp
let step = new ReplaceStep(3, 5, Slice.empty)
let result = step.apply(myDoc)
console.log(result.doc.toString()) // → p("heo")

Transforms:

Transforms = (Steps+) ****一个编辑行为可能会产生一个或者多个 steps。支持链式调用

常见的方法 deleteingreplaceing, addingremoveing marks 操作树数据结构的方法如 splitting, joining,lifting, 和 wrapping

let tr = new Transform(myDoc)
tr.delete(5, 7) // Delete between position 5 and 7
tr.split(5)     // Split the parent node at position 5
console.log(tr.doc.toString()) // The modified document
console.log(tr.steps.length)   // → 2

视图层view

Prosemirror 的 editor view 是一个用户界面的 component, 它展示 editor state给用户, 同时允许用户对其执行编辑操作。

Editable DOM

  • 基于浏览器contenteditable
  • 保证DOM Selection 和editor state的selection一致性
  • 大部分事件比如光标移动、鼠标事件、输入事件都交给浏览器处理,浏览器处理完后,Prosemirror检测当前DOM或者selection的变化,然后把这些变化部分转化为transaction

数据流

dispatch

派发一个 transaction。会调用 dispatchTransaction(如果设置了),否则默认应用该 transaction 到当前 state, 然后将其结果作为参数,传入 updateState方法,类似redux。

// The app's state
let appState = {
  editor: EditorState.create({schema}),
  score: 0
}
let view = new EditorView(document.body, {
  state: appState.editor,
  dispatchTransaction(transaction) {
    update({type: "EDITOR_TRANSACTION", transaction})
  }
})

// A crude app state update function, which takes an update object,
// updates the `appState`, and then refreshes the UI.
function update(event) {
  if (event.type == "EDITOR_TRANSACTION")
    appState.editor = appState.editor.apply(event.transaction)
  else if (event.type == "SCORE_POINT")
    appState.score++
  draw()
}
// An even cruder drawing function
function draw() {
  document.querySelector("#score").textContent = appState.score
  view.updateState(appState.editor)
}

Efficient updating

Prosemirror 内部有一些高效的更新策略。比如diff新旧虚拟dom 只更新变化部分、比如更新输入的文本,这些文本通过dom的方式被修改,Prosemirror 监听 DOM change 事件, 然后由此触发 transaction 将 DOM 的输入变化同步过来, 不需要再修改 DOM等。

Props

属性定义了组件的行为,这个概念来自React

let view = new EditorView({
  state: myState,
  editable() { return false }, // Enables read-only behavior
  handleDoubleClick() { console.log("Double click!") }
})

Decorations

Decorations 给了你绘制你的 document view 方面的一些能力

  • Node decorations增加样式或者其他 DOM 属性到单个 node 的 DOM 上去.
  • Widget decorations 在给定位置插入一个 DOM node, 其不是实际文档的一部分
  • Inline decorations在给定的 range 中的行内元素增加样式或者属性, 和 node decoration 类似, 不过只针对行内元素.
let purplePlugin = new Plugin({
  props: {
    decorations(state) {
      return DecorationSet.create(state.doc, [
        Decoration.inline(0, state.doc.content.size, {style: "color: purple"})
      ])
    }
  }
})

Node views

一系列小型且独立的 node 的 UI component

let view = new EditorView({
  state,
  nodeViews: {
    image(node) { return new ImageView(node) }
  }
})
class ImageView {
  constructor(node) {
    // The editor will use this as the node's DOM representation
    this.dom = document.createElement("img")
    this.dom.src = node.attrs.src
    this.dom.addEventListener("click", e => {
      console.log("You clicked me!")
      e.preventDefault()
    })
  }
  stopEvent() { return true }
}

Commands

prosemirror-commands模块提供了大量的编辑 commands,可以让用户通过按一些联合按键来执行操作或者菜单交互行为,通过工具函数函数 chainCommands可以实现命令的组合,除此外也支持用户自定义command函数。

/// Delete the selection, if there is one.
export const deleteSelection: Command = (state, dispatch) => {
  if (state.selection.empty) return false
  if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView())
  return true
}

模块间的关系

基于Prosemirror实现富文本编辑器

开源项目

Tiptap

基于 ProseMirror的无头编辑器. Tiptap提供了合理的默认值、通过抽象Extension扩展的提供统一的写法,增加了更多commonds便于实际使用,整体提高了编辑器的可用性和扩展性,可以作为开箱即用的工程化方案。

remirror

基于[ProseMirror](https://github.com/ProseMirror/prosemirror)的用于构建*跨平台*文本编辑器的 React工具包

[BlockNote

BlockNote 是一个Notion风格的基于ProseMirrorTiptap构建的可扩展块的文本编辑器

参考

https://prosemirror.net/docs/guide/
https://tiptap.dev/introduction
开源富文本编辑器技术的演进(2020 1024)
https://www.wenxi.tech/principles-of-modern-editor-technology

推荐

https://www.notion.so/

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions