Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changelog/20260429140000_ck_20026_title_custom_roots.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changelog/20260429140100_ck_20026_ghs_hgroup.md
Original file line number Diff line number Diff line change
@@ -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`.
9 changes: 9 additions & 0 deletions .changelog/20260429140200_ck_20026_html_comments.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/ckeditor5-core/src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<rootName>.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 ) ) {
Expand Down
90 changes: 58 additions & 32 deletions packages/ckeditor5-editor-multi-root/src/multirooteditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) ) {
/**
Expand Down Expand Up @@ -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.<rootName>.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' ),
Expand Down Expand Up @@ -447,7 +430,8 @@ 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';

if ( !this.model.schema.isLimit( modelElement ) ) {
Expand All @@ -473,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 );

Expand All @@ -484,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 ) {
Expand Down Expand Up @@ -1237,6 +1216,53 @@ function normalizeRootsAttributesConfig( config: Config<EditorConfig> ): void {
}
}

/**
* Normalize `placeholder` and `label` from `config.roots.<rootName>` into the `$rootEditableOptions` root model attribute,
* stored under `config.roots.<rootName>.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<EditorConfig> ): void {
const rootsConfig = config.get( 'roots' )!;

for ( const [ rootName, rootConfig ] of Object.entries( rootsConfig ) ) {
const modelAttributes: EditorRootAttributes = { ...rootConfig.modelAttributes };

if ( !ensureRootEditableOptions( modelAttributes, rootConfig ) ) {
continue;
}

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 );
}
Expand Down
Loading