iT邦幫忙

2021 iThome 鐵人賽

DAY 23
0
Modern Web

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

Day 23. slate × Operation × L-transform

https://ithelp.ithome.com.tw/upload/images/20211008/20139359SxGCqpgZuq.png

上一篇文章我們深入瞭解了 Operation 的 transform function 是如何實作針對各種不同的 Operation types 的更新功能的,並略過了一切關於 Location type 的更新相關的內容。包含 selection 的主要更新邏輯也一樣,我們只提到了它們是交由各自對應到的 transform method api 來處理,它們分別是:

  • Path.transform
  • Point.transform
  • Range.transform

我們在 Day 18 時就有先剪短提到過這幾個 methods 的功用了,它們同時負責 Location value 的更新以及確保存在於編輯器裡的 Locations 都是 Immutable 的。

基本上整個編輯器就是仰賴前一篇介紹的 transform function 以及今天的這三個 transform method apis 作為資料更新最底層的功能,就像是蓋房子一樣,其他如 Operations 或是最 high-level 的 Transform methods 都只是基於它們再向上搭建另外一層功能而已

https://ithelp.ithome.com.tw/upload/images/20211008/20139359fzycYlnWoY.png

前一篇是介紹 Node types 的更新,相信今天的任務是什麼就不用多贅述了 ?

Path.transform


這個 method 主要需要傳入兩個參數,分別是 pathoperation

  • path :主要傳入接受更新的路徑資料,會因應傳入的 operation 回傳相對應更新過後的 Immutable value 。
  • operation :執行的 Operation 資料,下方的內容會很常取出在 operation.path alias 為 op 使用,之後在這個小節看到 op 代表的就是 operation.path 的 value 。

還有一個 options.affinity ,我們在後續遇到它的使用情境時再介紹它的功用

/**
 * Transform a path by an operation.
 */
transform(
  path: Path,
  operation: Operation,
  options: { affinity?: 'forward' | 'backward' | null } = {}
): Path | null {
	return produce(path, p => {
		// ... Path.transform implementation
	});
}

insert_node


會受到『插入節點』所影響的路徑就只有三種:

  1. 完全相同的路徑
  2. 同層且位於 op 之後的 sibling 的路徑
  3. op 為祖先之路徑

做的操作一樣都是將 path 陣列裡與 op index 同層的值 + 1

case 'insert_node': {
  const { path: op } = operation

  if (
    Path.equals(op, p) ||
    Path.endsBefore(op, p) ||
    Path.isAncestor(op, p)
  ) {
    p[op.length - 1] += 1
  }
  break
}
    1. 容易懂,插入的節點位於同一層又在它的位置以前,所以要將自己的 index + 1。
  1. 就稍嫌 tricky 一點了,注意到它指定 + 1 的陣列 index 是 op 的 index 值,所以如果今天插入的節點路徑是位於 path 上方的祖先時,我們會將與 op index 同層的值 + 1 ,因為是它祖先那一層被異動到

https://ithelp.ithome.com.tw/upload/images/20211008/20139359bF9uSNnCzy.png

remove_node


將與『被刪除的節點』相同,或位於它子層的路徑直接刪除。

case 'remove_node': {
  const { path: op } = operation

  if (Path.equals(op, p) || Path.isAncestor(op, p)) {
    return null
  }

	// ... else if statement

  break
}

如果被刪除的節點路徑位於 path 之前的 sibling 的話則將 index 的值 - 1

else if (Path.endsBefore(op, p)) {
    p[op.length - 1] -= 1
}

https://ithelp.ithome.com.tw/upload/images/20211008/20139359RhkL8L7SEc.png

merge_node


