Report an issue

guideCustom template for annotations

Providing a custom template is a middle ground between using the default UI and providing a completely custom UI on your own.

This solution gives you a possibility to alter the HTML structure of comment and suggestion annotations. Thanks to that you can, for example:

  • Add new CSS classes or HTML elements to enable more complex styling.
  • Re-arrange annotation views.
  • Add new UI elements, linked with your custom features.
  • Remove UI elements.

It is highly recommended to get familiar with the CKEditor 5 UI library API and guide before continuing.

# Views for comments and suggestions

The view classes used by default by CKEditor 5 collaboration features are:

These classes are exported in files in their respective repositories at the following paths:

  • @ckeditor/ckeditor5-comments/src/comments/ui/view/commentthreadview.js,
  • @ckeditor/ckeditor5-comments/src/comments/ui/view/commentview.js,
  • @ckeditor/ckeditor5-track-changes/src/ui/view/suggestionthreadview.js.

Note that the source code of these files and classes is closed.

It is highly recommended to get familiar with the comment views API and suggestion views API before continuing.

# Changing the view template

To use a custom view template, you need to:

  1. Create a new view class by extending the default view class.
  2. Provide (or extend) the template by overwriting the _getTemplate() method.
  3. Set your custom view class through the editor configuration. We recommend setting it in the default configuration in the editor build file.

Creating a custom view:

// mycommentview.js

import CommentView from '@ckeditor/ckeditor5-comments/src/ui/view/commentview';

// Create a new comment view class basing on the default view.
class MyCommentView extends CommentView {
    // Overwrite the method to provide a custom template.
    _getTempate() {
        return {
            // Provide the template definition here.
        };
    }
}

Setting up configuration in the build file:

// build.js

import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import MyCommentView from './mycommentview';

export default class ClassicEditor extends ClassicEditorBase {}

// ...

// Make sure the `defaultConfig` object exists.
ClassicEditor.defaultConfig.comments = {
    CommentView: MyCommentView
};

Custom comment and suggestion thread views can be set in a similar way:

ClassicEditor.defaultConfig.comments = {
    CommentThreadView: MyCommentThreadView
};

ClassicEditor.defaultConfig.trackChanges = {
    SuggestionThreadView: MySuggestionThreadView
};

# Example: Adding a new button

Below is an example of how you can provide a simple custom feature that requires creating a new UI element and additional styling.

The proposed feature should allow for marking some comments as important. An important comment should have a distinct background color.

To bring this feature, we need:

  • A CSS class to change the background color of an important comment.
  • A button to toggle the comment state.
  • A way to load and save the comment state data.

Note that it is your responsibility to prepare the part of the application that manages the comment state. Actual implementation will vary depending on your application.

# Styling for an important comment

This step is easy. Simply, create a CSS file and add a CSS rule:

/* importantcomment.css */

/* Yellow border for an important comment. */
.ck-comment--important {
    border-right: 3px solid var(--ck-color-comment-box-border);
}

# Creating a button and adding it to the template

In this step, you will add some new elements to the template.

Keep in mind that you do not need to overwrite the whole template. You can extend it instead.

// importantcommentview.js

import CommentView from '@ckeditor/ckeditor5-comments/src/comments/ui/view/commentview';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';

import './importantcomment.css';

export default class ImportantCommentView extends CommentView {
    constructor( ...args ) {
        super( ...args );

        // Make `isImportant` observable so it can be bound to the template.
        this.set( 'isImportant', false );
    }

    _getTemplate() {
        // Use the original method to get the default template.
        // The default template definition structure is described in the comment view API.
        const templateDefinition = super._getTemplate();

        // If `isImportant` is `true`, add the `ck-comment--important` class to the template.
        templateDefinition.children[ 0 ].attributes.class.push( this.bindTemplate.if( 'isImportant', 'ck-comment--important' ) );

        // Add the new button next to other comment buttons (edit and remove).
        templateDefinition.children[ 0 ].children[ 1 ].children[ 1 ].children.unshift( this._createImportantButtonView() );

        return templateDefinition;
    }

