Creating a Custom Ckeditor5 Plugin in Drupal 10

One of the key tasks in my current role has been upgrading from CKEditor 4 to CKEditor 5. This transition is crucial, especially since CKEditor 4 is no longer supported in Drupal 10, and CKEditor 5 has become an integral part of the core modules.

Today we will be creating a custom plugin that splits the paragraphs into two columns, allowing the user to have two written columns in one paragraph. This can be seen below.

Introduction to CKEditor 5

CKEditor 5 is a significant redesign from CKEditor 4, engineered from scratch to cater to the needs of modern web applications and their developers. This version stands out due to its modular, MVC-based framework, which utilizes a custom data model and a virtual DOM, contrasting with the HTML/DOM model used in CKEditor 4. Built with TypeScript, CKEditor 5 supports robust coding practices and enhances software reliability. Its architecture boosts performance, facilitates extensive editing capabilities, and includes out-of-the-box support for real-time collaboration. These improvements make CKEditor 5 ideal for complex applications requiring detailed customization and advanced features.

 

Installing the CKEditor 5 Dev Tools Module

Firstly, to aid in plugin development, we need to install the custom CKEditor 5 dev tools module. This module provides a template for new plugins and a console for debugging:

Composer
composer require 'drupal/ckeditor5_dev:^1.0'

 

After installing the module, locate theckeditor5_plugin_starter_templatedirectory within it. Copy this folder to your custom modules directory and rename it— I named minecustom_ckeditor.

Configuring Your Plugin

Within your new module, replace all instances ofMODULE_NAMEwith 'custom_ckeditor' (or your chosen module name) in the file names and contents. Here's an example of how to adjust the YAML configuration files to define custom plugins:

YAML
  
# If using yml to configure plugins, rename this to {module_name}.ckeditor5.yml.
# If using annotations, this file can be removed.
# @see https://www.drupal.org/docs/drupal-apis/plugin-api/annotations-based-plugins
#   For information on using annotations to define plugins.
# @see the CKEditor 5 module's README.md for more details regarding plugin
#   configuration options.
# cSpell:ignore simplebox demobox

custom_ckeditor_namecoach:
  # Use the provider: property for this plugin to depend on another module.

  # Configuration that will be sent to CKEditor 5 JavaScript plugins.
  ckeditor5:
    plugins:
      - namecoach.NameCoach
      - twocoltpl.TwoColTpl #only focusting on this one
      - quotation.Quotation
    # *Additional configuration properties*
    # config: data sent to the constructor of any CKEditor 5 plugin
    #   editorPluginName:
    #     editorPluginProperty: editorPluginValue

  # Configuration that will be used directly by Drupal.
  drupal:
    label: Custom Ckeditor Plugins
    # The library loaded while using the editor.
    library: custom_ckeditor/custom
    # The library loaded when configuring the text format using this plugin.
    admin_library: custom_ckeditor/admin.custom
    toolbar_items:
      # This should match the name of the corresponding plugin exported in the
      # plugin's index.js.
      NameCoach:
        label: Name Coach
      TwoColTpl:
        label: Two Column Layout
      Quotation:
        label: Quotation
    # If the plugin does not provide elements, set this as
    # `elements: false`
    elements:
      # Note that it necessary for elements to separately provide both the tag
      # (f.e. `<h2>`) and the attribute being added to the tag
      # (f.e. `<h2 class="simple-box-title">`).
      - <h2>
      - <div>
      - <section>
      - <blockquote>
      - <p>
    # *Additional configuration properties*
    # conditions: for setting additional criteria that must be met for the
    #   plugin to be active.
    # class: Optional PHP class that makes it possible for the plugin to provide
    #   dynamic values, or a configuration UI.
  

 

Today, we'll focus on theTwoColTplplugin, which creates a two-column layout allowing users to input content in separate columns.

Building the Plugin

CKEditor 5 plugin development differs significantly from CKEditor 4. You'll need to set up a JavaScript environment using node modules. Here’s how you can organize the JavaScript files for the TwoColTpl plugin:

first create a TwoColTpl (or custom name) folder inside the /js directory, then inside that folder create a /src folder. the following files will live in there.

index.js: Serves as the entry point, exporting the plugin.

Javascript

import TwoColTpl from './twocoltpl';

export default {
  TwoColTpl,
};
  

twocoltpl.js (or customname.js): Acts as the core of the plugin, combining different components.

Javascript

import TwoColTplEditing from './twocoltplediting';
import TwoColTplUI from './twocoltplui';
import { Plugin } from 'ckeditor5/src/core';

export default class TwoColTpl extends Plugin {
  static get requires() {
    return [TwoColTplEditing,TwoColTplUI];
  }
}

twocoltplediting.js (or customname+editing.js): Manages the plugin's editing functionality, defining the schema and converters.

