Asko Nõmm

Asko Nõmm

Explorations in creating a basic text editor: cursor placement

In the previous post I discussed the underlying foundations on top of which I’ll be building my text editor, from the data structure I chose to the Web Components I built. In this blog post I’ll be making the cursor component actually do something.

Showing the cursor

Since we already made the cursor component in the last blog post, all I have to do now is just display it, and I designed this to be as easy as simply shoving a new node with a type: cursor into the content nodes array, like this:

const nodes = [
      {id: uuidv4(), type: 'cursor'} // <--- cursor is here
      {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'},
        ]
      }
];

And that on its own is enough to show a cursor when rendered with the paragraph-group component.

Creating a root component that holds state

Just showing the cursor isn’t much because I also want to interact with it by changing where it is – like pointing and clicking with a mouse and placing the cursor where I clicked. To do this I need to start having some state and re-render things when that state changes, as well as traversing the tree of nodes to remove the existing cursor (if there is one) and add a new one in.

To do all of this, I made a new component called paragraph-block, and this will be the root component of the entire editor which holds the state and does all the data manipulation. To start off, I need to store the cursor location, so the basic paragraph-block component looks like this:

export class ParagraphBlock extends LitElement {
  static properties = {
    cursorPosition: {type: String, state: true, attribute: false},
    content: {type: Array, state: true, attribute: false}
  }
}

The cursorPosition will be either "0" or a UUID pointing to a node in the nodes tree where I render the cursor. To make use of this data, I first we need to traverse the nodes tree and remove the existing cursor, which I do with a class method like this:

export class ParagraphBlock extends LitElement {
  /**
   * Traverses the content tree and removes the cursor node
   *
   * @param content
   * @returns {*[]}
   */
  traverseContentTreeAndRemoveCursorNode(content) {
    let newContent = [];

    for (let i = 0; i < content.length; i++) {
      const item = content[i];

      // chars
      if (item.type === 'char') {
        newContent.push(item);
      }

      // groups
      if (item.type === 'group') {
        newContent.push({
          ...item,
          content: this.traverseContentTreeAndRemoveCursorNode(item.content)
        })
      }
    }

    return newContent;
  }
}

It’s a simple recursive method that creates a new array of nodes that effectively filters out the cursor node. To add the cursor node back, and this time to a new location, I use a method like this:

export class ParagraphBlock extends LitElement {
  /**
   * Traverses the content tree and adds the cursor node
   *
   * @param content
   * @returns {*[]}
   */
  traverseContentTreeAndAddCursorNode(content) {
    if (this.cursorPosition === "0") {
      return [...content, {id: uuidv4(), type: 'cursor'}];
    }

    let newContent = [];

    for (let i = 0; i < content.length; i++) {
      const item = content[i];

      // chars
      if (item.type === 'char') {
        if (item.id === this.cursorPosition) {
          newContent.push({id: uuidv4(), type: 'cursor'});
        }

        newContent.push(item);
      }

      // groups
      if (item.type === 'group') {
        newContent.push({
          ...item,
          content: this.traverseContentTreeAndAddCursorNode(item.content)
        })
      }
    }

    return newContent;
  }
}

This, again, is a recursive method which goes through the entire tree until it finds the node corresponding to cursorPosition and creates the cursor node before that node. If however the cursorPosition is 0, it will add the cursor to the end of the tree.

And now I need to listen to state changes to cursorPosition so that I would know when to do the traversing and cursor position replacement. In Lit I can do it inside of the willUpdate method, like so:

export class ParagraphBlock extends LitElement {
  willUpdate() {
    const content = this.traverseContentTreeAndRemoveCursorNode(this.content);
    this.content = this.traverseContentTreeAndAddCursorNode(content);
  }
}

This can be further optimized by attempting to remove the cursor node when adding it to the new place, because if it turns out the old cursor is before the new one, I can remove it in one pass instead of two, which is obviously more efficient, but optimizations will probably be a blog post on its own entirely at some point in the future.

Anyway, you can now change this.cursorPosition with the ID of a node, and the cursor will render accordingly on its own. But, I also want to point and click, right? For that I need to look into manipulating the state of the root component from the char child component I created in the previous blog post.

Using context to update parent state

