iT邦幫忙

2021 iThome 鐵人賽

DAY 26
0
Modern Web

深入 slate.js x 一起打造專屬的富文字編輯器吧!系列 第 26

Day 26. slate × Normalizing × normalizeNode

https://ithelp.ithome.com.tw/upload/images/20211011/20139359cwqxqYM4C9.png

Slate 正規化的相關功能由兩個主要函式:

  • interfaces/editor.ts 的 normalize method
  • create-editor 編輯器的 normalizeNode action

以及兩個輔助函式: interfaces/editor.ts 裡的 withoutNormalizingisNormalizing 來完成。

我們在 Day24 也有提到過, normalizeNode action 是主要負責執行節點正規化運算的函式,也就是實作那些 Slate Built-In constraints 的地方,也是開發者自定義 custom constraints 時要 override 的 function 。

normalize method 則負責呼叫 normalizeNode action 以及呼叫前的預處理,它同時也是我們先前所提到過,會事先完整遍歷過 document 裡的 Element 節點並執行第 1. 的正規化,確保它們都擁有至少一個子層節點的地方。

https://ithelp.ithome.com.tw/upload/images/20211011/201393592SymzpzR0H.png

今天先讓我們從相對單純的 normalizeNode 裡頭的 code 下手

normalizeNode action


從函式的名稱就能看出它是針對『單一節點』的正規化,呼叫函式所需帶入的參數就只有一組:一組欲正規化的 NodeEntry

export type NodeEntry<T extends Node = Node> = [T, Path]

normalizeNode: (entry: NodeEntry) => void

在函式裡頭除了實作 Built-in constraints 的內容之外,還有抵擋不必進行正規化的節點的判斷式,加快執行速度:

// There are no core normalizations for text nodes.
if (Text.isText(node)) {
  return
}

以及一些在實作正規化的過程中會利用到的一些變數們:

  • shouldHaveInlines

    判斷子層節點應為 Block type 節點還是 Inline-Block type 節點的 boolean 變數,當節點為 Element 且符合下列四種情形時則為 true ,代表 node 的子層節點只接受 Inline-Block type 或 Text 節點:

    • node 為 Inline-Block type
    • node 不存在子層節點
    • node 第一順位的子層節點為 Text
    • node 第一順位的子層節點為 Inline-Block type

    它主要被用於實作第 3. & 5. 的 Built-In constraint:

    // Determine whether the node should have block or inline children.
    const shouldHaveInlines = Editor.isEditor(node)
      ? false
      : Element.isElement(node) &&
        (editor.isInline(node) ||
          node.children.length === 0 ||
          Text.isText(node.children[0]) ||
          editor.isInline(node.children[0]))
    
  • n

    一個『子層節點指針』,用於指向因應不同的正規化需要更新節點時,實際更新的節點路徑。

    // Since we'll be applying operations while iterating, keep track of an
    // index that accounts for any added/removed nodes.
    let n = 0
    

    在執行正規化時實際上需要更新的節點大多都為傳入節點的子層節點,因此在程式碼中段我們可以看到一個 for loop 遍歷過一輪 node 的子層節點,去對每一個子層節點判斷是否進行正規化。

    但因為每次透過『插入節點』或『移除節點』執行正規化時,子層節點的 index 資料都會過期,不能代表它於該層級的節點順位。

    也因此我們需要 n 為我們記錄每個子層節點要進行更新時的實際路徑,我們用下方的簡易舉例讓讀者對它的用途更有畫面一點

    for (let i = 0; i < node.children.length; i++, n++) {
    	// ...
    
    	// remove node update
    	Transforms.removeNodes(
    		editor,
    		// node's path concat with n
    		{ at: path.concat(n) },
    		...
    	)
    }
    
  • currentNode

    當前執行正規化的節點:

    for (let i = 0; i < node.children.length; i++, n++) {
      const currentNode = Node.get(editor, path)
    }
    
  • child

    傳入節點執行正規化前第 i 順位的子層節點

    for (let i = 0; i < node.children.length; i++, n++) {
    	const child = node.children[i] as Descendant
    }
    
  • prev

    實際執行更新的前一個 sibling 節點

    for (let i = 0; i < node.children.length; i++, n++) {
    	const prev = currentNode.children[n - 1] as Descendant
    }
    
  • isLast

    i 是否為子層節點最後一個順位

    for (let i = 0; i < node.children.length; i++, n++) {
    	const isLast = i === node.children.length - 1
    }
    
  • isInlineOrText

    child 是否為 Inline-Block 或 Text type

    for (let i = 0; i < node.children.length; i++, n++) {
    	const isInlineOrText =
    	  Text.isText(child) ||
    	  (Element.isElement(child) && editor.isInline(child))
    }
    

接著就讓我們依照 Day 24 所列舉的 Built-in constraints 的順序,依序介紹裡頭是如何實現這些 constraints 的吧!

