Creating a React Content Editor

React.js version used: 15.0.0

Webpack version used: 1.12.13

Source code at: GitHub

Backend not covered

Creating a React Content Editor

So I decided to study React(!). After spending a lot of time trying to understand deeper and deeper a framework, you can very easily miss out on some good alternatives out there. That is why I decided to finally learn React. So the idea was to create some sort of concept, from scratch, to better understand React and its internals.

So I decided to draft out a simple, but customisable and powerful, content editor. One that could turn template strings into a working editor interface, and then this interface would 'spit out' the content entered as HTML markup.

With the idea a bit more solid in mind it was time to decide how this template would be written. My idea was to have enough actual HTML in it so developers could prepare all the cosmetics and arrangement with plain HTML and CSS, and the content would be picked up by the editor itself. But, the template should be able to tell what parts of it would, in turn, become editable.

That is where a common syntax used on templates for developers came out, which is Mustache-like templating came to be the choice of flavour, like the one below:

<div>  
  {my-editable-content {attributes-object-literal}}
</div>  

That makes the template simple enough to understand, predict and read/write.

One note is that for this experiment I decided to use the most vanilla JS/ES6/7 I could.

Creating the ContentEditor skeleton

So, lets draft out how our ContentEditor should work. So the basics is that it should:

  1. Take in a template string
  2. Parse this template into living and breathing HTML components
  3. From that parse define editable areas of this HTML
  4. Retrieve the data entered from those editable areas
  5. Save any assets entered on those editable areas
  6. Glue all together on a HTML deliverable

With all that in mind, lets draft our React component:

import React, { Component, PropTypes } from 'react';  
import { autobind } from 'core-decorators';

export default class ContentEditor extends Component {  
  static propTypes = {
    template: PropTypes.string,
    componentsStyle: PropTypes.string,
    onSave: PropTypes.func
  };

  @autobind
  compileTemplate() {
    const { template } = this.props;
    this.props.onSave(template);
  }

  render() {
    const { template: rawTemplate, componentsStyle: style } = this.props;

    return (
      <div>
        <div className="control-bar">
          <button onClick={ this.compileTemplate }>Preview result</button>
        </div>
        <div dangerouslySetInnerHTML={(() => ({__html: rawTemplate}))()}></div>
      </div>
    );
  }
}

With the code above we have created a component that receives the template and then prints it with the render call. But that does no more than crossing our first item of the list. Now let's create our main script to have this component rendered into our page.

import 'babel-polyfill';  
import ContentEditor from './js/content-editor';  
import React from 'react';  
import { render } from 'react-dom';

const style = `  
div.outter {  
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: flex-start;
}
`;

const template = `  
<div class="outter">  
  {content.image {className: 'some-class-other', width: '100%', height: '11em'}}
  {content.image {className: 'some-class-other', width: '10em'}}
  {content.text {headingLevel: 'h4', className: 'a-text'}}
  {content.md {className: 'markdown-editor', width: '100%'}}
</div>  
`;

function saveData(data) {  
  window.console.info(data);
}

const props = {  
  template,
  componentsStyle: style,
  onSave: saveData
};

render(  
  <ContentEditor { ...props } />,
  document.querySelector('.editor')
);

So this simply declares a template with some editable parts in it. The example has a {content.x {props}} syntax for the editable parts. Where the x is the type for that specific editable area. The props are simply a way of passing some arguments to our editable area, so it behaves and displays its content as intended.

Next, lets create our template parser, in order to output a proper editor interface and take in data for our template.

Creating the template parser

Now with the basics of our ContentEditor done, we need to provide it with a parser. So it can handle the raw template string to it and have in return React components to render. Those components can either be simple representation of DOM elements or editable parts, with their own functionality built in.

The decided syntax for defining an editable part is {content.text {headingLevel: 'h4', className: 'a-text'}}, where we define the type, text, for that editable part with some properties to define some specific behaviour.

