Report an issue

guideComments outside the editor

The comments feature API, together with Context, lets you create deeper integrations with your application. One such integration is enabling comments on non-editor form fields.

In this guide, you will learn how to add this functionality to your application. Additionally, all users connected to the form will be visible in the presence list.

# Before you start

We highly recommend reading the Context and collaboration features guide before continuing.

For the purposes of this guide, the CKEditor Cloud Services and the real-time collaborative comments will be used. However, the comments feature API can also be used in a similar way together with standalone comments.

# Setting up the context

Complementary to this guide, we provide a ready-to-use sample and an example of Angular integration.

You may use them as an example or as a starting point for your own integration.

The goal is to enable comments on non-editor form fields, so we will need to use the context to initialize the comments feature without using the editor.

First, make sure that your build includes Context. You can build it together with the editor, as explained in the Context and collaboration features guide:

import ContextBase from '@ckeditor/ckeditor5-core/src/context';
import { ClassicEditor as ClassicEditorBase } from '@ckeditor/ckeditor5-editor-classic';

import { CloudServices } from '@ckeditor/ckeditor5-cloud-services';
import { CommentsRepository, NarrowSidebar, WideSidebar } from '@ckeditor/ckeditor5-comments';
import { CloudServicesCommentsAdapter, PresenceList } from '@ckeditor/ckeditor5-real-time-collaboration';

class Context extends ContextBase {}

// Plugins to include in the context.
Context.builtinPlugins = [
    CloudServices,
    CommentsRepository,
    NarrowSidebar,
    PresenceList,
    WideSidebar,
    CloudServicesCommentsAdapter,
];

Context.defaultConfig = {
    sidebar: {
        container: document.querySelector( '#sidebar' )
    },
    presenceList: {
        container: document.querySelector( '#presence-list-container' )
    },
    comments: {
        editorConfig: {}
    }
};

class ClassicEditor extends ClassicEditorBase {};

export default { ClassicEditor, Context };

Even though the editor is available in the build above, to keep it simple, you will only use the context in this guide.

# Preparing the HTML structure

When your build is ready, it is time to prepare an HTML structure with an example form, a presence list, and a sidebar.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>CKEditor&nbsp;5 Collaboration – Hello World!</title>

    <style type="text/css">
        #presence-list-container {
            width: 679px;
            margin: 0 auto;
        }

        #container {
            display: flex;
            position: relative;
            width: 679px;
            margin: 0 auto;
        }

        #sidebar {
            width: 300px;
        }

        .form-field {
            padding: 8px 10px;
            margin-bottom: 20px;
            outline: none;
            margin-right: 20px;
            border: 1px solid #DDDDDD;
            border-radius: 3px;
        }

        .form-field.has-comment {
            background: hsl(55, 98%, 83%);
        }

        .form-field.active {
            background: hsl(55, 98%, 68%);
        }

        .form-field label {
            display: inline-block;
            width: 100px;
        }

        .form-field input, .form-field select {
            width: 200px;
            margin: 0px;
            padding: 0px 8px;
            height: 29px;
            background: #FFFFFF;
            border: 1px solid #DDDDDD;
            border-radius: 3px;
            box-sizing: border-box;
        }

        .form-field button {
            width: 29px;
            margin: 0px;
            height: 29px;
            background: #EEEEEE;
            border: 1px solid #DDDDDD;
            border-radius: 3px;
            vertical-align: top;
        }
    </style>
</head>

