slate.js

source code study

Process

  1. WYSIWYG 的粗略介紹
  2. slate.js 的粗略介紹
  3. CustomType
  4. Normalization

WYSIWYG

WYSIWYG :中文名『所見即所得』,為英文 “ What You See Is What You Get ” 取字頭的縮寫,是由『菲利普·威爾遜( Flip Wilson )』所提出的一種電腦文字編輯器方面的技術,也是一種 UI design pattern

Microsoft word

IT 邦幫忙

slate.js

Document Model

const Element = ({ attributes, children, element }) => {
  switch (element.type) {
    case 'block-quote':
      return <blockquote {...attributes}>{children}</blockquote>
    case 'bulleted-list':
      return <ul {...attributes}>{children}</ul>
    case 'heading-one':
      return <h1 {...attributes}>{children}</h1>
    case 'heading-two':
      return <h2 {...attributes}>{children}</h2>
    case 'list-item':
      return <li {...attributes}>{children}</li>
    case 'numbered-list':
      return <ol {...attributes}>{children}</ol>
    default:
      return <p {...attributes}>{children}</p>
  }
}
const Leaf = ({ attributes, children, leaf }) => {
  if (leaf.bold) {
    children = <strong>{children}</strong>
  }

  if (leaf.code) {
    children = <code>{children}</code>
  }

  if (leaf.italic) {
    children = <em>{children}</em>
  }

  if (leaf.underline) {
    children = <u>{children}</u>
  }

  return <span {...attributes}>{children}</span>
}

Singleton Core

import { createEditor } from 'slate';

console.log('editor-->\n', createEditor());

Parallel to DOM

Data type Interface

Defined Interface

/**
 * `Text` objects represent the nodes that contain the actual text content of a
 * Slate document along with any formatting properties. They are always leaf
 * nodes in the document tree as they cannot contain any children.
 */

export interface BaseText {
  text: string
}

export interface TextInterface {
  equals: (text: Text, another: Text, options?: { loose?: boolean }) => boolean
  isText: (value: any) => value is Text
  isTextList: (value: any) => value is Text[]
  isTextProps: (props: any) => props is Partial<Text>
  matches: (text: Text, props: Partial<Text>) => boolean
  decorations: (node: Text, decorations: Range[]) => Text[]
}

Location type Interface

  • node
  • element
  • text
  • path
  • point
  • range

Provided Method api

Customizable

// custom-type.d.ts

export type CustomText = {
  bold?: boolean
  italic?: boolean
  code?: boolean
  text: string
}

export type EmptyText = {
  text: string
}

declare module 'slate' {
  interface CustomTypes {
    Text: CustomText | EmptyText
  }
}

Operation & Transforms

Operations are the granular, low-level actions that occur while invoking Transforms.

// Operation.ts

export type BaseInsertNodeOperation = {
  type: 'insert_node'
  path: Path
  node: Node
}

// transforms/node.ts

/**
* Insert nodes at a specific location in the Editor.
*/
insertNodes: <T extends Node>(
    editor: Editor,
    nodes: Node | Node[],
    options?: {
      at?: Location
      match?: NodeMatch<T>
      mode?: 'highest' | 'lowest'
      hanging?: boolean
      select?: boolean
      voids?: boolean
    }
  ) => void
  • Transforms.insertNodes may invokes multiple insert_node operations
  • slate sees Operations as micro-tasks. It triggers the screen re-render after a whole set of Operations ( a set of FLUSHING ) finished

Monorepo / Layer Separated

Data-Layer

View-Layer

CustomType

 interfaces/custom-types.ts

/**
 * Extendable Custom Types Interface
 */

type ExtendableTypes =
  | 'Editor'
  | 'Element'
  | 'Text'
  | 'Selection'
  | 'Range'
  | 'Point'
  | 'InsertNodeOperation'
  | 'InsertTextOperation'
  | 'MergeNodeOperation'
  | 'MoveNodeOperation'
  | 'RemoveNodeOperation'
  | 'RemoveTextOperation'
  | 'SetNodeOperation'
  | 'SetSelectionOperation'
  | 'SplitNodeOperation'

export interface CustomTypes {
  [key: string]: unknown
}

export type ExtendedType<
  K extends ExtendableTypes,
  B
> = unknown extends CustomTypes[K] ? B : CustomTypes[K]

ExtendableTypes

type ExtendableTypes =
  | 'Editor'
  | 'Element'
  | 'Text'
  | 'Selection'
  | 'Range'
  | 'Point'
  | 'InsertNodeOperation'
  | 'InsertTextOperation'
  | 'MergeNodeOperation'
  | 'MoveNodeOperation'
  | 'RemoveNodeOperation'
  | 'RemoveTextOperation'
  | 'SetNodeOperation'
  | 'SetSelectionOperation'
  | 'SplitNodeOperation'

