Report an issue

guideIntegrating comments with your application

The Comments plugin provides an API for managing comments added to the document. Through this API you can add, remove, or update comments in the rich-text editor. However, all these changes are local (unsaved) and will disappear after the editor is destroyed. To read the comments data from your own database and save them there you need to provide a proper integration.

This guide will discuss two ways to integrate CKEditor 5 with your comments data source:

A comments integration using the adapter is the recommended one because it gives you better control over the data.

Note that CKEditor 5 provides an out-of-the-box solution which is using comments as a part of the real-time collaboration. If you use it, comments will use CKEditor Cloud Services to store the data. Additionally, you do not need to define the application key in such case, however, you need to have your Cloud Services token.

# License key

First of all — the formalities.

Comments is a commercial plugin and you will need a license key to authenticate. If you do not have a key yet, please contact us.

Note that the key is not needed if you are using comments as a part of the real-time collaboration solution.

# Before you start

Before you start creating an integration, there are a few concepts you should be familiar with. This guide will explain how to create a custom build with the comments plugin, what data structure the comments use and what the comments plugin API looks like.

Make sure that you understand all of these concepts before you move to building the adapter.

# Prepare a custom build

The comments plugin is not included in any CKEditor 5 build. To enable it, you need to create a custom CKEditor 5 build that includes the comments plugin.

You need to install the @ckeditor5/ckeditor5-comments package using npm:

npm install --save-dev @ckeditor/ckeditor5-comments

To make comments work, you need to make these changes in your rich-text editor configuration:

  • Import the Comments plugin and add it to the list of plugins.
  • Add the comment button to the toolbar. If you use the ImageToolbar plugin, add the comment button to the image toolbar separately.

An updated sample should look like this.

// classiceditorwithcomments.js

import ClassicEditorBase from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
import Comments from '@ckeditor/ckeditor5-comments/src/comments';

export default class ClassicEditorWithComments extends ClassicEditorBase {}

ClassicEditorWithComments.builtinPlugins.push( Comments );

ClassicEditorWithComments.defaultConfig = {
    toolbar: [ 'heading', '|', 'bold', 'italic', 'link', '|', 'comment' ],
    image: {
        toolbar: [ 'imageStyle:full', 'imageStyle:side', '|',
            'imageTextAlternative', '|', 'comment' ]
    }
};

Note that your custom build needs to be bundled using webpack. To learn how to do it read more about installing plugins.

# HTML structure

When you have the comments package included in your custom build and the HTML structure for the sidebar defined, you can enable the comments plugin. Proceed as follows:

  • Set up a two-column layout.
  • Add the sidebar configuration.
  • Add your licenseKey. If you do not have a key yet, please contact us.
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>CKEditor 5 collaboration with comments</title>
        <style type="text/css">
         .container {
            /* To create the column layout. */
            display: flex;

            /* To make the container relative to its children. */
            position: relative;
         }

         .container .ck.ck-editor {
            /* To stretch the editor to max 700px
                (just to look nice for this example but it can be any size). */
            width: 100%;
            max-width: 700px;
         }

         .container .sidebar {
            /* Set some size for the sidebar (it can be any). */
            min-width: 300px;

            /* Add some distance. */
            padding: 0 10px;
         }
        </style>
    </head>
    <body>
        <div class="container">
            <div class="editor">
                <p>Let's edit this together!</p>
            </div>
            <div class="sidebar"></div>
        </div>
    </body>
    <script src="./classiceditorwithcomments.js"></script>
    <script>
        ClassicEditorWithComments
            .create( document.querySelector( '.editor' ), {
                licenseKey: 'your-license-key',
                sidebar: {
                    container: document.querySelector( '.sidebar' )
                }
            } );
    </script>
</html>

Refer to the Comments overview guide to learn more about the special HTML structure for the sidebar.

# Comment thread data structure

Below is the data format of the comment threads stored in CKEditor 5. Use this data structure to set the initial comments to the editor. You can expect the same structure when you get the comments data from the editor.

