Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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.
7 changes: 5 additions & 2 deletions src/ckeditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/

import React from 'react';
import React, { type ElementType } from 'react';
Comment thread
Mati365 marked this conversation as resolved.
Outdated

import type {
EventInfo,
Expand Down Expand Up @@ -209,8 +209,10 @@ 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 { tagName: TagName = 'div' } = this.props;

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

Expand Down Expand Up @@ -473,6 +475,7 @@ export interface Props<TEditor extends Editor> {
data?: string;
disabled?: boolean;
id?: any;
tagName?: ElementType;
}

interface ErrorDetails {
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
109 changes: 77 additions & 32 deletions src/multiroot/EditorEditable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,62 +3,66 @@
* 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, type ElementType, 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';

/**
* 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 ) => {
export const EditorEditable = memo( forwardRef<HTMLDivElement, Props>( ( { id, editor, rootName }, ref ) => {
const innerRef = useRef<HTMLDivElement>( 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;
}

const { name: TagName } = rootEditableOptions.element ?? { name: 'div' };

return (
<div
key={semaphore.revision}
<TagName
key={editor?.id}
Comment thread
Mati365 marked this conversation as resolved.
id={id}
ref={ mergeRefs( ref, innerRef ) }
/>
Expand All @@ -70,5 +74,46 @@ 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?: ViewRootElementDefinition;
};

type ViewRootElementDefinition = {

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

/**
* 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>;
};
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;
};
85 changes: 63 additions & 22 deletions src/multiroot/useMultiRootEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
uniq,
getInstalledCKBaseFeatures,
assignAttributesPropToMultiRootEditorConfig,
assignInitialDataToMultirootEditorConfig
assignInitialDataToMultirootEditorConfig,
omit
} from '@ckeditor/ckeditor5-integrations-common';

import type {
Expand All @@ -39,7 +40,7 @@ import { useRefSafeCallback } from '../hooks/useRefSafeCallback.js';
import { useInstantEditorEffect } from '../hooks/useInstantEditorEffect.js';

import { appendAllIntegrationPluginsToConfig } from '../plugins/appendAllIntegrationPluginsToConfig.js';
import { EditorEditable } from './EditorEditable.js';
import { EditorEditable, type RootEditableOptionsAttribute } from './EditorEditable.js';
import { EditorToolbarWrapper } from './EditorToolbar.js';
import { EditorWatchdogAdapter } from '../EditorWatchdogAdapter.js';

Expand Down Expand Up @@ -493,13 +494,6 @@ export const useMultiRootEditor = ( props: MultiRootHookProps ): MultiRootHookRe
[ setAttributes ]
);

const toolbarElement = (
<EditorToolbarWrapper
ref={ semaphoreElementRef }
editor={editorRefs.instance.current}
/>
);

useInstantEditorEffect( semaphore.current, ( { instance } ) => {
if ( props.disabled ) {
instance.enableReadOnlyMode( REACT_INTEGRATION_READ_ONLY_LOCK_ID );
Expand Down Expand Up @@ -550,32 +544,33 @@ export const useMultiRootEditor = ( props: MultiRootHookProps ): MultiRootHookRe

for ( const rootName of roots ) {
/* istanbul ignore next -- @preserve: attributes should be in sync with root keys */
const rootAttributes = attributes?.[ rootName ] || {};
const { $createRootOptions = {}, ...rootAttributes } = attributes?.[ rootName ] || {};
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
const rootData = data[ rootName ] || '';

const baseAttrs: Record<string, any> = {
isUndoable: true
/* istanbul ignore start -- compatibility branch for older CKEditor 5 versions */
let options: Record<string, any> = {
isUndoable: true,
...$createRootOptions as Record<string, unknown>
};

/* istanbul ignore start -- compatibility branch for older CKEditor 5 versions */
let attrs: Record<string, any>;
delete rootAttributes.$createRootOptions;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

if ( supports.rootsConfigEntry ) {
attrs = {
...baseAttrs,
options = {
...options,
initialData: rootData,
modelAttributes: rootAttributes
};
} else {
attrs = {
...baseAttrs,
options = {
...options,
data: rootData,
attributes: rootAttributes
};
}
/* istanbul ignore end -- compatibility branch for older CKEditor 5 versions */

instance.addRoot( rootName, attrs );
instance.addRoot( rootName, options );
}
};

Expand Down Expand Up @@ -627,26 +622,70 @@ export const useMultiRootEditor = ( props: MultiRootHookProps ): MultiRootHookRe
}
}, [ data, attributes ] );

const toolbarElement = (
<EditorToolbarWrapper
ref={ semaphoreElementRef }
editor={editorRefs.instance.current}
/>
);

const editableElements = roots.map(
rootName => (
<EditorEditable
key={rootName}
id={rootName}
rootName={rootName}
semaphore={semaphore}
editor={editorRefs.instance.current}
/>
)
);

const removeRoot = ( rootName: string ) => {
_externalSetAttributes( attributes => omit( [ rootName ], attributes ) );
_externalSetData( attributes => omit( [ rootName ], attributes ) );
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
};

const addRoot = ( { name, data, attributes, rootOptions, editableOptions }: AddRootOptions ) => {
_externalSetAttributes( rootsAttributes => ( {
...rootsAttributes,
[ name ]: {
...attributes,
...rootOptions && {
$createRootOptions: rootOptions
},
...editableOptions && {
$rootEditableOptions: editableOptions
}
}
} ) );

_externalSetData( rootsData => ( {
...rootsData,
[ name ]: data || ''
} ) );
};

return {
editor: editorRefs.instance.current,
editableElements,
toolbarElement,
data, setData: _externalSetData,
attributes, setAttributes: _externalSetAttributes
data,
setData: _externalSetData,
attributes,
setAttributes: _externalSetAttributes,
removeRoot,
addRoot
};
};

type AddRootOptions = {
name: string;
data?: string;
attributes?: Record<string, unknown>;
rootOptions?: Record<string, unknown>;
editableOptions?: RootEditableOptionsAttribute;
};

type LifeCycleMountResult = EditorSemaphoreMountResult<MultiRootEditor>;

type LifeCycleSemaphoreRefs = {
Expand Down Expand Up @@ -689,4 +728,6 @@ export type MultiRootHookReturns = {
setData: Dispatch<SetStateAction<Record<string, string>>>;
attributes: Record<string, Record<string, unknown>>;
setAttributes: Dispatch<SetStateAction<Record<string, Record<string, unknown>>>>;
addRoot: ( options: AddRootOptions ) => void;
removeRoot: ( name: string ) => void;
};
Loading