iT邦幫忙

2021 iThome 鐵人賽

DAY 28
0
Modern Web

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

Day 28. slate × Transforms × Node

https://ithelp.ithome.com.tw/upload/images/20211013/20139359KnxKbPp9bM.png

最後終於來到了我們最後一個章節:『 Transforms 』。

Transform 在 slate package 裡頭也是佔了舉足輕重的地位,它提供了最 high-level 操作 Slate editor 的方法,讓開發者在了解 Slate 的基本運作概念以後就能直接透過這些 apis 無痛開發。

但 High-level 的 Transform 與 Low-level 的 Operation 之間還是存在著差距的,即便名義上是說一組 Transform 是由複數個 Operations 所組成,我們仍不難猜想在各個 Transform methods 裡一定有事先為我們擋掉了各種 edge-cases 甚至下了不少優化操作效能的功夫。

如果我們選擇每個 Transform methods 都深入探討裡頭的程式碼的話 ... 另一個 30 天可能又過去了吧 ...

經過筆者縝密的思考(其實就是想省點事而已XD)後,決定讓這一整個章節以偏向 reference 的形式進行,剛好官方 document 上對各個 methods 的介紹也不太完整,因此這邊會盡可能補足官方 document 遺漏解釋的內容,會以介紹各個 Transform methods 的功用以及傳入的參數的用途為主。所以除非解釋上必較,否則我們會盡可能不過度深入程式碼的內容。

以下我們先從整個 NodeTrasforms 的通用 Options 開始介紹起。

NodeOptions


interface NodeOptions {
	at?: Location
	match?: NodeMatch<T>
	mode?: ('highest' | 'lowest') | ('all' | 'highest' | 'lowest')
	voids?: boolean
}
  • at : 欲將節點插入編輯器的 Location ,預設值為編輯器的 selection value

  • match : 自定義的 match function ,詳細的解釋請看 Day 12

  • mode :這個參數主要用於 Editor.nodes method 的 mode option ,以 insertNodes 為例:

    insertNodes(...) {
    	// ...
    
    	const [entry] = Editor.nodes(editor, {
        at: at.path,
        match,
        mode,
        voids,
      })
    
    	// ...
    }
    

    決定 Editor.nodes method 要以哪種模式遍歷 Slate node tree 。它分成三種模式:

    • 'all'

      at 出發,以正常的『垂直遍歷』的方式 yield 出查找到的節點

    • 'highest'

      只會查找並 yield 出最淺層的節點

    • 'lowest'

      只會查找並 yieldat 所涵蓋的 branch 最底層的節點

  • voids

    決定在這個 Transform method 有呼叫到的所有操作行為中,是否要略過或避開 void nodes 的出現 。

NodeTransforms


insertNodes


  • 用途:『插入單一/複數節點進編輯器』

  • 參數:

    • editor: Editor
    • nodes: Node | Node[]
    • options: InsertNodesOptions
  • options :

    interface InsertNodesOptions extends NodeOptions {
    	hanging?: boolean
    	select?: boolean
    }
    
    • hanging

      如果傳入的 at 為 Range type 的話,這個 value 會決定 Range 是否要另外修正為 unhanging type 。

      hanging 在 Slate 裡頭的意思代表『這段 Range 涵蓋到了不存在的節點』。

      我們假設目前的 Slate Document 如下:

      [{text: 'one '}, {text: 'two', bold: true}, {text: ' three'}]
      

      這時使用者看到的顯示方式應該如下:

      one two three

      假設使用者選取了 "two" ,此時的 selection 會有幾種 anchorfocus points 的可能性出現

      // 1 -- no hanging
      {
        anchor: { path: [1], offset: 0 },
        focus: { path: [1], offset: 3 }
      }
      
      // 2 -- anchor hanging
      {
        anchor: { path: [0], offset: 4 },
        focus: { path: [1], offset: 3 }
      }
      
      // 3 -- focus hanging
      {
        anchor: { path: [1], offset: 0 },
        focus: { path: [2], offset: 0 }
      }
      
      // 4 -- both hanging
      {
        anchor: { path: [0], offset: 4 },
        focus: { path: [2], offset: 0 }
      }
      

      當我們傳入 hanging: false 時, Slate 就會將這組 Range 傳入 Editor.unhangRange method 裡確保 Range 維持在第一種情形。

    • select

      決定是否更新編輯器的 selection ,如果沒有傳入 at 參數,以編輯器的 selection 位置去新增節點的話則會強制將 select 設為 true

