Report an issue

guideComments outside the editor

The comments feature API, together with Context, allows for creating 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.

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

For the purposes of this guide, the CKEditor Cloud Services comments adapter 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 ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';

import CloudServicesCommentsAdapter from '@ckeditor/ckeditor5-real-time-collaboration/src/realtimecollaborativecomments/cloudservicescommentsadapter';
import CommentsRepository from '@ckeditor/ckeditor5-comments/src/comments/commentsrepository';
import NarrowSidebar from '@ckeditor/ckeditor5-comments/src/annotations/narrowsidebar';
import WideSidebar from '@ckeditor/ckeditor5-comments/src/annotations/widesidebar';
import PresenceList from '@ckeditor/ckeditor5-real-time-collaboration/src/presencelist';

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 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.has-comment.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;

    // Thread ID must be unique.
    // Use a unique channel ID + field ID to generate a unique thread ID.
    const threadId = channelId + ':' + field.id;

    button.addEventListener( 'click', () => {
        // Add a new thread only if there isn't one.
        if ( field.classList.contains( 'has-comment' ) ) {
            return;
        }

        commentsRepository.openNewCommentThread( {
            channelId,
            threadId,
            target: field
        } );
    } );
} );

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

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

function handleCommentThread( thread ) {
    // Get the DOM element ID from the thread ID.
    const field = document.getElementById( thread.id.split( ':' )[ 1 ] );

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

    // Keep in mind that this class is also used to check
    // if the field has any comment thread.
    field.classList.add( 'has-comment' );

    // Activate this comment's annotation whenever the form field becomes focused.
    const threadView = commentsRepository._threadToController.get( thread ).view;
    const annotation = annotations.getByInnerView( threadView );

    annotation.focusableElements.add( field );
}

When the context is initialized, there could already be some comment threads created by remote users and loaded by the CKEditor Cloud Services comments adapter. These comments need to be handled as well.

for ( const thread of commentsRepository.getCommentThreads( { channelId } ) ) {
    handleCommentThread( thread );
}

# 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 ) => {
    // Get the DOM element ID from the thread ID.
    const field = document.getElementById( data.threadId.split( ':' )[ 1 ] );

    field.classList.remove( 'has-comment' );
}, { 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( ':' )[ 1 ] );

        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 ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';

import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices';
import CloudServicesCommentsAdapter from '@ckeditor/ckeditor5-real-time-collaboration/src/realtimecollaborativecomments/cloudservicescommentsadapter';
import CommentsRepository from '@ckeditor/ckeditor5-comments/src/comments/commentsrepository';
import NarrowSidebar from '@ckeditor/ckeditor5-comments/src/annotations/narrowsidebar';
import WideSidebar from '@ckeditor/ckeditor5-comments/src/annotations/widesidebar';
import PresenceList from '@ckeditor/ckeditor5-real-time-collaboration/src/presencelist';

class Context extends ContextBase {}

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

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>CKEditor 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.has-comment.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' );

        for ( const thread of commentsRepository.getCommentThreads( { channelId } ) ) {
            handleCommentThread( thread );
        }

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

        commentsRepository.on( 'removeCommentThread:' + channelId, ( evt, data ) => {
            // Get the DOM element ID from the thread ID.
            const field = document.getElementById( data.threadId.split( ':' )[ 1 ] );

            field.classList.remove( 'has-comment' );
        }, { priority: 'low' } );

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

            // Thread ID must be unique.
            // Use a unique channel ID + field ID to generate a unique thread ID.
            const threadId = channelId + ':' + field.id;

            button.addEventListener( 'click', () => {
                // Add a new thread only if there isn't one.
                if ( field.classList.contains( 'has-comment' ) ) {
                    return;
                }

                commentsRepository.openNewCommentThread( {
                    channelId,
                    threadId,
                    target: field
                } );
            } );
        } );

        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( ':' )[ 1 ] );

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

        function handleCommentThread( thread ) {
            // Get the DOM element ID from the thread ID.
            const field = document.getElementById( thread.id.split( ':' )[ 1 ] );

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

            // Keep in mind that this class is also used to check
            // if the field has any comment thread.
            field.classList.add( 'has-comment' );

            // Activate this comment's annotation whenever the form field is focused.
            const threadView = commentsRepository._threadToController.get( thread ).view;
            const annotation = annotations.getByInnerView( threadView );

            annotation.focusableElements.add( field );
        }
    } )();
    </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.