JavaScript
Drupal
min read
December 19, 2023

Creating a custom CKEditor 5 plugin for Svelte

Creating a custom CKEditor 5 plugin for Svelte
Table of contents

Svelte is an open-source JavaScript framework that helps to create interactive web pages. The Svelte plugin enables users to embed its components seamlessly into their content.

In this blog, we will consider how to create a custom plugin to integrate Svelte components into CKEditor 5, a powerful and extensible rich text editor that allows developers to tailor it to their specific needs.

To begin with, check out the directory structure that houses the essential components of the Svelte plugin.

Directory structure

Essential components of the Svelte plugin

1. package.json

Download the necessary CKEditor5 node modules for compiling custom plugins.


{

"name": "drupal-ckeditor5",

"version": "1.0.0",

"description": "Drupal CKEditor 5 integration",

"author": "",

"license": "GPL-2.0-or-later",

"scripts": {

"watch": "webpack --mode development --watch",

"build": "webpack"

},

"devDependencies": {

"@ckeditor/ckeditor5-dev-utils": "^30.0.0",

"ckeditor5": "~34.1.0",

"raw-loader": "^4.0.2",

"terser-webpack-plugin": "^5.2.0",

"webpack": "^5.51.1",

"webpack-cli": "^4.4.0"

},

"dependencies": {

"node": "^21.2.0"

}

}

2. webpack.config.js

The Webpack.config.js file is a script designed to automate the build process for CKEditor 5 plugins located in the js/ckeditor5_plugins directory. It employs the webpack module bundler to produce plugin files that are ready for production.

This configuration script is structured to bundle CKEditor 5 plugins individually, leveraging the capabilities of the getDirectories function. This function dynamically retrieves all subdirectories within the specified path (./js/ckeditor5_plugins). For each identified directory, a distinct Webpack configuration is generated and seamlessly integrated into the module.exports array.


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("ckeditor5/build/ckeditor5-dll.manifest.json"), // eslint-disable-line global-require, import/no-unresolved

scope: "ckeditor5/src",

name: "CKEditor5.dll",

}),

],

module: {

rules: [{ test: /\.svg$/, use: "raw-loader" }],

},

};

module.exports.push(bc);

});

3. index.js

This file exports an object as the default export of the ‘index.js’ file. The object possesses a property named Svelte, and its value is the imported Svelte plugin. This is how CKEditor 5 will identify and uncover the Svelte plugin during the execution of the build process. The exported object functions as a map of available plugins that CKEditor 5 can utilize.


/**
* @file The build process always expects an index.js file. Anything exported
* here will be recognized by CKEditor 5 as an available plugin. Multiple
* plugins can be exported in this one file.
*
* I.e. this file's purpose is to make plugin(s) discoverable.
*/

import Svelte from './svelte';

export default {
 Svelte,
};

4. svelte.js

The Svelte class serves as the glue that integrates the editing and UI components of the plugin. It extends CKEditor's Plugin class and specifies its dependencies.


/**
* @file This is what CKEditor refers to as a master (glue) plugin. Its role is
* just to load the "editing" and "UI" components of this Plugin. Those
* components could be included in this file, but
*
* I.e, this file's purpose is to integrate all the separate parts of the plugin
* before it's made discoverable via index.js.
*/

// The contents of SvelteUI and Svelte editing could be included in this
// file, but it is recommended to separate these concerns in different files.
import SvelteEditing from './svelteediting';
import SvelteUI from './svelteui';
import { Plugin } from 'ckeditor5/src/core';

export default class Svelte extends Plugin {
 // Note that SvelteEditing and SvelteUI also extend `Plugin`, but these
 // are not seen as individual plugins by CKEditor 5. CKEditor 5 will only
 // discover the plugins explicitly exported in index.js.
 static get requires() {
   return [SvelteEditing, SvelteUI];
 }
}

The static get requires() method in this context specifies that the Svelte master plugin requires both SvelteEditing and SvelteUI. Although these components extend the Plugin class, CKEditor 5 will not consider them as individual plugins unless explicitly exported in index.js. This emphasizes the importance of explicit export to ensure that CKEditor 5 recognizes these components as plugins.

5. SvelteEditing - Handling the model

The SvelteEditing class defines the data model for the Svelte element and the converters for handling its conversion to and from DOM markup.


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

/**
* 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.
*
* This file has the logic for defining the Svelte model, and for how it is
* converted to standard DOM markup.
*/
export default class SvelteEditing extends Plugin {
 static get requires() {
   return [Widget];
 }

 init() {
   this._defineSchema();
   this._defineConverters();
   this.editor.commands.add(
     'insertSvelte',
     new InsertSvelteCommand(this.editor),
   );
 }
 _defineSchema() {
   // Schemas are registered via the central `editor` object.
   const schema = this.editor.model.schema;

  
   schema.register('Svelte', {
     // Behaves like a self-contained object (e.g. an image).
     isObject: true,
     // Allow in places where other blocks are allowed (e.g. directly in the root).
     allowWhere: '$block',
     allowContentOf: '$block',
     allowAttributes: ['src'],
   });
 }

