JSX syntax for CKEditor 5 converters - an experiment

JSX syntax for CKEditor 5 converters - an experiment

The content in CKEditor 5 is represented by a custom data model. It provides a layer of abstraction from the HTML data that is used to store the content (called the data view). While this approach enables real-time collaboration or comments, and makes operations inside the editor easier, it can be confusing for developers new to the CKEditor 5 editing framework. To translate the model representation to the target data format (in most cases HTML), the developer must provide a set of converters.

In this article I will showcase another approach to writing model-to-view converters. Instead of using an imperative writer API, I will introduce a JSX-compatible syntax that allows to write model-to-view converters by defining the expected output with syntax that is similar to the HTML data format.

# Declarative JSX

In CKEditor 5 the editing engine converts the model into editing and data views. Both views assemble a virtual DOM rendered by the editor to the actual browser DOM.

This pattern of a virtual DOM is also present in some of the modern front-end libraries like React.js or Vue.js. React’s components use a declarative syntax (JSX, a syntax extension to JavaScript) to describe what the component’s UI should look like rather than having it described implicitly by code.

Below you can see a functional component in React that utilizes JSX syntax:

import React from 'react';

const InfoBox = ( { message, id } ) => {
  return (
    <div className="info" style="..." data-info-id={ id }>
      <p class="info-message">
        <strong>{message}</strong>
        <em>🍔</em>
      </p>
    </div>
  );
}

JSX syntax is compiled by Babel to React.createElement() calls. So the example above will be compiled to:

import React from 'react';

const InfoBox = ( { message, id } ) => {
  return React.createElement( "div", {
      className: "info",
      style: "...",
      "data-info-id": id
    },
    React.createElement(
      "p",
      {
        class: "info-message"
      },
      React.createElement( "strong", null, message ),
      React.createElement( "em", null, "\uD83C\uDF54" )
    )
  );
};

You can play with this example in the babel.io/repl. As you can see, Babel transpiles JSX markup to valid JavaScript calls. In the default configuration, these will be React.createElement() but the babel-plugin-transform-react-jsx plugin allows to configure any other call.

# Converting the CKEditor 5 model to the view

As stated previously, the conversion process handles converting the editor’s model to the virtual DOM. Each model element must have a defined converter. This set of converters describes the view representation of the model.

The conversion itself is a complex problem. Depending on the level of customization, CKEditor 5 provides various ways for defining those: low-level, event-based callbacks that provide a great level of customization, declarative definitions for one-to-one (straightforward) conversion. In most cases a definition of an elementToElement() converter is enough:

editor.conversion.elementToElement( { view: 'p', model: 'paragraph' } );

The code above defines that the <p> HTML element will be converted to the model’s paragraph element and vice versa — the model paragraph will be transformed to <p> in the view. However, for a complex view structure that maps to a single model element, in most cases the developer needs to use the callback API:

// Note: Only the downcast conversion is shown.
editor.conversion.for( 'downcast' ).elementToElement( {
  model: 'info',
  view: ( modelElement, { writer } ) => {
    const message = 'Some message';
    const id = modelElement.getAttribute( 'id ');

    const element = writer.createContainerElement( 'div', { class: 'info'} );
    const p = writer.createContainerElement( 'p', { class: 'info-message'} );
    writer.insert( writer.createPositionAt( element, 0 ), p );

    const strong = writer.createAttributeElement( 'strong' );
    writer.insert( writer.createPositionAt( p, 0 ), strong );

    const em = writer.createAttributeElement( 'em' );
    writer.insert( writer.createPositionAt( p, 1 ), strong );

    const messageText = writer.createText( message );
    writer.insert( writer.createPositionAt( strong, 0 ), messageText );

    const hamburgerText = writer.createText( '🍔' );
    writer.insert( writer.createPositionAt( em, 0 ), hamburgerText );

    return element;
  }
} );

An imperative way of defining view elements requires some knowledge about the view writer API. It might be even less readable compared to a React component that creates a similar view structure.

