iT邦幫忙

2021 iThome 鐵人賽

DAY 22
0
Modern Web

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

Day 22. slate × Operation × transform

https://ithelp.ithome.com.tw/upload/images/20211007/20139359yjog1Ek2s1.png

今天的內容將延續 上一篇 文章中 Operation Process 裡的 3. ,同時我們會非常深入地去解析實現整個 transform function 的程式碼邏輯,對 Operation 的運作流程還不熟悉的讀者們,筆者建議先回頭看完上一篇的內容後再接著繼續閱讀本篇的內容。

我們在 Day18 介紹 Slate 如何使用 Immer.js 時有先簡單提到這個 transform function 過,再丟一次 code 的內容幫讀者回憶一下:

export const GeneralTransforms: GeneralTransforms = {
  /**
   * Transform the editor by an operation.
   */

  transform(editor: Editor, op: Operation): void {
    editor.children = createDraft(editor.children)
    let selection = editor.selection && createDraft(editor.selection)

    try {
      selection = applyToDraft(editor, selection, op)
    } finally {
      editor.children = finishDraft(editor.children)

      if (selection) {
        editor.selection = isDraft(selection)
          ? (finishDraft(selection) as Range)
          : selection
      } else {
        editor.selection = null
      }
    }
  },
}

transform function 的內容存放在 transforms/general.ts 這個 file 裡面,跟其他 Transform methods 一起放在同一個 transforms/directory 底下。

這個 method 做的事情本身不多,主要就是將 editor 底下的 childrenselection 丟入 Immer 的 createDraft 製作 Draft-State ,並在整個運算結束以後執行 finishDraft 而已。

主要的運算工作都是透過 applyToDraft 來執行,所以 ... 是的!我們今天會把所有的精力都放在這個 function 上,那麼以下就~正文開始!

applyToDraft


這個 function 基本上就是由一連串的 Switch case 所組成不同的 Operation type 執行不同的運算內容,並在最後回傳計算完的 selection

https://ithelp.ithome.com.tw/upload/images/20211007/201393596BW04y6N9p.png

我們就照著圖片上的順序一一介紹吧!

insert_node


『插入節點』

先判斷欲 insert 的 node 路徑的最後一個值是否大於它的 parent node 的 children 數量,若大於則代表此路徑超過了同一層 nodes 最尾端的 index ,因而是個不合法的操作

const { path, node } = op
const parent = Node.parent(editor, path)
const index = path[path.length - 1]

if (index > parent.children.length) {
  throw new Error(
    `Cannot apply an "insert_node" operation at path [${path}] because the destination is past the end of the node.`
  )
}

若小於或等於則直接 insert 進指定的位置:

parent.children.splice(index, 0, node)

接著透過 Range.points method Iterate anchorfocus point ,再丟入 Point.transform 轉換:

if (selection) {
  for (const [point, key] of Range.points(selection)) {
    selection[key] = Point.transform(point, op)!
  }
}

insert_text


『插入文字節點』

如果要被 insert 的 text 為空值則直接 break 此次的 transform

const { path, offset, text } = op
if (text.length === 0) break

path 丟入 Node.leaf method 確保它是一個合法的 Text node

const node = Node.leaf(editor, path)

/** 順便附上 Node.leaf method 內容 */
// node.ts
/**
 * Get the node at a specific path, ensuring it's a leaf text node.
 */

leaf(root: Node, path: Path): Text {
  const node = Node.get(root, path)

  if (!Text.isText(node)) {
    throw new Error(
      `Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${node}`
    )
  }

  return node
}

結束事前的判斷程序以後再將字串組在一起 &賦值

const before = node.text.slice(0, offset)
const after = node.text.slice(offset)
node.text = before + text + after

最後跟 insertNode 一樣對 selection 做一模一樣的操作,程式碼是一樣的我們就直接略過了。

merge_node


『合併節點』

它的做法是將 operation 裡給定的 path 指向的 node 與它的『前一個 sibling node 合併』。

首先取得 path 指向的 node 資料、前一個 sibling node 資料、 parent node 資料以及 path 指向的 node 的 index

const { path } = op
const node = Node.get(editor, path)
const prevPath = Path.previous(path)
const prev = Node.get(editor, prevPath)
const parent = Node.parent(editor, path)
const index = path[path.length - 1]

