Cloning Medium with Parchment

注意:本节包含大量代码,原文都是 codepen 的代码预览视图,我为了阅读方便也将代码贴过来了,但是 HTML 和 css 代码我只会贴一遍,后面遇到 codepen 的地方我只贴 js 的代码。

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

在本指南中,我们将使用Parchment和Quill提供的构建块在Medium上复制编辑器。 我们将从Quill的骨干开始,没有任何主题,无关的模块或格式。 在这个基本级别,Quill只能理解纯文本。 但是,在本指南结束时,我们将了解链接,视频甚至推文。

Groundwork(准备工作)

让我们在没有使用Quill的情况下开始,只需要一个textarea和按钮,连接到一个虚拟事件监听器。 我们将在本指南中使用jQuery以方便使用,但Quill和Parchment都不依赖于此。在Google FontsFont Awesome的帮助下,我们还将添加一些基本样式。 这些与Quill或Parchment没有任何关系,所以我们会快速进行。

HTML:

  1. <div id="tooltip-controls">
  2. <button id="bold-button"><i class="fa fa-bold"></i></button>
  3. <button id="italic-button"><i class="fa fa-italic"></i></button>
  4. <button id="link-button"><i class="fa fa-link"></i></button>
  5. <button id="blockquote-button"><i class="fa fa-quote-right"></i></button>
  6. <button id="header-1-button"><i class="fa fa-header"><sub>1</sub></i></button>
  7. <button id="header-2-button"><i class="fa fa-header"><sub>2</sub></i></button>
  8. </div>
  9. <div id="sidebar-controls">
  10. <button id="image-button"><i class="fa fa-camera"></i></button>
  11. <button id="video-button"><i class="fa fa-play"></i></button>
  12. <button id="tweet-button"><i class="fa fa-twitter"></i></button>
  13. <button id="divider-button"><i class="fa fa-minus"></i></button>
  14. </div>
  15. <!-- <textarea id="editor-container">Tell your story...</textarea> -->
  16. <div id="editor-container">Tell your story...</div>

CSS:

  1. * {
  2. box-sizing: border-box;
  3. }
  4. html, body {
  5. height: 100%;
  6. margin: 0;
  7. width: 100%;
  8. }
  9. body {
  10. padding: 25px;
  11. }
  12. #editor-container {
  13. display: block;
  14. font-family: 'Open Sans', Helvetica, sans-serif;
  15. font-size: 1.2em;
  16. height: 200px;
  17. margin: 0 auto;
  18. width: 450px;
  19. }
  20. #tooltip-controls, #sidebar-controls {
  21. text-align: center;
  22. }
  23. button {
  24. background: transparent;
  25. border: none;
  26. cursor: pointer;
  27. display: inline-block;
  28. font-size: 18px;
  29. padding: 0;
  30. height: 32px;
  31. width: 32px;
  32. text-align: center;
  33. }
  34. button:active, button:focus {
  35. outline: none;
  36. }

JavaScript:

  1. $('button').click(function() {
  2. alert('Click!');
  3. });

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。

Adding Quill Core

接下来,我们将用Quill核心替换textarea,缺少主题,格式和无关模块。 在键入编辑器时打开开发人员控制台以检查演示。 您可以在工作中看到Parchment文档的基本构建块。

  1. $('button').click(function() {
  2. alert('Click!');
  3. });
  4. let quill = new Quill('#editor-container');

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。

与DOM一样,Parchment文档也是一棵树。 它的节点称为Blots,是DOM节点的抽象。我们已经为我们定义了一些blots:Scroll, Block, Inline, Text 和 Break。键入时,Text blot与相应的DOM Text节点同步; 输入通过创建新的blot块来处理。在Parchment中,可以拥有孩子的Blots必须至少有一个孩子,所以空的块填充了一个Break blot。这使得处理叶子简单且可预测。 所有这些都是在根Scroll blot下组织的。

您只能在此处键入内容来观察Inline blot,因为它不会为文档提供有意义的结构或格式。合理的Quill文档必须是规范和紧凑的。这里只有一个可以表示给定文档的有效DOM树,并且该DOM树包含最少数量的节点。

由于<p><span>文本</span> </p><p>文本</p>表示相同的内容,因此前者无效,并且它是Quill打开<span>的优化过程的一部分。同样,一旦我们添加格式,<p><em>Te</em><em>st</em></p><p><em><em>Test</em></em> </p>也无效,因为它们不是最紧凑的表示。

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

Basic Formatting

