⤴Top⤴

富文本原理

博客分类: 前端

富文本原理

富文本原理

富文本

contenteditable

contenteditable 是一个枚举属性,表示元素是否可被用户编辑。如果可以,浏览器会修改元素的部件以允许编辑:

<blockquote contenteditable="true">
  <p>Edit this content to add your own quote</p>
  <!-- 子元素不想被编辑,添加 false 即可 -->
  <p contentEditable="false">不能编辑我</p>
</blockquote>

<cite contenteditable="true">-- Write your own name here</cite>

Edit this content to add your own quote

不能编辑我

-- Write your own name here

document.execCommand

document.execCommand 允许运行命令来操纵可编辑内容区域的元素,说人话,当使用 contentEditable 时,调用 execCommand() 将影响当前活动的可编辑元素。语法如下:

/**
 * 返回一个 Boolean ,如果是 false 则表示操作不被支持或未被启用
 *
 * @param {*} aCommandName - 一个 DOMString ,命令的名称
 * @param {*} aShowDefaultUI - 一个 Boolean, 是否展示用户界面,一般为 false
 * @param {*} aValueArgument - 一些命令(如 insertImage)需要额外的参数(insertImage 需要提供插入 image 的 url),默认为 null。
 */
bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

针对命令名称,我们可以看下默认常用的有哪些,需要注意的是不同浏览器表现可能有所不同,使用的话要考虑到兼容性:

// 加粗
document.execCommand('bold', false, null)
// 添加一个块标签包裹
document.execCommand('formatBlock', false, '<blockquote>')

浏览器会对 contenteditable 生成的可编辑区维护一个 undo 栈和 redo 栈,使我们能够执行前进和后退的操作

Selection / Range

当我们执行 execCommand 的时候,我们需要指明对哪些选区进行操作,即 Selection,它表示用户选择的文本范围或插入符号的当前位置。文本选区由用户拖拽鼠标经过文字而产生,要获取用于检查或修改的 Selection 对象,请调用 window.getSelection(),它包含了以下术语:

Selection 对象所对应的是用户所选择的 ranges(区域),俗称拖蓝。默认情况下,该函数只针对一个区域,我们可以这样使用这个函数:

const selObj: Selection = window.getSelection()
const range: Range = selObj.getRangeAt(0) // 获取我们选中的区间

Selection 拥有以下常用的属性:

并且有以下常用方法:

Range 对象也包含了很多属性和方法,具体可以查阅这里 👈

我们直接用一些例子来对比下选区和相应的属性值:

rich-editor-selection

需要注意的是 2 步骤中,如果有一段文本有设置命令,则它会被分割成多个片段

setSelectionRange

上述是我们主动去选择一块儿区域,我们也可以通过 setSelectionRange 来创建一片选区:

/**
 * 每次调用这个这个方法会更新 HTMLInputElement 的 selectionStart, selectionEnd,和 selectionDirection 属性
 *
 * @param {*} selectionStart - 被选中的第一个字符的位置
 * @param {*} selectionEnd - 被选中的最后一个字符的 下一个 位置
 * @param {*} selectionDirection - 一个指明选择方向的字符串,有"forward","backward"和"none" 3个可选值
 */
inputElement.setSelectionRange(selectionStart, selectionEnd, [optional] selectionDirection);

注意,setSelectionRange 只能在一个被 focused 的 <input> 元素中选中特定范围的内容,否则无法选中

Tate & Snow Copied

如果不是 input 框呢,我们还可以通过其他方式来实现”隔空拷贝”。可以看是否能选中 Tate & Snow Copied,不行的话试试点击下上面的 “Copy text” 按钮 😄:

const execCopyText: (node: HTMLElement) => void = node => {
  let canUserSelect = true
  const selection = window.getSelection() as Selection
  const range = document.createRange() // 返回一个 Range 对象

  // 不让选也要选
  // 如果是 safari 浏览器,则需要判断 getComputedStyle(node)['-webkit-user-select'],其他同理做兼容性处理
  if (getComputedStyle(node).userSelect === 'none') {
    canUserSelect = false
    node.style.userSelect = 'text'
  }

  // 设置 Range 使其包含一个 Node 的内容
  range.selectNodeContents(node)

  selection.removeAllRanges()
  selection.addRange(range)
  document.execCommand('copy')

  if (!canUserSelect) {
    node.style.userSelect = 'none'
  }
}

