Contribute to this guide

guideImplementing a custom editor creator

The flexible architecture of CKEditor 5 allows creating custom editors. Not only the theme styling can be changed or the UI redesigned but also the entire editor initialization process can be modified allowing to create new editor types. Thanks to that, apart from the standard editors (like classic, inline, balloon or document), custom types like a multi-root editor can be created.

This guide goes through the process of implementing a custom, multi-root editor. You can check out the demo of the multi-root editor too.

# Editor class

The *Editor class is the main class of each editor type. It initializes the whole editor and its UI parts. The custom creator class should extend the base Editor class. For a multi-root editor, it may look like below:

import { Editor } from '@ckeditor/ckeditor5-core';
import { getDataFromElement, setDataInElement } from '@ckeditor/ckeditor5-utils';

/**
 * The multi-root editor implementation. It provides inline editables and a single toolbar.
 *
 * Unlike other editors, the toolbar is not rendered automatically and needs to be attached to the DOM manually.
 *
 * This type of an editor is dedicated to integrations which require a customized UI with an open
 * structure, allowing developers to specify the exact location of the interface.
 *
 * @implements module:core/editor/editorwithui~EditorWithUI
 * @extends module:core/editor/editor~Editor
 */
class MultirootEditor extends Editor {
    /**
     * Creates an instance of the multi-root editor.
     *
     * **Note:** Do not use the constructor to create editor instances. Use the static `MultirootEditor.create()` method instead.
     *
     * @protected
     * @param {Object.<String,HTMLElement>} sourceElements The list of DOM elements that will be the source
     * for the created editor (on which the editor will be initialized).
     * @param {module:core/editor/editorconfig~EditorConfig} config The editor configuration.
     */
    constructor( sourceElements, config ) {
        super( config );

        if ( this.config.get( 'initialData' ) === undefined ) {
            // Create initial data object containing data from all roots.
            const initialData = {};

            for ( const rootName of Object.keys( sourceElements ) ) {
                initialData[ rootName ] = getDataFromElement( sourceElements[ rootName ] );
            }

            this.config.set( 'initialData', initialData );
        }

        // Create root and UIView element for each editable container.
        for ( const rootName of Object.keys( sourceElements ) ) {
            this.model.document.createRoot( '$root', rootName );
        }

        this.ui = new MultirootEditorUI( this, new MultirootEditorUIView( this.locale, this.editing.view, sourceElements ) );
    }

    /**
     * @inheritDoc
     */
    destroy() {
        // Cache the data and editable DOM elements, then destroy.
        // It's safe to assume that the model->view conversion will not work after super.destroy(),
        // same as `ui.getEditableElement()` method will not return editables.
        const data = {};
        const editables = {};
        const editablesNames = Array.from( this.ui.getEditableElementsNames() );

        for ( const rootName of editablesNames ) {
            data[ rootName ] = this.getData( { rootName } );
            editables[ rootName ] = this.ui.getEditableElement( rootName );
        }

        this.ui.destroy();

        return super.destroy()
            .then( () => {
                for ( const rootName of editablesNames ) {
                    setDataInElement( editables[ rootName ], data[ rootName ] );
                }
            } );
    }

    /**
     * Creates a multi-root editor instance.
     *
     * @param {Object.<String,HTMLElement>} sourceElements The list of DOM elements that will be the source
     * for the created editor (on which the editor will be initialized).
     * @param {module:core/editor/editorconfig~EditorConfig} config The editor configuration.
     * @returns {Promise} A promise resolved once the editor is ready. The promise returns the created multi-root editor instance.
     */
    static create( sourceElements, config ) {
        return new Promise( resolve => {
            const editor = new this( sourceElements, config );

            resolve(
                editor.initPlugins()
                    .then( () => editor.ui.init() )
                    .then( () => editor.data.init( editor.config.get( 'initialData' ) ) )
                    .then( () => editor.fire( 'ready' ) )
                    .then( () => editor )
            );
        } );
    }
}

# EditorUI class

The *EditorUI class is the main UI class that initializes UI components (the main view and the toolbar) and sets up mechanisms like focus tracker or placeholder management. The custom *EditorUI class should extend the base EditorUI class like below:

import { EditorUI } from '@ckeditor/ckeditor5-ui';
import { enablePlaceholder } from '@ckeditor/ckeditor5-engine';

/**
 * The multi-root editor UI class.
 *
 * @extends module:ui/editorui/editorui~EditorUI
 */
class MultirootEditorUI extends EditorUI {
    /**
     * Creates an instance of the multi-root editor UI class.
     *
     * @param {module:core/editor/editor~Editor} editor The editor instance.
     * @param {module:ui/editorui/editoruiview~EditorUIView} view The view of the UI.
     */
    constructor( editor, view ) {
        super( editor );

        /**
         * The main (top–most) view of the editor UI.
         *
         * @readonly
         * @member {module:ui/editorui/editoruiview~EditorUIView} #view
         */
        this.view = view;
    }

