淺析富文字編輯器框架Slate.js

謎原發表於2021-07-08

淺析富文字編輯器框架Slate.js

本文不是關於Slate.js使用入門的文章,如果還不瞭解該框架,建議先閱讀下官方的文件:Slate官網文件

關於Slate的一些特性
  • 不同於其他編輯器類的庫,Slate並不提供譬如粗體、斜體、字型色等開箱即用的功能
  • Slate只是提供了一套自己定義的核心資料模型,以此一些運算元據和選區相關的API
  • 檢視層的渲染和行為完全由開發者基於React定製

從頂層設計上看,Slate的架構是典型的MVC模型,由自身定義資料模型(Model),暴露運算元據的方法(Controller),然後交由使用者使用該資料在React中做渲染(View)

雖然在實現簡單的編輯器應用時這種方式顯得有些繁冗,但在遇到需要對業務做較定製化的功能,如內嵌複雜表單、流程圖等時,就能展現出極大的靈活性。而這類需求在使用其他編輯器的庫時,經常是不可行的或者成本很高(往往要在原始碼層面進行改造)

Slate的資料模型

Slate.js資料模型的設計非常的“類DOM”,對於擁有Web基礎的開發者降低了心智負擔。下面從節點(Node)和選區(Selection)的設計上說明。

type Node = Editor | Element | Text

interface Editor {
    children: Node[]
}

interface Element {
    children: Node[]
    [key: string]: unknown
}

interface Text {
    text: string,
    [key: string]: unknown
}

Node作為最抽象的節點型別,包括以下三種型別:

  • Editor 編輯器的根節點型別

  • Element 具有children屬性,可以作為其他Node的父節點;由傳入的renderElement函式做自定義渲染

  • Text 包含文字資訊;由renderLeaf函式做自定義渲染;在新增mark時,將文字打散成不同的Leaf(這個行為是由Slate執行的,下面的例子會講)

除了介面中定義的屬性,也可以在節點中新增任意業務相關的屬性值(如下面的例子)。

一個基礎的使用示例如下:

const RichText = (props: any) => {
    // 建立Editor
    const editor = createEditor()
    // 初始值
    const [value, setValue] = useState([{
        type: 'paragraph',
        children: [{ text: "" }]
    }])

    // 自定義Element渲染
    const renderElement = (props) => {
        const { attributes, children, element } = props

        switch (element.type) {	// 根據Element中的type屬性判斷節點型別
            case "heading-one":
                return <h1 {...attributes}>{children}</h1>;
            case "heading-two":
                return <h2 {...attributes}>{children}</h2>;
            case "paragraph":
                return <p {...attributes}>{children}</p>;
        }
    }
    // 自定義Leaf渲染
    const renderLeaf = (props) => {
        const { attributes, children, leaf } = props
    
        // 根據Text中的自定義屬性判斷樣式型別
        if (leaf['background-color']) {
            children = <span style={{ backgroundColor: leaf['background-color']}}>
                {children}
            </span>
        }
    
        if (leaf['font-color']) {
            children = <span style={{ color: leaf['font-color']}}>
                {children}
            </span>
        }
    
        return <span {...attributes}>{children}</span>;
    }

    return <Slate editor={editor} value={value} onChange={(value) => setValue(value)}>
        <Editable
            renderElement={renderElement}
            renderLeaf={renderLeaf}/>
    </Slate>
}

假如我們在該編輯器中輸入了兩行文字,並選取一段文字新增顏色樣式:

則上圖的文字內容所對應的資料結構:

[
    {
        type: "paragraph",
        children: [
            {
                text: "著名武術泰斗馬保國。"
            }
        ]
    },
    {
        type: "paragraph",
        children: [
            {
                text: "著名"
            },
            {
                text: "籃球運動員",
                'font-size': "rgba(139, 87, 42, 1)"
            },
            {
                text: "蔡徐坤"
            }
        ]
    }
]

該陣列中最頂層的物件對映了Element元素(由renderElement渲染的),在其中的type欄位中設為paragraph標誌為預設的塊級元素(當然也可以設為其他任何值,Slate.js並不關心type欄位的含義)。下一層的葉子節點物件,包含了text欄位,表示文字內容;也可能含有自定義的一些marks,例如上面的'font-size',用來在renderLeaf中依據marks來實現自定義的樣式渲染。

接下來再選取一段文字賦予一個背景色:

新增背景色的選區是在上面帶有font-size的Text節點中的,因此會被打散成三個Text,變為如下形式:

[
    {
        type: "paragraph",
        children: [
            {
                text: "著名武術泰斗馬保國。"
            }
        ]
    },
    {
        type: "paragraph",
        children: [
            {
                text: "著名"
            },
            {
                text: "籃球",
                'font-size': "rgba(139, 87, 42, 1)"
            },
            {
                text: "運動",
                'font-size': "rgba(139, 87, 42, 1)",
                'background-color': "rgba(80, 227, 194, 1)"
            },
            {
                text: "員",
                'font-size': "rgba(139, 87, 42, 1)"
            },
            {
                text: "蔡徐坤"
            }
        ]
    }
]
游標和選區

游標的定位由一個 Pathoffset 確定,Path 代表節點在文件中的位置,offset 則代表在節點中的偏移量:

declare type Path = number[]

interface Point {
    path: Path,
    offset: number
}

Paht 是一個number型別陣列,包含的數值代表著從文件資料模型的根部到游標所在Text節點的路徑offset 表示游標在Text節點上的偏移量

如圖中框中的節點所對應的Path就是[1, 0]

選取的介面定義 Range 與原生selection的屬性非常相似:

interface Range {
    anchor: Point,
    focus: Point
}

錨點anchor代表選區的起始點,焦點focus代表選區的結束點;兩者都為上述的Point型別。

外掛機制

Slate提供了外掛的機制允許我們覆蓋編輯器原有的行為。除了直接使用slate-react和slate-history這些官方的外掛,也可以自定義外掛來對Slate編輯器進行擴充,而且實現方式非常簡易:提供一個函式,該函式接收一個編輯器的例項editor物件,在其中重寫例項物件上的方法,並返回editor例項。

下面是個例子,加入在實現業務時有這麼一個場景,需要在文字域中插入一些自定義的控制元件如按鈕、下拉框等,並且都不可被編輯;而預設情況下在文字域中所有的dom元素都是contenteditable=true的狀態,是能夠被游標聚焦和編輯的。為了改變這種行為,可以自行實現一個外掛:

import { createEditor } from "slate"
import { withReact } from "slate-react"
import { withHistory } from "slate-history"

const myCustomeEditor = (editor) => {
    const { isVoid } = editor	// editor原有的isVoid方法, 用以判斷節點是否可編輯
    
    editor.isVoid = (element) => {	// 根據自定義的type欄位將元素置為 不可編輯的
        return element.type === 'custome-element' ? true : isVoid(element)
    }
    
    return editor
}

// 建立了一個帶有三個外掛組合的Slate編輯器
const eidotr = myCustomeEditor(withHistory(withReact(createEditor())))

相關文章