diff --git a/demos/react/ContextDemo.tsx b/demos/react/ContextDemo.tsx index bcf69ff4..fc4be8cb 100644 --- a/demos/react/ContextDemo.tsx +++ b/demos/react/ContextDemo.tsx @@ -18,16 +18,8 @@ type ContextDemoProps = { content: string; }; -type ContextDemoState = { - editor1: ClassicEditor | null; - editor2: ClassicEditor | null; -}; - export default function ContextDemo( props: ContextDemoProps ): JSX.Element { - const [ state, setState ] = useState( { - editor1: null, - editor2: null - } ); + const [ state, setState ] = useState>( {} ); const simulateError = ( editor: ClassicEditor ) => { setTimeout( () => { @@ -48,10 +40,13 @@ export default function ContextDemo( props: ContextDemoProps ): JSX.Element { { + setState( editors as any ); + } } >
{ - window.editor2 = editor; - - setState( prevState => ( { ...prevState, editor1: editor } ) ); - } } />
{ - window.editor1 = editor; - - setState( prevState => ( { ...prevState, editor2: editor } ) ); - } } />
diff --git a/src/ckeditor.tsx b/src/ckeditor.tsx index eb7e879d..456e1fe0 100644 --- a/src/ckeditor.tsx +++ b/src/ckeditor.tsx @@ -23,11 +23,17 @@ import type { EditorSemaphoreMountResult } from './lifecycle/LifeCycleEditorSema import { uid } from './utils/uid'; import { LifeCycleElementSemaphore } from './lifecycle/LifeCycleElementSemaphore'; + +import { + withCKEditorReactContextMetadata, + type CKEditorConfigContextMetadata +} from './context/setCKEditorReactContextMetadata'; + import { ContextWatchdogContext, isContextWatchdogInitializing, isContextWatchdogReadyToUse -} from './ckeditorcontext'; +} from './context/ckeditorcontext'; const REACT_INTEGRATION_READ_ONLY_LOCK_ID = 'Lock from React integration (@ckeditor/ckeditor5-react)'; @@ -295,6 +301,12 @@ export default class CKEditor extends React.Component, config: EditorConfig ): Promise { + const { contextItemMetadata } = this.props; + + if ( contextItemMetadata ) { + config = withCKEditorReactContextMetadata( contextItemMetadata, config ); + } + return this.props.editor.create( element as HTMLElement, config ) .then( editor => { if ( 'disabled' in this.props ) { @@ -440,6 +452,7 @@ export interface Props extends InferProps( props: Props console.error( error, details ) } = props; @@ -43,10 +49,11 @@ const CKEditorContext = ( props: Props( { + const [ currentContextWatchdog, setCurrentContextWatchdog ] = useState>( { status: 'initializing' } ); + // Lets initialize the context watchdog when the layout is ready. useEffect( () => { if ( isLayoutReady ) { initializeContextWatchdog(); @@ -57,12 +64,19 @@ const CKEditorContext = ( props: Props () => { if ( currentContextWatchdog.status === 'initialized' ) { currentContextWatchdog.watchdog.destroy(); } }, [ currentContextWatchdog ] ); + // Listen for the editor initialization and destruction events and call the onChangeInitializedEditors function. + useInitializedCKEditorsMap( { + currentContextWatchdog, + onChangeInitializedEditors + } ); + /** * Regenerates the initialization ID by generating a random ID and updating the previous watchdog initialization ID. * This is necessary to ensure that the state update is performed only if the current initialization ID matches the previous one. @@ -194,19 +208,22 @@ export const isContextWatchdogReadyToUse = ( obj: any ): obj is ExtractContextWa /** * Represents the value of the ContextWatchdog in the CKEditor context. */ -export type ContextWatchdogValue = +export type ContextWatchdogValue = | { status: 'initializing'; } | { status: 'initialized'; - watchdog: ContextWatchdog; + watchdog: ContextWatchdog; } | { status: 'error'; error: ErrorDetails; }; +/** + * Represents the status of the ContextWatchdogValue. + */ export type ContextWatchdogValueStatus = ContextWatchdogValue[ 'status' ]; /** @@ -220,17 +237,19 @@ export type ExtractContextWatchdogValueByStatus = { - id?: string; - isLayoutReady?: boolean; - context?: { create( ...args: any ): Promise }; - contextWatchdog: typeof ContextWatchdog; - watchdogConfig?: WatchdogConfig; - config?: ContextConfig; - onReady?: ( context: TContext, watchdog: ContextWatchdog ) => void; - onError?: ( error: Error, details: ErrorDetails ) => void; - children?: ReactNode; -}; +export type Props = + & PropsWithChildren + & Pick, 'onChangeInitializedEditors'> + & { + id?: string; + isLayoutReady?: boolean; + context?: { create( ...args: any ): Promise }; + contextWatchdog: typeof ContextWatchdog; + watchdogConfig?: WatchdogConfig; + config?: ContextConfig; + onReady?: ( context: TContext, watchdog: ContextWatchdog ) => void; + onError?: ( error: Error, details: ErrorDetails ) => void; + }; type ErrorDetails = { phase: 'initialization' | 'runtime'; diff --git a/src/context/setCKEditorReactContextMetadata.ts b/src/context/setCKEditorReactContextMetadata.ts new file mode 100644 index 00000000..00ba6dea --- /dev/null +++ b/src/context/setCKEditorReactContextMetadata.ts @@ -0,0 +1,54 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import type { Config, EditorConfig } from 'ckeditor5'; + +/** + * The symbol cannot be used as a key because config getters require strings as keys. + */ +const ReactContextMetadataKey = '$__CKEditorReactContextMetadata'; + +/** + * Sets the metadata in the object. + * + * @param metadata The metadata to set. + * @param object The object to set the metadata in. + * @returns The object with the metadata set. + */ +export function withCKEditorReactContextMetadata( + metadata: CKEditorConfigContextMetadata, + config: EditorConfig +): EditorConfig & { [ ReactContextMetadataKey ]: CKEditorConfigContextMetadata } { + return { + ...config, + [ ReactContextMetadataKey ]: metadata + }; +} + +/** + * Tries to extract the metadata from the object. + * + * @param object The object to extract the metadata from. + */ +export function tryExtractCKEditorReactContextMetadata( object: Config ): CKEditorConfigContextMetadata | null { + return object.get( ReactContextMetadataKey ); +} + +/** + * The metadata that is stored in the React context. + */ +export type CKEditorConfigContextMetadata = { + + /** + * The name of the editor in the React context. It'll be later used in the `useInitializedCKEditorsMap` hook + * to track the editor initialization and destruction events. + */ + name?: string; + + /** + * Any additional metadata that can be stored in the context. + */ + [x: string | number | symbol]: unknown; +}; diff --git a/src/context/useInitializedCKEditorsMap.ts b/src/context/useInitializedCKEditorsMap.ts new file mode 100644 index 00000000..1d49b53b --- /dev/null +++ b/src/context/useInitializedCKEditorsMap.ts @@ -0,0 +1,121 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { useEffect } from 'react'; +import { useRefSafeCallback } from '../hooks/useRefSafeCallback'; + +import type { CollectionAddEvent, Context, ContextWatchdog, Editor } from 'ckeditor5'; +import type { ContextWatchdogValue } from './ckeditorcontext'; + +import { + tryExtractCKEditorReactContextMetadata, + type CKEditorConfigContextMetadata +} from './setCKEditorReactContextMetadata'; + +/** + * A hook that listens for the editor initialization and destruction events and updates the editors map. + * + * @param config The configuration of the hook. + * @param config.currentContextWatchdog The current context watchdog value. + * @param config.onChangeInitializedEditors The function that updates the editors map. + * @example + * ```ts + * useInitializedCKEditorsMap( { + * currentContextWatchdog, + * onChangeInitializedEditors: ( editors, context ) => { + * console.log( 'Editors:', editors ); + * } + * } ); + * ``` + */ +export const useInitializedCKEditorsMap = ( + { + currentContextWatchdog, + onChangeInitializedEditors + }: InitializedContextEditorsConfig +): void => { + // We need to use the safe callback to prevent the stale closure problem. + const onChangeInitializedEditorsSafe = useRefSafeCallback( onChangeInitializedEditors || ( () => {} ) ); + + useEffect( () => { + if ( currentContextWatchdog.status !== 'initialized' ) { + return; + } + + const { watchdog } = currentContextWatchdog; + const editors = watchdog?.context?.editors; + + if ( !editors ) { + return; + } + + // Get the initialized editors from + const getInitializedContextEditors = () => [ ...editors ].reduce( + ( map, editor ) => { + if ( editor.state !== 'ready' ) { + return map; + } + + const metadata = tryExtractCKEditorReactContextMetadata( editor.config ); + const nameOrId = metadata?.name ?? editor.id; + + map[ nameOrId ] = { + instance: editor, + metadata + }; + + return map; + }, + Object.create( {} ) // Prevent the prototype pollution. + ); + + // The function that is called when the editor status changes. + const onEditorStatusChange = () => { + onChangeInitializedEditorsSafe( + getInitializedContextEditors(), + watchdog + ); + }; + + // Add the existing editors to the map. + const onAddEditor = ( _: unknown, editor: Editor ) => { + editor.once( 'ready', onEditorStatusChange, { priority: 'lowest' } ); + editor.once( 'destroy', onEditorStatusChange, { priority: 'lowest' } ); + }; + + editors.on>( 'add', onAddEditor ); + + return () => { + editors.off( 'add', onAddEditor ); + }; + }, [ currentContextWatchdog ] ); +}; + +/** + * A map of initialized editors. + */ +type InitializedEditorsMap = Record; + +/** + * The configuration of the `useInitializedCKEditorsMap` hook. + */ +export type InitializedContextEditorsConfig = { + + /** + * The current context watchdog value. + */ + currentContextWatchdog: ContextWatchdogValue; + + /** + * The callback called when the editors map changes. + */ + onChangeInitializedEditors?: ( + editors: InitializedEditorsMap, + watchdog: ContextWatchdog + ) => void; +}; diff --git a/src/index.ts b/src/index.ts index ea354471..eb2356d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,5 @@ */ export { default as CKEditor } from './ckeditor'; -export { default as CKEditorContext } from './ckeditorcontext'; +export { default as CKEditorContext } from './context/ckeditorcontext'; export { default as useMultiRootEditor, type MultiRootHookProps, type MultiRootHookReturns } from './useMultiRootEditor'; diff --git a/src/useMultiRootEditor.tsx b/src/useMultiRootEditor.tsx index 14084f71..fa419ef7 100644 --- a/src/useMultiRootEditor.tsx +++ b/src/useMultiRootEditor.tsx @@ -21,7 +21,7 @@ import type { EventInfo } from 'ckeditor5'; -import { ContextWatchdogContext, isContextWatchdogReadyToUse } from './ckeditorcontext'; +import { ContextWatchdogContext, isContextWatchdogReadyToUse } from './context/ckeditorcontext'; import { EditorWatchdogAdapter } from './ckeditor'; import type { EditorSemaphoreMountResult } from './lifecycle/LifeCycleEditorSemaphore'; diff --git a/tests/ckeditorcontext.test.tsx b/tests/context/ckeditorcontext.test.tsx similarity index 79% rename from tests/ckeditorcontext.test.tsx rename to tests/context/ckeditorcontext.test.tsx index 17735e58..a86da6c0 100644 --- a/tests/ckeditorcontext.test.tsx +++ b/tests/context/ckeditorcontext.test.tsx @@ -4,22 +4,22 @@ */ import { describe, afterEach, it, expect, vi } from 'vitest'; -import React, { createRef } from 'react'; +import React, { createRef, StrictMode } from 'react'; import { render, waitFor, type RenderResult } from '@testing-library/react'; import CKEditorContext, { useCKEditorWatchdogContext, type Props, type ContextWatchdogValue, type ExtractContextWatchdogValueByStatus -} from '../src/ckeditorcontext.tsx'; +} from '../../src/context/ckeditorcontext.tsx'; -import CKEditor from '../src/ckeditor.tsx'; -import MockedEditor from './_utils/editor.js'; -import { ContextWatchdog, CKEditorError } from 'ckeditor5'; -import turnOffDefaultErrorCatching from './_utils/turnoffdefaulterrorcatching.js'; -import ContextMock, { DeferredContextMock } from './_utils/context.js'; -import { timeout } from './_utils/timeout.js'; -import { PromiseManager } from './_utils/promisemanager.js'; +import CKEditor from '../../src/ckeditor.tsx'; +import MockedEditor from '../_utils/editor.js'; +import { ClassicEditor, ContextWatchdog, CKEditorError } from 'ckeditor5'; +import turnOffDefaultErrorCatching from '../_utils/turnoffdefaulterrorcatching.js'; +import ContextMock, { DeferredContextMock } from '../_utils/context.js'; +import { timeout } from '../_utils/timeout.js'; +import { PromiseManager } from '../_utils/promisemanager.js'; const MockEditor = MockedEditor as any; @@ -373,6 +373,172 @@ describe( ' Component', () => { expect( editorReadySpy ).toHaveBeenCalledTimes( 2 ); } ); } ); + + describe( '#onChangeInitializedEditors', () => { + it( 'should call the callback once in strict mode', async () => { + const onChangeInitializedEditorsSpy = vi.fn(); + + component = render( + + + + + + ); + + await timeout( 200 ); + await waitFor( () => { + expect( onChangeInitializedEditorsSpy ).toHaveBeenCalledOnce(); + } ); + } ); + + it( 'should use editor uuid as key in the editors map', async () => { + const onChangeInitializedEditorsSpy = vi.fn(); + + component = render( + + + + ); + + await waitFor( () => { + expect( onChangeInitializedEditorsSpy ).toHaveBeenCalledOnce(); + + const [ editors, watchdog ] = onChangeInitializedEditorsSpy.mock.lastCall!; + const [ editorId ] = Object.keys( editors ); + + // Ensure that the editor UUID is returned. + expect( editorId ).to.have.length( 33 ); + expect( editors[ editorId ].instance ).to.be.instanceOf( ClassicEditor ); + + // Expect that watchdog is an instance of the ContextWatchdog. + expect( watchdog ).to.be.instanceOf( ClassicEditor.ContextWatchdog ); + } ); + } ); + + it( 'should use editorName property passed to the CKEditor component as key in the editors map', async () => { + const onChangeInitializedEditorsSpy = vi.fn(); + + component = render( + + + + ); + + await waitFor( () => { + expect( onChangeInitializedEditorsSpy ).toHaveBeenCalledOnce(); + + const [ editors ] = onChangeInitializedEditorsSpy.mock.lastCall!; + const editorId = 'my-editor'; + + expect( editors ).to.have.property( editorId ); + expect( editors[ editorId ].instance ).to.be.instanceOf( ClassicEditor ); + } ); + } ); + + it( 'should initialized multiple editors and track them', async () => { + const onChangeInitializedEditorsSpy = vi.fn(); + + component = render( + + + + + ); + + await waitFor( () => { + expect( onChangeInitializedEditorsSpy ).toHaveBeenCalledTimes( 2 ); + + const [ editors ] = onChangeInitializedEditorsSpy.mock.lastCall!; + + expect( Object.keys( editors ) ).to.have.length( 2 ); + expect( editors ).to.have.property( 'editor1' ); + expect( editors ).to.have.property( 'editor2' ); + } ); + } ); + + it( 'should be possible to forward metadata to the editors map', async () => { + const onChangeInitializedEditorsSpy = vi.fn(); + + component = render( + + + + ); + + await waitFor( () => { + expect( onChangeInitializedEditorsSpy ).toHaveBeenCalledOnce(); + + const [ editors ] = onChangeInitializedEditorsSpy.mock.lastCall!; + const editorId = 'editor1'; + + expect( editors[ editorId ].metadata ).to.deep.equal( { + name: 'editor1', + stuff: 2 + } ); + } ); + } ); + + it( 'should track only initialized editors', async () => { + const onChangeInitializedEditorsSpy = vi.fn().mockImplementation( ( editors: any ) => { + expect( editors.editor1.instance.state ).to.be.equal( 'ready' ); + } ); + + component = render( + + + + ); + + await waitFor( () => { + expect( onChangeInitializedEditorsSpy ).toHaveBeenCalledOnce(); + } ); + } ); + } ); } ); describe( 'restarting CKEditorContext with nested CKEditor components', () => { diff --git a/tests/issues/349-destroy-context-and-editor.test.tsx b/tests/issues/349-destroy-context-and-editor.test.tsx index 7dd8e2c8..a59805d2 100644 --- a/tests/issues/349-destroy-context-and-editor.test.tsx +++ b/tests/issues/349-destroy-context-and-editor.test.tsx @@ -11,7 +11,7 @@ import { createRoot } from 'react-dom/client'; import { Context, ContextWatchdog } from 'ckeditor5'; import CKEditor from '../../src/ckeditor.tsx'; -import CKEditorContext from '../../src/ckeditorcontext.tsx'; +import CKEditorContext from '../../src/context/ckeditorcontext.tsx'; import { TestClassicEditor } from '../_utils/classiceditor.js'; diff --git a/tests/issues/354-destroy-editor-inside-context.test.tsx b/tests/issues/354-destroy-editor-inside-context.test.tsx index c855d8b9..c5b0c97e 100644 --- a/tests/issues/354-destroy-editor-inside-context.test.tsx +++ b/tests/issues/354-destroy-editor-inside-context.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { Context, ContextWatchdog } from 'ckeditor5'; import { render, waitFor } from '@testing-library/react'; import CKEditor from '../../src/ckeditor.tsx'; -import CKEditorContext from '../../src/ckeditorcontext.tsx'; +import CKEditorContext from '../../src/context/ckeditorcontext.tsx'; import { TestClassicEditor } from '../_utils/classiceditor.js'; import { PromiseManager } from '../_utils/promisemanager.tsx'; diff --git a/tests/useMultiRootEditor.test.tsx b/tests/useMultiRootEditor.test.tsx index 7433708f..a8bac928 100644 --- a/tests/useMultiRootEditor.test.tsx +++ b/tests/useMultiRootEditor.test.tsx @@ -10,7 +10,7 @@ import React, { useEffect } from 'react'; import { CKEditorError } from 'ckeditor5'; import { render, waitFor, renderHook, act } from '@testing-library/react'; import useMultiRootEditor, { EditorEditable, EditorToolbarWrapper } from '../src/useMultiRootEditor.tsx'; -import { ContextWatchdogContext } from '../src/ckeditorcontext.tsx'; +import { ContextWatchdogContext } from '../src/context/ckeditorcontext.tsx'; import { timeout } from './_utils/timeout.js'; import { createDefer } from './_utils/defer.js'; import { createTestMultiRootWatchdog, TestMultiRootEditor } from './_utils/multirooteditor.js';