Quill源码初探

2,566 阅读5分钟

Quill 重要组层部分

目录结构

quill 
├── asset // stylus icon 资源
├── blots // 在 Parchment的EmbedBlot, InlineBlot、Blockblot的基础上实现
├── core  // 核心代码包括事件监听、quill类、selection光标、edit类
├── formats // 格式化文字 例如:blod、color、align 
├── themes // 主题样式
├── ui //一些颜色更变图片更改,下拉框,提示信息等ui
├── core.js 
└── quill.js //入口文件

同时quill将Parchment作为第三库单独维护。

Parchment

quill的文档模型。它是 DOM 树的抽象相当于vue的vnode tree,一个 Parchment 树是由 Blots 组成的,它反映了一个 DOM 节点的对应部分,相当于vnode。Blots 可以提供结构、格式(format)和/或内容。

blot

blot是Parchment基本组成部分,同时有一些基本的实现,如 Block、 Inline 和 Embed以便于进行二次开发。一个 Blot 必须使用静态 blotName 命名,并与 tagName 或 className 关联。如果同时使用标记和类定义了一个 Blot,则类优先。

// 链表结构有pre节点和next节点
export interface Blot extends LinkedNode {
  scroll: Root;
  parent: Parent;
  prev: Blot | null;
  next: Blot | null;
  domNode: Node;
  statics: {
    allowedChildren?: BlotConstructor[];
    blotName: string;
    className?: string;
    defaultChild?: BlotConstructor;
    requiredContainer?: BlotConstructor;
    scope: Scope;
    tagName: string;
  };
  // 格式化
  formatAt(index: number, length: number, name: string, value: any): void;
  // 内容更新函数
  update(mutations: MutationRecord[], context: { [key: string]: any }): void;
}

Attributor

属性是一种更轻量级的表示格式的方法。它们的 DOM 对应物是一个属性。就像 DOM 属性与节点的关系一样,attributer 也属于 Blots。在 Inline 或 Block blot 上调用 formats ()将返回相应的 DOM 节点所表示的格式,接口如下

class Attributor {
  // 属性名称
  attrName: string;
  // 在node节点挂载的属性名
  keyName: string;
  scope: Scope;
  whitelist: string[];
 
  constructor(attrName: string, keyName: string, options: Object = {});
  add(node: HTMLElement, value: string): boolean;
  canAdd(node: HTMLElement, value: string): boolean;
  remove(node: HTMLElement);
  value(node: HTMLElement);
}

Registry

全局注册 截屏2021-04-30 下午11.59.19.png

  1. 可以看到注册主要维护了一个blots,里面是真实node节点和Blot的映射集合。同时还有一些attributes、classes、tags、types 注册对象。
  2. 创建一个Blot的方法。和通过真实node找到对应的Blot方法...

delta

quill-delta也是作为第三库单独维护,它可以用来描述quill文本格式信息的内容和变化。没有 HTML 的模糊性和复杂性。将输入的内容转换为内部文档模型(在dom层上进行一层抽象,利用js结构对象来描述视图和操作),让使用者操作编辑器能更可控。通过quill的文档模型delta 设置content, 插入Hello并加粗.

quill.setContents(
    {
      "ops": [
        { "insert": "Hello",attributes: { bold: true  }},
      ]
    }
)

截屏2021-05-01 下午3.27.26.png

源码解析

根据以下代码 首先根据webpack配置找到入口文件core.jsquill.js

  • 以下代码都进行简化为了方便观察
