Building and Integrating CKEditor 5 with React

Introduction

Building a custom CKEditor 5 build from source is officially documented, but it can be a tricky task for those who aren't writing build tasks for their day-to-day job.

Suppose you are a guy like me who only seems to learn how to setup a webpack build environment when he really needs to, and always forgets how to do it as soon as the project is done ???? -- This guide is for you!

This guide will take you through building CKEditor 5 from source and creating a separate project to consume it through imports so that a custom CKEditor 5 can be used in your web application.

Prerequisites

This post will assume some knowledge in relatively modern web development concepts.

  • Knowledge of the yarn package management tool. You know how to add dependencies and install them in a project.
  • Some knowledge of webpack-- i.e. you know what it does.
  • Basic web development knowledge to write additional code to create an editor instance and mount it to a contenteditable HTMLDivElement on an HTML page
  • Some text editor to do all this in ???? Visual Studio Code is used in this tutorial, but it does not really matter here.

Approach and Expectations

Before we go any further, I'd like to make sure there is an understanding in what "building from source" means for this tutorial. This approach is based on the main tutorial found in the CKEditor 5 documentation. In their documentation, there is an explicit statement:

Similar results to what this method allows can be achieved by customizing an existing build and integrating your custom build. This will give faster build times (since CKEditor 5 will be built once and committed), however, it requires maintaining a separate repository and installing the code from that repository into your project (e.g. by publishing a new npm package or using tools like Lerna). This makes it less convenient than the method described in this scenario.

 https://ckeditor.com/docs/ckeditor5/latest/installation/advanced/alternative-setups/integrating-from-source-webpack.html

We cannot use the @ckeditor/ckeditor5-* packages directly to create an editor. We must pre-assemble one using the packages available, and then provide a mechanism to deliver back an instance of CKEditor 5.

In this case, we can create a factory function to assemble the editor, and return back the instance asynchronously when this factory function is invoked.

To reiterate once more, all the work relating to building a CKEditor 5 instance will be contained in a separate project.

In summary, here are the things to know:

  1. The custom editor will be in its own package. This is like the one you'd normally publish to the npm registry (for example).
  2. Consumers are expected to add the package as a dependency in their project. For the purpose of this tutorial, we can add the package locally with a file reference so that we do not have to publish to a registry.
  3. Additional work is needed in order to support TypeScript. This tutorial will go over that because... TypeScript.

Steps

The first step is to go through the initial steps found in the CKEditor 5 tutorial for building the editor from source.

If you want to just jump in right away and follow this tutorial completely, it is possible, and here it is.

Create a folder that will contain your project. In this case for me, it is editor.

Use npm init in the folder to create a package.json file. Give this project a nice name! It will be the package name so that you can use it in another project as a module. For example, I named this project @dev-cms/editor.

Ensure that in the package.json file, the main key is specified to be the correct js file that is ultimately built. For example, mine is called ckeditor.js.

In the same project, install the typescript dependency:

yarn add -D typescript

Also, add some other useful things:

yarn add -D copyfiles ts-loader

Now, add all the minimal dependencies to get something built. For this HOW-TO, I am building an InlineEditor . I am aware that the official tutorial focuses on ClassicEditor. The steps are generally the same.

yarn add @ckeditor/ckeditor5-theme-lark \
  @ckeditor/ckeditor5-autoformat \
  @ckeditor/ckeditor5-basic-styles \
  @ckeditor/ckeditor5-block-quote \
  @ckeditor/ckeditor5-editor-inline \
  @ckeditor/ckeditor5-essentials \
  @ckeditor/ckeditor5-heading \
  @ckeditor/ckeditor5-link \
  @ckeditor/ckeditor5-list \
  @ckeditor/ckeditor5-paragraph

Next, install all the other dependencies required by the CKEditor 5 build:

