对富文本编辑器的探索

2,444 阅读14分钟

heylagostechie-tWjzmNXKup4-unsplash.jpg

图片来源 unsplash.com/photos/tWjz…

什么是富文本编辑器 ?

富文本编辑器Rich Text Editor, 简称 RTE, 是一种可内嵌于浏览器,所见即所得的文本编辑器。它是一种解决可一般的用户不同html等网页标记但是需要在网页上设置字体的颜色、大小、样式等信息问题一个文本编辑器

前端常用的富文本编辑器

  • wangEditor:wangEditor 是一款使用 Typescript 开发的 Web 富文本编辑器, 轻量、简洁、易用、开源免费。
  • Quill:Quill是一种现代的 WYSIWYG 编辑器,旨在实现兼容性和可扩展性。
  • TinyMCE:TinyMCE是一款易用、且功能强大的所见即所得的富文本编辑器。

基本原理

对于支持富文本编辑的浏览器来说,通过设置 documentdesignMode 属性为 on 后,再通过执行 document.execCommand(aCommandName, aShowDefaultUI, aValueArgument) 即可,比方说,我们要加粗字体,执行 document.execCommand('bold', false) 即可。

document.execCommand

当一个HTML文档切换到设计模式时,document 暴露 execCommand 方法,该方法允许运行命令来操纵可编辑内容区域的元素。语法:bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument),返回值是一个 Boolean ,如果是 false 则表示操作不被支持或未被启用。 document.execCommand 这个API 已经废弃掉了,随时有可能被删除,所以现在使用这个API有风险。这个 API 本来也不是标准 API,而是一个 IE 的私有 API,在 IE9 时被引入,后续的若干年里陆续被Chrome / Firefix / Opera等浏览器也做了兼容支持,但始终没有形成标准。

废弃的主要原因

  • 安全问题,在用户未经授权的情况下就可以执行一些敏感操作;
  • 浏览器的行为不一致,不同浏览器生成的结果是不一样的;
  • document.execCommand 生成的内容会产生很多不必要的标签
  • 它是因为这是一个同步方法,而且操作了 DOM 对象,会阻塞页面渲染和脚本执行,因当初还没 Promise,所以没设计成异步。

富文本编辑器技术阶段划分

  • Level 0 是编辑器的起始阶段,代表旧一代的编辑器的实现;
  • Level 1 由第一阶段发展过来的,具有一定的先进性,也引入了主流的一些编程思想,对于富文本内容有一定的抽象;
  • Level 2 第三阶段,完全不依赖浏览器的编辑能力,独立的实现光标和排版;

不使用 document.execCommand 如何实现富文本编辑器?

Clipboard

Clipboard 接口实现了 Clipboard API,如果用户授予了相应的权限,就能提供系统剪贴板的读写访问。在 Web 应用程序中,Clipboard API 可用于实现剪切、复制和粘贴功能。系统剪贴板暴露于全局属性 Navigator.clipboard 之中。所有剪贴板 API 方法都是异步的;它们返回一个 Promise 对象,在剪贴板访问完成后被执行,不过它的兼容性不是很好

建立抽象数据层

富文本编辑器比较复杂,在此基础上抽象出数据结构难度比较高,但没有数据层就无法实现更高层次的功能。有了数据层的基础,可以实现灵活的定制化渲染、跨平台、在线多人协作等高级特性。

以富文本编辑器 quill 为例,quilljs自带一套数据系统来支撑内容生产,ParchmentDelta

Parchment

Parchment 是 Quill 的文档模型。它是一个并行的树结构,并且提供对内容的编辑(如Quill)的功能。一个Parchment 树是由 Blots 组成的,它反映了一个 DOM 对应的节点。Blots 能够提供结构、格式和内容或者只有内容。

官方例子

import Parchment from 'parchment';

class LinkBlot extends Parchment.Inline {
  static create(url) {
    let node = super.create();
    node.setAttribute('href', url);
    node.setAttribute('target', '_blank');
    node.setAttribute('title', node.textContent);
    return node;
  }

  static formats(domNode) {
    return domNode.getAttribute('href') || true;
  }

  format(name, value) {
    if (name === 'link' && value) {
      this.domNode.setAttribute('href', value);
    } else {
      super.format(name, value);
    }
  }