简单的富文本实现

我们了解了关于富文本的一些技术实现后,很容易就可以手动撸一个了:

不妨来试一试 😜
不妨来试一试 😜

但是我们一定要关注 focus 的状态,只有聚焦在编辑区才能执行相应的命令,因此我们可以定义一些方法来保存和恢复选区的 Range 对象:

let currentRange = null // 当前选区
const saveSelection = () => { // 保存当前 Range 对象
  const selection = window.getSelection()
  if (selection.rangeCount > 0) {
    return selection.getRangeAt(0)
  }
  return null
}
const restoreSelection = () => {
  const selection = window.getSelection()
  if (currentRange) {
    selection.removeAllRanges()  // 清空所有 Range 对象
    selection.addRange(currentRange) // 恢复保存的 Range
  }
}

Draft.js

Draft.js 是 facebook 推出的用于 React 的富文本编辑器框架,是通过 Immutable.js 来保存数据的。一个炒鸡简单的 demo 如下:

Draft.js

import React from 'react'
import ReactDOM from 'react-dom'
import { Editor, EditorState } from 'Draft.js'

function MyEditor() {
  const [editorState, setEditorState] = React.useState(
    EditorState.createEmpty()
  )

  const editor = React.useRef(null)

  function focusEditor() {
    editor.current.focus()
  }

  React.useEffect(() => {
    focusEditor()
  }, [])

  return (
    <div onClick={focusEditor}>
      <Editor
        ref={editor}
        editorState={editorState}
        onChange={editorState => setEditorState(editorState)}
      />
    </div>
  )
}

EditorState

EditorState 是编辑器最顶层的状态对象,它是一个 Immutable Record 对象,保存了编辑器中全部的状态信息,包括文本状态、选中状态等:

EditorState

当操作 EditorState 对象时,我们不应该直接通过 Immutable 的 API 来获取想要的值,而是通过它暴露出来的静态方法和实例方法:

// 创建空的 EditorState 实例
const createEmptyEditorState: (p?: any) => EditorState = decorator =>
  EditorState.createEmpty(decorator)

const createEditorState: (state: ContentState, p?: any) => EditorState = (contentState, decorator) =>
  EditorState.createWithContent(contentState, decorator)

// 基于 raw 来创建 EditorState 对象
const emptyEditorState = isEmptyObject(raw) ? createEmptyEditorState() : createEditorState(convertFromRaw(raw))

ContentState

ContentState 是用来保存编辑器里的全部内容和渲染前后的两个选中状态,常用的方法有:

我们可以通过 EditorState.getCurrentContent() 来获取当前的 ContentState,使用 toObject() 转换为普通 javascript 对象后,可以看到它主要包含了 BlockMapEntityMap 有序映射,我们可以分别通过上面提到的 getBlockMapgetEntityMap 方法来获取他们:

# blockMap: OrderedMap {size: 6, _map: Map, _list: List, __ownerID: undefined, __hash: undefined}
# entityMap: {0: "1"}
# selectionAfter: SelectionState {_map: Map, __ownerID: undefined}
# selectionBefore: SelectionState {_map: Map, __ownerID: undefined}

那么他们到底是什么呢?通过 convertToRaw(currentContent) 转换后看看其中的内容,这里只有 blocks 和 entityMap 这两项:

# blocks: Array(6)
  0: {key: "dpibs", text: "123", type: "unstyled", depth: "0", inlineStyleRanges: Array(5), …}
  1: {key: "8evo3", text: "", type: "unstyled", depth: "0", inlineStyleRanges: Array(0), …}
  2: {key: "9p4vp", text: "", type: "unstyled", depth: "0", inlineStyleRanges: Array(0), …}
  3: {key: "eigub", text: "tate", type: "unstyled", depth: "0", inlineStyleRanges: Array(2), …}
  4: {key: "607oo", text: "", type: "unstyled", depth: "0", inlineStyleRanges: Array(0), …}
  5:
    data: {}
    depth: "0"
    entityRanges: Array(1)
      0: {offset: 0, length: 4, key: 0}
    inlineStyleRanges: Array(2)
      0: {offset: 0, length: 4, style: "FONTFAMILY-Helvetica"}
      1: {offset: 0, length: 4, style: "LINEHEIGHT-1.5"}
    key: "ff87"
    text: "link"
    type: "unstyled"
