ckeditor5-engine(1)

276 阅读23分钟

以下基于ckeditor@22.0.0。

〇、CKEditor编辑器介绍

一、架构角度

二、整体分析代码

三、ckeditor5-core

四、ckeditor5-engine

五、ckeditor5-ui

六、ckeditor-utils

四、ckeditor5-engine

@ckeditor/ckeditor5-engine是 CKEditor 5 的文档编辑引擎,它管理编辑器的核心数据模型和视图层,是整个 CKEditor 5 框架最重要的部分之一。在版本22.0.0中,它负责处理文档模型、视图渲染、数据转换以及操作管理等。接下来将详细分析22.0.0版本的@ckeditor/ckeditor5-engine的核心功能和架构。

1.数据模型 (Model)

CKEditor 5 使用一个独特的模型来存储编辑器的文档数据。模型与 DOM 不直接对应,而是独立于视图的抽象层,这使得它能够更有效地管理文档的结构、内容和语义。模型层的一些核心概念包括:

Model文档树 (Document Tree):模型层使用树状结构来表示文档的语义结构,类似于 DOM 树。每个节点(Element或Text)代表文档的一部分。

不可见性 (Immutability):模型中的所有操作都是不可变的,这意味着模型节点一旦被创建,它们的内容不会被直接修改,而是通过应用操作进行更改。这种设计提高了编辑器的稳定性和数据一致性。

块级元素与内联元素:模型中定义了块级元素(如段落、标题等)和内联元素(如粗体、链接等),这些元素通过Element类进行实例化,确保文档结构和格式保持正确。

1.1 变更管理与操作

Operation类:模型变更通过操作 (Operation) 来实现。操作封装了对模型的所有修改,如插入文本、删除元素或修改属性。通过这一机制,所有操作都可以被序列化、撤销和重做。

批处理 (Batching):多个操作可以通过批处理机制(Batch)组合起来,这些批处理可以是常规批处理或撤销批处理,确保文档编辑过程中可以回溯历史操作。

2.视图层 (View)

CKEditor 5 的视图层负责将模型数据渲染为用户可见的内容,同时处理用户的输入和交互。视图层是一个虚拟 DOM 层,类似于 React 的虚拟 DOM,它优化了对真实 DOM 的操作。

虚拟 DOM:通过虚拟 DOM,视图层可以高效地计算出最小化的 DOM 更新操作。每当模型层发生变化时,视图层会计算出最小的差异,并将其应用到实际 DOM 上。

自定义渲染器:渲染器模块负责将虚拟 DOM 渲染到真实的 DOM 中。它高效地处理 DOM 更新,确保用户体验流畅且反应迅速。

视图文档 (View Document):与模型文档类似,视图文档也使用树状结构来表示当前显示在编辑器中的内容。

3.模型与视图之间的数据转换

核心引擎的重要功能之一是处理模型和视图之间的数据转换。因为模型表示文档的语义结构,而视图表示文档的呈现形式,它们之间需要一种机制来相互同步。

转换器 (Converters):转换器是引擎中用于将模型中的节点和属性转换为视图中对应的 DOM 节点和属性的关键部分。每次模型发生变更时,转换器会确保这些变更被正确映射到视图层。

双向同步:引擎不仅需要将模型变化同步到视图,还需要将用户的输入(如键盘事件、鼠标操作)从视图同步到模型。通过这种双向同步机制,确保编辑器中的数据始终是一致的。

4.操作与变更集

Operation类与Changes机制:所有对模型的修改都通过操作类进行。操作类包括插入、删除、合并、分离等基本文档操作。每当操作应用于模型时,都会触发变更集 (Change Set) 的生成,用于追踪模型的修改。

撤销与重做:所有操作都会被序列化为操作链,这使得撤销与重做操作非常高效。用户可以撤销最近的操作或通过操作链重做先前撤销的操作。

5.事件系统与观察器

观察器 (Observers):@ckeditor/ckeditor5-engine使用观察器来监听视图中的用户交互事件,如输入、键盘事件、鼠标事件等。观察器将这些事件转化为引擎内部的事件,使得引擎能够做出相应的响应。

事件管理 (Event System):与 CKEditor 5 其他模块类似,@ckeditor/ckeditor5-engine也提供了丰富的事件管理机制。所有的文档操作、视图更新都可以触发事件,插件可以通过监听这些事件来自定义行为。

6.选择 (Selection) 与光标管理

Selection类用于管理文档中的选区。选区可以在模型和视图中进行表示,它们用来定义当前用户选中的文本或元素。@ckeditor/ckeditor5-engine通过管理选区来处理用户的复制、粘贴、格式化等操作。

选区管理:引擎确保视图中的选区与模型中的选区保持同步,用户的选区操作会即时反映在模型层中,确保文档的编辑行为一致。

光标管理:光标位置通过选区来定义,用户每次移动光标时,选区会发生相应的变化。引擎为光标的处理提供了精准的控制,确保跨平台的一致性。

7.协作与实时编辑支持

CKEditor 5 的引擎设计为支持多人协作和实时编辑功能。通过操作与变更集,多个用户的操作可以通过引擎同步到同一个文档中。

操作序列化与传输:用户的操作可以被序列化为 JSON 格式并通过网络传输给其他用户。引擎会将接收到的操作应用于本地模型,确保多个用户的文档保持一致。

冲突解决:在实时协作场景下,不同用户可能会同时修改同一部分文档。引擎通过特定的算法来解决这种冲突,确保最终的文档状态是合理且一致的。

8.模块化与扩展性

@ckeditor/ckeditor5-engine是高度模块化的,它提供了大量的 API,供开发者根据需求进行扩展或修改。开发者可以通过继承核心类、编写自定义转换器、操作视图和模型等方式来定制编辑器的行为。

插件系统集成:虽然@ckeditor/ckeditor5-engine主要处理引擎层的逻辑,但它与 CKEditor 5 的插件系统无缝集成。插件可以基于引擎提供的功能来扩展编辑器的行为,比如添加新的文档操作、调整渲染逻辑等。

9.性能优化

最小化 DOM 操作:通过虚拟 DOM 机制,@ckeditor/ckeditor5-engine能够有效减少不必要的 DOM 操作,这在处理大量文本或复杂文档时,显著提高了性能。

增量更新:引擎在处理模型与视图的同步时采用了增量更新策略,只会更新需要变更的部分,而不会每次都重新渲染整个文档。

@ckeditor/ckeditor5-engine作为 CKEditor 5 的核心编辑引擎,提供了一个高度抽象和模块化的框架,能够有效处理文档模型与视图的同步、用户输入的管理、实时协作支持以及复杂文档结构的处理。在22.0.0版本中,可能引入了对引擎的性能优化、Bug 修复以及一些新功能,进一步提升了其稳定性和可扩展性。

