以下基于ckeditor@22.0.0。
〇、CKEditor编辑器介绍
一、架构角度
二、整体分析代码
三、ckeditor5-core
四、ckeditor5-engine
五、ckeditor5-ui
六、ckeditor-utils
二、ckeditor5-engine
2.Model
概念:Model 是 CKEditor 的抽象文档层,定义了文档的结构和语义。它是编辑器用来维护文档内容的内部数据模型,使用树状结构来表示文档内容及其关系。f
职责:
维护文档的抽象结构,包括文本节点、元素节点、段落、列表等语义元素。
允许对文档进行操作(如插入、删除、修改内容等)。
提供撤销(undo)和重做(redo)操作。
2.1Document与DocumentFragment
Document
-
概念:Document 是 Model 的具体实例,表示当前正在编辑的文档。它管理着文档的根节点、选区(光标)和文本状态。Document 依赖于 Model,是 Model 中内容的实时表示。
-
职责:
-
表示当前编辑的文档的状态。
-
追踪文档的根节点、选区(Selection)和光标。
-
处理选区变化,并触发相关事件(如 selectionChange)。
-
通常,文档只包含一个根元素,因此可以通过调用 getRoot 方法来检索根元素,无需指定其名称:
js
model.document.getRoot(); // -> 返回主根元素
然而,文档也可能包含多个根元素------例如,当编辑器有多个可编辑区域(如标题和消息正文)时。
源码分析
如下所示,一个Document实例具有以下基本功能:
-
version(文档版本)的作用:从 0 开始,每次操作都会增加版本号。它用于确保操作在正确的version上应用。Operation实例中有版本号信息。
-
history的作用:通过History实例保存文档的历史。内部通过_operations数组保存Operations,Operation实例的版本号与该数组的下标一致;通过_undoPairs Map保存撤回操作和被撤回操作;通过_undoneOperations Set保存被撤回操作。
-
selection的作用:是DocumentSelection实例。
-
Differ的作用:它的作用是缓冲在模型文档上所做的更改,然后计算这些更改的差异。
-
roots的作用:保存根节点。
-
PostFixer的作用:执行某个特性(feature)操作可能会导致文档树状态不正确。回调函数用于在文档树发生变化后修正该状态。后修正器会在所有来自最外层变化块的更改应用后、但在触发 change 事件之前执行。如果一个后修正回调进行了更改,它应该返回 true。当这种情况发生时,所有后修正器会再次被触发,以检查是否需要在新的文档树状态下进行其他修复。
在model.js中的change 和enqueueChange 方法中会调用_handleChangeBlock,_handleChangeBlock会判断在document上是否有变动,如果有,那么调用 _callPostFixers方法,执行post-fixers。
js
// model/document.js
_callPostFixers( writer ) {
let wasFixed = false;
do {
// _postFixers保存了所有PostFixer回调函数
for ( const callback of this._postFixers ) {
this.selection.refresh();
wasFixed = callback( writer );
// 当callback返回为true时,挑出循环,while重新执行
if ( wasFixed ) {
break;
}
}
} while ( wasFixed );
}
作为参数,后修正回调接收一个与执行的变化块相关联的 writer 实例。通过这个实例,回调所做的所有更改将与原始更改一起添加到同一个批次(和撤销步骤)中。这使得后修正的更改对于用户是透明的。
一个后修正的例子是检查是否所有的数据都从编辑器中移除。如果是的话,回调应当插入一个空的段落,以确保编辑器永远不会为空:
js
document.registerPostFixer( writer => {
const changes = document.differ.getChanges();
// 检查更改是否导致编辑器中的根元素为空。
for ( const entry of changes ) {
if ( entry.type == 'remove' && entry.position.root.isEmpty ) {
writer.insertElement( 'paragraph', entry.position.root, 0 );
// 即使需要修复多个根元素,也可以提前返回。
// 所有后修正器会再次被触发,因此如果有更多的空根,它们也会被修复。
return true;
}
}
} );
-
监听model的applyOperation操作
-
将操作(operation)保存在differ中;
-
将操作保存在history中;
-
将version增加一位。
-
-
监听selection(DocumentSelection实例)的change事件,标记selection已经变更。
-
监听mode.markers的update事件,并将marker信息保存在differ中。
js
class Document {
constructor( model ) {
this.model = model;
this.version = 0;
this.history = new History( this ); // The document's history.
this.selection = new DocumentSelection( this ); // The selection in this document.
this.roots = new Collection( { idProperty: 'rootName' } );
this.differ = new Differ( model.markers ); // Its role is to buffer changes done on the model document and then calculate a diff of those changes.
this._postFixers = new Set();
// ......
this.listenTo( model, 'applyOperation', ( evt, args ) => {
}, { priority: /* highest、high or low*/ } );
this.listenTo( this.selection, 'change', () => {
//
} );
this.listenTo( model.markers, 'update', ( evt, marker, oldRange, newRange ) => {
} );
}
}
DocumentFragment
DocumentFragment 表示模型的一部分,该部分没有一个共同的根节点,但其顶级节点可以视为兄弟节点。换句话说,它是模型树的一个分离部分,没有根节点。
DocumentFragment 拥有自己的 MarkerCollection。通过插入函数,来自该集合的标记将被设置到模型的标记中。
2.2Schema
在本篇1.2中初步介绍了Schema,在这里接着讲解。
编辑器的 schema 可以通过 editor.model.schema 属性访问。它定义了允许的模型结构(即模型元素如何嵌套)、允许的属性(包括元素和文本节点的属性)以及其他特征(如内联与块级元素、外部操作的原子性等)。这些信息随后被编辑功能和编辑引擎用来决定如何处理模型,在哪里启用特性等。
定义 Schema 规则:Schema 规则可以通过 Schema#register() 或 Schema#extend() 方法来定义。前者用于为某个项目名称注册一个单一的定义,确保只有一个编辑功能可以引入该项目。类似地,extend() 只能用于已定义的项目。
校验元素和属性:通过 Schema#checkChild() 和 Schema#checkAttribute() 方法,特性会分别检查元素和属性的合法性。
-
Schema#checkChild():检查某个元素是否可以作为另一个元素的子元素。
-
Schema#checkAttribute():检查某个元素是否允许拥有指定的属性。
定义允许的结构
当一个功能引入模型元素时,它应该在 schema 中注册该元素。除了定义该元素可以存在于模型中之外,功能还需要定义该元素可以放置的位置。这些信息通过 SchemaItemDefinition 中的 allowIn 属性提供:
js
schema.register( 'myElement', {
allowIn: '$root'
} );
这让 schema 知道 <myElement>
可以作为 <$root>
的子元素。$root
元素是编辑框架定义的通用节点之一。默认情况下,编辑器将主根元素命名为 <$root>
,因此上述定义允许在主编辑器元素中使用 <myElement>
。
换句话说,这样的结构是正确的:
html
<$root>
<myElement></myElement>
</$root>
而这样的结构是不正确的:
html
<$root>
<foo>
<myElement></myElement>
</foo>
</$root>
要声明哪些节点可以放置在注册的元素内,可以使用 allowChildren 属性:
js
schema.register( 'myElement', {
allowIn: '$root',
allowChildren: '$text'
} );
这允许以下结构:
html
<$root>
<myElement>
foobar
</myElement>
</$root>
allowIn 和 allowChildren 属性也可以从其他 SchemaItemDefinition 项继承。
禁止某些结构
除了允许某些结构外,schema 还可以用于确保某些结构是明确禁止的。这可以通过使用 disallow 规则来实现。
通常,您会使用 disallowChildren 属性来禁止某些元素在指定元素内出现。举个例子:
js
schema.register( 'myElement', {
inheritAllFrom: '$block',
disallowChildren: 'imageInline'
} );
在上面的示例中,定义了一个新的自定义元素 myElement,它应该像任何块级元素(段落、标题等)一样行为,但不允许在其中插入行内图片(imageInline)。
allow 规则与 disallow 规则的优先级
一般来说,所有的 disallow 规则的优先级要高于其对应的 allow 规则。当涉及到继承时,规则的优先级层级如下(从最高优先级到最低):
-
元素自身定义中的 disallowChildren / disallowIn。
-
元素自身定义中的 allowChildren / allowIn。
-
从继承的元素定义中继承的 disallowChildren / disallowIn。
-
从继承的元素定义中继承的 allowChildren / allowIn。
禁止规则的示例
虽然禁止某些元素的情况在简单情况下容易理解,但当涉及到复杂的规则时,可能会变得不太清楚。以下是一些示例,展示在规则继承的情况下如何使用禁止规则。
示例 1:使用 disallowChildren 禁止子元素
js
schema.register( 'baseChild' );
schema.register( 'baseParent', { allowChildren: [ 'baseChild' ] } );
schema.register( 'extendedChild', { inheritAllFrom: 'baseChild' } );
schema.register( 'extendedParent', { inheritAllFrom: 'baseParent', disallowChildren: [ 'baseChild' ] } );
在这个例子中,extendedChild 会被允许出现在 baseParent(因为继承了 baseChild)和 extendedParent(因为继承了 baseParent)中。然而,baseChild 只会在 baseParent 中被允许。尽管 extendedParent 继承了 baseParent 中的所有规则,但它专门禁止了 baseChild。
示例 2:使用 disallowIn 禁止元素出现
js
schema.register( 'baseParent' );
schema.register( 'baseChild', { allowIn: 'baseParent' } );
schema.register( 'extendedParent', { inheritAllFrom: 'baseParent' } );
schema.register( 'extendedChild', { inheritAllFrom: 'baseChild' } );
schema.extend( 'baseChild', { disallowIn: 'extendedParent' } );
在这个例子中,baseChild 会被允许在 baseParent 中出现。接着,extendedParent 继承了 baseParent 的规则,extendedChild 也会继承 baseChild。通过 disallowIn 规则,baseChild 和 extendedChild 都不再被允许在 extendedParent 中出现。
示例 3:混合使用 allowIn 和 disallowChildren
您还可以混合使用 allowIn 和 disallowChildren,以及 allowChildren 和 disallowIn 来定义复杂的结构规则。
示例 4:继承并重新允许元素
有时您可能会遇到这种情况:您继承自一个已经被禁止的元素,但新的元素应该重新允许该结构。在这种情况下,定义可以如下所示:
js
schema.register( 'baseParent', { inheritAllFrom: 'paragraph', disallowChildren: [ 'imageInline' ] } );
schema.register( 'extendedParent', { inheritAllFrom: 'baseParent', allowChildren: [ 'imageInline' ] } );
在这个例子中,imageInline 元素允许出现在 paragraph 元素中,但在 baseParent 中不允许。然而,在 extendedParent 中,imageInline 元素又被重新允许,因为新元素的定义比继承的定义更具优先权。
通过定义 disallow 规则,您可以精确控制在 CKEditor 中哪些元素或结构是被禁止的,并通过继承机制对这些规则进行灵活管理。这让您能够实现更复杂的结构约束,从而帮助您为编辑器中的内容定义和验证规则。
定义附加语义
除了设置允许的结构外,schema 还可以定义模型元素的其他特性。通过使用 is* 属性,功能开发者可以声明某个元素应如何被其他功能以及引擎处理。
以下是列出各种模型元素及其在 schema 中注册的属性的表格:
Schema entry | Properties in the definition | |||||
---|---|---|---|---|---|---|
isBlock | isLimit | isObject | isInline | isSelectable | isContent | |
$block | true | false | false | false | false | false |
$container | false | false | false | false | false | false |
$blockObject | true | true[1] | true | false | true[2] | true[3] |
$inlineObject | false | true[1] | true | true | true[2] | true[3] |
$clipboardHolder | false | true | false | false | false | false |
$documentFragment | false | true | false | false | false | false |
$marker | false | false | false | false | false | false |
$root | false | true | false | false | false | false |
$text | false | false | false | true | false | true |
blockQuote | false | false | false | false | false | false |
caption | false | true | false | false | false | false |
codeBlock | true | false | false | false | false | false |
heading1 | true | false | false | false | false | false |
heading2 | true | false | false | false | false | false |
heading3 | true | false | false | false | false | false |
horizontalLine | true | true[1] | true | false | true[2] | true[3] |
imageBlock | true | true[1] | true | false | true[2] | true[3] |
imageInline | false | true[1] | true | true | true[2] | true[3] |
listItem | true | false | false | false | false | false |
media | true | true[1] | true | false | true[2] | true[3] |
pageBreak | true | true[1] | true | false | true[2] | true[3] |
paragraph | true | false | false | false | false | false |
softBreak | false | false | false | true | false | false |
table | true | true[1] | true | false | true[2] | true[3] |
tableRow | false | true | false | false | false | false |
tableCell | false | true | false | false | true | false |
解释
- isBlock:表示该项是否像段落一样。一般来说,内容通常由块级元素组成,如段落、列表项、图片、标题等。
js
schema.isBlock( 'paragraph' ); // -> true
schema.isBlock( '$root' ); // -> false
const paragraphElement = writer.createElement( 'paragraph' );
schema.isBlock( paragraphElement ); // -> true
- isLimit:可以理解为该元素是否不应被 Enter 键分割。限制元素的示例包括:$root、表格单元格、图片标题等。换句话说,所有发生在限制元素内的操作都仅限于其内容。此外,所有对象也被视为限制元素。
js
schema.isLimit( 'paragraph' ); // -> false
schema.isLimit( '$root' ); // -> true
schema.isLimit( editor.model.document.getRoot() ); // -> true
schema.isLimit( 'image' ); // -> true
- isObject:表示该项是否"自包含",并应被视为一个整体。对象元素的示例包括:imageBlock、表格、视频等。对象本身也是限制元素,因此 isLimit() 对对象元素会自动返回 true。
js
schema.isObject( 'paragraph' ); // -> false
schema.isObject( 'image' ); // -> true
const imageElement = writer.createElement( 'image' );
schema.isObject( imageElement ); // -> true
- isInline:表示该项是否"文本般",应被视为内联节点。内联元素的示例包括:$text、软换行(
)等。
js
schema.isInline( 'paragraph' ); // -> false
schema.isInline( 'softBreak' ); // -> true
const text = writer.createText( 'foo' );
schema.isInline( text ); // -> true
- isSelectable:用户可以作为整体选择的元素(例如,复制整个元素或应用格式化操作的元素)会在模式(schema)中标记为 isSelectable 属性。
js
schema.isSelectable( 'paragraph' ); // -> false
schema.isSelectable( 'heading1' ); // -> false
schema.isSelectable( 'image' ); // -> true
schema.isSelectable( 'tableCell' ); // -> true
const text = writer.createText( 'foo' );
schema.isSelectable( text ); // -> false
- isContent:像图片或媒体这样的元素总是会进入编辑器的数据中,这就是它们成为内容元素的原因。与此同时,像段落、列表项或标题这样的元素不是内容元素,因为当它们为空时,它们会在编辑器输出中被跳过。从数据的角度来看,除非它们包含其他内容元素,否则它们是透明的(一个空的段落和没有段落是一样的)。
js
schema.isContent( 'paragraph' ); // -> false
schema.isContent( 'heading1' ); // -> false
schema.isContent( 'image' ); // -> true
schema.isContent( 'horizontalLine' ); // -> true
const text = writer.createText( 'foo' );
schema.isContent( text ); // -> true
特殊标注
- [1] 对于这个元素,isLimit 为 true,因为所有对象元素都是自动限制元素。
- [2] 对于这个元素,isSelectable 为 true,因为所有对象元素都是自动可选择元素。
- [3] 对于这个元素,isContent 为 true,因为所有对象元素都是自动内容元素。
通用项
js
//https://github.com/ckeditor/ckeditor5/blob/v20.0.0/packages/ckeditor5-engine/src/model/model.js
this.schema.register( '$root', {
isLimit: true
} );
this.schema.register( '$block', {
allowIn: '$root',
isBlock: true
} );
this.schema.register( '$text', {
allowIn: '$block',
isInline: true,
isContent: true
} );
this.schema.register( '$clipboardHolder', {
allowContentOf: '$root',
isLimit: true
} );
this.schema.extend( '$text', { allowIn: '$clipboardHolder' } );
this.schema.register( '$marker' );
2.3Node
Node基类
模型节点是模型树的最基本结构。这是一个抽象类,是表示模型中不同节点的其他类的基础。
需要注意:如果一个节点从模型树中分离,你可以使用它的 API 对其进行操作。然而,非常重要的一点是,已经附加到模型树中的节点只能通过 Writer API 进行修改。
通过 Node 方法(如 _insertChild 或 _setAttribute)所做的更改不会生成操作(operations,操作对于修改文档根节点中的节点时,编辑器的正常工作至关重要)。
操作 Node(以及继承自它的类)的流程如下:
-
创建一个 Node 实例,可以使用它的 API 进行修改。
-
使用 Batch API 将 Node 添加到模型中。
-
使用 Batch API 修改已经添加到模型中的 Node。
同样,你不能在没有被添加到模型树中的节点上使用 Batch API,唯一的例外是将该节点插入到模型树中。
需要注意的是,使用 Batch API 的 remove 方法不会允许使用 Node API,因为 Node 的信息仍然保留在模型文档中。
在元素节点(element node)的情况下,添加和移除子节点也被视为更改节点,并遵循相同的规则。
js
class Node {
constructor( attrs ) {
this.parent = null;
this._attrs = toMap( attrs );
}
}
Text类
Text类继承自Node基类,Text 实例可能会在模型更改时会间接地从模型树中移除。这发生在使用模型写入器(model writer)修改模型时,当一个文本节点与另一个文本节点合并时,两个文本节点都会被移除,并且会插入一个新的文本节点。由于这种行为,建议不要保留对 Text 节点的引用。相反,建议在文本节点之前创建一个活跃的位置(live position)。
js
class Text extends Node {
constructor( data, attrs ) {
super( attrs );
/**
* Text data contained in this text node.
*/
this._data = data || '';
}
}
Element类
Element类继承自Node基类。添加了name(名称)、child nodes,以及与children相关的一系列方法。
js
class Element extends Node {
constructor( name, attrs, children ) {
super( attrs );
/**
* Element name.
*/
this.name = name;
/**
* List of children nodes.
* @member {module:engine/model/nodelist~NodeList} module:engine/model/element~Element#_children
*/
this._children = new NodeList();
if ( children ) {
this._insertChild( 0, children );
}
}
}
TextProxy类
TextProxy 代表文本节点的一部分。
由于位置可以放置在文本节点的字符之间,范围(range)可能只包含文本节点的部分内容。当获取包含在此类范围中的项时,我们需要表示该文本节点的一部分,因为返回整个文本节点是不正确的。TextProxy 解决了这个问题。
TextProxy 的 API 类似于 Text,并允许执行大多数对模型节点进行的常见任务。需注意:
-
一些 TextProxy 实例可能代表整个文本节点,而不仅仅是它的一部分。请参考 isPartial 属性。
-
TextProxy 不是节点的实例。在将其用作方法参数时,请牢记这一点。
-
TextProxy 是只读接口。如果要对由 TextProxy 表示的模型数据进行更改,请使用模型写入器 API。
-
TextProxy 实例是动态创建的,基于当前模型的状态。因此,强烈不建议存储对 TextProxy 实例的引用。TextProxy 实例不会在模型更改时自动刷新,因此可能会失效。相反,建议创建一个活跃的位置(live position)。
TextProxy 实例由模型树遍历器(model tree walker)创建,通常不需要自己创建该类的实例。
js
class TextProxy {
constructor( textNode, offsetInText, length ) {
this.textNode = textNode;
this.data = textNode.data.substring( offsetInText, offsetInText + length );
this.offsetInText = offsetInText;
}
}
RootElement
模型树根节点的元素类型,继承了Element。
js
class RootElement extends Element {
constructor( document, name, rootName = 'main' ) {
super( name );
/**
* Document that is an owner of this root.
*/
this._document = document;
/**
* Unique root name used to identify this root element by Document.
* @readonly
*/
this.rootName = rootName;
}
get document() {
return this._document;
}
}
2.4Position与LivePosition
Position
位置(Position)表示模型树中的位置。位置由其根节点(root)和在该根节点中的路径(path)表示。可以通过构造函数或 Model 和 Writer 的 createPosition() 工厂方法来创建位置实例。
注意:位置是基于偏移量的,而不是索引。这意味着一个位于两个文本节点 foo 和 bar 之间的位置,其偏移量为 3,而不是 1。更多信息请参见路径。由于模型中的位置由位置根节点和路径表示,因此可以创建位于不存在地方的位置信息。这一要求对于操作变换算法非常重要。
源码(入参说明)
js
class Position {
constructor( root, path, stickiness = 'toNone' ) {
if ( !root.is( 'element' ) && !root.is( 'documentFragment' ) ) {
/**
* Position root is invalid.
* Positions can only be anchored in elements or document fragments.
*/
throw new CKEditorError(
'model-position-root-invalid',
root
);
}
// Normalize the root and path when element (not root) is passed.
if ( root.is( 'rootElement' ) ) {
path = path.slice();
} else {
path = [ ...root.getPath(), ...path ];
root = root.root;
}
/**
* Root of the position path.
*/
this.root = root;
this.path = path;
this.stickiness = stickiness;
}
}
stickness
Position 中的 Stickiness(粘附性)表示一个位置如何与相邻节点(特别是其前后的节点)绑定或"粘附"。它用于描述在某些情况下(如模型变化时,位置需要转移或重新计算时),如何处理位置的变动,编辑器就能确保操作后,文本或内容的选择和光标位置仍然保持一致且符合预期。有如下三种值:
toNone:位置与邻接的节点没有"粘附"关系,也就是说,位置不会自动跳到下一个节点或上一个节点。当模型结构发生变化时,位置将保持不变。
toNext:位置会"粘附"到紧跟其后的节点上。当模型发生变化(例如,删除、合并等)时,如果原位置所在的节点被移除或合并,位置将自动转移到该位置之后的节点。
toPrevious:位置会"粘附"到紧挨其前的节点上。当模型发生变化时,如果原位置所在的节点被移除或合并,位置将自动转移到该位置之前的节点。
举例说明:
less
Insert. Position is at | and nodes are inserted at the same position, marked as ^:
- sticks to none: <p>f^|oo</p> -> <p>fbar|oo</p>
- sticks to next node: <p>f^|oo</p> -> <p>fbar|oo</p>
- sticks to previous node: <p>f|^oo</p> -> <p>f|baroo</p>
Move. Position is at | and range [oo] is moved to position ^:
- sticks to none: <p>f|[oo]</p><p>b^ar</p> -> <p>f|</p><p>booar</p>
- sticks to none: <p>f[oo]|</p><p>b^ar</p> -> <p>f|</p><p>booar</p>
- sticks to next node: <p>f|[oo]</p><p>b^ar</p> -> <p>f</p><p>b|ooar</p>
- sticks to next node: <p>f[oo]|</p><p>b^ar</p> -> <p>f|</p><p>booar</p>
- sticks to previous node: <p>f|[oo]</p><p>b^ar</p> -> <p>f|</p><p>booar</p>
- sticks to previous node: <p>f[oo]|</p><p>b^ar</p> -> <p>f</p><p>boo|ar</p>
path
节点在树中的位置。路径包含的是偏移量,而不是索引。
Position 可以放置在节点之前、之后或节点内(如果该节点的 offsetSize 大于 1)。路径中的每个项表示位置祖先节点的起始偏移量,从直接的根子节点开始,一直到位置所在父节点的偏移量。
foo 和 bar 代表文本节点(textNode)。由于文本节点的偏移大小大于 1,因此你可以将位置的偏移量放置在它们的开始和结束之间:
html
ROOT
|- P
|- UL
|- LI
| |- f^o|o ^ has path: [ 1, 0, 1 ] | has path: [ 1, 0, 2 ]
|- LI
|- b^a|r ^ has path: [ 1, 1, 1 ] | has path: [ 1, 1, 2 ]
LivePosition
LivePosition 是一种Position ,它会随着文档通过操作的变化而自动更新,它适用于书签、动态选择、跟踪元素位置等功能。与 Position 相反,LivePosition 仅在根节点为 RootElement 时有效。如果传递的是 DocumentFragment ,将会抛出错误。在处理 LivePosition 时要非常小心。每个 LivePosition 实例都会绑定事件,这些事件可能需要解除绑定。每当不再需要 LivePosition 时,请使用 detach 方法,防止内存泄漏。
示例:
示例 1: 创建并使用 LivePosition
假设我们在编辑器中插入文本时需要创建一个书签,LivePosition 可以帮助我们记住书签的位置,并在文档变更时自动更新其位置。
js
// 获取文档的根元素
const root = editor.model.document.getRoot();
// 在某个位置插入文本
const position = editor.model.createPositionAt(root, 5); // 在根元素的第5个位置
const livePosition = editor.model.createLivePositionAt(position);
// 插入一个新节点,这时 LivePosition 会自动调整到新的位置
editor.model.change(writer => {
const paragraph = writer.createElement('paragraph');
writer.insert(paragraph, position);
});
// livePosition 会根据插入的变化自动调整位置,确保它始终指向正确的位置
```
在这个示例中,LivePosition 绑定到一个特定的位置,并且在插入操作发生时自动更新。
```js
**示例 2: 绑定 LivePosition 到选区**
假设我们需要创建一个持续跟踪选区的功能,LivePosition 可以用于保持选区的状态,即使文档发生变化。
// 获取当前文档的选区
const selection = editor.model.document.selection;
// 获取当前选区的起始位置
const position = selection.getFirstPosition();
// 创建一个 LivePosition 来跟踪这个位置
const livePosition = editor.model.createLivePositionAt(position);
// 执行一些编辑操作
editor.model.change(writer => {
// 插入一些内容
writer.insertText('Some text inserted here', position);
});
// LivePosition 会确保它在文档操作后始终保持对正确位置的引用
这里,LivePosition 被用来跟踪选区的位置,随着文本的插入,它会调整到新插入内容的位置。
示例 3: LivePosition 与根元素
LivePosition 只能在 RootElement 中使用。如果你试图在 DocumentFragment 中使用 LivePosition,会抛出错误。
js
const root = editor.model.document.getRoot();
const position = editor.model.createPositionAt(root, 0);
// 创建一个 LivePosition 实例
const livePosition = editor.model.createLivePositionAt(position);
// 尝试在 DocumentFragment 中创建 LivePosition 会抛出错误
const fragment = editor.model.createDocumentFragment();
try {
const invalidLivePosition = editor.model.createLivePositionAt(fragment, 0); // 报错
} catch (error) {
console.error('Cannot create LivePosition in DocumentFragment:', error);
}
在这个示例中,我们尝试在 DocumentFragment 中创建 LivePosition,但由于 LivePosition 只适用于 RootElement,因此会抛出错误。
2.5Range与LiveRange
Range
Range 是 CKEditor5 中用于定义和操作文档中区域的核心类之一, 是一个非常重要的概念,表示一个"区间"或"范围",它定义了文档中两个 Position 之间的内容区块。
Range 对于文本选择、插入内容、删除内容等编辑操作至关重要。。它允许你:
-
创建并操作从一个位置到另一个位置的区间。
-
判断范围是否为选区或光标。
-
在范围内插入、删除或移动内容。
-
修剪空白区域。
-
获取范围的公共祖先。
Range 的概念
Range 是由两个 Position 实例(起始位置 和结束位置)组成的。它表示从一个位置到另一个位置之间的范围。
-
起始位置(start):范围的起点。
-
结束位置(end):范围的终点。
Range 在 CKEditor5 中主要用于定义选择、插入、删除等操作的区域。例如,用户选择的文本、光标所在的文本区域,或者你用来操作文档内容的范围,都可以用 Range 来表示。
js
class Range {
constructor( start, end = null ) {
/**
* Start position.
*/
this.start = Position._createAt( start );
/**
* End position.
*/
this.end = end ? Position._createAt( end ) : Position._createAt( start );
// If the range is collapsed, treat in a similar way as a position and set its boundaries stickiness to 'toNone'.
// In other case, make the boundaries stick to the "inside" of the range.
this.start.stickiness = this.isCollapsed ? 'toNone' : 'toNext';
this.end.stickiness = this.isCollapsed ? 'toNone' : 'toPrevious';
}
}
创建 Range
Range 的创建需要两个 Position,分别指定起始位置和结束位置。你可以通过 Model 或 Writer 来创建 Range。
js
const model = editor.model;
const root = model.document.getRoot();
const positionStart = model.createPositionAt(root, 5); // 在根元素的第5个位置
const positionEnd = model.createPositionAt(root, 10); // 在根元素的第10个位置
// 创建一个从 positionStart 到 positionEnd 的 Range
const range = model.createRange(positionStart, positionEnd);
Range 的常见用法
- 获取当前选区的 Range
CKEditor5 提供了一个方法来获取当前文档中选择的范围。通常,你可以通过 editor.model.document.selection.getFirstRange() 来获取当前选区的第一个 Range。
js
const selection = editor.model.document.selection;
const range = selection.getFirstRange();
console.log('Range start:', range.start);
console.log('Range end:', range.end);
- 插入文本到 Range 位置
你可以使用 Range 在特定位置插入文本。下面的例子演示了如何在选区结束的位置插入文本:
js
editor.model.change(writer => {
const range = editor.model.document.selection.getFirstRange();
writer.insertText('Hello, CKEditor5!', range.end); // 插入文本
});
在这里,writer.insertText('Hello, CKEditor5!', range.end) 会在选区的结束位置插入文本。
- 删除 Range 中的内容
你还可以使用 Range 删除选区中的内容:
js
editor.model.change(writer => {
const range = editor.model.document.selection.getFirstRange();
writer.remove(range); // 删除选区内的内容
});
- 获取公共祖先节点
Range 提供了 getCommonAncestor() 方法来获取范围内所有元素的公共祖先。这在处理复杂的嵌套结构时非常有用。
js
const range = editor.model.document.selection.getFirstRange();
const commonAncestor = range.getCommonAncestor();
console.log('Common ancestor:', commonAncestor);
- 检查 Range 是否包含某个 Position
你可以检查某个 Position 是否在某个 Range 范围内。例如:
js
const range = editor.model.document.selection.getFirstRange();
const position = editor.model.createPositionAt(root, 7);
console.log('Is position inside range?', range.includesPosition(position)); // true 或 false
LiveRange
LiveRange 是一种特殊类型的 Range,它会随着文档通过操作的变化而自动更新。LiveRange 可用于实现书签功能或其他需要动态更新的场景。
LiveRange 主要的特点是它能够随着文档操作的变化而自我更新。这使得它非常适合用来做书签等需要动态跟踪的功能。例如,如果你在某个位置创建了一个 LiveRange,当文档的内容发生改变时,LiveRange 会自动调整自己的位置。
然而,由于它会绑定事件来实现这种自动更新,因此在不再使用 LiveRange 时需要小心管理其生命周期,确保解绑相关的事件,防止内存泄漏或不必要的性能开销。
举例:
1.创建一个 LiveRange:假设我们在编辑器中创建一个 LiveRange,它从某个文本的开始位置到某个结束位置:
js
// 获取编辑器的模型
const model = editor.model;
// 获取开始和结束位置
const startPosition = model.createPositionFromPath(root, [0, 0]); // 文档的起始位置
const endPosition = model.createPositionFromPath(root, [0, 10]); // 文档中的某个位置
// 创建一个 LiveRange
const liveRange = new editor.model.LiveRange(startPosition, endPosition);
2.更新范围:如果文档发生变化,例如插入文本或删除内容,LiveRange 会自动调整自身的位置。例如,假设在 startPosition 和 endPosition 中间插入了新的文本,LiveRange 会保持原始范围,但它会根据插入操作自动更新其位置。
js
// 假设插入了一段文本
model.change(writer => {
writer.insertText('Hello', model.createPositionFromPath(root, [0, 5]));
});
// LiveRange 自动更新
console.log(liveRange.start.offset); // 这个值应该反映插入文本后的新位置
- 移除 LiveRange:一旦不再需要 LiveRange,需要调用 detach() 方法来解除它与文档的绑定:
js
liveRange.detach();
2.6Selection、LiveSelection与DocumentSelection
Selection
Selection 是一组range。它具有由锚点(anchor)和焦点(focus)指定的方向(可以是前向或后向)。此外,selection还可以拥有自己的属性(例如------在该选择中输入的文本是否应具有这些属性------例如,文本是否应加粗)。
举例:
js
// 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 );
// Creates selection from the other selection.
// Note: It doesn't copies selection attributes.
const otherSelection = writer.createSelection();
const selection = writer.createSelection( otherSelection );
// Creates selection from the given document selection.
// Note: It doesn't copies selection attributes.
const documentSelection = model.document.selection;
const selection = writer.createSelection( documentSelection );
// Creates selection at the given position.
const position = writer.createPositionFromPath( root, path );
const selection = writer.createSelection( position );
// Creates selection at the given offset in the given element.
const paragraph = writer.createElement( 'paragraph' );
const selection = writer.createSelection( paragraph, offset );
// Creates a range inside an {@link module:engine/model/element~Element element} which starts before the
// first child of that element and ends after the last child of that element.
const selection = writer.createSelection( paragraph, 'in' );
// Creates a range on an {@link module:engine/model/item~Item item} which starts before the item and ends
// just after the item.
const selection = writer.createSelection( paragraph, 'on' );
// Selection's constructor allow passing additional options (`'backward'`) as the last argument.
// Creates backward selection.
const selection = writer.createSelection( range, { backward: true } );
LiveSelection
LiveSelection 是 Selection 的一种特殊类型,它能够随着文档的变化而自动更新。具体来说,LiveSelection 会持续追踪文档状态的变化,并根据这些变化动态调整其所包含的范围。这使得 LiveSelection 成为一种可以随着编辑操作而自动更新的"活动"选择。
js
class LiveSelection extends Selection {
constructor( doc ) {
super();
// ...
this.listenTo( this._model, 'applyOperation', ( evt, args ) => {
// Ensure selection is correct after each operation.
// ...
this.fire( 'change:range', { directChange: false } );
}, { priority: 'lowest' } );
this.on( 'change:range', () => {
// Ensure selection is correct and up to date after each range change.
})
// Update markers data stored by the selection after each marker change.
this.listenTo( this._model.markers, 'update', () => /* ... */ );
// Ensure selection is up to date after each change block.
this.listenTo( this._document, 'change', ( evt, batch ) => {
// ...
} );
}
DocumentSelection
DocumentSelection 是一种特殊的选择,用作文档的选择。每个文档中只能有一个 DocumentSelection 实例。
DocumentSelection 只能通过在 change() 块内使用 Writer 实例进行更改,因为它提供了一种安全的方式来修改模型。
DocumentSelection 会在文档发生更改时自动更新,以始终包含有效的范围。它的属性通常会从文本中继承,除非显式设置。
Selection 和 DocumentSelection 之间的区别:
-
DocumentSelection 中始终有一个范围------即使没有添加任何范围,选择中也会有一个"默认范围"。
-
添加到此选择中的范围会在文档更改时自动更新。
-
DocumentSelection 的属性会根据选择的范围自动更新。
由于 DocumentSelection 使用实时范围(range)并且在文档更改时更新,它不能设置在文档片段内的节点上。如果需要在文档片段(document fragment)中表示选择,应使用 Selection 类。
js
class DocumentSelection {
constructor( doc ) {
/**
* Selection used internally by that class (`DocumentSelection` is a proxy to that selection).
*/
this._selection = new LiveSelection( doc );
this._selection.delegate( 'change:range' ).to( this );
this._selection.delegate( 'change:attribute' ).to( this );
this._selection.delegate( 'change:marker' ).to( this );
}
}
2.7Operation
Operation基类
Operation是基础操作抽象类,其他操作类(如AttributeOperation、DetachOperation、InsertOperation、MarkerOperatio、MergeOperation等)都继承了该抽象类。
js
class Operation {
constructor( baseVersion ) {
this.baseVersion = baseVersion;
this.isDocumentOperation = this.baseVersion !== null;
this.batch = null;
}
toJSON() {
// ...
}
static fromJSON( json ) {
return new this( json.baseVersion );
}
}
toJSON 方法的作用是控制对象如何在 JSON 序列化时进行转换,允许修改或过滤对象的属性,使得对象的 JSON 表现形式更符合特定需求;在调用 JSON.stringify() 时,如果对象定义了 toJSON 方法,那么 JSON.stringify() 会优先调用该方法。
fromJSON方法的作用是反序列化,不过在 JavaScript 中并不是一个内建的标准方法。
下面以AttributeOperation类、InsertOperation为例进行源码分析。
AttributeOperation类
将给定的属性设置在给定范围(range)内的节点上。属性仅设置在范围的顶级节点上,而不作用于其子节点。
如果range涉及多个文本节点,那么可能涉及文本的合并。
js
// ckeditor5-engine/src/model/operation/attributeoperation.js
class AttributeOperation extends Operation {
constructor( range, key, oldValue, newValue, baseVersion ) {
super( baseVersion );
this.range = range.clone();
// Key of an attribute to change or remove.
this.key = key;
// Old value of the attribute with given key or `null`, if attribute was not set before.
this.oldValue = oldValue === undefined ? null : oldValue;
// New value of the attribute with given key or `null`, if operation should remove attribute.
this.newValue = newValue === undefined ? null : newValue;
}
get type() {
if ( this.oldValue === null ) {
return 'addAttribute';
} else if ( this.newValue === null ) {
return 'removeAttribute';
} else {
return 'changeAttribute';
}
}
clone() {
return new AttributeOperation( this.range, this.key, this.oldValue, this.newValue, this.baseVersion );
}
getReversed() {
return new AttributeOperation( this.range, this.key, this.newValue, this.oldValue, this.baseVersion + 1 );
}
_validate() {
// ...
}
_execute() {
// If value to set is same as old value, don't do anything.
if ( !isEqual( this.oldValue, this.newValue ) ) {
// Execution.
_setAttribute( this.range, this.key, this.newValue );
}
}
}
// ckeditor5-engine/src/model/operation/utils.js
function _setAttribute( range, key, value ) {
// Range might start or end in text nodes, so we have to split them.
_splitNodeAtPosition( range.start );
_splitNodeAtPosition( range.end );
// Iterate over all items in the range.
for ( const item of range.getItems( { shallow: true } ) ) {
// Iterator will return `TextProxy` instances but we know that those text proxies will
// always represent full text nodes (this is guaranteed thanks to splitting we did before).
// So, we can operate on those text proxies' text nodes.
const node = item.is( '$textProxy' ) ? item.textNode : item;
if ( value !== null ) {
node._setAttribute( key, value );
} else {
node._removeAttribute( key );
}
// After attributes changing it may happen that some text nodes can be merged. Try to merge with previous node.
_mergeNodesAtIndex( node.parent, node.index );
}
// Try to merge last changed node with it's previous sibling (not covered by the loop above).
_mergeNodesAtIndex( range.end.parent, range.end.index );
}
AttributeOperation类最关键的方法是_execute,负责在某range上为某属性赋值(可能是新增、删除或更新),内部调用了_setAttribute方法:
-
第48~49行:如果发现position所在节点是textNode,那么就要根据range的start和end对原textNode进行切割,至多可能分成三部分。
-
第52行:range.getItems( { shallow: true } )内通过TreeWalker遍历节点;
-
第58~62行:给节点的属性赋值或删除属性;
-
第65行:遍历range中的节点时,如果当前节点和前一个节点都是文本节点且具有相同属性(attributes),那么先删除这2个节点,然后插入这2个textNode合并后的新textNode。
-
第69行:遍历完range后,尝试将最后更改的节点与其前一个兄弟节点合并(上面的循环没有涵盖这一部分)。
InsertOperation类
在模型中的给定位置插入一个或多个节点的操作。model的change或enqueueChange回调中,调用writer实例的insert系列方法时,内部会生成InsertOperation实例,最终调用model的applyOperation方法。
js
this.model.applyOperation( InsertOperation实例 );
InsertOperation源码:
// ckeditor5-engine/src/model/operation/insertoperation.js
class InsertOperation extends Operation {
constructor( position, nodes, baseVersion ) {
super( baseVersion );
this.position = position.clone();
this.position.stickiness = 'toNone';
this.nodes = new NodeList( _normalizeNodes( nodes ) );
this.shouldReceiveAttributes = false;
}
get type() {
return 'insert';
}
get howMany() {
return this.nodes.maxOffset;
}
// ...
_execute() {
const originalNodes = this.nodes;
this.nodes = new NodeList( [ ...originalNodes ].map( node => node._clone( true ) ) );
_insert( this.position, originalNodes );
}
}
// ckeditor5-engine/src/model/operation/utils.js
export function _insert( position, nodes ) {
nodes = _normalizeNodes( nodes );
const offset = nodes.reduce( ( sum, node ) => sum + node.offsetSize, 0 );
const parent = position.parent;
// Insertion might be in a text node, we should split it if that's the case.
_splitNodeAtPosition( position );
const index = position.index;
// Insert nodes at given index. After splitting we have a proper index and insertion is between nodes,
// using basic `Element` API.
parent._insertChild( index, nodes );
// Merge text nodes, if possible. Merging is needed only at points where inserted nodes "touch" "old" nodes.
_mergeNodesAtIndex( parent, index + nodes.length );
_mergeNodesAtIndex( parent, index );
return new Range( position, position.getShiftedBy( offset ) );
}
InsertOperation类最关键的方法是_execute,负责在指定位置插入某个或某些节点,内部调用了_insert方法:
- 第33行:当插入的地方属于文本节点(textNode),那么需要将该节点拆分成两部分;
- 第34行:index是Position中的getter方法,可动态获取文本节点(如有)拆分后position的index(本概念见1.2节 Indexes and offsets);
- 第38行:在父节点中index处插入待插入节点;
- 第41行:插入节点后,如果最后一个待插入节点与其后的节点都是文本节点且具有相同属性(attributes),那么先删除这2个节点,然后插入这2个textNode合并后的新textNode。
- 第42行:插入节点后,如果第一个待插入节点与其前面的节点都是文本节点且具有相同属性(attributes),那么先删除这2个节点,然后插入这2个textNode合并后的新textNode。
2.8Batch
批处理(batch)实例将模型中的变化(操作)分组。所有在单个批处理中分组的操作可以一起撤销,因此你也可以将批处理视为一个单一的撤销步骤。如果你想扩展某个撤销步骤,可以通过使用 enqueueChange 向批处理中添加更多的变化。
js
model.enqueueChange( batch, writer => {
writer.insertText( 'foo', paragraph, 'end' );
} );
源码
Batch的类型(type)可以是'transparent'或'default',default是一种最常使用的type,transparent表明该batch是一个应该被其他功能忽略的批处理,即初始批处理或协作编辑(collaborative editing)的变更。Batch实例在Model的change和enqueueChange方法中有使用到。
js
class Batch {
/**
* type可以是'transparent'或'default',
* default是一种最常使用的type,transparent表明该batch是
* 一个应该被其他功能忽略的批处理,即初始批处理或协作编辑的变更。
*/
constructor( type = 'default' ) {
/**
* An array of operations that compose this batch.
*/
this.operations = [];
this.type = type;
}
/**
* Returns the base version of this batch, which is equal to the base version of the first operation in the batch.
* If there are no operations in the batch or neither operation has the base version set, it returns `null`.
*/
get baseVersion() {
for ( const op of this.operations ) {
if ( op.baseVersion !== null ) {
return op.baseVersion;
}
}
return null;
}
/**
* Adds an operation to the batch instance.
*/
addOperation( operation ) {
operation.batch = this;
this.operations.push( operation );
return operation;
}
}
2.9Writer
模型只能通过使用写入器(writer)进行修改。每当你想要创建节点、修改子节点、属性或文本、设置选择的位置及其属性时,都应该使用写入器。
js
model.change( writer => {
writer.insertText( 'foo', paragraph, 'end' );
} );
主要作用
- 创建节点:Writer 允许在模型中创建新的节点,如插入元素(例如段落、图片等)、文本节点等。
- 修改节点:它可以修改已存在的节点的属性、内容或子节点。例如,你可以更新节点的文本内容、设置节点的属性(如样式或类名)等。
- 设置和修改选择位置:Writer 可以控制文档中的选择位置(Selection),并允许设置其位置和范围。
- 操作元素和文本:Writer 提供了多种方法来插入、删除或修改文本。它还允许在文档中插入新的元素,或删除现有的元素。
- 批量操作:所有对模型的更改都是通过 Writer 完成的,并且这些更改是批量进行的(即一组操作会在同一个事务中提交)。Writer 还会确保这些操作的顺序正确,以便支持撤销和重做功能。
- 修改选择:它可以在文档中插入、删除或更改所选内容,并且可以通过 Writer 来控制选择的状态。
注意事项
-
在model的 change() 或 enqueueChange() 方法中使用 :Writer 实例只能在 change() 或 enqueueChange() 方法的回调函数中使用。这样做是为了确保所有模型修改操作是批量提交的,并且操作是可撤销的。
-
不检查 Schema:Writer 的方法并不直接检查 Schema。因此,通过 Writer 可以修改文档结构,但并不保证修改后的结构符合 Schema 定义。这意味着使用 Writer 时需要特别小心,以确保文档的结构仍然是有效的。
-
与文档选择(Selection)相关联:Writer 也用于设置和修改文档中的选择状态,允许操作文本插入或删除时,选择状态得到正确更新。
关于change方法,如1.2节所述,在单个 change() 块内进行的所有更改都会合并成一个撤销步骤(它们被添加到同一个批次中)。当嵌套 change() 块时,所有更改都会被添加到最外层 change() 块的批次中。
例如,下面的代码将创建一个撤销步骤:
js
editor.model.change( writer1 => {
writer1.insertText( 'foo', paragraph, 'end' ); // foo.
editor.model.change( writer2 => {
writer2.insertText( 'bar', paragraph, 'end' ); // foobar.
} );
writer1.insertText( 'bom', paragraph, 'end' ); // foobarbom.
} );
上述例子中,最终的结果是"foobarbom",从代码层面分析,writer1和writer2是共享同一个Writer实例,而Writer构造函数需传入Batch参数,这也就意味着writer1和writer2共用同一个Batch实例,所以说"当嵌套 change() 块时,所有更改都会被添加到最外层 change() 块的批次中"。
关于enqueue方法,enqueueChange() 的回调会在所有其他已排队的更改完成后执行;如果嵌套在其他更改块中,会被延迟执行,如下面例1所示,输出结果为"1, 3, 2"。默认情况下,会创建一个新的 Batch 。在下面例1中,change 和 enqueueChange 块使用不同的批次(并且使用不同的 Writer,因为每个块操作的批次是分开的),但enqueueChange() 允许你指定批次,可以创建新的批次或使用现有批次中,如下面例2所示。
js
// enqueue例1
model.change( writer => {
console.log( 1 );
model.enqueueChange( writer => {
console.log( 2 );
} );
console.log( 3 );
} ); // Will log: 1, 3, 2.
js
// enqueue例2,batch是一个Batch实例。
model.enqueueChange( batch, writer => {
writer.insertText( 'foo', paragraph, 'end' );
});
核心方法
- 节点创建方法:如createText、createElement、createDocumentFragment,用于创建相应节点
- 元素插入方法:如insert、insertText、insertElement等,用于在指定位置插入元素
- 增补相关方法:append、appendText、appendElement
- attribute相关操作:setAttribute、setAttributes、removeAttribute、clearAttributes
- 移动操作:move
- 移除操作:remove
- 合并操作:merge
- 分割操作:split
- 包裹解包操作:wrap、unwrap
- 位置相关操作:createPosition、createPositionFromPath、createPositionAt、createPositionAfter、createPositionBefore
- range相关操作:如createRange、、createRangeIn、createRangeOn
- marker相关操作:addMarker、updateMarker、removeMarker
- selection相关操作:createSelection、setSelection、setSelectionFocus、setSelectionAttribute、removeSelectionAttribute。
insert系列方法底层最终调用的都是insert方法,该方法用于将元素插入指定元素的某个位置,举例:
js
// Inserts item on given position.
const paragraph = writer.createElement( 'paragraph' );
writer.insert( paragraph, position );
// Instead of using position you can use parent and offset:
const text = writer.createText( 'foo' );
writer.insert( text, paragraph, 5 );
// You can also use `end` instead of the offset to insert at the end:
const text = writer.createText( 'foo' );
writer.insert( text, paragraph, 'end' );
// Or insert before or after another element:
const paragraph = writer.createElement( 'paragraph' );
writer.insert( paragraph, anotherParagraph, 'after' );
insert方法其逻辑如下:
- 如果有父节点,且元素和位置位于同一颗树,那么调用move方法,将元素移动到指定位置;
- 如果没有父节点,那么根据位置、元素和版本号信息生成InsertOperation实例insert;
- 执行this.batch.addOperation( insert )将insert缓存起来,可做后续撤回之用;this即为Writer实例,构造该实例时会传入Batch实例,即为this.batch。
- 执行this.model.applyOperation( insert )将insert应用到model中;
- 如果元素是DocumentFragment类型,那么需要将元素的markers信息移动到model上(涉及updateMarker或addMarker操作)。
每次通过 applyOperation 对模型应用操作时都会触发名为"applyOperation"的事件。请注意,此事件仅适用于非常特定的用例。如果您需要监听文档上应用的每一个操作,可以使用此事件。然而,在大多数情况下,应该使用 change 事件。引擎的内部类已经为此事件添加了一些回调:
- 在 highest 优先级下,操作会被验证(在Model实例和Document实例中注册了相关方法);
- 在 high优先级下,操作会被缓存起来(在Document实例中注册了相关方法,即调用differ.bufferOperation缓存操作);
- 在 normal 优先级下,操作会被执行(执行operation的_execute方法);
- 在 low 优先级下,Document 会更新其版本,并将操作加入history属性(在Document实例中注册了相关方法);
- 在 lowest 优先级下,LivePosition 和 LiveRange 会更新它们自身(在Document持有的DocumentSelection实例中注册了相关方法)。
2.10Differ
计算两个模型状态之间的差异:接收需要应用到模型文档的操作,标记模型文档树中已更改的部分,并在更改之前保存这些元素的状态;然后,它将保存的元素与应用了所有更改后的已更改元素进行比较。计算保存的元素和新元素之间的差异,并返回一个变更集。
Differ提供了bufferMarkerChange、bufferOperation方法缓存marker、operation:
在Document实例中,会往model.markers上注册一个'update'事件的回调方法(优先级为默认的normal),通过bufferMarkerChange缓存marker;
在Document实例中,会往model上注册一个'applyOperation'事件的回调方法(优先级为high),通过bufferOperation缓存操作。
在执行操作之前,操作必须被缓冲(buffer)。操作类型会被检查,并且会检查该操作将影响哪些节点。然后,这些节点会在操作执行前的状态下被存储在 Differ 中。
bufferOperation
该方法会针对类型的不同(有insert、addAtrribute、removeAttribute、changeAttribute、remove、move、reinsert、rename、split、merge类型),进行不同处理。以"insert"为例,进行说明。
js
bufferOperation( operation ) {
switch ( operation.type ) {
case 'insert': {
if ( this._isInInsertedElement( operation.position.parent ) ) {
return;
}
this._markInsert( operation.position.parent, operation.position.offset, operation.nodes.maxOffset );
break;
}
}
}
-
通过_isInInsertedElement检查给定的元素或其任何父元素是否为已缓冲的插入元素,如果返回为false,进行下一步。
-
通过_markInsert保存和处理模型更改(change):
-
首先,对该父元素的子元素进行快照(只有在之前没有创建过时才会创建)。
-
然后,获取该元素上已经执行的所有更改(如果这是第一次更改,则返回空数组)。
-
接着,遍历所有更改并对它们或新的更改进行转换。
-
在转换过程中,某些更改可能会被合并到另一个更改中。此时,该更改的 howMany 属性将被设置为 0 或更小。我们需要删除这些无效的更改。
其中第2大步第c步,主要用于处理并合并不同类型的变更操作,确保在多次操作之间进行适当的调整、合并和拆分,使得当多个操作同时发生时能够正确地调整偏移量和节点数量,避免变更之间的冲突或重复计算。
其中关键:
节点处理:区分待处理的节点数量(nodesToHandle)和实际受影响的节点数量(howMany)。
冲突处理:处理多个变更之间的冲突,尤其是插入、删除和属性变更之间的复杂交集。
拆分属性变更:当变更涉及属性时,如果变更范围重叠,需要拆分操作,确保每个变更能够精确描述。
举例说明:
当有多个更改受 inc 更改项的影响时,情况会变得复杂。例如:假设有两个插入更改:{ offset: 2, howMany: 1 } 和 { offset: 5, howMany: 1 }。假设 inc 更改是移除操作:{ offset: 2, howMany: 2, nodesToHandle: 2 }。nodesToHandle(仍需处理的节点数量)和 howMany(受影响的节点数量)需要区分开来。然后,我们的处理逻辑如下:
"忘记"第一个插入更改(它被移除操作"吞掉"了),
由于这个原因,最后我们只需要移除一个节点(nodesToHandle = 1),
但我们仍然需要将第二个插入更改的偏移量从 5 更改为 3!
因此,howMany 在整个项的转换过程中保持不变,它表示受影响的节点数量,而 nodesToHandle 表示在更改项被其他更改转换后,仍需处理的节点数量。
2.11Model类
构造方法源码分析
-
初始化marks collection、document、schema等;
-
通过ObservableMixin的decorate方法装饰insertContent、deleteContent、modifySelection、getSelectedContent和applyOperation方法,以保证执行这些方法前fire出同名事件(见ckeditor5-utils的第2节 ObservableMixin),原方法会被包装成事件的一个回调函数,优先级为"normal";
每次通过 applyOperation 对模型应用操作时都会触发名为"applyOperation"的事件。请注意,此事件仅适用于非常特定的用例。如果您需要监听文档上应用的每一个操作,可以使用此事件。然而,在大多数情况下,应该使用 change 事件。引擎的内部类已经为此事件添加了一些回调:
在 highest 优先级下,操作会被验证(在Model实例和Document实例中注册了相关方法);
在 high优先级下,操作会被缓存起来(在Document实例中注册了相关方法,即调用differ.bufferOperation缓存操作);
在 normal 优先级下,操作会被执行(执行operation的_execute方法);
在 low 优先级下,Document 会更新其版本,并将操作加入history属性(在Document实例中注册了相关方法);
在 low 优先级下,LivePosition 和 LiveRange 会更新它们自身(在Document持有的DocumentSelection实例中注册了相关方法)。
-
内部会监听"applyOperation"事件,检查操作是否有效;
-
往schema注册' <math xmlns="http://www.w3.org/1998/Math/MathML"> r o o t ′ 、 ′ root'、' </math>root′、′block'、' <math xmlns="http://www.w3.org/1998/Math/MathML"> t e x t ′ 、 ′ text'、' </math>text′、′clipboardHolder'和'$marker';
-
注册post-fixers:selection-post-fixer和autoParagraphEmptyRoots。autoParagraphEmptyRoots的作用是往空的根节点中插入一段空的paragraph。
selection-post-fixer
selection-post-fixer的作用是在执行 change() 块之后,确保selection的位置是正确的。
正确的位置意味着:
所有折叠的(collapsed)选择范围都位于 Schema 允许 $text 的地方。 selection的非折叠范围不会跨越限制元素的边界(range必须完全位于一个限制元素内)。 只有可选择元素(schema isSelectable)能从外部选择(例如 [<paragraph>foo</paragraph>]
是无效的)。此规则独立适用于选择的两个端点,因此,以下选择是正确的:<paragraph>f[oo</paragraph><imageBlock></imageBlock>]
。 如果位置不正确,该post fixer将自动修正它。
例如,考虑一个选择,它从一个 P1 元素开始,结束于 TD 元素的文本内([ 和 ] 是范围边界,(l) 表示一个被定义为 isLimit=true 的元素):
在上图中,TABLE、TR 和 TD 元素在schema中被定义为 isLimit=true。如果某个range不完全包含在单一的限制元素内,则该range必须被扩展,以选择最外层的限制元素。范围的结束点位于 TD 元素的文本节点内。由于 TD 元素是 TR 和 TABLE 元素的子元素,而这两个元素在模式中都被定义为 isLimit=true,因此该范围必须扩展以选择整个 TABLE 元素。
注意:如果选择包含多个range,那么经过扩展后选择 isLimit=true 元素,该方法会返回一个最小的不相交(intersect)的range集合。
结合上面的例子,进行源码分析:
js
// model/utils/selection-post-fixer.js
function selectionPostFixer( writer, model ) {
const selection = model.document.selection;
const schema = model.schema;
const ranges = [];
let wasFixed = false;
for ( const modelRange of selection.getRanges() ) {
const correctedRange = tryFixingRange( modelRange, schema );
if ( correctedRange && !correctedRange.isEqual( modelRange ) ) {
ranges.push( correctedRange );
wasFixed = true;
} else {
ranges.push( modelRange );
}
}
// If any of ranges were corrected update the selection.
if ( wasFixed ) {
writer.setSelection( mergeIntersectingRanges( ranges ), { backward: selection.isBackward } );
}
}
function tryFixingRange( range, schema ) {
if ( range.isCollapsed ) {
return tryFixingCollapsedRange( range, schema );
}
return tryFixingNonCollapsedRage( range, schema );
}
第9行:遍历selection中的所有range;
第10行:当range的isCollapsed为真,那么执行tryFixingCollapsedRange( range, schema ),该方法逻辑是:依据range.start位置,在其ancestor节点中寻找满足isLimit为真的节点,在该节点为范围寻找距离range.start位置最近的元素,以该元素起始和结束位置(如果该元素是文本节点,那么结束位置与开始位置一样)创建Range实例(即nearestSelectionRange)。如果range是展开的(即isCollapsed为假),那么执行tryFixingNonCollapsedRage( range, schema ),该方法逻辑是:如果起始点都属于同一个限制元素,需要先按下面所示的效果进行处理;如果某个范围(range)的位置位于限制元素内部,那么该范围跨越了限制元素的边界,需要进行修正,这就需要找到最外层的限制元素,因为限制元素可能直接嵌套在内部(例如 table > tableRow > tableCell),依据最外层的限制元素创建新的Range实例。
// Range that is on non-limit element (or is partially) must be fixed so it is placed inside the block around $text:
[<block>foo</block>]
-><block>[foo]</block>
[<block>foo]</block>
-><block>[foo]</block>
<block>f[oo</block>]
-><block>f[oo]</block>
[<block>foo</block><selectable></selectable>]
-><block>[foo</block><selectable></selectable>]
第20行:mergeIntersectingRanges方法是返回一个最小的不相交范围数组。以数组相交做类比,[1,2,3]与[2,3,4]可得[2,3],[1,2,3]与[0,1,2,3,4]可得[1,2,3]。源码在下面,逻辑比较清晰简明。
js
function mergeIntersectingRanges( ranges ) {
const nonIntersectingRanges = [];
// First range will always be fine.
nonIntersectingRanges.push( ranges.shift() );
for ( const range of ranges ) {
const previousRange = nonIntersectingRanges.pop();
if ( range.isIntersecting( previousRange ) ) {
// Get the sum of two ranges.
const start = previousRange.start.isAfter( range.start ) ? range.start : previousRange.start;
const end = previousRange.end.isAfter( range.end ) ? previousRange.end : range.end;
const merged = new Range( start, end );
nonIntersectingRanges.push( merged );
} else {
nonIntersectingRanges.push( previousRange );
nonIntersectingRanges.push( range );
}
}
return nonIntersectingRanges;
}
主要方法
change和enqueueChange
已在2.9节注意事项部分做过分析,在此从略。
applyOperation
该方法是修改模型的一种底层方式。它仅在非常特定的使用场景下暴露(例如撤销功能)。内部是调用operation的_execute方法。
js
applyOperation( operation ) {
operation._execute();
}
通常,修改模型时使用 Writer,但Writer的方法中也往往调用了applyOperation,例如:
js
editor.model.change( writer => {
const paragraph = writer.createElement( 'paragraph' );
editor.model.insertContent( paragraph );
});
Model的insertContent方法其实调用的是insertcontent.js的insertContent方法,如下:
js
function insertContent( model, content, selectable, placeOrOffset ) {
return model.change( writer => {
let selection;
// 中间过程省略 ......
selection = writer.createSelection( selectable, placeOrOffset );
// 中间过程省略 ......
const insertion = new Insertion( model, writer, selection.anchor );
// 中间过程省略 ......
}
}
第7行的Insertion类使用到了模型的Writer实例的insert等诸多方法:
js
this.writer.insert( node, this.position );
模型的Writer类insert方法执行时会执行applyOperation:
js
insert( item, itemOrPosition, offset = 0 ) {
// 中间过程省略 ......
const insert = new InsertOperation( position, item, version );
this.batch.addOperation( insert );
this.model.applyOperation( insert );
// 中间过程省略 ......
}
deleteContent
删除选区内容并合并相邻元素。
用法:
deleteContent( selection, [ options ] = { [options.direction], [options.doNotAutoparagraph],[options.doNotResetEntireContent], [options.leaveUnmerged], options[i: string] } )
注意:为了可预测性,结果选区应始终是折叠的。在某些功能需要修改删除行为以避免选区折叠时(例如,表格功能可能希望按下退格键后保持行选择),该行为应在视图监听器中实现。同时,表格功能也需要修改此方法的行为,例如"删除内容后折叠选区到最后一个选中的单元格内"或"删除行并将选区折叠到某个附近"。这样做是为了确保其他使用 deleteContent() 的功能能在表格中正常工作。
参数
-
selection : Selection | DocumentSelection
- 要删除内容的选区。
-
[options]: object,可选参数对象,包含以下属性:
-
[options.direction] : 'forward' | 'backward',内容被删除的方向。删除向后对应使用退格键(Backspace),而删除向前则对应 Shift+退格键。
-
[options.doNotAutoparagraph]: boolean,删除内容后,选区移到一个无法插入文本的位置时,是否创建一个段落。例如,
<paragraph>x</paragraph>[<imageBlock src="foo.jpg"></imageBlock>]
会变成:-
doNotAutoparagraph == false 时变为:x[]
-
doNotAutoparagraph == true 时变为:x[]
注意:如果没有有效的插入位置,段落会始终被创建:
[<imageBlock src="foo.jpg"></imageBlock>] -> <paragraph>[]</paragraph>
。
-
-
[options.doNotResetEntireContent]: boolean,当整个内容被选中时,是否跳过用段落替换整个内容。
例如,
<heading1>[x</heading1><paragraph>y]</paragraph>
会变成:-
doNotResetEntireContent == false 时变为:
<paragraph>^</paragraph>
-
doNotResetEntireContent == true 时变为:
<heading1>^</heading1>
-
-
[options.leaveUnmerged]: boolean,删除选区内容后,是否合并元素。
例如,
<heading1>x[x</heading1><paragraph>y]y</paragraph>
会变成:-
leaveUnmerged == false 时变为:
<heading1>x^y</heading1>
-
leaveUnmerged == true 时变为:
<heading1>x^</heading1><paragraph>y</paragraph>
-
注意:对象和限制元素将不会被合并。
-
insertContent
在编辑器中,将内容插入到由选择(selection)指定的位置,这种方式类似于粘贴功能的工作原理。
这是一个高级方法。在插入内容时,它会考虑模式(schema),在插入节点之前会清空给定选择(selection)的内容,并在结束时将选择(selection)移动到目标位置。它可以拆分元素、合并元素、将裸文本(bare text)节点包装到段落中等------就像粘贴功能应该执行的那样。
与 Writer 的方法不同,这个方法不需要在 change() 块内使用。
转换和模式
将元素和文本节点插入模型中并不足以使 CKEditor 5 将这些内容渲染给用户。CKEditor 5 实现了一个模型-视图-控制器(MVC)架构,而 model.insertContent() 所做的仅仅是将节点添加到模型中。此外,您还需要在模型和视图之间定义转换器,并在模式中定义这些节点。
因此,尽管这个方法看起来类似于 CKEditor 4 的 editor.insertHtml()(实际上,这两个方法都用于类似粘贴的内容插入),但在 CKEditor 5 中,除非为该 HTML 中的所有元素和属性定义了转换器,否则不能使用该方法插入任意的 HTML。
示例
使用 insertContent() 插入手动创建的模型结构:
js
// 创建一个包含以下内容的文档片段:
//
// <paragraph>foo</paragraph>
// <blockQuote>
// <paragraph>bar</paragraph>
// </blockQuote>
const docFrag = editor.model.change( writer => {
const p1 = writer.createElement( 'paragraph' );
const p2 = writer.createElement( 'paragraph' );
const blockQuote = writer.createElement( 'blockQuote' );
const docFrag = writer.createDocumentFragment();
writer.append( p1, docFrag );
writer.append( blockQuote, docFrag );
writer.append( p2, blockQuote );
writer.insertText( 'foo', p1 );
writer.insertText( 'bar', p2 );
return docFrag;
} );
// insertContent()不必在 change() 块中使用,当然也可以这样做,所以这个代码也可以移到上面定义的回调中。
editor.model.insertContent( docFrag );
使用 insertContent() 和一个转换为模型文档片段的 HTML 字符串(类似粘贴机制):
js
// 您可以创建自己的 `HtmlDataProcessor` 实例,
// 或者使用 `editor.data.processor`,如果没有覆盖默认的(即 `HtmlDataProcessor` 实例)。
const htmlDP = new HtmlDataProcessor( viewDocument );
// 将 HTML 字符串转换为视图文档片段:
const viewFragment = htmlDP.toView( htmlString );
// 在 '$root' 的上下文中将视图文档片段转换为模型文档片段。
// 这个转换考虑了模式,因此,如果例如视图文档片段包含一个裸文本节点,该文本节点不能作为 `$root` 的子节点,因此它会自动被包装成一个 <paragraph>。
// 您可以自行定义上下文(通过第二个参数),例如,将内容像在 <paragraph> 中一样转换。
// 注意:剪贴板功能使用了一个名为 `$clipboardHolder` 的自定义上下文,该上下文具有放宽的模式。
const modelFragment = editor.data.toModel( viewFragment );
editor.model.insertContent( modelFragment );
默认情况下,此方法将使用文档选择,但它也可以与位置、范围或选择实例一起使用。
js
// 在当前文档选择位置插入文本。
editor.model.change( writer => {
editor.model.insertContent( writer.createText( 'x' ) );
} );
// 在给定位置插入文本 - 文档选择不会被修改。
editor.model.change( writer => {
editor.model.insertContent( writer.createText( 'x' ), doc.getRoot(), 2 );
// 这等同于:
editor.model.insertContent( writer.createText( 'x' ), writer.createPositionAt( doc.getRoot(), 2 ) );
} );
如果您希望文档选择(document selection)移动到插入的内容,请在插入内容后使用 writer 的 setSelection() 方法:
js
editor.model.change( writer => {
const paragraph = writer.createElement( 'paragraph' );
// 在根元素的开头插入一个空的段落。
editor.model.insertContent( paragraph,
writer.createPositionAt( editor.model.document.getRoot(), 0 ) );
// 将文档选择移动到插入的段落。
writer.setSelection( paragraph, 'in' );
} );
如果传递了一个模型选择实例作为可选项,新内容将插入到传递的选择中(而不是文档选择):
js
editor.model.change( writer => {
// 在一个段落中创建一个选择,它将用作插入位置。
const selection = writer.createSelection( paragraph, 'in' );
// 在创建的选择中插入新文本。
editor.model.insertContent( writer.createText( 'x' ), selection );
// 'insertContent()' 修改了传递的选择实例,因此可以使用该选择设置文档选择。
// 注意:如果传递了文档选择给 `insertContent()`,则无需这么做。
writer.setSelection( selection );
} );
源码主要逻辑
-
检查位置(position)的parent(所在元素)或该元素的ancestors,是否在节点(node,待插入)的allowIn范围里,如果在,保存该ancestor(记为allowedIn变量),走第2、3、4步;否则,走第5步。
-
沿着从子节点往父节点的向上方向循环下去,每一步进行位置检查、节点分割,并赋予postion最新的值,直到position的parent等于allowedIn(这是一个不断往上爬寻,不断调整position的过程)。
如果schema检测到节点是object类型,需要对位置(position)进行重新调整,具体调整方式如下例所示:
<p>^Foo</p> -> ^<p>Foo</p>
<p>Foo^</p> -> <p>Foo</p>^
<p>x^y</p> -> <p>x</p>^<p>y</p>
- 调整位置后,还需要进行合并操作,具体合并方式如下例所示:
css
// 加号后的元素表示待插入元素,加号前的元素表示被插入的地方,^或[]表示原始position。
<p>x^</p> + <p>y</p> => <p>x</p><p>y</p> => <p>xy[]</p>
<p>x^y</p> + <p>z</p> => <p>x</p>^<p>y</p> + <p>z</p> => <p>x</p><p>z</p><p>y</p> => <p>xz[]y</p>
<p>x</p><p>^</p><p>z</p> + <p>y</p> => <p>x</p><p>y</p><p>z</p> (no merging)
<p>x</p>[<img>]<p>z</p> + <p>y</p> => <p>x</p><p>y</p><p>z</p> (no merging)
-
通过writer实例,将最新的位置(position)插入到节点(node)。
-
如果第1步中没有找到合适的ancestor,那么尝试用paragraph包裹节点(node),也就是paragraph成为新的节点,最后重复走第1步,做再一次尝试。
insertContent、deleteContent方法内部都使用到applyOperation,这点在applyOperation部分和2.7节的InsertOperation类都已阐明。
modifySelection
略
createPosition*
该系列方法包括有createPositionFromPath、createPositionAt、createPositionAfter和createPositionBefore,在此以createPositionAt( itemOrPosition, offset )为例进行分析,Writer中的createPositionAt调用的也是Model中的createPositionAt。
itemOrPosition表示位置(Position实例或节点),offset表示偏移量(具体数字,或者'end'、'before'、'after')。createPositionAt( itemOrPosition, offset )方法最终要调用position.js的静态方法_createAt,其逻辑如下:
- 如果itemOrPosition是Position实例,那么直接生成一个新的Position实例返回即可。
js
static _createAt( itemOrPosition, offset, stickiness = 'toNone' ) {
if ( itemOrPosition instanceof Position ) {
return new Position( itemOrPosition.root, itemOrPosition.path, itemOrPosition.stickiness );
} else {/* ... */}
}
- 如果itemOrPosition是节点。
如果offset不是数字,需要做类似下面的处理:
js
// offset值为'end'时,
offset = itemOrPosition.maxOffset;
// offset值为'before'时,
offset = itemOrPosition.startOffset;
// offset值为'after'时,
offset = itemOrPosition.endOffset;
处理完之后,将最新的offset塞入节点(即itemOrPosition)的path中,然后生成新的Position实例即可:
js
static _createAt( itemOrPosition, offset, stickiness = 'toNone' ) {
if ( itemOrPosition instanceof Position ) {
// ...
} else {
const path = itemOrPosition.getPath();
path.push( offset );
return new this( itemOrPosition.root, path, stickiness );
}
}
createRange*
Writer中也有同名方法,逻辑较为简单,分析略。
介绍markers
Marker(标记)
Marker 是模型中一个连续的部分(类似于range),它有一个名字,并表示有关模型文档中标记部分的某些信息。与构成模型文档树的节点(node)不同,标记并不直接存储在文档树中,而是存储在模型标记集合(model markers' collection)中。尽管如此,标记仍然是文档数据的一部分,因为它通过为模型文档中的标记起始位置和结束位置之间的部分赋予额外的含义。
从这个意义上讲,标记类似于在节点上添加和转换属性。区别在于属性是与给定节点相关联的(例如,一个字符是加粗的,无论它是否被移动或周围内容发生变化)。而标记则是连续的范围,它们的特征是其起始位置和结束位置。这意味着标记中的任何字符都会被标记。举个例子,如果一个字符被移出了标记范围,它将不再"特殊",而标记会缩小。类似地,当一个字符从文档中的其他地方移入标记范围时,它开始变得"特殊",而标记会扩大。
标记的另一个优点是查找标记的文档部分非常快速和容易。使用属性标记一些节点,并尝试找到该部分文档时,需要遍历整个文档树。而标记则提供对当前标记范围的即时访问。
标记的结构
-
范围:标记的范围是指模型文档中被标记的部分。标记的范围是通过动态范围(live range)机制自动更新的。
-
名称:名称用于分组和标识标记。名称必须是唯一的,但标记可以通过使用共同的前缀进行分组,前缀用冒号 : 分隔,例如:user:john 或 search:3。这种命名方式对于创建自定义元素的命名空间(例如,评论、突出显示等)很有用。你可以在事件更新监听器中使用这些前缀来监听标记组的变化。例如:model.markers.on( 'update:user', callback ); 会在任何 user:* 标记发生变化时被调用。
标记的类型
-
直接管理的标记:这种标记由 Writer 直接添加到 MarkerCollection 中,不使用任何附加机制。它们可以用作书签或视觉标记。比如,它们非常适合用于显示查找结果,或者当焦点位于输入框时高亮显示链接等。
-
通过操作管理的标记:这些标记同样存储在 MarkerCollection 中,但它们的变化像模型中其他结构的变化一样,都是通过操作进行管理的。因此,这些标记会被记录在撤销栈中,并且如果启用了协作插件,它们将在客户端之间进行同步。这种类型的标记适用于拼写检查、评论等功能。
标记的添加、更新与移除
无论是哪种类型的标记,都应该使用 addMarker 和 removeMarker 方法进行添加、更新和移除。
js
model.change( ( writer ) => {
const marker = writer.addMarker( name, { range, usingOperation: true } );
// 在这里可以执行其他操作...
writer.removeMarker( marker );
} );
性能注意事项
由于标记需要追踪文档的变化,出于效率考虑,最好尽量减少标记的创建并在不再需要时尽早移除它们。
标记的上下转换
标记可以进行下转(downcasting)和上转(upcasting)。
下转:标记的下转发生在 event-addMarker 和 event-removeMarker 事件中。可以使用下转转换器或将自定义转换器附加到这些事件上。对于数据管道,标记应该下转为一个元素,然后可以将其上转回标记。再次强调,可以使用上转转换器或将自定义转换器附加到 event-element 事件上。
上下转的过程允许在数据和模型之间进行灵活的转换,使得标记在不同场景下都能得到正确的表示。
标记实例的管理
标记实例只由 MarkerCollection 创建和销毁,确保标记在整个生命周期内得到正确的管理。
2.12 TreeWalker
主要作用
TreeWalker类在model中range、position和schema等处使用,用于遍历模型树(即文档结构),基于 模型(model)的层次结构来进行操作的,可以通过它来遍历文档中的元素、文本、节点等,其主要作用:
-
遍历文档节点:TreeWalker 允许你遍历文档的模型树。通过它,可以访问模型树中的各种元素、文本节点以及它们的子节点,按指定顺序(如从根节点开始,或者从某个特定位置开始)依次访问。
-
过滤节点:TreeWalker 允许你对需要遍历的节点应用筛选条件。通过指定一些过滤器(如节点类型、节点的属性、文本内容等),你可以只遍历那些符合条件的节点,而忽略其他节点。
-
处理顺序:TreeWalker 支持前序遍历(深度优先遍历)和后序遍历(基于父节点和子节点的遍历)。这使得你可以灵活地选择如何处理树中的节点。
-
遍历范围:TreeWalker 还支持指定遍历的范围,即你可以限制只在某个子树中进行遍历。它的开始位置和结束位置可以由你自己控制,遍历的节点可以在文档树的特定区域内。
源码解析
js
class TreeWalker {
constructor( options = {} ) {
if ( !options.boundaries && !options.startPosition ) {
throw new CKEditorError( 'model-tree-walker-no-start-position', null );
}
const direction = options.direction || 'forward';
this.direction = direction;
this.boundaries = options.boundaries || null;
if ( options.startPosition ) {
this.position = options.startPosition.clone();
} else {
this.position = Position._createAt( this.boundaries[ this.direction == 'backward' ? 'end' : 'start' ] );
}
this.position.stickiness = 'toNone';
this.singleCharacters = !!options.singleCharacters;
this.shallow = !!options.shallow;
this.ignoreElementEnd = !!options.ignoreElementEnd;
this._boundaryStartParent = this.boundaries ? this.boundaries.start.parent : null;
this._boundaryEndParent = this.boundaries ? this.boundaries.end.parent : null;
this._visitedParent = this.position.parent;
}
[ Symbol.iterator ]() {
return this;
}
next() {
if ( this.direction == 'forward' ) {
return this._next();
} else {
return this._previous();
}
}
}
-
构造器入参options是object,支持boundaries、startPosition、direction、ignoreElementEnd、shallow、singleCharacters。期中必选的有boundaries或startPosition(至少一个),其他是可选。
-
boundaries:是个range实例,用于给内部迭代器设置结束点,当迭代器向"前"遍历到boundaries的结束(end)处,或向"后"遍历到boundaries的起始(start)处时,返回 { done: true };
-
startPosition:用于定义迭代的起始位置(position),如果没有定义该值,那么当direction为'forward',其值为boundaries的起始(start)处,否则为boundaries的结束(end)处;
-
direction:用于定义迭代器遍历方向,默认为'forward',还可选'backward';
-
ignoreElementEnd:用于指示迭代器是否应忽略 elementEnd 标签。如果该选项为 true,则遍历器不会返回起始位置的父节点。如果该选项为 true,每个元素将只被返回一次;而如果该选项为 false,则元素可能会被返回两次:一次为 elementStart,一次为elementEnd;
-
shallow:用于指示迭代器是否应进入元素。如果迭代器为浅层(shallow),则任何被迭代节点的子节点将不会被返回,elementEnd 标签也不会被返回;
-
singleCharacters:用于指示是否应将所有具有相同属性的连续字符作为一个TextProxy返回(true),还是一个一个地返回(false)。
-
-
迭代器
定义迭代器,有2种常用方法:
js
// 方法1
class Foo {
*[Symbol.iterator]() {
yield 1;
yield 2;
yield 3;
}
}
console.log(...new Foo()); // 1 2 3
// 方法2
const FooIterator = {
x: 1,
next() {
return this.x <=3 ? {value: this.x++} : {done: true}
}
}
const Foo2 = {
[Symbol.iterator]() {
return FooIterator
}
}
console.log(...Foo2); // 1 2 3
TreeWalker采用的是第2种方法。