『合併節點』可以分為兩種情形:

  • op 與 path 相等,或為 path 之前的 sibling :

    直接將 path 對應的 index 值 - 1

    case 'merge_node': {
      const { path: op, position } = operation
    
      if (Path.equals(op, p) || Path.endsBefore(op, p)) {
        p[op.length - 1] -= 1
      }
    
    	// ... else if statement
    
      break
    }
    
  • op 為 path 的祖先路徑:

    除了將對應的 index 值 -1 之外,我們還需要子層的 index 值去加上 position ,也就是它前一個 sibling 的 index 值

    else if (Path.isAncestor(op, p)) {
      p[op.length - 1] -= 1
      p[op.length] += position
    }
    

    https://ithelp.ithome.com.tw/upload/images/20211008/20139359ZvaaSI6Dzk.png

split_node


『拆分節點』可以分為三種情形:

  • oppath 相等

    這裡就會受到 options.affinity 所影響了, affinity 在這裡代表的是將原始的 path 指向的節點向後( forward )或是向前( backward )做拆分,如果是向後拆分則將原始路徑的 index + 1 ,向前則不需要做任何操作。

    case 'split_node': {
      const { path: op, position } = operation
    
      if (Path.equals(op, p)) {
        if (affinity === 'forward') {
          p[p.length - 1] += 1
        } else if (affinity === 'backward') {
          // Nothing, because it still refers to the right path.
        } else {
          return null
        }
      }
    
      // ... other statements
    
      break
    }
    

    https://ithelp.ithome.com.tw/upload/images/20211008/20139359S6673FUYRu.png

  • oppath 同層,並位於 path 前面的 sibling

    直接將 path 的 index 值 + 1 。

    else if (Path.endsBefore(op, p)) {
      p[op.length - 1] += 1
    }
    
  • oppath 的祖先,且 op index 的子層對應到的 path index 位於 position 的後方:

    同層的 index 值 + 1 ,同時子層節點扣除掉拆分的 position offset

    else if (Path.isAncestor(op, p) && path[op.length] >= position) {
      p[op.length - 1] += 1
      p[op.length] -= position
    }
    

    https://ithelp.ithome.com.tw/upload/images/20211008/20139359rRa5qKouTx.png

move_node


首先擋掉了 no-op ,也就是不用執行任何操作,新舊路徑皆相等的情形

case 'move_node': {
  const { path: op, newPath: onp } = operation

  // If the old and new path are the same, it's a no-op.
  if (Path.equals(op, onp)) {
    return
  }
	
	// ... If else implementations

  break
}