Built-in constraints Implementation


  1. 所有的 Element 節點內必須含有至少一個 Text 子節點。在進行正規化時如果遭遇到不符合此規範的 Element 節點,會加入一個空的 Text 節點進入它的子層。
  2. 將兩個相鄰且擁有完全相同的 properties 的 Text nodes 合併成同一個節點(不會刪減文字內容,只是單純做節點合併而已)。
  3. Block type 節點只能在 Block type 或者 Inline-Block type & Text 節點之中,選擇一種作為它的子層節點。舉例: paragraph block 不能同時將另外的 paragraph block element 以及 link inline-block element 作為它的子層節點。 Slate 會以子層的第一個順位的節點作為判斷可接受類別的依據,不符合規範的子層節點將直接被移除。
  4. 保持所有的 Inline-Block 節點都被環繞在 Text 節點之間, Slate 會透過『插入空的 Text 節點』來修正違反此 constraint 的情形。
  5. 最頂層的 Editor 節點只能將 Block type 節點作為其子層節點,任何子層的 Inline type 與 Text 節點都會直接被移除。

1st constraint


單純去判斷 Element 節點是否存在子層節點,若不存在則插入一個空的 Text void 節點:

// Ensure that block and inline nodes have at least one text child.
if (Element.isElement(node) && node.children.length === 0) {
  const child = { text: '' }
  Transforms.insertNodes(editor, child, {
    at: path.concat(0),
    voids: true,
  })
  return
}

2nd constraint


透過針對子層的 if else 判斷來 child 變數的可能性縮減為只剩 Text type 的可能性:

for (let i = 0; i < node.children.length; i++, n++) {
	if (isInlineOrText !== shouldHaveInlines) {
		// ...
	}
	else if (Element.isElement(child)) {
		// ...
	}
	else {
		// ... 2nd constraint implementation
	}
}

同層的前一組 sibling 存在且為 Text 節點我們才需要繼續進行正規化判斷:

// Merge adjacent text nodes that are empty or match.
if (prev != null && Text.isText(prev)) {
	// ...
}

childprev 的內容相等 → 執行『節點合併』 & n - 1

if (Text.equals(child, prev, { loose: true })) {
  Transforms.mergeNodes(editor, { at: path.concat(n), voids: true })
  n--
}
// ... else if implementation

prev 為空字串節點 → 直接移除 prevn - 1

else if (prev.text === '') {
  Transforms.removeNodes(editor, {
    at: path.concat(n - 1),
    voids: true,
  })
  n--
}
// ... else if implementation

child 為最後一個順位的節點且為空字串則直接移除 childn - 1

else if (isLast && child.text === '') {
  Transforms.removeNodes(editor, {
    at: path.concat(n),
    voids: true,
  })
  n--
}

3rd constraint & 5th constraint


利用 shouldHaveInlinesisInlineOrText 決定是否移除當前 index 的子節點:

for (let i = 0; i < node.children.length; i++, n++) {
	// Only allow block nodes in the top-level children and parent blocks
	// that only contain block nodes. Similarly, only allow inline nodes in
	// other inline nodes, or parent blocks that only contain inlines and
	// text.
	if (isInlineOrText !== shouldHaveInlines) {
	  Transforms.removeNodes(editor, { at: path.concat(n), voids: true })
	  n--
	}
}

4th constraint


檢查 Inline-Block type 子層節點的前一組 sibling ,若不存在或不為 Text 節點則插入一組 Text void 節點:

for (let i = 0; i < node.children.length; i++, n++) {
	// Ensure that inline nodes are surrounded by text nodes.
  if (editor.isInline(child)) {
    if (prev == null || !Text.isText(prev)) {
      const newChild = { text: '' }
      Transforms.insertNodes(editor, newChild, {
        at: path.concat(n),
        voids: true,
      })
      n++
    }
    // ... else if statement
  }
}

如果該節點為同層節點的最後一個順位則在它後方插入一組 Text void 節點:

else if (isLast) {
  const newChild = { text: '' }
  Transforms.insertNodes(editor, newChild, {
    at: path.concat(n + 1),
    voids: true,
  })
  n++
}

下一篇就輪到深入 normalize method 的實作了。

筆者個人認為下一篇的內容才是 Slate 處理 Normalizing 相關功能最精華的地方,反而不是這些 constraints 的實作。

如果把這句話擺在開頭的話應該就沒有讀者會看這一篇的內容了吧 XD

它除了善用了 JS 函式相關的特性與 WeakMap 做搭配,處理了效能的優化之外,我們在 Day24 裡提到的 Slate Normalizing 相關特性也幾乎實作於其中。

只不過還是要偷抱怨一下,因為使用到的函式彼此之間的關係錯綜複雜,筆者當初在研讀時也是 cmd + D 關聯來關聯去的,迷路了好幾次才終於看到出口 ?

明天的篇章除了介紹程式碼的實作之外,我們也會在明天文章的最後擺上延續 Day10 Slate 完整的運作流程圖,為整個 Normalizing 篇章做一個收尾。

我們一樣明天再見囉~

鐵人賽倒數 4 天

上一篇
Day 25. slate × Normalizing × Dirty-Path
下一篇
Day 27. slate × Normalizing × normalize
系列文
深入 slate.js x 一起打造專屬的富文字編輯器吧!30

尚未有邦友留言

立即登入留言