Create and Integrate CKEditor 5 Plugin in Drupal 11
AI-TranslatedA complete set of free plugins that enhance CKEditor 5 for Drupal, overseen by CKSource.
The CKEditor 5 Plugin Pack lets you access some premium features from the CKEditor 5 Premium Features module for free, but you need this module to use it. You don't need a paid license for these features.
This blog provides a comprehensive walkthrough for creating a custom CKEditor 5 plugin in Drupal. We'll build a Callout Plugin that allows users to insert styled content blocks with customizable background colors. It covers everything from project setup to implementation details, including schema definitions, model-view architecture, and Drupal integration.
If you're looking for customized services for your Drupal website, be sure to check out what we offer.
Let's start the process!
CKEditor 5: Why Use Custom Plugins?
Custom plugins in CKEditor 5 enhance or change the editor's capabilities beyond the core and official plugins. This enables developers to customize the editor for specific requirements, such as adding special features, connecting with external services, or altering the user interface.
Here's a more detailed explanation:
- Extending Functionality: If the current plugins do not offer the precise feature you require, a custom plugin lets you create it.
- Integration with External Services: You can develop plugins to connect with third-party services, such as embedding videos or social media content.
- Custom UI: Plugins can change the editor's user interface by adding custom buttons, toolbars, or even creating new types of editors.
- Semantic Value: Plugins can provide semantic meaning to content, such as annotations or features for accessibility.
- Specific Use Cases: Custom plugins are crucial for situations where the standard editor does not fulfill the specific needs of a project.
- Flexibility and Adaptability: CKEditor 5's plugin-based design offers great flexibility and adaptability, enabling developers to create highly tailored editing experiences.
Example:
Consider needing a feature to highlight certain text passages with a specific color. The existing highlight plugin may not provide the exact color choices you want. In this scenario, you can develop a custom plugin that enhances the highlight feature to include your preferred color options.
In summary, custom plugins are essential for unlocking the full capabilities of CKEditor 5 and customizing it for various and unique editing requirements.
CKEditor 5: Custom Plugin Project Structure
To create a custom plugin for CKEditor 5 in a Drupal 11 setup. This plugin introduces a reusable, styled block (similar to a callout box) in the content editor, enabling content creators to easily emphasize important messages through a modal interface and visual formatting.
Plugin Concept
The blog details the process of developing a "Callout" block plugin that:
- Adds a button to the CKEditor 5 toolbar.
- Opens a custom modal form for user input (like title and message).
- Inserts a visually styled block (like a colored box) into the content.
- Allows the block to be edited again by clicking on it.
- Stores the content semantically so it can be saved, displayed, and modified correctly.
your_module/
├── js/
│ ├── ckeditor5_plugins/
│ │ └── callout/
│ │ ├── src/
│ │ │ ├── index.js # Plugin entry point
│ │ │ ├── callout.js # Main plugin class
│ │ │ ├── calloutui.js # UI implementation
│ │ │ └── calloutediting.js # Editing logic
│ │ │ └── insertcalloutcommand.js # Insert command
│ │ └── icons/
│ │ └── callout.svg # Toolbar icon
│ └── build/
│ └── callout.js # Built plugin file
├── css/
│ ├── callout.css # Callout styles
│ └── callout_inputbox.css # Modal styles
├── your_module.info.yml # Module info
├── your_module.libraries.yml # Libraries definition
├── your_module.ckeditor5.yml # Plugin configuration
└── webpack.config.js # Build configuration
1. Entry Point (index.js)
The index.js file acts as the central hub or starting point for the custom CKEditor 5 plugin. Think of it as the place where everything gets tied together and made available to the editor.
Here’s what it typically does
Loads and Registers Sub-Modules
CKEditor plugins often follow a modular design. The actual functionality and UI of the plugin are usually split into smaller parts:
- CalloutEditing: Handles how the plugin content behaves in the editor's data model (schema, conversion, commands, etc.)
- CalloutUI: Manages the toolbar button or interface that a user clicks to enable the feature
The entry point imports these sub-plugins and ensures they are registered when the editor loads your custom plugin. This helps keep the codebase clean and separated by responsibility.
Registers Commands
In CKEditor 5, every major action (like inserting a table, image, or a custom block like a callout) is handled through a command. These commands can be triggered by buttons or programmatically.
So in index.js, you also register the insertCallout command, which is what gets executed when a user clicks the toolbar button to add a callout box in the editor.
Tells CKEditor What This Plugin Is
Finally, this file defines the actual plugin class (usually called Callout) and uses the requires property to tell CKEditor which other plugins are required to make it work (i.e., the UI and Editing modules). CKEditor then bundles them together when initializing.
import Callout from './callout';
import CalloutUI from './calloutui';
import CalloutEditing from './calloutediting';
// Export the plugin for CKEditor 5
export default {
Callout,
CalloutUI,
CalloutEditing
};
2. Main Plugin Class (callout.js)
This file serves as the core plugin definition. The callout.js file represents the official definition of your custom plugin. If index.js is the launchpad, then callout.js is the blueprint that tells CKEditor:
“Hey, this is what the Callout plugin is, and here’s what it needs to function.”
Let’s break it down:
Acts as the Plugin Container
This file contains the main plugin class, typically named Callout. It's a class that extends CKEditor's Plugin base class. This tells CKEditor that it's dealing with a proper plugin.
Combines Logic and UI
Within the class, there is a required static property. This is where the plugin lists dependencies, specifically the two core pieces:
- CalloutEditing: Defines how the plugin behaves, what schema it uses, how data is stored/converted, etc.
- CalloutUI: Manages how the plugin appears in the editor UI (toolbar buttons, dropdowns, icons)
By aggregating both, the plugin ensures that both the functionality (editing) and the user experience (UI) are available to CKEditor when the plugin is initialized.
Plugin Initialization
Though the main logic lives in CalloutEditing and CalloutUI, the callout.js file acts as the central registry that binds them. It helps CKEditor recognize the plugin as a single unit, so it can be added easily when configuring the editor.
import { Plugin } from 'ckeditor5/src/core';
import CalloutEditing from './calloutediting';
import CalloutUI from './calloutui';
export default class Callout extends Plugin {
// Define required plugins
static get requires() {
return [CalloutEditing, CalloutUI];
}
// Plugin name used in configuration
static get pluginName() {
return 'Callout';
}
}
3. UI Implementation (calloutui.js)
The calloutui.js file is where the visual side of the plugin comes to life. It focuses entirely on how users interact with the plugin through the CKEditor toolbar and any UI elements like buttons or modals.
Let’s break it down:
Toolbar Button Setup
This file is responsible for creating and registering the toolbar button that appears in CKEditor, the one users click to insert a callout box.
It gives the button a label, tooltip, and an icon (which can be custom or from CKEditor's icon set).
It connects the button to a specific command, in this case, insertCallout. When a user clicks the button, that command is executed to insert the callout.
Custom UI Components
Besides the button, this file may also define custom interactions like a modal window or a dropdown.
In this instance, the plugin features a modal that allows users to choose the background color for the callout.
When the toolbar button is clicked, a UI pops up where the user can pick from predefined background colors (like info, warning, success, etc.).
Once a user selects the color, it’s applied to the inserted callout block.
This makes the plugin more flexible and user-friendly by letting users visually configure how the callout looks without writing any code.
Smooth Integration
Everything created in this file, buttons, modals, event handlers, is integrated into CKEditor’s UI framework. That means the plugin feels native to the editor, with a consistent look and feel.
import { Plugin } from 'ckeditor5/src/core';
import { ButtonView } from 'ckeditor5/src/ui';
import icon from '../../../../icons/callout.svg';
import '../../../../css/callout_inputbox.css';
import { DomEventObserver } from 'ckeditor5/src/engine.js';
/**
* Custom observer to handle double-click events.
*/
class DoubleClickObserver extends DomEventObserver {
constructor(view) {
super(view);
this.domEventType = 'dblclick';
}
onDomEvent(domEvent) {
this.fire(domEvent.type, domEvent);
}
}
/**
* CalloutUI Plugin for CKEditor 5
* Adds a button to the toolbar for inserting/editing callouts with customizable background colors.
*/
export default class CalloutUI extends Plugin {
init() {
const editor = this.editor;
// Register the callout button in the component factory
editor.ui.componentFactory.add('callout', (locale) => {
const command = editor.commands.get('insertCallout');
const buttonView = new ButtonView(locale);
// Configure the toolbar button
buttonView.set({
label: editor.t('Callout'),
icon,
tooltip: true,
});
// Bind button state to command
buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled');
// Open modal on button click
this.listenTo(buttonView, 'execute', () => this._openCalloutModal(editor));
return buttonView;
});
// Add double-click event listener for editing existing callouts
this._addClickListener(editor);
}
/**
* Opens a modal popup for editing or inserting callout properties.
* @param {Object} editor - CKEditor instance.
* @param {Object} existingData - Optional data for editing an existing callout.
*/
_openCalloutModal(editor, existingData = {}) {
const modal = document.createElement('div');
modal.classList.add('ck-modal');
modal.innerHTML = `
<div class="dialog-box">
<div class="ck-modal-popup">
<div class="header">
<div>Edit Callout</div>
<div id="closeButton">x</div>
</div>
<div class="form_input">
<label for="backgroundColorSelect">Background Color:</label>
<select id="backgroundColorSelect" style="width: 100%; margin-bottom: 10px;">
<option value="callout-blue" ${existingData.class === 'callout-blue' ? 'selected' : ''}>Blue</option>
<option value="callout-grey" ${existingData.class === 'callout-grey' ? 'selected' : ''}>Grey</option>
<option value="callout-black" ${existingData.class === 'callout-black' ? 'selected' : ''}>Black</option>
</select>
</div>
<div class="quote_buttons">
<button id="okButton">OK</button>
<button id="cancelButton">Cancel</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Close modal on clicking 'x' button
modal.querySelector('#closeButton').addEventListener('click', () => modal.remove());
// Close modal on clicking 'Cancel' button
modal.querySelector('#cancelButton').addEventListener('click', () => modal.remove());
// Save selected background color and insert callout on clicking 'OK' button
modal.querySelector('#okButton').addEventListener('click', () => {
try {
const backgroundColor = modal.querySelector('#backgroundColorSelect').value;
editor.execute('insertCallout', { backgroundColor });
modal.remove();
} catch (err) {
console.error('Error executing insertCallout command:', err);
}
});
}
/**
* Adds a double-click listener to the editor for opening the callout modal on existing callouts.
* @param {Object} editor - CKEditor instance.
*/
_addClickListener(editor) {
const view = editor.editing.view;
const viewDocument = view.document;
// Register observer for double-click event
view.addObserver(DoubleClickObserver);
// Listen for double-click event on callout elements
editor.listenTo(viewDocument, 'dblclick', (event, data) => {
const domTarget = data.domTarget;
const calloutElement = domTarget.closest('.callout');
if (calloutElement) {
const modelElement = this._getModelElementFromDom(editor, calloutElement);
if (modelElement) {
const existingClass = modelElement.getAttribute('class');
this._openCalloutModal(editor, { class: existingClass });
}
}
});
}
/**
* Retrieves the model element corresponding to a given DOM element.
* @param {Object} editor - CKEditor instance.
* @param {HTMLElement} domElement - DOM element representing a callout.
* @returns {Object|null} - The model element, or null if not found.
*/
_getModelElementFromDom(editor, domElement) {
const mapper = editor.editing.mapper;
const domConverter = editor.editing.view.domConverter;
const viewElement = domConverter.domToView(domElement);
return mapper.toModelElement(viewElement);
}
}
4. Editing Logic (calloutediting.js)
The calloutediting.js file is the functional backbone of your plugin. It defines how the content behaves internally inside CKEditor 5 using its Model-View-Controller (MVC) inspired architecture:
- Model: Internal data structure representing content.
- Editing View: WYSIWYG interface that users interact with.
- Data View: Clean HTML output for saving.
- Converters: Bridge between the model and views.
Manages the data model schema and the conversion pipeline between the model and the editor view.
import { Plugin } from 'ckeditor5/src/core';
import { toWidget, toWidgetEditable } from 'ckeditor5/src/widget';
import { Widget } from 'ckeditor5/src/widget';
import InsertCalloutCommand from './insertcalloutcommand';
/**
* This plugin handles the editing functionality for Callout elements in CKEditor.
*/
export default class CalloutEditing extends Plugin {
/**
* Specifies the required dependencies for this plugin.
*/
static get requires() {
return [Widget];
}
/**
* Initializes the plugin by defining the schema, converters, and adding the command.
*/
init() {
this._defineSchema();
this._defineConverters();
this.editor.commands.add('insertCallout', new InsertCalloutCommand(this.editor));
}
/**
* Defines the schema for Callout elements and their allowed attributes and content.
*/
_defineSchema() {
const schema = this.editor.model.schema;
/**
* **isObject: true**: It behaves like an independent unit (can't be split across lines).
* **allowWhere: '$block'**: Can be inserted where block-level elements (e.g., paragraphs) are allowed.
* **allowAttributes: ['class']**: Allows setting a CSS class (e.g., callout-blue).
*/
schema.register('callout', {
isObject: true,
allowWhere: '$block',
allowAttributes: ['class'],
});
/**
* **calloutDescription**: A child element inside callout, containing text.
* **isLimit: true**: Prevents nested callouts inside descriptions.
*/
schema.register('calloutDescription', {
isLimit: true,
allowIn: 'callout',
allowContentOf: '$root',
});
schema.register('calloutParagraph', {
allowWhere: '$block',
allowIn: 'calloutDescription',
allowContentOf: '$block',
});
// Prevents nested callout elements inside a calloutDescription.
schema.addChildCheck((context, childDefinition) => {
if (context.endsWith('calloutDescription') && childDefinition.name === 'callout') {
return false;
}
});
}
/**
* Defines the data and editing converters for callout-related elements.
*/
_defineConverters() {
const conversion = this.editor.conversion;
console.log(conversion, "conversion");
// Converter for callout element Upcasting (HTML → Editor Model)
// **<div class="callout">** and maps it to a CKEditor callout element.
conversion.for('upcast').elementToElement({
model: (viewElement, { writer }) => {
const cssClass = viewElement.getAttribute('class') || 'callout-blue';
return writer.createElement('callout', {
class: cssClass,
});
},
view: {
name: 'div',
classes: 'callout'
},
});
// Attribute converter for callout class
conversion.for('upcast').attributeToAttribute({
view: {
name: 'div',
key: 'class',
value: /callout-[a-z]+/
},
model: {
key: 'class',
value: viewElement => {
const classes = viewElement.getAttribute('class').split(' ');
return classes.find(cls => cls.startsWith('callout-')) || 'callout-blue';
}
}
});
conversion.for('downcast').attributeToAttribute({
model: {
name: 'callout',
key: 'class'
},
view: modelAttributeValue => ({
key: 'class',
value: `callout ${modelAttributeValue}`
})
});
// Downcasting (Editor Model → HTML)
// Writes CKEditor callout elements back as <div class="callout callout-blue"> in the final HTML.
conversion.for('dataDowncast').elementToElement({
model: 'callout',
view: (modelElement, { writer }) => {
const cssClass = modelElement.getAttribute('class') || 'callout-blue';
return writer.createContainerElement('div', { class: `callout ${cssClass}` });
}
});
conversion.for('editingDowncast').elementToElement({
model: 'callout',
view: (modelElement, { writer }) => {
const cssClass = modelElement.getAttribute('class') || 'callout-blue';
const div = writer.createContainerElement('div', { class: `callout ${cssClass}` });
return toWidget(div, writer, { label: 'Editable callout' });
}
});
// Converter for calloutDescription element (upcast, downcast, editing)
conversion.for('upcast').elementToElement({
model: 'calloutDescription',
view: {
name: 'div',
classes: 'callout-description'
},
});
conversion.for('dataDowncast').elementToElement({
model: 'calloutDescription',
view: {
name: 'div',
classes: 'callout-description'
},
});
// Wraps the callout in a CKEditor "widget" for better user interaction.
conversion.for('editingDowncast').elementToElement({
model: 'calloutDescription',
view: (modelElement, { writer }) => {
const div = writer.createEditableElement('div', { class: 'callout-description' });
return toWidgetEditable(div, writer, { label: 'Callout description editable area' });
},
});
// Converter for calloutParagraph element (upcast, downcast, editing)
conversion.for('upcast').elementToElement({
model: 'calloutParagraph',
view: 'p',
});
conversion.for('dataDowncast').elementToElement({
model: 'calloutParagraph',
view: 'p',
});
conversion.for('editingDowncast').elementToElement({
model: 'calloutParagraph',
view: (modelElement, { writer }) => {
return writer.createEditableElement('p');
},
});
}
}
5. Insert Command (insertcalloutcommand.js)
The insertcalloutcommand.js file defines the core logic that powers the callout feature in CKEditor 5.
It determines what happens when users insert or modify a callout, ensuring the correct structure and attributes are added to the model.
While triggered by the user interface, this file functions at the logic layer and is essential to making the plugin interactive and user-driven.
import { Command } from 'ckeditor5/src/core';
export default class InsertCalloutCommand extends Command {
execute(options = {}) {
try {
const { model } = this.editor;
model.change((writer) => {
// Get the first position in the selection
const firstPosition = model.document.selection.getFirstPosition();
// Find the closest callout element ancestor
const calloutElement = firstPosition.findAncestor('callout');
if (calloutElement) {
writer.setAttribute('class', options.backgroundColor, calloutElement);
} else {
const callout = createCallout(writer, options.backgroundColor || 'callout-blue');
model.insertContent(callout);
}
});
} catch (err) {
throw err;
}
}
refresh() {
const { model } = this.editor;
const { selection } = model.document;
const allowedIn = model.schema.findAllowedParent(selection.getFirstPosition(), 'callout');
this.isEnabled = true;
}
}
function createCallout(writer, calloutClass) {
const callout = writer.createElement('callout', {
class: calloutClass
});
const calloutDescription = writer.createElement('calloutDescription');
writer.append(calloutDescription, callout);
const paragraph = writer.createElement('paragraph');
writer.appendText('Insert callout content here...', paragraph);
writer.append(paragraph, calloutDescription);
return callout;
}
Drupal Configuration
your_module.libraries.yml
Defines the JavaScript and CSS libraries for the module.
callout:
js:
js/build/callout.js: { preprocess: false, minified: true }
dependencies:
- core/ckeditor5
css:
theme:
css/callout.css: {}
your_module.ckeditor5.yml
Crucial for integrating the plugin with CKEditor 5 in Drupal. It tells Drupal how to load the plugin.
your_module_callout:
ckeditor5:
plugins:
- callout.Callout
drupal:
label: Callout
library: your_module/callout
elements:
- <div class="callout">
- <div class="callout-blue">
- <div class="callout-grey">
- <div class="callout-black">
Webpack Configuration
Webpack is essential for building the CKEditor 5 plugin. Key Elements:
- DllReferencePlugin: Links the plugin with CKEditorʼs core DLL bundle.
- Module Rules: Handles SVG icons and CSS processing.
- Output Configuration: Builds the module in UMD format for Drupal.
const path = require('path');
const fs = require('fs');
const webpack = require('webpack');
const { styles, builds } = require('@ckeditor/ckeditor5-dev-utils');
const TerserPlugin = require('terser-webpack-plugin');
function getDirectories(srcpath) {
return fs
.readdirSync(srcpath)
.filter((item) => fs.statSync(path.join(srcpath, item)).isDirectory());
}
module.exports = [];
// Loop through every subdirectory in src, each a different plugin, and build
// each one in ./build.
getDirectories('./js/ckeditor5_plugins').forEach((dir) => {
const bc = {
mode: 'production',
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
format: {
comments: false,
},
},
test: /\.js(\?.*)?$/i,
extractComments: false,
}),
],
moduleIds: 'named',
},
entry: {
path: path.resolve(
__dirname,
'js/ckeditor5_plugins',
dir,
'src/index.js',
),
},
output: {
path: path.resolve(__dirname, './js/build'),
filename: `${dir}.js`,
library: ['CKEditor5', dir],
libraryTarget: 'umd',
libraryExport: 'default',
},
plugins: [
// It is possible to require the ckeditor5-dll.manifest.json used in
// core/node_modules rather than having to install CKEditor 5 here.
// However, that requires knowing the location of that file relative to
// where your module code is located.
new webpack.DllReferencePlugin({
manifest: require('./node_modules/ckeditor5/build/ckeditor5-dll.manifest.json'),
scope: 'ckeditor5/src',
name: 'CKEditor5.dll',
}),
],
module: {
rules: [
{ test: /\.svg$/, use: 'raw-loader' },
{
test: /\.css$/,
use: ['style-loader', 'css-loader'], // Process and inject CSS files
},
],
},
};
module.exports.push(bc);
});
Package.json
{
"name": "callout",
"main": "index.js",
"scripts": {
"dll:build": "webpack --config webpack.config.js"
},
"devDependencies": {
"@ckeditor/ckeditor5-dev-utils": "43.0.1",
"ckeditor5": "44.0.0",
"css-loader": "^7.1.2",
"prettier": "^2.8.8",
"raw-loader": "^4.0.2",
"style-loader": "^4.0.0",
"terser-webpack-plugin": "^3.0.2",
"webpack": "^5.88.2",
"webpack-cli": "^4.4.0"
},
"dependencies": {
"v18": "^1.0.2"
}
}
CKEditor 5: Testing Your Plugin
-
Run npm install to install dependencies.
-
Build the plugin using npm run build.
-
Clear Drupalʼs cache using drush cr.
-
Configure a text format to use your plugin in Drupalʼs admin interface.
-
Test the plugin in a content editor.
CKEditor 5: Visual Output
Here's how the plugin appears in CKEditor 5:
-
Toolbar Button:
-
Modal Dialog
3. Rendered Callout:
CKEditor 5: Common Issues and Solutions
- Plugin Not Appearing: Verify the plugin name in ckeditor5.yml matches exactly.
- Styling Issues: Ensure CSS is properly loaded through libraries.yml and check the browser console for errors.
- Conversion Problems: Double-check schema definitions and converter mappings.
Also Check Out:
1. Drupal SDC: Advantages of Single Directory Components
2. Drupal Debug: Effective Techniques And Tools
3. Drupal SDC v/s Storybook: What’s The Difference?
4. Starshot: Drupal’s New CMS Initiative
Key Takeaways:
1. The Callout plugin was created to enhance CKEditor 5 in Drupal 11, enabling editors to add styled content boxes in the WYSIWYG editor.
2. It comprises essential elements such as a toolbar button, an easy-to-use popup for customization, and functionality for insertion and rendering.
3. The plugin adheres to CKEditor’s Model-View architecture to maintain a clean structure, editing, and output.
4. A specific command manages the interactive actions when users insert or modify callouts.
5. Perfectly integrated with Drupal 11, it improves the editorial experience without requiring custom HTML.
Abonnieren
Verwandte Blogs
Zurück von der DrupalCon Atlanta 2025: Ein Meilenstein für OpenSense Labs

„Fit. Schnell. Für die Ewigkeit gebaut.“ Das war nicht nur ein Slogan, sondern die Denkweise, mit der wir zur DrupalCon…
Erklärbare KI-Tools: SHAPs Stärke in der KI

Wissen Sie, was erklärbare KI-Tools sind? Erklärbare KI-Tools sind Programme, die zeigen, wie eine KI ihre Entscheidungen…
KI-Chatbots: Präzision und Persönlichkeit in Perfektion

In der Welt der künstlichen Intelligenz ist die Entwicklung eines KI-Chatbots, der nicht nur akkurate Informationen liefert…