export type ExtendedType<
  K extends ExtendableTypes,
  B
> = ...

ExtendableTypes 事先為可以進行擴充的 types 做好限制,在 ExtendedType 吃的第一個 generic type 有限制了它的範圍必須縮限在 ExtendableTypes 之中

CustomTypes

CustomTypes 是主要讓開發者定義 custom types 的 interface ,利用 interface 可以 extend 的特性讓開發者可以透過 declare module 等方式擴充。

declare module 'slate' {
  interface CustomTypes {
    Editor: CustomEditor
    Element: CustomElement
    Text: CustomText | EmptyText
  }
}

ExtendedType utility

  • K : 縮限於上方的 ExtendableTypes ,負責比對 CustomTypes 中指定的 key 類型是否被擴充定義。
  • B : 傳入的 base-type ,如果對應到的 key 沒有在 CustomTypes 中被擴充定義的話會原封不動地回傳 B 。
export type ExtendedType<
  K extends ExtendableTypes,
  B
> = unknown extends CustomTypes[K] ? B : CustomTypes[K]

透過小範例來協助介紹

/** text.ts */
export interface BaseText {
  text: string
}

export type Text = ExtendedType<'Text', BaseText>

/** custom-types.ts */
export type ExtendedType<
  K extends ExtendableTypes,
  B
> = unknown extends CustomTypes[K] ? B : CustomTypes[K]
/** text.ts */
export interface BaseText {
  text: string
}

export type Text = ExtendedType<'Text', BaseText>

/** custom-types.ts */
export type ExtendedType<
  K extends ExtendableTypes,
  B
> = unknown extends CustomTypes[K] ? B : CustomTypes[K]
  1. 在我們將 'Text' 與 BaseText 傳入 ExtendedType 以後,它首先會確認 'Text' 是否存在於 ExtendableTypes 裡。
  2. 接著進行 unknown extends CustomTypes[K] 的三元判斷式,這裏運用了 unknown 只會 extends unknown 這項特性,當判斷結果為 true 時代表開發者未在 CustomTypes 裡對 K 做擴充,回傳 base-type 給 Text,反之則回傳 CustomTypes 裡 K 對應到的擴充內容給 Text 。

Normalizing

在關聯式資料庫的世界裡,執行資料正規化的『目的』是為了去除冗餘的資料,我們事先針對各種正規化形式定義出了不同的『 constraints 』,而資料庫的欄位結構則須符合這些『 constraints 』,例如:在第一正規化( 1NF )中,一個欄位只能存入一筆資料,而不能是複數筆,這就是其中一條 constraint 。

slate 為了應對過於彈性的 document model ,它也規範了一套內建的 constraints ,每當觸發了編輯器的資料更新時便會自動執行 Normalization ,確保資料符合 constraints 的規範

  1. 所有的 Element 節點內必須含有至少一個 Text 子節點。在進行正規化時如果遭遇到不符合此規範的 Element 節點,會加入一個空的 Text 節點進入它的子層。

  2. 將兩個相鄰且擁有完全相同的 properties 的 Text nodes 合併成同一個節點(不會刪減文字內容,只是單純做節點合併而已)。

  3. ...

Implement Properties

  • Normalization Deferring (正規化延遲)
Normalize 的執行方式並不是『一個 Operation 搭配一次 Normalize 』,而是『一組完整的 FLUSHING 搭配一次 Normalize 』畢竟一次 Transform 裡有好多次的 Operations ,才剛弄乾淨馬上就被下一個 Operation 弄髒顯然不是我們想要的
  • Multi-pass Normalizing (正規化的 Infinite-loop )

slate 實際執行正規化功能的方式是透過呼叫 Transforms api 達成,因為我們無法保證每次的正規化實際上會觸發執行哪些 Transform 。因此 slate 採用的方式是在每個 Operation 裡對更新的節點進行骯髒標記的演算法,並在骯髒標記的清單完全被清除以後才結束正規化

Normalization Deffering

withoutNormalize

所有會需要 Normalizing 功能的 Transform methods 裡都一定會包進這個 method 裡來實作
insertNodes<T extends Node>(
    editor: Editor,
    // ... args
  ): void {
    Editor.withoutNormalizing(editor, () => {
      // ... Implementation
    })
  },

withoutNormalize

/**
 * Call a function, deferring normalization until after it completes.
 */