# entityMap:
  0:
    data: {url: "www.baidu.com"}
    mutability: "MUTABLE"
    type: "LINK"

可以看到 blocks 这个数组中依次存放了各个 block 的信息,每一个 block 都是一个 ContentBlock 对象。而 entityMap 则包含了所有实体。

ContentBlock

ContentBlock 表示编辑器内容中每一个 block 的完整状态,类似于段落这种块级元素,主要包含了以下几方面:

通过在这些 characterList 列表和 CharacterMetadata 对象上大量使用不可变和数据持久化特性,使得在编辑器中编辑内容占用内存很小。当我们创建一个包含 text 但不包含 characterList 的 ContentBlock 对象时,会默认为提供的文本添加一个带有空 CharacterMetadata 对象的 characterList。

CharacterMetadata

CharacterMetadata 表示一个包含单一字符行内样式和实体信息的对象。CharacterMetadata 对象被即时的汇总和共享。如果两个字符拥有相同的行内样式和实体,它们会被表示为相同的 CharacterMetadata 对象。因此,我们只需要尽可能多的组合带有实体 key 的内联样式集合,以达到即便内容的大小和复杂性增加,我们的内存占用也很小的目的。为此,你需要用过提供的静态方法来为 CharacterMetadata 对象创建或应用变更,这能确保最大限度的复用:

比如我要做个选中后清除选区所有内联样式的功能,那我可能会针对所选的 block 中 characterList 进行遍历,清除每一个 CharacterMetadata 对象包含的内联样式信息,那么首先我们要筛选出选中的 block 萌:

const getSelectedBlocks: (state: EditorState) => ContentBlock[] = editorState => {
  const selection = editorState.getSelection()
  const contentState = editorState.getCurrentContent()

  const startKey = selection.getStartKey() // 包含选区起始位置的 block 的 key
  const endKey = selection.getEndKey() // 包含选区结束位置的 block 的 key
  const isSameBlock = startKey === endKey
  const startingBlock = contentState.getBlockForKey(startKey)
  const selectedBlocks = [startingBlock]

  if (!isSameBlock) { // 如果是好多 block
    let blockKey = startKey

    while (blockKey !== endKey) {
      const nextBlock = contentState.getBlockAfter(blockKey)
      selectedBlocks.push(nextBlock)
      blockKey = nextBlock.getKey()
    }
  }

  return selectedBlocks
}

然后我们需要更新 characterList 列表,把空的样式覆盖进去并更新当前的 EditorState:

// 更新 characterList 列表
const updateEachCharacterOfSelection: (state: EditorState, f: any) => any = (editorState, callback) => {
  const selection = editorState.getSelection()
  const contentState = editorState.getCurrentContent()
  const contentBlocks = contentState.getBlockMap()
  const selectedBlocks = getSelectedBlocks(editorState)

  if (selectedBlocks.length === 0) {
    return editorState
  }

  const startKey = selection.getStartKey()
  const startOffset = selection.getStartOffset()
  const endKey = selection.getEndKey()
  const endOffset = selection.getEndOffset()

  const nextContentBlocks = contentBlocks.map((block?: ContentBlock | undefined) => {
    const curBlock = block as ContentBlock
    if (!selectedBlocks.includes(curBlock)) {
      return block
    }

    const blockKey = curBlock.getKey()
    const charactersList = curBlock.getCharacterList()
    let nextCharactersList = null

    if (blockKey === startKey && blockKey === endKey) {
      nextCharactersList = charactersList.map((character, index) => {
        const i = index as number
        if (i >= startOffset && i < endOffset) {
          return callback(character)
        }
        return character
      })
    } else if (blockKey === startKey) {
      nextCharactersList = charactersList.map((character, index) => {
        const i = index as number
        if (i >= startOffset) {
          return callback(character)
        }
        return character
      })
    } else if (blockKey === endKey) {
      nextCharactersList = charactersList.map((character, index) => {
        const i = index as number
        if (i < endOffset) {
          return callback(character)
        }
        return character
      })
    } else {
      nextCharactersList = charactersList.map(character => {
        return callback(character)
      })
    }

    return curBlock.merge({
      characterList: nextCharactersList,
    })
  })

  return EditorState.push(editorState, (contentState as any).merge({
    blockMap: nextContentBlocks,
    selectionBefore: selection,
    selectionAfter: selection,
  }), 'remove-range')
}

