From 2d467d85b72654cd5b95998e069f23324dce7dd4 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 23 Apr 2026 14:34:17 +0200 Subject: [PATCH 01/16] Added api docs comments. --- packages/ckeditor5-core/src/editor/editor.ts | 8 ++++- .../docs/framework/deep-dive/schema.md | 31 +++++++++++++++++++ .../src/controller/datacontroller.ts | 14 +++++++++ .../src/conversion/upcastdispatcher.ts | 7 +++++ .../ckeditor5-engine/src/dev-utils/model.ts | 7 +++++ .../ckeditor5-engine/src/model/document.ts | 6 ++++ packages/ckeditor5-engine/src/model/writer.ts | 6 ++++ 7 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-core/src/editor/editor.ts b/packages/ckeditor5-core/src/editor/editor.ts index b853616c4f4..5e8a4adf05e 100644 --- a/packages/ckeditor5-core/src/editor/editor.ts +++ b/packages/ckeditor5-core/src/editor/editor.ts @@ -953,12 +953,18 @@ export abstract class Editor extends /* #__PURE__ */ ObservableMixin() { * Registers a given string as a root attribute key. Registered root attributes are added to * the {@link module:engine/model/schema~ModelSchema schema}. * - * Note: Attributes passed in the configuration for multi-root editors + * **Note:** Attributes passed in the configuration for multi-root editors * ({@link module:core/editor/editorconfig~EditorConfig#roots `config.roots..modelAttributes`}) or * single-root editors ({@link module:core/editor/editorconfig~EditorConfig#root `config.root.modelAttributes`}) * are automatically registered when the editor is initialized. However, registering the same attribute twice * does not have any negative impact, so it is recommended to use this method in any feature that uses * root attributes. + * + * **Note:** Registered attributes are attached only to the generic `$root` schema element. A custom root + * {@link module:core/editor/editorconfig~RootConfig#modelElement `modelElement`} must opt into the `$root` + * attribute chain via `allowAttributesOf: '$root'` to inherit these attributes. + * See the {@glink framework/deep-dive/schema#custom-root-elements Custom root elements} section of the + * {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. */ public registerRootAttribute( key: string ): void { if ( this._registeredRootsAttributesKeys.has( key ) ) { diff --git a/packages/ckeditor5-engine/docs/framework/deep-dive/schema.md b/packages/ckeditor5-engine/docs/framework/deep-dive/schema.md index a8bd608e6cf..90b113a63e9 100644 --- a/packages/ckeditor5-engine/docs/framework/deep-dive/schema.md +++ b/packages/ckeditor5-engine/docs/framework/deep-dive/schema.md @@ -765,6 +765,37 @@ Which, in turn, has these [semantics](#defining-additional-semantics): ``` +### Custom root elements + +The generic `$root` / `$container` / `$block` chain described above is keyed on the element name `$root`. By default, a root is created as `<$root>`, so every rule of the form `allowIn: '$root'` or `allowAttributesOf: '$root'` applies to it automatically. + +You can change the element name used for a root via the {@link module:core/editor/editorconfig~RootConfig#modelElement `config.root.modelElement`} (or `config.roots..modelElement` for the {@link module:editor-multi-root/multirooteditor~MultiRootEditor multi-root editor}) option. When you do, the created root is `` instead of `<$root>`, and **it does not automatically inherit the `$root` chain**. Features that define their elements as `allowIn: '$root'`, `allowContentOf: '$root'`, or `allowAttributesOf: '$root'` will not apply to the custom root unless you opt in. + +Declare the custom root in the schema and pick which parts of the `$root` chain you want: + +```js +// Inherit everything $root provides - allowed children, attributes, and is* flags. +schema.register( 'myRoot', { + inheritAllFrom: '$root', + allowChildren: [ '$container', '$block' ] +} ); + +// Or opt in selectively - e.g. only inherit attributes (the `$inlineRoot` pattern). +schema.register( 'myInlineRoot', { + allowContentOf: '$block', + allowAttributesOf: '$root', + isLimit: true +} ); +``` + +Key rules to remember for custom roots: + +* **Block / container content.** If the root should accept the generic block chain, declare `allowChildren: [ '$container', '$block' ]` (or `inheritAllFrom: '$root'`). Otherwise `$block`-based elements like `` and `$container`-based elements like `
` will not be allowed inside it. +* **Root attributes.** Attributes registered via {@link module:core/editor/editor~Editor#registerRootAttribute `editor.registerRootAttribute()`} are attached only to the `$root` schema element. Custom roots must opt into this chain via `allowAttributesOf: '$root'` to receive them. +* **Data conversion context.** When calling {@link module:engine/controller/datacontroller~DataController#parse `editor.data.parse()`} or {@link module:engine/controller/datacontroller~DataController#toModel `editor.data.toModel()`} against a custom root, pass the target root element (or its configured model element name) as the `context` argument. The default `'$root'` only matches the generic root and can produce wrong conversion results. + +The engine ships with one ready-made custom root definition - `$inlineRoot` - for roots that should only contain inline content. You can use it out of the box via `config.root.modelElement: '$inlineRoot'` without adding your own schema registration. + ## Defining advanced rules using callbacks The base {@link module:engine/model/schema~ModelSchemaItemDefinition declarative `SchemaItemDefinition` API} is by its nature limited, and some custom rules might not be possible to be implemented this way. diff --git a/packages/ckeditor5-engine/src/controller/datacontroller.ts b/packages/ckeditor5-engine/src/controller/datacontroller.ts index 26e6a0565a7..5a4c1837a2e 100644 --- a/packages/ckeditor5-engine/src/controller/datacontroller.ts +++ b/packages/ckeditor5-engine/src/controller/datacontroller.ts @@ -458,6 +458,13 @@ export class DataController extends /* #__PURE__ */ EmitterMixin() { * Returns the data parsed by the {@link #processor data processor} and then converted by upcast converters * attached to the {@link #upcastDispatcher}. * + * **Note:** The default `context` value is `'$root'`, which only matches the generic root. When the editor uses a + * custom root {@link module:core/editor/editorconfig~RootConfig#modelElement `modelElement`}, pass the target + * {@link module:engine/model/rootelement~ModelRootElement root element} (or its configured model element name) + * explicitly, otherwise the conversion result may be wrong. + * See the {@glink framework/deep-dive/schema#custom-root-elements Custom root elements} section of the + * {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. + * * @see #set * @param data Data to parse. * @param context Base context in which the view will be converted to the model. @@ -480,6 +487,13 @@ export class DataController extends /* #__PURE__ */ EmitterMixin() { * When marker elements were converted during the conversion process, it will be set as a document fragment's * {@link module:engine/model/documentfragment~ModelDocumentFragment#markers static markers map}. * + * **Note:** The default `context` value is `'$root'`, which only matches the generic root. When the editor uses a + * custom root {@link module:core/editor/editorconfig~RootConfig#modelElement `modelElement`}, pass the target + * {@link module:engine/model/rootelement~ModelRootElement root element} (or its configured model element name) + * explicitly, otherwise the conversion result may be wrong. + * See the {@glink framework/deep-dive/schema#custom-root-elements Custom root elements} section of the + * {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. + * * @fires toModel * @param viewElementOrFragment The element or document fragment whose content will be converted. * @param context Base context in which the view will be converted to the model. diff --git a/packages/ckeditor5-engine/src/conversion/upcastdispatcher.ts b/packages/ckeditor5-engine/src/conversion/upcastdispatcher.ts index 4c1a26f4fc3..ccd42dc2353 100644 --- a/packages/ckeditor5-engine/src/conversion/upcastdispatcher.ts +++ b/packages/ckeditor5-engine/src/conversion/upcastdispatcher.ts @@ -184,6 +184,13 @@ export class UpcastDispatcher extends /* #__PURE__ */ EmitterMixin() { /** * Starts the conversion process. The entry point for the conversion. * + * **Note:** The default `context` value is `[ '$root' ]`, which only matches the generic root. When the editor uses + * a custom root {@link module:core/editor/editorconfig~RootConfig#modelElement `modelElement`}, pass the target + * {@link module:engine/model/rootelement~ModelRootElement root element} (or its configured model element name) + * explicitly, otherwise the conversion result may be wrong. + * See the {@glink framework/deep-dive/schema#custom-root-elements Custom root elements} section of the + * {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. + * * @fires element * @fires text * @fires documentFragment diff --git a/packages/ckeditor5-engine/src/dev-utils/model.ts b/packages/ckeditor5-engine/src/dev-utils/model.ts index 1c049202ee3..d383a739f40 100644 --- a/packages/ckeditor5-engine/src/dev-utils/model.ts +++ b/packages/ckeditor5-engine/src/dev-utils/model.ts @@ -356,6 +356,13 @@ export function _stringifyModel( * <$text attribute="value">Text data * ``` * + * **Note:** The default `options.context` value is `'$root'`, which only matches the generic root. When the editor + * uses a custom root {@link module:core/editor/editorconfig~RootConfig#modelElement `modelElement`}, pass the target + * {@link module:engine/model/rootelement~ModelRootElement root element} (or its configured model element name) + * explicitly, otherwise the conversion result may be wrong. + * See the {@glink framework/deep-dive/schema#custom-root-elements Custom root elements} section of the + * {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. + * * @param data HTML-like string to be parsed. * @param schema A schema instance used by converters for element validation. * @param options Additional configuration. diff --git a/packages/ckeditor5-engine/src/model/document.ts b/packages/ckeditor5-engine/src/model/document.ts index 9e1aa483454..02adc27763e 100644 --- a/packages/ckeditor5-engine/src/model/document.ts +++ b/packages/ckeditor5-engine/src/model/document.ts @@ -227,6 +227,12 @@ export class ModelDocument extends /* #__PURE__ */ EmitterMixin() { * **Note:** do not use this method after the editor has been initialized! If you want to dynamically add a root, use * {@link module:engine/model/writer~ModelWriter#addRoot `model.Writer#addRoot`} instead. * + * **Note:** The default `elementName` value is `'$root'`. When the editor uses a custom root + * {@link module:core/editor/editorconfig~RootConfig#modelElement `modelElement`}, pass the configured model element + * name explicitly so the created root matches the schema the features expect. + * See the {@glink framework/deep-dive/schema#custom-root-elements Custom root elements} section of the + * {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. + * * @param elementName The element name. Defaults to `'$root'` which also has some basic schema defined * (e.g. `$block` elements are allowed inside the `$root`). Make sure to define a proper schema if you use a different name. * @param rootName A unique root name. diff --git a/packages/ckeditor5-engine/src/model/writer.ts b/packages/ckeditor5-engine/src/model/writer.ts index b1177aa8c76..ee1b234a86f 100644 --- a/packages/ckeditor5-engine/src/model/writer.ts +++ b/packages/ckeditor5-engine/src/model/writer.ts @@ -1335,6 +1335,12 @@ export class ModelWriter { * * Throws an error, if trying to add a root that is already added and attached. * + * **Note:** The default `elementName` value is `'$root'`. When the editor uses a custom root + * {@link module:core/editor/editorconfig~RootConfig#modelElement `modelElement`}, pass the configured model element + * name explicitly so the added root matches the schema the features expect. + * See the {@glink framework/deep-dive/schema#custom-root-elements Custom root elements} section of the + * {@glink framework/deep-dive/schema Schema deep-dive} guide for more details. + * * @param rootName Name of the added root. * @param elementName The element name. Defaults to `'$root'` which also has some basic schema defined * (e.g. `$block` elements are allowed inside the `$root`). Make sure to define a proper schema if you use a different name. From e04d95ea0dfa76b63a9df61b947d71bfc2ecfcdd Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 23 Apr 2026 15:57:42 +0200 Subject: [PATCH 02/16] Updated html comments schema check. --- .../ckeditor5-html-support/src/htmlcomment.ts | 10 ++- .../tests/htmlcomment.js | 69 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/ckeditor5-html-support/src/htmlcomment.ts b/packages/ckeditor5-html-support/src/htmlcomment.ts index 20c013bcf66..d59da635b6d 100644 --- a/packages/ckeditor5-html-support/src/htmlcomment.ts +++ b/packages/ckeditor5-html-support/src/htmlcomment.ts @@ -40,9 +40,15 @@ export class HtmlComment extends Plugin { editor.data.processor.skipComments = false; - // Allow storing comment's content as the $root attribute with the name `$comment:`. + // Declare the `$comment` attribute on `$root`. Custom roots that opt into the `$root` + // attribute chain via `allowAttributesOf: '$root'` get comment support automatically. + editor.model.schema.extend( '$root', { allowAttributes: '$comment' } ); + + // Allow storing comment's content as a root attribute with the name `$comment:`. + // Gate the per-comment attribute on the root already allowing the generic `$comment` attribute + // so the rule works for any root name. editor.model.schema.addAttributeCheck( ( context, attributeName ) => { - if ( context.endsWith( '$root' ) && attributeName.startsWith( '$comment' ) ) { + if ( attributeName.startsWith( '$comment:' ) && editor.model.schema.checkAttribute( context, '$comment' ) ) { return true; } } ); diff --git a/packages/ckeditor5-html-support/tests/htmlcomment.js b/packages/ckeditor5-html-support/tests/htmlcomment.js index 123ce70e9e7..6b470a23ed5 100644 --- a/packages/ckeditor5-html-support/tests/htmlcomment.js +++ b/packages/ckeditor5-html-support/tests/htmlcomment.js @@ -55,6 +55,75 @@ describe( 'HtmlComment', () => { expect( editor.getData() ).to.equal( '

Foo

' ); } ); } ); + + it( 'should declare the $comment attribute on $root so custom roots inheriting its attributes pick it up', () => { + model.schema.register( 'myRoot', { + inheritAllFrom: '$root' + } ); + + expect( model.schema.checkAttribute( [ 'myRoot' ], '$comment' ) ).to.be.true; + expect( model.schema.checkAttribute( [ 'myRoot' ], '$comment:abc123' ) ).to.be.true; + } ); + + it( 'should allow the per-comment attribute on a custom root that only opts into $root attributes', () => { + model.schema.register( 'myAttrRoot', { + allowAttributesOf: '$root' + } ); + + expect( model.schema.checkAttribute( [ 'myAttrRoot' ], '$comment' ) ).to.be.true; + expect( model.schema.checkAttribute( [ 'myAttrRoot' ], '$comment:abc123' ) ).to.be.true; + } ); + + it( 'should not allow the $comment attribute on a root that does not opt into $root attributes', () => { + model.schema.register( 'isolatedRoot', { + isLimit: true + } ); + + expect( model.schema.checkAttribute( [ 'isolatedRoot' ], '$comment' ) ).to.be.false; + expect( model.schema.checkAttribute( [ 'isolatedRoot' ], '$comment:abc123' ) ).to.be.false; + } ); + } ); + + describe( '$inlineRoot editor', () => { + let inlineEditor, inlineRoot; + + beforeEach( async () => { + inlineEditor = await VirtualTestEditor.create( { + plugins: [ HtmlComment ], + root: { modelElement: '$inlineRoot' } + } ); + + inlineRoot = inlineEditor.model.document.getRoot(); + } ); + + afterEach( () => { + return inlineEditor.destroy(); + } ); + + it( 'should preserve HTML comments around inline text through a setData/getData round trip', () => { + inlineEditor.setData( 'FooBar' ); + + expect( inlineEditor.getData() ).to.equal( 'FooBar' ); + } ); + + it( 'should store each HTML comment content as a $comment: attribute on the $inlineRoot', () => { + inlineEditor.setData( 'Foo' ); + + const commentAttributes = [ ...inlineRoot.getAttributeKeys() ] + .filter( attr => attr.startsWith( '$comment:' ) ) + .map( attr => inlineRoot.getAttribute( attr ) ); + + expect( commentAttributes ).to.have.members( [ ' alpha ', ' beta ' ] ); + } ); + + it( 'should create a $comment marker for each HTML comment upcast inside the $inlineRoot', () => { + inlineEditor.setData( 'FooBar' ); + + const commentMarkers = [ ...inlineEditor.model.markers ].filter( marker => marker.name.startsWith( '$comment:' ) ); + + expect( commentMarkers ).to.have.length( 1 ); + expect( commentMarkers[ 0 ].getStart().root ).to.equal( inlineRoot ); + } ); } ); describe( 'upcast conversion', () => { From e67f215a12d038b94c9787fd718bda0c5ca5c4ec Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 23 Apr 2026 16:51:12 +0200 Subject: [PATCH 03/16] Updated GHS schema for hgroup element so it does not hardcode $root. --- .../src/schemadefinitions.ts | 2 +- .../tests/integrations/heading.js | 136 +++++++++++++++++- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/packages/ckeditor5-html-support/src/schemadefinitions.ts b/packages/ckeditor5-html-support/src/schemadefinitions.ts index 5a8dd9bc2eb..41c54d10fd2 100644 --- a/packages/ckeditor5-html-support/src/schemadefinitions.ts +++ b/packages/ckeditor5-html-support/src/schemadefinitions.ts @@ -358,7 +358,7 @@ export const defaultConfig = { model: 'htmlHgroup', view: 'hgroup', modelSchema: { - allowIn: [ '$root', '$container' ], + allowWhere: '$container', allowChildren: [ 'paragraph', 'htmlP', diff --git a/packages/ckeditor5-html-support/tests/integrations/heading.js b/packages/ckeditor5-html-support/tests/integrations/heading.js index 658dbe72542..b8b1c1ae09a 100644 --- a/packages/ckeditor5-html-support/tests/integrations/heading.js +++ b/packages/ckeditor5-html-support/tests/integrations/heading.js @@ -4,8 +4,11 @@ */ import { ClassicTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; -import { _getViewData } from '@ckeditor/ckeditor5-engine'; +import { Plugin } from '@ckeditor/ckeditor5-core'; +import { _getModelData, _getViewData } from '@ckeditor/ckeditor5-engine'; import { HeadingEditing } from '@ckeditor/ckeditor5-heading'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; import { GeneralHtmlSupport } from '../../src/generalhtmlsupport.js'; import { getModelDataWithAttributes } from '../_utils/utils.js'; import { HeadingElementSupport } from '../../src/integrations/heading.js'; @@ -89,7 +92,7 @@ describe( 'HeadingElementSupport', () => { model: 'htmlHgroup', view: 'hgroup', modelSchema: { - allowIn: [ '$root', '$container' ], + allowWhere: '$container', allowChildren: [ 'paragraph', 'htmlP', @@ -521,7 +524,7 @@ describe( 'HeadingElementSupport', () => { model: 'htmlHgroup', view: 'hgroup', modelSchema: { - allowIn: [ '$root', '$container' ], + allowWhere: '$container', allowChildren: [ 'paragraph', 'htmlP', @@ -943,4 +946,131 @@ describe( 'HeadingElementSupport', () => { ); } ); } ); + + describe( 'htmlHgroup placement (allowWhere: $container)', () => { + async function createEditorWithPlugins( plugins, config = {} ) { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, HeadingEditing, GeneralHtmlSupport, ...plugins ], + ...config + } ); + + editor.plugins.get( 'DataFilter' ).loadAllowedConfig( [ { + name: /^(hgroup|h1|h2|h3|h4|h5|h6)$/, + attributes: true + } ] ); + + model = editor.model; + } + + it( 'should upcast hgroup placed directly in $root to htmlHgroup', async () => { + await createEditorWithPlugins( [] ); + + editor.setData( '

Sub

Title

' ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + 'Sub' + + 'Title' + + '' + ); + } ); + + it( 'should downcast htmlHgroup back to hgroup when placed in $root', async () => { + await createEditorWithPlugins( [] ); + + const html = '

Sub

Title

'; + + editor.setData( html ); + + expect( editor.getData() ).to.equal( html ); + } ); + + it( 'should allow hgroup nested inside a $container-based element (blockQuote)', async () => { + await createEditorWithPlugins( [ BlockQuote ] ); + + editor.setData( + '
' + + '

Sub

Title

' + + '

Foo

' + + '
' + ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '
' + + '' + + 'Sub' + + 'Title' + + '' + + 'Foo' + + '
' + ); + } ); + + it( 'should round-trip hgroup nested inside a blockQuote through setData/getData', async () => { + await createEditorWithPlugins( [ BlockQuote ] ); + + const html = + '
' + + '

Sub

Title

' + + '

Foo

' + + '
'; + + editor.setData( html ); + + expect( editor.getData() ).to.equal( html ); + } ); + + it( 'should allow hgroup in a custom root that opts into the $container chain', async () => { + class CustomRootSchema extends Plugin { + init() { + this.editor.model.schema.register( 'myRoot', { + inheritAllFrom: '$root' + } ); + + // Opt the custom root into the generic container chain so anything declaring + // `allowWhere: '$container'` (htmlHgroup, block-level GHS wrappers, etc.) can + // land inside it. + this.editor.model.schema.extend( '$container', { allowIn: 'myRoot' } ); + this.editor.model.schema.extend( '$block', { allowIn: 'myRoot' } ); + } + } + + await createEditorWithPlugins( [ CustomRootSchema ], { + root: { modelElement: 'myRoot' } + } ); + + editor.setData( '

Sub

Title

' ); + + // `setData` triggers DataFilter's deferred element registration, so the schema check is valid afterwards. + expect( model.schema.checkChild( [ 'myRoot' ], 'htmlHgroup' ) ).to.be.true; + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + 'Sub' + + 'Title' + + '' + ); + + expect( editor.getData() ).to.equal( '

Sub

Title

' ); + } ); + + it( 'should not allow hgroup in a custom root that does not opt into the $container chain', async () => { + class IsolatedRootSchema extends Plugin { + init() { + this.editor.model.schema.register( 'isolatedRoot', { + isLimit: true + } ); + } + } + + await createEditorWithPlugins( [ IsolatedRootSchema ], { + root: { modelElement: 'isolatedRoot' } + } ); + + expect( model.schema.checkChild( [ 'isolatedRoot' ], 'htmlHgroup' ) ).to.be.false; + } ); + } ); } ); From 81005518e286510efeec146e44604c8153e2b5d1 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 23 Apr 2026 16:58:50 +0200 Subject: [PATCH 04/16] The Title feature should not hardcode $root. --- packages/ckeditor5-heading/src/title.ts | 63 ++++++++++------- .../tests/title-integration.js | 52 ++++++++++++++ packages/ckeditor5-heading/tests/title.js | 68 +++++++++++++++++++ 3 files changed, 157 insertions(+), 26 deletions(-) diff --git a/packages/ckeditor5-heading/src/title.ts b/packages/ckeditor5-heading/src/title.ts index fb4556da3da..e2d0ec12ed8 100644 --- a/packages/ckeditor5-heading/src/title.ts +++ b/packages/ckeditor5-heading/src/title.ts @@ -21,8 +21,6 @@ import { type MapperModelToViewPositionEvent, type Model, type ModelRootElement, - type UpcastConversionApi, - type UpcastConversionData, type UpcastElementEvent, type EditingView, type ViewElement, @@ -83,7 +81,14 @@ export class Title extends Plugin { // // // See: https://github.com/ckeditor/ckeditor5/issues/2005. - model.schema.register( 'title', { isBlock: true, allowIn: '$root' } ); + // Collect every configured root model element name so the `title` schema entry and the + // upcast converter below keep working when `config.root.modelElement` (or any + // `config.roots..modelElement`) is customized. + const rootModelElements = new Set( + Object.values( editor.config.get( 'roots' )! ).map( rootConfig => rootConfig.modelElement! ) + ); + + model.schema.register( 'title', { isBlock: true, allowIn: Array.from( rootModelElements ) } ); model.schema.register( 'title-content', { isBlock: true, allowIn: 'title', allowAttributes: [ 'alignment' ] } ); model.schema.extend( '$text', { allowIn: 'title-content' } ); @@ -110,9 +115,11 @@ export class Title extends Plugin { // Custom converter is used for data v -> m conversion to avoid calling post-fixer when setting data. // See https://github.com/ckeditor/ckeditor5/issues/2036. - editor.data.upcastDispatcher.on( 'element:h1', dataViewModelH1Insertion, { priority: 'high' } ); - editor.data.upcastDispatcher.on( 'element:h2', dataViewModelH1Insertion, { priority: 'high' } ); - editor.data.upcastDispatcher.on( 'element:h3', dataViewModelH1Insertion, { priority: 'high' } ); + const h1Converter = dataViewModelH1Insertion( rootModelElements ); + + editor.data.upcastDispatcher.on( 'element:h1', h1Converter, { priority: 'high' } ); + editor.data.upcastDispatcher.on( 'element:h2', h1Converter, { priority: 'high' } ); + editor.data.upcastDispatcher.on( 'element:h3', h1Converter, { priority: 'high' } ); // Take care about correct `title` element structure. model.document.registerPostFixer( writer => this._fixTitleContent( writer ) ); @@ -469,36 +476,40 @@ export class Title extends Plugin { } /** - * A view-to-model converter for the h1 that appears at the beginning of the document (a title element). + * Creates a view-to-model converter for the h1 that appears at the beginning of the document (a title element). + * + * The upcast context element is named after the editor's configured root `modelElement`, so the converter matches + * the parent against every known root model element name rather than the literal `$root`. * * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element - * @param evt An object containing information about the fired event. - * @param data An object containing conversion input, a placeholder for conversion output and possibly other values. - * @param conversionApi Conversion interface to be used by the callback. + * @param rootModelElements Set of root element names the title plugin is scoped to. */ -function dataViewModelH1Insertion( evt: unknown, data: UpcastConversionData, conversionApi: UpcastConversionApi ) { - const modelCursor = data.modelCursor; - const viewItem = data.viewItem; +function dataViewModelH1Insertion( rootModelElements: Set ): GetCallback { + return ( evt, data, conversionApi ) => { + const modelCursor = data.modelCursor; + const viewItem = data.viewItem; + const parent = modelCursor.parent; - if ( !modelCursor.isAtStart || !modelCursor.parent.is( 'element', '$root' ) ) { - return; - } + if ( !modelCursor.isAtStart || !parent.is( 'element' ) || !rootModelElements.has( parent.name ) ) { + return; + } - if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) { - return; - } + if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) { + return; + } - const modelWriter = conversionApi.writer; + const modelWriter = conversionApi.writer; - const title = modelWriter.createElement( 'title' ); - const titleContent = modelWriter.createElement( 'title-content' ); + const title = modelWriter.createElement( 'title' ); + const titleContent = modelWriter.createElement( 'title-content' ); - modelWriter.append( titleContent, title ); - modelWriter.insert( title, modelCursor ); + modelWriter.append( titleContent, title ); + modelWriter.insert( title, modelCursor ); - conversionApi.convertChildren( viewItem, titleContent ); + conversionApi.convertChildren( viewItem, titleContent ); - conversionApi.updateConversionResult( title, data ); + conversionApi.updateConversionResult( title, data ); + }; } /** diff --git a/packages/ckeditor5-heading/tests/title-integration.js b/packages/ckeditor5-heading/tests/title-integration.js index 3774a797437..00456469c6e 100644 --- a/packages/ckeditor5-heading/tests/title-integration.js +++ b/packages/ckeditor5-heading/tests/title-integration.js @@ -9,6 +9,7 @@ import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; import { Enter } from '@ckeditor/ckeditor5-enter'; import { Bold } from '@ckeditor/ckeditor5-basic-styles'; +import { Plugin } from '@ckeditor/ckeditor5-core'; import { ClassicTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; import { _getModelData } from '@ckeditor/ckeditor5-engine'; @@ -98,3 +99,54 @@ describe( 'Title integration with multi root editor', () => { expect( barModelRoot.isEmpty ).to.be.true; } ); } ); + +describe( 'Title integration with multi root editor and custom root modelElement', () => { + let multiRoot; + + class CustomRootsSchema extends Plugin { + init() { + this.editor.model.schema.register( 'myRootA', { + inheritAllFrom: '$root', + allowChildren: [ '$container', '$block' ] + } ); + this.editor.model.schema.register( 'myRootB', { + inheritAllFrom: '$root', + allowChildren: [ '$container', '$block' ] + } ); + } + } + + beforeEach( async () => { + multiRoot = await MultiRootEditor.create( {}, { + plugins: [ CustomRootsSchema, Paragraph, Heading, Enter, Title ], + roots: { + foo: { + modelElement: 'myRootA', + initialData: '

FooTitle

Foo

' + }, + bar: { + modelElement: 'myRootB', + initialData: '

BarTitle

Bar

' + } + } + } ); + } ); + + afterEach( async () => { + await multiRoot.destroy(); + } ); + + it( 'should allow title inside every configured custom root model element', () => { + const schema = multiRoot.model.schema; + + expect( schema.checkChild( 'myRootA', 'title' ) ).to.equal( true ); + expect( schema.checkChild( 'myRootB', 'title' ) ).to.equal( true ); + } ); + + it( 'should upcast h1 to title at the start of every configured custom root', () => { + const titlePlugin = multiRoot.plugins.get( Title ); + + expect( titlePlugin.getTitle( { rootName: 'foo' } ) ).to.equal( 'FooTitle' ); + expect( titlePlugin.getTitle( { rootName: 'bar' } ) ).to.equal( 'BarTitle' ); + } ); +} ); diff --git a/packages/ckeditor5-heading/tests/title.js b/packages/ckeditor5-heading/tests/title.js index f9ce92c1553..8c038ec303f 100644 --- a/packages/ckeditor5-heading/tests/title.js +++ b/packages/ckeditor5-heading/tests/title.js @@ -5,6 +5,7 @@ import { ClassicTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import { Plugin } from '@ckeditor/ckeditor5-core'; import { Title } from '../src/title.js'; import { Heading } from '../src/heading.js'; import { Enter } from '@ckeditor/ckeditor5-enter'; @@ -944,6 +945,73 @@ describe( 'Title', () => { ); } ); } ); + + describe( 'with a custom root modelElement', () => { + let customElement, customEditor, customModel; + + class CustomRootSchema extends Plugin { + init() { + this.editor.model.schema.register( 'myRoot', { + inheritAllFrom: '$root', + allowChildren: [ '$container', '$block' ] + } ); + } + } + + beforeEach( async () => { + customElement = document.createElement( 'div' ); + document.body.appendChild( customElement ); + + customEditor = await ClassicTestEditor.create( customElement, { + plugins: [ CustomRootSchema, Paragraph, Title, Heading ], + root: { modelElement: 'myRoot' } + } ); + customModel = customEditor.model; + } ); + + afterEach( async () => { + await customEditor.destroy(); + customElement.remove(); + } ); + + it( 'should register title as an allowed child of the configured custom root element', () => { + expect( customModel.schema.checkChild( 'myRoot', 'title' ) ).to.equal( true ); + } ); + + it( 'should not register title as an allowed child of the generic $root when a custom root is configured', () => { + expect( customModel.schema.checkChild( '$root', 'title' ) ).to.equal( false ); + } ); + + it( 'should convert h1 at the start of a custom root to title on setData', () => { + customEditor.setData( '

Foo

Bar

' ); + + expect( _getModelData( customModel ) ).to.equal( + '<title-content>[]Foo</title-content>' + + 'Bar' + ); + } ); + + it( 'should convert h1 to title in data.parse() when the configured root is passed as context', () => { + const modelFrag = customEditor.data.parse( + '

Foo

Bar

', + customModel.document.getRoot( 'main' ) + ); + + expect( _stringifyModel( modelFrag ) ).to.equal( + '<title-content>Foo</title-content>' + + 'Bar' + ); + } ); + + it( 'should not convert h1 to title in data.parse() when the default $root context is used for a custom root', () => { + const modelFrag = customEditor.data.parse( '

Foo

Bar

' ); + + expect( _stringifyModel( modelFrag ) ).to.equal( + 'Foo' + + 'Bar' + ); + } ); + } ); } ); function getEventData( keyCode, { shiftKey = false } = {} ) { From a62fcb110d6d54f9a85757c0c3e902e71f30e1f0 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 23 Apr 2026 18:15:18 +0200 Subject: [PATCH 05/16] Reworked title feature for inline roots. --- packages/ckeditor5-heading/src/title.ts | 140 ++++++++++++------ .../tests/title-integration.js | 115 ++++++++++---- packages/ckeditor5-heading/tests/title.js | 120 +++++++++------ 3 files changed, 258 insertions(+), 117 deletions(-) diff --git a/packages/ckeditor5-heading/src/title.ts b/packages/ckeditor5-heading/src/title.ts index e2d0ec12ed8..8cfc36052c0 100644 --- a/packages/ckeditor5-heading/src/title.ts +++ b/packages/ckeditor5-heading/src/title.ts @@ -21,6 +21,8 @@ import { type MapperModelToViewPositionEvent, type Model, type ModelRootElement, + type UpcastConversionApi, + type UpcastConversionData, type UpcastElementEvent, type EditingView, type ViewElement, @@ -81,14 +83,14 @@ export class Title extends Plugin { // // // See: https://github.com/ckeditor/ckeditor5/issues/2005. - // Collect every configured root model element name so the `title` schema entry and the - // upcast converter below keep working when `config.root.modelElement` (or any - // `config.roots..modelElement`) is customized. - const rootModelElements = new Set( - Object.values( editor.config.get( 'roots' )! ).map( rootConfig => rootConfig.modelElement! ) - ); - - model.schema.register( 'title', { isBlock: true, allowIn: Array.from( rootModelElements ) } ); + // + // Title is scoped to roots whose `modelElement` is the generic `$root`. Custom root + // `modelElement` names (including `$inlineRoot`) are intentionally not supported: + // the title structure (`title` + `title-content` + paragraph body placeholder) relies on + // the root accepting `$block` content, which is not guaranteed for custom or inline roots. + // Runtime codepaths below additionally guard on `schema.checkChild( root, 'title' )` so + // the plugin gracefully no-ops on roots where the schema does not allow the title element. + model.schema.register( 'title', { isBlock: true, allowIn: '$root' } ); model.schema.register( 'title-content', { isBlock: true, allowIn: 'title', allowAttributes: [ 'alignment' ] } ); model.schema.extend( '$text', { allowIn: 'title-content' } ); @@ -115,11 +117,9 @@ export class Title extends Plugin { // Custom converter is used for data v -> m conversion to avoid calling post-fixer when setting data. // See https://github.com/ckeditor/ckeditor5/issues/2036. - const h1Converter = dataViewModelH1Insertion( rootModelElements ); - - editor.data.upcastDispatcher.on( 'element:h1', h1Converter, { priority: 'high' } ); - editor.data.upcastDispatcher.on( 'element:h2', h1Converter, { priority: 'high' } ); - editor.data.upcastDispatcher.on( 'element:h3', h1Converter, { priority: 'high' } ); + editor.data.upcastDispatcher.on( 'element:h1', dataViewModelH1Insertion, { priority: 'high' } ); + editor.data.upcastDispatcher.on( 'element:h2', dataViewModelH1Insertion, { priority: 'high' } ); + editor.data.upcastDispatcher.on( 'element:h3', dataViewModelH1Insertion, { priority: 'high' } ); // Take care about correct `title` element structure. model.document.registerPostFixer( writer => this._fixTitleContent( writer ) ); @@ -155,7 +155,13 @@ export class Title extends Plugin { public getTitle( options: Record = {} ): string { const rootName = options.rootName ? options.rootName as string : undefined; const titleElement = this._getTitleElement( rootName ); - const titleContentElement = titleElement!.getChild( 0 ) as ModelElement; + + // Root does not support the title structure (custom/inline root) — nothing to stringify. + if ( !titleElement ) { + return ''; + } + + const titleContentElement = titleElement.getChild( 0 ) as ModelElement; return this.editor.data.stringify( titleContentElement, options ); } @@ -177,6 +183,20 @@ export class Title extends Plugin { const model = editor.model; const rootName = options.rootName ? options.rootName as string : undefined; const root = editor.model.document.getRoot( rootName )!; + + // Root does not support the title structure (custom/inline root) — the whole root is the body. + // Delegate to the regular data getter so mixed-root callers receive useful content. + if ( !model.schema.checkChild( root, 'title' ) ) { + return data.get( { ...options, rootName: root.rootName } ); + } + + // Root is empty / missing the expected title element (e.g. detached root or transient state) — no body to stringify. + const firstChild = root.getChild( 0 ); + + if ( !firstChild || !firstChild.is( 'element', 'title' ) ) { + return ''; + } + const view = editor.editing.view; const viewWriter = new ViewDowncastWriter( view.document ); @@ -184,7 +204,7 @@ export class Title extends Plugin { const viewDocumentFragment = viewWriter.createDocumentFragment(); // Find all markers that intersects with body. - const bodyStartPosition = model.createPositionAfter( root.getChild( 0 )! ); + const bodyStartPosition = model.createPositionAfter( firstChild ); const bodyRange = model.createRange( bodyStartPosition, model.createPositionAt( root, 'end' ) ); const markers = new Map(); @@ -213,7 +233,13 @@ export class Title extends Plugin { * Returns the `title` element when it is in the document. Returns `undefined` otherwise. */ private _getTitleElement( rootName?: string ): ModelElement | undefined { - const root = this.editor.model.document.getRoot( rootName )!; + const model = this.editor.model; + const root = model.document.getRoot( rootName )!; + + // Root does not support the title structure (custom/inline root). + if ( !model.schema.checkChild( root, 'title' ) ) { + return; + } for ( const child of root.getChildren() as IterableIterator ) { if ( isTitle( child ) ) { @@ -263,6 +289,11 @@ export class Title extends Plugin { const model = this.editor.model; for ( const modelRoot of this.editor.model.document.getRoots() ) { + // Skip roots that do not support the title structure (custom/inline root). + if ( !model.schema.checkChild( modelRoot, 'title' ) ) { + continue; + } + const titleElements = Array.from( modelRoot.getChildren() as IterableIterator ).filter( isTitle ); const firstTitleElement = titleElements[ 0 ]; const firstRootChild = modelRoot.getChild( 0 ) as ModelElement; @@ -312,12 +343,15 @@ export class Title extends Plugin { * when it is needed for the placeholder purposes. */ private _fixBodyElement( writer: ModelWriter ) { + const schema = this.editor.model.schema; let changed = false; for ( const rootName of this.editor.model.document.getRootNames() ) { const modelRoot = this.editor.model.document.getRoot( rootName )!; - if ( modelRoot.childCount < 2 ) { + // Only insert the paragraph body placeholder when the root actually accepts `paragraph`. + // Custom/inline roots that do not accept `$block` are intentionally skipped. + if ( modelRoot.childCount < 2 && schema.checkChild( modelRoot, 'paragraph' ) ) { const placeholder = writer.createElement( 'paragraph' ); writer.insert( placeholder, modelRoot, 1 ); @@ -339,7 +373,12 @@ export class Title extends Plugin { for ( const rootName of this.editor.model.document.getRootNames() ) { const root = this.editor.model.document.getRoot( rootName )!; - const placeholder = this._bodyPlaceholder.get( rootName )!; + const placeholder = this._bodyPlaceholder.get( rootName ); + + // Roots that do not support the title structure never had a body placeholder created. + if ( !placeholder ) { + continue; + } if ( shouldRemoveLastParagraph( placeholder, root ) ) { this._bodyPlaceholder.delete( rootName ); @@ -390,7 +429,20 @@ export class Title extends Plugin { continue; } - // If `viewRoot` is not empty, then we can expect at least two elements in it. + // Skip roots whose schema does not support the title structure (custom/inline root). + // Their view root won't have the expected title+body layout. + const modelRoot = editor.editing.mapper.toModelElement( viewRoot )!; + + if ( !editor.model.schema.checkChild( modelRoot, 'title' ) ) { + continue; + } + + // Defensive: a title-allowed root should also have the body placeholder from `_fixBodyElement`, + // but skip if the layout is unexpectedly incomplete (e.g. a root that allows `title` but not `paragraph`). + if ( viewRoot.childCount < 2 ) { + continue; + } + const body = viewRoot!.getChild( 1 ) as ViewElement; const oldBody = bodyViewElements.get( viewRoot.rootName ); @@ -462,6 +514,11 @@ export class Title extends Plugin { const selectionPosition = selection.getFirstPosition()!; const root = editor.model.document.getRoot( selectionPosition.root.rootName! )!; + // Root does not support the title structure (custom/inline root) — no title to jump to. + if ( !model.schema.checkChild( root, 'title' ) ) { + return; + } + const title = root.getChild( 0 ) as ModelElement; const body = root.getChild( 1 ); @@ -476,40 +533,39 @@ export class Title extends Plugin { } /** - * Creates a view-to-model converter for the h1 that appears at the beginning of the document (a title element). + * A view-to-model converter for the h1 that appears at the beginning of the document (a title element). * - * The upcast context element is named after the editor's configured root `modelElement`, so the converter matches - * the parent against every known root model element name rather than the literal `$root`. + * Matches only the synthetic upcast parent named `$root` (the default generic root element). Title is not supported + * for roots whose `modelElement` is customized, so this converter intentionally does not fire on them. * * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element - * @param rootModelElements Set of root element names the title plugin is scoped to. + * @param evt An object containing information about the fired event. + * @param data An object containing conversion input, a placeholder for conversion output and possibly other values. + * @param conversionApi Conversion interface to be used by the callback. */ -function dataViewModelH1Insertion( rootModelElements: Set ): GetCallback { - return ( evt, data, conversionApi ) => { - const modelCursor = data.modelCursor; - const viewItem = data.viewItem; - const parent = modelCursor.parent; +function dataViewModelH1Insertion( evt: unknown, data: UpcastConversionData, conversionApi: UpcastConversionApi ) { + const modelCursor = data.modelCursor; + const viewItem = data.viewItem; - if ( !modelCursor.isAtStart || !parent.is( 'element' ) || !rootModelElements.has( parent.name ) ) { - return; - } + if ( !modelCursor.isAtStart || !modelCursor.parent.is( 'element', '$root' ) ) { + return; + } - if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) { - return; - } + if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) { + return; + } - const modelWriter = conversionApi.writer; + const modelWriter = conversionApi.writer; - const title = modelWriter.createElement( 'title' ); - const titleContent = modelWriter.createElement( 'title-content' ); + const title = modelWriter.createElement( 'title' ); + const titleContent = modelWriter.createElement( 'title-content' ); - modelWriter.append( titleContent, title ); - modelWriter.insert( title, modelCursor ); + modelWriter.append( titleContent, title ); + modelWriter.insert( title, modelCursor ); - conversionApi.convertChildren( viewItem, titleContent ); + conversionApi.convertChildren( viewItem, titleContent ); - conversionApi.updateConversionResult( title, data ); - }; + conversionApi.updateConversionResult( title, data ); } /** diff --git a/packages/ckeditor5-heading/tests/title-integration.js b/packages/ckeditor5-heading/tests/title-integration.js index 00456469c6e..ef79da510c0 100644 --- a/packages/ckeditor5-heading/tests/title-integration.js +++ b/packages/ckeditor5-heading/tests/title-integration.js @@ -9,7 +9,6 @@ import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; import { Enter } from '@ckeditor/ckeditor5-enter'; import { Bold } from '@ckeditor/ckeditor5-basic-styles'; -import { Plugin } from '@ckeditor/ckeditor5-core'; import { ClassicTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; import { _getModelData } from '@ckeditor/ckeditor5-engine'; @@ -98,55 +97,111 @@ describe( 'Title integration with multi root editor', () => { // Does not include title and body. expect( barModelRoot.isEmpty ).to.be.true; } ); -} ); -describe( 'Title integration with multi root editor and custom root modelElement', () => { - let multiRoot; + it( 'should return an empty string from getTitle() for a detached root', () => { + multiRoot.detachRoot( 'bar' ); - class CustomRootsSchema extends Plugin { - init() { - this.editor.model.schema.register( 'myRootA', { - inheritAllFrom: '$root', - allowChildren: [ '$container', '$block' ] - } ); - this.editor.model.schema.register( 'myRootB', { - inheritAllFrom: '$root', - allowChildren: [ '$container', '$block' ] - } ); - } - } + expect( multiRoot.plugins.get( Title ).getTitle( { rootName: 'bar' } ) ).to.equal( '' ); + } ); + + it( 'should return an empty string from getBody() for a detached root (first-child guard)', () => { + multiRoot.detachRoot( 'bar' ); + + const barRoot = multiRoot.model.document.getRoot( 'bar' ); + + // Detached root is empty but schema still reports `title` as allowed, so the + // first-child check is what prevents the NPE here. + expect( barRoot.isEmpty ).to.equal( true ); + expect( multiRoot.model.schema.checkChild( barRoot, 'title' ) ).to.equal( true ); + + expect( multiRoot.plugins.get( Title ).getBody( { rootName: 'bar' } ) ).to.equal( '' ); + } ); +} ); + +describe( 'Title integration with a mixed $root / $inlineRoot multi root editor', () => { + let multiRoot, titlePlugin, mainRoot, inlineRoot; beforeEach( async () => { multiRoot = await MultiRootEditor.create( {}, { - plugins: [ CustomRootsSchema, Paragraph, Heading, Enter, Title ], + plugins: [ Paragraph, Heading, Enter, Title ], roots: { - foo: { - modelElement: 'myRootA', - initialData: '

FooTitle

Foo

' + main: { + modelElement: '$root', + initialData: '

MainTitle

Main body

' }, - bar: { - modelElement: 'myRootB', - initialData: '

BarTitle

Bar

' + inline: { + modelElement: '$inlineRoot', + initialData: 'Inline content' } } } ); + titlePlugin = multiRoot.plugins.get( Title ); + mainRoot = multiRoot.model.document.getRoot( 'main' ); + inlineRoot = multiRoot.model.document.getRoot( 'inline' ); } ); afterEach( async () => { await multiRoot.destroy(); } ); - it( 'should allow title inside every configured custom root model element', () => { + it( 'should allow title only in the $root root, not in the $inlineRoot root', () => { const schema = multiRoot.model.schema; - expect( schema.checkChild( 'myRootA', 'title' ) ).to.equal( true ); - expect( schema.checkChild( 'myRootB', 'title' ) ).to.equal( true ); + expect( schema.checkChild( mainRoot, 'title' ) ).to.equal( true ); + expect( schema.checkChild( inlineRoot, 'title' ) ).to.equal( false ); } ); - it( 'should upcast h1 to title at the start of every configured custom root', () => { - const titlePlugin = multiRoot.plugins.get( Title ); + it( 'should create the title + body structure in the $root root', () => { + expect( mainRoot.getChild( 0 ).is( 'element', 'title' ) ).to.equal( true ); + expect( mainRoot.getChild( 1 ).is( 'element', 'paragraph' ) ).to.equal( true ); + } ); - expect( titlePlugin.getTitle( { rootName: 'foo' } ) ).to.equal( 'FooTitle' ); - expect( titlePlugin.getTitle( { rootName: 'bar' } ) ).to.equal( 'BarTitle' ); + it( 'should not insert a title element into the $inlineRoot root', () => { + const hasTitle = Array.from( inlineRoot.getChildren() ) + .some( child => child.is( 'element' ) && child.name === 'title' ); + + expect( hasTitle ).to.equal( false ); + } ); + + it( 'should not insert a paragraph body placeholder into the $inlineRoot root', () => { + const hasParagraph = Array.from( inlineRoot.getChildren() ) + .some( child => child.is( 'element' ) && child.name === 'paragraph' ); + + expect( hasParagraph ).to.equal( false ); + } ); + + it( 'should return title value for the $root root and an empty string for the $inlineRoot root', () => { + expect( titlePlugin.getTitle( { rootName: 'main' } ) ).to.equal( 'MainTitle' ); + expect( titlePlugin.getTitle( { rootName: 'inline' } ) ).to.equal( '' ); + } ); + + it( 'should return body value for the $root root and fall back to full data for the $inlineRoot root', () => { + expect( titlePlugin.getBody( { rootName: 'main' } ) ).to.equal( '

Main body

' ); + // No title structure on the inline root — the whole root is the body. + expect( titlePlugin.getBody( { rootName: 'inline' } ) ).to.equal( 'Inline content' ); + } ); + + it( 'should round-trip data on the $inlineRoot root without being touched by Title', () => { + expect( multiRoot.getData( { rootName: 'inline' } ) ).to.equal( 'Inline content' ); + } ); + + it( 'should not throw when the view post-fixer runs after a change in the $inlineRoot root', () => { + expect( () => { + multiRoot.model.change( writer => { + writer.insertText( '!', writer.createPositionAt( inlineRoot, 'end' ) ); + } ); + } ).not.to.throw(); + + expect( multiRoot.getData( { rootName: 'inline' } ) ).to.equal( 'Inline content!' ); + } ); + + it( 'should not throw when the view post-fixer runs after a change in the $root root', () => { + expect( () => { + multiRoot.model.change( writer => { + writer.insertText( '!', writer.createPositionAt( mainRoot.getChild( 1 ), 'end' ) ); + } ); + } ).not.to.throw(); + + expect( titlePlugin.getBody( { rootName: 'main' } ) ).to.equal( '

Main body!

' ); } ); } ); diff --git a/packages/ckeditor5-heading/tests/title.js b/packages/ckeditor5-heading/tests/title.js index 8c038ec303f..0a209f16f0d 100644 --- a/packages/ckeditor5-heading/tests/title.js +++ b/packages/ckeditor5-heading/tests/title.js @@ -5,7 +5,6 @@ import { ClassicTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; -import { Plugin } from '@ckeditor/ckeditor5-core'; import { Title } from '../src/title.js'; import { Heading } from '../src/heading.js'; import { Enter } from '@ckeditor/ckeditor5-enter'; @@ -946,70 +945,101 @@ describe( 'Title', () => { } ); } ); - describe( 'with a custom root modelElement', () => { - let customElement, customEditor, customModel; - - class CustomRootSchema extends Plugin { - init() { - this.editor.model.schema.register( 'myRoot', { - inheritAllFrom: '$root', - allowChildren: [ '$container', '$block' ] - } ); - } - } + describe( 'with an $inlineRoot modelElement', () => { + let inlineElement, inlineEditor, inlineModel, inlineRoot, titlePlugin; beforeEach( async () => { - customElement = document.createElement( 'div' ); - document.body.appendChild( customElement ); + inlineElement = document.createElement( 'div' ); + document.body.appendChild( inlineElement ); - customEditor = await ClassicTestEditor.create( customElement, { - plugins: [ CustomRootSchema, Paragraph, Title, Heading ], - root: { modelElement: 'myRoot' } + inlineEditor = await ClassicTestEditor.create( inlineElement, { + plugins: [ Paragraph, Title, Heading, BlockQuote, Clipboard, Image, ImageUpload, Enter, Undo ], + root: { modelElement: '$inlineRoot' } } ); - customModel = customEditor.model; + inlineModel = inlineEditor.model; + inlineRoot = inlineModel.document.getRoot(); + titlePlugin = inlineEditor.plugins.get( Title ); } ); afterEach( async () => { - await customEditor.destroy(); - customElement.remove(); + await inlineEditor.destroy(); + inlineElement.remove(); } ); - it( 'should register title as an allowed child of the configured custom root element', () => { - expect( customModel.schema.checkChild( 'myRoot', 'title' ) ).to.equal( true ); + it( 'should not allow title as a child of $inlineRoot', () => { + expect( inlineModel.schema.checkChild( inlineRoot, 'title' ) ).to.equal( false ); } ); - it( 'should not register title as an allowed child of the generic $root when a custom root is configured', () => { - expect( customModel.schema.checkChild( '$root', 'title' ) ).to.equal( false ); + it( 'should not allow paragraph as a child of $inlineRoot', () => { + // Sanity check behind the `_fixBodyElement` schema guard. + expect( inlineModel.schema.checkChild( inlineRoot, 'paragraph' ) ).to.equal( false ); } ); - it( 'should convert h1 at the start of a custom root to title on setData', () => { - customEditor.setData( '

Foo

Bar

' ); + it( 'should not insert a title element into $inlineRoot on load (model post-fixer no-op)', () => { + inlineEditor.setData( 'Foo' ); - expect( _getModelData( customModel ) ).to.equal( - '<title-content>[]Foo</title-content>' + - 'Bar' - ); + const hasTitle = Array.from( inlineRoot.getChildren() ) + .some( child => child.is( 'element' ) && child.name === 'title' ); + + expect( hasTitle ).to.equal( false ); } ); - it( 'should convert h1 to title in data.parse() when the configured root is passed as context', () => { - const modelFrag = customEditor.data.parse( - '

Foo

Bar

', - customModel.document.getRoot( 'main' ) - ); + it( 'should not insert a paragraph body placeholder into $inlineRoot', () => { + inlineEditor.setData( 'Foo' ); - expect( _stringifyModel( modelFrag ) ).to.equal( - '<title-content>Foo</title-content>' + - 'Bar' - ); + const hasParagraph = Array.from( inlineRoot.getChildren() ) + .some( child => child.is( 'element' ) && child.name === 'paragraph' ); + + expect( hasParagraph ).to.equal( false ); } ); - it( 'should not convert h1 to title in data.parse() when the default $root context is used for a custom root', () => { - const modelFrag = customEditor.data.parse( '

Foo

Bar

' ); + it( 'should return an empty string from getTitle() for $inlineRoot', () => { + inlineEditor.setData( 'Foo' ); - expect( _stringifyModel( modelFrag ) ).to.equal( - 'Foo' + - 'Bar' - ); + expect( titlePlugin.getTitle() ).to.equal( '' ); + } ); + + it( 'should fall back to the full root data from getBody() for $inlineRoot', () => { + inlineEditor.setData( 'Foo' ); + + // No title structure exists, so the whole root IS the body. + expect( titlePlugin.getBody() ).to.equal( 'Foo' ); + } ); + + it( 'should not upcast

to title when the target root is $inlineRoot', () => { + inlineEditor.setData( '

Foo

' ); + + const hasTitle = Array.from( inlineRoot.getChildren() ) + .some( child => child.is( 'element' ) && child.name === 'title' ); + + expect( hasTitle ).to.equal( false ); + } ); + + it( 'should no-op on Shift+Tab when the root is $inlineRoot', () => { + inlineEditor.setData( 'Foo' ); + + inlineModel.change( writer => { + writer.setSelection( inlineRoot, 0 ); + } ); + + const eventData = getEventData( keyCodes.tab, { shiftKey: true } ); + + inlineEditor.keystrokes.press( eventData ); + + sinon.assert.notCalled( eventData.preventDefault ); + sinon.assert.notCalled( eventData.stopPropagation ); + } ); + + it( 'should not throw when the view post-fixer runs after a model change on $inlineRoot', () => { + inlineEditor.setData( 'Foo' ); + + expect( () => { + inlineModel.change( writer => { + writer.insertText( ' bar', writer.createPositionAt( inlineRoot, 'end' ) ); + } ); + } ).not.to.throw(); + + expect( inlineEditor.getData() ).to.equal( 'Foo bar' ); } ); } ); } ); From 65d82d27c1e3c08c8dfb5bfba9d4679c9d7f5095 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 23 Apr 2026 18:35:25 +0200 Subject: [PATCH 06/16] Title feature updated for 100% cc. --- packages/ckeditor5-heading/src/title.ts | 10 +++------- packages/ckeditor5-heading/tests/title.js | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/ckeditor5-heading/src/title.ts b/packages/ckeditor5-heading/src/title.ts index 8cfc36052c0..1b41aec40fc 100644 --- a/packages/ckeditor5-heading/src/title.ts +++ b/packages/ckeditor5-heading/src/title.ts @@ -431,18 +431,14 @@ export class Title extends Plugin { // Skip roots whose schema does not support the title structure (custom/inline root). // Their view root won't have the expected title+body layout. + // A title-allowed root always has a paragraph body placeholder created by `_fixBodyElement`, + // so the second view child is guaranteed to exist once this guard passes. const modelRoot = editor.editing.mapper.toModelElement( viewRoot )!; if ( !editor.model.schema.checkChild( modelRoot, 'title' ) ) { continue; } - // Defensive: a title-allowed root should also have the body placeholder from `_fixBodyElement`, - // but skip if the layout is unexpectedly incomplete (e.g. a root that allows `title` but not `paragraph`). - if ( viewRoot.childCount < 2 ) { - continue; - } - const body = viewRoot!.getChild( 1 ) as ViewElement; const oldBody = bodyViewElements.get( viewRoot.rootName ); @@ -654,7 +650,7 @@ function fixTitleElement( title: ModelElement, writer: ModelWriter, model: Model * purpose and it's not needed anymore. Returns false otherwise. */ function shouldRemoveLastParagraph( placeholder: ModelElement, root: ModelRootElement ) { - if ( !placeholder || !placeholder.is( 'element', 'paragraph' ) || placeholder.childCount ) { + if ( !placeholder.is( 'element', 'paragraph' ) || placeholder.childCount ) { return false; } diff --git a/packages/ckeditor5-heading/tests/title.js b/packages/ckeditor5-heading/tests/title.js index 0a209f16f0d..712371b4f48 100644 --- a/packages/ckeditor5-heading/tests/title.js +++ b/packages/ckeditor5-heading/tests/title.js @@ -369,6 +369,25 @@ describe( 'Title', () => { '' ); } ); + + it( 'should keep the body placeholder paragraph once it has typed content', () => { + // On an empty editor the post-fixer creates a `` placeholder and remembers it. + // Typing into it gives the placeholder `childCount > 0`, which must short-circuit + // `shouldRemoveLastParagraph` so the paragraph is kept. + const root = model.document.getRoot(); + const placeholderParagraph = root.getChild( 1 ); + + expect( placeholderParagraph.name ).to.equal( 'paragraph' ); + expect( placeholderParagraph.childCount ).to.equal( 0 ); + + model.change( writer => { + writer.insertText( 'x', writer.createPositionAt( placeholderParagraph, 0 ) ); + } ); + + // The placeholder is still there with the typed content. + expect( root.getChild( 1 ) ).to.equal( placeholderParagraph ); + expect( placeholderParagraph.childCount ).to.equal( 1 ); + } ); } ); describe( 'getTitle()', () => { From db8d09922a7a8d7e0ff7273d34fe8077120b3ae9 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Wed, 29 Apr 2026 15:17:27 +0200 Subject: [PATCH 07/16] Warn when Title is loaded but no root supports the title structure. --- .../src/multirooteditor.ts | 1 + packages/ckeditor5-heading/src/title.ts | 50 ++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts b/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts index 8cf9efce8f1..d1fde19e37d 100644 --- a/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts +++ b/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts @@ -448,6 +448,7 @@ export class MultiRootEditor extends Editor { public addRoot( rootName: string, options: AddRootOptions & AddRootRootConfig = {} ): void { const initialData: string = options.initialData || options.data || ''; const modelAttributes: EditorRootAttributes = options.modelAttributes || options.attributes || {}; + // eslint-disable-next-line ckeditor5-rules/no-literal-dollar-root -- public API default for `addRoot()` const modelElement: string = options.modelElement || options.elementName || '$root'; if ( !this.model.schema.isLimit( modelElement ) ) { diff --git a/packages/ckeditor5-heading/src/title.ts b/packages/ckeditor5-heading/src/title.ts index 1b41aec40fc..39f3bd12af0 100644 --- a/packages/ckeditor5-heading/src/title.ts +++ b/packages/ckeditor5-heading/src/title.ts @@ -9,7 +9,7 @@ import { Plugin, type Editor, type ElementApi } from '@ckeditor/ckeditor5-core'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; -import { first, type GetCallback } from '@ckeditor/ckeditor5-utils'; +import { first, logWarning, type GetCallback } from '@ckeditor/ckeditor5-utils'; import { ViewDowncastWriter, enableViewPlaceholder, @@ -90,6 +90,7 @@ export class Title extends Plugin { // the root accepting `$block` content, which is not guaranteed for custom or inline roots. // Runtime codepaths below additionally guard on `schema.checkChild( root, 'title' )` so // the plugin gracefully no-ops on roots where the schema does not allow the title element. + // eslint-disable-next-line ckeditor5-rules/no-literal-dollar-root -- registered only on the default `$root` by design model.schema.register( 'title', { isBlock: true, allowIn: '$root' } ); model.schema.register( 'title-content', { isBlock: true, allowIn: 'title', allowAttributes: [ 'alignment' ] } ); model.schema.extend( '$text', { allowIn: 'title-content' } ); @@ -138,6 +139,50 @@ export class Title extends Plugin { // Attach Tab handling. this._attachTabPressHandling(); + + this._warnIfNoSupportedRoot(); + } + + /** + * Logs a single warning when none of the editor's roots can host the title structure. The Title feature + * only operates on roots whose `modelElement` is the default `$root`; roots configured with a custom + * `modelElement` are silently skipped at runtime. If no root supports the structure, the plugin is + * effectively a no-op and the integrator likely wants to know. + */ + private _warnIfNoSupportedRoot(): void { + const model = this.editor.model; + let sawAnyRoot = false; + + for ( const root of model.document.getRoots() ) { + if ( root.rootName === '$graveyard' ) { + continue; + } + + sawAnyRoot = true; + + if ( model.schema.checkChild( root, 'title' ) ) { + return; + } + } + + // No non-graveyard roots present at init — the editor will receive its roots later (e.g. via `addRoot()`). + // Stay quiet; we cannot judge support without knowing what the configured roots will be. + if ( !sawAnyRoot ) { + return; + } + + /** + * The Title feature was loaded, but none of the editor's roots supports the `title` element. The feature + * only operates on roots whose `modelElement` is the default `$root`; roots configured with a custom + * `modelElement` (including `$inlineRoot`) are silently skipped, so `getTitle()` / `getBody()` fall back + * to the regular data getter and no title structure is ever inserted. + * + * To use the Title feature, ensure at least one root uses the default `$root` model element. Otherwise, + * remove the Title plugin from this editor's plugin list. + * + * @error title-no-supported-root + */ + logWarning( 'title-no-supported-root' ); } /** @@ -543,6 +588,9 @@ function dataViewModelH1Insertion( evt: unknown, data: UpcastConversionData Date: Wed, 29 Apr 2026 15:27:27 +0200 Subject: [PATCH 08/16] Added changelog entries. --- .changelog/20260429140000_ck_20026_title_custom_roots.md | 9 +++++++++ .changelog/20260429140100_ck_20026_ghs_hgroup.md | 9 +++++++++ .changelog/20260429140200_ck_20026_html_comments.md | 9 +++++++++ 3 files changed, 27 insertions(+) create mode 100644 .changelog/20260429140000_ck_20026_title_custom_roots.md create mode 100644 .changelog/20260429140100_ck_20026_ghs_hgroup.md create mode 100644 .changelog/20260429140200_ck_20026_html_comments.md diff --git a/.changelog/20260429140000_ck_20026_title_custom_roots.md b/.changelog/20260429140000_ck_20026_title_custom_roots.md new file mode 100644 index 00000000000..124eea18d2b --- /dev/null +++ b/.changelog/20260429140000_ck_20026_title_custom_roots.md @@ -0,0 +1,9 @@ +--- +type: Fix +scope: + - ckeditor5-heading +closes: + - https://github.com/ckeditor/ckeditor5/issues/20026 +--- + +The Title feature now correctly handles editor configurations where some or all roots use a custom `modelElement`. Roots that do not accept the `title` element are silently skipped at runtime, and the feature logs a single warning when no root supports the title structure. diff --git a/.changelog/20260429140100_ck_20026_ghs_hgroup.md b/.changelog/20260429140100_ck_20026_ghs_hgroup.md new file mode 100644 index 00000000000..e6e4883d847 --- /dev/null +++ b/.changelog/20260429140100_ck_20026_ghs_hgroup.md @@ -0,0 +1,9 @@ +--- +type: Fix +scope: + - ckeditor5-html-support +closes: + - https://github.com/ckeditor/ckeditor5/issues/20026 +--- + +The General HTML Support schema for the `hgroup` element no longer hardcodes `$root` as its allowed parent, so the element is correctly registered in editor configurations using a custom root `modelElement`. diff --git a/.changelog/20260429140200_ck_20026_html_comments.md b/.changelog/20260429140200_ck_20026_html_comments.md new file mode 100644 index 00000000000..06acdd5a228 --- /dev/null +++ b/.changelog/20260429140200_ck_20026_html_comments.md @@ -0,0 +1,9 @@ +--- +type: Fix +scope: + - ckeditor5-html-support +closes: + - https://github.com/ckeditor/ckeditor5/issues/20026 +--- + +The HTML comments feature no longer assumes the root model element is `$root`. Comments are now preserved in editor configurations using a custom root `modelElement`. From 52890d1684a7a90a8d863133b9306c84999fe41e Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Wed, 29 Apr 2026 16:11:57 +0200 Subject: [PATCH 09/16] Added tests for title feature warning. --- packages/ckeditor5-heading/tests/title.js | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/ckeditor5-heading/tests/title.js b/packages/ckeditor5-heading/tests/title.js index 712371b4f48..840c08b57e2 100644 --- a/packages/ckeditor5-heading/tests/title.js +++ b/packages/ckeditor5-heading/tests/title.js @@ -1061,8 +1061,50 @@ describe( 'Title', () => { expect( inlineEditor.getData() ).to.equal( 'Foo bar' ); } ); } ); + + describe( '_warnIfNoSupportedRoot()', () => { + const WARNING_ID = 'title-no-supported-root'; + + let warnStub, warnEditorElement, warnEditor; + + beforeEach( () => { + warnStub = sinon.stub( console, 'warn' ); + warnEditorElement = document.createElement( 'div' ); + document.body.appendChild( warnEditorElement ); + } ); + + afterEach( async () => { + if ( warnEditor ) { + await warnEditor.destroy(); + warnEditor = null; + } + warnEditorElement.remove(); + warnStub.restore(); + } ); + + it( 'should not warn when at least one root supports the title element', async () => { + warnEditor = await ClassicTestEditor.create( warnEditorElement, { + plugins: [ Paragraph, Title, Heading ] + } ); + + expect( countWarnings( warnStub, WARNING_ID ) ).to.equal( 0 ); + } ); + + it( 'should warn exactly once when no root supports the title element', async () => { + warnEditor = await ClassicTestEditor.create( warnEditorElement, { + plugins: [ Paragraph, Title, Heading ], + root: { modelElement: '$inlineRoot' } + } ); + + expect( countWarnings( warnStub, WARNING_ID ) ).to.equal( 1 ); + } ); + } ); } ); +function countWarnings( warnStub, id ) { + return warnStub.getCalls().filter( call => String( call.args[ 0 ] ).includes( id ) ).length; +} + function getEventData( keyCode, { shiftKey = false } = {} ) { return { keyCode, From ac486fe18665bf4e5f2f994365bf6d256f2a6cca Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Wed, 29 Apr 2026 16:20:27 +0200 Subject: [PATCH 10/16] Removed not-needed checks. --- packages/ckeditor5-heading/src/title.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/ckeditor5-heading/src/title.ts b/packages/ckeditor5-heading/src/title.ts index 39f3bd12af0..ecce5c1c1ce 100644 --- a/packages/ckeditor5-heading/src/title.ts +++ b/packages/ckeditor5-heading/src/title.ts @@ -151,26 +151,13 @@ export class Title extends Plugin { */ private _warnIfNoSupportedRoot(): void { const model = this.editor.model; - let sawAnyRoot = false; for ( const root of model.document.getRoots() ) { - if ( root.rootName === '$graveyard' ) { - continue; - } - - sawAnyRoot = true; - if ( model.schema.checkChild( root, 'title' ) ) { return; } } - // No non-graveyard roots present at init — the editor will receive its roots later (e.g. via `addRoot()`). - // Stay quiet; we cannot judge support without knowing what the configured roots will be. - if ( !sawAnyRoot ) { - return; - } - /** * The Title feature was loaded, but none of the editor's roots supports the `title` element. The feature * only operates on roots whose `modelElement` is the default `$root`; roots configured with a custom From 9961f24841c40a959830404324c9ff2fb3f8b318 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Wed, 29 Apr 2026 16:51:28 +0200 Subject: [PATCH 11/16] Added console.warn stub in tests for inline root integration. --- packages/ckeditor5-heading/tests/title.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-heading/tests/title.js b/packages/ckeditor5-heading/tests/title.js index 840c08b57e2..35650acc183 100644 --- a/packages/ckeditor5-heading/tests/title.js +++ b/packages/ckeditor5-heading/tests/title.js @@ -965,9 +965,13 @@ describe( 'Title', () => { } ); describe( 'with an $inlineRoot modelElement', () => { - let inlineElement, inlineEditor, inlineModel, inlineRoot, titlePlugin; + let inlineElement, inlineEditor, inlineModel, inlineRoot, titlePlugin, warnStub; beforeEach( async () => { + // Title logs a single `title-no-supported-root` warning when no root accepts the title element; + // silence it here so the CI watchdog for unexpected console output does not fail the suite. + warnStub = sinon.stub( console, 'warn' ); + inlineElement = document.createElement( 'div' ); document.body.appendChild( inlineElement ); @@ -983,6 +987,7 @@ describe( 'Title', () => { afterEach( async () => { await inlineEditor.destroy(); inlineElement.remove(); + warnStub.restore(); } ); it( 'should not allow title as a child of $inlineRoot', () => { From cff3e68b4e59dcc01e80ab01ec1413819c14f619 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 5 May 2026 13:31:08 +0200 Subject: [PATCH 12/16] Added new linter rules for $root calls. --- eslint.config.mjs | 5 +++++ package.json | 4 ++-- pnpm-lock.yaml | 22 +++++++++++----------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 804fa2404ca..eebc172f650 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -95,6 +95,11 @@ export default defineConfig( [ 'ckeditor5-rules/validate-module-tag': 'error', 'ckeditor5-rules/no-default-export': 'error', 'ckeditor5-rules/allow-svg-imports-only-in-icons-package': 'error', + 'ckeditor5-rules/no-literal-dollar-root': [ 'error', { + allowedPackages: [ 'ckeditor5-engine', 'ckeditor5-core' ], + allowedCalls: [ 'is' ] + } ], + 'ckeditor5-rules/require-explicit-data-context': 'error', 'ckeditor5-rules/ckeditor-plugin-flags': [ 'error', { requiredFlags: [ { name: 'isOfficialPlugin', diff --git a/package.json b/package.json index 60ad72f153d..7731d1c8099 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,9 @@ "date-fns": "^4.0.0", "esbuild": "^0.25.0", "eslint": "^9.34.0", - "eslint-config-ckeditor5": "^15.0.0", + "eslint-config-ckeditor5": "^15.1.0", "eslint-formatter-stylish": "^8.40.0", - "eslint-plugin-ckeditor5-rules": "^15.0.0", + "eslint-plugin-ckeditor5-rules": "^15.1.0", "estree-walker": "^3.0.3", "fs-extra": "^11.0.0", "glob": "^13.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 480a333cf4f..8313d2e6433 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,14 +110,14 @@ importers: specifier: ^9.34.0 version: 9.36.0(jiti@2.6.1) eslint-config-ckeditor5: - specifier: ^15.0.0 - version: 15.0.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.5.4) + specifier: ^15.1.0 + version: 15.1.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.5.4) eslint-formatter-stylish: specifier: ^8.40.0 version: 8.40.0 eslint-plugin-ckeditor5-rules: - specifier: ^15.0.0 - version: 15.0.0 + specifier: ^15.1.0 + version: 15.1.0 estree-walker: specifier: ^3.0.3 version: 3.0.3 @@ -7766,8 +7766,8 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-config-ckeditor5@15.0.0: - resolution: {integrity: sha512-XpemPdkTE96OIRIBNbbVovqw/KejKheCHL3tlil0vz8mDdVp/TO7Cj81GEvHz2jdoR4SuY1GCTFUp8FzMRhdHg==} + eslint-config-ckeditor5@15.1.0: + resolution: {integrity: sha512-5Ac2JYj6QsvUngH6d+AzFxIX3VECTuwDmXV5InY9I8riNsSdc2msOOry9pov2yTEUc+CaoJA5Uy5ggkDvdMYpw==} engines: {node: '>=24.11.0'} peerDependencies: eslint: ^9.0.0 @@ -7777,8 +7777,8 @@ packages: resolution: {integrity: sha512-blbD5ZSQnjNEUaG38VCO4WG9nfDQWE8/IOmt8DFRHXUIfZikaIXmsQTdWNFk0/e0j7RgIVRza86MpsJ+aHgFLg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-plugin-ckeditor5-rules@15.0.0: - resolution: {integrity: sha512-naZRNFCpiEhanWKaCqqgxB9hlx+K8RmKATkVwxt4z7nPQnPjg5Yh2UYAoBtCBXhG9iiHclR35T4b694o0KynTw==} + eslint-plugin-ckeditor5-rules@15.1.0: + resolution: {integrity: sha512-qD0dNIMGVX0I5ntUGX0XSGsq2P6z8qGHHRIcA5eScZDxzCZ/Me2mpjLDd1WPU/1C0gmeuzjfFKWpd3xbB3WGUg==} engines: {node: '>=24.11.0'} eslint-plugin-mocha@11.2.0: @@ -16249,13 +16249,13 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-ckeditor5@15.0.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.5.4): + eslint-config-ckeditor5@15.1.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.5.4): dependencies: '@eslint/js': 9.39.4 '@eslint/markdown': 6.6.0 '@stylistic/eslint-plugin': 4.4.1(eslint@9.36.0(jiti@2.6.1))(typescript@5.5.4) eslint: 9.36.0(jiti@2.6.1) - eslint-plugin-ckeditor5-rules: 15.0.0 + eslint-plugin-ckeditor5-rules: 15.1.0 eslint-plugin-mocha: 11.2.0(eslint@9.36.0(jiti@2.6.1)) globals: 16.5.0 typescript: 5.5.4 @@ -16269,7 +16269,7 @@ snapshots: strip-ansi: 6.0.1 text-table: 0.2.0 - eslint-plugin-ckeditor5-rules@15.0.0: + eslint-plugin-ckeditor5-rules@15.1.0: dependencies: '@es-joy/jsdoccomment': 0.50.2 enhanced-resolve: 5.20.1 From f443899f6d58566d7c7bb865b0ffb4de6370df8f Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Wed, 6 May 2026 18:25:05 +0200 Subject: [PATCH 13/16] Multi root editor should use view-related root options in the root attributes so RH and RTC could use the same values. --- .../src/multirooteditor.ts | 64 +++++++---- .../tests/multirooteditor.js | 102 +++++++++++++----- 2 files changed, 116 insertions(+), 50 deletions(-) diff --git a/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts b/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts index d1fde19e37d..115be060861 100644 --- a/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts +++ b/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts @@ -115,6 +115,7 @@ export class MultiRootEditor extends Editor { normalizeRootsConfig( sourceElementsOrData, this.config, false ); normalizeRootsAttributesConfig( this.config ); + normalizeRootEditableOptionsConfig( this.config ); if ( this.config.get( 'lazyRoots' ) ) { /** @@ -175,30 +176,12 @@ export class MultiRootEditor extends Editor { } } - registerAndInitializeRootConfigAttributes( this ); + // Register `$rootEditableOptions` unconditionally, so it is always returned by `getRootAttributes()` (e.g. for RH). + // The value is set via `config.roots..modelAttributes.$rootEditableOptions` (see `normalizeRootEditableOptionsConfig`), + // which also makes it round-trip through RTC's initial-data path. + this.registerRootAttribute( '$rootEditableOptions' ); - // Registering `$rootEditableOptions` attribute to make it available in the editor model. - // This allows to store editable options for each root in the model, and make them available on other RTC clients. - // We do not use `registerRootAttribute()` method here, as this attribute is used internally - // and should not be returned by `getRootsAttributes()` method. - this.editing.model.schema.extend( '$root', { allowAttributes: '$rootEditableOptions' } ); - - this.data.on( 'init', () => { - this.model.enqueueChange( { isUndoable: false }, writer => { - for ( const [ rootName, rootConfig ] of rootsConfig ) { - const root = this.model.document.getRoot( rootName )!; - - // Set editable config for consistency with `addRoot()` method. This will allow features - // to use the same configuration for both initially loaded and dynamically added roots. - const rootEditableOptions: RootEditableOptions = { - ...rootConfig.placeholder && { placeholder: rootConfig.placeholder }, - ...rootConfig.label && { label: rootConfig.label } - }; - - writer.setAttribute( '$rootEditableOptions', rootEditableOptions, root ); - } - } ); - } ); + registerAndInitializeRootConfigAttributes( this ); const options = { shouldToolbarGroupWhenFull: !this.config.get( 'toolbar.shouldNotGroupWhenFull' ), @@ -1238,6 +1221,41 @@ function normalizeRootsAttributesConfig( config: Config ): void { } } +/** + * Normalize `placeholder` and `label` from `config.roots.` into the `$rootEditableOptions` root model attribute, + * stored under `config.roots..modelAttributes`. This way the attribute is registered, set on initial data load + * and shipped through RTC initial-data path together with the rest of `modelAttributes`. + * + * This is also required by the revision history feature: on editor load, RH compares the latest revision data against + * `initialData` and `modelAttributes` passed to the editor and logs a warning if they do not match. Because `$rootEditableOptions` + * ends up in the revision data, it must also be present in `modelAttributes` (even as an empty object when no options + * are configured), otherwise the comparison reports a spurious mismatch. + */ +function normalizeRootEditableOptionsConfig( config: Config ): void { + const rootsConfig = config.get( 'roots' )!; + + for ( const [ rootName, rootConfig ] of Object.entries( rootsConfig ) ) { + const existing = rootConfig.modelAttributes || {}; + + // If `$rootEditableOptions` is already set explicitly via `modelAttributes`, leave it untouched. + if ( '$rootEditableOptions' in existing ) { + continue; + } + + const rootEditableOptions: RootEditableOptions = { + ...rootConfig.placeholder && { placeholder: rootConfig.placeholder }, + ...rootConfig.label && { label: rootConfig.label } + }; + + const modelAttributes: EditorRootAttributes = { + ...existing, + $rootEditableOptions: rootEditableOptions + }; + + config.set( `roots.${ rootName }.modelAttributes`, modelAttributes ); + } +} + function isElement( value: any ): value is Element { return _isElement( value ); } diff --git a/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js b/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js index 96c07b66acf..2171313c0ff 100644 --- a/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js +++ b/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js @@ -1439,7 +1439,7 @@ describe( 'MultiRootEditor', () => { expect( root._isLoaded ).to.be.true; expect( editor.getData( { rootName: 'foo' } ) ).to.equal( '

Foo

' ); - expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { order: 100 } ); + expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { order: 100, $rootEditableOptions: {} } ); expect( editor.registerRootAttribute.calledWithExactly( 'order' ) ); } ); @@ -1449,7 +1449,7 @@ describe( 'MultiRootEditor', () => { expect( root._isLoaded ).to.be.true; expect( editor.getData( { rootName: 'foo' } ) ).to.equal( '' ); - expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( {} ); + expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { $rootEditableOptions: {} } ); } ); it( 'should log a warning and not do anything when a root is loaded for the second time', () => { @@ -1464,7 +1464,7 @@ describe( 'MultiRootEditor', () => { expect( root._isLoaded ).to.be.true; expect( editor.getData( { rootName: 'foo' } ) ).to.equal( '

Foo

' ); - expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { order: 100 } ); + expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { order: 100, $rootEditableOptions: {} } ); expect( spy.notCalled ).to.be.true; } ); @@ -1953,8 +1953,8 @@ describe( 'MultiRootEditor', () => { } ); expect( editor.getRootsAttributes() ).to.deep.equal( { - foo: { order: 10, isLocked: null }, - bar: { order: null, isLocked: false } + foo: { order: 10, isLocked: null, $rootEditableOptions: {} }, + bar: { order: null, isLocked: false, $rootEditableOptions: {} } } ); expect( editor.editing.model.schema.checkAttribute( '$root', 'order' ) ).to.be.true; @@ -2003,12 +2003,14 @@ describe( 'MultiRootEditor', () => { expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { isLocked: true, - order: 30 + order: 30, + $rootEditableOptions: {} } ); expect( editor.getRootAttributes( 'bar' ) ).to.deep.equal( { isLocked: true, - order: 20 + order: 20, + $rootEditableOptions: {} } ); await editor.destroy(); @@ -2034,12 +2036,14 @@ describe( 'MultiRootEditor', () => { expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { isLocked: null, - order: 10 + order: 10, + $rootEditableOptions: {} } ); expect( editor.getRootAttributes( 'bar' ) ).to.deep.equal( { isLocked: true, - order: null + order: null, + $rootEditableOptions: {} } ); await editor.destroy(); @@ -2064,12 +2068,14 @@ describe( 'MultiRootEditor', () => { expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { isLocked: true, - order: 10 + order: 10, + $rootEditableOptions: {} } ); expect( editor.getRootAttributes( 'bar' ) ).to.deep.equal( { isLocked: false, - order: 20 + order: 20, + $rootEditableOptions: {} } ); await editor.destroy(); @@ -2093,18 +2099,20 @@ describe( 'MultiRootEditor', () => { expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { isLocked: true, - order: 10 + order: 10, + $rootEditableOptions: {} } ); expect( editor.getRootAttributes( 'bar' ) ).to.deep.equal( { isLocked: false, - order: null + order: null, + $rootEditableOptions: {} } ); await editor.destroy(); } ); - it( 'should not include $rootEditableOptions', async () => { + it( 'should include $rootEditableOptions when placeholder or label are configured', async () => { editor = await MultiRootEditor.create( { foo: '' }, { roots: { foo: { @@ -2115,7 +2123,10 @@ describe( 'MultiRootEditor', () => { } } ); - expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { order: 10 } ); + expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { + order: 10, + $rootEditableOptions: { placeholder: 'Type here...', label: 'My label' } + } ); await editor.destroy(); } ); @@ -2133,12 +2144,14 @@ describe( 'MultiRootEditor', () => { expect( editor.getRootAttributes( 'foo' ) ).to.deep.equal( { isLocked: null, - order: 30 + order: 30, + $rootEditableOptions: {} } ); expect( editor.getRootAttributes( 'bar' ) ).to.deep.equal( { isLocked: true, - order: null + order: null, + $rootEditableOptions: {} } ); await editor.destroy(); @@ -2168,11 +2181,13 @@ describe( 'MultiRootEditor', () => { expect( editor.getRootsAttributes() ).to.deep.equal( { bar: { isLocked: true, - order: 20 + order: 20, + $rootEditableOptions: {} }, foo: { isLocked: true, - order: 30 + order: 30, + $rootEditableOptions: {} } } ); @@ -2207,11 +2222,13 @@ describe( 'MultiRootEditor', () => { expect( editor.getRootsAttributes( 'foo' ) ).to.deep.equal( { foo: { isLocked: null, - order: 10 + order: 10, + $rootEditableOptions: {} }, bar: { isLocked: true, - order: null + order: null, + $rootEditableOptions: {} } } ); @@ -2239,22 +2256,25 @@ describe( 'MultiRootEditor', () => { expect( editor.getRootsAttributes() ).to.deep.equal( { abc: { isLocked: null, - order: 30 + order: 30, + $rootEditableOptions: {} }, foo: { isLocked: true, - order: 10 + order: 10, + $rootEditableOptions: {} }, xxx: { isLocked: false, - order: 40 + order: 40, + $rootEditableOptions: {} } } ); await editor.destroy(); } ); - it( 'should not include $rootEditableOptions', async () => { + it( 'should include $rootEditableOptions when placeholder or label are configured', async () => { editor = await MultiRootEditor.create( { foo: '', bar: '' }, { roots: { foo: { @@ -2269,8 +2289,36 @@ describe( 'MultiRootEditor', () => { } ); expect( editor.getRootsAttributes() ).to.deep.equal( { - foo: { order: 10 }, - bar: { order: 20 } + foo: { + order: 10, + $rootEditableOptions: { placeholder: 'Foo placeholder', label: 'Foo label' } + }, + bar: { + order: 20, + $rootEditableOptions: {} + } + } ); + + await editor.destroy(); + } ); + + it( 'should not overwrite $rootEditableOptions explicitly set via modelAttributes', async () => { + const explicitOptions = { placeholder: 'From modelAttributes', label: 'From modelAttributes' }; + + editor = await MultiRootEditor.create( { foo: '' }, { + roots: { + foo: { + modelAttributes: { $rootEditableOptions: explicitOptions }, + placeholder: 'From config', + label: 'From config' + } + } + } ); + + expect( editor.getRootsAttributes() ).to.deep.equal( { + foo: { + $rootEditableOptions: explicitOptions + } } ); await editor.destroy(); From 423229635a1fc4a69698b6706840ffcaa57535f0 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Wed, 6 May 2026 18:48:20 +0200 Subject: [PATCH 14/16] Unified normalization of RootEditableOptions to model root attribute. --- .../src/multirooteditor.ts | 51 +++++++++++-------- .../tests/multirooteditor.js | 22 ++++++++ 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts b/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts index 115be060861..f8c97d36bab 100644 --- a/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts +++ b/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts @@ -430,7 +430,7 @@ export class MultiRootEditor extends Editor { public addRoot( rootName: string, options: AddRootOptions & AddRootRootConfig = {} ): void { const initialData: string = options.initialData || options.data || ''; - const modelAttributes: EditorRootAttributes = options.modelAttributes || options.attributes || {}; + const modelAttributes: EditorRootAttributes = { ...options.modelAttributes || options.attributes }; // eslint-disable-next-line ckeditor5-rules/no-literal-dollar-root -- public API default for `addRoot()` const modelElement: string = options.modelElement || options.elementName || '$root'; @@ -457,6 +457,9 @@ export class MultiRootEditor extends Editor { logWarning( 'multi-root-editor-add-root-element-option-ignored' ); } + // Storing editable options as a root attribute to make them available on other RTC clients. + ensureRootEditableOptions( modelAttributes, options ); + const _addRoot = ( writer: ModelWriter ) => { const root = writer.addRoot( rootName, modelElement ); @@ -468,14 +471,6 @@ export class MultiRootEditor extends Editor { this.registerRootAttribute( key ); writer.setAttribute( key, modelAttributes[ key ], root ); } - - // Storing editable options as a root attribute to make them available on other RTC clients. - const rootEditableOptions: RootEditableOptions = { - ...options.placeholder && { placeholder: options.placeholder }, - ...options.label && { label: options.label } - }; - - writer.setAttribute( '$rootEditableOptions', rootEditableOptions, root ); }; if ( options.isUndoable ) { @@ -1235,27 +1230,39 @@ function normalizeRootEditableOptionsConfig( config: Config ): voi const rootsConfig = config.get( 'roots' )!; for ( const [ rootName, rootConfig ] of Object.entries( rootsConfig ) ) { - const existing = rootConfig.modelAttributes || {}; + const modelAttributes: EditorRootAttributes = { ...rootConfig.modelAttributes }; - // If `$rootEditableOptions` is already set explicitly via `modelAttributes`, leave it untouched. - if ( '$rootEditableOptions' in existing ) { + if ( !ensureRootEditableOptions( modelAttributes, rootConfig ) ) { continue; } - const rootEditableOptions: RootEditableOptions = { - ...rootConfig.placeholder && { placeholder: rootConfig.placeholder }, - ...rootConfig.label && { label: rootConfig.label } - }; - - const modelAttributes: EditorRootAttributes = { - ...existing, - $rootEditableOptions: rootEditableOptions - }; - config.set( `roots.${ rootName }.modelAttributes`, modelAttributes ); } } +/** + * Mutates the given `modelAttributes` map by adding the `$rootEditableOptions` entry derived from `placeholder` and `label`. + * If `$rootEditableOptions` is already present, the map is left untouched. + * + * Returns `true` when the map was modified, `false` otherwise — useful to skip downstream work (e.g. `config.set`) + * when nothing changed. + */ +function ensureRootEditableOptions( + modelAttributes: EditorRootAttributes, + { placeholder, label }: RootEditableOptions +): boolean { + if ( '$rootEditableOptions' in modelAttributes ) { + return false; + } + + modelAttributes.$rootEditableOptions = { + ...placeholder && { placeholder }, + ...label && { label } + } satisfies RootEditableOptions; + + return true; +} + function isElement( value: any ): value is Element { return _isElement( value ); } diff --git a/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js b/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js index 2171313c0ff..ba5cda87afa 100644 --- a/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js +++ b/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js @@ -1288,6 +1288,28 @@ describe( 'MultiRootEditor', () => { } ); } ); + it( 'should not overwrite $rootEditableOptions explicitly passed in attributes', () => { + const explicitOptions = { placeholder: 'From attributes', label: 'From attributes' }; + + editor.addRoot( 'bar', { + attributes: { $rootEditableOptions: explicitOptions }, + placeholder: 'From options', + label: 'From options' + } ); + + const root = editor.model.document.getRoot( 'bar' ); + + expect( root.getAttribute( '$rootEditableOptions' ) ).to.deep.equal( explicitOptions ); + } ); + + it( 'should not mutate the attributes object passed by the caller', () => { + const attributes = { order: 10 }; + + editor.addRoot( 'bar', { attributes, placeholder: 'Type here...' } ); + + expect( attributes ).to.deep.equal( { order: 10 } ); + } ); + it( 'should prefer initialData over data', () => { editor.addRoot( 'bar', { initialData: '

New.

', data: '

Old.

' } ); From d18cc5944f674e955ccea8ba40362eab77176f1b Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 7 May 2026 16:21:46 +0200 Subject: [PATCH 15/16] Fixed watchdog-data manual test. --- .../tests/manual/watchdog-data.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ckeditor5-watchdog/tests/manual/watchdog-data.js b/packages/ckeditor5-watchdog/tests/manual/watchdog-data.js index 35d3b447a84..79cf67ad04f 100644 --- a/packages/ckeditor5-watchdog/tests/manual/watchdog-data.js +++ b/packages/ckeditor5-watchdog/tests/manual/watchdog-data.js @@ -65,12 +65,7 @@ function createWatchdog( editorElement, stateElement, name ) { const watchdog = new EditorWatchdog( ClassicEditor ); watchdog.setCreator( config => { - return ClassicEditor.create( { - ...config, - root: { - initialData: editorElement.innerHTML - } - } ).then( editor => { + return ClassicEditor.create( config ).then( editor => { console.log( `${ name } editor created (from creator).` ); editorElement.innerHTML = ''; @@ -87,7 +82,12 @@ function createWatchdog( editorElement, stateElement, name ) { return editor.destroy(); } ); - watchdog.create( editorConfig ); + watchdog.create( { + ...editorConfig, + root: { + initialData: editorElement.innerHTML + } + } ); watchdog.on( 'error', () => { console.log( `${ name } editor crashed!` ); From e37786e93651d1083e84e308d5df26495bc6ede9 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 7 May 2026 17:11:12 +0200 Subject: [PATCH 16/16] Updated watchdog tests. --- .../tests/editorwatchdog.js | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/ckeditor5-watchdog/tests/editorwatchdog.js b/packages/ckeditor5-watchdog/tests/editorwatchdog.js index 3dd59b4b4bc..e31b2715044 100644 --- a/packages/ckeditor5-watchdog/tests/editorwatchdog.js +++ b/packages/ckeditor5-watchdog/tests/editorwatchdog.js @@ -1723,8 +1723,8 @@ describe( 'EditorWatchdog', () => { } ); expect( watchdog.editor.getRootsAttributes() ).to.deep.equal( { - header: { order: 1 }, - content: { order: 2 } + header: { order: 1, $rootEditableOptions: {} }, + content: { order: 2, $rootEditableOptions: {} } } ); await watchdog.destroy(); @@ -1775,8 +1775,8 @@ describe( 'EditorWatchdog', () => { } ); expect( watchdog.editor.getRootsAttributes() ).to.deep.equal( { - header: { order: 1 }, - new: { order: 3 } + header: { order: 1, $rootEditableOptions: {} }, + new: { order: 3, $rootEditableOptions: {} } } ); await watchdog.destroy(); @@ -1920,8 +1920,8 @@ describe( 'EditorWatchdog', () => { } ); expect( watchdog.editor.getRootsAttributes() ).to.deep.equal( { - header: { order: 1 }, - content: { order: 2 } + header: { order: 1, $rootEditableOptions: {} }, + content: { order: 2, $rootEditableOptions: {} } } ); } ); @@ -1947,8 +1947,8 @@ describe( 'EditorWatchdog', () => { } ); expect( watchdog.editor.getRootsAttributes() ).to.deep.equal( { - header: { order: 1 }, - new: { order: 3 } + header: { order: 1, $rootEditableOptions: {} }, + new: { order: 3, $rootEditableOptions: {} } } ); } ); @@ -1974,9 +1974,9 @@ describe( 'EditorWatchdog', () => { } ); expect( watchdog.editor.getRootsAttributes() ).to.deep.equal( { - header: { order: 1 }, - content: { order: 2 }, - lazyTwo: { order: 5 } + header: { order: 1, $rootEditableOptions: {} }, + content: { order: 2, $rootEditableOptions: {} }, + lazyTwo: { order: 5, $rootEditableOptions: {} } } ); } ); } ); @@ -2081,8 +2081,8 @@ describe( 'EditorWatchdog', () => { } ); expect( watchdog.editor.getRootsAttributes() ).to.deep.equal( { - header: { order: 1 }, - new: { order: 3 } + header: { order: 1, $rootEditableOptions: {} }, + new: { order: 3, $rootEditableOptions: {} } } ); } ); @@ -2108,9 +2108,9 @@ describe( 'EditorWatchdog', () => { } ); expect( watchdog.editor.getRootsAttributes() ).to.deep.equal( { - header: { order: 1 }, - content: { order: 2 }, - lazyTwo: { order: 5 } + header: { order: 1, $rootEditableOptions: {} }, + content: { order: 2, $rootEditableOptions: {} }, + lazyTwo: { order: 5, $rootEditableOptions: {} } } ); } ); } ); @@ -2148,8 +2148,8 @@ describe( 'EditorWatchdog', () => { } ); expect( watchdog.editor.getRootsAttributes() ).to.deep.equal( { - header: { order: 1 }, - content: { order: 2 } + header: { order: 1, $rootEditableOptions: { placeholder: 'Type in header' } }, + content: { order: 2, $rootEditableOptions: { placeholder: 'Type in content' } } } ); const editables = watchdog.editor.ui.view.editables; @@ -2188,8 +2188,8 @@ describe( 'EditorWatchdog', () => { } ); expect( watchdog.editor.getRootsAttributes() ).to.deep.equal( { - header: { order: 1 }, - content: { order: 2 } + header: { order: 1, $rootEditableOptions: { placeholder: 'Type in some content' } }, + content: { order: 2, $rootEditableOptions: { placeholder: 'Type in some content' } } } ); const editables = watchdog.editor.ui.view.editables;