您的当前位置:首页正文

Quill文档(四):使用Parchment克隆Medium

2024-11-23 来源:个人技术集锦

为了提供一致的编辑体验,您需要一致的数据和可预测的行为。不幸的是,DOM缺乏这两个特性。现代编辑器的解决方案是维护自己的文档模型来表示它们的内容。对于Quill来说,Parchment就是这样的解决方案。它在自己的代码库中组织,并拥有自己的API层。通过Parchment,您可以定制Quill识别的内容和格式,或者添加全新的内容和格式。

在本指南中,我们将使用Parchment和Quill提供的基本构建块来复制媒体上的编辑器。我们将从没有任何主题、额外模块或格式的Quill的骨架开始。在这个基本层面上,Quill只理解纯文本。但通过本指南的结尾,链接、视频,甚至推文都将被理解。

基础工作


让我们从甚至不使用Quill开始,只用一个textarea和一个按钮,连接到一个虚拟事件监听器。我们将在整个指南中为了方便使用jQuery,但Quill或Parchment并不依赖于此。我们还将添加一些基本样式,借助Google Fonts和Font Awesome。这些与Quill或Parchment无关,所以我们将快速通过。

index.html

<link href="/styles.css" rel="stylesheet">

<div id="tooltip-controls">
  <button id="bold-button"><i class="fa fa-bold"></i></button>
  <button id="italic-button"><i class="fa fa-italic"></i></button>
  <button id="link-button"><i class="fa fa-link"></i></button>
  <button id="blockquote-button"><i class="fa fa-quote-right"></i></button>
  <button id="header-1-button"><i class="fa fa-header"><sub>1</sub></i></button>
  <button id="header-2-button"><i class="fa fa-header"><sub>2</sub></i></button>
</div>
<div id="sidebar-controls">
  <button id="image-button"><i class="fa fa-camera"></i></button>
  <button id="video-button"><i class="fa fa-play"></i></button>
  <button id="tweet-button"><i class="fa fa-twitter"></i></button>
  <button id="divider-button"><i class="fa fa-minus"></i></button>
</div>

<textarea id="editor">Tell your story...</textarea>

<script type="module" src="/index.js"></script>

styles.css

#editor {
  display: block;
  font-family: 'Open Sans', Helvetica, sans-serif;
  font-size: 1.2em;
  height: 180px;
  margin: 0 auto;
  width: 450px;
}

#tooltip-controls, #sidebar-controls {
  text-align: center;
}

button {
  background: transparent;
  border: none;
  cursor: pointer;
  display: inline-block;
  font-size: 18px;
  padding: 0;
  height: 32px;
  width: 32px;
  text-align: center;
}
button:active, button:focus {
  outline: none;
}

index.js

document.querySelectorAll('button').forEach((button) => {
  button.addEventListener('click', () => {
    alert('Click!');
  });
});

添加Quill核心


接下来,我们将用没有主题、格式和额外模块的Quill核心替换textarea。打开您的开发者控制台,在您输入编辑器时检查演示。您可以看到Parchment文档的基本构建块在工作。

index.html

<link href="https://cdn.jsdelivr.net/npm/quill@2.0.0-rc.4/dist/quill.core.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.0-rc.4/dist/quill.core.js"></script>

<link href="/styles.css" rel="stylesheet">

<div id="tooltip-controls">
  <button id="bold-button"><i class="fa fa-bold"></i></button>
  <button id="italic-button"><i class="fa fa-italic"></i></button>
  <button id="link-button"><i class="fa fa-link"></i></button>
  <button id="blockquote-button"><i class="fa fa-quote-right"></i></button>
  <button id="header-1-button"><i class="fa fa-header"><sub>1</sub></i></button>
  <button id="header-2-button"><i class="fa fa-header"><sub>2</sub></i></button>
</div>
<div id="sidebar-controls">
  <button id="image-button"><i class="fa fa-camera"></i></button>
  <button id="video-button"><i class="fa fa-play"></i></button>
  <button id="tweet-button"><i class="fa fa-twitter"></i></button>
  <button id="divider-button"><i class="fa fa-minus"></i></button>
</div>

<div id="editor">Tell your story...</div>

