Tutorials

Converting “Remove Div” CKEditor4 plugin to CKEditor5

24 October, 2024
Converting “Remove Div” CKEditor4 plugin to CKEditor5

In this article we will be showing how we converted our existing RemoveDiv CKEditor4 plugin to CKEditor5. For those who would like to know more about the RemoveDiv CKEditor plugin, feel free to check out our previous blog post.

We will cover following areas:

  • Brief introduction to our RemoveDiv CKEditor plugin
  • Tools needed for development of CKEditor 5 plugins
  • Conversion of the CKeditor plugin

A bit of history for CKEditor and Drupal

CKEditor 5 is the newest version of the popular web text editor, highly valued by content creators and web developers alike. It brings a fresh design, new features, and sets a high bar for web text editing. 

One of the biggest improvements is its modular architecture, offering greater flexibility and customization options. The cleaner UI and focus on an intuitive writing experience make it easier and more enjoyable to use.

CKEditor 5 was introduced in Drupal 9.3 and with the release of Drupal 10, it became the default WYSIWYG editor.

Why do we need to migrate from CKEditor4 to CKEditor5?

Upgrading to Drupal 10 means transitioning from CKEditor 4 to CKEditor 5, which brings some challenges. CKEditor 4 plugins are not compatible with CKEditor 5 because of its new modular architecture. Custom plugins need to be rewritten to fit the new framework, which can be a complex task requiring a deep understanding of the fundamentally different approach to plugin development in CKEditor 5.

RemoveDiv CKEditor plugin

A while back, we created this helpful CKEditor plugin to make it easier for our content editors to remove unnecessary <div> elements from the editor. For more information, you can visit our blog post.

Writing Plugins for CKEditor 4

Writing plugins for CKEditor 4 was relatively straightforward. All we needed to do was:

  • Register the plugin using hook_wysiwyg_plugin() in the Drupal module file.
  • Create the CKEditor plugin itself.

Registration of the Plugin in Drupal

This step will also add a new button to our editor.

Example Code
/**
 * Implements hook_wysiwyg_plugin()
 */
function ckeditor_custom_wysiwyg_plugin($editor, $version) {
  switch ($editor) {
    // Only do this for ckeditor
    case 'ckeditor':
      return array(
        'removediv' => array(
          'url' => '',
          'path' => drupal_get_path('module', 'ckeditor_custom') . '/plugins/removediv',
          'filename' => 'plugin.js',
          'buttons' => array(
            'RemoveDiv' => t('Remove Div'),
          ),
          'load' => TRUE,
          'internal' => FALSE,
        ),
      );
      break;
  }
}

Define Plugin Behavior

Next, we need to define what the editor should do with this button. Place this file at /plugins/removediv/plugin.js in your module.

Example Code
/* removeDiv plugin for CKEditor
 *
 * Plugin name:      removediv
 * Menu button name: RemoveDiv
 */
(function()
{
  CKEDITOR.plugins.add( 'removediv',
    {
      requires : [ 'div' ],
      init : function( editor )
      {
        editor.addCommand( 'removediv',
          {
            exec : function( editor )
            {
              var selection = editor.getSelection(),
                ranges = selection && selection.getRanges(),
                range,
                bookmarks = selection.createBookmarks(),
                walker,
                toRemove = [];
              function findDiv( node )
              {
                var path = new CKEDITOR.dom.elementPath( node ),
                  blockLimit = path.blockLimit,
                  div = blockLimit.is( 'div' ) && blockLimit;
                if ( div && !div.data( 'cke-div-added' ) )
                {
                  toRemove.push( div );
                  div.data( 'cke-div-added' );
                }
              }
              for ( var i = 0 ; i < ranges.length ; i++ )
              {
                range = ranges[ i ];
                if ( range.collapsed )
                  findDiv( selection.getStartElement() );
                else
                {
                  walker = new CKEDITOR.dom.walker( range );
                  walker.evaluator = findDiv;
                  walker.lastForward();
                }
              }
              for ( i = 0 ; i < toRemove.length ; i++ )
                toRemove[ i ].remove( true );
              selection.selectBookmarks( bookmarks );
            }
          } );
        editor.ui.addButton( 'RemoveDiv',
          {
            label : 'Remove Div',
            command :'removediv',
            toolbar: 'div',
            icon: this.path + 'icons/eraser.png'
          } );
      }
    } );
})();

How the Plugin Works

In short, the plugin selects the current content, searches for any <div> elements within the selected range, and removes them. It uses a combination of CKEditor's selection, range, and walker methods to locate <div> elements and ensure they are removed without affecting other parts of the content.

It's time to move on to the exciting part - converting this to CKEditor 5!

Tools for developing CKEditor5 plugins