Lit provides the ability to use context to share state to child components without passing it down via attributes and the ability to update parent state from the child, similar to how Context hooks in React.js work. Specifically, I’ll be using the ContextProvider and ContextConsumer classes.

First I’m creating a separate contexts.js file where I will create the context object so that I can easily import it in multiple components, and in it I put:

export const cursorPosition = createContext(Symbol('cursorPosition'));

Simple as that. Then, in the root component I create a provider:

export class ParagraphBlock extends LitElement {
  cursorProvider = new ContextProvider(this, {context: cursorPosition});
}

But I also want to set an initial value, and that I’m setting as an object holding the actual location ID as well as a setValue callback function, because I want to do some custom stuff with it, so in the constructor I’m calling:

export class ParagraphBlock extends LitElement {
  constructor() {
    this.cursorProvider.setValue({
      value: 0,
      setValue: this.cursorProviderUpdateHandle,
    });
  }
}

Where the cursorProviderUpdateHandle is:

export class ParagraphBlock extends LitElement {
  cursorProviderUpdateHandle = (value) => {
    this.cursorProvider.setValue({
      value,
      setValue: (value, right = false) => {
        if (right) {
          const rightValue = this.computeTreeNodeIdRightOf(value);
          this.cursorProvider.setValue({rightValue, setValue: this.cursorProviderUpdateHandle});
          this.cursorPosition = rightValue;
        } else {
          this.cursorProvider.setValue({value, setValue: this.cursorProviderUpdateHandle});
          this.cursorPosition = value;
        }
      }
    });
  }
}

And in it the computeTreeNodeIdRightOf, which like the name suggests computes what is the ID of a node next to a given ID, is:

export class ParagraphChar extends LitElement {
  computeTreeNodeIdRightOf(id) {
    const charNodes = this.content.flatMap(charNodeFlattenFn).filter((item) => item?.type === 'char');
    const foundIndex = charNodes.findIndex((item) => item?.id === id);

    if (foundIndex === -1) {
      return "0";
    }

    if (foundIndex === charNodes.length - 1) {
      return "0";
    }

    return charNodes[foundIndex + 1].id;
  }
}

So now in the child component I can just create a instance of ContextConsumer and then call setValue(nodeId) to place the cursor before the nodeId and setValue(nodeId, true) to set the cursor after nodeId.

Listening to clicks in the char component

I now have everything in place to update the char component so that it can listen to clicks and then position the cursor. First I’ll create the consumer, like this:

export class ParagraphChar extends LitElement {
  cursorConsumer = new ContextConsumer(this, {context: cursorPosition, subscribe: true});
}

And then in the render method I just make sure to add a click attribute:

export class ParagraphChar extends LitElement {
  render() {
    return html`<span class="char" @click="${this.setPosition}">
        ${this.value === ' ' ? html`&nbsp;` : this.value}
      </span>
    `;
  }
}

Where the setPosition just calls cursorConsumer.value.setValue(this.id);. And this, finally, will make it so that I can click and place the cursor location.

But, there’s one problem: the cursor will always be added to the left side, no matter if you clicked on the right-half side of the character or not. This is not ideal because it creates an experience different from other text editors as well as makes it impossible to place the cursor after the last character.

Determining on which side of the char you clicked

There’s a pretty easy “hack” to figure out on which side of the character you clicked, and that’s to create two absolute positioned spans to overlay the char component, one 50% wide on the left side and one 50% wide on the right side, and then instead of attaching the click attribute to the entire component I’ll just attach one attribute per side with a different callback.

In the above image green is the left side of a character and red is the right side, and so by doing that I end up with code like this:

export class ParagraphChar extends LitElement {
  setPositionLeft() {
    this.cursorConsumer.value.setValue(this.id);
  }

  setPositionRight() {
    this.cursorConsumer.value.setValue(this.id, true);
  }

  render() {
    return html`<span class="char">
        ${this.value === ' ' ? html`&nbsp;` : this.value}
        <span @click="${this.setPositionLeft}" class="left-side"></span>
        <span @click="${this.setPositionRight}" class="right-side"></span>
    </span>`;
  }
}

And woalaa, now the cursor will be placed in the right place. There’s more work that can be done here, such as changing the cursor location when you navigate using the arrow keys < and>, but going over that here would get a bit too dense and so just refer to the paragraph.js file to find out how I’m doing that.