diff --git a/demos/cdn-multiroot-react/App.tsx b/demos/cdn-multiroot-react/App.tsx new file mode 100644 index 00000000..ce6cb0d8 --- /dev/null +++ b/demos/cdn-multiroot-react/App.tsx @@ -0,0 +1,79 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React, { StrictMode, useState } from 'react'; +import MultiRootEditorDemo from './MultiRootEditorDemo'; +import MultiRootEditorRichDemo from './MultiRootEditorRichDemo'; +import ContextMultiRootEditorDemo from './ContextMultiRootEditorDemo'; + +type Demo = 'editor' | 'rich' | 'context'; + +const multiRootEditorContent = { + intro: '

Sample

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

', + content: '

It is the custom content

CKEditor 5 Sample image.
', + outro: '

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

' +}; + +const rootsAttributes = { + intro: { + row: '1', + order: 10 + }, + content: { + row: '1', + order: 20 + }, + outro: { + row: '2', + order: 10 + } +}; + +export default function App(): JSX.Element { + const [ demo, setDemo ] = useState( 'editor' ); + + const renderDemo = () => { + switch ( demo ) { + case 'context': + return ; + case 'editor': + return ; + case 'rich': + return ; + } + }; + + return ( + +

CKEditor 5 – useMultiRootEditor – development sample

+ +
+ + + + + +
+ { renderDemo() } +
+ ); +} diff --git a/demos/cdn-multiroot-react/ContextMultiRootEditorDemo.tsx b/demos/cdn-multiroot-react/ContextMultiRootEditorDemo.tsx new file mode 100644 index 00000000..1672ad0f --- /dev/null +++ b/demos/cdn-multiroot-react/ContextMultiRootEditorDemo.tsx @@ -0,0 +1,142 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React from 'react'; + +import { useMultiRootEditor, type MultiRootHookProps, CKEditorContext, withCKEditorCloud } from '../../src/index.js'; +import { useCKCdnMultiRootEditor } from './useCKCdnMultiRootEditor.js'; + +const ContextEditorDemo = ( { editor }: { editor: any } ): JSX.Element => { + const editorProps: Partial = { + editor, + + onChange: ( event, editor ) => { + console.log( 'event: onChange', { event, editor } ); + }, + onBlur: ( event, editor ) => { + console.log( 'event: onBlur', { event, editor } ); + }, + onFocus: ( event, editor ) => { + console.log( 'event: onFocus', { event, editor } ); + } + }; + + // First editor initialization. + const { + editor: editor1, editableElements: editableElements1, toolbarElement: toolbarElement1 + } = useMultiRootEditor( { + ...editorProps, + data: { + intro: '

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

', + content: '

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

' + }, + + onReady: editor => { + window.editor1 = editor; + + console.log( 'event: onChange', { editor } ); + } + } as MultiRootHookProps ); + + // Second editor initialization. + const { + editor: editor2, editableElements: editableElements2, toolbarElement: toolbarElement2 + } = useMultiRootEditor( { + ...editorProps, + data: { + notes: '

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

' + }, + + onReady: editor => { + window.editor2 = editor; + + console.log( 'event: onChange', { editor } ); + } + } as MultiRootHookProps ); + + // Function to simulate an error in the editor. + // It is used for testing purposes to trigger the Watchdog to restart the editor. + // Remove it in the actual integration. + const simulateError = ( editor: any ) => { + setTimeout( () => { + const err: any = new Error( 'foo' ); + + err.context = editor; + err.is = () => true; + + throw err; + } ); + }; + + return ( + <> +

Context Multi-root Editor Demo

+

+ This sample demonstrates integration with CKEditorContext.
+

+

Component's events are logged to the console.

+

+ +
+
+ +
+ + { toolbarElement1 } + +
+ { editableElements1 } +
+
+ +
+ +
+
+ +
+ + { toolbarElement2 } + +
+ { editableElements2 } +
+
+ + ); +}; + +const withCKCloud = withCKEditorCloud( { + cloud: { + version: '42.0.1', + languages: [ 'en', 'de' ], + withPremiumFeatures: true + } +} ); + +const ContextMultiRootEditorDemo = withCKCloud( ( { cloud } ): JSX.Element => { + const MultiRootEditor = useCKCdnMultiRootEditor( cloud ); + + return ( + + + + ); +} ); + +export default ContextMultiRootEditorDemo; diff --git a/demos/cdn-multiroot-react/MultiRootEditorDemo.tsx b/demos/cdn-multiroot-react/MultiRootEditorDemo.tsx new file mode 100644 index 00000000..ed461105 --- /dev/null +++ b/demos/cdn-multiroot-react/MultiRootEditorDemo.tsx @@ -0,0 +1,55 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React, { type ReactNode } from 'react'; + +import { useCKCdnMultiRootEditor } from './useCKCdnMultiRootEditor.js'; +import { + useMultiRootEditor, withCKEditorCloud, + type MultiRootHookProps, + type WithCKEditorCloudHocProps +} from '../../src/index.js'; + +type EditorDemoProps = WithCKEditorCloudHocProps & { + data: Record; + rootsAttributes: Record>; +}; + +const withCKCloud = withCKEditorCloud( { + cloud: { + version: '42.0.1', + languages: [ 'en', 'de' ], + withPremiumFeatures: true + } +} ); + +const MultiRootEditorDemo = withCKCloud( ( { data, cloud }: EditorDemoProps ): ReactNode => { + const MultiRootEditor = useCKCdnMultiRootEditor( cloud ); + const editorProps: MultiRootHookProps = { + editor: MultiRootEditor as any, + data + }; + + const { toolbarElement, editableElements } = useMultiRootEditor( editorProps ); + + return ( + <> +

Multi-root Editor Demo

+

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

+

+ +
+ { toolbarElement } + + { editableElements } +
+ + ); +} ); + +export default MultiRootEditorDemo; diff --git a/demos/cdn-multiroot-react/MultiRootEditorRichDemo.tsx b/demos/cdn-multiroot-react/MultiRootEditorRichDemo.tsx new file mode 100644 index 00000000..17d579e6 --- /dev/null +++ b/demos/cdn-multiroot-react/MultiRootEditorRichDemo.tsx @@ -0,0 +1,237 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React, { useState, type ChangeEvent } from 'react'; + +import { + useMultiRootEditor, withCKEditorCloud, + type WithCKEditorCloudHocProps, type MultiRootHookProps +} from '../../src/index.js'; + +import { useCKCdnMultiRootEditor } from './useCKCdnMultiRootEditor.js'; + +const SAMPLE_READ_ONLY_LOCK_ID = 'Integration Sample'; + +type EditorDemoProps = WithCKEditorCloudHocProps & { + data: Record; + rootsAttributes: Record>; +}; + +const withCKCloud = withCKEditorCloud( { + cloud: { + version: '42.0.1', + languages: [ 'en', 'de' ], + withPremiumFeatures: true + } +} ); + +const MultiRootEditorRichDemo = withCKCloud( ( props: EditorDemoProps ): JSX.Element => { + const MultiRootEditor = useCKCdnMultiRootEditor( props.cloud ); + const editorProps: MultiRootHookProps = { + editor: MultiRootEditor as any, + data: props.data, + rootsAttributes: props.rootsAttributes, + + onReady: editor => { + // @ts-expect-error: Caused by linking to parent project and conflicting react types + window.editor = editor; + + console.log( 'event: onChange', { editor } ); + }, + onChange: ( event, editor ) => { + console.log( 'event: onChange', { event, editor } ); + }, + onBlur: ( event, editor ) => { + console.log( 'event: onBlur', { event, editor } ); + }, + onFocus: ( event, editor ) => { + console.log( 'event: onFocus', { event, editor } ); + }, + + config: { + rootsAttributes: props.rootsAttributes + } + }; + + const { + editor, editableElements, toolbarElement, + data, setData, + attributes, setAttributes + } = useMultiRootEditor( editorProps ); + + // The element state with number of roots that should be added in one row. + // This is for demo purposes, and you may remove it in the actual integration or change accordingly to your needs. + const [ numberOfRoots, setNumberOfRoots ] = useState( 1 ); + + // A set with disabled roots. It is used to support read-only feature in multi root editor. + // This is for demo purposes, and you may remove it in the actual integration or change accordingly to your needs. + const [ disabledRoots, setDisabledRoots ] = useState>( new Set() ); + + // Function to toggle read-only mode for selected root. + const toggleReadOnly = () => { + const root = editor!.model.document.selection.getFirstRange()!.root; + + if ( !root || !root.rootName ) { + return; + } + + const isReadOnly = disabledRoots.has( root.rootName ); + + if ( isReadOnly ) { + disabledRoots.delete( root.rootName ); + editor!.enableRoot( root.rootName, SAMPLE_READ_ONLY_LOCK_ID ); + } else { + disabledRoots.add( root.rootName ); + editor!.disableRoot( root.rootName, SAMPLE_READ_ONLY_LOCK_ID ); + } + + setDisabledRoots( new Set( disabledRoots ) ); + }; + + // Function to simulate an error in the editor. + // It is used for testing purposes to trigger the Watchdog to restart the editor. + // Remove it in the actual integration. + const simulateError = () => { + setTimeout( () => { + const err: any = new Error( 'foo' ); + + err.context = editor; + err.is = () => true; + + throw err; + } ); + }; + + const addRoot = ( newRootAttributes: Record, rootId?: string ) => { + const id = rootId || new Date().getTime(); + + for ( let i = 1; i <= numberOfRoots; i++ ) { + const rootName = `root-${ i }-${ id }`; + + data[ rootName ] = ''; + + // Remove code related to rows if you don't need to handle multiple roots in one row. + attributes[ rootName ] = { ...newRootAttributes, order: i * 10, row: id }; + } + + setData( { ...data } ); + setAttributes( { ...attributes } ); + // Reset the element to the default value. + setNumberOfRoots( 1 ); + }; + + const removeRoot = ( rootName: string ) => { + setData( previousData => { + const { [ rootName! ]: _, ...newData } = previousData; + + return { ...newData }; + } ); + + setSelectedRoot( '' ); + }; + + // Group elements based on their row attribute and sort them by order attribute. + // Grouping in a row is used for presentation purposes, and you may remove it in actual integration. + // However, we recommend ordering the roots, so that rows are put in a correct places when undo/redo is used. + const groupedElements = Object.entries( + editableElements + .sort( ( a, b ) => ( attributes[ a.props.id ].order as number ) - ( attributes[ b.props.id ].order as number ) ) + .reduce( ( acc: Record>, element ) => { + const row = attributes[ element.props.id ].row as string; + acc[ row ] = acc[ row ] || []; + acc[ row ].push( element ); + + return acc; + }, {} ) + ); + + return ( + <> +

Multi-root Editor Demo (rich integration)

+

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

+

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

+

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

+

Component's events are logged to the console.

+

+ +
+ + + +
+ +
+ + + +
+ +
+ + + Number( e.target.value ) <= 4 && setNumberOfRoots( Number( e.target.value ) )} + /> +
+ +
+ + { toolbarElement } + + { /* Maps through `groupedElements` array to render rows that contains the editor roots. */ } + { groupedElements.map( ( [ row, elements ] ) => ( +
+ { elements } +
+ ) ) } + + ); +} ); + +export default MultiRootEditorRichDemo; diff --git a/demos/cdn-multiroot-react/index.html b/demos/cdn-multiroot-react/index.html new file mode 100644 index 00000000..8cc42a8a --- /dev/null +++ b/demos/cdn-multiroot-react/index.html @@ -0,0 +1,54 @@ + + + + + + CKEditor 5 – React Multi Root Component – demo + + + + + +
+ + + diff --git a/demos/cdn-multiroot-react/main.tsx b/demos/cdn-multiroot-react/main.tsx new file mode 100644 index 00000000..26eda597 --- /dev/null +++ b/demos/cdn-multiroot-react/main.tsx @@ -0,0 +1,25 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React from 'react'; +import App from './App'; + +const element = document.getElementById( 'root' ) as HTMLDivElement; + +if ( __REACT_VERSION__ === 16 ) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const ReactDOM = await import( 'react-dom' ); + + ReactDOM.render( React.createElement( App ), element ); +} else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { createRoot } = await import( 'react-dom/client' ); + + createRoot( element ).render( ); +} + +console.log( `%cVersion of React used: ${ React.version }`, 'color:red;font-weight:bold;' ); diff --git a/demos/cdn-multiroot-react/useCKCdnMultiRootEditor.tsx b/demos/cdn-multiroot-react/useCKCdnMultiRootEditor.tsx new file mode 100644 index 00000000..b156404e --- /dev/null +++ b/demos/cdn-multiroot-react/useCKCdnMultiRootEditor.tsx @@ -0,0 +1,100 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import type { MultiRootEditor } from 'https://cdn.ckeditor.com/typings/ckeditor5.d.ts'; +import type { CKEditorCloudResult } from '../../src/index.js'; + +export const useCKCdnMultiRootEditor = ( cloud: CKEditorCloudResult ): typeof MultiRootEditor => { + const { + MultiRootEditor: MultiRootEditorBase, + CloudServices, + Essentials, + CKFinderUploadAdapter, + Autoformat, + Bold, + Italic, + BlockQuote, + CKBox, + CKFinder, + EasyImage, + Heading, + Image, + ImageCaption, + ImageStyle, + ImageToolbar, + ImageUpload, + Indent, + Link, + List, + MediaEmbed, + Paragraph, + PasteFromOffice, + PictureEditing, + Table, + TableToolbar, + TextTransformation + } = cloud.CKEditor; + + return class MultiRootEditor extends MultiRootEditorBase { + public static override builtinPlugins = [ + Essentials, + CKFinderUploadAdapter, + Autoformat, + Bold, + Italic, + BlockQuote, + CKBox, + CKFinder, + CloudServices, + EasyImage, + Heading, + Image, + ImageCaption, + ImageStyle, + ImageToolbar, + ImageUpload, + Indent, + Link, + List, + MediaEmbed, + Paragraph, + PasteFromOffice, + PictureEditing, + Table, + TableToolbar, + TextTransformation + ]; + + public static override defaultConfig = { + toolbar: { + items: [ + 'undo', 'redo', + '|', 'heading', + '|', 'bold', 'italic', + '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', + '|', 'bulletedList', 'numberedList', 'outdent', 'indent' + ] + }, + image: { + toolbar: [ + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:side', + '|', + 'toggleImageCaption', + 'imageTextAlternative' + ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + }, + language: 'en' + }; + }; +}; diff --git a/demos/cdn-react/App.tsx b/demos/cdn-react/App.tsx new file mode 100644 index 00000000..35eeb587 --- /dev/null +++ b/demos/cdn-react/App.tsx @@ -0,0 +1,56 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React, { useState, type ReactNode } from 'react'; + +import { CKEditorCloudDemo } from './CKEditorCloudDemo'; +import { CKEditorCloudPluginsDemo } from './CKEditorCloudPluginsDemo'; +import { CKEditorCKBoxCloudDemo } from './CKEditorCKBoxCloudDemo'; + +const EDITOR_CONTENT = ` +

Sample

+

This is an instance of the + classic editor build. +

+
+ CKEditor 5 Sample image. +
+

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

+`; + +const DEMOS = [ 'Editor', 'CKBox', 'Cloud Plugins' ] as const; + +type Demo = ( typeof DEMOS )[ number ]; + +export const App = (): ReactNode => { + const [ currentDemo, setCurrentDemo ] = useState( 'Editor' ); + + const content = ( { + Editor: , + CKBox: , + 'Cloud Plugins': + } )[ currentDemo ]; + + return ( + +

CKEditor 5 – React Component – CDN demo

+ +
+ { DEMOS.map( demo => ( + + ) ) } +
+ + { content } +
+ ); +}; diff --git a/demos/cdn-react/CKEditorCKBoxCloudDemo.tsx b/demos/cdn-react/CKEditorCKBoxCloudDemo.tsx new file mode 100644 index 00000000..e06983c5 --- /dev/null +++ b/demos/cdn-react/CKEditorCKBoxCloudDemo.tsx @@ -0,0 +1,75 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React, { type ReactNode } from 'react'; +import { getCKCdnClassicEditor } from './getCKCdnClassicEditor.js'; +import { CKEditor, useCKEditorCloud } from '../../src/index.js'; + +type CKEditorCKBoxCloudDemoProps = { + content: string; +}; + +export const CKEditorCKBoxCloudDemo = ( { content }: CKEditorCKBoxCloudDemoProps ): ReactNode => { + const cloud = useCKEditorCloud( { + version: '42.0.1', + withPremiumFeatures: true, + withCKBox: { + version: '2.5.1' + } + } ); + + if ( cloud.status === 'error' ) { + console.error( cloud ); + } + + if ( cloud.status !== 'success' ) { + return
Loading...
; + } + + const { CKBox, CKBoxImageEdit } = cloud.CKEditor; + const CKEditorClassic = getCKCdnClassicEditor( { + cloud, + additionalPlugins: [ + CKBox, + CKBoxImageEdit + ], + overrideConfig: { + toolbar: { + items: [ + 'undo', 'redo', + '|', 'heading', + '|', 'bold', 'italic', + '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', + '|', 'bulletedList', 'numberedList', 'outdent', 'indent', + '|', 'ckbox', 'ckboxImageEdit' + ] + }, + image: { + toolbar: [ + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:side', + '|', + 'toggleImageCaption', + 'imageTextAlternative', + '|', + 'ckboxImageEdit' + ] + }, + ckbox: { + tokenUrl: 'https://api.ckbox.io/token/demo', + forceDemoLabel: true, + allowExternalImagesEditing: [ /^data:/, /^i.imgur.com\//, 'origin' ] + } + } + } ); + + return ( + + ); +}; diff --git a/demos/cdn-react/CKEditorCloudDemo.tsx b/demos/cdn-react/CKEditorCloudDemo.tsx new file mode 100644 index 00000000..9f00ae04 --- /dev/null +++ b/demos/cdn-react/CKEditorCloudDemo.tsx @@ -0,0 +1,38 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React, { type ReactNode } from 'react'; +import { getCKCdnClassicEditor } from './getCKCdnClassicEditor.js'; +import { CKEditor, useCKEditorCloud } from '../../src/index.js'; + +type CKEditorCloudDemoProps = { + content: string; +}; + +export const CKEditorCloudDemo = ( { content }: CKEditorCloudDemoProps ): ReactNode => { + const cloud = useCKEditorCloud( { + version: '42.0.1', + withPremiumFeatures: true + } ); + + if ( cloud.status === 'error' ) { + console.error( cloud ); + } + + if ( cloud.status !== 'success' ) { + return
Loading...
; + } + + const CKEditorClassic = getCKCdnClassicEditor( { + cloud + } ); + + return ( + + ); +}; diff --git a/demos/cdn-react/CKEditorCloudPluginsDemo.tsx b/demos/cdn-react/CKEditorCloudPluginsDemo.tsx new file mode 100644 index 00000000..ebfcbf0f --- /dev/null +++ b/demos/cdn-react/CKEditorCloudPluginsDemo.tsx @@ -0,0 +1,75 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React, { type ReactNode } from 'react'; + +import type { Plugin } from 'https://cdn.ckeditor.com/typings/ckeditor5.d.ts'; + +import { getCKCdnClassicEditor } from './getCKCdnClassicEditor.js'; +import { CKEditor, useCKEditorCloud } from '../../src/index.js'; + +type CKEditorCloudPluginsDemoProps = { + content: string; +}; + +declare global { + interface Window { + '@wiris/mathtype-ckeditor5': typeof Plugin; + } +} + +export const CKEditorCloudPluginsDemo = ( { content }: CKEditorCloudPluginsDemoProps ): ReactNode => { + const cloud = useCKEditorCloud( { + version: '42.0.1', + languages: [ 'pl', 'en', 'de' ], + withPremiumFeatures: true, + plugins: { + Wiris: { + scripts: [ + 'https://www.wiris.net/demo/plugins/app/WIRISplugins.js', + 'https://cdn.jsdelivr.net/npm/@wiris/mathtype-ckeditor5@8.10.0/dist/browser/index.umd.js' + ], + stylesheets: [ + 'https://cdn.jsdelivr.net/npm/@wiris/mathtype-ckeditor5@8.10.0/dist/browser/index.css' + ], + getExportedEntries: () => window[ '@wiris/mathtype-ckeditor5' ] + } + } + } ); + + if ( cloud.status === 'error' ) { + console.error( cloud ); + } + + if ( cloud.status !== 'success' ) { + return
Loading...
; + } + + const CKEditorClassic = getCKCdnClassicEditor( { + cloud, + additionalPlugins: [ + cloud.CKPlugins!.Wiris + ], + overrideConfig: { + toolbar: { + items: [ + 'undo', 'redo', + '|', 'heading', + '|', 'bold', 'italic', + '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', + '|', 'bulletedList', 'numberedList', 'outdent', 'indent', + '|', 'MathType', 'ChemType' + ] + } + } + } ); + + return ( + + ); +}; diff --git a/demos/cdn-react/getCKCdnClassicEditor.ts b/demos/cdn-react/getCKCdnClassicEditor.ts new file mode 100644 index 00000000..9423ea13 --- /dev/null +++ b/demos/cdn-react/getCKCdnClassicEditor.ts @@ -0,0 +1,104 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import type { ClassicEditor, Plugin, ContextPlugin, EditorConfig } from 'https://cdn.ckeditor.com/typings/ckeditor5.d.ts'; +import type { CKEditorCloudResult } from '../../src'; + +type ClassicEditorCreatorConfig = { + cloud: CKEditorCloudResult; + additionalPlugins?: Array; + overrideConfig?: EditorConfig; +}; + +export const getCKCdnClassicEditor = ( { + cloud, additionalPlugins, overrideConfig +}: ClassicEditorCreatorConfig ): typeof ClassicEditor => { + const { + ClassicEditor: ClassicEditorBase, + Essentials, + Autoformat, + Bold, + Italic, + BlockQuote, + CloudServices, + Heading, + Image, + ImageCaption, + ImageStyle, + ImageToolbar, + ImageUpload, + Indent, + Link, + List, + MediaEmbed, + Paragraph, + PasteFromOffice, + PictureEditing, + Table, + TableToolbar, + TextTransformation + } = cloud.CKEditor; + + class CustomEditor extends ClassicEditorBase { + public static builtinPlugins = [ + Essentials, + Autoformat, + Bold, + Italic, + BlockQuote, + Heading, + Image, + ImageCaption, + ImageStyle, + ImageToolbar, + ImageUpload, + Indent, + Link, + List, + MediaEmbed, + Paragraph, + PasteFromOffice, + PictureEditing, + Table, + TableToolbar, + TextTransformation, + CloudServices, + ...additionalPlugins || [] + ]; + + public static defaultConfig = { + toolbar: { + items: [ + 'undo', 'redo', + '|', 'heading', + '|', 'bold', 'italic', + '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', + '|', 'bulletedList', 'numberedList', 'outdent', 'indent' + ] + }, + image: { + toolbar: [ + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:side', + '|', + 'toggleImageCaption', + 'imageTextAlternative' + ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + }, + language: 'en', + ...overrideConfig + }; + } + + return CustomEditor; +}; diff --git a/demos/cdn-react/index.html b/demos/cdn-react/index.html new file mode 100644 index 00000000..96af53e7 --- /dev/null +++ b/demos/cdn-react/index.html @@ -0,0 +1,33 @@ + + + + + + CKEditor 5 – React Component – demo + + + + + + +
+ + + diff --git a/demos/cdn-react/main.tsx b/demos/cdn-react/main.tsx new file mode 100644 index 00000000..8a5a2254 --- /dev/null +++ b/demos/cdn-react/main.tsx @@ -0,0 +1,25 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React from 'react'; +import { App } from './App'; + +const element = document.getElementById( 'root' ) as HTMLDivElement; + +if ( __REACT_VERSION__ === 16 ) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const ReactDOM = await import( 'react-dom' ); + + ReactDOM.render( React.createElement( App ), element ); +} else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { createRoot } = await import( 'react-dom/client' ); + + createRoot( element ).render( ); +} + +console.log( `%cVersion of React used: ${ React.version }`, 'color:red;font-weight:bold;' ); diff --git a/demos/react/ContextDemo.tsx b/demos/react/ContextDemo.tsx index fc4be8cb..8901cdfd 100644 --- a/demos/react/ContextDemo.tsx +++ b/demos/react/ContextDemo.tsx @@ -4,13 +4,16 @@ */ import React, { useState } from 'react'; + +import type { Editor } from 'ckeditor5'; + import ClassicEditor from './ClassicEditor'; import { CKEditor, CKEditorContext } from '../../src/index.js'; declare global { interface Window { - editor1: ClassicEditor | null; - editor2: ClassicEditor | null; + editor1: Editor | null; + editor2: Editor | null; } } diff --git a/index.html b/index.html index 3be1d08e..aa77d9fa 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,9 @@ Editor Multiroot editor + + CDN Editor + CDN Multiroot editor diff --git a/package.json b/package.json index 16766ca2..3943991e 100644 --- a/package.json +++ b/package.json @@ -28,13 +28,17 @@ "./package.json": "./package.json" }, "dependencies": { - "prop-types": "^15.7.2" + "prop-types": "^15.7.2", + "@ckeditor/ckeditor5-integrations-common": "file:../ckeditor5-integrations-common" }, "peerDependencies": { "ckeditor5": ">=42.0.0 || ^0.0.0-nightly", + "ckeditor5-premium-features": ">=42.0.0 || ^0.0.0-nightly", + "@ckeditor/ckeditor5-integrations-common": "file:../ckeditor5-integrations-common", "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.8", "@ckeditor/ckeditor5-dev-bump-year": "^40.0.0", "@ckeditor/ckeditor5-dev-ci": "^40.0.0", "@ckeditor/ckeditor5-dev-release-tools": "^40.0.0", @@ -48,6 +52,7 @@ "@vitest/coverage-istanbul": "^2.0.0", "@vitest/ui": "^2.0.0", "ckeditor5": "^42.0.0", + "ckeditor5-premium-features": "^42.0.0", "coveralls": "^3.1.1", "eslint": "^7.19.0", "eslint-config-ckeditor5": "^5.3.2", diff --git a/src/ckeditor.tsx b/src/ckeditor.tsx index 456e1fe0..4213fb27 100644 --- a/src/ckeditor.tsx +++ b/src/ckeditor.tsx @@ -21,7 +21,7 @@ import type { import type { EditorSemaphoreMountResult } from './lifecycle/LifeCycleEditorSemaphore'; -import { uid } from './utils/uid'; +import { uid } from '@ckeditor/ckeditor5-integrations-common'; import { LifeCycleElementSemaphore } from './lifecycle/LifeCycleElementSemaphore'; import { diff --git a/src/cloud/useCKEditorCloud.tsx b/src/cloud/useCKEditorCloud.tsx new file mode 100644 index 00000000..a6769813 --- /dev/null +++ b/src/cloud/useCKEditorCloud.tsx @@ -0,0 +1,65 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { + loadCKEditorCloud, + type CKExternalPluginsMap, + type CKEditorCloudConfig, + type CKEditorCloudResult +} from '@ckeditor/ckeditor5-integrations-common'; + +import { useAsyncValue } from '../hooks/useAsyncValue'; +import type { AsyncCallbackState } from '../hooks/useAsyncCallback'; + +/** + * Hook that loads CKEditor bundles from CDN. + * + * @template A The type of the additional resources to load. + * @param config The configuration of the hook. + * @returns The state of async operation that resolves to the CKEditor bundles. + * @example + * + * ```ts + * const cloud = useCKEditorCloud( { + * version: '42.0.0', + * languages: [ 'en', 'de' ], + * withPremiumFeatures: true + * } ); + * + * if ( cloud.status === 'success' ) { + * const { ClassicEditor, Bold, Essentials } = cloud.CKEditor; + * const { SlashCommand } = cloud.CKEditorPremiumFeatures; + * } + * ``` + */ +export default function useCKEditorCloud( + config: CKEditorCloudConfig +): CKEditorCloudHookResult { + // Serialize the config to a string to fast compare if there was a change and re-render is needed. + const serializedConfigKey = JSON.stringify( config ); + + // Fetch the CKEditor Cloud Services bundles on every modification of config. + const result = useAsyncValue( + async (): Promise> => loadCKEditorCloud( config ), + [ serializedConfigKey ] + ); + + // Expose a bit better API for the hook consumers, so they don't need to access the constructor through the `data` property. + if ( result.status === 'success' ) { + return { + ...result.data, + status: 'success' + }; + } + + return result; +} + +/** + * The result of the `useCKEditorCloud` hook. It changes success state to be more intuitive. + */ +type CKEditorCloudHookResult = + | Exclude>, { status: 'success' }> + | ( CKEditorCloudResult & { status: 'success' } ); diff --git a/src/cloud/withCKEditorCloud.tsx b/src/cloud/withCKEditorCloud.tsx new file mode 100644 index 00000000..ed0506b4 --- /dev/null +++ b/src/cloud/withCKEditorCloud.tsx @@ -0,0 +1,108 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React, { type ReactNode, type ComponentType } from 'react'; +import type { + CKEditorCloudConfig, + CKEditorCloudResult, + CKExternalPluginsMap +} from '@ckeditor/ckeditor5-integrations-common'; + +import useCKEditorCloud from './useCKEditorCloud'; + +/** + * HOC that injects the CKEditor Cloud integration into a component. + * + * @template A The type of the additional resources to load. + * @param config The configuration of the CKEditor Cloud integration. + * @returns A function that injects the CKEditor Cloud integration into a component. + * @example + + * ```tsx + * const withCKCloud = withCKEditorCloud( { + * cloud: { + * version: '42.0.0', + * languages: [ 'en', 'de' ], + * withPremiumFeatures: true + * } + * } ); + * + * const MyComponent = withCKCloud( ( { cloud } ) => { + * const { Paragraph } = cloud.CKEditor; + * const { SlashCommands } = cloud.CKEditorPremiumFeatures; + * const { YourPlugin } = cloud.CKPlugins; + * + * return
CKEditor Cloud is loaded!
; + * } ); + * ``` + */ +const withCKEditorCloud =
( config: CKEditorCloudHocConfig ) => +

( + WrappedComponent: ComponentType & P> + ): ComponentType> => { + const ComponentWithCKEditorCloud = ( props: Omit ) => { + const ckeditorCloudResult = useCKEditorCloud( config.cloud ); + + switch ( ckeditorCloudResult.status ) { + // An error occurred while fetching the cloud information. + case 'error': + if ( !config.renderError ) { + return 'Unable to load CKEditor Cloud data!'; + } + + return config.renderError( ckeditorCloudResult.error ); + + // The cloud information has been fetched successfully. + case 'success': + return ; + + // The cloud information is being fetched. + default: + return config.renderLoader?.() ?? null; + } + }; + + ComponentWithCKEditorCloud.displayName = 'ComponentWithCKEditorCloud'; + + return ComponentWithCKEditorCloud; + }; + +export default withCKEditorCloud; + +/** + * Props injected by the `withCKEditorCloud` HOC. + * + * @template A The type of the additional resources to load. + */ +export type WithCKEditorCloudHocProps = { + + /** + * The result of the CKEditor Cloud integration. + */ + cloud: CKEditorCloudResult; +}; + +/** + * The configuration of the CKEditor Cloud integration. + * + * @template A The type of the additional resources to load. + */ +type CKEditorCloudHocConfig = { + + /** + * The configuration of the CKEditor Cloud integration. + */ + cloud: CKEditorCloudConfig; + + /** + * Component to render while the cloud information is being fetched. + */ + renderLoader?: () => ReactNode; + + /** + * Component to render when an error occurs while fetching the cloud information. + */ + renderError?: ( error: any ) => ReactNode; +}; diff --git a/src/context/ckeditorcontext.tsx b/src/context/ckeditorcontext.tsx index 8c607f36..351dec20 100644 --- a/src/context/ckeditorcontext.tsx +++ b/src/context/ckeditorcontext.tsx @@ -9,8 +9,8 @@ import React, { type ReactElement } from 'react'; +import { uid } from '@ckeditor/ckeditor5-integrations-common'; import { useIsMountedRef } from '../hooks/useIsMountedRef'; -import { uid } from '../utils/uid'; import { useInitializedCKEditorsMap, type InitializedContextEditorsConfig diff --git a/src/hooks/useAsyncCallback.ts b/src/hooks/useAsyncCallback.ts new file mode 100644 index 00000000..6c8b1f38 --- /dev/null +++ b/src/hooks/useAsyncCallback.ts @@ -0,0 +1,125 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { useState, useRef } from 'react'; +import { uid, isSSR } from '@ckeditor/ckeditor5-integrations-common'; + +import { useIsUnmountedRef } from './useIsUnmountedRef'; +import { useRefSafeCallback } from './useRefSafeCallback'; + +/** + * A hook that allows to execute an asynchronous function and provides the state of the execution. + * + * @param callback The asynchronous function to be executed. + * @returns A tuple with the function that triggers the execution and the state of the execution. + * + * @example + * ```tsx + * const [ onFetchData, fetchDataStatus ] = useAsyncCallback( async () => { + * const response = await fetch( 'https://api.example.com/data' ); + * const data = await response.json(); + * return data; + * } ); + * + * return ( + *

+ * ); + * ``` + */ +export const useAsyncCallback = , R>( + callback: ( ...args: Array ) => Promise +): AsyncCallbackHookResult => { + // The state of the asynchronous callback. + const [ asyncState, setAsyncState ] = useState>( { + status: 'idle' + } ); + + // A reference to the mounted state of the component. + const unmountedRef = useIsUnmountedRef(); + + // A reference to the previous execution UUID. It is used to prevent race conditions between multiple executions + // of the asynchronous function. If the UUID of the current execution is different than the UUID of the previous + // execution, the state is not updated. + const prevExecutionUIDRef = useRef( null ); + + // The asynchronous executor function, which is a wrapped version of the original callback. + const asyncExecutor = useRefSafeCallback( async ( ...args: Array ) => { + if ( unmountedRef.current || isSSR() ) { + return null; + } + + const currentExecutionUUID = uid(); + prevExecutionUIDRef.current = currentExecutionUUID; + + try { + // Prevent unnecessary state updates, keep loading state if the status is already 'loading'. + if ( asyncState.status !== 'loading' ) { + setAsyncState( { + status: 'loading' + } ); + } + + // Execute the asynchronous function. + const result = await callback( ...args ); + + // Update the state if the component is still mounted and the execution UUID matches the previous one, otherwise + // ignore the result and keep the previous state. + if ( !unmountedRef.current && prevExecutionUIDRef.current === currentExecutionUUID ) { + setAsyncState( { + status: 'success', + data: result + } ); + } + + return result; + } catch ( error: any ) { + console.error( error ); + + // Update the state if the component is still mounted and the execution UUID matches the previous one, otherwise + if ( !unmountedRef.current && prevExecutionUIDRef.current === currentExecutionUUID ) { + setAsyncState( { + status: 'error', + error + } ); + } + } + + return null; + } ); + + return [ asyncExecutor, asyncState ] as AsyncCallbackHookResult; +}; + +/** + * Represents the result of the `useAsyncCallback` hook. + */ +export type AsyncCallbackHookResult, R> = [ + ( ...args: Array ) => Promise, + AsyncCallbackState +]; + +/** + * Represents the state of an asynchronous callback. + */ +export type AsyncCallbackState = + | { + status: 'idle'; + } + | { + status: 'loading'; + } + | { + status: 'success'; + data: T; + } + | { + status: 'error'; + error: any; + }; diff --git a/src/hooks/useAsyncValue.ts b/src/hooks/useAsyncValue.ts new file mode 100644 index 00000000..d46bfaf9 --- /dev/null +++ b/src/hooks/useAsyncValue.ts @@ -0,0 +1,49 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import type { DependencyList } from 'react'; + +import { useInstantEffect } from './useInstantEffect'; +import { useAsyncCallback, type AsyncCallbackState } from './useAsyncCallback'; + +/** + * A hook that allows to execute an asynchronous function and provides the state of the execution. + * The asynchronous function is executed immediately after the component is mounted. + * + * @param callback The asynchronous function to be executed. + * @param deps The dependency list. + * @returns The state of the execution. + * + * @example + * ```tsx + * const asyncFetchState = useAsyncValue( async () => { + * const response = await fetch( 'https://api.example.com/data' ); + * const data = await response.json(); + * return data; + * }, [] ); + * + * if ( asyncFetchState.status === 'loading' ) { + * return

Loading...

; + * } + * + * if ( asyncFetchState.status === 'success' ) { + * return
{ JSON.stringify( asyncFetchState.data, null, 2 ) }
; + * } + * + * if ( asyncFetchState.status === 'error' ) { + * return

Error: { asyncFetchState.error.message }

; + * } + * ``` + */ +export const useAsyncValue =
, R>( + callback: ( ...args: Array ) => Promise, + deps: DependencyList +): AsyncCallbackState => { + const [ asyncCallback, asyncState ] = useAsyncCallback( callback ); + + useInstantEffect( asyncCallback, deps ); + + return asyncState; +}; diff --git a/src/hooks/useInstantEditorEffect.ts b/src/hooks/useInstantEditorEffect.ts index 9a0b3e66..88759fa0 100644 --- a/src/hooks/useInstantEditorEffect.ts +++ b/src/hooks/useInstantEditorEffect.ts @@ -5,6 +5,7 @@ import type { DependencyList } from 'react'; import type { LifeCycleElementSemaphore } from '../lifecycle/LifeCycleElementSemaphore'; + import { useInstantEffect } from './useInstantEffect'; /** diff --git a/src/hooks/useInstantEffect.ts b/src/hooks/useInstantEffect.ts index f9d8913c..3f20802e 100644 --- a/src/hooks/useInstantEffect.ts +++ b/src/hooks/useInstantEffect.ts @@ -4,7 +4,7 @@ */ import { useRef, type DependencyList } from 'react'; -import { shallowCompareArrays } from '../utils/shallowCompareArrays'; +import { shallowCompareArrays } from '@ckeditor/ckeditor5-integrations-common'; /** * Triggers an effect immediately if the dependencies change (during rendering of component). diff --git a/src/hooks/useIsUnmountedRef.ts b/src/hooks/useIsUnmountedRef.ts new file mode 100644 index 00000000..202356d3 --- /dev/null +++ b/src/hooks/useIsUnmountedRef.ts @@ -0,0 +1,26 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { useEffect, useRef, type MutableRefObject } from 'react'; + +/** + * Custom hook that returns a mutable ref object indicating whether the component is unmounted or not. + * + * @returns The mutable ref object. + */ +export const useIsUnmountedRef = (): MutableRefObject => { + const mountedRef = useRef( false ); + + useEffect( () => { + // Prevent issues in strict mode. + mountedRef.current = false; + + return () => { + mountedRef.current = true; + }; + }, [] ); + + return mountedRef; +}; diff --git a/src/index.ts b/src/index.ts index eb2356d0..3d41234f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,15 @@ export { default as CKEditor } from './ckeditor'; export { default as CKEditorContext } from './context/ckeditorcontext'; export { default as useMultiRootEditor, type MultiRootHookProps, type MultiRootHookReturns } from './useMultiRootEditor'; + +export { default as useCKEditorCloud } from './cloud/useCKEditorCloud'; +export { + default as withCKEditorCloud, + type WithCKEditorCloudHocProps +} from './cloud/withCKEditorCloud'; + +export type { + CKEditorCloudResult, + CKEditorCloudConfig, + CKExternalPluginsMap +} from '@ckeditor/ckeditor5-integrations-common'; diff --git a/src/lifecycle/LifeCycleElementSemaphore.ts b/src/lifecycle/LifeCycleElementSemaphore.ts index ee96c391..bec5cbf5 100644 --- a/src/lifecycle/LifeCycleElementSemaphore.ts +++ b/src/lifecycle/LifeCycleElementSemaphore.ts @@ -3,8 +3,7 @@ * For licensing, see LICENSE.md. */ -import { createDefer, type Defer } from '../utils/defer'; -import { once } from '../utils/once'; +import { createDefer, once, type Defer } from '@ckeditor/ckeditor5-integrations-common'; /** * This class is utilized to pause the initialization of an editor when another instance is already present on a specified element. diff --git a/src/useMultiRootEditor.tsx b/src/useMultiRootEditor.tsx index fa419ef7..daebca91 100644 --- a/src/useMultiRootEditor.tsx +++ b/src/useMultiRootEditor.tsx @@ -29,10 +29,7 @@ import type { EditorSemaphoreMountResult } from './lifecycle/LifeCycleEditorSema import { useLifeCycleSemaphoreSyncRef, type LifeCycleSemaphoreSyncRefResult } from './lifecycle/useLifeCycleSemaphoreSyncRef'; import { mergeRefs } from './utils/mergeRefs'; import { LifeCycleElementSemaphore } from './lifecycle/LifeCycleElementSemaphore'; -import { overwriteObject } from './utils/overwriteObject'; import { useRefSafeCallback } from './hooks/useRefSafeCallback'; -import { uniq } from './utils/uniq'; -import { overwriteArray } from './utils/overwriteArray'; import { useInstantEditorEffect } from './hooks/useInstantEditorEffect'; const REACT_INTEGRATION_READ_ONLY_LOCK_ID = 'Lock from React integration (@ckeditor/ckeditor5-react)'; diff --git a/src/utils/defer.ts b/src/utils/defer.ts deleted file mode 100644 index d7f0a19b..00000000 --- a/src/utils/defer.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -export type Defer = { - promise: Promise; - resolve: ( value: E ) => void; -}; - -/** - * This function generates a promise that can be resolved by invoking the returned `resolve` method. - * It proves to be beneficial in the creation of various types of locks and semaphores. - */ -export function createDefer(): Defer { - const deferred: Defer = { - resolve: null as any, - promise: null as any - }; - - deferred.promise = new Promise( resolve => { - deferred.resolve = resolve; - } ); - - return deferred; -} diff --git a/src/utils/once.ts b/src/utils/once.ts deleted file mode 100644 index 2ad6716a..00000000 --- a/src/utils/once.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/** - * Ensures that passed function will be executed only once. - */ -export function once, R = void>( fn: ( ...args: A ) => R ): ( ...args: A ) => R { - let lastResult: { current: R } | null = null; - - return ( ...args: A ): R => { - if ( !lastResult ) { - lastResult = { - current: fn( ...args ) - }; - } - - return lastResult.current; - }; -} diff --git a/src/utils/overwriteArray.ts b/src/utils/overwriteArray.ts deleted file mode 100644 index 4aee02ea..00000000 --- a/src/utils/overwriteArray.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/** - * Clear whole array while keeping its reference. - */ -export function overwriteArray>( source: A, destination: A ): A { - destination.length = 0; - destination.push( ...source ); - - return destination; -} diff --git a/src/utils/overwriteObject.ts b/src/utils/overwriteObject.ts deleted file mode 100644 index fa258d92..00000000 --- a/src/utils/overwriteObject.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/** - * Clears whole object while keeping its reference. - */ -export function overwriteObject>( source: O, destination: O ): O { - for ( const prop of Object.getOwnPropertyNames( destination ) ) { - delete destination[ prop ]; - } - - // Prevent assigning self referencing attributes which crashes `Object.assign`. - for ( const [ key, value ] of Object.entries( source ) ) { - if ( value !== destination && key !== 'prototype' && key !== '__proto__' ) { - ( destination as any )[ key ] = value; - } - } - - return destination; -} diff --git a/src/utils/shallowCompareArrays.ts b/src/utils/shallowCompareArrays.ts deleted file mode 100644 index 8a187e94..00000000 --- a/src/utils/shallowCompareArrays.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/** - * Shallow comparison of two arrays. - */ -export const shallowCompareArrays = ( - a: Readonly>, - b: Readonly> -): boolean => { - if ( a === b ) { - return true; - } - - if ( !a || !b ) { - return false; - } - - for ( let i = 0; i < a.length; ++i ) { - if ( a[ i ] !== b[ i ] ) { - return false; - } - } - - return true; -}; diff --git a/src/utils/uid.ts b/src/utils/uid.ts deleted file mode 100644 index 0031a67f..00000000 --- a/src/utils/uid.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/** - * A hash table of hex numbers to avoid using toString() in uid() which is costly. - * [ '00', '01', '02', ..., 'fe', 'ff' ] - */ -const HEX_NUMBERS = new Array( 256 ).fill( '' ) - .map( ( _, index ) => ( '0' + ( index ).toString( 16 ) ).slice( -2 ) ); - -/** - * Returns a unique id. The id starts with an "e" character and a randomly generated string of - * 32 alphanumeric characters. - * - * **Note**: The characters the unique id is built from correspond to the hex number notation - * (from "0" to "9", from "a" to "f"). In other words, each id corresponds to an "e" followed - * by 16 8-bit numbers next to each other. - * - * @returns An unique id string. - */ -export function uid(): string { - // Let's create some positive random 32bit integers first. - // - // 1. Math.random() is a float between 0 and 1. - // 2. 0x100000000 is 2^32 = 4294967296. - // 3. >>> 0 enforces integer (in JS all numbers are floating point). - // - // For instance: - // Math.random() * 0x100000000 = 3366450031.853859 - // but - // Math.random() * 0x100000000 >>> 0 = 3366450031. - const r1 = Math.random() * 0x100000000 >>> 0; - const r2 = Math.random() * 0x100000000 >>> 0; - const r3 = Math.random() * 0x100000000 >>> 0; - const r4 = Math.random() * 0x100000000 >>> 0; - - // Make sure that id does not start with number. - return 'e' + - HEX_NUMBERS[ r1 >> 0 & 0xFF ] + - HEX_NUMBERS[ r1 >> 8 & 0xFF ] + - HEX_NUMBERS[ r1 >> 16 & 0xFF ] + - HEX_NUMBERS[ r1 >> 24 & 0xFF ] + - HEX_NUMBERS[ r2 >> 0 & 0xFF ] + - HEX_NUMBERS[ r2 >> 8 & 0xFF ] + - HEX_NUMBERS[ r2 >> 16 & 0xFF ] + - HEX_NUMBERS[ r2 >> 24 & 0xFF ] + - HEX_NUMBERS[ r3 >> 0 & 0xFF ] + - HEX_NUMBERS[ r3 >> 8 & 0xFF ] + - HEX_NUMBERS[ r3 >> 16 & 0xFF ] + - HEX_NUMBERS[ r3 >> 24 & 0xFF ] + - HEX_NUMBERS[ r4 >> 0 & 0xFF ] + - HEX_NUMBERS[ r4 >> 8 & 0xFF ] + - HEX_NUMBERS[ r4 >> 16 & 0xFF ] + - HEX_NUMBERS[ r4 >> 24 & 0xFF ]; -} diff --git a/src/utils/uniq.ts b/src/utils/uniq.ts deleted file mode 100644 index ab002b4e..00000000 --- a/src/utils/uniq.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/** - * A utility function that removes duplicate elements from an array. - */ -export function uniq( source: Array ): Array { - return Array.from( new Set( source ) ); -} diff --git a/tests/ckeditor.test.tsx b/tests/ckeditor.test.tsx index 8617d1f3..81c862df 100644 --- a/tests/ckeditor.test.tsx +++ b/tests/ckeditor.test.tsx @@ -20,12 +20,6 @@ import { expectToBeTruthy } from './_utils/expectToBeTruthy.js'; import type { LifeCycleElementSemaphore } from '../src/lifecycle/LifeCycleElementSemaphore.js'; import type { EditorSemaphoreMountResult } from '../src/lifecycle/LifeCycleEditorSemaphore.js'; -declare global { - interface Window { - CKEDITOR_VERSION: any; - } -} - const MockEditor = MockedEditor as any; describe( ' Component', () => { @@ -59,7 +53,7 @@ describe( ' Component', () => { describe( 'initialization', async () => { it( 'should print a warning if the "window.CKEDITOR_VERSION" variable is not available', async () => { - delete window.CKEDITOR_VERSION; + window.CKEDITOR_VERSION = ''; const warnStub = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); component = render( diff --git a/tests/cloud/useCKEditorCloud.test.tsx b/tests/cloud/useCKEditorCloud.test.tsx new file mode 100644 index 00000000..708f499e --- /dev/null +++ b/tests/cloud/useCKEditorCloud.test.tsx @@ -0,0 +1,72 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; + +import type { CKEditorCloudConfig } from '@ckeditor/ckeditor5-integrations-common'; +import { removeAllCkCdnResources } from '@ckeditor/ckeditor5-integrations-common/test-utils'; + +import useCKEditorCloud from '../../src/cloud/useCKEditorCloud'; + +describe( 'useCKEditorCloud', () => { + beforeEach( removeAllCkCdnResources ); + + it( 'should load CKEditor bundles from CDN', async () => { + const { result } = renderHook( () => useCKEditorCloud( { + version: '43.0.0', + languages: [ 'en', 'de' ] + } ) ); + + expect( result.current.status ).toBe( 'loading' ); + + await waitFor( () => { + expect( result.current.status ).toBe( 'success' ); + + if ( result.current.status === 'success' ) { + expect( result.current.CKEditor ).toBeDefined(); + } + } ); + } ); + + it( 'should load additional bundle after updating deps', async () => { + const { result, rerender } = renderHook( + ( config: CKEditorCloudConfig ) => useCKEditorCloud( config ), + { + initialProps: { + version: '43.0.0', + withPremiumFeatures: false + } + } + ); + + await waitFor( () => { + expect( result.current.status ).toBe( 'success' ); + + if ( result.current.status === 'success' ) { + expect( result.current.CKEditor ).toBeDefined(); + expect( result.current.CKEditorPremiumFeatures ).toBeUndefined(); + } + } ); + + rerender( { + version: '43.0.0', + withPremiumFeatures: true + } ); + + act( () => { + expect( result.current.status ).toBe( 'loading' ); + } ); + + await waitFor( () => { + expect( result.current.status ).toBe( 'success' ); + + if ( result.current.status === 'success' ) { + expect( result.current.CKEditor ).toBeDefined(); + expect( result.current.CKEditorPremiumFeatures ).toBeDefined(); + } + } ); + } ); +} ); diff --git a/tests/cloud/withCKEditorCloud.test.tsx b/tests/cloud/withCKEditorCloud.test.tsx new file mode 100644 index 00000000..b3ac4252 --- /dev/null +++ b/tests/cloud/withCKEditorCloud.test.tsx @@ -0,0 +1,119 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import React, { type MutableRefObject } from 'react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; + +import { createDefer } from '@ckeditor/ckeditor5-integrations-common'; +import { removeAllCkCdnResources } from '@ckeditor/ckeditor5-integrations-common/test-utils'; + +import withCKEditorCloud, { type WithCKEditorCloudHocProps } from '../../src/cloud/withCKEditorCloud'; + +describe( 'withCKEditorCloud', () => { + const lastRenderedMockProps: MutableRefObject = { + current: null + }; + + afterEach( cleanup ); + + beforeEach( () => { + removeAllCkCdnResources(); + lastRenderedMockProps.current = null; + } ); + + const MockComponent = ( props: WithCKEditorCloudHocProps & { editorId: number } ) => { + lastRenderedMockProps.current = { ...props }; + + return ( +
+ Your Editor { props.editorId } +
+ ); + }; + + it( 'should inject cloud integration to the wrapped component', async () => { + const WrappedComponent = withCKEditorCloud( { + cloud: { + version: '43.0.0' + } + } )( MockComponent ); + + const { findByText } = render( ); + + expect( await findByText( 'Your Editor 1' ) ).toBeVisible(); + expect( lastRenderedMockProps.current ).toMatchObject( { + editorId: 1, + cloud: expect.objectContaining( { + CKEditor: expect.objectContaining( { + ClassicEditor: expect.any( Function ) + } ) + } ) + } ); + } ); + + it( 'should show loading spinner when cloud is not ready', async () => { + const deferredPlugin = createDefer(); + const WrappedComponent = withCKEditorCloud( { + renderLoader: () =>
Loading...
, + cloud: { + version: '43.0.0', + plugins: { + Plugin: { + getExportedEntries: () => deferredPlugin.promise + } + } + } + } )( MockComponent ); + + const { findByText } = render( ); + + expect( await findByText( 'Loading...' ) ).toBeVisible(); + + deferredPlugin.resolve( 123 ); + + expect( await findByText( 'Your Editor 1' ) ).toBeVisible(); + expect( lastRenderedMockProps.current?.cloud.CKPlugins?.Plugin ).toBe( 123 ); + } ); + + it( 'should show error message when cloud loading fails', async () => { + const WrappedComponent = withCKEditorCloud( { + renderError: error =>
Error: { error.message }
, + cloud: { + version: '43.0.0', + plugins: { + Plugin: { + getExportedEntries: () => { + throw new Error( 'Failed to load plugin' ); + } + } + } + } + } )( MockComponent ); + + const { findByText } = render( ); + + expect( await findByText( 'Error: Failed to load plugin' ) ).toBeVisible(); + } ); + + it( 'should render default error message when cloud loading fails and there is no error handler specified', async () => { + const WrappedComponent = withCKEditorCloud( { + cloud: { + version: '43.0.0', + plugins: { + Plugin: { + getExportedEntries: () => { + throw new Error( 'Failed to load plugin' ); + } + } + } + } + } )( MockComponent ); + + const { findByText } = render( ); + + expect( await findByText( 'Unable to load CKEditor Cloud data!' ) ).toBeVisible(); + } ); +} ); diff --git a/tests/hooks/useAsyncCallback.test.tsx b/tests/hooks/useAsyncCallback.test.tsx new file mode 100644 index 00000000..90d9cec0 --- /dev/null +++ b/tests/hooks/useAsyncCallback.test.tsx @@ -0,0 +1,160 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useAsyncCallback } from '../../src/hooks/useAsyncCallback'; +import { timeout } from '../_utils/timeout'; + +describe( 'useAsyncCallback', () => { + it( 'should execute the callback and update the state correctly when the callback resolves', async () => { + const fetchData = vi.fn().mockResolvedValue( 'data' ); + + const { result } = renderHook( () => useAsyncCallback( fetchData ) ); + const [ onFetchData ] = result.current; + + expect( result.current[ 1 ].status ).toBe( 'idle' ); + + act( () => { + onFetchData(); + } ); + + expect( result.current[ 1 ].status ).toBe( 'loading' ); + + await waitFor( () => { + const [ , fetchDataStatus ] = result.current; + + expect( fetchDataStatus.status ).toBe( 'success' ); + + if ( fetchDataStatus.status === 'success' ) { + expect( fetchDataStatus.data ).toBe( 'data' ); + } + } ); + } ); + + it( 'should execute the callback and update the state correctly when the callback rejects', async () => { + const fetchData = vi.fn().mockRejectedValue( new Error( 'error' ) ); + + const { result } = renderHook( () => useAsyncCallback( fetchData ) ); + const [ onFetchData ] = result.current; + + expect( result.current[ 1 ].status ).toBe( 'idle' ); + + act( () => { + onFetchData(); + } ); + + expect( result.current[ 1 ].status ).toBe( 'loading' ); + + await waitFor( () => { + const [ , fetchDataStatus ] = result.current; + + expect( fetchDataStatus.status ).toBe( 'error' ); + + if ( fetchDataStatus.status === 'error' ) { + expect( fetchDataStatus.error.message ).toBe( 'error' ); + } + } ); + } ); + + it( 'should not update the state to loading if the component is unmounted', async () => { + const fetchData = vi.fn().mockResolvedValue( 'data' ); + + const { result, unmount } = renderHook( () => useAsyncCallback( fetchData ) ); + + const [ onFetchData, fetchDataStatus ] = result.current; + + expect( fetchDataStatus.status ).toBe( 'idle' ); + unmount(); + + act( () => { + onFetchData(); + } ); + + expect( fetchDataStatus.status ).toBe( 'idle' ); + } ); + + it( 'should not update the state to error if the component is unmounted', async () => { + const fetchData = vi.fn().mockRejectedValue( new Error( 'error' ) ); + + const { result, unmount } = renderHook( () => useAsyncCallback( fetchData ) ); + const [ onFetchData ] = result.current; + + expect( result.current[ 1 ].status ).toBe( 'idle' ); + + act( () => { + onFetchData(); + } ); + + expect( result.current[ 1 ].status ).toBe( 'loading' ); + unmount(); + + await timeout( 50 ); + await waitFor( () => { + const [ , fetchDataStatus ] = result.current; + + expect( fetchDataStatus.status ).toBe( 'loading' ); + } ); + } ); + + it( 'should not update the state to success if the component is unmounted', async () => { + const fetchData = vi.fn().mockResolvedValue( 123 ); + + const { result, unmount } = renderHook( () => useAsyncCallback( fetchData ) ); + const [ onFetchData ] = result.current; + + expect( result.current[ 1 ].status ).toBe( 'idle' ); + + act( () => { + onFetchData(); + } ); + + expect( result.current[ 1 ].status ).toBe( 'loading' ); + unmount(); + + await timeout( 50 ); + await waitFor( () => { + const [ , fetchDataStatus ] = result.current; + + expect( fetchDataStatus.status ).toBe( 'loading' ); + } ); + } ); + + it( 'should not update the state if the execution UUID does not match the previous one', async () => { + let counter = 0; + const fetchData = vi.fn( async () => { + if ( !counter ) { + await timeout( 200 ); + } + + return counter++; + } ); + + const { result } = renderHook( () => useAsyncCallback( fetchData ) ); + + const [ onFetchData ] = result.current; + + expect( result.current[ 1 ].status ).toBe( 'idle' ); + + act( () => { + onFetchData(); + } ); + + // Do not batch this act() call with the previous one to ensure that the execution UUID is different. + act( () => { + onFetchData(); + } ); + + await waitFor( () => { + const [ , fetchDataStatus ] = result.current; + + expect( fetchDataStatus.status ).toBe( 'success' ); + + if ( fetchDataStatus.status === 'success' ) { + expect( fetchDataStatus.data ).toBe( 1 ); + } + } ); + } ); +} ); diff --git a/tests/hooks/useAsyncValue.test.tsx b/tests/hooks/useAsyncValue.test.tsx new file mode 100644 index 00000000..b4a6df8a --- /dev/null +++ b/tests/hooks/useAsyncValue.test.tsx @@ -0,0 +1,52 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { describe, expect, it } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useAsyncValue } from '../../src/hooks/useAsyncValue'; + +describe( 'useAsyncValue', () => { + it( 'should return a mutable ref object', async () => { + const { result } = renderHook( () => useAsyncValue( async () => 123, [] ) ); + + expect( result.current.status ).to.equal( 'loading' ); + + await waitFor( () => { + expect( result.current.status ).to.equal( 'success' ); + + if ( result.current.status === 'success' ) { + expect( result.current.data ).to.equal( 123 ); + } + } ); + } ); + + it( 'should reload async value on deps change', async () => { + let value = 0; + const { result, rerender } = renderHook( () => useAsyncValue( async () => value, [ value ] ) ); + + expect( result.current.status ).to.equal( 'loading' ); + + await waitFor( () => { + expect( result.current.status ).to.equal( 'success' ); + + if ( result.current.status === 'success' ) { + expect( result.current.data ).to.equal( 0 ); + } + } ); + + value = 1; + rerender(); + + expect( result.current.status ).to.equal( 'loading' ); + + await waitFor( () => { + expect( result.current.status ).to.equal( 'success' ); + + if ( result.current.status === 'success' ) { + expect( result.current.data ).to.equal( 1 ); + } + } ); + } ); +} ); diff --git a/tests/hooks/useIsUnmountedRef.test.tsx b/tests/hooks/useIsUnmountedRef.test.tsx new file mode 100644 index 00000000..4fff4fe6 --- /dev/null +++ b/tests/hooks/useIsUnmountedRef.test.tsx @@ -0,0 +1,27 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { describe, expect, it } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useIsUnmountedRef } from '../../src/hooks/useIsUnmountedRef'; + +describe( 'useIsUnmountedRef', () => { + it( 'should return a mutable ref object', () => { + const { result } = renderHook( () => useIsUnmountedRef() ); + + expect( result.current ).toHaveProperty( 'current' ); + expect( typeof result.current.current ).toBe( 'boolean' ); + } ); + + it( 'should update the ref object when the component is unmounted', () => { + const { result, unmount } = renderHook( () => useIsUnmountedRef() ); + + expect( result.current.current ).toBe( false ); + + unmount(); + + expect( result.current.current ).toBe( true ); + } ); +} ); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 00adb549..2df93053 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -10,7 +10,7 @@ import { render, type RenderResult } from '@testing-library/react'; import ContextMock from './_utils/context.js'; import Editor from './_utils/editor.js'; import { PromiseManager } from './_utils/promisemanager.js'; -import { CKEditor, CKEditorContext } from '../src/index'; +import { CKEditor, CKEditorContext } from '../src/index.js'; const MockEditor = Editor as any; diff --git a/tests/utils/defer.test.tsx b/tests/utils/defer.test.tsx deleted file mode 100644 index 7ef2d752..00000000 --- a/tests/utils/defer.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it } from 'vitest'; -import { createDefer, type Defer } from '../../src/utils/defer'; - -describe( 'createDefer', () => { - it( 'should resolve the promise with the provided value', async () => { - const value = 'test value'; - const defer: Defer = createDefer(); - - defer.resolve( value ); - - const result = await defer.promise; - expect( result ).toBe( value ); - } ); - - it( 'should create a promise that can be resolved asynchronously', async () => { - const value = 'test value'; - const defer: Defer = createDefer(); - - setTimeout( () => { - defer.resolve( value ); - }, 100 ); - - const result = await defer.promise; - expect( result ).toBe( value ); - } ); -} ); diff --git a/tests/utils/once.test.tsx b/tests/utils/once.test.tsx deleted file mode 100644 index 9b54aa12..00000000 --- a/tests/utils/once.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it, vi } from 'vitest'; -import { once } from '../../src/utils/once'; - -describe( 'once', () => { - it( 'should execute the function only once', () => { - const mockFn = vi.fn(); - const onceFn = once( mockFn ); - - onceFn(); - onceFn(); - onceFn(); - - expect( mockFn ).toHaveBeenCalledOnce(); - } ); - - it( 'should return the same result on subsequent calls', () => { - const mockFn = vi.fn().mockReturnValue( 'result' ); - const onceFn = once( mockFn ); - - const result1 = onceFn(); - const result2 = onceFn(); - const result3 = onceFn(); - - expect( result1 ).toBe( 'result' ); - expect( result2 ).toBe( 'result' ); - expect( result3 ).toBe( 'result' ); - } ); -} ); diff --git a/tests/utils/overwriteArray.test.tsx b/tests/utils/overwriteArray.test.tsx deleted file mode 100644 index bee61397..00000000 --- a/tests/utils/overwriteArray.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it } from 'vitest'; -import { overwriteArray } from '../../src/utils/overwriteArray'; - -describe( 'overwriteArray', () => { - it( 'should clear the destination array and copy the elements from the source array', () => { - const source = [ 1, 2, 3 ]; - const destination = [ 4, 5, 6 ]; - - const result = overwriteArray( source, destination ); - - expect( result ).toBe( destination ); - expect( result ).toEqual( source ); - } ); -} ); diff --git a/tests/utils/overwriteObject.test.tsx b/tests/utils/overwriteObject.test.tsx deleted file mode 100644 index f87dad39..00000000 --- a/tests/utils/overwriteObject.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it } from 'vitest'; -import { overwriteObject } from '../../src/utils/overwriteObject'; - -describe( 'overwriteObject', () => { - it( 'overwriteObject should clear the destination object and copy properties from the source object', () => { - const source = { a: 1, b: 2, c: 10 }; - const destination = { a: 10, b: 3, c: 20 }; - - const result = overwriteObject( source, destination ); - - expect( result ).toBe( destination ); - expect( result ).toEqual( { a: 1, b: 2, c: 10 } ); - } ); - - it( 'should not override prototype properties', () => { - const source = { a: 1, b: 2, c: 10 }; - const destination = Object.create( { a: 10, b: 3, c: 20 } ); - - const result = overwriteObject( source, destination ); - - expect( result ).toBe( destination ); - expect( result ).toEqual( { a: 1, b: 2, c: 10 } ); - } ); - - it( 'should remove properties from destination that are not present in source', () => { - const source = { a: 1, b: 2 }; - const destination = { a: 10, b: 3, c: 20 }; - - const result = overwriteObject( source, destination ); - - expect( result ).toBe( destination ); - expect( result ).toEqual( { a: 1, b: 2 } ); - } ); - - it( 'should not set self referencing attributes which crashes Object.assign', () => { - const source = { a: 1, b: 2, c: 10, d: null as any }; - const destination = { a: 10, b: 3, c: 20 }; - - source.d = destination; - - const result = overwriteObject( source, destination ); - - expect( result ).toBe( destination ); - expect( result ).toEqual( { a: 1, b: 2, c: 10 } ); - } ); -} ); diff --git a/tests/utils/shallowCompareArrays.test.tsx b/tests/utils/shallowCompareArrays.test.tsx deleted file mode 100644 index c052853f..00000000 --- a/tests/utils/shallowCompareArrays.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it } from 'vitest'; -import { shallowCompareArrays } from '../../src/utils/shallowCompareArrays'; - -describe( 'shallowCompareArrays', () => { - it( 'should return true if references are the same', () => { - const array = [ 1, 2, 3 ]; - expect( shallowCompareArrays( array, array ) ).to.be.true; - } ); - - it( 'should return true for equal arrays', () => { - const array1 = [ 1, 2, 3 ]; - const array2 = [ 1, 2, 3 ]; - - expect( shallowCompareArrays( array1, array2 ) ).to.be.true; - } ); - - it( 'should return false for different arrays', () => { - const array1 = [ 1, 2, 3 ]; - const array2 = [ 1, 2, 4 ]; - expect( shallowCompareArrays( array1, array2 ) ).to.be.false; - } ); - - it( 'should return false for arrays with different lengths', () => { - const array1 = [ 1, 2, 3 ]; - const array2 = [ 1, 2 ]; - expect( shallowCompareArrays( array1, array2 ) ).to.be.false; - } ); - - it( 'should return true for empty arrays', () => { - const array1: Array = []; - const array2: Array = []; - expect( shallowCompareArrays( array1, array2 ) ).to.be.true; - } ); -} ); diff --git a/tests/utils/uid.test.tsx b/tests/utils/uid.test.tsx deleted file mode 100644 index 7d913cce..00000000 --- a/tests/utils/uid.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { it, expect, describe } from 'vitest'; -import { uid } from '../../src/utils/uid'; - -describe( 'uid', () => { - it( 'uid should return a string starting with "e"', () => { - const id = uid(); - expect( id.startsWith( 'e' ) ).toBe( true ); - } ); - - it( 'uid should return a string of length 33', () => { - const id = uid(); - expect( id.length ).toBe( 33 ); - } ); - - it( 'uid should return unique ids', () => { - const id1 = uid(); - const id2 = uid(); - expect( id1 ).not.toBe( id2 ); - } ); - - it( 'uid should only contain hexadecimal characters', () => { - const id = uid(); - const hexRegex = /^[a-fA-F0-9]+$/; - expect( hexRegex.test( id.substring( 1 ) ) ).toBe( true ); - } ); -} ); diff --git a/tests/utils/uniq.test.tsx b/tests/utils/uniq.test.tsx deleted file mode 100644 index 76c18529..00000000 --- a/tests/utils/uniq.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it } from 'vitest'; -import { uniq } from '../../src/utils/uniq'; - -describe( 'uniq', () => { - it( 'should remove duplicate elements from an array', () => { - const input = [ 1, 2, 2, 3, 4, 4, 5 ]; - const expectedOutput = [ 1, 2, 3, 4, 5 ]; - - const result = uniq( input ); - - expect( result ).toEqual( expectedOutput ); - } ); - - it( 'should return an empty array if the input is empty', () => { - const input: Array = []; - const expectedOutput: Array = []; - - const result = uniq( input ); - - expect( result ).toEqual( expectedOutput ); - } ); -} ); diff --git a/vite.config.ts b/vite.config.ts index 8324a69c..505ae2f2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,6 +49,7 @@ export default defineConfig( { // https://vitest.dev/config/ test: { + setupFiles: [ './vitest-setup.ts' ], include: [ 'tests/**/*.test.[j|t]sx' ], diff --git a/vitest-setup.ts b/vitest-setup.ts new file mode 100644 index 00000000..bbd99e5f --- /dev/null +++ b/vitest-setup.ts @@ -0,0 +1,6 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import '@testing-library/jest-dom/vitest';