Contribute to this guide

guide编辑引擎

@ckeditor/ckeditor5-engine 包是迄今为止所有包中最大的一个。因此,本指南只会介绍主要架构层和概念。更详细的指南将会陆续发布。

我们建议使用官方的 CKEditor 5 检查器 进行开发和调试。它将为您提供有关编辑器状态的大量有用信息,例如内部数据结构、选择、命令等等。

# 概述

编辑引擎实现了模型-视图-控制器(MVC)架构。架构本身并未由引擎强制执行,但在大多数实现中,它可以用此图来描述

Diagram of the engine’s MVC architecture.

您所看到的,是三个层:模型控制器视图。有一个模型文档,它被 转换 成独立的视图,即 编辑视图数据视图。这两个视图分别代表用户正在编辑的内容(您在浏览器中看到的 DOM 结构)和编辑器输入和输出数据(以插入数据处理器理解的格式)。这两个视图都包含虚拟 DOM 结构(自定义的 DOM 类结构),转换器和功能在此结构上运行,然后被渲染到 DOM。

绿色块是编辑器功能(插件)引入的代码。这些功能控制对模型所做的更改、这些更改如何转换为视图,以及如何根据触发的事件(视图和模型的事件)更改模型。

现在让我们分别谈谈每一层。

# 模型

模型由 元素文本节点 的 DOM 类树结构实现。与实际的 DOM 不同,在模型中,元素和文本节点都可以具有属性。

与 DOM 中一样,模型结构包含在 文档 中,该文档包含 根元素(模型和视图可能有多个根)。文档还保存其 选择 和其 更改历史记录

最后,文档、其 模式文档标记Model 的属性。Model 类的一个实例在 editor.model 属性中可用。除了保存上面描述的属性之外,模型还提供了更改文档及其标记的 API。

editor.model;                       // -> The data model.
editor.model.document;              // -> The document.
editor.model.document.getRoot();    // -> The document's root.
editor.model.document.selection;    // -> The document's selection.
editor.model.schema;                // -> The model's schema.

# 更改模型

文档结构、文档选择甚至元素创建的所有更改只能通过使用 模型编写器 来完成。其实例在 change()enqueueChange() 块中可用。

// Inserts text "foo" at the selection position.
editor.model.change( writer => {
    writer.insertText( 'foo', editor.model.document.selection.getFirstPosition() );
} );

// Apply bold to the entire selection.
editor.model.change( writer => {
    for ( const range of editor.model.document.selection.getRanges() ) {
        writer.setAttribute( 'bold', true, range );
    }
} );

在单个 change() 块中完成的所有更改将合并成一个撤消步骤(它们被添加到单个 批次 中)。当嵌套 change() 块时,所有更改都会被添加到最外层 change() 块的批次中。例如,下面的代码将创建一个撤消步骤

editor.model.change( writer => {
    writer.insertText( 'foo', paragraph, 'end' ); // foo.

    editor.model.change( writer => {
        writer.insertText( 'bar', paragraph, 'end' ); // foobar.
    } );

    writer.insertText( 'bom', paragraph, 'end' ); // foobarbom.
} );

对文档结构所做的所有更改都是通过应用 操作 来完成的。操作的概念来自 操作转换(简而言之:OT),这是一项支持协作功能的技术。由于 OT 需要一个系统能够通过每一个操作来转换每一个操作(以确定并发应用的操作的结果),因此操作集需要很小。CKEditor 5 具有非线性模型(通常,OT 实现使用扁平的、类似数组的模型,而 CKEditor 5 使用树结构),因此潜在的语义变化集更复杂。操作被分组到 批次 中。批次可以理解为一个撤消步骤。

# 文本属性

“粗体”和“斜体”等文本样式不是作为元素保留在模型中,而是作为文本属性保留(想想——就像元素属性)。下面的 DOM 结构

<p>
    "Foo "
    <strong>
        "bar"
    </strong>
</p>

将转换为以下模型结构

<paragraph>
    "Foo "  // text node
    "bar"   // text node with the bold=true attribute
</paragraph>

这种对内联文本样式的表示允许显着降低在模型上操作的算法的复杂性。例如,如果您有以下 DOM 结构

<p>
    "Foo "
    <strong>
        "bar"
    </strong>