    /**
     * Initializes the UI.
     */
    init() {
        const view = this.view;
        const editor = this.editor;
        const editingView = editor.editing.view;

        let lastFocusedEditableElement;

        view.render();

        // Keep track of the last focused editable element. Knowing which one was focused
        // is useful when the focus moves from editable to other UI components like balloons
        // (especially inputs) but the editable remains the "focus context" (e.g. link balloon
        // attached to a link in an editable). In this case, the editable should preserve visual
        // focus styles.
        this.focusTracker.on( 'change:focusedElement', ( evt, name, focusedElement ) => {
            for ( const editable of this.view.editables ) {
                if ( editable.element === focusedElement ) {
                    lastFocusedEditableElement = editable.element;
                }
            }
        } );

        // If the focus tracker loses focus, stop tracking the last focused editable element.
        // Wherever the focus is restored, it will no longer be in the context of that editable
        // because the focus "came from the outside", as opposed to the focus moving from one element
        // to another within the editor UI.
        this.focusTracker.on( 'change:isFocused', ( evt, name, isFocused ) => {
            if ( !isFocused ) {
                lastFocusedEditableElement = null;
            }
        } );

        for ( const editable of this.view.editables ) {
            // The editable UI element in DOM is available for sure only after the editor UI view has been rendered.
            // But it can be available earlier if a DOM element has been passed to MultirootEditor.create().
            const editableElement = editable.element;

            // Register each editable UI view in the editor.
            this.setEditableElement( editable.name, editableElement );

            // Let the editable UI element respond to the changes in the global editor focus
            // tracker. It has been added to the same tracker a few lines above but, in reality, there are
            // many focusable areas in the editor, like balloons, toolbars or dropdowns and as long
            // as they have focus, the editable should act like it is focused too (although technically
            // it isn't), e.g. by setting the proper CSS class, visually announcing focus to the user.
            // Doing otherwise will result in editable focus styles disappearing, once e.g. the
            // toolbar gets focused.
            editable.bind( 'isFocused' ).to( this.focusTracker, 'isFocused', this.focusTracker, 'focusedElement',
                ( isFocused, focusedElement ) => {
                    // When the focus tracker is blurred, it means the focus moved out of the editor UI.
                    // No editable will maintain focus then.
                    if ( !isFocused ) {
                        return false;
                    }

                    // If the focus tracker says the editor UI is focused and currently focused element
                    // is the editable, then the editable should be visually marked as focused too.
                    if ( focusedElement === editableElement ) {
                        return true;
                    }
                    // If the focus tracker says the editor UI is focused but the focused element is
                    // not an editable, it is possible that the editable is still (context–)focused.
                    // For instance, the focused element could be an input inside of a balloon attached
                    // to the content in the editable. In such case, the editable should remain _visually_
                    // focused even though technically the focus is somewhere else. The focus moved from
                    // the editable to the input but the focus context remained the same.
                    else {
                        return lastFocusedEditableElement === editableElement;
                    }
                } );

            // Bind the editable UI element to the editing view, making it an end– and entry–point
            // of the editor's engine. This is where the engine meets the UI.
            editingView.attachDomRoot( editableElement, editable.name );
        }

        this._initPlaceholder();
        this._initToolbar();
        this.fire( 'ready' );
    }

    /**
     * @inheritDoc
     */
    destroy() {
        super.destroy();

        const view = this.view;
        const editingView = this.editor.editing.view;

        for ( const editable of this.view.editables ) {
            editingView.detachDomRoot( editable.name );
        }

        view.destroy();
    }

    /**
     * Initializes the editor main toolbar and its panel.
     *
     * @private
     */
    _initToolbar() {
        const editor = this.editor;
        const view = this.view;
        const toolbar = view.toolbar;

        toolbar.fillFromConfig( editor.config.get( 'toolbar' ), this.componentFactory );

        // Register the toolbar so it becomes available for Alt+F10 and Esc navigation.
        this.addToolbar( view.toolbar );
    }

    /**
     * Enable the placeholder text on the editing root, if any was configured.
     *
     * @private
     */
    _initPlaceholder() {
        const editor = this.editor;
        const editingView = editor.editing.view;

        for ( const editable of this.view.editables ) {
            const editingRoot = editingView.document.getRoot( editable.name );
            const sourceElement = this.getEditableElement( editable.name );

            const placeholderText = editor.config.get( 'placeholder' )[ editable.name ] ||
                sourceElement && sourceElement.tagName.toLowerCase() === 'textarea' && sourceElement.getAttribute( 'placeholder' );

            if ( placeholderText ) {
                editingRoot.placeholder = placeholderText;

                enablePlaceholder( {
                    view: editingView,
                    element: editingRoot,
                    isDirectHost: false,
                    keepOnFocus: true
                } );
            }
        }
    }
}

# EditorUIView class

Finally, the *EditorUIView class is responsible for registering and handling all editable elements and for creating the editor toolbar. The custom *EditorUIView class should extend the base EditorUIView class:

import { EditorUIView, InlineEditableUIView, Template, ToolbarView } from '@ckeditor/ckeditor5-ui';

/**
 * The multi-root editor UI view. It is a virtual view providing an inline editable, but without
 * any specific arrangement of the components in the DOM.
 *
 * @extends module:ui/editorui/editoruiview~EditorUIView
 */
class MultirootEditorUIView extends EditorUIView {
    /**
     * Creates an instance of the multi-root editor UI view.
     *
     * @param {module:utils/locale~Locale} locale The locale instance.
     * @param {module:engine/view/view~View} editingView The editing view instance this view is related to.
     * @param {Object.<String,HTMLElement>} editableElements The list of editable elements, containing name and html element
     * for each editable.
     */
    constructor( locale, editingView, editableElements ) {
        super( locale );

        /**
         * The main toolbar of the multi-root editor UI.
         *
         * @readonly
         * @member {module:ui/toolbar/toolbarview~ToolbarView}
         */
        this.toolbar = new ToolbarView( locale );

        /**
         * The editables of the multi-root editor UI.
         *
         * @readonly
         * @member {Array.<module:ui/editableui/inline/inlineeditableuiview~InlineEditableUIView>}
         */
        this.editables = [];

        // Create InlineEditableUIView instance for each editable.
        for ( const editableName of Object.keys( editableElements ) ) {
            const editable = new InlineEditableUIView( locale, editingView, editableElements[ editableName ] );

            editable.name = editableName;
            this.editables.push( editable );
        }

        // This toolbar may be placed anywhere in the page so things like font size need to be reset in it.
        // Because of the above, make sure the toolbar supports rounded corners.
        // Also, make sure the toolbar has the proper dir attribute because its ancestor may not have one
        // and some toolbar item styles depend on this attribute.
        Template.extend( this.toolbar.template, {
            attributes: {
                class: [
                    'ck-reset_all',
                    'ck-rounded-corners'
                ],
                dir: locale.uiLanguageDirection
            }
        } );
    }

    /**
     * @inheritDoc
     */
    render() {
        super.render();

        this.registerChild( this.editables );
        this.registerChild( [ this.toolbar ] );
    }
}

# Initializing custom editor instance

Now with the multi-root editor creator ready, you can create a fully usable editor instance.

With HTML like:

<div id="toolbar"></div>

<header id="header">
    <h2>Gone traveling</h2>
    <h3>Monthly travel news and inspiration</h3>
</header>

<div id="content">
    <h3>Destination of the Month</h3>

    <h4>Valletta</h4>

    <figure class="image image-style-align-right">
        <img alt="Picture of a sunlit facade of a Maltan building." src="../../../assets/img/malta.jpg">
        <figcaption>It's siesta time in Valletta.</figcaption>
    </figure>

    <p>The capital city of <a href="https://en.wikipedia.org/wiki/Malta" target="_blank" rel="external">Malta</a> is the top destination this summer. It’s home to a cutting-edge contemporary architecture, baroque masterpieces, delicious local cuisine, and at least 8 months of sun. It’s also a top destination for filmmakers, so you can take a tour through locations familiar to you from Game of Thrones, Gladiator, Troy and many more.</p>
</div>

<div class="demo-row">
    <div class="demo-row__half">
        <div id="footer-left">
            <h3>The three greatest things you learn from traveling</h3>
            <p><a href="#">Find out more</a></p>
        </div>
    </div>

    <div class="demo-row__half">
        <div id="footer-right">
            <h3>Walking the capitals of Europe: Warsaw</h3>
            <p><a href="#">Find out more</a></p>
        </div>
    </div>
</div>

You can initialize the editor with the code below:

MultirootEditor
    .create( {
        header: document.querySelector( '#header' ),
        content: document.querySelector( '#content' ),
        footerleft: document.querySelector( '#footer-left' ),
        footerright: document.querySelector( '#footer-right' )
    }, {
        plugins: [ Essentials, Paragraph, Heading, Bold, Italic, List, Link, BlockQuote, Image, ImageCaption,
            ImageStyle, ImageToolbar, ImageUpload, Table, TableToolbar, MediaEmbed, EasyImage ],
        toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'uploadImage', 'blockQuote',
            'insertTable', 'mediaEmbed', 'undo', 'redo' ],
        image: {
            toolbar: [ 'toggleImageCaption', 'imageTextAlternative', '|', 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText' ],
        },
        table: {
            contentToolbar: [
                'tableColumn',
                'tableRow',
                'mergeTableCells'
            ]
        },
        placeholder: {
            header: 'Header text goes here',
            content: 'Type content here',
            footerleft: 'Left footer content',
            footerright: 'Right footer content'
        },
    } )
    .then( newEditor => {
        document.querySelector( '#toolbar' ).appendChild( newEditor.ui.view.toolbar.element );

        window.editor = newEditor;
    } )
    .catch( err => {
        console.error( err.stack );
    } );

This will create the exact same editor as the one used on the multi-root editor editor example page.