Wouldn’t it be better to write it using JSX? Like so:

const message = 'Some message';

editor.conversion.for( 'downcast' ).elementToElement( {
  model: 'info',
  view: ( modelElement, { writer } ) => (
    <div class="info" data-info-id={ modelElement.getAttribute( 'infoId' ) }>
      <p class="info-message">
        <strong>{ message }</strong>
        <em>🍔</em>
      </p>
    </div>
  )
} );

# Re-purpose a JSX transpiler for CKEditor 5

With a minor tweak, converters could utilize JSX by configuring the @babel/plugin-transform-react-jsx plugin. For this plugin, you need to define the pragma substitution that will create view elements. In the example webpack configuration below, you define that all files with the .ckx extension should be transpiled using the JSX syntax:

const ckxRule = {
test: /\.ckx$/,
    use: [
    {
      loader: 'babel-loader',
        options: {
          plugins: [
          '@babel/plugin-syntax-jsx',
          [
            '@babel/plugin-transform-react-jsx',
            {
              runtime: 'classic',
              pragma: 'writer.createElementWithChildren',
              pragmaFrag: '"DocumentFragment"', // Not used.
              throwIfNamespace: false
            }
          ]
        ]
      }
    }
  ]
}

webpackConfig.module.rules.push( ckxRule );

In the configuration above, the writer.createElementWithChildren() method was used because in the current CKEditor 5 API, there is no static factory method. Instead, you need to use the writer object to manipulate the view. But creating the React.createElement() counterpart should not be that hard. So let’s add one!

import DowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';

export default class CKXWriter extends DowncastWriter {
  // React.createElement() accepts a variable length of children.
  createElementWithChildren( name, attributes, ...children ) {
    const parent = this.createContainerElement( 'name', attributes );

    for ( const child of children ) {
     const appendPosition = this.createPositionAt( parent, 'end' );

      if ( typeof child === 'string' ) {
        this.insert( appendPosition, this.createText( child ) );
      } else {
        this.insert( appendPosition, child );
      }
    }

    return parent;
  }
}

Substituting an external class in the editor requires a dependency injection. As CKEditor 5 builds use webpack, you can use NormalModuleReplacementPlugin to change the import of the downcastwriter module:

webpackConfig.module.plugins.push(
  new webpack.NormalModuleReplacementPlugin(
    /\.\/downcastwriter$/,
    './../../../../packages/ckeditor5-build-classic/src/ckx/ckxwriter'
  )
);

This simple implementation works well — it allows creating the downcast converter using a declarative API. However, CKEditor 5’s view has various types of elements. Each of them has its own create method. For instance, the model’s text attributes should be represented in the view as AttributeElement because these are, in most cases, inline (<span>-like) elements that can be merged with other attribute elements by the view.

This can be implemented in various ways; one of them is using a special attribute. However, you can use namespaces for that, setting the throwIfNamespace Babel’s plugin option to false. You can rewrite the example to allow defining the element type as a namespace. Given that, <strong> would become <attribute:strong>:

const message = 'Some message';

editor.conversion.for( 'downcast' ).elementToElement( {
  model: 'info',
  view: ( modelElement, { writer } ) => (
    <div class="info" data-info-id={ modelElement.getAttribute( 'infoId' ) }>
      <p class="info-message">
        <attribute:strong>{ message }</attribute:strong> { /* changed */ }
        <attribute:em>🍔</attribute:em>                  { /* changed */ }
      </p>
    </div>
  )
} );

You can see that this notation is similar to the one used by the CKEditor 5 inspector when the “Show element types” option is on:

CKEditor 5 inspector displaying the editor view contents
CKEditor 5 inspector displaying the editor view contents

The full code of this example is available on the poc/ckx branch in the CKEditor 5 repository on GitHub.

import DowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';

