【長文】Web 富文本編輯器框架 slate.js - 從基本使用到核心概念

(內容較多,請你們耐心閱讀)css

介紹

slate.js 提供了 Web 富文本編輯器的底層能力,並非開箱即用的,須要本身二次開發許多內容。前端

也正是這個特色,使得它的擴展性特別好,許多想要定製開發編輯器的,都會選擇基於 slate.js 進行二次開發。node

slate.js 能知足全世界用戶進行定製開發、擴展功能,說明它自己底層能力強大且完善,能將編輯器的經常使用 API 高度抽象。這也正是我要使用它、解讀它、學習它的部分。react

能夠直接看一些 demo 和源碼,從這裏能夠體會到,使用 slate.js 須要大量的二次開發工做。git

PS:從實現原理上,slate.js 是 L1 級編輯器(具體可參考語雀編輯器的 ppt 分享)。若是僅僅是使用者,則不用關心這個。github


基本使用

slate.js 是基於 React 的渲染機制,用於其餘框架須要本身二次開發。算法

最簡單的編輯器

npm 安裝 slate slate-react ,編寫以下代碼,便可生成一個簡單的編輯器。npm

import React, { useState, useMemo } from 'react'import { createEditor } from 'slate'import { Slate, Editable, withReact } from 'slate-react'function BasicEditor() {// Create a Slate editor object that won't change across renders.// editor 即該編輯器的對象實例const editor = useMemo(() => withReact(createEditor()) ,[])// 初始化 value ,即編輯器的內容。其數據格式相似於 vnode ,下文會詳細結實。const initialValue = [
        {type: 'paragraph',children: [ { text: '我是一行文字' } ]
        }
    ]const [value, setValue] = useState(initialValue)return (<div style={{ border: '1px solid #ccc', padding: '10px' }}><Slateeditor={editor}onChange={newValue => setValue(newValue)}
            ><Editable/></Slate></div>)
}複製代碼

但這個編輯器什麼都沒有,你能夠輸入文字,而後經過 onchange 能夠獲取內容。api

PS:以上代碼中的 editor 變量比較重要,它是編輯器的對象實例,可使用它的 API 或者繼續擴展其餘插件。數組

renderElement

上面的 demo ,輸入幾行文字,看一下 DOM 結構,會發現每一行都是 div 展現的。

但文字內容最好使用 p 標籤來展現。語義化標準一些,這樣也好擴展其餘類型,例如 ul ol table quote image 等。

slate.js 提供了 renderElement 讓咱們來自定義渲染邏輯,不過先彆着急。富文本編輯器嘛,確定不只僅只有文字,還有不少數據類型,這些都是須要渲染的,因此都要依賴於這個 renderElement 。

例如,須要渲染文本和代碼塊。此時的 initialValue 也應該包含代碼塊的數據。代碼以下,內有註釋。

import React, { useState, useMemo, useCallback } from 'react'import { createEditor } from 'slate'import { Slate, Editable, withReact } from 'slate-react'// 第一,定義兩個基礎組件,分別用來渲染文本和代碼塊// 默認文本段落function DefaultElement(props) {return <p {...props.attributes}>{props.children}</p>}// 渲染代碼塊function CodeElement(props) {return <pre {...props.attributes}><code>{props.children}</code></pre>}function BasicEditor() {// Create a Slate editor object that won't change across renders.const editor = useMemo(() => withReact(createEditor()), [])// 初始化 valueconst initialValue = [
        {type: 'paragraph',children: [{ text: '我是一行文字' }]
        },// 第二,數據中包含代碼塊{type: 'code',children: [{ text: 'hello world' }]
        }
    ]const [value, setValue] = useState(initialValue)// 第三,定義一個函數,用來判斷如何渲染// Define a rendering function based on the element passed to `props`. We use// `useCallback` here to memoize the function for subsequent renders.const renderElement = useCallback(props => {switch(props.element.type) {case 'code':return <CodeElement {...props}/>default:return <DefaultElement {...props}/>}
    }, [])return (<div style={{ border: '1px solid #ccc', padding: '10px' }}><Slateeditor={editor}value={value}onChange={newValue => setValue(newValue)}
            ><Editable renderElement={renderElement}/> {/* 第四,使用 renderElement */}</Slate></div>)
}複製代碼