// 清除内联样式
const removeSelectionInlineStyles: (state: EditorState) => EditorState = editorState =>
  updateEachCharacterOfSelection(editorState, (characterMetadata: CharacterMetadata) =>
    (characterMetadata as any).merge({
      style: Immutable.OrderedSet([]),
    }),
  )

Entity

文本中会有许多高级的元数据,Entity 实体就是用于表示这些元数据,使得我们可以在内容中添加链接,图片等,链接,提及和嵌入式内容都可以使用实体来实现。在上面 ContentBlock 里有介绍到,它有三个属性:

使用 decorator 装饰器或自定义块组件,可以根据实体元数据向编辑器添加丰富的渲染样式

Entity 本身的 create 等方法已经被弃用,直接使用上述 ContentState 对象的 createEntity 代替,其他同理。我们尝试创建和检索实体:

  1. 通过 contentState.createEntity 来创建实体。
  2. 他接受上面 3 个属性作为参数。
  3. 此方法返回一个 ContentState 记录。
  4. 通过 contentState.getLastCreatedEntityKey 来获取创建的实体记录的 key
const contentState = editorState.getCurrentContent()
const contentStateWithEntity = contentState.createEntity(
  'LINK',
  'MUTABLE',
  { url: 'http://www.baidu.com' }
)
const entityKey = contentStateWithEntity.getLastCreatedEntityKey()
const currentContent = Modifier.applyEntity(
  contentStateWithEntity,
  selectionState,
  entityKey
)
const newEditorState = EditorState.push(editorState, { currentContent })

对于给定的文本范围,则可以通过在 ContentBlock 对象上使用 getEntityAt() 方法来提取其关联的实体 key,从而传递目标偏移值:

const getEntityKey: (state: EditorState, k?: SelectionState) => string = (editorState, selection) => {
  const selectionState = selection || editorState.getSelection()
  const startKey = selectionState.getStartKey()
  const startOffset = selectionState.getStartOffset()
  return editorState
    .getCurrentContent()
    .getBlockForKey(startKey)
    .getEntityAt(startOffset)
}

Decorator

内联和块级样式并不是我们想要添加到编辑器的唯一富文本样式。例如,Facebook 评论输入给提及和标签提供了蓝色背景高亮。为了支持自定义富文本的灵活性,Draft 提供了一个“修饰器”系统,即 Decorator,我们可以看作是用来添加高级富内容的一种方法。

我们可以使用 CompositeDecorator 类定义所需的修饰器行为。该类允许您提供多个 DraftDecorator 对象,并根据策略依次搜索每一组文本。修饰器存储在 EditorState 记录中。当创建一个新的 EditorState 对象(例如:EditorState.createEmpty())的时候,可以选择提供修饰器参数。当然我们也可以动态区更新这些修饰器:

// 获取 entity type
const getEntityType: (state: ContentState, k: string) => DraftEntityType = (contentState, entityKey) =>
  contentState
    .getEntity(entityKey)
    .getType()

const getEntityData: (state: ContentState, k: string) => any = (contentState, entityKey) =>
  contentState
    .getEntity(entityKey)
    .getData()

// 设置新的修饰器
const turnOffHandleDecorations: (state: EditorState, k: object) => EditorState = (editorState, decorator) =>
  EditorState.set(editorState, { decorator })

function findLinkEntities(contentBlock: ContentBlock, callback: (start: number, end: number) => void, contentState: ContentState) {
  contentBlock.findEntityRanges(
    (character: CharacterMetadata) => {
      const entityKey = character.getEntity()
      return (
        entityKey !== null && getEntityType(contentState, entityKey) === LINK_ENTITY
      )
    },
    callback,
  )
}

const LinkComponent = (props: ILinkProps) => {
  const { contentState, children, entityKey } = props
  const { url } = getEntityData(contentState, entityKey) // 获取实体内容
  return (
    <a target='_blank' onClick={e => previewLink(e, url)} href={url}>
      {children}
    </a>
  )
}

// 定义一些修饰器
const decorator = new CompositeDecorator([
  {
    strategy: findLinkEntities, // 指定策略
    component: LinkComponent, // 指定 React 组件去渲染它们
  },
])

function RichEditor(props: IProps) {
  //...
  const [editorState, setEditorState] = useState<EditorState>(turnOffHandleDecorations(emptyEditorState, decorator))
}