<script type="module" src="/index.js"></script>

styles.css

#editor {
  border: 1px solid #ccc;
  font-family: 'Open Sans', Helvetica, sans-serif;
  font-size: 1.2em;
  height: 180px;
  margin: 0 auto;
  width: 450px;
}

#tooltip-controls, #sidebar-controls {
  text-align: center;
}

button {
  background: transparent;
  border: none;
  cursor: pointer;
  display: inline-block;
  font-size: 18px;
  padding: 0;
  height: 32px;
  width: 32px;
  text-align: center;
}
button:active, button:focus {
  outline: none;
}

index.js

document.querySelectorAll('button').forEach((button) => {
  button.addEventListener('click', () => {
    alert('Click!');
  });
});

const quill = new Quill('#editor');

与DOM类似,Parchment文档是一个树形结构。它的节点,称为Blots,是对DOM节点的抽象。对于我们来说,已经定义了一些基本的Blots:Scroll(滚动)、Block(块)、Inline(内联)、Text(文本)和Break(断点)。当您输入时,Text blot会与对应的DOM Text节点同步;按下回车键则通过创建一个新的Block blot来处理。在Parchment中,能够拥有子节点的Blots至少必须有一个子节点,因此空的Blocks会填充一个Break blot。这使得处理叶子节点变得简单且可预测。所有这些都组织在一个根Scroll blot下。

在这个阶段,您无法仅通过打字来观察Inline blot,因为它不会对文档产生有意义的结构或格式。一个有效的Quill文档必须是规范且紧凑的。能够表示给定文档的只有一个有效的DOM树,而这个DOM树包含最小数量的节点。

由于<p><span>Text</span></p><p>Text</p>表示相同的内容,前者是无效的,解包<span>是Quill优化过程的一部分。类似地,一旦我们添加了格式,<p><em>Te</em><em>st</em></p><p><em><em>Test</em></em></p>也是无效的,因为它们不是最紧凑的表示形式。

由于这些限制,Quill不能支持任意的DOM树和HTML更改。但正如我们将看到的,这种结构提供的一致性和可预测性使我们能够轻松构建丰富的编辑体验。

基本格式


我们之前提到,内联(Inline)不贡献格式。这是基本情况的例外,而不是规则,为基本的内联类而设。基本的块级(Block)Blot与块级元素的工作方式相同。

为了实现加粗和斜体,我们只需要从Inline继承,设置blotNametagName,并将其注册到Quill。有关继承和静态方法和变量签名的完整参考,请查看Parchment。

const Inline = Quill.import('blots/inline');

class BoldBlot extends Inline {
  static blotName = 'bold';
  static tagName = 'strong';
}

class ItalicBlot extends Inline {
  static blotName = 'italic';
  static tagName = 'em';
}

Quill.register(BoldBlot);
Quill.register(ItalicBlot);

我们在这里遵循Medium的示例使用strongem标签,但您也可以使用bi标签。Blot的名称将被用作Quill的格式名称。通过注册我们的Blot,我们现在可以使用Quill的完整API在我们的新格式上:

在Quill富文本编辑器中,Blot是构建编辑器内容的基本单位。每个Blot代表文档中的一个可编辑元素,比如一个段落、一个图片或一个文本格式(例如加粗或斜体)。Quill利用一个富文本模型(Parchment)来定义和管理这些Blots,它们共同构成了编辑器的文档模型。

Blot名称和格式名称

当定义一个自定义Blot时,该Blot的名称非常重要,因为它直接关联到Quill的格式名称。这意味着:

  • Blot名称的定义:在创建一个自定义Blot时,你需要为它指定一个唯一的名称。这个名称在Quill编辑器中用于引用该格式。
  • 作为格式名称使用:在编辑器中应用格式时,使用的格式名称实际上就是定义Blot时所指定的名称。例如,如果你创建了一个自定义Blot,命名为myCustomFormat,那么在通过API应用这个格式到选中的文本时,将使用这个名称:quill.format('myCustomFormat', true);

这条命令会应用myCustomFormat格式到当前选中的文本或插入点。在实际效果中,这意味着Quill会创建一个MyCustomBlot的实例,并将其插入到文档中,从而将选中的文本包裹或标记为myCustomFormat格式。

