乐趣区

从插入图片功能的实现来介绍如何用-Draftjs-编写富文本编辑器

在前段时间的工作中,我遇到了一个在桌面端和移动端进行图文混排编辑的需求。虽然如果只需要编辑纯文本和图片,不一定要使用富文本编辑器来实现。但是为了以后方便扩展,比如文本会有样式要求,我还是用 Draft.js 实现了一个功能较基础的富文本编辑器。

我将代码开源在了这个项目 draft-editor 中,也可以在这里在线预览。本文中我将介绍一下一些关于 Draft.js 的基础知识,并由此扩展到如何在 Draft.js 编辑器中插入图片功能的实现。

从一个基本的编辑器开始

Draft.js 是 Facebook 推出的用于 React 的富文本编辑器框架,初始化一个最基本的 Draft.js 的代码如下:

import React from‘react’;
import ReactDOM from‘react-dom’;
import {Editor, EditorState} from‘draft-js’;

class MyEditor 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} />
    );
  }
}

可以在这里查看。这里给 Editor 传入一个 editorState 属性,并绑定一个 onChange 事件,当发生编辑操作时,返回一个新的 editorState。这样我们就得到了一个可以进行基本的文本操作的编辑器了。

Immutable.js 数据结构

在说明什么是 EditorState 及 Draft.js 对于数据的存储方式之前,需要简略介绍一下 Immutables.js

Draft.js 是利用 Immutable.js 来保存数据的,正如其名,这是一种不可变的数据结构。对于一个 Immutable 的对象,你无法修改它本身,若想修改其值,只会返回一个新的修改后的对象。将这一点应用在编辑器上,用户的每一次修改都会生成一个最新的状态快照,就很容易实现撤销功能了。

在 Draft.js 的使用过程中,可能会遇到下面这些数据结构。

Map 类似于 js 中的对象,用 .set().get() 方法进行写和读。

const Immutable = require(‘immutable’);
const framework = Immutable.Map({name: 'React', age: 6});
const newFramework = client.set('name', 'Vue');
console.log(framework.get('name'));

OrderedMap 混合了 objectarray 的特点。通过使用 orderedMap.get(‘key’)
orderedMap.set(‘key’, newValue) 这两个方法,可以将它当成一个普通的 object 来使用。但和 Map 的不同点在于其中的 key 是按照被加入时的顺序排序的。

Record 也类似于 Map,但有一些不同之处。

  • 一个 record 一旦被初始化,就不能再添加新的 key 了
  • 你可以给一个 record 实例添加默认值

还有一点,immutable 的对象,提供了 toJS() 方法,可将其转成普通的 js 对象,这一方法在想查看其内部内容时非常有用。

Immutable.js 参考文章:Immutable Data with Immutable.js | Jscrambler Blog

Draft 是如何存储数据的

什么是 EditorState

在创建基本的编辑器的时候,我们用到了 EditorStateEditorState 是编辑器最顶层的状态对象,它是一个 Immutable Record 对象,保存了编辑器中全部的状态信息,包括文本状态、选中状态等。

调用 editorState.toJS() 可将 immutable record 转换成一个普通的 object,打印出来如下:

简单地来看下其中的部分内容:

  • currentContent 是一个 ContentState 对象,存放的是当前编辑器中的内容
  • selection 中是当前选中的状态
  • redoStackundoStack 就是撤销 / 重做栈,它是一个数组,存放的是 ContentState 类型的编辑器状态
  • decorator 会寻找特定的模式,并用特定的组件渲染出来

什么是 ContentState

既然编辑器中的内容是存储在一个 ContentState 对象中,那么这个 ContentState 又是什么?

ContentState 也是一个 Immutable Record 对象,其中保存了编辑器里的全部内容和渲染前后的两个选中状态。可以通过 EditorState.getCurrentContent() 来获取当前的 ContentState,同样调用 .toJS() 后将它打印出来看下:

blockMapentityMap 里放置的就是编辑器中的 blockentity,它们是构建 Draft 编辑器的砖瓦。

什么是 ContentBlock 和 Entity

一个 ContentBlock 表示一个编辑器内容中的一个独立的 block,即视觉上独立的一块。

以下图的编辑器作为一个例子,图中的四个红框标出的部分都是 block。在平时阅读文章时,内容是以段落为单位的,在编辑器中每个段落就是一个 block,如第一个和最后一个红框中的文字内容。第二个红框中是一张图片,它也是一个 block,但显示方式不同于普通的 block,为了自定义它的显示方式还需要额外做一些工作,后面会加以详细说明。

还有一点需要稍作说明,第三个红框中虽然是空白,但它也是一个 block,只不过其中的文本为空而已。

此时,输出一下 convertToRaw(currentContent),看看其中的内容。注意这里的输出结构与上面的 currentContent.toJS() 略有所区别,这里只有 blocksentityMap 这两项。

可以看到 blocks 这个数组中依次存放了各个 block 的信息,每一个 block 都是一个 contentBlock 对象。

每个 contentBlock 都有如下的几个属性值:

  • key: 标识出这是哪一个 block
  • type: 这是何种类型的 block
  • text: 其中的文字
  • ……

Draft.js 中 blocktype 有 unstyled,paragraph,header-one,atomic …… 等值,在 Draft.js 的文档中 atomic 类型对应的是 <figure /> 元素,我们也选取了它来实现插入图片的功能。

图中的这些 block 的除了第三个 key =“1u22q”的 block 的 type 值是 atomic 外,其余的值都是 “unstyled”。再仔细看下这个 atomic 类型的 block:

除了 keytexttype 等值之外,在 entityRanges 这一项中保存它保存了使用到的 entity 的信息:offset 和 length 确定了 entity 在 block 中的范围,而 key 则能让我们去取出对应的 entity

回到上面的打印出的 contentState 的内容,除了 blocks 数组外还有一个 entityMap 对象。它是以 entitykey 作为键值的对象,里面保存了图片、链接等种类的 entity 信息,从中就可获得 blocks 所需要的 entity

entityMap: {0: { type: "image", mutability: "IMUTABLE", data: {} }
} 

以上介绍了 Draft.js 是如何对编辑器中的数据进行存储的,接下来会从代码实现的角度来说明插入图片是如何实现的。

插入图片的实现

如何插入图片

插入图片有着这样的流程:首先为图片创建一个 entity,然后创建一个带有这个 entity 的新 EditorState,然后更新即可。以下是关键部分的代码:

import {AtomicBlockUtils} from 'draft.js';
// ...

const editorState = this.state.editorState;
const contentState = editorState.getCurrentContent();

// 使用 `contentState.createEntity` 创建一个 `entity`,指定其 `type` 为 `image`
const contentStateWithEntity = contentState.createEntity(‘image’,‘IMMUTABLE’,
  {src}
);

// 获取新创建的 `entity` 的 key
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();


// 用 `EditorState.set()`  来建立一个带有这个 `entity` 的新的 EditorState 
const newEditorState = EditorState.set(
  editorState,
  {currentContent: contentStateWithEntity},‘create-entity’);

// 利用 `AtomicBlockUtils.insertAtomicBlock` 来插入一个新的 `block`
this.setState({editorState: AtomicBlockUtils.insertAtomicBlock(newEditorState, entityKey,‘‘)
},

如何使用 blockRendererFn 来渲染图片

上面我们已经见到了,一张图片是作为一个 atomic 类型的 block 插入的。Draft.js 提供了 blockRendererFn 让我们可以自定义 ContentBlock 的渲染方式,给它传入一个函数后,由该函数来判断这个 block 的 type 是什么,然后决定如何渲染。

以下的这段代码来自 Draft.js 的官方文档,展示了如何处理一个 type 为 atomicContentBlock

function myBlockRenderer(contentBlock) {const type = contentBlock.getType();
  if (type === 'atomic') {
    return {
      component: MediaComponent,
      editable: false,
      props: {foo: 'bar',},
    };
  }
}

// Then...
import {Editor} from 'draft-js';
class EditorWithMedia extends React.Component {
  ...
  render() {return <Editor ... blockRendererFn={myBlockRenderer} />;
  }
}

可以看到这里传递了一个 props

component: MediaComponent,
props: {foo: 'bar',},

结果等同于 <MediaComponent foo='bar' />,可以利用这里的 props 传入所需要的其他数据。

这里我们就可以定义一个自己的 MediaComponent 来决定展现方式。因为不管是图片还是视频等其它的媒体类型,它们的 type 都是 atomic。在 MediaComponent 里就需要通过 entitytype 来确定其种类。

const entity = props.contentState.getEntity(props.block.getEntityAt(0));
const {src} = entity.getData();    // 取出图片的地址
const type = entity.getType();  // 判断 entity 的 type 的 

entitytype 是我们自定义的 image 时就可以返回 <Image /> 组件了。

<Image src={src} /> // 自定义的图片组件 <Image />

完整代码可见此文件

如何删除一张图片

既然已经插入了图片,那么如何删除它呢?当然我们可以按键盘上的 Backspace 键来删除。也可以在图片的右上角加入一个“X”的图标,点击后删除该图片,实现方式如下。

  deleteImage = (block) => {
    const editorState = this.state.editorState;
    const contentState = editorState.getCurrentContent();
    const key = block.getKey();

    const selection = editorState.getSelection();
    const selectionOfAtomicBlock = selection.merge({
      anchorKey: key,
      anchorOffset: 0,
      focusKey: key,
      focusOffset: block.getLength(),});

    // 重写 entity 数据,将其从 block 中移除,防止这个 entity 还被其它的 block 引用
    const contentStateWithoutEntity = Modifier.applyEntity(contentState, selectionOfAtomicBlock, null);
    const editorStateWithoutEntity = EditorState.push(editorState, contentStateWithoutEntity,‘apply-entity’);

    // 移除 block
    const contentStateWithoutBlock = Modifier.removeRange(contentStateWithoutEntity, selectionOfAtomicBlock,‘backward’);
    const newEditorState =  EditorState.push(editorStateWithoutEntity, contentStateWithoutBlock,‘remove-range’,);

    this.onChange(newEditorState);
  }

至此,对图片的相关操作就完成了。

总结与其他

在本文中,介绍了 Draft.js 的基本功能,它是如何进行数据的存储的,及 EditorStateContentStateContentBlockEntity 等对象间的关系。并以此为基础说明了如何在编辑器中对图片进行操作。

当然关于 Draft.js 还有很多内容没有在本文中提及,如修改行内文本的样式,利用 decorators 来插入与渲染链接等等。这些就需要读者探索下 Draft.js 的官方文档和其他人的分享并亲自尝试下了。

参考文章及资源

本文所基于的编辑器项目:draft-editor

How Draft.js Represents Rich Text Data
Building a Rich Text Editor with React and Draft.js, Part 2.4: Embedding Images
Draft.js 在知乎的实践


本文原链接:从插入图片功能的实现来介绍如何用 Draft.js 编写富文本编辑器

退出移动版