<body>
    <div id="presence-list-container"></div>

    <div id="container">
        <div class="form">
            <div class="form-field" id="field-1" tabindex="-1">
                <label>Field 1:</label>
                <input name="field-1" type="text" value="Input 1">
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-2" tabindex="-1">
                <label>Field 2:</label>
                <input name="field-2" type="text" value="Input 2">
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-3" tabindex="-1">
                <label>Field 3:</label>
                <input name="field-3" type="text" value="Input 3">
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-4" tabindex="-1">
                <label>Field 4:</label>
                <select name="field-4">
                    <option>Option 1</option>
                    <option>Option 2</option>
                    <option>Option 3</option>
                </select>
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-5" tabindex="-1">
                <label>Field 5:</label>
                <select name="field-5">
                    <option>Option 1</option>
                    <option>Option 2</option>
                    <option>Option 3</option>
                </select>
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-6" tabindex="-1">
                <label>Field 6:</label>
                <select name="field-6">
                    <option>Option 1</option>
                    <option>Option 2</option>
                    <option>Option 3</option>
                </select>
                <button class="add-comment">+</button>
            </div>
        </div>
        <div id="sidebar"></div>
    </div>

    <script src="../build/ckeditor.js"></script>
    <script>
    ( async () => {
        // The channel ID could be, for example, an ID of the form in the database.
        const channelId = 'your-channel-id';
        const { Context } = ClassicEditor;

        const context = await Context.create( {
            cloudServices: {
                // PROVIDE CORRECT VALUES HERE:
                tokenUrl: 'https://example.com/cs-token-endpoint',
                uploadUrl: 'https://your-organization-id.cke-cs.com/easyimage/upload/',
                webSocketUrl: 'your-organization-id.cke-cs.com/ws/'
            },
            // Collaboration configuration for the context:
            collaboration: {
                channelId
            }
        } );

        const commentsRepository = context.plugins.get( 'CommentsRepository' );
        const annotations = context.plugins.get( 'Annotations' );

        // The integration code goes here.
    } )();
    </script>
</body>
</html>

The form contains several fields as shown above. Each field has a button that allows for creating a comment attached to that field. Each field is assigned a unique ID. Also, the tabindex="-1" attribute was added to make it possible to focus the DOM elements (and add them to the focus trackers).

# Implementing comments on form fields

The integration will meet the following requirements:

  1. It will be possible to add a comment thread to any form field.
  2. The comments should be sent, received, and handled in real-time.
  3. There can be just one comment thread on a non-editor form field.
  4. A button click creates a comment thread or activates an existing thread.
  5. There should be a visible indication that there is a comment thread on a given field.

# Adding a comment thread

To create a new comment thread attached to a form field, use CommentsRepository#openNewCommentThread().

document.querySelectorAll( '.form-field button' ).forEach( button => {
    const field = button.parentNode;

    button.addEventListener( 'click', () => {
        // Thread ID must be unique.
        // Use field ID + current date time to generate a unique thread ID.
        const threadId = field.id + ':' + new Date().getTime();

        commentsRepository.openNewCommentThread( {
            channelId,
            threadId,
            target: () => getAnnotationTarget( field, threadId ),
            // `context` is additional information about what the comment was made on.
            // It can be left empty but it also can be set to a custom message.
            // The value is used when the comment is displayed in comments archive.
            context: {
                type: 'text',
                value: getCustomContextMessage( field )
            },
            // `isResolvable` indicates whether the comment thread can become resolved.
            // Set this flag to `false` to disable the possibility of resolving given comment thread.
            // You will still be able to remove the comment thread.
            isResolvable: true
        } );
    } );
} );

function getCustomContextMessage( field ) {
    // This function should return the custom context value for given form field.
    // It will depend on your application.
    // Below, we assume HTML structure from this sample.
    return field.previousSibling.innerText + ' ' + field.value;
}

# Handling new comment threads

Define a callback that will handle comment threads added to the comments repository – both created by the local user and incoming from remote users. For that, use the CommentsRepository#addCommentThread event.

Note that the event name includes the context channel ID. Only comments “added to the context” will be handled.

// This `Map` is used to store all open threads for a given field.
// An open thread is a non-resolved, non-removed thread.
// Keys are field IDs and values are arrays with all opened threads on this field.
// Since it is possible to create multiple comment threads on the same field, this `Map`
// is used to check if a given field has an open thread.
const commentThreadsForField = new Map();

commentsRepository.on( 'addCommentThread:' + channelId, ( evt, data ) => {
    handleNewCommentThread( data.threadId );
}, { priority: 'low' } );

