├── .changelog └── .gitkeep ├── .circleci └── config.yml ├── .editorconfig ├── .gitattributes ├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .husky └── pre-commit ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── demos ├── cdn-multiroot-react │ ├── App.tsx │ ├── ContextMultiRootEditorDemo.tsx │ ├── MultiRootEditorDemo.tsx │ ├── MultiRootEditorRichDemo.tsx │ ├── index.html │ ├── main.tsx │ └── useCKCdnMultiRootEditor.tsx ├── cdn-react │ ├── App.tsx │ ├── CKEditorCKBoxCloudDemo.tsx │ ├── CKEditorCloudContextDemo.tsx │ ├── CKEditorCloudDemo.tsx │ ├── CKEditorCloudPluginsDemo.tsx │ ├── getCKCdnClassicEditor.ts │ ├── index.html │ └── main.tsx ├── npm-multiroot-react │ ├── App.tsx │ ├── ContextMultiRootEditorDemo.tsx │ ├── MultiRootEditor.tsx │ ├── MultiRootEditorDemo.tsx │ ├── MultiRootEditorRichDemo.tsx │ ├── index.html │ └── main.tsx ├── npm-react │ ├── App.tsx │ ├── ClassicEditor.tsx │ ├── ContextDemo.tsx │ ├── EditorDemo.tsx │ ├── index.html │ └── main.tsx ├── sample.jpg └── tsconfig.json ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── scripts ├── bump-year.js ├── ci │ └── is-project-ready-to-release.js ├── postinstall.js ├── preparechangelog.js ├── preparepackages.js ├── publishpackages.js └── utils │ ├── constants.js │ ├── getlistroptions.js │ ├── parsearguments.js │ └── preparepackagejson.js ├── src ├── ckeditor.tsx ├── cloud │ ├── useCKEditorCloud.tsx │ └── withCKEditorCloud.tsx ├── context │ ├── ckeditorcontext.tsx │ ├── setCKEditorReactContextMetadata.ts │ └── useInitializedCKEditorsMap.ts ├── hooks │ ├── useAsyncCallback.ts │ ├── useAsyncValue.ts │ ├── useInstantEditorEffect.ts │ ├── useInstantEffect.ts │ ├── useIsMountedRef.ts │ ├── useIsUnmountedRef.ts │ └── useRefSafeCallback.ts ├── index.ts ├── lifecycle │ ├── LifeCycleEditorSemaphore.ts │ ├── LifeCycleElementSemaphore.ts │ └── useLifeCycleSemaphoreSyncRef.tsx ├── plugins │ ├── ReactIntegrationUsageDataPlugin.ts │ └── appendAllIntegrationPluginsToConfig.ts ├── useMultiRootEditor.tsx └── utils │ └── mergeRefs.ts ├── tests ├── _utils-tests │ ├── context.test.tsx │ └── editor.test.tsx ├── _utils │ ├── classiceditor.js │ ├── context.ts │ ├── defer.js │ ├── editor.ts │ ├── expectToBeTruthy.ts │ ├── multirooteditor.ts │ ├── promisemanager.tsx │ ├── timeout.js │ └── turnOffErrors.ts ├── ckeditor.test.tsx ├── cloud │ ├── useCKEditorCloud.test.tsx │ └── withCKEditorCloud.test.tsx ├── context │ ├── ckeditorcontext.test.tsx │ └── useInitializedCKEditorsMap.test.tsx ├── hooks │ ├── useAsyncCallback.test.tsx │ ├── useAsyncValue.test.tsx │ ├── useInstantEditorEffect.test.tsx │ ├── useInstantEffect.test.tsx │ ├── useIsMountedRef.test.tsx │ ├── useIsUnmountedRef.test.tsx │ └── useRefSafeCallback.test.tsx ├── index.test.tsx ├── integrations │ ├── ckeditor-classiceditor.test.tsx │ ├── ckeditor-editor-data.test.tsx │ └── usemultirooteditor-multirooteditor.test.tsx ├── issues │ ├── 349-destroy-context-and-editor.test.tsx │ ├── 354-destroy-editor-inside-context.test.tsx │ └── 39-frozen-browser.test.tsx ├── lifecycle │ ├── LifeCycleElementSemaphore.test.tsx │ └── useLifeCycleSemaphoreSyncRef.test.tsx ├── tsconfig.json ├── useMultiRootEditor.test.tsx └── utils │ └── mergeRefs.test.tsx ├── tsconfig.json ├── vite-env.d.ts ├── vite.config.ts └── vitest-setup.ts /.changelog/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckeditor/ckeditor5-react/0537f2cfd3a854342a7ec5aa61cac4f1332472ea/.changelog/.gitkeep -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Configurations to normalize the IDE behavior. 2 | # http://editorconfig.org/ 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = tab 8 | tab_width = 4 9 | charset = utf-8 10 | end_of_line = lf 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [package.json] 15 | indent_style = space 16 | tab_width = 2 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.htaccess eol=lf 4 | *.cgi eol=lf 5 | *.sh eol=lf 6 | 7 | *.css text 8 | *.htm text 9 | *.html text 10 | *.js text 11 | *.json text 12 | *.php text 13 | *.txt text 14 | *.md text 15 | 16 | *.png -text 17 | *.gif -text 18 | *.jpg -text 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | ### 🚀 Summary 15 | 16 | *A brief summary of what this PR changes.* 17 | 18 | --- 19 | 20 | ### 📌 Related issues 21 | 22 | 27 | 28 | * Closes #000 29 | 30 | --- 31 | 32 | ### 💡 Additional information 33 | 34 | *Optional: Notes on decisions, edge cases, or anything helpful for reviewers.* 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | .idea 5 | .tmp 6 | /release/ 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | # @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 2 | # For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ======================================== 3 | 4 | See the [official contributors' guide to CKEditor 5](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html) to learn more about the general rules followed by the CKEditor 5 core team. 5 | 6 | See also the [Contributing](https://github.com/ckeditor/ckeditor5-react#contributing) section of this specific package to learn more about contributing to this package. 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Software License Agreement 2 | ========================== 3 | 4 | **CKEditor 5 component for React** – https://github.com/ckeditor/ckeditor5-react
5 | Copyright (c) 2003-2025, [CKSource](http://cksource.com) Holding sp. z o.o. All rights reserved. 6 | 7 | Licensed under a dual-license model, this software is available under: 8 | 9 | * the [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html), 10 | * or commercial license terms from CKSource Holding sp. z o.o. 11 | 12 | For more information, see: [https://ckeditor.com/legal/ckeditor-licensing-options](https://ckeditor.com/legal/ckeditor-licensing-options). 13 | 14 | Sources of Intellectual Property Included in CKEditor 15 | ----------------------------------------------------- 16 | 17 | Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource with their express permission. 18 | 19 | Trademarks 20 | ---------- 21 | 22 | **CKEditor** is a trademark of [CKSource](http://cksource.com) Holding sp. z o.o. All other brand and product names are trademarks, registered trademarks or service marks of their respective holders. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CKEditor 5 rich text editor component for React 2 | 3 | [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-react.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-react) 4 | [![CircleCI](https://circleci.com/gh/ckeditor/ckeditor5-react.svg?style=shield)](https://app.circleci.com/pipelines/github/ckeditor/ckeditor5-react?branch=master) 5 | [![Coverage Status](https://coveralls.io/repos/github/ckeditor/ckeditor5-react/badge.svg?branch=master)](https://coveralls.io/github/ckeditor/ckeditor5-react?branch=master) 6 | ![Dependency Status](https://img.shields.io/librariesio/release/npm/@ckeditor/ckeditor5-react) 7 | 8 | Official [CKEditor 5](https://ckeditor.com/ckeditor-5/) rich text editor component for React. 9 | 10 | ## [Developer Documentation](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/frameworks/react.html) 📖 11 | 12 | See the ["Rich text editor component for React"](https://ckeditor.com/docs/ckeditor5/latest/getting-started/installation/react/react.html) guide in the [CKEditor 5 documentation](https://ckeditor.com/docs/ckeditor5/latest) to learn more: 13 | 14 | * [Quick start](https://ckeditor.com/docs/ckeditor5/latest/getting-started/installation/react/react.html#quick-start) 15 | * [Using CKEditr 5 Builder](https://ckeditor.com/docs/ckeditor5/latest/getting-started/installation/react/react.html#using-ckeditor-5-builder) 16 | * [Installing from npm](https://ckeditor.com/docs/ckeditor5/latest/getting-started/installation/react/react.html#installing-from-npm) 17 | * [Component properties](https://ckeditor.com/docs/ckeditor5/latest/getting-started/installation/react/react.html#component-properties) 18 | 19 | ## Contributing 20 | 21 | > [!NOTE] 22 | > This project requires **pnpm v10** or higher. You can check your version with `pnpm --version` and update if needed with `npm install -g pnpm@latest`. 23 | 24 | After cloning this repository, install necessary dependencies: 25 | 26 | ```bash 27 | pnpm install 28 | ``` 29 | 30 | ### Running the development server 31 | 32 | To manually test the editor integration with different versions of React, you can start the development server using one of the commands below: 33 | 34 | ```bash 35 | pnpm run dev:16 # Open the demo projects using React 16. 36 | pnpm run dev:18 # Open the demo projects using React 18. 37 | pnpm run dev:19 # Open the demo projects using React 19. 38 | ``` 39 | 40 | ### Executing tests 41 | 42 | To test the editor integration against a set of automated tests, run the following command: 43 | 44 | ```bash 45 | pnpm run test 46 | ``` 47 | 48 | If you want to run the tests in watch mode, use the following command: 49 | 50 | ```bash 51 | pnpm run test:watch 52 | ``` 53 | 54 | ### Building the package 55 | 56 | To build the package that is ready to publish, use the following command: 57 | 58 | ```bash 59 | pnpm run build 60 | ``` 61 | 62 | ## Releasing package 63 | 64 | CircleCI automates the release process and can release both channels: stable (`X.Y.Z`) and pre-releases (`X.Y.Z-alpha.X`, etc.). 65 | 66 | Before you start, you need to prepare the changelog entries. 67 | 68 | 1. Make sure the `#master` branch is up-to-date: `git fetch && git checkout master && git pull`. 69 | 1. Prepare a release branch: `git checkout -b release-[YYYYMMDD]` where `YYYYMMDD` is the current day. 70 | 1. Generate the changelog entries: `pnpm run release:prepare-changelog`. 71 | * You can specify the release date by passing the `--date` option, e.g., `--date=2025-06-11`. 72 | * By passing the `--dry-run` option, you can check what the script will do without actually modifying the files. 73 | * Read all the entries, correct poor wording and other issues, wrap code names in backticks to format them, etc. 74 | * Add the missing `the/a` articles, `()` to method names, "it's" -> "its", etc. 75 | * A newly introduced feature should have just one changelog entry – something like "The initial implementation of the FOO feature." with a description of what it does. 76 | 1. Commit all changes and prepare a new pull request targeting the `#master` branch. 77 | 1. Ping the `@ckeditor/ckeditor-5-platform` team to review the pull request and trigger the release process. 78 | 79 | ## License 80 | 81 | Licensed under a dual-license model, this software is available under: 82 | 83 | * the [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html), 84 | * or commercial license terms from CKSource Holding sp. z o.o. 85 | 86 | For more information, see: [https://ckeditor.com/legal/ckeditor-licensing-options](https://ckeditor.com/legal/ckeditor-licensing-options). 87 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting security issues 2 | 3 | If you believe you have found a security issue in the CKEditor software, please contact us immediately. 4 | 5 | When reporting a suspected security problem, please bear this in mind: 6 | 7 | * Make sure to provide as many details as possible about the vulnerability. 8 | * Please do not disclose publicly any security issues until we fix them and publish security releases. 9 | 10 | Contact the security team at security@cksource.com. As soon as we receive the security report, we'll work promptly to confirm the issue and then to provide a security fix. 11 | -------------------------------------------------------------------------------- /demos/cdn-multiroot-react/App.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { StrictMode, useState } from 'react'; 7 | import MultiRootEditorDemo from './MultiRootEditorDemo.js'; 8 | import MultiRootEditorRichDemo from './MultiRootEditorRichDemo.js'; 9 | import ContextMultiRootEditorDemo from './ContextMultiRootEditorDemo.js'; 10 | 11 | type Demo = 'editor' | 'rich' | 'context'; 12 | 13 | const multiRootEditorContent = { 14 | intro: '

Sample

This is an instance of the ' + 15 | 'multi-root editor build.

', 16 | content: '

It is the custom content

CKEditor 5 Sample image.
', 17 | outro: '

You can use this sample to validate whether your ' + 18 | 'custom build works fine.

' 19 | }; 20 | 21 | const rootsAttributes = { 22 | intro: { 23 | row: '1', 24 | order: 10 25 | }, 26 | content: { 27 | row: '1', 28 | order: 20 29 | }, 30 | outro: { 31 | row: '2', 32 | order: 10 33 | } 34 | }; 35 | 36 | export default function App(): JSX.Element { 37 | const [ demo, setDemo ] = useState( 'editor' ); 38 | 39 | const renderDemo = () => { 40 | switch ( demo ) { 41 | case 'context': 42 | return ; 43 | case 'editor': 44 | return ; 45 | case 'rich': 46 | return ; 47 | } 48 | }; 49 | 50 | return ( 51 | 52 |

CKEditor 5 – useMultiRootEditor – CDN development sample

53 | 54 |
55 | 61 | 62 | 68 | 69 | 75 |
76 | { renderDemo() } 77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /demos/cdn-multiroot-react/ContextMultiRootEditorDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import { useMultiRootEditor, type MultiRootHookProps, CKEditorContext, withCKEditorCloud } from '../../src/index.js'; 9 | import { useCKCdnMultiRootEditor } from './useCKCdnMultiRootEditor.js'; 10 | 11 | const ContextEditorDemo = ( { editor }: { editor: any } ): JSX.Element => { 12 | const editorProps: Partial = { 13 | editor, 14 | 15 | onChange: ( event, editor ) => { 16 | console.log( 'event: onChange', { event, editor } ); 17 | }, 18 | onBlur: ( event, editor ) => { 19 | console.log( 'event: onBlur', { event, editor } ); 20 | }, 21 | onFocus: ( event, editor ) => { 22 | console.log( 'event: onFocus', { event, editor } ); 23 | } 24 | }; 25 | 26 | // First editor initialization. 27 | const { 28 | editor: editor1, editableElements: editableElements1, toolbarElement: toolbarElement1 29 | } = useMultiRootEditor( { 30 | ...editorProps, 31 | data: { 32 | intro: '

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

', 33 | content: '

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

' 34 | }, 35 | 36 | onReady: editor => { 37 | window.editor1 = editor; 38 | 39 | console.log( 'event: onChange', { editor } ); 40 | } 41 | } as MultiRootHookProps ); 42 | 43 | // Second editor initialization. 44 | const { 45 | editor: editor2, editableElements: editableElements2, toolbarElement: toolbarElement2 46 | } = useMultiRootEditor( { 47 | ...editorProps, 48 | data: { 49 | notes: '

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

' 50 | }, 51 | 52 | onReady: editor => { 53 | window.editor2 = editor; 54 | 55 | console.log( 'event: onChange', { editor } ); 56 | } 57 | } as MultiRootHookProps ); 58 | 59 | // Function to simulate an error in the editor. 60 | // It is used for testing purposes to trigger the Watchdog to restart the editor. 61 | // Remove it in the actual integration. 62 | const simulateError = ( editor: any ) => { 63 | setTimeout( () => { 64 | const err: any = new Error( 'foo' ); 65 | 66 | err.context = editor; 67 | err.is = () => true; 68 | 69 | throw err; 70 | } ); 71 | }; 72 | 73 | return ( 74 | <> 75 |

Context Multi-root Editor Demo

76 |

77 | This sample demonstrates integration with CKEditorContext.
78 |

79 |

Component's events are logged to the console.

80 |

81 | 82 |
83 |
84 | 90 |
91 | 92 | { toolbarElement1 } 93 | 94 |
95 | { editableElements1 } 96 |
97 |
98 | 99 |
100 | 101 |
102 |
103 | 109 |
110 | 111 | { toolbarElement2 } 112 | 113 |
114 | { editableElements2 } 115 |
116 |
117 | 118 | ); 119 | }; 120 | 121 | const withCKCloud = withCKEditorCloud( { 122 | cloud: { 123 | version: '43.0.0', 124 | translations: [ 'es', 'de' ], 125 | premium: true 126 | } 127 | } ); 128 | 129 | const ContextMultiRootEditorDemo = withCKCloud( ( { cloud } ): JSX.Element => { 130 | const MultiRootEditor = useCKCdnMultiRootEditor( cloud ); 131 | 132 | return ( 133 | 137 | 138 | 139 | ); 140 | } ); 141 | 142 | export default ContextMultiRootEditorDemo; 143 | -------------------------------------------------------------------------------- /demos/cdn-multiroot-react/MultiRootEditorDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { type ReactNode } from 'react'; 7 | 8 | import { useCKCdnMultiRootEditor } from './useCKCdnMultiRootEditor.js'; 9 | import { 10 | useMultiRootEditor, withCKEditorCloud, 11 | type MultiRootHookProps, 12 | type WithCKEditorCloudHocProps 13 | } from '../../src/index.js'; 14 | 15 | type EditorDemoProps = WithCKEditorCloudHocProps & { 16 | data: Record; 17 | rootsAttributes: Record>; 18 | }; 19 | 20 | const withCKCloud = withCKEditorCloud( { 21 | cloud: { 22 | version: '43.0.0', 23 | translations: [ 'de' ], 24 | premium: true 25 | } 26 | } ); 27 | 28 | const MultiRootEditorDemo = withCKCloud( ( { data, cloud }: EditorDemoProps ): ReactNode => { 29 | const MultiRootEditor = useCKCdnMultiRootEditor( cloud ); 30 | const editorProps: MultiRootHookProps = { 31 | editor: MultiRootEditor as any, 32 | data 33 | }; 34 | 35 | const { toolbarElement, editableElements } = useMultiRootEditor( editorProps ); 36 | 37 | return ( 38 | <> 39 |

Multi-root Editor Demo

40 |

41 | This sample demonstrates the minimal React application that uses multi-root editor integration.
42 | You may use it as a starting point for your application. 43 |

44 |

45 | 46 |
47 | { toolbarElement } 48 | 49 | { editableElements } 50 |
51 | 52 | ); 53 | } ); 54 | 55 | export default MultiRootEditorDemo; 56 | -------------------------------------------------------------------------------- /demos/cdn-multiroot-react/MultiRootEditorRichDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { useState, type ChangeEvent } from 'react'; 7 | 8 | import { 9 | useMultiRootEditor, withCKEditorCloud, 10 | type WithCKEditorCloudHocProps, type MultiRootHookProps 11 | } from '../../src/index.js'; 12 | 13 | import { useCKCdnMultiRootEditor } from './useCKCdnMultiRootEditor.js'; 14 | 15 | const SAMPLE_READ_ONLY_LOCK_ID = 'Integration Sample'; 16 | 17 | type EditorDemoProps = WithCKEditorCloudHocProps & { 18 | data: Record; 19 | rootsAttributes: Record>; 20 | }; 21 | 22 | const withCKCloud = withCKEditorCloud( { 23 | cloud: { 24 | version: '43.0.0', 25 | translations: [ 'de' ], 26 | premium: true 27 | } 28 | } ); 29 | 30 | const MultiRootEditorRichDemo = withCKCloud( ( props: EditorDemoProps ): JSX.Element => { 31 | const MultiRootEditor = useCKCdnMultiRootEditor( props.cloud ); 32 | const editorProps: MultiRootHookProps = { 33 | editor: MultiRootEditor as any, 34 | data: props.data, 35 | rootsAttributes: props.rootsAttributes, 36 | 37 | onReady: editor => { 38 | // @ts-expect-error: Caused by linking to parent project and conflicting react types 39 | window.editor = editor; 40 | 41 | console.log( 'event: onChange', { editor } ); 42 | }, 43 | onChange: ( event, editor ) => { 44 | console.log( 'event: onChange', { event, editor } ); 45 | }, 46 | onBlur: ( event, editor ) => { 47 | console.log( 'event: onBlur', { event, editor } ); 48 | }, 49 | onFocus: ( event, editor ) => { 50 | console.log( 'event: onFocus', { event, editor } ); 51 | }, 52 | 53 | config: { 54 | rootsAttributes: props.rootsAttributes 55 | } 56 | }; 57 | 58 | const { 59 | editor, editableElements, toolbarElement, 60 | data, setData, 61 | attributes, setAttributes 62 | } = useMultiRootEditor( editorProps ); 63 | 64 | // The element state with number of roots that should be added in one row. 69 | // This is for demo purposes, and you may remove it in the actual integration or change accordingly to your needs. 70 | const [ numberOfRoots, setNumberOfRoots ] = useState( 1 ); 71 | 72 | // A set with disabled roots. It is used to support read-only feature in multi root editor. 73 | // This is for demo purposes, and you may remove it in the actual integration or change accordingly to your needs. 74 | const [ disabledRoots, setDisabledRoots ] = useState>( new Set() ); 75 | 76 | // Function to toggle read-only mode for selected root. 77 | const toggleReadOnly = () => { 78 | const root = editor!.model.document.selection.getFirstRange()!.root; 79 | 80 | if ( !root || !root.rootName ) { 81 | return; 82 | } 83 | 84 | const isReadOnly = disabledRoots.has( root.rootName ); 85 | 86 | if ( isReadOnly ) { 87 | disabledRoots.delete( root.rootName ); 88 | editor!.enableRoot( root.rootName, SAMPLE_READ_ONLY_LOCK_ID ); 89 | } else { 90 | disabledRoots.add( root.rootName ); 91 | editor!.disableRoot( root.rootName, SAMPLE_READ_ONLY_LOCK_ID ); 92 | } 93 | 94 | setDisabledRoots( new Set( disabledRoots ) ); 95 | }; 96 | 97 | // Function to simulate an error in the editor. 98 | // It is used for testing purposes to trigger the Watchdog to restart the editor. 99 | // Remove it in the actual integration. 100 | const simulateError = () => { 101 | setTimeout( () => { 102 | const err: any = new Error( 'foo' ); 103 | 104 | err.context = editor; 105 | err.is = () => true; 106 | 107 | throw err; 108 | } ); 109 | }; 110 | 111 | const addRoot = ( newRootAttributes: Record, rootId?: string ) => { 112 | const id = rootId || new Date().getTime(); 113 | 114 | for ( let i = 1; i <= numberOfRoots; i++ ) { 115 | const rootName = `root-${ i }-${ id }`; 116 | 117 | data[ rootName ] = ''; 118 | 119 | // Remove code related to rows if you don't need to handle multiple roots in one row. 120 | attributes[ rootName ] = { ...newRootAttributes, order: i * 10, row: id }; 121 | } 122 | 123 | setData( { ...data } ); 124 | setAttributes( { ...attributes } ); 125 | // Reset the element to the default value. 126 | setNumberOfRoots( 1 ); 127 | }; 128 | 129 | const removeRoot = ( rootName: string ) => { 130 | setData( previousData => { 131 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 132 | const { [ rootName! ]: _, ...newData } = previousData; 133 | 134 | return { ...newData }; 135 | } ); 136 | 137 | setSelectedRoot( '' ); 138 | }; 139 | 140 | // Group elements based on their row attribute and sort them by order attribute. 141 | // Grouping in a row is used for presentation purposes, and you may remove it in actual integration. 142 | // However, we recommend ordering the roots, so that rows are put in a correct places when undo/redo is used. 143 | const groupedElements = Object.entries( 144 | editableElements 145 | .sort( ( a, b ) => ( attributes[ a.props.id ].order as number ) - ( attributes[ b.props.id ].order as number ) ) 146 | .reduce( ( acc: Record>, element ) => { 147 | const row = attributes[ element.props.id ].row as string; 148 | acc[ row ] = acc[ row ] || []; 149 | acc[ row ].push( element ); 150 | 151 | return acc; 152 | }, {} ) 153 | ); 154 | 155 | return ( 156 | <> 157 |

Multi-root Editor Demo (rich integration)

158 |

This sample demonstrates a more advanced integration of the multi-root editor in React.

159 |

160 | Multiple extra features are implemented to illustrate how you can customize your application and use the provided API.
161 | They are optional, and you do not need to include them in your application.
162 | However, they can be a good starting point for your own custom features. 163 |

164 |

165 | The 'Simulate an error' button makes the editor throw an error to show you how it is restarted by 166 | the Watchdog mechanism.
167 | Note, that Watchdog is enabled by default.
168 | It can be disabled by passing the `disableWatchdog` flag to the `useMultiRootEditor` hook. 169 |

170 |

Component's events are logged to the console.

171 |

172 | 173 |
174 | 180 | 181 | 187 |
188 | 189 |
190 | 196 | 197 | 206 |
207 | 208 |
209 | 214 | 215 | Number( e.target.value ) <= 4 && setNumberOfRoots( Number( e.target.value ) )} 221 | /> 222 |
223 | 224 |
225 | 226 | { toolbarElement } 227 | 228 | { /* Maps through `groupedElements` array to render rows that contains the editor roots. */ } 229 | { groupedElements.map( ( [ row, elements ] ) => ( 230 |
231 | { elements } 232 |
233 | ) ) } 234 | 235 | ); 236 | } ); 237 | 238 | export default MultiRootEditorRichDemo; 239 | -------------------------------------------------------------------------------- /demos/cdn-multiroot-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CKEditor 5 via CDN – React Multi Root Component – demo 7 | 8 | 48 | 49 | 50 | 51 |
52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /demos/cdn-multiroot-react/main.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React from 'react'; 7 | import App from './App.js'; 8 | 9 | const element = document.getElementById( 'root' ) as HTMLDivElement; 10 | 11 | if ( __REACT_VERSION__ <= 17 ) { 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-ignore 14 | const ReactDOM = await import( 'react-dom' ); 15 | 16 | ReactDOM.render( React.createElement( App ), element ); 17 | } else { 18 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 19 | // @ts-ignore 20 | const { createRoot } = await import( 'react-dom/client' ); 21 | 22 | createRoot( element ).render( ); 23 | } 24 | 25 | console.log( `%cVersion of React used: ${ React.version }`, 'color:red;font-weight:bold;' ); 26 | -------------------------------------------------------------------------------- /demos/cdn-multiroot-react/useCKCdnMultiRootEditor.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { MultiRootEditor } from 'https://cdn.ckeditor.com/typings/ckeditor5.d.ts'; 7 | import type { CKEditorCloudConfig, CKEditorCloudResult } from '../../src/index.js'; 8 | 9 | export const useCKCdnMultiRootEditor = ( cloud: CKEditorCloudResult ): typeof MultiRootEditor => { 10 | const { 11 | MultiRootEditor: MultiRootEditorBase, 12 | CloudServices, 13 | Essentials, 14 | CKFinderUploadAdapter, 15 | Autoformat, 16 | Bold, 17 | Italic, 18 | BlockQuote, 19 | CKBox, 20 | CKFinder, 21 | EasyImage, 22 | Heading, 23 | Image, 24 | ImageCaption, 25 | ImageStyle, 26 | ImageToolbar, 27 | ImageUpload, 28 | Indent, 29 | Link, 30 | List, 31 | MediaEmbed, 32 | Paragraph, 33 | PasteFromOffice, 34 | PictureEditing, 35 | Table, 36 | TableToolbar, 37 | TextTransformation 38 | } = cloud.CKEditor; 39 | 40 | return class MultiRootEditor extends MultiRootEditorBase { 41 | public static override builtinPlugins = [ 42 | Essentials, 43 | CKFinderUploadAdapter, 44 | Autoformat, 45 | Bold, 46 | Italic, 47 | BlockQuote, 48 | CKBox, 49 | CKFinder, 50 | CloudServices, 51 | EasyImage, 52 | Heading, 53 | Image, 54 | ImageCaption, 55 | ImageStyle, 56 | ImageToolbar, 57 | ImageUpload, 58 | Indent, 59 | Link, 60 | List, 61 | MediaEmbed, 62 | Paragraph, 63 | PasteFromOffice, 64 | PictureEditing, 65 | Table, 66 | TableToolbar, 67 | TextTransformation 68 | ]; 69 | 70 | public static override defaultConfig = { 71 | toolbar: { 72 | items: [ 73 | 'undo', 'redo', 74 | '|', 'heading', 75 | '|', 'bold', 'italic', 76 | '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', 77 | '|', 'bulletedList', 'numberedList', 'outdent', 'indent' 78 | ] 79 | }, 80 | image: { 81 | toolbar: [ 82 | 'imageStyle:inline', 83 | 'imageStyle:block', 84 | 'imageStyle:side', 85 | '|', 86 | 'toggleImageCaption', 87 | 'imageTextAlternative' 88 | ] 89 | }, 90 | table: { 91 | contentToolbar: [ 92 | 'tableColumn', 93 | 'tableRow', 94 | 'mergeTableCells' 95 | ] 96 | }, 97 | language: 'en' 98 | }; 99 | }; 100 | }; 101 | -------------------------------------------------------------------------------- /demos/cdn-react/App.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { useState, type ReactNode } from 'react'; 7 | 8 | import { CKEditorCloudDemo } from './CKEditorCloudDemo.js'; 9 | import { CKEditorCloudPluginsDemo } from './CKEditorCloudPluginsDemo.js'; 10 | import { CKEditorCKBoxCloudDemo } from './CKEditorCKBoxCloudDemo.js'; 11 | import { CKEditorCloudContextDemo } from './CKEditorCloudContextDemo.js'; 12 | 13 | const EDITOR_CONTENT = ` 14 |

Sample

15 |

This is an instance of the 16 | classic editor build. 17 |

18 |
19 | CKEditor 5 Sample image. 20 |
21 |

You can use this sample to validate whether your 22 | custom build works fine.

23 | `; 24 | 25 | const DEMOS = [ 'Editor', 'Context', 'CKBox', 'Cloud Plugins' ] as const; 26 | 27 | type Demo = ( typeof DEMOS )[ number ]; 28 | 29 | export const App = (): ReactNode => { 30 | const [ currentDemo, setCurrentDemo ] = useState( 'Editor' ); 31 | 32 | const content = ( { 33 | Editor: , 34 | Context: , 35 | CKBox: , 36 | 'Cloud Plugins': 37 | } )[ currentDemo ]; 38 | 39 | return ( 40 | 41 |

CKEditor 5 – React Component – CDN demo

42 | 43 |
44 | { DEMOS.map( demo => ( 45 | 52 | ) ) } 53 |
54 | 55 | { content } 56 |
57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /demos/cdn-react/CKEditorCKBoxCloudDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { type ReactNode } from 'react'; 7 | import { getCKCdnClassicEditor } from './getCKCdnClassicEditor.js'; 8 | import { CKEditor, useCKEditorCloud } from '../../src/index.js'; 9 | 10 | type CKEditorCKBoxCloudDemoProps = { 11 | content: string; 12 | }; 13 | 14 | export const CKEditorCKBoxCloudDemo = ( { content }: CKEditorCKBoxCloudDemoProps ): ReactNode => { 15 | const cloud = useCKEditorCloud( { 16 | version: '43.0.0', 17 | premium: true, 18 | ckbox: { 19 | version: '2.5.1' 20 | } 21 | } ); 22 | 23 | if ( cloud.status === 'error' ) { 24 | console.error( cloud ); 25 | } 26 | 27 | if ( cloud.status !== 'success' ) { 28 | return
Loading...
; 29 | } 30 | 31 | const { CKBox, CKBoxImageEdit } = cloud.CKEditor; 32 | const CKEditorClassic = getCKCdnClassicEditor( { 33 | cloud, 34 | additionalPlugins: [ 35 | CKBox, 36 | CKBoxImageEdit 37 | ], 38 | overrideConfig: { 39 | toolbar: { 40 | items: [ 41 | 'undo', 'redo', 42 | '|', 'heading', 43 | '|', 'bold', 'italic', 44 | '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', 45 | '|', 'bulletedList', 'numberedList', 'outdent', 'indent', 46 | '|', 'ckbox', 'ckboxImageEdit' 47 | ] 48 | }, 49 | image: { 50 | toolbar: [ 51 | 'imageStyle:inline', 52 | 'imageStyle:block', 53 | 'imageStyle:side', 54 | '|', 55 | 'toggleImageCaption', 56 | 'imageTextAlternative', 57 | '|', 58 | 'ckboxImageEdit' 59 | ] 60 | }, 61 | ckbox: { 62 | tokenUrl: 'https://api.ckbox.io/token/demo', 63 | forceDemoLabel: true, 64 | allowExternalImagesEditing: [ /^data:/, /^i.imgur.com\//, 'origin' ] 65 | } 66 | } 67 | } ); 68 | 69 | return ( 70 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /demos/cdn-react/CKEditorCloudContextDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React from 'react'; 7 | import { CKEditor, CKEditorContext, useCKEditorCloud } from '../../src/index.js'; 8 | 9 | export const CKEditorCloudContextDemo = (): JSX.Element => { 10 | const cloud = useCKEditorCloud( { 11 | version: '43.0.0', 12 | premium: true 13 | } ); 14 | 15 | if ( cloud.status === 'error' ) { 16 | console.error( cloud ); 17 | return
Error!
; 18 | } 19 | 20 | if ( cloud.status === 'loading' ) { 21 | return
Loading...
; 22 | } 23 | 24 | const { ClassicEditor } = cloud.CKEditor; 25 | 26 | return ( 27 | { 31 | console.log( 'Initialized editors:', editors ); 32 | } } 33 | > 34 | 38 | 39 |
40 | 41 | 45 |
46 | ); 47 | }; 48 | 49 | function CKEditorNestedInstanceDemo( { name, content }: { name: string; content?: string } ): JSX.Element { 50 | const cloud = useCKEditorCloud( { 51 | version: '43.0.0' 52 | } ); 53 | 54 | if ( cloud.status === 'error' ) { 55 | console.error( cloud ); 56 | return
Error!
; 57 | } 58 | 59 | if ( cloud.status === 'loading' ) { 60 | return
Loading...
; 61 | } 62 | 63 | const { CKEditor: CK } = cloud; 64 | 65 | return ( 66 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /demos/cdn-react/CKEditorCloudDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { type ReactNode } from 'react'; 7 | import { getCKCdnClassicEditor } from './getCKCdnClassicEditor.js'; 8 | import { CKEditor, useCKEditorCloud } from '../../src/index.js'; 9 | 10 | type CKEditorCloudDemoProps = { 11 | content: string; 12 | }; 13 | 14 | export const CKEditorCloudDemo = ( { content }: CKEditorCloudDemoProps ): ReactNode => { 15 | const cloud = useCKEditorCloud( { 16 | version: '43.0.0', 17 | premium: true 18 | } ); 19 | 20 | if ( cloud.status === 'error' ) { 21 | console.error( cloud ); 22 | } 23 | 24 | if ( cloud.status !== 'success' ) { 25 | return
Loading...
; 26 | } 27 | 28 | const CKEditorClassic = getCKCdnClassicEditor( { 29 | cloud 30 | } ); 31 | 32 | return ( 33 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /demos/cdn-react/CKEditorCloudPluginsDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { type ReactNode } from 'react'; 7 | 8 | import type { Plugin } from 'https://cdn.ckeditor.com/typings/ckeditor5.d.ts'; 9 | 10 | import { getCKCdnClassicEditor } from './getCKCdnClassicEditor.js'; 11 | import { CKEditor, useCKEditorCloud } from '../../src/index.js'; 12 | 13 | type CKEditorCloudPluginsDemoProps = { 14 | content: string; 15 | }; 16 | 17 | declare global { 18 | interface Window { 19 | '@wiris/mathtype-ckeditor5': typeof Plugin; 20 | } 21 | } 22 | 23 | export const CKEditorCloudPluginsDemo = ( { content }: CKEditorCloudPluginsDemoProps ): ReactNode => { 24 | const cloud = useCKEditorCloud( { 25 | version: '43.0.0', 26 | translations: [ 'pl', 'de' ], 27 | premium: true, 28 | plugins: { 29 | Wiris: { 30 | scripts: [ 31 | 'https://www.wiris.net/demo/plugins/app/WIRISplugins.js', 32 | 'https://cdn.jsdelivr.net/npm/@wiris/mathtype-ckeditor5@8.11.0/dist/browser/index.umd.js' 33 | ], 34 | stylesheets: [ 35 | 'https://cdn.jsdelivr.net/npm/@wiris/mathtype-ckeditor5@8.11.0/dist/browser/index.css' 36 | ], 37 | checkPluginLoaded: () => window[ '@wiris/mathtype-ckeditor5' ] 38 | } 39 | } 40 | } ); 41 | 42 | if ( cloud.status === 'error' ) { 43 | console.error( cloud ); 44 | } 45 | 46 | if ( cloud.status !== 'success' ) { 47 | return
Loading...
; 48 | } 49 | 50 | const CKEditorClassic = getCKCdnClassicEditor( { 51 | cloud, 52 | additionalPlugins: [ 53 | cloud.loadedPlugins!.Wiris 54 | ], 55 | overrideConfig: { 56 | toolbar: { 57 | items: [ 58 | 'undo', 'redo', 59 | '|', 'heading', 60 | '|', 'bold', 'italic', 61 | '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', 62 | '|', 'bulletedList', 'numberedList', 'outdent', 'indent', 63 | '|', 'MathType', 'ChemType' 64 | ] 65 | } 66 | } 67 | } ); 68 | 69 | return ( 70 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /demos/cdn-react/getCKCdnClassicEditor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { ClassicEditor, Plugin, ContextPlugin, EditorConfig } from 'https://cdn.ckeditor.com/typings/ckeditor5.d.ts'; 7 | import type { CKEditorCloudConfig, CKEditorCloudResult } from '../../src/index.js'; 8 | 9 | type ClassicEditorCreatorConfig = { 10 | cloud: CKEditorCloudResult; 11 | additionalPlugins?: Array; 12 | overrideConfig?: EditorConfig; 13 | }; 14 | 15 | export const getCKCdnClassicEditor = ( { 16 | cloud, additionalPlugins, overrideConfig 17 | }: ClassicEditorCreatorConfig ): typeof ClassicEditor => { 18 | const { 19 | ClassicEditor: ClassicEditorBase, 20 | Essentials, 21 | Autoformat, 22 | Bold, 23 | Italic, 24 | BlockQuote, 25 | CloudServices, 26 | Heading, 27 | Image, 28 | ImageCaption, 29 | ImageStyle, 30 | ImageToolbar, 31 | ImageUpload, 32 | Indent, 33 | Link, 34 | List, 35 | MediaEmbed, 36 | Paragraph, 37 | PasteFromOffice, 38 | PictureEditing, 39 | Table, 40 | TableToolbar, 41 | TextTransformation 42 | } = cloud.CKEditor; 43 | 44 | class CustomEditor extends ClassicEditorBase { 45 | public static builtinPlugins = [ 46 | Essentials, 47 | Autoformat, 48 | Bold, 49 | Italic, 50 | BlockQuote, 51 | Heading, 52 | Image, 53 | ImageCaption, 54 | ImageStyle, 55 | ImageToolbar, 56 | ImageUpload, 57 | Indent, 58 | Link, 59 | List, 60 | MediaEmbed, 61 | Paragraph, 62 | PasteFromOffice, 63 | PictureEditing, 64 | Table, 65 | TableToolbar, 66 | TextTransformation, 67 | CloudServices, 68 | ...additionalPlugins || [] 69 | ]; 70 | 71 | public static defaultConfig = { 72 | toolbar: { 73 | items: [ 74 | 'undo', 'redo', 75 | '|', 'heading', 76 | '|', 'bold', 'italic', 77 | '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', 78 | '|', 'bulletedList', 'numberedList', 'outdent', 'indent' 79 | ] 80 | }, 81 | image: { 82 | toolbar: [ 83 | 'imageStyle:inline', 84 | 'imageStyle:block', 85 | 'imageStyle:side', 86 | '|', 87 | 'toggleImageCaption', 88 | 'imageTextAlternative' 89 | ] 90 | }, 91 | table: { 92 | contentToolbar: [ 93 | 'tableColumn', 94 | 'tableRow', 95 | 'mergeTableCells' 96 | ] 97 | }, 98 | language: 'en', 99 | ...overrideConfig 100 | }; 101 | } 102 | 103 | return CustomEditor; 104 | }; 105 | -------------------------------------------------------------------------------- /demos/cdn-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CKEditor 5 via CDN – React Component – demo 7 | 8 | 9 | 27 | 28 | 29 | 30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /demos/cdn-react/main.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React from 'react'; 7 | import { App } from './App.js'; 8 | 9 | const element = document.getElementById( 'root' ) as HTMLDivElement; 10 | 11 | if ( __REACT_VERSION__ <= 17 ) { 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-ignore 14 | const ReactDOM = await import( 'react-dom' ); 15 | 16 | ReactDOM.render( React.createElement( App ), element ); 17 | } else { 18 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 19 | // @ts-ignore 20 | const { createRoot } = await import( 'react-dom/client' ); 21 | 22 | createRoot( element ).render( ); 23 | } 24 | 25 | console.log( `%cVersion of React used: ${ React.version }`, 'color:red;font-weight:bold;' ); 26 | -------------------------------------------------------------------------------- /demos/npm-multiroot-react/App.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { StrictMode, useState } from 'react'; 7 | import MultiRootEditorDemo from './MultiRootEditorDemo.js'; 8 | import MultiRootEditorRichDemo from './MultiRootEditorRichDemo.js'; 9 | import ContextMultiRootEditorDemo from './ContextMultiRootEditorDemo.js'; 10 | 11 | type Demo = 'editor' | 'rich' | 'context'; 12 | 13 | const multiRootEditorContent = { 14 | intro: '

Sample

This is an instance of the ' + 15 | 'multi-root editor type.

', 16 | content: '

It is the custom content

CKEditor 5 Sample image.
', 17 | outro: '

You can use CKEditor Builder' + 18 | ' to create a custom configuration with your favorite features.

' 19 | }; 20 | 21 | const rootsAttributes = { 22 | intro: { 23 | row: '1', 24 | order: 10 25 | }, 26 | content: { 27 | row: '1', 28 | order: 20 29 | }, 30 | outro: { 31 | row: '2', 32 | order: 10 33 | } 34 | }; 35 | 36 | export default function App(): JSX.Element { 37 | const [ demo, setDemo ] = useState( 'editor' ); 38 | 39 | const renderDemo = () => { 40 | switch ( demo ) { 41 | case 'context': 42 | return ; 43 | case 'editor': 44 | return ; 45 | case 'rich': 46 | return ; 47 | } 48 | }; 49 | 50 | return ( 51 | 52 |

CKEditor 5 – useMultiRootEditor – development sample

53 | 54 |
55 | 61 | 62 | 68 | 69 | 75 |
76 | { renderDemo() } 77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /demos/npm-multiroot-react/ContextMultiRootEditorDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import { useMultiRootEditor, type MultiRootHookProps, CKEditorContext } from '../../src/index.js'; 9 | import MultiRootEditor from './MultiRootEditor.js'; 10 | 11 | export default function ContextMultiRootEditorDemo(): JSX.Element { 12 | return ( 13 | <> 14 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | function ContextEditorDemo(): JSX.Element { 25 | const editorProps: Partial = { 26 | editor: MultiRootEditor, 27 | 28 | onChange: ( event, editor ) => { 29 | console.log( 'event: onChange', { event, editor } ); 30 | }, 31 | onBlur: ( event, editor ) => { 32 | console.log( 'event: onBlur', { event, editor } ); 33 | }, 34 | onFocus: ( event, editor ) => { 35 | console.log( 'event: onFocus', { event, editor } ); 36 | } 37 | }; 38 | 39 | // First editor initialization. 40 | const { 41 | editor: editor1, editableElements: editableElements1, toolbarElement: toolbarElement1 42 | } = useMultiRootEditor( { 43 | ...editorProps, 44 | data: { 45 | intro: '

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

', 46 | content: '

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

' 47 | }, 48 | 49 | onReady: editor => { 50 | window.editor1 = editor; 51 | 52 | console.log( 'event: onChange', { editor } ); 53 | } 54 | } as MultiRootHookProps ); 55 | 56 | // Second editor initialization. 57 | const { 58 | editor: editor2, editableElements: editableElements2, toolbarElement: toolbarElement2 59 | } = useMultiRootEditor( { 60 | ...editorProps, 61 | data: { 62 | notes: '

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

' 63 | }, 64 | 65 | onReady: editor => { 66 | window.editor2 = editor; 67 | 68 | console.log( 'event: onChange', { editor } ); 69 | } 70 | } as MultiRootHookProps ); 71 | 72 | // Function to simulate an error in the editor. 73 | // It is used for testing purposes to trigger the Watchdog to restart the editor. 74 | // Remove it in the actual integration. 75 | const simulateError = ( editor: MultiRootEditor ) => { 76 | setTimeout( () => { 77 | const err: any = new Error( 'foo' ); 78 | 79 | err.context = editor; 80 | err.is = () => true; 81 | 82 | throw err; 83 | } ); 84 | }; 85 | 86 | return ( 87 | <> 88 |

Context Multi-root Editor Demo

89 |

90 | This sample demonstrates integration with CKEditorContext.
91 |

92 |

Component's events are logged to the console.

93 |

94 | 95 |
96 |
97 | 103 |
104 | 105 | { toolbarElement1 } 106 | 107 |
108 | { editableElements1 } 109 |
110 |
111 | 112 |
113 | 114 |
115 |
116 | 122 |
123 | 124 | { toolbarElement2 } 125 | 126 |
127 | { editableElements2 } 128 |
129 |
130 | 131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /demos/npm-multiroot-react/MultiRootEditor.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { 7 | MultiRootEditor as MultiRootEditorBase, 8 | CloudServices, 9 | Essentials, 10 | CKFinderUploadAdapter, 11 | Autoformat, 12 | Bold, 13 | Italic, 14 | BlockQuote, 15 | CKBox, 16 | CKFinder, 17 | EasyImage, 18 | Heading, 19 | Image, 20 | ImageCaption, 21 | ImageStyle, 22 | ImageToolbar, 23 | ImageUpload, 24 | Indent, 25 | Link, 26 | List, 27 | MediaEmbed, 28 | Paragraph, 29 | PasteFromOffice, 30 | PictureEditing, 31 | Table, 32 | TableToolbar, 33 | TextTransformation, 34 | Base64UploadAdapter 35 | } from 'ckeditor5'; 36 | 37 | import 'ckeditor5/ckeditor5.css'; 38 | 39 | export default class MultiRootEditor extends MultiRootEditorBase { 40 | public static override builtinPlugins = [ 41 | Essentials, 42 | CKFinderUploadAdapter, 43 | Autoformat, 44 | Bold, 45 | Italic, 46 | BlockQuote, 47 | CKBox, 48 | CKFinder, 49 | CloudServices, 50 | EasyImage, 51 | Heading, 52 | Image, 53 | ImageCaption, 54 | ImageStyle, 55 | ImageToolbar, 56 | ImageUpload, 57 | Indent, 58 | Link, 59 | List, 60 | MediaEmbed, 61 | Paragraph, 62 | PasteFromOffice, 63 | PictureEditing, 64 | Table, 65 | TableToolbar, 66 | TextTransformation, 67 | Base64UploadAdapter 68 | ]; 69 | 70 | public static override defaultConfig = { 71 | licenseKey: 'GPL', 72 | toolbar: { 73 | items: [ 74 | 'undo', 'redo', 75 | '|', 'heading', 76 | '|', 'bold', 'italic', 77 | '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', 78 | '|', 'bulletedList', 'numberedList', 'outdent', 'indent' 79 | ] 80 | }, 81 | image: { 82 | toolbar: [ 83 | 'imageStyle:inline', 84 | 'imageStyle:block', 85 | 'imageStyle:side', 86 | '|', 87 | 'toggleImageCaption', 88 | 'imageTextAlternative' 89 | ] 90 | }, 91 | table: { 92 | contentToolbar: [ 93 | 'tableColumn', 94 | 'tableRow', 95 | 'mergeTableCells' 96 | ] 97 | }, 98 | language: 'en' 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /demos/npm-multiroot-react/MultiRootEditorDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import { useMultiRootEditor, type MultiRootHookProps } from '../../src/index.js'; 9 | import MultiRootEditor from './MultiRootEditor.js'; 10 | 11 | type EditorDemoProps = { 12 | data: Record; 13 | rootsAttributes: Record>; 14 | }; 15 | 16 | export default function MultiRootEditorDemo( props: EditorDemoProps ): JSX.Element { 17 | const editorProps: MultiRootHookProps = { 18 | editor: MultiRootEditor, 19 | data: props.data 20 | }; 21 | 22 | const { toolbarElement, editableElements } = useMultiRootEditor( editorProps ); 23 | 24 | return ( 25 | <> 26 |

Multi-root Editor Demo

27 |

28 | This sample demonstrates the minimal React application that uses multi-root editor integration.
29 | You may use it as a starting point for your application. 30 |

31 |

32 | 33 |
34 | { toolbarElement } 35 | 36 | { editableElements } 37 |
38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /demos/npm-multiroot-react/MultiRootEditorRichDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { useState, type ChangeEvent } from 'react'; 7 | 8 | import { useMultiRootEditor, type MultiRootHookProps } from '../../src/index.js'; 9 | import MultiRootEditor from './MultiRootEditor.js'; 10 | 11 | const SAMPLE_READ_ONLY_LOCK_ID = 'Integration Sample'; 12 | 13 | type EditorDemoProps = { 14 | data: Record; 15 | rootsAttributes: Record>; 16 | }; 17 | 18 | export default function MultiRootEditorRichDemo( props: EditorDemoProps ): JSX.Element { 19 | const editorProps: MultiRootHookProps = { 20 | editor: MultiRootEditor, 21 | data: props.data, 22 | rootsAttributes: props.rootsAttributes, 23 | 24 | onReady: editor => { 25 | // @ts-expect-error: Caused by linking to parent project and conflicting react types 26 | window.editor = editor; 27 | 28 | console.log( 'event: onChange', { editor } ); 29 | }, 30 | onChange: ( event, editor ) => { 31 | console.log( 'event: onChange', { event, editor } ); 32 | }, 33 | onBlur: ( event, editor ) => { 34 | console.log( 'event: onBlur', { event, editor } ); 35 | }, 36 | onFocus: ( event, editor ) => { 37 | console.log( 'event: onFocus', { event, editor } ); 38 | }, 39 | 40 | config: { 41 | rootsAttributes: props.rootsAttributes 42 | } 43 | }; 44 | 45 | const { 46 | editor, editableElements, toolbarElement, 47 | data, setData, 48 | attributes, setAttributes 49 | } = useMultiRootEditor( editorProps ); 50 | 51 | // The element state with number of roots that should be added in one row. 56 | // This is for demo purposes, and you may remove it in the actual integration or change accordingly to your needs. 57 | const [ numberOfRoots, setNumberOfRoots ] = useState( 1 ); 58 | 59 | // A set with disabled roots. It is used to support read-only feature in multi root editor. 60 | // This is for demo purposes, and you may remove it in the actual integration or change accordingly to your needs. 61 | const [ disabledRoots, setDisabledRoots ] = useState>( new Set() ); 62 | 63 | // Function to toggle read-only mode for selected root. 64 | const toggleReadOnly = () => { 65 | const root = editor!.model.document.selection.getFirstRange()!.root; 66 | 67 | if ( !root || !root.rootName ) { 68 | return; 69 | } 70 | 71 | const isReadOnly = disabledRoots.has( root.rootName ); 72 | 73 | if ( isReadOnly ) { 74 | disabledRoots.delete( root.rootName ); 75 | editor!.enableRoot( root.rootName, SAMPLE_READ_ONLY_LOCK_ID ); 76 | } else { 77 | disabledRoots.add( root.rootName ); 78 | editor!.disableRoot( root.rootName, SAMPLE_READ_ONLY_LOCK_ID ); 79 | } 80 | 81 | setDisabledRoots( new Set( disabledRoots ) ); 82 | }; 83 | 84 | // Function to simulate an error in the editor. 85 | // It is used for testing purposes to trigger the Watchdog to restart the editor. 86 | // Remove it in the actual integration. 87 | const simulateError = () => { 88 | setTimeout( () => { 89 | const err: any = new Error( 'foo' ); 90 | 91 | err.context = editor; 92 | err.is = () => true; 93 | 94 | throw err; 95 | } ); 96 | }; 97 | 98 | const addRoot = ( newRootAttributes: Record, rootId?: string ) => { 99 | const id = rootId || new Date().getTime(); 100 | 101 | for ( let i = 1; i <= numberOfRoots; i++ ) { 102 | const rootName = `root-${ i }-${ id }`; 103 | 104 | data[ rootName ] = ''; 105 | 106 | // Remove code related to rows if you don't need to handle multiple roots in one row. 107 | attributes[ rootName ] = { ...newRootAttributes, order: i * 10, row: id }; 108 | } 109 | 110 | setData( { ...data } ); 111 | setAttributes( { ...attributes } ); 112 | // Reset the element to the default value. 113 | setNumberOfRoots( 1 ); 114 | }; 115 | 116 | const removeRoot = ( rootName: string ) => { 117 | setData( previousData => { 118 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 119 | const { [ rootName! ]: _, ...newData } = previousData; 120 | 121 | return { ...newData }; 122 | } ); 123 | 124 | setSelectedRoot( '' ); 125 | }; 126 | 127 | // Group elements based on their row attribute and sort them by order attribute. 128 | // Grouping in a row is used for presentation purposes, and you may remove it in actual integration. 129 | // However, we recommend ordering the roots, so that rows are put in a correct places when undo/redo is used. 130 | const groupedElements = Object.entries( 131 | editableElements 132 | .sort( ( a, b ) => ( attributes[ a.props.id ].order as number ) - ( attributes[ b.props.id ].order as number ) ) 133 | .reduce( ( acc: Record>, element ) => { 134 | const row = attributes[ element.props.id ].row as string; 135 | acc[ row ] = acc[ row ] || []; 136 | acc[ row ].push( element ); 137 | 138 | return acc; 139 | }, {} ) 140 | ); 141 | 142 | return ( 143 | <> 144 |

Multi-root Editor Demo (rich integration)

145 |

This sample demonstrates a more advanced integration of the multi-root editor in React.

146 |

147 | Multiple extra features are implemented to illustrate how you can customize your application and use the provided API.
148 | They are optional, and you do not need to include them in your application.
149 | However, they can be a good starting point for your own custom features. 150 |

151 |

152 | The 'Simulate an error' button makes the editor throw an error to show you how it is restarted by 153 | the Watchdog mechanism.
154 | Note, that Watchdog is enabled by default.
155 | It can be disabled by passing the `disableWatchdog` flag to the `useMultiRootEditor` hook. 156 |

157 |

Component's events are logged to the console.

158 |

159 | 160 |
161 | 167 | 168 | 174 |
175 | 176 |
177 | 183 | 184 | 193 |
194 | 195 |
196 | 201 | 202 | Number( e.target.value ) <= 4 && setNumberOfRoots( Number( e.target.value ) )} 208 | /> 209 |
210 | 211 |
212 | 213 | { toolbarElement } 214 | 215 | { /* Maps through `groupedElements` array to render rows that contains the editor roots. */ } 216 | { groupedElements.map( ( [ row, elements ] ) => ( 217 |
218 | { elements } 219 |
220 | ) ) } 221 | 222 | ); 223 | } 224 | -------------------------------------------------------------------------------- /demos/npm-multiroot-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CKEditor 5 via NPM – React Component – demo 7 | 8 | 48 | 49 | 50 | 51 |
52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /demos/npm-multiroot-react/main.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React from 'react'; 7 | import App from './App.js'; 8 | 9 | const element = document.getElementById( 'root' ) as HTMLDivElement; 10 | 11 | if ( __REACT_VERSION__ <= 17 ) { 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-ignore 14 | const ReactDOM = await import( 'react-dom' ); 15 | 16 | ReactDOM.render( React.createElement( App ), element ); 17 | } else { 18 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 19 | // @ts-ignore 20 | const { createRoot } = await import( 'react-dom/client' ); 21 | 22 | createRoot( element ).render( ); 23 | } 24 | 25 | console.log( `%cVersion of React used: ${ React.version }`, 'color:red;font-weight:bold;' ); 26 | -------------------------------------------------------------------------------- /demos/npm-react/App.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { useState } from 'react'; 7 | import EditorDemo from './EditorDemo.js'; 8 | import ContextDemo from './ContextDemo.js'; 9 | 10 | type Demo = 'editor' | 'context'; 11 | 12 | const editorContent = ` 13 |

Sample

14 |

This is an instance of the 15 | classic editor build. 16 |

17 |
18 | CKEditor 5 Sample image. 19 |
20 |

21 | You can use 22 | CKEditor Builder 23 | to create a custom build with your favorite features. 24 |

25 | `; 26 | 27 | export default function App(): JSX.Element { 28 | const [ demo, setDemo ] = useState( 'editor' ); 29 | 30 | return ( 31 | 32 |

CKEditor 5 – React Component – development sample

33 | 34 |
35 | 41 | 42 | 48 |
49 | { 50 | demo == 'editor' ? 51 | : 52 | 53 | } 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /demos/npm-react/ClassicEditor.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { 7 | ClassicEditor as ClassicEditorBase, 8 | Essentials, 9 | CKFinderUploadAdapter, 10 | Autoformat, 11 | Bold, 12 | Italic, 13 | BlockQuote, 14 | CKBox, 15 | CKFinder, 16 | CloudServices, 17 | EasyImage, 18 | Heading, 19 | Image, 20 | ImageCaption, 21 | ImageStyle, 22 | ImageToolbar, 23 | ImageUpload, 24 | Indent, 25 | IndentBlock, 26 | Link, 27 | List, 28 | MediaEmbed, 29 | Paragraph, 30 | PasteFromOffice, 31 | PictureEditing, 32 | Table, 33 | TableToolbar, 34 | TextTransformation, 35 | Base64UploadAdapter 36 | } from 'ckeditor5'; 37 | 38 | import 'ckeditor5/ckeditor5.css'; 39 | 40 | export default class ClassicEditor extends ClassicEditorBase { 41 | public static override builtinPlugins = [ 42 | Essentials, 43 | CKFinderUploadAdapter, 44 | Autoformat, 45 | Bold, 46 | Italic, 47 | BlockQuote, 48 | CKBox, 49 | CKFinder, 50 | CloudServices, 51 | EasyImage, 52 | Heading, 53 | Image, 54 | ImageCaption, 55 | ImageStyle, 56 | ImageToolbar, 57 | ImageUpload, 58 | Indent, 59 | IndentBlock, 60 | Link, 61 | List, 62 | MediaEmbed, 63 | Paragraph, 64 | PasteFromOffice, 65 | PictureEditing, 66 | Table, 67 | TableToolbar, 68 | TextTransformation, 69 | Base64UploadAdapter 70 | ]; 71 | 72 | public static override defaultConfig = { 73 | licenseKey: 'GPL', 74 | toolbar: { 75 | items: [ 76 | 'undo', 'redo', 77 | '|', 'heading', 78 | '|', 'bold', 'italic', 79 | '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', 80 | '|', 'bulletedList', 'numberedList', 'outdent', 'indent' 81 | ] 82 | }, 83 | image: { 84 | toolbar: [ 85 | 'imageStyle:inline', 86 | 'imageStyle:block', 87 | 'imageStyle:side', 88 | '|', 89 | 'toggleImageCaption', 90 | 'imageTextAlternative' 91 | ] 92 | }, 93 | table: { 94 | contentToolbar: [ 95 | 'tableColumn', 96 | 'tableRow', 97 | 'mergeTableCells' 98 | ] 99 | }, 100 | // This value must be kept in sync with the language defined in webpack.config.js. 101 | language: 'en' 102 | }; 103 | } 104 | -------------------------------------------------------------------------------- /demos/npm-react/ContextDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { useState } from 'react'; 7 | 8 | import type { Editor } from 'ckeditor5'; 9 | 10 | import ClassicEditor from './ClassicEditor.js'; 11 | import { CKEditor, CKEditorContext } from '../../src/index.js'; 12 | 13 | declare global { 14 | interface Window { 15 | editor1: Editor | null; 16 | editor2: Editor | null; 17 | } 18 | } 19 | 20 | type ContextDemoProps = { 21 | content: string; 22 | }; 23 | 24 | export default function ContextDemo( props: ContextDemoProps ): JSX.Element { 25 | const [ state, setState ] = useState>( {} ); 26 | 27 | const simulateError = ( editor: ClassicEditor ) => { 28 | setTimeout( () => { 29 | const err: any = new Error( 'foo' ); 30 | 31 | err.context = editor; 32 | err.is = () => true; 33 | 34 | throw err; 35 | } ); 36 | }; 37 | 38 | return ( 39 | <> 40 |

Editor Context Demo

41 |

Component's events are logged to the console.

42 | 43 | { 47 | console.log( 'Initialized editors:', editors ); 48 | setState( editors as any ); 49 | } } 50 | > 51 |
52 | 58 |
59 | 60 | 67 | 68 |
69 | 75 |
76 | 77 | 84 |
85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /demos/npm-react/EditorDemo.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { useState } from 'react'; 7 | import ClassicEditor from './ClassicEditor.js'; 8 | import { CKEditor } from '../../src/index.js'; 9 | 10 | declare global { 11 | interface Window { 12 | editor: ClassicEditor | null; 13 | } 14 | } 15 | 16 | const SAMPLE_READ_ONLY_LOCK_ID = 'Integration Sample'; 17 | 18 | type EditorDemoProps = { 19 | content: string; 20 | }; 21 | 22 | type EditorDemoState = { 23 | documents: Array; 24 | documentID: number; 25 | editor: ClassicEditor | null; 26 | }; 27 | 28 | export default function EditorDemo( props: EditorDemoProps ): JSX.Element { 29 | const [ isWatchdogDisabled, setIsWatchdogDisabled ] = useState( false ); 30 | const [ state, setState ] = useState( { 31 | documents: [ props.content ], 32 | documentID: 0, 33 | editor: null 34 | } ); 35 | 36 | const updateData = () => { 37 | setState( prevState => ( { 38 | ...prevState, 39 | documents: state.documents.map( ( data, index ) => { 40 | if ( index === state.documentID ) { 41 | return state.editor!.getData(); 42 | } 43 | 44 | return data; 45 | } ) 46 | } ) ); 47 | }; 48 | 49 | const toggleReadOnly = () => { 50 | const editor = state.editor!; 51 | 52 | if ( editor.isReadOnly ) { 53 | editor.disableReadOnlyMode( SAMPLE_READ_ONLY_LOCK_ID ); 54 | } else { 55 | editor.enableReadOnlyMode( SAMPLE_READ_ONLY_LOCK_ID ); 56 | } 57 | }; 58 | 59 | const simulateError = () => { 60 | setTimeout( () => { 61 | const err: any = new Error( 'foo' ); 62 | 63 | err.context = state.editor; 64 | err.is = () => true; 65 | 66 | throw err; 67 | } ); 68 | }; 69 | 70 | const nextDocumentID = () => { 71 | setState( prevState => ( { 72 | ...prevState, 73 | documentID: state.documentID + 1, 74 | documents: state.documents.length < state.documentID + 1 ? 75 | state.documents : 76 | [ ...state.documents, props.content ] 77 | } ) ); 78 | }; 79 | 80 | const previousDocumentID = () => { 81 | setState( prevState => ( { ...prevState, documentID: Math.max( state.documentID - 1, 0 ) } ) ); 82 | }; 83 | 84 | return ( 85 | <> 86 |

Editor Demo

87 |

Component's events are logged to the console.

88 | 89 |
90 | 96 | 97 | 103 | 104 | 110 | 111 | 116 | 121 |
122 | 123 | { 131 | window.editor = editor; 132 | 133 | console.log( 'event: onReady' ); 134 | 135 | setState( prevState => ( { ...prevState, editor } ) ); 136 | } } 137 | 138 | onChange={ ( event, editor ) => { 139 | updateData(); 140 | 141 | console.log( 'event: onChange', { event, editor } ); 142 | } } 143 | 144 | onBlur={ ( event, editor ) => { 145 | console.log( 'event: onBlur', { event, editor } ); 146 | } } 147 | 148 | onFocus={ ( event, editor ) => { 149 | console.log( 'event: onFocus', { event, editor } ); 150 | } } 151 | /> 152 | 153 | ); 154 | } 155 | -------------------------------------------------------------------------------- /demos/npm-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CKEditor 5 via NPM – React Component – demo 7 | 8 | 9 | 27 | 28 | 29 | 30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /demos/npm-react/main.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React from 'react'; 7 | import App from './App.js'; 8 | 9 | const element = document.getElementById( 'root' ) as HTMLDivElement; 10 | 11 | if ( __REACT_VERSION__ <= 17 ) { 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-ignore 14 | const ReactDOM = await import( 'react-dom' ); 15 | 16 | ReactDOM.render( React.createElement( App ), element ); 17 | } else { 18 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 19 | // @ts-ignore 20 | const { createRoot } = await import( 'react-dom/client' ); 21 | 22 | createRoot( element ).render( ); 23 | } 24 | 25 | console.log( `%cVersion of React used: ${ React.version }`, 'color:red;font-weight:bold;' ); 26 | -------------------------------------------------------------------------------- /demos/sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckeditor/ckeditor5-react/0537f2cfd3a854342a7ec5aa61cac4f1332472ea/demos/sample.jpg -------------------------------------------------------------------------------- /demos/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "../vite-env.d.ts" 6 | ] 7 | }, 8 | "include": [ 9 | "**/*" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import globals from 'globals'; 7 | import { defineConfig } from 'eslint/config'; 8 | import ckeditor5Rules from 'eslint-plugin-ckeditor5-rules'; 9 | import ckeditor5Config from 'eslint-config-ckeditor5'; 10 | import pluginReact from 'eslint-plugin-react'; 11 | import ts from 'typescript-eslint'; 12 | 13 | export default defineConfig( [ 14 | { 15 | ignores: [ 16 | 'coverage/**', 17 | 'dist/**', 18 | 'release/**', 19 | '**/*.d.ts' 20 | ] 21 | }, 22 | 23 | { 24 | extends: [ 25 | ckeditor5Config, 26 | pluginReact.configs.flat.recommended 27 | ], 28 | 29 | languageOptions: { 30 | ecmaVersion: 'latest', 31 | sourceType: 'module', 32 | globals: { 33 | ...globals.browser 34 | } 35 | }, 36 | 37 | linterOptions: { 38 | reportUnusedDisableDirectives: 'warn', 39 | reportUnusedInlineConfigs: 'warn' 40 | }, 41 | 42 | plugins: { 43 | 'ckeditor5-rules': ckeditor5Rules, 44 | '@typescript-eslint': ts.plugin 45 | }, 46 | 47 | settings: { 48 | react: { 49 | version: 'detect' 50 | } 51 | }, 52 | 53 | rules: { 54 | '@stylistic/func-call-spacing': 'off', 55 | '@stylistic/function-call-spacing': [ 'error', 'never' ], 56 | '@stylistic/operator-linebreak': 'off', 57 | 'react/prop-types': 'off', 58 | 'react/no-deprecated': 'off', 59 | 'no-console': 'off', 60 | '@stylistic/no-trailing-spaces': 'error', 61 | 'ckeditor5-rules/prevent-license-key-leak': 'error', 62 | 'ckeditor5-rules/allow-imports-only-from-main-package-entry-point': 'off', 63 | 'ckeditor5-rules/license-header': [ 'error', { headerLines: [ 64 | '/**', 65 | ' * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.', 66 | ' * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options', 67 | ' */' 68 | ] } ], 69 | 'ckeditor5-rules/require-file-extensions-in-imports': [ 70 | 'error', 71 | { 72 | extensions: [ '.ts', '.js', '.json' ] 73 | } 74 | ] 75 | } 76 | }, 77 | 78 | // Rules specific to `tests` folder. 79 | { 80 | files: [ 'tests/**' ], 81 | 82 | 'rules': { 83 | 'react/no-render-return-value': 'off', 84 | 'no-unused-expressions': 'off', 85 | '@typescript-eslint/no-unused-expressions': 'off' 86 | } 87 | }, 88 | 89 | // Rules specific to `demos` folder. 90 | { 91 | 'files': [ 'demos/**' ], 92 | 'rules': { 93 | 'ckeditor5-rules/license-header': 'off' 94 | } 95 | }, 96 | 97 | // Rules specific to `scripts` folder. 98 | { 99 | files: [ 'scripts/**/*' ], 100 | 101 | languageOptions: { 102 | globals: { 103 | ...globals.node 104 | } 105 | } 106 | } 107 | ] ); 108 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CKEditor React example 8 | 9 | 10 |
11 |

Select the demo you want to test:

12 | 13 | NPM Editor 14 | NPM Multiroot editor 15 | 16 | CDN Editor 17 | CDN Multiroot editor 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ckeditor/ckeditor5-react", 3 | "version": "11.0.0", 4 | "description": "Official React component for CKEditor 5 – the best browser-based rich text editor.", 5 | "keywords": [ 6 | "wysiwyg", 7 | "rich text", 8 | "editor", 9 | "html", 10 | "contentEditable", 11 | "editing", 12 | "react", 13 | "react-component", 14 | "ckeditor", 15 | "ckeditor5", 16 | "ckeditor 5" 17 | ], 18 | "type": "module", 19 | "main": "./dist/index.umd.cjs", 20 | "module": "./dist/index.js", 21 | "types": "./dist/index.d.ts", 22 | "exports": { 23 | ".": { 24 | "types": "./dist/index.d.ts", 25 | "import": "./dist/index.js", 26 | "require": "./dist/index.umd.cjs" 27 | }, 28 | "./package.json": "./package.json" 29 | }, 30 | "dependencies": { 31 | "@ckeditor/ckeditor5-integrations-common": "^2.2.2" 32 | }, 33 | "peerDependencies": { 34 | "ckeditor5": ">=46.0.0 || ^0.0.0-nightly", 35 | "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" 36 | }, 37 | "devDependencies": { 38 | "@ckeditor/ckeditor5-dev-bump-year": "^53.0.0", 39 | "@ckeditor/ckeditor5-dev-changelog": "^53.0.0", 40 | "@ckeditor/ckeditor5-dev-ci": "^53.0.0", 41 | "@ckeditor/ckeditor5-dev-release-tools": "^53.0.0", 42 | "@ckeditor/ckeditor5-dev-utils": "^53.0.0", 43 | "@testing-library/dom": "^10.3.1", 44 | "@testing-library/jest-dom": "^6.4.8", 45 | "@testing-library/react": "^16.0.0", 46 | "@types/react": "^18.0.0", 47 | "@types/react-dom": "^18.0.0", 48 | "@vitejs/plugin-react": "^4.3.1", 49 | "@vitest/browser": "^2.1.9", 50 | "@vitest/coverage-istanbul": "^2.1.4", 51 | "@vitest/ui": "^2.1.4", 52 | "ckeditor5": "^46.0.0", 53 | "ckeditor5-premium-features": "^46.0.0", 54 | "coveralls": "^3.1.1", 55 | "eslint": "^9.26.0", 56 | "eslint-config-ckeditor5": "^10.0.0", 57 | "eslint-plugin-ckeditor5-rules": "^10.0.0", 58 | "eslint-plugin-react": "^7.37.5", 59 | "globals": "^16.1.0", 60 | "husky": "^9.1.7", 61 | "lint-staged": "^10.2.11", 62 | "listr2": "^6.5.0", 63 | "minimist": "^1.2.5", 64 | "prop-types": "^15.8.1", 65 | "react": "^18.0.0", 66 | "react-dom": "^18.0.0", 67 | "react16": "npm:react@^16.0.0", 68 | "react16-dom": "npm:react-dom@^16.0.0", 69 | "react17": "npm:react@^17.0.0", 70 | "react17-dom": "npm:react-dom@^17.0.0", 71 | "react18": "npm:react@^18.0.0", 72 | "react18-dom": "npm:react-dom@^18.0.0", 73 | "react19": "npm:react@19.0.0", 74 | "react19-dom": "npm:react-dom@19.0.0", 75 | "semver": "^7.0.0", 76 | "typescript": "^5.0.0", 77 | "typescript-eslint": "^8.32.1", 78 | "upath": "^2.0.1", 79 | "vite": "^5.3.1", 80 | "vitest": "^2.1.9", 81 | "webdriverio": "^9.12.7" 82 | }, 83 | "pnpm": { 84 | "overrides": { 85 | "form-data": "^4.0.4", 86 | "semver": "^7.0.0", 87 | "@stylistic/eslint-plugin": "^5.3.1" 88 | } 89 | }, 90 | "engines": { 91 | "node": ">=22.0.0", 92 | "pnpm": ">=10.14.0", 93 | "yarn": "\n\n┌─────────────────────────┐\n│ Hey, we use pnpm now! │\n└─────────────────────────┘\n\n" 94 | }, 95 | "scripts": { 96 | "nice": "ckeditor5-dev-changelog-create-entry", 97 | "dev": "echo \"Use 'dev:16', 'dev:17', 'dev:18', or 'dev:19' depending on the version of React you want to test\"", 98 | "dev:16": "REACT_VERSION=16 vite", 99 | "dev:17": "REACT_VERSION=17 vite", 100 | "dev:18": "REACT_VERSION=18 vite", 101 | "dev:19": "REACT_VERSION=19 vite", 102 | "build": "vite build && tsc --emitDeclarationOnly", 103 | "test": "vitest run --coverage", 104 | "test:watch": "vitest --ui --watch", 105 | "test:check:types": "tsc --noEmit -p ./tests/tsconfig.json", 106 | "lint": "eslint", 107 | "postinstall": "node ./scripts/postinstall.js", 108 | "release:prepare-changelog": "node ./scripts/preparechangelog.js", 109 | "release:prepare-packages": "node ./scripts/preparepackages.js", 110 | "release:publish-packages": "node ./scripts/publishpackages.js" 111 | }, 112 | "author": "CKSource (http://cksource.com/)", 113 | "license": "SEE LICENSE IN LICENSE.md", 114 | "repository": { 115 | "type": "git", 116 | "url": "https://github.com/ckeditor/ckeditor5-react.git" 117 | }, 118 | "bugs": { 119 | "url": "https://github.com/ckeditor/ckeditor5-react/issues" 120 | }, 121 | "homepage": "https://github.com/ckeditor/ckeditor5-react", 122 | "files": [ 123 | "dist", 124 | "README.md", 125 | "CHANGELOG.md", 126 | "LICENSE.md" 127 | ], 128 | "lint-staged": { 129 | "**/*": [ 130 | "eslint --quiet" 131 | ] 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | shellEmulator: true 2 | shamefullyHoist: true 3 | preferFrozenLockfile: true 4 | 5 | onlyBuiltDependencies: 6 | - edgedriver 7 | - esbuild 8 | - geckodriver 9 | - msw 10 | - protobufjs 11 | -------------------------------------------------------------------------------- /scripts/bump-year.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 5 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 6 | */ 7 | 8 | /* 9 | 10 | Usage: 11 | node scripts/bump-year.js 12 | 13 | And after reviewing the changes: 14 | git commit -am "Internal: Bumped the year." && git push 15 | 16 | */ 17 | 18 | import { bumpYear } from '@ckeditor/ckeditor5-dev-bump-year'; 19 | 20 | bumpYear( { 21 | cwd: process.cwd(), 22 | globPatterns: [ 23 | { // LICENSE.md, .eslintrc.js, etc. 24 | pattern: '*', 25 | options: { 26 | dot: true 27 | } 28 | }, 29 | { 30 | pattern: '!(coverage|.nyc_output|dist|demo-*)/**' 31 | }, 32 | { 33 | pattern: '.husky/*' 34 | } 35 | ] 36 | } ); 37 | -------------------------------------------------------------------------------- /scripts/ci/is-project-ready-to-release.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 5 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import * as releaseTools from '@ckeditor/ckeditor5-dev-release-tools'; 11 | import pkg from '../../package.json' with { type: 'json' }; 12 | 13 | const changelogVersion = releaseTools.getLastFromChangelog(); 14 | const npmTag = releaseTools.getNpmTagFromVersion( changelogVersion ); 15 | 16 | releaseTools.isVersionPublishableForTag( pkg.name, changelogVersion, npmTag ) 17 | .then( result => { 18 | if ( !result ) { 19 | console.error( `The proposed changelog (${ changelogVersion }) version is not higher than the already published one.` ); 20 | process.exit( 1 ); 21 | } else { 22 | console.log( 'The project is ready to release.' ); 23 | } 24 | } ); 25 | -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import upath from 'upath'; 7 | import { existsSync } from 'fs'; 8 | import { ROOT_DIRECTORY } from './utils/constants.js'; 9 | 10 | main() 11 | .catch( err => { 12 | console.error( err ); 13 | } ); 14 | 15 | async function main() { 16 | // When installing a repository as a dependency, the `.git` directory does not exist. 17 | // In such a case, husky should not attach its hooks as npm treats it as a package, not a git repository. 18 | if ( existsSync( upath.join( ROOT_DIRECTORY, '.git' ) ) ) { 19 | const { default: husky } = await import( 'husky' ); 20 | 21 | husky(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /scripts/preparechangelog.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { generateChangelogForSingleRepository } from '@ckeditor/ckeditor5-dev-changelog'; 7 | import { ROOT_DIRECTORY } from './utils/constants.js'; 8 | import parseArguments from './utils/parsearguments.js'; 9 | 10 | const cliOptions = parseArguments( process.argv.slice( 2 ) ); 11 | 12 | const changelogOptions = { 13 | cwd: ROOT_DIRECTORY, 14 | disableFilesystemOperations: cliOptions.dryRun 15 | }; 16 | 17 | if ( cliOptions.date ) { 18 | changelogOptions.date = cliOptions.date; 19 | } 20 | 21 | generateChangelogForSingleRepository( changelogOptions ) 22 | .then( maybeChangelog => { 23 | if ( maybeChangelog ) { 24 | console.log( maybeChangelog ); 25 | } 26 | } ); 27 | -------------------------------------------------------------------------------- /scripts/preparepackages.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 5 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 6 | */ 7 | 8 | import { Listr } from 'listr2'; 9 | import * as releaseTools from '@ckeditor/ckeditor5-dev-release-tools'; 10 | import * as devUtils from '@ckeditor/ckeditor5-dev-utils'; 11 | import parseArguments from './utils/parsearguments.js'; 12 | import getListrOptions from './utils/getlistroptions.js'; 13 | import { preparePackageJson } from './utils/preparepackagejson.js'; 14 | 15 | const latestVersion = releaseTools.getLastFromChangelog(); 16 | const versionChangelog = releaseTools.getChangesForVersion( latestVersion ); 17 | const cliArguments = parseArguments( process.argv.slice( 2 ) ); 18 | 19 | const tasks = new Listr( [ 20 | { 21 | title: 'Verifying the repository.', 22 | task: async () => { 23 | const errors = await releaseTools.validateRepositoryToRelease( { 24 | version: latestVersion, 25 | changes: versionChangelog, 26 | branch: cliArguments.branch 27 | } ); 28 | 29 | if ( !errors.length ) { 30 | return; 31 | } 32 | 33 | return Promise.reject( 'Aborted due to errors.\n' + errors.map( message => `* ${ message }` ).join( '\n' ) ); 34 | }, 35 | skip: () => { 36 | // When compiling the packages only, do not validate the release. 37 | if ( cliArguments.compileOnly ) { 38 | return true; 39 | } 40 | 41 | return false; 42 | } 43 | }, 44 | { 45 | title: 'Updating the `#version` field.', 46 | task: () => { 47 | return releaseTools.updateVersions( { 48 | version: latestVersion 49 | } ); 50 | }, 51 | skip: () => { 52 | // When compiling the packages only, do not update any values. 53 | if ( cliArguments.compileOnly ) { 54 | return true; 55 | } 56 | 57 | return false; 58 | } 59 | }, 60 | { 61 | title: 'Running build command.', 62 | task: () => { 63 | return devUtils.tools.shExec( 'pnpm run build', { async: true, verbosity: 'silent' } ); 64 | } 65 | }, 66 | { 67 | title: 'Creating the `ckeditor5-react` package in the release directory.', 68 | task: async () => { 69 | return releaseTools.prepareRepository( { 70 | outputDirectory: 'release', 71 | rootPackageJson: await preparePackageJson() 72 | } ); 73 | } 74 | }, 75 | { 76 | title: 'Cleaning-up.', 77 | task: () => { 78 | return releaseTools.cleanUpPackages( { 79 | packagesDirectory: 'release' 80 | } ); 81 | } 82 | }, 83 | { 84 | title: 'Commit & tag.', 85 | task: () => { 86 | return releaseTools.commitAndTag( { 87 | version: latestVersion, 88 | files: [ 89 | 'package.json' 90 | ] 91 | } ); 92 | }, 93 | skip: () => { 94 | // When compiling the packages only, do not update any values. 95 | if ( cliArguments.compileOnly ) { 96 | return true; 97 | } 98 | 99 | return false; 100 | } 101 | } 102 | ], getListrOptions( cliArguments ) ); 103 | 104 | tasks.run() 105 | .catch( err => { 106 | process.exitCode = 1; 107 | 108 | console.log( '' ); 109 | console.error( err ); 110 | } ); 111 | -------------------------------------------------------------------------------- /scripts/publishpackages.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 5 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 6 | */ 7 | 8 | import { Listr } from 'listr2'; 9 | import * as releaseTools from '@ckeditor/ckeditor5-dev-release-tools'; 10 | import parseArguments from './utils/parsearguments.js'; 11 | import getListrOptions from './utils/getlistroptions.js'; 12 | 13 | const cliArguments = parseArguments( process.argv.slice( 2 ) ); 14 | const latestVersion = releaseTools.getLastFromChangelog(); 15 | const versionChangelog = releaseTools.getChangesForVersion( latestVersion ); 16 | 17 | let githubToken; 18 | 19 | if ( !cliArguments.npmTag ) { 20 | cliArguments.npmTag = releaseTools.getNpmTagFromVersion( latestVersion ); 21 | } 22 | 23 | const tasks = new Listr( [ 24 | { 25 | title: 'Publishing packages.', 26 | task: async ( _, task ) => { 27 | return releaseTools.publishPackages( { 28 | packagesDirectory: 'release', 29 | npmOwner: 'ckeditor', 30 | npmTag: cliArguments.npmTag, 31 | listrTask: task, 32 | confirmationCallback: () => { 33 | if ( cliArguments.ci ) { 34 | return true; 35 | } 36 | 37 | return task.prompt( { type: 'Confirm', message: 'Do you want to continue?' } ); 38 | } 39 | } ); 40 | } 41 | }, 42 | { 43 | title: 'Pushing changes.', 44 | task: () => { 45 | return releaseTools.push( { 46 | releaseBranch: cliArguments.branch, 47 | version: latestVersion 48 | } ); 49 | } 50 | }, 51 | { 52 | title: 'Creating the release page.', 53 | task: async ( _, task ) => { 54 | const releaseUrl = await releaseTools.createGithubRelease( { 55 | token: githubToken, 56 | version: latestVersion, 57 | description: versionChangelog 58 | } ); 59 | 60 | task.output = `Release page: ${ releaseUrl }`; 61 | }, 62 | options: { 63 | persistentOutput: true 64 | } 65 | } 66 | ], getListrOptions( cliArguments ) ); 67 | 68 | ( async () => { 69 | try { 70 | if ( process.env.CKE5_RELEASE_TOKEN ) { 71 | githubToken = process.env.CKE5_RELEASE_TOKEN; 72 | } else { 73 | githubToken = await releaseTools.provideToken(); 74 | } 75 | 76 | await tasks.run(); 77 | } catch ( err ) { 78 | process.exitCode = 1; 79 | 80 | console.error( err ); 81 | } 82 | } )(); 83 | -------------------------------------------------------------------------------- /scripts/utils/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { fileURLToPath } from 'url'; 7 | import upath from 'upath'; 8 | 9 | const __filename = fileURLToPath( import.meta.url ); 10 | const __dirname = upath.dirname( __filename ); 11 | 12 | export const RELEASE_DIRECTORY = 'release'; 13 | export const ROOT_DIRECTORY = upath.join( __dirname, '..', '..' ); 14 | -------------------------------------------------------------------------------- /scripts/utils/getlistroptions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | /** 7 | * @param {ReleaseOptions} cliArguments 8 | * @returns {Object} 9 | */ 10 | export default function getListrOptions( cliArguments ) { 11 | return { 12 | renderer: cliArguments.verbose ? 'verbose' : 'default' 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /scripts/utils/parsearguments.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import minimist from 'minimist'; 7 | 8 | /** 9 | * @param {Array.} cliArguments 10 | * @returns {ReleaseOptions} options 11 | */ 12 | export default function parseArguments( cliArguments ) { 13 | const config = { 14 | boolean: [ 15 | 'verbose', 16 | 'compile-only', 17 | 'ci', 18 | 'dry-run' 19 | ], 20 | 21 | string: [ 22 | 'branch', 23 | 'from', 24 | 'npm-tag', 25 | 'date' 26 | ], 27 | 28 | default: { 29 | branch: 'master', 30 | ci: false, 31 | 'compile-only': false, 32 | 'npm-tag': null, 33 | verbose: false 34 | } 35 | }; 36 | 37 | const options = minimist( cliArguments, config ); 38 | 39 | replaceKebabCaseWithCamelCase( options, [ 40 | 'npm-tag', 41 | 'compile-only', 42 | 'dry-run' 43 | ] ); 44 | 45 | if ( process.env.CI ) { 46 | options.ci = true; 47 | } 48 | 49 | return options; 50 | } 51 | 52 | function replaceKebabCaseWithCamelCase( options, keys ) { 53 | for ( const key of keys ) { 54 | const camelCaseKey = toCamelCase( key ); 55 | 56 | options[ camelCaseKey ] = options[ key ]; 57 | delete options[ key ]; 58 | } 59 | } 60 | 61 | function toCamelCase( value ) { 62 | return value.split( '-' ) 63 | .map( ( item, index ) => { 64 | if ( index == 0 ) { 65 | return item.toLowerCase(); 66 | } 67 | 68 | return item.charAt( 0 ).toUpperCase() + item.slice( 1 ).toLowerCase(); 69 | } ) 70 | .join( '' ); 71 | } 72 | 73 | /** 74 | * @typedef {Object} ReleaseOptions 75 | * 76 | * @property {String} [branch='master'] 77 | * 78 | * @property {String|null} [npmTag=null] 79 | * 80 | * @property {Boolean} [compileOnly=false] 81 | * 82 | * @property {Boolean} [verbose=false] 83 | * 84 | * @property {Boolean} [ci=false] 85 | * 86 | * @property {Boolean} [dryRun=false] 87 | * 88 | * @property {String} [date] 89 | */ 90 | -------------------------------------------------------------------------------- /scripts/utils/preparepackagejson.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | export async function preparePackageJson() { 7 | const { default: packageJson } = await import( '../../package.json', { with: { type: 'json' } } ); 8 | 9 | if ( packageJson.engines ) { 10 | delete packageJson.engines; 11 | } 12 | 13 | return packageJson; 14 | } 15 | -------------------------------------------------------------------------------- /src/cloud/useCKEditorCloud.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { 7 | loadCKEditorCloud, 8 | type CKEditorCloudConfig, 9 | type CKEditorCloudResult 10 | } from '@ckeditor/ckeditor5-integrations-common'; 11 | 12 | import { useAsyncValue, type AsyncValueHookResult } from '../hooks/useAsyncValue.js'; 13 | 14 | /** 15 | * Hook that loads CKEditor bundles from CDN. 16 | * 17 | * @template Config The type of the CKEditor Cloud configuration. 18 | * @param config The configuration of the hook. 19 | * @returns The state of async operation that resolves to the CKEditor bundles. 20 | * @example 21 | * 22 | * ```ts 23 | * const cloud = useCKEditorCloud( { 24 | * version: '42.0.0', 25 | * translations: [ 'es', 'de' ], 26 | * premium: true 27 | * } ); 28 | * 29 | * if ( cloud.status === 'success' ) { 30 | * const { ClassicEditor, Bold, Essentials } = cloud.CKEditor; 31 | * const { SlashCommand } = cloud.CKEditorPremiumFeatures; 32 | * } 33 | * ``` 34 | */ 35 | export default function useCKEditorCloud( 36 | config: Config 37 | ): CKEditorCloudHookResult { 38 | // Serialize the config to a string to fast compare if there was a change and re-render is needed. 39 | const serializedConfigKey = JSON.stringify( config ); 40 | 41 | // Fetch the CKEditor Cloud Services bundles on every modification of config. 42 | const result = useAsyncValue( 43 | async (): Promise> => loadCKEditorCloud( config ), 44 | [ serializedConfigKey ] 45 | ); 46 | 47 | // Expose a bit better API for the hook consumers, so they don't need to access the constructor through the `data` property. 48 | if ( result.status === 'success' ) { 49 | return { 50 | ...result.data, 51 | status: 'success' 52 | }; 53 | } 54 | 55 | return result; 56 | } 57 | 58 | /** 59 | * The result of the `useCKEditorCloud` hook. It changes success state to be more intuitive. 60 | */ 61 | type CKEditorCloudHookResult = 62 | | Exclude>, { status: 'success' }> 63 | | ( CKEditorCloudResult & { status: 'success' } ); 64 | -------------------------------------------------------------------------------- /src/cloud/withCKEditorCloud.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { type ReactNode, type ComponentType } from 'react'; 7 | import type { 8 | CKEditorCloudConfig, 9 | CKEditorCloudResult 10 | } from '@ckeditor/ckeditor5-integrations-common'; 11 | 12 | import useCKEditorCloud from './useCKEditorCloud.js'; 13 | 14 | /** 15 | * HOC that injects the CKEditor Cloud integration into a component. 16 | * 17 | * @template A The type of the additional resources to load. 18 | * @param config The configuration of the CKEditor Cloud integration. 19 | * @returns A function that injects the CKEditor Cloud integration into a component. 20 | * @example 21 | 22 | * ```tsx 23 | * const withCKCloud = withCKEditorCloud( { 24 | * cloud: { 25 | * version: '42.0.0', 26 | * translations: [ 'es', 'de' ], 27 | * premium: true 28 | * } 29 | * } ); 30 | * 31 | * const MyComponent = withCKCloud( ( { cloud } ) => { 32 | * const { Paragraph } = cloud.CKEditor; 33 | * const { SlashCommands } = cloud.CKEditorPremiumFeatures; 34 | * const { YourPlugin } = cloud.CKPlugins; 35 | * 36 | * return
CKEditor Cloud is loaded!
; 37 | * } ); 38 | * ``` 39 | */ 40 | const withCKEditorCloud = ( config: CKEditorCloudHocConfig ) => 41 |

( 42 | WrappedComponent: ComponentType & P> 43 | ): ComponentType>> => { 44 | const ComponentWithCKEditorCloud = ( props: Omit> ) => { 45 | const ckeditorCloudResult = useCKEditorCloud( config.cloud ); 46 | 47 | switch ( ckeditorCloudResult.status ) { 48 | // An error occurred while fetching the cloud information. 49 | case 'error': 50 | if ( !config.renderError ) { 51 | return 'Unable to load CKEditor Cloud data!'; 52 | } 53 | 54 | return config.renderError( ckeditorCloudResult.error ); 55 | 56 | // The cloud information has been fetched successfully. 57 | case 'success': 58 | return ; 59 | 60 | // The cloud information is being fetched. 61 | default: 62 | return config.renderLoader?.() ?? null; 63 | } 64 | }; 65 | 66 | ComponentWithCKEditorCloud.displayName = 'ComponentWithCKEditorCloud'; 67 | 68 | return ComponentWithCKEditorCloud; 69 | }; 70 | 71 | export default withCKEditorCloud; 72 | 73 | /** 74 | * Props injected by the `withCKEditorCloud` HOC. 75 | * 76 | * @template Config The configuration of the CKEditor Cloud integration. 77 | */ 78 | export type WithCKEditorCloudHocProps = { 79 | 80 | /** 81 | * The result of the CKEditor Cloud integration. 82 | */ 83 | cloud: CKEditorCloudResult; 84 | }; 85 | 86 | /** 87 | * The configuration of the CKEditor Cloud integration. 88 | * 89 | * @template Config The configuration of the CKEditor Cloud integration. 90 | */ 91 | type CKEditorCloudHocConfig = { 92 | 93 | /** 94 | * The configuration of the CKEditor Cloud integration. 95 | */ 96 | cloud: Config; 97 | 98 | /** 99 | * Component to render while the cloud information is being fetched. 100 | */ 101 | renderLoader?: () => ReactNode; 102 | 103 | /** 104 | * Component to render when an error occurs while fetching the cloud information. 105 | */ 106 | renderError?: ( error: any ) => ReactNode; 107 | }; 108 | -------------------------------------------------------------------------------- /src/context/setCKEditorReactContextMetadata.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { Config, EditorConfig } from 'ckeditor5'; 7 | 8 | /** 9 | * The symbol cannot be used as a key because config getters require strings as keys. 10 | */ 11 | const ReactContextMetadataKey = '$__CKEditorReactContextMetadata'; 12 | 13 | /** 14 | * Sets the metadata in the object. 15 | * 16 | * @param metadata The metadata to set. 17 | * @param object The object to set the metadata in. 18 | * @returns The object with the metadata set. 19 | */ 20 | export function withCKEditorReactContextMetadata( 21 | metadata: CKEditorConfigContextMetadata, 22 | config: EditorConfig 23 | ): EditorConfig & { [ ReactContextMetadataKey ]: CKEditorConfigContextMetadata } { 24 | return { 25 | ...config, 26 | [ ReactContextMetadataKey ]: metadata 27 | }; 28 | } 29 | 30 | /** 31 | * Tries to extract the metadata from the object. 32 | * 33 | * @param object The object to extract the metadata from. 34 | */ 35 | export function tryExtractCKEditorReactContextMetadata( object: Config ): CKEditorConfigContextMetadata | null { 36 | return object.get( ReactContextMetadataKey ); 37 | } 38 | 39 | /** 40 | * The metadata that is stored in the React context. 41 | */ 42 | export type CKEditorConfigContextMetadata = { 43 | 44 | /** 45 | * The name of the editor in the React context. It'll be later used in the `useInitializedCKEditorsMap` hook 46 | * to track the editor initialization and destruction events. 47 | */ 48 | name?: string; 49 | 50 | /** 51 | * Any additional metadata that can be stored in the context. 52 | */ 53 | [x: string | number | symbol]: unknown; 54 | }; 55 | -------------------------------------------------------------------------------- /src/context/useInitializedCKEditorsMap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { useEffect } from 'react'; 7 | import { useRefSafeCallback } from '../hooks/useRefSafeCallback.js'; 8 | 9 | import type { CollectionAddEvent, Context, ContextWatchdog, Editor, GetCallback } from 'ckeditor5'; 10 | import type { ContextWatchdogValue } from './ckeditorcontext.js'; 11 | 12 | import { 13 | tryExtractCKEditorReactContextMetadata, 14 | type CKEditorConfigContextMetadata 15 | } from './setCKEditorReactContextMetadata.js'; 16 | 17 | /** 18 | * A hook that listens for the editor initialization and destruction events and updates the editors map. 19 | * 20 | * @param config The configuration of the hook. 21 | * @param config.currentContextWatchdog The current context watchdog value. 22 | * @param config.onChangeInitializedEditors The function that updates the editors map. 23 | * @example 24 | * ```ts 25 | * useInitializedCKEditorsMap( { 26 | * currentContextWatchdog, 27 | * onChangeInitializedEditors: ( editors, context ) => { 28 | * console.log( 'Editors:', editors ); 29 | * } 30 | * } ); 31 | * ``` 32 | */ 33 | export const useInitializedCKEditorsMap = ( 34 | { 35 | currentContextWatchdog, 36 | onChangeInitializedEditors 37 | }: InitializedContextEditorsConfig 38 | ): void => { 39 | // We need to use the safe callback to prevent the stale closure problem. 40 | const onChangeInitializedEditorsSafe = useRefSafeCallback( onChangeInitializedEditors || ( () => {} ) ); 41 | 42 | useEffect( () => { 43 | if ( currentContextWatchdog.status !== 'initialized' ) { 44 | return; 45 | } 46 | 47 | const { watchdog } = currentContextWatchdog; 48 | const editors = watchdog?.context?.editors; 49 | 50 | if ( !editors ) { 51 | return; 52 | } 53 | 54 | // Get the initialized editors from 55 | const getInitializedContextEditors = () => [ ...editors ].reduce( 56 | ( map, editor ) => { 57 | if ( editor.state !== 'ready' ) { 58 | return map; 59 | } 60 | 61 | const metadata = tryExtractCKEditorReactContextMetadata( editor.config ); 62 | const nameOrId = metadata?.name ?? editor.id; 63 | 64 | map[ nameOrId ] = { 65 | instance: editor, 66 | metadata 67 | }; 68 | 69 | return map; 70 | }, 71 | Object.create( {} ) // Prevent the prototype pollution. 72 | ); 73 | 74 | // The function that is called when the editor status changes. 75 | const onEditorStatusChange = () => { 76 | onChangeInitializedEditorsSafe( 77 | getInitializedContextEditors(), 78 | watchdog 79 | ); 80 | }; 81 | 82 | // Add the existing editors to the map. 83 | const trackEditorLifecycle = ( editor: Editor ) => { 84 | editor.once( 'ready', onEditorStatusChange, { priority: 'lowest' } ); 85 | editor.once( 'destroy', onEditorStatusChange, { priority: 'lowest' } ); 86 | }; 87 | 88 | const onAddEditorToCollection: GetCallback> = ( _, editor ) => { 89 | trackEditorLifecycle( editor ); 90 | }; 91 | 92 | editors.forEach( trackEditorLifecycle ); 93 | editors.on>( 'add', onAddEditorToCollection ); 94 | 95 | // Fire the initial change event if there is at least one editor ready, otherwise wait for the first ready editor. 96 | if ( Array.from( editors ).some( editor => editor.state === 'ready' ) ) { 97 | onEditorStatusChange(); 98 | } 99 | 100 | return () => { 101 | editors.off( 'add', onAddEditorToCollection ); 102 | }; 103 | }, [ currentContextWatchdog ] ); 104 | }; 105 | 106 | /** 107 | * A map of initialized editors. 108 | */ 109 | type InitializedEditorsMap = Record; 113 | 114 | /** 115 | * The configuration of the `useInitializedCKEditorsMap` hook. 116 | */ 117 | export type InitializedContextEditorsConfig = { 118 | 119 | /** 120 | * The current context watchdog value. 121 | */ 122 | currentContextWatchdog: ContextWatchdogValue; 123 | 124 | /** 125 | * The callback called when the editors map changes. 126 | */ 127 | onChangeInitializedEditors?: ( 128 | editors: InitializedEditorsMap, 129 | watchdog: ContextWatchdog 130 | ) => void; 131 | }; 132 | -------------------------------------------------------------------------------- /src/hooks/useAsyncCallback.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { useState, useRef } from 'react'; 7 | import { uid, isSSR } from '@ckeditor/ckeditor5-integrations-common'; 8 | 9 | import { useIsUnmountedRef } from './useIsUnmountedRef.js'; 10 | import { useRefSafeCallback } from './useRefSafeCallback.js'; 11 | 12 | /** 13 | * A hook that allows to execute an asynchronous function and provides the state of the execution. 14 | * 15 | * @param callback The asynchronous function to be executed. 16 | * @returns A tuple with the function that triggers the execution and the state of the execution. 17 | * 18 | * @example 19 | * ```tsx 20 | * const [ onFetchData, fetchDataStatus ] = useAsyncCallback( async () => { 21 | * const response = await fetch( 'https://api.example.com/data' ); 22 | * const data = await response.json(); 23 | * return data; 24 | * } ); 25 | * 26 | * return ( 27 | *

28 | * 29 | * { fetchDataStatus.status === 'loading' &&

Loading...

} 30 | * { fetchDataStatus.status === 'success' &&
{ JSON.stringify( fetchDataStatus.data, null, 2 ) }
} 31 | * { fetchDataStatus.status === 'error' &&

Error: { fetchDataStatus.error.message }

} 32 | *
33 | * ); 34 | * ``` 35 | */ 36 | export const useAsyncCallback = , R>( 37 | callback: ( ...args: A ) => Promise 38 | ): AsyncCallbackHookResult => { 39 | // The state of the asynchronous callback. 40 | const [ asyncState, setAsyncState ] = useState>( { 41 | status: 'idle' 42 | } ); 43 | 44 | // A reference to the mounted state of the component. 45 | const unmountedRef = useIsUnmountedRef(); 46 | 47 | // A reference to the previous execution UUID. It is used to prevent race conditions between multiple executions 48 | // of the asynchronous function. If the UUID of the current execution is different than the UUID of the previous 49 | // execution, the state is not updated. 50 | const prevExecutionUIDRef = useRef( null ); 51 | 52 | // The asynchronous executor function, which is a wrapped version of the original callback. 53 | const asyncExecutor = useRefSafeCallback( async ( ...args: A ) => { 54 | if ( unmountedRef.current || isSSR() ) { 55 | return null; 56 | } 57 | 58 | const currentExecutionUUID = uid(); 59 | prevExecutionUIDRef.current = currentExecutionUUID; 60 | 61 | try { 62 | // Prevent unnecessary state updates, keep loading state if the status is already 'loading'. 63 | if ( asyncState.status !== 'loading' ) { 64 | setAsyncState( { 65 | status: 'loading' 66 | } ); 67 | } 68 | 69 | // Execute the asynchronous function. 70 | const result = await callback( ...args ); 71 | 72 | // Update the state if the component is still mounted and the execution UUID matches the previous one, otherwise 73 | // ignore the result and keep the previous state. 74 | if ( !unmountedRef.current && prevExecutionUIDRef.current === currentExecutionUUID ) { 75 | setAsyncState( { 76 | status: 'success', 77 | data: result 78 | } ); 79 | } 80 | 81 | return result; 82 | } catch ( error: any ) { 83 | console.error( error ); 84 | 85 | // Update the state if the component is still mounted and the execution UUID matches the previous one, otherwise 86 | if ( !unmountedRef.current && prevExecutionUIDRef.current === currentExecutionUUID ) { 87 | setAsyncState( { 88 | status: 'error', 89 | error 90 | } ); 91 | } 92 | } 93 | 94 | return null; 95 | } ); 96 | 97 | return [ asyncExecutor, asyncState ] as AsyncCallbackHookResult; 98 | }; 99 | 100 | /** 101 | * Represents the result of the `useAsyncCallback` hook. 102 | */ 103 | export type AsyncCallbackHookResult, R> = [ 104 | ( ...args: A ) => Promise, 105 | AsyncCallbackState 106 | ]; 107 | 108 | /** 109 | * Represents the state of an asynchronous callback. 110 | */ 111 | export type AsyncCallbackState = 112 | | { 113 | status: 'idle'; 114 | } 115 | | { 116 | status: 'loading'; 117 | } 118 | | { 119 | status: 'success'; 120 | data: T; 121 | } 122 | | { 123 | status: 'error'; 124 | error: any; 125 | }; 126 | -------------------------------------------------------------------------------- /src/hooks/useAsyncValue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { DependencyList } from 'react'; 7 | 8 | import { useAsyncCallback, type AsyncCallbackState } from './useAsyncCallback.js'; 9 | import { useInstantEffect } from './useInstantEffect.js'; 10 | 11 | /** 12 | * A hook that allows to execute an asynchronous function and provides the state of the execution. 13 | * The asynchronous function is executed immediately after the component is mounted. 14 | * 15 | * @param callback The asynchronous function to be executed. 16 | * @param deps The dependency list. 17 | * @returns The state of the execution. 18 | * 19 | * @example 20 | * ```tsx 21 | * const asyncFetchState = useAsyncValue( async () => { 22 | * const response = await fetch( 'https://api.example.com/data' ); 23 | * const data = await response.json(); 24 | * return data; 25 | * }, [] ); 26 | * 27 | * if ( asyncFetchState.status === 'loading' ) { 28 | * return

Loading...

; 29 | * } 30 | * 31 | * if ( asyncFetchState.status === 'success' ) { 32 | * return
{ JSON.stringify( asyncFetchState.data, null, 2 ) }
; 33 | * } 34 | * 35 | * if ( asyncFetchState.status === 'error' ) { 36 | * return

Error: { asyncFetchState.error.message }

; 37 | * } 38 | * ``` 39 | */ 40 | export const useAsyncValue = ( 41 | callback: () => Promise, 42 | deps: DependencyList 43 | ): AsyncValueHookResult => { 44 | const [ asyncCallback, asyncState ] = useAsyncCallback( callback ); 45 | 46 | useInstantEffect( asyncCallback, deps ); 47 | 48 | // There might be short delay between the effect and the state update. 49 | // So it is possible that the status is still 'idle' after the effect. 50 | // In such case, we should return 'loading' status because the effect is already queued to be executed. 51 | if ( asyncState.status === 'idle' ) { 52 | return { 53 | status: 'loading' 54 | }; 55 | } 56 | 57 | return asyncState; 58 | }; 59 | 60 | /** 61 | * The result of the `useAsyncValue` hook. 62 | */ 63 | export type AsyncValueHookResult = Exclude, { status: 'idle' }>; 64 | -------------------------------------------------------------------------------- /src/hooks/useInstantEditorEffect.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { DependencyList } from 'react'; 7 | import type { LifeCycleElementSemaphore } from '../lifecycle/LifeCycleElementSemaphore.js'; 8 | 9 | import { useInstantEffect } from './useInstantEffect.js'; 10 | 11 | /** 12 | * `useEffect` alternative but executed after mounting of editor. 13 | */ 14 | export const useInstantEditorEffect = ( 15 | semaphore: Pick, 'runAfterMount'> | null, 16 | fn: ( mountResult: R ) => void, 17 | deps: DependencyList 18 | ): void => { 19 | useInstantEffect( () => { 20 | if ( semaphore ) { 21 | semaphore.runAfterMount( fn ); 22 | } 23 | }, [ semaphore, ...deps ] ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/hooks/useInstantEffect.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { useState, type DependencyList } from 'react'; 7 | import { shallowCompareArrays } from '@ckeditor/ckeditor5-integrations-common'; 8 | 9 | /** 10 | * Triggers an effect immediately if the dependencies change (during rendering of component). 11 | * 12 | * @param fn The effect function to execute. 13 | * @param deps The dependency list. 14 | */ 15 | export const useInstantEffect = ( fn: VoidFunction, deps: DependencyList ): void => { 16 | const [ prevDeps, setDeps ] = useState( null ); 17 | 18 | if ( !shallowCompareArrays( prevDeps, deps ) ) { 19 | fn(); 20 | setDeps( [ ...deps ] ); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/hooks/useIsMountedRef.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { useEffect, useRef, type MutableRefObject } from 'react'; 7 | 8 | /** 9 | * Custom hook that returns a mutable ref object indicating whether the component is mounted or not. 10 | * 11 | * @returns The mutable ref object. 12 | */ 13 | export const useIsMountedRef = (): MutableRefObject => { 14 | const mountedRef = useRef( false ); 15 | 16 | useEffect( () => { 17 | mountedRef.current = true; 18 | 19 | return () => { 20 | mountedRef.current = false; 21 | }; 22 | }, [] ); 23 | 24 | return mountedRef; 25 | }; 26 | -------------------------------------------------------------------------------- /src/hooks/useIsUnmountedRef.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { useEffect, useRef, type MutableRefObject } from 'react'; 7 | 8 | /** 9 | * Custom hook that returns a mutable ref object indicating whether the component is unmounted or not. 10 | * 11 | * @returns The mutable ref object. 12 | */ 13 | export const useIsUnmountedRef = (): MutableRefObject => { 14 | const mountedRef = useRef( false ); 15 | 16 | useEffect( () => { 17 | // Prevent issues in strict mode. 18 | mountedRef.current = false; 19 | 20 | return () => { 21 | mountedRef.current = true; 22 | }; 23 | }, [] ); 24 | 25 | return mountedRef; 26 | }; 27 | -------------------------------------------------------------------------------- /src/hooks/useRefSafeCallback.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { useCallback, useRef } from 'react'; 7 | 8 | /** 9 | * Hook that guarantees that returns constant reference for passed function. 10 | * Useful for preventing closures from capturing cached scope variables (avoiding the stale closure problem). 11 | */ 12 | export const useRefSafeCallback =
, R>( fn: ( ...args: A ) => R ): typeof fn => { 13 | const callbackRef = useRef(); 14 | callbackRef.current = fn; 15 | 16 | return useCallback( 17 | ( ...args: A ): R => ( callbackRef.current as typeof fn )( ...args ), 18 | [] 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | export { default as CKEditor } from './ckeditor.js'; 7 | export { default as CKEditorContext } from './context/ckeditorcontext.js'; 8 | export { default as useMultiRootEditor, type MultiRootHookProps, type MultiRootHookReturns } from './useMultiRootEditor.js'; 9 | 10 | export { default as useCKEditorCloud } from './cloud/useCKEditorCloud.js'; 11 | export { 12 | default as withCKEditorCloud, 13 | type WithCKEditorCloudHocProps 14 | } from './cloud/withCKEditorCloud.js'; 15 | 16 | export { 17 | loadCKEditorCloud, 18 | type CKEditorCloudResult, 19 | type CKEditorCloudConfig 20 | } from '@ckeditor/ckeditor5-integrations-common'; 21 | -------------------------------------------------------------------------------- /src/lifecycle/LifeCycleEditorSemaphore.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { Editor, EditorWatchdog } from 'ckeditor5'; 7 | 8 | import type { EditorWatchdogAdapter } from '../ckeditor.js'; 9 | import type { LifeCycleElementSemaphore } from './LifeCycleElementSemaphore.js'; 10 | 11 | export type EditorSemaphoreMountResult = { 12 | 13 | /** 14 | * Holds the instance of the editor if `props.disableWatchdog` is set to true. 15 | */ 16 | instance: TEditor; 17 | 18 | /** 19 | * An instance of EditorWatchdog or an instance of EditorWatchdog-like adapter for ContextWatchdog. 20 | * It holds the instance of the editor under `this.watchdog.editor` if `props.disableWatchdog` is set to false. 21 | */ 22 | watchdog: EditorWatchdog | EditorWatchdogAdapter | null; 23 | }; 24 | 25 | export type LifeCycleEditorSemaphore = LifeCycleElementSemaphore< 26 | EditorSemaphoreMountResult 27 | >; 28 | -------------------------------------------------------------------------------- /src/lifecycle/useLifeCycleSemaphoreSyncRef.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { useRef, useState, type RefObject } from 'react'; 7 | import type { LifeCycleElementSemaphore, LifeCycleAfterMountCallback } from './LifeCycleElementSemaphore.js'; 8 | 9 | /** 10 | * When using the `useState` approach, a new instance of the semaphore must be set based on the previous 11 | * one within the `setState` callback, as shown in this example: 12 | * 13 | * setState( prevSemaphore => ... ) 14 | * 15 | * The issue arises from the uncertainty of whether React has batched and cancelled some `setState` calls. 16 | * This means that setting the state with a semaphore three times might result in the collapsing of these three calls into a single one. 17 | * 18 | * Although this may not seem like a significant issue in theory, it can lead to a multitude of minor issues in practice that may 19 | * generate race conditions. This is because semaphores handle batching independently. 20 | * 21 | * A solution involving refs is safer in terms of preserving object references. In other words, `semaphoreRef.current` is guaranteed to 22 | * always point to the most recent instance of the semaphore. 23 | */ 24 | export const useLifeCycleSemaphoreSyncRef = (): LifeCycleSemaphoreSyncRefResult => { 25 | const semaphoreRef = useRef | null>( null ); 26 | const [ revision, setRevision ] = useState( () => Date.now() ); 27 | 28 | const refresh = () => { 29 | setRevision( Date.now() ); 30 | }; 31 | 32 | const release = ( rerender: boolean = true ) => { 33 | if ( semaphoreRef.current ) { 34 | semaphoreRef.current.release(); 35 | semaphoreRef.current = null; 36 | } 37 | 38 | if ( rerender ) { 39 | setRevision( Date.now() ); 40 | } 41 | }; 42 | 43 | const unsafeSetValue = ( value: R ) => { 44 | semaphoreRef.current?.unsafeSetValue( value ); 45 | refresh(); 46 | }; 47 | 48 | const runAfterMount = ( callback: LifeCycleAfterMountCallback ) => { 49 | if ( semaphoreRef.current ) { 50 | semaphoreRef.current.runAfterMount( callback ); 51 | } 52 | }; 53 | 54 | const replace = ( newSemaphore: () => LifeCycleElementSemaphore ) => { 55 | release( false ); 56 | semaphoreRef.current = newSemaphore(); 57 | 58 | refresh(); 59 | runAfterMount( refresh ); 60 | }; 61 | 62 | const createAttributeRef = ( key: K ): RefObject => ( { 63 | get current() { 64 | if ( !semaphoreRef.current || !semaphoreRef.current.value ) { 65 | return null; 66 | } 67 | 68 | return semaphoreRef.current.value[ key ]; 69 | } 70 | } ); 71 | 72 | return { 73 | get current() { 74 | return semaphoreRef.current; 75 | }, 76 | revision, 77 | createAttributeRef, 78 | unsafeSetValue, 79 | release, 80 | replace, 81 | runAfterMount 82 | }; 83 | }; 84 | 85 | export type LifeCycleSemaphoreSyncRefResult = RefObject> & { 86 | revision: number; 87 | unsafeSetValue: ( value: R ) => void; 88 | runAfterMount: ( callback: LifeCycleAfterMountCallback ) => void; 89 | release: ( rerender?: boolean ) => void; 90 | replace: ( newSemaphore: () => LifeCycleElementSemaphore ) => void; 91 | createAttributeRef: ( key: K ) => RefObject; 92 | }; 93 | -------------------------------------------------------------------------------- /src/plugins/ReactIntegrationUsageDataPlugin.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React from 'react'; 7 | import { createIntegrationUsageDataPlugin } from '@ckeditor/ckeditor5-integrations-common'; 8 | 9 | /** 10 | * This part of the code is not executed in open-source implementations using a GPL key. 11 | * It only runs when a specific license key is provided. If you are uncertain whether 12 | * this applies to your installation, please contact our support team. 13 | */ 14 | export const ReactIntegrationUsageDataPlugin = createIntegrationUsageDataPlugin( 15 | 'react', 16 | { 17 | version: __REACT_INTEGRATION_VERSION__, 18 | frameworkVersion: React.version 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /src/plugins/appendAllIntegrationPluginsToConfig.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { appendExtraPluginsToEditorConfig, isCKEditorFreeLicense } from '@ckeditor/ckeditor5-integrations-common'; 7 | import type { EditorConfig } from 'ckeditor5'; 8 | 9 | import { ReactIntegrationUsageDataPlugin } from './ReactIntegrationUsageDataPlugin.js'; 10 | 11 | /** 12 | * Appends all integration plugins to the editor configuration. 13 | * 14 | * @param editorConfig The editor configuration. 15 | * @returns The editor configuration with all integration plugins appended. 16 | */ 17 | export function appendAllIntegrationPluginsToConfig( editorConfig: EditorConfig ): EditorConfig { 18 | /** 19 | * Do not modify the editor configuration if the editor is using a free license. 20 | */ 21 | if ( isCKEditorFreeLicense( editorConfig.licenseKey ) ) { 22 | return editorConfig; 23 | } 24 | 25 | return appendExtraPluginsToEditorConfig( editorConfig, [ 26 | /** 27 | * This part of the code is not executed in open-source implementations using a GPL key. 28 | * It only runs when a specific license key is provided. If you are uncertain whether 29 | * this applies to your installation, please contact our support team. 30 | */ 31 | ReactIntegrationUsageDataPlugin 32 | ] ); 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/mergeRefs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { MutableRefObject } from 'react'; 7 | 8 | type CallbackRef = ( element: T ) => void; 9 | 10 | type ReactRef = CallbackRef | MutableRefObject | null; 11 | 12 | /** 13 | * Combine multiple react refs into one. 14 | */ 15 | export function mergeRefs( ...refs: Array> ): CallbackRef { 16 | return value => { 17 | refs.forEach( ref => { 18 | if ( typeof ref === 'function' ) { 19 | ref( value ); 20 | } else if ( ref != null ) { 21 | ref.current = value; 22 | } 23 | } ); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /tests/_utils-tests/context.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, expect, it } from 'vitest'; 7 | import Context from '../_utils/context.js'; 8 | 9 | describe( 'Context', () => { 10 | describe( 'constructor()', () => { 11 | it( 'saves the config', () => { 12 | const config = { foo: 'bar' }; 13 | const context = new Context( config ); 14 | 15 | expect( context.config ).to.equal( config ); 16 | } ); 17 | } ); 18 | 19 | describe( 'destroy()', () => { 20 | it( 'should return a promise that resolves properly', () => { 21 | return Context.create() 22 | .then( context => { 23 | const promise = context.destroy(); 24 | 25 | expect( promise ).to.be.an.instanceof( Promise ); 26 | 27 | return promise; 28 | } ); 29 | } ); 30 | } ); 31 | 32 | describe( 'create()', () => { 33 | it( 'should return a promise that resolves properly', () => { 34 | const promise = Context.create(); 35 | 36 | expect( promise ).to.be.an.instanceof( Promise ); 37 | 38 | return promise; 39 | } ); 40 | } ); 41 | } ); 42 | -------------------------------------------------------------------------------- /tests/_utils-tests/editor.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, expect, it } from 'vitest'; 7 | import Editor from '../_utils/editor.js'; 8 | 9 | describe( 'Editor', () => { 10 | describe( 'constructor()', () => { 11 | it( 'defines a model and view layers', () => { 12 | const editor = new Editor(); 13 | 14 | expect( editor.model ).is.not.undefined; 15 | expect( editor.editing ).is.not.undefined; 16 | } ); 17 | } ); 18 | 19 | describe( 'enableReadOnlyMode()', () => { 20 | it( 'should enable the read-only mode for given identifier', async () => { 21 | const editor = await Editor.create(); 22 | 23 | expect( editor.isReadOnly ).is.false; 24 | 25 | editor.enableReadOnlyMode( 'foo' ); 26 | 27 | expect( editor.isReadOnly ).is.true; 28 | } ); 29 | } ); 30 | 31 | describe( 'disableReadOnlyMode()', () => { 32 | it( 'should enable the read-only mode for given lock identifier', async () => { 33 | const editor = await Editor.create(); 34 | 35 | expect( editor.isReadOnly ).is.false; 36 | 37 | editor.enableReadOnlyMode( 'foo' ); 38 | 39 | expect( editor.isReadOnly ).is.true; 40 | 41 | editor.disableReadOnlyMode( 'foo' ); 42 | 43 | expect( editor.isReadOnly ).is.false; 44 | } ); 45 | } ); 46 | 47 | describe( '#isReadOnly', () => { 48 | it( 'should be disabled by default when creating a new instance of the editor', () => { 49 | const editor = new Editor(); 50 | 51 | expect( editor.isReadOnly ).is.false; 52 | } ); 53 | 54 | it( 'should throw an error when using the setter for switching to read-only mode', async () => { 55 | const editor = await Editor.create(); 56 | 57 | expect( () => { 58 | editor.isReadOnly = true; 59 | } ).to.throw( Error, 'Cannot use this setter anymore' ); 60 | } ); 61 | } ); 62 | 63 | describe( 'destroy()', () => { 64 | it( 'should return a promise that resolves properly', () => { 65 | return Editor.create() 66 | .then( editor => { 67 | const promise = editor.destroy(); 68 | 69 | expect( promise ).to.be.an.instanceof( Promise ); 70 | 71 | return promise; 72 | } ); 73 | } ); 74 | } ); 75 | 76 | describe( 'create()', () => { 77 | it( 'should return a promise that resolves properly', () => { 78 | const promise = Editor.create(); 79 | 80 | expect( promise ).to.be.an.instanceof( Promise ); 81 | 82 | return promise; 83 | } ); 84 | } ); 85 | 86 | describe( 'DataApi interface', () => { 87 | it( 'setData() is defined', () => { 88 | return Editor.create() 89 | .then( editor => { 90 | expect( editor.setData ).is.a( 'function' ); 91 | } ); 92 | } ); 93 | 94 | it( 'getData() is defined', () => { 95 | return Editor.create() 96 | .then( editor => { 97 | expect( editor.getData ).is.a( 'function' ); 98 | } ); 99 | } ); 100 | } ); 101 | } ); 102 | -------------------------------------------------------------------------------- /tests/_utils/classiceditor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { 7 | ClassicEditor, 8 | Essentials, 9 | Autoformat, 10 | Bold, 11 | Italic, 12 | BlockQuote, 13 | CloudServices, 14 | Heading, 15 | Image, 16 | ImageCaption, 17 | ImageStyle, 18 | ImageToolbar, 19 | ImageUpload, 20 | Indent, 21 | Link, 22 | List, 23 | MediaEmbed, 24 | Paragraph, 25 | PasteFromOffice, 26 | Table, 27 | TableToolbar, 28 | TextTransformation 29 | } from 'ckeditor5'; 30 | 31 | // import 'ckeditor5/ckeditor5.css'; 32 | 33 | export class TestClassicEditor extends ClassicEditor {} 34 | 35 | TestClassicEditor.builtinPlugins = [ 36 | Essentials, 37 | Autoformat, 38 | Bold, 39 | Italic, 40 | BlockQuote, 41 | CloudServices, 42 | Heading, 43 | Image, 44 | ImageCaption, 45 | ImageStyle, 46 | ImageToolbar, 47 | ImageUpload, 48 | Indent, 49 | Link, 50 | List, 51 | MediaEmbed, 52 | Paragraph, 53 | PasteFromOffice, 54 | Table, 55 | TableToolbar, 56 | TextTransformation 57 | ]; 58 | 59 | TestClassicEditor.defaultConfig = { 60 | toolbar: { 61 | items: [ 62 | 'heading', 63 | '|', 64 | 'bold', 65 | 'italic', 66 | 'link', 67 | 'bulletedList', 68 | 'numberedList', 69 | '|', 70 | 'outdent', 71 | 'indent', 72 | '|', 73 | 'uploadImage', 74 | 'blockQuote', 75 | 'insertTable', 76 | 'mediaEmbed', 77 | 'undo', 78 | 'redo' 79 | ] 80 | }, 81 | image: { 82 | toolbar: [ 83 | 'imageStyle:inline', 84 | 'imageStyle:block', 85 | 'imageStyle:side', 86 | '|', 87 | 'toggleImageCaption', 88 | 'imageTextAlternative' 89 | ] 90 | }, 91 | table: { 92 | contentToolbar: [ 93 | 'tableColumn', 94 | 'tableRow', 95 | 'mergeTableCells' 96 | ] 97 | }, 98 | language: 'en' 99 | }; 100 | -------------------------------------------------------------------------------- /tests/_utils/context.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { Context } from 'ckeditor5'; 7 | import { createDefer } from './defer.js'; 8 | 9 | /** 10 | * Mock of class that representing the Context feature. 11 | * 12 | * @see: https://ckeditor.com/docs/ckeditor5/latest/api/module_core_context-Context.html 13 | */ 14 | export default class ContextMock { 15 | public config: any; 16 | 17 | constructor( config: any ) { 18 | this.config = config; 19 | } 20 | 21 | public static create( config?: ConstructorParameters ): Promise { 22 | return Promise.resolve( new ContextMock( config ) as Context ); 23 | } 24 | 25 | public destroy(): Promise { 26 | return Promise.resolve(); 27 | } 28 | } 29 | 30 | /** 31 | * A mock class representing a deferred context. 32 | */ 33 | export class DeferredContextMock { 34 | public static create(): any { 35 | const defer = createDefer(); 36 | 37 | return { 38 | defer, 39 | create: ( ...args: ConstructorParameters ) => defer.promise.then( () => new ContextMock( ...args ) ) 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/_utils/defer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | export function createDefer() { 7 | const deferred = { 8 | resolve: ( ) => {}, 9 | promise: Promise.resolve( null ) 10 | }; 11 | 12 | deferred.promise = new Promise( resolve => { 13 | deferred.resolve = resolve; 14 | } ); 15 | 16 | return deferred; 17 | } 18 | -------------------------------------------------------------------------------- /tests/_utils/editor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { EditorWatchdog, ContextWatchdog } from 'ckeditor5'; 7 | 8 | /** 9 | * Mock of class that representing a basic, generic editor. 10 | * 11 | * @see: https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html 12 | */ 13 | export default class MockEditor { 14 | public static EditorWatchdog: any = EditorWatchdog; 15 | public static ContextWatchdog: any = ContextWatchdog; 16 | 17 | // In order to tests events, we need to somehow mock those properties. 18 | public static _on = (): void => {}; 19 | public static _once = (): void => {}; 20 | 21 | public static _model = { 22 | document: createDocument(), 23 | markers: [] 24 | }; 25 | 26 | public static _editing = { 27 | view: { 28 | document: createDocument() 29 | } 30 | }; 31 | 32 | public declare state?: string; 33 | public declare element?: HTMLElement; 34 | public declare config?: Record; 35 | public declare model: any; 36 | public declare editing: any; 37 | public declare on: any; 38 | public declare once: any; 39 | public declare data: any; 40 | public declare createEditable: any; 41 | public declare ui: any; 42 | public declare plugins: Set; 43 | public declare _readOnlyLocks: Set; 44 | 45 | constructor( element?: HTMLElement, config?: Record ) { 46 | this.element = element; 47 | this.config = config; 48 | 49 | this.initializeProperties(); 50 | } 51 | 52 | public initializeProperties(): void { 53 | this.model = MockEditor._model; 54 | this.editing = MockEditor._editing; 55 | this.on = MockEditor._on; 56 | this.once = MockEditor._once; 57 | this.data = { 58 | get() { 59 | return ''; 60 | }, 61 | set() { 62 | 63 | } 64 | }; 65 | this.createEditable = () => document.createElement( 'div' ); 66 | this.ui = { 67 | getEditableElement() { 68 | return document.createElement( 'div' ); 69 | } 70 | }; 71 | this.plugins = new Set(); 72 | this._readOnlyLocks = new Set(); 73 | } 74 | 75 | public get isReadOnly(): boolean { 76 | return this._readOnlyLocks.size > 0; 77 | } 78 | 79 | public set isReadOnly( _: boolean ) { 80 | throw new Error( 'Cannot use this setter anymore' ); 81 | } 82 | 83 | public enableReadOnlyMode( lockId: string ): void { 84 | this._readOnlyLocks.add( lockId ); 85 | } 86 | 87 | public disableReadOnlyMode( lockId: string ): void { 88 | this._readOnlyLocks.delete( lockId ); 89 | } 90 | 91 | public detachEditable(): Promise { 92 | return Promise.resolve(); 93 | } 94 | 95 | public destroy(): Promise { 96 | return Promise.resolve(); 97 | } 98 | 99 | // Implements the `DataApi` interface. 100 | // See: https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_utils_dataapimixin-DataApi.html 101 | public setData( ...args: Array ): void { 102 | return this.data.set.call( this, ...args ); 103 | } 104 | 105 | public getData( ...args: Array ): string { 106 | return this.data.get.call( this, ...args ); 107 | } 108 | 109 | public static create( element?: HTMLElement, config?: Record ): Promise { 110 | return Promise.resolve( new this( element, config ) ); 111 | } 112 | } 113 | 114 | function createDocument() { 115 | return { 116 | on() {}, 117 | off() {}, 118 | getRootNames() { 119 | return [ 'main' ]; 120 | }, 121 | differ: { 122 | getChanges() { 123 | return []; 124 | }, 125 | getChangedRoots() { 126 | return []; 127 | } 128 | }, 129 | roots: { 130 | filter() { 131 | return [ { 132 | getAttributes: () => { 133 | return {}; 134 | }, 135 | getChildren: () => { 136 | return []; 137 | }, 138 | _isLoaded: false 139 | } ]; 140 | } 141 | } 142 | }; 143 | } 144 | -------------------------------------------------------------------------------- /tests/_utils/expectToBeTruthy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { expect } from 'vitest'; 7 | 8 | export function expectToBeTruthy( 9 | value: T | undefined | null 10 | ): asserts value is T { 11 | expect( value ).to.be.toBeTruthy(); 12 | } 13 | -------------------------------------------------------------------------------- /tests/_utils/multirooteditor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { 7 | MultiRootEditor, 8 | Essentials, 9 | Autoformat, 10 | Bold, 11 | Italic, 12 | BlockQuote, 13 | CloudServices, 14 | Heading, 15 | Image, 16 | ImageCaption, 17 | ImageStyle, 18 | ImageToolbar, 19 | ImageUpload, 20 | Indent, 21 | Link, 22 | List, 23 | MediaEmbed, 24 | Paragraph, 25 | PasteFromOffice, 26 | Table, 27 | TableToolbar, 28 | TextTransformation, 29 | type ContextWatchdog 30 | } from 'ckeditor5'; 31 | 32 | export class TestMultiRootEditor extends MultiRootEditor {} 33 | 34 | export const createTestMultiRootWatchdog = async (): Promise => { 35 | const contextWatchdog = new TestMultiRootEditor.ContextWatchdog( TestMultiRootEditor.Context ); 36 | await contextWatchdog.create(); 37 | return contextWatchdog; 38 | }; 39 | 40 | TestMultiRootEditor.builtinPlugins = [ 41 | Essentials, 42 | Autoformat, 43 | Bold, 44 | Italic, 45 | BlockQuote, 46 | CloudServices, 47 | Heading, 48 | Image, 49 | ImageCaption, 50 | ImageStyle, 51 | ImageToolbar, 52 | ImageUpload, 53 | Indent, 54 | Link, 55 | List, 56 | MediaEmbed, 57 | Paragraph, 58 | PasteFromOffice, 59 | Table, 60 | TableToolbar, 61 | TextTransformation 62 | ]; 63 | 64 | TestMultiRootEditor.defaultConfig = { 65 | toolbar: { 66 | items: [ 67 | 'heading', 68 | '|', 69 | 'bold', 70 | 'italic', 71 | 'link', 72 | 'bulletedList', 73 | 'numberedList', 74 | '|', 75 | 'outdent', 76 | 'indent', 77 | '|', 78 | 'uploadImage', 79 | 'blockQuote', 80 | 'insertTable', 81 | 'mediaEmbed', 82 | 'undo', 83 | 'redo' 84 | ] 85 | }, 86 | image: { 87 | toolbar: [ 88 | 'imageStyle:inline', 89 | 'imageStyle:block', 90 | 'imageStyle:side', 91 | '|', 92 | 'toggleImageCaption', 93 | 'imageTextAlternative' 94 | ] 95 | }, 96 | table: { 97 | contentToolbar: [ 98 | 'tableColumn', 99 | 'tableRow', 100 | 'mergeTableCells' 101 | ] 102 | }, 103 | // This value must be kept in sync with the language defined in webpack.config.js. 104 | language: 'en' 105 | }; 106 | -------------------------------------------------------------------------------- /tests/_utils/promisemanager.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | export class PromiseManager { 7 | public promises: Array> = []; 8 | 9 | public resolveOnRun( cb?: T ): () => T { 10 | let resolve: Function | null = null; 11 | const promies = new Promise( res => { 12 | resolve = res; 13 | } ); 14 | 15 | this.promises.push( promies ); 16 | 17 | return ( ...args: Array ) => resolve?.( cb?.( ...args ) ); 18 | } 19 | 20 | public async all(): Promise { 21 | await Promise.all( this.promises ); 22 | } 23 | 24 | public clear(): void { 25 | this.promises = []; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/_utils/timeout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | export function timeout( ms ) { 7 | return new Promise( resolve => { 8 | setTimeout( resolve, ms ); 9 | } ); 10 | } 11 | -------------------------------------------------------------------------------- /tests/_utils/turnOffErrors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { Awaitable } from '@ckeditor/ckeditor5-integrations-common'; 7 | import { timeout } from './timeout.js'; 8 | 9 | export async function turnOffErrors( callback: () => Awaitable ): Promise { 10 | const handler = ( evt: ErrorEvent ) => { 11 | evt.preventDefault(); 12 | }; 13 | 14 | window.addEventListener( 'error', handler, { capture: true, once: true } ); 15 | 16 | try { 17 | await callback(); 18 | await timeout( 150 ); 19 | } finally { 20 | window.removeEventListener( 'error', handler ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/cloud/useCKEditorCloud.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { afterEach, describe, expect, expectTypeOf, it } from 'vitest'; 7 | import { renderHook, waitFor, act } from '@testing-library/react'; 8 | 9 | import type { CKEditorCloudConfig } from '@ckeditor/ckeditor5-integrations-common'; 10 | import { removeAllCkCdnResources } from '@ckeditor/ckeditor5-integrations-common/test-utils'; 11 | 12 | import useCKEditorCloud from '../../src/cloud/useCKEditorCloud.js'; 13 | 14 | describe( 'useCKEditorCloud', { timeout: 8000 }, () => { 15 | afterEach( () => { 16 | removeAllCkCdnResources(); 17 | } ); 18 | 19 | it( 'should load CKEditor bundles from CDN', async () => { 20 | const { result } = renderHook( () => useCKEditorCloud( { 21 | version: '45.0.0', 22 | translations: [ 'es', 'de' ] 23 | } ) ); 24 | 25 | await waitFor( () => { 26 | expect( result.current.status ).toBe( 'success' ); 27 | 28 | if ( result.current.status === 'success' ) { 29 | expect( result.current.CKEditor ).toBeDefined(); 30 | } 31 | }, { timeout: 5000 } ); 32 | } ); 33 | 34 | it( 'should load additional bundle after updating deps', async () => { 35 | const { result, rerender } = renderHook( 36 | ( config: CKEditorCloudConfig ) => useCKEditorCloud( config ), 37 | { 38 | initialProps: { 39 | version: '45.0.0', 40 | premium: false 41 | } 42 | } 43 | ); 44 | 45 | await waitFor( () => { 46 | expect( result.current.status ).toBe( 'success' ); 47 | 48 | if ( result.current.status === 'success' ) { 49 | expect( result.current.CKEditor ).toBeDefined(); 50 | expect( result.current.CKEditorPremiumFeatures ).toBeUndefined(); 51 | } 52 | }, { timeout: 5000 } ); 53 | 54 | rerender( { 55 | version: '45.0.0', 56 | premium: true 57 | } ); 58 | 59 | act( () => { 60 | expect( result.current.status ).toBe( 'loading' ); 61 | } ); 62 | 63 | await waitFor( () => { 64 | expect( result.current.status ).toBe( 'success' ); 65 | 66 | if ( result.current.status === 'success' ) { 67 | expect( result.current.CKEditor ).toBeDefined(); 68 | expect( result.current.CKEditorPremiumFeatures ).toBeDefined(); 69 | } 70 | }, { timeout: 5000 } ); 71 | } ); 72 | 73 | describe( 'typings', () => { 74 | it( 'should return non-nullable premium features entry type if premium is enabled', async () => { 75 | const { result } = renderHook( () => useCKEditorCloud( { 76 | version: '45.0.0', 77 | premium: true 78 | } ) ); 79 | 80 | await waitFor( () => { 81 | expect( result.current.status ).toBe( 'success' ); 82 | }, { timeout: 5000 } ); 83 | 84 | if ( result.current.status === 'success' ) { 85 | expectTypeOf( result.current.CKEditorPremiumFeatures ).not.toBeNullable(); 86 | } 87 | } ); 88 | 89 | it( 'should return nullable premium features entry type if premium is disabled', async () => { 90 | const { result } = renderHook( () => useCKEditorCloud( { 91 | version: '45.0.0', 92 | premium: false 93 | } ) ); 94 | 95 | await waitFor( () => { 96 | expect( result.current.status ).toBe( 'success' ); 97 | }, { timeout: 5000 } ); 98 | 99 | if ( result.current.status === 'success' ) { 100 | expectTypeOf( result.current.CKEditorPremiumFeatures ).toBeNullable(); 101 | } 102 | } ); 103 | 104 | it( 'should return nullable premium features entry type if premium is not provided', async () => { 105 | const { result } = renderHook( () => useCKEditorCloud( { 106 | version: '45.0.0' 107 | } ) ); 108 | 109 | await waitFor( () => { 110 | expect( result.current.status ).toBe( 'success' ); 111 | }, { timeout: 5000 } ); 112 | 113 | if ( result.current.status === 'success' ) { 114 | expectTypeOf( result.current.CKEditorPremiumFeatures ).toBeNullable(); 115 | } 116 | } ); 117 | 118 | it( 'should return non-nullable ckbox entry type if ckbox enabled', async () => { 119 | const { result } = renderHook( () => useCKEditorCloud( { 120 | version: '45.0.0', 121 | ckbox: { 122 | version: '2.5.1' 123 | } 124 | } ) ); 125 | 126 | await waitFor( () => { 127 | expect( result.current.status ).toBe( 'success' ); 128 | }, { timeout: 5000 } ); 129 | 130 | if ( result.current.status === 'success' ) { 131 | expectTypeOf( result.current.CKBox ).not.toBeNullable(); 132 | } 133 | } ); 134 | 135 | it( 'should return a nullable ckbox entry type if ckbox is not configured', async () => { 136 | const { result } = renderHook( () => useCKEditorCloud( { 137 | version: '45.0.0' 138 | } ) ); 139 | 140 | await waitFor( () => { 141 | expect( result.current.status ).toBe( 'success' ); 142 | }, { timeout: 5000 } ); 143 | 144 | if ( result.current.status === 'success' ) { 145 | expectTypeOf( result.current.CKBox ).toBeNullable(); 146 | } 147 | } ); 148 | } ); 149 | } ); 150 | -------------------------------------------------------------------------------- /tests/cloud/withCKEditorCloud.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import React, { type MutableRefObject } from 'react'; 7 | import { afterEach, describe, expect, it } from 'vitest'; 8 | import { render } from '@testing-library/react'; 9 | 10 | import { createDefer } from '@ckeditor/ckeditor5-integrations-common'; 11 | import { removeAllCkCdnResources } from '@ckeditor/ckeditor5-integrations-common/test-utils'; 12 | 13 | import withCKEditorCloud, { type WithCKEditorCloudHocProps } from '../../src/cloud/withCKEditorCloud.js'; 14 | 15 | describe( 'withCKEditorCloud', { timeout: 5000 }, () => { 16 | const lastRenderedMockProps: MutableRefObject = { 17 | current: null 18 | }; 19 | 20 | afterEach( () => { 21 | removeAllCkCdnResources(); 22 | lastRenderedMockProps.current = null; 23 | } ); 24 | 25 | const MockComponent = ( props: WithCKEditorCloudHocProps & { editorId: number } ) => { 26 | lastRenderedMockProps.current = { ...props }; 27 | 28 | return ( 29 |
30 | Your Editor { props.editorId } 31 |
32 | ); 33 | }; 34 | 35 | it( 'should inject cloud integration to the wrapped component', async () => { 36 | const WrappedComponent = withCKEditorCloud( { 37 | cloud: { 38 | version: '45.0.0' 39 | } 40 | } )( MockComponent ); 41 | 42 | const { findByText } = render( ); 43 | 44 | expect( await findByText( 'Your Editor 1' ) ).toBeVisible(); 45 | expect( lastRenderedMockProps.current ).toMatchObject( { 46 | editorId: 1, 47 | cloud: expect.objectContaining( { 48 | CKEditor: expect.objectContaining( { 49 | ClassicEditor: expect.any( Function ) 50 | } ) 51 | } ) 52 | } ); 53 | } ); 54 | 55 | it( 'should show loading spinner when cloud is not ready', async () => { 56 | const deferredPlugin = createDefer(); 57 | const WrappedComponent = withCKEditorCloud( { 58 | renderLoader: () =>
Loading...
, 59 | cloud: { 60 | version: '45.0.0', 61 | plugins: { 62 | Plugin: { 63 | checkPluginLoaded: () => deferredPlugin.promise 64 | } 65 | } 66 | } 67 | } )( MockComponent ); 68 | 69 | const { findByText } = render( ); 70 | 71 | expect( await findByText( 'Loading...' ) ).toBeVisible(); 72 | 73 | deferredPlugin.resolve( 123 ); 74 | 75 | expect( await findByText( 'Your Editor 1' ) ).toBeVisible(); 76 | expect( lastRenderedMockProps.current?.cloud.loadedPlugins?.Plugin ).toBe( 123 ); 77 | } ); 78 | 79 | it( 'should show error message when cloud loading fails', async () => { 80 | const WrappedComponent = withCKEditorCloud( { 81 | renderError: error =>
Error: { error.message }
, 82 | cloud: { 83 | version: '45.0.0', 84 | plugins: { 85 | Plugin: { 86 | checkPluginLoaded: () => { 87 | throw new Error( 'Failed to load plugin' ); 88 | } 89 | } 90 | } 91 | } 92 | } )( MockComponent ); 93 | 94 | const { findByText } = render( ); 95 | 96 | expect( await findByText( 'Error: Failed to load plugin' ) ).toBeVisible(); 97 | } ); 98 | 99 | it( 'should render default error message when cloud loading fails and there is no error handler specified', async () => { 100 | const WrappedComponent = withCKEditorCloud( { 101 | cloud: { 102 | version: '45.0.0', 103 | plugins: { 104 | Plugin: { 105 | checkPluginLoaded: () => { 106 | throw new Error( 'Failed to load plugin' ); 107 | } 108 | } 109 | } 110 | } 111 | } )( MockComponent ); 112 | 113 | const { findByText } = render( ); 114 | 115 | expect( await findByText( 'Unable to load CKEditor Cloud data!' ) ).toBeVisible(); 116 | } ); 117 | } ); 118 | -------------------------------------------------------------------------------- /tests/context/useInitializedCKEditorsMap.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, it, expect, vi, afterEach } from 'vitest'; 7 | import { renderHook } from '@testing-library/react'; 8 | import { Collection } from 'ckeditor5'; 9 | 10 | import { useInitializedCKEditorsMap } from '../../src/context/useInitializedCKEditorsMap.js'; 11 | 12 | import type { ContextWatchdogValue } from '../../src/context/ckeditorcontext.js'; 13 | import type { CKEditorConfigContextMetadata } from '../../src/context/setCKEditorReactContextMetadata.js'; 14 | 15 | import MockEditor from '../_utils/editor.js'; 16 | 17 | describe( 'useInitializedCKEditorsMap', () => { 18 | afterEach( () => { 19 | vi.clearAllMocks(); 20 | } ); 21 | 22 | it( 'should not call onChangeInitializedEditors when context is not initialized', () => { 23 | const onChangeInitializedEditors = vi.fn(); 24 | const mockWatchdog = { 25 | status: 'initializing' as const, 26 | watchdog: null 27 | }; 28 | 29 | renderHook( () => useInitializedCKEditorsMap( { 30 | currentContextWatchdog: mockWatchdog, 31 | onChangeInitializedEditors 32 | } ) ); 33 | 34 | expect( onChangeInitializedEditors ).not.toHaveBeenCalled(); 35 | } ); 36 | 37 | it( 'should not track editors that are not ready', () => { 38 | const editors = new Collection(); 39 | const onChangeInitializedEditors = vi.fn(); 40 | const notReadyEditor = createMockEditor( 'initializing', { name: '1' } ); 41 | editors.add( notReadyEditor ); 42 | 43 | renderHook( () => useInitializedCKEditorsMap( { 44 | currentContextWatchdog: createMockContextWatchdog( editors ), 45 | onChangeInitializedEditors 46 | } ) ); 47 | 48 | expect( onChangeInitializedEditors ).not.toHaveBeenCalled(); 49 | } ); 50 | 51 | it( 'should track ready editors', () => { 52 | const editors = new Collection(); 53 | const onChangeInitializedEditors = vi.fn(); 54 | const readyEditor = createMockEditor( 'ready', { name: '1' } ); 55 | editors.add( readyEditor ); 56 | 57 | renderHook( () => useInitializedCKEditorsMap( { 58 | currentContextWatchdog: createMockContextWatchdog( editors ), 59 | onChangeInitializedEditors 60 | } ) ); 61 | 62 | expect( onChangeInitializedEditors ).toHaveBeenCalledWith( 63 | expect.objectContaining( { 64 | '1': { 65 | instance: readyEditor, 66 | metadata: { 67 | name: '1' 68 | } 69 | } 70 | } ), 71 | expect.anything() 72 | ); 73 | } ); 74 | 75 | it( 'should handle adding new editors to collection', () => { 76 | const editors = new Collection(); 77 | const onChangeInitializedEditors = vi.fn(); 78 | 79 | renderHook( () => useInitializedCKEditorsMap( { 80 | currentContextWatchdog: createMockContextWatchdog( editors ), 81 | onChangeInitializedEditors 82 | } ) ); 83 | 84 | const newEditor = createMockEditor( 'ready', { name: '2' } ); 85 | editors.add( newEditor ); 86 | 87 | // Simulate 'ready' event 88 | const readyCallback = newEditor.once.mock.calls.find( ( call: any ) => call[ 0 ] === 'ready' )![ 1 ]; 89 | readyCallback(); 90 | 91 | expect( onChangeInitializedEditors ).toHaveBeenLastCalledWith( 92 | expect.objectContaining( { 93 | '2': { 94 | instance: newEditor, 95 | metadata: { 96 | name: '2' 97 | } 98 | } 99 | } ), 100 | expect.anything() 101 | ); 102 | } ); 103 | 104 | it( 'should handle editor destruction', () => { 105 | const editors = new Collection(); 106 | const onChangeInitializedEditors = vi.fn(); 107 | const editor = createMockEditor( 'ready', { name: '1' } ); 108 | editors.add( editor ); 109 | 110 | renderHook( () => useInitializedCKEditorsMap( { 111 | currentContextWatchdog: createMockContextWatchdog( editors ), 112 | onChangeInitializedEditors 113 | } ) ); 114 | 115 | // Simulate 'destroy' event 116 | const destroyCallback = editor.once.mock.calls.find( ( call: any ) => call[ 0 ] === 'destroy' )![ 1 ]; 117 | editors.remove( editor ); 118 | destroyCallback(); 119 | 120 | expect( onChangeInitializedEditors ).toHaveBeenLastCalledWith( 121 | expect.objectContaining( {} ), 122 | expect.anything() 123 | ); 124 | } ); 125 | } ); 126 | 127 | function createMockEditor( state = 'ready', contextMetadata: CKEditorConfigContextMetadata, config = {} ) { 128 | const editor = new MockEditor( document.createElement( 'div' ), { 129 | get( name: string ) { 130 | if ( name === '$__CKEditorReactContextMetadata' ) { 131 | return contextMetadata; 132 | } 133 | 134 | if ( Object.prototype.hasOwnProperty.call( config, name ) ) { 135 | return config[ name ]; 136 | } 137 | 138 | return undefined; 139 | } 140 | } ); 141 | 142 | editor.state = state; 143 | editor.once = vi.fn(); 144 | 145 | return editor; 146 | } 147 | 148 | function createMockContextWatchdog( editors = new Collection() ) { 149 | return ( { 150 | status: 'initialized' as const, 151 | watchdog: { 152 | context: { 153 | editors 154 | } 155 | } 156 | } ) as unknown as ContextWatchdogValue; 157 | } 158 | -------------------------------------------------------------------------------- /tests/hooks/useAsyncCallback.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, expect, it, vi } from 'vitest'; 7 | import { renderHook, act, waitFor } from '@testing-library/react'; 8 | import { useAsyncCallback } from '../../src/hooks/useAsyncCallback.js'; 9 | import { timeout } from '../_utils/timeout.js'; 10 | 11 | describe( 'useAsyncCallback', () => { 12 | it( 'should execute the callback and update the state correctly when the callback resolves', async () => { 13 | const fetchData = vi.fn().mockResolvedValue( 'data' ); 14 | 15 | const { result } = renderHook( () => useAsyncCallback( fetchData ) ); 16 | const [ onFetchData ] = result.current; 17 | 18 | expect( result.current[ 1 ].status ).toBe( 'idle' ); 19 | 20 | act( () => { 21 | onFetchData(); 22 | } ); 23 | 24 | expect( result.current[ 1 ].status ).toBe( 'loading' ); 25 | 26 | await waitFor( () => { 27 | const [ , fetchDataStatus ] = result.current; 28 | 29 | expect( fetchDataStatus.status ).toBe( 'success' ); 30 | 31 | if ( fetchDataStatus.status === 'success' ) { 32 | expect( fetchDataStatus.data ).toBe( 'data' ); 33 | } 34 | } ); 35 | } ); 36 | 37 | it( 'should execute the callback and update the state correctly when the callback rejects', async () => { 38 | const fetchData = vi.fn().mockRejectedValue( new Error( 'error' ) ); 39 | 40 | const { result } = renderHook( () => useAsyncCallback( fetchData ) ); 41 | const [ onFetchData ] = result.current; 42 | 43 | expect( result.current[ 1 ].status ).toBe( 'idle' ); 44 | 45 | act( () => { 46 | onFetchData(); 47 | } ); 48 | 49 | expect( result.current[ 1 ].status ).toBe( 'loading' ); 50 | 51 | await waitFor( () => { 52 | const [ , fetchDataStatus ] = result.current; 53 | 54 | expect( fetchDataStatus.status ).toBe( 'error' ); 55 | 56 | if ( fetchDataStatus.status === 'error' ) { 57 | expect( fetchDataStatus.error.message ).toBe( 'error' ); 58 | } 59 | } ); 60 | } ); 61 | 62 | it( 'should not update the state to loading if the component is unmounted', async () => { 63 | const fetchData = vi.fn().mockResolvedValue( 'data' ); 64 | 65 | const { result, unmount } = renderHook( () => useAsyncCallback( fetchData ) ); 66 | 67 | const [ onFetchData, fetchDataStatus ] = result.current; 68 | 69 | expect( fetchDataStatus.status ).toBe( 'idle' ); 70 | unmount(); 71 | 72 | act( () => { 73 | onFetchData(); 74 | } ); 75 | 76 | expect( fetchDataStatus.status ).toBe( 'idle' ); 77 | } ); 78 | 79 | it( 'should not update the state to error if the component is unmounted', async () => { 80 | const fetchData = vi.fn().mockRejectedValue( new Error( 'error' ) ); 81 | 82 | const { result, unmount } = renderHook( () => useAsyncCallback( fetchData ) ); 83 | const [ onFetchData ] = result.current; 84 | 85 | expect( result.current[ 1 ].status ).toBe( 'idle' ); 86 | 87 | act( () => { 88 | onFetchData(); 89 | } ); 90 | 91 | expect( result.current[ 1 ].status ).toBe( 'loading' ); 92 | unmount(); 93 | 94 | await timeout( 50 ); 95 | await waitFor( () => { 96 | const [ , fetchDataStatus ] = result.current; 97 | 98 | expect( fetchDataStatus.status ).toBe( 'loading' ); 99 | } ); 100 | } ); 101 | 102 | it( 'should not update the state to success if the component is unmounted', async () => { 103 | const fetchData = vi.fn().mockResolvedValue( 123 ); 104 | 105 | const { result, unmount } = renderHook( () => useAsyncCallback( fetchData ) ); 106 | const [ onFetchData ] = result.current; 107 | 108 | expect( result.current[ 1 ].status ).toBe( 'idle' ); 109 | 110 | act( () => { 111 | onFetchData(); 112 | } ); 113 | 114 | expect( result.current[ 1 ].status ).toBe( 'loading' ); 115 | unmount(); 116 | 117 | await timeout( 50 ); 118 | await waitFor( () => { 119 | const [ , fetchDataStatus ] = result.current; 120 | 121 | expect( fetchDataStatus.status ).toBe( 'loading' ); 122 | } ); 123 | } ); 124 | 125 | it( 'should not update the state if the execution UUID does not match the previous one', async () => { 126 | let counter = 0; 127 | const fetchData = vi.fn( async () => { 128 | if ( !counter ) { 129 | await timeout( 200 ); 130 | } 131 | 132 | return counter++; 133 | } ); 134 | 135 | const { result } = renderHook( () => useAsyncCallback( fetchData ) ); 136 | 137 | const [ onFetchData ] = result.current; 138 | 139 | expect( result.current[ 1 ].status ).toBe( 'idle' ); 140 | 141 | act( () => { 142 | onFetchData(); 143 | } ); 144 | 145 | // Do not batch this act() call with the previous one to ensure that the execution UUID is different. 146 | act( () => { 147 | onFetchData(); 148 | } ); 149 | 150 | await waitFor( () => { 151 | const [ , fetchDataStatus ] = result.current; 152 | 153 | expect( fetchDataStatus.status ).toBe( 'success' ); 154 | 155 | if ( fetchDataStatus.status === 'success' ) { 156 | expect( fetchDataStatus.data ).toBe( 1 ); 157 | } 158 | } ); 159 | } ); 160 | } ); 161 | -------------------------------------------------------------------------------- /tests/hooks/useAsyncValue.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, expect, it } from 'vitest'; 7 | import { renderHook, waitFor } from '@testing-library/react'; 8 | import { useAsyncValue } from '../../src/hooks/useAsyncValue.js'; 9 | 10 | describe( 'useAsyncValue', () => { 11 | it( 'should return a mutable ref object', async () => { 12 | const { result } = renderHook( () => useAsyncValue( async () => 123, [] ) ); 13 | 14 | expect( result.current.status ).to.equal( 'loading' ); 15 | 16 | await waitFor( () => { 17 | expect( result.current.status ).to.equal( 'success' ); 18 | 19 | if ( result.current.status === 'success' ) { 20 | expect( result.current.data ).to.equal( 123 ); 21 | } 22 | } ); 23 | } ); 24 | 25 | it( 'should reload async value on deps change', async () => { 26 | let value = 0; 27 | const { result, rerender } = renderHook( () => useAsyncValue( async () => value, [ value ] ) ); 28 | 29 | expect( result.current.status ).to.equal( 'loading' ); 30 | 31 | await waitFor( () => { 32 | expect( result.current.status ).to.equal( 'success' ); 33 | 34 | if ( result.current.status === 'success' ) { 35 | expect( result.current.data ).to.equal( 0 ); 36 | } 37 | } ); 38 | 39 | value = 1; 40 | rerender(); 41 | 42 | expect( result.current.status ).to.equal( 'loading' ); 43 | 44 | await waitFor( () => { 45 | expect( result.current.status ).to.equal( 'success' ); 46 | 47 | if ( result.current.status === 'success' ) { 48 | expect( result.current.data ).to.equal( 1 ); 49 | } 50 | } ); 51 | } ); 52 | } ); 53 | -------------------------------------------------------------------------------- /tests/hooks/useInstantEditorEffect.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, expect, it, vi } from 'vitest'; 7 | import { renderHook } from '@testing-library/react'; 8 | import { useInstantEditorEffect } from '../../src/hooks/useInstantEditorEffect.js'; 9 | 10 | describe( 'useInstantEditorEffect', () => { 11 | it( 'should execute the provided function after mounting of editor', () => { 12 | const semaphore = { 13 | runAfterMount: vi.fn() 14 | }; 15 | 16 | const fn = vi.fn(); 17 | 18 | renderHook( () => useInstantEditorEffect( semaphore, fn, [] ) ); 19 | expect( semaphore.runAfterMount ).toHaveBeenCalledWith( fn ); 20 | } ); 21 | 22 | it( 'should not execute the provided function if semaphore is null', () => { 23 | const semaphore = null; 24 | 25 | const fn = vi.fn(); 26 | 27 | renderHook( () => useInstantEditorEffect( semaphore, fn, [] ) ); 28 | 29 | expect( fn ).not.toHaveBeenCalled(); 30 | } ); 31 | 32 | it( 'should re-execute the provided function when the dependencies change', () => { 33 | const semaphore = { 34 | runAfterMount: vi.fn() 35 | }; 36 | 37 | const fn = vi.fn(); 38 | 39 | const { rerender } = renderHook( ( { semaphore, fn } ) => useInstantEditorEffect( semaphore, fn, [ semaphore ] ), { 40 | initialProps: { semaphore, fn } 41 | } ); 42 | 43 | expect( semaphore.runAfterMount ).toHaveBeenCalledOnce(); 44 | 45 | const newSemaphore = { 46 | runAfterMount: vi.fn() 47 | }; 48 | 49 | rerender( { 50 | semaphore: newSemaphore, 51 | fn 52 | } ); 53 | 54 | expect( semaphore.runAfterMount ).toHaveBeenCalledOnce(); 55 | expect( newSemaphore.runAfterMount ).toHaveBeenCalledOnce(); 56 | } ); 57 | 58 | it( 'should not re-execute the provided function when the dependencies do not change', () => { 59 | const semaphore = { 60 | runAfterMount: vi.fn() 61 | }; 62 | 63 | const fn = vi.fn(); 64 | 65 | const { rerender } = renderHook( ( { semaphore, fn } ) => useInstantEditorEffect( semaphore, fn, [ semaphore ] ), { 66 | initialProps: { semaphore, fn } 67 | } ); 68 | 69 | expect( semaphore.runAfterMount ).toHaveBeenCalledOnce(); 70 | 71 | rerender( { 72 | semaphore, 73 | fn 74 | } ); 75 | 76 | expect( semaphore.runAfterMount ).toHaveBeenCalledOnce(); 77 | } ); 78 | } ); 79 | -------------------------------------------------------------------------------- /tests/hooks/useInstantEffect.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, expect, it, vi } from 'vitest'; 7 | import { renderHook } from '@testing-library/react'; 8 | import { useInstantEffect } from '../../src/hooks/useInstantEffect.js'; 9 | 10 | describe( 'useInstantEffect', () => { 11 | it( 'should call the effect function when dependencies change', () => { 12 | const effectFn = vi.fn(); 13 | const { rerender } = renderHook( deps => useInstantEffect( effectFn, deps ), { 14 | initialProps: [ 1, 2, 3 ] 15 | } ); 16 | 17 | expect( effectFn ).toHaveBeenCalledTimes( 1 ); 18 | 19 | rerender( [ 4, 5, 6 ] ); 20 | expect( effectFn ).toHaveBeenCalledTimes( 2 ); 21 | 22 | rerender( [ 4, 5, 6 ] ); 23 | expect( effectFn ).toHaveBeenCalledTimes( 2 ); 24 | 25 | rerender( [ 7, 8, 9 ] ); 26 | expect( effectFn ).toHaveBeenCalledTimes( 3 ); 27 | } ); 28 | 29 | it( 'should not call the effect function when dependencies do not change', () => { 30 | const effectFn = vi.fn(); 31 | const { rerender } = renderHook( deps => useInstantEffect( effectFn, deps ), { 32 | initialProps: [ 1, 2, 3 ] 33 | } ); 34 | 35 | expect( effectFn ).toHaveBeenCalledTimes( 1 ); 36 | 37 | rerender( [ 1, 2, 3 ] ); 38 | expect( effectFn ).toHaveBeenCalledTimes( 1 ); 39 | 40 | rerender( [ 1, 2, 3 ] ); 41 | expect( effectFn ).toHaveBeenCalledTimes( 1 ); 42 | } ); 43 | } ); 44 | -------------------------------------------------------------------------------- /tests/hooks/useIsMountedRef.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, expect, it } from 'vitest'; 7 | import { renderHook } from '@testing-library/react'; 8 | import { useIsMountedRef } from '../../src/hooks/useIsMountedRef.js'; 9 | 10 | describe( 'useIsMountedRef', () => { 11 | it( 'should return a mutable ref object', () => { 12 | const { result } = renderHook( () => useIsMountedRef() ); 13 | 14 | expect( result.current.current ).to.be.true; 15 | } ); 16 | 17 | it( 'should update the ref object when the component is unmounted', () => { 18 | const { result, unmount } = renderHook( () => useIsMountedRef() ); 19 | 20 | expect( result.current.current ).to.be.true; 21 | 22 | unmount(); 23 | 24 | expect( result.current.current ).to.be.false; 25 | } ); 26 | } ); 27 | -------------------------------------------------------------------------------- /tests/hooks/useIsUnmountedRef.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, expect, it } from 'vitest'; 7 | import { renderHook } from '@testing-library/react'; 8 | import { useIsUnmountedRef } from '../../src/hooks/useIsUnmountedRef.js'; 9 | 10 | describe( 'useIsUnmountedRef', () => { 11 | it( 'should return a mutable ref object', () => { 12 | const { result } = renderHook( () => useIsUnmountedRef() ); 13 | 14 | expect( result.current ).toHaveProperty( 'current' ); 15 | expect( typeof result.current.current ).toBe( 'boolean' ); 16 | } ); 17 | 18 | it( 'should update the ref object when the component is unmounted', () => { 19 | const { result, unmount } = renderHook( () => useIsUnmountedRef() ); 20 | 21 | expect( result.current.current ).toBe( false ); 22 | 23 | unmount(); 24 | 25 | expect( result.current.current ).toBe( true ); 26 | } ); 27 | } ); 28 | -------------------------------------------------------------------------------- /tests/hooks/useRefSafeCallback.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { expect, it, describe, vi } from 'vitest'; 7 | import { renderHook, act } from '@testing-library/react'; 8 | import { useRefSafeCallback } from '../../src/hooks/useRefSafeCallback.js'; 9 | 10 | describe( 'useRefSafeCallback', () => { 11 | it( 'should return a function', () => { 12 | const { result } = renderHook( () => useRefSafeCallback( () => {} ) ); 13 | 14 | expect( typeof result.current ).toBe( 'function' ); 15 | } ); 16 | 17 | it( 'should return the same function reference', () => { 18 | const { result, rerender } = renderHook( callback => useRefSafeCallback( callback ), { 19 | initialProps: () => {} 20 | } ); 21 | 22 | const initialCallback = result.current; 23 | const newFnSpy = vi.fn(); 24 | 25 | rerender( newFnSpy ); 26 | 27 | expect( result.current ).toBe( initialCallback ); 28 | expect( newFnSpy ).not.toHaveBeenCalled(); 29 | 30 | result.current(); 31 | expect( newFnSpy ).toHaveBeenCalledOnce(); 32 | } ); 33 | 34 | it( 'should call the callback with the provided arguments', () => { 35 | const callback = vi.fn(); 36 | const { result } = renderHook( () => useRefSafeCallback( callback ) ); 37 | 38 | act( () => { 39 | result.current( 'arg1', 'arg2' ); 40 | } ); 41 | 42 | expect( callback ).toHaveBeenCalledWith( 'arg1', 'arg2' ); 43 | } ); 44 | 45 | it( 'should return the result of the callback', () => { 46 | const { result } = renderHook( () => useRefSafeCallback( () => 'result' ) ); 47 | 48 | const callbackResult = result.current(); 49 | 50 | expect( callbackResult ).toBe( 'result' ); 51 | } ); 52 | } ); 53 | -------------------------------------------------------------------------------- /tests/index.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, afterEach, it, expect, vi } from 'vitest'; 7 | import React from 'react'; 8 | import { ContextWatchdog } from 'ckeditor5'; 9 | import { render, type RenderResult } from '@testing-library/react'; 10 | import ContextMock from './_utils/context.js'; 11 | import Editor from './_utils/editor.js'; 12 | import { PromiseManager } from './_utils/promisemanager.js'; 13 | import { CKEditor, CKEditorContext } from '../src/index.js'; 14 | 15 | const MockEditor = Editor as any; 16 | 17 | describe( 'index.js', () => { 18 | const manager: PromiseManager = new PromiseManager(); 19 | let component: RenderResult | null = null; 20 | 21 | afterEach( () => { 22 | vi.restoreAllMocks(); 23 | vi.clearAllTimers(); 24 | vi.unstubAllEnvs(); 25 | vi.unstubAllGlobals(); 26 | 27 | component?.unmount(); 28 | manager.clear(); 29 | } ); 30 | 31 | it( 'should be the CKEditor Component', async () => { 32 | let editor: typeof MockEditor; 33 | 34 | component = render( 35 | { 38 | editor = instance; 39 | } ) } 40 | /> 41 | ); 42 | 43 | await manager.all(); 44 | 45 | expect( CKEditor ).to.be.a( 'function' ); 46 | expect( editor ).to.be.instanceOf( MockEditor ); 47 | } ); 48 | 49 | it( 'should be the CKEditorContext Component', async () => { 50 | let context: typeof ContextMock; 51 | 52 | component = render( 53 | { 57 | context = instance; 58 | } ) } 59 | /> 60 | ); 61 | 62 | await manager.all(); 63 | 64 | expect( CKEditorContext ).to.be.a( 'function' ); 65 | expect( context! ).to.be.instanceOf( ContextMock ); 66 | } ); 67 | } ); 68 | -------------------------------------------------------------------------------- /tests/integrations/ckeditor-classiceditor.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, afterEach, it, expect } from 'vitest'; 7 | import React, { createRef } from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | import { render, type RenderResult } from '@testing-library/react'; 10 | 11 | import CKEditor from '../../src/ckeditor.js'; 12 | 13 | import { timeout } from '../_utils/timeout.js'; 14 | import { TestClassicEditor } from '../_utils/classiceditor.js'; 15 | import { PromiseManager } from '../_utils/promisemanager.js'; 16 | 17 | describe( 'CKEditor Component + ClassicEditor Build', () => { 18 | let component: RenderResult | null = null; 19 | const manager: PromiseManager = new PromiseManager(); 20 | 21 | afterEach( () => { 22 | component?.unmount(); 23 | manager.clear(); 24 | } ); 25 | 26 | it( 'should initialize the ClassicEditor properly', async () => { 27 | const editorRef = createRef>(); 28 | 29 | component = render( 30 | 35 | ); 36 | 37 | await manager.all(); 38 | 39 | expect( editorRef.current?.editor ).to.be.instanceOf( TestClassicEditor ); 40 | } ); 41 | } ); 42 | 43 | // The memory test based on: https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-core/tests/_utils/memory.js. 44 | // It's the simplified, adjusted version that allows checking whether the component destroys all references. 45 | const TEST_TIMEOUT = 5000; 46 | const GARBAGE_COLLECTOR_TIMEOUT = 500; 47 | 48 | // Will skip test suite if tests are run inside incompatible environment: 49 | // - No window.gc (only Google Chrome). 50 | // - Chrome on Windows (tests heavily break). 51 | // 52 | // Currently on Google Chrome supports this method and must be run with proper flags: 53 | // 54 | // google-chrome -js-flags="--expose-gc" 55 | // 56 | describe.skipIf( !window.gc || isWindows() )( ' memory usage', () => { 57 | const config = { 58 | initialData: '

Editor 1

\n' + 59 | '

This is an editor instance. And there\'s some link.

' 60 | }; 61 | 62 | let div; 63 | 64 | // Single test case for memory usage test. Handles the memory leak test procedure. 65 | // 66 | // 1. Mount and unmount the component to pre-fill the memory with some cacheable data. 67 | // 2. Record the heap size. 68 | // 3. Mount and unmount the component 5 times. 69 | // 4. Record the heap size and compare with the previous result. 70 | // 5. Fail when exceeded a 1MB treshold (see code comments for why 1MB). 71 | it( 'should not grow on multiple component creations', async () => { 72 | await timeout( TEST_TIMEOUT ); 73 | 74 | function createEditor() { 75 | div = document.createElement( 'div' ); 76 | document.body.appendChild( div ); 77 | 78 | return new Promise( res => { 79 | ReactDOM.render( , div ); 80 | } ); 81 | } 82 | 83 | function destroyEditor() { 84 | return new Promise( res => { 85 | ReactDOM.unmountComponentAtNode( div ); 86 | div.remove(); 87 | 88 | res(); 89 | } ); 90 | } 91 | 92 | return runTest( createEditor, destroyEditor ); 93 | } ); 94 | 95 | // Runs a single test case. 96 | function runTest( createEditor, destroyEditor ) { 97 | let memoryAfterFirstStart; 98 | 99 | return Promise 100 | .resolve() 101 | // Initialize the first editor before measuring the heap size. 102 | // A cold start may allocate a bit of memory on the module-level. 103 | .then( createAndDestroy ) 104 | .then( () => { 105 | return collectMemoryStats().then( mem => { 106 | memoryAfterFirstStart = mem; 107 | } ); 108 | } ) 109 | // Run create&destroy multiple times. Helps scaling up the issue. 110 | .then( createAndDestroy ) // #1 111 | .then( createAndDestroy ) // #2 112 | .then( createAndDestroy ) // #3 113 | .then( createAndDestroy ) // #4 114 | .then( createAndDestroy ) // #5 115 | .then( collectMemoryStats ) 116 | .then( memory => { 117 | const memoryDifference = ( memory as any ).usedJSHeapSize - memoryAfterFirstStart.usedJSHeapSize; 118 | // While theoretically we should get 0KB when there's no memory leak, in reality, 119 | // the results we get (when there are no leaks) vary from -500KB to 500KB (depending on which tests are executed). 120 | // However, when we had memory leaks, memoryDifference was reaching 20MB, 121 | // so, in order to detect significant memory leaks we can expect that the heap won't grow more than 1MB. 122 | expect( memoryDifference, 'used heap size should not grow' ).to.be.at.most( 1e6 ); 123 | } ); 124 | 125 | function createAndDestroy() { 126 | return Promise.resolve() 127 | .then( createEditor ) 128 | .then( destroyEditor ) 129 | .then( () => { 130 | // Simulate real world rerendering queue. Unmounting of editor is *async* so it'll take a while 131 | // to unmount existing instance of editor. 132 | return timeout( 200 ); 133 | } ); 134 | } 135 | } 136 | 137 | function collectMemoryStats() { 138 | return new Promise( resolve => { 139 | // Enforce garbage collection before recording memory stats. 140 | window.gc?.(); 141 | 142 | setTimeout( () => { 143 | const memeInfo = ( window.performance as any ).memory; 144 | 145 | resolve( { 146 | totalJSHeapSize: memeInfo.totalJSHeapSize, 147 | usedJSHeapSize: memeInfo.usedJSHeapSize, 148 | jsHeapSizeLimit: memeInfo.jsHeapSizeLimit 149 | } ); 150 | }, GARBAGE_COLLECTOR_TIMEOUT ); 151 | } ); 152 | } 153 | } ); 154 | 155 | // The windows environment does not cooperate with this tests. 156 | function isWindows() { 157 | const userAgent = window.navigator.userAgent.toLowerCase(); 158 | 159 | return userAgent.indexOf( 'windows' ) > -1; 160 | } 161 | -------------------------------------------------------------------------------- /tests/integrations/ckeditor-editor-data.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, beforeEach, afterEach, it, expect } from 'vitest'; 7 | import React from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | 10 | import CKEditor from '../../src/ckeditor.js'; 11 | 12 | import { TestClassicEditor } from '../_utils/classiceditor.js'; 13 | 14 | const Editor = props => { 15 | return ( 16 | 17 | ); 18 | }; 19 | 20 | class AppUsingState extends React.Component { 21 | public declare editor: any; 22 | public declare props: any; 23 | public declare state: any; 24 | 25 | constructor( props ) { 26 | super( props ); 27 | 28 | this.state = { 29 | content: '' 30 | }; 31 | 32 | this.editor = null; 33 | } 34 | 35 | public render() { 36 | return ( 37 | this.setState( { content: editor.getData() } ) } 40 | onReady={ editor => { 41 | this.editor = editor; 42 | this.props.onReady(); 43 | } } 44 | /> 45 | ); 46 | } 47 | } 48 | 49 | class AppUsingStaticString extends React.Component { 50 | public declare editor: any; 51 | public declare props: any; 52 | public declare state: any; 53 | 54 | constructor( props ) { 55 | super( props ); 56 | 57 | this.state = { 58 | content: '' 59 | }; 60 | 61 | this.editor = null; 62 | } 63 | 64 | public render() { 65 | return ( 66 | Initial data.

' } 68 | onChange={ ( _, editor ) => this.setState( { content: editor.getData() } ) } 69 | onReady={ editor => { 70 | this.editor = editor; 71 | this.props.onReady(); 72 | } } 73 | /> 74 | ); 75 | } 76 | } 77 | 78 | describe( 'CKEditor Component - integration', () => { 79 | describe( 'update the editor\'s content', () => { 80 | // Common usage of the component - a component's state is passed to the component. 81 | describe( '#data is connected with the state', () => { 82 | let div, component; 83 | 84 | beforeEach( () => { 85 | div = document.createElement( 'div' ); 86 | document.body.appendChild( div ); 87 | 88 | return new Promise( resolve => { 89 | component = ReactDOM.render( , div ); 90 | } ); 91 | } ); 92 | 93 | afterEach( () => { 94 | div.remove(); 95 | } ); 96 | 97 | it( 'returns initial state', () => { 98 | const editor = component.editor; 99 | 100 | expect( editor.getData() ).to.equal( '' ); 101 | expect( component.state.content ).to.equal( '' ); 102 | } ); 103 | 104 | it( 'updates the editor\'s content when state has changed', () => { 105 | component.setState( { content: 'Foo.' } ); 106 | 107 | const editor = component.editor; 108 | 109 | // State has been updated because we attached the `onChange` callback. 110 | expect( component.state.content ).to.equal( '

Foo.

' ); 111 | expect( editor.getData() ).to.equal( '

Foo.

' ); 112 | } ); 113 | 114 | it( 'updates state when a user typed something', () => { 115 | const editor = component.editor; 116 | 117 | editor.model.change( writer => { 118 | writer.insertText( 'Plain text.', editor.model.document.selection.getFirstPosition() ); 119 | } ); 120 | 121 | expect( component.state.content ).to.equal( '

Plain text.

' ); 122 | expect( editor.getData() ).to.equal( '

Plain text.

' ); 123 | } ); 124 | } ); 125 | 126 | // Non-common usage but it shouldn't blow or freeze the editor. 127 | describe( '#data is a static string', () => { 128 | let div, component; 129 | 130 | beforeEach( () => { 131 | div = document.createElement( 'div' ); 132 | document.body.appendChild( div ); 133 | 134 | return new Promise( resolve => { 135 | component = ReactDOM.render( , div ); 136 | } ); 137 | } ); 138 | 139 | afterEach( () => { 140 | div.remove(); 141 | } ); 142 | 143 | it( 'returns initial state', () => { 144 | const editor = component.editor; 145 | 146 | expect( component.state.content ).to.equal( '' ); 147 | expect( editor.getData() ).to.equal( '

Initial data.

' ); 148 | } ); 149 | 150 | it( 'updates the editor\'s content when state has changed', () => { 151 | component.setState( { content: 'Foo.' } ); 152 | 153 | const editor = component.editor; 154 | 155 | // The editor's content has not been updated because the component's `[data]` property isn't connected with it. 156 | expect( editor.getData() ).to.equal( '

Initial data.

' ); 157 | expect( component.state.content ).to.equal( 'Foo.' ); 158 | } ); 159 | 160 | it( 'updates state when a user typed something', () => { 161 | const editor = component.editor; 162 | 163 | editor.model.change( writer => { 164 | writer.insertText( 'Plain text. ', editor.model.document.selection.getFirstPosition() ); 165 | } ); 166 | 167 | expect( component.state.content ).to.equal( '

Plain text. Initial data.

' ); 168 | expect( editor.getData() ).to.equal( '

Plain text. Initial data.

' ); 169 | } ); 170 | } ); 171 | } ); 172 | } ); 173 | -------------------------------------------------------------------------------- /tests/integrations/usemultirooteditor-multirooteditor.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, beforeEach, it, expect } from 'vitest'; 7 | import React from 'react'; 8 | import { render, type RenderResult } from '@testing-library/react'; 9 | import useMultiRootEditor from '../../src/useMultiRootEditor.js'; 10 | import { TestMultiRootEditor } from '../_utils/multirooteditor.js'; 11 | 12 | const AppUsingHooks = props => { 13 | const { 14 | editableElements, toolbarElement 15 | } = useMultiRootEditor( props ); 16 | 17 | return ( 18 |
19 |
{ toolbarElement }
20 |
{ editableElements }
21 |
22 | ); 23 | }; 24 | 25 | describe( 'useMultiRootEditor Hook + MultiRootEditor Build', () => { 26 | let component: RenderResult | null = null; 27 | 28 | const data = { 29 | intro: '

Sample

This is an instance of the.

', 30 | content: '

It is the custom content

' 31 | }; 32 | 33 | const rootsAttributes = { 34 | intro: { 35 | row: '1', 36 | order: 10 37 | }, 38 | content: { 39 | row: '1', 40 | order: 20 41 | } 42 | }; 43 | 44 | const editorProps = { 45 | editor: TestMultiRootEditor, 46 | data, 47 | rootsAttributes, 48 | config: { 49 | rootsAttributes 50 | } 51 | }; 52 | 53 | beforeEach( () => { 54 | component?.unmount(); 55 | } ); 56 | 57 | it( 'should initialize the MultiRootEditor properly', async () => { 58 | component = render( ); 59 | 60 | expect( component.getByTestId( 'toolbar' ).children ).to.have.length( 1 ); 61 | expect( component.getByTestId( 'roots' ).children ).to.have.length( 2 ); 62 | } ); 63 | } ); 64 | -------------------------------------------------------------------------------- /tests/issues/349-destroy-context-and-editor.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest'; 7 | import React from 'react'; 8 | import { createRoot } from 'react-dom/client'; 9 | import { Context, ContextWatchdog } from 'ckeditor5'; 10 | 11 | import CKEditor from '../../src/ckeditor.js'; 12 | import CKEditorContext from '../../src/context/ckeditorcontext.js'; 13 | 14 | import { TestClassicEditor } from '../_utils/classiceditor.js'; 15 | 16 | class CustomContext extends Context {} 17 | 18 | class App extends React.Component { 19 | public declare editor: any; 20 | public declare state: any; 21 | public declare props: any; 22 | public declare sidebarElementRef: React.RefObject; 23 | 24 | constructor( props ) { 25 | super( props ); 26 | 27 | this.state = { 28 | // You need this state to render the component after the layout is ready. 29 | // needs HTMLElements of `Sidebar` and `PresenceList` plugins provided through 30 | // the `config` property and you have to ensure that both are already rendered. 31 | isLayoutReady: false 32 | }; 33 | 34 | this.editor = null; 35 | this.sidebarElementRef = React.createRef(); 36 | } 37 | 38 | public componentDidMount() { 39 | // When the layout is ready you can switch the state and render the `` component. 40 | this.setState( { isLayoutReady: true } ); 41 | } 42 | 43 | public render() { 44 | return ( 45 |
46 | { /* Do not render the component before the layout is ready. */ } 47 | { this.state.isLayoutReady && ( 48 | 53 | { 55 | this.props.onReady(); 56 | } } 57 | onChange={ ( event, editor ) => console.log( { event, editor } ) } 58 | editor={ TestClassicEditor } 59 | config={ {} } 60 | data={ '

Paragraph

' } 61 | /> 62 |
63 | ) } 64 |
65 |
66 | ); 67 | } 68 | } 69 | 70 | describe( 'issue #349: crash when destroying an editor with the context', () => { 71 | let div: HTMLElement, root; 72 | 73 | beforeEach( () => { 74 | div = document.createElement( 'div' ); 75 | root = createRoot( div ); 76 | document.body.appendChild( div ); 77 | 78 | return new Promise( resolve => { 79 | root.render( ); 80 | } ); 81 | } ); 82 | 83 | afterEach( () => { 84 | div.remove(); 85 | } ); 86 | 87 | // Watchdog and Context features are asynchronous. They reject a promise instead of throwing an error. 88 | // React does not handle async errors. Hence, we add a custom error handler for unhandled rejections. 89 | it( 'should not crash when destroying an editor with the context feature', async () => { 90 | // Create a stub to describe exact assertions. 91 | const handlerStub = vi.fn(); 92 | 93 | // Save a callback to a variable to remove the listener at the end. 94 | const errorHandler = evt => { 95 | return handlerStub( evt.reason.message ); 96 | }; 97 | 98 | window.addEventListener( 'unhandledrejection', errorHandler ); 99 | 100 | root.unmount(); 101 | 102 | // Does not work with `0`. 103 | await wait( 1 ); 104 | 105 | window.removeEventListener( 'unhandledrejection', errorHandler ); 106 | 107 | expect( handlerStub ).not.toHaveBeenCalledWith( 'Cannot read properties of undefined (reading \'then\')' ); 108 | expect( handlerStub ).not.toHaveBeenCalledWith( 'Cannot read properties of null (reading \'model\')' ); 109 | } ); 110 | } ); 111 | 112 | function wait( ms: number ) { 113 | return new Promise( resolve => { 114 | setTimeout( resolve, ms ); 115 | } ); 116 | } 117 | -------------------------------------------------------------------------------- /tests/issues/354-destroy-editor-inside-context.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, it, expect } from 'vitest'; 7 | import React from 'react'; 8 | import { Context, ContextWatchdog } from 'ckeditor5'; 9 | import { render, waitFor } from '@testing-library/react'; 10 | import CKEditor from '../../src/ckeditor.js'; 11 | import CKEditorContext from '../../src/context/ckeditorcontext.js'; 12 | import { TestClassicEditor } from '../_utils/classiceditor.js'; 13 | import { PromiseManager } from '../_utils/promisemanager.js'; 14 | 15 | class CustomContext extends Context {} 16 | 17 | class App extends React.Component { 18 | public declare props: any; 19 | public declare editor: any; 20 | public declare state: any; 21 | 22 | constructor( props: { onReady: Function; renderEditor?: boolean } ) { 23 | super( props ); 24 | 25 | this.state = { 26 | isLayoutReady: false 27 | }; 28 | 29 | this.editor = null; 30 | } 31 | 32 | public componentDidMount() { 33 | this.setState( { isLayoutReady: true } ); 34 | } 35 | 36 | public render() { 37 | return ( 38 |
39 | { this.state.isLayoutReady && ( 40 | 45 | { this.props.renderEditor && ( 46 | this.props.onReady() } 48 | onChange={ ( event, editor ) => console.log( { event, editor } ) } 49 | editor={ TestClassicEditor } 50 | config={ {} } 51 | data={ '

Paragraph

' } 52 | /> 53 | ) } 54 |
55 | ) } 56 |
57 | ); 58 | } 59 | } 60 | 61 | describe( 'issue #354: unable to destroy the editor within a context', () => { 62 | it( 'should destroy the editor within a context', async () => { 63 | const manager = new PromiseManager(); 64 | const wrapper = render( ); 65 | 66 | await manager.all(); 67 | await waitFor( () => { 68 | expect( wrapper.queryByText( 'Rich Text Editor' ) ).not.to.be.null; 69 | } ); 70 | 71 | wrapper.rerender( 72 | 73 | ); 74 | 75 | await waitFor( () => { 76 | expect( wrapper.queryByText( 'Rich Text Editor' ) ).to.be.null; 77 | } ); 78 | 79 | wrapper.unmount(); 80 | } ); 81 | } ); 82 | -------------------------------------------------------------------------------- /tests/issues/39-frozen-browser.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { describe, beforeEach, afterEach, it, expect } from 'vitest'; 7 | import React from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | 10 | import CKEditor from '../../src/ckeditor.js'; 11 | 12 | import { TestClassicEditor } from '../_utils/classiceditor.js'; 13 | 14 | const Editor = props => { 15 | return ( 16 | 17 | ); 18 | }; 19 | 20 | class App extends React.Component { 21 | public declare editor: any; 22 | public declare props: any; 23 | 24 | constructor( props ) { 25 | super( props ); 26 | 27 | this.state = { 28 | content: '' 29 | }; 30 | 31 | this.editor = null; 32 | } 33 | 34 | public render() { 35 | return ( 36 | this.setState( { content: editor.getData() } ) } 38 | onReady={ editor => { 39 | this.editor = editor; 40 | this.props.onReady(); 41 | } } 42 | /> 43 | ); 44 | } 45 | } 46 | 47 | describe( 'issue #37: the browser is being frozen', () => { 48 | let div, component; 49 | 50 | beforeEach( () => { 51 | div = document.createElement( 'div' ); 52 | document.body.appendChild( div ); 53 | 54 | return new Promise( resolve => { 55 | component = ReactDOM.render( , div ); 56 | } ); 57 | } ); 58 | 59 | afterEach( () => { 60 | div.remove(); 61 | } ); 62 | 63 | it( 'if the "#data" property is not specified, the browser should not freeze', () => { 64 | const editor = component.editor; 65 | 66 | editor.model.change( writer => { 67 | writer.insertText( 'Plain text', editor.model.document.selection.getFirstPosition() ); 68 | } ); 69 | 70 | expect( editor.getData() ).to.equal( '

Plain text

' ); 71 | expect( component.state.content ).to.equal( '

Plain text

' ); 72 | } ); 73 | } ); 74 | -------------------------------------------------------------------------------- /tests/lifecycle/useLifeCycleSemaphoreSyncRef.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { it, describe, expect, vi, beforeEach, afterEach } from 'vitest'; 7 | import { renderHook, act } from '@testing-library/react'; 8 | 9 | import { LifeCycleElementSemaphore } from '../../src/lifecycle/LifeCycleElementSemaphore.js'; 10 | import { useLifeCycleSemaphoreSyncRef } from '../../src/lifecycle/useLifeCycleSemaphoreSyncRef.js'; 11 | 12 | describe( 'useLifeCycleSemaphoreSyncRef', () => { 13 | let semaphore: LifeCycleElementSemaphore; 14 | 15 | beforeEach( () => { 16 | semaphore = new LifeCycleElementSemaphore( document.createElement( 'div' ), { 17 | mount: async () => {}, 18 | unmount: async () => {} 19 | } ); 20 | } ); 21 | 22 | afterEach( () => { 23 | vi.restoreAllMocks(); 24 | vi.clearAllTimers(); 25 | } ); 26 | 27 | it( 'should initialize with null semaphore', () => { 28 | const { result } = renderHook( () => useLifeCycleSemaphoreSyncRef() ); 29 | 30 | expect( result.current.current ).toBeNull(); 31 | } ); 32 | 33 | it( 'should create attribute ref with null value if semaphore is not assigned', () => { 34 | const { result } = renderHook( () => useLifeCycleSemaphoreSyncRef<{ key: string }>() ); 35 | const attributeRef = result.current.createAttributeRef( 'key' ); 36 | 37 | expect( attributeRef.current ).toBeNull(); 38 | } ); 39 | 40 | it( 'should set proper value and refresh if semaphore is assigned', () => { 41 | const { result } = renderHook( () => useLifeCycleSemaphoreSyncRef<{ key: string }>() ); 42 | 43 | act( () => { 44 | result.current.replace( () => semaphore ); 45 | result.current.unsafeSetValue( { key: 'value' } ); 46 | } ); 47 | 48 | expect( result.current.current?.value ).toEqual( { key: 'value' } ); 49 | expect( result.current.revision ).not.toBeNaN(); 50 | } ); 51 | 52 | it( 'should not execute callback passed to `runAfterMount` if semaphore didn\'t resolve anything', async () => { 53 | const runAfterMountSpy = vi.fn(); 54 | const { result } = renderHook( () => useLifeCycleSemaphoreSyncRef() ); 55 | 56 | act( () => { 57 | result.current.replace( () => semaphore ); 58 | result.current.runAfterMount( runAfterMountSpy ); 59 | } ); 60 | 61 | await new Promise( resolve => setTimeout( resolve, 30 ) ); 62 | 63 | expect( runAfterMountSpy ).not.toHaveBeenCalled(); 64 | } ); 65 | 66 | it( 'should execute callback passed to `runAfterMount` if semaphore resolved value', async () => { 67 | const runAfterMountSpy = vi.fn(); 68 | const { result } = renderHook( () => useLifeCycleSemaphoreSyncRef<{ key: string }>() ); 69 | 70 | act( () => { 71 | result.current.replace( () => semaphore ); 72 | result.current.unsafeSetValue( { key: 'value' } ); 73 | result.current.runAfterMount( runAfterMountSpy ); 74 | } ); 75 | 76 | await new Promise( resolve => setTimeout( resolve, 30 ) ); 77 | 78 | expect( runAfterMountSpy ).toHaveBeenCalledWith( { key: 'value' } ); 79 | } ); 80 | 81 | it( 'should reset semaphore and refresh when `release` is called', async () => { 82 | const { result } = renderHook( () => useLifeCycleSemaphoreSyncRef<{ key: string }>() ); 83 | 84 | act( () => { 85 | result.current.replace( () => semaphore ); 86 | result.current.unsafeSetValue( { key: 'value' } ); 87 | } ); 88 | 89 | const currentRevision = result.current.revision; 90 | 91 | await new Promise( resolve => setTimeout( resolve, 30 ) ); 92 | 93 | act( () => { 94 | result.current.release(); 95 | } ); 96 | 97 | expect( result.current.current ).toBeNull(); 98 | expect( result.current.revision ).to.be.greaterThan( currentRevision ); 99 | } ); 100 | 101 | it( 'should create new revision if semaphore is replaced', async () => { 102 | const { result } = renderHook( () => useLifeCycleSemaphoreSyncRef<{ key: string }>() ); 103 | const currentRevision = result.current.revision; 104 | 105 | await new Promise( resolve => setTimeout( resolve, 30 ) ); 106 | 107 | act( () => { 108 | result.current.replace( () => semaphore ); 109 | } ); 110 | 111 | expect( result.current.revision ).to.be.greaterThan( currentRevision ); 112 | } ); 113 | 114 | it( 'should bind semaphore values to attribute refs', () => { 115 | const { result } = renderHook( () => useLifeCycleSemaphoreSyncRef<{ key: string }>() ); 116 | const attributeRef = result.current.createAttributeRef( 'key' ); 117 | 118 | expect( attributeRef.current ).toBeNull(); 119 | 120 | act( () => { 121 | result.current.replace( () => semaphore ); 122 | result.current.unsafeSetValue( { key: 'value' } ); 123 | } ); 124 | 125 | expect( attributeRef.current ).toBe( 'value' ); 126 | } ); 127 | } ); 128 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "emitDeclarationOnly": false, 5 | "noImplicitAny": false, 6 | "types": [ 7 | "@testing-library/jest-dom/vitest", 8 | "../vite-env.d.ts" 9 | ] 10 | }, 11 | "include": [ 12 | "./", 13 | "../src" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tests/utils/mergeRefs.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import type { MutableRefObject } from 'react'; 7 | 8 | import { describe, expect, it, vi } from 'vitest'; 9 | import { mergeRefs } from '../../src/utils/mergeRefs.js'; 10 | 11 | describe( 'mergeRefs', () => { 12 | it( 'should call the callback ref with the provided value', () => { 13 | const callbackRef = vi.fn(); 14 | 15 | mergeRefs( callbackRef )( 'test' ); 16 | expect( callbackRef ).toHaveBeenCalledWith( 'test' ); 17 | } ); 18 | 19 | it( 'should assign the value to the mutable ref object', () => { 20 | const mutableRefObject: MutableRefObject = { current: null }; 21 | 22 | mergeRefs( mutableRefObject )( 'test' ); 23 | expect( mutableRefObject.current ).toBe( 'test' ); 24 | } ); 25 | 26 | it( 'should assign the value to multiple refs', () => { 27 | const callbackRef1 = vi.fn(); 28 | const mutableRefObject1 = { current: null }; 29 | const callbackRef2 = vi.fn(); 30 | const mutableRefObject2 = { current: null }; 31 | 32 | mergeRefs( 33 | callbackRef1, 34 | mutableRefObject1, 35 | callbackRef2, 36 | mutableRefObject2 37 | )( 'test' ); 38 | 39 | expect( callbackRef1 ).toHaveBeenCalledWith( 'test' ); 40 | expect( mutableRefObject1.current ).toBe( 'test' ); 41 | expect( callbackRef2 ).toHaveBeenCalledWith( 'test' ); 42 | expect( mutableRefObject2.current ).toBe( 'test' ); 43 | } ); 44 | 45 | it( 'should handle null refs', () => { 46 | const callbackRef = vi.fn(); 47 | const mutableRefObject = { current: null }; 48 | 49 | mergeRefs( callbackRef, null, mutableRefObject )( null ); 50 | expect( callbackRef ).toHaveBeenCalledWith( null ); 51 | expect( mutableRefObject.current ).toBe( null ); 52 | } ); 53 | } ); 54 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": [ 7 | "ES2019", // Must match the "target" 8 | "ES2020.String", 9 | "DOM", 10 | "DOM.Iterable" 11 | ], 12 | "types": [ 13 | "./vite-env.d.ts" 14 | ], 15 | "skipLibCheck": true, 16 | "jsx": "react", 17 | 18 | /* Bundler mode */ 19 | "moduleResolution": "bundler", 20 | "isolatedModules": true, 21 | "moduleDetection": "force", 22 | 23 | /* Linting */ 24 | "strict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noFallthroughCasesInSwitch": true, 28 | "emitDeclarationOnly": true, 29 | "declaration": true, 30 | "declarationDir": "./dist", 31 | }, 32 | "include": [ 33 | "src" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | declare const __REACT_VERSION__: number; 7 | 8 | declare const __REACT_INTEGRATION_VERSION__: string; 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import { resolve } from 'path'; 7 | import { defineConfig } from 'vitest/config'; 8 | import react from '@vitejs/plugin-react'; 9 | import pkg from './package.json' with { type: 'json' }; 10 | 11 | const REACT_VERSION = Number( process.env.REACT_VERSION ) || 18; 12 | 13 | export default defineConfig( { 14 | plugins: [ 15 | react( { jsxRuntime: 'classic' } ) 16 | ], 17 | 18 | publicDir: false, 19 | 20 | build: { 21 | minify: false, 22 | sourcemap: true, 23 | target: 'es2019', 24 | 25 | // https://vitejs.dev/guide/build#library-mode 26 | lib: { 27 | entry: resolve( __dirname, 'src/index.ts' ), 28 | name: 'CKEDITOR_REACT', 29 | fileName: 'index' 30 | }, 31 | 32 | rollupOptions: { 33 | external: Object.keys( { 34 | ...pkg.dependencies, 35 | ...pkg.peerDependencies 36 | } ), 37 | 38 | output: { 39 | globals: { 40 | 'react': 'React', 41 | '@ckeditor/ckeditor5-integrations-common': 'CKEDITOR_INTEGRATIONS_COMMON' 42 | } 43 | } 44 | } 45 | }, 46 | 47 | // https://vitest.dev/config/ 48 | test: { 49 | setupFiles: [ './vitest-setup.ts' ], 50 | include: [ 51 | 'tests/**/*.test.[j|t]sx' 52 | ], 53 | sequence: { 54 | shuffle: true 55 | }, 56 | coverage: { 57 | provider: 'istanbul', 58 | include: [ 'src/*' ], 59 | exclude: [ 'src/demos' ], 60 | thresholds: { 61 | 100: true 62 | }, 63 | reporter: [ 64 | 'text-summary', 65 | 'text', 66 | 'html', 67 | 'lcovonly', 68 | 'json' 69 | ] 70 | }, 71 | browser: { 72 | enabled: true, 73 | headless: true, 74 | provider: 'webdriverio', 75 | name: 'chrome', 76 | screenshotFailures: false 77 | } 78 | }, 79 | 80 | /** 81 | * Code needed to run the demos using different React versions. 82 | * 83 | * Notice that in `package.json`, aside from the regular `react` and `react-dom` dependencies, 84 | * there are also: 85 | * 86 | * - `react16` and `react16-dom`, 87 | * - `react18` and `react18-dom`, 88 | * - `react19` and `react19-dom`. 89 | * 90 | * These point to the respective React versions, and are used to test the demos with different 91 | * React versions, depending on the `REACT_VERSION` environment variable. 92 | */ 93 | resolve: { 94 | alias: { 95 | 'react': resolve( __dirname, `node_modules/react${ REACT_VERSION }` ), 96 | 'react-dom/client': resolve( __dirname, `node_modules/react${ REACT_VERSION }-dom${ REACT_VERSION <= 17 ? '' : '/client' }` ), 97 | 'react-dom': resolve( __dirname, `node_modules/react${ REACT_VERSION }-dom` ) 98 | } 99 | }, 100 | 101 | define: { 102 | __REACT_VERSION__: REACT_VERSION, 103 | __REACT_INTEGRATION_VERSION__: JSON.stringify( pkg.version ) 104 | } 105 | } ); 106 | -------------------------------------------------------------------------------- /vitest-setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. 3 | * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options 4 | */ 5 | 6 | import '@testing-library/jest-dom/vitest'; 7 | import { cleanup } from '@testing-library/react'; 8 | import { beforeEach, afterEach } from 'vitest'; 9 | 10 | declare global { 11 | interface Window { 12 | CKEDITOR_GLOBAL_LICENSE_KEY: string; 13 | } 14 | } 15 | 16 | window.CKEDITOR_GLOBAL_LICENSE_KEY = 'GPL'; 17 | 18 | beforeEach( cleanup ); 19 | afterEach( cleanup ); 20 | --------------------------------------------------------------------------------