ckeditor / ckeditor5-react

Official CKEditor 5 React component.
https://ckeditor.com/ckeditor-5
Other
426 stars 100 forks source link

Cloud integration #510

Closed Mati365 closed 2 months ago

Mati365 commented 4 months ago

React CDN Integration

Related Vue integration: https://github.com/ckeditor/ckeditor5-vue/pull/301 Related Angular integration: https://github.com/ckeditor/ckeditor5-angular/pull/431

❓ Tl;dr

The useCKEditorCloud hook is designed for integrating CKEditor from a CDN into React applications, aiming to simplify the process of adding CKEditor by managing its asynchronous loading, handling errors, and providing access to the editor and its dependencies once loaded. This addresses the common challenge in React and other frameworks of integrating third-party JavaScript libraries, which involves managing asynchronous script loading and ensuring scripts and styles are loaded in the correct order without blocking the main thread.

Additionally, the hook specifically tackles the challenges associated with loading CKEditor from its CDN. It optimizes the loading process by preventing redundant script injections and handling race conditions and caching effectively. This ensures that scripts are not injected multiple times if they are already present, which is crucial for applications that dynamically destroy and reinitialize components, including CKEditor , as users navigate. This approach enhances performance and user experience by ensuring efficient and error-free loading of CKEditor's assets from the CDN.

Demos

Plain editor from CDN (with external plugins): http://localhost:5174/demos/cdn-react/index.html Multiroot hook: http://localhost:5174/demos/cdn-multiroot-react/index.html

🔧 General format of the useCKEditorCloud hook call

The useCKEditorCloud hook is responsible for returning information that:

  1. CKEditor is still being downloaded from the CDN with status = "loading".
  2. An error occurred during the download when status = "error", then further information is returned in the error field.
  3. Returning the editor in the data field and its dependencies when status = "success".

Simplest config

The simplest usage example that will load CKEditor from CDN and return its data in cloud.data:

const App = () => {
  const cloud = useCKEditorCloud( {
    version: '42.0.1',
    // Optional flags:
    // languages: [ 'pl', 'en', 'de' ],
    // withPremiumFeatures: true,
    // plugins: {}
  } );

  if ( cloud.status !== 'success' ) {
    return null;
  }

  const { ClassicEditor, Essentials } = cloud.CKEditor;

  class MyClassicEditor extends ClassicEditor {
    public static override builtinPlugins = [
      Essentials,
      // ... other plugins
    ];
  }

  return (
    <CKEditor
      editor={ MyClassicEditor }
      data="Hello World!"
    />
  );
};

CKBox config

CKBox integration example:

export const CKEditorCKBoxCloudDemo = ( { content }: CKEditorCKBoxCloudDemoProps ): ReactNode => {
  const cloud = useCKEditorCloud( {
    version: '42.0.1',
    withCKBox: {
      version: '2.5.1'
    }
  } );

  if ( cloud.status === 'error' ) {
    console.error( cloud );
  }

  if ( cloud.status !== 'success' ) {
    return <div>Loading...</div>;
  }

  const {
    CKBox,
    CKBoxImageEdit,
    CKFinder,
    CKFinderUploadAdapter
  } = cloud.CKEditor;

  const CKEditorClassic = useCKCdnClassicEditor( {
    cloud,
    additionalPlugins: [
      CKBox,
      CKFinder,
      CKFinderUploadAdapter,
      CKBoxImageEdit
    ],
    ...
  } );

  return (
    <CKEditor
      editor={ CKEditorClassic }
      data={ content }
    />
  );
};

Third party plugins config

A more advanced example that allows specify whether external stylesheets or scripts should be loaded:

const cloud = useCKEditorCloud({
  version: '42.0.1',
  plugins: {
    YourPlugin: {
      scripts: ["https://example.com/your-plugin.js"],
      stylesheets: ["https://example.com/your-plugin.css"],
      getExportedEntries: () => window.YourPlugin,
    },
  },
});

if (cloud.status === "success") {
  const { ClassicEditor, Bold, Essentials } = cloud.CKEditor;
  const { SlashCommand } = cloud.CKEditorPremiumFeatures;
  const { YourPlugin } = cloud.CKPlugins;
}

⚠️ Potential blockers

  1. 🔴 The Window typings of CKEditor 5 are not present. So we have to manually re-export them:
declare global {
    interface Window {
        CKEDITOR: typeof CKEditor;
        ckeditor5: Window['CKEDITOR'];
    }
}

which is problematic when we want use type with prototype (like EventInfo):

obraz

So at this moment we have this:

declare module 'https://cdn.ckeditor.com/typings/ckeditor5-premium-features.d.ts' {
    export type * from 'ckeditor5-premium-features';
}

and later:

import type { EventInfo } from 'https://cdn.ckeditor.com/typings/ckeditor5.d.ts';