To write CKEditor 5 plugins, you need to be comfortable using the following tools and programming languages:

  • Node.js: Used to run the development environment and manage dependencies.
  • npm or Yarn: Package managers for installing and managing JavaScript libraries and tools.Webpack: For bundling CKEditor 5 assets.
  • CKEditor 5 framework concepts: Understanding these concepts is essential for using the CKEditor 5 API and related functions/methods.

Compared to developing plugins for CKEditor 4, where the process was much more straightforward - requiring only basic JavaScript knowledge and direct use of the CKEditor 4 API without any special tools - the CKEditor 5 plugin development process introduces a higher level of complexity.

Exposing the CKEditor 5 Plugin in Drupal

Our first goal is to create a dedicated Drupal module with the following responsibilities:

  • Define the CKEditor 5 plugin.
  • Expose this plugin to Drupal.

To ensure Drupal recognizes the plugin, we need to create a file named ckeditor_remove_div.ckeditor5.yml.

ckeditor_remove_div.ckeditor5.yml

Example Code
ckeditor_remove_div_plugin:
  ckeditor5:
    plugins:
      - removeDivPlugin.RemoveDiv
  # Configuration that will be used directly by Drupal.
  drupal:
    label: Remove Div
    library: ckeditor_remove_div/ckeditor_remove_div
    admin_library: ckeditor_remove_div/admin.ckeditor_remove_div
    toolbar_items:
      RemoveDiv:
        label: Remove Div
    elements:
      - <div>

This is the minimum requirement for Drupal to expose the plugin in the WYSIWYG settings. We also need to declare libraries that contain our JavaScript and CSS files:

  • The first library, ckeditor_remove_div, is the main one that contains the actual plugin.
  • The second library `admin.ckeditor_remove_div`, is used for the admin section, typically for specifying icons for the button.

ckeditor_remove_div.libraries.yml

Example Code
# This adds the plugin JavaScript to the page.
ckeditor_remove_div:
  js:
    js/build/removeDivPlugin.js: { preprocess: false, minified: true }
  dependencies:
    - core/ckeditor5
# Loaded in the text format configuration form to provide styling for the icon
# used in toolbar config.
admin.ckeditor_remove_div:
  css:
    theme:
      css/ckeditor_remove_div.admin.css: { }

Example of admin css file:

ckeditor_remove_div.admin.css

Example Code
.ckeditor5-toolbar-button-RemoveDiv {
  background-image: url(../icons/eraser.svg);
}

At this point we should have something like this in our module:

Example Code
├── css
│   └── ckeditor_remove_div.admin.css
├── icons
│   └── eraser.svg
├── ckeditor_remove_div.ckeditor5.yml
├── ckeditor_remove_div.info.yml
└── ckeditor_remove_div.libraries.yml

This is everything that we need for Drupal. Now let’s focus on creating the CKEditor 5 plugin.

Building the CKEditor 5 plugin

When creating CKEditor 5 plugins, a great starting point is the CKEditor 5 Dev Tools contrib module. To begin, we can copy the package.json and webpack.config.js files from the ckeditor5_plugin_starter_template folder of the dev module into the root of our module. No changes are needed in these files.

CKEditor 5 plugins typically consist of the following files and classes:

  • Main Plugin File: Serves as the entry point.
  • Main Class: Defines the plugin class and exposes the plugin to CKEditor.
  • UI Class: Handles the user interface components.
  • Editing Class: Manages editing behavior and integrates the plugin with the editor.
  • Command Class: Implements commands that are executed after users clicks on the CKEditor button

Main plugin file - Index.js

This is the main entry point for CKEditor 5 to recognize and load plugins. CKEditor 5 identifies available plugins through it.

Example Code
/**
* @file
* The main entry point for CKEditor 5 to recognize and load plugins.
* This file is crucial in the build process, as CKEditor 5 identifies
* available plugins through it. Multiple plugins can be consolidated and
* exported from this single file.
*
* @description
* Acts as a bridge between CKEditor 5 and custom plugins. It imports
* and exports the necessary plugins, making them accessible to the editor.
*/
// Importing the RemoveDiv plugin from the RemoveDiv.js file.
import RemoveDiv from './removediv';
// Exporting the RemoveDiv plugin for CKEditor 5 to recognize and use.
export default {
 RemoveDiv: RemoveDiv,
};

Main class for the plugin - removediv.js

This class represents the RemoveDiv plugin in CKEditor 5. It is responsible for integrating the RemoveDiv functionality into the CKEditor 5 editor. The plugin brings together editing functionalities and user interface components required to enable the removal of 'div' elements from the editor content.