Quill.register(BoldBlot);
Quill.register(ItalicBlot);

const quill = new Quill('#editor');

quill.insertText(0, 'Test', { bold: true });
quill.formatText(0, 4, 'italic', true);
// 如果我们将斜体Blot命名为"myitalic",我们将调用
// quill.formatText(0, 4, 'myitalic', true);

让我们摆脱我们的虚拟按钮处理程序,并将加粗和斜体按钮连接到Quill的format()。为了简单起见,我们将硬编码为true,始终添加格式。在您的应用程序中,您可以使用getFormat()来检索任意范围的当前格式,以决定是否添加或删除格式。工具栏模块为Quill实现了这一点,我们将在这里重新实现它。

链接


链接稍微复杂一些,因为我们存储链接url需要不仅仅是一个布尔值。这在两个方面影响我们的链接Blot:创建和格式检索。我们将url表示为字符串值,但我们也可以以其他方式表示,例如具有url键的对象,允许设置其他键/值对来定义链接。我们将在稍后使用图像演示这一点。

class LinkBlot extends Inline {
  static blotName = 'link';
  static tagName = 'a';

  static create(value) {
    const node = super.create();
    // 如果需要,对url值进行消毒
    node.setAttribute('href', value);
    // 可以设置其他非格式相关属性
    // 这些对Parchment是不可见的,所以必须是静态的
    node.setAttribute('target', '_blank');
    return node;
  }

  static formats(node) {
    // 我们只会被调用,使用已经
    // 确定为链接Blot的节点,所以我们
    // 不需要检查自己
    return node.getAttribute('href');
  }
}

Quill.register(LinkBlot);

现在我们可以将我们的链接按钮连接到一个花哨的提示,再次为了保持简单,然后传递给Quill的format()

引用和标题


引用(Blockquotes)的实现方式与加粗Blot相同,只不过我们将从块级(Block)基础Blot继承。虽然内联(Inline)Blot可以嵌套,但块级Blot不能。当应用于相同的文本范围时,块级Blot不会包裹,而是相互替换。

const Block = Quill.import('blots/block');

class BlockquoteBlot extends Block {
  static blotName = 'blockquote';
  static tagName = 'blockquote';
}

标题的实现方式完全相同,只有一个区别:它可以由多个DOM元素表示。格式的值默认成为tagName,而不仅仅是true。我们可以通过扩展formats()来自定义这一点,就像我们对链接所做的那样。

class HeaderBlot extends Block {
  static blotName = 'header';
  // Medium只支持两个标题大小,所以我们只演示两个,
  // 但我们很容易就在这个数组中添加更多的标签
  static tagName = ['H1', 'H2'];

  static formats(node) {
    return HeaderBlot.tagName.indexOf(node.tagName) + 1;
  }
}

让我们将这些新的Blot连接到它们各自的按钮,并为<blockquote>标签添加一些CSS。

分隔线


现在让我们实现我们的第一个叶子Blot。虽然我们之前的Blot示例贡献了格式(formatting)并实现了format(),但叶子Blot贡献了内容并实现了value()叶子Blot可以是文本或嵌入(Embed)Blot,所以我们的章节分隔线将是一个嵌入。一旦创建,嵌入Blot的值就是不可变的,需要删除和重新插入来改变该位置的内容。

我们的方法与之前类似,只不过我们从块级嵌入(BlockEmbed)继承。嵌入(Embed)也存在于blots/embed下,但那是为内联级Blot设计的。我们希望对分隔线使用块级实现。

const BlockEmbed = Quill.import('blots/block/embed');

class DividerBlot extends BlockEmbed {
  static blotName = 'divider';
  static tagName = 'hr';
}

我们的点击处理程序调用insertEmbed(),它不像format()那样方便地确定、保存和恢复用户选择,所以我们不得不做更多的工作来自己保留选择。此外,当我们尝试在块中间插入一个块级嵌入时,Quill会为我们分割块。为了使这种行为更清晰,我们将通过在插入分隔线之前插入一个新行来明确地分割块。

index.html

