基于Draft.js自定義富文本編輯器

寫寫文章總結(jié)一下之前的工作內(nèi)容,看來以后還是要及時寫總結(jié),現(xiàn)在寫好多細節(jié)都想不起來了??。
公司小程序后臺管理頁面,由于業(yè)務(wù)需求需要自定義富文本編輯器用于文章格式的編輯。使用第三方的富文本編輯器改動起來不太靈活,經(jīng)過調(diào)研,決定使用facebook的開源庫Draft.js來自定義一個富文本編輯器。
Draft.js官網(wǎng)如下: https://draftjs.org,它是基于React開發(fā)的,并不是一個開箱即用的編輯器,如果你直接使用,像這樣子:

import React from 'react';
import {Editor, EditorState} from 'draft-js';

class RichEditor extends React.Component {
  constructor(props) {
    super(props);
    this.state = {editorState: EditorState.createEmpty()};
    this.onChange = (editorState) => this.setState({editorState});
  }
  render() {
    return (
        <Editor editorState={this.state.editorState} onChange={this.onChange} />
    );
  }
}

export default RichEditor;

這樣界面只會出現(xiàn)一個可編輯的空白行。Draft.js只提供基礎(chǔ)功能模塊,開發(fā)者需要根據(jù)業(yè)務(wù)需求做進一步的編碼。那么相比其他的富文本編輯器Draft.js有什么優(yōu)勢呢?要回答這個答案就要先了解它使用和存儲富文本的方式。

  1. EditorState與ContentState
    EditorState 是 Draft.js 最重要的一個對象,它是用來存儲富文本編輯器所有內(nèi)容和狀態(tài)的。這個對象作為組件屬性輸入給 Editor 組件,一旦用戶進行操作,比如敲一個回車,Editor 組件的 onChange 事件觸發(fā),onChange 函數(shù)返回一個全新的 EditorState 實例,Editor 接收這個新的輸入,渲染新的內(nèi)容,所以,最簡單的寫法就是前面代碼所示那樣。

EditorState 包括的內(nèi)容大致如下:
(1) 當前文本內(nèi)容狀態(tài)(ContentState)
(2) 當前選中內(nèi)容狀態(tài)(SelectionState)
(3) 所有的內(nèi)容修飾器(Decorator)
(4) 撤銷和重做棧
(5) 最后一次變更操作的類型。
Draft.js 提供 covertToRaw 方法可以將 EditorState 對象轉(zhuǎn)化為 plain JavaScript 對象,從而可以將這些數(shù)據(jù)上傳到后臺,并提供 convertFromRaw 方法將 plain JavaScript 對象轉(zhuǎn)化為 EditorState 對象。那么轉(zhuǎn)化成的 plain JavaScript 對象是保存了什么東西呢?
舉個例子,現(xiàn)在Draft.js編輯器的內(nèi)容如下:


Snip20181116_195.png

那么經(jīng)過 covertToRaw 轉(zhuǎn)換的 plain JavaScript 對象打印如下:


Snip20181116_196.png

可以看到,這個 plain JavaScript 對象包含兩個字段 blocks 和 entityMap,各自保存著一個數(shù)組。其中blocks數(shù)組有7個元素,每個元素都描述著當前內(nèi)容的一個塊級元素,當前內(nèi)容有4行文字,一張圖片,2行空白行(圖片的前后是兩行空白行,這是Draft.js添加圖片,視頻等資源時默認生成的空白行),展開blocks數(shù)組下標為0和1兩個元素如下:
Snip20181116_197.png

text表示該塊級元素中的純文本,type 表示該塊級元素的類型,header-one 表示一級標題、unstyled 表示普通段落、atomic 表示多媒體類的塊級元素,這些類型,可以直接是庫提供的,也可以自定義。庫提供的類型如下:
Snip20181116_186.png

展開blocks數(shù)組下標為2和3的兩個元素如下:
Snip20181116_198.png