 /**
  * Converters determine how CKEditor 5 models are converted into markup and
  * vice-versa.
  */
 _defineConverters() {
   // Converters are registered via the central editor object.
   const { conversion } = this.editor;

   // Upcast Converters: determine how existing HTML is interpreted by the
   // editor. These trigger when an editor instance loads.
   //

   conversion.for('upcast').elementToElement({
     model: 'Svelte',
     view: {
       name: 'iframe',
       classes: '-svelte-embed',
     },
   });
   conversion.for('dataDowncast').elementToElement({
     model: 'Svelte',
     view: (modelElement, { writer: viewWriter }) => {
       const src = modelElement.getAttribute('src') || modelElement.getAttribute('htmlAttributes')['attributes']['src'] || 'https://www.google.com';
       const iframe = viewWriter.createEditableElement('iframe', {
         class: '-svelte-embed',
         src: src,
       });
  
       return iframe;
     },
   });
   conversion.for('editingDowncast').elementToElement({
     model: 'Svelte',
     view: (modelElement, { writer: viewWriter }) => {
       const div = viewWriter.createEditableElement('iframe', {
         class: '-svelte-embed',
         src: modelElement.getAttribute('src') || modelElement.getAttribute('htmlAttributes')['attributes']['src'] || 'https://www.google.com',
       });
       return toWidgetEditable(div, viewWriter);
     },
   });
 }
}

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

The init method initializes the plugin, calling two helper methods: _defineSchema and _defineConverters, to set up the schema and converters for the Svelte model. Additionally, it adds the 'insertSvelte' command to the editor, associating it with the InsertSvelteCommand class.


_defineSchema() {
 // Schemas are registered via the central `editor` object.
 const schema = this.editor.model.schema;

 schema.register('Svelte', {
 // Behaves like a self-contained object (e.g. an image).
 isObject: true,
 // Allow in places where other blocks are allowed (e.g. directly in the root).
 allowWhere: '$block',
 allowContentOf: '$block',
 allowAttributes: ['src'], 
 });
 }

The _defineSchema method sets up the schema for the Svelte model, registering the model type and specifying that it behaves like a self-contained object (isObject: true). It also defines where the model is allowed (allowWhere), where its content is allowed (allowContentOf), and the allowed attributes (allowAttributes), in this case, the 'src' attribute.


/**
 * Converters determine how CKEditor 5 models are converted into markup and
 * vice-versa.
 */
 _defineConverters() {
 // Converters are registered via the central editor object.
 const { conversion } = this.editor;

The _defineConverters method sets up the converters for the Svelte model, using the CKEditor conversion object to register converters for upcasting and downcasting.


// Upcast Converters: determine how existing HTML is interpreted by the
 // editor. These trigger when an editor instance loads.
 //
 conversion.for('upcast').elementToElement({
 model: 'Svelte',
 view: {
 name: 'iframe',
 classes: '-svelte-embed',
 },
 });

This section registers an upcast converter, determining how existing HTML is interpreted by the editor when it loads. It specifies that when encountering an HTML iframe element with the class '-svelte-embed,' it should be upcasted to a Svelte model element.


conversion.for('dataDowncast').elementToElement({
 model: 'Svelte',
 view: (modelElement, { writer: viewWriter }) => {
 const src = modelElement.getAttribute('src') || modelElement.getAttribute('htmlAttributes')['attributes']['src'] || 'https://www.google.com';
 const iframe = viewWriter.createEditableElement('iframe', {
 class: '-svelte-embed',
 src: src,
 });
 
 return iframe;
 },
 });

This section registers a downcast converter for data, defining how the Svelte model should be converted to DOM markup. If the model has a 'src' attribute, it uses that value; otherwise, it defaults to 'https://www.google.com'. It creates an iframe element with the specified class and source, which is then returned.


conversion.for('editingDowncast').elementToElement({
 model: 'Svelte',
 view: (modelElement, { writer: viewWriter }) => {
 const div = viewWriter.createEditableElement('iframe', {
 class: '-svelte-embed',
 src: modelElement.getAttribute('src') || modelElement.getAttribute('htmlAttributes')['attributes']['src'] || 'https://www.google.com',
 });
 return toWidgetEditable(div, viewWriter);
 },
 });
 }
}

This section registers an editing downcast converter, defining how the Svelte model should be converted to editable DOM markup. It creates an iframe element similar to the data downcast converter and then uses toWidgetEditable to wrap it as a widget in the editable view.

6. SvelteUI - User interface integration

The SvelteUI class registers the toolbar button for Svelte embeds, complete with an icon and dropdown functionality. It listens for user interactions and triggers the insertion of Svelte components into the editor.


/**
* @file registers the Svelte toolbar button and binds functionality to it.
*/

import { Plugin } from 'ckeditor5/src/core';
import { Collection } from 'ckeditor5/src/utils';
import icon from '../../../../icons/svelte.svg';
import { createDropdown, addListToDropdown , Model } from 'ckeditor5/src/ui';