withoutNormalizing(editor: Editor, fn: () => void): void {
  const value = Editor.isNormalizing(editor)
  NORMALIZING.set(editor, false)
  try {
    fn()
  } finally {
    NORMALIZING.set(editor, value)
  }
  Editor.normalize(editor)
},
  1. 儲存了 isNormalizing method 回傳的資料作為 NORMALIZING WeakMap 的初始值。
  2. NORMALIZING value 設為 false ,等執行完傳入的 fn 以後再設 NORMALIZING 回先前存下來的初始值
  3. 重新執行 normalize method

isNormalizing

/**
 * Check if the editor is currently normalizing after each operation.
 */

isNormalizing(editor: Editor): boolean {
  const isNormalizing = NORMALIZING.get(editor)
  return isNormalizing === undefined ? true : isNormalizing
},
isNormalizing 其實單純就是回傳當前 editor 的 NORMALIZING value ,代表編輯器當前是否為『完成正規化』的狀態,只是多加了一層三元判斷:如果 value === undefined 則回傳 true (因為 undefined 為初始值,編輯器的初始狀態就是已經完成正規化的狀態了)。
normalize(
  editor: Editor,
  options: {
    force?: boolean
  } = {}
): void {
	// ...

	if (!Editor.isNormalizing(editor)) {
		return
	}

	// ... Implementation
}

normalize

呼叫節點正規化的主要 function

這些操作使得一組 Transform 裡頭除了最初呼叫的那次包進 withoutNormalizing 的 fn method 會推遲執行 normalize method 之外,其餘在過程中呼叫的 normalize 都會因為 isNormalizing 回傳值為 false 因而直接跳過。

Multi-pass Normalizing feat. Dirty-Path

DIRTY_PATHS WeakMap

// weak-map.ts
export const DIRTY_PATHS: WeakMap<Editor, Path[]> = new WeakMap()

Function call when Operation triggered

const set = new Set()
const dirtyPaths: Path[] = []

const add = (path: Path | null) => {
  if (path) {
    const key = path.join(',')

    if (!set.has(key)) {
      set.add(key)
      dirtyPaths.push(path)
    }
  }
}

const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)

for (const path of oldDirtyPaths) {
  const newPath = Path.transform(path, op)
  add(newPath)
}

for (const path of newDirtyPaths) {
  add(path)
}

DIRTY_PATHS.set(editor, dirtyPaths)

在每一次的 Operation 中:

  • oldDirtyPaths 會去取得儲存在 DIRTY_PATHS 裡頭前一次的結果,經過這次的 operation transform 為正確的 path 以後經由 add method 推入 dirtyPaths 變數裡。
  • newDirtyPaths 會透過 getDirtyPaths 取得這次 operation 會製造出的 Dirty-Path 並經由 add method 推入 dirtyPaths 變數裡。
  • add method 會將丟入的 path 與第一行的 set 比對,只推入還不存在於 dirtyPaths 變數裡的 path 以避免重複推入。
  • 最後將 dirtyPaths 存為 DIRTY_PATHS 裡 editor 的 value 。
const set = new Set()
const dirtyPaths: Path[] = []

const add = (path: Path | null) => {
  if (path) {
    const key = path.join(',')

    if (!set.has(key)) {
      set.add(key)
      dirtyPaths.push(path)
    }
  }
}

const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)

for (const path of oldDirtyPaths) {
  const newPath = Path.transform(path, op)
  add(newPath)
}

for (const path of newDirtyPaths) {
  add(path)
}

DIRTY_PATHS.set(editor, dirtyPaths)

Multi-pass Normalizing

normalize(
  editor: Editor,
  options: {
    force?: boolean
  } = {}
): void {
	...
    
	if (getDirtyPaths(editor).length === 0) {
		return
	}

	// Main-Process
	Editor.withoutNormalizing(editor, () => {
		...
	});
},

withoutNormalizing(editor: Editor, fn: () => void): void {
	...
  
	try {
		fn()
	}
	...
  
	Editor.normalize(editor)
},
// Main-Process
Editor.withoutNormalizing(editor, () => {

	// ...

	const max = getDirtyPaths(editor).length * 42 // HACK: better way?
	let m = 0

 	while (getDirtyPaths(editor).length !== 0) {
		if (m > max) {
			throw new Error(`...`)
		}

		const dirtyPath = getDirtyPaths(editor).pop()!

		// If the node doesn't exist in the tree, it does not need to be normalized.
		if (Node.has(editor, dirtyPath)) {
			const entry = Editor.node(editor, dirtyPath)
			editor.normalizeNode(entry)
		}
		m++
	}
});

Reference

Made with Slides.com