前言
前面2篇文章我们分析了Quill的2个模块,一个是剪切板,一个是主题
这篇文章分析下Quill的工具栏模块(toolbar),看看它们内部是怎么实现的。
它们有2种类型的工具栏,主要是样式不一样,逻辑共用一套(modules目录下的toolbar.js)。
我们用的是snow主题的,样式如下
还有一种是bubble主题的,样式如下
以下分析都是基于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,如果value是false则把它当作默认值。比如:[{ header: ['1', '2', '3', false] }],则第四项就是默认值,会渲染成normal。如果想自定义名称,那么传参别传数组,传字符串或者dom元素,我们通过dom元素自定义我们的名称。
如果遍历toolbar的value不是数组,则调用addButton方法生成button标签。
如果有value则设置value。比如有序列表和无序列表,它们的value分别是ordered和bullet。
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);
}
双向
看上面的图,这个是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事件,然后会调用picker的update方法。
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的一个模块都要跟好几个模块有关联,你中有我,我中有你,这个过程真的看吐了,不好理解。
但是,如果梳理成功了,也会有一点点点开心,继续加油吧!