Report an issue

Custom view for annotations

A custom view is the most powerful way to customize annotations. In this case, you need to provide the whole view: a template, UI elements, and any necessary behavior logic, but you can still use some of the default building blocks.

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

Examples below are based on a working editor setup that includes collaboration features. We highly recommend you get your setup ready based on the comments feature integration guide before moving any further.

# Using base views

Providing a custom view is based on the same solution as 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:

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.

The default view classes also extend the base view classes.

# Default view template

The default template used to create comment thread views is shown here: CommentThreadView#getTemplate().

The default template used to create suggestion thread views is shown here: SuggestionThreadView#getTemplate().

# 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:

// main.js

import { BaseCommentThreadView } from 'ckeditor5-premium-features';

class CustomCommentThreadView extends BaseCommentThreadView {
	// CustomCommentThreadView implementation.
	// ...
}

// ...

const editorConfig = {
	// ...

	comments: {
		CommentThreadView: CustomCommentThreadView
	},

	// ...
};

ClassicEditor.create(document.querySelector('#editor'), editorConfig);

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 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.

const bind = this.bindTemplate;

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

// More code.
// ...

this.setTemplate( {
    tag: 'div',

    attributes: {
        class: [
            // Bind the new observable property with the template.
            bind.if( 'isImportant', 'ck-comment--important' ),
            // Bind an existing observable property with the template.
            bind.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 the BaseCommentThreadView class.

# 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, and placed above the thread UI.

# Creating a custom thread view with a new template

First, create a foundation for your custom solution:

// main.js

import { BaseCommentThreadView } from 'ckeditor5-premium-features';

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

		const bind = this.bindTemplate;

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

			attributes: {
				class: [
					'ck',
					'ck-thread',
					'ck-reset_all-excluded',
					'ck-rounded-corners',
					bind.if( 'isActive', 'ck-thread--active' )
				],
				// Needed for the native DOM Tab key navigation.
				tabindex: 0,
				role: 'listitem',
				'aria-label': bind.to( 'ariaLabel' ),
				'aria-describedby': this.ariaDescriptionView.id
			},

			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() {
		// _createActionsDropdown() implementation.
		// ...
	}
}

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

// main.js

import { ViewModel, addListToDropdown, createDropdown, Collection } from 'ckeditor5';

class CustomCommentThreadView extends BaseCommentThreadView {
	// ...

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

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

		const items = new Collection();

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

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

		const resolveButtonModel = new ViewModel( {
			withText: true,
			label: 'Resolve',
			action: 'resolve'
		} );

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

		const reopenButtonModel = new ViewModel( {
			withText: true,
			label: 'Reopen',
			action: 'reopen'
		} );

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

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

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

		addListToDropdown( dropdownView, items );

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

		// Enable tab key navigation for the dropdown.
		this.focusables.add( dropdownView, 0 );

		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 it 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.

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

        const bind = this.bindTemplate;

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

            attributes: {
                class: [
                    'ck',
                    'ck-thread',
                    'ck-reset_all-excluded',
                    'ck-rounded-corners',
                    bind.if( 'isActive', 'ck-thread--active' )
                ],
                // Needed for the native DOM Tab key navigation.
                tabindex: 0,
                role: 'listitem',
                'aria-label': bind.to( 'ariaLabel' ),
                'aria-describedby': this.ariaDescriptionView.id
            },

            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. Additionally, the resolve/reopen button should be displayed based on the comment thread resolve state.

// main.js

class CustomCommentThreadView extends BaseCommentThreadView {
    // ...

    _createActionsDropdown() {
        // ...

        const editButtonModel = new ViewModel( {
            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 resolveButtonModel = new ViewModel( {
            withText: true,
            label: 'Resolve',
            action: 'resolve'
        } );

        // Hide the button if the thread is resolved or cannot be resolved.
        resolveButtonModel.bind( 'isVisible' )
            .to( this._model, 'isResolved', this._model, 'isResolvable',
                ( isResolved, isResolvable ) => !isResolved && isResolvable );

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

        const reopenButtonModel = new ViewModel( {
            withText: true,
            label: 'Reopen',
            action: 'reopen'
        } );

        // Hide the button if the thread is not resolved or cannot be resolved.
        reopenButtonModel.bind( 'isVisible' )
            .to( this._model, 'isResolved', this._model, 'isResolvable',
                ( isResolved, isResolvable ) => isResolved && isResolvable );

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

        const removeButtonModel = new ViewModel( {
            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:

/* style.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;
}

# 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();
    }

    // More actions.
    // ...
} );

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' );
    }

    if ( action == 'resolve' ) {
        this.fire( 'resolveCommentThread' );
    }

    if ( action == 'reopen' ) {
        this.fire( 'reopenCommentThread' );
    }

    if ( action == 'resolve' ) {
        this.fire( 'resolveCommentThread' );
    }

    if ( action == 'reopen' ) {
        this.fire( 'reopenCommentThread' );
    }
} );

# 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.

// main.js

class CustomCommentThreadView extends BaseCommentThreadView {
    constructor( ...args ) {
        // More code.
        // ...

        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();
            } );
        }
    }

    // More code.
    // ...

    _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:

/* style.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;
}
// main.js

import { ViewModel, addListToDropdown, createDropdown, Collection, Bold, Italic } from 'ckeditor5';
import { BaseCommentThreadView } from 'ckeditor5-premium-features';

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

		const bind = this.bindTemplate;

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

			attributes: {
				class: [
					'ck',
					'ck-thread',
					'ck-reset_all-excluded',
					'ck-rounded-corners',
					bind.if( 'isActive', 'ck-thread--active' )
				],
				// Needed for the native DOM Tab key navigation.
				tabindex: 0,
				role: 'listitem',
				'aria-label': bind.to( 'ariaLabel' ),
				'aria-describedby': this.ariaDescriptionView.id
			},

			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 ViewModel( {
			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 resolveButtonModel = new ViewModel( {
			withText: true,
			label: 'Resolve',
			action: 'resolve'
		} );

		// Hide the button if the thread is resolved or cannot be resolved.
		resolveButtonModel.bind( 'isVisible' )
			.to( this._model, 'isResolved', this._model, 'isResolvable',
				( isResolved, isResolvable ) => !isResolved && isResolvable );

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

		const reopenButtonModel = new ViewModel( {
			withText: true,
			label: 'Reopen',
			action: 'reopen'
		} );

		// Hide the button if the thread is not resolved or cannot be resolved.
		reopenButtonModel.bind( 'isVisible' )
			.to( this._model, 'isResolved', this._model, 'isResolvable',
				( isResolved, isResolvable ) => isResolved && isResolvable );

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


		const removeButtonModel = new ViewModel( {
			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' );
			}
		} );

		// Enable tab key navigation for the dropdown.
		this.focusables.add( dropdownView, 0 );

		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;
	}
}

// ...

const editorConfig = {
	// ...

	comments: {
		CommentThreadView: CustomCommentThreadView,

		editorConfig: {
			extraPlugins: [ Bold, Italic ]
		}
	},

	// ...
};

ClassicEditor.create(document.querySelector('#editor'), editorConfig);

# Live demo