1.官方说明(2024)

translate from editing-engine

1.1概览

编辑引擎实现了模型-视图-控制器(MVC)架构。它的结构并不是由引擎本身强制要求的,但在大多数实现中,可以通过以下图示来描述:

image.png 你所看到的是三个层次:模型、控制器和视图。存在一个模型文档,该文档被转换为两个独立的视图——编辑视图和数据视图。这两个视图分别代表用户正在编辑的内容(浏览器中看到的 DOM 结构)和编辑器的输入输出数据(以插件的数据处理器理解的格式)。这两个视图都具有虚拟 DOM 结构(自定义的类似 DOM 的结构),转换器和功能在这些结构上工作,然后将其渲染到 DOM 中。

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

1.2Model

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

与 DOM 一样,模型结构包含在一个文档中,该文档包含根元素(模型和视图可能都有多个根元素)。该文档还包含其选区以及变化历史。

最后,文档(document)、其模式(schema)和文档标记(document markers)是模型的属性。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.
Changing the model

文档结构、文档选区(selection)的所有更改,甚至元素的创建,都只能通过使用模型写入器(writer)来完成。模型写入器的实例可以在 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.
    }
);

对文档结构所做的所有更改都是通过应用操作(operations)来实现的。操作的概念源自操作转换( Operational Transformation,简称 OT),这是一种支持协作功能的技术。由于 OT 需要一个系统能够通过每个操作来转换其他操作(以确定同时应用的操作的结果),因此操作集需要保持简小。CKEditor 5 采用了非线性模型(通常,OT 实现使用平坦的、类似数组的模型,而 CKEditor 5 使用的是树形结构),因此潜在的语义变化集更加复杂。操作被分组到批次中,一个批次可以理解为一个撤销步骤。

Text attributes

像“加粗”和“斜体”这样的文本样式在模型中不是作为元素保存,而是作为文本属性(可以类比为元素属性)。以下是对应的 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"。

Selection attributes

但在上述情况下,如何让 CKEditor 5 知道我希望选区“变为加粗”呢?这是重要的信息,因为它会影响输入的文本是否也会变成加粗。

为了解决这个问题,选区也有属性。如果选区位于 "Foo ^bar" 并且具有 bold=true 属性,那么你就知道用户将输入加粗文本。

Indexes and offsets

然而,正如刚才所提到的,在 中有两个文本节点:“Foo ” 和 “bar”。如果您了解原生 DOM 的 Range 工作原理,您可能会问:“但是,如果选择位于两个文本节点的边界上,它是锚定在左边的文本节点、右边的文本节点,还是在包含元素内?”

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

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

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

“Foo ”文本节点在其父元素中的索引(index)为 0, 位于索引 1,而“bar”位于索引 2。

另一方面, 中的偏移量(offset)x 转换为:

OffsetPositionNode
0<paragraph>^Foo <imageInline></imageInline>bar</paragraph>"Foo "
1<paragraph>F^oo <imageInline></imageInline>bar</paragraph>"Foo "
4<paragraph>Foo ^<imageInline></imageInline>bar</paragraph>
6<paragraph>Foo <imageInline></imageInline>b^ar</paragraph>"bar"
Positions, ranges and selections

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

  1. Position 实例 包含一个偏移量数组(称为“路径”)。可以参考 Position#path API 文档中的示例,更好地理解路径是如何工作的。
  2. Range 包含两个位置:起始位置和结束位置。
  3. 最后是 Selection,它包含一个或多个范围、属性,并具有方向(表示选择是从左到右还是从右到左)。你可以根据需要创建任意数量的 Selection 实例,并且可以在任何时候自由修改它。此外,还有一个 DocumentSelection。它表示文档的选区,并且只能通过模型写入器进行修改。当文档结构发生变化时,它会自动更新。
Markers

标记(Markers)是特殊类型的范围(Ranges)。

  • 它们由 MarkerCollection 管理。
  • 它们只能通过模型写入器(model writer)进行创建和修改。
  • 它们可以通过网络与其他协作客户端同步。
  • 当文档结构发生变化时,它们会自动更新。
  • 它们可以转换为编辑视图,以便在编辑器中显示(作为高亮或元素)。
  • 它们可以转换为数据视图,以便与文档数据一起存储。
  • 它们可以与文档数据一起加载。

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

Schema

模型的模式(schema)定义了模型外观的几个方面:

  • 节点的允许与禁止位置:例如,paragraph 在 $root 中是允许的,但在 heading1 中是不允许的。
  • 某个节点允许的属性:例如,image 可以拥有 src 和 alt 属性。
  • 模型节点的附加语义:例如,image 是“object”类型,而 paragraph 是“block”类型。

模式(schema)也能定义不允许的子节点和属性,这在节点从其他节点继承属性,但希望排除某些内容时非常有用:

  • 节点可以在某些地方被禁止。例如,一个自定义元素 specialParagraph 继承了 paragraph 的所有属性,但需要禁止 imageInline。
  • 可以禁止某个节点上的属性。例如,一个自定义元素 specialPurposeHeading 继承了 heading2 的属性,但不允许 alignment 属性。

这些信息随后被功能和引擎用来决定如何处理模型。例如,来自模式的信息将影响:

  • 粘贴内容时发生的操作以及哪些内容会被过滤掉(注意:在粘贴的情况下,另一个重要的机制是转换(conversion)。HTML 元素和属性,如果没有被任何已注册的转换器向上转换,会在它们成为模型节点之前被过滤掉,因此模式不会应用于它们;转换将在本指南稍后介绍)。
  • 标题功能可以应用于哪些元素(哪些块可以转换为标题,哪些元素首先是块元素)。
  • 哪些元素可以被包裹在块引用(block quote)中。
  • 当选区位于标题中时,粗体按钮是否启用(以及该标题中的文本是否可以变为粗体)。
  • 选区可以放置的位置(即——只能放置在文本节点和对象元素上)。
  • 等等。

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

目前,没有直接的方法来覆盖由功能(features)预配置的模式。如果你希望在初始化编辑器时覆盖默认设置,最佳解决方案是用一个新的实例替换 editor.model.schema。然而,这样做需要重新构建编辑器。

模式的实例可以通过 editor.model.schema 访问。关于如何使用模式 API 的详细指南,请阅读Schema deep dive

1.3View

再来看下这张架构图:

image.png

我们已经讨论了这个图表的最上层——模型(model)。模型层的作用是对数据进行抽象。它的格式被设计成以最方便的方式存储和修改数据,同时支持实现复杂功能。大多数功能都在模型上操作(从中读取并修改数据)。

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

