Skip to content

Commit

Permalink
Merge pull request #514 from ckeditor/ck/513
Browse files Browse the repository at this point in the history
Feature: Add an onChangeInitializedEditors callback to CKEditorContext to allow tracking of newly initialized editors within the JSX React tree. Closes #513
  • Loading branch information
Mati365 authored Aug 19, 2024
2 parents da3faa2 + 1ef1b72 commit 5306563
Show file tree
Hide file tree
Showing 11 changed files with 417 additions and 53 deletions.
33 changes: 12 additions & 21 deletions demos/react/ContextDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContextDemoState>( {
editor1: null,
editor2: null
} );
const [ state, setState ] = useState<Record<string, { instance: ClassicEditor }>>( {} );

const simulateError = ( editor: ClassicEditor ) => {
setTimeout( () => {
Expand All @@ -48,43 +40,42 @@ export default function ContextDemo( props: ContextDemoProps ): JSX.Element {
<CKEditorContext
context={ ClassicEditor.Context as any }
contextWatchdog={ ClassicEditor.ContextWatchdog as any }
onChangeInitializedEditors={ editors => {
setState( editors as any );
} }
>
<div className="buttons">
<button
onClick={ () => simulateError( state.editor1! ) }
onClick={ () => simulateError( state.editor1!.instance ) }
disabled={ !state.editor1 }
>
Simulate an error in the first editor
</button>
</div>

<CKEditor
contextItemMetadata={{
name: 'editor1'
}}
editor={ ClassicEditor as any }
data={ props.content }
onReady={ ( editor: any ) => {
window.editor2 = editor;

setState( prevState => ( { ...prevState, editor1: editor } ) );
} }
/>

<div className="buttons">
<button
onClick={ () => simulateError( state.editor2! ) }
onClick={ () => simulateError( state.editor2!.instance ) }
disabled={ !state.editor2 }
>
Simulate an error in the second editor
</button>
</div>

<CKEditor
contextItemMetadata={{
name: 'editor2'
}}
editor={ ClassicEditor as any }
data="<h2>Another Editor</h2><p>... in common Context</p>"
onReady={ ( editor: any ) => {
window.editor1 = editor;

setState( prevState => ( { ...prevState, editor2: editor } ) );
} }
/>
</CKEditorContext>
</>
Expand Down
15 changes: 14 additions & 1 deletion src/ckeditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)';

Expand Down Expand Up @@ -295,6 +301,12 @@ export default class CKEditor<TEditor extends Editor> extends React.Component<Pr
* @param config CKEditor 5 editor configuration.
*/
private _createEditor( element: HTMLElement | string | Record<string, string>, config: EditorConfig ): Promise<TEditor> {
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 ) {
Expand Down Expand Up @@ -440,6 +452,7 @@ export interface Props<TEditor extends Editor> extends InferProps<typeof CKEdito
EditorWatchdog: typeof EditorWatchdog;
ContextWatchdog: typeof ContextWatchdog;
};
contextItemMetadata?: CKEditorConfigContextMetadata;
config?: EditorConfig;
watchdogConfig?: WatchdogConfig;
disableWatchdog?: boolean;
Expand Down
53 changes: 36 additions & 17 deletions src/ckeditorcontext.tsx → src/context/ckeditorcontext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@

import React, {
useRef, useContext, useState, useEffect,
type ReactNode, type ReactElement
type PropsWithChildren,
type ReactElement
} from 'react';

import { useIsMountedRef } from './hooks/useIsMountedRef';
import { uid } from './utils/uid';
import { useIsMountedRef } from '../hooks/useIsMountedRef';
import { uid } from '../utils/uid';
import {
useInitializedCKEditorsMap,
type InitializedContextEditorsConfig
} from './useInitializedCKEditorsMap';

import type {
ContextWatchdog,
Expand All @@ -35,6 +40,7 @@ const CKEditorContext = <TContext extends Context = Context>( props: Props<TCont
children, config, onReady,
contextWatchdog: ContextWatchdogConstructor,
isLayoutReady = true,
onChangeInitializedEditors,
onError = ( error, details ) => console.error( error, details )
} = props;

Expand All @@ -43,10 +49,11 @@ const CKEditorContext = <TContext extends Context = Context>( props: Props<TCont

// The currentContextWatchdog state is set to 'initializing' because it is checked later in the CKEditor component
// which is waiting for the full initialization of the context watchdog.
const [ currentContextWatchdog, setCurrentContextWatchdog ] = useState<ContextWatchdogValue>( {
const [ currentContextWatchdog, setCurrentContextWatchdog ] = useState<ContextWatchdogValue<TContext>>( {
status: 'initializing'
} );

// Lets initialize the context watchdog when the layout is ready.
useEffect( () => {
if ( isLayoutReady ) {
initializeContextWatchdog();
Expand All @@ -57,12 +64,19 @@ const CKEditorContext = <TContext extends Context = Context>( props: Props<TCont
}
}, [ id, isLayoutReady ] );

// Cleanup the context watchdog when the component is unmounted. Abort if the watchdog is not initialized.
useEffect( () => () => {
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.
Expand Down Expand Up @@ -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<TContext extends Context = Context> =
| {
status: 'initializing';
}
| {
status: 'initialized';
watchdog: ContextWatchdog;
watchdog: ContextWatchdog<TContext>;
}
| {
status: 'error';
error: ErrorDetails;
};

/**
* Represents the status of the ContextWatchdogValue.
*/
export type ContextWatchdogValueStatus = ContextWatchdogValue[ 'status' ];

/**
Expand All @@ -220,17 +237,19 @@ export type ExtractContextWatchdogValueByStatus<S extends ContextWatchdogValueSt
/**
* Props for the CKEditorContext component.
*/
export type Props<TContext extends Context> = {
id?: string;
isLayoutReady?: boolean;
context?: { create( ...args: any ): Promise<TContext> };
contextWatchdog: typeof ContextWatchdog<TContext>;
watchdogConfig?: WatchdogConfig;
config?: ContextConfig;
onReady?: ( context: TContext, watchdog: ContextWatchdog<TContext> ) => void;
onError?: ( error: Error, details: ErrorDetails ) => void;
children?: ReactNode;
};
export type Props<TContext extends Context> =
& PropsWithChildren
& Pick<InitializedContextEditorsConfig<TContext>, 'onChangeInitializedEditors'>
& {
id?: string;
isLayoutReady?: boolean;
context?: { create( ...args: any ): Promise<TContext> };
contextWatchdog: typeof ContextWatchdog<TContext>;
watchdogConfig?: WatchdogConfig;
config?: ContextConfig;
onReady?: ( context: TContext, watchdog: ContextWatchdog<TContext> ) => void;
onError?: ( error: Error, details: ErrorDetails ) => void;
};

type ErrorDetails = {
phase: 'initialization' | 'runtime';
Expand Down
54 changes: 54 additions & 0 deletions src/context/setCKEditorReactContextMetadata.ts
Original file line number Diff line number Diff line change
@@ -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<any> ): 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;
};
Loading

0 comments on commit 5306563

Please sign in to comment.