Javascript

import { Plugin } from 'ckeditor5/src/core';
import { toWidget, toWidgetEditable } from 'ckeditor5/src/widget';
import { Widget } from 'ckeditor5/src/widget';
import InsertTwoColTplCommand from './inserttwocoltplcommand';

/**
 * Template pulled from ckeditor5_dev
 * CKEditor 5 plugins do not work directly with the DOM. They are defined as
 * plugin-specific data models that are then converted to markup that
 * is inserted in the DOM.
 *
 * CKEditor 5 internally interacts with twocoltpl as this model:
 * 
 *    
 *    
 * 
 *
 * Which is converted for the browser/user as this markup
 * 
 *
 * This file has the logic for defining the nameCoach model, and for how it is
 * converted to standard DOM markup.
 */

export default class InsertTwoColTplEditing extends Plugin {
  static get requires() {
    return [Widget];
  }

  init() {
    this._defineSchema();
    this._defineConverters();
    this.editor.commands.add(
      'insertTwoColTpl',
      new InsertTwoColTplCommand(this.editor),
    );
  }

  /*
   * The logic in _defineConverters() will determine how this is converted to
   * markup.
   */

  _defineSchema() {
    const schema = this.editor.model.schema;

    schema.register('TwoColTpl', {
      isObject: true,
      allowWhere: '$block',
    });

    schema.register('twocoltplTitle', {
      isLimit: true,
      allowIn: 'twocoltplColumn',
      allowContentOf: '$block',
    });

    schema.register('twocoltplDescription', {
      isLimit: true,
      allowIn: 'twocoltplColumn',
      allowContentOf: '$root',
    });

        schema.register('twocoltplColumn', {
      allowIn: 'TwoColTpl',
      allowContentOf: '$root',
    });

    schema.addChildCheck((context, childDefinition) => {
      // Disallow twocoltpl inside twocoltplDescription.
      if (
        context.endsWith('twocoltplDescription') &&
        childDefinition.name === 'TwoColTplColumn'
      ) {
        return false;
      }
    });
  }

  /**
   * Converters determine how CKEditor 5 models are converted into markup and
   * vice-versa.
   */
  _defineConverters() {

    const { conversion } = this.editor;

    conversion.for('upcast').elementToElement({
      model: 'TwoColTpl',
      view: {
        name: 'section',
        classes: 'twocoltpl',
      },
    });

    conversion.for('upcast').elementToElement({
      model: 'twocoltplTitle',
      view: {
        name: 'h4',
        classes: 'twocoltpl-title',
      },
    });

    conversion.for('upcast').elementToElement({
      model: 'twocoltplColumn',
      view: {
          name: 'div',
          classes: 'twocoltpl-column'
      }
    });

    conversion.for('upcast').elementToElement({
      model: 'twocoltplDescription',
      view: {
        name: 'p',
        classes: 'twocoltpl-description',
      },
    });

    // Data Downcast Converters: converts stored model data into HTML.
    // These trigger when content is saved.
    conversion.for('dataDowncast').elementToElement({
      model: 'TwoColTpl',
      view: (modelElement, { writer: viewWriter }) => {
        return viewWriter.createContainerElement('div', {
          class: 'twocoltpl o-row',
        });
      },
    });

    conversion.for('dataDowncast').elementToElement({
      model: 'twocoltplColumn',
      view: (modelElement, { writer: viewWriter }) => {
        return viewWriter.createContainerElement('div', {
          class: 'twocoltpl-column o-col o-col-sm-6',
        });
      },
    });

    conversion.for('dataDowncast').elementToElement({
      model: 'twocoltplTitle',
      view: (modelElement, { writer: viewWriter }) => {
        return viewWriter.createEditableElement('h4', {
          class: 'twocoltpl-title',
        });
      },
    });

    conversion.for('dataDowncast').elementToElement({
      model: 'twocoltplDescription',
      view: (modelElement, { writer: viewWriter }) => {
        return viewWriter.createEditableElement('p', {
          class: 'twocoltpl-description',
        });
      },
    });
    // Editing Downcast Converters. These render the content to the user for
    // editing, i.e. this determines what gets seen in the editor. These trigger
    // after the Data Upcast Converters, and are re-triggered any time there
    // are changes to any of the models' properties.
    //
    // Convert the  model into a container widget in the editor UI.
    // Editing Downcast Converters
    conversion.for('editingDowncast').elementToElement({
      model: 'twocoltplColumn',
      view: (modelElement, { writer: viewWriter }) => {
        const div = viewWriter.createContainerElement('div', {
          class: 'twocoltpl-column o-col o-col-sm-6',
        });
        return toWidget(div, viewWriter, { label: 'Column widget' });
      },
    });

    conversion.for('editingDowncast').elementToElement({
      model: 'twocoltplTitle',
      view: (modelElement, { writer: viewWriter }) => {
        const h4 = viewWriter.createEditableElement('h4', {
          class: 'twocoltpl-title',
        });
        return toWidgetEditable(h4, viewWriter);
      },
    });
    
        conversion.for('editingDowncast').elementToElement({
      model: 'twocoltplDescription',
      view: (modelElement, { writer: viewWriter }) => {
        const p = viewWriter.createEditableElement('p', {
          class: 'twocoltpl-description',
        });
        return toWidgetEditable(p, viewWriter);
      },
    });

    // Editing Downcast Converters. These render the content to the user for
    // editing, i.e. this determines what gets seen in the editor. These trigger
    // after the Data Upcast Converters, and are re-triggered any time there
    // are changes to any of the models' properties.
    //
    // Convert the  model into a container widget in the editor UI.
    conversion.for('editingDowncast').elementToElement({
      model: 'TwoColTpl',
      view: (modelElement, { writer: viewWriter }) => {
        const section = viewWriter.createContainerElement('section', {
          class: 'twocoltpl o-row',
        });

        return toWidget(section, viewWriter, { label: 'Two Column Layout Widget' });
      },
    });
  }
}