接著判斷它們可否合併,只有『同時為 Text node 』與『同時不為 Text node 』這兩種情形可以進行合併。前者將兩組字串合併在一起,後者則是將兩個 Node 的 children 合併在一起

if (Text.isText(node) && Text.isText(prev)) {
  prev.text += node.text
} else if (!Text.isText(node) && !Text.isText(prev)) {
  prev.children.push(...node.children)
} else {
  throw new Error(
    `Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${node} ${prev}`
  )
}

然後再拔掉 path 指向的 node value

parent.children.splice(index, 1)

最後跟 insertNode 一樣對 selection 做一模一樣的操作,程式碼是一樣的我們就直接略過了。

move_node


『移動節點』

與字面上的意思一樣,做的事情就是將節點舊的 path 移動到新的 path 。

首先先避開『舊路徑為新路徑的祖先』這個可能性並取得節點、父節點、 index 等資料:

const { path, newPath } = op

if (Path.isAncestor(path, newPath)) {
  throw new Error(
    `Cannot move a path [${path}] to new path [${newPath}] because the destination is inside itself.`
  )
}

const node = Node.get(editor, path)
const parent = Node.parent(editor, path)
const index = path[path.length - 1]

接著是更新節點資料的部分,因為在異動了原始的節點資料後會導致傳入的 path 資料過期而變得不可用,所以這邊的做法是取得 transform 後的新 path 以後再取得這個新 path 的父節點以及新的 index 資料,然後再對這些取得的新資料進行操作,而不是直接操作傳入的 newPath 資料

// This is tricky, but since the `path` and `newPath` both refer to
// the same snapshot in time, there's a mismatch. After either
// removing the original position, the second step's path can be out
// of date. So instead of using the `op.newPath` directly, we
// transform `op.path` to ascertain what the `newPath` would be after
// the operation was applied.
parent.children.splice(index, 1)
const truePath = Path.transform(path, op)!
const newParent = Node.get(editor, Path.parent(truePath)) as Ancestor
const newIndex = truePath[truePath.length - 1]

newParent.children.splice(newIndex, 0, node)

最後跟 insertNode 一樣對 selection 做一模一樣的操作,程式碼是一樣的我們就直接略過了。

remove_node


『刪除節點』

刪除節點本身的操作非常的基本,就是直接透過 Array 的 splice method 來達成而已:

const { path } = op
const index = path[path.length - 1]
const parent = Node.parent(editor, path)
parent.children.splice(index, 1)

主要的內容都集中在處理 selection 的更新上,因為刪除的節點有可能是 selection 裡的 anchorfocus point 。

這邊的作法是:

  1. 一樣先更新 selection 裡的 anchorfocus point
  2. 如果更新後的節點並不存在(被刪除了),就分別透過遍歷的方式找到 op.path 『之前』或是『之後』的文字節點
  3. 如果找到的是『之前』的節點就將 selection point 更新為該節點的最後一個字,如果是『之後』的就更新為該節點的第一個字,都沒找到就直接將 selection 設為 null
// Transform all of the points in the value, but if the point was in the
// node that was removed we need to update the range or remove it.
if (selection) {
  for (const [point, key] of Range.points(selection)) {
    const result = Point.transform(point, op)

    if (selection != null && result != null) {
      selection[key] = result
    } else {
      let prev: NodeEntry<Text> | undefined
      let next: NodeEntry<Text> | undefined

      for (const [n, p] of Node.texts(editor)) {
        if (Path.compare(p, path) === -1) {
          prev = [n, p]
        } else {
          next = [n, p]
          break
        }
      }

      if (prev) {
        point.path = prev[1]
        point.offset = prev[0].text.length
      } else if (next) {
        point.path = next[1]
        point.offset = 0
      } else {
        selection = null
      }
    }
  }
}

remove_text


『移除節點內的文字』

因為是操作同一個節點內的文本內容所以很基本,就是取得字串後組合而已。

const { path, offset, text } = op
if (text.length === 0) break
const node = Node.leaf(editor, path)
const before = node.text.slice(0, offset)
const after = node.text.slice(offset + text.length)
node.text = before + after

