学习富文本编辑器开源JS库之Quill

简介

富文本编辑器,具备良好的可配置性和可扩展性
官网: quilljs.com/
中文翻译版:doc.quilljs.cn/1409366
优点

简单使用

<div id="editor" />
new Quill('#editor', options); // options为配置项
复制代码

渲染效果

截屏2022-03-12 11.41.42.png

工具栏的两种使用方式

  • 数组选项
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>
复制代码

主题

  1. snow

截屏2022-03-12 00.37.09.png
2. bubble
截屏2022-03-12 00.45.48.png

获取富文本html

const html = quill.root.innerHTML;
复制代码

配置项

Quill配置选项.png

API

Quill API.png
以上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 属性,那么元素会从其父元素继承该属性。

工具栏选择 -> 编辑器中展示效果

  1. 定义工具栏image图标的点击回调

image.png
2. insert({ image: e.target.result }) 是怎么执行的呢?
image.png
即在Delta实例中增加属性

ops:[{insert: {image: 'xxxxxxx......'}}]
复制代码
  1. 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
image.png

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 } },
  ]
}
复制代码

以上包括几种操作:

  1. insert
  2. delete
  3. retain 保留

retain 操作只表示不做修改的保留接下来指定数量的字符串。如果带有attributes值,则表示只保留这些字符串但要应用被attributes定义的格式。如果attributes定义的格式值为null表示移除文本原来的格式。

Parchment

quill的文档模型,DOM树的抽象。
一个Parchment树是由Blots组成的,它反映了一个DOM对应的节点。Blots能够提供结构、格式和内容或者只有内容。Attributors能够提供轻量级的格式化信息。
image.png
蓝色表示 interface,橙色表示 class

从以上插入图片的示例可知,quill.scroll很关键,很多操作blot的api都是通过scroll调用的。

  1. 通过Quill.register({‘blots/scroll’: Scroll,});注册
  2. Quill构造函数中通过Parchment.creat将quill.scroll指向Scroll
  3. 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生命周期

image.png

  1. register

使用Parchment前需要先注册
image.png
image.png

源码

截屏2022-03-12 15.27.41.png

工具栏

  1. 初始化

image.png

image.png
初始化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>';
复制代码

图片上传新增进度条和关闭按钮

  1. modules -> toolbar -> handlers 自定义image点击回调
  2. 触发input file的点击
  3. 选择完图片后,通过insertEmbed在编辑器内插入本地图片
  4. 通过addContainer在ql-container内新增绝对定位dom用来展示图片上传进度条,关闭按钮
  5. 该绝对定位dom的位置通过getBounds获得
  6. 上传,更新进度
  7. 删除进度条,展示上传结果
  8. 监听editor-change,更新以上绝对定位dom的位置

支持插入表情

blog.csdn.net/kagol/artic…

参考

www.shuzhiduo.com/A/QW5Y61Bqd…
juejin.cn/post/684490…
juejin.cn/post/698233…
blog.csdn.net/kagol/artic…

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享