而後,就能夠看到渲染效果。固然了,此時仍是一個比較基礎的編輯器,除了能輸入文字,啥功能沒有。

renderLeaf

富文本編輯器最多見的文本操做就是:加粗、斜體、下劃線、刪除線。slate.js 如何實現這些呢?咱們先無論怎麼是操做,先看看如何去渲染。

上文的 renderElement 是渲染 Element ,可是它關不了更底層的 Text (Editor ELement Text 的關係,後面會詳細結實,此處先實踐起來),因此 slate.js 提供了 renderLeaf ,用來控制文本格式。

import React, { useState, useMemo, useCallback } from 'react'import { createEditor } from 'slate'import { Slate, Editable, withReact } from 'slate-react'// 第一,定義一個組件,用於渲染文本樣式function Leaf(props) {return (<span{...props.attributes}style={{fontWeight: props.leaf.bold ? 'bold' : 'normal',textDecoration: props.leaf.underline ? 'underline': null/* 其餘樣式可繼續補充…… */
            }}
        >{props.children}</span>)
}function BasicEditor() {// Create a Slate editor object that won't change across renders.const editor = useMemo(() => withReact(createEditor()), [])// 初始化 valueconst initialValue = [
        {type: 'paragraph',// 第二,這裏存儲文本的樣式children: [ { text: '我是' }, { text: '一行', bold: true }, { text: '文本', underline: true } ]
        }
    ]const [value, setValue] = useState(initialValue)const renderLeaf = useCallback(props => {return <Leaf {...props}/>}, [])return (<div style={{ border: '1px solid #ccc', padding: '10px' }}><Slateeditor={editor}value={value}onChange={newValue => setValue(newValue)}
            ><Editable renderLeaf={renderLeaf}/> {/* 第三,使用 reader */}</Slate></div>)
}複製代碼

PS:renderElement 和 renderLeaf 並不衝突,能夠一塊兒用,並且通常要一塊兒用。這裏爲了演示簡潔,沒有用 renderElement 。

富文本操做

【預警】這一部分比較麻煩,涉及到 slate.js 的不少 API 。本文只能演示一兩個,剩下的本身去看文檔和 demo 。

上述只介紹瞭如何渲染,還未介紹如何設置樣式。以加粗和代碼塊爲例,自定義一個命令集合,代碼以下。
這其中會設計到 Editor 和 Transforms 的一些 API ,其實光看名字,就能猜出什麼意思。

import { Transforms, Text, Editor } from 'slate'// Define our own custom set of helpers.const CustomCommand = {// 當前光標的文字,是否加粗?isBoldMarkActive(editor) {const [ match ] = Editor.nodes(editor, {match: n => n.bold === true,universal: true})return !!match
    },// 當前光標的文字,是不是代碼塊?isCodeBlockActive(editor) {const [ match ] = Editor.nodes(editor, {match: n => n.type === 'code'})return !!match
    },// 設置/取消 加粗toggleBoldMark(editor) {const isActive = CustomCommand.isBoldMarkActive(editor)
        Transforms.setNodes(
            editor,
            { bold: isActive ? null : true },
            {match: n => Text.isText(n),split: true}
        )
    },// 設置/取消 代碼塊toggleCodeBlock(editor) {const isActive = CustomCommand.isCodeBlockActive(editor)
        Transforms.setNodes(
            editor,
            { type: isActive ? null : 'code' },
            { match: n => Editor.isBlock(editor, n) }
        )
    }
}export default CustomCommand複製代碼

而後本身寫一個菜單欄,定義加粗和代碼塊的按鈕。