</p>

并且您在字母 "b" 之前有一个选择("Foo ^bar"),这个位置是在 <strong> 内还是外?如果您使用 原生 DOM 选择,您可能会得到两个位置,一个是锚定在 <p> 中,另一个是锚定在 <strong> 中。在 CKEditor 5 中,这个位置精确地转换为 "Foo ^bar"

# 选择属性

好的,但是如何让 CKEditor 5 知道我想在上面描述的情况下让选择“变为粗体”?这非常重要,因为它会影响输入的文本是否也会变为粗体。

为了解决这个问题,选择也有 属性。如果选择放在 "Foo ^bar" 中并且它具有属性 bold=true,您就知道用户将输入粗体文本。

# 索引和偏移量

但是,刚才已经说过,在 <paragraph> 内有两个文本节点:"Foo ""bar"。如果您知道 原生 DOM 范围 的工作原理,您可能会问:“但是,如果选择位于两个文本节点的边界上,它是在左边的文本节点中锚定,右边的文本节点中锚定,还是在包含元素中锚定?”

这确实是 DOM API 的另一个问题。不仅在某个元素内部和外部的位置在视觉上可能相同,而且它们也可能在文本节点内部或外部锚定(如果位置位于文本节点边界)。当实现编辑算法时,所有这些都会造成极大的复杂性。

为了避免这些麻烦,并使协作编辑真正成为可能,CKEditor 5 使用了索引偏移量的概念。索引与节点(元素和文本节点)相关联,而偏移量与位置相关联。例如,在以下结构中

<paragraph>
    "Foo "
    <imageInline></imageInline>
    "bar"
</paragraph>

"Foo " 文本节点在其父节点中的索引为 0<imageInline></imageInline> 的索引为 1"bar" 的索引为 2

另一方面,<paragraph> 中的偏移量 x 转换为

偏移量 位置 节点
0 <paragraph>^Foo <imageInline></imageInline>bar</paragraph> "Foo "
1 <paragraph>F^oo <imageInline></imageInline>bar</paragraph> "Foo "
4 <paragraph>Foo ^<imageInline></imageInline>bar</paragraph> <imageInline>
6 <paragraph>Foo <imageInline></imageInline>b^ar</paragraph> "bar"

# 位置、范围和选择

引擎还定义了三个级别在偏移量上操作的类

  • Position 实例包含一个 偏移量数组(称为“路径”)。请参阅 Position#path API 文档 中的示例,以更好地了解路径的工作原理。
  • Range 包含两个位置:开始 位置和 结束 位置。
  • 最后,还有一个 Selection,它包含一个或多个范围、属性,并且有一个方向(是从左到右还是从右到左)。您可以根据需要创建任意多个实例,并且可以随时自由修改它。此外,还有一个 DocumentSelection。它代表文档的选择,并且只能通过 模型编写器 更改。当文档结构发生更改时,它会自动更新。

# 标记

标记是一种特殊的范围类型。

标记非常适合存储和维护与文档部分相关的附加数据,例如评论或其他用户的选择。

# 模式

模型的模式定义了模型外观的几个方面

  • 节点允许或不允许的位置。例如,paragraph 允许在 $root 中,但不允许在 heading1 中。
  • 对于特定节点允许哪些属性。例如,image 可以具有 srcalt 属性。
  • 模型节点的附加语义。例如,image 属于“object”类型,而 paragraph 属于“block”类型。

模式还可以定义哪些子节点和属性是特别不允许的,这在节点从其他节点继承属性但又想排除一些东西时很有用

  • 节点可以在某些地方被禁止。例如,自定义元素 specialParagraphparagraph 继承所有属性,但需要禁止 imageInline
  • 可以在特定节点上禁止属性。例如,自定义元素 specialPurposeHeadingheading2 继承属性,但不允许 alignment 属性。

