Report an issue

guideCustom view for annotation

Providing a custom view is the most powerful way to customize annotations in CKEditor 5 collaboration features.

In this case, you will need to provide the whole view: the template, UI elements and any behavior logic. This gives you more control than just template modifications, as you can provide any custom view you need. However, you can still use some of the default building blocks to speed up your development.

It is highly recommended to get familiar with the Custom template for annotations guide before continuing.

# Using base views

Providing a custom view is based on the same solution as when providing a custom template. You will need to create your own class for the view. In this case, you will be interested in extending base view classes:

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

  • @ckeditor/ckeditor5-comments/src/comments/ui/view/basecommentthreadview.js,
  • @ckeditor/ckeditor5-comments/src/comments/ui/view/basecommentview.js,
  • @ckeditor/ckeditor5-track-changes/src/ui/view/basesuggestionthreadview.js.

Base view classes provide some core functionality that is necessary for the view to operate, no matter what the view looks like or what its template is.

You can learn more about these classes in the Comments API and Track changes API documentation.

Note that the default view classes also extend the base view classes.

# Default view template

The default template used by the CommentThreadView looks as follows:

_getTemplate() {
    const bind = this.bindTemplate;

    return {
        tag: 'div',

        attributes: {
            class: [
                'ck-thread',
                bind.if( 'isActive', 'ck-thread--active' ),
                bind.if( 'isConfirm', 'ck-thread--remove-confirmation' ),
                bind.to( 'actionIndicator', value => value ? `ck-thread--${ value }` : '' )
            ],
            'data-thread-id': this._model.id,
            // Needed for managing focus after adding new comment.
            tabindex: -1
        },

        children: [
            {
                tag: 'div',
                attributes: {
                    class: 'ck-thread__container'
                },
                children: [
                    this.commentsListView,
                    this.commentThreadInputView
                ]
            }
        ]
    };
}

# Creating and enabling a custom view

As a reminder, the code snippet below shows how to enable a custom view for CKEditor 5 collaboration features:

import BaseCommentThreadView from '@ckeditor/ckeditor5-comments/src/comments/ui/view/basecommentthreadview.js';

class CustomCommentThreadView extends BaseCommentThreadView {
    // ...
}

// ...

Editor.defaultConfig.comments = {
    CommentThreadView: CustomCommentThreadView
}

The only obligatory action that needs to be done in your custom view constructor is setting up a template:

class CustomCommentThreadView extends BaseCommentThreadView {
    constructor( ...args ) {
        super( ...args );

        this.setTemplate( {
            // ... Template definition here.
        } );
    }
}

It is your responsibility to construct the template and all the UI elements that are needed for the view.

# Reading data and binding with template

Your view is passed a model object that is available under the _model property. It should be used to set (or bind to) the initial data of your view’s properties.

Some of your view’s properties may be used to control the view template. For example, you can bind a view property so that when it is set to true, the template main element will receive an additional CSS class. To bind view properties with a template, the properties need to be observable. Of course, you can bind already existing observable properties with your template.

You can also bind two observable properties in a way that the value of one property will depend on the other. You can also directly listen to the changes of an observable property.

// Set an observable property.
this.set( 'isImportant', false );

// ...

this.setTemplate( {
    tag: 'div',

    attributes: {
        class: [
            // Bind the new observable property with the template.
            this.bindTemplate.if( 'isImportant', 'ck-comment--important' ),
            // Bind an existing observable property with the template.
            this.bindTemplate.if( 'isDirty', 'ck-comment--unsaved' )
        ]
    }
} );

# Performing actions

The view needs to communicate with other parts of the system. When a user performs an action, something needs to be executed, for example: a comment should be removed. This communication is achieved by firing events (with appropriate data). See the example below:

this.removeButton = new ButtonView();
this.removeButton.on( 'execute', () => this.fire( 'removeCommentThread' ) );

The list of all events that a given view class can fire is available in the API documentation of base view classes.

# Example: Comment thread actions dropdown

In this example you will create a custom comment thread view with action buttons (edit, remove) moved to a dropdown UI element. The dropdown will be added inside a new element, placed above the thread UI.

# Creating a custom thread view with a new template

First, create a foundation for your custom solution:

// customcommentthreadview.js

import BaseCommentThreadView from '@ckeditor/ckeditor5-comments/src/comments/ui/view/basecommentthreadview.js';