我们之前提到Inline不提供格式化。 这是基础Inline类的例外,而不是规则。 基本块级blot对块级元素的工作方式相同。

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

  1. let Inline = Quill.import('blots/inline');
  2. class BoldBlot extends Inline { }
  3. BoldBlot.blotName = 'bold';
  4. BoldBlot.tagName = 'strong';
  5. class ItalicBlot extends Inline { }
  6. ItalicBlot.blotName = 'italic';
  7. ItalicBlot.tagName = 'em';
  8. Quill.register(BoldBlot);
  9. Quill.register(ItalicBlot);

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

  1. Quill.register(BoldBlot);
  2. Quill.register(ItalicBlot);
  3. var quill = new Quill('#editor');
  4. quill.insertText(0, 'Test', { bold: true });
  5. quill.formatText(0, 4, 'italic', true);
  6. // If we named our italic blot "myitalic", we would call
  7. // quill.formatText(0, 4, 'myitalic', true);

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

打开您的开发人员控制台,以新的粗体和斜体格式试用Quill的API! 确保将上下文设置为正确的CodePen iframe,以便能够访问演示中的quill变量。

  1. let Inline = Quill.import('blots/inline');
  2. class BoldBlot extends Inline { }
  3. BoldBlot.blotName = 'bold';
  4. BoldBlot.tagName = 'strong';
  5. class ItalicBlot extends Inline { }
  6. ItalicBlot.blotName = 'italic';
  7. ItalicBlot.tagName = 'em';
  8. Quill.register(BoldBlot);
  9. Quill.register(ItalicBlot);
  10. var quill = new Quill('#editor-container');
  11. $('#bold-button').click(function() {
  12. quill.format('bold', true);
  13. });
  14. $('#italic-button').click(function() {
  15. quill.format('italic', true);
  16. });

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。

请注意,如果您对某些文本同时应用粗体和斜体,则无论您执行何种顺序,Quill都会以一致的顺序将<strong>标记包装在<em>标记之外。

Links

链接稍微复杂一些,因为我们需要不止一个布尔值来存储链接URL。这会以两种方式影响我们的链接blot:创建和格式检索。我们将url表示为字符串值,但我们可以通过其他方式轻松实现,例如带有url键的对象,允许设置其他键/值对并定义链接。我们稍后将用下文的image演示。

  1. class LinkBlot extends Inline {
  2. static create(value) {
  3. let node = super.create();
  4. // 如果需要,清除url值
  5. node.setAttribute('href', value);
  6. // 可以设置其他非格式相关的属性
  7. // 这些对于Parchment是不可见的,所以必须是静态的
  8. node.setAttribute('target', '_blank');
  9. return node;
  10. }
  11. static formats(node) {
  12. // 我们将只在已经有节点的情况下被调用
  13. // 确定是Link blot,所以我们这样做
  14. // 不需要检查自己
  15. return node.getAttribute('href');
  16. }
  17. }
  18. LinkBlot.blotName = 'link';
  19. LinkBlot.tagName = 'a';
  20. Quill.register(LinkBlot);

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

  1. let Inline = Quill.import('blots/inline');
  2. class BoldBlot extends Inline { }
  3. BoldBlot.blotName = 'bold';
  4. BoldBlot.tagName = 'strong';
  5. class ItalicBlot extends Inline { }
  6. ItalicBlot.blotName = 'italic';
  7. ItalicBlot.tagName = 'em';
  8. class LinkBlot extends Inline {
  9. static create(url) {
  10. let node = super.create();
  11. node.setAttribute('href', url);
  12. node.setAttribute('target', '_blank');
  13. return node;
  14. }
  15. static formats(node) {
  16. return node.getAttribute('href');
  17. }
  18. }
  19. LinkBlot.blotName = 'link';
  20. LinkBlot.tagName = 'a';
  21. Quill.register(BoldBlot);
  22. Quill.register(ItalicBlot);
  23. Quill.register(LinkBlot);
  24. let quill = new Quill('#editor-container');
  25. $('#bold-button').click(function() {
  26. quill.format('bold', true);
  27. });
  28. $('#italic-button').click(function() {
  29. quill.format('italic', true);
  30. });
  31. $('#link-button').click(function() {
  32. let value = prompt('Enter link URL');
  33. quill.format('link', value);
  34. });

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。

Blockquote and Headers

Blockquotes的实现方式与Bold blots相同,只是我们要继承的基本块级别的Blot是Block,而不是Inline。 虽Inline blots可以嵌套,但Block blots不能。 当应用于相同的文本范围时,Block blots不是合并,而是直接相互替换。

  1. let Block = Quill.import('blots/block');
  2. class BlockquoteBlot extends Block { }
  3. BlockquoteBlot.blotName = 'blockquote';
  4. BlockquoteBlot.tagName = 'blockquote';

