Integrating track changes with your application
The track changes plugin provides an API that lets you manage suggestions added to the document. To save and access suggestions in your database, you first need to integrate this feature.
This guide describes integrating track changes as a standalone plugin (the asynchronous version of it). If you are using real-time collaboration, refer to the Real-time collaboration features integration guide.
# Integration methods
This guide will discuss two ways to integrate CKEditor 5 with your suggestions data source:
- A simple “load and save” integration using the
TrackChanges
plugin API. - An adapter integration which saves the suggestions data immediately in the database.
The adapter integration is the recommended one because it gives you better control over the data.
It is also recommended to use the track changes plugin together with the comments plugin. Check how to integrate the comments plugin with your WYSIWYG editor.
# Before you start
Complementary to this guide, we provide ready-to-use samples available for download. You may use these samples as an example or a starting point for your integration.
# Preparing a custom editor setup
To use the track changes plugin, prepare a custom editor setup with the asynchronous version of the track changes feature included.
The easiest way to do that is by using the Builder. Pick a preset and start customizing your editor.
The Builder allows you to pick your preferred distribution method and framework. For this guide, we will use the “Vanilla JS” option with “npm” and a simple setup based on the “Classic Editor (basic)” preset, with the comments feature enabled.
In the “Features” section of the Builder (2nd step), make sure to:
- turn off the “real-time” toggle next to the “Collaboration” group,
- enable the “Collaboration → Track Changes” feature.
Once you finish the setup, the Builder will provide you with the necessary HTML, CSS, and JavaScript code snippets. We will use those code snippets in the next step.
# Setting up a sample project
Once we have a custom editor setup we need a simple JavaScript project to run it. For this, we recommend cloning the basic project template from our repository:
npx -y degit ckeditor/ckeditor5-tutorials-examples/sample-project sample-project
cd sample-project
npm install
Then, install the necessary dependencies:
npm install ckeditor5
npm install ckeditor5-premium-features
This project template uses Vite under the hood and contains 3 source files that we will use: index.html
, style.css
, and main.js
.
It is now the time to use our custom editor setup. Go to the “Installation” section of the Builder and copy the generated code snippets to those 3 files.
# Activating the feature
To use this premium feature, you need to activate it with a license key. Refer to the License key and activation guide for details.
After you have successfully obtained the license key open the main.js
file and update the your-license-key
string with your license key.
# Building the project
Finally, build the project by running:
npm run dev
When you open the sample in the browser you should see the WYSIWYG editor with the track changes plugin. However, it still does not load or save any data. You will learn how to add data to the track changes plugin later in this guide.
Let’s now dive deeper into the structure of this setup.
# Basic setup’s anatomy
Examples below implement the wide sidebar display mode for track changes annotations. If you want to use the inline display mode, remove parts of the snippets that set up the sidebar.
Let’s now go through the key fragments of this basic setup.
# HTML structure
The HTML and CSS structure of the page creates two columns:
<div class="editor-container__editor">
is the container used by the editor.<div class="editor-container__sidebar">
is the container used by the sidebar that holds the annotations (namely track changes).
# JavaScript
The main.js
file sets up the editor instance:
- Loads all necessary editor plugins (including the
TrackChanges
plugin). - Sets the
licenseKey
configuration option. - Sets the
sidebar.container
configuration option to the container mentioned above. - Adds the
trackChanges
button to the editor toolbar.
# Comments
Track changes use the comments plugin to allow discussion in suggestions. You should be familiar with the comments integration guide before you start integrating suggestions.
For that reason, the main.js
file obtained in the previous step does the following on top of the track changes plugin setup:
- Loads the
Comments
plugin (dependency of theTrackChanges
plugin). - Defines the plugin templates:
- For the
CommentsIntegration
plugin (learn how to save comments). - For the
UsersIntegrations
plugin that is shared by both track changes and comments features and we will be used in the next steps of this tutorial.
- For the
- Adds the
comment
andcommentsArchive
buttons to the editor toolbar.
# Next steps
We have set up a simple JavaScript project that runs a basic CKEditor instance with the asynchronous version of the track changes feature. It does not yet handle loading or saving data, though. The next two sections cover the two available integration methods.
# A simple “load and save” integration
In this solution, user and suggestions data is loaded during the editor initialization, and suggestions data is saved after you finish working with the editor (for example, when you submit the form containing the WYSIWYG editor).
This method is recommended only if you can trust your users or if you provide additional validation of the submitted data to make sure that the user changed their suggestions only.
Complementary to this guide, we provide ready-to-use samples available for download. You may use the samples as an example or a starting point for your integration.
The integration below uses the track changes API. Making yourself familiar with the API may help you understand the code snippets. In case of any problems, refer to the track changes API documentation.
# Loading the data
When the track changes plugin is already included in the editor, you need to create plugins which will initialize users and existing suggestions.
First, dump the users and the suggestions data to a variable that will be available for your plugin.
If your application needs to request the suggestions data from the server asynchronously, instead of putting the data in the HTML source, you can create a plugin that will fetch the data from the database. In this case, your plugin should return a Promise
from the Plugin.init()
method to make sure that the editor initialization waits for your data.
// Application data will be available under a global variable `appData`.
const appData = {
// Users data.
users: [
{
id: 'user-1',
name: 'Mex Haddox'
},
{
id: 'user-2',
name: 'Zee Croce'
}
],
// The ID of the current user.
userId: 'user-1',
// Comment threads data.
commentThreads: [
{
threadId: 'thread-1',
comments: [
{
commentId: 'comment-1',
authorId: 'user-1',
content: '<p>Are we sure we want to use a made-up disorder name?</p>',
createdAt: new Date( '09/20/2018 14:21:53' ),
attributes: {}
},
{
commentId: 'comment-2',
authorId: 'user-2',
content: '<p>Why not?</p>',
createdAt: new Date( '09/21/2018 08:17:01' ),
attributes: {}
}
],
context: {
type: 'text',
value: 'Bilingual Personality Disorder'
},
unlinkedAt: null,
resolvedAt: null,
resolvedBy: null,
attributes: {}
}
],
// Suggestions data.
suggestions: [
{
id: 'suggestion-1',
type: 'insertion',
authorId: 'user-2',
createdAt: new Date( 2019, 1, 13, 11, 20, 48 ),
data: null,
attributes: {}
},
{
id: 'suggestion-2',
type: 'deletion',
authorId: 'user-1',
createdAt: new Date( 2019, 1, 14, 12, 7, 20 ),
data: null,
attributes: {}
},
{
id: 'suggestion-3',
type: 'attribute:bold|ci1tcnk0lkep',
authorId: 'user-1',
createdAt: new Date( 2019, 2, 8, 10, 2, 7 ),
data: {
key: 'bold',
oldValue: null,
newValue: true
},
attributes: {
groupId: 'e29adbb2f3963e522da4d2be03bc5345f'
}
}
],
// Editor initial data.
initialData:
`<h2>
<comment-start name="thread-1"></comment-start>
Bilingual Personality Disorder
<comment-end name="thread-1"></comment-end>
</h2>
<p>
This may be the first time you hear about this
<suggestion-start name="insertion:suggestion-1:user-2"></suggestion-start>
made-up<suggestion-end name="insertion:suggestion-1:user-2"></suggestion-end>
disorder but it actually is not that far from the truth.
As recent studies show, the language you speak has more effects on you than you realize.
According to the studies, the language a person speaks affects their cognition,
<suggestion-start name="deletion:suggestion-2:user-1"></suggestion-start>
feelings, <suggestion-end name="deletion:suggestion-2:user-1"></suggestion-end>
behavior, emotions and hence <strong>their personality</strong>.
</p>
<p>
This shouldn’t come as a surprise
<a href="https://en.wikipedia.org/wiki/Lateralization_of_brain_function">since we already know</a>
that different regions of the brain become more active depending on the activity.
The structure, information and especially
<suggestion-start name="attribute:bold|ci1tcnk0lkep:suggestion-3:user-1"></suggestion-start><strong>the
culture of languages<suggestion-end name="attribute:bold|ci1tcnk0lkep:suggestion-3:user-1"></strong></suggestion-end>
varies substantially
and the language a person speaks is an essential element of daily life.
</p>`
};
The Builder’s output sample already provides templates of three plugins: UsersIntegration
, CommentsIntegration
, and TrackChangesIntegration
. Replace them with ones that read the data from appData
and use the Users
, CommentsRepository
, and TrackChanges
APIs, respectively:
class UsersIntegration extends Plugin {
static get requires() {
return [ 'Users' ];
}
static get pluginName() {
return 'UsersIntegration';
}
init() {
const usersPlugin = this.editor.plugins.get( 'Users' );
// Load the users data.
for ( const user of appData.users ) {
usersPlugin.addUser( user );
}
// Set the current user.
usersPlugin.defineMe( appData.userId );
}
}
class CommentsIntegration extends Plugin {
static get requires() {
return [ 'CommentsRepository', 'UsersIntegration' ];
}
static get pluginName() {
return 'CommentsIntegration';
}
init() {
const commentsRepositoryPlugin = this.editor.plugins.get( 'CommentsRepository' );
// Load the comment threads data.
for ( const commentThread of appData.commentThreads ) {
commentsRepositoryPlugin.addCommentThread( commentThread );
}
}
}
class TrackChangesIntegration extends Plugin {
static get requires() {
return [ 'TrackChanges', 'UsersIntegration' ];
}
static get pluginName() {
return 'TrackChangesIntegration';
}
init() {
const trackChangesPlugin = this.editor.plugins.get( 'TrackChanges' );
// Load the suggestions data.
for ( const suggestion of appData.suggestions ) {
trackChangesPlugin.addSuggestion( suggestion );
}
}
}
Update the editorConfig.initialData
property to use appData.initialData
value:
const editorConfig = {
// ...
initialData: appData.initialData
// ...
};
And build the project:
npm run dev
You should now we see an editor instance with one comment thread and several track changes suggestions.
# Saving the data
To save the suggestions data you need to get it from the TrackChanges
API first. To do this, use the getSuggestions()
method.
Then, use the suggestions data to save it in your database in the way you prefer. See the example below.
In index.html
add:
<button id="get-data">Get data</button>
In main.js
update the ClassicEditor.create()
call with a chained then()
:
ClassicEditor
.create( /* ... */ )
.then( editor => {
// After the editor is initialized, add an action to be performed after a button is clicked.
const trackChanges = editor.plugins.get( 'TrackChanges' );
// Get the data on demand.
document.querySelector( '#get-data' ).addEventListener( 'click', () => {
const editorData = editor.data.get();
const suggestionsData = trackChanges.getSuggestions( {
skipNotAttached: true,
toJSON: true
} );
// Now, use `editorData` and `suggestionsData` to save the data in your application.
// For example, you can set them as values of hidden input fields.
console.log( editorData );
console.log( suggestionsData );
} );
} )
.catch( error => console.error( error ) );
It is recommended to stringify the attributes
value to JSON, save it as a string in your database, and then parse the value from JSON when loading suggestions.
# Demo
Console
// Use the `Save data with track changes` button to see the result...
# Adapter integration
Adapter integration uses an adapter object – provided by you – to immediately save suggestions in your data store. This is the recommended way of integrating track changes with your application because it lets you handle client-server communication more securely. For example, you can check user permissions, validate sent data, or update the data with information obtained on the server side, like the suggestion creation date. You will see how to handle the server response in the following steps.
Complementary to this guide, we provide ready-to-use samples available for download. You may use the samples as an example or as a starting point for your own integration.
# Implementation
First, define the adapter using the TrackChanges#adapter
setter. Adapter
methods allow you to load and save changes in your database.
On the UI side each change in suggestions is performed immediately, however, all adapter actions are asynchronous and are performed in the background. Because of this all adapter methods need to return a Promise
. When the promise is resolved, it means that everything went fine and a local change was successfully saved in the data store. When the promise is rejected, the editor throws a CKEditorError error, which works nicely together with the watchdog feature. When you handle the server response you can decide if the promise should be resolved or rejected.
While the adapter is saving the suggestion data, a pending action is automatically added to the editor PendingActions
plugin, so you do not have to worry that the editor will be destroyed before the adapter action has finished.
Note, that it is critical to properly handle the suggestionData.originalSuggestionId
property when saving suggestions with the addSuggestion()
method. Otherwise, the suggestions data will be incorrect and this can lead to errors in certain scenarios.
The suggestionData.originalSuggestionId
property should be used when saving a suggestion to set the correct suggestion author. Consider the following example:
- User A creates an insertion suggestion.
- Then, User B starts typing inside that suggestion with track changes mode off.
- In this case, the original suggestion gets split into two parts, creating a new suggestion.
- Although the new suggestion is created by User B, the real author is User A.
- When the new suggestion is sent to the database, it should be saved with the correct author id (User A in this case).
- The author should be taken from the original suggestion (using
originalSuggestionId
).
Now you are ready to implement the adapter.
If you have set up the sample project as recommended in the “Before you start” section, open the main.js
file and add the following code right after the imports:
// Application data will be available under a global variable `appData`.
const appData = {
// Users data.
users: [
{
id: 'user-1',
name: 'Mex Haddox'
},
{
id: 'user-2',
name: 'Zee Croce'
}
],
// The ID of the current user.
userId: 'user-1',
// Comment threads data.
commentThreads: [
{
threadId: 'thread-1',
comments: [
{
commentId: 'comment-1',
authorId: 'user-1',
content: '<p>Are we sure we want to use a made-up disorder name?</p>',
createdAt: new Date( '09/20/2018 14:21:53' ),
attributes: {}
},
{
commentId: 'comment-2',
authorId: 'user-2',
content: '<p>Why not?</p>',
createdAt: new Date( '09/21/2018 08:17:01' ),
attributes: {}
}
],
context: {
type: 'text',
value: 'Bilingual Personality Disorder'
},
unlinkedAt: null,
resolvedAt: null,
resolvedBy: null,
attributes: {}
}
],
// Editor initial data.
initialData:
`<h2>
<comment-start name="thread-1"></comment-start>
Bilingual Personality Disorder
<comment-end name="thread-1"></comment-end>
</h2>
<p>
This may be the first time you hear about this
<suggestion-start name="insertion:suggestion-1:user-2"></suggestion-start>
made-up<suggestion-end name="insertion:suggestion-1:user-2"></suggestion-end>
disorder but it actually is not that far from the truth.
As recent studies show, the language you speak has more effects on you than you realize.
According to the studies, the language a person speaks affects their cognition,
<suggestion-start name="deletion:suggestion-2:user-1"></suggestion-start>
feelings, <suggestion-end name="deletion:suggestion-2:user-1"></suggestion-end>
behavior, emotions and hence <strong>their personality</strong>.
</p>
<p>
This shouldn’t come as a surprise
<a href="https://en.wikipedia.org/wiki/Lateralization_of_brain_function">since we already know</a>
that different regions of the brain become more active depending on the activity.
The structure, information and especially
<suggestion-start name="attribute:bold|ci1tcnk0lkep:suggestion-3:user-1"></suggestion-start><strong>the
culture of languages<suggestion-end name="attribute:bold|ci1tcnk0lkep:suggestion-3:user-1"></strong></suggestion-end>
varies substantially
and the language a person speaks is an essential element of daily life.
</p>`
};
The Builder’s output sample already provides templates of three plugins: UsersIntegration
, CommentsIntegration
, and TrackChangesIntegration
. Replace them with ones that read the data from appData
and use the Users
, CommentsRepository
, and TrackChanges
APIs, respectively:
class UsersIntegration extends Plugin {
static get requires() {
return [ 'Users' ];
}
static get pluginName() {
return 'UsersIntegration';
}
init() {
const usersPlugin = this.editor.plugins.get( 'Users' );
// Load the users data.
for ( const user of appData.users ) {
usersPlugin.addUser( user );
}
// Set the current user.
usersPlugin.defineMe( appData.userId );
}
}
class CommentsIntegration extends Plugin {
static get requires() {
return [ 'CommentsRepository', 'UsersIntegration' ];
}
static get pluginName() {
return 'CommentsIntegration';
}
init() {
const commentsRepositoryPlugin = this.editor.plugins.get( 'CommentsRepository' );
// Load the comment threads data.
for ( const commentThread of appData.commentThreads ) {
commentsRepositoryPlugin.addCommentThread( commentThread );
}
}
}
class TrackChangesIntegration extends Plugin {
static get requires() {
return [ 'TrackChanges', 'UsersIntegration' ];
}
static get pluginName() {
return 'TrackChangesIntegration';
}
init() {
const trackChangesPlugin = this.editor.plugins.get( 'TrackChanges' );
// Set the adapter to the `TrackChanges#adapter` property.
trackChangesPlugin.adapter = {
getSuggestion: suggestionId => {
console.log( 'Getting suggestion', suggestionId );
// Write a request to your database here.
// The returned `Promise` should be resolved with the suggestion
// data object when the request has finished.
switch ( suggestionId ) {
case 'suggestion-1':
return Promise.resolve( {
id: suggestionId,
type: 'insertion',
authorId: 'user-2',
createdAt: new Date(),
data: null,
attributes: {}
} );
case 'suggestion-2':
return Promise.resolve( {
id: suggestionId,
type: 'deletion',
authorId: 'user-1',
createdAt: new Date(),
data: null,
attributes: {}
} );
case 'suggestion-3':
return Promise.resolve( {
id: 'suggestion-3',
type: 'attribute:bold|ci1tcnk0lkep',
authorId: 'user-1',
createdAt: new Date( 2019, 2, 8, 10, 2, 7 ),
data: {
key: 'bold',
oldValue: null,
newValue: true
},
attributes: {
groupId: 'e29adbb2f3963e522da4d2be03bc5345f'
}
} );
}
},
addSuggestion: suggestionData => {
console.log( 'Suggestion added', suggestionData );
// Write a request to your database here.
// The returned `Promise` should be resolved when the request
// has finished. When the promise resolves with the suggestion data
// object, it will update the editor suggestion using the provided data.
return Promise.resolve( {
createdAt: new Date() // Should be set on the server side.
} );
},
updateSuggestion: ( id, suggestionData ) => {
console.log( 'Suggestion updated', id, suggestionData );
// Write a request to your database here.
// The returned `Promise` should be resolved when the request
// has finished.
return Promise.resolve();
}
};
// In order to load comments added to suggestions, you
// should also integrate the comments adapter.
}
}
Update the editorConfig.initialData
property to use appData.initialData
value:
const editorConfig = {
// ...
initialData: appData.initialData
// ...
};
And build the project:
npm run dev
You should now we see an editor instance with one comment thread and several track changes suggestions. The adapter is now ready to use with your rich text editor.
It is recommended to stringify the attributes
value to JSON, save it as a string in your database, and then parse the value from JSON when loading suggestions.
Note that this sample does not contain the comments adapter. Check the comments integration guide to learn how to build a complete solution. Also, note that both snippets define the same list of users. Make sure to deduplicate this code and define the list of users only once to avoid errors.
# Demo
Pending adapter actions console
// Add a suggestion to see the result...
Since the track changes adapter saves suggestions immediately after they are performed, it is also recommended to use the Autosave plugin to save the editor content after each change.
# Why is there no event when I accept or discard a suggestion?
Note that when you discard or accept a suggestion, no event is fired in the adapter. This is because the suggestion is never removed from the editor during the editing session. You are able to restore it using undo (Cmd+Z or Ctrl+Z). The same happens when you remove a paragraph with suggestions – there will be no event fired because no data is really removed.
However, to make sure that you do not keep outdated suggestions in your database, you should do a cleanup when the editor is destroyed or closed. You can compare the suggestions stored in the editor data with the suggestions stored in your database and remove all the suggestions that are no longer in the editor data from your database.
# Track changes samples
Please visit the ckeditor5-collaboration-samples
GitHub repository to find several sample integrations of the track changes feature.
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.