普通视图

发现新文章,点击刷新页面。
昨天 — 2025年7月1日首页

Quill 自定义模块的开发之旅

作者 Arise
2025年7月1日 14:32

在开发一个旧项目的富文本组件(Quill)的过程中在新增功能的时候用到了自定义模块的api。记录了一些遇到的问题和解决方式。

先说一下基本的代码内容

const config = {
  modules: {
    toolbar: {
      container: [
        [{ size: ['12px','14px','16px','18px','20px'], default: 14 }],
        ['bold', 'italic', 'c-underline-style', 'strike', 'link'],
        ['c-line-height'],
        [{ align: [] }],
        [{ color: [] }, { background: [] }],
        ['c-h-divider'],
        ['clean'],
      ],
      handlers: {
        underline() {
          return false;
        },
        'line-height'() {
          return false;
        },
      },
    },
  },
  theme: 'snow',
};
/**
* 一共开发3个模块
* c-underline-style 自定义文字下划线
* c-line-height 自定义行高
* c-h-divider 自定义分割线
*/ 

// 注册插件
registerPlugin(QuillClass);
// 生成实例
const editor: Quill = new QuillClass(ref, config);
// 创建自定义菜单
createMenu();
// 绑定自定义模块的matcher匹配
bindQuillMatcher(editor, Delta)

自定义的模块的注册一定要在实例创建之前,否则的话不会生效; 这部分是主体逻辑,然后我们分开的单独说每一部分;

先说 registerPlugin 自定义模块注册部分: 字体大小和字体是Quill内置的功能,只需要注册一下你要增加字体的白名单就可以。

不过关于字体需要说明一下,最好采用 'attributors/style/font' 这个行内样式的方式对字体进行赋值方式。 也尝试过使用 ‘attributors/class/font’ 的方式。这种是会产生一个类名,你需要在类名下自己设置使用的字体。如果你要用到的字体都是浏览器本身就支持的,或者你采用@face-font 的方式注册的也没有什么问题。 但是如果你用的是 Fontface 的方式注册追加的字体。用类名的方式就不太方便了。所以采用'attributors/style/font'的方式适配面更广一些。

注册

  const registerPlugin = (Quill) => {
    // 注册字体大小白名单
    const SizeStyle = Quill.import('attributors/style/size');
    SizeStyle.whitelist = ['12px','14px','16px','18px','20px'];
    Quill.register(SizeStyle, true);
    // 注册字体白名单
    const FontAttributor = Quill.import('attributors/style/font');
    FontAttributor.whitelist = ['字体1','字体2'];
    Quill.register(FontAttributor, true);

    // 注册分割线
    registerDividerBlots(Quill);
    // 注册下划线
    registerUnderlineFormats(Quill);
    // 注册行高
    registerLineHeightBlots(Quill);
  };

下划线的自定义注册

Quill 内置的 underline 仅支持单线的。他的设置值是 true(开启)和 false(关闭)

我需要对 underline 的改造:

  1. 增加下划线的颜色设置
  2. 增加下划线的样式设置
  3. 增加下划线粗细设置 (这里的粗细并不是直接设置,是属于样式中的一种:单线<标准>和单线<粗>)

正常来说,Quill 对类的返回值都采用的简单类型(即:基础数据类型); 不过我采用了复杂的数值返回,是为了方便对值的直接调用,而不用每次都去做转换。 这个看个人意愿。

