Implementing an inline widget
In this tutorial, you will learn how to implement an inline widget. You will build a “placeholder” feature that allows the users to insert predefined placeholders, like a date or a surname, into the document.
We have an official implementation of this feature!
While this tutorial was created for learning purposes, it only offers a basic, simplified solution. We have an official implementation of this mechanism, called the merge fields feature. It is much more robust than the solution presented here, and offers many configuration options.
First, you will use widget utilities and conversion to define the behavior of this feature. Later on, you will use dropdown utilities to create a dropdown that will allow for inserting new placeholders. You will also learn how to use the editor configuration to define allowed placeholder names.
If you want to see the final product of this tutorial before you plunge in, check out the demo.
If you want to use this tutorial with CDN, follow the steps in the Adapt this tutorial to CDN section.
# Before you start
This guide assumes that you are familiar with the widgets concept introduced in the Implementing a block widget tutorial, especially the Let’s start and Plugin structure sections. The tutorial will also reference various concepts from the CKEditor 5 architecture.
# Bootstrapping the project
The easiest way to get started is to grab the starter project using the commands below.
npx -y degit ckeditor/ckeditor5-tutorials-examples/inline-widget/starter-files inline-widget
cd inline-widget
npm install
npm run dev
This will create a new directory called inline-widget
with the necessary files. The npm install
command will install all the dependencies, and npm run dev
will start the development server.
The editor with some basic plugins is created in the main.js
file.
First, let’s define the Placeholder
plugin. The project should have a structure shown below:
├── main.js
├── index.html
├── node_modules
├── package.json
├── placeholder
│ ├── placeholder.js
│ ├── placeholdercommand.js
│ ├── placeholderediting.js
│ ├── placeholderui.js
│ └── theme
│ └── placeholder.css
└─ ...
You can see that the placeholder feature has an established plugin structure: the master (glue) plugin (placeholder/placeholder.js
), the “editing” (placeholder/placeholderediting.js
) and the “UI” (placeholder/placeholderui.js
) parts.
The master (glue) plugin:
// placeholder/placeholder.js
import { Plugin } from 'ckeditor5';
import PlaceholderEditing from './placeholderediting';
import PlaceholderUI from './placeholderui';
export default class Placeholder extends Plugin {
static get requires() {
return [ PlaceholderEditing, PlaceholderUI ];
}
}
The UI part (empty for now):
// placeholder/placeholderui.js
import { Plugin } from 'ckeditor5';
export default class PlaceholderUI extends Plugin {
init() {
console.log( 'PlaceholderUI#init() got called' );
}
}
And the editing part (empty for now):
// placeholder/placeholderediting.js
import { Plugin } from 'ckeditor5';
export default class PlaceholderEditing extends Plugin {
init() {
console.log( 'PlaceholderEditing#init() got called' );
}
}
Finally, you need to load the Placeholder
plugin in your main.js
file:
// main.js
import Placeholder from './placeholder/placeholder';
ClassicEditor
.create( document.querySelector( '#editor' ), {
licenseKey: 'GPL', // Or '<YOUR_LICENSE_KEY>'.
plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, Placeholder ],
toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList', '|', 'undo', 'redo' ]
} );
At this point, you can run the development server and see in the browser console that the plugins are being initialized.
# The model and the view layers
The placeholder feature will be defined as an inline (text-like) element so it will be inserted into other editor blocks, like <paragraph>
, that allow text. The placeholder will have a name
attribute. This means that the model containing some text and a placeholder will look like this:
<paragraph>
Hello <placeholder name="name"></placeholder>!
</paragraph>
# Defining the schema
The <placeholder>
element should be treated as an object in $text
so it must be defined with inheritAllFrom: '$inlineObject'
. You will also need the name
attribute.
You will also use this opportunity to import the theme file (theme/placeholder.css
).
// placeholder/placeholderediting.js
import { Plugin } from 'ckeditor5';
import './theme/placeholder.css'; // ADDED
export default class PlaceholderEditing extends Plugin {
init() {
console.log( 'PlaceholderEditing#init() got called' );
this._defineSchema(); // ADDED
}
_defineSchema() { // ADDED
const schema = this.editor.model.schema;
schema.register( 'placeholder', {
// Behaves like a self-contained inline object (e.g. an inline image)
// allowed in places where $text is allowed (e.g. in paragraphs).
// The inline widget can have the same attributes as text (for example linkHref, bold).
inheritAllFrom: '$inlineObject',
// The placeholder can have many types, like date, name, surname, etc:
allowAttributes: [ 'name' ]
} );
}
}
The schema is defined so now you can define the model-view converters.
# Defining converters
The HTML structure (data output) of the converter will be a <span>
with the placeholder
class. The text inside the <span>
will be the placeholder’s name.
<span class="placeholder">{name}</span>
- Upcast conversion. This view-to-model converter will look for
<span>
s with theplaceholder
class, read the<span>
's text and create model<placeholder>
elements with thename
attribute set accordingly. - Downcast conversion. The model-to-view conversion will be slightly different for “editing” and “data” pipelines as the “editing downcast” pipeline will use widget utilities to enable widget-specific behavior in the editing view. In both pipelines, the element will be rendered using the same structure.
import { Plugin, Widget, toWidget } from 'ckeditor5';
import './theme/placeholder.css';
export default class PlaceholderEditing extends Plugin {
static get requires() { // ADDED
return [ Widget ];
}
init() {
console.log( 'PlaceholderEditing#init() got called' );
this._defineSchema();
this._defineConverters(); // ADDED
}
_defineSchema() {
// Previously registered schema.
// ...
}
_defineConverters() { // ADDED
const conversion = this.editor.conversion;
conversion.for( 'upcast' ).elementToElement( {
view: {
name: 'span',
classes: [ 'placeholder' ]
},
model: ( viewElement, { writer: modelWriter } ) => {
// Extract the "name" from "{name}".
const name = viewElement.getChild( 0 ).data.slice( 1, -1 );
return modelWriter.createElement( 'placeholder', { name } );
}
} );
conversion.for( 'editingDowncast' ).elementToElement( {
model: 'placeholder',
view: ( modelItem, { writer: viewWriter } ) => {
const widgetElement = createPlaceholderView( modelItem, viewWriter );
// Enable widget handling on a placeholder element inside the editing view.
return toWidget( widgetElement, viewWriter );
}
} );
conversion.for( 'dataDowncast' ).elementToElement( {
model: 'placeholder',
view: ( modelItem, { writer: viewWriter } ) => createPlaceholderView( modelItem, viewWriter )
} );
// Helper method for both downcast converters.
function createPlaceholderView( modelItem, viewWriter ) {
const name = modelItem.getAttribute( 'name' );
const placeholderView = viewWriter.createContainerElement( 'span', {
class: 'placeholder'
} );
// Insert the placeholder name (as a text).
const innerText = viewWriter.createText( '{' + name + '}' );
viewWriter.insert( viewWriter.createPositionAt( placeholderView, 0 ), innerText );
return placeholderView;
}
}
}
# Feature styles
The editing part imports the ./theme/placeholder.css
CSS file which describes how the placeholder is displayed in the editing view:
/* placeholder/theme/placeholder.css */
.placeholder {
background: #ffff00;
padding: 4px 2px;
outline-offset: -2px;
line-height: 1em;
margin: 0 1px;
}
.placeholder::selection {
display: none;
}
# Command
The command for the placeholder feature will insert a <placeholder>
element (if allowed by the schema) at the selection. The command will accept the options.value
parameter (other CKEditor 5 commands also use this pattern) to set the placeholder name.
// placeholder/placeholdercommand.js
import { Command } from 'ckeditor5';
export default class PlaceholderCommand extends Command {
execute( { value } ) {
const editor = this.editor;
const selection = editor.model.document.selection;
editor.model.change( writer => {
// Create a <placeholder> element with the "name" attribute (and all the selection attributes)...
const placeholder = writer.createElement( 'placeholder', {
...Object.fromEntries( selection.getAttributes() ),
name: value
} );
// ... and insert it into the document. Put the selection on the inserted element.
editor.model.insertObject( placeholder, null, null, { setSelection: 'on' } );
} );
}
refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const isAllowed = model.schema.checkChild( selection.focus.parent, 'placeholder' );
this.isEnabled = isAllowed;
}
}
Import the created command and add it to the editor commands:
// placeholder/placeholderediting.js
import { Plugin, Widget, toWidget } from 'ckeditor5';
import PlaceholderCommand from './placeholdercommand'; // ADDED
import './theme/placeholder.css';
export default class PlaceholderEditing extends Plugin {
static get requires() {
return [ Widget ];
}
init() {
console.log( 'PlaceholderEditing#init() got called' );
this._defineSchema();
this._defineConverters();
// ADDED
this.editor.commands.add( 'placeholder', new PlaceholderCommand( this.editor ) );
}
_defineSchema() {
// Previously registered schema.
// ...
}
_defineConverters() {
// Previously defined converters.
// ...
}
}
# Let’s see it!
You should be able to execute the placeholder
command to insert a new placeholder:
editor.execute( 'placeholder', { value: 'time' } );
This should result in:
# Fixing position mapping
If you play more with the widget (for example, try to select it by dragging the mouse from its right to the left edge), you will see the following error logged to the console:
Uncaught CKEditorError: model-nodelist-offset-out-of-bounds: Given offset cannot be found in the node list.
This error is thrown because there is a difference in text node mapping between the model and the view due to the different structures:
model:
foo<placeholder name="time"></placeholder>bar
view:
foo<span class="placeholder">{name}</span>bar
You could say that in the view there is “more” text than in the model. This means that some positions in the view cannot automatically map to positions in the model. Namely – those are positions inside the <span>
element.
Fortunately, CKEditor 5 allows customizing the mapping logic. Also, since mapping to an empty model element is a pretty common scenario, there is a ready-to-use utility viewToModelPositionOutsideModelElement()
that you can use here like that:
// placeholder/placeholderediting.js
import {
Plugin,
// MODIFIED
Widget,
toWidget,
viewToModelPositionOutsideModelElement
} from 'ckeditor5';
import PlaceholderCommand from './placeholdercommand';
import './theme/placeholder.css';
export default class PlaceholderEditing extends Plugin {
static get requires() {
return [ Widget ];
}
init() {
console.log( 'PlaceholderEditing#init() got called' );
this._defineSchema();
this._defineConverters();
this.editor.commands.add( 'placeholder', new PlaceholderCommand( this.editor ) );
// ADDED
this.editor.editing.mapper.on(
'viewToModelPosition',
viewToModelPositionOutsideModelElement( this.editor.model, viewElement => viewElement.hasClass( 'placeholder' ) )
);
}
_defineSchema() {
// Previously registered schema.
// ...
}
_defineConverters() {
// Previously defined converters.
// ...
}
}
After adding the custom mapping, the mapping will work perfectly. Every position inside the view <span>
element will be mapped to a position outside the <placeholder>
in the model.
# Creating the UI
The UI part will provide a dropdown button from which the user can select a placeholder to insert into the editor.
CKEditor 5 Framework includes helpers to create different dropdowns like toolbar or list dropdowns.
In this tutorial, you will create a dropdown with a list of available placeholders.
// placeholder/placeholderui.js
import {
Plugin,
ViewModel,
addListToDropdown,
createDropdown,
Collection
} from 'ckeditor5';
export default class PlaceholderUI extends Plugin {
init() {
const editor = this.editor;
const t = editor.t;
const placeholderNames = [ 'date', 'first name', 'surname' ];
// The "placeholder" dropdown must be registered among the UI components of the editor
// to be displayed in the toolbar.
editor.ui.componentFactory.add( 'placeholder', locale => {
const dropdownView = createDropdown( locale );
// Populate the list in the dropdown with items.
addListToDropdown( dropdownView, getDropdownItemsDefinitions( placeholderNames ) );
dropdownView.buttonView.set( {
// The t() function helps localize the editor. All strings enclosed in t() can be
// translated and change when the language of the editor changes.
label: t( 'Placeholder' ),
tooltip: true,
withText: true
} );
// Disable the placeholder button when the command is disabled.
const command = editor.commands.get( 'placeholder' );
dropdownView.bind( 'isEnabled' ).to( command );
// Execute the command when the dropdown item is clicked (executed).
this.listenTo( dropdownView, 'execute', evt => {
editor.execute( 'placeholder', { value: evt.source.commandParam } );
editor.editing.view.focus();
} );
return dropdownView;
} );
}
}
function getDropdownItemsDefinitions( placeholderNames ) {
const itemDefinitions = new Collection();
for ( const name of placeholderNames ) {
const definition = {
type: 'button',
model: new ViewModel( {
commandParam: name,
label: name,
withText: true
} )
};
// Add the item definition to the collection.
itemDefinitions.add( definition );
}
return itemDefinitions;
}
Add the dropdown to the toolbar:
// main.js
import Placeholder from './placeholder/placeholder';
ClassicEditor
.create( document.querySelector( '#editor' ), {
licenseKey: 'GPL', // Or '<YOUR_LICENSE_KEY>'.
plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, Placeholder ],
// Insert the "placeholder" dropdown into the editor toolbar.
toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList', '|', 'placeholder' ]
} );
To make this plugin extensible, placeholder types will be read from the editor configuration.
The first step is to define the placeholder configuration in the editing plugin:
// Previously imported packages.
// ...
export default class PlaceholderEditing extends Plugin {
static get requires() {
return [ Widget ];
}
init() {
console.log( 'PlaceholderEditing#init() got called' );
this._defineSchema();
this._defineConverters();
this.editor.commands.add( 'placeholder', new PlaceholderCommand( this.editor ) );
this.editor.editing.mapper.on(
'viewToModelPosition',
viewToModelPositionOutsideModelElement( this.editor.model, viewElement => viewElement.hasClass( 'placeholder' ) )
);
this.editor.config.define( 'placeholderConfig', { // ADDED
types: [ 'date', 'first name', 'surname' ]
} );
}
_defineConverters() {
// Previously defined converters.
// ...
}
_defineSchema() {
// Previously registered schema.
// ...
}
}
Now modify the UI plugin so it will read placeholder types from the configuration:
// placeholder/placeholderui.js
export default class PlaceholderUI extends Plugin {
init() {
const editor = this.editor;
const placeholderNames = editor.config.get( 'placeholderConfig.types' ); // CHANGED
editor.ui.componentFactory.add( 'placeholder', locale => {
// Previously registered dropdown among UI components.
// ...
} );
}
}
The plugin is now ready to accept the configuration. Check how this works by adding the placeholderConfig
configuration in the editor’s create()
method:
// Previously imported packages.
// ...
ClassicEditor
.create( document.querySelector( '#editor' ), {
licenseKey: 'GPL', // Or '<YOUR_LICENSE_KEY>'.
plugins: [ Essentials, Paragraph, Heading, List, Bold, Italic, Widget, Placeholder ],
toolbar: [ 'heading', 'bold', 'italic', 'numberedList', 'bulletedList', '|', 'placeholder' ],
placeholderConfig: {
types: [ 'date', 'color', 'first name', 'surname' ] // ADDED
}
} )
// Promise handling.
// ...
If you open the dropdown in the toolbar, you will see a new list of placeholders to insert.
# Demo
You can see the placeholder widget implementation in action in the editor below. You can also check out the full source code of this tutorial if you want to develop your own inline widgets.
Hello {first name} {surname}!
# Final solution
If you got lost at any point in the tutorial or want to go straight to the solution, there is a repository with the final project available.
npx -y degit ckeditor/ckeditor5-tutorials-examples/inline-widget/final-project final-project
cd final-project
npm install
npm run dev
# Adapt this tutorial to CDN
If you want to use the editor from CDN, you can adapt this tutorial by following these steps.
First, clone the repository the same way as before. But do not install the dependencies. Instead, open the index.html
file and add the following tags:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CKEditor 5 Framework – tutorial CDN</title>
<link rel="stylesheet" href="https://cdn.ckeditor.com/ckeditor5/44.1.0/ckeditor5.css" />
</head>
<body>
<div id="editor">
<p>Hello world!</p>
</div>
<script src="https://cdn.ckeditor.com/ckeditor5/44.1.0/ckeditor5.umd.js"></script>
<script type="module" src="/main.js"></script>
</body>
</html>
The CSS file contains the editor and content styles. Consequentially, you do not need to import styles into your JavaScript file.
// Before:
import 'ckeditor5/ckeditor5.css';
// After:
// No need to import the styles.
The script tag loads the editor from the CDN. It exposes the global variable CKEDITOR
. You can use it in your project to access the editor class and plugins. That is why you must change the import statements to destructuring in the JavaScript files:
// Before:
import { ClassicEditor, Essentials, Bold, Italic, Paragraph } from 'ckeditor5';
// After:
const { ClassicEditor, Essentials, Bold, Italic, Paragraph } = CKEDITOR;
After following these steps and running the npm run dev
command, you should be able to open the editor in browser.
Every day, we work hard to keep our documentation complete. Have you spotted outdated information? Is something missing? Please report it via our issue tracker.
With the release of version 42.0.0, we have rewritten much of our documentation to reflect the new import paths and features. We appreciate your feedback to help us ensure its accuracy and completeness.