return (<div>
  <div style={{ background: '#f1f1f1', padding: '3px 5px' }}>  <button  onMouseDown={event => {
              event.preventDefault()
              CustomCommand.toggleBoldMark(editor)
          }}
      >B</button>  <button  onMouseDown={event => {
              event.preventDefault()
              CustomCommand.toggleCodeBlock(editor)
          }}
      >code</button>
  </div>
  <Slate editor={editor} value={value} onChange={changeHandler}>  <Editable  renderElement={renderElement}  renderLeaf={renderLeaf}  />
  </Slate></div>)複製代碼

折騰了這麼半天,就只能實現一個很是簡單的 demo ,確實很急人。但它就是這樣的,沒辦法。

自定義事件監聽

比較簡單,直接在 <Editable> 組件監聽 DOM 事件便可。一些快捷鍵,能夠經過這種方式來設置。

    <Slate editor={editor} value={value} onChange={value => setValue(value)}>      <EditableonKeyDown={event => {
          // `ctrl + b` 加粗的快捷鍵
          if (!event.ctrlKey) return
          if (event.key === 'b') {
            event.preventDefault()
            CustomCommand.toggleBoldMark(editor)
          }
        }}
      /></Slate>複製代碼

插入圖片

富文本編輯器,最基本的就是圖文編輯,圖片是最基本的內容。但圖片確實和文本徹底不同的東西。

文本是可編輯、可選中、可輸入的、很是靈活的編輯方式,而圖片咱們不指望它能夠像文本同樣靈活,最好能按照咱們既定的方式來操做。

不只僅是圖片,還有代碼塊(雖然上面也是看成文字處理的,但現實場景不是這樣)、表格、視頻等。這些咱們通常都稱之爲「卡片」。若是將編輯器比做海洋,那麼文本就是海水,卡片就是一個一個的小島。水是靈活的,而小島是封閉的,不和水摻和在一塊兒。

插入圖片能夠直接參考 demo ,能夠看出,圖片插入以後是不可像文本同樣編輯的。從 源碼 中能夠看出,渲染圖片時設置了 contentEditable={false}

const ImageElement = ({ attributes, children, element }) => {  const selected = useSelected()  const focused = useFocused()  return (<div {...attributes}>  <div contentEditable={false}><img  src={element.url}  className={css`display: block;max-width: 100%;max-height: 20em;box-shadow: ${selected && focused ? '0 0 0 3px #B4D5FF' : 'none'};
          `}
        />  </div>  {children}</div>
  )
}複製代碼

slate.js 還專門作了一個 embeds demo ,它是拿視頻做爲例子來作的,比圖片的 demo 更加複雜一點。

插件

slate.js 提供的是編輯器的基本能力,若是不能知足使用,它提供了插件機制供用戶去自行擴展。
另外,有了規範的插件機制,還能夠造成本身的社區,能夠直接下載使用第三方插件。
咱們研發的開源富文本編輯器 wangEditor 也會盡快擴展插件機制,擴展更多功能。

開發插件

插件開發其實很簡單,就是對 editor 的擴展和裝飾。你想要作什麼,能夠充分返回本身的想象力。slate.js 提供的 demo 也都是用插件實現的,功能很強大。

const withImages = editor => {  const { isVoid } = editor
  editor.isVoid = element => {return element.type === 'image' ? true : isVoid(element)
  }  return editor
}複製代碼

單個插件使用很是方便,代碼以下。但若是插件多了,想一塊兒疊加使用,那就有點難看了,如 a(b(c(d(e(editor)))))

import { createEditor } from 'slate'const editor = withImages(createEditor())複製代碼

可用的第三方插件

能夠 github 或者搜索引擎上搜一下 「slate plugins」 能夠獲得一些結果。例如

核心概念

數據模型

回顧一下上面代碼中的初始化數據。