// 本质上 Quill 是有内置事件类的,因为改动大,所以需要覆盖掉内置的下划线执行类。
export function registerUnderlineFormats(Quill: any) {
  const Underline = Quill.import('formats/underline');

  class UnderlineStyleBlot extends Underline {
    static blotName = 'underline'; // 格式名称
    static tagName = 'u'; // 使用 <u> 标签
    
    static create(value: any) {
      const node = super.create();
      node.style.textDecorationLine = 'underline'; // 默认下划线

      // 回退时会传入一个对象
      if (typeof value === 'object') {
        Object.assign(node.style, {
          textDecorationStyle: value.style,
          textDecorationThickness: value.thickness,
          textDecorationColor: value.color,
        });
        return node;
      }

      // 对布尔值的处理
      if (typeof value === 'boolean') {
        if (value) {
          Object.assign(node.style, {
            textDecorationStyle: 'solid',
          });
        } else {
          // 如果是 false,移除下划线样式
          Object.assign(node.style, {
            textDecoration: 'none',
            textDecorationStyle: null,
            textDecorationThickness: null,
            textDecorationColor: null,
          });
          return node;
        }
      }

      // 根据 value 设置样式
      if (value === 'bold') {
        Object.assign(node.style, {
          textDecorationStyle: 'solid',
          textDecorationThickness: '0.15em',
        });
      } else if (isColorFormat(value)) {
        Object.assign(node.style, {
          textDecorationStyle: 'solid',
          textDecorationColor: value,
        });
      } else if (value) {
        Object.assign(node.style, {
          textDecorationStyle: value,
        });
      }
      return node;
    }

    /**
     * ! 这里的设计是为了保证撤销回退的功能。 因为设置的时候都是单一属性的变动,所以传入变动值是单一的,撤销回退的时候是多个属性的变动
     * ! 但是会导致传入值出现两种结构 string | object
     * ! 例如: { style: 'solid', thickness: '0.15em', color: '#000' }
     * ! 但是在设置的时候传入的值是 string
     * ! 例如: 'solid' | 'dashed' | 'wavy'
     * */
    static formats(node: HTMLElement): object | null {
      // 如果不是下划线样式
      if (node.style.textDecorationLine !== 'underline') return null;
      if (!includesStyleType(node.style.textDecorationStyle)) return null;

      return {
        color: node.style.textDecorationColor || null,
        style: node.style.textDecorationStyle || null,
        thickness: node.style.textDecorationThickness || null,
      };
    }

    /**
     * 同上注释
     */
    format(name: string, value: any) {
      if (name !== UnderlineStyleBlot.blotName) {
        super.format(name, value);
        return;
      }

      // 移除格式
      if (value === false) {
        Object.assign(this.domNode.style, {
          textDecoration: 'none',
          textDecorationStyle: null,
          textDecorationThickness: null,
          textDecorationColor: null,
        });
        return;
      }

      // 撤销下划线时会将这个值传入
      if (typeof value === 'object') {
        // 存在有效的下划线样式值
        if (!includesStyleType(value?.style)) {
          Object.assign(this.domNode.style, {
            textDecoration: 'none',
            textDecorationStyle: null,
            textDecorationThickness: null,
            textDecorationColor: null,
          });
        } else {
          Object.assign(this.domNode.style, {
            textDecoration: 'underline',
            textDecorationStyle: value.style,
            textDecorationThickness: value.thickness,
            textDecorationColor: value.color,
          });
        }
        return;
      }

      // 判断是否是颜色类型
      if (isColorFormat(value)) {
        if (!includesStyleType(this.domNode.style?.textDecorationStyle)) {
          this.domNode.style.textDecorationStyle = 'solid';
        }
        this.domNode.style.textDecorationColor = value;
        return;
      }

      // 先清空旧样式
      Object.assign(this.domNode.style, {
        textDecorationStyle: '',
        textDecorationThickness: '',
      });

      // 再根据实际样式设置属性值
      if (value === 'bold') {
        Object.assign(this.domNode.style, {
          textDecorationStyle: 'solid',
          textDecorationThickness: '0.15em',
        });
      } else if (value) {
        this.domNode.style.textDecorationStyle = value;
      }
    }
  }
  // 需要覆盖掉内置逻辑,这个地方就要回顾一下 我在config 的时候 handlers 里面是设置了 underline 的回调的,是为了避免内置的underline指令执行
  Quill.register('formats/underline', UnderlineStyleBlot, true);
}

菜单部分就看 UI 如何设计,自己添加就好了。 这样之后,调用也就比较常规

// editor 是 Quill 的实例
// 设置样式:css 支持的属性有哪些就可以传那些值 
editor.format('underline', 'solid');
editor.format('underline', 'dashed');
editor.format('underline', 'double');
// 设置颜色
editor.format('underline', '#fff');

// 取消下划线
editor.format('underline', false);

得到的结果

image.png

节点上的内容

image.png

行高自定义模块

行高比较常规, 没什么好说的