这意味着:

  • 视图是另一个自定义结构。
  • 它类似于 DOM。虽然模型的树形结构与 DOM 仅略微相似(例如,通过引入文本属性),但视图则更接近于 DOM。换句话说,它是一个虚拟 DOM。
  • 有两个“管道”:编辑管道(也称为“编辑视图”)和数据管道(“数据视图”)。可以将它们视为同一个模型的两个独立视图。编辑管道渲染和处理用户看到并能编辑的 DOM。数据管道在调用 editor.getData()、editor.setData() 或将内容粘贴到编辑器时使用。
  • 视图通过渲染器(Renderer)渲染到 DOM 中,渲染器处理编辑管道中使用的 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).
Element types and custom data

视图的结构与 DOM 中的结构非常相似。HTML 的语义在其规范中定义。视图结构是“DTD-free”的,因此,为了提供额外的信息并更好地表达内容的语义,视图结构实现了六种元素类型(ContainerElement、AttributeElement、EmptyElement、RawElement、UIElement 和 EditableElement),以及所谓的“自定义属性custom properties”(即未渲染的自定义元素属性)。这些由编辑器功能提供的额外信息随后被渲染器(Renderer)和转换器(converters)使用。

这些元素类型可以定义如下:

  • Container element(容器元素):构建内容结构的元素。用于块级元素,例如 <p>、<h1>、<blockQuote>、<li> 等。

  • Attribute element(属性元素):不能包含容器元素的元素。大多数模型文本属性会转换为视图中的属性元素。它们主要用于内联样式元素,如 <strong>、<i>、<a>、<code>等。类似的属性元素会被视图写入器扁平化。例如,<a href="..."><a class="bar">x</a></a> 会自动优化为 <a href="..." class="bar">x</a>

  • Empty element(空元素):不能包含任何子节点的元素,例如 <img>

  • UI element(UI 元素):这些元素不是“数据”的一部分,但需要“内嵌”在内容中。它们会被selection忽略(selection会跳过它们),并且一般情况下会被视图写入器忽略。这些元素的内容和来自它们的事件也会被过滤掉。

  • Raw element(原始元素):作为数据容器(“包装器”,“沙盒”)工作的元素,但它们的子元素对编辑器是透明的。适用于当必须渲染非标准数据时,但编辑器不需要关心它是什么以及如何工作。用户不能将选区放入原始元素中,不能将其拆分为更小的部分,也不能直接修改其内容。

  • Editable element(可编辑元素):作为非可编辑内容片段的“嵌套可编辑元素”使用的元素。例如,图像小部件(image widget)中的标题, 元素包装图像但不可编辑(它是一个小部件),而其中的 是一个可编辑元素。

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

  • 元素是否是小部件(通过 toWidget() 添加)。
  • 当标记高亮显示元素时,应该如何标记该元素。
  • 元素是否属于某个特定功能——例如,它是否是链接(link)、进度条(progress bar)、标题(caption)等。
Non-semantic views(非语义视图)

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

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

我们将在本指南后面解释转换过程。目前,您只需要知道,渲染和数据检索使用的是语义视图,而数据输入则使用非语义视图。

Changing the view

除非你非常清楚自己在做什么,否则不要手动更改视图。如果视图需要更改,在大多数情况下,这意味着应该首先更改模型。然后,您对模型所做的更改将通过特定的转换器(转换将在下面讲解)转换到视图中。

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

为此,就像在模型中一样,你应该使用 change() 块(视图中的)来操作,在该块中你将能够访问视图的下级写入器(downcast writer)。

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

有两种视图写入器:

  • DowncastWriter — 在 change() 块中可用,用于将模型转换为视图时进行下级转换。它作用于“语义视图”,即区分不同类型元素的视图结构(参见元素类型和自定义数据)。
  • UpcastWriter — 用于预处理“输入”数据(例如粘贴的内容),通常发生在转换(向上转换)到模型之前。它作用于“非语义视图”。
Positions

就像在模型中一样,视图中也有 3 个级别的类,用于描述视图结构中的点:位置、范围和选区。位置是文档中的单个点。范围由两个位置组成(起始位置和结束位置)。选区由一个或多个范围组成,并具有方向性(即从左到右还是从右到左)。

视图中的范围与其 DOM 对应项类似,因为视图位置由父元素和在该父元素中的偏移量表示。这意味着,与模型中的偏移量不同,视图中的偏移量描述的是:

  • 如果位置(position)的父元素是一个元素,则描述该父元素子节点之间的点(points);
  • 如果位置(position)的父元素是文本节点,则描述文本节点中字符之间的点(points)。

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

ParentOffsetPosition
<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>
Foo1<p>F^oo<img></img>bar</p>
Foo3<p>Foo^<img></img>bar</p>

正如你所看到的,这两个位置(positions )表示文档中相同的点:

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

一些浏览器(如 Safari、Chrome 和 Opera)也认为它们是相同的(当用于选区selection时),并且通常将第一个位置(锚定在元素中的位置)标准化为锚定在文本节点中的位置(第二个位置)。不要惊讶于视图中的选区位置不是你预期的那样。好消息是,CKEditor 5 的渲染器可以识别这两个位置是相同的,并避免不必要地重新渲染 DOM 选区。

有时你会在文档中看到HTML中的位置(position)使用 {} 和 [] 字符标记。它们之间的区别在于,前者表示锚定在文本节点中的位置,后者表示锚定在元素中的位置。例如,以下示例:

  • {Foo]Bar

描述了一个范围,从文本节点 Foo 的偏移量 0 开始,结束于 元素的偏移量 1。

DOM 位置(position)这种不太方便的表示方式,是另一个需要考虑并使用模型位置的原因。

Observers

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

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

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

  • MutationObserver
  • SelectionObserver
  • FocusObserver
  • KeyObserver
  • FakeSelectionObserver
  • CompositionObserver
  • ArrowKeysObserver

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

有关观察者触发的所有事件的完整列表,请查阅文档中的事件列表。

您可以通过使用 view.addObserver() 方法添加自己的观察者(它应该是 Observer 的子类)。请查看现有观察者的代码,了解如何编写观察者:现有观察者代码

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

1.4Conversion

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

Conversion nameDescription
Data upcasting将数据加载到编辑器中。首先,数据(例如 HTML 字符串)通过 DataProcessor 处理为一个视图 DocumentFragment。然后,这个视图文档片段被转换为模型文档片段(document fragment)。最后,模型文档的根节点被填充上这个内容。
Data downcasting从编辑器中取回(Retrieve)数据。首先,模型根节点的内容被转换为视图文档片段。然后,这个视图文档片段通过数据处理器(data processor)处理为目标数据格式。
Editing downcasting将编辑器内容渲染给用户进行编辑。这个过程在编辑器初始化期间一直进行。首先,当数据向上转换(data upcasting)完成后,模型的根节点会被转换为视图的根节点。之后,这个视图根节点会被渲染到用户在编辑器的 contentEditable DOM 元素中(也称为“可编辑元素”)。然后,每当模型发生变化时,这些变化会被转换为视图中的变化。最后,如果需要(如果 DOM 与视图不同),视图可以重新渲染到 DOM 中。

