Skip to main content

Building a markdown editor

·4 mins

Text editing is a surprisingly tricky problem. Each editor has its own quirks and issues, even after many build iterations.

We’re going to explore what a basic Markdown editor implementation might look like. It should handle text entry, undo/redo, and showing a live preview.

We’ll try out a few patterns to make this work: the Command Pattern for operations, smart batching for logical undo units, and reactive state management for real-time preview updates.

Approach #

Command Pattern for everything #

Every text operation becomes a command object with a simple contract: it knows how to execute itself and how to undo what it did.

This gives us some nice benefits: every operation behaves consistently for undo/redo, you can compose complex operations from simple ones, and there’s a clean separation between what an operation does and how it gets managed.

Rather than directly modifying text, the editor creates command objects for insertions, deletions, and markdown formatting. Each command encapsulates both the forward operation and its reversal.

Smart batching system #

The interesting part is intelligent command batching. Instead of creating separate undo operations for each keystroke, the system groups related edits into logical chunks.

The batching system looks at a few things to decide when to start a new batch:

  • Time-based: 700ms pause automatically finalizes a batch
  • Action type changes: Switching from typing to formatting breaks the batch
  • Position jumps: Moving the cursor to a different location starts a new batch
  • Manual triggers: Operations like paste or blur force finalization

The result feels much more natural—pressing Ctrl+Z undoes logical editing units instead of individual characters.

Live preview #

The editor has a split-pane setup with real-time Markdown rendering. Type in the left pane, and the right pane updates instantly with the rendered HTML.

The preview updates through Svelte’s reactive stores. When the editor state changes, any subscribed components automatically re-render with the new content. All the markdown parsing happens client-side using a component-based renderer.

Building the command system #

Text editing commands #

Basic text operations work through insert and delete commands that capture exactly what changed and where:

Text command implementation
export class InsertTextCommand implements Command {
  constructor(
    private editorState: EditorState,
    private position: number,
    private text: string
  ) {}

  execute(): void {
    this.editorState.insertText(this.position, this.text);
    this.editorState.setCursorPosition(this.position + this.text.length);
  }

  undo(): void {
    this.editorState.deleteText(this.position, this.position + this.text.length);
    this.editorState.setCursorPosition(this.position);
  }
}

These commands store just what they need for reversal while keeping cursor positioning accurate. The editor state is the single source of truth for both content and cursor position.

Markdown formatting operations #

Markdown commands handle syntax insertion and formatting toggles. They’re smart enough to detect existing formatting and either apply it or remove it:

Bold formatting command
export class MarkdownBoldCommand implements Command {
  execute(): void {
    const selectedText = this.editorState.getSelectedText();

    // Check if already bold
    const wasFormatted = selectedText.startsWith('**') && selectedText.endsWith('**');

    if (wasFormatted) {
      // Remove bold formatting
      const unwrappedText = selectedText.slice(2, -2);
      this.editorState.replaceSelection(unwrappedText);
    } else {
      // Add bold formatting
      this.editorState.replaceSelection(`**${selectedText}**`);
    }
  }
}

These commands work with text selections and handle the edge cases—like toggling existing formatting or inserting syntax at the cursor position when nothing’s selected.

Reactive state management #

Svelte stores for coordination #

The editor uses Svelte’s reactive stores to keep components in sync. The editor state exposes stores for content, cursor position, and preview content:

State management setup
export class EditorState {
  private _content = writable('');
  private _cursorPosition = writable(0);

  get content() { return this._content; }
  get cursorPosition() { return this._cursorPosition; }

  insertText(position: number, text: string): void {
    this._content.update(content =>
      content.slice(0, position) + text + content.slice(position)
    );
  }
}

This reactive setup keeps the UI in sync with the editor state without any manual event handling. Content changes, and the preview just updates automatically through store subscriptions.

Integration with command manager #

The text editor component bridges user input to the command system by figuring out what type of change happened and creating the right commands:

Input handling integration
function handleInput(event: Event) {
  const newContent = (event.target as HTMLTextAreaElement).value;
  const cursorPos = target.selectionStart || 0;

  if (newContent.length > $contentStore.length) {
    // Text was inserted
    const insertPos = cursorPos - (newContent.length - $contentStore.length);
    const insertedText = newContent.slice(insertPos, cursorPos);
    const command = new InsertTextCommand(editorState, insertPos, insertedText);
    commandManager.executeCommand(command, CommandType.INSERT, cursorPos);
  } else if (newContent.length < $contentStore.length) {
    // Text was deleted
    const deleteStart = cursorPos;
    const deleteEnd = deleteStart + ($contentStore.length - newContent.length);
    const command = new DeleteTextCommand(editorState, deleteStart, deleteEnd);
    commandManager.executeCommand(command, CommandType.DELETE, deleteStart);
  }
}

This keeps everything feeling snappy while building up the command history for solid undo/redo functionality.

Wrapping up #

We intentionally didn’t make this feature-rich, but the modular architecture makes it pretty straightforward to add more to it. There’s a solid foundation here for future features like collaborative editing, document persistence, or fancier markdown support.

The complete implementation is available in the repository here.

You can also see the live demo here!