export function registerLineHeightBlots(Quill: typeof import('quill')['default']) {
  const Block = Quill.import('blots/block') as typeof import('parchment').BlockBlot;
  class LineHeightBlot extends Block {
    static blotName = 'line-height';
    static tagName = 'p';

    static create(value: any) {
      const node = super.create();
      node.style.lineHeight = value;
      return node;
    }

    static formats(node) {
      return node.style.lineHeight || null;
    }

    format(name: string, value: any) {
      if (name === 'line-height' && value) {
        this.domNode.style.lineHeight = value;
      } else {
        super.format(name, value);
      }
    }
  }

  Quill.register(LineHeightBlot, true);
}

// 调用
editor.format('line-height', 1);
editor.format('line-height', 2);

分割线模块

这个部分是功能中比较麻烦的部分

export function registerDividerBlots(Quill: typeof import('quill')['default']) {
  // 使用类型断言
  const QuillBlockEmbed = Quill.import('blots/block/embed') as typeof BlockEmbed;

  // 基础分割线类
  class BaseDivider extends QuillBlockEmbed {
    static blotName: string;
    static tagName: string; // 直接设置默认值
    static dividerType: DividerType;
    static isAtomic = true;
    static allowedAttributes = ['data-color', 'data-divider-type', 'class']; // 允许的属性

    static create(value: any) {
      const node = super.create() as HTMLElement;

      // 处理两种可能的数据结构
      let color: string;
      let type: string;

      // 使用默认值
      color = value?.color || '#e60000';
      type = value?.type || this.dividerType;

      node.setAttribute('contenteditable', 'false');
      node.setAttribute('data-color', color);
      node.setAttribute('data-divider-type', type);
      node.classList.add('ql-custom-divider');
      return node;
    }

    static value(node: HTMLElement) {
      return {
        color: node.getAttribute('data-color') || '#e60000',
        type: node.getAttribute('data-divider-type') || this.dividerType,
      };
    }

    static formats(node: HTMLElement) {
      return {
        color: node.getAttribute('data-color') || '#e60000',
        type: node.getAttribute('data-divider-type') || this.dividerType,
      };
    }

    format(name: string, value: any) {
      if (name === 'color' && value) {
        this.domNode.setAttribute('data-color', value);
        this.updateColor(value);
      }
    }

    protected updateColor(color: string): void {}
  }

  // 单实线基类
  class SingleLineDivider extends BaseDivider {
    static lineHeight: number;

    static create(value: any) {
      const node = super.create(value);
      const color = typeof value === 'object' ? value.color : '#e60000';
      const self = this as unknown as typeof SingleLineDivider & ISingleLineDividerBlot;

      // 容器样式
      Object.assign(node.style, {
        height: 'auto',
        padding: `3.75pt 0 ${(5 - self.lineHeight) * 0.75}pt`,
        width: '100%',
      });

      // 使用 hr 替代 div
      const line = document.createElement('hr');
      Object.assign(line.style, {
        height: `${self.lineHeight * 0.75}pt`,
        border: 'none',
        color,
        borderTop: `${self.lineHeight * 0.75}pt solid ${color}`,
        width: '100%',
        size: `${self.lineHeight * 0.75}pt`,
        margin: 0,
      });

      line.setAttribute('contenteditable', 'false');
      line.setAttribute('size', `${self.lineHeight * 0.75}pt`);
      line.classList.add('ql-divider-line');
      node.appendChild(line);

      return node;
    }

    html() {
      const node = this.domNode as HTMLElement;
      const type = node.getAttribute('data-divider-type');
      const color = node.getAttribute('data-color');
      const self = this as unknown as typeof SingleLineDivider & ISingleLineDividerBlot;

      const getHrHtml = (type: DividerType, color: string) => {
        const baseStyle = `margin: 0; border: none; width: 100%; height: ${self.lineHeight * 0.75}pt; size: ${
          self.lineHeight * 0.75
        }pt`;
        return `<hr class="ql-divider-line" contenteditable="false" style="${baseStyle} border-top: ${
          self.lineHeight * 0.75
        }pt solid ${color};">`;
      };

      const html = `<section class="ql-custom-divider" 
                    data-divider-type="${type}" 
                    data-color="${color}" 
                    contenteditable="false"
                    style="padding: 3.75pt 0 3pt; position: relative;">
                    ${getHrHtml(type as DividerType, color)}
                  </section>`;

      return html;
    }

    protected updateColor(color: string) {
      const line = this.domNode.querySelector('.ql-divider-line') as HTMLHRElement;
      if (line) {
        line.style.borderTopColor = color;
      }
    }
  }

  // 双实线基类
  class DoubleLineDivider extends BaseDivider {
    static containerHeight: string | number;
    static lineHeights: [number, number];
    static gap: number;

    static create(value: any) {
      const node = super.create(value);
      const color = typeof value === 'object' ? value.color : '#e60000';
      const self = this as unknown as typeof DoubleLineDivider & IDoubleLineDividerBlot;

      Object.assign(node.style, {
        height: 'auto',
        padding: `3.75pt 0 ${(5 - self.lineHeights[1]) * 0.75}pt`,
        width: '100%',
      });

      // 使用两个 hr 创建双线
      self.lineHeights.forEach((height: number, index: number) => {
        const line = document.createElement('hr');
        Object.assign(line.style, {
          height: `${height * 0.75}pt`,
          border: 'none',
          color,
          borderTop: `${height * 0.75}pt solid ${color}`,
          width: '100%',
          size: `${height * 0.75}pt`,
          margin: 0,
          marginBottom: index === 0 ? `${(self.gap - height) * 0.75}pt` : undefined,
        });

        line.setAttribute('contenteditable', 'false');
        line.setAttribute('size', `${height * 0.75}pt`);
        line.classList.add('ql-divider-line');
        node.appendChild(line);
      });

      return node;
    }

    html() {
      const node = this.domNode as HTMLElement;
      const type = node.getAttribute('data-divider-type');
      const color = node.getAttribute('data-color');
      const self = this as unknown as typeof DoubleLineDivider & IDoubleLineDividerBlot;

      const getHrHtml = (type: DividerType, color: string) => {
        const baseStyle = `margin: 0; border: none; width: 100%;`;

        const lines = self.lineHeights.map((height: number, index: number) => {
          const lineStyle = `${baseStyle} border-top: ${height * 0.75}pt solid ${color}; ${
            index === 0 ? `margin-bottom: ${(self.gap - height) * 0.75}pt;` : ''
          }; size: ${height * 0.75}pt; height: ${height * 0.75}pt`;

          return `<hr class="ql-divider-line" 
            contenteditable="false" 
            size="${height * 0.75}pt" 
            style="${lineStyle}">`;
        });

        return lines.join('\n');
      };

      const html = `<section class="ql-custom-divider" 
                    data-divider-type="${type}" 
                    data-color="${color}" 
                    contenteditable="false"
                    style="padding: 3.75pt 0 ${(5 - self.lineHeights[1]) * 0.75}pt; position: relative;">
                    ${getHrHtml(type as DividerType, color)}
                  </section>`;

      return html;
    }

    protected updateColor(color: string) {
      const lines = this.domNode.querySelectorAll('hr');
      lines.forEach((line: HTMLHRElement) => {
        line.style.borderTopColor = color;
      });
    }
  }

  // 具体分割线实现类
  class SingleThinDivider extends SingleLineDivider {
    static dividerType = 'single-thin' as const;
    static lineHeight = 1; // 修改为纯数字
  }

  class SingleMediumDivider extends SingleLineDivider {
    static dividerType = 'single-medium' as const;
    static lineHeight = 2; // 修改为纯数字
  }

  class DoubleThinDivider extends DoubleLineDivider {
    static dividerType = 'double-thin' as const;
    static containerHeight = 'auto';
    static lineHeights = [1, 1] as [number, number];
    static gap = 2; 
  }

  class DoubleMediumDivider extends DoubleLineDivider {
    static dividerType = 'double-medium' as const;
    static containerHeight = 'auto';
    static lineHeights = [3, 3] as [number, number];
    static gap = 2;
  }

  class DoubleThickThinDivider extends DoubleLineDivider {
    static dividerType = 'double-thick-thin' as const;
    static containerHeight = 'auto';
    static lineHeights = [3, 1] as [number, number];
    static gap = 2;
  }

  class DoubleThinThickDivider extends DoubleLineDivider {
    static dividerType = 'double-thin-thick' as const;
    static containerHeight = 'auto';
    static lineHeights = [1, 3] as [number, number];
    static gap = 2;
  }

  type DividerClass =
    | typeof SingleThinDivider
    | typeof SingleMediumDivider
    | typeof DoubleThinDivider
    | typeof DoubleMediumDivider
    | typeof DoubleThickThinDivider
    | typeof DoubleThinThickDivider;

  // 注册所有分割线类型
  const dividerClasses: DividerClass[] = [
    SingleThinDivider,
    SingleMediumDivider,
    DoubleThinDivider,
    DoubleMediumDivider,
    DoubleThickThinDivider,
    DoubleThinThickDivider,
  ];

  // 注册所有Blot
  dividerClasses.forEach((DividerClass) => {
    DividerClass.blotName = `divider-${DividerClass.dividerType}`;
    DividerClass.tagName = 'section';

    Quill.register(DividerClass, true);
  });
}


