Skip to main content

Overview

Editor extensions allow you to extend CodeMirror’s functionality with custom behavior, decorations, and interactions.
import { ViewPlugin } from '@codemirror/view';

const myExtension = ViewPlugin.fromClass(class {
  constructor(view: EditorView) {
    // Initialize
  }
  
  update(update: ViewUpdate) {
    // Handle updates
  }
  
  destroy() {
    // Clean up
  }
});

this.registerEditorExtension(myExtension);

Registration

registerEditorExtension
(extension: Extension) => void
Register a CodeMirror extension.
export default class MyPlugin extends Plugin {
  async onload() {
    this.registerEditorExtension(myCustomExtension);
  }
}
Extensions are automatically removed when your plugin unloads.

CodeMirror Imports

You’ll need to install CodeMirror packages:
npm install @codemirror/state @codemirror/view
Common imports:
import { EditorView, ViewPlugin, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
import { Extension, RangeSetBuilder } from '@codemirror/state';

View Plugins

View plugins observe and react to editor changes.

Basic View Plugin

import { ViewPlugin, EditorView } from '@codemirror/view';

const myViewPlugin = ViewPlugin.fromClass(class {
  constructor(view: EditorView) {
    console.log('Plugin initialized');
  }

  update(update: ViewUpdate) {
    if (update.docChanged) {
      console.log('Document changed');
    }
    if (update.selectionSet) {
      console.log('Selection changed');
    }
  }

  destroy() {
    console.log('Plugin destroyed');
  }
});

this.registerEditorExtension(myViewPlugin);

View Plugin with Decorations

import { ViewPlugin, Decoration, DecorationSet } from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';

const highlightPlugin = ViewPlugin.fromClass(
  class {
    decorations: DecorationSet;

    constructor(view: EditorView) {
      this.decorations = this.buildDecorations(view);
    }

    update(update: ViewUpdate) {
      if (update.docChanged || update.viewportChanged) {
        this.decorations = this.buildDecorations(update.view);
      }
    }

    buildDecorations(view: EditorView): DecorationSet {
      const builder = new RangeSetBuilder<Decoration>();
      const text = view.state.doc.toString();
      const regex = /TODO/g;
      let match;

      while ((match = regex.exec(text)) !== null) {
        const from = match.index;
        const to = from + match[0].length;
        const deco = Decoration.mark({
          class: 'cm-todo-highlight',
        });
        builder.add(from, to, deco);
      }

      return builder.finish();
    }
  },
  {
    decorations: (v) => v.decorations,
  }
);

this.registerEditorExtension(highlightPlugin);

Decorations

Decorations add visual elements to the editor.

Mark Decorations

Highlight text ranges:
// Highlight selection
const markDeco = Decoration.mark({
  class: 'cm-highlight',
  attributes: { 'data-type': 'highlight' },
});
builder.add(from, to, markDeco);

Widget Decorations

Insert custom DOM elements:
class LineNumberWidget extends WidgetType {
  constructor(private lineNumber: number) {
    super();
  }

  toDOM() {
    const span = document.createElement('span');
    span.className = 'cm-line-number';
    span.textContent = String(this.lineNumber);
    return span;
  }
}

const widgetDeco = Decoration.widget({
  widget: new LineNumberWidget(10),
  side: -1, // Before the position
});
builder.add(pos, pos, widgetDeco);

Replace Decorations

Replace text with a widget:
class EmojiWidget extends WidgetType {
  constructor(private emoji: string) {
    super();
  }

  toDOM() {
    const span = document.createElement('span');
    span.textContent = this.emoji;
    span.className = 'cm-emoji';
    return span;
  }
}

const replaceDeco = Decoration.replace({
  widget: new EmojiWidget('😄'),
});
builder.add(from, to, replaceDeco);

Line Decorations

Style entire lines:
const lineDeco = Decoration.line({
  class: 'cm-active-line',
});
builder.add(lineStart, lineStart, lineDeco);

Emoji Plugin Example

From the built-in Emoji plugin:
import { Plugin } from '@inkdown/api';
import { EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { GITHUB_EMOJI_MAP } from './emojiMap';

const EMOJI_REGEX = /:([a-z0-9_+-]+):/gi;

class EmojiWidget extends WidgetType {
  constructor(private readonly emoji: string) {
    super();
  }

  toDOM(): HTMLElement {
    const span = document.createElement('span');
    span.textContent = this.emoji;
    span.className = 'cm-emoji-widget';
    span.style.cursor = 'default';
    return span;
  }

  eq(other: EmojiWidget): boolean {
    return other.emoji === this.emoji;
  }
}

function buildDecorations(view: EditorView): DecorationSet {
  const builder = new RangeSetBuilder<Decoration>();
  const visibleRanges = view.visibleRanges;
  const selection = view.state.selection;

  for (const { from, to } of visibleRanges) {
    const text = view.state.sliceDoc(from, to);
    EMOJI_REGEX.lastIndex = 0;

    let match;
    while ((match = EMOJI_REGEX.exec(text)) !== null) {
      const emojiCode = match[1];
      const emoji = GITHUB_EMOJI_MAP[emojiCode.toLowerCase()];

      if (emoji) {
        const start = from + match.index;
        const end = start + match[0].length;

        // Don't decorate if cursor is inside
        let overlaps = false;
        for (const range of selection.ranges) {
          if (range.from <= end && range.to >= start) {
            overlaps = true;
            break;
          }
        }

        if (!overlaps) {
          const deco = Decoration.replace({
            widget: new EmojiWidget(emoji),
          });
          builder.add(start, end, deco);
        }
      }
    }
  }

  return builder.finish();
}

export default class EmojiPlugin extends Plugin {
  async onload() {
    // Markdown post processor
    this.registerMarkdownPostProcessor((el, ctx) => {
      el.replaceText(EMOJI_REGEX, (match, code) => {
        const emoji = GITHUB_EMOJI_MAP[code.toLowerCase()];
        return emoji || match;
      });
    });

    // Editor extension
    const emojiExtension = ViewPlugin.fromClass(
      class {
        decorations: DecorationSet;

        constructor(view: EditorView) {
          this.decorations = buildDecorations(view);
        }

        update(update: ViewUpdate) {
          if (update.docChanged || update.viewportChanged || update.selectionSet) {
            this.decorations = buildDecorations(update.view);
          }
        }
      },
      {
        decorations: (v) => v.decorations,
      }
    );

    this.registerEditorExtension(emojiExtension);
  }
}

Common Patterns

Highlight Current Line

const currentLineHighlight = ViewPlugin.fromClass(
  class {
    decorations: DecorationSet;

    constructor(view: EditorView) {
      this.decorations = this.buildDecorations(view);
    }

    update(update: ViewUpdate) {
      if (update.selectionSet) {
        this.decorations = this.buildDecorations(update.view);
      }
    }

    buildDecorations(view: EditorView) {
      const builder = new RangeSetBuilder<Decoration>();
      const line = view.state.doc.lineAt(view.state.selection.main.head);
      const deco = Decoration.line({ class: 'cm-active-line' });
      builder.add(line.from, line.from, deco);
      return builder.finish();
    }
  },
  { decorations: (v) => v.decorations }
);

Show Line Numbers

class LineNumberWidget extends WidgetType {
  constructor(private num: number) {
    super();
  }

  toDOM() {
    const el = document.createElement('span');
    el.className = 'cm-line-number';
    el.textContent = String(this.num);
    return el;
  }
}

const lineNumbers = ViewPlugin.fromClass(
  class {
    decorations: DecorationSet;

    constructor(view: EditorView) {
      this.decorations = this.buildDecorations(view);
    }

    update(update: ViewUpdate) {
      if (update.docChanged || update.viewportChanged) {
        this.decorations = this.buildDecorations(update.view);
      }
    }

    buildDecorations(view: EditorView) {
      const builder = new RangeSetBuilder<Decoration>();
      for (const { from, to } of view.visibleRanges) {
        const firstLine = view.state.doc.lineAt(from).number;
        const lastLine = view.state.doc.lineAt(to).number;

        for (let i = firstLine; i <= lastLine; i++) {
          const line = view.state.doc.line(i);
          const deco = Decoration.widget({
            widget: new LineNumberWidget(i),
            side: -1,
          });
          builder.add(line.from, line.from, deco);
        }
      }
      return builder.finish();
    }
  },
  { decorations: (v) => v.decorations }
);

Trailing Whitespace Indicator

const trailingWhitespace = ViewPlugin.fromClass(
  class {
    decorations: DecorationSet;

    constructor(view: EditorView) {
      this.decorations = this.buildDecorations(view);
    }

    update(update: ViewUpdate) {
      if (update.docChanged || update.viewportChanged) {
        this.decorations = this.buildDecorations(update.view);
      }
    }

    buildDecorations(view: EditorView) {
      const builder = new RangeSetBuilder<Decoration>();
      const doc = view.state.doc;

      for (const { from, to } of view.visibleRanges) {
        for (let pos = from; pos <= to; ) {
          const line = doc.lineAt(pos);
          const text = line.text;
          const match = text.match(/(\s+)$/);

          if (match) {
            const start = line.from + text.length - match[1].length;
            const end = line.from + text.length;
            const deco = Decoration.mark({
              class: 'cm-trailing-whitespace',
            });
            builder.add(start, end, deco);
          }

          pos = line.to + 1;
        }
      }

      return builder.finish();
    }
  },
  { decorations: (v) => v.decorations }
);

Best Practices

Only Process Visible Content

// ✅ Good - use visibleRanges
buildDecorations(view: EditorView) {
  const builder = new RangeSetBuilder<Decoration>();
  for (const { from, to } of view.visibleRanges) {
    const text = view.state.sliceDoc(from, to);
    // Process only visible text
  }
  return builder.finish();
}

// ❌ Bad - process entire document
buildDecorations(view: EditorView) {
  const text = view.state.doc.toString(); // Entire document!
  // ...
}

Avoid Expensive Operations

// ✅ Good - efficient updates
update(update: ViewUpdate) {
  if (update.docChanged || update.viewportChanged) {
    this.decorations = this.buildDecorations(update.view);
  }
}

// ❌ Bad - rebuild on every update
update(update: ViewUpdate) {
  this.decorations = this.buildDecorations(update.view);
}

Respect Cursor Position

// ✅ Good - don't decorate at cursor
for (const range of selection.ranges) {
  if (range.from <= end && range.to >= start) {
    continue; // Skip this decoration
  }
}

// ❌ Bad - decorate everywhere
// User can't edit decorated text!

Clean Up Resources

class MyPlugin {
  private resources: Resource[] = [];

  constructor(view: EditorView) {
    this.resources.push(new Resource());
  }

  destroy() {
    for (const resource of this.resources) {
      resource.cleanup();
    }
    this.resources = [];
  }
}