接著拆分成四種情形去探討:

  • op ( Operation 裡欲移動的舊路徑 )為 p 的祖先或 opp 的路徑相等:

    此時的 p 被包含在 Operation 的搬遷範圍內,我們要將 onp ( Operation 裡欲移動到的新路徑 )補上 op index 之後、 p 所涵蓋到的子層。

    if (Path.isAncestor(op, p) || Path.equals(op, p)) {
      const copy = onp.slice()
    
      // ... if statement for the edge case
    
      return copy.concat(p.slice(op.length))
    }
    

    https://ithelp.ithome.com.tw/upload/images/20211008/20139359fPO0Z42qgz.png

    上圖的結果就是:[0, 2] 會向前調動到 [0, 1] ,而 [0, 1] 的整個 branch 則會一起向後移動一個 sibling 。

    再來這個情形還有一個 edge case 是需要注意的,也就是當 op 位於 onp 之前,同時為 onp 的上層路徑。

    此時我們會需要調整 onpop 層的 index value 將它 -1 ,因為 op 會遭到移除

    if (Path.endsBefore(op, onp) && op.length < onp.length) {
      copy[op.length - 1] -= 1
    }
    

    https://ithelp.ithome.com.tw/upload/images/20211008/20139359ZvC2AwVynq.png

  • oponp 同層,且 onpp 的祖先或 onpp 的路徑相等:

    此時我們需要調整 pop index 層的 value 。如果 op 位於 p 之前,代表移動後 value 會因為該層前面的 sibling 遭到拔除因而需要 -1 ;反之則代表該層會有新的 sibling 被移動到前面因而需要 +1

    else if (
      Path.isSibling(op, onp) &&
      (Path.isAncestor(onp, p) || Path.equals(onp, p))
    ) {
      if (Path.endsBefore(op, p)) {
        p[op.length - 1] -= 1
      } else {
        p[op.length - 1] += 1
      }
    }
    

    https://ithelp.ithome.com.tw/upload/images/20211008/20139359y3Z6joGodj.png

  • onp 位於 p 之前,或 onpp 相等,或 onpp 之祖先:

    代表我們要將某一組路徑搬遷到 p 之前,因此我們需要將 ponp index 層的 value +1

    else if (
      Path.endsBefore(onp, p) ||
      Path.equals(onp, p) ||
      Path.isAncestor(onp, p)
    ) {
      // ... if statement for the edge case
    
      p[onp.length - 1] += 1
    }
    

    https://ithelp.ithome.com.tw/upload/images/20211008/20139359jV4ar3W64n.png

    需要注意的 edge case 為:如果要搬遷的 op 位於 p 之前,代表會有一組路徑從 p 之前的位置受到搬遷,我們需要扣除 pop 層的 index value

    if (Path.endsBefore(op, p)) {
      p[op.length - 1] -= 1
    }
    
  • op 位於 p 之前:

    代表我們要將某一組路徑從 p 之前搬遷走,因此我們需要將 pop index 層的 value -1

    else if (Path.endsBefore(op, p)) {
      if (Path.equals(onp, p)) {
        p[onp.length - 1] += 1
      }
    
      p[op.length - 1] -= 1
    }
    

    https://ithelp.ithome.com.tw/upload/images/20211008/20139359XGzs8O3LoR.png

    這裡 If statement 的內容筆者也不太確定,照理來說 Path.equals(onp, p) 應該已經在前一組 else if 中被篩掉了才對。歡迎在下方留言補充!

Point.transform


主要需要傳入兩個參數,分別是 pointop

  • point :主要傳入接受更新的 Point 資料,會因應傳入的 operation 回傳相對應更新過後的 Immutable value 。
  • op :執行的 Operation 資料 。

還有一個 options.affinity ,我們一樣在後續遇到它的使用情境時再介紹它的功用

/**
 * Transform a point by an operation.
 */
transform(
  point: Point,
  op: Operation,
  options: { affinity?: 'forward' | 'backward' | null } = {}
): Point | null {
	return produce(point, p => {
		const { affinity = 'forward' } = options
        const { path, offset } = p
		// ... Point.transform implementation
	});
}

insert_node & move_node


因為不會異動到 offset 的值所以實作上非常的 simple ,就只是呼叫 Path.transform 把參數都丟進去而已。

case 'insert_node':
case 'move_node': {
  p.path = Path.transform(path, op, options)!
  break
}

insert_text


如果 pathop.path 為同一路徑,且 op.offset 小於等於 offset ,則代表插入的文字位於欲更新的 Point 之前,因此將 p.offset 增加 op.text 的長度。

case 'insert_text': {
  if (Path.equals(op.path, path) && o <= offset) {
    p.offset += op.text.length
  }

  break
}

merge_node


op.pathpath 為同一個路徑時,將 position 加進 offset 裡,再將 path 丟入 Path.transform

case 'merge_node': {
  if (Path.equals(op.path, path)) {
    p.offset += op.position
  }

  p.path = Path.transform(path, op, options)!
  break
}

remove_text


要移除的文字 op.offset 位於 offset 之前,代表刪除的文字位於欲更新的 Point 之前,因此扣除掉兩個 offset 之間的長度,最長扣除到 op.text 的長度

case 'remove_text': {
  if (Path.equals(op.path, path) && op.offset <= offset) {
    p.offset -= Math.min(offset - op.offset, op.text.length)
  }

  break
}

remove_node


如果 op.pathpath 為相同路徑,或為它的祖先,則直接設為 null ,否則則丟入 Path.transform

case 'remove_text': {
  if (Path.equals(op.path, path) && op.offset <= offset) {
    p.offset -= Math.min(offset - op.offset, op.text.length)
  }

  break
}