Example Code
import {Plugin} from 'ckeditor5/src/core';
import RemoveDivEditing from './removedivediting';
import RemoveDivUI from './removedivui';
/**
 * @class RemoveDiv
 *
 * @description
 * This class represents the RemoveDiv plugin in CKEditor 5. It is responsible for
 * integrating the RemoveDiv functionality into the CKEditor 5 editor. The plugin
 * brings together editing functionalities and user interface components required
 * to enable the removal of 'div' elements from the editor content.
 */
export default class RemoveDiv extends Plugin {
  /**
   * Specifies the required dependencies for this plugin.
   * @returns {Array} An array of required classes.
   */
  static get requires() {
    return [RemoveDivEditing, RemoveDivUI];
  }
  /**
   * Provides the name of the plugin. This is used by CKEditor 5 for plugin identification.
   * @returns {string} The name of the plugin.
   */
  static get pluginName() {
    return 'RemoveDiv';
  }
}

UI class - removedivui.js

This class is responsible for the user interface part of the RemoveDiv plugin in CKEditor 5. It handles the creation and configuration of a toolbar button that allows users to execute the `removeDiv` command. The button is equipped with an icon, label, and tooltip.

Example Code
import {Plugin} from 'ckeditor5/src/core';
import {ButtonView} from 'ckeditor5/src/ui';
import icon from '../../../../icons/eraser.svg';
/**
 * @class RemoveDivUI
 * @extends Plugin
 * @description
 * This class is responsible for the user interface part of the RemoveDiv plugin in CKEditor 5.
 * It handles the creation and configuration of a toolbar button that allows users to execute
 * the `removeDiv` command. The button is equipped with an icon, label, and tooltip.
 */
export default class RemoveDivUI extends Plugin {
  /**
   * Initializes the UI component of the plugin. This method is responsible for
   * adding the `removeDiv` button to the editor's toolbar and setting up its properties
   * and behavior.
   */
  init() {
    const editor = this.editor;
    const t = editor.t;
    // Add the removeDiv button to the toolbar
    editor.ui.componentFactory.add('removeDiv', () => {
      const button = new ButtonView();
      button.set({
        label: t('Remove Div'),
        icon: icon, // Set the icon path
        tooltip: true
      });
      // Execute the command when the button is clicked
      button.on('execute', () => {
        editor.execute('removeDiv');
        editor.editing.view.focus();
      });
      return button;
    });
  }
}

Editing class - removedivediting.js

This class handles the editing aspect of the RemoveDiv plugin. It is responsible for extending the CKEditor 5 model schema to include functionalities specific to the handling of `div` elements. 
The class may include schema or converter definitions necessary for the proper functioning of the RemoveDiv command.

More about CKEditor 5 schema and conversion can be found on official CKEditor 5 documentation:

Example Code
import {Plugin} from 'ckeditor5/src/core';
import RemoveDivCommand from "./removedivcommand";
/**
 * @class RemoveDivEditing
 * @extends Plugin
 * @description
 * This class handles the editing aspect of the RemoveDiv plugin. It is responsible
 * for extending the CKEditor 5 model schema to include functionalities specific to
 * the handling of `div` elements. The class may include schema extensions or
 * converter definitions necessary for the proper functioning of the RemoveDiv command.
 */
export default class RemoveDivEditing extends Plugin {
  /**
   * Initializes the plugin's editing behavior. This method sets up the schema
   * extensions and converters for handling `div` elements within the editor's model.
   * It registers the 'removeDiv' command in the editor using the RemoveDivCommand class.
   */
  init() {
    const editor = this.editor;
    this.editor.commands.add('removeDiv', new RemoveDivCommand(this.editor));
  }
}

Command class - removedivcommand.js

This class is responsible for actions that happen when a user clicks on the CKEditor button. For our plugin, this is where the most functionality is happening.

Example Code
import {Command} from 'ckeditor5/src/core';
/**
 * @class RemoveDivCommand
 * @extends Command
 * @description
 * This class creates a command for removing `div` elements from the editor's content.
 * It extends CKEditor 5's built-in Command class.
 */
