Created | ![]() |
Favourites | Opened | Upvotes | Comments |
31. Jul 2019 | 4 | 0 | 309 | 0 | 6 |
Updated Jul 2021. This is the ultimate tutorial on how to build a CKEditor 5 plugin.
After you have completed this tutorial, you will be able to build advanced plugins for CKEditor 5 creating professional plugins and easily roll your own ad-hoc plugins. If you have already created some plugins and maybe been through the CKEditor 5 official plugin guides, this tutorial will take your plugin developing skills to the next level.
(Note that if you are interested only in the documentation how to add the CKEditor 5 bookmark plugin to your own CKEditor 5 build, see ckeditor5-bookmark documentation).
Index : (this index is actually using the very same Bookmark plugin, we are about to create)
Appendixes :
When building a CKEditor 5 plugin, we always need a dummy CKEditor 5 to test from, so we need to start this tutorial with building such a dummy CKEditor 5, which we will do fast without much explanation (refer to Building CKEditor 5 from source for an indept explanation of how to build your own CKEditor 5).
Since we are building both a plugin and a dummy CKEditor 5, we need to construct an appropriate folder structure to organize all of the code - I use the following template based folder structure for CKEditor 5 plugin development : (only create the 6 folders in bold, the 5 non-bold folders will be automatically created later)
CKEditor 5 is build from NPM packages, therefore we need to be sure we have the Node.js & NPM installed and in addtion we will use Webpack for the build process :
Install the above 3 environment tools :
With the main folder structure in place, we can start creating the dummy CKEditor 5 :
{
"dependencies": {
"@ckeditor/ckeditor5-editor-classic": "x",
"@ckeditor/ckeditor5-essentials": "x",
"@ckeditor/ckeditor5-heading": "x",
"@ckeditor/ckeditor5-list": "x",
"@ckeditor/ckeditor5-link": "x",
"@ckeditor/ckeditor5-basic-styles": "x",
"@ckeditor/ckeditor5-theme-lark": "x",
"@ckeditor/ckeditor5-inspector": "2.2.0"
},
"devDependencies": {
"@ckeditor/ckeditor5-dev-utils": "x",
"postcss-loader": "3.0.0",
"raw-loader": "4.0.1",
"style-loader": "1.2.1",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12"
}
}
import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Link from '@ckeditor/ckeditor5-link/src/link';
import List from '@ckeditor/ckeditor5-list/src/list';
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
export default class ClassicEditor extends ClassicEditorBase { }
ClassicEditor
.create(document.querySelector('#editor'), {
plugins: [Heading, Essentials, Bold, Link, List],
toolbar: ['heading', 'bold', 'link', 'bulletedList', 'numberedList'],
})
.then(editor => {
CKEditorInspector.attach(editor);
window.editor = editor;
})
.catch(error => {
console.error(error.stack);
});
'use strict';
const path = require('path');
const { styles } = require('@ckeditor/ckeditor5-dev-utils');
module.exports = {
entry: './app.js',
output: {
library: 'CustomEditor',
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
libraryTarget: 'var',
libraryExport: 'default'
},
module: {
rules: [
{
test: /\.svg$/,
use: ['raw-loader']
},
{
test: /\.css$/,
use: [
{
loader: 'style-loader',
options: {
injectType: 'singletonStyleTag',
attributes: {
'data-cke': true
}
}
},
{
loader: 'postcss-loader',
options: styles.getPostCssConfig({
themeImporter: {
themePath: require.resolve('@ckeditor/ckeditor5-theme-lark')
},
minify: true
})
},
]
}
]
},
mode: 'development',
// Useful for debugging.
devtool: 'source-map',
// By default webpack logs warnings if the bundle is bigger than 200kb, however we know our bundle will bigger and we don't want to see this warning on every build.
performance: { hints: false }
};
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CKEditor 5 - Creating a Bookmark Plugin</title>
</head>
<body>
<div id="editor">
<p>Editor content goes here.
</div>
<script src="dist/bundle.js"></script>
</body>
</html>
Ok, the dummy CKEditor 5 have been build, we can now start on our real quest - building a plugin.
First we need to add a few more NPM packages for plugin support :
Install the NPM packages specifically for the bookmark plugin :
{
"dependencies": {
"@ckeditor/ckeditor5-editor-classic": "x",
"@ckeditor/ckeditor5-essentials": "x",
"@ckeditor/ckeditor5-heading": "x",
"@ckeditor/ckeditor5-link": "x",
"@ckeditor/ckeditor5-list": "x",
"@ckeditor/ckeditor5-basic-styles": "x",
"@ckeditor/ckeditor5-theme-lark": "x",
"@ckeditor/ckeditor5-core": "x",
"@ckeditor/ckeditor5-widget": "x",
"@ckeditor/ckeditor5-ui": "x",
"@ckeditor/ckeditor5-utils": "x",
"@ckeditor/ckeditor5-inspector": "2.2.0"
},
"devDependencies": {
"@ckeditor/ckeditor5-dev-utils": "x",
"postcss-loader": "3.0.0",
"raw-loader": "4.0.1",
"style-loader": "1.2.1",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12"
}
}
The bookmark plugin tutorial is more complicated than the CKEditor 5 official plugin tutorials in 2 ways :
While we could have the whole plugin source code in one file only (including the SVG icons), it is better to organize the code into files of separate concerns - that will allow for more easy development as well as maintenance.
In addition since we will have multiple code files for the plugin, we don't want to mix them up with the CKEditor 5 files like package.json, app.js etc. so we want to have all the plugin code files in separate folders, which by convention we will call src for code files and theme for css & images.
Update the plugin folder & file structure within the CKBookmark\BookmarkDev organizational folder : (as usual purple is folder and green is file, while if in bold it is manually created and if not bold then it is automatically created by a tool)
The fastes way to create all the new folders & files is from the command prompt :
shell> mkdir foldername : eg. mkdir theme.
shell> type nul > filename : eg. type nul > bookmark.css.
With the final folder and file structure for CKBookmark\BookmarDev created, it is time to start coding and the best place to start is the entry point : bookmark.js.
To understand how a plugin plugs into the CKEditor 5 framework, let's start creating a minimal plugin frame or a Hello World plugin.
// CKBookmark\BookmarkDev\bookmark.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import BookmarkEditing from './bookmarkediting';
import BookmarkUI from './bookmarkui';
export default class Bookmark extends Plugin {
static get requires() {
return [BookmarkEditing, BookmarkUI]; // array of plugins required by this plugin
}
static get pluginName() { // makes the plugin availabe in the PluginCollection.get('name') (otherwise the plugin is only availabe using it's constructor)
return 'Bookmark';
}
init() {
console.log("Bookmark#init");
}
}
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
export default class BookmarkEditing extends Plugin {
init() {
console.log("BookmarkEditing#init");
}
}
// CKBookmark\BookmarkDev\src\bookmarkui.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
export default class BookmarkUI extends Plugin {
init() {
console.log("BookmarkUI#init");
}
}
// CKBookmark\BookmarkDev\app.js
import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Link from '@ckeditor/ckeditor5-link/src/link';
import List from '@ckeditor/ckeditor5-list/src/list';
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
import Bookmark from './src/bookmark'; // ADD THIS (import the Bookmark class from the bookmark.js file)
export default class ClassicEditor extends ClassicEditorBase { }
ClassicEditor
.create(document.querySelector('#editor'), {
plugins: [
Heading,
Essentials,
Bold,
Link,
List,
Bookmark // ADD THIS (add the Bookmark plugin to the CKEditor 5 plugins collection)
],
toolbar: ['heading', 'bold', 'link', 'bulletedList', 'numberedList'],
})
.then(editor => {
CKEditorInspector.attach(editor);
window.editor = editor;
})
.catch(error => {
console.error(error.stack);
});
Success, We have now created a Hello World plugin and added that plugin (Bookmark and it's 2 dependencies BookmarkEditing & BookmarkUI) to the CKEditor 5 plugins collection.
So far our plugin does not have any toolbar button nor is it able to do anything. The main way of the CKEditor 5 framework to make a plugin do something is via a Command - the CKEditor 5 framework supplies a Command class that the plugin developer can extend to do custom stuff.
Any custom object of type Command (extending the Command class) can (and should) be added to the CKEditor 5 commands collection, A custom object of type Command can then handily be executed like this : editor.execute('commandName'); which internally will get a reference to the custom Command object and call its execute() function - that's smart (another interface method on the Command class is refresh(), which we will cover later).
Create the Hello World Command
// CKBookmark\BookmarkDev\src\bookmarkcommand.js
import Command from '@ckeditor/ckeditor5-core/src/command';
export default class InsertBookmark extends Command {
execute() {
console.log("InsertBookmark#execute");
}
}
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import InsertBookmark from './bookmarkcommand'; // ADD THIS
export default class BookmarkEditing extends Plugin {
init() {
console.log("BookmarkEditing#init");
this.editor.commands.add('insertBookmark', new InsertBookmark(this.editor)); // ADD THIS
}
}
Success : we have extended the CKEditor 5 framework Command class to a custom class called InsertBookmark and added custom code (writing to the console) to the execute() (Command interface) function AND we have added InsertBookmark to the CKEditor 5 commands collection.
The only thing missing in our Hello World Bookmark plugin is a toolbar button and attach the toolbar buttons click even to the InsertBookmark.execute() function - so that then clicking the toolbar button we will execute the InsertBookmark Command.
CKEditor 5 keeps toolbar buttons not as ready objects, but as functions that can create a toolbar button object. Toolbar button functions must be registered with CKEditor 5 ComponentFactory under a name identifying the function like this :
editor.ui.componentFactory.add('buttonName', buttonFunction);
When a toolbar button is internally created by the CKEditor 5 framework, a locale is passed to the function that creates the button, so we can write the whole thing like this :
editor.ui.componentFactory.add('buttonName', locale => {
... code to create the button ...
});
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M472.5 0c-7 0-14.3 1.5-21.2 4.6-50.5 22.7-87.8 30.3-119.1 30.3C266.1 34.9 227.7.4 151.4.4c-28.4 0-62.2 4.9-104.5 18C44.3 7.9 35.3 0 24 0 10.7 0 0 10.7 0 24v476c0 6.6 5.4 12 12 12h24c6.6 0 12-5.4 12-12V398.1c37.3-11.8 69.6-16.5 98.5-16.5 81.2 0 137.8 34.4 219.1 34.4 35.3 0 75.1-6.5 123.7-25 14-5.4 22.8-17.9 22.8-31.2V33.4C512 13 493.4 0 472.5 0zM464 349.1c-35.3 12.7-67.6 18.9-98.5 18.9-75.5 0-128.5-34.4-219.1-34.4-31.9 0-64.5 4.7-98.5 14.2V68.5C87.7 55 121.7 48.4 151.4 48.4c66.3 0 105.2 34.5 180.8 34.5 40.3 0 82.3-10 131.8-31.5v297.7z"/></svg>
// CKBookmark\BookmarkDev\src\bookmarkui.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; // ADD THIS
import bookmarkIcon from '../theme/icons/bookmark.svg'; // ADD THIS
export default class BookmarkUI extends Plugin {
init() {
console.log("BookmarkUI#init");
const editor = this.editor; // ADD THIS
const t = editor.t; // ADD THIS
// ADD THIS
editor.ui.componentFactory.add('bookmark', locale => { // we register this toolbar creating function under the name of 'bookmark'
const btn = new ButtonView(locale);
btn.set({
label: t('Bookmark'), // translate 'Bookmark' to passed locale
withText: false, // don't display the label ('Bookmark') on the button face
tooltip: true, // show a tooltip with the label ('Bookmark') then the mouse hover the button
icon: bookmarkIcon
});
// Execute the command when the button is clicked (executed)
this.listenTo(btn, 'execute', () => { // a btn have an execute event (it should probably have been called click event)
editor.execute('insertBookmark'); // here execute is an appropriate name
});
return btn;
});
}
}
// CKBookmark\BookmarkDev\app.js
import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Link from '@ckeditor/ckeditor5-link/src/link';
import List from '@ckeditor/ckeditor5-list/src/list';
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
import Bookmark from './src/bookmark';
export default class ClassicEditor extends ClassicEditorBase { }
ClassicEditor
.create(document.querySelector('#editor'), {
plugins: [Heading, Essentials, Bold, Link, List, Bookmark],
toolbar: [
'heading',
'bold',
'link',
'bulletedList',
'numberedList',
'bookmark' // ADD THIS
],
})
.then(editor => {
CKEditorInspector.attach(editor);
window.editor = editor;
})
.catch(error => {
console.error(error.stack);
});
Success : we have now created a CKEditor Hello World plugin, which we will use as a frame for developing the full featured Bookmark plugin.
The Bookmark plugin like most other plugins must create structural content of some sort that will eventually convert to HTML, eg. the official Table plugin will create content that will eventually convert to an HTML table.
Unlike CKEditor 4 and practically all other Rich Text Editors, CKEditor 5 does NOT work directly with HTML structures and neither does CKEditor 5 plugins. Instead CKEditor 5 implements 3 different representations of content :
So a plugin will work on the Model content and the Model content only, inserting and removing content from the Model (and then use converters to create the View & Data content from the Model content - see later) . However, before a plugin can insert any element into the model schema, the plugin MUST register that element with the CKEditor 5 model schema - registering an element with the model schema makes the plugin responsible for that particular element and it also allows us to tell the CKEditor 5 what kind of plugin it is (eg. where it can be positioned, if it can contain other elements and so on) .
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import InsertBookmark from './bookmarkcommand';
export default class BookmarkEditing extends Plugin {
init() {
console.log("BookmarkEditing#init");
this._defineSchema(); // ADD THIS
this.editor.commands.add('insertBookmark', new InsertBookmark(this.editor));
}
// ADD THIS
_defineSchema() {
const schema = this.editor.model.schema;
schema.register('bookmark', {
allowWhere: '$text', // 'bookmark' element is allowed wherever text is allowed.
isInline: true, // 'bookmark' element will act as an inline node.
isObject: true, // 'bookmark' is self-contained and should be treated as a whole (and also since isObject will set isLimt to true, bookmark cannot be split by the caret).
allowAttributes: ['name', 'class'] // 'bookmark' element is allowed a 'name' and a 'class' attribute.
});
}
}
Schema relevant documentation :
After we have registered the 'bookmark' element, the Bookmark plugin is now allowed to insert a 'bookmark' element into the model content, however we still need to code the actual insertion.
// CKBookmark\BookmarkDev\src\bookmarkcommand.js
import Command from '@ckeditor/ckeditor5-core/src/command';
export default class InsertBookmark extends Command {
// ADD THIS
constructor(editor) {
super(editor); // necessary for using the this-keyword inside a derived class.
}
execute(bookmarkName) { // bookmarkName will be passed to this on (data)upcast (see later).
console.log("InsertBookmark#execute");
const editor = this.editor; // ADD THIS (here we use that this-keyword, we added support for by calling the base class constructor : super(editor)).
// ADD THIS
editor.model.change(modelWriter => {
bookmarkName = bookmarkName || ''; // in case of (data)upcast (see later) bookmarkName will have a value, otherwise (then clicking the Bookmark toolbar button) it will be undefined - here we secure that undefined is changed to empty string.
const bookmark = modelWriter.createElement('bookmark', { name: bookmarkName });
editor.model.insertContent(bookmark);
modelWriter.setSelection(bookmark, 'on'); // set the selection on the just inserted Bookmark element (CKEditor 5 will automatically apply this selection to the View).
});
}
}
Success. We have registered a model element, 'bookmark', and inserted that element into the model content.
However, it is not enough to just manipulate the model content, we must also provide code to specify how to translate (convert) the Model content to the CKEditor 5 View content (so we can display it in the CKEditor 5 editing area) and to the Data content (the actual HTML result the user want to create) as well as how to translate any existing Data content to Model content - 3 different conversions.
So when a user click the Bookmark toolbar button and fill in a bookmark-name (we will build the UI for filling in a bookmark-name later), eventually the HTML result should be <a name="bookmark-name"></a> - that result is the data representation.
Here are the 3 content representations for the Bookmark plugin :
For our Bookmark plugin the 3 different content representations are all very similar, however for many plugins they are not and it is IMPORTANT to understand that they are conceptually different.
CKEditor 5 uses Converters to convert between the different content representations or schemas (there are no conversion between View & Data) : (official description)
These schema conversions (how you come from one representation to another representation of the same data) needs to be coded. CKEditor 5 framework have several builtin function in the conversion api to help coding this conversion, the most important being elementToElement().
While in very simple cases you can use the same elementToElement() function for all the 3 schema conversions, this is very limited and rarely useful. Instead we typically code conversion logic for a specific conversion using the conversion api for() function like this :
const conversion = this.editor.conversion;
conversion.for('dataDowncast').elementToElement({
model: 'bookmark',
view: 'a' // note that the property name 'view' is unfortunately used for both the View and Data schemas (since there is never a conversion between View & Data, this is not really a problem, however it is confusing).
});
In the above code snippet we have used the for() function to specify that this elementToElement() conversion is for dataDowncast (Model -> Data). To the elementToElement() function we have passed a conversion configuration object. specifying that then asking for the data representation of the Model, any Model elements of type 'bookmark' should be converted to an 'a' element in the Data.
A conversion configuration object have 2 properties : one for the source schema and one for the target schema.
Conversion configuration object properties :
Source property | Target property | |
Downcasting (editingDowncast & dataDowncast) | model | view |
Upcasting (upcast) | view | model |
So the conversion configuration object passed to the elementToElement() function must specify 2 properties, model and view, which can be set in 3 different ways :
Let's see code examples : (ckeditor 5 schema conversion examples)
const conversion = this.editor.conversion;
conversion.for('editingDowncast').elementToElement({
model: 'bookmark', // literal match
view: 'span' // literal element (we cannot create attributes)
});
const conversion = this.editor.conversion;
conversion.for('editingDowncast').elementToElement({
model: 'bookmark', // literal match
view: { // object specification (we cannot create custom attributes)
name: 'span',
classes: [ 'ck-bookmark' ]
}
});
const conversion = this.editor.conversion;
conversion.for('editingDowncast').elementToElement({
model: 'bookmark', // literal match
view: (modelItem, { writer: viewWriter }) => { // function - we have full control and access to the modelItem
const name = modelItem.getAttribute('name'); // using the passed modelItem to retrieve the value of the name attribute from the <bookmark name="bookmark-name"></bookmark> model element)
const elmBookmark = viewWriter.createContainerElement('span', { name, class: 'ck-bookmark' }); // using the passed viewWriter to create a View element called 'span' with the attributes name and class
viewWriter.setCustomProperty('bookmarkName', true, aBookmark); // makes it possible to easily identify this span as a bookmark if parsing the View elements
return elmBookmark; // CKEditor 5 will convert any <bookmark name="..."></bookmark> Model element to whatever View element is returned here and insert it into the View document.
}
});
const conversion = this.editor.conversion;
conversion.for('upcast').elementToElement({
view: { // object match (indeed the view attribute is used even if the source is the Data content and have nothing to do with the View content)
name: 'a',
attributes {
name: true // the a-element MUST have a name-attribute (we don't want to match a hyperlink a-element with a href-attribute)
}
},
model: (viewlItem, { writer: modelWriter }) => {
const name = viewItem.getAttribute('name'); // using the passed viewItem to retrieve the value of the name attribute from the <a name="bookmark-name"></a> data element.
var elmBookmark = modelWriter.createElement('bookmark', { name }); // using the passwed modelWriter to create a Model element called 'bookmark' with the attribute name.
return elmBookmark; // CKEditor 5 will convert any <a name="..."></a> Data element to whatever is returned here and insert it into the Model document.
}
});
Time to implement our CKEditor 5 schema conversion knowledge in the Bookmark plugin
The obvious place to code the schema conversions (how you come from one content representation to another content representation is in the BookmarkEditing.init() function - because that is our earliest hook in the Bookmark plugin.
We will build the Bookmark schema conversion in 4 progressively more advanced steps :
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import InsertBookmark from './bookmarkcommand';
export default class BookmarkEditing extends Plugin {
init() {
console.log("BookmarkEditing#init");
this._defineSchema();
this._defineConvertersTest1(); // ADD THIS
this.editor.commands.add('insertBookmark', new InsertBookmark(this.editor));
}
_defineSchema() {
const schema = this.editor.model.schema;
schema.register('bookmark', {
allowWhere: '$text', // 'bookmark' element is allowed wherever text is allowed.
isInline: true, // 'bookmark' element will act as an inline node.
isObject: true, // 'bookmark' is self-contained and should be treated as a whole (and also since isObject will set isLimt to true, bookmark cannot be split by the caret).
allowAttributes: ['name', 'class'] // 'bookmark' element are allowed a 'name' and a 'class' attribute.
});
}
// ADD THIS
_defineConvertersTest1() {
const conversion = this.editor.conversion;
conversion.for('editingDowncast').elementToElement({
model: 'bookmark',
view: (modelItem, { writer: viewWriter }) => {
const elmBookmark = viewWriter.createContainerElement('span');
var txtBookmark = viewWriter.createText('BOOKMARK');
viewWriter.insert(viewWriter.createPositionAt(elmBookmark, 0), txtBookmark);
viewWriter.setCustomProperty('bookmarkName', true, elmBookmark);
return elmBookmark;
}
});
}
}
In the above code we have used several functions on the ViewWriter object, I will go through these functions together with functions on the ModelWriter in the CKEditor 5 API Examples section.
However, before we can continue, we need to solve one problem in the above code : when you click somewhere in the BOOKMARK text, you will get an error "model-nodelist-offset-out-of-bounds". We get this error because there are no implicit way to translate your position or selection in the View element <span>BOOKMARK</span> into a position or selection in the corresponding Model element <bookmark name=""><bookmark> - eg. in the View document (CKEditor 5 editing area) the cursor positioned between B & O is different from the cursor positioned between K & M, but this difference gives no meaning in the Model document markup.
As earlier statet there are no View upcast (View -> Model), instead then the user do something in the CKEditor 5 editing area, events are raised on the View document and these events can update the Model. One such event is then you make a selection (including positioning the cursor somewhere) - such a selection (or position) will trigger the viewToModelPosition event which must result in a position in the Model.
However, in case of the Bookmark plugin the model and the view have different structures and for any View position within the BOOKMARK text there are no automatic corresponding position in the Model - to solve the "model-nodelist-offset-out-of-bounds" problem we need to customize the mapping logic.
Solve the "model-nodelist-offset-out-of-bounds" error :
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
// ADD THIS
import Position from '@ckeditor/ckeditor5-engine/src/model/position';
import InsertBookmark from './bookmarkcommand';
export default class BookmarkEditing extends Plugin {
init() {
console.log("BookmarkEditing#init");
this._defineSchema();
this._defineConvertersTest1();
// ADD THIS
this.editor.editing.mapper.on(
'viewToModelPosition',
(evt, data) => {
if (data.viewPosition && data.viewPosition.parent && data.viewPosition.parent._textData == "BOOKMARK") {
const elmBookmark = data.mapper.toModelElement(data.viewPosition.parent.parent);
data.modelPosition = new Position(elmBookmark, [0]);
evt.stop();
}
}
);
this.editor.commands.add('insertBookmark', new InsertBookmark(this.editor));
}
_defineSchema() {
// ...
}
_defineConvertersTest1() {
// ...
}
}
In the above code we specify that if upon a viewToModelPosition event the cursor is positioned in a span with the text BOOKMARK, the Model position should be set to before the corresponding Model element.
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
// ADD THIS
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import InsertBookmark from './bookmarkcommand';
export default class BookmarkEditing extends Plugin {
// ADD THIS (to load the Widget plugin the same way that Bookmark (in bookmark.js) loads BookmarkEditing & BookmarkUI)
static get requires() {
return [Widget];
}
init() {
console.log("BookmarkEditing#init");
this._defineSchema();
//this._defineConvertersTest1(); OUT-COMMENT THIS (we will never use it again)
this._defineConvertersTest2(); // ADD THIS
this.editor.editing.mapper.on(
'viewToModelPosition',
(evt, data) => {
if (data.viewPosition && data.viewPosition.parent && data.viewPosition.parent._textData == "BOOKMARK") {
const elmBookmark = data.mapper.toModelElement(data.viewPosition.parent.parent);
data.modelPosition = new Position(elmBookmark, [0]);
evt.stop();
}
}
);
this.editor.commands.add('insertBookmark', new InsertBookmark(this.editor));
}
_defineSchema() {
// ...
}
_defineConvertersTest1() {
// ...
}
// ADD THIS
_defineConvertersTest2() {
const conversion = this.editor.conversion;
conversion.for('editingDowncast').elementToElement({
model: 'bookmark',
view: (modelItem, { writer: viewWriter }) => {
const aBookmark = viewWriter.createContainerElement('span');
var txtBookmark = viewWriter.createText('BOOKMARK');
viewWriter.insert(viewWriter.createPositionAt(aBookmark, 0), txtBookmark);
viewWriter.setCustomProperty('bookmarkName', true, aBookmark);
return toWidget(aBookmark, viewWriter); // This is the only difference from _defineConvertersTest1()
}
});
}
}
So the widget is pretty sweet and for block elements (our bookmark is an inline element), the widget also creates a convenient ad-hoc UI for inserting a paragraph before or after the block element.
/* CKBookmark\BookmarkDev\theme\bookmark.css */
.ck-bookmark {
width: 16px;
height: 16px;
display: inline-block;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNNDcyLjUgMGMtNyAwLTE0LjMgMS41LTIxLjIgNC42LTUwLjUgMjIuNy04Ny44IDMwLjMtMTE5LjEgMzAuM0MyNjYuMSAzNC45IDIyNy43LjQgMTUxLjQuNGMtMjguNCAwLTYyLjIgNC45LTEwNC41IDE4QzQ0LjMgNy45IDM1LjMgMCAyNCAwIDEwLjcgMCAwIDEwLjcgMCAyNHY0NzZjMCA2LjYgNS40IDEyIDEyIDEyaDI0YzYuNiAwIDEyLTUuNCAxMi0xMlYzOTguMWMzNy4zLTExLjggNjkuNi0xNi41IDk4LjUtMTYuNSA4MS4yIDAgMTM3LjggMzQuNCAyMTkuMSAzNC40IDM1LjMgMCA3NS4xLTYuNSAxMjMuNy0yNSAxNC01LjQgMjIuOC0xNy45IDIyLjgtMzEuMlYzMy40QzUxMiAxMyA0OTMuNCAwIDQ3Mi41IDB6TTQ2NCAzNDkuMWMtMzUuMyAxMi43LTY3LjYgMTguOS05OC41IDE4LjktNzUuNSAwLTEyOC41LTM0LjQtMjE5LjEtMzQuNC0zMS45IDAtNjQuNSA0LjctOTguNSAxNC4yVjY4LjVDODcuNyA1NSAxMjEuNyA0OC40IDE1MS40IDQ4LjRjNjYuMyAwIDEwNS4yIDM0LjUgMTgwLjggMzQuNSA0MC4zIDAgODIuMy0xMCAxMzEuOC0zMS41djI5Ny43eiIvPjwvc3ZnPg==");
}
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import InsertBookmark from './bookmarkcommand';
import theme from '../theme/bookmark.css'; // ADD THIS (earlier in this tutorial we have used webpack.config.js to instruct Webpack to use style-loader to handle CSS imports)
export default class BookmarkEditing extends Plugin {
init() {
console.log("BookmarkEditing#init");
this._defineSchema();
//this._defineConvertersTest1();
//this._defineConvertersTest2(); // OUT-COMMENT THIS
this._defineConverters(); // ADD THIS
this.editor.editing.mapper.on(
'viewToModelPosition',
(evt, data) => {
if (data.viewPosition && data.viewPosition.parent && data.viewPosition.parent._textData == "BOOKMARK") {
const elmBookmark = data.mapper.toModelElement(data.viewPosition.parent.parent);
data.modelPosition = new Position(elmBookmark, [0]);
evt.stop();
}
}
);
this.editor.commands.add('insertBookmark', new InsertBookmark(this.editor));
}
_defineSchema() {
// ...
}
_defineConvertersTest1() {
// ...
}
_defineConvertersTest2() {
// ...
}
// ADD THIS
_defineConverters() {
const conversion = this.editor.conversion;
conversion.for('editingDowncast').elementToElement({
model: 'bookmark',
view: (modelItem, { writer: viewWriter }) => {
const name = modelItem.getAttribute('name'); // not relevant yet, but later we will use popup UI to add the bookmark name
const aBookmark = viewWriter.createContainerElement('span', { name, class: 'ck-bookmark' }); // adding the ck-bookmark class
viewWriter.setCustomProperty('bookmarkName', true, aBookmark);
return toWidget(aBookmark, viewWriter);
}
});
}
}
Since there are no content within the View span element anymore, there are no position within the span element that cannot be automatically translated into a position in the Model bookmark element - therefore we do not need the custom mapping for the viewToModelPosition event.
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import InsertBookmark from './bookmarkcommand';
import theme from '../theme/bookmark.css';
export default class BookmarkEditing extends Plugin {
init() {
console.log("BookmarkEditing#init");
this._defineSchema();
//this._defineConvertersTest1();
//this._defineConvertersTest2();
this._defineConverters();
// OUT-COMMENT THIS
/*
this.editor.editing.mapper.on(
'viewToModelPosition',
(evt, data) => {
if (data.viewPosition && data.viewPosition.parent && data.viewPosition.parent._textData == "BOOKMARK") {
const elmBookmark = data.mapper.toModelElement(data.viewPosition.parent.parent);
data.modelPosition = new Position(elmBookmark, [0]);
evt.stop();
}
}
);
*/
this.editor.commands.add('insertBookmark', new InsertBookmark(this.editor));
}
_defineSchema() {
// ...
}
_defineConvertersTest1() {
// ...
}
_defineConvertersTest2() {
// ...
}
_defineConverters() {
// ...
}
}
Until now we have written conversion code only for the editingDowncast (Model -> View), so that we can see how insertion of a <bookmark ..> element in the Model document looks like in the CKEditor 5 editing area (which is built upon the View document).
However, we have still to write code for upcast (Data -> Model) so we can create a Model document from any existing data (html) that we want to edit as well as code for dataDowncast (Model -> Data) so we can save our content typically in a database.
Let's investigate the problem of the 2 missing converters
<!-- CKBookmark\BookmarkDev\index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CKEditor 5 Bookmark Plugin</title>
</head>
<body>
<div id="editor">
<p>
<a name="my-bookmark"></a> <!-- ADD THIS -->
Editor content goes here.</p>
</div>
<script src="dist/bundle.js"></script>
</body>
</html>
Create the upcast conversion code
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import InsertBookmark from './bookmarkcommand';
import theme from '../theme/bookmark.css';
export default class BookmarkEditing extends Plugin {
init() {
// ...
}
_defineSchema() {
// ...
}
_defineConvertersTest1() {
// ...
}
_defineConvertersTest2() {
// ...
}
_defineConverters() {
const conversion = this.editor.conversion;
// ADD THIS
conversion.for('upcast').elementToElement({
view: { // view attribute is used for the Data layer
name: 'a', // match any a-element in the Data layer
attributes: {
name: true // but ONLY if the a-element have a name-attribute
}
},
model: (viewElement, { writer: modelWriter }) => { // in upcast, the Model is target schema and therefore the writer passed by the CKEditor 5 framework will be a ModelWriter
const name = viewElement.getAttribute('name'); // get the value of the name-attribute in the a-element
var bookmark = modelWriter.createElement('bookmark', { name });
return bookmark;
}
});
conversion.for('editingDowncast').elementToElement({
model: 'bookmark',
view: (modelItem, { writer: viewWriter }) => {
const name = modelItem.getAttribute('name');
const aBookmark = viewWriter.createContainerElement('span', { name, class: 'ck-bookmark' });
viewWriter.setCustomProperty('bookmarkName', true, aBookmark);
return toWidget(aBookmark, viewWriter);
}
});
}
}
Create the dataDowncast code
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import InsertBookmark from './bookmarkcommand';
import theme from '../theme/bookmark.css';
export default class BookmarkEditing extends Plugin {
init() {
// ...
}
_defineSchema() {
// ...
}
_defineConvertersTest1() {
// ...
}
_defineConvertersTest2() {
// ...
}
_defineConverters() {
const conversion = this.editor.conversion;
conversion.for('upcast').elementToElement({
view: {
name: 'a',
attributes: {
name: true
}
},
model: (viewElement, { writer: modelWriter }) => {
const name = viewElement.getAttribute('name');
var bookmark = modelWriter.createElement('bookmark', { name });
return bookmark;
}
});
conversion.for('editingDowncast').elementToElement({
model: 'bookmark',
view: (modelItem, { writer: viewWriter }) => {
const name = modelItem.getAttribute('name');
const aBookmark = viewWriter.createContainerElement('span', { name, class: 'ck-bookmark' });
viewWriter.setCustomProperty('bookmarkName', true, aBookmark);
return toWidget(aBookmark, viewWriter);
}
});
// ADD THIS
conversion.for('dataDowncast').elementToElement({
model: 'bookmark',
view: (modelItem, { writer: viewWriter }) => {
const name = modelItem.getAttribute('name');
const aBookmark = viewWriter.createAttributeElement('a', { name });
return aBookmark;
}
});
}
}
That's it - the CKEditor 5 Schema Conversion code is finished. Before ending this chapter and continuing to create the View popup UI for inserting the bookmark name, let's first lightly go through the parts of the CKEditor 5 Conversion API we have used in the above schema conversion code.
The CKEditor 5 API is HUGE and easy to get lost in. Here I list what I think is the most important parts to learn regarding schema conversion - use this list for a fast reference until getting more intimate the navigating the official CKEditor 5 API documentation.
Currently the bookmark toolbar button have only 1 state : Enabled. However it is common to let toolbar buttons reflect contextual circumstance by either greying it out then the action of the toolbar button is not applicable or show the toolbar button as selected if in our case the current selection is a bookmark.
CKEditor 5 Button comes with 2 flags that we can set to manipulate the appearance of the button, which gives 3 meaningful states :
// CKBookmark\BookmarkDev\src\bookmarkcommand.js
import Command from '@ckeditor/ckeditor5-core/src/command';
export default class InsertBookmark extends Command {
constructor(editor) {
super(editor);
// ADD THIS
this.set("isBookmark"); // make isBookmark an observable property of InsertBookmark - we need to listen to this custom property in BookmarkUI.
}
execute(bookmarkName) {
console.log("InsertBookmark#execute");
const editor = this.editor;
editor.model.change(modelWriter => {
bookmarkName = bookmarkName || '';
const bookmark = modelWriter.createElement('bookmark', { name: bookmarkName });
editor.model.insertContent(bookmark);
modelWriter.setSelection(bookmark, 'on');
});
}
// ADD THIS
refresh() { // CKEditor 5 framework will call refresh() on any Command on any update to the Model (I think), eg. then changing the (cursor) position.
const model = this.editor.model;
const modelDocument = model.document;
const elmSelected = modelDocument.selection.getSelectedElement();
if (elmSelected) {
this.value = elmSelected.getAttribute('name'); // will be used later in the popup UI then editing an already written bookmark name.
this.isBookmark = elmSelected.name == "bookmark"; // updating the value of the isBookmark observable property.
}
else {
this.value = null;
this.isBookmark = false;
}
var isAllowed = !this.isBookmark ? modelDocument.selection.isCollapsed : true; // if a bookmark is NOT currently selected, the InsertCommand should only be allowed to execute if the selection is collapsed (to avoid supporting bookmarks that spans elements)
if (isAllowed) {
isAllowed = model.schema.checkChild(modelDocument.selection.focus.parent, 'bookmark'); // we should always test if our element is valid at current location
}
this.isEnabled = isAllowed; // isEnabled is a builtin already observable property on Command, which we will listen to in BookmarkUI.
}
}
this.on( 'execute', evt => {
if ( !this.isEnabled ) { // HERE
evt.stop();
}
}, { priority: 'high' } );
// CKBookmark\BookmarkDev\src\bookmarkui.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import bookmarkIcon from '../theme/icons/bookmark.svg';
export default class BookmarkUI extends Plugin {
init() {
console.log("BookmarkUI#init");
const editor = this.editor;
const t = editor.t;
editor.ui.componentFactory.add('bookmark', locale => {
const btn = new ButtonView(locale);
btn.set({
label: t('Bookmark'),
withText: false,
tooltip: true,
icon: bookmarkIcon
});
this.listenTo(btn, 'execute', () => {
editor.execute('insertBookmark');
});
// ADD THIS
const bookmarkCommand = editor.commands.get('insertBookmark');
btn.bind('isEnabled').to(bookmarkCommand, 'isEnabled'); // bind the value of the buttons isEnabled property to the InsertCommands isEnabled property.
btn.bind('isOn').to(bookmarkCommand, 'isBookmark'); // bind the value of the buttons isOn property to the InsertBookmark isBookmark property.
return btn;
});
}
}
CKEditor 5 API in use :
CKEditor 5 comes with a builtin popup plugin called ContextualBalloon, onto which we can load View objects - View objects will make up our Bookmark popup UI (ContextualBalloon handling the popup).
We will create 2 such View objects :
We will add these 2 Views (ViewPopup & EditPopup) to the ContextualBalloon adhoc upon request. ContextualBalloon will do the actual popup just displaying one of the two Views within it's popup container (the reason I name the 2 Views ...Popup is to signify that these 2 Views have to be rendered within the ContextualBalloon popup container).
A View must have a Template, which will create (or render) elements to be attached to DOM, eg. the EditPopup must create a Template which will render an <input type="text"> element, which then attached to DOM will signal the browser to create a textbox (in addition the EditPopup template will also need to create DOM elements for the 2 buttons : submit & cancel).
The EditPopup template must first of all produce a <form> so that we can submit. This form then must have 3 children :
The children must themselves extend the View class, so the textbox and the 2 buttons are themselves Views, however CKEditor 5 comes with some builtin Views and we will use the InputTextView and ButtonView (for our 2 buttons) and also extend the Template for each of these buitin templates.
CKEditor 5 View API in use :
Ok, let's implement the EditPopup :
// CKBookmark\CKBookmarkDev\src\ui\editpopup.js
import View from '@ckeditor/ckeditor5-ui/src/view';
import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
// CKEditor 5 comes with a few ready to use common SVG icons, which we will use instead of rolling our own (we already made our own Flag-icon)
import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
export default class EditPopup extends View { // EditPopup is a View
constructor(locale) {
super(locale); // The View takes a locale for translations
// The TextBox for inputting the bookmark name
this.tbName = new InputTextView(locale); // use the CKEditor 5 builtin InputTextView
this.tbName.placeholder = 'Bookmark Name';
this.tbName.extendTemplate({ // extend the Template of this instance of the CKEditor 5 builtin InputTextView to add a CSS class attribute to the final DOM element (<input type="text" class="ck-bookmark-edit-tbName">
attributes: {
class: ['ck-bookmark-edit-tbName']
}
});
// The Save button we create ourselves using our smart _createButton function below
this.saveButtonView = this._createButton(locale.t('Save'), checkIcon, 'ck-bookmark-edit-btnSave'); // ck-button-save
this.saveButtonView.type = 'submit';
// The Cancel button we create ourselves
this.cancelButtonView = this._createButton(locale.t('Cancel'), cancelIcon, 'ck-bookmark-edit-btnCancel', 'cancel'); // ck-button-cancel
// Template of the EditPopup View
this.setTemplate({
tag: 'form',
attributes: {
class: ['ck-bookmark-edit'],
tabindex: '-1' // https://github.com/ckeditor/ckeditor5-link/issues/90
},
children: [ // add our 3 children Views (render() will automatically call render() on children Views)
this.tbName,
this.saveButtonView,
this.cancelButtonView
]
});
}
render() {
super.render();
submitHandler({
view: this
});
}
// reuseable function to create buttons (we create 2 buttons, Save & Cancel, using this function)
_createButton(label, icon, className, eventName) {
const button = new ButtonView(this.locale); // we start with the CKEditor 5 builtin ButtonView
button.set({ // this is the reason we don't make the Button from scratch ourselves
label,
icon,
tooltip: true
});
button.extendTemplate({
attributes:{
class: [className]
}
});
if (eventName) {
button.delegate('execute').to(this, eventName);
}
return button;
}
}
// CKBookmark\BookmarkDev\src\bookmarkui.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
// ADD THIS
import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver'; // allows us to listen for mouse clicks on objects we bind the observer to
import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon'; // builtin popup plugin we will use for our popup UI
// ADD THIS
import EditPopup from './ui/editpopup'; // EditPopup View class is found in the editpopup.js file we just wrote above
import bookmarkIcon from '../theme/icons/bookmark.svg';
export default class BookmarkUI extends Plugin {
init() {
console.log("BookmarkUI#init");
const editor = this.editor;
const t = editor.t;
editor.ui.componentFactory.add('bookmark', locale => {
const btn = new ButtonView(locale);
btn.set({
label: t('Bookmark'),
withText: false,
tooltip: true,
icon: bookmarkIcon
});
this.listenTo(btn, 'execute', () => {
editor.execute('insertBookmark');
});
const bookmarkCommand = editor.commands.get('insertBookmark');
btn.bind('isEnabled').to(bookmarkCommand, 'isEnabled');
btn.bind('isOn').to(bookmarkCommand, 'isBookmark');
return btn;
});
// ADD THIS (our popup container)
this._balloon = editor.plugins.get(ContextualBalloon); // make a reference to the builtin ContextualBalloon plugin
// ADD THIS (our EditPopup View to show inside the _balloon)
this._editPopup = this._createEditPopup(); // we will create an instance of the EditPopup class and store it in a local variable _editPopup
// ADD THIS (Attach lifecycle actions to the the balloon, this time only clicking on the ViewDocument but later also ESC and outside clicking)).
editor.editing.view.addObserver(ClickObserver); // the ViewDocument must listen to mouse clicks (so that we can attach a handler then the user clicks on the Bookmark icon in the CKEditor 5 editing area)
this._enableUserBalloonInteractions(); // this._editPopup (& later this._viewPopup) MUST be initialized before calling this._enableUserBalloonInteractions())
}
// ADD THIS (create an instance of the EditPopup View)
_createEditPopup() {
const editor = this.editor;
const editPopup = new EditPopup(editor.locale);
const command = editor.commands.get('insertBookmark');
editPopup.tbName.bind('value').to(command, 'value');
// Execute insertBookmark command after clicking the "Save" button.
this.listenTo(editPopup, 'submit', () => {
const bookmarkName = editPopup.tbName.element.value;
editor.execute('insertBookmark', bookmarkName);
this._hideUI();
});
// Hide the panel after clicking the "Cancel" button.
this.listenTo(editPopup, 'cancel', () => {
this._hideUI();
});
return editPopup;
}
// ADD THIS (Show the popup (_balloon))
_showUI() {
const editor = this.editor;
const command = editor.commands.get('insertBookmark');
const elmBookmark = this._getSelectedBookmarkElement();
if (!elmBookmark) {
return;
}
showEditPopup(this);
function showEditPopup(linkUI) {
if (linkUI._balloon.hasView(linkUI._editPopup)) {
return;
}
linkUI._balloon.add({
view: linkUI._editPopup,
position: linkUI._getBalloonPositionData() // returns a DOM range
});
linkUI._editPopup.tbName.select();
}
}
// ADD THIS (Hide the popup (_balloon))
_hideUI() {
if (this._balloon.hasView(this._editPopup)) {
this._editPopup.saveButtonView.focus(); // some browsers have problems then removing a textbox with focus from DOM (https://github.com/ckeditor/ckeditor5/issues/1501)
this._balloon.remove(this._editPopup);
}
this.editor.editing.view.focus(); // the editPopup has a tbName (though actually saveButtonView) with focus, then removing the editPopup focus is lost - bring focus back to the editing view.
}
// ADD THIS
_enableUserBalloonInteractions() {
const viewDocument = this.editor.editing.view.document;
this.listenTo(viewDocument, 'click', () => {
const elmBookmark = this._getSelectedBookmarkElement();
if (elmBookmark) {
this._showUI();
}
});
}
// ADD THIS
_getSelectedBookmarkElement() {
const view = this.editor.editing.view;
const selection = view.document.selection;
var elm = selection.getSelectedElement();
if (elm && elm.is('containerElement')) { // on the View, bookmark is a containerElement (while on the Model, bookmark is an Element)
const customBookmarkProperty = !!elm.getCustomProperty('bookmarkName');
if (customBookmarkProperty) {
return elm;
}
}
}
// ADD THIS (find the position (or selection) for which the popup (_balloon) needs to be positioned)
_getBalloonPositionData() {
const view = this.editor.editing.view;
const viewDocument = view.document;
const targetBookmark = this._getSelectedBookmarkElement();
const target = targetBookmark ?
// When selection is inside bookmark element, then attach panel to this element.
view.domConverter.mapViewToDom(targetBookmark) :
// Otherwise attach panel to the selection.
view.domConverter.viewRangeToDom(viewDocument.selection.getFirstRange());
return { target };
}
}
/* CKBookmark\BookmarkDev\theme\bookmark.css */
.ck-bookmark {
width: 16px;
height: 16px;
display: inline-block;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNNDcyLjUgMGMtNyAwLTE0LjMgMS41LTIxLjIgNC42LTUwLjUgMjIuNy04Ny44IDMwLjMtMTE5LjEgMzAuM0MyNjYuMSAzNC45IDIyNy43LjQgMTUxLjQuNGMtMjguNCAwLTYyLjIgNC45LTEwNC41IDE4QzQ0LjMgNy45IDM1LjMgMCAyNCAwIDEwLjcgMCAwIDEwLjcgMCAyNHY0NzZjMCA2LjYgNS40IDEyIDEyIDEyaDI0YzYuNiAwIDEyLTUuNCAxMi0xMlYzOTguMWMzNy4zLTExLjggNjkuNi0xNi41IDk4LjUtMTYuNSA4MS4yIDAgMTM3LjggMzQuNCAyMTkuMSAzNC40IDM1LjMgMCA3NS4xLTYuNSAxMjMuNy0yNSAxNC01LjQgMjIuOC0xNy45IDIyLjgtMzEuMlYzMy40QzUxMiAxMyA0OTMuNCAwIDQ3Mi41IDB6TTQ2NCAzNDkuMWMtMzUuMyAxMi43LTY3LjYgMTguOS05OC41IDE4LjktNzUuNSAwLTEyOC41LTM0LjQtMjE5LjEtMzQuNC0zMS45IDAtNjQuNSA0LjctOTguNSAxNC4yVjY4LjVDODcuNyA1NSAxMjEuNyA0OC40IDE1MS40IDQ4LjRjNjYuMyAwIDEwNS4yIDM0LjUgMTgwLjggMzQuNSA0MC4zIDAgODIuMy0xMCAxMzEuOC0zMS41djI5Ny43eiIvPjwvc3ZnPg==");
}
/* ADD THIS */
.ck-bookmark-edit-tbName {
min-width: 250px !important; /* needed to overwrite the builtin --ck-input-text-width */
width: 250px !important;
}
/* ADD THIS */
.ck-bookmark-edit-btnSave {
color: #008a00 !important;
margin-left: 6px !important;
}
/* ADD THIS */
.ck-bookmark-edit-btnCancel {
color: #db3700 !important;
margin-left: 6px !important;
}
We will implement 3 different keystrokes and a 'ClickOutside' handler to the EditPopup UI
The ESC key need to remove the popup and therefore it cannot be implemented in EditPopup, however it still need to be listened to through EditPopup. TAB & CTRL+TAB keystrokes can be fully coded within EditPopup.
// CKBookmark\CKBookmarkDev\src\ui\editpopup.js
import View from '@ckeditor/ckeditor5-ui/src/view';
import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
// ADD THIS
import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
export default class EditPopup extends View {
constructor(locale) {
super(locale);
this.tbName = new InputTextView(locale);
this.tbName.placeholder = 'Bookmark Name';
this.tbName.extendTemplate({
attributes: {
class: ['ck-bookmark-edit-tbName']
}
});
this.saveButtonView = this._createButton(locale.t('Save'), checkIcon, 'ck-bookmark-edit-btnSave');
this.saveButtonView.type = 'submit';
this.cancelButtonView = this._createButton(locale.t('Cancel'), cancelIcon, 'ck-bookmark-edit-btnCancel', 'cancel'); // ck-button-cancel
// ADD THIS
this.keystrokes = new KeystrokeHandler(); // we will add the ESC keystroke to this KeystrokeHandler property in BookmarkUI._createPopupView() because we want the handler code to execute from BookmarkUI
// ADD THIS
this.focusTracker = new FocusTracker();
this._focusables = new ViewCollection(); // we add Views (textinput, submit & cancel buttons) to this ViewCollection in the render() function below
this._focusCycler = new FocusCycler({
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: 'shift + tab', // Navigate form fields backwards using the Shift + Tab keystroke.
focusNext: 'tab' // Navigate form fields forwards using the Tab key.
}
});
this.setTemplate({
tag: 'form',
attributes: {
class: ['ck-bookmark-edit'],
tabindex: '-1'
},
children: [
this.tbName,
this.saveButtonView,
this.cancelButtonView
]
});
}
render() {
super.render();
submitHandler({
view: this
});
// ADD THIS
const childViews = [
this.tbName,
this.saveButtonView,
this.cancelButtonView
];
// ADD THIS
childViews.forEach(v => {
this._focusables.add(v); // Register the view as focusable.
this.focusTracker.add(v.element); // Register the view in the focus tracker.
});
// ADD THIS
this.keystrokes.listenTo(this.element);// Start listening for the keystrokes coming from #element.
}
_createButton(label, icon, className, eventName) {
// ...
}
}
// CKBookmark\BookmarkDev\src\bookmarkui.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
// ADD THIS
import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
import EditPopup from './ui/editpopup';
import bookmarkIcon from '../theme/icons/bookmark.svg';
export default class BookmarkUI extends Plugin {
init() {
// ...
}
_createEditPopup() {
const editor = this.editor;
const editPopup = new EditPopup(editor.locale);
const command = editor.commands.get('insertBookmark');
editPopup.tbName.bind('value').to(command, 'value');
// ADD THIS (EditPopup must be the element listening for the ESC button, however we want the ESC event handler here so we can call this._hideUI())
editPopup.keystrokes.set('Esc', (data, cancel) => {
this._hideUI();
cancel(); // will call preventDefault() and stopPropagation()
});
this.listenTo(editPopup, 'submit', () => {
const bookmarkName = editPopup.tbName.element.value;
editor.execute('insertBookmark', bookmarkName);
this._hideUI();
});
this.listenTo(editPopup, 'cancel', () => {
this._hideUI();
});
return editPopup;
}
_showUI() {
// ...
}
_hideUI() {
// ...
}
_enableUserBalloonInteractions() {
const viewDocument = this.editor.editing.view.document;
this.listenTo(viewDocument, 'click', () => {
const elmBookmark = this._getSelectedBookmarkElement();
if (elmBookmark) {
this._showUI();
}
});
// ADD THIS (Close on click outside of balloon panel element)
clickOutsideHandler({
emitter: this._editPopup,
activator: () => this._balloon.visibleView === this._editPopup,
contextElements: [this._balloon.view.element],
callback: () => this._hideUI()
});
}
_getSelectedBookmarkElement() {
// ...
}
_getBalloonPositionData() {
// ...
}
}
Just like the official Link plugin, our Bookmark plugin will have 2 Views in the Popup : a View for editing the bookmark (the EditPopup) and a View for just seeing the bookmark name (ViewPopup). Both these Views will be hosted by the ContextualBalloon and the following rule applies :
Apart from using a Label View to display the bookmark name (instead of a TextInput View), the ViewPopup have 2 buttons :
// CKBookmark\BookmarkDev\src\bookmarkdeletecommand.js
import Command from '@ckeditor/ckeditor5-core/src/command';
export default class DeleteBookmark extends Command {
execute() {
const editor = this.editor;
const modelSelection = this.editor.model.document.selection;
editor.model.change(modelWriter => {
if (modelSelection.isCollapsed) {
return;
}
else {
var elm = modelSelection.getSelectedElement();
if (elm && elm.is('element')) {
if (elm.hasAttribute('name')) {
modelWriter.remove(elm);
}
}
}
});
}
}
// CKBookmark\BookmarkDev\src\bookmarkediting.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import InsertBookmark from './bookmarkcommand';
import theme from '../theme/bookmark.css';
export default class BookmarkEditing extends Plugin {
init() {
console.log("BookmarkEditing#init");
this._defineSchema();
//this._defineConvertersTest1();
//this._defineConvertersTest2();
this._defineConverters();
/*
this.editor.editing.mapper.on(
'viewToModelPosition',
(evt, data) => {
if (data.viewPosition && data.viewPosition.parent && data.viewPosition.parent._textData == "BOOKMARK") {
const elmBookmark = data.mapper.toModelElement(data.viewPosition.parent.parent);
data.modelPosition = new Position(elmBookmark, [0]);
evt.stop();
}
}
);
*/
this.editor.commands.add('insertBookmark', new InsertBookmark(this.editor));
// ADD THIS
this.editor.commands.add('deleteBookmark', new DeleteBookmark(this.editor));
}
_defineSchema() {
// ...
}
_defineConvertersTest1() {
// ...
}
_defineConvertersTest2() {
// ...
}
_defineConverters() {
// ...
}
}
// CKBookmark\BookmarkDev\src\ui\viewpopup.js
import View from '@ckeditor/ckeditor5-ui/src/view';
import LabelView from '@ckeditor/ckeditor5-ui/src/label/labelview';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
import pencilIcon from '@ckeditor/ckeditor5-core/theme/icons/pencil.svg';
import deleteIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
export default class ViewPopup extends View {
constructor(locale) {
super(locale);
this.keystrokes = new KeystrokeHandler(); // BookmarkUI._createPopupView() is setting the ESC keystroke
this.lblName = new LabelView(locale);
this.lblName.extendTemplate({
attributes: {
class: ['ck-bookmark-view-lblName']
}
});
this.editButtonView = this._createButton(locale.t('Edit'), pencilIcon, 'ck-bookmark-view-btnEdit', 'edit');
this.deleteButtonView = this._createButton(locale.t('Delete'), deleteIcon, 'ck-bookmark-view-btnDelete', 'delete');
this.setTemplate({
tag: 'div',
attributes: {
class: ['ck-bookmark-view']
},
children: [
this.lblName,
this.editButtonView,
this.deleteButtonView
]
});
}
render() {
super.render();
this.keystrokes.listenTo(this.element);// Start listening for the keystrokes coming from #element.
}
_createButton(label, icon, className, eventName) {
const button = new ButtonView(this.locale);
button.set({
label,
icon,
tooltip: true
});
button.extendTemplate({
attributes: {
class: [className]
}
});
button.delegate('execute').to(this, eventName); // then clicking the button fire eventName (eg. 'edit') directly on viewPopup
return button;
}
}
/* CKBookmark\BookmarkDev\theme\bookmark.css */
.ck-bookmark {
width: 16px;
height: 16px;
display: inline-block;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNNDcyLjUgMGMtNyAwLTE0LjMgMS41LTIxLjIgNC42LTUwLjUgMjIuNy04Ny44IDMwLjMtMTE5LjEgMzAuM0MyNjYuMSAzNC45IDIyNy43LjQgMTUxLjQuNGMtMjguNCAwLTYyLjIgNC45LTEwNC41IDE4QzQ0LjMgNy45IDM1LjMgMCAyNCAwIDEwLjcgMCAwIDEwLjcgMCAyNHY0NzZjMCA2LjYgNS40IDEyIDEyIDEyaDI0YzYuNiAwIDEyLTUuNCAxMi0xMlYzOTguMWMzNy4zLTExLjggNjkuNi0xNi41IDk4LjUtMTYuNSA4MS4yIDAgMTM3LjggMzQuNCAyMTkuMSAzNC40IDM1LjMgMCA3NS4xLTYuNSAxMjMuNy0yNSAxNC01LjQgMjIuOC0xNy45IDIyLjgtMzEuMlYzMy40QzUxMiAxMyA0OTMuNCAwIDQ3Mi41IDB6TTQ2NCAzNDkuMWMtMzUuMyAxMi43LTY3LjYgMTguOS05OC41IDE4LjktNzUuNSAwLTEyOC41LTM0LjQtMjE5LjEtMzQuNC0zMS45IDAtNjQuNSA0LjctOTguNSAxNC4yVjY4LjVDODcuNyA1NSAxMjEuNyA0OC40IDE1MS40IDQ4LjRjNjYuMyAwIDEwNS4yIDM0LjUgMTgwLjggMzQuNSA0MC4zIDAgODIuMy0xMCAxMzEuOC0zMS41djI5Ny43eiIvPjwvc3ZnPg==");
}
.ck-bookmark-edit-tbName {
min-width: 250px !important;
width: 250px !important;
}
.ck-bookmark-edit-btnSave {
color: #008a00 !important;
margin-left: 6px !important;
}
.ck-bookmark-edit-btnCancel {
color: #db3700 !important;
margin-left: 6px !important;
}
/* ADD THIS */
.ck-bookmark-view {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
padding: 10px !important;
}
/* ADD THIS */
.ck-bookmark-view-lblName {
font-weight: normal !important;
}
/* ADD THIS */
.ck-bookmark-view-btnEdit {
color: #008a00 !important;
margin-left: 6px !important;
}
/* ADD THIS */
.ck-bookmark-view-btnDelete {
color: #db3700 !important;
margin-left: 6px !important;
}
// CKBookmark\BookmarkDev\src\bookmarkui.js
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
import EditPopup from './ui/editpopup';
// ADD THIS
import ViewPopup from './ui/viewpopup';
import bookmarkIcon from '../theme/icons/bookmark.svg';
export default class BookmarkUI extends Plugin {
init() {
console.log("BookmarkUI#init");
const editor = this.editor;
const t = editor.t;
editor.ui.componentFactory.add('bookmark', locale => {
const btn = new ButtonView(locale);
btn.set({
label: t('Bookmark'),
withText: false,
tooltip: true,
icon: bookmarkIcon
});
this.listenTo(btn, 'execute', () => {
editor.execute('insertBookmark');
});
const bookmarkCommand = editor.commands.get('insertBookmark');
btn.bind('isEnabled').to(bookmarkCommand, 'isEnabled');
btn.bind('isOn').to(bookmarkCommand, 'isBookmark');
return btn;
});
this._balloon = editor.plugins.get(ContextualBalloon);
this._editPopup = this._createEditPopup();
// ADD THIS
this._viewPopup = this._createViewPopup();
editor.editing.view.addObserver(ClickObserver);
this._enableUserBalloonInteractions();
}
_createEditPopup() {
// ..
}
// ADD THIS
_createViewPopup() {
const editor = this.editor;
const viewPopup = new ViewPopup(editor.locale)
const command = editor.commands.get('insertBookmark');
viewPopup.lblName.bind('text').to(command, 'value');
this.listenTo(viewPopup, 'edit', () => { // then the viewPopup.editButtonView is clicked it will fire 'edit' on the viewPopup
this._balloon.remove(this._viewPopup);
this._balloon.add({
view: this._editPopup,
position: this._getBalloonPositionData()
});
this._editPopup.tbName.select();
});
this.listenTo(viewPopup, 'delete', () => {
this.editor.execute('deleteBookmark');
this._hideUI();
});
viewPopup.keystrokes.set('Esc', (data, cancel) => {
this._hideUI();
cancel();
});
return viewPopup;
}
_showUI() {
const editor = this.editor;
const command = editor.commands.get('insertBookmark');
const elmBookmark = this._getSelectedBookmarkElement();
if (!elmBookmark) {
return;
}
// DELETE THIS
showEditPopup(this);
// ADD THIS (if there is already a bookmarkName then open in view mode otherwise in edit mode)
const bookmarkName = elmBookmark.getAttribute('name');
if (bookmarkName) {
showViewPopup(this);
}
else {
showEditPopup (this);
}
// ADD THIS
function showViewPopup(linkUI) {
if (linkUI._balloon.hasView(linkUI._viewPopup)) { return; }
linkUI._balloon.add({
view: linkUI._viewPopup,
position: linkUI._getBalloonPositionData()
});
}
function showEditPopup(linkUI) {
if (linkUI._balloon.hasView(linkUI._editPopup)) {
return;
}
linkUI._balloon.add({
view: linkUI._editPopup,
position: linkUI._getBalloonPositionData()
});
linkUI._editPopup.tbName.select();
}
}
_hideUI() {
if (this._balloon.hasView(this._editPopup)) {
this._editPopup.saveButtonView.focus();
this._balloon.remove(this._editPopup);
}
// ADD THIS
if (this._balloon.hasView(this._viewPopup)) {
this._balloon.remove(this._viewPopup);
}
this.editor.editing.view.focus();
}
_enableUserBalloonInteractions() {
const viewDocument = this.editor.editing.view.document;
this.listenTo(viewDocument, 'click', () => {
const elmBookmark = this._getSelectedBookmarkElement();
if (elmBookmark) {
this._showUI();
}
});
clickOutsideHandler({
emitter: this._editPopup,
// DELETE THIS
activator: () => this._balloon.visibleView === this._editPopup,
// ADD THIS
activator: () => this._balloon.visibleView === this._editPopup || this._balloon.visibleView == this._viewPopup,
contextElements: [this._balloon.view.element],
callback: () => this._hideUI()
});
}
_getSelectedBookmarkElement() {
// ...
}
_getBalloonPositionData() {
// ...
}
}
Congratulation : YOU have built a full fledged and production ready CKEditor 5 plugin! (it took me quite a time to write the tutorial and even longer to understand it)
To publish the Bookmark plugin to the NPM repository, we need the following :
{
"name": "ckeditor5-bookmark",
"version": "1.1.3",
"description": "Bookmark Plugin for CKEditor 5.",
"keywords": [
"ckeditor 5",
"bookmark",
"link"
],
"repository": {
"type": "git",
"url": "https://github.com/RasmusRummel/ckeditor5-bookmark.git"
},
"files": [
"src",
"theme",
"theme/icons"
],
"main": "src/bookmark.js",
"author": "Rasmus Rummel",
"license": "MIT",
"homepage": "https://topiqs.online/7413",
"dependencies": {
"@ckeditor/ckeditor5-core": "x",
"@ckeditor/ckeditor5-widget": "x",
"@ckeditor/ckeditor5-ui": "x",
"@ckeditor/ckeditor5-utils": "x"
}
}
# ckeditor5-bookmark
ckeditor5-bookmark is a 3. party free bookmark plugin for CKEditor 5. It solves the problem of creating bookmarks, <a name="bookmark-name"></a>, in bigger documents. You can then use the offical CKEditor 5 Link plugin to create links to your bookmarks : <a href="#bookmark-name">Bookmark Name</a>

There is an in-dept tutorial, how to build a CKEditor 5 bookmark plugin, based on the ckeditor5-bookmark plugin here : [CKBookmark - a CKEditor 5 plugin tutorial](https://topiqs.online/Home/Index/1169).
Below is a short usage documentation.
# 1 : In your CKEditor 5 build file ADD a reference to ckeditor5-bookmark:
```javascript
// app.js
import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
import Bookmark from 'ckeditor5-bookmark'; // ADD THIS
// ...
export default class ClassicEditor extends ClassicEditorBase { }
ClassicEditor.builtinPlugins = [
Essentials,
Autoformat,
Bold,
Italic,
Bookmark // ADD THIS
// ...
]
```
<br />
# 2 : Then creating the CKEditor 5 add the bookmark button :
```javascript
ClassicEditor.create(document.querySelector('#editor'), {
toolbar: [
'heading',
'bold',
'italic',
'bookmark' // ADD THIS
// ...
]
// ...
});
```
Success - the Bookmark plugin was published.
To be sure that the published Bookmark plugin does actually work for other people then they try to download it and use in their custom CKEditor 5 builds, we best test that process ourselves (putting ourselves in the users shoes).
We will build a fast testing environment in \CKBookmark\BookmarkTest :
{
"dependencies": {
"@ckeditor/ckeditor5-editor-classic": "x",
"@ckeditor/ckeditor5-essentials": "x",
"@ckeditor/ckeditor5-heading": "x",
"@ckeditor/ckeditor5-link": "x",
"@ckeditor/ckeditor5-list": "x",
"@ckeditor/ckeditor5-basic-styles": "x",
"@ckeditor/ckeditor5-theme-lark": "x",
"ckeditor5-bookmark": "1.0.0"
},
"devDependencies": {
"@ckeditor/ckeditor5-dev-utils": "x",
"postcss-loader": "3.0.0",
"raw-loader": "4.0.1",
"style-loader": "1.2.1",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12"
}
}
// CKBookmark\BookmarkTest\app.js
import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Link from '@ckeditor/ckeditor5-link/src/link';
import List from '@ckeditor/ckeditor5-list/src/list';
// DELETE THIS
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
// DELETE THIS
import Bookmark from './src/bookmark';
// ADD THIS
import Bookmark from 'ckeditor5-bookmark'; // our very own CKEditor 5 Bookmark plugin
export default class ClassicEditor extends ClassicEditorBase { }
ClassicEditor
.create(document.querySelector('#editor'), {
plugins: [Heading, Essentials, Bold, Link, List, Bookmark],
toolbar: [
'heading',
'bold',
'link',
'bulletedList',
'numberedList',
'bookmark'
],
})
.then(editor => {
// DELETE THIS
CKEditorInspector.attach(editor);
window.editor = editor;
})
.catch(error => {
console.error(error.stack);
});
Congratulation - you have made a production ready fairly advanced CKEditor 5 plugin, I padded myself on the back then I did (actually I also padded myself on the back then finish writing this tutorial).
Just a few handy commands for building, testig and publishing a CKEditor 5 Plugin:
Error "Must call super constructor in derived class before accessing 'this' or returning from derived constructor".
Reason : I forgot to call super in the custom (that is: derived) Command class, InsertBookmark .
Solution :
In any derived class that uses the this-keyword, you must make a call to the construtor of the inherited class (using super()), eg. in the InsertBookmark you must :
// CKBookmark\BookmarkDev\src\bookmarkcommand.js
export default class InsertBookmark extends Command {
constructor(editor) {
super(editor); // HERE - you can now use the this-keyword.
}
// ...
}
Error : "model-position-before-root".
Reason : There are likely multiple ways to get this error, however one way to get it is to try to insert an element into the model that have not been registered with the model schema.
Solution : Register all model elements typically inserted by custom Command objects. Eg. InsertBookmark (which is a custom Command object) will insert an element called 'bookmark' into the model - therefore 'bookmark' must be registered with the model schema like this :
// CKBookmark\BookmarkDev\src\bookmarkediting.js#init
const schema = this.editor.model.schema;
schema.register('bookmark', { // HERE 'bookmark' is registered with the model schema.
allowWhere: '$text', // allowWhere is the only mandatory schema property of any registered element.
// ... various other schema properties of the bookmark element.
});
Error : "model-nodelist-offset-out-of-bounds".
Reason : Happens then the viewToModelPosition is raised in the View document - that is : a user changes selection or position in the CKEditor 5 editing area, which raises the viewToModelPosition event to update the selection or position in the Model document. However, if the element structures differ in the Model & View documents in a way so that a position in the View document does NOT directly translate to a position in the Model document, CKEditor 5 framework don't know what to do and raises this error. See View -> Model updates - the viewToModelPosition event for an indept explanation.
Solution : Register a custom handler for the viewToModelPosition event that updates any position in your View document schema (for your specific plugin) to your Model document schema, eg. in this tutorial I registered the following handler in BookmarkEditing.init() :
this.editor.editing.mapper.on(
'viewToModelPosition',
(evt, data) => {
if (data.viewPosition && data.viewPosition.parent && data.viewPosition.parent._textData == "BOOKMARK") {
const elmBookmark = data.mapper.toModelElement(data.viewPosition.parent.parent);
data.modelPosition = new Position(elmBookmark, [0]);
evt.stop();
}
}
);
The above handler will set any position within the Bookmark representation in the View document to just before the same Bookmark representation in the Model document.
Note that I register the handler in BookmarkEditing.init() because that is the earliest hook in the Bookmark plugin for custom code to execute.
Error : "Cannot destructure property 'writer' of 'undefined' as it is undefined".
When : Happened for me in a callback for the elementToElement() target schema as I tried to write a View element using what I though was a ViewWriter, however I had forgot to specify that this elementToElement() was for editingDowncast and as such CKEditor treat is a 2-way cast and therefore there are no ViewWriter :
conversion.elementToElement({ // no cast is specified!
model: 'bookmark',
view: (modelItem, { writer: viewWriter }) => { // ViewWriter only gives meaning in the context of dowcasting
const aBookmark = viewWriter.createContainerElement('span'); // trying to use undefined viewWriter
...
}
});
Solution : If using a ViewWriter in schema conversion, this scema conversion must be either an editingDowncast or a dataDowncast :
conversion.for('editingDowncast').elementToElement({ // specify the cast
model: 'bookmark',
view: (modelItem, { writer: viewWriter }) => { // CKEditor 5 framework will pass a ViewWriter here in case of downcast
const aBookmark = viewWriter.createContainerElement('span'); // this viewWriter object is initialized
...
}
});