class CustomCommentThreadView extends BaseCommentThreadView {
    constructor( ...args ) {
        super( ...args );

        // This template definition is partially based on the default comment thread view.
        this.setTemplate( {
            tag: 'div',

            attributes: {
                class: [
                    'ck-thread',
                    this.bindTemplate.if( 'isActive', 'ck-thread--active' )
                ],
                // Needed for managing focus after adding a new comment.
                tabindex: -1
            },

            children: [
                // Adding the top bar element that will hold the dropdown.
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread-top-bar'
                    },
                    children: [
                        this._createActionsDropdown()
                    ]
                },
                // The rest of the view is as in the default view.
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread__container'
                    },
                    children: [
                        this.commentsListView,
                        this.commentThreadInputView
                    ]
                }
            ]
        } );
    }

    _createActionsDropdown() {
        // ...
    }
}

Then, you need to create a dropdown UI element and fill it with items:

import { createDropdown, addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
import UIModel from '@ckeditor/ckeditor5-ui/src/model';
import Collection from '@ckeditor/ckeditor5-utils/src/collection';

// ...

_createActionsDropdown() {
    const dropdownView = createDropdown( this.locale );

    dropdownView.buttonView.set( {
        label: 'Actions',
        withText: true
    } );

    const items = new Collection();

    const editButtonModel = new UIModel( {
        withText: true,
        label: 'Edit',
        action: 'edit'
    } );

    items.add( {
        type: 'button',
        model: editButtonModel
    } );

    const removeButtonModel = new UIModel( {
        withText: true,
        label: 'Delete',
        action: 'delete'
    } );

    items.add( {
        type: 'button',
        model: removeButtonModel
    } );

    addListToDropdown( dropdownView, items );

    dropdownView.on( 'execute', evt => {
        // ...
    } );

    return dropdownView;
}

Note that the dropdown should not be visible if the current local user is not the author of the thread.

Since the first comment in the comment thread represents the whole thread, you can base on the properties of the first comment.

If there are no comments in the thread, it means that this is a new thread so the local user is the author.

constructor( ...args ) {
    super( ...args );

    // The template definition is partially based on the default comment thread view.
    const templateDefinition = {
        tag: 'div',

        attributes: {
            class: [
                'ck-thread',
                this.bindTemplate.if( 'isActive', 'ck-thread--active' )
            ],
            // Needed for managing focus after adding a new comment.
            tabindex: -1
        },

        children: [
            {
                tag: 'div',
                attributes: {
                    class: 'ck-thread__container'
                },
                children: [
                    this.commentsListView,
                    this.commentThreadInputView
                ]
            }
        ]
    };

    const isNewThread = this.length == 0;
    const isAuthor = isNewThread || this._localUser == this._model.comments.get( 0 ).author;

    // Add the actions dropdown only if the local user is the author of the comment thread.
    if ( isAuthor ) {
        templateDefinition.children.unshift(
            {
                tag: 'div',
                attributes: {
                    class: 'ck-thread-top-bar'
                },

                children: [
                    this._createActionsDropdown()
                ]
            }
        );
    }

    this.setTemplate( templateDefinition );
}

As far as disabling the UI is concerned, the actions in the dropdown should be disabled if the comment thread is in the read-only mode. Also, the edit button should be hidden if there are no comments in the thread.

const editButtonModel = new UIModel( {
    withText: true,
    label: 'Edit',
    action: 'edit'
} );

// The button should be enabled when the read-only mode is off.
// So, `isEnabled` should be a negative of `isReadOnly`.
editButtonModel.bind( 'isEnabled' )
    .to( this._model, 'isReadOnly', isReadOnly => !isReadOnly );

// Hide the button if the thread has no comments yet.
editButtonModel.bind( 'isVisible' )
    .to( this, 'length', length => length > 0 );

items.add( {
    type: 'button',
    model: editButtonModel
} );

const removeButtonModel = new UIModel( {
    withText: true,
    label: 'Delete',
    action: 'delete'
} );

removeButtonModel.bind( 'isEnabled' )
    .to( this._model, 'isReadOnly', isReadOnly => !isReadOnly );

items.add( {
    type: 'button',
    model: removeButtonModel
} );

Finally, some styling will be required for the new UI elements:

/* customcommentthreadview.css */

.ck-thread-top-bar {
    padding: 2px 4px 3px 4px;
    background: #404040;
    text-align: right;
}

.ck-thread-top-bar .ck.ck-dropdown {
    font-size: 14px;
    width: 100px;
}

.ck-thread-top-bar .ck.ck-dropdown .ck-button.ck-dropdown__button {
    color: #000000;
    background: #EEEEEE;
}

Add it in customcommentthreadview.js:

import './customcommentthreadview.css';

# Linking buttons with actions

The edit button should turn the first comment into edit mode:

dropdownView.on( 'execute', evt => {
    const action = evt.source.action;

    if ( action == 'edit' ) {
        this.commentsListView.commentViews.get( 0 ).switchToEditMode();
    }

    // ...
} );

The delete button should remove the comment thread.

As described earlier, your view should fire events to communicate with other parts of the system:

dropdownView.on( 'execute', evt => {
    const action = evt.source.action;

    if ( action == 'edit' ) {
        this.commentsListView.commentViews.get( 0 ).switchToEditMode();
    }

    if ( action == 'delete' ) {
        this.fire( 'removeCommentThread' );
    }
} );

# Altering the first comment view

Your new custom comment thread view is ready.

For comment views, you will use the default comment views. However, there is one thing you need to take care of. Since you moved comment thread controls to a separate dropdown, you should hide these buttons from the first comment view.

This modification will be added in a custom comment thread view. It should not be done in a custom comment view because that would have an impact on comments in suggestion threads.

The first comment view can be obtained from the commentsListView property. If there are no comments yet, you can listen to the property and apply the custom behavior when the first comment view is added.

constructor( ...args ) {
    // ...

    if ( this.length > 0 ) {
        // If there is a comment when the thread is created, apply custom behavior to it.
        this._modifyFirstCommentView();
    } else {
        // If there are no comments (an empty thread was created by the user),
        // listen to `this.commentsListView` and wait for the first comment to be added.
        this.listenTo( this.commentsListView.commentViews, 'add', evt => {
            // And apply the custom behavior when it is added.
            this._modifyFirstCommentView();

            evt.off();
        } );
    }
}

// ...

_modifyFirstCommentView() {
    // Get the first comment.
    const commentView = this.commentsListView.commentViews.get( 0 );

    // By default, the comment button is bound to the model state
    // and the buttons are visible only if the current local user is the author.
    // You need to remove this binding and make buttons for the first
    // comment always invisible.
    commentView.removeButton.unbind( 'isVisible' );
    commentView.removeButton.isVisible = false;

    commentView.editButton.unbind( 'isVisible' );
    commentView.editButton.isVisible = false;
}

# Final solution

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

/* customcommentthreadview.css */

.ck-thread-top-bar {
    padding: 2px 4px 3px 4px;
    background: #404040;
    text-align: right;
}

.ck-thread-top-bar .ck.ck-dropdown {
    font-size: 14px;
    width: 100px;
}

.ck-thread-top-bar .ck.ck-dropdown .ck-button.ck-dropdown__button {
    color: #000000;
    background: #EEEEEE;
}
// customcommentthreadview.js

import { createDropdown, addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
import UIModel from '@ckeditor/ckeditor5-ui/src/model';
import Collection from '@ckeditor/ckeditor5-utils/src/collection';

import BaseCommentThreadView from '@ckeditor/ckeditor5-comments/src/comments/ui/view/basecommentthreadview.js';

import './customcommentthreadview.css';

export default class CustomCommentThreadView extends BaseCommentThreadView {
    constructor( ...args ) {
        super( ...args );

        // The template definition is partially based on the default comment thread view.
        const templateDefinition = {
            tag: 'div',

            attributes: {
                class: [
                    'ck-thread',
                    this.bindTemplate.if( 'isActive', 'ck-thread--active' )
                ],
                // Needed for managing focus after adding a new comment.
                tabindex: -1
            },

            children: [
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread__container'
                    },
                    children: [
                        this.commentsListView,
                        this.commentThreadInputView
                    ]
                }
            ]
        };

        const isNewThread = this.length == 0;
        const isAuthor = isNewThread || this._localUser == this._model.comments.get( 0 ).author;

        // Add the actions dropdown only if the local user is the author of the comment thread.
        if ( isAuthor ) {
            templateDefinition.children.unshift(
                {
                    tag: 'div',
                    attributes: {
                        class: 'ck-thread-top-bar'
                    },

                    children: [
                        this._createActionsDropdown()
                    ]
                }
            );
        }

        this.setTemplate( templateDefinition );

        if ( this.length > 0 ) {
            // If there is a comment when the thread is created, apply custom behavior to it.
            this._modifyFirstCommentView();
        } else {
            // If there are no comments (an empty thread was created by a user),
            // listen to `this.commentsListView` and wait for the first comment to be added.
            this.listenTo( this.commentsListView.commentViews, 'add', evt => {
                // And apply the custom behavior when it is added.
                this._modifyFirstCommentView();

                evt.off();
            } );
        }
    }

    _createActionsDropdown() {
        const dropdownView = createDropdown( this.locale );

        dropdownView.buttonView.set( {
            label: 'Actions',
            withText: true
        } );

        const items = new Collection();

        const editButtonModel = new UIModel( {
            withText: true,
            label: 'Edit',
            action: 'edit'
        } );

        // The button should be enabled when the read-only mode is off.
        // So, `isEnabled` should be a negative of `isReadOnly`.
        editButtonModel.bind( 'isEnabled' )
            .to( this._model, 'isReadOnly', isReadOnly => !isReadOnly );

        // Hide the button if the thread has no comments yet.
        editButtonModel.bind( 'isVisible' )
            .to( this, 'length', length => length > 0 );

        items.add( {
            type: 'button',
            model: editButtonModel
        } );

        const removeButtonModel = new UIModel( {
            withText: true,
            label: 'Delete',
            action: 'delete'
        } );

        removeButtonModel.bind( 'isEnabled' )
            .to( this._model, 'isReadOnly', isReadOnly => !isReadOnly );

        items.add( {
            type: 'button',
            model: removeButtonModel
        } );

        addListToDropdown( dropdownView, items );

        dropdownView.on( 'execute', evt => {
            const action = evt.source.action;

            if ( action == 'edit' ) {
                this.commentsListView.commentViews.get( 0 ).switchToEditMode();
            }

            if ( action == 'delete' ) {
                this.fire( 'removeCommentThread' );
            }
        } );

        return dropdownView;
    }

    _modifyFirstCommentView() {
        // Get the first comment.
        const commentView = this.commentsListView.commentViews.get( 0 );

        // By default, the comment button is bound to the model state
        // and the buttons are visible only if the current local user is the author.
        // You need to remove this binding and make buttons for the first
        // comment always invisible.
        commentView.removeButton.unbind( 'isVisible' );
        commentView.removeButton.isVisible = false;

        commentView.editButton.unbind( 'isVisible' );
        commentView.editButton.isVisible = false;
    }
}
// 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 CustomCommentThreadView from './customcommentthreadview';

