Quill之剪切板模块源码分析

1,554 阅读6分钟

前言

最近一直在负责公司的编辑器项目,是基于Quill开发的,然后用到了Quill的一些功能,也因为业务需求看了Quill的一些源码,今天趁着这个机会总结一下看源码的成果,算是温故而知新,方便后面查阅。

介绍

Quill有以下内置模块,剪切板,历史,键盘,语法高亮,工具栏

今天分析的是Quill的剪切板模块(clipboard.js,位于module目录下)

在分析之前,你要首先对quill相关的知识点熟悉

  • delta结构: quill是通过delta结构来描述内容以及内容的改变.(insert 插入, retain保留, delete删除)
  • parchment/blot: quill中封装了一套文档模型,把delta结构转成对应的dom,也方便定制化开发,parchment相当于整个document,blot对应dom的节点。

如果不熟悉,可以看看我这篇入门文章《编辑器系列-初探quill》

正文

以下分析都是基于quilljs的1.3.7版本

剪切板在编辑器对应的表现就是粘贴,要实现粘贴的文本格式不能丢失,比如加粗,颜色等等,所以基本上都要监听粘贴事件,quill也不例外。

class Clipboard extends Module {
  constructor (quill, options) {
    super(quill, options);
    this.quill.root.addEventListener('paste', this.onPaste.bind(this)); // 监听paste事件
    this.container = this.quill.addContainer('ql-clipboard'); // 额外的div
    this.container.setAttribute('contenteditable', true); // 可以编辑
    this.container.setAttribute('tabindex', -1); // 禁止tab选中
    this.matchers = [];
    CLIPBOARD_CONFIG.concat(this.options.matchers).forEach(([selector, matcher]) => {
      if (!options.matchVisual && matcher === matchSpacing) return;
      this.addMatcher(selector, matcher);
    });
  }
}

它在Quill的根元素监听粘贴事件。

但是Quill有意思的是,它会额外创建一个div,叫ql-clipboard,然后设置它可以编辑但不能通过tab键选中,也就是隐藏的编辑框。

这个编辑框就是当你粘贴的时候,quill会聚焦到这个编辑框,然后你复制的内容会粘贴到这个编辑框,接着对这个编辑框的内容解析成delta,最后把delta应用到真实的编辑器中。

妙,确实妙,这样原始粘贴的内容就不用直接粘贴到真实的编辑器中,而是先转化delta再应用,防止其他格式污染的问题。

咱们接着往下看。

CLIPBOARD_CONFIG是剪切板默认的的格式匹配方法,它会和你传入的匹配方法合并,组成matchers

const CLIPBOARD_CONFIG = [
  [Node.TEXT_NODE, matchText],
  [Node.TEXT_NODE, matchNewline],
  ['br', matchBreak],
  [Node.ELEMENT_NODE, matchNewline],
  [Node.ELEMENT_NODE, matchBlot],
  [Node.ELEMENT_NODE, matchSpacing],
  [Node.ELEMENT_NODE, matchAttributor],
  [Node.ELEMENT_NODE, matchStyles],
  ['li', matchIndent],
  ['b', matchAlias.bind(matchAlias, 'bold')],
  ['i', matchAlias.bind(matchAlias, 'italic')],
  ['style', matchIgnore]
];

CLIPBOARD_CONFIG 是一个二维数组,它会针对匹配到的元素的类型(文本还是dom节点),或者标签名(比如‘li’),执行后面的matcher方法。

如果我们想自定义匹配规则,传入matchers,也是以这种格式。

clipboard: {
  matchers: [
      [Node.TEXT_NODE, customMatcherFn]
    ]
  }

咱们再接着来看看粘贴事件的逻辑

