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
全局注册
- 可以看到注册主要维护了一个blots,里面是真实node节点和Blot的映射集合。同时还有一些attributes、classes、tags、types 注册对象。
- 创建一个Blot的方法。和通过真实node找到对应的Blot方法...
delta
quill-delta也是作为第三库单独维护,它可以用来描述quill文本格式信息的内容和变化。没有 HTML 的模糊性和复杂性。将输入的内容转换为内部文档模型(在dom层上进行一层抽象,利用js结构对象来描述视图和操作),让使用者操作编辑器能更可控。通过quill的文档模型delta 设置content, 插入Hello并加粗.
quill.setContents(
{
"ops": [
{ "insert": "Hello",attributes: { bold: true }},
]
}
)
源码解析
根据以下代码
首先根据webpack配置找到入口文件core.js
和 quill.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();
}
- 将用户的选项与默认的选项就行合并,Quill默认初始化的模块
clipbord
、history
、keyboard
、toolbar
、uploader
.
2. Emitter在EventEmitter3的基础上进行实现。主要就是维护一个listener对象,添加并监听
selectionchange
、mousedown
、mouseup
、click
四个key,值对应相应事件的节点元素和处理回掉函数的对象数组。
- 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);
}
- 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
函数,所以在这个地方加个断点,看下具体的修改流程
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;
}
过程效果图如下
输入和删除文本的流程
输入文字(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事件
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);
}
}
- 触发
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
- 触发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;
}
}
- 执行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);
}
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;
}
- 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监听到,然后主要修改Textblot
的 text
属性。原来为: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会监听
mousedown
和mouseup
事件,主要调用更新函数(上文仔细说了),在selection
实例上保存最新的range
对象即lastRange
和lastNative
属性,方以便后续逻辑处理。 - 同时会触发
Emitter.events.EDITOR_CHANGE
事件传入新旧的Range
对象当作用户回掉函数的参数.
光标回车换行流程
如图在a后面敲下回车键,会自动生成一段p标签并且里面嵌套了个
在源码中core/quill.js
里有如下代码:
const contents = this.clipboard.convert({
html: `${html}<p><br></p>`,
text: '\n',
this.setContents(contents);
因为在class
为ql-editor
的div
上有属性contenteditable
,所以当回车时编辑器里默认会多加个<p><br></p>
可以看到,两个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);
}
主要过程就是两部分:
- 根据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
- 设置光标位置
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
对象startNode
和endNode
都是p元素startOffset
和endOffset
都为0,所以光标显示在编辑器的第二行.