使用 Parchment 克隆 Medium
为了提供一致的编辑体验,你需要一致的数据和可预测的行为。不幸的是,DOM 缺少这两项功能。现代编辑器的解决方案是维护自己的文档模型来表示其内容。羊皮纸 是 Quill 的解决方案。它拥有自己的代码库和 API 层。通过 Parchment,你可以自定义 Quill 识别的内容和格式,或添加全新的内容和格式。
¥To provide a consistent editing experience, you need both consistent data and predictable behaviors. The DOM unfortunately lacks both of these. The solution for modern editors is to maintain their own document model to represent their contents. Parchment is that solution for Quill. It is organized in its own codebase with its own API layer. Through Parchment you can customize the content and formats Quill recognizes, or add entirely new ones.
在本指南中,我们将使用 Parchment 和 Quill 提供的构建块在 Medium 上复制编辑器。我们将从 Quill 的基本框架开始,不包含任何主题、无关模块或格式。在这个基本层面上,Quill 只能理解纯文本。但在本指南结束时,链接、视频甚至推文都将被理解。
¥In this guide, we will use the building blocks provided by Parchment and Quill to replicate the editor on Medium. We will start with the bare bones of Quill, without any themes, extraneous modules, or formats. At this basic level, Quill only understands plain text. But by the end of this guide, links, videos, and even tweets will be understood.
基础工作
¥Groundwork
让我们先不使用 Quill,只使用一个文本区域和一个按钮,并连接到一个虚拟事件监听器。为了方便起见,我们将在本指南中使用 jQuery,但 Quill 和 Parchment 都不依赖 jQuery。我们还将借助 Google 字体 和 Font Awesome 添加一些基本样式。以上内容与 Quill 或 Parchment 无关,因此我们快速浏览一遍。
¥Let's start without even using Quill, with just a textarea and button, hooked up to a dummy event listener. We'll use jQuery for convenience throughout this guide, but neither Quill nor Parchment depends on this. We'll also add some basic styling, with the help of Google Fonts and Font Awesome. None of this has anything to do with Quill or Parchment, so we'll move through quickly.
<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>
添加 Quill 核心
¥Adding Quill Core
接下来,我们将用 Quill 核心替换文本区域,移除主题、格式和无关模块。在编辑器中输入内容时,打开开发者控制台查看演示。你可以看到 Parchment 文档的基本构建块是如何工作的。
¥Next, we'll replace the textarea with Quill core, absent of themes, formats and extraneous modules. Open up your developer console to inspect the demo while you type into the editor. You can see the basic building blocks of a Parchment document at work.
<link href="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/quill/2.0.0-dev.4/quill.core.css" rel="stylesheet" /> <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/quill/2.0.0-dev.4/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>
与 DOM 类似,Parchment 文档是一棵树。它的节点称为 Blot,是对 DOM 节点的抽象。一些 blot 已经为我们定义好了:滚动、块、内联、文本和中断。当你键入时,文本块会与相应的 DOM 文本节点同步;通过创建新的块块来处理输入。在 Parchment 中,可以包含子块的 blot 必须至少包含一个子块,因此空块会用 Break blot 填充。这使得处理叶子变得简单且可预测。所有这些都组织在根滚动块下。
¥Like the DOM, a Parchment document is a tree. Its nodes, called Blots, are an abstraction over DOM Nodes. A few blots are already defined for us: Scroll, Block, Inline, Text and Break. As you type, a Text blot is synchronized with the corresponding DOM Text node; enters are handled by creating a new Block blot. In Parchment, Blots that can have children must have at least one child, so empty Blocks are filled with a Break blot. This makes handling leaves simple and predictable. All this is organized under a root Scroll blot.
目前你无法通过键入来观察内联 Blot,因为它不会为文档提供有意义的结构或格式。有效的 Quill 文档必须规范且紧凑。只有一个有效的 DOM 树可以表示给定的文档,并且该 DOM 树包含最少数量的节点。
¥You cannot observe an Inline blot by just typing at this point since it does not contribute meaningful structure or formatting to the document. A valid Quill document must be canonical and compact. There is only one valid DOM tree that can represent a given document, and that DOM tree contains the minimal number of nodes.
由于 <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>
也将无效,因为它们不是最紧凑的表示形式。
¥Since <p><span>Text</span></p>
and <p>Text</p>
represent the same content, the former is invalid and it is part of Quill's optimization process to unwrap the <span>
. Similarly, once we add formatting, <p><em>Te</em><em>st</em></p>
and <p><em><em>Test</em></em></p>
are also invalid, as they are not the most compact representation.
由于这些限制,Quill 无法支持任意 DOM 树和 HTML 更改。但正如我们将看到的,这种结构提供的一致性和可预测性使我们能够轻松构建丰富的编辑体验。
¥Because of these constraints, Quill cannot support arbitrary DOM trees and HTML changes. But as we will see, the consistency and predicability this structure provides enables us to easily build rich editing experiences.
基本格式
¥Basic Formatting
我们之前提到过,内联不提供格式化功能。这是为基本 Inline 类设置的例外,而非规则。基本块块操作对块级元素的工作方式相同。
¥We mentioned earlier that an Inline does not contribute formatting. This is the exception, rather than the rule, made for the base Inline class. The base Block blot works the same way for block level elements.
要实现粗体和斜体,我们只需继承 Inline 类,设置 blotName
和 tagName
,并将其注册到 Quill 即可。有关继承和静态方法及变量签名的完整参考,请查看 羊皮纸。
¥To implement bold and italics, we need only to inherit from Inline, set the blotName
and tagName
, and register it with Quill. For a compelete reference of the signatures of inherited and static methods and variables, take a look at 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 的示例,使用 strong
和 em
标签,但你也可以使用 b
和 i
标签。Quill 将使用 blot 的名称作为格式名称。通过注册我们的 Blot,我们现在可以在新格式上使用 Quill 的完整 API:
¥We follow Medium's example here in using strong
and em
tags but you could just as well use b
and i
tags. The name of the blot will be used as the name of the format by Quill. By registering our blots, we can now use Quill's full API on our new formats:
Quill.register(BoldBlot);Quill.register(ItalicBlot);
const quill = new Quill('#editor');
quill.insertText(0, 'Test', { bold: true });quill.formatText(0, 4, 'italic', true);// If we named our italic blot "myitalic", we would call// quill.formatText(0, 4, 'myitalic', true);
让我们去掉虚拟按钮处理程序,并将粗体和斜体按钮连接到 Quill 的 format()
。为了简单起见,我们将对 true
进行硬编码,使其始终添加格式。在你的应用中,你可以使用 getFormat()
检索任意范围内的当前格式,以决定是添加还是删除格式。工具栏 模块为 Quill 实现了此功能,我们在此不再赘述。
¥Let's get rid of our dummy button handler and hook up the bold and italic buttons to Quill's format()
. We will hardcode true
to always add formatting for simplicity. In your application, you can use getFormat()
to retrieve the current formatting over a arbitrary range to decide whether to add or remove a format. The Toolbar module implements this for Quill, and we will not reimplement it here.
打开开发者控制台,在新的粗体和斜体格式上试用 Quill 的 APIs!确保将上下文设置为正确的 CodePen iframe,以便能够在演示中访问 quill
变量。
¥Open your developer console and try out Quill's APIs on your new bold and italic formats! Make sure to set the context to the correct CodePen iframe to be able to access the quill
variable in the demo.
import './formats/boldBlot.js'; import './formats/italicBlot.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); }); const quill = new Quill('#editor');
请注意,如果你对某些文本同时应用粗体和斜体,无论你执行的顺序如何,Quill 都会以一致的顺序将 <strong>
标签封装在 <em>
标签之外。
¥Note that if you apply both bold and italic to some text, regardless of what order you do so, Quill wraps the <strong>
tag outside of the <em>
tag, in a consistent order.
链接
¥Links
链接稍微复杂一些,因为我们需要的不仅仅是一个布尔值来存储链接 URL。这会以两种方式影响我们的 Link blot:创建和格式检索。我们将使用字符串值来表示 URL,但我们也可以使用其他方式轻松实现,例如使用带有 url 键的对象,这样就可以设置其他键/值对并定义链接。我们稍后将使用 images 进行演示。
¥Links are slightly more complicated, since we need more than a boolean to store the link url. This affects our Link blot in two ways: creation and format retrieval. We will represent the url as a string value, but we could easily do so in other ways, such as an object with a url key, allowing for other key/value pairs to be set and define a link. We will demonstrate this later with images.
class LinkBlot extends Inline { static blotName = 'link'; static tagName = 'a';
static create(value) { const node = super.create(); // Sanitize url value if desired node.setAttribute('href', value); // Okay to set other non-format related attributes // These are invisible to Parchment so must be static node.setAttribute('target', '_blank'); return node; }
static formats(node) { // We will only be called with a node already // determined to be a Link blot, so we do // not need to check ourselves return node.getAttribute('href'); }}
Quill.register(LinkBlot);
现在,我们可以将链接按钮连接到一个更酷的 prompt
,同样是为了简单起见,然后再传递给 Quill 的 format()
。
¥Now we can hook our link button up to a fancy prompt
, again to keep things simple, before passing to Quill's format()
.
const Inline = Quill.import('blots/inline'); class LinkBlot extends Inline { static blotName = 'link'; static tagName = 'a'; static create(url) { let node = super.create(); // Sanitize url if desired node.setAttribute('href', url); // Okay to set other non-format related attributes node.setAttribute('target', '_blank'); return node; } static formats(node) { // We will only be called with a node already // determined to be a Link blot, so we do // not need to check ourselves return node.getAttribute('href'); } } Quill.register(LinkBlot);
区块引用和标题
¥Blockquote and Headers
块引用的实现方式与粗体块相同,只是我们将继承自块,即基础块级块。内联 Blot 可以嵌套,但块 Blot 不能。当块块应用于同一文本范围时,它们会相互替换,而不是换行。
¥Blockquotes are implemented the same way as Bold blots, except we will inherit from Block, the base block level Blot. While Inline blots can be nested, Block blots cannot. Instead of wrapping, Block blots replace one another when applied to the same text range.
const Block = Quill.import('blots/block');
class BlockquoteBlot extends Block { static blotName = 'blockquote'; static tagName = 'blockquote';}
标题的实现方式完全相同,只有一点区别:它可以由多个 DOM 元素表示。默认情况下,格式的值会变成 tagName,而不仅仅是 true
。我们可以通过扩展 formats()
来定制它,类似于我们对 links 所做的操作。
¥Headers are implemented exactly the same way, with only one difference: it can be represented by more than one DOM element. The value of the format by default becomes the tagName, instead of just true
. We can customize this by extending formats()
, similar to how we did so for links.
class HeaderBlot extends Block { static blotName = 'header'; // Medium only supports two header sizes, so we will only demonstrate two, // but we could easily just add more tags into this array static tagName = ['H1', 'H2'];
static formats(node) { return HeaderBlot.tagName.indexOf(node.tagName) + 1; }}
让我们将这些新的块连接到各自的按钮,并为 <blockquote>
标签添加一些 CSS。
¥Let's hook these new blots up to their respective buttons and add some CSS for the <blockquote>
tag.
const Block = Quill.import('blots/block'); class BlockquoteBlot extends Block { static blotName = 'blockquote'; static tagName = 'blockquote'; } Quill.register(BlockquoteBlot);
尝试将一些文本设置为 H1,然后在控制台中运行 quill.getContents()
。你将看到我们自定义的静态 formats()
函数正在运行。确保将上下文设置为正确的 CodePen iframe,以便能够在演示中访问 quill
变量。
¥Try setting some text to H1, and in your console, run quill.getContents()
. You will see our custom static formats()
function at work. Make sure to set the context to the correct CodePen iframe to be able to access the quill
variable in the demo.
分隔符
¥Dividers
现在让我们实现第一个叶子 Blot。我们之前的 Blot 示例贡献格式并实现 format()
,而叶 Blot 贡献内容并实现 value()
。叶子块可以是文本块,也可以是嵌入块,所以我们的分隔符将是嵌入块。一旦创建,嵌入 Blot 的值是不可变的,需要删除并重新插入才能更改该位置的内容。
¥Now let's implement our first leaf Blot. While our previous Blot examples contribute formatting and implement format()
, leaf Blots contribute content and implement value()
. Leaf Blots can either be Text or Embed Blots, so our section divider will be an Embed. Once created, Embed Blots' value is immutable, requiring deletion and reinsertion to change the content at that location.
我们的方法与之前类似,只是我们继承自 BlockEmbed。Embed 也存在于 blots/embed
下,但用于内联级别的 blot。我们希望使用块级实现来表示分隔符。
¥Our methodology is similar to before, except we inherit from a BlockEmbed. Embed also exists under blots/embed
, but is meant for inline level blots. We want the block level implementation instead for dividers.
const BlockEmbed = Quill.import('blots/block/embed');
class DividerBlot extends BlockEmbed { static blotName = 'divider'; static tagName = 'hr';}
我们的点击处理程序调用 insertEmbed()
,它不像 format()
那样方便地为我们确定、保存和恢复用户选择,因此我们必须自己做一些额外的工作来保存选择。此外,当我们尝试在块中间插入一个 BlockEmbed 时,Quill 会为我们拆分块。为了使此行为更加清晰,我们将通过在插入分隔符之前插入换行符来明确地拆分块。查看 CodePen 中的 Babel 标签以了解更多详情。
¥Our click handler calls insertEmbed()
, which does not as conveniently determine, save, and restore the user selection for us like format()
does, so we have to do a little more work to preserve selection ourselves. In addition, when we try to insert a BlockEmbed in the middle of the Block, Quill splits the Block for us. To make this behavior more clear, we will explicitly split the block oursevles by inserting a newline before inserting the divider. Take a look at the Babel tab in the CodePen for specifics.
const BlockEmbed = Quill.import('blots/block/embed'); class DividerBlot extends BlockEmbed { static blotName = 'divider'; static tagName = 'hr'; } Quill.register(DividerBlot);
图片
¥Images
你可以利用我们在构建 链接 和 分隔线 blot 时学到的知识来添加图片。我们将使用一个对象作为值来展示如何支持此功能。我们用于插入图片的按钮处理程序将使用静态值,因此我们不会被与 羊皮纸(本指南的重点)无关的工具提示 UI 代码所干扰。
¥Images can be added with what we learned building the Link and Divider blots. We will use an object for the value to show how this is supported. Our button handler to insert images will use a static value, so we are not distracted by tooltip UI code irrelevant to Parchment, the focus of this guide.
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') }; }}
const BlockEmbed = Quill.import('blots/block/embed'); class ImageBlot extends BlockEmbed { static blotName = 'image'; static tagName = 'img'; static create(value) { let 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);
视频
¥Videos
我们将以与 images 类似的方式实现视频。我们可以使用 HTML5 <video>
标签,但无法以这种方式播放 YouTube 视频,而且由于这可能是更常见且更相关的用例,我们将使用 <iframe>
来支持这一点。这里我们不必这样做,但如果你希望多个 Blot 使用相同的标签,除了 tagName
之外,你还可以使用 className
,如下一个 推文 示例中所示。
¥We will implement videos in a similar way as we did images. We could use the HTML5 <video>
tag but we cannot play YouTube videos this way, and since this is likely the more common and relevant use case, we will use an <iframe>
to support this. We do not have to here, but if you want multiple Blots to use the same tag, you can use className
in addition to tagName
, demonstrated in the next Tweet example.
此外,我们将添加对宽度和高度的支持,作为未注册的格式。嵌入特有的格式无需单独注册,只要命名空间与已注册的格式不冲突即可。这是有效的,因为 Blots 只会将未知格式传递给其子级,最终到达叶子节点。这也允许不同的 Embed 以不同的方式处理未注册的格式。例如,我们之前嵌入的 image 识别和处理 width
格式的方式可能与我们此处的视频不同。
¥Additionally we will add support for widths and heights, as unregistered formats. Formats specific to Embeds do not have to be registered separately, as long as there is no namespace collision with registered formats. This works since Blots just pass unknown formats to its children, eventually reaching the leaves. This also allows different Embeds to handle unregistered formats differently. For example, our image embed from earlier could have recognized and handled the width
format differently than our video does here.
class VideoBlot extends BlockEmbed { static blotName = 'video'; static tagName = 'iframe';
static create(url) { const node = super.create(); node.setAttribute('src', url); // Set non-format related attributes with static values node.setAttribute('frameborder', '0'); node.setAttribute('allowfullscreen', true);
return node; }
static formats(node) { // We still need to report unregistered embed formats 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) { // Handle unregistered embed formats if (name === 'height' || name === 'width') { if (value) { this.domNode.setAttribute(name, value); } else { this.domNode.removeAttribute(name, value); } } else { super.format(name, value); } }}
请注意,如果你打开控制台并调用 getContents
,Quill 会将视频报告为:
¥Note if you open your console and call getContents
, Quill will report the video as:
{ ops: [{ insert: { video: 'https://www.youtube.com/embed/QHH3iSeDBLo?showinfo=0' }, attributes: { height: '170', width: '400' } }]}
const BlockEmbed = Quill.import('blots/block/embed'); class VideoBlot extends BlockEmbed { static blotName = 'video'; static tagName = 'iframe'; static create(url) { let node = super.create(); node.setAttribute('src', url); node.setAttribute('frameborder', '0'); node.setAttribute('allowfullscreen', true); return node; } static formats(node) { let 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);
推文
¥Tweets
Medium 支持多种嵌入类型,但本指南中我们只关注推文。Tweet 块的实现方式与 images 几乎完全相同。我们利用了嵌入块不必与空节点对应的特性。它可以是任意节点,Quill 会将其视为空节点,而不会遍历其子节点或后代节点。这使我们能够使用 <div>
和原生 Twitter JavaScript 库在我们指定的 <div>
容器中执行其所需的操作。
¥Medium supports many embed types, but we will just focus on Tweets for this guide. The Tweet blot is implemented almost exactly the same as images. We take advantage of the fact that Embed blots do not have to correspond to a void node. It can be any arbitrary node and Quill will treat it like a void node and not traverse its children or descendants. This allows us to use a <div>
and the native Twitter JavaScript library to do what it pleases within the <div>
container we specify.
由于我们的根 Scroll Blot 也使用 <div>
,因此我们还指定了 className
来消除歧义。注意:内联 Blot 默认使用 <span>
,块 Blot 默认使用 <p>
,因此如果你想在自定义 Blot 中使用这些标签,则除了 tagName
之外,还必须指定 className
。
¥Since our root Scroll Blot also uses a <div>
, we also specify a className
to disambiguate. Note Inline blots use <span>
and Block Blots use <p>
by default, so if you would like to use these tags for your custom Blots, you will have to specify a className
in addition to a tagName
.
我们使用推文 ID 作为定义块的值。同样,我们的点击处理程序使用静态值,以避免被不相关的 UI 代码干扰。
¥We use the Tweet id as the value defining our Blot. Again our click handler uses a static value to avoid distraction from irrelevant UI code.
class TweetBlot extends BlockEmbed { static blotName = 'tweet'; static tagName = 'div'; static className = 'tweet';
static create(id) { const node = super.create(); node.dataset.id = id; // Allow twitter library to modify our contents twttr.widgets.createTweet(id, node); return node; }
static value(domNode) { return domNode.dataset.id; }}
const BlockEmbed = Quill.import('blots/block/embed'); class TweetBlot extends BlockEmbed { static blotName = 'tweet'; static tagName = 'div'; static className = 'tweet'; static create(id) { let node = super.create(); node.dataset.id = id; twttr.widgets.createTweet(id, node); return node; } static value(domNode) { return domNode.dataset.id; } } Quill.register(TweetBlot);