import Quill from './core/quill';
Quill.register(
  {
    'attributors/attribute/direction': DirectionAttribute,
    'attributors/class/align': AlignClass,
    'attributors/style/size': SizeStyle,
  },
  true,
);
Quill.register(
    {
        'formats/align': AlignClass,
    }
)
Quill.register({
  'blots/block': Block
}

主要是做一些全局的formats、attribute、blots、class、style的注册,

quill实例化的过程

以下通过打断点调试创建quill实例化所要进行的步骤。

<div id="editor">
  <p>Hello World!</p>
  <p>Some initial <strong>bold</strong> text</p>
  <p><br></p>
</div>
<script src="../dist/quill.js"></script>
<script>
  var quill = new Quill('#editor', {
      theme: 'snow'
  });
<script>

首先进入到了Quill构造函数


class Quill {
    constructor(container, options = {}) {
        // mergeOptions 
        this.options = expandConfig(container, options);
        // emitter init
        this.emitter = new Emitter();
        // 通过blotname查询到相应的scrollblot构造函数
        const ScrollBlot = this.options.registry.query(
          Parchment.ScrollBlot.blotName,
        );
        // 挂载根blot
        this.scroll = new ScrollBlot(this.options.registry, this.root, {
          emitter: this.emitter,
        });
        // 初始化编辑器
        this.editor = new Editor(this.scroll);
        // selection init
        this.selection = new Selection(this.scroll, this.emitter);
        // 返回SnowTheme
        this.theme = new this.options.theme(this, this.options);
        // 初始化主题样式
        this.theme.init();
}

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

  1. 将用户的选项与默认的选项就行合并,Quill默认初始化的模块clipbordhistorykeyboardtoolbaruploader.

截屏2021-04-30 下午9.01.41.png 2. Emitter在EventEmitter3的基础上进行实现。主要就是维护一个listener对象,添加并监听selectionchangemousedownmouseupclick四个key,值对应相应事件的节点元素和处理回掉函数的对象数组。

  1. ScrollBlot作为所有blots的根节点,使用了MutationObserver监听dom结点的变化。比如在编辑器中新增文字,则会被oberserve拦截到,然后调用update函数。
// parchment/src/blot/scroll.ts
class ScrollBlot extends ParentBlot implements Root {
  constructor(registry: Registry, node: HTMLDivElement) {
    super(null, node);
    // 拦截editor内容的dom变化的回掉函数
    this.observer = new MutationObserver((mutations: MutationRecord[]) => {
      // 更新节点内容
      this.update(mutations);
    });
    // 监听dom节点并配置相关参数
    this.observer.observe(this.domNode, OBSERVER_CONFIG);
  }
  1. theme init主要的流程如下
 // core/theme.js
 init() {
     // 添加options.moudle下的的各个模块
    Object.keys(this.options.modules).forEach(name => {
      if (this.modules[name] == null) {
      // 当前this为SnowTheme所以调用base.js的BaseTheme addModule
        this.addModule(name);
      }
    });
  }
  addModule(name) {
     // 根据各个模块名称返回不同的构造函数 例如`Keyboard`
    const ModuleClass = this.quill.constructor.import(`modules/${name}`);
    // 完成模块的实现
    this.modules[name] = new ModuleClass(
      this.quill,
      this.options.modules[name] || {},
    );
    return this.modules[name];
  }
// themes/base.js
addModule(name) {
    // 调用theme.js 的addModule
    const module = super.addModule(name);
    if (name === 'toolbar') {
      this.extendToolbar(module);
    }
    return module;
}
// themes/snow.js
// 根据不同主题扩展toolbar ,此以snow theme为例
extendToolbar(toolbar) {
    toolbar.container.classList.add('ql-snow');
    // 绑定图标
    this.buildButtons(toolbar.container.querySelectorAll('button'), icons);
    // 绑定选择后的下拉框图标
    this.buildPickers(toolbar.container.querySelectorAll('select'), icons);
    // 提示
    this.tooltip = new SnowTooltip(this.quill, this.options.bounds);
    if (toolbar.container.querySelector('.ql-link')) {
        //绑定快捷键
      this.quill.keyboard.addBinding(
        { key: 'k', shortKey: true },
        (range, context) => {
          toolbar.handlers.link.call(toolbar, !context.format.link);
        },
      );
    }
  }

至此toolbar和内置的module已经完全初始化好了

前置总流程图

以下是获取重新获取焦点的代码

focus() {
    if (this.hasFocus()) return;
    this.root.focus();
    this.setRange(this.savedRange);
  }

选区文字加粗流程

class Toolbar extends Module {
  constructor(quill, options) {
    super(quill, options);
    Array.from(this.container.querySelectorAll('button, select')).forEach(
      input => {
      // 给toolbar按钮添加监听事件
        this.attach(input);
      },
    );
}
 attach(input) {
    const eventName = input.tagName === 'SELECT' ? 'change' : 'click';
    input.addEventListener(eventName, e => {
        // 修改格式
        this.quill.format(format, true, Quill.sources.USER);
    });
  }

因为在toolbar里按钮都添加了监听事件,所以到加粗文本后最终会执行core/quill format函数,所以在这个地方加个断点,看下具体的修改流程 截屏2021-04-30 下午10.36.44.png format核心代码

// core/quill.js

format(name, value, source = Emitter.sources.API) {
// Handle selection preservation and TEXT_CHANGE emission
// common to modification APIs
    return modify.call(
      this,
      () => {
        // 返回range 对象{index: 34,length: 1}
        const range = this.getSelection(true);
        let change = new Delta();
        if (range == null) return change;
        // 如果是块级元素
        if (this.scroll.query(name, Parchment.Scope.BLOCK)) {
          change = this.editor.formatLine(range.index, range.length, {
            [name]: value,
          });
        // 没有选择文字
        } else if (range.length === 0) {
          this.selection.format(name, value);
          return change;
        } else {
          // 选字加粗会走这 调用以下的core/editor.js formatText方法
          change = this.editor.formatText(range.index, range.length, {
            [name]: value,
          });
        }
        // 修改完后需要重新选中选区
        this.setSelection(range, Emitter.sources.SILENT);
        // 返回delta change
        return change;
      },
      source,
    );
  }
// core/quill.js
function modify(modifier, source, index, shift) {
  let range = index == null ? null : this.getSelection();
  const oldDelta = this.editor.delta;
  // 调用modify回掉函数获得delta change
  const change = modifier();
  if (range != null) {
    if (index === true) {
      index = range.index; 
    }
     // 没有偏移的长度shift
    if (shift == null) {
      range = shiftRange(range, change, source);
    } else if (shift !== 0) {
       // 根据delta和shift偏移长度计算出index,length 并生成返回Range对象
       // 主要会调用Delta.transformPosition获取 如下quill-delta的方法
      range = shiftRange(range, index, shift, source);
    }
    // 设置光标
    this.setSelection(range, Emitter.sources.SILENT);
  }
  // 触发事件
  if (change.length() > 0) {
    const args = [Emitter.events.TEXT_CHANGE, change, oldDelta, source];
    this.emitter.emit(Emitter.events.EDITOR_CHANGE, ...args);
    if (source !== Emitter.sources.SILENT) {
      this.emitter.emit(...args);
    }
  }
  return change;
}
// quill-delta/delta.js
   // 根据ops转换光标位置
  Delta.prototype.transformPosition = function(){
    var thisIter = Op_1.default.iterator(this.ops);
    var offset = 0
     // 迭代ops数组
     while (thisIter.hasNext() && offset <= index) {
        var length_4 = thisIter.peekLength();// 获取单个ops的长度
        var nextType = thisIter.peekType(); //获取单个ops的类型 contains、delete、insert
        thisIter.next();
        // delete
        if (nextType === 'delete') {
            index -= length_4;
            continue;
        }
        else if (nextType === 'insert')) {
            index += length_4;
        }
        // insert、contain defalut
        offset += length_4;
    }
    return index
  }
  
// core/editor.js
 formatText(index, length, formats = {}) {
     // 根据索引和长度进行格式修改
    Object.keys(formats).forEach(format => {
      this.scroll.formatAt(index, length, format, formats[format]);
    });
    // {ops:[{retain: 34},{attributes: {bold: true},retain: 1}]}
    const delta = new Delta().retain(index).retain(length,cloneDeep(formats));
    // 主要会diff newDelta 和old Delta 并返回change
    return this.update(delta);
  }

调用的format函数最终会调用以下函数

// parchment/src/blot/scroll.ts
public update(
    mutations?: MutationRecord[],
    context: { [key: string]: any } = {},
  ): void {
    mutations = mutations || this.observer.takeRecords();
    // mutationMap:{Node1:[MutationRecord1,MutationRecord2],Node2...}
    const mutationsMap = new WeakMap();
    // 根据node节点返回对应的blot数组
    mutations
      .map((mutation: MutationRecord) => {
         // 根据node节点找到对应的blot
        const blot = Registry.find(mutation.target, true);
        if (blot == null) {
          return null;
        }
        // 根据node节点生成变化的mutationRecord数组且添加mutationsMap映射关系
        if (mutationsMap.has(blot.domNode)) {
          mutationsMap.get(blot.domNode).push(mutation);
          return null;
        } else {
          mutationsMap.set(blot.domNode, [mutation]);
          return blot;
        }
      }) //遍历所有的blot进行更新
      .forEach((blot: Blot | null) => {
        if (blot != null && blot !== this && mutationsMap.has(blot.domNode)) {
          blot.update(mutationsMap.get(blot.domNode) || [], context);
        }
      });
    context.mutationsMap = mutationsMap;
    // 执行parent的update
    if (mutationsMap.has(this.domNode)) {
      super.update(mutationsMap.get(this.domNode), context);
    }
    this.optimize(mutations, context);
  }
  // 优化DOM操作都降低DOM树的复杂性。
  optimize(mutations = [], context = {}) {
    if (this.batch) return;
    super.optimize(mutations, context);
    if (mutations.length > 0) {
       // 触发 SCROLL_OPTIMIZE 事件
      this.emitter.emit(Emitter.events.SCROLL_OPTIMIZE, mutations, context);
    }
  }
}

因为调用关系太多了,所以省略中间过程,最终加粗操作的核心逻辑在此

// parchment/src/blot/abstract/shadow.ts
public formatAt(
    index: number,
    length: number,
    name: string,
    value: any,
  ): void {
     // 根据索引分割字符
    const blot = this.isolate(index, length);
      // 在t字符外面包裹一层<strong></strong>
      blot.wrap(name, value);
  }
  public wrap(name: string | Parent, value?: any): Parent {
    // 创建Blod Attributor
    const wrapper =
      typeof name === 'string'
        ? (this.scroll.create(name, value) as Parent)
        : name;
    if (this.parent != null) {
      // 最终会根据blot调用相应的dom的insertBefore方法,
      // 这段因为第二个传的参数为undefined所以会插入在父节点最后面
      this.parent.insertBefore(wrapper, this.next || undefined);
    }
    // 将strong标签里增加t"字符"
    wrapper.appendChild(this);
    return wrapper;
  }

过程效果图如下 截屏2021-05-01 下午1.37.28.png

截屏2021-05-01 下午1.38.22.png

输入和删除文本的流程

输入文字(dom变化)会被MutationObserver捕获到并调用update函数

// parchment/src/blot/scroll.ts
this.observer = new MutationObserver((mutations: MutationRecord[]) => {
      this.update(mutations);
});
// core/scroll.js
update(){
    if (mutations.length > 0) {
      this.emitter.emit(Emitter.events.SCROLL_BEFORE_UPDATE, source, mutations);
     }
    super.update(mutations.concat([])); 
    if (mutations.length > 0) {
      this.emitter.emit(Emitter.events.SCROLL_UPDATE, source, mutations);
    }
}

如上update函数主要实现三个事情

  • super.update(mutations.concat([]))更新函数
  • 触发SCROLL_BEFORE_UPDATE事件
  • 触发SCROLL_UPDATE事件(最后会执行一次性的那个监听事件) 除此之外,输入文字意味着光标会发生改变,所以最后还会执行selection里selectionchange事件
  1. update更新函数 update函数上文提到了,所以不再赘述,主要会根据mutations执行相应的blot更新,如下
// parchment/src/blot/text.ts
public update(
    mutations: MutationRecord[],
    _context: { [key: string]: any },
  ): void {
     // 过滤type为文本且mutation作用的目标和当前blot的dom一样
    if (
      mutations.some((mutation) => {
        return (
          mutation.type === 'characterData' && mutation.target === this.domNode
        );
      })
    ) {
      // 修改Textblot的 text 属性。原来为:Hello 现在更新为:Helloa
      this.text = this.statics.value(this.domNode);
    }
  }
  1. 触发SCROLL_BEFORE_UPDATE事件
// core/selection.js
this.emitter.on(Emitter.events.SCROLL_BEFORE_UPDATE, () => {
      if (!this.hasFocus()) return;
      // 获取光标对象
      const native = this.getNativeRange();
      if (native == null) return;
      if (native.start.node === this.cursor.textNode) return; // cursor.restore() will handle
      // 一次性监听,当触发一次后,会移除该监听
      // 注意Emitter.events.SCROLL_UPDATE事件队列里现在有两个事件
      // 这是后者,在quill 监听的update事件后执行!
      this.emitter.once(Emitter.events.SCROLL_UPDATE, () => {
        // 更新光标
        this.update(Emitter.sources.SILENT);
      });
    });

保存一个当前的editor保存一个全局delta变量,以及触发给用户监听Emitter.events.TEXT_CHANGE事件来得到 delta change

  1. 触发Emitter.events.SCROLL_UPDATE事件
// core/quill.js
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函数就不赘述了,上述加粗操作有仔细说
  modify.call(
    this,
    // 回调update方法会返回deltaChange
    () => this.editor.update(null, mutations, selectionInfo),
    source,
  );
});
// core/editor.js
update(change, mutations = [], selectionInfo = undefined) {
    const oldDelta = this.delta;
    // 必须是ascill的普通文本
    if (
      mutations.length === 1 &&
      mutations[0].type === 'characterData' &&
      mutations[0].target.data.match(ASCII) &&
      this.scroll.find(mutations[0].target)
    ) {
      // Optimization for character changes
      // 根据node节点 找到对应的textBlot
      const textBlot = this.scroll.find(mutations[0].target);
      // 找到blot的format并冒泡查找夫节点的format
      const formats = bubbleFormats(textBlot);
      // textblot 在根节点中的偏移量,显然当前为0
      const index = textBlot.offset(this.scroll);
      // Zero width no break space
      const oldValue = mutations[0].oldValue.replace(CursorBlot.CONTENTS, '');
      // ops:[{insert: "Hello"}]
      const oldText = new Delta().insert(oldValue);
      // ops:[{insert: "Helloa"}]
      const newText = new Delta().insert(textBlot.value());
      // 获取相对于编辑器的光标位置
      const relativeSelectionInfo = selectionInfo && {
        oldRange: shiftRange(selectionInfo.oldRange, -index),
        newRange: shiftRange(selectionInfo.newRange, -index),
      };
      // 得到ops:[{retain: 5},{insert: "a"}]
      const diffDelta = new Delta()
        .retain(index)
        .concat(oldText.diff(newText, relativeSelectionInfo));
      // 获取insert后的delta change- 包含格式
      // ops:[{retain: 5},{attributes: {bold: true},insert: "a" } ]
      change = diffDelta.reduce((delta, op) => {
        if (op.insert) {
          return delta.insert(op.insert, formats);
        }
        return delta.push(op);
      }, new Delta());
      // 合并两次delta
   // { ops:[ {attributes: {bold: true},insert: "Helloa"},{ insert: "\n"} ]}
      this.delta = oldDelta.compose(change);
    } else {
      this.delta = this.getDelta();
      if (!change || !isEqual(oldDelta.compose(change), this.delta)) {
        change = oldDelta.diff(this.delta, selectionInfo);
      }
    }
    // 返回oldDelta 和 newDelta 的 diff
    return change;
  }
}
  1. 执行selectionchange事件
