以下基于ckeditor@22.0.0。
〇、CKEditor编辑器介绍
一、架构角度
二、整体分析代码
三、ckeditor5-core
四、ckeditor5-engine
五、ckeditor5-ui
六、ckeditor-utils
二、ckeditor5-engine
3.View
第一节官方说明中提到了视图层是一个虚拟DOM,在此先系统地介绍下。
一、虚拟 DOM 的核心由以下几个组件构成:
a. View
虚拟 DOM 的主要数据结构,包括:
- View Node: 用于表示 DOM 节点的抽象模型,具体分为 ContainerElement、AttributeElement、UIElement 等。
- View Document: 表示整个文档的根结构,管理视图的状态(如 Selection 和 Range)。
- View Text: 表示虚拟 DOM 中的文本节点。
b.Document
Document 对象表示了编辑器的整个内容,所有的文本、元素和节点都通过它来管理和组织。可以将其视为编辑器中所有内容的容器。
c. Renderer
- 负责将虚拟 DOM 的更新同步到真实 DOM。
- 通过 render() 方法计算最小差异,并只对变化的部分更新真实 DOM。
d. DomConverter
- 负责在虚拟 DOM 和真实 DOM 之间进行转换。
- 提供了双向转换功能,比如从虚拟 DOM 转换到实际 DOM,或从实际 DOM 转换到虚拟 DOM。
e. Downcast & Upcast
- Downcast: 将 Model 层的数据转化为虚拟 DOM(View)。
- Upcast: 将用户对真实 DOM 的输入行为转换为 Model 数据。
二、虚拟 DOM 的特点
a. 精细化的渲染更新
CKEditor5 的虚拟 DOM 并不追求通用的性能优化,而是专注于以下目标:
- 确保输入法(IME)行为的正确性。
- 减少原生功能(如拼写检查、自动完成等)的干扰。
- 最小化 DOM 操作,确保复杂内容编辑时的流畅性。
b. 自定义虚拟节点类型
- CKEditor5 的虚拟 DOM 提供了额外的节点类型,例如 UIElement 和 RawElement,分别用于处理编辑器的界面控件和原始 HTML 内容。
c. 双向绑定
- 通过 DomConverter 和 Renderer 实现虚拟 DOM 与真实 DOM 的同步更新。
三、工作流程
当用户进行编辑时(例如输入文字或应用样式),CKEditor5 的虚拟 DOM 会经历以下步骤:
- 事件监听:通过 Observer 捕获用户在真实 DOM 上的操作。
- Upcast 转换:将真实 DOM 的变化映射为虚拟 DOM 的变化,并更新 Model 层。
- Downcast 转换:根据 Model 层的数据更新虚拟 DOM。
- 渲染更新:通过 Renderer 比较虚拟 DOM 和真实 DOM 的差异,并将必要的更新同步到真实 DOM。
四、虚拟 DOM 的优点
- 性能优化:仅更新必要的部分,避免频繁的 DOM 操作。
- 功能丰富:支持复杂的文档编辑操作,如多段落格式化、嵌套结构等。
- 原生功能兼容:保留了输入法、拼写检查等浏览器原生功能的正确行为。
- 模块化设计:虚拟 DOM 与 CKEditor5 的其他模块(如 Model 层、插件系统)紧密集成,支持扩展和自定义。
五、与传统虚拟 DOM 的区别
与 React 或 Vue 的通用虚拟 DOM 实现相比,CKEditor5 的虚拟 DOM 有一些独特之处:
- 专注于文本编辑:优化文本编辑场景,而不是全页面应用。
- 非全面重绘:只更新局部的节点结构。
- 集成性:虚拟 DOM 是 CKEditor5 数据流的一部分,与 Model 和 DOM 层无缝衔接。
View是编辑器的视图控制器类。它的主要职责是进行用于编辑目的的DOM - View管理,提供对 DOM 结构和事件的抽象,并隐藏所有浏览器的差异性。
视图控制器会在视图结构发生变化时,将视图文档渲染到 DOM 中。为了确定何时可以渲染视图,所有的更改都需要使用 change 方法,并使用 DowncastWriter:
view.change( writer => {
writer.insert( position, writer.createText( 'foo' ) );
} );
视图控制器还会注册观察者,这些观察者用于监听 DOM 上的变化,并在文档上触发事件。请注意,以下观察者是由类构造函数(通过观察者模式)添加的,并且始终可用:
- SelectionObserver
- FocusObserver
- KeyObserver
- FakeSelectionObserver
- CompositionObserver
- InputObserver
- ArrowKeysObserver
- TabObserver
该类还会绑定 DOM 和视图元素。
如果你不需要完整的 DOM - 视图管理,仅仅是希望将视图元素的树转换为 DOM 元素的树,那么你不需要使用这个控制器。你可以改用 DomConverter。
3.1Document与DocumentFragment
Document 类在可编辑区域上创建了一个抽象层,包含一个视图元素树以及与该文档相关的视图选择(view selection)。
一个Document实例具有以下基本功能:
- selection的作用:是DocumentSelection实例(view下的,而非model下的,后面有很多跟model下类似的概念)。
- roots的作用:保存根节点。
- 通过set方法设置isReadOnly、isFocused、isComposing变量为false。
- PostFixer的作用:PostFixer机制允许在视图树渲染到 DOM 之前更新视图树。PostFixer会在所有来自最外层变化块的更改应用之后立即执行,但在触发渲染事件之前。如果后修复回调做出了更改,它应返回 true。当这种情况发生时,所有后修复会再次触发,以检查是否需要在新的文档树状态下进行其他修复。
3.2Node
Node基类
这是一个抽象类。其构造函数不应该直接使用,应使用 DowncastWriter 或 UpcastWriter 来创建新的视图节点实例。
该Node类跟Model里的Node类,从源码看, 极为相似,主要区别有:
- Model中的Node类构造器入参为attrs,其用于保存设置在节点上的attributes,另外设置了跟attrs相关的get、set等方法。
- View中的Node类构造器入参为document,用来保存节点所属的document实例。
Text类
继承了Node类,这个类的构造函数不应该直接使用。在处理从模型下转化的数据时,应该使用 DowncastWriter#createText() 方法来创建新的文本节点实例;在处理非语义视图时,则应该使用 UpcastWriter#createText() 方法。
该Text类跟Model里的Text类,从源码看,极为相似,主要区别有:
- Model中的Text类,构造器入参是data和attrs,而View中的Text类,入参是document和attrs。
- Model中的Text类有toJSON和fromJSON方法用于序列化和反序列化,而View中的Text类没有。
TextProxy类
封装了Text实例。该TextProxy类跟Model里的TextProxy类,从源码看,极为相似,主要区别有:
- Model中的TextProxy类实现了getPath方法和attrs相关的方法,而View的TextProxy类没有。
Element
继承了Node类,构造器参数有document、name、attrs和children。
View中的Element与Model中的Element差别较大,前者代码量大概是后者两倍,主要区别有:
- 构造函数都有attrs,那么和children,但后者(Model中的Element)另外还需要传入document。
- children相关的不同,前者的children直接赋值为数组,相关插入、移除等操作会fire出名为'children'的事件;后者children赋值为NodeList(内部实现本质上也是基于数组),且操作不会fire出事件。
- 入参attrs的相关操作实现方式相似,但前者是直接实现了一套方法(新增、移除等),而后者是通过继承Model中的Node而获得了相关方法。
- 前者会从attrs中分离出style和class,并且style和class分别管理。
- 前者可以向元素实例添加自定义属性(Custom properties),这些属性会被克隆,但不会渲染到 DOM 中。
RawElement
继承了Element,raw elements作为数据容器(“包装器wrappers”,“沙箱sandboxes”)工作,但它们的子元素不会被编辑器管理或识别。这种封装允许集成在编辑器内容中维护自定义的 DOM 结构,而无需担心与其他编辑器功能的兼容性。原始元素是与外部框架和数据源集成的理想工具。编辑器不会试图处理其内部的内容或与其进行交互,除非显式地通过自定义代码来实现。它的子元素和内容不会被 CKEditor 的选择或修改操作所影响,这使得它成为处理外部内容时的一个理想选择。
与 UI 元素不同,raw elements像真实的编辑器内容一样工作(类似于 ContainerElement 或 EmptyElement),它们会被编辑器的选择(selection)管理,并且可以作为小部件(widget)使用。小部件是 CKEditor 中的一个概念,指的是可以被选中、拖动、删除的自包含的元素。通过 Raw Element,可以将外部的 DOM 结构包装成可互动的小部件。
RawElement内部没有直接实现render方法,但是在downcastwriter中的createRawElement() 方法中,需要实现render方法,这允许在 DOM 层级渲染一个 RawElement 的子元素。
render方法由 DomConverter 调用,传递一个原始 DOM 元素作为参数,子元素的数量和形状由集成者决定。
// ckeditor5-engine/src/view/downcastwriter.js
createRawElement( name, attributes, renderFunction ) {
const rawElement = new RawElement( this.document, name, attributes );
rawElement.render = renderFunction || ( () => {} );
return rawElement;
}
// 举例
const myRawElement = downcastWriter.createRawElement( 'div' );
myRawElement.render = function( domElement ) {
domElement.innerHTML = '<b>This is the raw content of myRawElement.</b>';
};
AttributeElement
继承了Element,属性元素(AttributeElement)用于表示视图中的格式化元素(比如 <b>、<span style="font-size: 2em"> 等)。它们通常在下行转换模型文本属性时创建。
编辑引擎并未定义固定的 HTML DTD(文档类型定义)。这也是为什么在开发功能时,功能开发者需要在多种元素类型(如容器元素、属性元素、空元素等)之间进行选择。
要创建新的 属性元素 实例,可以使用 DowncastWriter#createAttributeElement() 方法。
AttributeElement在父类Element的基础上主要新增了:
- getFillerOffset方法:用于返回当前元素的块级填充符(block filler)的offset。
- priority:元素优先级,决定了元素在被 DowncastWriter 包裹时的顺序。
- id:元素标识符,如果设置,它将被 isSimilar 使用,然后只有当两个元素具有相同的 ID和priority 时,它们才会被认为是相似的。
EmptyElement
继承了Element,它用于表示不能包含任何子节点的元素(例如 <img> 元素)。要创建一个新的空元素,可以使用 downcastWriter#createEmptyElement() 方法。
其在父类Element的基础上主要新增了:
-
getFillerOffset方法:返回为null,意为不需要block filler。 -
_insertChild方法:重写了该方法,throw了错误,意为不需要child nodes。
UIElement
继承了Element。UI element 类用于表示需要注入到编辑视图中的编辑 UI。如果可能的话,应该将 UI 保持在编辑视图之外。然而,如果不可能,UI 元素可以被使用。
如何渲染一个 UI 元素是由你来控制的(你将回调传递给 downcastWriter#createUIElement())。编辑器会忽略你的 UI 元素——不能将光标放置在它上面,当用户使用箭头键修改选择(selection)时,它会被跳过(不可见),编辑器也不会监听在 UI 元素内部发生的任何变动。
限制是,不能将模型元素转换为 UI 元素。需要为markers或作为普通容器元素中的附加元素创建UI 元素。
要创建一个新的 UI 元素,请使用 downcastWriter#createUIElement() 方法,本质是调用UIElement的render方法,举例:
// 如果你的 UI 元素中的更改应触发编辑器 UI 的更新,你应该在渲染 UI 元素后调用editor.ui.update()方法。
const myUIElement = downcastWriter.createUIElement( 'span' );
myUIElement.render = function( domDocument ) {
const domElement = this.toDomElement( domDocument );
return domElement;
};
render默认实现跟例例一样,在render内部,toDomElement依据UI元素的name属性作为tag创建一个dom元素,然后遍历传给UI元素的attrs,逐个赋值给新的dom元素。
ContainerElement
继承了Element。容器元素是定义文档结构(document structure)的元素,其定义了属性(attributes)的边界。容器元素通常用于块级元素,如 <p> 或 <div>。
编辑引擎并没有定义固定的 HTML DTD。因此,特性开发者在开发功能时需要在多种类型(容器元素、属性元素、空元素等)之间做出选择。
在编写转换器时,容器元素应该是默认的选择,除非:
- 该元素代表模型文本属性(此时使用 AttributeElement),
- 该元素是空元素,如
(此时使用 EmptyElement),
- 该元素是根元素,
- 该元素是嵌套的可编辑元素(此时使用 EditableElement)。
要创建新的容器元素实例,请使用 DowncastWriter#createContainerElement() 方法。
该类继承了Element,自身主要是实现了getFillerOffset方法,用于返回当前元素的块级填充符(block filler)的offset。
EditableElement
继承了ContainerElement,它可以是编辑器中的根元素或嵌套的可编辑区域。当其 Document 为只读时,Editable 元素会自动变为只读。
此类的构造函数不应直接使用。要创建新的 EditableElement,请使用 downcastWriter#createEditableElement() 方法。
在ContainerElement的基础上,mix了ObservableMixin后,设置了observable变量isReadOnly和isFocused:
class EditableElement extends ContainerElement {
this.set( 'isReadOnly', false );
this.set( 'isFocused', false );
this.bind( 'isReadOnly' ).to( document );
this.bind( 'isFocused' ).to(
document,
'isFocused',
isFocused => isFocused && document.selection.editableElement == this
);
// Update focus state based on selection changes.
this.listenTo( document.selection, 'change', () => {
this.isFocused = document.isFocused && document.selection.editableElement == this;
} );
}
isReadOnly:用于设置editable元素是可读可写还是只读模式,并将该变量绑定到document;
isFocused:用于设置editable元素是否是focused,并将该变量绑定到document;
RootEditableElement
继承了EditableElement,该类表示数据视图(data view)中的单个根元素。根元素可以是可编辑的,也可以是只读的,但在这两种情况下,它都被称为“可编辑的”。根元素可以包含其他可编辑元素,使其成为“嵌套可编辑元素”。
它在EditableElement基础上定义了rootName变量为‘main’,借助_customProperties,定义了rootName的getter和setter方法。
3.3Filler(填充符)
填充元素处理相关的工具集
浏览器不允许将光标放置在没有高度的元素中。因此,我们需要用被称为“填充符”的元素或字符填充所有应当可选择的空元素。遗憾的是,并没有一个通用的填充符,因此使用了两种类型的填充符:
- 块级填充符(Block Filler)
块级填充符用于填充块级元素,例如 <p>。在编辑过程中,CKEditor 使用 <br> 作为块级填充符,就像浏览器本地处理的那样。因此,空的 <p> 元素会变成 <p><br></p>。块级填充符的优势是它对选择透明,当光标位于 <br> 前面且用户按下右箭头时,光标会移动到下一个段落,而不是 <br> 后面。缺点是它会打破块级元素的结构,因此不能在一行文本的中间使用。<br> 填充符(BR_FILLER)可以在数据输出时被替换为其他字符,例如不换行空格(NBSP_FILLER)。
- 内联填充符(Inline Filler)
内联填充符不会打破文本行,因此可以用于文本中,例如在被文本包围的空 <b> 元素中:foo<b></b>bar,如果我们希望将光标放置在其中,CKEditor 使用一系列零宽空格作为内联填充符(INLINE_FILLER),并且具有预定的长度(INLINE_FILLER_LENGTH)。使用一系列零宽空格而不是单一字符的目的是为了避免将随机的零宽空格误识别为内联填充符。内联填充符的缺点是它对选择不透明。箭头键会在零宽空格字符之间移动光标,因此需要额外的代码来处理光标。
这两种填充符都由渲染器处理,并且不会出现在视图中。
源码分析
// '\u00A0'是NO-BREAK SPACE(可见的不换行空格,也叫不间断空格)
export const NBSP_FILLER = domDocument => domDocument.createTextNode( '\u00A0' );
// 这是一个创建 `<br data-cke-filler="true">` 元素的函数。
export const BR_FILLER = domDocument => {
const fillerBr = domDocument.createElement( 'br' );
fillerBr.dataset.ckeFiller = true;
return fillerBr;
};
export const INLINE_FILLER_LENGTH = 7;
// 使用 IIF(立即执行函数表达式),以便 INLINE_FILLER 显示为常量。
// \u200B是零宽度空格(Zero Width Space)字符。它是一种不可见的字符
// ,通常用于避免文本换行或保持文本的连续性。
export const INLINE_FILLER = ( () => {
let inlineFiller = '';
for ( let i = 0; i < INLINE_FILLER_LENGTH; i++ ) {
inlineFiller += '\u200b';
}
return inlineFiller;
} )();
/* 检查节点是否为文本节点,并且该文本节点以inline filler开头。
* startsWithFiller( document.createTextNode( INLINE_FILLER ) ); // true
* startsWithFiller( document.createTextNode( INLINE_FILLER + 'foo' ) ); // true
* startsWithFiller( document.createTextNode( 'foo' ) ); // false
* startsWithFiller( document.createElement( 'p' ) ); // false
*/
export function startsWithFiller( domNode ) {
return isText( domNode ) && ( domNode.data.substr( 0, INLINE_FILLER_LENGTH ) === INLINE_FILLER );
}
// 检查文本节点是否仅包含inline filler}。
export function isInlineFiller( domText ) {
return domText.data.length == INLINE_FILLER_LENGTH && startsWithFiller( domText );
}
/* 从文本节点获取字符串数据,移除其中的内联填充符(如果文本节点包含该填充符)。
* getDataWithoutFiller( document.createTextNode( INLINE_FILLER + 'foo' ) ) == 'foo' // true
* getDataWithoutFiller( document.createTextNode( 'foo' ) ) == 'foo' // true
*/
export function getDataWithoutFiller( domText ) {
if ( startsWithFiller( domText ) ) {
return domText.data.slice( INLINE_FILLER_LENGTH );
} else {
return domText.data;
}
}
export function injectQuirksHandling( view ) {
view.document.on( 'keydown', jumpOverInlineFiller );
}
// 当按下左箭头时,将光标从inline filler的末尾移动到开头,以防止填充符打断导航。
function jumpOverInlineFiller( evt, data ) {
if ( data.keyCode == keyCodes.arrowleft ) {
const domSelection = data.domTarget.ownerDocument.defaultView.getSelection();
if ( domSelection.rangeCount == 1 && domSelection.getRangeAt( 0 ).collapsed ) {
const domParent = domSelection.getRangeAt( 0 ).startContainer;
const domOffset = domSelection.getRangeAt( 0 ).startOffset;
if ( startsWithFiller( domParent ) && domOffset <= INLINE_FILLER_LENGTH ) {
// 将光标收起到domParent节点 的开头
domSelection.collapse( domParent, 0 );
}
}
}
}
\u200B 和 \u00A0 都是 Unicode 字符,但它们有不同的用途和表现:
- \u200B (零宽空格,Zero Width Space) :
- 作用:它是一个不可见的字符,占据了空间,但不会在视觉上呈现出来。通常用于控制文本的断行(例如,在不希望自动换行的地方插入零宽空格),或者用于隐藏信息。
- 使用场景:常用于排版和文本格式化中,比如防止某些文本在自动换行时被拆开,或在需要在视觉上隐藏字符时使用。
- 表现:它不会占据可见的宽度或间隙,因此在页面上看不见。
- \u00A0 (不间断空格,Non-Breaking Space) :
- 作用:这是一个正常的空格字符,但与普通空格不同,它的特点是不会被自动断行。即使在长文本中,如果遇到 \u00A0,浏览器也不会将其拆开换行,常用于避免单词、数字或符号之间被拆开。
- 使用场景:通常用于排版中,保持特定的空格不被拆开。例如,保持数字和单位、名字等紧凑显示在一起,避免换行。
- 表现:它在页面上显示为一个普通的空格,但不会允许自动换行。
简单说就是:
-
\u200B:零宽空格,不占空间,不可见,主要用于控制换行。 -
\u00A0:不间断空格,占空间,可见,但阻止自动换行。
getFillerOffset
getFillerOffset() 方法用于帮助计算视图中元素的填充位置,尤其是在处理空段落或没有实际内容的元素时。它确保编辑器能够正确管理光标位置和占位符内容,保证用户体验不会受到影响。通过这个方法,CKEditor 5 能够在各种情况下保持视图和布局的正确性。
在3.2Node节中各节点,只有AttributeElement、ContainerElement、EmptyElement、RawElement、UIElement实现了getFillerOffset方法,后三者该方法会返回null,前两者实现类似,都是在元素的末尾渲染块级填充元素(在所有 UI 子元素之后)。
3.4Position
视图树中的位置。位置由其父节点和在该父节点中的偏移量表示。为了创建一个新的位置实例,请使用以下工厂方法中的 createPosition*():
- View
- DowncastWriter
- UpcastWriter
Position主要提供了获取前后节点的方法、Position实例比较前后位置的方法以及在指定位置创建新位置的方法,在提供的主要能力方面与Model的Position类似。View的Position与Model中的Position不同点主要有:
- 构造器入参不同,前者为parent和offset,后者为root,path和stickness;
- 前者没有在实例上设置path,虽然可以通过parent属性向上递归获取;
- 前者parent不像后者是一个getter(动态计算),只是一个属性;
- 前者没有stickness,它的作用是表示一个位置如何与相邻节点(特别是其前后的节点)绑定或“粘附”,描述在某些情况下(如模型变化时,位置需要转移或重新计算时),如何处理位置的变动。
3.5Range
视图树中的范围。范围由其起始位置和结束位置表示。为了创建一个新的范围实例,使用以下工厂方法中的 createPosition*():
- View
- DowncastWriter
- UpcastWriter
View中的Range的基本能力(方法)在Model的Range中都有,额外提供了getTrimmed、getEnlarged方法。
- getTrimmed()创建一个最小范围,它与此范围具有相同的内容,但在两端(起始和结束)进行了修剪。例如:
<p>Foo</p>[<p><b>Bar</b>]</p> -> <p>Foo</p><p><b>{Bar}</b></p>
<p><b>foo[</b>bar<span></span>]</p> -> <p><b>foo</b>{bar}<span></span></p>
- getEnlarged()创建一个最大范围,它与此范围具有相同的内容,但在两端(起始和结束)进行了扩展。例如:
<p>Foo</p><p><b>{Bar}</b></p> -> <p>Foo</p>[<p><b>Bar</b>]</p>
<p><b>foo</b>{bar}<span></span></p> -> <p><b>foo[</b>bar<span></span>]</p>
请注意,在上述示例中:
-
<p>的类型是 ContainerElement, -
<b>的类型是 AttributeElement, -
<span>的类型是 UIElement。
3.6Selection与DocumentSelection
Selection
新的选择(selection)实例可以通过构造函数或以下方法创建:
- View#createSelection()
- UpcastWriter#createSelection()
例如:
// Creates empty selection without ranges.
const selection = writer.createSelection();
// Creates selection at the given range.
const range = writer.createRange( start, end );
const selection = writer.createSelection( range );
// Creates selection at the given ranges
const ranges = [ writer.createRange( start1, end2 ), writer.createRange( star2, end2 ) ];
const selection = writer.createSelection( ranges );
selection的构造函数允许作为最后一个参数传递附加选项(backward、fake 和 label)。
// Creates backward selection.
const selection = writer.createSelection( range, { backward: true } );
// 此外,假选择的标签可以提供。它将用于在 DOM 中描述假选择(并由屏幕阅读器正确处理)。// 创建带有标签的假选择。
const selection = writer.createSelection(range, { fake: true, label: 'foo' });
与Model中的selection的异同:
FakeSelection
在 CKEditor 5 中,Fake Selection(假选择)是一种虚拟的选择,它与浏览器原生的选择不同。它不在界面中以浏览器默认的选择方式(如文本高亮显示)呈现,而是通过其他方式进行表示,例如通过 CSS 类 或自定义的样式,或者在某些情况下通过 DOM 标记。
特点
- 不可见的原生选择:假选择不会像浏览器的原生选择那样在页面中显示出被选中的文本或元素(如背景色、文本选中的反转色等)。也就是说,假选择并不会展示出典型的浏览器用户界面效果。
- 自定义样式或表示:相反,开发者可以自定义该选择的呈现方式,例如通过 CSS 类、边框、背景色等其他方式来表示选择的区域。
- 适用于特定用途:假选择适用于一些特殊场景,例如在富文本编辑器中,用户可能不希望看到浏览器默认的选区样式,而是想通过自定义的视觉效果来表示选择区域。
使用场景
- 隐藏选择的 UI:假选择不显示浏览器的原生选区效果,适用于需要隐藏原生选择UI的场景。例如,在 CKEditor 5 中,你可能希望让编辑器中的选区不显示为常见的蓝色高亮,而是通过其他方式进行样式化(比如改变背景色或使用边框)。
- 无障碍性支持:假选择仍然是有意义的,它可以与屏幕阅读器一起使用,通过 label 属性向用户传达假选择的意义,确保无障碍性。
如何创建
假选择可以通过 writer.createSelection() 方法的 fake 选项来创建。你还可以使用 label 选项为假选择提供一个标签,这样有助于屏幕阅读器识别假选择的目的。
// 创建带有标签的假选择。
const selection = writer.createSelection(range, { fake: true, label: 'foo' });
在上面的代码中:
fake: true 表示这是一个假选择。
label: 'foo' 给假选择分配了一个标签,屏幕阅读器可以读取这个标签,帮助用户理解选择的意义。
DocumentSelection
表示视图中文档选择的类。其实例可以通过 Document#selection 访问。
它类似于 Selection,但具有只读 API,并且只能通过在 View#change() 块中可用的写入器进行修改(即通过 DowncastWriter#setSelection())。
DocumentSelection中内部维护了Selection实例,且构造器的参数直接映射到该实例上,可以认为DocumentSelection是selection的一个proxy:
class DocumentSelection {
constructor( selectable = null, placeOrOffset, options ) {
this._selection = new Selection();
// 将Selection实例的change事件委托给DocumentSelection
this._selection.delegate( 'change' ).to( this );
this._selection.setTo( selectable, placeOrOffset, options );
}
get isFake() {
return this._selection.isFake;
}
}
3.7DomConverter
DomConverter 是一组用于在 DOM 节点和视图节点之间进行转换的工具。它还处理这些节点之间的绑定。
DOM 转换器的实例可以通过 editor.editing.view.domConverter 获取。
DOM 转换器不会检查哪些节点需要被渲染(应使用 Renderer),也不会维护树的状态或保持树视图(tree view)与 DOM 树之间的同步(应使用 Document)。
DOM 转换器保持 DOM 元素和视图元素之间的绑定关系,因此当转换器被销毁时,这些绑定关系会丢失。两个转换器将维护独立的绑定映射,因此一个树视图可以与两个 DOM 树进行绑定。
主要方法有:
- 构造器:
- a. 根据入参设定块填充符(block filler)模式,可选值有“br”、“nbsp”;并根据该模式设置块填充符构造方法,构造方法可以调用dom文档的createElement方法生成相应元素。
- b. 通过属性preElements是一个包含预格式化元素标签的数组,默认值为['pre']。
预格式化元素(pre-formatted elements)是指在 DOM 中的那些元素,它们在渲染时应该保留其原始格式或内容,不会进行 HTML 转换或修改。例如,
<pre> 标签、<code>标签(当然也可以通过设置css的whitespace达到同样效果,在此不涉及)等通常是预格式化的,这意味着它们内部的空白字符(如空格和换行)应该被保留,且不会被压缩或折叠。主要功能
- 保持空白字符:对于 preElements 中列出的元素类型,DOM 转换器会特别处理它们,保留其中的空白字符(如空格和换行符),因为这些元素的格式应该被保持不变。
- 格式化行为:在视图和 DOM 之间进行转换时,preElements 中的元素将确保其内容不被其他转换规则改变。例如,在转换过程中,如果一个元素被认为是预格式化的,DOM 转换器会确保其内容不会被自动压缩或格式化。
- 防止自动换行:通常,预格式化元素中的内容会根据实际输入的格式来显示,包括换行和空格。这与一般元素的行为不同,后者的空白字符可能会被忽略或折叠。
preElements 的主要作用是帮助 DomConverter 识别并处理那些需要保留原始格式的元素。这对于一些格式化要求高的内容,如代码块、预格式化文本等非常重要,可以确保它们在转换到 DOM 时不会丢失格式信息。
-
c. 预置一些被认为是块级元素的元素(因此应该填充块级填充器),元素是否被视为块级元素还会影响尾随空白字符的处理。如果引入了尚未在此处识别的块级元素,可以扩展此数组。默认有'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'dd', 'dt', 'figcaption', 'td', 'th'。
-
d.通过weakMap设置
_domToViewMapping、_viewToDomMapping和_fakeSelectionMapping,分别用于保存dom与view、view与dom、虚拟选择容器(fakeSelection)与相应视图选择之间的映射关系。
-
fakeSelection相关的:基于_fakeSelectionMapping,保存和获取dom元素对应的文档选择(document selection)的位置。 -
DOM元素的绑定与解绑:保存dom与view的双向关系,即通过_domToViewMapping保存和删除dom元素对应的view元素或dom fragment对应的view fragment,通过_viewToDomMapping保存和删除view元素对应的dom元素或view fragment对应的dom fragment。
-
view*ToDom:将视图转换为 DOM
具体包括viewToDom、viewRangeToDom、viewPositionToDom。
- a. viewToDom( viewNode, domDocument, options = {} ) :将视图转换为 DOM。对于所有文本节点、未绑定的元素和文档片段,将创建新项,子元素递归交给viewChildrenToDom处理。
如果给定节点是文本节点:从给定的文本数据中获取文本并处理,使其在 DOM 中正确显示。如果节点的任何祖先元素的名称(name)在 preElements 数组中,则当前处理的视图文本节点(将会是)处于预格式化元素(preformatted element)中。在这种情况下,我们不应该更改空白字符。其他情况须执行以下更改:
- 如果这是容器元素(container element)中的第一个文本节点(text node),或者如果前一个文本节点以空格字符结尾,则该文本开头的空格会被更改为 (不间断空格,Unicode 字符为\u00A0)。
- 如果文本节点的末尾有两个空格,或者其下一个节点以空格开始,或者它是容器元素中的最后一个文本节点,则文本末尾的空格会被更改为 。
- 其余的空格,如果连续有2个空格,那么其中第二个空格会被替换为会被替换 (例如 'x x' 变为 'x x')。
如果给定节点不是文本节点,将根据节点类型分别进行处理:
- 如果给定节点(viewNode)在_viewToDomMapping中存在,那么直接通过_viewToDomMapping返回对应value值。
- 如果给定节点是documentFragment,那么创建一个DOM document fragment实例,当options.bind为true时,通过_domToViewMapping,_viewToDomMapping两个mapper将该fragment实例与给定节点进行双向绑定。
- 如果是给点节点是uiElement,那么通过调用ui元素的render方法,生成一个dom元素,当options.bind为true时,通过_domToViewMapping,_viewToDomMapping两个mapper将该dom元素与给定节点进行双向绑定。
- 如果是其他类型,依据给定节点的name(作为tag)创建一个dom元素,当给定节点是raw element,那么还需要调用raw element的render方法,对新建的dom元素进行处理。当options.bind为true时,依照前面的方式,对dom元素与给定节进行双向绑定。
如果option.withChildren为真或undefined,通过递归的方式处理子元素,如果子元素有需要插入block filler的地方,那么在该位置生成block filler,子元素和block filler都通过DOM的appendChild方法生成dom子元素。
- b. viewRangeToDom( viewRange ) :将view range转换为dom的range。view range的起始或结束点分别通过viewPositionToDom(viewPosition)方法获取viewPosition对应的dom父节点和在父节点中offset(如果父节点是以inline filler开头的,那么要将inline filller的长度加进offset),再通过dom的createRange方法生成dom的range。
viewRangeToDom( viewRange ) {
const domStart = this.viewPositionToDom( viewRange.start );
const domEnd = this.viewPositionToDom( viewRange.end );
const domRange = document.createRange();
domRange.setStart( domStart.parent, domStart.offset );
domRange.setEnd( domEnd.parent, domEnd.offset );
return domRange;
}
viewPositionToDom的作用是根据起始或结束点(即位置)的parent,在_viewToDomMapping中找到其对应的dom元素及该dom元素的offset(如果该dom元素是文本,且以inline filler开始,那么offset需要加上inline filler的长度)
-
dom*ToView:将视图转换为 DOM
具体包括domToView、domSelectionToView、domRangeToView、domPositionToView。
- a. domToView( domNode, options = {} ) :将 DOM 转换为视图。对于所有文本节点、未绑定元素和文档片段,将创建新的视图元素。对于绑定元素和文档片段,该函数将返回对应的视图元素。对于填充元素,将返回null。对于所有由 UIElement 渲染的 DOM 元素,将返回该UIElement。
如果给定dom节点是block filler或注释节点,那么返回null。
如果在遍历ancestors(都是dom元素)时,能从_domToViewMapping中获取到UIElement或RawElement这样的view元素,那么返回该view元素(由内向外处理ancestors,若满足条件,则停止检查)。
如果给定dom节点是文本节点,若其是inline filler,则返回null,否则按如下逻辑处理该文本节点,处理后用View Text包装后返回:
- 将多个whitespace(包括空格字符、换行符\n、制表符\t和回车符\r)替换为单个空格;(注:Whitespace characters include spaces, tabs, and newlines.)
- 如果文本节点是其容器元素中的第一个文本节点,或者如果前一个文本节点以空格字符结尾,则删除文本节点开头的空格;
- 如果文本节点结尾有两个空格,或者下一个节点以空格开头,或者这是容器中的最后一个文本节点,则删除文本节点结尾的空格;
- 将不间断空格(nbsp)转换为普通空格。
值得一提的是,在处理过程中用到浏览器原生自带的createTreeWalker方法生成TreeWalker对象,进行节点遍历; 这点不同于viewToDom中用到的TreeWalker(它是CKEditor5自己实现的walker)。
如果给定dom节点是原生document fragment(文档片段)类型,则生成一个(view里的)DocumentFragment对象,若options.bind为true,则将dom节点和这个对象进行双向绑定。如果withChildren是true或undefined,那么需用递归的方式遍历dom子节点,获取子节点对应的view节点,然后将其插入新生成的DocumentFragment对象。
如果给定dom节点是其他类型,那么依据dom节点的tagname生成Element对象,遍历dom节点的attributes属性,依次设置到这个新对象上,若options.bind为true,还需将dom节点和这个对象进行双向绑定。如果withChildren是true或undefined,那么需用递归的方式遍历dom子节点,获取子节点对应的view节点,然后将其插入新生成的Element对象。
- b.
domSelectionToView方法依赖domRangeToView方法,而domRangeToView方法依赖domPositionToView。在此详述下实现难度相对最高的domPositionToView。
3.8Renderer
使用方式和效果可以参考测试用例。
Renderer 负责根据更新后的视图节点信息更新 DOM 结构和 DOM 选择(selection)。换句话说,它将视图渲染到 DOM 中。
它的主要责任是仅对 DOM 进行必要的、最小的修改。然而,与许多虚拟 DOM 实现不同,进行最小修改的主要原因不是为了性能,而是为了确保原生编辑功能(如文本组成、自动完成、拼写检查、选择的 x 坐标索引(selection's x-index))受到尽可能少的影响。
Renderer 使用 DomConverter 来转换视图节点和位置,进行视图和 DOM 之间的双向转换。
主要方法:
markToSync( type, node )
标记一个视图节点,以便通过 render() 在 DOM 中更新。 请注意,只有那些父节点有对应 DOM 元素的视图节点需要被标记为同步,而父节点是否有对应 DOM 元素,要通过3.7节DomConverter实例中的_viewToDomMapping是否有保存相关信息。根据type的不同(有text、attributes和children三种)将node(view节点)分别保存在不同的Set(markedTexts、markedAttributes和markedChildren)中。最终在renderer方法中会依次对三种Set进行遍历、处理。该方法在mutationobserver.js和view.js中使用到,当元素有变化时,会触发markToSync方法的调用。
render
在view类的change方法最后,先处理完post fixers后,再触发render方法。
- 遍历markedChildren(更新绑定关系) :通过_updateChildrenMappings( viewElement )对每个元素逐个处理,目的是更新视图元素子节点的映射关系。在视图结构中被相似元素(具有相同标签名)替换的子节点会被视为“replaced”节点。这意味着可以更新它们的映射关系,使新的视图元素映射到现有的 DOM 元素上。因此,这些元素不需要被完全重新渲染。
- 处理inline filler:判断是否需要内联填充元素(inline filler),如不需要则将之删除,如需要则获取内联填充元素所在位置。
- 遍历markedAttributes:通过_updateAttrs( viewElement )对每个元素逐个处理,通过domConverter获取viewElement对应的dom元素,通过处理确保dom元素上的attributes跟viewElement上的一致。
- 遍历markedChildren:通过_updateChildren( viewElement, { inlineFillerPosition })对每个子元素逐个处理,目的是检查子元素是否需要更新,并可能进行更新。在第一步中已经更新了视图元素子节点的映射关系,在此基础上执行删除、插入操作,并更新绑定关系。
- 遍历markedTexts:_updateText( viewText, { inlineFillerPosition } )对每个子元素逐个处理,目的是检测文本是否需要更新(包括插入新文本和删除文本)。
- 处理inlineFiller位置:检查是否需要内联填充符,并确认它在 DOM 中的实际位置。在大多数情况下,它应该已经存在于 DOM 中,但也有一些例外情况。例如,如果内联填充符位于创建的 DOM 结构中较深的位置,它将不会被创建。类似地,如果它在该函数开始时被移除,且之后没有更新文本或子元素,它也将不会存在。修复这些及类似的情况。
- 处理Selection:检查selection是否需要更新(如view selection与dom selection不一致时需要更新),并在必要时更新它。
- 处理Focus:检查焦点是否需要更新,并在必要时更新它。如果renderer中isFocused为true,那么需要执行editableElement的focus方法,并保持各元素原本的滚动状态(scrollLeft、scrollTop)和页面原本整体的滚动条位置。
举例简单说明:
假设有一段内容:<p><b>Foo</b>Bar<i>Baz</i><b>Bax</b></p>,被用户修改为:<p>Bar<b>123</b><i>Baz</i><b>456</b></p>。也就是:
Actual DOM: <p><b>Foo</b>Bar<i>Baz</i><b>Bax</b></p>
Expected DOM: <p>Bar<b>123</b><i>Baz</i><b>456</b></p>
Newest View: <p>Bar<b>123</b><i>Baz</i><b>456</b></p>
1. 在_updateChildrenMappings方法中,会生成两种actions:
Input actions: [ insert, insert, delete, delete, equal, insert, delete ]
Output actions: [ insert, replace, delete, equal, replace ]
然后,按Output actions进行遍历,重新绑定view节点与dom节点的绑定关系,如下:
view node <-> dom node
<b>123</b> <-> <b>Foo</b>
<b>456</b> <-> <b>Bax</b>
解除<b>Foo</b>原有的绑定,并将view节点<b>123</b>加入markedChildren、markedAttributes;
解除<b>Bax</b>原有的绑定,并将view节点<b>456</b>加入markedChildren、markedAttributes。
2. 通过_updateAttrs处理markedAttributes,更新attributes,确保dom节点的attributes与view节点一致。
3. 通过_updateChildren处理markedChildren,过程如下:
按Input actions遍历,对Actual DOM进行变更:
(1)处理p view元素
对Actual DOM <p><b>Foo</b>Bar<i>Baz</i><b>Bax</b></p> 进行处理:
执行delete:-> <p><i>Baz</i></p>
执行insert:-> <p>Bar<b>123</b><i>Baz</i><b>456</b></p>
执行equal:-> 对<i>Baz</i>这个view节点进行处理,将Baz这个view文本加入到markedTexts中。
解除Actual DOM中被删除节点原有的绑定关系:Foo、Bar、Bax。
(2)处理<b>123</b> view元素(第一大步新加入的)
对Actual DOM Foo 进行处理,遍历其子节点(即Foo文本):
Actual DOM:Foo
Expected DOM:123
Newest View:123
Input actions:[insert, delete],按此遍历:
执行delete:-> <b></b>,解除Foo这个dom原有的绑定,并删除此Foo文本;
执行insert:-> <b>123</b>
(3)处理<b>456</b> view元素(第一大步新加入的)
同上,最终<b>456</b> view元素对应的dom <b>Bax</b>被变更成<b>456</b>。
4. 通过_updateText处理markedTexts,过程如下:由于Baz在actual和expected中一样,所以无需处理。
3.9Observer
Observer是抽象基类观察者。主要用于:
- 监听 DOM 事件:观察器会捕捉到与视图交互相关的 DOM 事件,例如鼠标点击、键盘输入、元素变动等。
- 事件处理与预处理:捕捉到事件后,观察器会进行必要的预处理或转换,以便后续的逻辑可以使用。
- 触发视图事件:通过处理和转发事件,观察器会在 Document 对象上触发相应的视图事件。这些事件通常用于更新视图状态或重新渲染视图。
- 添加视图功能:观察器可以为视图提供额外的功能,例如更新 UI 状态、刷新元素、添加动画效果等。
class Observer {
constructor( view ) {
this.view = view;
this.document = view.document;
/**
* State of the observer. If it is disabled events will not be fired.
*/
this.isEnabled = false;
}
enable() {
this.isEnabled = true;
}
disable() {
this.isEnabled = false;
}
destroy() {
this.disable();
this.stopListening();
}
}
mix( Observer, DomEmitterMixin );
下面以MutationObserver、DomEventObserver和ClickObserver为例说明。
MutationObserver
继承了Observer。Mutation Observer 的作用是监视编辑器内部所有非由编辑器渲染器自身执行的 DOM 变化,并撤销这些变化。
它通过观察 DOM 中的所有变化,标记相关的视图元素为已更改,并调用 render 方法来实现这一点。由于所有变更的节点都会被标记为“待渲染”,并且调用了 render() 方法,因此所有变化都会在 DOM 中被撤销(DOM 会与编辑器的视图结构同步)。
Mutation Observer 还负责减少触发的 mutations 数量。它会移除重复的 mutations,以及那些没有对应视图元素的那些元素上的 mutations。此外,只有当父元素没有改变子元素列表时,才会触发文本 mutation。
请注意,这个观察者由 View 添加,并且默认是可用的。
提供的功能有:
- 像Observer一样,构造器需要传入View实例,该实例的多处需要用到该View实例的renderer、domConverter的诸多方法;
- 内部用到了window.MutationObserver这种原生高性能的dom变化观察器,并定义了回调函数,具体来讲:
- 监听childList、characterData这2种dom变化(排除RawElements和UIElements),对于characterData类型的变化,通过markToSync('text', dom节点对应的view节点)进行标记;对应childList类型的变化,通过markToSync( 'children', dom节点对应的view节点 )进行标记。
- 如果有选择(dom selection),那么生成对应的view selection。
- 如果在第一步有变化,触发‘mutations’事件,并调用view类的forceRender方法,该方法强制将视图文档渲染到DOM。
- 提供了observe方法,用于设置待观察的dom元素,以供window.MutationObserver观察;
- 像Observer一样,提供了enable方法,用于打开'isEnabled',让观察者能真正开启观察;
DomEventObserver
它继承了Observer,是DOM事件观察者(如ClickObserver)的基类。该类处理向DOM元素添加监听器、禁用和重新启用事件。子类需要定义DOM事件类型和回调函数。
源码分析:
// observer/domevemtobserver.js
class DomEventObserver extends Observer {
/**
* Type of the DOM event the observer should listen on.
* @member {String|Array.<String>} #domEventType
*/
/**
* Callback which should be called when the DOM event occurred.
* @method #onDomEvent
*/
constructor( view ) {
super( view );
this.useCapture = false;
}
observe( domElement ) {
const types = typeof this.domEventType == 'string' ? [ this.domEventType ] : this.domEventType;
types.forEach( type => {
this.listenTo( domElement, type, ( eventInfo, domEvent ) => {
if ( this.isEnabled ) {
this.onDomEvent( domEvent );
}
}, { useCapture: this.useCapture } );
} );
}
fire( eventType, domEvent, additionalData ) {
if ( this.isEnabled ) {
this.document.fire( eventType, new DomEventData( this.view, domEvent, additionalData ) );
}
}
}
- 新增了useCapture,用于控制事件顺序。
事件流的三个阶段
- 捕获阶段(Capture Phase):事件从根元素(document)向目标元素传播。最先触发的事件监听器是在捕获阶段,逐级向下到达目标元素。
- 目标阶段(Target Phase):事件到达目标元素时,会触发目标元素上绑定的事件监听器。
- 冒泡阶段(Bubble Phase):事件从目标元素开始,逐级向上冒泡到根元素(document)。在这个阶段,父元素的事件监听器会被触发,直到根元素。
- 新增了domEventType,用于定义待监听的事件类型;新增了onDomEvent方法,用于在事件发生时调用。举例:
class ClickObserver extends DomEventObserver {
constructor( view ) {
super( view );
this.domEventType = 'click';
}
onDomEvent( domEvt ) {
this.fire( 'click', domEvt, { foo: 1, bar: 2 } );
}
}
class MultiObserver extends DomEventObserver {
constructor( view ) {
super( view );
this.domEventType = [ 'evt1', 'evt2' ];
}
onDomEvent( domEvt ) {
this.fire( domEvt.type, domEvt );
}
}
class ClickCapturingObserver extends ClickObserver {
constructor( view ) {
super( view );
this.useCapture = true;
}
}
- 重写了observe方法:由于基类Observer混入DomEmitterMixin,获得了监听原生dom事件的能力,在此用于监听指定类型(通过domEventType指定)的事件,并执行回调(onDomEvent)。
- 重写了fire方法:在view document上调用fire方法,触发事件。
ClickObserver
很简单,看源码:
class ClickObserver extends DomEventObserver {
constructor( view ) {
super( view );
this.domEventType = 'click';
}
onDomEvent( domEvent ) {
this.fire( domEvent.type, domEvent );
}
}
3.10Writer
在 CKEditor 5 中,DowncastWriter 和 UpcastWriter 都是用于操作视图节点的写入器(writer),但它们的使用场景和功能有所不同。以下是它们的主要区别:
UpcastWriter
作用:用于处理 非语义化视图,即那些 从非语义化来源(如 HTML 字符串)创建的视图。这种视图并没有对应的模型数据,它通常是在视图中操作的原始数据。
使用场景:例如,用户粘贴了一个 HTML 内容片段,CKEditor 会使用 UpcastWriter 将这个 HTML 内容转换为视图节点。这个过程称为上升(Upcasting) ,即从非语义数据(例如 HTML)上升为视图表示。
主要功能:
创建视图节点。
操作这些视图节点以构建视图结构。
适用于从外部源(如 HTML 字符串、用户输入)创建视图结构。
例子:
const writer = new UpcastWriter(viewDocument);
const text = writer.createText('foo!');
writer.appendChild(text, someViewElement);
DowncastWriter
- 作用:用于处理 语义化视图,即那些 已从模型数据(例如文档模型)转换来的视图。这种视图是和编辑器的数据模型紧密关联的,它体现了编辑器中的内容和结构。
- 使用场景:当需要将 模型数据 转换为视图表示时,通常会使用 DowncastWriter。这个过程称为下升(Downcasting) ,即从数据模型转到视图结构。比如,当你修改了文档模型,DowncastWriter 会将这些变化反映到视图中。
- 主要功能:
- 修改视图结构。
- 更新视图中的节点。
- 与数据模型紧密协作,修改已绑定的视图节点。
- 适用于从模型(例如 CKEditor 的内部数据模型)更新视图结构。
例子1:
const writer = new DowncastWriter(viewDocument);
writer.setAttribute('bold', true, someViewElement);
例子2:
在第1章1.5节Conversion深入分析中,在“将元素转换为结构”部分,讲到将单个模型元素转换为一个更复杂的视图结构,该结构包含一个视图元素及其子元素,使用到 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()
] )
] );
}
} );
上述转换器将把所有 <myElement> 模型元素转换为如下结构:<div class="wrapper"><div class="inner-wrapper"><p>...</p></div></div>。其中view回调函数中使用到writer,即是downcastWriter。
区别总结
两者的根本区别在于处理的视图类型不同:UpcastWriter 处理的是非语义化视图(例如粘贴的 HTML 内容),而 DowncastWriter 处理的是语义化视图,通常用于与编辑器的模型数据同步。
3.11Matcher
在第1章 1.5节 Conversion深入分析中,为了将视图元素转换为相应的模型元素,通过使用 elementToElement() 方法注册转换器来实现:
editor.conversion
.for( 'upcast' )
.elementToElement( {
view: {
name: 'div',
classes: [ 'example' ]
},
model: 'example'
} );
其中elementToElement方法入参中的view对象,会作为Matcher的pattern入参生成Matcher对象。Matcher 用于 匹配视图节点(view nodes) 与给定的条件或模式进行比较。可以理解为 模式匹配器,用于检测视图节点是否符合某些规则或条件,通常用于 视图节点的转换(如:从模型到视图的转化或反向操作)或者 视图元素的处理。Matcher除了支持上例中的name和classes配置,还支持attributes和styles。
3.12TreeWalker
与Model中的TreeWalker类似,在此略。
3.13StylesProcessor
StylesProcessor负责写入和读取规范化的样式对象,在view中Document类有StylesProcessor入参。举例:
// toNormalizedForm举例
const styles = {};
stylesProcessor.toNormalizedForm( 'margin', '1px', styles );
// styles will consist: { margin: { top: '1px', right: '1px', bottom: '1px', left: '1px; } }
// getNormalized举例
const styles = {
margin: { top: '1px', right: '1px', bottom: '1px', left: '1px; },
background: { color: '#f00' }
};
stylesProcessor.getNormalized( 'background' ); // will return: { color: '#f00' }
stylesProcessor.getNormalized( 'margin-top' );// will return: '1px'
4.conversion
如对conversion不了解,需要先看下1.4Conversion和1.5Conversion深入分析。
| Conversion name | Description |
|---|---|
| Data upcasting | 将数据加载到编辑器中。首先,数据(例如 HTML 字符串)通过 DataProcessor 处理为一个视图 DocumentFragment。然后,这个视图文档片段被转换为模型文档片段(document fragment)。最后,模型文档的根节点被填充上这个内容。 |
| Data downcasting | 从编辑器中取回(Retrieve)数据。首先,模型根节点的内容被转换为视图文档片段。然后,这个视图文档片段通过数据处理器(data processor)处理为目标数据格式。 |
| Editing downcasting | 将编辑器内容渲染给用户进行编辑。这个过程在编辑器初始化期间一直进行。首先,当数据向上转换(data upcasting)完成后,模型的根节点会被转换为视图的根节点。之后,这个视图根节点会被渲染到用户在编辑器的 contentEditable DOM 元素中(也称为“可编辑元素”)。然后,每当模型发生变化时,这些变化会被转换为视图中的变化。最后,如果需要(如果 DOM 与视图不同),视图可以重新渲染到 DOM 中。 |
4.1事件传导过程
模型的更改(如插入操作)是如何传导至转换器,最终触发视图层相关节点的插入操作?
在 CKEditor 5 中,downcastDispatcher 是负责将模型中的变更(如插入、删除、属性修改等)转换为视图(DOM)表示的组件。insert 事件是其中一个事件,表示某些节点被插入到模型中。在这种情况下,downcastDispatcher 会触发 insert 事件,以便通知视图层相关节点的插入操作。我们来深入了解它是如何触发 insert 事件的。
编辑状态下:触发 insert 事件的过程
1. 模型中的插入操作:
当一个新元素被插入到 CKEditor 5 的模型中时,模型会发出一个变化通知。模型通常使用 applyOperation 方法来应用这些变化(比如插入新节点)。一旦模型的树发生变化,downcastDispatcher 会被激活。
当你调用 editor.model.insertContent(paragraph) 时,首先发生的是在 模型层 中插入一个新的节点 paragraph。具体来说,调用 insertContent 方法会在模型中将该节点插入到当前光标的位置。
editor.model.change(writer => {
const paragraph = writer.createElement('paragraph');
editor.model.insertContent(paragraph); // 将段落元素插入到模型中
});
这个方法内部涉及的操作包括:
- editor.model.change(writer => {...}) 使得 writer 对象在编辑器模型中执行操作。它封装了对模型的变更,使得这些变更可以记录和管理。
- writer.createElement('paragraph') 创建了一个新的模型元素 。
- editor.model.insertContent(paragraph) 将该元素插入到当前光标所在位置或指定的范围内。
在 CKEditor 5 中,模型的任何变化(如插入、删除等)都会转化为一个“操作(operation)”。这些操作会被添加到模型的操作队列中,然后通过 applyOperation 方法依次应用到模型上。当 editor.model.insertContent(paragraph) 被调用时,内部会通过 applyOperation 将插入操作应用到模型中,模型树会相应地发生变化。(2.11节的insertContent部分已详呈)
一旦操作被应用,model.document 会触发 change 事件。这是一个非常重要的事件,它是所有编辑器变化的“总入口”,无论是插入、删除还是属性修改都会触发该事件。
editor.model.document.on('change', () => {
// 这里会有一些与编辑器状态相关的操作
});
2. 模型变更被捕捉到和触发 insert 事件:
每次model的change或enqueueChange方法,都会依次触发_beforeChanges、document的change:data或change、_afterChanges事件。在editorcontroller中会监听这三个事件:
class EditingController {
constructor( model, stylesProcessor ) {
this.model = model;
this.view = new View( stylesProcessor );
this.mapper = new Mapper();
this.downcastDispatcher = new DowncastDispatcher( {
mapper: this.mapper,
schema: model.schema
} );
const doc = this.model.document;
this.listenTo( this.model, '_beforeChanges', () => {
this.view._disableRendering( true );
}, { priority: 'highest' } );
this.listenTo( this.model, '_afterChanges', () => {
this.view._disableRendering( false );
}, { priority: 'lowest' } );
this.listenTo( doc, 'change', () => {
this.view.change( writer => {
this.downcastDispatcher.convertChanges( doc.differ, markers, writer );
this.downcastDispatcher.convertSelection( selection, markers, writer );
} );
}, { priority: 'low' } );
第19行会监听模型document上的change或change:data事件。其回调方法中,通过view的change方法获得view的downcastwriter实例(即writer),传递到downcastDispatcher的convertChanges和convertSelection方法中。convertChanges方法会遍历document differ中缓存的changes信息,触发不同处理逻辑,例如如果是对模型中的节点进行插入(例如新节点或元素),changes信息就是insert类的信息,将会触发“insert:${ modelElementName }”(若modelElementName不存在,则触发insert:$text)事件(例如 insert:p、insert:li 等)。至于“remove”、“attribute”类的信息,也有各自的逻辑。
3. 事件监听器处理:
在 downcastDispatcher 上注册的事件监听器(各转换器会通过downcastDispatcher注册时间监听器)会捕获这个 insert 事件。监听器的作用是将模型的变更应用到视图中。具体来说,它会根据事件的内容(如 data.viewItem)在视图中插入相应的 DOM 节点。
例如,当插入
元素时,downcastDispatcher 会触发 insert:p 事件,事件处理器会在视图中插入对应的
元素。
示例:插入事件触发的过程
假设有如下代码,监听 insert:paragraph 事件,并在视图中插入一个
元素:
editor.data.downcastDispatcher.on( 'insert:paragraph', ( evt, data, conversionApi ) => {
// 获取插入的段落元素
const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
// 创建一个 <p> 元素
const viewElement = conversionApi.writer.createContainerElement( 'p' );
// 将新创建的 <p> 元素插入到视图中
conversionApi.writer.insert( viewPosition, viewElement );
});
在这段代码中,downcastDispatcher 监听 insert:paragraph 事件并执行回调。回调内的 conversionApi 用于将模型中的插入操作转换为视图中的 DOM 元素。
如何插入节点到模型中
当你在模型中执行插入操作时,CKEditor 会调用模型的 API(如 insert)来将新的节点插入到特定位置。这些操作会触发 downcastDispatcher,并发出相应的 insert 事件。通常,insert 操作是通过以下方式在模型中触发的:
editor.model.change( writer => {
const paragraph = writer.createElement( 'paragraph' );
editor.model.insertContent( paragraph );
});
上述代码会在模型中插入一个 paragraph 元素。随着插入操作的发生,downcastDispatcher 会触发 insert:paragraph 事件,通知视图层进行相应的更新。
总结
- downcastDispatcher 会在模型发生插入操作时触发 insert: 事件。
- 事件监听器 会捕获这些事件,并根据事件中的数据(如 data.viewItem)将模型内容转换成视图中的 DOM 元素。
- 这使得 CKEditor 5 能够将模型中的变更实时更新到视图中,实现双向绑定和同步。
通过 downcastDispatcher 机制,CKEditor 5 能够确保模型和视图之间的变更能够高效、精确地同步。
数据导出时:触发 insert 事件的过程
Editor对外提供了setData和getData方法,效果示例如下。
editor.setData( '<p>This is editor!</p>' );
editor.getData(); // -> '<p>This is editor!</p>'
在Editor中,生成并保存了DataController实例,editor的setData和getData方法本质是调用了该实例的set和get方法。以get方法为例,讲述事件是怎么传导的。
// ViewDowncastWriter是view下的DowncastWriter,详见3.10节
this._viewWriter = new ViewDowncastWriter( this.viewDocument );
this.downcastDispatcher = new DowncastDispatcher( {mapper: this.mapper, schema: model.schema} );
// modelRange是model document上的根元素
this.downcastDispatcher.convertInsert( modelRange, this._viewWriter );
DowncastDispatcher的convertInsert方法负责范围(range)插入的转换。对于范围中的每个节点,都会触发insert 事件。对于每个节点上的每个属性,也会触发attribute 事件。转换器响应相应事件,将model的changes转换成view中对应的,最后通过DataProcessor将view转换成html。
4.2 Conversion
Conversion是一个帮助将转换器(converters)添加到 upcast 和 downcast 调度器(dispatchers)的工具类。
我们建议首先阅读编辑器转换指南,以理解转换机制的核心概念。
Conversion的一个实例可以通过 editor.conversion 属性访问,默认情况下它有以下几组调度器(即转换的方向):
- downcast(编辑和数据下行转换)
- editingDowncast
- dataDowncast
- upcast
- 单向转换器
要将转换器添加到特定的组,使用 for() 方法:
// 将转换器添加到编辑下行转换和数据下行转换。
editor.conversion.for( 'downcast' ).elementToElement( config );
// 只将转换器添加到数据管道:
editor.conversion.for( 'dataDowncast' ).elementToElement( dataConversionConfig );
// 为编辑管道添加一个稍微不同的转换器:
editor.conversion.for( 'editingDowncast' ).elementToElement( editingConversionConfig );
基本概念
转换器:上例中的elementToElement方法就是一个转换器,负责将config中的view和data进行互转。
- elementToElement可以将 Model 的一个元素转换为 View 中的一个元素,反之亦然。例如,可以将 Model 中的 转换为 View 中的
。
- attributeToElement可以将 Model 中的一个属性转换为 View 中的一个元素。例如,可以将 Model 中的某个格式化属性(如加粗)映射到 View 中的一个
<strong>元素。
调度器:负责触发转换器并执行相应的转换操作,它监听 Model 或 View 中的变化,并将变化转交给相应的转换器来进行处理。其本质上是一个事件机制,当 Model 或 View 中的数据发生变化时,调度器会触发相关的转换器执行相应的转换。每当需要从 Model 到 View 或从 View 到 Model 执行转换时,调度器会根据预定义的转换规则调用相应的转换器。
helpers:上例中的for( 'downcast' )方法会返回DowncastHelpers,for( 'upcast' )方法会返回UpcastHelpers,这些helpers类封装了多个转换器,并将调度器注入到转换器内,使其具备监听事件并执行相应回调的能力。
入口
如下代码所示,在editor中提供了conversion访问入口,DataController实例内部生成了DowncastDispatcher和UpcastDispatcher实例,EditingController实例内部生成了DowncastDispatcher实例,三者提供给Conversion生成实例。
// ckeditor5-core/src/editor/editor.js
this.data = new DataController( this.model, stylesProcessor );
this.editing = new EditingController( this.model, stylesProcessor );
this.conversion = new Conversion( [ this.editing.downcastDispatcher, this.data.downcastDispatcher ], this.data.upcastDispatcher );
this.conversion.addAlias( 'dataDowncast', this.data.downcastDispatcher );
this.conversion.addAlias( 'editingDowncast', this.editing.downcastDispatcher );
另外,Conversion类提供了elementToElement、attributeToElement和attributeToAttribute三种转换器,本质上是封装了上行和下行转换器。例如elementToElement封装了for( 'downcast' )的elementToElement转换器和.for( 'upcast' ) 的elementToElement转换器,attributeToElement封装了for( 'downcast' )的attributeToElement转换器和for( 'upcast' ) 的elementToAttribute转换器,attributeToAttribute封装了for( 'downcast' )的attributeToAttribute转换器和for( 'upcast' )的attributeToAttribute转换器。关于转换器在后面详述。
4.3 ConversionHelpers与转换器
如上图所示,有2种conversion helpers类,Conversion在生成helper实例时会注入调度器。简化代码逻辑:
class SomeHelper {
constructor( dispatchers ) {
this._dispatchers = dispatchers;
}
add( conversionHelper ) {
for ( const dispatcher of this._dispatchers ) {
conversionHelper( dispatcher );
}
return this;
}
elementToElement( config ) {
return this.add( downcastElementToElement( config ) );
}
}
// Model element to view element conversion helper.
function downcastElementToElement( config ) {
// normalizeToElementConfig会将config.view转换成函数,该函数用于生成一个view元素。
config.view = normalizeToElementConfig( config.view, 'container' );
return dispatcher => {
dispatcher.on( 'insert:' + config.model, insertElement( config.view ), { priority: config.converterPriority || 'normal' } );
};
}
export function insertElement( elementCreator ) {
return ( evt, data, conversionApi ) => {
const viewElement = elementCreator( data.item, conversionApi );
const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
conversionApi.mapper.bindElements( data.item, viewElement );
conversionApi.writer.insert( viewPosition, viewElement );
};
}
// 使用示例:将 Model 中的 text 元素转化为 View中的 span 标签。
editor.conversion.for('downcast').elementToElement({
model: 'text',
view: 'span'
});
Converter(转换器)
downcast转换器
downcast转换器在downcasthelpers中定义了多个。
使用例子:
// 这次转换的结果是创建一个视图元素。
// 例如,模型中的 `<paragraph>Foo</paragraph>` 会转换为视图中的 `<p>Foo</p>`。
// 例子1
editor.conversion.for( 'downcast' ).elementToElement( {
model: 'paragraph',
view: 'p'
} );
// 例子2
editor.conversion.for( 'downcast' ).elementToElement( {
model: 'paragraph',
view: 'div',
converterPriority: 'high'
} );
// 例子3
editor.conversion.for( 'downcast' ).elementToElement( {
model: 'fancyParagraph',
view: {
name: 'p',
classes: 'fancy'
}
} );
// 例子4
editor.conversion.for( 'downcast' ).elementToElement( {
model: 'heading',
view: ( modelElement, conversionApi ) => {
const { writer } = conversionApi;
return writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) );
}
} );
以elementToElement为例进行阐述。
elementToElement( config ) {
return this.add( downcastElementToElement( config ) );
}
function downcastElementToElement( config ) {
config = cloneDeep( config );
config.view = normalizeToElementConfig( config.view, 'container' );
return dispatcher => {
dispatcher.on( 'insert:' + config.model,
insertElement( config.view ),
{ priority: config.converterPriority || 'normal' } // 回调(监听器)的优先级
);
};
}
downcastElementToElement方法是一个高阶函数,执行后返回一个入参为dispatcher的新函数,dispatcher入参通过add方法注入。在新函数中,往dispatcher注册了一个监听器insertElement,用于响应"insert: **"事件。
normalizeToElementConfig方法:接受 config.view,如果它是一个ElementDefinition,则将其转换为一个函数(因为更低级别的转换器仅接受元素创建函数)。
ElementDefinition举例:
const viewDefinition = {
name: 'span',
styles: {
'font-size': '12px',
'font-weight': 'bold'
},
attributes: {
'data-id': '123'
},
classes: [ 'foo', 'bar' ]
}
normalizeToElementConfig:
function normalizeToElementConfig( view, viewElementType ) {
if ( typeof view == 'function' ) {
// If `view` is already a function, don't do anything.
return view;
}
return ( modelData, conversionApi ) => createViewElementFromDefinition( view, conversionApi, viewElementType );
}
createViewElementFromDefinition的作用:
- view writer(conversionApi.writer)根据viewElementType,创建相应的View节点(如ContainerElement、AttributeElement、UIElement),并将attributes设置到该View节点;
- 遍历styles,view writer往View节点上设置style;
- 遍历classes,view writer往View节点上设置class。
insertElement:
// elementCreator即已经转换成函数的config.view,可以生成View节点
function insertElement( elementCreator ) {
return ( evt, data, conversionApi ) => {
const viewElement = elementCreator( data.item, conversionApi );
if ( !viewElement ) {
return;
}
// 如果已消费,直接返回
if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
return;
}
// 根据range的start获取在view中的插入位置
const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
// 设置model元素与view元素的绑定关系
conversionApi.mapper.bindElements( data.item, viewElement );
// 调用view writer,将view元素插入到指定为止
conversionApi.writer.insert( viewPosition, viewElement );
};
}
upcast转换器
与downcast类似,略。
4.4 Dispatcher(调度器)
DowncastDispatcher
ModelConsumable
管理模型项(model items)的可消费值(consumable values)列表。
可消费值是模型的不同方面。一个模型项可以被分解为若干个单独的属性,这些属性在转换该项时可能需要被考虑。
ModelConsumable 被 DowncastDispatcher 用来分析文档中变化的部分。添加、变化或删除的模型项被分解成单独的属性(即项本身和它的属性)。这些部分被保存到 ModelConsumable 中。然后,在转换过程中,当模型项的某个部分被转换时(例如视图元素已经被插入到视图中,但没有属性),该部分的可消费值会从 ModelConsumable 中移除。
对于模型项,ModelConsumable 存储以下几种类型的可消费值:insert、addattribute:<attributeKey>、changeattributes:<attributeKey>、removeattributes:<attributeKey>。
在大多数情况下,DowncastDispatcher 会自动收集可消费值,因此通常不需要直接使用 add 方法。然而,理解如何消费可消费值非常重要。
请记住,一个转换事件可能有多个回调(转换器)附加到它上面。每个回调都能转换模型的一个或多个部分。然而,当其中一个回调实际进行转换时,其他回调就不应该再进行转换,因为那样会重复结果。使用 ModelConsumable 有助于避免这种情况,因为回调只会转换那些还没有被消费的值。
在单个回调中消费多个值:
// 自定义 imageBlock 元素的转换器,可能包含一个 `caption` 元素,决定图像在视图中的显示方式:
// 模型:
// [imageBlock]
// └─ [caption]
// └─ foo
// 视图:
// <figure>
// ├─ <img />
// └─ <caption>
// └─ foo
modelConversionDispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => {
// 首先,消费 `imageBlock` 元素。
conversionApi.consumable.consume( data.item, 'insert' );
// 为视图创建普通的图像元素。可能稍后会进行“装饰”。
const viewImage = new ViewElement( 'img' );
const insertPosition = conversionApi.mapper.toViewPosition( data.range.start );
const viewWriter = conversionApi.writer;
// 检查 `imageBlock` 元素是否有子元素。
if ( data.item.childCount > 0 ) {
const modelCaption = data.item.getChild( 0 );
// 消费 `modelCaption` 插入变化。
// 其他转换器将不会再处理它,但它的子元素(可能是一些文本)仍然会被处理。
// 通过映射,文本的转换器会知道在哪里插入 `modelCaption` 的内容。
if ( conversionApi.consumable.consume( modelCaption, 'insert' ) ) {
const viewCaption = new ViewElement( 'figcaption' );
const viewImageHolder = new ViewElement( 'figure', null, [ viewImage, viewCaption ] );
conversionApi.mapper.bindElements( modelCaption, viewCaption );
conversionApi.mapper.bindElements( data.item, viewImageHolder );
viewWriter.insert( insertPosition, viewImageHolder );
}
} else {
conversionApi.mapper.bindElements( data.item, viewImage );
viewWriter.insert( insertPosition, viewImage );
}
evt.stop();
} );
解释:
- ModelConsumable:用于存储和管理模型项的可消费值(例如,插入、添加属性、变更属性、移除属性等)。它帮助管理哪些部分的模型项已经被转换和消费,避免重复转换。
- consumable.consume():标记某个模型项已经被消费,表示该项的某个属性或变化已经被处理过,不再进行重复处理。
- conversionApi.mapper 和 viewWriter:这两个对象用于执行模型与视图之间的映射和操作,确保模型项的正确转换和插入视图中。
- 事件的停止 (evt.stop()) :通过调用 stop() 方法,停止事件的进一步传播,避免其他处理程序再次处理同一个事件。
该代码示例展示了如何通过 ModelConsumable 管理模型项的变化,在一次转换中有效地消费多个部分,避免重复转换,并确保模型项正确地映射到视图中。
实现方式:
通过两级map的方式,对某元素的某操作进行标记、消费、判断等,例如针对'insert:imageBlock',两级map形似{imageBlock: {insert: true}};对于textProxy类型,还需要借助一个三级map,以'insert:$text'为例,假如文本节点startOffset为1,endOffset为3,parent为p元素,那么这个三级map形如{1: {3: {p元素: Symbol( 'textProxySymbol' )}}},以此Symbol( 'textProxySymbol' )为key,形成两级map形似{Symbol( 'textProxySymbol' ): {insert: true}}。如果执行消费,那么将true修改为false;新添加时,设置为true。
downcast调度器
editor.conversion
.for( 'downcast' )
.elementToElement( {
model: 'heading',
view: ( modelElement, { writer } ) => {
return writer.createContainerElement( 'h1' );
}
} );
downcast调度器是模型到视图转换的核心部分,这个过程会响应模型中的变化并触发一系列事件。监听这些事件的回调函数称为转换器(converters,如上例中的elementToElement)。转换器的作用是将模型中的变化转换为视图中的变化(例如,添加视图节点或更改视图元素的属性)。
在转换过程中,downcast调度器会根据模型的状态触发事件,并为这些事件准备数据。需要理解的是,这些事件与模型上的变化相关,例如“节点已插入”或“属性已更改”。这与upcast(视图到模型的转换)相反,后者是将视图状态(视图节点)转换为模型树。
这些事件的准备是基于Differ创建的差异(diff),该差异会将旧的模型状态和新的模型状态之间的变化缓冲并传递给downcast调度器。
需要注意的是,因为变化(changes)会被转换,所以必须有模型结构与视图结构之间的映射。在downcast过程中(模型到视图的转换),使用Mapper来映射位置和元素。
源码:
class DowncastDispatcher {
constructor( conversionApi ) {
/**
* An interface passed by the dispatcher to the event callbacks.
*/
this.conversionApi = Object.assign( { dispatcher: this }, conversionApi );
}
convertChanges( differ, markers, writer ) {
// 中间省略......
for ( const entry of differ.getChanges() ) {
if ( entry.type == 'insert' ) {
this.convertInsert( Range._createFromPositionAndShift( entry.position, entry.length ), writer );
} else if ( entry.type == 'remove' ) {
this.convertRemove( entry.position, entry.length, entry.name, writer );
} else {
// entry.type == 'attribute'.
this.convertAttribute( entry.range, entry.attributeKey, entry.attributeOldValue, entry.attributeNewValue, writer );
}
}
// 中间省略......marks相关的
}
// writer是view下的DowncastWriter
convertInsert( range, writer ) {
this.conversionApi.writer = writer;
this.conversionApi.consumable = this._createInsertConsumable( range );
for ( const value of range ) {
this._testAndFire( 'insert', {
item: value.item,
range: Range._createFromPositionAndShift( value.previousPosition, value.length )
} );
// 中间省略.....针对item的attributes逐个执行this._testAndFire( `attribute:${ key }`, data )
}
this._clearConversionApi();
}
// 中间省略......convertRemove、convertAttribute、convertSelection、convertMarkerAdd、convertMarkerRemove
// 创建消费品
_createInsertConsumable( range ) {
const consumable = new Consumable();
for ( const value of range ) {
const item = value.item;
// 初始化,标志位默认为true,表示还未消费
consumable.add( item, 'insert' );
for ( const key of item.getAttributeKeys() ) {
consumable.add( item, 'attribute:' + key );
}
}
return consumable;
}
// 中间省略......_createConsumableForRange、_createSelectionConsumable
// fire类似insert:**的事件,转换器中会响应该事件,执行model到view的具体转换
_testAndFire( type, data ) {
if ( !this.conversionApi.consumable.test( data.item, type ) ) {
// 如果已经消费过,不用fire事件
return;
}
const name = data.item.name || '$text';
// 类似insert:**
this.fire( type + ':' + name, data, this.conversionApi );
}
_clearConversionApi() {
delete this.conversionApi.writer;
delete this.conversionApi.consumable;
}
}
在editingController中生成DowncastDispacher实例:
this.downcastDispatcher = new DowncastDispatcher( {
mapper: this.mapper,
schema: model.schema
} );
Mapper:
负责将元素、位置和标记在视图和模型之间进行映射。用于编辑管道的 Mapper 实例可以在 editor.editing.mapper 中找到。Mapper 使用绑定的元素来查找对应的元素和位置,因此,为了获得正确的结果,所有模型元素都应该被绑定。
为了映射复杂的模型与视图之间的关系,您可以为 modelToViewPosition 事件和 viewToModelPosition 事件提供自定义回调,这些事件会在每次发生位置映射请求时被触发。这些事件是通过 toViewPosition 和 toModelPosition 方法触发的。Mapper 会添加其自身的默认回调,并且优先级为“最低”。若要覆盖默认的 Mapper 映射,可以添加具有更高优先级的自定义回调并停止事件的传播。
downcast调度器会在模型树变化时触发以下事件:
- insert:如果一段节点范围被插入到模型树中。
- remove:如果一段节点范围从模型树中被移除。
- attribute:如果模型节点的属性被添加、更改或移除。
对于insert和attribute事件,downcast调度器会生成可消费的(consumables)事件。这些事件用于控制哪些变化已经被消费。当某些转换器覆盖其他转换器或者同时转换多个变化(例如,同时转换元素的插入和该元素的属性)时,这非常有用。
此外,downcast调度器还会触发标记器(marker)变化的事件:
- addMarker:如果一个标记器被添加。
- removeMarker:如果一个标记器被移除。
注意,标记器的变化是通过从旧范围中移除标记器并将其添加到新范围来完成的,因此这两个事件都会被触发。
最后,downcast调度器还会处理与模型选择(model selection)的转换相关的事件:
- selection:将模型中的选择转换为视图中的选择。
- attribute:每个选择属性都会触发此事件。
- addMarker:每个包含选择的标记器都会触发此事件。
与模型树和标记器不同,选择(selection)相关的事件不会针对具体的变化触发,而是根据选择状态触发。
使用downcast调度器时的一些注意事项:
- 在为downcast调度器提供自定义监听器时,记得检查给定的变化是否已被消费。
- 在为downcast调度器提供自定义监听器时,请确保不要阻止事件。如果阻止了事件,最低优先级的默认转换器将无法触发该节点属性和子节点的转换。
- 在为downcast调度器提供自定义监听器时,记得使用提供的视图downcast写入器(view downcast writer)来将变化应用到视图文档中。
downcast调度器的自定义转换器示例:
// 你将“插入一个‘段落’模型元素”转换到视图中。
downcastDispatcher.on( 'insert:paragraph', ( evt, data, conversionApi ) => {
// 记得检查变化是否已经被消费,并消费该变化。
if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
return;
}
// 将模型中的位置转换为视图中的位置。
const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
// 创建一个<p>元素,它将被插入到视图中的`viewPosition`位置。
const viewElement = conversionApi.writer.createContainerElement( 'p' );
// 将新创建的视图元素与模型元素绑定,以便将来可以正确映射位置。
conversionApi.mapper.bindElements( data.item, viewElement );
// 将新创建的视图元素插入到视图中。
conversionApi.writer.insert( viewPosition, viewElement );
} );
关键点总结:
- downcast调度器:将模型的变化(例如节点插入、属性更改)转换为视图中的变化。
- 事件机制:通过事件触发和转换器回调,完成从模型到视图的转换。
- 转换器:负责处理具体的转换逻辑,如插入节点、改变属性、转换选区等。
- Mapper:用于在模型和视图之间进行位置和元素的映射。
editingDowncast和dataDowncast
在 CKEditor 5 中,conversion.for( 'editingDowncast' ) 和 conversion.for( 'dataDowncast' ) 都是用来定义模型到视图的转换(downcasting),但是它们的用途和执行时机有所不同。具体区别如下:
1. editingDowncast
editingDowncast 主要用于 编辑时的视图更新。它在用户与编辑器交互时触发,用于在编辑状态下将模型数据转换成视图。这个转换通常发生在 用户输入或修改内容 时,并且会实时更新视图,以便用户能够看到即时的编辑效果。
- 使用场景:当用户在编辑器中进行修改(例如输入文本、插入图片或修改格式)时,模型中的变化需要被实时转换为视图。
- 触发时机:每当模型层发生变化时,editingDowncast 会被触发,以便更新视图。
- 常见操作:例如,当用户在文本框中输入内容时,模型数据会被转换为 DOM 元素并显示在页面上;或者当用户执行插入、删除等操作时,视图会更新以反映这些变化。
示例:
conversion.for( 'editingDowncast' ).elementToElement( {
model: 'paragraph',
view: 'p'
} );
这里,当 paragraph 被插入到模型中时,editingDowncast 会将其转换为视图中的
元素,以便显示在编辑区域。
2. dataDowncast
dataDowncast 主要用于 数据导出或保存时的视图转换。它在将内容从编辑器导出为 HTML、JSON 或其他格式时使用。与 editingDowncast 不同,dataDowncast 只在 导出数据或将数据转存到外部格式时 执行,而不会实时响应用户的输入和编辑。
- 使用场景:当需要将编辑器中的内容导出为 HTML 或者其他数据格式时,dataDowncast 会将模型转换为最终保存或导出的视图格式。
- 触发时机:每当你从编辑器获取数据(例如通过 .getData())时,dataDowncast 会被触发。
- 常见操作:将模型数据转化为 HTML 或其他格式的视图,供导出、保存或发送到服务器。
示例:
conversion.for( 'dataDowncast' ).elementToElement( {
model: 'paragraph',
view: 'p'
} );
这里,当数据被导出(如通过 .getData())时,模型中的 paragraph 会被转换为视图中的
元素。这通常用于导出和持久化数据。
3. 关键区别
| 特性 | editingDowncast | dataDowncast |
|---|---|---|
| 用途 | 编辑状态下的视图转换 | 数据导出时的视图转换 |
| 触发时机 | 用户编辑时,实时转换模型到视图 | 数据导出或保存时,转换模型到视图 |
| 场景 | 编辑器的实时更新(如输入、插入、修改等) | 将编辑器内容导出为 HTML 或其他格式 |
| 频率 | 频繁触发(每次用户交互时) | 触发较少(通常在数据导出时触发) |
4. 总结
editingDowncast 用于在编辑过程中实时更新视图,通常涉及用户交互和编辑操作。
dataDowncast 用于在导出或保存时将模型转换为视图,通常涉及导出编辑器数据。
两者的目标是不同的,但都涉及将模型中的数据转换为视图,只是它们的应用场景和触发时机不同。
UpcastDispatcher
Upcast调度器是视图到模型转换的核心部分,它的作用是将给定的视图文档片段或视图元素转换为正确的模型结构。
在转换过程中,调度器会为被转换的视图文档片段中的所有视图节点触发事件。特殊的回调函数(称为“转换器”)会监听这些事件并处理视图节点的转换。
回调函数的第二个参数是一个包含以下属性的数据对象:
- data.viewItem:当前正在转换的视图节点或视图文档片段,回调函数可以处理该节点。
- data.modelRange:表示当前转换结果的范围(例如正在插入的元素),如果转换成功,它始终是一个Range对象。
- data.modelCursor:转换器应将新创建的项目插入到此位置。
回调函数的第三个参数是一个UpcastConversionApi实例,它为转换器提供了额外的工具。
事件驱动的转换器示例:
// 一个用于链接(<a>)的转换器。
editor.data.upcastDispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
if ( conversionApi.consumable.consume( data.viewItem, { name: true, attributes: [ 'href' ] } ) ) {
// <a> 元素是内联元素,在模型中由属性表示。
// 这就是为什么你只需要转换其子元素。
const { modelRange } = conversionApi.convertChildren( data.viewItem, data.modelCursor );
for ( let item of modelRange.getItems() ) {
if ( conversionApi.schema.checkAttribute( item, 'linkHref' ) ) {
conversionApi.writer.setAttribute( 'linkHref', data.viewItem.getAttribute( 'href' ), item );
}
}
}
} );
// 转换 <p> 元素的 font-size 样式。
// 注意:你应该使用低优先级的观察器,确保它在元素到元素的转换器之后执行。
editor.data.upcastDispatcher.on( 'element:p', ( evt, data, conversionApi ) => {
const { consumable, schema, writer } = conversionApi;
if ( !consumable.consume( data.viewItem, { style: 'font-size' } ) ) {
return;
}
const fontSize = data.viewItem.getStyle( 'font-size' );
// 不要向数据模型光标之后的模型元素进行处理,因为有可能一个视图元素被转换为多个模型元素。获取所有元素。
for ( const item of data.modelRange.getItems( { shallow: true } ) ) {
if ( schema.checkAttribute( item, 'fontSize' ) ) {
writer.setAttribute( 'fontSize', fontSize, item );
}
}
}, { priority: 'low' } );
// 将没有自定义转换器的所有元素转换为段落(自动段落化)。
editor.data.upcastDispatcher.on( 'element', ( evt, data, conversionApi ) => {
// 检查是否可以转换一个元素。
if ( !conversionApi.consumable.test( data.viewItem, { name: data.viewItem.name } ) ) {
// 如果元素已经被高优先级的转换器消费,则不做任何操作。
return;
}
const paragraph = conversionApi.writer.createElement( 'paragraph' );
// 尝试安全地将段落插入模型光标位置——它会为当前元素找到允许的父元素。
if ( !conversionApi.safeInsert( paragraph, data.modelCursor ) ) {
// 如果元素没有插入,意味着不能在此位置插入段落。
return;
}
// 消耗已插入的元素。
conversionApi.consumable.consume( data.viewItem, { name: data.viewItem.name } );
// 将子元素转换为段落。
const { modelRange } = conversionApi.convertChildren( data.viewItem, paragraph );
// 更新`modelRange`和`modelCursor`,作为转换结果。
conversionApi.updateConversionResult( paragraph, data );
}, { priority: 'low' } );
关键点:
upcastDispatcher是用于视图到模型转换的核心调度器。
通过监听不同的元素类型(如element:a、element:p等),可以执行自定义的转换逻辑。
每个转换器都可以使用consumable来检查元素是否已被消费,并使用conversionApi提供的工具来执行实际的转换操作。
convertChildren方法会将视图元素的子元素转换为模型结构,并返回一个modelRange,即转换结果。
ViewConsumable和ModelConsumable的异同
ViewConsumable 和 ModelConsumable 都是 CKEditor 中用于管理“已消费”状态的机制,它们通过处理模型和视图的变化来确保转换过程中各个部分不会被重复处理或转换。尽管它们的目标相似,但它们在用途、作用域和工作方式上有所不同。
1. ModelConsumable
- 作用范围: 管理模型部分的“已消费”状态,主要用于模型到视图的转换过程中。
- 主要用途: 在模型层(Model)中,ModelConsumable 主要处理模型项(如节点、属性等)的“已消费”状态。它追踪哪些模型部分已经被转换,并确保这些部分不会再次被转换。
- 使用场景: 当对模型进行增、删、改操作时,ModelConsumable 会记录每个变更(如插入、删除、属性更改等),并确保这些操作不会重复处理。
- 工作机制: 通过“已消费”标记来标识哪些模型元素已被处理,在后续的转换过程中,任何已被消费的元素不会被再次转换。
2. ViewConsumable
- 作用范围: 管理视图部分的“已消费”状态,主要用于视图到模型的转换过程中。
- 主要用途: 在视图层(View)中,ViewConsumable 主要处理视图元素(如节点、标记等)的“已消费”状态。它在视图到模型的转换中确保视图的部分不被重复转换,且能够按需触发视图到模型的更新。
- 使用场景: 当视图发生变化时,ViewConsumable 会帮助判断哪些视图元素已经被处理并且不会再次处理。这在视图和模型之间进行双向同步时非常重要。
- 工作机制: 通过“已消费”标记来标识哪些视图元素已经被处理,从而避免重复转换。
异同点对比:
总结:
ModelConsumable 和 ViewConsumable 主要的区别在于它们的作用域:前者专注于管理模型中的元素,而后者专注于视图中的元素。
它们的工作原理相似,都是通过标记哪些元素已经被“消费”,从而避免在后续的转换过程中重复处理这些元素。
它们都是在 CKEditor 的转换流程中扮演关键角色,通过精确控制模型和视图之间的变化,确保编辑器在处理变更时能够高效且不重复地进行转换。
设计模式
在 CKEditor 5 中,调度器(Dispatcher) 和 转换器(Converter) 是用于处理视图和模型之间数据流的核心组件,虽然它们的设计可能与某些设计模式相关,但它们本身并不严格属于某种单一的设计模式。不过,这两者的设计和使用方式确实借鉴了多个常见的设计模式,主要包括以下几种:
观察者模式
调度器(Dispatcher) 和 转换器(Converter) 的工作方式类似于观察者模式。在这个模式中,某个事件(如数据变更、插入、删除等)会被触发,多个“观察者”(即转换器)会响应这些事件并做出相应的处理。
例如,当模型中的内容发生变化时,调度器会触发相关的事件,注册到这些事件的转换器会根据需要转换模型数据为视图数据(或反之)。调度器和转换器之间的关系可以视为事件的发布与订阅。
典型的应用:
事件驱动:调度器触发事件,转换器订阅并响应。
松耦合:调度器不关心转换器的实现,只触发事件;转换器只关注如何处理相关的事件。
代码示例:
// Dispatcher触发事件
dispatcher.emit( 'element:insert', { /* event data */ });
// 转换器监听并处理
conversion.for( 'editingDowncast' ).add( viewToModelConverter );
责任链模式
在 CKEditor 中,事件的触发和处理涉及到一个责任链的概念。调度器会按顺序触发多个事件(如 insert:paragraph),而注册的多个转换器会依次处理这些事件。在处理事件时,如果一个转换器无法处理某些情况,它会将事件“传递”给下一个转换器。
这种设计允许多个转换器按顺序处理一个事件,每个转换器可以做特定的工作并决定是否需要继续传递给下一个转换器。
典型的应用:
- 顺序处理:每个转换器可能有不同的优先级,先注册的转换器优先处理。
- 灵活扩展:可以根据需求新增或调整转换器,而无需修改调度器本身。
某些情况下,转换器会标记事件为“已处理”或“已消费”,这意味着该事件将不会继续传递。
**代码示例**:
// 设置低优先级的转换器
elementToElement({ priority: 'low', ... })
// 等价于
conversion.for( 'editingDowncast' ).add( lowPriorityConverter({ priority: 'low', ... }));
// 设置高优先级的转换器
elementToElement({ priority: 'highest', ... })
// 等价于
conversion.for( 'editingDowncast' ).add( highPriorityConverter({ priority: 'normal', ... }));
// 转换器会标记事件为“已处理”或“已消费”,让该事件将不会继续传递
conversion.for( 'editingDowncast' ).add( dispatcher => dispatcher.on( 'insert:paragraph', ( evt, data, conversionApi ) => {
// 如果事件已经被消费,直接返回
if ( conversionApi.consumable.consume( data.item, 'insert' ) ) {
return;
}
// 否则继续执行转换逻辑
const viewElement = conversionApi.writer.createContainerElement( 'p' );
conversionApi.writer.insert(
conversionApi.mapper.toViewPosition( data.range.start ), viewElement
);
evt.stop()
}, { priority: 'highest' }));
如果高优先级的转换器处理了某个事件,它就会把事件“消耗掉”,其他转换器将不再处理。如果高优先级的转换器没有处理事件,事件会“传递”给中优先级的转换器继续处理。如果中优先级的转换器没有处理,最终低优先级的转换器会接手事件。通过调用 stop() 方法,停止事件的进一步传播,避免其他处理程序再次处理同一个事件。
策略模式
- 转换器 充当了策略模式中的策略角色,它们为模型到视图或视图到模型的转换提供不同的实现。当触发特定的事件时,调度器根据事件类型选择合适的转换器执行相应的策略。
- 每个转换器实现了不同的转换逻辑(比如将段落插入模型或视图),调度器选择合适的策略来完成转换操作,而不关心具体的实现细节。
典型的应用:
- 多种转换方式:不同的事件可以由不同的转换器来处理。
- 解耦:调度器不关心转换的具体方式,只负责调用适当的转换器。
代码示例:
// 注册具体的转换器(策略)
conversion.for( 'editingDowncast' ).add( paragraphConverter );
conversion.for( 'editingDowncast' ).add( imageConverter );
中介者模式
- 调度器 本质上也像是一个中介者(mediator),它在事件的触发者(比如模型中的数据变更)和事件的处理者(如转换器)之间起到中介作用。调度器负责协调事件的触发,并确保合适的转换器响应事件,从而将模型与视图分离开来。
- 中介者模式的目标是减少系统中组件之间的直接依赖,使得组件之间通过中介者来通信。
典型的应用:
- 解耦事件触发与处理:调度器作为事件的中介,简化了事件的处理逻辑。
- 集中管理:调度器集中管理事件的触发和事件监听的注册。
代码示例:
// 调度器触发事件
dispatcher.emit( 'insert:p', changeData );
// 转换器处理事件
conversion.for( 'dataDowncast' ).add( changeHandler );
5.Controller与Processor
5.1DataController
- 概念:DataController 负责在 Model 层和外部数据格式(如 HTML、纯文本等)之间进行转换。它是数据的输入和输出接口,管理文档内容的导入(将外部数据格式转换为 Model 结构)和导出(将 Model 结构转换为外部数据格式)。
- 职责:
- 从外部数据格式(如 HTML 或文本)解析数据,并将其导入到 Model 中。
- 将 Model 中的内容导出为外部数据格式,以便保存或展示。
- 控制文档的加载和保存过程。
源码分析
构造函数:
- 入参形式引入Model实例;
- 通过View Document类生成实例viewDocument,此处用到view Document,其目的是主要是用于toView方法生成view document fragment之用,与EditingController中的view Document是同一个类,但不是同一个实例;
- 以viewDocument为入参,通过View下的DowncastWriter类生成实例viewWriter;
- 通过UpcastDispatcher类生成实例upcastDispatcher,并监听“text”、“element”、“documentFragment”事件,为text和elements定义默认的转换器(如果元素没有默认的转换器,它将被跳过,例如
<b>foo</b>将不会被转换。因此,我们将 convertToModelFragment 作为最后一个转换器添加,以便将该元素的子元素转换为文档片段,这样,如果<b>没有对应的转换器,<b>foo</b>将会被转换为 foo。); - 通过DowncastDispatcher类生成实例downcastDispatcher,为文本插入变化创建一个默认的下行转换器,同时用于toView;
set(对应editor的setData) : 用modelWriter清除model根节点内容 --> 通过DataProcessor的toView将html字符串转换成view节点 --> 通过Processor的 toModel 将view节点转换成 model节点 --> modelWriter重新插入model节点。
get(对应editor的getData) :在自身toView方法内,通过view Writer和转换器将model节点转换为view节点 --> DataProcessor内部通过DomConverter的viewToDom方法将view节点转换成dom节点,再转换成html字符串。
5.2EditingController
- 概念:EditingController 负责处理用户的输入和交互,将用户的编辑行为转化为对 Model 的操作,并确保视图与 Model 保持同步。它是用户输入与文档模型之间的桥梁,并负责管理用户编辑过程中的选区、命令执行和实时反馈。
- 职责:
- 捕获用户输入(键盘、鼠标操作等)并将这些输入转化为对 Model 的修改。
- 处理编辑器中的命令(如加粗、斜体、删除文本等),并将命令操作应用于 Model。
- 维护光标和选区的状态,并确保它们与 Model 的结构保持一致。
- 确保视图层(用户可见的内容)根据 Model 的变化进行实时更新。
源码分析
构造函数:
- 入参形式引入Model实例,监听模型的_beforeChanges、_afterChanges事件;
- 通过View类生成实例view;
- 通过DowncastDispatcher类生成实例downcastDispatcher;
- 监听Model实例内部的model document的change事件,通过downcastDispatcher内部的转换器处理model document上的变化;
- 监听View实例内部的view document的selectionChange事件,将view下的selection转换成model下的selection。
- 监听downcastDispatcher的insert:$text、remove、selection事件,添加一些关于model、model selection的默认的低优先级转换器,如果有高优先级自定义转换器且主动消费,那么这些默认的低优先级转换器将得不到执行。
5.3data controller、editing controller、model、document的关系
四者共同协作,形成了一个强大且灵活的编辑系统,确保用户操作能够无缝地在文档中体现,并且文档可以与外部数据格式保持一致。
- 核心:Model 和 Document
Model 是文档内容的抽象表示,它管理文档的结构和内容。Document 是 Model 的具体实例,记录了当前文档的根节点和选区的状态。用户的编辑操作最终会对 Model 中的数据进行修改。
Document 与 Model 的关系:Document 是 Model 的一种实例化形式,专注于管理与用户编辑操作相关的状态,特别是光标和选区的管理。Model 则是对文档内容的通用表示。
- DataController :负责数据的输入和输出
与 Model 的关系:DataController 直接操作 Model,负责将外部数据(如 HTML)导入到 Model 中,或将 Model 中的内容导出为外部数据格式。它是 Model 与外部世界的数据接口,确保 Model 的内容可以从外部加载或导出。
与 Document 的关系:DataController 通过 Document 了解当前文档的状态,并将这些状态反映在数据的导入和导出中。例如,在导入外部数据时,它需要知道当前 Document 中的选区以决定插入位置。
- EditingController :管理用户交互与视图更新
与 Model 的关系:EditingController 监听用户的编辑操作(如输入文本、删除、应用格式等),并将这些操作转化为对 Model 的修改。所有的编辑操作最终都要反映在 Model 中。
与 Document 的关系:EditingController 通过 Document 来了解和管理当前的选区和光标位置。用户的输入操作(如文本编辑、移动光标)会通过 EditingController 更新 Document 的选区状态。
与 DataController 的关系:虽然 EditingController 主要处理用户交互,但它与 DataController 配合,在需要导入或导出数据时工作。例如,当编辑器加载一个新文档时,DataController 会将外部数据导入到 Model,而 EditingController 确保用户的视图和编辑体验与新的内容保持同步。
流程举例
- 用户输入文本:
- 用户通过键盘输入内容,EditingController 捕获到输入事件,并根据输入内容修改 Model 中的文档结构。
- Document 会更新当前的光标位置和选区状态,并触发相应的事件。
- 通过 EditingController,视图会立即反映 Model 的变化,展示用户输入的文本。
- 导入数据:
- 通过 DataController 导入 HTML 内容,它将外部数据解析为 Model 结构并插入到 Document 中。
- Document 会更新选区和光标,并确保导入的数据反映在当前文档状态中。
- EditingController 会确保导入的数据正确显示在用户界面中,并与用户当前的操作保持同步。
- 保存文档:
- 当需要保存文档时,DataController 会从 Model 中提取当前文档的内容,并将其转换为外部格式(如 HTML)。
- Document 确保当前的文档状态被正确导出,反映最新的内容和选区位置。
5.4DataProcessor
将模型数据转换为外部格式(例如 HTML)或将外部格式(例如 HTML)转换为模型数据,以及数据过滤与处理。
略。