    _createImportantButtonView() {
        // Create a new button view.
        const button = new ButtonView( this.locale );

        // Create an icon for the button view.
        const starIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M15.22 18.36c.18-.02.35-.1.46-.25a.6.6 0 00.11-.5l-1.12-5.32 4.12-3.66a.6.6 0 00.18-.65.63.63 0 00-.54-.42l-5.54-.6L10.58 2a.64.64 0 00-.58-.37.64.64 0 00-.58.37l-2.3 4.94-5.55.6a.63.63 0 00-.54.43.6.6 0 00.18.65l4.12 3.66-1.12 5.32c-.05.24.04.49.25.63.2.14.47.16.68.04L10 15.59l4.86 2.69c.1.06.23.09.36.08zm-.96-1.83l-3.95-2.19a.65.65 0 00-.62 0l-3.95 2.19.91-4.33a.6.6 0 00-.2-.58L3.1 8.64l4.51-.5a.64.64 0 00.51-.36L10 3.76l1.88 4.02c.09.2.28.34.5.36l4.52.5-3.35 2.98a.6.6 0 00-.2.58l.91 4.33z"/></svg>';

        // Use the localization service.
        // The feature will be translatable.
        const t = this.locale.t;

        // Set the label and the icon for the button.
        button.set( {
            icon: starIcon,
            isToggleable: true,
            tooltip: t( 'Important' ),
            withText: true
        } );

        // Add a class to the button to style it.
        button.extendTemplate( {
            attributes: {
                class: 'ck-button-important'
            }
        } );

        // The button should be enabled if the comment model is not in read-only mode.
        // The same setting is used for other comment buttons.
        button.bind( 'isEnabled' ).to( this._model, 'isReadOnly', value => !value );

        // The button should be hidden if the comment is not editable
        // (this is true when the current user is not the comment author).
        // The same setting is used for other comment buttons.
        button.bind( 'isVisible' ).to( this._model, 'isEditable' );

        // When the button is clicked, change the comment state.
        // The callback will be written later in this guide.
        button.on( 'execute', () => {} );

        return button;
    }
}

# Handling new data

You need to load and save the comment status.

In this sample, you will take an approach similar to the “load and save” integration.

First, define a data storage that will keep the IDs of important comments. It will be a common (singleton-like) object, used by all instances of the custom comment view. Comment views will read and set data in this storage.

// importantcommentstorage.js
import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
import mix from '@ckeditor/ckeditor5-utils/src/mix';

class ImportantCommentStorage {
    constructor() {
        this._importantComments = null;
    }

    // Load the initial data.
    load( commentIds ) {
        this._importantComments = new Set( commentIds );
    }

    // Get the status for a comment with a given `commentId`.
    get( commentId ) {
        return this._importantComments.has( commentId );
    }

    // Get the IDs of all important comments.
    getAll() {
        return Array.from( this._importantComments );
    }

    // Toggle the status of a comment with a given `commentId`
    toggle( commentId ) {
        const isImportant = !this.get( commentId );

        if ( isImportant ) {
            this._importantComments.add( commentId );
        } else {
            this._importantComments.delete( commentId );
        }

        // Whenever the data is changed in the store, fire an event
        // so a view can refresh itself.
        this.fire( 'change:' + commentId, isImportant );
    }
}

mix( ImportantCommentStorage, EmitterMixin );

// Create and export a storage object.
// Since you export an instance, it will be common for
// all the code that will use this package.
const storage = new ImportantCommentStorage();

export default storage;

After the data storage is created, it can be used by the custom comment view. Switch to importantcommentview.js and perform the following changes.

Import the storage object at the beginning of the file:

import storage from './importantcommentstorage';

Toggle the comment state when the new button is clicked:

button.on( 'execute', () => storage.toggle( this._model.id ) );

Set the initial value of isImportant to the value from the storage:

this.set( 'isImportant', storage.get( this._model.id ) );

And finally, bind the isImportant value to the value in the storage. To do that, you will listen to the change event fired by the storage object:

this.listenTo(
    storage,
    'change:' + this._model.id,
    ( evt, isImportant ) => this.isImportant = isImportant
);

There are multiple ways to implement data handling. Use a solution that fits your application and the integration type you are using.

# Enabling the custom view

The custom view will be enabled in the build file, as shown earlier.

In addition to that, you will also export the storage object to make it accessible in a convenient way:

// build.js

import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';

import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import TrackChanges from '@ckeditor/ckeditor5-track-changes/src/trackchanges';

import ImportantCommentView from './importantcommentview';
import storage from './importantcommentstorage';

class Editor extends ClassicEditorBase {}

Editor.builtinPlugins = [ Essentials, Paragraph, Bold, Italic, TrackChanges ];

Editor.defaultConfig = {
    language: 'en',
    comments: {
        CommentView: ImportantCommentView
    }
};

// Export the editor build and the storage in one object.
export default { Editor, storage };

# Loading the initial comment data

As mentioned earlier, in this example you will use the “load and save” integration.

// index.js

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

    importantComments: [ 'comment-1' ]
};