split_node


我們能先區分成兩種情形: op.pathpath 是否為相同的路徑,如果不同則不會異動到 offset value ,直接將 path 丟入 Path.transform 就好

case 'split_node': {
    if (Path.equals(op.path, path)) {
      // Implementation
    } else {
      p.path = Path.transform(path, op, options)!
    }

    break
  }
}

這裡的 options.affinity 用途與 Path 段落介紹的 split_node 裡的一樣,代表著節點向後( Forward )或向前( Backward )做拆分。

如果是預設的向後拆分則將在其前面被拆走的 offset 扣掉並一樣丟入 Path.transform

if (op.position === offset && affinity == null) {
  return null
} else if (
  op.position < offset ||
  (op.position === offset && affinity === 'forward')
) {
  p.offset -= op.position

  p.path = Path.transform(path, op, {
    ...options,
    affinity: 'forward',
  })!
}
affinity: null 在這邊做的事情跟 Path.transformnull affinity 一樣,將與 Operation 操作相同的 Path 或 Point 節點直接移除,回傳 null 。但筆者不太確定它的使用情境就是了,一樣歡迎下方留言補充!

Range.transform


這裡頭主要都是針對 affinity 參數的控制,最後將 range 的 anchorfocus point 丟入 Point.transform 進行轉換並回傳新的 Immutable Range :

/**
 * Transform a range by an operation.
 */

transform(
  range: Range,
  op: Operation,
  options: {
    affinity?: 'forward' | 'backward' | 'outward' | 'inward' | null
  } = {}
): Range | null {
  // ... affinity statement control

  return produce(range, r => {
    const anchor = Point.transform(r.anchor, op, { affinity: affinityAnchor })
    const focus = Point.transform(r.focus, op, { affinity: affinityFocus })

    if (!anchor || !focus) {
      return null
    }

    r.anchor = anchor
    r.focus = focus
  })
},

這裡的 affinity 除了 'forward''backward' 與前面在 Path 與 Point 提到的用法一樣,是決定節點的拆分( split_node )方向之外,還有 'inward' (向內)以及 'outward' (向外)額外兩種情形。中間的判斷式就是拿來決定 Range 裡的 anchorfocus point 要分別以哪種 affinity 進行 transform 的

const { affinity = 'inward' } = options
let affinityAnchor: 'forward' | 'backward' | null
let affinityFocus: 'forward' | 'backward' | null

if (affinity === 'inward') {
  if (Range.isForward(range)) {
    affinityAnchor = 'forward'
    affinityFocus = 'backward'
  } else {
    affinityAnchor = 'backward'
    affinityFocus = 'forward'
  }
} else if (affinity === 'outward') {
  if (Range.isForward(range)) {
    affinityAnchor = 'backward'
    affinityFocus = 'forward'
  } else {
    affinityAnchor = 'forward'
    affinityFocus = 'backward'
  }
} else {
  affinityAnchor = affinity
  affinityFocus = affinity
}

Operation 章節到這邊總算告一個段落了,這個章節的後兩篇我們非常深入地去探討了 Operation 底層的程式碼是如何運作的,希望這能讓讀者在以 slate 為基礎開發編輯器時能更清楚在每一個操作的背後,編輯器實際上都是如何更新的。

不要像筆者一開始一樣純粹靠對 api 名稱的直覺做開發XD

下一篇開始我們要來探討 slate 是如何做骯髒標記與完成資料正規化的。

Normalization 在 slate 中也是一個非常重要的功能,它也支援開發者加入自定義的 Normalizing constraints 。

除了探討其中的運作方式,我們也會介紹有哪些原始存在的 constraints 、他們的存在意義以及如何自定義 constaints 。

明天新的篇章見囉~


上一篇
Day 22. slate × Operation × transform
下一篇
Day 24. slate × Normalizing
系列文
深入 slate.js x 一起打造專屬的富文字編輯器吧!30

尚未有邦友留言

立即登入留言