onPaste (e) {
    if (e.defaultPrevented || !this.quill.isEnabled()) return; // 如果编辑器不可以编辑则return
    let range = this.quill.getSelection();
    let delta = new Delta().retain(range.index);
    let scrollTop = this.quill.scrollingContainer.scrollTop;
    this.container.focus();
    this.quill.selection.update(Quill.sources.SILENT);
    setTimeout(() => {
      delta = delta.concat(this.convert()).delete(range.length);
      this.quill.updateContents(delta, Quill.sources.USER);
      // range.length contributes to delta.length()
      this.quill.setSelection(delta.length() - range.length, Quill.sources.SILENT);
      this.quill.scrollingContainer.scrollTop = scrollTop;
      this.quill.focus();
    }, 1);
  }

首先是要先获取当前的选区range,通过delta保留选区的内容,同时也要记录当前编辑器滚动的距离,因为它要聚焦到隐藏的那个编辑框那里。

同时把调用convert方法,这个是关键,主要是把隐藏的编辑框的内容转成delta,然后调用quill的updateContents方法,更新内容,接着设置当前的选区(要减去range的length,代表粘贴前选中的内容的长度),最后把之前记录的滚动距离更新,并且quill重新聚焦。

这样粘贴的整体处理就完成了。

我们接着来讲讲它这个convert方法,究竟是怎么转化的。

convert (html) {
    if (typeof html === 'string') {
      this.container.innerHTML = html.replace(/\>\r?\n +\</g, '><'); // Remove spaces between tags
      return this.convert();
    }
    const formats = this.quill.getFormat(this.quill.selection.savedRange.index);
    if (formats[CodeBlock.blotName]) {
      const text = this.container.innerText;
      this.container.innerHTML = '';
      return new Delta().insert(text, { [CodeBlock.blotName]: formats[CodeBlock.blotName] });
    }
    let [elementMatchers, textMatchers] = this.prepareMatching();
    let delta = traverse(this.container, elementMatchers, textMatchers);
    // Remove trailing newline
    if (deltaEndsWith(delta, '\n') && delta.ops[delta.ops.length - 1].attributes == null) {
      delta = delta.compose(new Delta().retain(delta.length() - 1).delete(1));
    }
    debug.log('convert', this.container.innerHTML, delta);
    this.container.innerHTML = '';
    return delta;
  }

convert方法会转化传入的html,需要是string格式的,同时把它设置成隐藏编辑框的内容。

接着这里会有一个判断,就是在粘贴的时候Quill编辑器的光标是否处于CodeBlock里(也就是pre标签),如果在,则把隐藏的编辑框的内容插入即可,继承pre的格式。

如果没有,则会调用prepareMatching方法

  prepareMatching () {
    let elementMatchers = [], textMatchers = [];
    this.matchers.forEach((pair) => {
      let [selector, matcher] = pair;
      switch (selector) {
        case Node.TEXT_NODE:
          textMatchers.push(matcher);
          break;
        case Node.ELEMENT_NODE:
          elementMatchers.push(matcher);
          break;
        default:
          [].forEach.call(this.container.querySelectorAll(selector), (node) => {
            // TODO use weakmap
            node[DOM_KEY] = node[DOM_KEY] || [];
            node[DOM_KEY].push(matcher);
          });
          break;
      }
    });
    return [elementMatchers, textMatchers];
  }

这个方法是把之前合并的matchers分成三类

  • Node.TEXT_NODE 文本元素一类
  • Node.ELEMENT_NODE,dom节点
  • 标签名,某一类标签,比如li,b等,给这些标签赋值DOM_KEYmatcher方法,后面遍历的时候会执行对应matcher方法。

prepareMatching方法返回 [elementMatchers, textMatchers]

接着会继续执行traverse方法,这个方法就是递归遍历隐藏编辑框的所有dom,根据dom的类型对应执行prepareMatching方法返回的elementMatchers, textMatchers。同时还会判断dom是否有DOM_KEY,如果有,则执行对应的matcher方法

最后返回拼接好的delta。