class ClassicEditor extends ClassicEditorBase {}

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

ClassicEditor.defaultConfig = {
    language: 'en',
    comments: {
        CommentThreadView: CustomCommentThreadView
    }
};

export default ClassicEditor;
<!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>
        <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()
                        },
                        {
                            commentId: 'comment-3',
                            authorId: 'user-1',
                            content: '<p>This comment should have normal action buttons.</p>',
                            createdAt: new Date()
                        }
                    ]
                },
                {
                    threadId: 'thread-2',
                    comments: [
                        {
                            commentId: 'comment-4',
                            authorId: 'user-2',
                            content: '<p>A comment by another user.</p>',
                            createdAt: new Date()
                        }
                    ]
                }
            ],

            // Initial editor data.
            initialData:
                `<h2>
                    <comment-start name="thread-1"></comment-start>
                    Bilingual Personality Disorder
                    <comment-end name="thread-1"></comment-end>
                </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.
                    <comment-start name="thread-2"></comment-start>
                    As recent studies show,
                    <comment-end name="thread-2"></comment-end> 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>`
        };

        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 the comment threads data.
                for ( const commentThread of appData.commentThreads ) {
                    commentsRepositoryPlugin.addCommentThread( commentThread );
                }
            }
        }

        ClassicEditor
            .create( document.querySelector( '#editor' ), {
                initialData: appData.initialData,
                extraPlugins: [ CommentsIntegration ],
                licenseKey: 'your-license-key',
                sidebar: {
                    container: document.querySelector( '#sidebar' )
                },
                toolbar: {
                    items: [ 'bold', 'italic', '|', 'comment', 'trackChanges' ]
                }
            } )
            .catch( error => console.error( error ) );
    </script>
</html>

# Live demo