跳至主要内容

proseMirror guide 翻譯

自己的整理​

plugin / node view / decoration 的差異​

plugin 用來改變編輯器的文檔,可以加一些按鍵綁定、修改編輯器的狀態... node view 為特定節點擴展自定義的結構 decoration 將文檔添加視覺效果

介紹​

ProseMirror 提供了能完成文字編輯器的一系列工具與概念。

PM 的主要原則是讓 code 能有完全對於 document 的掌控,能完全知道發生什麼事。document 不是 html,是指一個自訂的資料結構,這個結構可以規範什麼樣的 DOM 可以包含在結構中,所有的更新都經過一個特定的點,讓你可以觀察與回應。

core module 的設計是以模組化與客製化的能力為主要考量。

有 4 個重要的 module,進行任何編輯都需要這些。還有一些額外的插件,但可以忽略,如果你只需要做一些簡單的功能。或者你也可以自己做一個相關插件取代。

重要的 module 包含:

  1. prosemirror-model 定義用來描述編輯器內容的文件模型,是 state 的一部分。

  2. prosemirror-state 提供描述整個編輯器狀態的資料結構,包括選取範圍與狀態改變的變更機制(transaction system)。

  3. prosemirror-view 實現了可以顯示 editor state 的 ui 介面,使用 html editable element,並處理使用者互動。

  4. prosemirror-transform 提供可以修改 document 的功能,並且可以被紀錄與重複,這是 state module 的 transaction system 的基礎,也是有這些功能,使協作功能、復原功能可以實現

transactions​

當使用者與 view 互動時,會產生 state transaction,代表在更改 state 時,並不是在內部更改 document 的狀態,而是將每個改變都 create 一個 transaction。transaction 就是描述與原 state 的差異,可以被 state apply 成一個新的 state。

預設的上述的機制都發生在幕後。可以寫 plugin 或是 設定自己的 view 來 hook。 例如:dispatchTransaction,是 EditorView 可傳入的參數,dispatchTransaction 是一個 callback,在 transaction 被創建時觸發。

每一次 state 的更新,都透過 EditorView.updateState,每一個編輯更新都由 dispatch transaction 觸發。

plugins​

plugin 透過不同的方式來擴充編輯器與編輯器狀態。有些特別簡單,像是 keymap plugin。有些則是涉入較多,像是 history plugin。因為 plugin 在創立 state 時註冊,因為 plugin 可以取得 state transaction。

commands​

大部分的編輯動作都是一個 command。 prosemirror command 提供一系列基本的編輯 command,也包含一些基本的操作功能包(baseKeymap),像是 delete, enter 等。

content​

整個編輯器的 document state 是存在 EditorState.doc 裡。這是一個唯讀的資料結構,用多層級的 node 表示 document,類似於 DOM 結構。

Documents​

Structure​

PM document 是一個 class Node。 class Node 有 property content 是 class Fragment 而 Fragment 有 property content 是 [NODE]

Fragment 是代表 子 node 的集合

node 與 fragment 都是 persistent 的特性。這意味著我們不該 mutate 它。

如果是這樣一段 html <p>This is <strong>strong text with <em>emphasis</em> </strong></p> 在 DOM Tree 裡概念上是這樣

{
type: 'p',
content: [
{
type: 'text',
text: 'This is',
},
{
type: 'strong',
content: [
{
type: 'text',
text: 'strong text with'
},
{
type: 'em',
content: [
{
type: 'text',
text: 'emphasis'
}
]
}
]
}
]
}

但在 ProseMirror 概念是這樣

{
type: 'p',
content: [
{
type: 'text',
text: 'This is',
},
{
type: 'text',
text: 'strong text with',
mark: [{ type: 'strong' }]
},
{
type: 'text',
text: 'emphasis',
mark: [{ type: 'strong' }, { type: 'em' }]
},
]
}

所以實際上,ProseMirror 把 inline 元素 flatten 了,ProseMirror document 實際上是 block 元素的樹狀結構。

這更符合我們傾向於思考和處理此類文本的方式。它允許我們使用字元偏移量而不是樹中的路徑來表示段落中的位置,並且可以更輕鬆地執行拆分或更改內容樣式等操作,而無需執行笨拙的樹操作。

Document transformations​

steps​

更新 docuement 這個行為,可以分解成多個 step,通常不需要直接用到 step,但知道如何實作是有用的。

steps 的例子:用 replaceStep 取代 document 中的一塊。 step 可以被 apply 進 document,建立一個新的 document

console.log(myDoc.toString()); // p('hello');
// A step that deletes the content between position 3 and 5
let step = new ReplaceStep(3, 5, Slice.empty);
let result = step.apply(mydoc);
console.log(result.doc.toString()); // p('heo')

apply step 是一個相對直觀的行為,意思是,是有可能會報錯的。例如:如果嘗試刪除 opening token(<p> 標籤),可能會導致 token 不平衡。這也是為什麼 apply 會回傳一個 result object。result 會在成功時回傳 doc,失敗時回傳 error message。

通常會習慣使用 Transform 提供的一系列 helper function 處理 step,這樣就不用顧慮太多細節。

