Back to Home

Building a text editor: The pieces

Text editors have a lot of moving parts, but at their core they're made up of these four pieces:

Buffer

The buffer stores all the text. Every edit is made to it, so it can tell other components what changed.

class Buffer {
  lines: string[];

  constructor(text: string) {
    this.lines = text.split("\n");
  }

  replace(from: Point, to: Point, text: string) {
    // ...
  }
}

const buffer = new Buffer("Hello\nworld");
buffer.replace({ line: 0, column: 5 }, { line: 0, column: 5 }, ","); // ["Hello,", "world"]
buffer.replace({ line: 0, column: 6 }, { line: 1, column: 0 }, " "); // ["Hello, world"]

replace handles every edit. Typing inserts text between two identical positions, Backspace replaces one character with nothing, and paste replaces the selection with the content of the clipboard. Since nothing else can change the text, the rest of the editor only has to watch one place.

Cursor

The cursor holds where the user is in the text. The blinking caret is just the cursor drawn on screen.

class Cursor {
  position: Point = { line: 0, column: 0 };

  moveRight() {
    const { line, column } = this.position;
    this.position = { line, column: column + 1 };
  }
}

The position is a line and a column, not pixels. The cursor doesn't know where it is on screen. Working that out is the view's job. Moving it is trickier than the snippet shows: at the end of a line, moving right has to jump to the next one. Some editors even allow multiple cursors to be active simultaneously.

Selection

The area of text selected by the user, by dragging, shift+clicking or shift with the arrow keys. Every cursor has one. When nothing is selected, the selection's two ends are the same point, so the selection is collapsed and the user just sees a caret.

class Selection {
  anchor: Point; // where the selection started
  focus: Point; // where it is now, the cursor's position

  get isCollapsed() {
    return this.anchor.line === this.focus.line && this.anchor.column === this.focus.column;
  }
}

In practice, the cursor holds a selection, and its position is just the focus:

class Cursor {
  selection: Selection;

  get position() {
    return this.selection.focus;
  }
}

View

The view is what the user sees: a picture of the editor's state, meaning the text, the cursors and their selections. It's also responsible for listening to user input and passing it back to the editor, which holds the other three pieces:

class Editor {
  buffer: Buffer;
  cursors: Cursor[];

  insertText(text: string) {
    for (const cursor of this.cursors) cursor.insert(text); // calls buffer.replace on its selection
  }
}

The Editor doesn't know whether it ends up as <div>s, SVG or pixels on a canvas. A view just reads from it and draws. With the DOM, that's one element per line:

function DomView({ editor }) {
  return editor.buffer.lines.map((text, i) => <div key={i}>{text}</div>);
}

On a canvas, it's one fillText per line, placed by hand:

function drawCanvas(ctx: CanvasRenderingContext2D, editor: Editor) {
  ctx.textBaseline = "top";
  editor.buffer.lines.forEach((text, i) => {
    ctx.fillText(text, 0, i * lineHeight);
  });
}

So you can swap the view without touching anything else, and the text, cursors and selections stay exactly where they were.

Editor playground
buffer.lines = [ "Hello", "world", ] cursor.selection.anchor = { line: 0, column: 0 } cursor.selection.focus = { line: 0, column: 0 } cursor.selection.isCollapsed = true
Type, click or select to see the state the view draws from.

Of course, there's more to it than this: undo and redo, search, syntax highlighting, plugins, collaboration, and keeping it fast on huge files. But most of that is built on top of these four pieces.

I built one from scratch as a side project while working on Clivo, mostly to understand how editors work. Most people don't need to since there are a lot of solid open source options available, but it's a good way to see what's underneath.