简介
富文本编辑器,具备良好的可配置性和可扩展性
官网: quilljs.com/
中文翻译版:doc.quilljs.cn/1409366
优点:
简单使用
<div id="editor" />
new Quill('#editor', options); // options为配置项
复制代码
渲染效果
工具栏的两种使用方式
- 数组选项
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ 'header': 1 }, { 'header': 2 }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'direction': 'rtl' }],
[{ 'size': ['small', false, 'large', 'huge'] }],
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'color': [] }, { 'background': [] }],
[{ 'font': [] }],
[{ 'align': [] }],
['link', 'image', 'video', 'formula'],
['clean'],
];
const quill = new Quill('#editor', {
modules: {
toolbar: toolbarOptions
},
theme: 'snow'
});
复制代码
- html
<div id="toolbar-container">
<span class="ql-formats">
<select class="ql-font"></select>
<select class="ql-size"></select>
</span>
<span class="ql-formats">
<button class="ql-bold"></button>
<button class="ql-italic"></button>
<button class="ql-underline"></button>
<button class="ql-strike"></button>
</span>
<span class="ql-formats">
<select class="ql-color"></select>
<select class="ql-background"></select>
</span>
<span class="ql-formats">
<button class="ql-script" value="sub"></button>
<button class="ql-script" value="super"></button>
</span>
<span class="ql-formats">
<button class="ql-header" value="1"></button>
<button class="ql-header" value="2"></button>
<button class="ql-blockquote"></button>
<button class="ql-code-block"></button>
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered"></button>
<button class="ql-list" value="bullet"></button>
<button class="ql-indent" value="-1"></button>
<button class="ql-indent" value="+1"></button>
</span>
<span class="ql-formats">
<button class="ql-direction" value="rtl"></button>
<select class="ql-align"></select>
</span>
<span class="ql-formats">
<button class="ql-link"></button>
<button class="ql-image"></button>
<button class="ql-video"></button>
<button class="ql-formula"></button>
</span>
<span class="ql-formats">
<button class="ql-clean"></button>
</span>
</div>
<div id="editor"></div>
<script>
const quill = new Quill('#editor', {
modules: {
toolbar: '#toolbar'
}
});
</script>
复制代码
主题
- snow
2. bubble
获取富文本html
const html = quill.root.innerHTML;
复制代码
配置项
API
以上souce有三个值:
- user
- api
- silent
api表示使用api去操作quill内容,silent表示静默的操作quill内容,user和api类似,表示用户触发的操作
这个参数主要会影响两个地方,一个是quill的事件是否触发,另一个是redo/undo是否记录。
quill中比较重要的有三个事件:
- text-change:当Quill的内容发生改变时触发(source为silent不会触发)
- selection-change: 当Quill的光标选区发生改变时触发,null表示失焦(source为silent不会触发)
- editor-change:前面两个事件甚至是silent的事件都会触发
实现原理
可编辑
/quill-1.3.7/blots/scroll.js
enable(enabled = true) {
this.domNode.setAttribute('contenteditable', enabled);
}
复制代码
contenteditable 属性规定元素内容是否可编辑。
如果元素未设置 contenteditable 属性,那么元素会从其父元素继承该属性。
工具栏选择 -> 编辑器中展示效果
- 定义工具栏image图标的点击回调
2. insert({ image: e.target.result }) 是怎么执行的呢?
即在Delta实例中增加属性
ops:[{insert: {image: 'xxxxxxx......'}}]
复制代码
- updateContents插入图片
quill.updateContents
应用传入的Delta数据,将其渲染到编辑器中
->
editor.applyDelta
reduce Delta的ops,分别insert(调用scroll.inserAt和scroll.formatAt)、delete(调用scroll.deleteAt)。
->
scroll.insertAt
->
ScrollBlot.insertAt
->
ContainerBlot.insertAt
\parchment\src\blot\abstract\container.ts
insertAt(index: number, value: string, def?: any): void {
let [child, offset] = this.children.find(index);
if (child) {
child.insertAt(offset, value, def);
} else {
let blot = def == null ? Registry.create('text', value) : Registry.create(value, def);
this.appendChild(blot);
}
}
复制代码
以上Registry.create
export function create(input: Node | string | Scope, value?: any): Blot {
let match = query(input);
if (match == null) {
throw new ParchmentError(`Unable to create ${input} blot`);
}
let BlotClass = <BlotConstructor>match;
let node =
// @ts-ignore
input instanceof Node || input['nodeType'] === Node.TEXT_NODE ? input : BlotClass.create(value);
return new BlotClass(<Node>node, value);
}
复制代码
通过query去查找相应的BlotClass
export function query(
query: string | Node | Scope,
scope: Scope = Scope.ANY,
): Attributor | BlotConstructor | null {
let match;
if (typeof query === 'string') {
match = types[query] || attributes[query];
// @ts-ignore
} else if (query instanceof Text || query['nodeType'] === Node.TEXT_NODE) {
match = types['text'];
} else if (typeof query === 'number') {
if (query & Scope.LEVEL & Scope.BLOCK) {
match = types['block'];
} else if (query & Scope.LEVEL & Scope.INLINE) {
match = types['inline'];
}
} else if (query instanceof HTMLElement) {
let names = (query.getAttribute('class') || '').split(/\s+/);
for (let i in names) {
match = classes[names[i]];
if (match) break;
}
match = match || tags[query.tagName];
}
if (match == null) return null;
// @ts-ignore
if (scope & Scope.LEVEL & match.scope && scope & Scope.TYPE & match.scope) return match;
return null;
}
复制代码
在types中或attributes中查找,而这些是通过quill初始化的时候register进去到types中的。所以使用Parchment之前要先注册。
找到对应的:formats/image.js
quill.insertEmbed(10, 'image', 'https://quilljs.com/images/cloud.png');
复制代码
即会调起formats/image.js,生成图片标签,设置src
另想要自定义图片的渲染,可以写一个类似以上的js,在初始化时:
Quill.register(Image, true); // Image自定义
复制代码
以上,返回了Image Blot
->
还需要通过appendChild转成Dom
appendChild(other: Blot): void { // ContainerBlot
this.insertBefore(other);
}
复制代码
->
insertBefore(childBlot: Blot, refBlot?: Blot): void { // ContainerBlot
if (
this.statics.allowedChildren != null &&
!this.statics.allowedChildren.some(function(child: Registry.BlotConstructor) {
return childBlot instanceof child;
})
) {
throw new Registry.ParchmentError(
`Cannot insert ${(<ShadowBlot>childBlot).statics.blotName} into ${this.statics.blotName}`,
);
}
childBlot.insertInto(this, refBlot);
}
复制代码
->
ShadowBlot
/parchment-master/src/blot/abstract/shadow.ts
insertInto(parentBlot: Parent, refBlot: Blot | null = null): void {
if (this.parent != null) {
this.parent.children.remove(this);
}
let refDomNode: Node | null = null;
parentBlot.children.insertBefore(this, refBlot);
if (refBlot != null) {
refDomNode = refBlot.domNode;
}
if (this.domNode.parentNode != parentBlot.domNode ||
this.domNode.nextSibling != refDomNode) {
parentBlot.domNode.insertBefore(this.domNode, refDomNode);
}
this.parent = parentBlot;
this.attach();
}
复制代码
到domNode.insertBefore则转换成了实际的dom操作
用户输入触发数据模型更新
关键类Scroll父类ScrollBlot
/parchment-master/src/blot/scroll.ts
constructor(node: HTMLDivElement) {
super(node);
this.scroll = this;
this.observer = new MutationObserver((mutations: MutationRecord[]) => {
this.update(mutations);
});
this.observer.observe(this.domNode, OBSERVER_CONFIG);
this.attach();
}
复制代码
通过MutationObserver监听dom变化
update(mutations?: MutationRecord[], context: { [key: string]: any } = {}): void {
mutations = mutations || this.observer.takeRecords();
// TODO use WeakMap
mutations
.map(function(mutation: MutationRecord) {
let blot = Registry.find(mutation.target, true);
if (blot == null) return null;
// @ts-ignore
if (blot.domNode[Registry.DATA_KEY].mutations == null) {
// @ts-ignore
blot.domNode[Registry.DATA_KEY].mutations = [mutation];
return blot;
} else {
// @ts-ignore
blot.domNode[Registry.DATA_KEY].mutations.push(mutation);
return null;
}
})
.forEach((blot: Blot | null) => {
if (
blot == null ||
blot === this ||
//@ts-ignore
blot.domNode[Registry.DATA_KEY] == null
)
return;
// @ts-ignore
blot.update(blot.domNode[Registry.DATA_KEY].mutations || [], context);
});
// @ts-ignore
if (this.domNode[Registry.DATA_KEY].mutations != null) {
// @ts-ignore
super.update(this.domNode[Registry.DATA_KEY].mutations, context);
}
this.optimize(mutations, context);
}
复制代码
将变化mutations转成blot更新,调用相应的blot类的update方法进行更新
如文字:
/parchment-master/src/blot/text.ts
update(mutations: MutationRecord[], context: { [key: string]: any }): void {
if (
mutations.some(mutation => {
return mutation.type === 'characterData' && mutation.target === this.domNode;
})
) {
this.text = this.statics.value(this.domNode);
}
}
复制代码
每一次调用update之后就会调用一次optimize。值得注意的是,optimize调用应该永远不会改变最终的渲染视觉效果。但是,这个方法的主要作用是减少文档复杂性和保持Delta渲染结果的一致性。
以上用到了Delta、blot,都是作为第三方库单独维护的,都是些什么呢?
Delta
Delta被用做描述Quill编辑器的内容和变化,是一种简单但表达力强的数据格式。这种格式本质上是一种JSON格式
注意:不推荐手动构建Delta格式,推荐用链式操作insert(),delete(),retain()新建Delta对象。
{
ops: [
{ insert: 'Gandalf', attributes: { bold: true } },
{ insert: ' the ' },
{
// An image link
insert: {
image: 'https://quilljs.com/assets/images/icon.png'
},
attributes: {
link: 'https://quilljs.com'
}
},
{ delete: 10 }, // 删除接下来的10个字符数
// Unbold and italicize "Gandalf"
{ retain: 7, attributes: { bold: null, italic: true } },
]
}
复制代码
以上包括几种操作:
- insert
- delete
- retain 保留
retain
操作只表示不做修改的保留接下来指定数量的字符串。如果带有attributes
值,则表示只保留这些字符串但要应用被attributes
定义的格式。如果attributes
定义的格式值为null
表示移除文本原来的格式。
Parchment
quill的文档模型,DOM树的抽象。
一个Parchment树是由Blots组成的,它反映了一个DOM对应的节点。Blots能够提供结构、格式和内容或者只有内容。Attributors能够提供轻量级的格式化信息。
蓝色表示 interface,橙色表示 class
从以上插入图片的示例可知,quill.scroll很关键,很多操作blot的api都是通过scroll调用的。
- 通过Quill.register({‘blots/scroll’: Scroll,});注册
- Quill构造函数中通过Parchment.creat将quill.scroll指向Scroll
- Scroll继承自上图的ScrollBlot
Blot分为两种:
- 非原子节点(ScrollBlot、InlineBlot、BlockBlot)
- 原子节点(EmbedBlot、TextBlot)
每一种Blot都需要实现blot接口规范
\parchment\src\blot\abstract\blot.ts
export interface Blot extends LinkedNode {
scroll: Parent; // 顶级blot
parent: Parent; // 父Blot,如果该Blot是顶级Blot,则.parent为null
prev: Blot; // 上一个同级blot, 与当前blot拥有同一个parent, 若当前blot为第一个child,则为null
next: Blot; // 下一个同级blot, 与当前blot拥有同一个parent, 若当前blot为最后一个child,则为nu
domNode: Node; // 当前blot在DOM树中的实际dom
attach(): void; // 设置blot的scroll为父blot的scroll
clone(): Blot; // 复制当前domNode生成新的blot
detach(): void; 删除当前blot
insertInto(parentBlot: Parent, refBlot?: Blot): void;
isolate(index: number, length: number): Blot; // 找到blot
offset(root?: Blot): number; // 距离root多少层
remove(): void; // 删除domNode
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; // name生成父blot wrap,插入当前blot,将wrap插入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;
}
复制代码
\parchment\src\blot\abstract\shadow.ts
ShadowBlot 核心类
- create() 根据tagName创建一个真实的element,并且添加className,返回该node。
- formatAt(index,length,name,value) 默认的格式化行为, 会根据index和length格式化一个范围。
- optimize() Blot有任何变动,都会触发optimize方法,它是支持重写的,leaf也支持这个方法。一般做一些最后的校验或限定操作,比如表格插入数据后,重写计算格子大小。 比如更新完成后,对数据结果进行实时保存。避免在optimize方法中改变document的length和value。该方法中很适合做一些降低document复杂度的事。
import { Blot, Parent, Formattable } from './blot';
import * as Registry from '../../registry';
class ShadowBlot implements Blot {
static blotName = 'abstract';
static className: string;
static scope: Registry.Scope;
static tagName: string;
// @ts-ignore
prev: Blot;
// @ts-ignore
next: Blot;
// @ts-ignore
parent: Parent;
// @ts-ignore
scroll: Parent;
// Hack for accessing inherited static methods
get statics(): any {
return this.constructor;
}
static create(value: any): Node {
if (this.tagName == null) {
throw new Registry.ParchmentError('Blot definition missing tagName');
}
let node;
if (Array.isArray(this.tagName)) {
if (typeof value === 'string') {
value = value.toUpperCase();
if (parseInt(value).toString() === value) {
value = parseInt(value);
}
}
if (typeof value === 'number') {
node = document.createElement(this.tagName[value - 1]);
} else if (this.tagName.indexOf(value) > -1) {
node = document.createElement(value);
} else {
node = document.createElement(this.tagName[0]);
}
} else {
node = document.createElement(this.tagName);
}
if (this.className) {
node.classList.add(this.className);
}
return node;
}
constructor(public domNode: Node) {
// @ts-ignore
this.domNode[Registry.DATA_KEY] = { blot: this };
}
attach(): void {
if (this.parent != null) {
this.scroll = this.parent.scroll;
}
}
clone(): Blot {
let domNode = this.domNode.cloneNode(false);
return Registry.create(domNode);
}
detach() {
if (this.parent != null) this.parent.removeChild(this);
// @ts-ignore
delete this.domNode[Registry.DATA_KEY];
}
deleteAt(index: number, length: number): void {
let blot = this.isolate(index, length);
blot.remove();
}
formatAt(index: number, length: number, name: string, value: any): void {
let blot = this.isolate(index, length);
if (Registry.query(name, Registry.Scope.BLOT) != null && value) {
blot.wrap(name, value);
} else if (Registry.query(name, Registry.Scope.ATTRIBUTE) != null) {
let parent = <Parent & Formattable>Registry.create(this.statics.scope);
blot.wrap(parent);
parent.format(name, value);
}
}
insertAt(index: number, value: string, def?: any): void {
let blot = def == null ? Registry.create('text', value) : Registry.create(value, def);
let ref = this.split(index);
this.parent.insertBefore(blot, ref);
}
insertInto(parentBlot: Parent, refBlot: Blot | null = null): void {
if (this.parent != null) {
this.parent.children.remove(this);
}
let refDomNode: Node | null = null;
parentBlot.children.insertBefore(this, refBlot);
if (refBlot != null) {
refDomNode = refBlot.domNode;
}
if (this.domNode.parentNode != parentBlot.domNode ||
this.domNode.nextSibling != refDomNode) {
parentBlot.domNode.insertBefore(this.domNode, refDomNode);
}
this.parent = parentBlot;
this.attach();
}
isolate(index: number, length: number): Blot {
let target = this.split(index);
target.split(length);
return target;
}
length(): number {
return 1;
}
offset(root: Blot = this.parent): number {
if (this.parent == null || this == root) return 0;
return this.parent.children.offset(this) + this.parent.offset(root);
}
optimize(context: { [key: string]: any }): void {
// TODO clean up once we use WeakMap
// @ts-ignore
if (this.domNode[Registry.DATA_KEY] != null) {
// @ts-ignore
delete this.domNode[Registry.DATA_KEY].mutations;
}
}
remove(): void {
if (this.domNode.parentNode != null) {
this.domNode.parentNode.removeChild(this.domNode);
}
this.detach();
}
replace(target: Blot): void {
if (target.parent == null) return;
target.parent.insertBefore(this, target.next);
target.remove();
}
replaceWith(name: string | Blot, value?: any): Blot {
let replacement = typeof name === 'string' ? Registry.create(name, value) : name;
replacement.replace(this);
return replacement;
}
split(index: number, force?: boolean): Blot {
return index === 0 ? this : this.next;
}
update(mutations: MutationRecord[], context: { [key: string]: any }): void {
// Nothing to do by default
}
wrap(name: string | Parent, value?: any): Parent {
let wrapper = typeof name === 'string' ? <Parent>Registry.create(name, value) : name;
if (this.parent != null) {
this.parent.insertBefore(wrapper, this.next);
}
wrapper.appendChild(this);
return wrapper;
}
}
export default ShadowBlot;
复制代码
Parchment生命周期
- register
使用Parchment前需要先注册
源码
工具栏
- 初始化
初始化modules下的每个实例
toolbar:
class Toolbar extends Module {
constructor(quill, options) {
super(quill, options);
// this.options.container: 配置项module.toolbar.container
// 传入数组,如['bold', 'italic', 'underline', 'strike'],则生成工具栏dom
if (Array.isArray(this.options.container)) {
let container = document.createElement("div");
// 生成工具栏dom
addControls(container, this.options.container);
// 插入编辑容器的第一位
quill.container.parentNode.insertBefore(container, quill.container);
this.container = container;
} else if (typeof this.options.container === "string") {
// 传入string则将该值为选择器的dom作为工具栏dom
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");
this.controls = [];
this.handlers = {};
// 自定义handlers存入handlers
Object.keys(this.options.handlers).forEach((format) => {
this.addHandler(format, this.options.handlers[format]);
});
// 给工具栏按钮绑定点击事件
[].forEach.call(
this.container.querySelectorAll("button, select"),
(input) => {
this.attach(input);
}
);
// 选中变化的时候更新工具栏状态
this.quill.on(Quill.events.EDITOR_CHANGE, (type, range) => {
if (type === Quill.events.SELECTION_CHANGE) {
this.update(range);
}
});
// 在scroll的optimize中有触发
/**
* optimize Blot有任何变动,都会触发optimize方法,它是支持重写的,leaf也支持这个方法。一般做一些最后的校验或限定操作,比如表格插入数据后,重写计算格子大小。
* 比如更新完成后,对数据结果进行实时保存。避免在optimize方法中改变document的length和value。该方法中很适合做一些降低document复杂度的事。
*/
this.quill.on(Quill.events.SCROLL_OPTIMIZE, () => {
let [range] = this.quill.selection.getRange(); // quill.getSelection triggers update
this.update(range);
});
}
...
}
复制代码
定制
一般的,常见的定制是通过配置(configurations)设置,用户界面通过主题(Themes) 和 CSS实现,功能是通过模块(modules)实现,编辑器内容是用Parchment实现。
自定义模块
// 实现并注册模块
Quill.register('modules/counter', function(quill, options) {
var container = document.querySelector('#counter');
quill.on('text-change', function() {
var text = quill.getText();
if (options.unit === 'word') {
container.innerText = text.split(/\s+/).length + ' words';
} else {
container.innerText = text.length + ' characters';
}
});
});
// 现在,我们可以像这样初始化Quill
var quill = new Quill('#editor', {
modules: {
counter: true
}
});
复制代码
自定义blots
var Bold = Quill.import('formats/bold');
Bold.tagName = 'B'; // Quill uses <strong> by default
Quill.register(Bold, true);
复制代码
自定义字体白名单
var FontAttributor = Quill.import('attributors/class/font');
FontAttributor.whitelist = [
'sofia', 'slabo', 'roboto', 'inconsolata', 'ubuntu'
];
Quill.register(FontAttributor, true);
// TODO 还需要将这些类的样式添加到Css文件中
复制代码
替换工具栏icon
const icons = Quill.import('ui/icons');
icons.image = '<svg>...</svg>';
复制代码
图片上传新增进度条和关闭按钮
- modules -> toolbar -> handlers 自定义image点击回调
- 触发input file的点击
- 选择完图片后,通过insertEmbed在编辑器内插入本地图片
- 通过addContainer在ql-container内新增绝对定位dom用来展示图片上传进度条,关闭按钮
- 该绝对定位dom的位置通过getBounds获得
- 上传,更新进度
- 删除进度条,展示上传结果
- 监听editor-change,更新以上绝对定位dom的位置
支持插入表情
参考
www.shuzhiduo.com/A/QW5Y61Bqd…
juejin.cn/post/684490…
juejin.cn/post/698233…
blog.csdn.net/kagol/artic…