會發(fā)現(xiàn)下標為2的元素的data字段是有值的,該字段表示塊級元素的樣式(可以是自定義的樣式),比如我這一行的樣式,就設(shè)置為了字間距為4px,行間距是2,縮進2個字符,對齊樣式為默認(左對齊)。下標為3的元素的inlineStyleRanges字段存儲的數(shù)組有2個元素,描述著該行的行內(nèi)樣式,比如 0: {offset: 0, length: 5, style: "color-rgb(223, 41, 41)"} 表示該塊級元素的文本,從下標為0的文字開始,長度為5的字符串的顏色為color-rgb(223, 41, 41);entityRanges字段存儲的是超鏈接、圖片、視頻等多媒體資源的信息,比如現(xiàn)在“功”這個字添加了超鏈接,那么entityRanges 對應的數(shù)組的第一個元素是0: {offset: 4, length: 1, key: 0},就表示下標為4,長度為1的字符串關(guān)聯(lián)著一個多媒體資源,而這個資源的具體數(shù)據(jù),存儲在entityMap數(shù)組中,這個key就是用來索引到entityMap數(shù)組中的資源的。blocks數(shù)組下標為5的元素描述一張圖片(4和6下標的元素是圖片兩個前后空白行),展開如下:
Snip20181116_200.png

entityRanges展開如下:
Snip20181116_201.png

根據(jù)key就能在entityRanges數(shù)組中找到對應位置的資源。其中,data字段是資源的鏈接等信息,mutability分為"MUTABLE","IMMUTABLE","Segmented",該字段是用來表示對應著 entity 的文本將如何被修改/刪除;"MUTABLE"表示對于的文本在鏈接資源后是可以任意的更改的,"IMMUTABLE"表示對于的文本鏈接資源后不能隨意更改,一旦更改鏈接就將失效。type表示資源的類型,可以為"LINK","IMAGE","AUDIO","VIDEO"。

由此,知道了 Draft.js 是通過json數(shù)據(jù)來存儲富文本數(shù)據(jù)的,和傳統(tǒng)的使用html文本存儲符文文本相比大概有以下幾點好處:
(1)更容易取出富文本里面的信息。比如圖片,如果用html文本存儲,需要寫復雜的正則表達式去匹配圖片的url,寬高,才能取到這些信息。
(2)多端復用。json存儲的數(shù)據(jù),app將更容易解析出來用原生渲染,而html由于寫法的不統(tǒng)一,有時候很難保證渲染細節(jié)的正確性。
(3)更加靈活的使用巴拉巴拉。

  1. 自定義塊樣式,行內(nèi)樣式
    Draft.js 提供了豐富的接口讓開發(fā)者高度定制自己的編輯器,例如像我這樣基于antd組件開發(fā)的編輯器界面如下:


    Snip20181117_202.png

    上面的一排按鈕就是使用antd組件創(chuàng)建的,基本的思路是點擊按鈕或者其他操作的時候創(chuàng)建一個新的editorState,再賦值給Editor組件,就改變了內(nèi)容的狀態(tài)。比如下面的一系列塊類型是系統(tǒng)提供的塊類型:


    Snip20181117_203.png

    我點擊其中一種類型,改變光標所在行的塊類型,代碼片段如下:
// 塊類型
const blockTypes = [
    { label: '普通', style: 'unstyled' },
    { label: 'h1', style: 'header-one' },
    { label: 'h2', style: 'header-two' },
    { label: 'h3', style: 'header-three' },
    { label: 'h4', style: 'header-four' },
    { label: 'h5', style: 'header-five' },
    { label: 'h6', style: 'header-six' },
    { label: '引用', style: 'blockquote' },
    { label: '代碼', style: 'code-block' },
    // { label: 'atomic', style: 'atomic' },這個有問題
    { label: '有序列表', style: 'ordered-list-item' },
    { label: '無序列表', style: 'unordered-list-item' },
]

    // 點擊菜單
    clickMenu = (e) => {
        const newEditState = RichUtils.toggleBlockType(
            this.props.editorState,
            e.key // unstyled header-one header-two ... blockquote code-block ordered-list-item unordered-list-item ...
        )
        this.props.onBlockTypeChange(newEditState)
    }

通過toggleBlockType函數(shù),傳入上一個editorState和系統(tǒng)塊類型的key,返回一個新的editorState。
當光標位置改變時,需要獲取到當前光標所在行的塊類型,改變按鈕的文字,代碼如下:

// 得到當前塊樣式的label
    getCurrentBlockLabel = () => {
        const editorState = this.props.editorState
        const selection = editorState.getSelection()
        const blockStyle = editorState.getCurrentContent().getBlockForKey(selection.getStartKey()).getType()
        let blockLabel = ''
        blockTypes.forEach((blockType) => {
            if (blockType.style === blockStyle) {
                blockLabel = blockType.label
                return
            }
        })
        return blockLabel
    }

使用系統(tǒng)的行內(nèi)樣式,也是差不多的邏輯:

// 行內(nèi)樣式
const inlineTypes = [
    { label: '加粗', style: 'BOLD' },
    { label: '傾斜', style: 'ITALIC' },
    { label: '下劃線', style: 'UNDERLINE' },
    { label: '刪除線', style: 'STRIKETHROUGH' },
]

// 點擊按鈕
    clickBtn = (e, style) => {
        // 阻止點擊按鈕后editor失去了焦點,而且按鈕的事件必須是onMouseDown,onClick調(diào)用該方法editor還是會失去焦點
        e.preventDefault()
        const newEditState = RichUtils.toggleInlineStyle(
            this.props.editorState,
            style
        )
        this.props.onInlineTypeChange(newEditState)
    }

調(diào)用 toggleInlineStyle 函數(shù),需要注意的是在點擊按鈕事件需要使用 onMouseDown ,并且在觸發(fā)的函數(shù)里開頭需要寫 e.preventDefault(),這樣可以阻止按鈕獲取到焦點,光標依然保持選中文本的狀態(tài)。
自定義行內(nèi)樣式,調(diào)用的是 toggleCustomInlineStyle 函數(shù)比如自定義字體大小,文本顏色,代碼如下:

// 點擊菜單
    clickMenu = (e) => {
        
        const newEditState = toggleCustomInlineStyle(
            this.props.editorState,
            'fontSize',
            Number(e.key),
          )
        this.props.onFontSizeChange(newEditState)
    }

// 顏色選擇器選擇的顏色改變,draft.js不支持更改文字透明度
    handleChangeComplete = (color) => {
        const newTextColor = `rgb(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b})`
        this.setState({ textColor: newTextColor})
        const newEditState = toggleCustomInlineStyle(
            this.props.editorState,
            'color',
            newTextColor,
          )
        this.props.onTextColorChange(newEditState)
    }

改變文字的透明度貌似是不支持的,也可能是我姿勢不對??。
自定義塊樣式就稍微復雜點,分為2步:
(1)塊樣式是存儲在上文說過的data字段中的,像這個:


Snip20181117_206.png

那么就是往data塞入你想添加的塊樣式。
(2)根據(jù)data中的塊樣式渲染文本內(nèi)容。需要實現(xiàn) blockStyleFn 函數(shù),如下圖:


Snip20181117_207.png

所以代碼也是分2步走,第一步,構(gòu)建data字段中的數(shù)據(jù),需要注意的是當你添加一個塊樣式的時候,原先的塊樣式會被完全替換,所以需要記錄下之前所有的塊樣式,再在此基礎(chǔ)上添加新的塊樣式,在賦值回去。例如現(xiàn)在添加縮進:
// 點擊縮進按鈕
    onHandleIndentation = (e) => {
        e.preventDefault()

        const { editorState } = this.props
        const selectedBlocksMetadata = getSelectedBlocksMetadata(editorState)
        let newEditorState = null

        if (selectedBlocksMetadata.get('text-indent')) {
            const types = this.getAllBlockType(undefined, selectedBlocksMetadata.get('line-height'), selectedBlocksMetadata.get('letter-spacing'), selectedBlocksMetadata.get('text-align'))
            newEditorState = setBlockData(editorState, types)
        } else {
            const types = this.getAllBlockType('2em', selectedBlocksMetadata.get('line-height'), selectedBlocksMetadata.get('letter-spacing'), selectedBlocksMetadata.get('text-align'))
            newEditorState = setBlockData(editorState, types)
        }

        this.props.onBlockStyleChange(newEditorState)
    }