{
    threadId: 'thread-1', // String
    comments: [           // Array of objects
        {
            commentId: 'comment-1',      // String
            author: 'author-id',         // String
            content: 'First comment',    // String
            createdAt: new Date( ... )   // Date instance
        },
        {
            commentId: 'comment-2',      // String
            author: 'author-id',         // String
            content: 'Second comment',   // String
            createdAt: new Date( ... )   // Date instance
        }
    ]
}

# Comments API

The Comments plugin provides a few methods to handle the editor comments.

/**
 * Adds a comment thread with comments to the editor.
 *
 * @param {Object} data The comment thread data.
 * @param {String} data.threadId The ID of the comment thread.
 * @param {Array.<Object>} data.comments Data of all the comments that
 * belong to this thread.
 * @param {String} data.comments[].commentId The comment ID.
 * @param {String} data.comments[].author The comment author ID.
 * @param {String} data.comments[].content The comment content.
 * @param {Date} [data.comments[].createdAt] The comment creation date.
 */
addCommentThread( data ) {}

/**
 * Adds a new comment to a comment thread.
 *
 * @param {Object} data The comment data.
 * @param {String} data.threadId The ID of the thread that the comment belongs to.
 * @param {String} data.commentId The comment ID.
 * @param {String} data.author The comment author ID.
 * @param {String} data.content The comment content.
 * @param {Date} [data.createdAt] The comment creation date.
 */
addComment( data ) {}

/**
 * Updates a comment.
 *
 * @param {Object} data The comment data.
 * @param {String} data.commentId The ID of the comment to update.
 * @param {String} [data.content] The new content of the comment.
 * @param {Date} [data.createdAt] The new creation date of the comment.
 */
updateComment( data ) {}

/**
 * Removes a comment.
 *
 * @param {String} commentId The ID of the comment to remove.
 */
removeComment( commentId ) {}

/**
 * A generator that iterates over all visible comment
 * threads in the document and returns their data.
 *
 * @returns {Iterable.<Object>}
 */
* getVisibleCommentThreads() {}

/**
 * An observable property that tells if all markers have corresponding
 * comment threads loaded. It can be one of the following values:
 * - loading - When there is at least one comment marker waiting
 *             for the corresponding comment thread. Initial state.
 * - loaded - All comment threads are loaded.
 *
 * @observable
 * @readonly
 * @member {'loaded'|'loading'} #state
 */
state

/**
 * The adapter object. An adapter object should implement a set of functions
 * that will be automatically called by the editor whenever a user changes the
 * comments data. The adapter should communicate with the application database
 * and update information in it. You will see how to set the adapter in details
 * in the next steps.
 */
adapter

To use this API you need to get the comments plugin:

// Get the comments plugin:
const commentsPlugin = editor.plugins.get( 'Comments' )

// Add a comment:
commentsPlugin.addComment( data );

// Get all comment threads:
Array.from( commentsPlugin.getVisibleCommentThreads() );

If editor.plugins.get( 'Comments' ) returns undefined, make sure that your build contains the Comments plugin.

Now you are ready to provide the integration.

# A simple “load and save” integration

In this solution, users and comments data is loaded during the editor initialization and comments data is saved after you finish working with the editor (for example when you submit the form containing the editor).

This method is recommended only if you can trust your users or if you provide additional validation of the submitted data to make sure that the user changed their comments only.

# Loading the data

When the comments plugin is already included in the editor, you need to create a plugin which will initialize users and existing comments.

If your plugin needs to request the comments data from the server asynchronously, you can return a Promise from the Plugin.init method to make sure that the editor initialization waits for your data.

First, dump the users and comments data to a variable that will be available for your plugin.

// Application data will be available under a global variable `appData`.
const appData = {};