function handleNewCommentThread( threadId ) {
    // Get the thread instance and the related DOM element using the thread ID.
    // Note that thread ID format is "fieldId:time".
    const thread = commentsRepository.getCommentThread( threadId );
    const field = document.getElementById( threadId.split( ':' )[ 0 ] );

    // If the thread is not attached yet, attach it.
    // This is the difference between local and remote comments.
    // Locally created comments are attached in the `openNewCommentThread()` call.
    // Remotely created comments need to be attached when they are received.
    if ( !thread.isAttached ) {
        thread.attachTo( () => thread.isResolved ? null : field );
    }

    // Add a CSS class to the field to show that it has a comment.
    field.classList.add( 'has-comment' );

    // Get all open threads for given field.
    const openThreads = commentThreadsForField.get( field.id ) || [];

    // When an annotation is created or reopened we need to bound its focus manager with the field.
    // Thanks to that, the annotation will be focused whenever the field is focused as well.
    // However, this can be done only for one annotation, so we do it only if there are no open
    // annotations for given field.
    if ( !openThreads.length ) {
        const threadView = commentsRepository._threadToController.get( thread ).view;
        const annotation = annotations.collection.getByInnerView( threadView );

        annotation.focusableElements.add( field );
    }

    // Add new thread to open threads list.
    openThreads.push( thread );

    commentThreadsForField.set( field.id, openThreads );
}

When the context is initialized, there could already be some comment threads created by remote users and loaded while the editor was initialized. These comments need to be handled as well.

for ( const thread of commentsRepository.getCommentThreads( { channelId } ) ) {
    // Ignore threads that have been already resolved.
    if ( !thread.isResolved ) {
        handleNewCommentThread(thread.id);
    }
}

# Handling removed comment threads

You should also handle removing comment threads. To provide that, use the CommentsRepository#removeCommentThread event. Again, note the event name.

commentsRepository.on( 'removeCommentThread:' + channelId, ( evt, data ) => {
    handleRemovedCommentThread( data.threadId );
}, { priority: 'low' } );

function handleRemovedCommentThread( threadId ) {
    // Note that thread ID format is "fieldId:time".
    const field = document.getElementById( threadId.split( ':' )[ 0 ] );
    const openThreads = commentThreadsForField.get( field.id );
    const threadIndex = openThreads.findIndex( openThread => openThread.id === threadId );

    // Remove this comment thread from the list of open comment threads for given field.
    openThreads.splice( threadIndex, 1 );

    // In `handleNewCommentThread` we bound the first comment thread annotation focus manager with the field.
    // If we are removing that comment thread, we need to handle field focus as well.
    // After removing or resolving the first thread you should field focus to the next thread's annotation.
    if ( threadIndex === 0 ) {
        const thread = commentsRepository.getCommentThread( threadId );
        const threadController = commentsRepository._threadToController.get( thread );

        // Remove the old binding between removed annotation and field.
        if ( threadController ) {
            const threadView = threadController.view;
            const annotation = annotations.collection.getByInnerView( threadView );

            annotation.focusableElements.remove( field );
        }

        const newActiveThread = commentThreadsForField[ 0 ];

        // If there other open threads, bind another annotation to the field.
        if ( newActiveThread ) {
            const newThreadView = commentsRepository._threadToController.get( newActiveThread ).view;
            const newAnnotation = annotations.collection.getByInnerView( newThreadView );

            newAnnotation.focusableElements.add( field );
        }
    }

    // If there are no more active threads the CSS classes should be removed.
    if ( openThreads.length === 0 ) {
        field.classList.remove( 'has-comment', 'active' );
    }

    commentThreadsForField.set( field.id, openThreads );
}

# Handling resolved/reopened comment threads

Handling the resolving of comment threads is significant to keep your UI up to date. To manage that, use the CommentsRepository#resolveCommentThread event and, when the thread is opened again, CommentsRepository#reopenCommentThread event. As with the previous point, note the event name.

After resolving, the comment thread is removed from the sidebar, however, you can still obtain the annotation from Annotations#collection and render in a custom comments archive UI.