// 得到總樣式
    getAllBlockType = (textIndent, lineHeight, letterSpacing, textAlign) => {
        return {
            'text-indent': textIndent,
            'line-height': lineHeight,
            'letter-spacing': letterSpacing,
            'text-align': textAlign
        }
    }

接下來實現(xiàn) myBlockStyleFn 函數(shù)。取出data,動態(tài)創(chuàng)建一個css樣式并返回:

// 自定義樣式匹配
    myBlockStyleFn = contentBlock => {
        const type = contentBlock.getType()
        const metaData = contentBlock.getData()

        const textIndent = metaData.get('text-indent')
        const lineHeight = metaData.get('line-height')
        const letterSpacing = metaData.get('letter-spacing')
        const textAlign = metaData.get('text-align')

        if (textIndent || lineHeight || letterSpacing || textAlign) {
            let letterSpacingName = ''
            if (!letterSpacing) {
                letterSpacingName = letterSpacing
            } else {
                letterSpacingName = Math.round(
                    Number(
                        letterSpacing.substring(0, letterSpacing.indexOf('px'))
                    ) * 100
                ).toString()
            }

            const className =
                'custom' +
                textIndent +
                Math.round(lineHeight * 100) +
                letterSpacingName +
                textAlign
            const { dymanicCssList } = this.state
            let classIsExist = false

            for (const dymanicCss of dymanicCssList) {
                if (dymanicCss === className) {
                    classIsExist = true
                    break
                }
            }

            if (!classIsExist) {
                // console.log(className,textIndent,lineHeight,letterSpacing)
                dymanicCssList.push(className)
                this.loadCssCode(`.${className} {
                    text-indent: ${textIndent};
                    line-height: ${lineHeight};
                    letter-spacing: ${letterSpacing};
                    text-align: ${textAlign};
                }`)
            }
            
            return className
        }
    }

// 動態(tài)創(chuàng)建css
    loadCssCode = code => {
        const style = document.createElement('style')
        style.type = 'text/css'
        // style.rel = 'stylesheet';
        // for Chrome Firefox Opera Safari
        style.appendChild(document.createTextNode(code))
        // for IE
        // style.styleSheet.cssText = code;
        const head = document.getElementsByTagName('head')[0]
        head.appendChild(style)
    }

樣式名的創(chuàng)建寫的有些復雜,目的就是防止和別的樣式名重復了,之前還踩過樣式名存在某些特殊字符的時候樣式就無效的坑。。。,新創(chuàng)建的樣式名會放入一個數(shù)組中,下次創(chuàng)建的時候判斷數(shù)組里面有沒有同名的樣式,如果存在就不重復創(chuàng)建了。因為這個 myBlockStyleFn 函數(shù)是會頻繁調(diào)用的,基本上你只要改變富文本的任何一個狀態(tài)(例如光標位置改變,添加一個文字)就會調(diào)用,其他賦值給Editor的函數(shù)也是同理,所以如果你在函數(shù)里的實現(xiàn)比較耗時,就會導致你在編輯器中快速添加文字的時候產(chǎn)生延遲。

3.使用 Entity 對象創(chuàng)建超鏈接
Entity 是 Draft.js 中用于存儲元數(shù)據(jù)的概念,所以可以用來表示超鏈接、圖片、視頻等需要額外數(shù)據(jù)項的多媒體內(nèi)容。該對象有三個屬性:
(1)用于表示該 Entity 類型的 type,比如可以取值為 link、image。
(2)根據(jù) Entity 是否可變,mutability 具有三種取值:IMMUTABLE、MUTABLE 和 SEGMENTED。
(3)用于存儲 Entity 元數(shù)據(jù)的 data 字段,比如對于超鏈接 Entity,應該有一個 href 值。
例如,現(xiàn)在我選中一段文字,點擊添加鏈接按鈕為其添加超鏈接:


image.png

首先需要獲取到選中的文字,然后根據(jù)鏈接創(chuàng)建一個entity對象,再將選中文字和entity對象綁定,再創(chuàng)建新的editorState,代碼如下:

// 得到editorState的title
    getBeginTitle = (editorState) => {
        const selectionState = editorState.getSelection()
        const anchorKey = selectionState.getAnchorKey()
        const currentContent = editorState.getCurrentContent()
        const currentContentBlock = currentContent.getBlockForKey(anchorKey)
        const start = selectionState.getStartOffset()
        const end = selectionState.getEndOffset()
        const title = currentContentBlock.getText().slice(start, end)
        return title
    }

// 點擊確認按鈕
    handleOk = (e) => {
        e.preventDefault()
        
        // 參考wysiwyg
        const { title, editorUrl } = this.state
        const { editorState } = this.props
        const selection = editorState.getSelection()
        const entityKey = editorState
            .getCurrentContent()
            .createEntity('LINK', 'MUTABLE', { url: editorUrl })
            .getLastCreatedEntityKey()
        const contentState = Modifier.replaceText(
            editorState.getCurrentContent(),
            selection,
            `${title}`,
            editorState.getCurrentInlineStyle(),
            entityKey,
        )
        const newEditorState = EditorState.push(editorState, contentState, 'insert-characters')
        this.props.onAddLink(newEditorState)
        this.setState({
            visible: false,
            title: '',
            editorUrl: ''
        })
    }
  1. 自定義塊級元素的渲染
    Draft.js允許開發(fā)者自己實現(xiàn)塊級元素的渲染,只要實現(xiàn) blockRendererFn 函數(shù)。例如現(xiàn)在我要往富文本中加入一張圖片,然后用img標簽,左對齊顯示這張圖片,如圖:


    Snip20181117_210.png
// 點擊確定按鈕
    handleOk = e => {
        e.preventDefault()
        const { editorState } = this.props
        const { url, width, height } = this.state
        const contentState = editorState.getCurrentContent()
        const contentStateWithEntity = contentState.createEntity(
            'IMAGE',
            'IMMUTABLE',
            {
                src: url,
                width,
                height
            }
        )
        const entityKey = contentStateWithEntity.getLastCreatedEntityKey()
        const newEditorState = EditorState.set(editorState, {
            currentContent: contentStateWithEntity
        })

        const newNewEditorState = AtomicBlockUtils.insertAtomicBlock(
            newEditorState,
            entityKey,
            ' '
        )
        this.props.onAddImage(newNewEditorState)
    }

然后在實現(xiàn) blockRendererFn 函數(shù),該函數(shù)接受一個block,判斷block是否為atomic類型,如果是,使用自定義組件渲染:

// image,mp3,mp4的渲染組件匹配
    mediaBlockRenderer = block => {
        if (block.getType() === 'atomic') {
            return {
                component: Media,
                editable: false
            }
        }
        return null
    }

const Audio = (props) => {
    return <audio controls src={props.src} style={{ width: '100%', whiteSpace: 'initial' }} />
}
const Image = (props) => {

    return <div style={{textAlign:'left'}}><img src={props.src} style={{ width: props.width, height: props.height,whiteSpace: 'initial'}} /></div>
}
const Video = (props) => {
    return <video controls src={props.src} style={{ width: '100%', whiteSpace: 'initial' }} />
}
const Media = (props) => {

    const key = props.block.getEntityAt(0)

    if (key) {
        const entity = props.contentState.getEntity(
            key
        );
        const { src } = entity.getData()
        const type = entity.getType()
        let media
        if (type === 'audio') {
            media = <Audio src={src} />
        } else if (type === 'IMAGE') {
            const { width, height } = entity.getData()
            media = <Image src={src} width={width} height={height} />
        } else if (type === 'video') {
            media = <Video src={src} />
        }
        return media
    }
    
    return null
};

需要注意的是,這里需要實現(xiàn) handleKeyCommand 函數(shù),處理鍵盤事件,否則你使用鍵盤的delete 鍵刪除圖片時,只是將圖片的塊級元素刪除掉,entityMap數(shù)組里依然保存著這張圖片的數(shù)據(jù):


Snip20181117_211.png
handleKeyCommand = (command, editorState) => {
        const newState = RichUtils.handleKeyCommand(editorState, command)
        if (newState) {
            this.onEditorStateChange(newState)
            return true
        }
        return false
    }
  1. 自定義行內(nèi)元素的渲染
    Draft.js使用裝飾器 Decorator 來渲染行內(nèi)元素,比如對于上面的超鏈接元素,則需要如下的代碼將其渲染成一個 Link 組件:
/ 自定義組件,用于超鏈接
const Link = (props) => {
    // 這里通過contentState來獲取entity?,之后通過getData獲取entity中包含的數(shù)據(jù)
    const { url } = props.contentState.getEntity(props.entityKey).getData();
    return (
        <a href={url}>
            {props.children}
        </a>
    )
}

// decorator,用于超鏈接
const decorator = new CompositeDecorator([
    {
        strategy (contentBlock, callback, contentState) {

            // 這個方法接收2個函數(shù)作為參數(shù),如果第一個參數(shù)的函數(shù)執(zhí)行時?返回true,就會執(zhí)行第二個參數(shù)函數(shù),同時會?將匹配的?字符的起始位置和結(jié)束位置傳遞給第二個參數(shù)。
            contentBlock.findEntityRanges(
                (character) => {
                    const entityKey = character.getEntity();
                    return (
                        entityKey !== null &&
                        contentState.getEntity(entityKey).getType() === 'LINK'
                    );
                }, (...arr) => {
                    callback(...arr)
                }
            );
        },
        component: Link
    }
]);

然后在初始化 editorState 的時候傳入 decorator:

state = {
        editorState: EditorState.createEmpty(decorator)
    }
  1. editorState plainObject html字符串的相互轉(zhuǎn)化
    有時候使用Draft.js生成的富文本可能需要轉(zhuǎn)化為html字符串,官方只提供editorState與plainObject的相互轉(zhuǎn)化,不提供editorState與html的相互轉(zhuǎn)化。不過已經(jīng)有人將plainObject轉(zhuǎn)html這一層寫好了,github鏈接:https://github.com/jpuri/draftjs-to-html。也能將html轉(zhuǎn)化為editorState。github鏈接:https://github.com/jpuri/html-to-draftjs。這兩個工具都是同一個作者,是為作者寫的富文本編輯器服務(wù)的:https://github.com/jpuri/react-draft-wysiwyg。實際測試的時候發(fā)現(xiàn),如果是你自定義的樣式很有可能使用上面兩個工具在html和editorState相互轉(zhuǎn)化會失敗。現(xiàn)在我的解決方案是將 plainObject 轉(zhuǎn)化成 json 字符串,利用 draftjs-to-html 將 plainObject 轉(zhuǎn) html 字符串,將兩種字符串都傳遞給后臺,這樣使用Draft.js 編輯的富文本可以轉(zhuǎn)化為 html 顯示,而使用Draft.js編輯時也能取到j(luò)son字符串轉(zhuǎn)化為editorState顯示。

  2. 自定義的富文本編輯器github鏈接:https://github.com/linzhesheng/YdjRichEditor。

  3. 參考文章:
    Draft.js文檔
    使用 Draft.js 來構(gòu)建一個現(xiàn)代化的編輯器
    draft.js在知乎的實踐

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Draft.js是Facebook開源的開發(fā)React富文本編輯器開發(fā)框架。和其它富文本編輯器不同,draft.j...
    MarxJiao閱讀 14,951評論 0 13
  • 1、通過CocoaPods安裝項目名稱項目信息 AFNetworking網(wǎng)絡(luò)請求組件 FMDB本地數(shù)據(jù)庫組件 SD...
    陽明AI閱讀 16,172評論 3 119
  • 小學和初中,總是讓寫記敘文,閱讀題也讓分析記敘文用什么手法表達什么情感之類的。近日,人民日報微博批評小學生認為家長...
    冷無常閱讀 786評論 3 7
  • 我想我作 是因為我覺得你給我的安全感不夠 我慌了 所以我總想找點事情證明你愛我 我不知道你為什么總在上班總在代課 ...
    鸗鸑閱讀 319評論 0 0
  • 百日練,一百天看一百本書,第80天,《黃金時代》30分鐘,理解70%,2800字/分鐘 講到劉老先生為了吃烤鴨而死...
    騎了蝸牛闖世界閱讀 240評論 0 0

友情鏈接更多精彩內(nèi)容