// core/selection.js
this.emitter.listenDOM('selectionchange', document, () => {
  if (!this.mouseDown && !this.composing) {
    setTimeout(this.update.bind(this, Emitter.sources.USER), 1);
  }
});
 update(source = Emitter.sources.USER) {
    const oldRange = this.lastRange;
    // 获取最新的标准化后的range和普通的range对象
    const [lastRange, nativeRange] = this.getRange();
    this.lastRange = lastRange;
    this.lastNative = nativeRange;
    if (this.lastRange != null) {
      this.savedRange = this.lastRange;
    }
    if (!isEqual(oldRange, this.lastRange)) {
      if (
        !this.composing &&
        nativeRange != null &&
        nativeRange.native.collapsed &&
        nativeRange.start.node !== this.cursor.textNode
      ) {
        // 重置光标
        const range = this.cursor.restore();
        if (range) {
          this.setNativeRange(
            range.startNode,
            range.startOffset,
            range.endNode,
            range.endOffset,
          );
        }
      }
      // 上一次的光标位置,最新的光标位置,来源
      const args = [
        Emitter.events.SELECTION_CHANGE,
        cloneDeep(this.lastRange),
        cloneDeep(oldRange),
        source,
      ];
      // 触发给用户的Emitter.events.EDITOR_CHANGE监听事件
      this.emitter.emit(Emitter.events.EDITOR_CHANGE, ...args);

    }
  }
}