// Users data.
appData.users = [
    {
        id: 'user-1',
        name: 'Joe Doe',
        // Note that the avatar is optional.
        avatar: 'https://randomuser.me/api/portraits/thumb/men/26.jpg'
    },
    {
        id: 'user-2',
        name: 'Ella Harper',
        avatar: 'https://randomuser.me/api/portraits/thumb/women/65.jpg'
    }
];

// The ID of the current user.
appData.userId = 'user-1';

// Comment threads data.
appData.commentThreads = [
    {
        threadId: 'thread-1',
        comments: [
            {
                commentId: 'comment-1',
                author: 'user-1',
                content: '<p>Are we sure we want to use a made-up disorder name?</p>',
                createdAt: new Date( '09/20/2018 14:21:53' )
            },
            {
                commentId: 'comment-2',
                author: 'user-2',
                content: '<p>Why not?</p>',
                createdAt: new Date( '09/21/2018 08:17:01' )
            }
        ]
    }
];

Then, prepare a plugin that will read the data from appData and use Users and Comments API.

class CommentsIntegration {
    constructor( editor ) {
        this.editor = editor;
    }

    init() {
        const usersPlugin = this.editor.plugins.get( 'Users' );
        const commentsPlugin = this.editor.plugins.get( 'Comments' );

        // Load the users data.
        for ( const user of appData.users ) {
            usersPlugin.addUser( user );
        }

        // Set the current user.
        usersPlugin.defineMe( appData.userId );

        // Load the comment threads data.
        for ( const commentThread of appData.commentThreads ) {
            commentsPlugin.addCommentThread( commentThread );
        }
    }
}

Finally, add the plugin in the editor configuration.

ClassicEditorWithComments
    .create( document.querySelector( '.editor' ), {
        extraPlugins: [ CommentsIntegration ],
        licenseKey: 'your-license-key',
        sidebar: {
            container: document.querySelector( '.sidebar' )
        }
    } );

# Saving the data

To save the editor data you need to get it from the Comments API first. To do this, use the getVisibleCommentThreads() method.

Then, use the comment threads data to save it in your database in a way you prefer. See the example below.

ClassicEditorWithComments
    .create( document.querySelector( '.editor' ), {
        extraPlugins: [ CommentsIntegration ],
        licenseKey: 'your-license-key',
        sidebar: {
            container: document.querySelector( '.sidebar' )
        }
    } )
    .then( editor => {
        // After the editor is initialized, add an action to be performed after a button is clicked.
        const comments = editor.plugins.get( 'Comments' );

        // Get the data on demand.
        document.querySelector( '.get-data' ).addEventListener( 'click', () => {
            const editorData = editor.data.get();
            const commentThreadsData = Array.from( comments.getVisibleCommentThreads() );

            // Now, use `editorData` and `commentThreadsData` to save the data in your application.
            // For example, you can set them as values of hidden input fields.
        } );
    } );

