使用draft.js开发富文本编辑器

Draft.js是Facebook开源的开发React富文本编辑器开发框架。和其它富文本编辑器不同,draft.js并不是一个开箱即用的富文本编辑器,而是一个提供了一系列开发富文本编辑器的工具。本文通过开发一些简单的富文本编辑器,来介绍draft.js提供的各种能力。

draft.js解决的问题

  1. 统一html标签contenteditable=”true”,在编辑内容时,不同浏览器下产生不同dom结构的问题;
  2. 给html的改变赋予onChange时的监听能力;
  3. 使用不可变的数据结构,每次修改都生成新的状态,保证里历史记录的可回溯;
  4. 可以结构化存储富文本内容,而不需要保存html片段。

不可变的数据结构

这里要介绍下不可变的数据,draft.js使用immutable.js提供的数据结构。draft.js中所有的数据都是不可变的。每次修改都会新建数据,并且内存中会保存原来的状态,方便回到上一步,这里很符合react的单向数据流的设计思路。

Editor组件

Draft.js提供了一个Editor组件。Editor组件是内容呈现的载体。我们先看一个基础编辑器。在线示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React, {Component} from 'react';
import {Editor, EditorState} from 'draft-js';

export default class extends Component {
constructor(props) {
super(props);
this.state = {
editorState: EditorState.createEmpty()
};
this.onChange = editorState => {
this.setState({editorState});
};
}
render() {
return (
<div className="basic">
基础编辑器
<div className="editor">
<Editor
editorState={this.state.editorState}
onChange={this.onChange}/>
</div>
</div>
)
}
}

这里的Editor组件接收2个props:editorState是整个编辑器的状态,类似文本框的valueonChange监听状态改变并把新的状态传给对应的函数。初始化的时候我们使用了EditorState提供的createEmpty方法,根据语意我们很容易知道这个是生成一个没有内容的EditorState对象。

富文本样式

提到富文本编辑器,当然避免不了各种丰富的样式。富文本样式包含两种,行内样式和块级样式。行内样式是在段落中某些字段上添加的样式,如粗体、斜体、文字加下划线等等。块级样式是在整个段落上加的样式,如段落缩进、有序列表、无需列表等。Draft.js提供了RichUtils模块来处理富文本样式。

行内样式

RichUtils.toggleInlineStyle方法可以切换光标所在位置的行内样式。该函数接收2个参数。第一个是editorState,在editorState中已经包含了光标选中内容的信息。第二个参数是样式名,draft.js提供了’BOLD’, ‘ITALIC’, ‘UNDERLINE’,’CODE’这几个默认的样式名。

1
2
3
4
toggleInlineStyle(
editorState: EditorState,
inlineStyle: string
): EditorState

点击「Bold」按钮使选中字体变粗的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import React, {Component} from 'react';
import {Editor, EditorState, RichUtils} from 'draft-js';

export default class extends Component {
constructor(props) {
super(props);
this.state = {
editorState: EditorState.createEmpty()
};
this.onChange = editorState => {
this.setState({editorState});
};
this.toggleInlineStyle = this.toggleInlineStyle.bind(this);
}
toggleInlineStyle(inlineStyle) {
this.onChange(
RichUtils.toggleInlineStyle(
this.state.editorState,
inlineStyle
)
);
}
render() {
return (
<div className="basic">
<button onClick={() => {this.toggleInlineStyle('BOLD')}}>Bold</button>
<div className="editor">
<Editor
editorState={this.state.editorState}
onChange={this.onChange}/>
</div>
</div>
)
}
}

除此之外还可以为Editor提供customStyleMapprop来自定义行内样式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

// ...
const styleMap = {
'RED': {
color: 'red'
}
}

class MyEditor extends React.Component {
// ...
render() {
return (
<div className="basic">
<button onClick={() => {this.toggleInlineStyle('BOLD')}}>Bold</button>
<!-- 点击之后会在styleMap里查找「RED」对应的样式 -->
<button onClick={() => {this.toggleInlineStyle('RED')}}>Red</button>
<div className="editor">
<Editor
customStyleMap={styleMap}
editorState={this.state.editorState}
onChange={this.onChange}/>
</div>
</div>
)
}
}

在线示例

块级样式

Draft.js的块级样式是写在css文件中的,要使用默认样式需要引用draft-js/dist/Draft.css。下面是一些标签对应的样式名
block type

可以使用RichUtils.toggleBlockType来改变block对应的类型。

1
2
3
4
toggleBlockType(
editorState: EditorState,
blockType: string
): EditorState

EditorblockStyleFnprop可以方便自定义样式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import 'draft-js/dist/Draft.css';
import './index.css';
import React, {Component} from 'react';
import {Editor, EditorState, RichUtils} from 'draft-js';