Transform​

一個編輯動作,可能會產生一個或多個 step。 如果要操作一系列的 step,最方便的方式是使用 Transform。

Transform 這個 class 就是用來建立與追蹤一系列 step 的抽象

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 modeified document
console.log(tr.steps.length); // 2

transform 大部分的 method 都會回傳自己,也就是可以把方法 chain 起來。 tr.delete(5, 7).split(5)

Mapping​

當改變 document,每個 postion index 其實也會改變。例如插入一個字元或是刪除字元,都會導致整個 doc 的 index 與之前不一樣。

通常會需要在文字的變更中,保留位置。ex: selection boundaries。 為了這個需求,proseMirror 的 step 提供 map, 可以透過比較改變前後的 index 變化。

let step = new ReplaceStep(4, 6, Slice.empty); // delete 4-5
let map = step.getMap();
console.log(map.map(8)); // -> 6 (原本是 8 變成 6)
console.log(map.map(2)); // -> 2 (原本是 2 不變)

Tramsform 有 mapping 這個 property 紀錄了一系列 steps 的 maps。

let tr = new Transform(myDoc);
tr.split(10) // split a node, +2 tokens at 10
tr.delete(2, 5) // -3 tokens at 2

console.log(tr.mapping.map(15)) // -> 14
console.log(tr.mapping.map(6)) // -> 3
console.log(tr.mapping.map(10)) // -> 9

某些時候,並不是很清楚原始的 index 要 map 到哪一個位置。例如:前面例子 index 10,究竟應該把 10 放在分割的後面或者是前面。

如果其實 index 10 應該在前面,可以這樣做 tr.mapping.map(10, -1)

將各種 step 定義的小而直觀的原因是,讓這樣子的 mapping 紀錄可以實現。甚至可以反轉 step。

Rebasing​

當透過 step 或是 position map 實作較複雜的行為,例如:追蹤變更紀錄,或是在多人協作中增加其他功能,可能會使用到 rebase steps。

資訊

太抽象了,有實作這段,再來說明

The editor state​

編輯器要記得狀態有哪些,如何文章的資料結構(document)外,還有 current selection、目前 mark 的狀態。 ProseMirror state 有 3 個主要的狀態就是 doc, selection, storeMarks。

plugin 也需要儲存 state。undo history 也需要記得歷史紀錄。這就是為什麼 editorState 裡,也有儲存目前 active 的 plugin。這些 plugin 也可以儲存 plugin 自己的 state。

Selection​

PM 支援多種不同的 selection。所有的 selection 都是繼承 Class Selection 的實例。editor state 裡的 selection 也是 immutable 的,要改變 selection,要建一個新的 selection object,與一個新的 state。

.from:selection 當中,文本前端的那個 point .to:selection 當中,文本後端的那個 point .anchor:selection 當中,一開始釘下的 point,不會動的那個 point .head:selection 當中,在移動的 point

Transaction​

在一般正常編輯的時候,newState 會根據舊的 state 產生。

state 可以 apply transaction 來產生新的 state。

Transaction 繼承自 Transform,transaction 會追蹤 selection 與其他有狀態的元件,Transaction 實際上被歸類在 prosemirror-state,像是 store marks 的改變,也會追蹤。

我覺得可以說 Transaction 是針對編輯器有加上了一些特化功能的 Transform。

所以 Transaction 有一系列修該編輯器相關狀態的 method。setStoreMarks、ensureMarks、scrollIntoView...等。

Plugins​

當建一個新的 editorState 時,可以帶入一個 array 設定一系列的 plugin。這些 plugin 會被存在 state 裡面,並且可以在 plugin 裡 apply transaction,或影響 editor 處理 state 的行為。

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] });

plugin 也可以定義自己的 state 所以其實 PM 有分有 state 的 plugin 和沒有 state 的 plugin。 有 state 的 plugin 一定要放在 EditorState 被建立。

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

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

getState 會從整個 editorState 中提取 plugin 的 state。 整個 editor state 是 immutable 的,所以 apply method 需回傳一個新值。

metadata​

metadata 主要用於對 plugin 在 apply 新的 state 時,加上一些資訊。 metadata 的相關 method 是透過 transaction 處理的。有 tr.getMeta(),tr.setMeta(), 也就是會標記該 transaction 的狀態。

例如:undo history plugin,在進行 執行 undo 時,會標記該 transaction,當 plugin 發現這個 transaction 有被標記時,有別於一般的紀錄更新(把 transaction 加到 undo stack),plugin 會將 undo stack 最上面的 transaction 搬移到 redo stack。

而為了讓 plugin 能夠判斷不同情況,就要用 metadata 來輔助。例如上面的 transactionCounter,如果有特殊的情境要讓 count 不 +1 的話。可以這樣:

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

function markAsUncounted(tr) {
tr.setMeta(transactionCounter, true);
}

setMeta 的 type 定義,實際上 value 可以是 any setMeta 的 key 推薦使用 Plugin 本身,如果要 string 的話,就要確保其唯一性。 有些 PM 的插件裡面,有用 string 當作 key。