function traverse (node, elementMatchers, textMatchers) {  // Post-order
  if (node.nodeType === node.TEXT_NODE) {
    return textMatchers.reduce(function (delta, matcher) {
      return matcher(node, delta);
    }, new Delta());
  } else if (node.nodeType === node.ELEMENT_NODE) {
    return [].reduce.call(node.childNodes || [], (delta, childNode) => {
      let childrenDelta = traverse(childNode, elementMatchers, textMatchers);
      if (childNode.nodeType === node.ELEMENT_NODE) {
        childrenDelta = elementMatchers.reduce(function (childrenDelta, matcher) {
          return matcher(childNode, childrenDelta);
        }, childrenDelta);
        childrenDelta = (childNode[DOM_KEY] || []).reduce(function (childrenDelta, matcher) {
          return matcher(childNode, childrenDelta);
        }, childrenDelta);
      }
      return delta.concat(childrenDelta);
    }, new Delta());
  } else {
    return new Delta();
  }
}

在conver方法里,拿到返回delta格式,接着判断是否后面有空行(\n),并且没有任何attribute,如果是则把这个空行删除。

配置项

上面是Quill剪切板的总体逻辑,但是quill会配置哪些配置项?以及Quill匹配到dom节点和文本节点后如何处理的?

const ATTRIBUTE_ATTRIBUTORS = [
  AlignAttribute,
  DirectionAttribute
].reduce(function (memo, attr) {
  memo[attr.keyName] = attr;
  return memo;
}, {});

const STYLE_ATTRIBUTORS = [
  AlignStyle,
  BackgroundStyle,
  ColorStyle,
  DirectionStyle,
  FontStyle,
  SizeStyle
].reduce(function (memo, attr) {
  memo[attr.keyName] = attr;
  return memo;
}, {});

列举一下:

  • 文本对齐
  • 文本方向
  • 背景颜色
  • 字体颜色
  • 字体大小

另外挑选几个CLIPBOARD_CONFIG的matcher方法讲解一下

['br', matchBreak]

function matchBreak (node, delta) {
  if (!deltaEndsWith(delta, '\n')) {
    delta.insert('\n');
  }
  return delta;
}

就是遍历的时候,识别到标签如果是br标签,如果当前delta没有换行符,就会插入换行符。

[Node.ELEMENT_NODE, matchBlot]

function matchBlot (node, delta) {
  let match = Parchment.query(node);
  if (match == null) return delta;
  if (match.prototype instanceof Parchment.Embed) {
    let embed = {};
    let value = match.value(node);
    if (value != null) {
      embed[match.blotName] = value;
      delta = new Delta().insert(embed, match.formats(node));
    }
  } else if (typeof match.formats === 'function') {
    delta = applyFormat(delta, match.blotName, match.formats(node));
  }
  return delta;
}

如果当前dom去找对应的blot,如果能找到,而且如果是嵌入类型,,就插入嵌入类型的delta,否则就应用blot对应的格式。

[Node.ELEMENT_NODE, matchStyles]

function matchStyles (node, delta) {
  let formats = {};
  let style = node.style || {};
  if (style.fontStyle && computeStyle(node).fontStyle === 'italic') {
    formats.italic = true;
  }
  if (style.fontWeight && (computeStyle(node).fontWeight.startsWith('bold') ||
    parseInt(computeStyle(node).fontWeight) >= 700)) {
    formats.bold = true;
  }
  if (Object.keys(formats).length > 0) {
    delta = applyFormat(delta, formats);
  }
  if (parseFloat(style.textIndent || 0) > 0) {  // Could be 0.5in
    delta = new Delta().insert('\t').concat(delta);
  }
  return delta;
}

这个是针对标签的style样式,如果标签是斜体,或者字体有加粗,就应用对应格式。或者会有文字缩进,就插入\t制表符

总结

Quill在github上是有35k+的star,很多细节还是考虑到的,所以它的源码还是值得阅读和分析的,但是这个过程不是一蹴而就的,肯定会遇到不懂的地方,这时候可以下载它们的源码,打断点,打印日志,通过这些方式加深理解。

Quill的源码我也不敢说我每行都读懂了,只能说大致分析了一下,了解它们是怎么实现的。

感谢你们的阅读。