const onReady = ( evt: EventInfo ) => { ... };
  1. 🟡 A potential issue is the lack of batching for translation file downloads. Currently, each language is a separate file that needs to be fetched. This means that an editor with 2 languages and premium features enabled has to download 4 separate language files. It would be better to download all the files at once in a single request.

🔧 Operation algorithm of useCKEditorCloud

Dependency packages

Under the hood, the hook uses objects describing the dependencies of bundles served in the CDN. These objects define which styles and which scripts must be injected on the page to receive objects on the window returned by getExportedEntries. In other words, to download a bundle from CKEditor5, we pass the link to our CDN in scripts, similarly in stylesheets, and in getExportedEntries we return the window.CKEDITOR5 object. This object will then be returned by the hook after loading is complete.

Package format:

export type CKCdnResourcesPack<R = any> = {

  /**
   * List of resources to preload, it should improve the performance of loading the resources.
   */
  preload?: Array<string>;

  /**
   * List of scripts to load. Scripts are loaded in the order they are defined.
   */
  scripts?: Array<string | ( () => Awaitable<unknown> )>;
  /**
   * List of stylesheets to load. Stylesheets are loaded in the order they are defined.
   */
  stylesheets?: Array<string>;

  /**
   * Get JS object with global variables exported by scripts.
   */
  getExportedEntries?: () => Awaitable<R>;
};

Currently, we have two pre-defined packages:

Depending on the provided configuration, the useCKEditorCloud hook merges these packages using the combineCKCdnBundlesPacks method and transforms them into this form:

{
  scripts: [
    'https://cdn.ckeditor.com/ckeditor5/42.0.2/ckeditor5.umd.js',
    'https://cdn.ckeditor.com/ckeditor5-premium-features/42.0.2/ckeditor5-premium-features.umd.js',
    // ... and translations
  ],

  stylesheets: [
    'https://cdn.ckeditor.com/ckeditor5/42.0.2/ckeditor5.css',
    'https://cdn.ckeditor.com/ckeditor5-premium-features/42.0.2/ckeditor5-premium-features.css',
  ],

  getExportedEntries: () => ( {
    CKEditorPremiumFeatures: window.CKEDITOR_PREMIUM_FEATURES,
    CKEditor: window.CKEDITOR,
  } )
}

This form is extended by other UMD scripts (like WIRIS) and thanks to exports from createCKCdnBaseBundlePack, it allows specifying whether users want to download only styles, only JS, or both. By merging, we get the ability to load all css styles and js scripts before starting to load the main code.

HTML Injection

Preload

At the moment of the first render of the App component, the useCKEditorCloud will inject tags responsible for rel=preload into the head:

<link injected-by="ck-editor-react" rel="preload" as="style" href="https://cdn.ckeditor.com/ckeditor5/42.0.1/ckeditor5.css">
<link injected-by="ck-editor-react" rel="preload" as="script" href="https://cdn.ckeditor.com/ckeditor5/42.0.1/ckeditor5.umd.js">
<link injected-by="ck-editor-react" rel="preload" as="script" href="https://cdn.ckeditor.com/ckeditor5/42.0.1/translations/en.umd.js">

This informs the browser that while downloading .css files, it can also start downloading .umd.js files. This counteracts a dependency cascade where we would have to wait for the main CKEditor bundle to load, then the CKEditor Premium Features bundle, and then some custom plugin code before starting the editor initialization. preload loads them all in parallel. This is noticeable on slower connections, it is not required for the implementation itself, but significantly improves UX by about 500-600ms on load at Fast 3G speed.

Target resources

<link injected-by="ck-editor-react" rel="stylesheet" href="https://cdn.ckeditor.com/ckeditor5/42.0.1/ckeditor5.css">
<script injected-by="ck-editor-react" type="text/javascript" async="" src="https://cdn.ckeditor.com/ckeditor5/42.0.1/ckeditor5.umd.js"></script>
<script injected-by="ck-editor-react" type="text/javascript" async="" src="https://cdn.ckeditor.com/ckeditor5/42.0.1/translations/en.umd.js"></script>

When the hook has preloaded the resources, it proceeds to load the target scripts and creates a Promise from this. This is problematic because React does not support asynchronicity in components in the stable version. In the unstable version, it has a hook use, which currently cannot be used because we support React 16, which does not support it.

At this stage, it looks like this:

const pack = combineCKCdnBundlesPacks( {
  CKEditor: base,
  CKEditorPremiumFeatures: premium,
  CKPlugins: combineCKCdnBundlesPacks( plugins )
} );

const data = await loadCKCdnResourcesPack( pack );

Dependency packages are merged. All CSS styles are loaded, then JS, and finally plugins.

Handling Asynchrony in React

Since the handling of Promise in React without suspense does not exist, it is managed through a customly added hook useAsyncValue. Its usage looks roughly like this