Below is the final solution.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>CKEditor 5 collaboration with comments</title>
        <style type="text/css">
         .container {
            /* To create the column layout. */
            display: flex;

            /* To make the container relative to its children. */
            position: relative;
         }

         .container .ck.ck-editor {
            /* To stretch the editor to max 700px
                (just to look nice for this example but it can be any size). */
            width: 100%;
            max-width: 700px;
         }

         .container .sidebar {
            /* Set some size for the sidebar (it can be any). */
            min-width: 300px;

            /* Add some distance. */
            padding: 0 10px;
         }
        </style>
    </head>
    <body>
        <button class="get-data">Get editor data</button>
        <div class="container">
            <div class="editor">
                <h2>
                    <comment id="thread-1" type="start"></comment>
                    Bilingual Personality Disorder
                    <comment id="thread-1" type="end"></comment>
                </h2>
                <p>
                    This may be the first time you hear about this made-up disorder but it actually isn’t so far from the truth.
                    As recent studies show, the language you speak has more effects on you then you realise.
                    According to the studies, the language a person speaks affects their cognition,
                    behaviour, emotions and hence <strong>their personality</strong>.
                </p>
                <p>
                    This shouldn’t come as a surprise
                    <a href="https://en.wikipedia.org/wiki/Lateralization_of_brain_function">since we already know</a>
                    that different regions of the brain becomes more active depending on the activity.
                    Since structure, information and especially <strong>the culture</strong> of languages varies substantially
                    and the language a person speaks is a essential element of daily life.
                </p>
            </div>
            <div class="sidebar"></div>
        </div>
    </body>
    <script src="./classiceditorwithcomments.js"></script>
    <script>
        // Application data will be available under a global variable `appData`.
        const appData = {};

        // Users data.
        appData.users = [
            {
                id: 'user-1',
                name: 'Joe Doe',
                // Note that the avatar is optional.
                avatar: 'https://randomuser.me/api/portraits/thumb/men/26.jpg'
            },
            {
                id: 'user-2',
                name: 'Ella Harper',
                avatar: 'https://randomuser.me/api/portraits/thumb/women/65.jpg'
            }
        ];

        // The ID of the current user.
        appData.userId = 'user-1';

        // Comment threads data.
        appData.commentThreads = [
            {
                threadId: 'thread-1',
                comments: [
                    {
                        commentId: 'comment-1',
                        author: 'user-1',
                        content: '<p>Are we sure we want to use a made-up disorder name?</p>',
                        createdAt: new Date( '09/20/2018 14:21:53' )
                    },
                    {
                        commentId: 'comment-2',
                        author: 'user-2',
                        content: '<p>Why not?</p>',
                        createdAt: new Date( '09/21/2018 08:17:01' )
                    }
                ]
            }
        ];

        class CommentsIntegration {
            constructor( editor ) {
                this.editor = editor;
            }

            init() {
                const usersPlugin = this.editor.plugins.get( 'Users' );
                const commentsPlugin = this.editor.plugins.get( 'Comments' );

                // Load the users data.
                for ( const user of appData.users ) {
                    usersPlugin.addUser( user );
                }

                // Set the current user.
                usersPlugin.defineMe( appData.userId );

                // Load the comment threads data.
                for ( const commentThread of appData.commentThreads ) {
                    commentsPlugin.addCommentThread( commentThread );
                }
            }
        }

        ClassicEditorWithComments
            .create( document.querySelector( '.editor' ), {
                extraPlugins: [ CommentsIntegration ],
                licenseKey: 'your-license-key',
                sidebar: {
                    container: document.querySelector( '.sidebar' )
                }
            } )
            .then( editor => {
                // After the editor is initialized, add an action to be performed after a button is clicked.
                const comments = editor.plugins.get( 'Comments' );

                // Get the data on demand.
                document.querySelector( '.get-data' ).addEventListener( 'click', () => {
                    const editorData = editor.data.get();
                    const commentThreadsData = Array.from( comments.getVisibleCommentThreads() );

                    console.log( editorData );
                    console.log( commentThreadsData );
                } );
            } );
    </script>
</html>

You can see how it works below:

Console

// Use `Save data with comments` button to see the result...

# Adapter integration

An adapter integration uses the provided adapter object to immediately save changes in comments in your data store. This is a recommended way of integrating comments with your application because it lets you handle the client-server communication in a more secure way. For example, you can check user permissions, validate sent data or update the data with information obtained on the server side, like a comment creation date. You will see how to handle the server response in the next steps.

# Implementation

First, define the adapter using the Comments#adapter setter. Adapter methods are called after a user makes a change in the comments. They allow you to save the change in your database. Each comment action has a separate adapter method. You should implement the following four adapter methods.

/**
 * Called each time the user adds a new comment to a thread.
 *
 * It saves the comment data in the database and returns a promise
 * that will be resolved when the save is completed.
 *
 * The `data` object does not expect the `userId` property.
 * For security reasons, the author of the comment should be set
 * on the server side.
 *
 * The `data` object does not expect the `createdAt` property either.
 * You should use the server-side time generator to ensure that all users
 * see the same date.
 *
 * @param {Object} data
 * @param {String} data.threadId The ID of the comment thread that
 * the new comment belongs to (needed only when adding a new comment).
 * @param {String} data.commentId The comment ID.
 * @param {String} data.content The comment content.
 * @returns {Promise}
 */