然后,这些信息将被功能和引擎用于决定如何处理模型。例如,模式中的信息会影响

  • 粘贴内容时会发生什么以及哪些内容会被过滤掉(注意:在粘贴的情况下,另一个重要机制是转换。任何已注册的转换器都没有上转换的 HTML 元素和属性在成为模型节点之前就被过滤掉了,因此模式不会应用于它们;转换将在本指南的后面部分介绍)。
  • 标题功能可以应用于哪些元素(哪些块可以转换为标题,哪些元素首先是块)。
  • 哪些元素可以包含在块引用中。
  • 当选择在标题中时,粗体按钮是否启用(以及该标题中的文本是否可以加粗)。
  • 选择可以放置在何处(即 - 仅在文本节点和对象元素上)。
  • 等等。

模式默认由编辑器插件配置。建议每个编辑器功能都附带规则,这些规则能够在编辑器中启用和预先配置它。这将确保插件用户可以启用它,而不必担心重新配置它们的模式。

目前,没有直接的方法来覆盖由功能预先配置的模式。如果您想在初始化编辑器时覆盖默认设置,最佳解决方案是将 editor.model.schema 替换为它的新实例。但是,这需要重新构建编辑器。

模式的实例在editor.model.schema 中可用。在Schema 深入讲解指南中阅读有关使用模式 API 的详细指南。

# 视图

让我们再次看一下编辑引擎的架构

Diagram of the engine’s MVC architecture.

我们讨论了该图的最顶层 - 模型。模型层的目的是对数据进行抽象。它的格式旨在以最便捷的方式存储和修改数据,同时允许实现复杂的功能。大多数功能都作用于模型(从模型读取数据并更改模型)。

另一方面,视图是 DOM 结构的抽象表示,应该呈现给用户(用于编辑),并且在大多数情况下应该表示编辑器的输入和输出(即,由 editor.getData() 返回的数据,由 editor.setData() 设置的数据,粘贴的内容等)。

这意味着

  • 视图是另一种自定义结构。
  • 它类似于 DOM。虽然模型的树结构仅略微类似于 DOM(例如,通过引入文本属性),但视图更接近于 DOM。换句话说,它是一个**虚拟 DOM**。
  • 有两个“管道”:编辑管道(也称为“编辑视图”)和数据管道(“数据视图”)。将它们视为一个模型的两个独立视图。编辑管道呈现并处理用户可以看到和可以编辑的 DOM。数据管道在您调用 editor.getData()editor.setData() 或将内容粘贴到编辑器中时使用。
  • 视图由Renderer 呈现到 DOM,Renderer 处理驯服编辑管道中使用的 contentEditable 所需的所有怪癖。

API 中存在两个视图这一事实是可见的

editor.editing;                 // The editing pipeline (EditingController).
editor.editing.view;            // The editing view's controller.
editor.editing.view.document;   // The editing view's document.
editor.data;                    // The data pipeline (DataController).

从技术上讲,数据管道没有文档和视图控制器。它作用于为处理数据而创建的独立视图结构。

它比编辑管道简单得多,在本节的下一部分中,我们将讨论编辑视图。

查看EditingControllerDataController 的 API 以了解更多详细信息。

# 元素类型和自定义数据

视图的结构与 DOM 中的结构非常相似。HTML 的语义在其规范中定义。视图结构是“无 DTD 的”,因此为了提供附加信息并更好地表达内容的语义,视图结构实现了六种元素类型(ContainerElementAttributeElementEmptyElementRawElementUIElementEditableElement)以及所谓的“自定义属性”(即未呈现的自定义元素属性)。编辑器功能提供的这些附加信息随后被Renderer转换器 使用。

元素类型可以定义如下

  • 容器元素 - 用于构建内容结构的元素。用于块元素,例如 <p><h1><blockQuote><li> 等。
  • 属性元素 - 无法在其内部容纳容器元素的元素。大多数模型文本属性被转换为视图属性元素。它们主要用于内联样式元素,例如 <strong><i><a><code>。类似的属性元素被视图编写器扁平化。例如,<a href="..."><a class="bar">x</a></a> 将自动优化为 <a href="..." class="bar">x</a>
  • 空元素 - 不得有任何子节点的元素,例如 <img>
  • UI 元素 - 不属于“数据”但需要“内联”到内容中的元素。它们被选择(它跳过它们)和视图编写器一般忽略。这些元素的内容以及来自它们的事件也会被过滤掉。
  • 原始元素 - 用作数据容器(“包装器”,“沙箱”)的元素,但它们的子节点对编辑器是透明的。当必须渲染非标准数据但编辑器不应该关心它是什么是如何工作的时,这很有用。用户不能将选择放在原始元素内部,将其拆分为更小的块或直接修改其内容。
  • 可编辑元素 - 用作内容不可编辑片段的“嵌套可编辑元素”的元素。例如,图像小部件中的标题,其中包含图像的 <figure> 不可编辑(它是一个小部件),而它内部的 <figcaption> 是一个可编辑元素。