commentsRepository.on( 'resolveCommentThread:' + channelId, ( evt, { threadId } ) => {
    handleRemovedCommentThread( threadId );
}, { priority: 'low' } );

commentsRepository.on( 'reopenCommentThread:' + channelId, ( evt, { threadId } ) => {
    handleNewCommentThread( threadId );
}, { priority: 'low' } );

# Highlighting an active form field

To make the UI more responsive, it is a good idea to highlight the form field corresponding to the active comment. To add this improvement, add a listener to the CommentsRepository#activeCommentThread observable property.

commentsRepository.on( 'change:activeCommentThread', ( evt, propName, activeThread ) => {
    // When an active comment thread changes, remove the 'active' class from all the fields.
    document.querySelectorAll( '.form-field.active' )
        .forEach( el => el.classList.remove( 'active' ) );

    // If `activeThread` is not null, highlight the corresponding form field.
    // Handle only comments added to the context channel ID.
    if ( activeThread && activeThread.channelId == channelId ) {
        const field = document.getElementById( activeThread.id.split( ':' )[ 0 ] );

        field.classList.add( 'active' );
    }
} );

# Full implementation

Below you can find the final solution.

Build source file:

import ContextBase from '@ckeditor/ckeditor5-core/src/context';
import { ClassicEditor as ClassicEditorBase } from '@ckeditor/ckeditor5-editor-classic';

import { CloudServices } from '@ckeditor/ckeditor5-cloud-services';
import { CommentsRepository, NarrowSidebar, WideSidebar } from '@ckeditor/ckeditor5-comments';
import { CloudServicesCommentsAdapter, PresenceList } from '@ckeditor/ckeditor5-real-time-collaboration';

class Context extends ContextBase {}

// Plugins to include in the context.
Context.builtinPlugins = [
    CloudServices,
    CommentsRepository,
    NarrowSidebar,
    PresenceList,
    WideSidebar,
    CloudServicesCommentsAdapter,
];

Context.defaultConfig = {
    sidebar: {
        container: document.querySelector( '#sidebar' )
    },
    presenceList: {
        container: document.querySelector( '#presence-list-container' )
    },
    comments: {
        editorConfig: {}
    }
};

class ClassicEditor extends ClassicEditorBase {};

export default { ClassicEditor, Context };

The HTML structure and the integration code:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>CKEditor5 Collaboration – Hello World!</title>

    <style type="text/css">
        #presence-list-container {
            width: 679px;
            margin: 0 auto;
        }

        #container {
            display: flex;
            position: relative;
            width: 679px;
            margin: 0 auto;
        }

        #sidebar {
            width: 300px;
        }

        .form-field {
            padding: 8px 10px;
            margin-bottom: 20px;
            outline: none;
            margin-right: 20px;
            border: 1px solid #DDDDDD;
            border-radius: 3px;
        }

        .form-field.has-comment {
            background: hsl(55, 98%, 83%);
        }

        .form-field.active {
            background: hsl(55, 98%, 68%);
        }

        .form-field label {
            display: inline-block;
            width: 100px;
        }

        .form-field input, .form-field select {
            width: 200px;
            margin: 0px;
            padding: 0px 8px;
            height: 29px;
            background: #FFFFFF;
            border: 1px solid #DDDDDD;
            border-radius: 3px;
            box-sizing: border-box;
        }

        .form-field button {
            width: 29px;
            margin: 0px;
            height: 29px;
            background: #EEEEEE;
            border: 1px solid #DDDDDD;
            border-radius: 3px;
            vertical-align: top;
        }
    </style>
</head>