yarn add @ckeditor/ckeditor5-dev-translations \
    @ckeditor/ckeditor5-dev-utils \
    css-loader@5 \
    postcss-loader@4 \
    raw-loader@4 \
    style-loader@2 \
    webpack@5 \
    webpack-cli@4

To configure webpack, start with this template found here. Proceed to the next section for more details on what we are doing with this webpack.config.js.

TypeScript

In order to make TypeScript possible, you will need to modify the webpack.config.js file and then create the tsconfig.json.

But that is not enough! In order to properly import the package in any other TypeScript project, you will also need to create the .d.ts file. Thankfully, you can generate this by having an additional configuration named tsconfig.types.json.

We need to alter webpack.config.js to make it "TypeScript" aware -- sorry for the lack of better term. ????

Here is what the output should look like:

module.exports = {
  // ... other properties
  
  entry: "./ckeditor.ts",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "ckeditor.js",
    libraryTarget: "umd",
    libraryExport: "default",
  },
  
  // ... rest of the stuff here
};

Just be sure to define the entry as the main TypeScript ts file containing the code to assemble/build your CKEditor instance. The output property should also have libraryTarget and libraryExport as shown above.

First, the tsconfig.json should look like this:

{
  "compilerOptions": {
    "types": [],
    "lib": ["ES2019", "ES2020.String", "DOM", "DOM.Iterable"],
    "noImplicitAny": true,
    "noImplicitOverride": true,
    "strict": true,
    "module": "es6",
    "target": "es2019",
    "sourceMap": true,
    "allowJs": true,
    "moduleResolution": "node",
    "skipLibCheck": true
  },
  "include": ["./**/*.ts"],
  "exclude": ["node_modules/**/*"]
}

This is like any other regular tsconfig.json file specific to some project. The next step is to create the tsconfig.types.json. This will be used to generate the .d.ts file:

{
  "compilerOptions": {
    "lib": ["DOM", "DOM.Iterable"],
    "module": "es6",
    "target": "ES2019",
    "sourceMap": true,
    "allowJs": true,
    "moduleResolution": "node",
    "skipLibCheck": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "declarationDir": "dist",
    "stripInternal": true
  },
  "include": ["*.ts"]
}

Notice the additional compile options: declaration, emitDeclarationOnly, declarationDir, and stripInternal. If you're pretty seasoned, you can kind of infer what the intention for this config to tell tsc to do: Look for the ts files and only emit the interface definitions which will then be emitted to the dist directory.

Create the appropriate build scripts to copy the additional package.json and .d.ts files to the dist directory. This will be the package source.

Here, we will need to define a build, build:dev and postbuild command for our workflow to build our package:

build

"build": "webpack --mode production && copyfiles ./package.json ./dist && yarn postbuild"

build:dev

"build:dev": "webpack --mode development && copyfiles ./package.json ./dist && yarn postbuild"

build and build:dev are just convenience to run webpack build the bundles in either development or production mode. The last command postbuild is important. The command will run tsc again to generate the declarations:

postbuild

"postbuild": "tsc -p ./tsconfig.types.json"

The output will emit into dist -- therefore, it is not necessary to manually run copyfiles to copy the output to dist as we did with package.json.

Creating the Factory Function

Phew, so far we've done a lot of work just setting up the build chain! We still need some code.

Time to create the ckeditor.ts file. We will build the editor here and have an exposed function for the consumer to call to return back an instance of the editor.

Let's define a function called CreateEditor that takes in a simple HTMLDivElement as an argument, and build a very basic editor. If you are interested in additional customization, I really recommend going through and reading the CKEditor 5 documentation. It will have lots of details how you can add additional plugins and configuration to enrich your editor.

import {
  Bold,
  Code,
  Italic,
  Strikethrough,
  Underline,
} from "@ckeditor/ckeditor5-basic-styles";
import { Essentials } from "@ckeditor/ckeditor5-essentials";
import { InlineEditor } from "@ckeditor/ckeditor5-editor-inline";
import { Paragraph } from "@ckeditor/ckeditor5-paragraph";