最後跟 insertNode 一樣對 selection 做一模一樣的操作,程式碼是一樣的我們就直接略過了

set_node


『設定節點屬性』

它會擋掉對 root node 的節點屬性設定、對 childrentext 等主要資料的設定

const { path, properties, newProperties } = op

  if (path.length === 0) {
    throw new Error(`Cannot set properties on the root node!`)
  }

  const node = Node.get(editor, path)

  for (const key in newProperties) {
    if (key === 'children' || key === 'text') {
      throw new Error(`Cannot set the "${key}" property of nodes!`)
    }
		
		// ...
	}

剩下的就是實作邏輯了, 屬性 valuenull ,或是原本有這項屬性但更新後卻沒有的話,會刪掉節點裡的這項屬性,否則會直接賦值到指定的屬性上

for (const key in newProperties) {
	// ...

	const value = newProperties[key]

  if (value == null) {
    delete node[key]
  } else {
    node[key] = value
  }
}

// properties that were previously defined, but are now missing, must be deleted
for (const key in properties) {
  if (!newProperties.hasOwnProperty(key)) {
    delete node[key]
  }
}

set_selection


『設定 selection 屬性』

功能很直觀,裡頭的程式碼主要也都是處理一些 edge cases ,例如: selection 原本為 null 的話,新設定的屬性內容必須符合一個合法的 Range type 該有的 properties ,以及不能將 anchorfocus 的 value 設為 null 等等

case 'set_selection': {
  const { newProperties } = op

  if (newProperties == null) {
    selection = newProperties
  } else {
    if (selection == null) {
      if (!Range.isRange(newProperties)) {
        throw new Error(
          `Cannot apply an incomplete "set_selection" operation properties ${JSON.stringify(
            newProperties
          )} when there is no current selection.`
        )
      }

      selection = { ...newProperties }
    }

    for (const key in newProperties) {
      const value = newProperties[key]

      if (value == null) {
        if (key === 'anchor' || key === 'focus') {
          throw new Error(`Cannot remove the "${key}" selection property`)
        }

        delete selection[key]
      } else {
        selection[key] = value
      }
    }
  }

  break
}

split_node


『拆分節點』

就是一個將節點一分為二的功能,唯一的限制是不能對 Editor 做拆分

const { path, position, properties } = op

  if (path.length === 0) {
    throw new Error(
      `Cannot apply a "split_node" operation at path [${path}] because the root node cannot be split.`
    )
  }

將基本的資料取出來設為變數以後,會接著區分出 Text node 與 Element node 。

前者會對節點內的字串做操作:

const node = Node.get(editor, path)
const parent = Node.parent(editor, path)
const index = path[path.length - 1]
let newNode: Descendant

if (Text.isText(node)) {
  const before = node.text.slice(0, position)
  const after = node.text.slice(position)
  node.text = before
  newNode = {
    ...(properties as Partial<Text>),
    text: after,
  }
}

後者則是對 children node 做操作:

else {
	const before = node.children.slice(0, position)
	const after = node.children.slice(position)
	node.children = before
	
	newNode = {
	  ...(properties as Partial<Element>),
	  children: after,
	}
}

然後將新生成的 newNode 塞進對應的位置:

parent.children.splice(index + 1, 0, newNode)

最後跟 insertNode 一樣對 selection 做一模一樣的操作,程式碼是一樣的我們就直接略過了。


呼~一個接著一個介紹,總算是迎來尾聲了。

雖說這篇介紹的 transform function 是 Slate 主要用於處理 Slate node tree 的資料更新的,但我們同時也能發現其實還是有部分的邏輯是被拆到其他的 function 去處理的。

也就是針對『 Location types 』更新的內容,只要是諸如 selection 的 point 更新,或是 path 更新等等的功能都是交由各自對應到的 type 的 transform method api 來處理( Point.transformPath.transform

下一篇我們就會將目光聚焦在這些 methods 上,來看看在這裡頭又是如何更新 Location type 的內容的。

明天見各位~


上一篇
Day 21. slate × Operation × Entrance
下一篇
Day 23. slate × Operation × L-transform
系列文
深入 slate.js x 一起打造專屬的富文字編輯器吧!30

尚未有邦友留言

立即登入留言