Report an issue

guideIntegrating track changes with custom features

The track changes feature was designed with custom features in mind. In this guide, you will learn how to integrate your plugins with the track changes feature.

# Enabling commands

CKEditor 5 uses commands to change the editor content. Most modifications of the editor content are done through command execution. Also, commands are usually connected with toolbar buttons that represent their state. For example, if a given command is disabled at the moment, the toolbar button is disabled as well.

By default, a command is disabled when tracking changes is turned on. This is to prevent errors and incorrect behavior of a command that has not been prepared to work in the suggestion mode. The first step to integrating your command is to enable it:

const trackChangesEditing = editor.plugins.get( 'TrackChangesEditing' );

trackChangesEditing.enableCommand( 'commandName' );

Now the command will be enabled in the track changes mode. However, this does not introduce any additional functionality related to integration with the track changes feature.

The enableCommand() method may take an additional parameter: a callback that will be fired instead of the original command. This is the way to provide custom integration with the track changes feature. Using the callback, you can overwrite what happens when the command is executed in the suggestion mode.

trackChangesEditing.enableCommand( 'commandName', ( executeCommand, options ) => {
    // Here you can overwrite what happens when the command is executed in the suggestion mode
    // ...
} );

# Insertions and deletions

Track changes is partially integrated with the CKEditor 5 engine. Chances are that your feature will work out of the box. Automatically supported actions are insertions and deletions.

Many custom features are focused on introducing a new kind of element or a widget. It is recommended to use model.insertContent() to insert this object into the document content. This method, apart from taking care of maintaining a proper document structure, is also integrated with the track changes feature. Any content inserted with this method will be marked with an insertion suggestion.

Similarly, removing is also integrated with track changes through the integration with model.deleteContent(). Calling this method while in the suggestion mode will create a deletion suggestion instead. The same goes for actions that remove content, for example using the Backspace and Delete keys, typing over selected content, or pasting over the selected content.

To generate a proper suggestion description, you will need to register a label for the introduced element. The description mechanism supports translations and different labels for single and multiple added/removed elements.

An example of integration for the page break element:

const t = editor.t;

trackChangesEditing._descriptionFactory.registerElementLabel(
    'pageBreak',

    quantity => t( {
        string: 'page break',
        plural: '%0 page breaks',
        id: 'ELEMENT_PAGE_BREAK'
    }, quantity )
);

In this case, the suggestion description could be “Remove: page break” or, for example, “Insert: 3 page breaks.” Using the translation system and adding translations for custom features is described in the Localization guide.

An example of an integration without using the translation system:

trackChangesEditing._descriptionFactory.registerElementLabel(
    'customElement',

    quantity => quantity == 1 ? 'custom element' : quantity + ' custom elements';
);

If your feature cannot use model.insertContent() and model.deleteContent(), or you need a more advanced integration, you may want to check the following methods from the track changes API:

# Formatting changes

The above covers inserting and removing elements which are simpler types of changes.

The other types of changes are formatting changes. This may apply to you if your feature introduces attributes (or other properties) that can change after the element is inserted into the content and you want this to be tracked as well.

In this case, you will need to pass a custom callback to enableCommand() and you will need to use the track changes API:

  • markInlineFormat() – Marks a given range as an inline format suggestion. Used for attribute changes on inline elements and text.
  • markBlockFormat() – Marks a format suggestion on a block element.
  • markMultiRangeBlockFormat() – Used for format suggestions that contain multiple elements that are not next to one another.

Refer to the API documentation of these methods to learn more.

Format suggestions are strictly connected with commands. These suggestions have a specified range, command name and command options. When such a suggestion is accepted, the specified command is executed on the suggestion range with the specified options.

The general approach to handle format suggestions for your custom feature is to use a similar logic as in your custom feature but instead of applying changes to the content, create a suggestion through the track changes API:

  1. Overwrite the command callback (using enableCommand()) so the command is not executed.
  2. Use the same logic as in your custom command to decide whether the command should do anything.
  3. Use the same logic as in your custom command to evaluate all command parameters that are not set (that would use a default value). A suggestion must have a set, solid and complete state, so its execution does not rely on the content state.
  4. Use the selection range or evaluate a more precise range(s) for the suggestion(s).
  5. Use markInlineFormat() or markBlockFormat() to create one or more suggestions using the previously evaluated variables.

An example of an integration for the image text alternative property:

trackChangesEditing.enableCommand( 'imageTextAlternative', ( executeCommand, options ) => {
    // Commands work on the current selection, so the track
    // changes integration also works on the current selection.
    const range = editor.model.document.selection.getFirstRange();
    const image = range.start.nodeAfter;

    // Get the current value of this property for a given image.
    const currentValue = image.hasAttribute( 'alt' ) ? image.getAttribute( 'alt' ) : '';

    // If there was no change, do not do anything.
    if ( currentValue == options.newValue ) {
        return;
    }

    this.editor.model.change( () => {
        // Set a suggestion on the image.
        trackChangesEditing.markBlockFormat(
            image,
            {
                // The command to be executed when the suggestion is accepted.
                commandName: 'imageTextAlternative',
                // Parameters for the command.
                commandParams: [ options ]
            }
        );
    } );
} );

An example of an integration for the bold style:

trackChangesEditing.enableCommand( 'bold', ( executeCommand, options = {} ) => {
    const selection = model.document.selection;

    if ( selection.isCollapsed ) {
        // If the selection is collapsed, execute the default behavior of the command.
        executeCommand( options );

        return;
    }

    // Evaluate the default value of the command for the suggestion so that
    // the suggestion execution does not depend on the content state at the
    // moment when the suggestion is executed.
    const forceValue = typeof options.forceValue != 'undefined' ? options.forceValue : !command.value;

    model.change( () => {
        const selectionRanges = Array.from( selection.getRanges() );

        // If the attribute is being set, use the original selection range
        // to make the suggestion.
        //
        // If the attribute is being unset, check an exact range on which the
        // attribute is set and use it.
        // This results in having a suggestion only on already bolded fragments,
        // which results in a better UX.
        for ( const selectionRange of selectionRanges ) {
            const ranges = forceValue ? [ selectionRange ] : getRangesWithAttribute( 'bold', selectionRange, model );

            for ( const range of ranges ) {
                plugin.markInlineFormat(
                    range,
                    {
                        commandName,
                        commandParams: [ { forceValue } ]
                    }
                );
            }
        }
    } );
} );

# Setting the suggestion description

To complete the integration of your command using the format suggestion, you will need to provide a callback that will generate a description for such a suggestion. This is different from registering a label for insertion/deletion suggestions as format suggestions are more varied and complex.

An example of a description callback for the bold style:

plugin._descriptionFactory.registerDescriptionCallback( suggestion => {
    const { data } = suggestion;

    // Omit suggestions that are not bold style suggestions.
    if ( !data || data.commandName !== 'bold' ) {
        return;
    }

    const isSet = !!data.commandParams[ 0 ].forceValue;
    const content = isSet ? '*Set format:* bold' : '*Remove format:* bold';

    return {
        type: 'format',
        content
    };
} );

Note that the description callback can be also used for insertion and deletion suggestions if you want to overwrite the default description.