本文假定您已经阅读了编辑引擎架构介绍中的“模式 ”部分。

快速回顾​

编辑器的模式提供于editor.model.schema属性。它定义了当前允许的模型结构(模型元素如何嵌套)、允许的属性(元素和文本节点的属性)以及其它特性(内联与分块、外部操作的原子性)。编辑功能(Editing Features)和编辑引擎(Editing Engine)随后会使用这些信息来决定如何处理模型,以及在哪里启用功能等等。

可以使用Schema#register()Schema#extend()方法来定义模式规则。前者只能对给定的项目名称使用一次,这就确保了只有一个编辑功能可以引入该项目。同样,extend()也只能用于已定义的项目。

使用Schema#checkChild()Schema#checkAttribute()方法可分别检查元素和属性。

允许结构声明​

当编辑功能(Editing Feature)引入一个 Model 元素时,应将其注册到 Schema 中。除了定义 Model 中可能存在这样的元素外,编辑功能还需要定义该元素的位置。这些信息由SchemaItemDefinitionallowIn属性提供:

JavaScript
schema.register( 'myElement', {
    allowIn: '$root'
} );

这允许模式知晓<myElement>可以是<$root>的子元素。$root元素是编辑框架定义的通用节点之一。默认情况下,编辑器会将主根元素命名为<$root>,因此上述定义允许<myElement>出现在主编辑器元素中。换句话说,这样做是正确的:

XML
<$root>
    <myElement></myElement>
</$root>

这么做则是不正确的:

XML
<$root>
    <foo>
        <myElement></myElement>
    </foo>
</$root>

要声明注册元素内部允许使用哪些节点,可以使用allowChildren属性:

JavaScript
schema.register( 'myElement', {
    allowIn: '$root',
    allowChildren: '$text'
} );

若如此做,则允许类似这样的结构:

XML
<$root>
    <myElement>
        foobar
    </myElement>
</$root>

属性allowInallowChildren也可以从其它SchemaItemDefinition项继承。

禁止结构声明​

模式除了允许某些结构外,还可用于确保明确禁止某些结构——可以通过使用不允许规则来实现。

通常情况下,可以使用disallowChildren属性。它可用于定义指定元素内部不允许使用的节点:

JavaScript
schema.register( 'myElement', {
    inheritAllFrom: '$block',
    disallowChildren: 'imageInline'
} );

在上面的示例中,新的自定义元素的行为应与所有块级元素(段落、标题等)相同,但不能在其中插入内联图片。

优先于允许规则​

一般来说,所有禁止规则的优先级都高于允许规则。如果把继承也考虑在内,规则的层次结构如下(从最高优先级开始):

  1. 来自元素自身定义的disallowChildren/disallowIn

  2. 继承自父元素定义的disallowChildren/disallowIn

禁止规则示例​

简单的禁止规则很容易理解,但当涉及到更复杂的规则时,情况就可能开始变得不清楚了。下面是一些示例,解释在涉及规则继承时,不允许是如何工作的。

JavaScript
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规则:

JavaScript
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的规则,而没有其它允许扩展子代在扩展父代中使用的规则。

当然,可以将allowIndisallowChildren混合使用,也可以将allowChildrendisallowIn混合使用。

最后可能会出现这样的情况:需要从一个已被禁止的项目继承,但新元素又应该被重新允许。在这种情况下,定义应如下所示:

JavaScript
schema.register( 'baseParent', { inheritAllFrom: 'paragraph', disallowChildren: [ 'imageInline' ] } );
schema.register( 'extendedParent', { inheritAllFrom: 'baseParent', allowChildren: [ 'imageInline' ] } );

这里的段落中允许使用imageInline,但baseParent中不允许使用。然而extendedParent将再次允许它,因为自己的定义比继承的定义更重要。

额外定义语义​

除了设置允许的结构外,模式还可以定义模型元素的其它特征。通过使用is*属性,开发者可以声明其它特征和引擎应如何处理某个元素。

此处的表格列出了在模式中注册的各种模型元素及其属性。原文下方的标注翻译如下:

  1. [1] 该元素的isLimit值为true,因为所有对象都是自动限制元素(Limit Elements);

  2. [2] 该元素的isSelectable值为true,因为所有对象都是自动可选元素(Selectable Elements);

  3. [3] 该元素的isContenttrue,因为所有对象都自动成为内容元素(Content Elements)。

限制元素​