// Assumed that the build result is available under the `ClassicEditor` property.
// This can be changed in the webpack configuration.
// Remember that in the build file you exported the editor build and the storage object.
const { Editor, storage } = ClassicEditor;

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

    init() {
        const usersPlugin = this.editor.plugins.get( 'Users' );
        const commentsRepositoryPlugin = this.editor.plugins.get( 'CommentsRepository' );

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

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

        // Load comment statuses to the storage.
        storage.load( appData.importantComments );

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

Editor
    .create( document.querySelector( '#editor' ), {
        initialData: appData.initialData,
        extraPlugins: [ CommentsIntegration ],
        licenseKey: 'your-license-key',
        sidebar: {
            container: document.querySelector( '#sidebar' )
        },
        toolbar: {
            items: [ 'bold', 'italic', '|', 'comment', 'trackChanges' ]
        }
    } )
    .then( editor => {
        // After the editor is initialized, add an action to be performed after the button is clicked.
        const commentsRepository = editor.plugins.get( 'CommentsRepository' );

        // Get the data on demand.
        document.querySelector( '#get-data' ).addEventListener( 'click', () => {
            const editorData = editor.data.get();
            const commentThreadsData = commentsRepository.getCommentThreads( {
                skipNotAttached: true,
                skipEmpty: true
            } );
            const importantComments = storage.getAll();

            // Now, use `editorData` and `commentThreadsData` to save the data in your application.
            // For example, you can set them as values of hidden input fields.
            console.log( 'editorData', editorData );
            console.log( 'commentThreadsData', commentThreadsData );
            console.log( 'importantComments', importantComments );
        } );
    } )
    .catch( error => console.error( error ) );

# Full implementation

You can find the final code for the created components below, together with the complete HTML code and an example of appData.

/* importantcomment.css */

/* Yellow border for an important comment. */
.ck-comment--important {
    border-right: 3px solid var(--ck-color-comment-box-border);
}
// importantcommentstorage.js
import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
import mix from '@ckeditor/ckeditor5-utils/src/mix';

class ImportantCommentStorage {
    constructor() {
        this._importantComments = null;
    }

    // Load the initial data.
    load( commentIds ) {
        this._importantComments = new Set( commentIds );
    }

    // Get the status for a comment with a given `commentId`.
    get( commentId ) {
        return this._importantComments.has( commentId );
    }

    // Get the IDs of all important comments.
    getAll() {
        return Array.from( this._importantComments );
    }

    // Toggle the status of a comment with a given `commentId`
    toggle( commentId ) {
        const isImportant = !this.get( commentId );

        if ( isImportant ) {
            this._importantComments.add( commentId );
        } else {
            this._importantComments.delete( commentId );
        }

        // Whenever data is changed in the store, fire an event
        // so a view can refresh itself.
        this.fire( 'change:' + commentId, isImportant );
    }
}

mix( ImportantCommentStorage, EmitterMixin );

// Create and export a storage object.
// Since you export an instance, it will be common for
// all the code that will use this package.
const storage = new ImportantCommentStorage();

export default storage;
// importantcommentview.js

import CommentView from '@ckeditor/ckeditor5-comments/src/comments/ui/view/commentview';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';

import storage from './importantcommentstorage';
import './importantcomment.css';

export default class ImportantCommentView extends CommentView {
    constructor( ...args ) {
        super( ...args );

        // Make `isImportant` observable so it can be bound to the template.
        this.set( 'isImportant', storage.get( this._model.id ) );

        this.listenTo(
            storage,
            'change:' + this._model.id,
            ( evt, isImportant ) => this.isImportant = isImportant
        );
    }

    _getTemplate() {
        // Use the original method to get the default template.
        // The default template definition structure is described in the comment view API.
        const templateDefinition = super._getTemplate();

        // If `isImportant` is `true`, add the `ck-comment--important` class to the template.
        templateDefinition.children[ 0 ].attributes.class.push( this.bindTemplate.if( 'isImportant', 'ck-comment--important' ) );

        // Add the new button next to other comment buttons (edit and remove).
        templateDefinition.children[ 0 ].children[ 1 ].children[ 1 ].children.unshift( this._createImportantButtonView() );

        return templateDefinition;
    }

    _createImportantButtonView() {
        // Create a new button view.
        const button = new ButtonView( this.locale );

        // Create an icon for the button view.
        const starIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M15.22 18.36c.18-.02.35-.1.46-.25a.6.6 0 00.11-.5l-1.12-5.32 4.12-3.66a.6.6 0 00.18-.65.63.63 0 00-.54-.42l-5.54-.6L10.58 2a.64.64 0 00-.58-.37.64.64 0 00-.58.37l-2.3 4.94-5.55.6a.63.63 0 00-.54.43.6.6 0 00.18.65l4.12 3.66-1.12 5.32c-.05.24.04.49.25.63.2.14.47.16.68.04L10 15.59l4.86 2.69c.1.06.23.09.36.08zm-.96-1.83l-3.95-2.19a.65.65 0 00-.62 0l-3.95 2.19.91-4.33a.6.6 0 00-.2-.58L3.1 8.64l4.51-.5a.64.64 0 00.51-.36L10 3.76l1.88 4.02c.09.2.28.34.5.36l4.52.5-3.35 2.98a.6.6 0 00-.2.58l.91 4.33z"/></svg>';

        // Use the localization service.
        // The feature will be translatable.
        const t = this.locale.t;

        // Set the label and the icon for the button.
        button.set( {
            icon: starIcon,
            isToggleable: true,
            tooltip: t( 'Important' ),
            withText: true
        } );

        // Add a class to the button to style it.
        button.extendTemplate( {
            attributes: {
                class: 'ck-button-important'
            }
        } );

        // The button should be enabled if the comment model is not in read-only mode.
        // The same setting is used for other comment buttons.
        button.bind( 'isEnabled' ).to( this._model, 'isReadOnly', value => !value );

        // The button should be hidden if the comment is not editable
        // (this is true when the current user is not the comment author).
        // The same setting is used for other comment buttons.
        button.bind( 'isVisible' ).to( this._model, 'isEditable' );

        // When the button is clicked, change the comment state.
        button.on( 'execute', () => storage.toggle( this._model.id ) );

        return button;
    }
}
// build.js

import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';

import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import TrackChanges from '@ckeditor/ckeditor5-track-changes/src/trackchanges';

import ImportantCommentView from './importantcommentview';
import storage from './importantcommentstorage';

class Editor extends ClassicEditorBase {}

Editor.builtinPlugins = [ Essentials, Paragraph, Bold, Italic, TrackChanges ];

Editor.defaultConfig = {
    language: 'en',
    comments: {
        CommentView: ImportantCommentView
    }
};

// Export the editor build and the storage in one object.
export default { Editor, storage };
<!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;
             }

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

                 /* Add some distance. */
                 padding: 0 10px;
             }
        </style>
    </head>
    <body>
        <button id="get-data">Get editor data</button>

        <div id="container">
            <div id="editor"></div>
            <div id="sidebar"></div>
        </div>
    </body>
    <script src="../build/ckeditor.js"></script>
    <script>
        // Application data will be available under a global variable `appData`.
        const appData = {
            // Users data.
            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.
            userId: 'user-1',

            // Comment threads data.
            commentThreads: [
                {
                    threadId: 'thread-1',
                    comments: [
                        {
                            commentId: 'comment-1',
                            authorId: 'user-1',
                            content: '<p>Are we sure we want to use a made-up disorder name?</p>',
                            createdAt: new Date()
                        },
                        {
                            commentId: 'comment-2',
                            authorId: 'user-2',
                            content: '<p>Why not?</p>',
                            createdAt: new Date()
                        }
                    ]
                }
            ],

            // Initial editor data.
            initialData:
                `<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 than you realize.
                    According to the studies, the language a person speaks affects their cognition,
                    behavior, 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 become more active depending on the activity.
                    The structure, information and especially <strong>the culture</strong> of languages varies substantially
                    and the language a person speaks is an essential element of daily life.
                </p>`,

                importantComments: [ 'comment-1' ]
            };

            // Assumed that the build result is available under the `ClassicEditor` global variable.
            // This can be changed in the webpack configuration.
            // Remember that in the build file you exported the editor class and the storage object.
            const { Editor, storage } = ClassicEditor;

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

                init() {
                    const usersPlugin = this.editor.plugins.get( 'Users' );
                    const commentsRepositoryPlugin = this.editor.plugins.get( 'CommentsRepository' );

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

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

                    // Load comment statuses to the storage.
                    storage.load( appData.importantComments );

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

            Editor
                .create( document.querySelector( '#editor' ), {
                    initialData: appData.initialData,
                    extraPlugins: [ CommentsIntegration ],
                    licenseKey: 'your-license-key',
                    sidebar: {
                        container: document.querySelector( '#sidebar' )
                    },
                    toolbar: {
                        items: [ 'bold', 'italic', '|', 'comment', 'trackChanges' ]
                    }
                } )
                .then( editor => {
                    // After the editor is initialized, add an action to be performed after the button is clicked.
                    const commentsRepository = editor.plugins.get( 'CommentsRepository' );

                    // Get the data on demand.
                    document.querySelector( '#get-data' ).addEventListener( 'click', () => {
                        const editorData = editor.data.get();
                        const commentThreadsData = commentsRepository.getCommentThreads( {
                            skipNotAttached: true,
                            skipEmpty: true
                        } );
                        const importantComments = storage.getAll();

                        // Now, use `editorData` and `commentThreadsData` to save the data in your application.
                        // For example, you can set them as values of hidden input fields.
                        console.log( 'editorData', editorData );
                        console.log( 'commentThreadsData', commentThreadsData );
                        console.log( 'importantComments', importantComments );
                    } );
                } )
                .catch( error => console.error( error ) );
    </script>
</html>

# Live demo