  formats() {
    let formats = super.formats();
    formats['link'] = LinkBlot.formats(this.domNode);
    return formats;
  }
}
LinkBlot.blotName = 'link';
LinkBlot.tagName = 'A';

Parchment.register(LinkBlot);
Parchment 架构解析

未命名绘图.png

由上图看出 Blot 大概可以分成两类:

  1. 第一种是继承自 ParentBlot
  • ContainerBlot 表示容器节点;
  • ScrollBlot 表示文档的根节点,不可格式化;
  • BlockBlot 表示块级节点,可格式化的节点;
  • InlineBlot 内联节点,可格式化的节点;
  1. 第二种是继承自 LeafBlot
  • EmbedBlot 嵌入式节点;
  • TextBlot 文本节点;

这两种类型的主要区别在于继承 LeafBlot 的节点是都属于一个原子节点,不可用做父节点。所以 leaf 的节点,没有对 child 节点操作的方法。

Blots

Blots 是 Parchment 文档的基本组成部分。提供了几个基本的实现,如:Block、Inline 和Embed 等。

export interface Blot extends LinkedNode {
  scroll: Parent;
  parent: Parent;
  prev: Blot;
  next: Blot;
  domNode: Node;

  attach(): void;
  clone(): Blot;
  detach(): void;
  insertInto(parentBlot: Parent, refBlot?: Blot): void;
  isolate(index: number, length: number): Blot;
  offset(root?: Blot): number;
  remove(): void;
  replace(target: Blot): void;
  replaceWith(name: string, value: any): Blot;
  replaceWith(replacement: Blot): Blot;
  split(index: number, force?: boolean): Blot;
  wrap(name: string, value: any): Parent;
  wrap(wrapper: Parent): Parent;

  deleteAt(index: number, length: number): void;
  formatAt(index: number, length: number, name: string, value: any): void;
  insertAt(index: number, value: string, def?: any): void;
  optimize(context: { [key: string]: any }): void;
  optimize(mutations: MutationRecord[], context: { [key: string]: any }): void;
  update(mutations: MutationRecord[], context: { [key: string]: any }): void;
}

interface LinkedNode {
  prev: LinkedNode | null;
  next: LinkedNode | null;

  length(): number;
}

export default LinkedNode;

从上面的代码中可以看出 Blot 采用的是链表作为存储结构。相比数组,链表是一种稍微复杂一点的数据结构,让我们从底层的存储结构来看看二者的区别。

未命名绘图 (1).png

从上图中我们看到,数组需要一块连续的内存空间来存储,对内存的要求比较高。如果我们申请一个 10 MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 10 MB,仍然会申请失败。
而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是 10 MB 大小的链表,根本不会有问题。

未命名绘图 (6).png

上图是一种单链表,我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点。

在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以时间复杂度是 O(n)。而在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的。

未命名绘图 (8).png

在进行富文本编辑的时候会进行大量的插入删除操作,所以选择链表这种数据结构比较高效。

Delta

Delta 是一种用来描述内容和修改的基于 JSON 的格式。可以描述任意的富文本文档,包括文本和格式化信息。Delta 是用户操作的数据化表示,一个 Delta 对象和一种 DOM 结构有唯一的双向对应关系。Delta 是 JSON 的一个子集,只包含一个 ops 属性,它的值是一个对象数组,每个数组项代表对编辑器的一个操作。

// Document with text "Gandalf the Grey"
// with "Gandalf" bolded, and "Grey" in grey
const delta = new Delta([
  { insert: 'Gandalf', attributes: { bold: true } },
  { insert: ' the ' },
  { insert: 'Grey', attributes: { color: '#ccc' } }
]);

// Change intended to be applied to above:
// Keep the first 12 characters, delete the next 4,
// and insert a white 'White'
const death = new Delta().retain(12)
                         .delete(4)
                         .insert('White', { color: '#fff' });
// {
//   ops: [
//     { retain: 12 },
//     { delete: 4 },
//     { insert: 'White', attributes: { color: '#fff' } }
//   ]
// }