考虑像图片标题这样的功能。标题文本区域应为某些内部操作构建一个边界:

  1. 从内部开始的选择不应在外部结束;

  2. BackspaceDelete键不应删除该区域。按Enter键不应分割该区域;

  3. 它还应作为外部操作的边界——主要是通过选择后修复程序(Selection post-fixer)来实现的,该程序可确保从外部开始的选择不应在内部结束。这意味着大多数操作要么会应用于此类元素的外部,要么会应用于其内部的内容。

考虑到这些特点,应使用isLimit属性将图片标题定义为限制元素。

JavaScript
schema.register( 'myCaption', {
    isLimit: true
} );

然后 Engine 和各种功能会通过Schema::isLimit()对其进行检查,并采取相应措施。

限制元素并不意味着可编辑元素可编辑元素的概念是为视图保留的,由EditableElement类表示。

对象元素​

对于上例中的图片标题:选择标题框,然后复制或拖动此框到其它地方并没有多大意义。

单单一个文本,而没有其所描述的图片,这样的标题意义不大。相比之下,图片则更能自给自足。大多数情况下,用户应该能够选择整个图片(及其所有内部结构),然后复制或移动它。应该使用isObject属性来标识这种行为。

JavaScript
schema.register( 'myImage', {
    isObject: true
} );

之后可以使用Schema::isObject()来检查该属性。通用blockObject$inlineObject对象的isObject属性也设置为true。大多数对象类型的项目都继承自$blockObject$inlineObject(通过inheritAllFrom)。此外,还有以下这些情况:

  1. 限制元素:对于每一个isObject设置为true的元素,Schema::isLimit(element)总是返回true

  2. 可选元素:对于每一个isObject设置为true的元素,Schema::isSelectable(element)总是返回true

  3. 内容元素:对于每一个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属性:

JavaScript
schema.register( 'mySelectable', {
    isSelectable: true
} );

之后可使用Schema::isSelectable()方法检查该属性。

默认情况下所有对象元素都是可选择的。不过编辑器中还注册了其它可选择元素。例如tableCell模型元素(在编辑视图中渲染为<td>),虽然它没有注册为对象,但也是可选择的。表格选择插件就是利用了这一点,从而允许用户创建由多个表格单元格组成的矩形选区。

内容元素​

可以通过编辑器数据中的内容模型元素(使用editor.getData()Model::hasContent()来查看)来区分内容模型元素和其它元素。

图片等媒体元素总是会被打入编辑器数据,因而它们都属于内容元素。它们在模式中被标记为isContent属性:

JavaScript
schema.register( 'myImage', {
    isContent: true
} );

之后可以使用Schema::isContent()方法来检查该属性。

段落、列表项或标题等元素并不是内容元素,因为当其为空时,编辑器输出器会跳过它们。从数据角度来看,除非它们包含其它内容元素,否则它们就是透明的(空段落和没有段落相等效)。

通用对象​

有几种内置通用对象:$root$container$block$blockObject$inlineObject$text。它们的定义如下:

JavaScript
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)功能将其对象定义为:

JavaScript
schema.register( 'paragraph', {
    inheritAllFrom: '$block'
} );

等效转译为:

JavaScript
schema.register( 'paragraph', {
    allowWhere: '$block',
    allowContentOf: '$block',
    allowAttributesOf: '$block',
    inheritTypesFrom: '$block'
} );

可以理解为:

  1. 在允许使用<$block>的元素(如<$root>)中,允许使用<paragraph>元素;

  2. <paragraph>元素允许使用<$block>中允许使用的所有节点(如$text);

  3. <paragraph>元素允许使用<$block>中允许使用的所有属性;

  4. <paragraph>元素将继承<$block>的所有is*属性(如isBlock)。

由于<paragraph>定义继承自<$block>,因此其它功能可以使用<$block>类型间接扩展<paragraph>定义。例如 BlockQuote 功能就是这么做的:

JavaScript
schema.register( 'blockQuote', {
    inheritAllFrom: '$container'
} );

由于<$block>允许在<$container>中使用(见schema.register( '$block' ...)),尽管引用区块(BlockQuote)和段落(Paragraph)功能互为独立,但段落会被允许在引用区块使用:模式规则允许链式连接。更进一步说,如果有人注册了<section>元素(使用allowContentOf: '$root'规则),由于<$root>中也允许使用<$container>(参阅schema.register( '$container' ...)),因此<section>元素也会立即允许使用引用区块。

通用项目之间的关系​

通用项目之间的关系(哪一个项目可以在哪里使用)可以通过以下抽象结构直观地体现出来:

XML
<$root>
    <$block>                <!-- 例如 <paragraph>, <heading1> -->
        <$text/>
        <$inlineObject/>    <!-- 例如 <imageInline> -->
    </$block>
    <$blockObject/>         <!-- 例如 <imageBlock>, <table> -->
    <$container>            <!-- 例如 <blockQuote> -->
        <$container/>
        <$block/>
        <$blockObject/>
    </$container>
</$root>

例如这样的模型内容就符合上述规则:

XML
<$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>

它反过来还包含这些语义:

XML
<$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()可以提供函数回调,用以实现检查模型结构的特定高级规则。可以提供仅在检查特定子代时触发的回调,也可以提供对模式执行的所有检查触发的通用回调。下面是一个特定回调的示例,该回调禁止在代码块内使用内联图片:

JavaScript
schema.addChildCheck( context => {
    if ( context.endsWith( 'codeBlock' ) ) {
        return false;
    }
}, 'imageInline' );

第二个参数'imageInline'指定只有在检查imageInline时才使用回调。

也可以使用回调强制允许指定项目。例如允许在任何地方使用特殊的$marker对象:

JavaScript
schema.addChildCheck( () => true, '$marker' );

注意回调可能返回truefalseundefined。如果返回truefalse,则表示已做出决定,将不再检查其它回调或声明规则,对应当前对象将被允许或不允许。如果没有返回值,则将进一步检查以决定是否允许该项目。

在某些情况下可能需要定义一个通用监听器,该监听器将在每次模式检查时触发。例如要禁止引用区块内的所有块级对象(如表格),可以定义以下回调:

JavaScript
schema.addChildCheck( ( context, childDefinition ) => {
    if ( context.endsWith( 'blockQuote' ) && childDefinition.isBlock && childDefinition.isObject ) {
        return false;
    }
} );

上述功能将在每次调用checkChild()时触发,从而提供更多灵活性。不过切记:若大量使用通用回调,可能会增加编辑器的性能开销。

属性检查​

可以定义回调来检查给定的对象是否允许使用给定属性,使用Schema::addAttributeCheck()提供回调。

例如允许在所有标题上使用自定义属性headingMarker

JavaScript
schema.addAttributeCheck( ( context, attributeName ) => {
    const isHeading = context.last.name.startsWith( 'heading' );
    
    if ( isHeading ) {
        return true;
    }
}, 'headingMarker' );

也可以使用通用回调。例如禁止对所有标题内的文本使用格式属性(如粗体或斜体):

JavaScript
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>”的规则和下方的初始内容:

XML
<$root>
    <x>foo</x>
    <y>bar[bom</y>
    <z>bom]bar</z>
</$root>

现在想象一下,用户按下“引用块”按钮。通常情况下会出现一个<blockQuote>元素来封装两个选定的块<y><z>

XML
<$root>
    <x>foo</x>
    <blockQuote>
        <y>bar[bom</y>
        <z>bom]bar</z>
    </blockQuote>
</$root>

然而,事实证明这样做会产生错误的结构:<x>后面不再有<y>

那应该怎么办呢?至少有四种可能的解决方案:

  1. 在这种情况下,不应使用引用区块功能;

  2. 应在<x>之后创建新的<y>

  3. 应将<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 来处理这个问题(它的工作原理与事务类似:在结束时状态必须正确)。但这种方法没有被采用,因为存在以下问题:

  1. 如何在事务提交后修复内容?从用户的角度来看,不可能实现不破坏内容的合理启发式;

  2. 在实时协作变更过程中,模型可能会失效。操作转换(OT)是通过丰富的形式实现的,它能确保冲突解决和最终的一致性,但不能确保模型的有效性。

因此,应选择使用更具表现力和灵活性的模型后置修正器(Model's Post-fixers)来逐个处理此类情况。此外还应将检查 Schema 的责任转移给开发者自己的功能上。他们可以在进行更改之前做出更好的决定。有关这方面的更多信息,请参阅上文的附加约束实现章节。

高层 API​

其他更高级别的方法呢?我们建议建立在写入器(Writer)之上的所有 API 都应检查模式。

例如Model::insertContent()方法会确保插入节点在其插入位置是被允许的。如果要插入的元素是被允许的,它还会尝试拆分插入容器(如果模式允许)等操作。

同样地,如果一个指令不应该在当前位置执行,那么如果后续处理执行得当,那么这些指令也会被禁用。

最后,模式在从视图到模型的转换(Upcast)过程中起着至关重要的作用。在这个过程中,Converter 会决定是否可以将特定的视图元素或属性转换到模型中的指定位置。因此,如果试图将不正确的数据加载到编辑器中,或者粘贴从其他网站复制的内容时,数据的结构和属性会根据当前的模式规则进行调整。