<body>
    <div id="presence-list-container"></div>

    <div id="container">
        <div class="form">
            <div class="form-field" id="field-1" tabindex="-1">
                <label>Field 1:</label>
                <input name="field-1" type="text" value="Input 1">
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-2" tabindex="-1">
                <label>Field 2:</label>
                <input name="field-2" type="text" value="Input 2">
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-3" tabindex="-1">
                <label>Field 3:</label>
                <input name="field-3" type="text" value="Input 3">
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-4" tabindex="-1">
                <label>Field 4:</label>
                <select name="field-4">
                    <option>Option 1</option>
                    <option>Option 2</option>
                    <option>Option 3</option>
                </select>
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-5" tabindex="-1">
                <label>Field 5:</label>
                <select name="field-5">
                    <option>Option 1</option>
                    <option>Option 2</option>
                    <option>Option 3</option>
                </select>
                <button class="add-comment">+</button>
            </div>
            <div class="form-field" id="field-6" tabindex="-1">
                <label>Field 6:</label>
                <select name="field-6">
                    <option>Option 1</option>
                    <option>Option 2</option>
                    <option>Option 3</option>
                </select>
                <button class="add-comment">+</button>
            </div>
        </div>
        <div id="sidebar"></div>
    </div>

    <script src="../build/ckeditor.js"></script>
    <script>
    ( async () => {
        // The channel ID could be, for example, an ID of the form in the database.
        const channelId = 'your-channel-id';
        const { Context } = ClassicEditor;

        const context = await Context.create( {
            cloudServices: {
                // PROVIDE CORRECT VALUES HERE:
                tokenUrl: 'https://example.com/cs-token-endpoint',
                uploadUrl: 'https://your-organization-id.cke-cs.com/easyimage/upload/',
                webSocketUrl: 'your-organization-id.cke-cs.com/ws/'
            },
            // Collaboration configuration for the context:
            collaboration: {
                channelId
            }
        } );

        // This `Map` is used to store all open threads for a given field.
        // An open thread is a non-resolved, non-removed thread.
        // Keys are field IDs and values are arrays with all opened threads on this field.
        // Since it is possible to create multiple comment threads on the same field, this `Map`
        // is used to check if a given field has an open thread.
        const commentThreadsForField = new Map();

        const commentsRepository = context.plugins.get( 'CommentsRepository' );
        const annotations = context.plugins.get( 'Annotations' );

        for ( const thread of commentsRepository.getCommentThreads( { channelId } ) ) {
            // Ignore threads that have been already resolved.
            if ( !thread.isResolved ) {
                handleNewCommentThread(thread.id);
            }
        }

        commentsRepository.on( 'addCommentThread:' + channelId, ( evt, data ) => {
            handleNewCommentThread( data.threadId );
        }, { priority: 'low' } );

        commentsRepository.on( 'resolveCommentThread:' + channelId, ( evt, { threadId } ) => {
            handleRemovedCommentThread( threadId );
        }, { priority: 'low' } );

        commentsRepository.on( 'reopenCommentThread:' + channelId, ( evt, { threadId } ) => {
            handleNewCommentThread( threadId );
        }, { priority: 'low' } );

        commentsRepository.on( 'removeCommentThread:' + channelId, ( evt, data ) => {
            handleRemovedCommentThread( data.threadId );
        }, { priority: 'low' } );

        document.querySelectorAll( '.form-field button' ).forEach( button => {
            const field = button.parentNode;

            button.addEventListener( 'click', () => {
                // Thread ID must be unique.
                // Use field ID + current date time to generate a unique thread ID.
                const threadId = field.id + ':' + new Date().getTime();

                commentsRepository.openNewCommentThread( {
                    channelId,
                    threadId,
                    target: () => getAnnotationTarget( field, threadId ),
                    // `context` is additional information about what the comment was made on.
                    // It can be left empty but it also can be set to a custom message.
                    // The value is used when the comment is displayed in comments archive.
                    context: {
                        type: 'text',
                        value: getCustomContextMessage( field )
                    },
                    // `isResolvable` indicates whether the comment thread can become resolved.
                    // Set this flag to `false` to disable the possibility of resolving given comment thread.
                    // You will still be able to remove the comment thread.
                    isResolvable: true
                } );
            } );
        } );

        commentsRepository.on( 'change:activeCommentThread', ( evt, propName, activeThread ) => {
            // When an active comment thread changes, remove the 'active' class from all the fields.
            document.querySelectorAll( '.form-field.active' )
                    .forEach( el => el.classList.remove( 'active' ) );

            // If `activeThread` is not null, highlight the corresponding form field.
            // Handle only comments added to the context channel ID.
            if ( activeThread && activeThread.channelId == channelId ) {
                const field = document.getElementById( activeThread.id.split( ':' )[ 0 ] );

                field.classList.add( 'active' );
            }
        } );

        function getCustomContextMessage( field ) {
            // This function should return the custom context value for given form field.
            // It will depend on your application.
            // Below, we assume HTML structure from this sample.
            return field.previousSibling.innerText + ' ' + field.value;
        }

        function handleNewCommentThread( threadId ) {
            // Get the thread instance and the related DOM element using the thread ID.
            // Note that thread ID format is "fieldId:time".
            const thread = commentsRepository.getCommentThread( threadId );
            const field = document.getElementById( threadId.split( ':' )[ 0 ] );

            // If the thread is not attached yet, attach it.
            // This is the difference between local and remote comments.
            // Locally created comments are attached in the `openNewCommentThread()` call.
            // Remotely created comments need to be attached when they are received.
            if ( !thread.isAttached ) {
                thread.attachTo( () => thread.isResolved ? null : field );
            }

            // Add a CSS class to the field to show that it has a comment.
            field.classList.add( 'has-comment' );

            // Get all open threads for given field.
            const openThreads = commentThreadsForField.get( field.id ) || [];

            // When an annotation is created or reopened we need to bound its focus manager with the field.
            // Thanks to that, the annotation will be focused whenever the field is focused as well.
            // However, this can be done only for one annotation, so we do it only if there are no open
            // annotations for given field.
            if ( !openThreads.length ) {
                const threadView = commentsRepository._threadToController.get( thread ).view;
                const annotation = annotations.collection.getByInnerView( threadView );

                annotation.focusableElements.add( field );
            }

            // Add new thread to open threads list.
            openThreads.push( thread );

            commentThreadsForField.set( field.id, openThreads );
        }

        function getAnnotationTarget( target, threadId ) {
            const thread = commentsRepository.getCommentThread( threadId );

            return thread.isResolved ? null : target;
        }

        function handleRemovedCommentThread( threadId ) {
            // Note that thread ID format is "fieldId:time".
            const field = document.getElementById( threadId.split( ':' )[ 0 ] );
            const openThreads = commentThreadsForField.get( field.id );
            const threadIndex = openThreads.findIndex( openThread => openThread.id === threadId );

            // Remove this comment thread from the list of open comment threads for given field.
            openThreads.splice( threadIndex, 1 );

            // In `handleNewCommentThread` we bound the first comment thread annotation focus manager with the field.
            // If we are removing that comment thread, we need to handle field focus as well.
            // After removing or resolving the first thread you should field focus to the next thread's annotation.
            if ( threadIndex === 0 ) {
                const thread = commentsRepository.getCommentThread( threadId );
                const threadController = commentsRepository._threadToController.get( thread );

                // Remove the old binding between removed annotation and field.
                if ( threadController ) {
                    const threadView = threadController.view;
                    const annotation = annotations.collection.getByInnerView( threadView );

                    annotation.focusableElements.remove( field );
                }

                const newActiveThread = openThreads[ 0 ];

                // If there other open threads, bind another annotation to the field.
                if ( newActiveThread ) {
                    const newThreadView = commentsRepository._threadToController.get( newActiveThread ).view;
                    const newAnnotation = annotations.collection.getByInnerView( newThreadView );

                    newAnnotation.focusableElements.add( field );
                }
            }

            // If there are no more active threads the CSS classes should be removed.
            if ( openThreads.length === 0 ) {
                field.classList.remove( 'has-comment', 'active' );
            }

            commentThreadsForField.set( field.id, openThreads );
        }
    } )();
    </script>
</body>
</html>

# Demo

Share the complete URL of this page with your colleagues to collaborate in real-time!

Click the “plus” button to add a comment.