<link href="https://cdn.jsdelivr.net/npm/quill@2.0.0-rc.4/dist/quill.core.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.0-rc.4/dist/quill.core.js"></script>

<link href="/styles.css" rel="stylesheet">

<div id="tooltip-controls">
  <button id="bold-button"><i class="fa fa-bold"></i></button>
  <button id="italic-button"><i class="fa fa-italic"></i></button>
  <button id="link-button"><i class="fa fa-link"></i></button>
  <button id="blockquote-button"><i class="fa fa-quote-right"></i></button>
  <button id="header-1-button"><i class="fa fa-header"><sub>1</sub></i></button>
  <button id="header-2-button"><i class="fa fa-header"><sub>2</sub></i></button>
</div>
<div id="sidebar-controls">
  <button id="image-button"><i class="fa fa-camera"></i></button>
  <button id="video-button"><i class="fa fa-play"></i></button>
  <button id="tweet-button"><i class="fa fa-twitter"></i></button>
  <button id="divider-button"><i class="fa fa-minus"></i></button>
</div>

<div id="editor">Tell your story...</div>

<script type="module" src="/index.js"></script>

dividerBlot.js

const BlockEmbed = Quill.import('blots/block/embed');

class DividerBlot extends BlockEmbed {
  static blotName = 'divider';
  static tagName = 'hr';
}

Quill.register(DividerBlot);

index.js

import './formats/boldBlot.js';
import './formats/italicBlot.js';
import './formats/linkBlot.js';
import './formats/blockquoteBlot.js';
import './formats/headerBlot.js';
import './formats/dividerBlot.js';

const onClick = (selector, callback) => {
  document.querySelector(selector).addEventListener('click', callback);
};

onClick('#bold-button', () => {
  quill.format('bold', true);
});

onClick('#italic-button', () => {
  quill.format('italic', true);
});

onClick('#link-button', () => {
  const value = prompt('Enter link URL');
  quill.format('link', value);
});

onClick('#blockquote-button', () => {
  quill.format('blockquote', true);
});

onClick('#header-1-button', () => {
  quill.format('header', 1);
});

onClick('#header-2-button', () => {
  quill.format('header', 2);
});

onClick('#divider-button', () => {
  const range = quill.getSelection(true);
  quill.insertText(range.index, '\n', Quill.sources.USER);
  quill.insertEmbed(range.index + 1, 'divider', true, Quill.sources.USER);
  quill.setSelection(range.index + 2, Quill.sources.SILENT);
});

const quill = new Quill('#editor');

图片


图片可以使用我们在构建链接和分隔线Blot时学到的知识添加。我们将使用一个对象来表示值,以展示这种支持。我们插入图片的按钮处理程序将使用一个静态值,因此我们不会被与Parchment无关的提示UI代码分散注意力,Parchment是本指南的重点。

const BlockEmbed = Quill.import('blots/block/embed');

class ImageBlot extends BlockEmbed {
  static blotName = 'image';
  static tagName = 'img';

  static create(value) {
    const node = super.create();
    node.setAttribute('alt', value.alt);
    node.setAttribute('src', value.url);
    return node;
  }

  static value(node) {
    return {
      alt: node.getAttribute('alt'),
      url: node.getAttribute('src')
    };
    }
}
Quill.register(ImageBlot);

<button id="image-button"><i class="fa fa-camera"></i></button>

<div id="editor">Tell your story...</div>

<script>
  onClick('#image-button', () => {
  const range = quill.getSelection(true);
  quill.insertText(range.index, '\n', Quill.sources.USER);
  quill.insertEmbed(range.index + 1, 'image', {
    alt: 'Quill Cloud',
    url: 'https://quilljs.com/0.20/assets/images/cloud.png'
  }, Quill.sources.USER);
  quill.setSelection(range.index + 2, Quill.sources.SILENT);
});

  const quill = new Quill('#editor');
</script>

视频


我们将以与图片类似的方式实现视频。我们可以使用HTML5的<video>标签,但我们不能用这种方式播放YouTube视频,由于这可能是更常见和相关的用例,我们将使用<iframe>来支持这一点。我们在这里不需要这样做,但如果您希望多个Blot使用相同的标签(tag),您可以在下一个推文示例中使用className以及tagName