// Applying the above:
const restored = delta.compose(death);
// {
//   ops: [
//     { insert: 'Gandalf', attributes: { bold: true } },
//     { insert: ' the ' },
//     { insert: 'White', attributes: { color: '#fff' } }
//   ]
// }

Delta 只有 3 种操作和 1 种属性。

3 种操作:

  • insert:插入
  • retain:保留
  • delete:删除

1 种属性:

  • attributes:格式属性

WeChat57ccaf821f8c753c5e3141bbbd35c077.png

fast-diff

通过阅读 delta 源码 知道,比较两条 delta 之间的差异采用的是 fast-diff 库。fast-diff 这个库只能用来处理 string 类型数据的 diff。fast-diff 是 将 googlediff-match-patch 库的简化导入到 Node.js环境中。

diff-match-patch: github.com/google/diff…

fast-diff: github.com/jhchen/fast…

diff 方法在源码中的位置: github.com/quilljs/del…

const NULL_CHARACTER = String.fromCharCode(0); // Placeholder char for embed in diff()

 diff(other: Delta, cursor?: number | diff.CursorInfo): Delta {
    if (this.ops === other.ops) {
      return new Delta();
    }
    const strings = [this, other].map((delta) => {
      return delta
        .map((op) => {
          if (op.insert != null) {
            return typeof op.insert === 'string' ? op.insert : NULL_CHARACTER;
          }
          const prep = delta === other ? 'on' : 'with';
          throw new Error('diff() called ' + prep + ' non-document');
        })
        .join('');
    });
    ......
  }

从上面的源码中可以看出,在调用 fast-diff 之前把所有的 insert 操作全部转成了字符串,对于插入的 number 类型的数据和 object 类型的数据,都转成了一个特殊字符,然后和 string 类型的数据拼在一起,就成了一个大字符串,然后给 fast-diff 处理。转换为字符串然后再进行 diff 算法主要原因是字符串之间的 diff 更加快速

DOM 修改后,怎样同步到 delta?

ScrollBlot 是最顶层的 ContainerBlot, 即 root Blot, 包裹所有 blots, 并且管理编辑器中的内容变化。ScrollBlot 会创建一个 MutationObserver, 用来监控 DOM 更新。DOM 更新时会调用 ScrollBlot 的 update 方法。在 Quill 的 scroll blot 中重写了update 方法,其中对外抛出 SCROLL_UPDATE 事件和 mutations 参数。

 update(mutations) {
    .....
    if (mutations.length > 0) {
      this.emitter.emit(Emitter.events.SCROLL_BEFORE_UPDATE, source, mutations);
    }
    .....
  }

editor 会监听 SCROLL_UPDATE 事件,然后触发 editor 的 update 方法,传入 mutations 参数,然后在 editor 的 update 方法中,会依据 mutations 构建出对应的delta 数组,与已有的 delta 合并,使当前 delta 保持最新

this.emitter.on(Emitter.events.SCROLL_UPDATE, (source, mutations) => {
      const oldRange = this.selection.lastRange;
      const [newRange] = this.selection.getRange();
      const selectionInfo =
        oldRange && newRange ? { oldRange, newRange } : undefined;
      modify.call(
        this,
        () => this.editor.update(null, mutations, selectionInfo),
        source,
      );
    });
    
    

源码中的位置 github.com/quilljs/qui…

update(change, mutations = [], selectionInfo = undefined) {
    const oldDelta = this.delta;
    if (
      mutations.length === 1 &&
      mutations[0].type === 'characterData' &&
      mutations[0].target.data.match(ASCII) &&
      this.scroll.find(mutations[0].target)
    ) {
      // Optimization for character changes
      const textBlot = this.scroll.find(mutations[0].target);
      const formats = bubbleFormats(textBlot);
      const index = textBlot.offset(this.scroll);
      const oldValue = mutations[0].oldValue.replace(CursorBlot.CONTENTS, '');
      const oldText = new Delta().insert(oldValue);
      const newText = new Delta().insert(textBlot.value());
      const relativeSelectionInfo = selectionInfo && {
        oldRange: shiftRange(selectionInfo.oldRange, -index),
        newRange: shiftRange(selectionInfo.newRange, -index),
      };
      const diffDelta = new Delta()
        .retain(index)
        .concat(oldText.diff(newText, relativeSelectionInfo));
      change = diffDelta.reduce((delta, op) => {
        if (op.insert) {
          return delta.insert(op.insert, formats);
        }
        return delta.push(op);
      }, new Delta());
      this.delta = oldDelta.compose(change);
    } else {
      this.delta = this.getDelta();
      if (!change || !isEqual(oldDelta.compose(change), this.delta)) {
        change = oldDelta.diff(this.delta, selectionInfo);
      }
    }
    return change;
  }
}