export default class extends Component {
constructor(props) {
super(props);
this.state = {
editorState: EditorState.createEmpty()
};
this.onChange = editorState => {
this.setState({editorState});
};
this.toggleBlockType = this.toggleBlockType.bind(this);
}
toggleBlockType(blockType) {
this.onChange(
RichUtils.toggleBlockType(
this.state.editorState,
blockType
)
);
}
render() {
return (
<div className="basic">
<button onClick={() => {this.toggleBlockType('header-one')}}>H1</button>
<button onClick={() => {this.toggleBlockType('blockquote')}}>blockquote</button>
<div className="editor">
<Editor
blockStyleFn={getBlockStyle}
editorState={this.state.editorState}
onChange={this.onChange}/>
</div>
</div>
)
}
}
function getBlockStyle(block) {
switch (block.getType()) {
case 'blockquote': return 'RichEditor-blockquote';
default: return null;
}
}

在css文件中,可以自定义.RichEditor-blockquote的样式。

1
2
3
4
5
6
7
8
.RichEditor-blockquote {
border-left: 5px solid #eee;
color: #666;
font-family: 'Hoefler Text', 'Georgia', serif;
font-style: italic;
margin: 16px 0;
padding: 10px 20px;
}

在线示例

我们可以使用editorState.getCurrentContent()获取contentState对象,contentState.getBlockForKey(blockKey)可以获取到blockKey对应的contentBlockcontentBlock.getType()可以获取到当前contentBlock对应的类型。

自定义组件渲染

除了上定义的contentBlock类型对应的标签之外,Draft.js还提供了自定义组件渲染功能。实现起来非常简单。自定义一个渲染函数,之后把这个函数传个Editor组件blockRendererFn这个prop就行。

先自定义渲染函数和组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

const ImgComponent = (props) => {
return (
<img
style={{height: '300px', width: 'auto'}}
src={props.blockProps.src}
alt="图片"/>
)
}

function myBlockRenderer(contentBlock) {

// 获取到contentBlock的文本信息,可以用contentBlock提供的其它方法获取到想要使用的信息
const text = contentBlock.getText();

// 我们假定这里图片的文本格式为![图片名称](htt://....)
let matches = text.match(/\!\[(.*)\]\((http.*)\)/);
if (matches) {
return {
component: ImgComponent, // 指定组件
editable: false, // 这里设置自定义的组件可不可以编辑,因为是图片,这里选择不可编辑
// 这里的props在自定义的组件中需要用this.props.blockProps来访问
props: {
src: matches[2],,
}
};
}
}

之后只要在Editor上加blockRendererFn:

1
2
3
4
<Editor
editorState={this.state.editorState}
onChange={this.onChange}
blockRendererFn={myBlockRenderer}/>

在线示例

示例代码

Decorator

除了使用自定义样式外,我们也可以使用自定义组件来渲染特定的内容。为了支持自定义富文本的灵活性,Draft.js提供了一个decrator系统。Decorator基于扫描给定ContentBlock的内容,找到满足与定义的策略匹配的文本范围,然后使用指定的React组件呈现它们。

可以使用CompositeDecorator类定义所需的装饰器行为。 此类允许你提供多个DraftDecorator对象,并依次搜索每个策略的文本块。

Decrator 保存在EditorState记录中。当新建一个EditorState对象时,例如使用EditorState.createEmpty(),可以提供一个decorator。

新建一个Decorator类似这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const HandleSpan = (props) => {
return (
<span
style={styles.handle}
data-offset-key={props.offsetKey}
>
{props.children}
</span>
);
};
const HashtagSpan = (props) => {
return (
<span
style={styles.hashtag}
data-offset-key={props.offsetKey}
>
{props.children}
</span>
);
};
const compositeDecorator = new CompositeDecorator([
{
strategy: function (contentBlock, callback, contentState) {
// 这里可以根据contentBlock和contentState做一些判断,根据判断给出要使用对应组件渲染的位置执行callback
// callback函数接收2个参数,start组件包裹的起始位置,end组件的结束位置
// callback(start, end);
},
component: HandleSpan
},
{
strategy: function (contentBlock, callback, contentState) {},
component: HashtagSpan
}
]);

export default class extends React.Component {
constructor() {
super();
this.state = {
editorState: EditorState.createEmpty(compositeDecorator),
};
// ...
}
render() {
return (
<div style={styles.root}>
<div style={styles.editor} onClick={this.focus}>
<Editor
editorState={this.state.editorState}
onChange={this.onChange}
/>
</div>
</div>
);
}
}

在线示例

示例源码

Entity

对于一些特殊情况,我们需要在文本上附加一些额外的信息,比如超链接中,超链接的文字和对应的链接地址是不一样的,我们就需要对超链接文字附加上链接地址信息。这个时候就需要entity来实现了。

contentState.createEntity可以新建entity。