twocoltplui.js(or customname+ui.js): Defines the UI components, such as buttons and icons.

Javascript

    /**
    * @file registers the TwoColTpl Completion button and binds functionality to it.
    */
    
    import {Plugin} from 'ckeditor5/src/core';
    import {ButtonView} from 'ckeditor5/src/ui';
    import icon from '../../../../icons/twocoltpl.svg';
    
    export default class TwoColTplUI extends Plugin {
    init() {
    const editor = this.editor;
    
    editor.ui.componentFactory.add('twocoltpl', (locale) => {
    const command = editor.commands.get('insertTwoColTpl');
    const buttonView = new ButtonView(locale);
    
    // Create the toolbar button.
    buttonView.set({
    label: editor.t('Two Column Layout'),
    icon,
    tooltip: true,
    });
    
    // Bind the state of the button to the command.
    buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled');
    
    // Execute the command when the button is clicked (executed).
    this.listenTo(buttonView, 'execute', () =>
    editor.execute('insertTwoColTpl'),
    );
    
    return buttonView;
    });
    
    }
    }

  

inserttwocoltplcommand.js(or insert+customname+command.js): Implements the command logic that executes the plugin's core functionality.

Javascript

import { Command } from 'ckeditor5/src/core';

export default class InsertTwoColTplCommand extends Command {
  execute() {
    const editor = this.editor;

    editor.model.change(writer => {

      // Create the main 'TwoColTpl' element
      const twoColTpl = writer.createElement('TwoColTpl');

      // Create two column containers within 'TwoColTpl'
      const column1 = writer.createElement('twocoltplColumn');
      const column2 = writer.createElement('twocoltplColumn');

      // For each column, create a 'twocoltplTitle' and insert some initial content
      const titleColumn1 = writer.createElement('twocoltplTitle');
      const titleColumn2 = writer.createElement('twocoltplTitle');

      writer.insert(writer.createText('This is an h4'), titleColumn1);
      writer.insert(writer.createText('This is another h4'), titleColumn2);

      const descColumn1 = writer.createElement('twocoltplDescription');
      const descColumn2 = writer.createElement('twocoltplDescription');
      writer.insert(writer.createText('Interdum eu natoque ultrices nibh facilisi, libero tempus sociosqu.'), descColumn1);
      writer.insert(writer.createText('Erat elementum venenatis natoque, dapibus volutpat porttitor.'), descColumn2);

      // Append 'twocoltplTitle' to each
      writer.append(titleColumn1, column1);
      writer.append(titleColumn2, column2);
      writer.append(descColumn1, column1);
      writer.append(descColumn2, column2);
      writer.append(column1, twoColTpl);
      writer.append(column2, twoColTpl);

      // Insert the 'TwoColTpl' element into the document
      editor.model.insertContent(twoColTpl, editor.model.document.selection.getFirstPosition());

      //Place the selection inside the first column
      writer.setSelection(titleColumn1, 'in');
    });
  }
}

After setting up these files, navigate to the root directory of your custom plugin, and run the following commands to build your plugin:

Shell
npm install
npm run build

Finally, configure your text format settings in Drupal to include the new plugin button in the CKEditor 5 toolbar.

Conclusion

With these steps, you've successfully created a custom CKEditor 5 plugin within Drupal 10. See bellow for a photo of the functionality. If you have any questions or need further clarification, feel free to contact me via Twitter, GitHub, or email. Happy coding!

Author: Richard Ford April 25, 2024, 6:51 PM