liftNodes


  • 用途:將特定 Location 指向的內容,於 Document tree 裡向上提升一個層級。如果有必要的話會將它的父層節點一分為二。

  • 參數

    • editor: Editor
    • options: LiftNodesOptions
  • options :

    interface LiftNodesOptions extends NodeOptions {}
    

這個 method 限制無法提升路徑長度小於 2 的節點(路徑長度為 1 的節點上層就是 Editor root 了)

if (path.length < 2) {
  throw new Error(
    `Cannot lift node at a path [${path}] because it has a depth of less than \`2\`.`
  )
}

這個 method 又可以分成四種可能性:

  1. 要提升的節點為其父層節點裡的唯一子節點:向上提升並移除父層節點(因為它不再含有任何子節點了)
  2. 要提升的節點為同層節點的第一順位:將其移動到父層節點的原本路徑
  3. 要提升的節點為同層節點的最後一個順位:將其移動到父層節點的後一個 sibling 位置
  4. 其餘狀況則將要提升的節點的後一個 sibling 節點作為基準點,將父層節點拆分為二,並將要提升的節點移動到原始父層節點的後一個 sibling 。
這邊附上主要判斷式段落的程式碼,變數的命名上蠻清楚的可以大致猜想他們的用途,再搭配上述講解的四種可能性應該就能理解程式碼的撰寫邏輯了。但如果讀者看得很吃力的話也可以再將套件 clone 下來搭配著看會更輕鬆一點。
if (length === 1) {
  const toPath = Path.next(parentPath)
  Transforms.moveNodes(editor, { at: path, to: toPath, voids })
  Transforms.removeNodes(editor, { at: parentPath, voids })
} else if (index === 0) {
  Transforms.moveNodes(editor, { at: path, to: parentPath, voids })
} else if (index === length - 1) {
  const toPath = Path.next(parentPath)
  Transforms.moveNodes(editor, { at: path, to: toPath, voids })
} else {
  const splitPath = Path.next(path)
  const toPath = Path.next(parentPath)
  Transforms.splitNodes(editor, { at: splitPath, voids })
  Transforms.moveNodes(editor, { at: path, to: toPath, voids })
}

mergeNodes


  • 用途:將特定 Location 指向的內容,與它同層的前一個 sibling node 做合併。並會移除合併過後所產生的空節點。

  • 參數:

    • editor: Editor
    • options: MergeNodesOptions
  • options :

    interface MergeNodesOptions extends NodeOptions {
    	hanging?: boolean
    }
    
    • hanging

      insertNodeshanging option 一樣,決定是否將 at 為 Range 時的 value 修正成為 unhanging type 。

moveNodes


  • 用途:將單個/複數個節點從舊的 Location 搬遷到新的 Path

  • 參數:

    • editor: Editor
    • options: MoveNodesOptions
  • options :

    interface MoveNodesOptions extends NodeOptions {
    	to: Path
    }
    
    • to

      欲將 at 指向的節點搬遷到的新路徑( Path )

removeNodes


  • 用途:將 at Location 指向的單個/複數個節點從 Document 中移除

  • 參數:

    • editor: Editor
    • options: RemoveNodesOptions
  • options :

    interface RemoveNodesOptions extends NodeOptions {
    	hanging?: boolean
    }
    
    • hanging

      insertNodeshanging option 一樣,決定是否將 at 為 Range 時的 value 修正成為 unhanging type 。

setNodes


  • 用途:為 at Location 指向的節點設置新屬性

  • 參數:

    • editor: Editor

    • props: Partial<Node>

      欲設置的新屬性

    • options: SetNodesOptions

  • options :

    interface SetNodesOptions extends NodeOptions {
    	hanging?: boolean
      split?: boolean
    }
    
    • hanging

      insertNodeshanging option 一樣,決定是否將 at 為 Range 時的 value 修正成為 unhanging type 。

    • split

      at 為 Range type 時,決定是否將節點拆分開來。