此外,您可以定义自定义属性,这些属性可用于存储信息,例如

  • 元素是否是一个小部件(由toWidget() 添加)。
  • 标记 突出显示元素时,元素应该如何标记。
  • 元素是否属于某个特定功能 - 是否是链接、进度条、标题等。

# 非语义视图

并非所有视图树都需要(也不能)使用语义元素类型构建。直接从输入数据(例如,粘贴的 HTML 或使用 editor.setData())生成的视图结构仅包含基本元素 实例。这些视图结构通常转换为模型结构,然后为了编辑或数据检索的目的再次转换为视图结构,此时它们再次成为语义视图。

语义视图中传递的附加信息以及功能开发人员希望对这些树执行的特殊操作类型(与对非语义视图的简单树操作相比)意味着两种结构都需要由不同的工具修改

我们将在本指南的后面部分解释转换。目前,您只需要知道存在用于渲染和数据检索的语义视图以及用于数据输入的非语义视图。

# 更改视图

不要手动更改视图,除非您确实知道自己在做什么。如果需要更改视图,在大多数情况下,这意味着应首先更改模型。然后,您对模型所做的更改将被转换为(转换 在下面介绍)通过特定的转换器转换为视图。

如果更改视图的原因在模型中没有表示,则可能需要手动更改视图。例如,模型不存储有关焦点的信息,而焦点是视图的属性。当焦点发生变化时,如果您想在某个元素的类中表示出来,则需要手动更改该类。

为此,与模型一样,您应该使用 change() 块(视图),您将在其中访问视图下行转换编写器。

editor.editing.view.change( writer => {
    writer.insert( position, writer.createText( 'foo' ) );
} );

有两个视图编写器

  • DowncastWriter - 可用于 change() 块中,用于将模型下行转换到视图。它作用于“语义视图”,因此视图结构区分不同类型的元素(请参阅元素类型和自定义数据)。
  • UpcastWriter - 在预处理“输入”数据(例如,粘贴的内容)时使用的编写器,这通常发生在转换为模型(上行转换)之前。它作用于“非语义视图”

# 位置

模型中的位置 一样,视图中有 3 级类来描述视图结构中的点:位置范围选择。位置是文档中的一个点。范围包含两个位置(开始和结束)。选择包含一个或多个范围,并且具有方向(是从左到右还是从右到左)。

视图范围与其 DOM 对应部分 类似,因为视图位置由父节点和该父节点中的偏移量表示。这意味着,与模型偏移量不同,视图偏移量描述

  • 如果位置的父节点是元素,则在父节点的子节点之间点;
  • 或者,如果位置的父节点是文本节点,则在文本节点的字符之间点。

因此,可以说视图偏移量更像模型索引而不是模型偏移量。

父节点 偏移量 位置
<p> 0 <p>^Foo<img></img>bar</p>
<p> 1 <p>Foo^<img></img>bar</p>
<p> 2 <p>Foo<img></img>^bar</p>
<img> 0 <p>Foo<img>^</img>bar</p>
Foo 1 <p>F^oo<img></img>bar</p>
Foo 3 <p>Foo^<img></img>bar</p>

如您所见,其中两个位置代表您可能认为文档中相同点

  • { parent: paragraphElement, offset: 1 }
  • { parent: fooTextNode, offset: 3 }

一些浏览器(Safari、Chrome 和 Opera)也认为它们相同(在选择中使用时),并且通常将第一个位置(锚定在元素中)规范化为锚定在文本节点中的位置(第二个位置)。不要惊讶于视图选择不在您想要的位置。好消息是,CKEditor 5 渲染器可以判断两个位置是否相同,并避免不必要地重新渲染 DOM 选择。

有时您可能会在文档中发现位置用 {}[] 字符标记。它们之间的区别在于前者表示锚定在文本节点中的位置,而后者表示锚定在元素中的位置。例如,以下示例

