富文本框架Draft.js的入门与实践

1,032 阅读5分钟

什么是Draft.js

官网地址:draftjs.org/ 建议先看一遍官网文档再来阅读本文章

Draft.js是应用在react里的富文本编辑器框架,它主要优点如下

  • 可拓展可定制,可以自定义文本块和流媒体
  • immutable EditeState,相比js原生对象更加简单安全,相对于深浅拷贝更加节约内存

基础概念

EditeState分多层结构组织,大致的结构如下,下文会逐一介绍关键的概念

EditeState

EditeState是Draft富文本编辑器的顶层对象,它主要包含了编辑器中的内容、光标选中以及Entity&inline style等元信息

ContentState

是描述富文本编辑内容状态的对象,你可以认为你打字输入文本内容都是由这个对象下管理控制的。它会分有多个ContentBlock(为简化描述,后文都称作block),多个block实际上又是CharacterMetaData组成的,每一个CharacterMetaData会包含实际的字符以及他的Entity和inline style等信息

block是怎么划分的呢?

block可以想象是html的块级元素,列表、段落、代码块都看作是一个block

SelectionState

一个富文本编辑器,它除了内容状态的描述,它应该还会有光标状态的描述。这个状态可以判断光标是否重合、光标选中的锚点或焦点、选中的起始或终点的偏移量等

Entity

实体对象,它是一个数据结构,包含了一些元数据用来描述一段文本

用它可以实现链接插入、引用跳转等功能

Demo实践

Demo1

实现一个简单的文本编辑器,可以通过操作按钮、快捷键、markdown语法三种方式实现文本的加粗显示

操作按钮是类似于顶部功能菜单,快捷键通过command + b的方式加粗,特定语法是通过x加空格的方式加粗 源码地址:github.com/YYYYYHHHHHH…

环境准备

  1. 搭建react应用环境
// 安装脚手架 
npm install -g create-react-app
// 创建app
npx create-react-app draft-demo
cd draft-demo
npm install
// 查看效果
npm run start

截屏2024-04-11 23.37.06.png
应用成功启动

关于create-react-app的更多使用,可以参考官方文档create-react-app.dev/docs/gettin…

  1. 安装draft.js
npm install draft-js

功能菜单实现加粗

交互设计:用户使用光标选中文本后,可以点击功能按钮实现选中文本的加粗

  1. 首先我们要搭建编辑器的基础样式,为编辑器上方添加一排功能菜单,其中引入加粗功能按钮
import { useState } from "react";
import { Editor, EditorState } from "draft-js";

import "draft-js/dist/Draft.css";
import "./App.css";

function App() {
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );

  return (
    <div className="editor-body">
      <div className="tools">
        <div
          className='tool'
        >
          B
        </div>
      </div>
      <div className="editor-wrap">
        <Editor
          placeholder="请输入..."
          editorState={editorState}
          onChange={setEditorState}
        />
      </div>
    </div>
  );
}

export default App;

.editor-body {
  width: 375px;
  height: 200px;
  border: 1px solid rgba(38, 38, 38, 0.5);
  border-radius: 4px;
  margin: 32px auto;

  .tools {
    padding: 4px 4px;
    box-sizing: border-box;
    border-bottom: 1px solid rgba(38, 38, 38, 0.5);
    display: flex;

    .tool {
      color: #585a5a;
      font-size: 14px;
      padding: 4px 8px;
      border: 4px;
      font-weight: 500;
      cursor: pointer;
    }
  }

  .editor-wrap {
    padding: 8px;
  }
}

查看运行效果
1712851384914-442b7944-0652-4c1d-ae59-05421460db78.png 2. 我们为加粗功能按钮添加点击事件,实现文案加粗


// ...
function App() {
  // ...

  
  const doBold = () => {
    const selection = editorState.getSelection();

    if (!selection.isCollapsed()) {
      const newEditorState = RichUtils.toggleInlineStyle(editorState, 'BOLD');
      setEditorState(newEditorState);
    }
  }
  // 为加粗按钮添加点击事件
  const onBoldClick = () => {
    doBold();
  }

  return (
    <div className="editor-body">
      <div className="tools">
        <div
          className='tool'
          onClick={onBoldClick}
        >
          B
        </div>
      </div>
      ...
    </div>
  );
}

我们使用了draft.js内置的RichUtils去实现了文本的加粗
运行效果
Kapture 2024-04-14 at 20.25.32.gif

快捷键实现加粗

官网文档:draftjs.org/docs/advanc…

我们要实现command + b实现文案加粗,需要利用到自定义指令和指令的处理