Now, let's revise the goal of our parser:

  1. Receive a template string
  2. Extract the HTML components and editable areas from the template
  3. Keep the same tree configuration as defined on the string
  4. Return an array to be used as a child list on our ContentEditor

Element.innerHTML to the rescue

So after some research I found that probably the easiest way of parsing this template into a proper tree, where the components have the same determined children list as defined on the string, would be to use the good old Element.innerHTML. The reason why is that it is a parser itself. It will read the string and try to convert the structure into a tree with the given elements. The different editable parts will too become a new Element, but as a text only. Also, we gain all of that for free.

With that, we simplify the initial parsing of our template string to two simple lines:

const node = document.createElement('div');  
node.innerHTML = template;  

And voilĂ , we have our template represented as an Element object which is our intended tree structure. Now what we gotta do is go through our tree structure and transform those elements into React elements, and, where it fits, our special React components that will represent the editable areas.

Transforming content.x literals into React components

Now that we have our basic tree, we have to navigate through it and collect those {content.x {props}} strings in order to convert it into the appropriate React component class.

I decided to call those 'plugins', belonging to the parser namespace. So, whenever we find one we will require them within the same namespace as the parser is, under a subfolder called 'plugins'.

Now lets see how can we assemble this into an initial and basic Parser object:

// RegEx for the plugin syntax: {content.pluginName {pluginProp: 'propValue'}}
const pluginRegEx = /\{content\.\w+(\s\{(\w+:\s?('|")?\w+((-|_|\s)\w+){0,}%?('|")?(,)?\s?){1,}\})?\}/g; // eslint-disable-line max-len  
// RegEx for the plugin props part on the plugin syntax, using JSON-like values
const propsRegEX = /\{(\w+:\s?('|")?\w+((-|_|\s)\w+){0,}%?('|")?(,)?\s?){1,}\}/g;

// Object to be used as the this keyword on each new instance for the mapPluginMarkdown
// function, in order to get the markdown content out of the Parser plugins
const pluginDataMap = [];  
const mainNode = document.createElement('div');

const Parser = {  
  getChildrenNodes({ template, style, props }) {
    // Transform the template into a DOM tree in order to better transverse it
    // and transform it into React elements to be rendered into the screen
    if (mainNode.innerHTML.length === 0 || mainNode.innerHTML !== template) {
      mainNode.innerHTML = template;
    }

    // Call parseNodes in order to transform the childNodes into React Elements
    // or into Parser plugin instances. Return the parsed nodes to be rendered.
    return this.parseNodes({ node: mainNode, style, props, nodeId: '0' });
  },

  parseNodes({ node: { childNodes = [] }, style, props, nodeId }) {
    let nodeList = [];

    childNodes.forEach((node, index) => {
      const childNodeId = `${nodeId}-${index}`;
      // If the node has no tagName it indicates that it is a text, it could be
      // just a text or a snippet for the plugin syntax e.g: {content.image ...}
      if (typeof node.tagName === 'undefined') {
        // Call extractPlugins to check for snippets for the plugin syntax.
        // Receive in return an array of node lists to be concatenated into our
        // current node list.
        const { textContent } = node;
        nodeList = nodeList.concat(
          this.extractPlugins({ textContent, props, nodeId: childNodeId })
        );
      } else {
        const { tagName, className } = node;
        const key = `${childNodeId}-${tagName}`;
        let childrenList = null;

        // If we have childNodes call parseNodes on the node to keep traversing
        // and parsing the tree. Receive the result into a array, childrenList
        if (node.hasChildNodes()) {
          childrenList = this.parseNodes({ node, props, nodeId: childNodeId });
        }

        // If we have style defined to be used, create a style tag for inline
        // styling the component
        if (typeof style !== 'undefined') {
          nodeList.push(React.createElement('style', { key: `${childNodeId}-style` }, style));
        }

        nodeList.push(React.createElement(tagName.toLowerCase(), { className, key }, childrenList));
      }
    });

    return nodeList;
  },

  extractPlugins({ textContent, props, nodeId }) {
    // Receive any matches for the plugin syntax
    const editableParts = textContent.match(pluginRegEx);
    let matches = [];

    if (editableParts !== null) {
      // If we find plugin matches map them into React Elements on a two part step
      matches = editableParts.map((entry, index) => {
        const pluginName = entry.replace(/(\{)|(content\.)|(\})/g, '').split(' ')[0];
        const pluginId = `${nodeId}-${pluginName}-${index}`;
        const pluginIndex = pluginDataMap.findIndex(({ pluginId: id }) => id === pluginId);
        let pluginProps = { ...props };

        if (pluginIndex !== -1) {
          // If the plugin already exists in the pluginDataMap, we fetch it's props,
          // but also keep the new props passed by the ContentEditor
          pluginProps = Object.assign({}, pluginDataMap[pluginIndex], pluginProps);
        } else {
          // Check for the presence of props passed to the plugin syntax
          const unparsedProps = entry.match(propsRegEX);

          if (unparsedProps !== null) {
            // If we have props, normalize it into a JSON string to then parse it into
            // a JSON object.
            const normalizedPropsString = unparsedProps[0]
              .replace(/\w+:/g, (match) => (`"${match.split(':')[0]}":`))
              .replace(/'/g, '"');

            pluginProps = Object.assign({
              pluginData: {}
            }, JSON.parse(normalizedPropsString), pluginProps);
          }

          pluginDataMap.push({ pluginId, ...pluginProps });
        }

        // Require the React component and create a new React element with it
        return React.createElement(
          require(`./plugins/${pluginName}-plugin`).default,
          // Create a new object combining the declared plugin props on the template with
          // other needed props such as the key, the pluginId and the setPluginData
          Object.assign({
            key: pluginId,
            pluginId,
            // Pass the mapPluginMarkdown to index the markdown content
            setPluginData: this.mapPluginData
          }, pluginProps)
        );
      });
    } else {
      // If no plugin syntax is found, simply return the text
      matches = [textContent];
    }

    return matches;
  },

  // Function to be used as a model for the setPluginData prop for each Parser plugin instance
  // into the ContentEditor
  mapPluginData({ pluginData, pluginId }) {
    const pluginIndex = pluginDataMap.findIndex(({ pluginId: id }) => id === pluginId);

    // Create a new props object, which will be all current props + an updated pluginData
    const props = Object.assign({},
      pluginDataMap[pluginIndex],
      { pluginData }
    );

    // Update the plugin props with the new props object
    pluginDataMap[pluginIndex] = props;
  },

  // A simple getter for the private variable pluginDataMap
  getPluginData() {
    return pluginDataMap;
  }
};

That is the bare minimum for our Parser to cover the checklist of features that we want out of it. So let's run check some of the details on the implementation.

At the very top of the code we define two constants for our plugin RegEx:

// RegEx for the plugin syntax: {content.pluginName {pluginProp: 'propValue'}}
const pluginRegEx = /\{content\.\w+(\s\{(\w+:\s?('|")?\w+((-|_|\s)\w+){0,}%?('|")?(,)?\s?){1,}\})?\}/g; // eslint-disable-line max-len  
// RegEx for the plugin props part on the plugin syntax, using JSON-like values
const propsRegEX = /\{(\w+:\s?('|")?\w+((-|_|\s)\w+){0,}%?('|")?(,)?\s?){1,}\}/g;1  

Now, I wanted to be a bit more flexible on the syntax for the plugin definitions, but I still suck at RegEx, so it is quite limited for this implementation example. We also isolate the props RegEx for better checking down on the road to extract and parse into JSON.

Down on our getChildrenNodes implementation we get our template parsed into DOM nodes. Using the ever dangerous, but useful in cases such as this, innerHTML property of our mainNode we are able to use the browser brains to convert our template into a usable DOM subtree.

The next step is to traverse the tree and create React elements to each node, be that a plain HTML element or one of our special editable parts. So on the parseNodes function we are going to separate the two kinds of nodes. What will happen when we hit a node that was meant to be an editable part is that it will be parsed by the browser as a plain text node. With that, we are able to simply check if the current node has a tagName attribute to evoke the extractPlugins function, which returns an array of React classes that represents our plugins defined on the template, or simply creating a React element out of that node. We also create, if provided, a style element in order to supply any styling need that the template might have, passed to the style parameter.

Notice that we are using the React Top-level API and not JSX, such as React.createElement. The reason for that is that we are parsing strings into components and we need to programatically create our components, we want to avoid using the dangerouslySetInnerHTML, for it is not a good design for this case.

On the extractPlugins function we first make sure that the textContent actually refers to a plugin component, by checking against the pluginRegEx. If it does matches, we then map each entry to an instance of its related plugin. We also normalise the props part of the plugin definition on the template, by parsing it into JSON.

When creating an instance of the plugin class, we cache all the defined props and plugin data into the pluginDataMap. This variable will be used as a data accessor/proxy layer, between the ContentEditor and the different plugin instances. That way we have one single source of data, which is easily required from the ContentEditor. And is mapped to be accessed by the plugin instances by the mapPluginData, which is represented by the setPluginData prop passed to the plugin instance.

The mapPluginData is simply the way that the plugins will communicate their data change upwards. By passing the pluginId and pluginData objects we simply overwrite any new values passed on that object into those found on the pluginDataMap.

With that, we have a working and capable Parser to use on our ContentEditor.

Creating the plugin base structure

Though I am not going to cover any of the specific plugin created for the demo project, such as the MDPlugin or the ImagePlugin, it is important to cover the very basics that a plugin instance should have.

The way we define the bare basics of a plugin is by creating a Higher Order Component. This component is going to be a transparent layer, that hides the common tasks/behaviours needed for a plugin to work.

import React, { Component, PropTypes } from 'react';  
import { findDOMNode } from 'react-dom';  
import { autobind } from 'core-decorators';

const updatedClass = 'plugin--updated';  
const dataSavedClass = 'plugin--data-saved';  
const hasFileClass = 'plugin--has-file';

export default function PluginConstructor(Plugin) {  
  return class extends Component {
    static propTypes = {
      className: PropTypes.string,
      setPluginData: PropTypes.func.isRequired,
      isPreviewing: PropTypes.bool.isRequired,
      pluginId: PropTypes.string.isRequired,
      pluginData: PropTypes.object.isRequired,
      isDataUpdated: PropTypes.bool.isRequired
    };

    constructor(props) {
      super(props);

      this.state = {
        editMode: false,
        pluginData: Object.assign({ markdown: '' }, this.props.pluginData)
      };
    }

    @autobind
    toggleEditMode() {
      this.setState({ editMode: !this.state.editMode });
    }

    @autobind
    updatePluginData({ pluginData, editMode = this.state.editMode }) {
      this.setState({ pluginData, editMode });
    }

    componentDidUpdate() {
      const { file } = this.state.pluginData;

      if (this.props.isDataUpdated && this.state.pluginData.isPluginUpdated && file === null) {
        const pluginElement = findDOMNode(this);
        const callback = function transitionendEventHandler() {
          pluginElement.classList.remove(updatedClass);
          pluginElement.classList.add(dataSavedClass);

          pluginElement.removeEventListener('transitionend', callback);
        };

        pluginElement.classList.add(updatedClass);
        pluginElement.classList.remove(hasFileClass);
        pluginElement.addEventListener('transitionend', callback, false);
      }
    }

    render() {
      const style = {
        padding: '1em',
        border: 'dashed 2px #E6E5E5',
        display: 'inline-block'
      };
      const pluginStyle = Object.assign({}, style, {
        border: ((this.props.isPreviewing) ? 'none' : style.border)
      });
      const { pluginId } = this.props;
      const { pluginData } = this.state;
      const { file = null } = pluginData;
      const className = `plugin ${this.props.className} ${file !== null ? hasFileClass : ''}`;

      this.props.setPluginData({ pluginData, pluginId });
      return (<Plugin
        {...this.props}
        {...this.state}
        className={className}
        toggleEditMode={this.toggleEditMode}
        updatePluginData={this.updatePluginData}
        style={pluginStyle}
      />);
    }
  };
}

This basic plugin implementation is going to abstract all the basic logic behind the Plugin - Parser communication, also it has defined the basic style for the plugin, which can be overwritten. It will return the composed plugin component to be used/rendered.

Creating the store

Now it is time to create our communication layer, between the ContentEditor and the server, in order to save any asset that the plugin may have as data. I decided, for the sake of experimentation, to create my own, down to the basics, store implementation. But it does not mean that it has to/ should be used, nor that it is the best implementation possible.

The way I decided that the store API should be designed is that it should be, mainly, an interface layer. The actual implementations should be created separately. What this allows is a bigger flexibility. By providing an unified top-level API to be used by the ContentEditor, masking any of the specifics done by the chosen store implementation (in this case plain HTTP, but it would be a wrapper for, lets say, Dropbox).

const NO_DATA = 'No data to send';

function httpSave({ data, config }) {  
  return new Promise((resolve, reject) => {
    if (data === null) reject({ cause: NO_DATA });
    const { endpoint, ...settings } = config;
    settings.body = data;

    fetch(endpoint, settings)
      .then((response) => resolve(response))
      .catch(reject);
  });
}

function store(adapter) {  
  function save({ rawData }) {
    const { save: { config, serialize, normalize } } = adapter;
    return serialize(rawData)
      .then(({ data, pluginIds }) => httpSave({ data, config })
      .then(normalize)
      .then((response) => {
        const json = response;
        json.pluginId = json.pluginId || pluginIds[0];
        return json;
      }));
  }

  return {
    save
  };
}

export {  
  store
};

The store provides a save function, which in turns uses the implementation serialize and normalize helpers for the method in order to parse the data and response.

Let's take a look on the HTTP implementation for the store:

export default {  
  save: {
    serialize(data) {
      return new Promise((resolve, reject) => {
        const formData = new FormData();
        let pluginId = null;

        data.forEach(({ pluginData = {}, pluginId: id }) => {
          const { file } = pluginData;
          if (typeof file === 'undefined'
            || file === null
            || typeof file.name === 'undefined') {
            return;
          }

          formData.append('pluginId', id);
          formData.append('asset', file);
          pluginId = id;
        });

        if (typeof formData.entries().next().value === 'undefined') reject();

        resolve({ data: formData, pluginId });
      });
    },

    normalize(response) {
      return response.json();
    },

    config: {
      endpoint: '/asset',
      method: 'POST'
    }
  }
};

With the store in place, we will have to update our plugin data, once the asset is properly saved on the server. For that we will create the updatePluginData to iterate over our pluginDataMap on the Parser and update our plugins accordingly.

  // Run though the pluginDataMap matching the pluginId. Once found update the plugin data
  // with the passed value.
  updatePluginData({ pluginId: targetId, value }) {
    pluginDataMap.forEach((plugin, index) => {
      const { pluginId, pluginData } = plugin;
      let isTarget = false;
      let pluginValueIndex = null;

      // The targetId can be either an object or array, depending on the number of plugins
      // that sent data to be saved
      if (typeof targetId.push !== 'undefined') {
        isTarget = targetId.includes(pluginId);
        pluginValueIndex = targetId.indexOf(pluginId);
      } else {
        isTarget = pluginId === targetId;
      }

      pluginDataMap[index].pluginData.isPluginUpdated = false;

      if (isTarget) {
        // Update the desired entry on the pluginData, indicated by the pluginData.key property
        // The value property can be either an array or object, depending on the number of plugins
        // that sent data to be saved
        const newValue = (pluginValueIndex !== null) ? value[pluginValueIndex] : value;
        pluginData[pluginData.key] = newValue;
        // Remove the file from memory after updating the plugin data
        pluginData.file = null;
        pluginData.isPluginUpdated = true;
        pluginDataMap[index].pluginData = pluginData;
      }
    });
  }

With that, our plugin will be properly updated on the next reconciliation after the render pass.

Back to the ContentEditor

Now it is time to use the all mighty Parser on the ContentEditor so we are able to use all the goodies that we just implemented. But first, we want to implement a compiler function to convert the Markdown from the plugin data into HTML. So, on the Parser, let's create the compileTemplate function.

  // Compiles the template by matching the plugin matches with the pluginRegEx and
  // parsing their Markdown content with marked
  compileTemplate({ template }) {
    let pluginIndex = 0;

    return template.replace(pluginRegEx, () => {
      const { markdown = '' } = pluginDataMap[pluginIndex].pluginData;
      const pluginMarkdown = (typeof markdown === 'function'
        ? markdown(pluginDataMap[pluginIndex])
        : markdown
      );
      pluginIndex++;

      return marked(pluginMarkdown);
    });
  }

The function is designed to have our pluginData.markdown being either a string or a function. The reason why is that functions are an easier way, for plugins that will save assets, to provide an accurate markup representation of the volatile data.

Now let's bring all of this into the ContentEditor

import React, { Component, PropTypes } from 'react';  
import { Parser } from './parser';  
import { autobind } from 'core-decorators';

export default class ContentEditor extends Component {  
  static propTypes = {
    template: PropTypes.string,
    componentsStyle: PropTypes.string,
    store: PropTypes.object,
    onSave: PropTypes.func
  };

  constructor(props) {
    super(props);

    this.state = { isPreviewing: false, isDataUpdated: false };
  }

  @autobind
  togglePreview() {
    const isPreviewing = !this.state.isPreviewing;
    this.setState({ isPreviewing, isDataUpdated: false });
  }

  @autobind
  compileTemplate() {
    const { template } = this.props;
    this.props.onSave(Parser.compileTemplate({ template }));
  }

  @autobind
  saveData() {
    const pluginDataMap = Parser.getPluginData();

    this.props.store.save({ rawData: pluginDataMap })
      .then(({ pluginId, path: value }) => {
        Parser.updatePluginData({ pluginId, value });
        this.setState({ isDataUpdated: true });
      });
  }

  render() {
    const { template: rawTemplate, componentsStyle: style } = this.props;
    const template = rawTemplate.replace(/\n|(\s{1,}(?=<))/g, '');
    const editorElements = Parser.getChildrenNodes({
      template,
      style,
      props: this.state
    });

    const className = `editor-wrapper${this.state.isPreviewing ? ' editor-wrapper--preview' : ''}`;

    return (
      <div className={className}>
        <div className="control-bar">
          <button onClick={ this.togglePreview }>Toggle Preview</button>
          <button onClick={ this.saveData }>Save Data</button>
          <button onClick={ this.compileTemplate }>Preview result</button>
        </div>
        { editorElements }
      </div>
    );
  }
}

Finalising

On the GitHub page for the example project you can find much more details on the implementation for this ContentEditor and it's pre-existing plugins. The idea behind this implementation is to make it easy and transparent for the ContentEditor to use any existing, or future, plugins that one might create. All one has to do is to use the pluginConstructor call, and follow the example implementations, to create any new plugins to use.

One thing that I would like to get improved for this existing version is a better RegEx for the plugin syntax. For now it is too strict, due to my limitations with RegEx.

Another observation that is worth sharing is that, after spending time with the native implementations and VanillaJS/ES6/7, I value much more the self imposed limitation for using external libraries such as jQuery. Specially for learning purposes.

That's all folks!