export default function CreateEditor(
  sourceElement: HTMLDivElement
) {
  return InlineEditor.create(sourceElement, {
    plugins: [
      Essentials,
      Paragraph,
      Bold,
      Italic,
      Strikethrough,
      Underline,
      Code,
    ],
    toolbar: [
      "bold",
      "italic",
      "strikethrough",
      "underline",
      "code",
    ],
  })
    .then((editor) => {
      console.log("Editor is initialized");

      return editor;
    })
    .catch((error) => {
      console.error(error.stack);

      return null;
    });
}

By saving this as ckeditor.ts, our webpack configuration will read this file in and then begin to build the entire package with the CreateEditor exported along with the editor. Any consumer of our package will then be able to import this function to create an editor and receive the reference to it to do more stuff!

Congrats! We've built the package! Now, it is time to use it.

Consuming the Package

In your other project which will make use of your CKEditor 5 build, you can add it as a devDependency to your package.json by running the following command:

yarn add file:/path/to/the/editor/project

Here is a more concrete example, assuming my dist is within editor at the parent directory of the current project.

yarn add file:../editor/dist

Once done, we can just run yarn install --force again on the project.

With the project now added, we can invoke our custom factory function with something like this:

import CreateEditor from "@dev-cms/editor";
import React from "react";

export const WritePost = () => {
    const editableRef = React.useRef<HTMLDivElement>(null);

    React.useEffect(() => {
        editableRef.current &&
            CreateEditor(editableRef.current, {
                sourceElementClassNames: [],
            });
    }, []);
    return <div ref={editableRef}></div>;
};

CreateEditor is the same function exposed by our CKEditor 5 build to be able to construct the CKEditor 5 instance in this case. The important line of code in which I would like to emphasize is in the way to import it.

import CreateEditor from "@dev-cms/editor";

Building this same project, we can then render out the page. I personally added more plugins to my build.

Export More!

Here is a use case which you may want. Suppose we want to define additional helpers and functions in addition to CreateEditor. This is particularly useful if you're interested in creating a framework on top of CKEditor 5.

As an example, let's define an abstraction over InlineEditor called WrappedEditor. It is a simple interface in a file called wrapped-editor.interface.ts.

import type { InlineEditor } from "@ckeditor/ckeditor5-editor-inline";

export interface IWrappedEditor {
    readonly editorInstance: InlineEditor;
    getData(): string;
    setData(value: string): void;
}

Let's also modify ckeditor.ts to no longer have a default export in CreateEditor.

export function CreateEditor(....) { }

Now, we need a new entry point... We can do just that by adding an index.ts to our project to export all our files.

export * from "./ckeditor";
export * from "./wrapped-editor.interface";

Modify package.json to now specify the main entry point to be index.js.

Next, let's modify the webpack.config.js file. We will need to change the entry to now be index.ts, and also output to indicate a new filename for our compiled file, and library property to indicate that we want to output a UMD module called "editor".

module.exports = {
  // ... other entries
  
  entry: "./index.ts",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "index.js",
    library: {
      name: "editor",
      type: "umd",
    },
  },
  
  // ... other entries
};

If you then compile again using yarn build:dev as specified earlier, you'll now notice you'll have additional d.ts files generated. Going back to the consuming project, you can now consume the new package like this:

import { CreateEditor, IWrappedEditor }  from "@dev-cms/editor";

This allows for more convenience in being less restricted to just only being export a factory function. Make what you will of it. ????

Conclusion

Building an editor from source allows us to programmatically control what type of features we need for our CKEditor 5 instance. We don't need to continuously swap out predefined builds as our requirements change throughout the software development lifecycle. Instead, we can simply make code modifications to add, or remove plugins.

If you have any questions, or feedback in how to improve or update this guide, please send an email to me!

References

These are basic references used to "get started" for someone who does not know where to begin.