getRange方法获取标准化Range通过调用this.normalizedToRange();实现

// 根据普通的Range进行标准化(简单的格式)
class Range {
  constructor(index, length = 0) {
    this.index = index;
    this.length = length;
  }
}
normalizedToRange(range) {
    // 初始开始光标结点位置,若为选中文字(即开始光标和结束光标不相等)则push光标结结束节点位置
    const positions = [[range.start.node, range.start.offset]];
    if (!range.native.collapsed) {
      positions.push([range.end.node, range.end.offset]);
    }
    const indexes = positions.map(position => {
      const [node, offset] = position;
      const blot = this.scroll.find(node, true);
      // 获取textblot在编辑器中的开始位置
      const index = blot.offset(this.scroll);
      if (offset === 0) {
        return index;
      }
      // 如果是TextBlot EmbedBlot 索引将要加上自身的offset,
      if (blot instanceof LeafBlot) {
        return index + blot.index(node, offset);
      }
      return index + blot.length();
    });
    // 结束光标位置
    const end = Math.min(Math.max(...indexes), this.scroll.length() - 1);
    // 开始光标位置
    const start = Math.min(end, ...indexes);
    return new Range(start, end - start);
  }
  1. blot.offset方法调用了shadow.ts的offset方法执行了this.parent.children.offset(this) + this.parent.offset(root); 获取了父亲blot(strong加粗结点)的子结点链表结构,当前文本处于链表的什么位置 + 父亲blot在祖父blot的offset..一直递归下去,直到根结点为止,具体实现如下
