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,能夠前往它的官網論壇,但願你能成爲它的貢獻者。

相關文章
相關標籤/搜索