Asko Nõmm

Asko Nõmm

Explorations in creating a basic text editor: the foundation

As part of the development of Sember I’ve long thought I’d like to try create a text editor of my own, completely from scratch, that would then be used as the Paragraph block in the post editor. Currently that role is played by the Markdown block, which is how I’m writing this very blog post, but I personally always liked visual editing a lot more than Markdown, it’s just that Markdown was a quick and dirty way to get to writing blog posts in Sember.

Oh and by text editor I don’t mean using textarea or contentEditable, I mean a completely from-scratch text editor, all with rendering individual nodes, cursor placement, grouping and node management via listening to key presses (to actually be able to type stuff) and so on. But, how? I’ve absolutely no idea, so here start a series of explorations in creating a basic text editor.

Data structure

The very first thing I’m thinking about is the underlying data structure. I know I need to store each character separately, because I need to be able to set individual characters as bold, italic or be a link. I’m thinking perhaps there’s 3 types of nodes: a cursorchar and group, where the cursor node is the visible cursor indicating the active place in the node tree, the char would be an individual character and a group would be other char‘s or group‘s, recursively.

An example of this data structure would be this:

const nodes = [
      {id: uuidv4(), type: 'char', value: 'H'},
      {id: uuidv4(), type: 'char', value: 'e'},
      {id: uuidv4(), type: 'char', value: 'l'},
      {id: uuidv4(), type: 'char', value: 'l'},
      {id: uuidv4(), type: 'char', value: 'o'},
      {id: uuidv4(), type: 'char', value: ','},
      {id: uuidv4(), type: 'char', value: ' '},
      {
        id: uuidv4(),
        type: 'group',
        groupType: 'bold',
        content: [
          {id: uuidv4(), type: 'char', value: 'W'},
          {id: uuidv4(), type: 'char', value: 'o'},
          {id: uuidv4(), type: 'char', value: 'r'},
          {id: uuidv4(), type: 'char', value: 'l'},
          {id: uuidv4(), type: 'char', value: 'd'},
        ]
      }
];

The group node type is what allows to make things bold, italic or linked. A group can contain other groups, which would allow to make things like bold links or bold italic links. There really shouldn’t be any limit here.

Each node has its own unique id, which I’ll be using to be able to position the cursor (either before or after a certain node) as well as being able to delete and add nodes.

The char component

The very first thing I’m creating is the char component which is responsible for rendering individual characters. Every component I will be creating in this blog post is solely to be able to render our data structure visually to the user in such a way that it looks like normal text. Before I can learn to manipulate the data, I have to figure out how to display the data. Walk before run, y’know.

Part of the Sember development plan is to make things as vanilla as possible, so that means avoiding any sort of build step as much as I can, and thankfully I can avoid it quite a bit thanks to modern ES modules and the Lit framework, which makes creating Web Components a breeze, and Web Components are what I’ll be using to make this entire thing work.

With that all said, a char component looks like this:

import {LitElement, html, css} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';

export class ParagraphChar extends LitElement {
  static properties = {
    value: {}
  }

  static styles = css`
      .char {
          display: inline-block;
          position: relative;
          user-select: none;
      }
  `;

  constructor() {
    super();
  }

  // Render the UI as a function of component state
  render() {
    return html`<span class="char">
        ${this.value === ' ' ? html`&nbsp;` : this.value}
    </span>`;
  }
}


customElements.define('paragraph-char', ParagraphChar);

This allows me to render characters by writing:

<paragraph-char value="A"></paragraph-char>

Now you may be wondering why do I need an entirely separate component just to render a single character, but the reason for that is actually explained in the next blog post and has everything to do with cursor placement, so stay tuned for that.

The cursor component

Even though I won’t be going into cursor placement in this blog post yet, next comes the cursor component anyway because it helps illustrate how the rendering works. The cursor components job is mostly cosmetic, but lets the user know where in the node tree we are and thus is quite important. The cursor component looks like this:

import {LitElement, html, css} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';

export class ParagraphCursor extends LitElement {
  static properties = {}

  static styles = css`
      @keyframes blink {
          0% {
              opacity: 1;
          }
          50% {
              opacity: 0;
          }
          100% {
              opacity: 1;
          }
      }
      
      span {
          height: calc(100% - 4px);
          width: 1px;
          background: #111;
          position: absolute;
          margin-left: -1px;
          animation: blink 1s infinite;
          margin-top: 2px;
      }
  `;

  constructor() {
    super();
  }

  render() {
    return html`<span></span>`;
  }
}

customElements.define('paragraph-cursor', ParagraphCursor);

That’s right, it’s just a empty span element that draws a 1px wide and almost full height rectangle that blinks every 1s.

Putting it all together with the group component

The group component is arguably the most important part here, because it enables the recursive nature that makes having stylized or enhanced char nodes possible, and yet the group component is rather simple to make:

import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';
import './paragraph-cursor.js';
import './paragraph-char.js';

export class ParagraphGroup extends LitElement {
  static properties = {
    type: {}
    content: {}
  }

  constructor() {
    super();
  }

  render() {
    const out = html`${this.content.map((item) => {
      switch(item.type) {
        case 'cursor':
          return html`<paragraph-cursor></paragraph-cursor>`;
        case 'char':
          return html`<paragraph-char id=${item.id} .value=${item.value}></paragraph-char>`;
        case 'group':
          return html`<paragraph-group id=${item.id} type=${item.groupType} .content=${item.content}></paragraph-group>`;
      }
    })}`

    switch(this.type) {
      case 'bold':
        return html`<strong>${out}</strong>`;
      case 'italic':
        return html`<em>${out}</em>`;
      default:
        return out;
    }
  }
}

customElements.define('paragraph-group', ParagraphGroup);

This only supports bold and italic for now, but it’s a good start, and adding additional group support is as easy as adding a new switch case. And so with the paragraph-group component made, I can now start rendering things, which in essence is as simple as taking the node tree I outlined in the beginning of this blog post and then just:

<paragraph-group content="${this.nodes}"></paragraph-group>

Now it will render everything on its own, recursively. Though obviously that’s not enough and to actually make use of the cursor node I need to start managing some state and traversing the tree quite a bit in many different ways.