export default class RemoveDivCommand extends Command {
  /**
   * Refreshes the state of the command. It is called automatically by the editor
   * to decide whether the command should be enabled or disabled based on the current context.
   */
  refresh() {
    const model = this.editor.model;
    const selection = model.document.selection;
    // Assuming 'div' elements are represented by a specific model element (e.g., 'htmlDivParagraph')
    const position = selection.focus || selection.anchor;
    const ancestors = position.getAncestors();
    // Enable the command only if the selection is within a 'div' element or its equivalent
    this.isEnabled = this._hasDivAncestor(ancestors);
  }
  /**
   * Removes a `div` element from the editor's content, handling it differently based on its content:
   * - If the `div` contains only text, it is replaced with a paragraph (`<p>`) containing the same text.
   * - If the `div` contains nested elements, those elements are moved outside the `div`, preserving their structure.
   * The function first identifies a `div` ancestor of the current selection. If found, it checks the `div`'s content and
   * applies the appropriate handling before removing the `div` from the document.
   */
  execute() {
    const model = this.editor.model;
    const selection = model.document.selection;
    model.change(writer => {
      const ancestors = selection.focus.getAncestors();
      const divElement = this._findDivAncestor(ancestors);
      if (divElement) {
        // Get the position before the div element to start reinserting its content
        const positionBeforeDiv = writer.createPositionBefore(divElement);
        const children = Array.from(divElement.getChildren());
        // Check if the div contains only text or is effectively standalone
        const isStandaloneText = children.every(child => child.is('text'));
        if (isStandaloneText) {
          // Convert the standalone div to a paragraph
          const paragraph = writer.createElement('paragraph');
          writer.insert(paragraph, positionBeforeDiv);
          // Move the text into the paragraph
          for (const child of children) {
            writer.append(child, paragraph);
          }
        } else {
          // For divs with nested elements, move each direct child to the position before the div
          for (const child of children) {
            writer.move(writer.createRangeOn(child), positionBeforeDiv);
          }
        }
        // After handling the children, remove the div element
        writer.remove(divElement);
      }
    });
  }
  /**
   * Checks if any of the ancestors in the given array is a div element.
   *
   * @param {Array<Object>} ancestors - An array of ancestor elements. Each element is an object representing a node in the CKEditor model tree.
   * @returns {boolean} Returns true if at least one ancestor is a div element (case-insensitive check on the element's name), false otherwise.
   */
  _hasDivAncestor(ancestors) {
    return ancestors.some(ancestor => {
      if (typeof ancestor === 'object' && 'name' in ancestor) {
        return ancestor.name.toLowerCase().includes('div');
      }
      return false;
    });
  }
  /**
   * Finds and returns the first ancestor from the given array that is a div element.
   *
   * This function iterates through the array of ancestors and returns the first element that is identified as a div (based on the 'name' property).
   * The check is case-insensitive, meaning it does not matter whether the div is represented as 'DIV', 'div', or any other case variation.
   * If no div ancestor is found, the function returns null.
   *
   * @param {Array<Object>} ancestors - An array of ancestor elements, where each element is an object with properties representing attributes of a node in the CKEditor model tree.
   * @returns {Object|null} The first ancestor object that represents a div element, or null if no such ancestor is found.
   */
  _findDivAncestor(ancestors) {
    for (const ancestor of ancestors) {
      if (typeof ancestor === 'object' && 'name' in ancestor && ancestor.name.toLowerCase().includes('div')) {
        return ancestor; // Return the first ancestor that satisfies the condition
      }
    }
    return null; // Return null if no such ancestor is found
  }
}

Compiling the CKEDitor 5 plugin

Now that we have all the files for our CKEditor 5 plugin, this is how our module is suppose to look like:

Example Code
├── css
│   └── ckeditor_remove_div.admin.css
├── icons
│   └── eraser.svg
├── js
│   └── ckeditor5_plugins
│       └── removeDivPlugin
│           └── src
│               ├── index.js
│               ├── removediv.js
│               ├── removedivcommand.js
│               ├── removedivediting.js
│               └── removedivui.js
├── ckeditor_remove_div.ckeditor5.yml
├── ckeditor_remove_div.info.yml
├── ckeditor_remove_div.libraries.yml
├── package.json
├── webpack.config.js
└── yarn.lock

Once we have all these files ready, we can start compiling the code. First we need to install all the dependencies.

Example Code
yarn install

Next we can run the build command.

Example Code
yarn build

After build is successful, the compiled plugin will be located at js/build/removeDivPlugin.js. This is the file that is added in the javascript library of the module.

We finally have everything we need for the CKEditor5 plugin to work in Drupal.

Let’s recap what we have done here:

  • We created a Drupal module that will hold CKEditor5 plugin
  • We let Drupal know about our plugin existence and configured the button
  • We created libraries with css and js for our plugin
  • We defined all the files needed for the CKEditor5 plugin:
    • index.js
    • removediv.js
    • removedivui.js
    • removedivediting.js
    • removedivcommand.js
  • We compiled all the code to get the final `removeDivPlugin.js` file that we included in our library file

If you would like to see RemoveDiv plugin in action, check it out on https://www.drupal.org/project/ckeditor_remove_div

Need Help Migrating to CKEditor 5?

Moving from CKEditor 4 to CKEditor 5 involves significant changes, especially with custom plugins. Our team is ready to assist with plugin conversion, setup, and integration. Reach out now for a free consultation and ensure a smooth migration!

CKEditorDrupalDrupal Modules

Other blog posts you might be interested in...