ProseMirror - 模組化的富文字編輯框架

lijiaxunOuO發表於2018-07-19

關於富文字編輯器,很多同學沒用過也聽過了。是大家都不想去踩的坑。到底有多坑呢?

我這裡摘了一部分一位大哥在知乎上的回答,如果有興趣,可以去看看。 要讓一款編輯器達到商業級質量,從目前接觸到主要的例子來看,獨立開發時間太長:

  • Quill編輯器Quill 從 2012 年收到第一個 Issue 到 2016 年釋出 1.0 版本,已經過去了四年。
  • Prosemirror編輯器Prosemirror 作者在 2015 年正式開源前籌款維護時已經開發了半年,而到釋出 1.0 版本時,已經過去了接近三年。
  • Slate 從開源到接近兩年時,仍然有一堆邊邊角角用起來莫名其妙的 bug 。

上面這幾個單人主導的編輯器專案要達到穩定質量,時間是以年為單位來計算的。考慮到目前網際網路“下週上線”的節奏,動輒幾年的時間是不划算的。所以在人力,時間合理性各方面的約束下,使用開源框架是最好的選擇。

想要一款配置性強,模組化的編輯器,這就決定了這不是一個開箱即用的應用,而Quill整合了許多樣式和互動邏輯,已經算是一個應用了,有時一些制定需求不能完全滿足。Slate是基於的React檢視層的,我們的技術棧是Vue,就不做考慮了,以後有機會可以研究一下,所以最後選擇了prosemirror,但另外兩款依然是很強大值得去學習的編輯器框架。

由於prosemirror目前使用搜尋引擎能搜出來的中文資料幾乎沒有,遇到問題也只能去論壇issue裡面搜,或者向作者提問。以下的內容是從官網,加上自己在使用過程中對它的理解簡化出來的。希望看完後,能讓你對prosemirror產生興趣,並從作者的設計思路中,學到東西,一起分享。

ProseMirror簡介

A toolkit for building rich-text editors on the web

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簡單文件模式等。

現在你應該大概瞭解了它們各自的作用,它們是整個編輯器的基礎。

實現一個編輯器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,可以前往它的官網論壇,希望你能成為它的貢獻者。

相關文章