RichUtils

RichUtils 是为实现富文本编辑器准备的一组实用的静态函数集合。在使用中,这些方法接收带有相关参数的 EditorState 对象,并且返回 EditorState 对象,我们常用的如下:

// 切换内联样式,比如 BOLD、ITALIC、UNDERLINE 等
const toggleInlineStyle = (inlineStyle: string) => {
  setEditorState(
    RichUtils.toggleInlineStyle(
      editorState,
      inlineStyle,
    ),
  )
}

对于我们自定义的内联样式,切记如果用户的选区是折叠的,则应用或移除内部状态的样式:

const toggleSelectionInlineStyle: (state: EditorState, p: string, k: string) => EditorState = (editorState, style, prefix = '') => {
  let nextEditorState = editorState
  if (prefix) {
    nextEditorState = updateEachCharacterOfSelection(nextEditorState, (characterMetadata: CharacterMetadata) => {
      // tslint:disable-next-line:max-line-length
      return (characterMetadata as any).toJS().style.reduce((c: CharacterMetadata, characterStyle: string): CharacterMetadata => {
        if (characterStyle.startsWith(prefix) && style !== characterStyle) {
          return CharacterMetadata.removeStyle(c, characterStyle)
        } else {
          return c
        }
      }, characterMetadata)
    })
  }
  return RichUtils.toggleInlineStyle(nextEditorState, style)
}

RichUtils 还提供有关 Web 编辑器可用的核心键盘命令的信息,如 Cmd + B(粗体),Cmd + I(斜体)等。我们可以通过 handleKeyCommand 属性来观察和处理键盘命令,并将它们传入 RichUtils 中来应用或删除所需的样式:

const handleKeyCommand: (p: DraftEditorCommand, k: EditorState) => DraftHandleValue = (command, state) => {
  const newState = RichUtils.handleKeyCommand(state, command)
  if (newState) {
    setEditorState(newState)
    return 'handled'
  }
  return 'not-handled'
}

// 返回的 Editor 组件
<Editor
  handleKeyCommand={handleKeyCommand}
  onChange={state => setEditorState(state)}
/>

Modifier

Modifier 模块是一组实用的静态函数,主要封装 ContentState 对象上的各种常用编辑操作。任何情况下,这些方法都接收具有相关参数的 ContentState 对象,并返回一个新的 ContentState 对象。如果实际并未发生任何编辑行为,将原样返回输入的 ContentState 对象。具体方法可以查看文档,比如 removeInlineStyle 方法可以从整个选中范围中移除指定的内联样式:

// deprecated 可以用上述的 toggleSelectionInlineStyle 优化
const setNextEditorState: (
  state: EditorState, selection: SelectionState, p: IStyleObject, k: EditorChangeType,
) => EditorState = (editorState, selection, reduceStyle, changeType) => {
  // 清除之前的样式
  const nextContentState = Object.keys(reduceStyle).reduce(
    (state, font) => Modifier.removeInlineStyle(
      state, selection, font,
    ), getCurrentContent(editorState),
  )

  // 由 nextContentState 产生新的 editorState
  return EditorState.push(
    editorState,
    nextContentState,
    changeType,
  )
}

数据转换

因为富文本编辑器不可能凭空存在,因此对内容进行保存和传输非常重要,你可能希望将 ContentState 对象转换为原生 JS,或者反过来将原生 JS 转换为 ContentState 对象。目前提供了三个方法:

const sampleMarkup =
  '<b>Bold text</b>, <i>Italic text</i><br/ ><br />' +
  '<a href="http://www.facebook.com">Example link</a>'

const blocksFromHTML = convertFromHTML(sampleMarkup)
const state = ContentState.createFromBlockArray(
  blocksFromHTML.contentBlocks,
  blocksFromHTML.entityMap
)

setEditorState(EditorState.createWithContent(state))

emmmm… 是的你没看错,它并没有转换成 html 的方法,因此我们常借助于一些其他库,比如 draft-js-export-html,下面会介绍到详细用法

Draft.js 实战

默认样式

Draft 里面有默认的块级和内联样式,块级比如有 “blockquote”、”code-block”、”ordered-list-item” 等;内联样式比如有 “bold”、”italic”、”underline” 等,我们看其中一个例子:

const BLOCK_TYPES: IRichEditorControls[] = [
  { label: '引用', style: 'blockquote', icon: FormatQuote },
  { label: '无序列表', style: 'unordered-list-item', icon: FormatListBulleted },
  { label: '有序列表', style: 'ordered-list-item', icon: FormatListNumbered },
  { label: '代码块', style: 'code-block', icon: Code },
]

// 获取 block type
const getBlockType: (state: EditorState) => DraftBlockType = editorState =>
  editorState
    .getCurrentContent()
    .getBlockForKey(editorState.getSelection().getStartKey())
    .getType()

interface IProps {
  editorState: EditorState
  onToggle: (p: DraftBlockType) => void
  children?: React.ReactNode
}

function BlockStyleControls(props: IProps) {
  const { editorState, onToggle, children } = props
  const blockType = getBlockType(editorState)

  return (
    <div className='RichEditor-controls'>
      {children}
      {BLOCK_TYPES.map(type => (
        <StyleButton
          key={type.label}
          active={type.style === blockType}
          icon={type.icon}
          label={type.label}
          onToggle={onToggle}
          style={type.style}
        />
      ))}
    </div>
  )
}

export default BlockStyleControls

这里从父组件传入的 onToggle 其实就是上述自定义的 toggleInlineStyle 方法,是不是很简单,而且我们可以直接实现 redo/undo 操作:

const undo: (state: EditorState) => EditorState = editorState =>
  EditorState.undo(editorState)

const redo: (state: EditorState) => EditorState = editorState =>
  EditorState.redo(editorState)

// 只要传过来一个标示即可
const toggleUndoOrRedo = (doStyle: string) => {
  if (doStyle === 'undo') {
    setEditorState(undo(editorState))
  } else {
    setEditorState(redo(editorState))
  }
}

当然我们也可以去修改默认的配置,就要用到下面讲到的 Editor 对象去操作

自定义样式

Editor

在自定义样式前,我们有必要先了解下核心受控的 contentEditable 组件,即 Editor 自身的 API 和 props:

块级样式

这一节主要讲 blockRenderMap 的应用,以“居中、居左”这种功能项为例,我们直接在默认样式里的配置上加入这几种自定义样式:

const BLOCK_TYPES: IRichEditorControls[] = [
  // ...
  { label: '居左', style: ALIGN_KEYS.left, icon: FormatAlignLeft },
  { label: '居中', style: ALIGN_KEYS.center, icon: FormatAlignCenter },
  { label: '居右', style: ALIGN_KEYS.right, icon: FormatAlignRight },
]

然后我们新建一个 Align.ts 文件来定义我们需要用来渲染的组件和样式:

// Align.ts
function Align(props: IProps) {
  const { type, children } = props
  return (
    <div className={'align-' + type}>
      {/* here, children contains a <section> container, as that was the matching element */}
      {children}
    </div>
  )
}

export const ALIGN_KEYS = {
  center: 'align-center',
  left: 'align-left',
  right: 'align-right',
}

// https://draftjs.org/docs/advanced-topics-custom-block-render-map
const blockRenderMap = Immutable.Map({
  [ALIGN_KEYS.center]: {
    wrapper: <Align type='center' />,
  },
  [ALIGN_KEYS.left]: {
    wrapper: <Align type='left' />,
  },
  [ALIGN_KEYS.right]: {
    wrapper: <Align type='right' />,
  },
})

export default blockRenderMap

我们导出了一个 blockRenderMap 对象,我们需要编辑器去应用我们所配置的样式,因此这里需要用到 Editor 对象的 blockRenderMap 属性进行配置:

import { DefaultDraftBlockRenderMap } from 'draft-js'

// 与默认的块级样式合并
const extendedBlockRenderMap = DefaultDraftBlockRenderMap.merge(blockRenderMapAlign)

function RichEditor(props: IProps) {
  // ...
  return {
    // ...
    <Editor
      ref={editor}
      blockRenderMap={extendedBlockRenderMap}
      editorState={editorState}
      onChange={state => setEditorState(state)}
      placeholder='写点什么呢...'
      spellCheck={true}
    />
  }
}

内联样式

这一节主要讲 customStyleMapcustomStyleFn 的应用,以设置字体为例,我们不妨先针对这个控件创建新的文件:

// FontFamilyControls
const FONTFAMILY_TYPES = [
  { label: '默认', style: 'FONTFAMILY-inherit' },
  { label: 'Arial', style: 'FONTFAMILY-Arial' },
  { label: 'Artnext', style: 'FONTFAMILY-Artnext' },
  { label: 'Avenir', style: 'FONTFAMILY-Avenir' },
  { label: 'Banghdad', style: 'FONTFAMILY-Banghdad' },
  { label: 'Helvetica', style: 'FONTFAMILY-Helvetica' },
  { label: 'Muli', style: 'FONTFAMILY-Muli' },
  { label: 'SF ui test', style: 'FONTFAMILY-SF ui test' },
  { label: 'PingFangSC', style: 'FONTFAMILY-PingFangSC' },
  { label: '思源黑体', style: 'FONTFAMILY-Souce Han Sans CN' },
]

const defaultValue = 'FONTFAMILY-inherit'

interface IProps {
  editorState: EditorState
  saveCurrentSelection: () => void // 保存选区
  onToggle(p: string, k: string): void
}

function FontFamilyControls(props: IProps) {
  const { onToggle, saveCurrentSelection, editorState } = props
  const classes = useStyle()
  const [currentValue, setCurrentValue] = useState<string>(defaultValue)
  const inputLabel = useRef<HTMLLabelElement>(null)
  const [labelWidth, setLabelWidth] = useState(0)

  useEffect(() => {
    setLabelWidth(inputLabel.current!.offsetWidth)
  }, [])

  const getFontFamilyFromStyle: (p: string) => string = style => style.replace(fontFamilyKey, '')

  const onChoose = (e: React.ChangeEvent<{ value: unknown }>) => {
    const { target: { value = '' } = {} } = e
    saveCurrentSelection()
    onToggle(value as string, fontFamilyKey)
  }

  const fontFamilyStyle: (p: string) => React.CSSProperties = style => ({
    fontFamily: getFontFamilyFromStyle(style),
  })

  return (
    <FormControl variant='outlined' className={classes.formControl}>
      <InputLabel ref={inputLabel} htmlFor='select-outlined-label'>字体</InputLabel>
      <Select
        value={currentValue}
        onChange={onChoose}
        MenuProps={HigherMenuProps}
        input={<OutlinedInput labelWidth={labelWidth} name='style' id='select-outlined-label' />}
      >
        {FONTFAMILY_TYPES.map(({ style, label }: IFontSize) => (
          <MenuItem key={label} style={fontFamilyStyle(style)} value={style}>{label}</MenuItem>
        ))}
      </Select>
    </FormControl>
  )
}

我们可以看到,最终实现的也是 toggle 方法,让我们看看父组件里面怎么实现吧:

// 切换字体大小,将此方法传入子组件,属性为 toggle
const changeFont: (p: string, k: string) => void = (inlineStyle, key) => {
  // 储存选择过的颜色,是为了在去除掉以前的样式中用
  let newStyleMap = {}
  const value = getStyleValue(inlineStyle) // 根据内联样式获取对应的 css 样式值

  // 假设这里对字体、字体大小和行高等样式进行处理
  if (key === fontSizeKey) {
    newStyleMap = {
      [inlineStyle]: {
        fontSize: `${value}px`,
      },
    }
  } else if (key === fontFamilyKey) {
    newStyleMap = {
      [inlineStyle]: {
        fontFamily: value,
      },
    }
  } else if (key === lineHeightKey) {
    newStyleMap = {
      [inlineStyle]: {
        lineHeight: value,
      },
    }
  }

  // 更新自定义颜色
  setCustomStyleMap({ ...customStyleMap, ...newStyleMap })
  // 清除旧样式并更新新样式
  setEditorState(toggleSelectionInlineStyle(editorState, inlineStyle, key))
  clearCurrentSelection()
}

我们可以看到,我们使用了钩子 setCustomStyleMap 来设置了自定义的内联样式,那么同样我们还是需要 Editor 的 customStyleMap 配置来应用这些样式:

<Editor
  ref={editor}
  blockRenderMap={extendedBlockRenderMap}
  blockStyleFn={getBlockStyle}
  customStyleMap={customStyleMap}
  customStyleFn={customStyleFn}
  editorState={editorState}
  onChange={state => setEditorState(state)}
  placeholder='写点什么呢...'
  spellCheck={true}
/>

但是有一点需要注意的是,我们新增的自定义样式都是临时的,是根据不同样式类型自动生成的,当我们关闭编辑器保存内容后重新打开时,这些样式如果没有被保存的话,将无法按照它来渲染,因为我们需要 customStyleFn 来定义渲染规则:

// 设置样式 key
const styleKeys = {
  fontSizeKey: 'FONTSIZE-',
  fontFamilyKey: 'FONTFAMILY-',
  fontColorKey: 'COLOR-',
  fontBgColorKey: 'BGCOLOR-',
  lineHeightKey: 'LINEHEIGHT-',
}

const removeKey: (p: string) => string = key => key.replace('Key', '')

const getFirstElement = (styles: DraftInlineStyle) => {
  const styleElement: IInlineStyleElement = {}
  Object.keys(styleKeys).forEach((key: string) => {
    // styles is immutable
    (styleElement as any)[removeKey(key)] = styles.filter((value: any) => value.startsWith((styleKeys as any)[key])).first()
  })
  return styleElement
}

const customStyleFn: (p: DraftInlineStyle) => any = style => {
  const output: any = {}
  // styles immutable
  const { getFirstElement, getStyleValue } = stateToHtmlOptions
  const {
    fontColor, fontBgColor, fontSize, fontFamily, lineHeight,
  } = getFirstElement(style)

  if (fontColor) { output.color = getStyleValue(fontColor) }

  if (fontBgColor) { output.backgroundColor = getStyleValue(fontBgColor) }

  if (fontSize) { output.fontSize = `${getStyleValue(fontSize)}px` }

  if (fontFamily) { output.fontFamily = getStyleValue(fontFamily) }

  if (lineHeight) { output.lineHeight = getStyleValue(lineHeight) }

  return output
}

ok 完事,至于其他超链接的实现这里就不展示了,上面再讲 entity 的时候,代码已经贴的差不多了。剩下的就是如何导出我们写好的富文本了。

导出 html

当我们导出 html 标签的时候,我们又需要对不同样式类型进行解析和转换,这里推荐一个库 draft-js-export-html,使用方法很简单:

import {stateToHTML} from 'draft-js-export-html'

const html = stateToHTML(contentState, options)

它支持众多的 options 可选项,具体可以查阅文档,我们这里直接贴例子:

const options = {
  defaultBlockTag: 'div',
  inlineStyleFn: (styles: DraftInlineStyle) => {
    // styles immutable
    const {
      fontSize, fontColor, fontBgColor, fontFamily, lineHeight,
    } = getFirstElement(styles)

    let style = {}

    if (fontSize) {
      style = { ...style, 'font-size': getStyleValue(fontSize) }
    }

    if (fontFamily) {
      style = { ...style, 'font-family': getStyleValue(fontFamily) }
    }

    if (fontColor) {
      style = { ...style, color: getStyleValue(fontColor) }
    }

    if (fontBgColor) {
      style = { ...style, 'background-color': getStyleValue(fontBgColor) }
    }

    if (lineHeight) {
      // FIXME: 为了防止自动加上 px,所以暂时采用 !important
      style = { ...style, 'line-height': `${getStyleValue(lineHeight)} !important` }
    }

    if (!isEmptyObject(style)) {
      return {
        element: 'span',
        style,
      }
    }

    return styles
  },
  blockStyleFn: (block: ContentBlock) => {
    let output
    switch (block.getType()) {
      case ALIGN_KEYS.center:
        output = getAlignStyle('center')
        break
      case ALIGN_KEYS.left:
        output = getAlignStyle('left')
        break
      case ALIGN_KEYS.right:
        output = getAlignStyle('right')
        break
      default:
        break
    }

    return output
  },
  entityStyleFn: (entity: EntityInstance) => {
    if (entity.getType() === LINK_ENTITY) {
      const { url: href } = entity.getData()
      return {
        element: 'a',
        attributes: {
          href,
          target: '_blank',
        },
        // style: {
        //   // Put styles here...
        // },
      }
    }
  },
}

这样转换之后,我们就可以得到我们心爱的 html 文本啦,赶紧拿去前台渲染吧 😁

基于 draft.js 开发的也有好多好用的插件和成熟的富文本工具,比如 braft-editor 等,其他关于富文本的库也有很多,比如 squire 等。

参考链接

  1. 富文本原理了解一下? By 尤水就下
  2. 从插入图片功能的实现来介绍如何用 Draft.js 编写富文本编辑器 By 吴锴
  3. Draft.js 中文翻译文档