splitNodes


  • 用途:拆分 Location 指向的節點。

  • 參數:

    • editor: Editor
    • options: SplitNodesOptions
  • options :

    interface SplitNodesOptions extends NodeOptions {
    	always?: boolean
      height?: number
    }
    
    • always

      這個布林值會決定,如:作為基準的子節點的順位為整個子層節點的邊境(第一或最後順位)。這類實際上不需要拆分父層節點的狀況是否仍要強行拆分。

    • height

      欲拆分的父層節點與 at Location 指向的節點所相差的層級高度

『拆分節點』這項功能裡頭針對了許多額外的情形去覆蓋了 options 裡頭的 value ,建議讀者在使用它時先閱覽過一遍裡頭的功能,大致了解裡頭額外處理了哪些情境後再去使用會比較順心一些。

unsetNodes


  • 用途:取消 at Location 指向的節點屬性設置

  • 參數:

    • editor: Editor

    • props: Partial<Node>

      欲取消設置的屬性

    • options: UnsetNodesOptions

  • options :

    interface UnsetNodesOptions extends NodeOptions {
      split?: boolean
    }
    
  • split

    這個 method 基本上就是多經過一層簡單的處理後,呼叫 setNodes method ,因此這裡的 split option 只是原封不動地作為傳給 setNodes method 的參數而已。

unwrapNodes


  • 用途:將 at Location 指向的節點內容展開並提升至上一層的位置,如果傳入的 at 為 Range type 則會拆分父層節點,為了確保只有展開 Range 涵蓋的內容

  • 參數:

    • editor: Editor
    • options: UnwrapNodesOptions
  • options :

    interface UnwrapNodesOptions extends NodeOptions {
      split?: boolean
    }
    
  • split

    at 為 Range type 時,決定是否將節點拆分開來。

這個 method 主要的工作內容是因應各種傳入的 atmatch 參數來決定要丟入進 Transforms.liftNodes method 的內容。

如果傳入的 at 為 Path ,要提升的內容則為 Path 指向的節點涵蓋到的所有文字作為 Range 傳入到 Transforms.liftNodes

如果傳入的 at 為 Range type 同時 split 參數設為 true ,才會去尋找 at Range 與欲展開的節點之間的文字交集,並丟入到 Transforms.liftNodes 由它來展開 Range 內的文字內容並拆分父層節點,否則傳入的 Range 仍會以要提升的節點為單位去涵蓋節點內的所有文字。

*程式碼的內容有點繁瑣,有興趣的讀者再麻煩自行前往查閱 ? *

wrapNodes


  • 用途:將 element 節點裡的 at Location 指向的內容包裝進一個新的 container 節點

  • 參數:

    • editor: Editor

    • element: Element

      涵蓋了 at Location 的父層 container 節點,因應不同的 Block-type 會決定後續遍歷節點所傳入的 match 參數:

      if (match == null) {
        if (Path.isPath(at)) {
          match = matchPath(editor, at)
        } else if (editor.isInline(element)) {
          match = n => Editor.isInline(editor, n) || Text.isText(n)
        } else {
          match = n => Editor.isBlock(editor, n)
        }
      }
      
      // ...
      
      const matches = Array.from(
        Editor.nodes(editor, { at: a, match, mode, voids })
      )
      
      if (matches.length > 0) {
      	// wrapNodes implementation
      }
      
    • options: WrapNodesOptions

  • options :

    interface WrapNodesOptions extends NodeOptions {
    	split?: boolean
    }
    
    • split

      at 為 Range type 時,決定是否將節點拆分開來。

如果傳入的 at 為 Range type 同時 split 參數設為 true ,則會先將 Range 所涵蓋到的文字範圍與其之外的文字邊界先做節點拆分,確保只有 at 涵蓋到的文字集合被包裝進新的 container 節點:

if (split && Range.isRange(at)) {
  const [start, end] = Range.edges(at)
  const rangeRef = Editor.rangeRef(editor, at, {
    affinity: 'inward',
  })
  Transforms.splitNodes(editor, { at: end, match, voids })
  Transforms.splitNodes(editor, { at: start, match, voids })
  at = rangeRef.unref()!

  if (options.at == null) {
    Transforms.select(editor, at)
  }
}
剩下的就是包裝新節點所需的相關操作了,一樣請有興趣的讀者自行前往查閱

上一篇
Day 27. slate × Normalizing × normalize
下一篇
Day 29. slate × Transforms × Selection & Text
系列文
深入 slate.js x 一起打造專屬的富文字編輯器吧!30

尚未有邦友留言

立即登入留言