editor中的update 方法 github.com/quilljs/qui…

delta 修改后,如何同步到 DOM ?

当 delta 修改后,会遍历 delta 数组, 生成相应的 Blot, Attributor,然后生成 DOM 结构,然后进行 format 操作。

主要的 API

  • setContents
setContents(delta: Delta, source: String = 'api'): Delta

setContents 用给定的内容覆盖编辑器的内容。内容应该以一个新行或者换行符结束。返回一个改变的Delta。如果被给定的 Delta 没有无效操作,那么就会作为新的 Delta 通过。操作来源可能为:'user'、'api' 或者 'silent'。

github.com/quilljs/qui…

// 源码
 setContents(delta, source = Emitter.sources.API) {
    return modify.call(
      this,
      () => {
        delta = new Delta(delta);
        const length = this.getLength();
        // Quill will set empty editor to \n
        const delete1 = this.editor.deleteText(0, length);
        // delta always applied before existing content
        const applied = this.editor.applyDelta(delta);
        // Remove extra \n from empty editor initialization
        const delete2 = this.editor.deleteText(this.getLength() - 1, 1);
        return delete1.compose(applied).compose(delete2);
      },
      source,
    );
  }
quill.setContents([
  { insert: 'Hello ' },
  { insert: 'World!', attributes: { bold: true } },
  { insert: '\n' }
]);
  • applyDelta

applyDelta 把传入的 Delta 数据应用和渲染到编辑器中。

// applyDelta 源码
applyDelta(delta) {
    let consumeNextNewline = false;
    this.scroll.update();
    let scrollLength = this.scroll.length();
    this.scroll.batchStart();
    const normalizedDelta = normalizeDelta(delta);
    normalizedDelta.reduce((index, op) => {
      const length = op.retain || op.delete || op.insert.length || 1;
      let attributes = op.attributes || {};
       // 1.插入文本
      if (op.insert != null) {
        if (typeof op.insert === 'string') {
          let text = op.insert;
          if (text.endsWith('\n') && consumeNextNewline) {
            consumeNextNewline = false;
            text = text.slice(0, -1);
          }
          if (
            (index >= scrollLength ||
              this.scroll.descendant(BlockEmbed, index)[0]) &&
            !text.endsWith('\n')
          ) {
            consumeNextNewline = true;
          }
          this.scroll.insertAt(index, text);
          const [line, offset] = this.scroll.line(index);
          let formats = merge({}, bubbleFormats(line));
          if (line instanceof Block) {
            const [leaf] = line.descendant(LeafBlot, offset);
            formats = merge(formats, bubbleFormats(leaf));
          }
          attributes = AttributeMap.diff(formats, attributes) || {};
        } else if (typeof op.insert === 'object') {
          const key = Object.keys(op.insert)[0]; // There should only be one key
          if (key == null) return index;
          this.scroll.insertAt(index, key, op.insert[key]);
        }
        scrollLength += length;
      }
      // 2.对文本进行格式化
      Object.keys(attributes).forEach(name => {
        this.scroll.formatAt(index, length, name, attributes[name]);
      });
      return index + length;
    }, 0);
    normalizedDelta.reduce((index, op) => {
      if (typeof op.delete === 'number') {
        this.scroll.deleteAt(index, op.delete);
        return index;
      }
      return index + (op.retain || op.insert.length || 1);
    }, 0);
    this.scroll.batchEnd();
    this.scroll.optimize();
    return this.update(normalizedDelta);
  }

示例


    <div id="editor">
      // 内容区域
    </div>
    <script src="https://cdn.quilljs.com/1.0.0/quill.js"></script>
    <!-- Initialize Quill editor -->
    <script>
        var editor = new Quill('#editor', {
            modules: { toolbar: [['bold', 'italic'], ['link', 'image']] },
            theme: 'snow'
        });
    </script>