标题的实现方式完全相同,只有一个区别:它可以由多个DOM元素表示。默认情况下,格式的值变为tagName,而不是true。我们可以通过扩展formats()来定制它,类似于我们links那样。

  1. class HeaderBlot extends Block {
  2. static formats(node) {
  3. return HeaderBlot.tagName.indexOf(node.tagName) + 1;
  4. }
  5. }
  6. HeaderBlot.blotName = 'header';
  7. // Medium只支持两种页眉大小,所以我们只演示两种,
  8. // 但我们可以轻松地在这个数组中添加更多标签
  9. HeaderBlot.tagName = ['H1', 'H2'];

让我们将这些新的blot挂钩到各自的按钮,并为<blockquote>标记添加一些CSS。

  1. let Inline = Quill.import('blots/inline');
  2. let Block = Quill.import('blots/block');
  3. class BoldBlot extends Inline { }
  4. BoldBlot.blotName = 'bold';
  5. BoldBlot.tagName = 'strong';
  6. class ItalicBlot extends Inline { }
  7. ItalicBlot.blotName = 'italic';
  8. ItalicBlot.tagName = 'em';
  9. class LinkBlot extends Inline {
  10. static create(url) {
  11. let node = super.create();
  12. node.setAttribute('href', url);
  13. node.setAttribute('target', '_blank');
  14. return node;
  15. }
  16. static formats(node) {
  17. return node.getAttribute('href');
  18. }
  19. }
  20. LinkBlot.blotName = 'link';
  21. LinkBlot.tagName = 'a';
  22. class BlockquoteBlot extends Block { }
  23. BlockquoteBlot.blotName = 'blockquote';
  24. BlockquoteBlot.tagName = 'blockquote';
  25. class HeaderBlot extends Block { }
  26. HeaderBlot.blotName = 'header';
  27. HeaderBlot.tagName = ['h1', 'h2'];
  28. Quill.register(BoldBlot);
  29. Quill.register(ItalicBlot);
  30. Quill.register(LinkBlot);
  31. Quill.register(BlockquoteBlot);
  32. Quill.register(HeaderBlot);
  33. let quill = new Quill('#editor-container');
  34. $('#bold-button').click(function() {
  35. quill.format('bold', true);
  36. });
  37. $('#italic-button').click(function() {
  38. quill.format('italic', true);
  39. });
  40. $('#link-button').click(function() {
  41. let value = prompt('Enter link URL');
  42. quill.format('link', value);
  43. });
  44. $('#blockquote-button').click(function() {
  45. quill.format('blockquote', true);
  46. });
  47. $('#header-1-button').click(function() {
  48. quill.format('header', 1);
  49. });
  50. $('#header-2-button').click(function() {
  51. quill.format('header', 2);
  52. });

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。

尝试将一些文本设置为H1,然后在控制台中运行quill.getContents()。您将看到我们的自定义静态formats()函数正常工作。确保将上下文设置为正确的CodePen iframe,以便能够访问演示中的quill变量。

Dividers

现在让我们实现我们的第一片叶子Blot。虽然我们之前的Blot示例提供格式化并实现format(),leaf Blots 提供内容并实现value()。Leaf Blots可以是Text或Embed Blots,因此我们的section divider将是一个Embed。一旦创建,Embed Blots的值是不可变的,需要删除和重新插入才能更改该位置的内容。

除了从BlockEmbed继承之外,我们的方法与之前类似。Embed也存在于blots/embed下,但是用于内联级别的blots。我们希望块级实现代替分区器。

  1. let BlockEmbed = Quill.import('blots/block/embed');
  2. class DividerBlot extends BlockEmbed { }
  3. DividerBlot.blotName = 'divider';
  4. DividerBlot.tagName = 'hr';

我们的点击处理程序调用insertEmbed(),它不像我们那样可以方便地确定,保存和恢复用户选择,例如format()。是的,所以我们必须做更多的工作来保护自己的选择。另外,当我们尝试在Block的中间插入一个BlockEmbed时,Quill会为我们分割Block。为了使这种行为更加清晰,我们将通过在插入分隔符之前插入换行符来自行拆分块。请查看CodePen中的Babel选项卡以了解具体信息。

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。

Images