让我们来看一下引擎的 MVC 架构图,并查看每个转换过程发生的位置:

image.png

Data pipeline

数据向上转换(Data upcasting) 是一个从图表的右下角(视图层)开始的过程,经过数据视图,经过控制层中的转换器(绿色框),最终到达模型文档的右上角。如你所见,它是从下到上进行的,因此叫做“向上转换(upcasting)”。它通过数据管道(图表的右侧分支)进行处理,因此称为“数据向上转换”。注意:数据向上转换也用于处理粘贴内容(类似于加载数据的过程)。

数据向下转换(Data downcasting) 是数据向上转换的相反过程。它从右上角开始,向下到达右下角。再次强调,转换过程的名称与方向和管道一致。

Editing pipeline

编辑向下转换(Editing downcasting) 是一个与其他两个过程不同的过程。

  • 它发生在“编辑管道”中(图表的左侧分支)。
  • 它没有对应的过程。没有编辑向上转换,因为所有用户操作都通过编辑器功能来处理,功能通过监听视图事件、分析发生了什么并对模型应用必要的更改来处理用户操作。因此,这个过程不涉及转换。
  • 与处理数据管道的 DataController 不同,EditingController 在其整个生命周期中维护一个视图文档的单一实例。模型中的每一次变化都会转换为该视图中的变化,以便这些变化可以被渲染到 DOM 中(如果需要——即,如果此时 DOM 与视图不同的话)。

1.5 Conversion深入分析

下行转换

所有的更改,如输入或从剪贴板粘贴,都会直接应用到模型(model)中。为了更新编辑视图(即用户看到的层),引擎会将这些模型中的更改转换为视图中的内容。当需要生成数据时(例如,复制编辑器内容或使用 editor.getData() 时),也会执行相同的过程。这些过程被称为 编辑转换(editing conversions)和 下行转换(downcast conversions)。

image.png

注册转换器

为了告诉引擎如何将特定的模型元素转换为视图元素,你需要通过 editor.conversion.for( 'downcast' ) 方法注册一个下行转换器,并列出在转换过程中需要转换的元素:

editor.conversion
    .for( 'downcast' )
    .elementToElement( {
        model: 'paragraph',
        view: 'p'
    } );

上述转换器将处理每个 <paragraph> 模型元素转换为 <p> 视图元素。你可以在下面的代码片段中查看输入和输出:

转存失败,建议直接上传图片文件 

image.png

下行转换管道

image.png

之前展示的简单代码示例为这两个管道同时注册了一个转换器。这意味着, 模型元素将在数据视图和编辑视图中都转换为

视图元素。

有时你可能希望为特定的管道修改转换器逻辑。例如,在编辑视图中,你可能希望给视图元素添加一些额外的类。这样的操作需要为两个视图分别设置转换器,而不是像之前的示例那样设置一个通用的下行转换。

// 为数据管道设置 dataDowncast

editor.conversion

    .for( 'dataDowncast' )

    .elementToElement( {

        model: 'paragraph',

        view: 'p'

    } );

 

// 为编辑管道设置 editingDowncast

editor.conversion

    .for( 'editingDowncast' )

    .elementToElement( {

        model: 'paragraph',

        view: {

            name: 'p',

            classes: 'paragraph-in-editing-view'

        }

    } );

在这个例子中,数据管道将 <paragraph> 模型元素转换为 <p> 视图元素,而编辑管道则会在 <p> 元素上添加 paragraph-in-editing-view 类。

转换文本属性

正如你在关于模型的章节中已经了解到的那样,属性可以应用于模型的文本节点。

这些文本节点的属性可以转换为视图元素。为此,你可以使用 attributeToElement() 转换助手注册一个转换器:

editor.conversion

    .for( 'downcast' )

    .attributeToElement( {

        model: 'bold',

        view: 'strong'

    } );

上述转换器将处理每个 bold 模型文本节点属性的转换,将其转换为 <strong> 视图元素,如下面的代码片段所示。

image.png

将元素转换为元素

类似于之前的示例,你可以使用 elementToElement() 转换助手将 模型元素转换为 <h1> 视图元素。实现这一点的代码如下:

editor.conversion

    .for( 'downcast' )

    .elementToElement( {

        model: 'heading',

        view: 'h1'

    } );

这等同于:

editor.conversion

    .for( 'downcast' )

    .elementToElement( {

        model: 'heading',

        view: ( modelElement, { writer } ) => {

            return writer.createContainerElement( 'h1' );

        }

    } );

你之前已经学过,视图属性(view)可以是一个简单的字符串,也可以是一个对象。上述示例展示了也可以定义一个自定义回调函数来返回创建的元素。对于这种类型的转换,效果可以在下面的代码片段中观察到:

image.png

如果你能够在视图中设置heading level,那么 元素就更加有意义。

在前一章中,你已经学习了如何将属性应用于文本节点。同样,也可以像下面的例子一样,将属性添加到元素中:

editor.conversion

    .for( 'downcast' )

    .elementToElement( {

        model: {

            name: 'heading',

            attributes: [ 'level' ]

        },

        view: ( modelElement, { writer } ) => {

            return writer.createContainerElement(

                'h' + modelElement.getAttribute( 'level' )

            );

        }

    } );

从现在开始,每次 level 属性更新时,整个 <heading> 元素将被转换为 <h[level]> 元素(例如 <h1>、<h2> 等)。

将元素转换为结构

有时你可能希望将单个模型元素转换为一个更复杂的视图结构,该结构包含一个视图元素及其子元素。

你可以使用 elementToStructure() 转换助手来实现这一点:

editor.conversion

    .for( 'downcast' ).elementToStructure( {

        model: 'myElement',

        view: ( modelElement, { writer } ) => {

            return writer.createContainerElement( 'div', { class: 'wrapper' }, [

                writer.createContainerElement( 'div', { class: 'inner-wrapper' }, [

                    writer.createSlot()

                ] )

            ] );

        }

    } );

上述转换器将把所有 模型元素转换为如下结构:<div class="wrapper"><div class="inner-wrapper"><p>...</p></div></div>,如下面所示:

image.png

注意:

使用自定义的模型元素需要首先在 schema 中定义它。

对于编辑器用户,处理复杂结构的最佳方式是将它们作为独立实体进行操作,保持其完整性,例如在复制、粘贴和编辑时。CKEditor 5 通过 widget API 允许这种方式。可以参考Widget举例小结。

上行转换

当你将数据加载到编辑器中时,视图是从标记(markup)中创建的。然后,通过上行转换器(upcast converters)的帮助,模型被创建出来。一旦完成,模型就成为编辑器的状态。整个过程被称为上行转换(upcast conversion)。

image.png 注册转换器

为了告诉引擎如何将特定的视图元素转换为模型元素,你需要通过 editor.conversion.for( 'upcast' ) 方法注册一个上行转换器:

editor.conversion

    .for( 'upcast' )

    .elementToElement( {

        view: 'p',

        model: 'paragraph'

    } );

上述转换器将处理每个 <p> 视图元素的转换,将其转换为 <paragraph> 模型元素。

image.png

上行转换管道

与下行转换需要同时处理数据管道和编辑管道不同,上行转换过程只发生在数据管道中,这被称为 数据上行转换(data upcast)。

编辑视图只能通过先更改模型来进行修改,因此编辑管道仅需要下行转换过程。

image.png

之前的代码示例为两个管道同时注册了一个转换器。这意味着,<paragraph> 模型元素将在数据视图和编辑视图中都转换为

视图元素。

转换为文本属性

表示内联文本格式的视图元素(如 <strong><i>)需要转换为模型文本节点上的属性。

要注册这样的转换器,可以使用 elementToAttribute() 方法:

editor.conversion

    .for( 'upcast' )

    .elementToAttribute( {

        view: 'strong',

        model: 'bold'

    } );

<strong> 标签包裹的文本将被转换为一个模型文本节点,并应用 bold 属性,如下所示:

image.png

如果你需要将视图元素的属性“复制”到模型元素,可以使用 attributeToAttribute() 方法。

模型元素必须注册自己的转换器,否则属性无法复制到任何地方。

editor.conversion

    .for( 'upcast' )

    .attributeToAttribute( {

        view: 'src',

        model: 'source'

    } );

假设编辑器中已经有其他功能注册了 模型元素的上行转换器,你可以扩展该功能,以允许 src 属性。这个属性将被转换为模型元素上的 source 属性,如下所示:

image.png

转换为元素

将视图元素转换为相应的模型元素,可以通过使用 elementToElement() 方法注册转换器来实现:

editor.conversion

    .for( 'upcast' )

    .elementToElement( {

        view: {

            name: 'div',

            classes: [ 'example' ]

        },

        model: 'example'

    } );

这个转换器将处理每个 <div class="example"> 视图元素,将其转换为 <example> 模型元素。

image.png

注意:使用自定义的模型元素需要首先在 schema 中定义它。

转换结构

正如你在上一章中所学到的,一个模型元素可以被下行转换为由多个视图元素组成的结构。

相反的过程将需要检测该结构(例如,主要元素),并将其转换为一个简单的模型元素。

对于上行转换,并没有提供 structureToElement() 辅助方法。要为整个结构注册上行转换器,并创建一个单一的模型元素,你必须使用基于事件的 API。以下示例展示了如何实现这一点:

editor.conversion.for( 'upcast' ).add( dispatcher => {

    // 查找每个视图 <div> 元素。
    dispatcher.on( 'element:div', ( evt, data, conversionApi ) => {

        // 从转换 API 对象中获取所有必要的项目。
        const {
            consumable,
            writer,
            safeInsert,
            convertChildren,
            updateConversionResult
        } = conversionApi;

        // 从数据对象中获取视图项。
        const { viewItem } = data;

        // 定义元素可消耗的项目。
        const wrapper = { name: true, classes: 'wrapper' };

        const innerWrapper = { name: true, classes: 'inner-wrapper' };
 
        // 测试视图元素是否可以被消耗。
        if ( !consumable.test( viewItem, wrapper ) ) {
            return;
        }

        // 检查是否只有一个子元素。
        if ( viewItem.childCount !== 1 ) {
            return;
        }

        // 获取第一个子元素。
        const firstChildItem = viewItem.getChild( 0 );


        // 检查第一个元素是否是 <div>。
        if ( !firstChildItem.is( 'element', 'div' ) ) {
            return;
        }


        // 测试第一个子元素是否可以被消耗。
        if ( !consumable.test( firstChildItem, innerWrapper ) ) {
            return;
        }
 

        // 创建模型元素。
        const modelElement = writer.createElement( 'myElement' );
 

        // 在当前光标位置插入元素。
        if ( !safeInsert( modelElement, data.modelCursor ) ) {
            return;
        }
 

        // 消耗主外部包装元素。
        consumable.consume( viewItem, wrapper );

        // 消耗内部包装元素。
        consumable.consume( firstChildItem, innerWrapper );
 
        // 处理内部包装元素中的子元素转换。
        convertChildren( firstChildItem, modelElement );
 

        // 在某些特定情况下,当元素被拆分时,必要的函数调用帮助设置模型范围和光标。
        updateConversionResult( modelElement, data );
    } );

} );

该转换器将检测所有 <div class="wrapper"><div class="inner-wrapper"><p>...</p></div></div> 结构(通过扫描外部的 <div> 并将其转换为单个 <myElement> 模型元素)。效果应该如下所示:

image.png

 

注意:使用自定义的模型元素需要首先在 schema 中定义它。

Widget举例

如下图所示,你将构建一个“简单框(Simple box)”功能,允许用户将一个带有标题和内容字段的自定义框插入到文档中。你将使用小部件工具并与模型-视图转换一起工作,以正确设置此功能的行为。之后,你将创建一个用户界面,允许通过工具栏按钮将新的简单框插入到文档中。源码可见于final-project of block-widget

image.png

说明:

  •  InsertSimpleBoxCommand命令用于在用户编辑区域插入一个简单框小部件;

  •  SimpleBoxUI类用于往编辑器工具栏中注册一个名为“simpleBox”按钮,当点击该按钮时,会执行“insertSimpleBox”命令;该按钮要在工具栏显示,需要在执行ClassicEditor.create时往toolbar上配置“simpleBox”按钮;

  •  SimpleBoxEditing类用于定义schema、转换器等,并将InsertSimpleBoxCommand命令注册到编辑器,命名为“insertSimpleBox”;

-  SimpleBox是总入口,会在其requires方法中引入SimpleBoxEditing类和SimpleBoxUI类;需在执行ClassicEditor.create时往plugins中配置“SimpleBox”类。

模型和视图层

CKEditor 5 实现了 MVC 架构,并且它的自定义数据模型虽然仍然是树形结构,但并不会与 DOM 进行 1:1 的映射。你可以将模型视为编辑器内容的一个更具语义的表示,而 DOM 则是其可能的表示之一。

由于你的简单框功能旨在实现一个带有标题和描述字段的框,定义它的模型表示如下:

<simpleBox>

    <simpleBoxTitle></simpleBoxTitle>

    <simpleBoxDescription></simpleBoxDescription>

</simpleBox>
定义模式(Schema)

首先,你需要定义模型的模式(schema)。你需要定义三个元素及其类型,以及允许的父子关系。

SimpleBoxEditing 插件:

import { Plugin } from 'ckeditor5';

 

export default class SimpleBoxEditing extends Plugin {

    init() {

        console.log('SimpleBoxEditing#init() got called');

        this._defineSchema();  // 添加了此部分

    }

 

    _defineSchema() {  // 添加了此部分

        const schema = this.editor.model.schema;

 

        schema.register('simpleBox', {

            // 行为类似自包含的块元素(例如块级图片),可以放置在其他块元素允许的地方(例如直接放在根节点)。

            inheritAllFrom: '$blockObject'

        });

 

        schema.register('simpleBoxTitle', {

            // 不能被拆分或光标离开

            isLimit: true,

            allowIn: 'simpleBox',

           // 允许的内容类型为块级元素(即带有属性的文本)

            allowContentOf: '$block'

        });

 

        schema.register('simpleBoxDescription', {

            // Cannot be split or left by the caret.

            isLimit: true,

            allowIn: 'simpleBox',

            // 允许的内容类型为根元素内的内容(例如段落)

            allowContentOf: '$root'

        });

    }

}

定义模式之后,编辑器尚未有任何效果。它只是为插件和编辑器引擎提供了关于如何处理某些操作(如按下 Enter 键、点击元素、输入文本、插入图片等)的信息。

为了让简单框插件开始工作,你需要定义模型与视图之间的转换。接下来就来做这件事!

定义转换器

转换器告诉编辑器如何将视图转换为模型(例如,在加载数据到编辑器或处理粘贴的内容时),以及如何将模型呈现到视图(用于编辑目的,或在获取编辑器数据时)。

此时,你需要思考如何将 元素及其子元素渲染到 DOM 中(用户将看到的内容)和数据中。CKEditor 5 允许为编辑目的将模型转换为不同的结构,并为数据存储或在与其他应用交换内容时使用不同的结构。然而,为了简便起见,暂时在两个管道中使用相同的表示形式。

你希望在视图中实现的结构是:

<section class="simple-box">

    <h1 class="simple-box-title"></h1>

    <div class="simple-box-description"></div>

</section>

使用 conversion.elementToElement() 方法来定义所有的转换器。

由于你为数据和编辑管道定义了相同的转换器,因此可以使用这个高层次的双向转换定义。

稍后,你可以切换到更精细的转换器,以便更好地控制转换过程。

你需要为三个模型元素定义转换器。更新 SimpleBoxEditing 插件,如下所示:

import { Plugin } from 'ckeditor5';

 

export default class SimpleBoxEditing extends Plugin {

    init() {

        console.log('SimpleBoxEditing#init() got called');

        this._defineSchema();

        this._defineConverters();  // 添加了此部分

    }

 

    _defineSchema() {

        // 之前注册的 schema。

        // ...

    }

 

    _defineConverters() {  // 添加了此部分

        const conversion = this.editor.conversion;

 

        conversion.elementToElement({

            model: 'simpleBox',

            view: {

                name: 'section',

                classes: 'simple-box'

            }

        });

 

        conversion.elementToElement({

            model: 'simpleBoxTitle',

            view: {

                name: 'h1',

                classes: 'simple-box-title'

            }

        });

 

        conversion.elementToElement({

            model: 'simpleBoxDescription',

            view: {

                name: 'div',

                classes: 'simple-box-description'

            }

        });

    }

}

一旦你定义了转换器,你可以尝试查看简单框在实际应用中的效果。目前,你还没有定义插入新的简单框的方式,因此可以通过编辑器数据来加载它。为此,你需要修改 index.html 文件:

<!DOCTYPE html>

<html lang="en">

    <head>

        <meta charset="utf-8">

        <title>CKEditor 5 Framework – Implementing a simple widget</title>

 

        <style>

            .simple-box {

                padding: 10px;

                margin: 1em 0;

                background: rgba( 0, 0, 0, 0.1 );

                border: solid 1px hsl(0, 0%, 77%);

                border-radius: 2px;

            }

 

            .simple-box-title, .simple-box-description {

                padding: 10px;

                margin: 0;

                background: #FFF;

                border: solid 1px hsl(0, 0%, 77%);

            }

 

            .simple-box-title {

                margin-bottom: 10px;

            }

        </style>

    </head>

    <body>

        <div id="editor">

            <p>This is a simple box:</p>

 

            <section class="simple-box">

                <h1 class="simple-box-title">Box title</h1>

                <div class="simple-box-description">

                    <p>The description goes here.</p>

                    <ul>

                        <li>It can contain lists,</li>

                        <li>and other block elements like headings.</li>

                    </ul>

                </div>

            </section>

        </div>

 

        <script src="dist/bundle.js"></script>

    </body>

</html>

image.png

模型中包含什么?

你在 index.html 文件中添加的 HTML 就是编辑器的数据。这就是 editor.getData() 返回的内容。同时,目前这也是 CKEditor 5 引擎在可编辑区域渲染的 DOM 结构:

image.png

要了解模型里有什么,可以使用官方的 CKEditor 5 检查器。安装后,你需要在 main.js 文件中加载它:
// main.js

import CKEditorInspector from '@ckeditor/ckeditor5-inspector'; // 新添加的

 

ClassicEditor

    .create( document.querySelector( '#editor' ), {

        plugins: [

            Essentials, Paragraph, Heading, List, Bold, Italic,

            SimpleBox

        ],

        toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList' ]

    } )

    .then( editor => {

        console.log( '编辑器已初始化', editor );

        CKEditorInspector.attach( { 'editor': editor } ); // 新添加的

        window.editor = editor;

    } );

刷新页面后,你将看到检查器:

image.png

可以看到类似HTML的结构:

<paragraph>[]This is a simple box:</paragraph>

<simpleBox>

    <simpleBoxTitle>Box title</simpleBoxTitle>

    <simpleBoxDescription>

        <paragraph>The description goes here.</paragraph>

        <listItem listIndent="0" listType="bulleted">It can contain lists,</listItem>

        <listItem listIndent="0" listType="bulleted">and other block elements like headings.</listItem>

    </simpleBoxDescription>

</simpleBox>

正如你所看到的,这个结构与 HTML 输入/输出非常不同。如果仔细观察,你还会注意到第一个段落中的 [] 字符——这是选择位置。

是否符合预期

是时候检查简单框是否按预期工作了(可在本地或在线demo上操作)。你可以观察到以下行为:

  1. 你可以在标题中输入文本。按下 Enter 键不会将其拆分,按下 Backspace 键也不会完全删除它。这是因为它在模式中被标记为 isLimit 元素。

  2. 你不能在标题中应用列表,也不能将其转为标题(除了 <h1 class="simple-box-title">之外)。这是因为它只允许块级元素中允许的内容(例如段落)。但是,你可以在标题中应用斜体(因为斜体在其他块级元素中是允许的)。

  3. 描述的行为与标题相似,但它允许更多的内容,如列表和其他标题。

  4. 如果你尝试选择整个简单框实例并按下 Delete,它会作为一个整体被删除。复制和粘贴时也会如此。这是因为它在模式中被标记为 isObject 元素。

  5. 你无法通过点击的方式轻松选择整个简单框实例。当你将鼠标悬停时,光标指针也不会改变。换句话说,它看起来有些“死板”。这是因为你尚未定义视图行为。(在线demo是完整示例,第5点无法验证)

通过少量代码,就能够定义简单框插件的行为,确保这些元素的完整性。引擎确保用户不会破坏这些实例。

继续改进!

将简单框转换为小部件(widget)

在 CKEditor 5 中,小部件(widget)系统主要由引擎处理。部分功能包含在 @ckeditor/ckeditor5-widget 包中,部分则需要通过 CKEditor 5 Framework 提供的其他工具处理。

因此,CKEditor 5 的实现是开放的,可以扩展和重组。你可以选择你想要的行为并跳过其他不需要的部分,或者自行实现它们。

你定义的转换器将 模型元素转换为视图中的普通 ContainerElement(并在上游转换时反向转换)。

你希望稍微改变这种行为,使得在编辑视图中创建的结构通过 toWidget() 和 toWidgetEditable() 工具得到增强。但你不希望影响数据视图。因此,你需要分别为编辑和数据下转换定义转换器。

现在是时候重新审视你之前定义的 _defineConverters() 方法了。你将使用 elementToElement() 上转换帮助器和 elementToElement() 下转换帮助器,而不是双向的 elementToElement() 转换器帮助器。

此外,你需要确保已加载 Widget 插件。如果你忽略它,视图中的元素将拥有所有classes(例如 ck-widget),但不会加载“行为”(例如,点击小部件时不会选择它)。

以下是修改后的 SimpleBoxEditing 插件代码:

// 新增 2 个导入

import { Plugin, Widget, toWidget, toWidgetEditable } from 'ckeditor5';

 

export default class SimpleBoxEditing extends Plugin {

    static get requires() { // 新增

        return [ Widget ];

    }

 

    init() {

        console.log( 'SimpleBoxEditing#init() got called' );

 

        this._defineSchema();

        this._defineConverters();

    }

 

    _defineSchema() {

        // 之前注册的模式

        // ...

    }

 

    _defineConverters() {  // 修改

        const conversion = this.editor.conversion;

 

        /***** <simpleBox> 转换器 *****/

        conversion.for( 'upcast' ).elementToElement( {

            model: 'simpleBox',

            view: {

                name: 'section',

                classes: 'simple-box'

            }

        } );

        conversion.for( 'dataDowncast' ).elementToElement( {

            model: 'simpleBox',

            view: {

                name: 'section',

                classes: 'simple-box'

            }

        } );

        conversion.for( 'editingDowncast' ).elementToElement( {

            model: 'simpleBox',

            view: ( modelElement, { writer: viewWriter } ) => {

                const section = viewWriter.createContainerElement( 'section', { class: 'simple-box' } );

 

                return toWidget( section, viewWriter, { label: 'simple box widget' } );

            }

        } );

 

        /***** <simpleBoxTitle> 转换器 *****/

        conversion.for( 'upcast' ).elementToElement( {

            model: 'simpleBoxTitle',

            view: {

                name: 'h1',

                classes: 'simple-box-title'

            }

        } );

        conversion.for( 'dataDowncast' ).elementToElement( {

            model: 'simpleBoxTitle',

            view: {

                name: 'h1',

                classes: 'simple-box-title'

            }

        } );

        conversion.for( 'editingDowncast' ).elementToElement( {

            model: 'simpleBoxTitle',

            view: ( modelElement, { writer: viewWriter } ) => {

                // 注意:此处使用了更专用的 createEditableElement() 方法

                const h1 = viewWriter.createEditableElement( 'h1', { class: 'simple-box-title' } );

 

                return toWidgetEditable( h1, viewWriter );

            }

        } );

 

        /***** <simpleBoxDescription> 转换器 *****/

        conversion.for( 'upcast' ).elementToElement( {

            model: 'simpleBoxDescription',

            view: {

                name: 'div',

                classes: 'simple-box-description'

            }

        } );

        conversion.for( 'dataDowncast' ).elementToElement( {

            model: 'simpleBoxDescription',

            view: {

                name: 'div',

                classes: 'simple-box-description'

            }

        } );

        conversion.for( 'editingDowncast' ).elementToElement( {

            model: 'simpleBoxDescription',

            view: ( modelElement, { writer: viewWriter } ) => {

                // 注意:此处使用了更专用的 createEditableElement() 方法

                const div = viewWriter.createEditableElement( 'div', { class: 'simple-box-description' } );

 

                return toWidgetEditable( div, viewWriter );

            }

        } );

    }

} 

通过这些更改,你已经将简单框元素转化为了一个小部件(widget),增强了它在编辑视图中的交互性。

转变为小部件

将简单框(simple box)转变为小部件(widget)后,你应该可以看到插件行为的变化。

image.png

你应该观察到以下变化:
  • <section>、<h1><div>元素现在都有 contentEditable 属性(以及一些class)。这个属性告诉浏览器一个元素是否是可编辑的。通过 toWidget() 将元素传递给视图时,它的内容会变为不可编辑。相反,传递给 toWidgetEditable() 时,内容将重新变为可编辑。
  • 现在,你可以点击小部件(灰色区域)来选择它。一旦选中,就更容易进行复制和粘贴操作。
  • 小部件及其嵌套的可编辑区域会对悬停、选择和焦点(轮廓)做出反应。
  • 换句话说,简单框实例变得更加响应式。

此外,如果你调用 editor.getData(),你将获得与将简单框转换为小部件之前相同的 HTML。这是因为只在 editingDowncast 流水线中使用了 toWidget() 和 toWidgetEditable()。如下所示,通过getData获取的html字符串中section标签上没有‘ck-widget’,而在最终渲染出的dom上是有该class的:

<p>This is a simple box:https://ckeditor.com/docs/ckeditor5/latest/framework/tutorials/widgets/implementing-a-block-widget.html#demo</p>
<section class="simple-box">
    <h1 class="simple-box-title">
    <i><strong>Box 按时title</strong></i>
    </h1>
    <div class="simple-box-description">
        <p>The description goes here.</p>
        <ul>
            <li>It can contain lists,</li>
            <li>and other block elements like headings.</li>
        </ul>
    </div>