export default class SvelteUI extends Plugin {
 init() {
   const editor = this.editor;

   editor.ui.componentFactory.add('Svelte', (locale) => {
     const dropdownView = createDropdown(locale);
     dropdownView.buttonView.set({
         icon,
     });

     const items = new Collection();
     const directories = editor.config.get('svelte_embed');

     for (const key in directories) {
       items.add({
         type: 'button',
         model: new Model({
             id: key,
             icon,
             src: directories[key],
             withText: true,
             label: key,
         }),
       });
     }

     addListToDropdown(dropdownView, items);

     // Inside SvelteUI class
     this.listenTo(dropdownView, 'execute', (eventInfo) => {
       let { src, label } = eventInfo.source;
       src += label + '/dist/index.html';
       editor.execute('insertSvelte', src);
     });
     return dropdownView;
 });
 }
}

editor.ui.componentFactory.add('Svelte', (locale) => {
 const dropdownView = createDropdown(locale);
 dropdownView.buttonView.set({
 icon,
 });

This section adds the Svelte component to the CKEditor UI, creating a dropdown view using the createDropdown function. It sets its button view with an icon and associates it with the specified locale.


const items = new Collection();
 const directories = editor.config.get('svelte_embed');

 for (const key in directories) {
 items.add({
 type: 'button',
 model: new Model({
 id: key,
 icon,
 src: directories[key],
 withText: true,
 label: key,
 }),
 });
 }

Here, a collection of items is created, representing the Svelte components to be displayed in the dropdown. It iterates through the directories obtained from the editor's configuration and adds each Svelte component as a button to the collection.


addListToDropdown(dropdownView, items);

This line adds the list of Svelte components to the previously created dropdown view using the addListToDropdown function.


// Inside SvelteUI class
 this.listenTo(dropdownView, 'execute', (eventInfo) => {
 let { src, label } = eventInfo.source;
 src += label + '/dist/index.html';
 editor.execute('insertSvelte', src);
 });

This section sets up an event listener using 'this.listenTo' to listen for the 'execute' event on the dropdown view. When an item is selected, it extracts the src and label from the selected item, modifies the src path, and then executes the 'insertSvelte' command on the editor with the modified src.


return dropdownView;
 });
 }

Finally, the init method returns the configured dropdown view, completing the initialization of the SvelteUI plugin.

7. insertsveltecommand

The heart of our plugin lies in the InsertSvelteCommand class. This command executes when the toolbar button for embedding Svelte components is pressed, utilizing CKEditor's model to insert the desired content into the editor.


/**
* @file defines InsertSveltecommand, which is executed when the Svelte
* toolbar button is pressed.
*/

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

export default class InsertSveltecommand extends Command {
 execute(src) {
   const { model } = this.editor;

   model.change((writer) => {
     model.insertContent(createSvelte(writer, src));
   });
 }
}

function createSvelte(writer, src) {
 const SVelte = writer.createElement('Svelte', {src: src});
 return SVelte;
}

export default class InsertSveltecommand extends Command {

This line declares the InsertSvelteCommand class, extending the CKEditor 5 Command class. This class encapsulates the logic executed when the toolbar button is pressed.


execute(src) {
    const { model } = this.editor;

    model.change((writer) => {
      model.insertContent(createSvelte(writer, src));
    });
  }

The execute method is part of the Command class and contains the logic to be executed when the command is invoked. It takes a src parameter, representing the source content to be inserted.

  • const { model } = this.editor;: Destructures the model property from the editor object. The editor object represents the CKEditor instance.
  • model.change((writer) => { ... });: Initiates a model change, ensuring that the changes are handled as a single transaction. The writer parameter provides methods for modifying the model.
  • model.insertContent(createSvelte(writer, src));: Inserts content into the model, calling the createSvelte function to generate the content to be inserted.

function createSvelte(writer, src) {
 const SVelte = writer.createElement('Svelte', {src: src});
 return SVelte;
}

The createSvelte function generates a CKEditor 5 model element representing the Svelte component. It uses the writer.createElement method to create an element with the specified name ('Svelte') and attributes (in this case, the src attribute). The created element is then returned.

The final look

After enabling the module in Drupal, you should see the screen below:

In this requirement, we are passing the folders inside a specific directory from Drupal to the CKEditor5 plugin. When you access any content-type page with full HTML, you will start seeing this:

On clicking the "View Source" option, the type casting of CKEditor5 will display the iframe tag with the src attribute set to the folder's index.html.

Conclusion

Creating a custom CKEditor 5 plugin to embed Svelte components adds a new dimension to content creation. The Svelte plugin seamlessly integrates into the editor, providing users with a streamlined experience for incorporating dynamic Svelte content.

If you want to know how to integrate CKEditor 5 in Drupal 9, you can find all the important steps here.

Happy editing with Svelte !

Written by
Artwork by
No art workers.
We'd love to talk about your business objectives