function setMeta(
key: string | Plugin | PluginKey
value: any
): Transaction

The View Component​

PM editor view 是用來轉換 editor state 至 DOM 的元件。並允許使用者進行一些編輯(editing actions)。

EditorView 元件對於 editing actions 的定義是嚴謹的。主要為直接對編輯器的操作,像是 typing, clicking, copying, pasting, dragging,不超過以上範圍。因此像是,menu,快捷鍵行為綁定等功能,並不屬於 EditorView 元件的職責範圍,需要透過 plugin 來實現。

Browser 透過 contentEditable 可以讓 DOM 元素被編輯,讓元素可以被 focus 與 select,也可以在其中輸入內容。View 將 PM document 轉換為 DOM,並且讓 DOM 的編輯也連動到 editor state 上(ex: selection...)。

EditorView 本身也註冊了許多 DOM events,並將這些 event 轉換為適當的 transaction。例如:貼上這個行為,被貼上的內容會被轉換為 ProseMirror document Slice,並被插入 document 中。

許多事件也被原樣傳遞,然後才跟著 PM 的資料模型被重新解釋。Browser 對於 cursor 與 selection 的行為十分在行。所以大部分 cursor 的動作都被 browser 處理,在處理完後,PM 才去檢查目前的 DOM selection 是否符合到 PM 的 text selection。假若不符合,便會 dispatch 關於 selection 的 transaction。

一般的打字,也是留給 browser 處理,因為干擾打字可能會破壞瀏覽器提供的原始功能,例:拼字檢查、自動補全...等。editorView 會監聽 browser 的 dom 內容改變後,parse 目前的結構,並生成可以提供修訂原始 state 的 transaction。

Data flow​

所以 editorview 將給定的 editor state 顯示出來。當 DOM event 觸發時,editorview 建立 transaction,並且可以透過 apply tr 建立一個新的 state,最後將新的 state 給 view 執行 updateState(state)

如上圖是其資料流。

Props​

props 可以理解為 react props。就是 ui component 的參數。 state 也是一個 prop。所有其他的 props 在運行時都可以被更新。 Plugin 也可以帶入 props,如果 props 被定義了多次(可以從 EditorView 或 Plugin 定義 props),一般來說,會按順序確定 props 的值為何,依序執行。EditorView 為最先的,其次依據 plugin 的順序執行。 如果是 handler function,會一直執行直到 return true 的那一個為止。 對於一些可以有多種值的 props,就會被聚集起來。(ex:attribue, decoration)

Decorations​

Decoration 讓你可以控制 view 如何顯示文本。但 Decoration 不會在 document 中插入 node,而是有自己的 tree 結構。 Decoration 分成 3 種:

  1. Node Decoration:對一個 node DOM 加上 style 或 attribute
  2. Widget Decoration:在給定 position 插入一個 DOM node,這並不在 document 中。
  3. Inline Decoration:類似 Node Decoration,但作用在 inline node 上。

為了要有效率的繪製與比對 decorations,需要透過 decotation set 加入 decoration。 decotation set 是一個 Tree 的 資料結構

let purplePlugin = new Plugin({
props: {
decorations(state) {
return DecorationSet.create(state.doc, [
Decoration.inline(0, state.doc.content.size, { style: 'color: purple' })
])
}
}
})

如果有大量 decoration,每次重繪都要透過 DecorationSet recreate 大量 decoration,是耗效能的。針對這種情況,建議把 decorationSet 寫進 plugin state 裡。透過 decorationSet.map 處理。

Node View​

資訊

自己的理解:node view 是一個內容可以自定義的元件,node view 必須跟 schema 的 spec 搭配。spec 定義外面的 DOM 結構。node view 可以在對內部的內容做客製功能。

nodeSpec 的 toDOM 定義了外面的 DOM 結構,例: ['p', 0] node view 像是可以用 js 調整 0 裡面的東西

待補

commands​

在 PM,command 專指一個術語,是實現 editor action 的 function。

根據實際情況,command 可以是一個複雜的介面。在簡單的情形下,command 是一個 function 接收 editor state 與 dispatch function 做為參數,並且 return true or false。

為了要知道 command 在給定的 state 下是否可以執行,但又必須確保在確定可執行後,在觸發 dispatch,command 被設計成,如果參數只有給 state,command 只會 return true or false,來確定 command 是否可執行。

例:

function deleteSelection(state, dispatch) {
if (state.selection.empty) {
return false;
}
if (dispatch) {
dispatch(state.tr.deleteSelection);
}
return true;
}

prosemirror-commands 模組提供了許多 editing commands。像是 deleteSelection。也有較複雜的像是 joinBackward。joinBackward 主要是應用在 backspace 鍵觸發時,若在游標置於 textblock 最前方時,要實作 2 個 block 合併的操作。

資訊

chainCommad 好難解釋,有機會再補

有些時候,command 需要跟 view 互動,這時候 command 會再多一個參數 view。 所以 command 完整的 type 是這樣

(
state: EditorState,
dispatch: (tr: Transaction) => void,
view?: EditorView
) => boolean

參考文章​

https://prosemirror.net/docs/guide/