Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
gnipbao opened this issue Jul 6, 2023 · 0 comments
Open

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

gnipbao opened this issue Jul 6, 2023 · 0 comments

Comments

@gnipbao
Copy link
Owner

gnipbao commented Jul 6, 2023

了解富文本编辑器基础

浏览器特性

  • 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/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant