Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
5 changes: 5 additions & 0 deletions .changelog/20260519132746_ck_9933.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
type: Feature
---

Added support for the `tagName` property in the CKEditor component, enabling the customization of the editor's host element tag.
101 changes: 101 additions & 0 deletions src/EditorElement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/

import React, { forwardRef, memo, type ElementType } from 'react';
import { normalizeClassList } from './utils/normalizeClassList.js';

/**
* Creates and renders a dynamic React element based on the element definition owned and provided by the editor.
*
* @param props The component properties.
*/
export const EditorElement = memo( forwardRef<HTMLElement, EditorElementProps>( ( { definition }, ref ) => {
const { id, name: Tag, classes, styles, attributes } = normalizeEditorElementDefinition( definition ?? {
name: 'div'
} );

return (
<Tag
ref={ref}
{...attributes}
id={id}
className={normalizeClassList( classes )}
style={styles}
/>
);
} ) );

EditorElement.displayName = 'EditorElement';

Comment thread
Mati365 marked this conversation as resolved.
/**
* Normalizes an editor element definition into a structured object.
*
* @param definition The definition to normalize.
* @returns A strictly typed object definition containing at least the element name.
*/
function normalizeEditorElementDefinition( definition: EditorElementDefinition ): EditorElementObjectDefinition {
if ( typeof HTMLElement !== 'undefined' && definition instanceof HTMLElement ) {
throw new Error(
'An HTMLElement cannot be used as an editor element definition. ' +
'Please pass a string, a React component, or an object definition.'
);
}

if ( typeof definition !== 'object' || definition === null ) {
return {
name: definition
};
}

return definition as EditorElementObjectDefinition;
}

/**
* Defines an editor element. It can be a basic React element type or a detailed configuration object.
*/
export type EditorElementDefinition = ElementType | EditorElementObjectDefinition;

/**
* An object-based definition for an editor element, allowing customization
* of classes, styles, and HTML attributes.
*/
export type EditorElementObjectDefinition = {

/**
* The DOM tag name or React component to use.
*/
name: ElementType;

/**
* The unique identifier (ID) to apply to the editable element.
*/
id?: string;

/**
* Class name or array of class names to apply to the editable element. Each name can be provided as a string.
*/
classes?: string | Array<string>;

/**
* Inline styles to apply to the editable element as a record of style properties.
*/
styles?: Record<string, string>;

/**
* Additional DOM attributes to apply to the editable element.
*/
attributes?: Record<string, string>;
};

/**
* Properties for the {@link EditorElement} component.
*/
export type EditorElementProps = {

/**
* The definition of the element to be rendered. Defaults to a `div` element if not provided or null.
*/
definition?: EditorElementDefinition | null;
};
12 changes: 10 additions & 2 deletions src/ckeditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ import {
assignElementToEditorConfig,
compareInstalledCKBaseVersion,
getInstalledCKBaseFeatures,
type EditorRelaxedConstructor
type EditorRelaxedConstructor,
type EditorRelaxedConfig
} from '@ckeditor/ckeditor5-integrations-common';
import { EditorElement, type EditorElementObjectDefinition } from './EditorElement.js';

const REACT_INTEGRATION_READ_ONLY_LOCK_ID = 'Lock from React integration (@ckeditor/ckeditor5-react)';

Expand Down Expand Up @@ -209,8 +211,14 @@ export default class CKEditor<TEditor extends Editor> extends React.Component<Pr
* Render a <div> element which will be replaced by CKEditor.
*/
public override render(): React.ReactNode {
const config = ( this.props.config ?? {} ) as EditorRelaxedConfig;
const definition = ( config.roots?.main?.element ?? config.root?.element ) as EditorElementObjectDefinition | undefined;

return (
<div ref={ this.domContainer }></div>
<EditorElement
ref={ this.domContainer }
definition={definition}
/>
);
}

Expand Down
4 changes: 1 addition & 3 deletions src/lifecycle/useLifeCycleSemaphoreSyncRef.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,7 @@ export const useLifeCycleSemaphoreSyncRef = <R extends object>(): LifeCycleSemap
};

const runAfterMount = ( callback: LifeCycleAfterMountCallback<R> ) => {
if ( semaphoreRef.current ) {
semaphoreRef.current.runAfterMount( callback );
}
semaphoreRef.current?.runAfterMount( callback );
};

const replace = ( newSemaphore: () => LifeCycleElementSemaphore<R> ) => {
Expand Down
93 changes: 59 additions & 34 deletions src/multiroot/EditorEditable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,71 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/

import React, { forwardRef, useEffect, useRef, memo } from 'react';
import React, { forwardRef, useEffect, useRef, memo, useMemo } from 'react';

import { mergeRefs } from '../utils/mergeRefs.js';

import type { LifeCycleSemaphoreSyncRefResult } from '../lifecycle/useLifeCycleSemaphoreSyncRef.js';
import type { EditorSemaphoreMountResult } from '../lifecycle/LifeCycleEditorSemaphore.js';
import type { InlineEditableUIView, MultiRootEditor } from 'ckeditor5';
import type { MultiRootEditor } from 'ckeditor5';
import { EditorElement, type EditorElementObjectDefinition } from '../EditorElement.js';

/**
* A React component that renders a single editable area (root) for the `MultiRootEditor`.
* It handles the lifecycle of the editable element by attaching it to the editor
* instance once mounted and safely detaching it during cleanup.
*/
export const EditorEditable = memo( forwardRef<HTMLDivElement, Props>( ( { id, semaphore, rootName }, ref ) => {
const innerRef = useRef<HTMLDivElement>( null );
export const EditorEditable = memo( forwardRef<HTMLElement, Props>( ( { id, editor, rootName }, ref ) => {
const innerRef = useRef<HTMLElement>( null );

useEffect( () => {
let editable: InlineEditableUIView | null;
let editor: MultiRootEditor | null;
const root = useMemo( () => editor?.model.document.getRoot( rootName ), [ editor, rootName ] );
const rootEditableOptions = useMemo( () => {
if ( !root ) {
return null;
}

semaphore.runAfterMount( ( { instance } ) => {
if ( !innerRef.current ) {
return;
}
const options = root.getAttribute( '$rootEditableOptions' ) as RootEditableOptionsAttribute | undefined;

editor = instance;
return { ...options };
}, [ root ] );
Comment thread
Mati365 marked this conversation as resolved.

const { ui, model } = editor;
const root = model.document.getRoot( rootName );
useEffect( () => {
if ( !editor || !root || !rootEditableOptions ) {
return;
}
Comment thread
Mati365 marked this conversation as resolved.

if ( root && editor.ui.getEditableElement( rootName ) ) {
editor.detachEditable( root );
}
// Detach already attached editable if any.
if ( editor.ui.getEditableElement( rootName ) ) {
editor.detachEditable( root );
}

editable = ui.view.createEditable( rootName, innerRef.current );
ui.addEditable( editable );
const editableElement = innerRef.current!;
const editable = editor.ui.view.createEditable( rootName, editableElement, rootEditableOptions.label );

instance.editing.view.forceRender();
} );
editor.ui.addEditable( editable, rootEditableOptions.placeholder );
editor.editing.view.forceRender();

return () => {
/* istanbul ignore next -- @preserve: It depends on the version of the React and may not happen all of the times. */
if ( editor && editor.state !== 'destroyed' && innerRef.current ) {
const root = editor.model.document.getRoot( rootName );
if ( editor && editor.state !== 'destroyed' ) {
const currentRoot = editor.model.document.getRoot( rootName );

/* istanbul ignore else -- @preserve */
if ( root ) {
if ( currentRoot === root ) {
editor.detachEditable( root );
}
}
};
}, [ semaphore.revision ] );
}, [ editor, root, rootEditableOptions ] );

if ( !rootEditableOptions ) {
return null;
}

return (
<div
key={semaphore.revision}
id={id}
<EditorElement
key={editor?.id}
Comment thread
Mati365 marked this conversation as resolved.
ref={ mergeRefs( ref, innerRef ) }
definition={{
id,
name: 'div',
...rootEditableOptions.element
}}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
/>
);
} ) );
Expand All @@ -70,5 +77,23 @@ EditorEditable.displayName = 'EditorEditable';
type Props = {
id: string;
rootName: string;
semaphore: LifeCycleSemaphoreSyncRefResult<EditorSemaphoreMountResult<MultiRootEditor>>;
editor: MultiRootEditor | null;
};

export type RootEditableOptionsAttribute = {

/**
* Placeholder for the editable element. If not set, placeholder value from the editor configuration will be used (if it was provided).
*/
placeholder?: string;

/**
* The accessible label text describing the editable to the assistive technologies.
*/
label?: string;

/**
* A description of the editable root element to create.
*/
element?: EditorElementObjectDefinition;
};
10 changes: 8 additions & 2 deletions src/multiroot/EditorToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
*/

import React, { forwardRef, useEffect, useRef } from 'react';
import type { MultiRootEditor } from 'ckeditor5';

import { mergeRefs } from '../utils/mergeRefs.js';

/**
* A React component that wraps and renders the CKEditor toolbar.
* It extracts the toolbar DOM element from the provided editor instance
* and safely appends it to a local `div` container, handling cleanup on unmount.
*/
export const EditorToolbarWrapper = forwardRef( ( { editor }: any, ref ) => {
export const EditorToolbarWrapper = forwardRef( ( { editor }: Props, ref ) => {
const toolbarRef = useRef<HTMLDivElement>( null );

useEffect( () => {
Expand All @@ -30,9 +32,13 @@ export const EditorToolbarWrapper = forwardRef( ( { editor }: any, ref ) => {
toolbarContainer.removeChild( element! );
}
};
}, [ editor && editor.id ] );
}, [ editor ] );

return <div ref={mergeRefs( toolbarRef, ref )}></div>;
} );

EditorToolbarWrapper.displayName = 'EditorToolbarWrapper';

type Props = {
editor: MultiRootEditor | null;
};
Loading