此外,我们将添加对宽度和高度的支持,作为未注册的格式。特定于嵌入的格式不需要单独注册,只要没有与注册格式的命名空间冲突即可。这之所以有效,是因为Blot只是将未知格式传递给它的子元素,最终到达叶子。这也允许不同的嵌入以不同的方式处理未注册格式。例如,我们之前的image嵌入可以以与此处的video不同的方式识别和处理宽度格式。

class VideoBlot extends BlockEmbed {
  static blotName = 'video';
  static tagName = 'iframe';

  static create(url) {
    const node = super.create();
    node.setAttribute('src', url);
    // 使用静态值设置非格式相关属性
    node.setAttribute('frameborder', '0');
    node.setAttribute('allowfullscreen', true);

    return node;
  }

  static formats(node) {
    // 我们仍然需要报告未注册的嵌入格式
    const format = {};
    if (node.hasAttribute('height')) {
      format.height = node.getAttribute('height');
    }
    if (node.hasAttribute('width')) {
      format.width = node.getAttribute('width');
    }
    return format;
  }

  static value(node) {
    return node.getAttribute('src');
  }

  format(name, value) {
    // 处理未注册的嵌入格式
    if (name === 'height' || name === 'width') {
      if (value) {
        this.domNode.setAttribute(name, value);
      } else {
        this.domNode.removeAttribute(name, value);
      }
    } else {
      super.format(name, value);
    }
  }
}

Quill.register(VideoBlot);

请注意,如果您打开控制台并调用getContents,Quill将报告视频如下:

{
  ops: [{
    insert: {
      video: 'https://www.youtube.com/embed/QHH3iSeDBLo?showinfo=0&'
    },
    attributes: {
      height: '170',
      width: '400'
    }
  }]
}

<button id="video-button"><i class="fa fa-play"></i></button>

<div id="editor">Tell your story...</div>

<script>
  onClick('#video-button', () => {
  let range = quill.getSelection(true);
  quill.insertText(range.index, '\n', Quill.sources.USER);
  let url = 'https://www.youtube.com/embed/QHH3iSeDBLo?showinfo=0';
  quill.insertEmbed(range.index + 1, 'video', url, Quill.sources.USER);
  quill.formatText(range.index + 1, 1, { height: '170', width: '400' });
  quill.setSelection(range.index + 2, Quill.sources.SILENT);
});

const quill = new Quill('#editor');
</script>

推特


Medium支持许多嵌入类型,但我们将只关注本指南中的推文。推文Blot几乎与图片完全相同实现。我们利用了嵌入Blot不必对应一个空节点的事实。它可以是任何任意节点,Quill将其视为空节点,不会遍历其子元素或后代。这使我们能够使用<div>和原生Twitter JavaScript库在指定的<div>容器内做它想做的事情。

由于我们的根滚动Blot也使用<div>,我们还指定了一个className来消除歧义。请注意,内联Blot默认使用<span>,块级Blot默认使用<p>,所以如果您想为您的自定义Blot使用这些标签,您必须指定className以及tagName

我们使用推文id作为定义我们Blot的值。再次,我们的点击处理程序使用一个静态值,以避免分散注意力到无关的UI代码上。

class TweetBlot extends BlockEmbed {
  static blotName = 'tweet';
  static tagName = 'div';
  static className = 'tweet';

  static create(id) {
    const node = super.create();
    node.dataset.id = id;
    // 允许twitter库修改我们的内容
    twttr.widgets.createTweet(id, node);
    return node;
  }

  static value(domNode) {
    return domNode.dataset.id;
  }
}

Quill.register(TweetBlot);

<button id="tweet-button"><i class="fa fa-twitter"></i></button>
<div id="editor">Tell your story...</div>

<script>
  onClick('#tweet-button', () => {
    const range = quill.getSelection(true);
    const id = '464454167226904576';
    quill.insertText(range.index, '\n', Quill.sources.USER);
    quill.insertEmbed(range.index + 1, 'tweet', id, Quill.sources.USER);
    quill.setSelection(range.index + 2, Quill.sources.SILENT);
});

const quill = new Quill('#editor');
</script>
显示全文