// parchment/src/collection/linked-list.ts
public offset(target: T): number {
    let index = 0;
    //  获取链表头
    let cur = this.head;
    while (cur != null) {
      if (cur === target) {
        return index;
      }
      //length函数返回的是文本的长度 == this.text.length
      index += cur.length();
      // 链表的下一个结点
      cur = cur.next as T;
    }
    return -1;
  }
  1. Blot大概可以分成两种 第一种是继承ParentBlot的
  • ContainerBlot 容器节点
  • ScrollBlot root 文档的根节点,不可格式化
  • BlockBlot 块级 可格式化的父级节点
  • InlineBlot 内联 可格式化的父级节点 第二种是继承LeafBlot的 EmbedBlot 嵌入式节点 和 TextBlot 文本两种类型的主要区别在于继承LeafBlot的节点是都属于独立的节点,不可用做Parent节点,两者没有对child节点操作的方法。

删除文本流程

moudules下的keyboard模块在初始化的时候会维护一个binding对象里面绑定各种键盘按键对应的监听事件和属性,如下:

在之前的加粗基础上,再删除a来体验下删除的流程。quill会拦截keydown事件,主要就是功能就是终止一些事件。

// moudules/keyboard.js
listen() {
    this.quill.root.addEventListener('keydown', evt => {
      // 获取键盘按键对应的binding 此时是Backspace
       const bindings = (this.bindings[evt.key] || []).concat(
        this.bindings[evt.which] || [],
      );
       const matches = bindings.filter(binding => Keyboard.match(evt, binding));
      const range = this.quill.getSelection();

      if (range == null || !this.quill.hasFocus()) return;
      // 根据 range 获取行信息,获取叶子(文本信息),
      const [line, offset] = this.quill.getLine(range.index);
      const [leafStart, offsetStart] = this.quill.getLeaf(range.index);
      const [leafEnd, offsetEnd] =
        range.length === 0
          ? [leafStart, offsetStart]
          : this.quill.getLeaf(range.index + range.length);
        // index 光标选中的叶子节点的后缀和前缀
      const prefixText =
        leafStart instanceof TextBlot
          ? leafStart.value().slice(0, offsetStart)
          : '';
      const suffixText =
        leafEnd instanceof TextBlot ? leafEnd.value().slice(offsetEnd) : '';
        // 组成上下文对象
      const curContext = {
        collapsed: range.length === 0,
        empty: range.length === 0 && line.length() <= 1,
        format: this.quill.getFormat(range),
        line,
        offset,
        prefix: prefixText,
        suffix: suffixText,
        event: evt,
      };
      const prevented = matches.some(binding => {
        // 满足一些条件...
      });
      // 中止此时事件
      if (prevented) {
        evt.preventDefault();
      }
    });
  }