如下图: 截屏2021-04-18 下午7.01.22.png

当我们在编辑区域田间 DOM ,

  • setContents
setContents(delta: Delta, source: String = 'api'): Delta

setContents 用给定的内容覆盖编辑器的内容。内容应该以一个新行或者换行符结束。返回一个改变的 Delta。如果被给定的 Delta 没有无效操作,那么就会作为新的 Delta 通过。操作来源可能为:'user'、'api' 或者 'silent',默认是 api。

setContents:github.com/quilljs/qui…

// 源码
 setContents(delta, source = Emitter.sources.API) {
    return modify.call(
      this,
      () => {
        delta = new Delta(delta);
        const length = this.getLength();
        // 当富文本编辑器为空时,quill会插入 \n;
        const delete1 = this.editor.deleteText(0, length);
        // delta 总是在现有内容之前应用
        const applied = this.editor.applyDelta(delta);
        // 从空的编辑器初始化中删除多余的 \n
        const delete2 = this.editor.deleteText(this.getLength() - 1, 1);
        return delete1.compose(applied).compose(delete2);
      },
      source,
    );
  }
quill.setContents([
  { insert: '欢迎使用wangEditor富文本' },
  { insert: '编辑器!', attributes: { bold: true } },
  { insert: '\n' }
]);

通过传入 delta 对象和 source 操作来源,我们可以知道编辑器文本的变化以及是谁触发变化的,在方法中通过调用 applyDelta 方法把传入的 delta 数据渲染到编辑器中

  • updateContents (delta: Delta, source: String = 'api'): Delta
updateContents(delta: Delta, source: String = 'api'): Delta
quill/core/quill.js

updateContents(delta, source = Emitter.sources.API) {
    return modify.call(
      this,
      () => {
        delta = new Delta(delta);
        return this.editor.applyDelta(delta, source);
      },
      source,
      true,
    );
  }

updateContents 相比于 setContents 相对简单一些,没有对 ‘\n’ 进行什么操作,因为 setContents 以及处理了。但是多了一个入参 ‘true’,通过对 modify 方法源码查看,多了一个入参是为了获取用户的选择范围

function modify(modifier, source, index, shift) {
  ........
  let range = index == null ? null : this.getSelection();// 获取用户的选择范围
  const oldDelta = this.editor.delta;
  const change = modifier();
  if (range != null) {
    if (index === true) {
      index = range.index; // eslint-disable-line prefer-destructuring
    }
    if (shift == null) {
      range = shiftRange(range, change, source);
    } else if (shift !== 0) {
      range = shiftRange(range, index, shift, source);
    }
    this.setSelection(range, Emitter.sources.SILENT);
  }
  ........
  return change;
}

index 形参就是 updateContents 方法传入的第三个参数 true 的实参

当我们用鼠标选中了其中一段文本时,通过 getSelection 方法获取到鼠标选中的范围。之所以要传入这个参数是因为当用户更新内容的时候 quill 需要知道更新内容的范围

  • getContents
  getContents(index: Number = 0, length: Number = remaining): Delta

getContents 方法用来检索编辑器的内容,返回 Delta 对象表示的格式数据。该方法接受 indexlength 两个参数,表示要获取的内容开始内容的位置下标以及长度,有了这两个参数就可以准确获取到内容。

quill/core/quill.js

getContents(index = 0, length = this.getLength() - index) {
    ......
    return this.editor.getContents(index, length);
  }
quill/core/editor.js
  applyDelta(delta) {
  ......
    getContents(index, length) {
    return this.delta.slice(index, index + length);// 返回截取的内容
  }
  .....
 }

从上面的源码我们可以知道,通过调用 quill 的 getContents 方法会接着调用 applyDelta 方法中的 getContents 方法并传入 index , length 参数,通过对 applyDelta 方法中传入的 delta 进行截取,从而获取到内容。

  • getSelection
getSelection(focus = false): { index: Number, length: Number }

getSelection 检索用户的选择范围,如果编辑器没有焦点,则可能返回 null。