</section>

image.png

到目前为止,模型和视图层的处理已经完成。在可编辑性和数据输入/输出方面,它已经完全功能化。接下来,找到一种方法将新的简单框插入到文档中!

创建命令

命令是动作和状态的组合。你可以通过命令与编辑器的大多数功能进行交互。这不仅允许你执行这些功能(例如,将文本片段设置为粗体),还可以检查该动作是否可以在当前选择的位置执行,并观察其他状态属性(例如,当前选中的文本是否已被设置为粗体)。

对于简单框,情况很简单:

  •  你需要一个“插入新简单框”的动作。

  •  你需要一个“在当前选择位置是否可以插入新简单框”的检查。

你将使用 model.insertObject() 方法,这个方法将能够在你尝试将简单框插入段落中间时自动拆分段落(这在模式中是不允许的)。

import { Command } from 'ckeditor5';

export default class InsertSimpleBoxCommand extends Command {
    execute() {
        this.editor.model.change( writer => {
            // Insert <simpleBox>*</simpleBox> at the current selection position
            // in a way that will result in creating a valid model structure.
            this.editor.model.insertObject( createSimpleBox( writer ) );

        } );
    }


    refresh() {
        const model = this.editor.model;

        const selection = model.document.selection;

        // 检查当前selection所在位置,在schema中是否允许插入simpleBox
        const allowedIn = model.schema.findAllowedParent( selection.getFirstPosition(), 'simpleBox' );
 

        this.isEnabled = allowedIn !== null;
    }
}


function createSimpleBox( writer ) {

    const simpleBox = writer.createElement( 'simpleBox' );
    const simpleBoxTitle = writer.createElement( 'simpleBoxTitle' );
    const simpleBoxDescription = writer.createElement( 'simpleBoxDescription' );
 

    writer.append( simpleBoxTitle, simpleBox );
    writer.append( simpleBoxDescription, simpleBox );
 

    // There must be at least one paragraph for the description to be editable.
    // See https://github.com/ckeditor/ckeditor5/issues/1464.

    writer.appendElement( 'paragraph', simpleBoxDescription );

    return simpleBox;

}

导入命令并在 SimpleBoxEditing 插件中注册它:

import { Plugin, Widget, toWidget, toWidgetEditable } from 'ckeditor5';

import InsertSimpleBoxCommand from '...somewhere'; // ADDED


export default class SimpleBoxEditing extends Plugin {

    static get requires() {
        return [ Widget ];
    }


    init() {
        console.log( 'SimpleBoxEditing#init() got called' );
 

        this._defineSchema();
        this._defineConverters();
 

        // ADDED,将InsertSimpleBoxCommand命令加入到编辑器中
        this.editor.commands.add( 'insertSimpleBox', new InsertSimpleBoxCommand( this.editor ) );
    }
 

    _defineSchema() {
        // Previously registered schema.
        // ...
    }


    _defineConverters() {
        // Previously defined converters.
        // ...
    }

}

现在你可以执行这个命令来插入一个新的简单框。调用:

editor.execute( 'insertSimpleBox' );

结果如下:

image.png

在继续之前,先修改一点——禁止在 simpleBoxDescription 内部插入 simpleBox。可以通过定义一个自定义的子元素检查来实现:

export default class SimpleBoxEditing extends Plugin {

    static get requires() {
        return [ Widget ];
    }
 

    init() {
        console.log( 'SimpleBoxEditing#init() got called' );
 

        this._defineSchema();
        this._defineConverters();


        this.editor.commands.add( 'insertSimpleBox', new InsertSimpleBoxCommand( this.editor ) );
    }
 

    _defineSchema() {
        const schema = this.editor.model.schema;
        // Previously registered schema.
        // ...
 

        // 新增的
        schema.addChildCheck( ( context, childDefinition ) => {
            if ( context.endsWith( 'simpleBoxDescription' ) && childDefinition.name == 'simpleBox' ) {
                return false;
            }
        } );
    }
 

    _defineConverters() {

        // Previously defined converters.
        // ...
    }

}

现在,当光标位于另一个 simpleBox 实例的描述部分(simpleBoxDescription)内时,插入命令应该被禁用。

创建按钮

现在是时候让编辑器用户通过工具栏上的 UI 按钮来插入小部件了。最好的方式是使用 CKEditor 5 提供的 UI 框架中的 ButtonView 类来快速创建一个按钮。

这个按钮应该在点击时执行命令,并且如果在某个特定位置无法插入小部件(如在 schema 中定义的),按钮将变为不可用(置灰)。

实现如下(扩展之前创建的 SimpleBoxUI 插件):

import { ButtonView, Plugin } from 'ckeditor5'export default class SimpleBoxUI extends Plugin {
    init() {
        console.log( 'SimpleBoxUI#init() got called' );
 

        const editor = this.editor;
        const t = editor.t;
 

        // "simpleBox" 按钮必须在编辑器的 UI 组件中注册,才能在工具栏显示
        editor.ui.componentFactory.add( 'simpleBox', locale => {
            // 按钮的状态将绑定到小部件命令
            const command = editor.commands.get( 'insertSimpleBox' );
 

            // 按钮将是 ButtonView 的一个实例
            const buttonView = new ButtonView( locale );

            buttonView.set( {

                // t() 函数有助于本地化编辑器。所有被 t() 包围的字符串可以翻译,并且会随着编辑器语言的更改而变化。
                label: t( 'Simple Box' ),
                withText: true,
                tooltip: true
            } );
 

            // 将按钮的状态与命令的状态绑定
            buttonView.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
 

            // 当按钮被点击时执行insertSimpleBox命令,该命令已在SimpleBoxEditing中注册
            this.listenTo( buttonView, 'execute', () => editor.execute( 'insertSimpleBox' ) );

            return buttonView;

        } );
    }
}

最后,您需要告诉编辑器在工具栏中显示这个按钮。为此,您需要稍微修改运行编辑器实例的代码,并将按钮添加到工具栏配置中:

ClassicEditor

    .create( document.querySelector( '#editor' ), {

        licenseKey: 'GPL', // 或 '<YOUR_LICENSE_KEY>'。

        plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, SimpleBox ],

        // 将 "simpleBox" 按钮插入到编辑器的工具栏中

        toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList', 'simpleBox' ]

    } )

    .then( editor => {

        // 这段代码在编辑器初始化后运行

        // ...

    } )

    .catch( error => {

        // 如果初始化过程中出现错误,进行错误处理

        // ...

    } );

刷新网页并尝试插入小部件,您应该能看到工具栏上出现了一个按钮,点击它将插入新的简单框(Simple Box)。

image.png