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 require 'drupal/ckeditor5_dev:^1.0'
After installing the module, locate theckeditor5_plugin_starter_template
directory 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_NAME
with '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:
# 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 theTwoColTpl
plugin, 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.
import TwoColTpl from './twocoltpl';
export default {
TwoColTpl,
};
twocoltpl.js (or customname.js): Acts as the core of the plugin, combining different components.
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.
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.
/**
* @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.
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:
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!