Comments 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.
# Preparing the context
Complementary to this guide, we provide a ready-to-use sample.
You may use it 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, you need to prepare Context
configuration. You can refer to the Context and collaboration features guide for more in-depth explanation:
import { CloudServices } from 'ckeditor5';
import { CommentsRepository, NarrowSidebar, WideSidebar, CloudServicesCommentsAdapter, PresenceList } from 'ckeditor5-premium-features';
// The context's configuration.
const contextConfig = {
// Plugins specific for the context:
plugins: [
CloudServices,
CommentsRepository,
NarrowSidebar,
PresenceList,
WideSidebar,
CloudServicesCommentsAdapter,
],
// Sidebar and presence list's shared locations:
sidebar: {
container: document.querySelector( '#editor-annotations' )
},
presenceList: {
container: document.querySelector( '#editor-presence' )
},
comments: {
editorConfig: {}
},
// Real-time features configuration:
// NOTE: PROVIDE CORRECT VALUES HERE.
cloudServices: {
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: {
channelId: 'your-channel-id'
}
};
# Preparing the HTML structure
When the context config is ready, it is time to prepare an HTML structure with an example form, a presence list, and a sidebar.
<div id="editor-presence"></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="editor-annotations"></div>
</div>
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).
Then, style HTML as below.
#editor-presence {
width: 679px;
margin: 0 auto;
}
#container {
display: flex;
position: relative;
width: 679px;
margin: 0 auto;
}
#editor-annotations {
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;
}
# Implementing comments on form fields
Now, it is time to integrate comments with our custom UI. The integration will meet the following requirements:
- It will be possible to add a comment thread to any form field.
- The comments should be sent, received, and handled in real-time.
- There can be just one comment thread on a non-editor form field.
- A button click creates a comment thread or activates an existing thread.
- There should be a visible indication that there is a comment thread on a given field.
# Creating a context
First, create a context instance using the contextConfig
defined earlier.
import { Context } from 'ckeditor5';
Context.create( contextConfig ).then( context => {
const commentsRepository = context.plugins.get( 'CommentsRepository' );
const annotations = context.plugins.get( 'Annotations' );
const channelId = context.config.get( 'collaboration.channelId' );
// ...
} );
# Adding a comment thread
To create a new comment thread attached to a form field, use CommentsRepository#openNewCommentThread()
.
Context.create( contextConfig ).then( context => {
// ...
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.
Context.create( contextConfig ).then( context => {
// ...
// 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 a 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.
Context.create( contextConfig ).then( context => {
// ...
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 the Annotations#collection
and render it in a custom comments archive UI.
Context.create( contextConfig ).then( context => {
// ...
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.
Context.create( contextConfig ).then( context => {
// ...
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.
This is the content of the main.js
file:
import { CloudServices, Context } from 'ckeditor5';
import { CommentsRepository, NarrowSidebar, WideSidebar, CloudServicesCommentsAdapter, PresenceList } from 'ckeditor5-premium-features';
// NOTE: Add style sheets according to the used installation method.
// The context's configuration.
const contextConfig = {
// Plugins specific for the context:
plugins: [
CloudServices,
CommentsRepository,
NarrowSidebar,
PresenceList,
WideSidebar,
CloudServicesCommentsAdapter,
],
// Sidebar and presence list's shared locations:
sidebar: {
container: document.querySelector( '#editor-annotations' )
},
presenceList: {
container: document.querySelector( '#editor-presence' )
},
comments: {
editorConfig: {}
},
// Real-time features configuration:
// NOTE: PROVIDE CORRECT VALUES HERE.
cloudServices: {
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: {
channelId: 'your-channel-id'
}
};
Context.create( contextConfig ).then( context => {
const commentsRepository = context.plugins.get( 'CommentsRepository' );
const annotations = context.plugins.get( 'Annotations' );
// 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();
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 );
}
} );
The HTML structure and styles of the index.html
file:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>CKEditor5 Collaboration – Hello World!</title>
<style type="text/css">
#editor-presence {
width: 679px;
margin: 0 auto;
}
#container {
display: flex;
position: relative;
width: 679px;
margin: 0 auto;
}
#editor-annotations {
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="editor-presence"></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="editor-annotations"></div>
</div>
<script src="main.js"></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.
Every day, we work hard to keep our documentation complete. Have you spotted outdated information? Is something missing? Please report it via our issue tracker.
With the release of version 42.0.0, we have rewritten much of our documentation to reflect the new import paths and features. We appreciate your feedback to help us ensure its accuracy and completeness.