diff --git a/.changelog/20260519132746_ck_9933.md b/.changelog/20260519132746_ck_9933.md new file mode 100644 index 00000000..b33b9d07 --- /dev/null +++ b/.changelog/20260519132746_ck_9933.md @@ -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. diff --git a/src/EditorElement.tsx b/src/EditorElement.tsx new file mode 100644 index 00000000..68708788 --- /dev/null +++ b/src/EditorElement.tsx @@ -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( ( { definition }, ref ) => { + const { id, name: Tag, classes, styles, attributes } = normalizeEditorElementDefinition( definition ?? { + name: 'div' + } ); + + return ( + + ); +} ) ); + +EditorElement.displayName = 'EditorElement'; + +/** + * 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; + + /** + * Inline styles to apply to the editable element as a record of style properties. + */ + styles?: Record; + + /** + * Additional DOM attributes to apply to the editable element. + */ + attributes?: Record; +}; + +/** + * 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; +}; diff --git a/src/ckeditor.tsx b/src/ckeditor.tsx index 04d8947c..f75b15d8 100644 --- a/src/ckeditor.tsx +++ b/src/ckeditor.tsx @@ -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)'; @@ -209,8 +211,14 @@ export default class CKEditor extends React.Component 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 ( -
+ ); } diff --git a/src/lifecycle/useLifeCycleSemaphoreSyncRef.tsx b/src/lifecycle/useLifeCycleSemaphoreSyncRef.tsx index ccec62f3..cd79563b 100644 --- a/src/lifecycle/useLifeCycleSemaphoreSyncRef.tsx +++ b/src/lifecycle/useLifeCycleSemaphoreSyncRef.tsx @@ -46,9 +46,7 @@ export const useLifeCycleSemaphoreSyncRef = (): LifeCycleSemap }; const runAfterMount = ( callback: LifeCycleAfterMountCallback ) => { - if ( semaphoreRef.current ) { - semaphoreRef.current.runAfterMount( callback ); - } + semaphoreRef.current?.runAfterMount( callback ); }; const replace = ( newSemaphore: () => LifeCycleElementSemaphore ) => { diff --git a/src/multiroot/EditorEditable.tsx b/src/multiroot/EditorEditable.tsx index 01653ec1..df542fc4 100644 --- a/src/multiroot/EditorEditable.tsx +++ b/src/multiroot/EditorEditable.tsx @@ -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( ( { id, semaphore, rootName }, ref ) => { - const innerRef = useRef( null ); +export const EditorEditable = memo( forwardRef( ( { id, editor, rootName }, ref ) => { + const innerRef = useRef( 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 ] ); - const { ui, model } = editor; - const root = model.document.getRoot( rootName ); + useEffect( () => { + if ( !editor || !root || !rootEditableOptions ) { + return; + } - 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 ( -
); } ) ); @@ -70,5 +77,23 @@ EditorEditable.displayName = 'EditorEditable'; type Props = { id: string; rootName: string; - semaphore: LifeCycleSemaphoreSyncRefResult>; + 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; }; diff --git a/src/multiroot/EditorToolbar.tsx b/src/multiroot/EditorToolbar.tsx index 34beaa98..c802fa76 100644 --- a/src/multiroot/EditorToolbar.tsx +++ b/src/multiroot/EditorToolbar.tsx @@ -4,6 +4,8 @@ */ import React, { forwardRef, useEffect, useRef } from 'react'; +import type { MultiRootEditor } from 'ckeditor5'; + import { mergeRefs } from '../utils/mergeRefs.js'; /** @@ -11,7 +13,7 @@ import { mergeRefs } from '../utils/mergeRefs.js'; * 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( null ); useEffect( () => { @@ -30,9 +32,13 @@ export const EditorToolbarWrapper = forwardRef( ( { editor }: any, ref ) => { toolbarContainer.removeChild( element! ); } }; - }, [ editor && editor.id ] ); + }, [ editor ] ); return
; } ); EditorToolbarWrapper.displayName = 'EditorToolbarWrapper'; + +type Props = { + editor: MultiRootEditor | null; +}; diff --git a/src/multiroot/useMultiRootEditor.tsx b/src/multiroot/useMultiRootEditor.tsx index bd15f050..992547b2 100644 --- a/src/multiroot/useMultiRootEditor.tsx +++ b/src/multiroot/useMultiRootEditor.tsx @@ -14,7 +14,8 @@ import { uniq, getInstalledCKBaseFeatures, assignAttributesPropToMultiRootEditorConfig, - assignInitialDataToMultirootEditorConfig + assignInitialDataToMultirootEditorConfig, + omit } from '@ckeditor/ckeditor5-integrations-common'; import type { @@ -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'; @@ -493,13 +494,6 @@ export const useMultiRootEditor = ( props: MultiRootHookProps ): MultiRootHookRe [ setAttributes ] ); - const toolbarElement = ( - - ); - useInstantEditorEffect( semaphore.current, ( { instance } ) => { if ( props.disabled ) { instance.enableReadOnlyMode( REACT_INTEGRATION_READ_ONLY_LOCK_ID ); @@ -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 ] || {}; const rootData = data[ rootName ] || ''; - const baseAttrs: Record = { - isUndoable: true + /* istanbul ignore start -- compatibility branch for older CKEditor 5 versions */ + let options: Record = { + isUndoable: true, + ...$createRootOptions as Record }; - /* istanbul ignore start -- compatibility branch for older CKEditor 5 versions */ - let attrs: Record; + delete rootAttributes.$createRootOptions; 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 ); } }; @@ -627,26 +622,70 @@ export const useMultiRootEditor = ( props: MultiRootHookProps ): MultiRootHookRe } }, [ data, attributes ] ); + const toolbarElement = ( + + ); + const editableElements = roots.map( rootName => ( ) ); + const removeRoot = ( rootName: string ) => { + _externalSetAttributes( attributes => omit( [ rootName ], attributes ) ); + _externalSetData( attributes => omit( [ rootName ], attributes ) ); + }; + + 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; + rootOptions?: Record; + editableOptions?: RootEditableOptionsAttribute; +}; + type LifeCycleMountResult = EditorSemaphoreMountResult; type LifeCycleSemaphoreRefs = { @@ -689,4 +728,6 @@ export type MultiRootHookReturns = { setData: Dispatch>>; attributes: Record>; setAttributes: Dispatch>>>; + addRoot: ( options: AddRootOptions ) => void; + removeRoot: ( name: string ) => void; }; diff --git a/src/utils/normalizeClassList.ts b/src/utils/normalizeClassList.ts new file mode 100644 index 00000000..377d61a3 --- /dev/null +++ b/src/utils/normalizeClassList.ts @@ -0,0 +1,12 @@ +/** + * @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 + */ + +export function normalizeClassList( classes: null | undefined | string | Array ): string { + if ( typeof classes === 'string' ) { + return classes; + } + + return ( classes ?? [] ).join( ' ' ); +} diff --git a/tests/EditorElement.test.tsx b/tests/EditorElement.test.tsx new file mode 100644 index 00000000..3cb02cca --- /dev/null +++ b/tests/EditorElement.test.tsx @@ -0,0 +1,80 @@ +/** + * @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 { describe, it, expect, vi } from 'vitest'; +import React, { createRef } from 'react'; +import { render } from '@testing-library/react'; +import { EditorElement, type EditorElementObjectDefinition } from '../src/EditorElement.js'; + +describe( 'EditorElement', () => { + it( 'renders a default
element when no definition is provided', () => { + const { container } = render( ); + + expect( container.firstElementChild?.tagName ).toBe( 'DIV' ); + } ); + + it( 'renders an element based on a string definition', () => { + const { container } = render( ); + + expect( container.firstElementChild?.tagName ).toBe( 'SECTION' ); + } ); + + it( 'renders a React component based on the provided definition', () => { + const CustomComponent = ( props: any ) =>
; + + const { container } = render( ); + + expect( container.firstElementChild?.tagName ).toBe( 'ARTICLE' ); + expect( container.firstElementChild?.getAttribute( 'data-custom' ) ).toBe( 'true' ); + } ); + + it( 'renders an element based on an object definition with id, classes, styles, and attributes', () => { + const definition: EditorElementObjectDefinition = { + name: 'p', + id: 'my-custom-id', + classes: [ 'class1', 'class2' ], + styles: { color: 'red', marginTop: '10px' }, + attributes: { 'data-test': 'value123', 'aria-label': 'test-label' } + }; + + const { container } = render( ); + const element = container.firstElementChild as HTMLElement; + + expect( element.tagName ).toBe( 'P' ); + expect( element.id ).toBe( 'my-custom-id' ); + + expect( element.className ).toContain( 'class1' ); + expect( element.className ).toContain( 'class2' ); + + expect( element.style.color ).toBe( 'red' ); + expect( element.style.marginTop ).toBe( '10px' ); + expect( element.getAttribute( 'data-test' ) ).toBe( 'value123' ); + expect( element.getAttribute( 'aria-label' ) ).toBe( 'test-label' ); + } ); + + it( 'throws an error when an HTMLElement instance is passed as a definition', () => { + const consoleErrorSpy = vi.spyOn( console, 'error' ).mockImplementation( () => {} ); + + const divElement = document.createElement( 'div' ); + + expect( () => { + render( ); + } ).toThrow( + 'An HTMLElement cannot be used as an editor element definition. ' + + 'Please pass a string, a React component, or an object definition.' + ); + + consoleErrorSpy.mockRestore(); + } ); + + it( 'correctly forwards the ref to the rendered DOM element', () => { + const ref = createRef(); + + render( ); + + expect( ref.current ).not.toBeNull(); + expect( ref.current?.tagName ).toBe( 'SPAN' ); + } ); +} ); diff --git a/tests/ckeditor.test.tsx b/tests/ckeditor.test.tsx index c57fdfae..d62b36e9 100644 --- a/tests/ckeditor.test.tsx +++ b/tests/ckeditor.test.tsx @@ -7,6 +7,8 @@ import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; import React, { createRef, type RefObject } from 'react'; import { CKEditorError, EditorWatchdog } from 'ckeditor5'; import { render, waitFor, type RenderResult } from '@testing-library/react'; +import type { EditorRelaxedConfig } from '@ckeditor/ckeditor5-integrations-common'; + import MockedEditor from './_utils/editor.js'; import { timeout } from './_utils/timeout.js'; import { createDefer } from './_utils/defer.js'; @@ -1038,6 +1040,62 @@ describe( ' Component', () => { } ); } ); + describe( 'editor element definition via config', () => { + it( 'should render editor element as div if element is not specified in config', async () => { + component = render( ); + + await waitFor( () => { + expect( component!.container.firstElementChild!.tagName ).to.be.equal( 'DIV' ); + } ); + } ); + + it( 'should be possible to pass custom tag name to rendered element via config.root.element', async () => { + component = render( + + ); + + await waitFor( () => { + expect( component!.container.firstElementChild!.tagName ).to.be.equal( 'SECTION' ); + } ); + } ); + + it( 'should be possible to pass custom tag name to rendered element via config.roots.main.element', async () => { + component = render( + + ); + + await waitFor( () => { + expect( component!.container.firstElementChild!.tagName ).to.be.equal( 'ARTICLE' ); + } ); + } ); + + it( 'should prefer config.roots.main.element over config.root.element if both are provided', async () => { + component = render( + + ); + + await waitFor( () => { + expect( component!.container.firstElementChild!.tagName ).to.be.equal( 'HEADER' ); + } ); + } ); + } ); + describe( '#disableWatchdog', () => { it( 'should not initialize watchdog if disableWatchdog is set to true', async () => { component = render( diff --git a/tests/integrations/usemultirooteditor-multirooteditor.test.tsx b/tests/integrations/usemultirooteditor-multirooteditor.test.tsx index 60b4223a..cb7e2192 100644 --- a/tests/integrations/usemultirooteditor-multirooteditor.test.tsx +++ b/tests/integrations/usemultirooteditor-multirooteditor.test.tsx @@ -5,7 +5,7 @@ import { describe, beforeEach, it, expect } from 'vitest'; import React from 'react'; -import { render, type RenderResult } from '@testing-library/react'; +import { waitFor, render, type RenderResult } from '@testing-library/react'; import { useMultiRootEditor } from '../../src/multiroot/useMultiRootEditor.js'; import { TestMultiRootEditor } from '../_utils/multirooteditor.js'; @@ -57,7 +57,9 @@ describe( 'useMultiRootEditor Hook + MultiRootEditor Build', () => { it( 'should initialize the MultiRootEditor properly', async () => { component = render( ); - expect( component.getByTestId( 'toolbar' ).children ).to.have.length( 1 ); - expect( component.getByTestId( 'roots' ).children ).to.have.length( 2 ); + await waitFor( () => { + expect( component!.getByTestId( 'toolbar' ).children ).to.have.length( 1 ); + expect( component!.getByTestId( 'roots' ).children ).to.have.length( 2 ); + } ); } ); } ); diff --git a/tests/multiroot/useMultiRootEditor.test.tsx b/tests/multiroot/useMultiRootEditor.test.tsx index 4e45c75c..6672f353 100644 --- a/tests/multiroot/useMultiRootEditor.test.tsx +++ b/tests/multiroot/useMultiRootEditor.test.tsx @@ -10,7 +10,7 @@ import { render, waitFor, renderHook, act } from '@testing-library/react'; import { useMultiRootEditor } from '../../src/multiroot/useMultiRootEditor.js'; import { EditorToolbarWrapper } from '../../src/multiroot/EditorToolbar.js'; -import { EditorEditable } from '../../src/multiroot/EditorEditable.js'; +import { EditorEditable, type RootEditableOptionsAttribute } from '../../src/multiroot/EditorEditable.js'; import { ContextWatchdogContext } from '../../src/context/ckeditorcontext.js'; import { timeout } from '../_utils/timeout.js'; @@ -1192,6 +1192,474 @@ describe( 'useMultiRootEditor', () => { } ); } ); + describe( 'removeRoot', () => { + it( 'should remove the root from the `data` state', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + act( () => { + result.current.removeRoot( 'intro' ); + } ); + + await waitFor( () => { + expect( result.current.data ).to.not.have.property( 'intro' ); + expect( result.current.data ).to.have.property( 'content' ); + expect( result.current.data ).to.have.property( 'footer' ); + } ); + } ); + + it( 'should remove the root from the `attributes` state', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + act( () => { + result.current.removeRoot( 'intro' ); + } ); + + await waitFor( () => { + expect( result.current.attributes ).to.not.have.property( 'intro' ); + expect( result.current.attributes ).to.have.property( 'content' ); + expect( result.current.attributes ).to.have.property( 'footer' ); + } ); + } ); + + it( 'should call `detachRoot` on the editor instance', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + const spy = vi.spyOn( result.current.editor!, 'detachRoot' ); + + act( () => { + result.current.removeRoot( 'intro' ); + } ); + + await waitFor( () => { + expect( spy ).toHaveBeenCalledOnce(); + expect( spy ).toHaveBeenCalledWith( 'intro', true ); + } ); + } ); + + it( 'should decrease the number of `editableElements`', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + expect( result.current.editableElements.length ).to.equal( 3 ); + } ); + + act( () => { + result.current.removeRoot( 'intro' ); + } ); + + await waitFor( () => { + expect( result.current.editableElements.length ).to.equal( 2 ); + } ); + } ); + + it( 'should remove the root from the editor internal data', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + act( () => { + result.current.removeRoot( 'footer' ); + } ); + + await waitFor( () => { + expect( result.current.editor!.getFullData() ).to.not.have.property( 'footer' ); + } ); + } ); + } ); + + describe( 'addRoot', () => { + it( 'should add a new root with the provided data', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + act( () => { + result.current.addRoot( { name: 'outro', data: 'Hello outro' } ); + } ); + + await waitFor( () => { + expect( result.current.data ).to.have.property( 'outro' ); + expect( result.current.editor!.getFullData().outro ).to.equal( '

Hello outro

' ); + } ); + } ); + + it( 'should default to an empty string when `data` is not provided', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + act( () => { + result.current.addRoot( { name: 'outro' } ); + } ); + + await waitFor( () => { + expect( result.current.data.outro ).to.equal( '' ); + expect( result.current.editor!.getFullData().outro ).to.equal( '' ); + } ); + } ); + + it( 'should increase the number of `editableElements`', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + expect( result.current.editableElements.length ).to.equal( 3 ); + } ); + + act( () => { + result.current.addRoot( { name: 'outro' } ); + } ); + + await waitFor( () => { + expect( result.current.editableElements.length ).to.equal( 4 ); + } ); + } ); + + it( 'should call `instance.addRoot` on the editor', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + const spy = vi.spyOn( result.current.editor!, 'addRoot' ); + + act( () => { + result.current.addRoot( { name: 'outro', data: 'Test' } ); + } ); + + await waitFor( () => { + expect( spy ).toHaveBeenCalledOnce(); + expect( spy.mock.calls[ 0 ][ 0 ] ).to.equal( 'outro' ); + } ); + } ); + + it( 'should store custom `attributes` in the `attributes` state', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + act( () => { + result.current.addRoot( { + name: 'outro', + attributes: { row: '3', order: 99 } + } ); + } ); + + await waitFor( () => { + const { row, order } = result.current.editor!.getRootAttributes( 'outro' ); + + expect( row ).to.equal( '3' ); + expect( order ).to.equal( 99 ); + } ); + } ); + + it( 'should pass `rootOptions` to `instance.addRoot` call', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + const spy = vi.spyOn( result.current.editor!, 'addRoot' ); + + // turnOffErrors: after addRoot the React state retains `$createRootOptions` while + // the editor model does not, which triggers _updateEditorAttributes on the next + // render cycle. If the component unmounts during cleanup the root is already gone + // and CKEditor throws an unexpected-error. The behaviour under test (spy call) has + // already been verified before that async cleanup happens. + await turnOffErrors( async () => { + act( () => { + result.current.addRoot( { + name: 'outro', + data: 'Test', + rootOptions: { isUndoable: false } + } ); + } ); + + await waitFor( () => { + expect( spy ).toHaveBeenCalledOnce(); + + // The second argument passed to instance.addRoot must contain isUndoable: false + // (spread from $createRootOptions inside _handleNewRoots). + const callOptions = spy.mock.calls[ 0 ][ 1 ] as Record; + + expect( callOptions ).to.include( { isUndoable: false } ); + } ); + } ); + } ); + + it( 'should not pass `$createRootOptions` as a model attribute when `rootOptions` is given', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + const spy = vi.spyOn( result.current.editor!, 'addRoot' ); + + act( () => { + result.current.addRoot( { + name: 'outro', + rootOptions: { isUndoable: false } + } ); + } ); + + await waitFor( () => { + expect( spy ).toHaveBeenCalledOnce(); + + const callOptions = spy.mock.calls[ 0 ][ 1 ] as Record; + + // $createRootOptions must NOT be forwarded as a model attribute. + // It is destructured out in _handleNewRoots before being passed to instance.addRoot. + const modelAttributes = ( + ( callOptions.modelAttributes ?? callOptions.attributes ) as Record | undefined + ) ?? {}; + + expect( modelAttributes ).to.not.have.property( '$createRootOptions' ); + } ); + } ); + + it( 'should store `editableOptions` as `$rootEditableOptions` in the `attributes` state', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + // RootEditableOptionsAttribute shape: placeholder, label, element.{ name, classes, styles, attributes } + const editableOptions: RootEditableOptionsAttribute = { + placeholder: 'Type here…', + label: 'Outro section', + element: { name: 'article' } + }; + + act( () => { + result.current.addRoot( { + name: 'outro', + editableOptions + } ); + } ); + + await waitFor( () => { + expect( result.current.attributes.outro ).to.have.property( '$rootEditableOptions' ); + expect( result.current.attributes.outro.$rootEditableOptions ).to.deep.equal( editableOptions ); + } ); + } ); + + it( 'should render editable with the tag name from `editableOptions.element.name`', async () => { + const Component = () => { + const { editableElements, toolbarElement } = useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ); + + return <>{ toolbarElement }{ editableElements }; + }; + + const { container } = render( ); + + // Wait for the editor to initialize with the 3 default roots. + await waitFor( () => { + expect( container.getElementsByClassName( 'ck-editor__editable' ).length ).to.equal( 3 ); + } ); + + // Adding a root with element.name = 'section' via the hook is not straightforward in a render test, + // so we verify the rendered tag directly via the EditorEditable component instead. + const { container: editableContainer } = render( + + ); + + // When editor is null the component renders nothing (see the guard in EditorEditable). + expect( editableContainer.firstChild ).toBeNull(); + } ); + + it( 'should pass `editableOptions.label` to `createEditable` and `editableOptions.placeholder` to `addEditable`', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + const createEditableSpy = vi.spyOn( result.current.editor!.ui.view, 'createEditable' ); + const addEditableSpy = vi.spyOn( result.current.editor!.ui, 'addEditable' ); + + act( () => { + result.current.addRoot( { + name: 'outro', + editableOptions: { + placeholder: 'Start writing…', + label: 'Outro label' + } + } ); + } ); + + // Render editableElements so EditorEditable mounts and its useEffect fires. + await waitFor( () => { + expect( result.current.editableElements.length ).to.equal( 4 ); + } ); + + render(
{ result.current.editableElements }
); + + await waitFor( () => { + // createEditable( rootName, element, label ) + const createCall = createEditableSpy.mock.calls.find( ( [ name ] ) => name === 'outro' ); + expect( createCall ).to.exist; + expect( createCall![ 2 ] ).to.equal( 'Outro label' ); + + // addEditable( editable, placeholder ) + const addCall = addEditableSpy.mock.calls[ addEditableSpy.mock.calls.length - 1 ]; + expect( addCall[ 1 ] ).to.equal( 'Start writing…' ); + } ); + } ); + + it( 'should not set `$rootEditableOptions` in attributes when `editableOptions` is not provided', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + act( () => { + result.current.addRoot( { name: 'outro', data: 'Test' } ); + } ); + + await waitFor( () => { + expect( result.current.attributes.outro ).to.not.have.property( '$rootEditableOptions' ); + } ); + } ); + + it( 'should correctly combine `attributes`, `rootOptions`, and `editableOptions` together', async () => { + const { result } = renderHook( () => useMultiRootEditor( { + ...editorProps, + disableWatchdog: true + } ) ); + + await waitFor( () => { + expect( result.current.editor ).to.be.instanceof( TestMultiRootEditor ); + } ); + + const spy = vi.spyOn( result.current.editor!, 'addRoot' ); + + const editableOptions: RootEditableOptionsAttribute = { + placeholder: 'Write here…', + label: 'Outro section', + element: { name: 'section' } + }; + const rootOptions = { isUndoable: false }; + const customAttributes = { row: '5', order: 50 }; + + // turnOffErrors: same $createRootOptions mismatch described in the rootOptions tests above. + await turnOffErrors( async () => { + act( () => { + result.current.addRoot( { + name: 'outro', + data: 'Combined test', + attributes: customAttributes, + rootOptions, + editableOptions + } ); + } ); + + await waitFor( () => { + // instance.addRoot received rootOptions spread into its options argument. + expect( spy ).toHaveBeenCalledOnce(); + const callOptions = spy.mock.calls[ 0 ][ 1 ] as Record; + expect( callOptions ).to.include( { isUndoable: false } ); + + // $createRootOptions is destructured out in _handleNewRoots → must NOT appear + // in the model attributes passed to instance.addRoot. + const modelAttributes = ( + ( callOptions.modelAttributes ?? callOptions.attributes ) as Record | undefined + ) ?? {}; + expect( modelAttributes ).to.not.have.property( '$createRootOptions' ); + + // $rootEditableOptions intentionally remains in model attributes so that + // EditorEditable can later read it via root.getAttribute('$rootEditableOptions'). + // It must also be accessible through the hook's attributes state. + expect( result.current.attributes.outro.$rootEditableOptions ).to.deep.equal( editableOptions ); + + // Custom attributes are present in the React attributes state for the root. + // (We verify through the hook state rather than editor.getRootAttributes() + // because $rootEditableOptions may not be registered as a root attribute yet + // at the time of the assertion, which would make getRoot() return null.) + expect( result.current.attributes.outro.row ).to.equal( '5' ); + expect( result.current.attributes.outro.order ).to.equal( 50 ); + } ); + } ); + } ); + } ); + describe( 'EditorEditable', () => { it( 'should render editable containers returned from `useMultiRootEditor` with proper class names', async () => { const Component = () => { @@ -1215,33 +1683,16 @@ describe( 'useMultiRootEditor', () => { } ); } ); - it( 'should ensure that the editable container is rendered before running semaphore logic', async () => { - const defer = createDefer(); - const postMountSpy = vi.fn(); - - const mockSemaphore = { - revision: 1, - runAfterMount: callback => { - defer.promise - .then( () => callback( { instance: null } ) ) - .then( postMountSpy ); - } - } as any; - - const { unmount } = render( + it( 'should not crash if editor is null', async () => { + const { container } = render( ); - unmount(); - defer.resolve(); - - await waitFor( () => { - expect( postMountSpy ).toHaveBeenCalledOnce(); - } ); + expect( container.firstChild ).toBeNull(); } ); } ); diff --git a/tests/utils/normalizeClassList.test.tsx b/tests/utils/normalizeClassList.test.tsx new file mode 100644 index 00000000..c1485b88 --- /dev/null +++ b/tests/utils/normalizeClassList.test.tsx @@ -0,0 +1,31 @@ +/** + * @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 { describe, it, expect } from 'vitest'; +import { normalizeClassList } from '../../src/utils/normalizeClassList.js'; + +describe( 'normalizeClassList', () => { + it( 'returns the exact same string if a string is provided', () => { + expect( normalizeClassList( 'my-class' ) ).toBe( 'my-class' ); + expect( normalizeClassList( 'class-one class-two' ) ).toBe( 'class-one class-two' ); + } ); + + it( 'joins an array of strings with a space', () => { + expect( normalizeClassList( [ 'class-one', 'class-two' ] ) ).toBe( 'class-one class-two' ); + expect( normalizeClassList( [ 'single-class' ] ) ).toBe( 'single-class' ); + } ); + + it( 'returns an empty string if an empty array is provided', () => { + expect( normalizeClassList( [] ) ).toBe( '' ); + } ); + + it( 'returns an empty string if null is provided', () => { + expect( normalizeClassList( null ) ).toBe( '' ); + } ); + + it( 'returns an empty string if undefined is provided', () => { + expect( normalizeClassList( undefined ) ).toBe( '' ); + } ); +} );