addComment( data ) {}

/**
 * Called each time the user changes the existing comment.
 *
 * It updates the comment data in the database and returns a promise
 * that will be resolved when the update is completed.
 *
 * @param {Object} data
 * @param {String} data.commentId The ID of the comment to update.
 * @param {String} data.content The new content of the comment.
 * @returns {Promise}
 */
updateComment( data ) {}

/**
 * Called each time the user removes a comment from the thread.
 *
 * It removes the comment from the database and returns a promise
 * that will be resolved when the remove is completed.
 *
 * @param {String} commentId The ID of the comment to remove.
 * @returns {Promise}
 */
removeComment( commentId ) {}

/**
 * Called when the editor needs the data for a comment thread.
 *
 * It returns a promise that resolves with the comment thread data.
 *
 * @param {String} threadId The ID of the thread to fetch.
 * @returns {Promise}
 */
getCommentThread( threadId ) {}

On the UI side each change in comments is performed immediately, however, all adapter actions are asynchronous and are performed in the background. Because of this all adapter methods need to return a Promise. When the promise is resolved, it means that everything went fine and a local change was successfully saved in the data store. When the promise is rejected, the editor fires the crash event. When you handle the server response you can decide if the promise should be resolved or rejected.

While any of the adapter action is being performed, a pending action is automatically added to the editor PendingActions plugin, so you do not have to worry that the editor will be destroyed before the adapter action has finished.

Implement the adapter now.

// Application data will be available under a global variable `appData`.
const appData = {};

// Users data.
appData.users = [
    {
        id: 'user-1',
        name: 'Joe Doe',
        // Note that the avatar is optional.
        avatar: 'https://randomuser.me/api/portraits/thumb/men/26.jpg'
    },
    {
        id: 'user-2',
        name: 'Ella Harper',
        avatar: 'https://randomuser.me/api/portraits/thumb/women/65.jpg'
    }
];

// The ID of the current user.
appData.userId = 'user-1';

class CommentsAdapter {
    constructor( editor ) {
        this.editor = editor;
    }

    init() {
        const usersPlugin = this.editor.plugins.get( 'Users' );
        const commentsPlugin = this.editor.plugins.get( 'Comments' );

        // Load the users data.
        for ( const user of appData.users ) {
            usersPlugin.addUser( user );
        }

        // Set the current user.
        usersPlugin.defineMe( appData.userId );

        // Set the adapter to the `Comments#adapter` property.
        commentsPlugin.adapter = {
            addComment( data ) {
                console.log( 'Comment added', data );

                // Write a request to your database here. The returned `Promise`
                // should be resolved when the request has finished.
                return Promise.resolve();
            },

            updateComment( data ) {
                console.log( 'Comment updated', data );

                // Write a request to your database here. The returned `Promise`
                // should be resolved when the request has finished.
                return Promise.resolve();
            },

            removeComment( commentId ) {
                console.log( 'Comment removed', commentId );

                // Write a request to your database here. The returned `Promise`
                // should be resolved when the request has finished.
                return Promise.resolve();
            },

            getCommentThread( threadId ) {
                console.log( 'Getting comment thread', threadId );

                // Write a request to your database here. The returned `Promise`
                // should resolve with the comment thread data.
                return Promise.resolve();
            }
        };
    }
}

ClassicEditorWithComments
    .create( document.querySelector( '.editor' ), {
        extraPlugins: [ CommentsAdapter ],
        licenseKey: 'your-license-key',
        sidebar: {
            container: document.querySelector( '.sidebar' )
        }
    } );

The adapter is now ready to use with your rich-text editor.