之后就会放行,a字符被删除,被MutationObserver监听到,然后主要修改Textblottext 属性。原来为:Helloa 现在更新为:Hello,触发两次Emitter.events.EDITOR_CHANGE事件,一次传给用户delta change,另一次传range change信息

回车换行流程

 // core/selection.js
 handleDragging() {
    this.emitter.listenDOM('mousedown', document.body, () => {
      this.mouseDown = true;
    });
    this.emitter.listenDOM('mouseup', document.body, () => {
      this.mouseDown = false;
      this.update(Emitter.sources.USER);
    });
  }
 update(source = Emitter.sources.USER) {
    const oldRange = this.lastRange;
    const [lastRange, nativeRange] = this.getRange();
    this.lastRange = lastRange;
    this.lastNative = nativeRange;
    if (this.lastRange != null) {
      this.savedRange = this.lastRange;
    }
    if (!isEqual(oldRange, this.lastRange)) {
      if (
        !this.composing &&
        nativeRange != null &&
        nativeRange.native.collapsed &&
        nativeRange.start.node !== this.cursor.textNode
      ) {
        const range = this.cursor.restore();
        if (range) {
          this.setNativeRange(
            range.startNode,
            range.startOffset,
            range.endNode,
            range.endOffset,
          );
        }
      }
      const args = [
        Emitter.events.SELECTION_CHANGE,
        cloneDeep(this.lastRange),
        cloneDeep(oldRange),
        source,
      ];
      this.emitter.emit(Emitter.events.EDITOR_CHANGE, ...args);
      if (source !== Emitter.sources.SILENT) {
        this.emitter.emit(...args);
      }
    }
  }
}
  • 如代码所示selection会监听mousedownmouseup事件,主要调用更新函数(上文仔细说了),在selection实例上保存最新的range对象即lastRangelastNative属性,方以便后续逻辑处理。
  • 同时会触发Emitter.events.EDITOR_CHANGE事件传入新旧的Range对象当作用户回掉函数的参数.