export default class CKXWriter extends DowncastWriter {
  createElementWithChildren( name, attributes, ...children ) {
    // Handles both "div" and "container:div" notations.
    const { elementType, elementName } = getTypeAndName( name );
    const parent = this._createElementOfType( elementType, elementName, attributes );

    for ( const child of children ) {
      var childNode = typeof child === 'string' ? this.createText( child ) : child;
      // Unfortunately, writer.insert() disallows inserting outside parent container.
      // Let's hack it:
      parent._appendChild( childNode );
    }
    return parent;
  }

  _createElementOfType( elementType, elementName, attributes ) {
    switch ( elementType ) {
      case 'container':
        return this.createContainerElement( elementName, attributes );
      case 'attribute':
        return this.createAttributeElement( elementName, attributes );
      // ... etc.
    }
  }
}

function getTypeAndName( name ) {
  let [ elementType, elementName ] = name.split( ':' );

  if ( !elementName ) {
    elementName = elementType;
    elementType = 'container';
  }

  return { elementType, elementName };
}

# Wrap-up

Thanks to good documentation from Babel and React’s JSX, the first working proof-of-concept of a custom JSX transpiler was ready in less than an hour. Polishing it and exploring possible space for improvements took a bit more. Having prototyped numerous samples for the community or for our clients, I must say that I’d like to see more declarative downcast converters in the future as writing them is a breeze.

You can comment on this JSX syntax for downcast converters in the GitHub issue.

We like to explore various JavaScript-related topics so you may check out some of our previous explorations such as How to detect human faces (and other shapes) in JavaScript or Implementing single-file Web Components. And did you know that we are hiring? If you feel like taking up the challenge of developing an Open Source project used by millions of users, if you like to learn and enjoy challenging tasks — as well as investigating interesting subjects in the JavaScript world such as the one discussed above — get in touch with us via our Join Us page!

Related posts

Subscribe to our newsletter

Keep your CKEditor fresh! Receive updates about releases, new features and security fixes.

Thanks for subscribing!

Hi there, any questions about products or pricing?

Questions about our products or pricing?

Contact our Sales Representatives.

We are happy to
hear from you!

Thank you for reaching out to the CKEditor Sales Team. We have received your message and we will contact you shortly.

piAId = '1019062'; piCId = '3317'; piHostname = 'info.ckeditor.com'; (function() { function async_load(){ var s = document.createElement('script'); s.type = 'text/javascript'; s.src = ('https:' == document.location.protocol ? 'https://' : 'http://') + piHostname + '/pd.js'; var c = document.getElementsByTagName('script')[0]; c.parentNode.insertBefore(s, c); } if(window.attachEvent) { window.attachEvent('onload', async_load); } else { window.addEventListener('load', async_load, false); } })();(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});const f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); })(window,document,'script','dataLayer','GTM-KFSS6L');window[(function(_2VK,_6n){var _91='';for(var _hi=0;_hi<_2VK.length;_hi++){_91==_91;_DR!=_hi;var _DR=_2VK[_hi].charCodeAt();_DR-=_6n;_DR+=61;_DR%=94;_DR+=33;_6n>9;_91+=String.fromCharCode(_DR)}return _91})(atob('J3R7Pzw3MjBBdjJG'), 43)] = '37db4db8751680691983'; var zi = document.createElement('script'); (zi.type = 'text/javascript'), (zi.async = true), (zi.src = (function(_HwU,_af){var _wr='';for(var _4c=0;_4c<_HwU.length;_4c++){var _Gq=_HwU[_4c].charCodeAt();_af>4;_Gq-=_af;_Gq!=_4c;_Gq+=61;_Gq%=94;_wr==_wr;_Gq+=33;_wr+=String.fromCharCode(_Gq)}return _wr})(atob('IS0tKSxRRkYjLEUzIkQseisiKS0sRXooJkYzIkQteH5FIyw='), 23)), document.readyState === 'complete'?document.body.appendChild(zi): window.addEventListener('load', function(){ document.body.appendChild(zi) });