{Foo]Bar

描述了一个范围,它从文本节点 Foo 偏移量 0 开始,在 <p> 元素偏移量 1 结束。

DOM 位置这种很不方便的表示方式是另一个让我们思考并使用模型位置的原因。

# 观察者

为了在原生 DOM 事件之上创建更安全、更有用的抽象,视图实现了 观察者 的概念。它提高了编辑器的可测试性,并通过将原生事件转换为更有用的形式,简化了编辑器功能添加的监听器。

观察者监听一个或多个 DOM 事件,对该事件进行初步处理,然后在 视图文档 上触发一个自定义事件。观察者不仅在事件本身,而且在其数据上创建了一个抽象。理想情况下,事件的消费者不应该访问原生 DOM。

默认情况下,视图添加以下观察者

此外,一些功能添加了自己的观察者。例如,剪贴板功能 添加了 ClipboardObserver

有关观察者触发的完整事件列表,请查看 Document 的事件列表。

您可以使用 view.addObserver() 方法添加自己的观察者(应该是 Observer 的子类)。查看现有观察者的代码以了解如何编写它们:https://github.com/ckeditor/ckeditor5-engine/tree/master/src/view/observer

由于所有事件默认都在 Document 上触发,因此建议第三方包使用项目的标识符为其事件添加前缀,以避免命名冲突。例如,MyApp 的功能应该触发 myApp:keydown 而不是 keydown

# 转换

我们已经讨论了模型和视图作为两个完全独立的子系统。现在是将它们连接起来的时候了。这两个层级相遇的三个主要情况是

转换名称 描述
数据上行转换 将数据加载到编辑器中。
首先,数据(例如 HTML 字符串)由 DataProcessor 处理为视图 DocumentFragment。然后,将此视图文档片段转换为模型 文档片段。最后,模型文档的 用此内容填充。
数据下行转换 从编辑器中检索数据。
首先,模型根的内容被转换为视图文档片段。然后,此视图文档片段由数据处理器处理为目标数据格式。
编辑下行转换 将编辑器内容呈现给用户进行编辑。
此过程在编辑器初始化的整个过程中进行。首先,模型根在数据上行转换完成后转换为视图根。之后,此视图根在编辑器的 contentEditable DOM 元素(也称为“可编辑元素”)中呈现给用户。然后,每次模型发生更改时,这些更改都会转换为视图中的更改。最后,如果需要,可以将视图重新渲染到 DOM(如果 DOM 与视图在此时不同)。

让我们看一下引擎的 MVC 架构图,看看每个转换过程在其中发生的位置

Diagram of the engine’s MVC architecture.

# 数据管道

数据上行转换 是一个从图的右下角(视图层)开始,从数据视图经过控制器层中的转换器(绿色方框)到右上角的模型文档的过程。如您所见,它从底部到顶部,因此称为“上行转换”。此外,它由数据管道(图的右分支)处理,因此称为“数据上行转换”。注意:数据上行转换也用于处理粘贴的内容(类似于加载数据)。

数据下行转换数据上行转换的相反过程。它从右上角开始,向下到右下角。同样,转换过程的名称与方向和管道相匹配。

# 编辑管道

编辑下行转换 是一个与其他两个略有不同的过程。

  • 它发生在“编辑管道”(图的左分支)中。
  • 它没有对应的过程。没有编辑上行转换,因为所有用户操作都由编辑器功能处理,它们通过监听 视图事件、分析发生的事情并对模型应用必要的更改来处理。因此,此过程不涉及转换。
  • DataController(处理数据管道)不同,EditingController 为其整个生命周期维护 Document 视图文档的单个实例。模型中的每一个更改都会转换为该视图中的更改,因此该视图中的更改随后可以渲染到 DOM(如果需要,即如果 DOM 确实与该阶段的视图不同)。

# 更多信息

您可以在 专用转换指南 中找到带有示例的更深入介绍。

有关更多信息,您还可以查看 实现块小部件实现内联小部件 教程。

在您了解如何实现编辑功能后,是时候为它们添加一个 UI 了。您可以在 UI 库 指南中阅读有关 CKEditor 5 标准 UI 框架和 UI 库的信息。