/**
 * 插入分割线
 * @param quill Quill编辑器实例
 * @param type 分割线类型
 * @param color 分割线颜色
 */
export function insertDivider(quill: Quill, type: DividerType, color = '#e60000') {
  if (!quill) return;

  const range = quill.getSelection(true);
  quill.insertText(range.index, '\n', Quill.sources.USER);
  quill.insertEmbed(range.index + 1, `divider-${type}`, { color }, Quill.sources.USER);
  quill.setSelection(range.index + 2, 0, Quill.sources.SILENT);
}

// 调用 
insertDivider(editor, type, color); 

因为这个需求是 Quill 内完全不支持的功能,所以为了解决 Quill 一些内部机制限制下,需要做很多额外的支持。

使用的节点是 section 并不是 div、p 等其他的块级元素; 原因: Quill 内部有匹配和合并机制。 如果使用内部已经使用过的节点,Quill 内部会对属性进行合并处理。这个机制是完全避免不了的,所以为了脱离这个合并机制,采用 section 的块级标签。 但是如果使用了 Quill 内置不支持的标签,就会产生额外处理。 如 html 函数, 需要增加输出节点函数,这样 Quill 在外部调用 getHtml 函数的时候,才能正确获取。

并且对下划线和分割线,需要处理复制的问题(这也是为什么分割线要采用section标签的原因之一),感兴趣的可以深度了解一下 Quill 的 matcher 匹配机制。

 // 增加对分割线标签的独立处理
  quill.clipboard.addMatcher('section', (node: HTMLElement, delta) => {
    const dividerType = node.getAttribute('data-divider-type');
    const color = node.getAttribute('data-color');

    if (!dividerType || !color) {
      return delta;
    }

    // 只返回分割线内容,不附加换行
    return new Delta().insert(
      {
        [`divider-${dividerType}`]: {
          color,
          type: dividerType,
        },
      },
      {
        color,
        type: dividerType,
      }
    );
  });
  
  quill.clipboard.addMatcher('u', (node: HTMLElement, delta) => {
    // 检查是否包含下划线样式
    const hasUnderline = node.style.textDecorationStyle;

    // 获取完整的下划线样式属性
    const underlineAttributes = hasUnderline
      ? {
          color: node.style.textDecorationColor || null,
          style: node.style.textDecorationStyle || null,
          thickness: node.style.textDecorationThickness || null,
        }
      : null;

    // 如果没有下划线样式,返回原始delta
    if (!hasUnderline) return delta;

    // 创建新的delta并合并属性
    const newDelta = new Delta();
    delta.ops.forEach((op) => {
      if (op.insert) {
        const attributes = {
          ...op.attributes,
          underline: underlineAttributes, // 使用包含所有样式的对象
        };
        newDelta.insert(op.insert, attributes);
      } else {
        newDelta.push(op);
      }
    });

    return newDelta;
  });

结果

image.png

以上就是3个自定义功能的主要代码部分,Quill 作为一个很久之前的富文本插件,他的设计还是比较合理的,虽然功能并不是那么丰富,不过还是够用了。

如果有小伙伴在使用的过程中遇到想扩展但是没有头绪可以以此做个参考。

❌
❌