const cloud = useAsyncValue(
  async (): Promise<CKEditorCloudResult<A>> => {
    const { packs } = normalizedConfig;
    const { base, premium, plugins } = packs;

    const pack = combineCKCdnBundlesPacks({
      CKEditor: base,
      CKEditorPremiumFeatures: premium,
      CKPlugins: combineCKCdnBundlesPacks( plugins )
    });

    return loadCKCdnResourcesPack(pack);
  },
  [serializedConfigKey]
);

After finishing loading, it will return:

{
  status: 'success',
  data: {
    CKEditor: window.CKEDITOR,
    CKEditorPremiumFeatures: window.CKEDITOR_PREMIUM_FEATURES,
    CKPlugins: {
      // additional external plugins loaded by the user
    }
  }
}

However, the hook also has loading, idle, and error states. The hook, with each change of serializedConfigKey, which is the config's form serialized to a string, reloads the data from the CDN. This allows controlling the loading of premium plugins and recreating the editor.

Handling race-conditions and cache

Each injecting function has a verification whether the script has already been embedded or not. Example for JS tags:

const INJECTED_SCRIPTS = new Map<string, Promise<void>>();
/**
 * Injects a script into the document.
 *
 * @param src The URL of the script to be injected.
 * @returns A promise that resolves when the script is loaded.
 */
export const injectScript = (src: string): Promise<void> => {
  // Return the promise if the script is already injected by this function.
  if (INJECTED_SCRIPTS.has(src)) {
    return INJECTED_SCRIPTS.get(src)!;
  }

  // Return the promise if the script is already present in the document but not injected by this function.
  // We are not sure if the script is loaded or not, so we have to show warning in this case.
  if (document.querySelector(`script[src="${src}"]`)) {
    console.warn('Script already injected:', src);
    return Promise.resolve();
  }

  // ...
}

thanks to this, destroying the component and initializing it at the moment when it was in the process of injecting the script does not cause problems and works. Navigation between pages that also have the same dependencies has been accelerated. This gives us the possibility to have 2 editors on the site - one having premium features, the other not.

🔧 General format of the withCKEditorCloud HOC

The main reason for the creation of this HOC is the hook useMyMultiRootEditor. Thanks to it, we avoid a situation where we have to add conditional rendering in the useMyMultiRootEditor hook:

const App = () => {
  const cloud = useCKEditorCloud({
    version: '42.0.1'
  });

  const result = useMultiRootEditor({
    editor: // What now? Cloud is not yet loaded.
  });
  if (cloud.status === 'loading') {
    return;
  }

  const { MultiRootEditor, Essentials } = cloud.CKEditor;

  class MyCustomMultiRootEditor extends MultiRootEditor {
    public static override builtinPlugins = [
      Essentials,
      ...
    ];
  };

  useMultiRootEditor({
    // ...
  })
};

In theory, modifying useMultiRootEditor is possible, but it would potentially cause problems with race-conditions and the stability of the integration itself at the time of quick loading flag switches cloud. Using HOC, we avoid conditioning, and the embedding component already has the cloud injected with initialized CKEditor constructors:

const withCKCloud = withCKEditorCloud({
  cloud: {
    version: '42.0.0',
    languages: ['en', 'de'],
    withPremiumFeatures: true
  }
});

const useMyMultiRootEditor = (cloud) => {
    const { MultiRootEditor, Essentials } = cloud.CKEditor;

    class MyCustomMultiRootEditor extends MultiRootEditor {
      public static override builtinPlugins = [
        Essentials,
        ...
      ];
    };
};

const MyComponent = withCKCloud(({ cloud }) => {
  const YourMultiRootEditor = useMyMultiRootEditor(cloud);
  const result = useMultiRootEditor({
    editor: YourMultiRootEditor
  });
});
Mati365 commented 3 months ago

@pszczesniak

Another thought - what about with plugins that are not published on NPM? I know that it's some kind of rare edge case but we got one - ckeditor5-mermaid, i know it's marked as experimental but do we want to only shrug to our potential clients?

const cloud = useCKEditorCloud({
  version: '42.0.1',
  plugins: {
    YourPlugin: {
      getExportedEntries: () => import( './your-plugin' ),
    },
  },
});

It can be done using async imports + proper globals configuration in bundlers (we have to point that ckeditor5 is CKEDITOR window dependency).

Mati365 commented 3 months ago

Thx @gorzelinski! I applied fixes.

coveralls commented 3 months ago

Pull Request Test Coverage Report for Build 60d18044-4cac-4919-9da1-9826eb395ef7

Details


Totals Coverage Status
Change from base Build b63e3319-c29c-469a-9a26-761f5c32893e: 0.0%
Covered Lines: 582
Relevant Lines: 582

💛 - Coveralls