1
2
3
4
5
6
7
8
9
const contentState = editorState.getCurrentContent();
const contentStateWithEntity = contentState.createEntity(
'LINK',
'MUTABLE',
{url: 'http://www.zombo.com'}
);

// 要把entity和内容对应上,我们需要知道entity的key值
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();

contentState.createEntity接收三个参数:

  • type: 指示了entity的类型,例如:’LINK’、’MENTION’、’PHOTO’等。
  • mutability: 可变性。不要将不可变性和immutable.js混淆,此属性表示在编辑器中编辑文本范围时,使用此Enity对象对应的一系列文本的行为。 这在下面更详细地讨论。
  • data: 一个包含了一些对于当前enity可选数据的对象。例如,’LINK’ enity包含了该链接的href值的数据对象。

mutability

IMMUTABLE

如果不移除文本上的entity,文本不能被改变。当文本改变时,entity自动移除,当删除字符的时候整个entity连同上边携带的文字也会被删除。

MUTABLE

如果设置Mutability为MUTABLE,被加了enity的文字可以随意编辑。比如超链接的文字是可以随意编辑的,一般超链接的文字和链接的指向是没有关系的。

SEGMENTED

设置为「SEGMENTED」的entity和设置为「IMMUTABLE」很类似,但是删除行为有些不同,比如一段带有entity的英文文本(因为英文单词间都有空格),按删除键,只会删除当前光标所在的单词,不会把当前entity对应的文本都删除掉。

这里可以直观体会三种entity的区别。

我们使用RichUtils.toggleLink来管理entity和内容。

1
2
3
4
5
toggleLink(
editorState: EditorState,
targetSelection: SelectionState,
entityKey: string
): EditorState

下面通过一个能够编辑超链接的编辑器来了解entity的使用。

首先我们新建一个Link组件来渲染超链接。

1
2
3
4
5
6
7
8
9
const Link = (props) => {
// 这里通过contentState来获取entity,之后通过getData获取entity中包含的数据
const {url} = props.contentState.getEntity(props.entityKey).getData();
return (
<a href={url}>
{props.children}
</a>
);
};

新建decorator,这里面contentBlock.findEntityRanges接收2个函数作为参数,如果第一个参数的函数执行时返回true,就会执行第二个参数函数,同时会将匹配的字符的起始位置和结束位置传递给第二个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const decorator = new CompositeDecorator([
{
strategy: function (contentBlock, callback, contentState) {

// 这个方法接收2个函数作为参数,如果第一个参数的函数执行时返回true,就会执行第二个参数函数,同时会将匹配的字符的起始位置和结束位置传递给第二个参数。
contentBlock.findEntityRanges(
(character) => {
const entityKey = character.getEntity();
return (
entityKey !== null &&
contentState.getEntity(entityKey).getType() === 'LINK'
);
},
function () {
callback(...arguments);
}

);
},
component: Link
}
]);

下面来新建编辑器组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class LinkEditor extends Component {
constructor(props) {
super(props);

this.state = {
// 新建editor时加入上边建的decorator
editorState: EditorState.createEmpty(decorator),
url: ''
};
this.onChange = editorState => {
this.setState({editorState});
};
this.addLink = this.addLink.bind(this);
this.urlChange = this.urlChange.bind(this);
}

/**
* 添加链接
*/
addLink() {
const {editorState, url} = this.state;
// 获取contentState
const contentState = editorState.getCurrentContent();
// 在contentState上新建entity
const contentStateWithEntity = contentState.createEntity(
'LINK',
'MUTABLE',
{url}
);
// 获取到刚才新建的entity
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
// 把带有entity的contentState设置到editorState上
const newEditorState = EditorState.set(editorState, { currentContent: contentStateWithEntity });
// 把entity和选中的内容对应
this.setState({
editorState: RichUtils.toggleLink(
newEditorState,
newEditorState.getSelection(),
entityKey
),
url: '',
}, () => {
setTimeout(() => this.refs.editor.focus(), 0);
});
}

/**
* 链接改变
*
* @param {Object} event 事件
*/
urlChange(event) {
const target = event.target;
this.setState({
url: target.value
});
}

render() {
return (
<div>
链接编辑器
<div className="tools">
<Input value={this.state.url} onChange={this.urlChange}></Input>
<Button className="addlink" onClick={this.addLink}>addLink</Button>
</div>
<div className="editor">
<Editor
editorState={this.state.editorState}
onChange={this.onChange}
ref="editor"/>
</div>
</div>
)
}
}

在线示例

示例代码

总结

draft.js提供了很多丰富的功能,还有自定义快捷键等功能本文没有提及。在使用过程中,感觉主要难点在decorator和entity的理解上。希望本文能够对你了解draft.js有所帮助。

开发了一些简单的demo供参考:https://marxjiao.com/draft-demo/

demo源码:https://github.com/MarxJiao/draft-demo

相关链接

Draft.js官方文档

Draft.js 在知乎的实践