const initialValue = [
    {type: 'paragraph',children: [{ text: '我是', bold: true }, { text: '一行', underline: true }, {text: '文字'}]
    },
    {type: 'code',children: [{ text: 'hello world' }]
    },
    {    	type: 'image',children: [],url: 'xxx.png'}// 其餘的繼續擴展]複製代碼

數據類型和關係

slate.js 的數據模型模擬 DOM 的構造函數,結構很是簡單,也很好理解。

整個編輯區域是 Editor 實例,下面就是一個單層的 Element 實例列表。Element 裏的子元素是 Text 實例列表。Element 能夠經過上述的 renderElement 自定義渲染,Text 能夠經過上述的 renderLeaf 渲染樣式。

PS:Element 也不是必須單層的,Element 實例下,還能夠繼續擴展 Element 。

block 和 inline

Element 默認都是 block 的,Text 是 inline 的。不過,有些時候 Element 須要是 inline 的,例如文本連接。

參考 demo 源碼 能看到,能夠經過擴展插件,將 link 變爲 inline Element。

const withLinks = editor => {  const { isInline } = editor

  editor.isInline = element => {return element.type === 'link' ? true : isInline(element)
  }  
  // 其餘代碼省略了……}複製代碼

而後,渲染的時候直接輸出 <a> 標籤。

const renderElement = ({ attributes, children, element }) => {  switch (element.type) {case 'link':      return (<a {...attributes} href={element.url}>  {children}</a>  )default:      return <p {...attributes}>{children}</p>
  }
}複製代碼

此處你可能會有一個疑問:渲染是 <a> 標籤原本就是 inline 的,這是瀏覽器的默認渲染邏輯,那爲什麼還要重寫 editor.isInline 方法呢?
答案其實很簡單,slate.js 是 model view 分離的,<a> 在瀏覽器默認是 inline 這是 view 的,而後還要將其同步到 model ,因此要修改 editor.isInline 。

Selection 和 Range

SelectionRange 是 L1 級 Web 富文本編輯器的核心 API 。用於找到選區範圍,都包含哪些 DOM 節點,開始在哪裏,結束在哪裏。

slate.js 封裝了原生 API ,提供了本身的 API ,供用戶二次開發使用。

slate.js 在原生 API 的基礎上進行了更細節的拆分,分紅了 Path Point Range Selection ,文檔在這裏。 它們層層依賴,又簡潔易懂,設計的很是的巧妙合理。

Path 是一個數組,用於在組件樹中找到某個具體的節點。例以下圖的樹中,找到紅色的節點,就能夠表示爲 [0, 1, 0]

Point 在 Path 的基礎上,進一步肯定選區對應的 offset 偏移量,即具體選中了哪一個文本。offset 在原生 API 也有,概念是同樣的。

const start = {  path: [0, 0],  offset: 0,
}const end = {  path: [0, 0],  offset: 15,
}複製代碼

Range 即表示某一段範圍,它和選區還不同,僅僅表示一段範圍,無它功能意義。既然是範圍,用兩個 Point 表示便可,一個開始,一個結束。

interface Range {  anchor: Point  focus: Point
}複製代碼

最後,Selection 其實就是一個 Range 對象,用 Range 表示選區,代碼以下。

原生 API 中 Selection 可包含多個 Range ,而 slate.js 不支持,僅是一對一的關係。
其實大部分狀況下 Selection 和 Range 一對一沒問題,除了特殊場景,例如 vscode 中使用 ctrl+d 多選。

const editor = {  selection: {anchor: { path: [0, 0], offset: 0 },focus: { path: [0, 0], offset: 15 },
  },  // 其餘屬性……}複製代碼

slate.js 做爲一個富文本編輯器的底層能力提供者,Selection 和 Range 很是重要的 API ,它也提供了詳細的 API 文檔 供用戶參考。

commands 和 operations

  • commands high-level 可擴展,內部使用 Transforms API 實現
  • operations low-level 原子 不可擴展
  • 一個 command 可包含多個 operation

commands

commands 就是對原生 execCommand API 的重寫。由於原生 API 在 MDN 中已宣佈過期,並且這個 API 確實不太友好,具體能夠看一篇老博客《Why ContentEditable is Terrible》。

commands 即對富文本操做的命令,例如插入文本、刪除文本等。slate.js 內置了一些經常使用 command ,可參考 Editor API

Editor.insertText(editor, 'A new string of text to be inserted.')
Editor.deleteBackward(editor, { unit: 'word' })
Editor.insertBreak(editor)複製代碼

slate.js 很是推薦用戶本身分裝 command ,其實就是對 Editor 擴展本身的 helper API ,內部可使用強大的 Transforms API 來實現。

const MyEditor = {
  ...Editor,  insertParagraph(editor) {// ...
  },
}複製代碼

operations

要理解 Operations 存在的意義,還須要配合理解 OT 算法,以實現多人協同編輯。

執行 command 會生成 operations ,而後經過 editor.apply(operation) 來生效。

operation 是不可擴展的,就這三種類型,並且是原子的。有了 operation 可方便支持撤銷、多人協同編輯。

editor.apply({  type: 'insert_text',  path: [0, 0],  offset: 15,  text: 'A new string of text to be inserted.',
})

editor.apply({  type: 'remove_node',  path: [0, 0],  node: {text: 'A line of text!',
  },
})

editor.apply({  type: 'set_selection',  properties: {anchor: { path: [0, 0], offset: 0 },
  },  newProperties: {anchor: { path: [0, 0], offset: 15 },
  },
})複製代碼

operation 的 type 和 Quill 編輯器 Delt 基本一致,都符合 OT 算法的基本類型。但這裏的 operation 更適合富文本樹結構。

PS:若是你不考慮多人協同編輯,那這部分不用關心,都是 slate.js 內部封裝的。

Normalizing 數據校驗

富文本編輯器,內容是複雜的、嵌套的、不可枚舉的。因此,須要有一些規則來保證數據格式的規範,這就是數據校驗。

可能會引起數據格式混亂的狀況有不少,常見的有

  • 複雜的、重複、連續的的文本格式操做,例如加粗、斜體、設置顏色、設置連接、換行等……會讓數據格式變的很是複雜
  • 粘貼。從各類網頁拷貝、從 word 拷貝、從 excel 拷貝、從微信 qq 拷貝…… 即,粘貼的數據來源沒法肯定,因此粘貼過來的數據也就沒法保證格式統一,混亂是很正常的。

slate.js 內置了一些校驗規則,來確保最基本的數據格式

  • 每一個 Element 必須包含至少一個子孫 Text 節點。即,若是一個 Element 是空的,則默認給一個空 Text 子節點。
  • 兩個連續的 Text 若是有相同屬性,則合併爲一個 Text 。
  • block 節點只能包含另外一個 block 節點,或者 inline 節點和 Text 。即一個 block 節點不能同時包含一個 block 節點外加一個 inline 節點。
  • Inline nodes cannot be the first or last child of a parent block, nor can it be next to another inline node in the children array. If this is the case, an empty text node will be added to correct this to be in complience with the constraint.(筆者:這一個沒看明白,就直接貼過來,就不翻譯了)
  • 最頂級的 Element 只能是 block 類型

slate.js 也容許用戶自定義校驗規則,例如寫一個插件,來校驗:paragraph 的自元素只能是 Text 或者 inline Element 。

import { Transforms, Element, Node } from 'slate'const withParagraphs = editor => {  const { normalizeNode } = editor

  editor.normalizeNode = entry => {const [node, path] = entry// If the element is a paragraph, ensure its children are valid.if (Element.isElement(node) && node.type === 'paragraph') {      for (const [child, childPath] of Node.children(editor, path)) {if (Element.isElement(child) && !editor.isInline(child)) {
          Transforms.unwrapNodes(editor, { at: childPath })          return}
      }
    }// Fall back to the original `normalizeNode` to enforce other constraints.normalizeNode(entry)
  }  return editor
}複製代碼
總結

到此應該能體會到,slate.js 是一個功能強大的富文本編輯器框架,須要大量的二次開發。

關於 Web 富文本編輯器和 slate.js 的內容還有不少。包括本文提到未深刻的,如歷史記錄、協同編輯;也包括未提到的,如內部實現過程、不可變數據等。有廣度和有深度。之後我還會寫文章分享。

曾經有人戲稱 「Web 富文本編輯器是前端技術的天花板」 ,有些絕對,但也是有必定的道理。

相關文章
相關標籤/搜索