(內容較多,請你們耐心閱讀)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 或者繼續擴展其餘插件。數組
上面的 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>) }複製代碼
而後,就能夠看到渲染效果。固然了,此時仍是一個比較基礎的編輯器,除了能輸入文字,啥功能沒有。
富文本編輯器最多見的文本操做就是:加粗、斜體、下劃線、刪除線。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 。
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 是 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 就是對原生 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 存在的意義,還須要配合理解 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 內部封裝的。
富文本編輯器,內容是複雜的、嵌套的、不可枚舉的。因此,須要有一些規則來保證數據格式的規範,這就是數據校驗。
可能會引起數據格式混亂的狀況有不少,常見的有
slate.js 內置了一些校驗規則,來確保最基本的數據格式
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 富文本編輯器是前端技術的天花板」 ,有些絕對,但也是有必定的道理。