Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add onChangeInitializedEditors callback to CKEditorContext #514

Merged
merged 12 commits into from
Aug 19, 2024
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, { editor: 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 }
onWatchInitializedEditors={ editors => {
setState( editors as any );
} }
>
<div className="buttons">
<button
onClick={ () => simulateError( state.editor1! ) }
onClick={ () => simulateError( state.editor1!.editor ) }
disabled={ !state.editor1 }
>
Simulate an error in the first editor
</button>
</div>

<CKEditor
context={{
editorName: 'editor1'
}}
Mati365 marked this conversation as resolved.
Show resolved Hide resolved
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!.editor ) }
disabled={ !state.editor2 }
>
Simulate an error in the second editor
</button>
</div>

<CKEditor
context={{
editorName: 'editor2'
}}
Mati365 marked this conversation as resolved.
Show resolved Hide resolved
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,
onWatchInitializedEditors,
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 onWatchInitializedEditors function.
useInitializedCKEditorsMap( {
currentContextWatchdog,
onWatchInitializedEditors
} );

/**
* 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>, 'onWatchInitializedEditors'>
& {
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