Below is the final solution.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>CKEditor 5 Collaboration with comments</title>
        <style type="text/css">
         .container {
            /* To create the column layout. */
            display: flex;

            /* To make the container relative to its children. */
            position: relative;
         }

         .container .ck.ck-editor {
            /* To stretch the editor to max 700px
                (just to look nice for this example but it can be any size). */
            width: 100%;
            max-width: 700px;
         }

         .container .sidebar {
            /* Set some size for the sidebar (it can be any). */
            min-width: 300px;

            /* Add some distance. */
            padding: 0 10px;
         }
        </style>
    </head>
    <body>
        <button class="save">Save the editor data</button>

        <div class="container">
            <div class="editor">
                <h2>
                    <comment id="thread-1" type="start"></comment>
                    Bilingual Personality Disorder
                    <comment id="thread-1" type="end"></comment>
                </h2>
                <p>
                    This may be the first time you hear about this made-up disorder but it actually isn’t so far from the truth.
                    As recent studies show, the language you speak has more effects on you then you realise.
                    According to the studies, the language a person speaks affects their cognition,
                    behaviour, emotions and hence <strong>their personality</strong>.
                </p>
                <p>
                    This shouldn’t come as a surprise
                    <a href="https://en.wikipedia.org/wiki/Lateralization_of_brain_function">since we already know</a>
                    that different regions of the brain becomes more active depending on the activity.
                    Since structure, information and especially <strong>the culture</strong> of languages varies substantially
                    and the language a person speaks is a essential element of daily life.
                </p>
            </div>
            <div class="sidebar"></div>
        </div>
    </body>
    <script src="./classiceditorwithcomments.js"></script>
    <script>
        // Application data will be available under a global variable `appData`.
        const appData = {};

        // Users data.
        appData.users = [
            {
                id: 'user-1',
                name: 'Joe Doe',
                // Note that the avatar is optional.
                avatar: 'https://randomuser.me/api/portraits/thumb/men/26.jpg'
            },
            {
                id: 'user-2',
                name: 'Ella Harper',
                avatar: 'https://randomuser.me/api/portraits/thumb/women/65.jpg'
            }
        ];

        // The ID of the current user.
        appData.userId = 'user-1';

        class CommentsAdapter {
            constructor( editor ) {
                this.editor = editor;
            }

            init() {
                const usersPlugin = this.editor.plugins.get( 'Users' );
                const commentsPlugin = this.editor.plugins.get( 'Comments' );

                // Load the users data.
                for ( const user of appData.users ) {
                    usersPlugin.addUser( user );
                }

                // Set the current user.
                usersPlugin.defineMe( appData.userId );

                // Set the adapter to the `Comments#adapter` property.
                commentsPlugin.adapter = {
                    addComment( data ) {
                        console.log( 'Comment added', data );

                        // Write a request to your database here. The returned `Promise`
                        // should be resolved when the request has finished.
                        return Promise.resolve();
                    },

                    updateComment( data ) {
                        console.log( 'Comment updated', data );

                        // Write a request to your database here. The returned `Promise`
                        // should be resolved when the request has finished.
                        return Promise.resolve();
                    },

                    removeComment( commentId ) {
                        console.log( 'Comment removed', commentId );

                        // Write a request to your database here. The returned `Promise`
                        // should be resolved when the request has finished.
                        return Promise.resolve();
                    },

                    getCommentThread( threadId ) {
                        console.log( 'Getting comment thread', threadId );

                        // Write a request to your database here. The returned `Promise`
                        // should resolve with the comment thread data.
                        return Promise.resolve();
                    }
                };
            }
        }

        ClassicEditorWithComments
            .create( document.querySelector( '.editor' ), {
                extraPlugins: [ CommentsAdapter ],
                licenseKey: 'your-license-key',
                sidebar: {
                    container: document.querySelector( '.sidebar' )
                }
            } );
    </script>
</html>

You can see how it works below:

Pending adapter actions console

// Add, remove or update comment to see the result...

Since the comments adapter saves comments changes immediately after they are performed, it is also recommended to use the Autosave plugin to save the editor content after each change.