本文假定您已经阅读了编辑引擎架构介绍中的“模式 ”部分。
快速回顾
编辑器的模式提供于editor.model.schema属性。它定义了当前允许的模型结构(模型元素如何嵌套)、允许的属性(元素和文本节点的属性)以及其它特性(内联与分块、外部操作的原子性)。编辑功能(Editing Features)和编辑引擎(Editing Engine)随后会使用这些信息来决定如何处理模型,以及在哪里启用功能等等。
可以使用Schema#register()或Schema#extend()方法来定义模式规则。前者只能对给定的项目名称使用一次,这就确保了只有一个编辑功能可以引入该项目。同样,extend()也只能用于已定义的项目。
使用Schema#checkChild()和Schema#checkAttribute()方法可分别检查元素和属性。
允许结构声明
当编辑功能(Editing Feature)引入一个 Model 元素时,应将其注册到 Schema 中。除了定义 Model 中可能存在这样的元素外,编辑功能还需要定义该元素的位置。这些信息由SchemaItemDefinition的allowIn属性提供:
schema.register( 'myElement', {
allowIn: '$root'
} );
这允许模式知晓<myElement>可以是<$root>的子元素。$root元素是编辑框架定义的通用节点之一。默认情况下,编辑器会将主根元素命名为<$root>,因此上述定义允许<myElement>出现在主编辑器元素中。换句话说,这样做是正确的:
<$root>
<myElement></myElement>
</$root>
这么做则是不正确的:
<$root>
<foo>
<myElement></myElement>
</foo>
</$root>
要声明注册元素内部允许使用哪些节点,可以使用allowChildren属性:
schema.register( 'myElement', {
allowIn: '$root',
allowChildren: '$text'
} );
若如此做,则允许类似这样的结构:
<$root>
<myElement>
foobar
</myElement>
</$root>
属性allowIn和allowChildren也可以从其它SchemaItemDefinition项继承。
禁止结构声明
模式除了允许某些结构外,还可用于确保明确禁止某些结构——可以通过使用不允许规则来实现。
通常情况下,可以使用disallowChildren属性。它可用于定义指定元素内部不允许使用的节点:
schema.register( 'myElement', {
inheritAllFrom: '$block',
disallowChildren: 'imageInline'
} );
在上面的示例中,新的自定义元素的行为应与所有块级元素(段落、标题等)相同,但不能在其中插入内联图片。
优先于允许规则
一般来说,所有禁止规则的优先级都高于允许规则。如果把继承也考虑在内,规则的层次结构如下(从最高优先级开始):
- 来自元素自身定义的
disallowChildren/disallowIn; - 继承自父元素定义的
disallowChildren/disallowIn。
禁止规则示例
简单的禁止规则很容易理解,但当涉及到更复杂的规则时,情况就可能开始变得不清楚了。下面是一些示例,解释在涉及规则继承时,不允许是如何工作的。
schema.register( 'baseChild' );
schema.register( 'baseParent', { allowChildren: [ 'baseChild' ] } );
schema.register( 'extendedChild', { inheritAllFrom: 'baseChild' } );
schema.register( 'extendedParent', { inheritAllFrom: 'baseParent', disallowChildren: [ 'baseChild' ] } );
在这种情况下,允许在baseParent(由于继承自baseChild)和extendedParent(由于继承自baseParent)中使用extendedChild。
而baseChild只允许在baseParent中使用。虽然extendedParent继承了baseParent的所有规则,但作为其定义的一部分,它明确禁止使用baseChild。
下面是一个不同的示例,其中baseChild被扩展为disallowIn规则:
schema.register( 'baseParent' );
schema.register( 'baseChild', { allowIn: 'baseParent' } );
schema.register( 'extendedParent', { inheritAllFrom: 'baseParent' } );
schema.register( 'extendedChild', { inheritAllFrom: 'baseChild' } );
schema.extend( 'baseChild', { disallowIn: 'extendedParent' } );
上述行为改变了模式规则的解析方式。 baseChild仍将像以前一样在extendedParent中被禁止。但现在extendedParent中也不允许使用extendedChild,这是因为扩展子代将继承baseChild的规则,而没有其它允许扩展子代在扩展父代中使用的规则。
当然,可以将allowIn与disallowChildren混合使用,也可以将allowChildren与disallowIn混合使用。
最后可能会出现这样的情况:需要从一个已被禁止的项目继承,但新元素又应该被重新允许。在这种情况下,定义应如下所示:
schema.register( 'baseParent', { inheritAllFrom: 'paragraph', disallowChildren: [ 'imageInline' ] } );
schema.register( 'extendedParent', { inheritAllFrom: 'baseParent', allowChildren: [ 'imageInline' ] } );
这里的段落中允许使用imageInline,但baseParent中不允许使用。然而extendedParent将再次允许它,因为自己的定义比继承的定义更重要。
额外定义语义
除了设置允许的结构外,模式还可以定义模型元素的其它特征。通过使用is*属性,开发者可以声明其它特征和引擎应如何处理某个元素。
此处的表格列出了在模式中注册的各种模型元素及其属性。原文下方的标注翻译如下:
- [1] 该元素的
isLimit值为true,因为所有对象都是自动限制元素(Limit Elements); - [2] 该元素的
isSelectable值为true,因为所有对象都是自动可选元素(Selectable Elements); - [3] 该元素的
isContent值true,因为所有对象都自动成为内容元素(Content Elements)。
限制元素
考虑像图片标题这样的功能。标题文本区域应为某些内部操作构建一个边界:
- 从内部开始的选择不应在外部结束;
- 按
Backspace或Delete键不应删除该区域。按Enter键不应分割该区域; - 它还应作为外部操作的边界——主要是通过选择后修复程序(Selection post-fixer)来实现的,该程序可确保从外部开始的选择不应在内部结束。这意味着大多数操作要么会应用于此类元素的外部,要么会应用于其内部的内容。
isLimit属性将图片标题定义为限制元素。
schema.register( 'myCaption', {
isLimit: true
} );
然后 Engine 和各种功能会通过Schema::isLimit()对其进行检查,并采取相应措施。
限制元素并不意味着可编辑元素。可编辑元素的概念是为视图保留的,由EditableElement类表示。
对象元素
对于上例中的图片标题:选择标题框,然后复制或拖动此框到其它地方并没有多大意义。
单单一个文本,而没有其所描述的图片,这样的标题意义不大。相比之下,图片则更能自给自足。大多数情况下,用户应该能够选择整个图片(及其所有内部结构),然后复制或移动它。应该使用isObject属性来标识这种行为。
schema.register( 'myImage', {
isObject: true
} );
之后可以使用Schema::isObject()来检查该属性。通用blockObject和$inlineObject对象的isObject属性也设置为true。大多数对象类型的项目都继承自$blockObject或$inlineObject(通过inheritAllFrom)。此外,还有以下这些情况:
- 限制元素:对于每一个
isObject设置为true的元素,Schema::isLimit(element)总是返回true; - 可选元素:对于每一个
isObject设置为true的元素,Schema::isSelectable(element)总是返回true; - 内容元素:对于每一个
isObject设置为true的元素,Schema::isContent(element)总是返回true。
块级元素
一般来说,内容通常由段落、列表项、图片、标题等区块组成。所有这些元素都应使用isBlock标记为块。
设置了isBlock属性的模式项(除其它外)会影响Selection::getSelectedBlocks()行为,从而允许为适当的元素设置对齐方式等块级属性。
切记一个块内不允许有另一个块。像<blockQuote>这样可以包含其它块元素的容器元素不应被标记为块。
通用$block和$blockObject对象的isBlock属性被设置为true。大多数块级项目都会从$block或$blockObject继承(通过inheritAllFrom)。注意每个继承自$block的项目都设置了isBlock属性,但并非每个设置了isBlock属性的项目都必须是$block。
内联元素
在编辑器中,所有格式化 HTML 元素如<strong>或<code>,都由文本属性标识。因此内联模型元素不应该用于这些场景。
目前isInline属性用于$text标签(即文本节点)和<softBreak>、<imageInline>等元素或占位符元素。CKEditor 5 中对内联元素的支持仅限于自包含的元素。因此,所有标记为isInline的元素也应标记为isObject。
通用$inlineObject对象将isInline属性设置为true。大多数内联对象类型项目都继承自$inlineObject(通过inheritAllFrom)。
可选择元素
用户可以整体(连同其所有内部结构)选择元素,然后复制或应用格式化,这些元素在模式中都标有isSelectable属性:
schema.register( 'mySelectable', {
isSelectable: true
} );
之后可使用Schema::isSelectable()方法检查该属性。
默认情况下所有对象元素都是可选择的。不过编辑器中还注册了其它可选择元素。例如tableCell模型元素(在编辑视图中渲染为<td>),虽然它没有注册为对象,但也是可选择的。表格选择插件就是利用了这一点,从而允许用户创建由多个表格单元格组成的矩形选区。
内容元素
可以通过编辑器数据中的内容模型元素(使用editor.getData()或Model::hasContent()来查看)来区分内容模型元素和其它元素。
图片等媒体元素总是会被打入编辑器数据,因而它们都属于内容元素。它们在模式中被标记为isContent属性:
schema.register( 'myImage', {
isContent: true
} );
之后可以使用Schema::isContent()方法来检查该属性。
段落、列表项或标题等元素并不是内容元素,因为当其为空时,编辑器输出器会跳过它们。从数据角度来看,除非它们包含其它内容元素,否则它们就是透明的(空段落和没有段落相等效)。
通用对象
有几种内置通用对象:$root、$container、$block、$blockObject、$inlineObject和$text。它们的定义如下:
schema.register( '$root', {
isLimit: true
} );
schema.register( '$container', {
allowIn: [ '$root', '$container' ]
} );
schema.register( '$block', {
allowIn: [ '$root', '$container' ],
isBlock: true
} );
schema.register( '$blockObject', {
allowWhere: '$block',
isBlock: true,
isObject: true
} );
schema.register( '$inlineObject', {
allowWhere: '$text',
allowAttributesOf: '$text',
isInline: true,
isObject: true
} );
schema.register( '$text', {
allowIn: '$block',
isInline: true,
isContent: true
} );
其它功能可以重复使用这些定义,从而以更灵活的方式创建自己的定义。例如段落(Paragraph)功能将其对象定义为:
schema.register( 'paragraph', {
inheritAllFrom: '$block'
} );
等效转译为:
schema.register( 'paragraph', {
allowWhere: '$block',
allowContentOf: '$block',
allowAttributesOf: '$block',
inheritTypesFrom: '$block'
} );
可以理解为:
- 在允许使用
<$block>的元素(如<$root>)中,允许使用<paragraph>元素; <paragraph>元素允许使用<$block>中允许使用的所有节点(如$text);<paragraph>元素允许使用<$block>中允许使用的所有属性;<paragraph>元素将继承<$block>的所有is*属性(如isBlock)。
<paragraph>定义继承自<$block>,因此其它功能可以使用<$block>类型间接扩展<paragraph>定义。例如 BlockQuote 功能就是这么做的:
schema.register( 'blockQuote', {
inheritAllFrom: '$container'
} );
由于<$block>允许在<$container>中使用(见schema.register( '$block' ...)),尽管引用区块(BlockQuote)和段落(Paragraph)功能互为独立,但段落会被允许在引用区块使用:模式规则允许链式连接。更进一步说,如果有人注册了<section>元素(使用allowContentOf: '$root'规则),由于<$root>中也允许使用<$container>(参阅schema.register( '$container' ...)),因此<section>元素也会立即允许使用引用区块。
通用项目之间的关系
通用项目之间的关系(哪一个项目可以在哪里使用)可以通过以下抽象结构直观地体现出来:
<$root>
<$block> <!-- 例如 <paragraph>, <heading1> -->
<$text/>
<$inlineObject/> <!-- 例如 <imageInline> -->
</$block>
<$blockObject/> <!-- 例如 <imageBlock>, <table> -->
<$container> <!-- 例如 <blockQuote> -->
<$container/>
<$block/>
<$blockObject/>
</$container>
</$root>
例如这样的模型内容就符合上述规则:
<$root>
<heading1> <!-- inheritAllFrom: $block -->
<$text/> <!-- allowIn: $block -->
</heading1>
<paragraph> <!-- inheritAllFrom: $block -->
<$text/> <!-- allowIn: $block -->
<softBreak/> <!-- allowWhere: $text -->
<$text/> <!-- allowIn: $block -->
<imageInline/> <!-- inheritAllFrom: $inlineObject -->
</paragraph>
<imageBlock> <!-- inheritAllFrom: $blockObject -->
<caption> <!-- allowIn: imageBlock, allowContentOf: $block -->
<$text/> <!-- allowIn: $block -->
</caption>
</imageBlock>
<blockQuote> <!-- inheritAllFrom: $container -->
<paragraph/> <!-- inheritAllFrom: $block -->
<table> <!-- inheritAllFrom: $blockObject -->
<tableRow> <!-- allowIn: table -->
<tableCell> <!-- allowIn: tableRow, allowContentOf: $container -->
<paragraph> <!-- inheritAllFrom: $block -->
<$text/> <!-- allowIn: $block -->
</paragraph>
</tableCell>
</tableRow>
</table>
</blockQuote>
</$root>
它反过来还包含这些语义:
<$root> <!-- isLimit: true -->
<heading1> <!-- isBlock: true -->
<$text/> <!-- isInline: true, isContent: true -->
</heading1>
<paragraph> <!-- isBlock: true -->
<$text/> <!-- isInline: true, isContent: true -->
<softBreak/> <!-- isInline: true -->
<$text/> <!-- isInline: true, isContent: true -->
<imageInline/> <!-- isInline: true, isObject: true -->
</paragraph>
<imageBlock> <!-- isBlock: true, isObject: true -->
<caption> <!-- isLimit: true -->
<$text/> <!-- isInline: true, isContent: true -->
</caption>
</imageBlock>
<blockQuote>
<paragraph/> <!-- isBlock: true -->
<table> <!-- isBlock: true, isObject: true -->
<tableRow> <!-- isLimit: true -->
<tableCell> <!-- isLimit: true -->
<paragraph> <!-- isBlock: true -->
<$text/> <!-- isInline: true, isContent: true -->
</paragraph>
</tableCell>
</tableRow>
</table>
</blockQuote>
</$root>
使用回调定义高级规则
基本的声明式SchemaItemDefinition API 在本质上是有限的,一些自定义规则可能无法通过这种方式实现。因此也可以通过提供回调来定义模式,这样就可以灵活地实现所需的任何逻辑。这些回调既可用于子检查(模型结构检查),也可用于属性检查。
注意:回调优先于通过声明式 API 定义的规则,并可覆盖这些规则。
子检查(结构检查)
使用Schema::addChildCheck()可以提供函数回调,用以实现检查模型结构的特定高级规则。可以提供仅在检查特定子代时触发的回调,也可以提供对模式执行的所有检查触发的通用回调。下面是一个特定回调的示例,该回调禁止在代码块内使用内联图片:
schema.addChildCheck( context => {
if ( context.endsWith( 'codeBlock' ) ) {
return false;
}
}, 'imageInline' );
第二个参数'imageInline'指定只有在检查imageInline时才使用回调。
也可以使用回调强制允许指定项目。例如允许在任何地方使用特殊的$marker对象:
schema.addChildCheck( () => true, '$marker' );
注意回调可能返回true、false或undefined。如果返回true或false,则表示已做出决定,将不再检查其它回调或声明规则,对应当前对象将被允许或不允许。如果没有返回值,则将进一步检查以决定是否允许该项目。
在某些情况下可能需要定义一个通用监听器,该监听器将在每次模式检查时触发。例如要禁止引用区块内的所有块级对象(如表格),可以定义以下回调:
schema.addChildCheck( ( context, childDefinition ) => {
if ( context.endsWith( 'blockQuote' ) && childDefinition.isBlock && childDefinition.isObject ) {
return false;
}
} );
上述功能将在每次调用checkChild()时触发,从而提供更多灵活性。不过切记:若大量使用通用回调,可能会增加编辑器的性能开销。
属性检查
可以定义回调来检查给定的对象是否允许使用给定属性,使用Schema::addAttributeCheck()提供回调。
例如允许在所有标题上使用自定义属性headingMarker:
schema.addAttributeCheck( ( context, attributeName ) => {
const isHeading = context.last.name.startsWith( 'heading' );
if ( isHeading ) {
return true;
}
}, 'headingMarker' );
也可以使用通用回调。例如禁止对所有标题内的文本使用格式属性(如粗体或斜体):
schema.addAttributeCheck( ( context, attributeName ) => {
const parent = context.getItem( context.length - 2 );
const insideHeading = parent && parent.name.startsWith( 'heading' );
if ( insideHeading && context.endsWith( '$text' ) && schema.getAttributeProperties( attributeName ).isFormatting ) {
return false;
}
} );
所有与子检查回调相关的说明也适用于属性回调。
附加约束实现
模式的功能仅限于简单(原子)的Schema::checkChild()和Schema::checkAttribute()检查。
但事实上模式应支持定义更复杂的规则,如 “元素<x>的后面必须始终有<y>”。虽然创建一个 API 以向模式提供此类定义是可行的,但遗憾的是,期望每个编辑功能在处理 Model 时都考虑这些规则是不现实的。指望模式和编辑引擎本身会自动考虑这些规则也是不现实的。
例如现在让我们回到 “元素<x>后面必须始终有<y>”的规则和下方的初始内容:
<$root>
<x>foo</x>
<y>bar[bom</y>
<z>bom]bar</z>
</$root>
现在想象一下,用户按下“引用块”按钮。通常情况下会出现一个<blockQuote>元素来封装两个选定的块<y>和<z>:
<$root>
<x>foo</x>
<blockQuote>
<y>bar[bom</y>
<z>bom]bar</z>
</blockQuote>
</$root>
然而,事实证明这样做会产生错误的结构:<x>后面不再有<y>。
那应该怎么办呢?至少有四种可能的解决方案:
- 在这种情况下,不应使用引用区块功能;
- 应在
<x>之后创建新的<y>; - 应将
<x>和<y>一起移到<blockQuote>中,反之亦然。
因此如果需要对编辑器实施此类规则,就应该通过 Model 的后期修复程序(Post-fixers)来修复不正确的内容,或者主动防止此类情况的发生(例如禁用某些功能)。这意味着这些约束将由额外代码专门为使用场景而定制,从而使其实施变得更加容易。
总而言之,谁应该以及如何实施附加约束的答案是:开发者所定制的功能,或者编辑器本身(通过 CKEditor 5 API)。
谁来检查模式?
CKEditor 5 API 提供了许多处理(更改)模型的方法。可以通过写入器(Writer)、Model::insertContent()等方法、指令(Command)等实现。
浅层 API
最低级的 API 是写入器(准确来讲下面还有原始操作,但它们只用于特殊情况)。它允许对内容进行原子更改,如插入、删除、移动或分割节点,设置和删除属性等。需要注意的是,写入器不会阻止应用违反模式中定义的规则的更改。
这么做的原因是:在执行 Command 或任意其它功能时,可能需要执行多个操作才能完成所有必要的更改。在此期间(在这些原子操作之间)的状态可能是不正确的。Writer 必须允许这种情况。例如,现在需要将<foo>从<$root>移动到<bar>,并(同时)将其重命名为<oof>。但模式定义不允许在<$root>中使用<oof>,也不允许在<bar>中使用<foo>。如果写入器检查了模式,那么无论重命名和移动操作的顺序如何,都会发生冲突。可以说:引擎可以通过在Model::change()块结束时检查 Schema 来处理这个问题(它的工作原理与事务类似:在结束时状态必须正确)。但这种方法没有被采用,因为存在以下问题:
- 如何在事务提交后修复内容?从用户的角度来看,不可能实现不破坏内容的合理启发式;
- 在实时协作变更过程中,模型可能会失效。操作转换(OT)是通过丰富的形式实现的,它能确保冲突解决和最终的一致性,但不能确保模型的有效性。
高层 API
其他更高级别的方法呢?我们建议建立在写入器(Writer)之上的所有 API 都应检查模式。
例如Model::insertContent()方法会确保插入节点在其插入位置是被允许的。如果要插入的元素是被允许的,它还会尝试拆分插入容器(如果模式允许)等操作。
同样地,如果一个指令不应该在当前位置执行,那么如果后续处理执行得当,那么这些指令也会被禁用。
最后,模式在从视图到模型的转换(Upcast)过程中起着至关重要的作用。在这个过程中,Converter 会决定是否可以将特定的视图元素或属性转换到模型中的指定位置。因此,如果试图将不正确的数据加载到编辑器中,或者粘贴从其他网站复制的内容时,数据的结构和属性会根据当前的模式规则进行调整。