Quill之工具栏模块源码分析

495 阅读4分钟

前言

前面2篇文章我们分析了Quill的2个模块,一个是剪切板,一个是主题

这篇文章分析下Quill的工具栏模块(toolbar),看看它们内部是怎么实现的。

它们有2种类型的工具栏,主要是样式不一样,逻辑共用一套(modules目录下的toolbar.js)。

我们用的是snow主题的,样式如下

image.png

还有一种是bubble主题的,样式如下

image.png

以下分析都是基于quilljs的1.3.7版本

正文

我们先来看看工具栏toolbar.js初始化做了什么

class Toolbar extends Module {
  constructor (quill, options) {
    super(quill, options);
    if (Array.isArray(this.options.container)) {
      let container = document.createElement('div');
      addControls(container, this.options.container);
      quill.container.parentNode.insertBefore(container, quill.container);
      this.container = container;
    } else if (typeof this.options.container === 'string') {
      this.container = document.querySelector(this.options.container);
    } else {
      this.container = this.options.container;
    }
    if (!(this.container instanceof HTMLElement)) {
      return debug.error('Container required for toolbar', this.options);
    }
    this.container.classList.add('ql-toolbar');
    // ...省略代码
  }
}

首先会判断你传入的参数,toolbar的container是什么类型,支持三种类型:

  • 数组: 二维数组,类似上一篇文章讲的TOOLBAR_CONFIG,, 格式如[[{ header: ['1', '2', '3', false] }], ['bold', 'italic', 'underline']]
  • 字符串,会当作dom的选择器,读取里面的子元素,当作toolbar的配置
  • dom元素,同上。

如果是数组,会创建一个div,然后通过调用addControls方法

function addControls (container, groups) {
  if (!Array.isArray(groups[0])) {
    groups = [groups];
  }
  groups.forEach(function (controls) {
    let group = document.createElement('span');
    group.classList.add('ql-formats');
    controls.forEach(function (control) {
      if (typeof control === 'string') {
        addButton(group, control);
      } else {
        let format = Object.keys(control)[0];
        let value = control[format];
        if (Array.isArray(value)) {
          addSelect(group, format, value);
        } else {
          addButton(group, format, value);
        }
      }
    });
    container.appendChild(group);
  });
}

addControls方法会遍历toolbar的配置,如果是遍历到toolbar的value还是数组,就调用addSelect方法,去生成select标签和option标签

function addSelect (container, format, values) {
  let input = document.createElement('select');
  input.classList.add('ql-' + format);
  values.forEach(function (value) {
    let option = document.createElement('option');
    if (value !== false) {
      option.setAttribute('value', value);
    } else {
      option.setAttribute('selected', 'selected');
    }
    input.appendChild(option);
  });
  container.appendChild(input);
}

生成的select标签类名都有ql前缀,同时遍历values,如果valuefalse则把它当作默认值。比如:[{ header: ['1', '2', '3', false] }],则第四项就是默认值,会渲染成normal。如果想自定义名称,那么传参别传数组,传字符串或者dom元素,我们通过dom元素自定义我们的名称。

如果遍历toolbar的value不是数组,则调用addButton方法生成button标签。

如果有value则设置value。比如有序列表和无序列表,它们的value分别是orderedbullet

function addButton (container, format, value) {
  let input = document.createElement('button');
  input.setAttribute('type', 'button');
  input.classList.add('ql-' + format);
  if (value != null) {
    input.value = value;
  }
  container.appendChild(input);
}

双向

image.png

看上面的图,这个是toolbar最终渲染的结果。我们可以发现,生成的select是隐藏的,quill是额外生成span标签来模拟select的效果。

这部分逻辑是在ui目录下的picker.js,这个picker.js会在baseTheme调用extendToolbar方法,里面会调用buildPickers方法,在里面实例化picker。

class Picker {
  constructor(select) {
    this.select = select;
    this.container = document.createElement('span');
    this.buildPicker();
    this.select.style.display = 'none';
    this.select.parentNode.insertBefore(this.container, this.select);
  }
}    

实例picker的时候,会传入select标签,然后调用buildPicker方法,生成对应的span标签,模拟selection和option标签。

那么既然有span标签,又有select标签,它们是怎么双向关联起来的呢?

第一种

第一种是选择toolbar的选择框的时候,span标签会监听click事件,会设置selectedIndex

  selectItem(item, trigger = false) {
    // ...省略代码
   this.select.selectedIndex = [].indexOf.call(item.parentNode.children, item); // 刷新select的value
   // ...省略代码
    if (trigger) {
      if (typeof Event === 'function') {
        this.select.dispatchEvent(new Event('change'));
      } else if (typeof Event === 'object') {     // IE11
        let event = document.createEvent('Event');
        event.initEvent('change', true, true);
        this.select.dispatchEvent(event);
      }
      this.close();
    }
  }

然后会模拟事件来触发select的change事件

select标签会在toolbar初始化时,调用attach方法,监听select标签的change事件

  attach (input) {
    // ...省略代码
    let eventName = input.tagName === 'SELECT' ? 'change' : 'click';
    input.addEventListener(eventName, (e) => {
      // ...省略代码
      let [range,] = this.quill.selection.getRange();
      if (this.handlers[format] != null) {
        this.handlers[format].call(this, value);
      } else if (Parchment.query(format).prototype instanceof Parchment.Embed) {
      // ...省略代码
      } else {
        this.quill.format(format, value, Quill.sources.USER); // 格式
      }
      this.update(range);
    })
  }

所以触发的时候,会调用quill.format格式内容,这样就格式就设置成功了

第二种

第二种是聚焦编辑器的时候,如果聚焦的内容有设置了格式,那么工具栏此时对应的格式模块会高亮。

首先聚焦会触发EDITOR_CHANGE事件,toolbar会监听,导致触发update方法

    this.quill.on(Quill.events.EDITOR_CHANGE, (type, range) => {
      if (type === Quill.events.SELECTION_CHANGE) {
        this.update(range);
      }
    });

update方法会刷新select的value,如果有option就设置,没有就设置-1

  update (range) {
    let formats = range == null ? {} : this.quill.getFormat(range);
    this.controls.forEach(function (pair) {
      if (input.tagName === 'SELECT') {
         // 省略代码
        if (option == null) {
          input.value = '';   // TODO make configurable?
          input.selectedIndex = -1;
        } else {
          option.selected = true;
        }
      }
    });
  }

然后BaseTheme也会监听EDITOR_CHANGE事件,然后会调用pickerupdate方法。

    let update = () => {
      this.pickers.forEach(function (picker) {
        picker.update();
      });
    };
    this.quill.on(Emitter.events.EDITOR_CHANGE, update);

update方法也会根据select的selectedIndex,调用selectItem方法去刷新toolbar的span标签,这样就能实时刷新toolbar的显示。

  update() {
    let option;
    if (this.select.selectedIndex > -1) {
      let item = this.container.querySelector('.ql-picker-options').children[this.select.selectedIndex];
      option = this.select.options[this.select.selectedIndex];
      this.selectItem(item);
    } else {
      this.selectItem(null);
    }
  }

总结

分析完工具栏模块,我感觉quill的一个模块都要跟好几个模块有关联,你中有我,我中有你,这个过程真的看吐了,不好理解。

但是,如果梳理成功了,也会有一点点点开心,继续加油吧!