image的实现可以借鉴LinkDividerblots的实现方式。我们将使用一个对象作为值来显示如何支持它。我们用于插入图像的按钮处理程序将使用静态值,因此我们不会被与Parchment无关的工具提示UI代码分心,这是本指南的重点。

  1. let BlockEmbed = Quill.import('blots/block/embed');
  2. class ImageBlot extends BlockEmbed {
  3. static create(value) {
  4. let node = super.create();
  5. node.setAttribute('alt', value.alt);
  6. node.setAttribute('src', value.url);
  7. return node;
  8. }
  9. static value(node) {
  10. return {
  11. alt: node.getAttribute('alt'),
  12. url: node.getAttribute('src')
  13. };
  14. }
  15. }
  16. ImageBlot.blotName = 'image';
  17. ImageBlot.tagName = 'img';

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。

Videos

我们将以与image类似的方式实现视频。我们可以使用HTML5<video>标签,但我们不能以这种方式播放YouTube视频,因为这可能是更常见和相关的用例,我们将使用<iframe>来支持此功能。在此我们不必,但是如果你想要多个Blots来使用相同的标签,除了tagName之外你还可以使用className,在下一个Tweet示例中进行了演示。

此外,我们将添加对宽度和高度的支持,作为未注册的格式。只要没有与注册格式的命名空间冲突,就不必单独注册Embeds特有的格式。这是有效的,因为Blots只是将未知格式传递给它的孩子,最终到达了叶子。这也允许不同的Embeds以不同方式处理未注册的格式。例如,我们从早期嵌入的image可以识别和处理width格式,与我们的视频在这里不同。

  1. class VideoBlot extends BlockEmbed {
  2. static create(url) {
  3. let node = super.create();
  4. node.setAttribute('src', url);
  5. // 使用静态值设置非格式相关属性
  6. node.setAttribute('frameborder', '0');
  7. node.setAttribute('allowfullscreen', true);
  8. return node;
  9. }
  10. static formats(node) {
  11. // 我们仍然需要报告未注册的嵌入格式
  12. let format = {};
  13. if (node.hasAttribute('height')) {
  14. format.height = node.getAttribute('height');
  15. }
  16. if (node.hasAttribute('width')) {
  17. format.width = node.getAttribute('width');
  18. }
  19. return format;
  20. }
  21. static value(node) {
  22. return node.getAttribute('src');
  23. }
  24. format(name, value) {
  25. // 处理未注册的嵌入格式
  26. if (name === 'height' || name === 'width') {
  27. if (value) {
  28. this.domNode.setAttribute(name, value);
  29. } else {
  30. this.domNode.removeAttribute(name, value);
  31. }
  32. } else {
  33. super.format(name, value);
  34. }
  35. }
  36. }
  37. VideoBlot.blotName = 'video';
  38. VideoBlot.tagName = 'iframe';

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

  1. {
  2. ops: [{
  3. insert: {
  4. video: 'https://www.youtube.com/embed/QHH3iSeDBLo?showinfo=0'
  5. },
  6. attributes: {
  7. height: '170',
  8. width: '400'
  9. }
  10. }]
  11. }

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。

Tweets

Medium支持许多嵌入类型,但我们只关注本指南的推文。Tweet blot实现与image几乎完全相同。我们利用Embed blots不必对应于void节点的事实。它可以是任意节点,Quill会将其视为void节点,而不是遍历其子节点或后代。这允许我们使用<div>和本地Twitter Javascript库在我们指定的<div>容器中做它喜欢的事情。

由于我们的根Scroll Blot也使用className``<span>``<p>``tagName``className

我们使用Tweet id作为定义Blot的值。 我们的点击处理程序再次使用静态值来避免分散不相关的UI代码。

  1. class TweetBlot extends BlockEmbed {
  2. static create(id) {
  3. let node = super.create();
  4. node.dataset.id = id;
  5. // Allow twitter library to modify our contents
  6. twttr.widgets.createTweet(id, node);
  7. return node;
  8. }
  9. static value(domNode) {
  10. return domNode.dataset.id;
  11. }
  12. }
  13. TweetBlot.blotName = 'tweet';
  14. TweetBlot.tagName = 'div';
  15. TweetBlot.className = 'tweet';

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。

Final Polish

我们从一堆按钮和一个只能理解明文的Quill核心开始。使用Parchment,我们可以添加粗体,斜体,链接,块引用,标题,部分分隔符,图像,视频甚至推文。所有这些都是在维护可预测且一致的文档的同时,允许我们使用Quill强大的API以及这些新格式和内容。

让我们添加一些最后的润色来完成我们的演示。 它不会与Medium的用户界面相比,但我们会尽力接近。

注:原文这里是一个 codepen 的视图,我们可以直接通过这个链接过去。