光标回车换行流程

截屏2021-05-09 下午8.15.07.png 如图在a后面敲下回车键,会自动生成一段p标签并且里面嵌套了个

在源码中core/quill.js里有如下代码:

    const contents = this.clipboard.convert({
      html: `${html}<p><br></p>`,
      text: '\n',
    this.setContents(contents);

因为在classql-editordiv上有属性contenteditable,所以当回车时编辑器里默认会多加个<p><br></p>

截屏2021-05-09 下午8.13.18.png 可以看到,两个p节点对应的blot在scrollBlot.children的链表结构中,

// modules/keyboard
handleEnter(range, context) {
    // 查找块级的format 此案例返回空
    const lineFormats = Object.keys(context.format).reduce(
      (formats, format) => {
        if (
          this.quill.scroll.query(format, Scope.BLOCK) &&
          !Array.isArray(context.format[format])
        ) {
          formats[format] = context.format[format];
        }
        return formats;
      },
      {},
    );
    // 生成插入换行符的delta
    const delta = new Delta()
      .retain(range.index)
      .delete(range.length)
      .insert('\n', lineFormats);
    // 更新编辑器内容
    this.quill.updateContents(delta, Quill.sources.USER);
    // 修改光标位置
    // 主要会调用this.selection.setRange(7, 0), 'slient');方法
    this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
    this.quill.focus();
    // 修改换行后的格式
    this.quill.format(name, context.format[name], Quill.sources.USER);
  }
}

delta : {ops: [{ retain: 6 }, { insert: "\n" }]},所以 主要看下光标的的设置

 setRange(range, force = false, source = Emitter.sources.API) {
      // 获取光标的起始结束真实dom的结点和索引位置startNode,startOffset,endNode,endOffset
      const args = this.rangeToNative(range);
      this.setNativeRange(...args, force);
}

主要过程就是两部分:

  1. 根据blot获取真实光标信息
rangeToNative(range) {
   // 获取光标的开始和结束位置
    const indexes = range.collapsed
      ? [range.index]
      : [range.index, range.index + range.length];
    const args = [];
    // 获取根节点下的blots的长度
    const scrollLength = this.scroll.length();
    indexes.forEach((index, i) => {
      index = Math.min(scrollLength - 1, index);
      // 获取textblot和它的长度
      const [leaf, leafOffset] = this.scroll.leaf(index);
      // 获取text的真实dom和它的长度
      const [node, offset] = leaf.position(leafOffset, i !== 0);
      args.push(node, offset);
    });
    // 如果只有光标的开始节点,则说明没有选中文字,即开始节点和结束节点一样。
    if (args.length < 2) {
      return args.concat(args);
    }
    return args;
  }

scrollLength调用的是scrollBlot.chidren的长度,因为有两个p元素的blot,所以用delta 来描述就是 [{insert: "Helloa", attributes: {…}},{insert: "\n"}],[{insert: "\n"}],自然返回的长度就是8

  1. 设置光标位置
setNativeRange(
    startNode,
    startOffset,
    endNode = startNode,
    endOffset = startOffset,
    force = false,
  ) {
    const selection = document.getSelection();
    if (startNode != null) {
      // 获取原生的range对象
      const { native } = this.getNativeRange() || {};
      // 传入的range是否与当前range不一样
      if (
        native == null ||
        force ||
        startNode !== native.startContainer ||
        startOffset !== native.startOffset ||
        endNode !== native.endContainer ||
        endOffset !== native.endOffset
      ) {
         // 如果光标选中的是br,就让光标回到br前
        if (startNode.tagName === 'BR') {
           // 得到br在父节点的偏移量
          startOffset = Array.from(startNode.parentNode.childNodes).indexOf(
            startNode,
          );
          // 将startNode 改为父节点
          startNode = startNode.parentNode;
        }
        // 同理
        if (endNode.tagName === 'BR') {
          endOffset = Array.from(endNode.parentNode.childNodes).indexOf(
            endNode,
          );
          endNode = endNode.parentNode;
        }
        // 更新range的开始结束节点及相应的位置
        const range = document.createRange();
        range.setStart(startNode, startOffset);
        range.setEnd(endNode, endOffset);
        selection.removeAllRanges();
        selection.addRange(range);
      }
    } else {
      // 清除光标 定位到根结点上
      selection.removeAllRanges();
      this.root.blur();
    }
  }

设置的range对象startNodeendNode都是p元素startOffsetendOffset都为0,所以光标显示在编辑器的第二行.