// 源码
getSelection(focus = false) {
    if (focus) this.focus();
    this.update();
    return this.selection.getRange()[0];// getRange 返回的是一个数组,数组的第一项是 range 对象
  }

截屏2021-04-18 下午7.10.58.png

getSelection 方法返回的是一个 Range 对象,里面包含了 index 和 length 属性,有了这两个属性我们就可以知道用户选中的区域。

格式化 Quill 的内容

Toolbar module 使用户可以格式化 Quill 的内容,它能够被配置一个自定义的容器和处理程序,下面列举了其中一种配置方式。

更多配置 quilljs.com/docs/module…

var editor = new Quill('#editor', {
            modules: { toolbar: [['bold', 'italic'], ['link', 'image']] },
            theme: 'snow'
});

效果图

截屏2021-04-29 上午7.06.29.png

当我们初始化编辑器的时候,首先会给 toolbar 选项 button 按钮添加 以 ql- 开头的 class 类名。

WeChat2899249e58d6fca4f7040e0e55c8a9a8.png

接下来再为每一个 button 注册一个 click 事件,通过截取类名ql- 后面字符串作为 format。通过获取到的 format,我就能为 delta 添加 attributes 属性。

quill/modules/toolbar.js

// 获取 container 容器内的所有 button, select
 Array.from(this.container.querySelectorAll('button, select')).forEach(
      input => {
        this.attach(input);
      },
);


attach(input) {
    let format = Array.from(input.classList).find(className => {
      return className.indexOf('ql-') === 0;
    });
    if (!format) return;
    // 截取类名ql- 后面字符串作为 format
    format = format.slice('ql-'.length);
    .......
    const eventName = input.tagName === 'SELECT' ? 'change' : 'click';
    input.addEventListener(eventName, e => {// 注册事件
      .......
    });
   .........
  }

WeChat476a05a11a97c10dd5e52d4970b63537.png

通过 click 点击事件以及 format 值 , 以及 getSelection 方法会返回包含了 index 下标和 length 长度的对象,给选中区域设置 attributes:{bold: true} 属性,实现文字加粗。

WeChatd9ed8e9870400590eae125e2d2a13d16.png

点击取消掉加粗时,会调用 Quill 的 removeFormat方法,传入 index, length, source 三个参数。

quill/core/quill.js

 removeFormat(index, length, source) {
    [index, length, , source] = overload(index, length, source);
    return modify.call(
      this,
      () => {
        return this.editor.removeFormat(index, length);
      },
      source,
      index,
    );
  }

WeChat7dcd71b9c1a79ff713c3c6ad7d43afb0.png

紧接着会调用 editor 的 removeFormat 方法。

quill/core/editor.js

 removeFormat(index, length) {
    const text = this.getText(index, length);// 获取要去掉格式化的文本
    const [line, offset] = this.scroll.line(index + length);
    let suffixLength = 0;
    let suffix = new Delta();
    if (line != null) {
      suffixLength = line.length() - offset;
      suffix = line
        .delta()
        .slice(offset, offset + suffixLength - 1)
        .insert('\n');// 获取去除选中要去掉格式化的文本的 delta ops 值
    }
    const contents = this.getContents(index, length + suffixLength);
    const diff = contents.diff(new Delta().insert(text).concat(suffix));// 进行 diff 算法
    const delta = new Delta().retain(index).concat(diff);
    return this.applyDelta(delta);
  }
  • const text = this.getText(index, length) 获取要去掉格式化的文本

WeChatda438fa9b9e9ea763c4c84bf30d47eb7.png

  • suffix = line.delta().slice(offset, offset + suffixLength - 1).insert('\n') 获取去除选中要去掉格式化后面文本的 delta 值

截屏2021-04-30 上午7.53.39.png

  • const contents = this.getContents(index, length + suffixLength) 获取选中去掉格式化文本节点以及后面节点的 delta 值

  • const diff = contents.diff(new Delta().insert(text).concat(suffix)) 算出要改变的文本节点属性

截屏2021-04-30 上午7.58.08.png

  • const delta = new Delta().retain(index).concat(diff) 获取格式化节点以及它前面的 delta 值

  • return this.applyDelta(delta) 更新 DOM 节点

WeChat39e82ef641fb7e002ac0ef4c198ad087.png

参考资料