// ...
function App() {
  // ...
  // 自定义快捷键函数
  const keyBindingFn = (e) => {
    if (e.key === 'b' && KeyBindingUtil.hasCommandModifier(e)) {
      return 'my-bold'
    }

    // draft内置了许多快捷键处理,如undo、redo
    return getDefaultKeyBinding(e);
  }
  // 自定义指令处理
  const handleKeyCommand = (command) => {
    if (command === 'my-bold') {
      doBold();
      return 'handled'
    }

    return 'not-handled'
  };
  
  return (
    <div className="editor-body">
      ...
      <div className="editor-wrap">
        <Editor
          placeholder="请输入..."
          editorState={editorState}
          onChange={setEditorState}
          keyBindingFn={keyBindingFn}
          handleKeyCommand={handleKeyCommand}
        />
      </div>
    </div>
  );
}

export default App;

其中KeyBindingUtil是draft.js提供的内置类,他提供的hasCommandModifier是用来判断当前是否按下了快捷键的命令组合键(mac中的command,win中ctrl)
getDefaultKeyBinding则是内置提供的默认快捷键命令处理,内置了undo、redo等功能(其实也是内置了加粗字体功能,实现加粗只不过是为了熟悉其功能)

实现效果
Kapture 2024-04-14 at 21.02.57.gif

markdown语法实现加粗

markdown加粗语法

实现效果:
Kapture 2024-04-28 at 18.10.57.gif
实现思路:
我们是需要捕获输入时的空格,然后需要寻找成对的**,去做替换,所以我们需要用到handleBeforeInput,在字符输入到富文本前,做操作拦截
handleBeforeInput
官方文档 表明:在此方法返回handled时,就能够阻止默认事件,这对我们的代码实现很有用,想象一下如果我们在实现加粗功能时,不能够阻止默认事件会怎么样 —— 输入空格后,文字确实被加粗了,但空格也被输入到编辑器中,这是我们不希望看到的
截屏2024-04-28 18.18.52.png
代码实现

import { useEffect, useState } from "react";
import {
  Editor,
  EditorState,
  RichUtils,
  KeyBindingUtil,
  getDefaultKeyBinding,
  CompositeDecorator,
  Modifier,
  SelectionState,
} from "draft-js";

import "draft-js/dist/Draft.css";
import "./App.css";

const regex = /\*\*(.*?)\*\*/g;

function App() {
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );
  // ...
  const handleBeforeInput = (e) => {
    // 如果输入空格,需要检查markdown语法
    if (e === " ") {
      const selection = editorState.getSelection();
      const contentState = editorState.getCurrentContent();
      const block = contentState.getBlockForKey(selection.getStartKey());
      const text = block.getText();
      // 获取光标的起始位置
      const startOffset = selection.getStartOffset();
      // 匹配到关键字,开始向前寻找关键字
      if (text.slice(startOffset - 2, startOffset) === "**") {
        let end = startOffset;
        let start = end - 3;

        while (start >= 0) {
          if (text.slice(start, start + 2) === "**") {
            break;
          } else {
            start--;
          }
        }
        // 没有匹配到关键字,走默认事件
        if (start < 0) {
          return 'not-handled'
        }

        // 文本替换,把 **xx** 替换成 xx
        const newContentStateWithText = Modifier.replaceText(
          contentState,
          selection.merge({
            anchorOffset: start,
            focusOffset: end,
          }),
          regex.exec(text.slice(0, startOffset))?.[1],
          
        );

        // 文本加粗
        const newContentStateWithBold = Modifier.applyInlineStyle(
          newContentStateWithText,
          selection.merge({
            anchorOffset: start,
            focusOffset: end - 4,
          }),
          "BOLD"
        );

        // 生成新的EditorState对象
        const newEditorState = EditorState.push(
          editorState,
          newContentStateWithBold,
          "insert-characters"
        );

        // 为编辑器设置新的状态,并且把光标移动到最后
        setEditorState(EditorState.moveFocusToEnd(newEditorState));
      }
      return "handled";
    }
  };

  return (
    <div className="editor-body">
      // ...
      <div className="editor-wrap">
        <Editor
          // ...
          handleBeforeInput={handleBeforeInput}
        />
      </div>
    </div>
  );
}

export default App;

上述代码中,因为editorState.getSelection();拿到的值是基于Immutable封装的,所以我们需要调用merge去修改光标选中的位置。正如文档开头说到的,Draft.js是immutable的,因为SelectionState是可以直接调用Immutable的api**, **所以我们在官方文档中没看到修改数据的api(这一块踩坑踩了好久,当初一直好奇怎么没有修改值的api :)