Skip to content

CodeMirror 6 扩展开发

Obsidian 的编辑器基于 CodeMirror 6 构建。通过开发 CodeMirror 6 扩展,你可以深度定制编辑体验——添加语法高亮、自定义装饰、键盘快捷键等。

CodeMirror 6 架构

核心概念

概念说明
State编辑器的不可变状态对象
View编辑器的视图层
Extension扩展编辑器功能的配置单元
Facet扩展间的通信机制
Effect状态变更的消息
Decoration视觉装饰(高亮、小部件等)
Command可执行的编辑操作

扩展类型

text
CodeMirror 6 扩展
├── 语法扩展 (Language)
│   ├── 自定义语法高亮
│   └── 代码折叠
├── 装饰扩展 (Decoration)
│   ├── 行内装饰(高亮、下划线)
│   ├── 替换装饰(替换文本显示)
│   └── 挂件装饰(在文本中嵌入元素)
├── 命令扩展 (Command)
│   ├── 键盘快捷键
│   └── 命令面板命令
├── 状态扩展 (StateField)
│   ├── 自定义状态
│   └── 状态持久化
└── 视图插件 (ViewPlugin)
    ├── 响应视图更新
    └── 管理 DOM 元素

在 Obsidian 中注册扩展

使用 EditorView 扩展

typescript
// main.ts
import { Plugin } from "obsidian";
import { myExtension } from "./codemirror/myExtension";

export default class MyPlugin extends Plugin {
  async onload() {
    // 注册 CodeMirror 6 扩展
    this.registerEditorExtension(myExtension);
  }
}

条件注册

typescript
export default class MyPlugin extends Plugin {
  async onload() {
    // 根据设置条件注册
    this.registerEditorExtension(
      this.settings.enableHighlight ? myHighlightExtension : []
    );

    // 动态切换扩展
    this.addSettingTab(new MySettingTab(this.app, this));
  }

  updateExtensions() {
    // 重新注册扩展以应用设置变更
    this.app.workspace.updateOptions();
  }
}

装饰扩展

装饰(Decoration)是 CodeMirror 6 中最常用的扩展类型,用于改变文本的视觉呈现。

创建装饰集

typescript
import {
  Decoration,
  DecorationSet,
  EditorView,
  ViewPlugin,
  ViewUpdate,
  Range,
} from "@codemirror/view";
import { EditorState, RangeSetBuilder } from "@codemirror/state";

// 定义装饰类型
const highlightDecoration = Decoration.mark({
  class: "cm-my-highlight",
  attributes: { style: "background-color: rgba(255, 200, 0, 0.3)" },
});

const errorDecoration = Decoration.mark({
  class: "cm-my-error",
  attributes: { style: "text-decoration: wavy underline red" },
});

const widgetDecoration = (text: string) =>
  Decoration.widget({
    widget: new MyWidget(text),
  });

class MyWidget extends WidgetType {
  constructor(readonly text: string) {
    super();
  }

  toDOM(): HTMLElement {
    const span = document.createElement("span");
    span.className = "cm-my-widget";
    span.textContent = this.text;
    return span;
  }
}

创建视图插件

typescript
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 doc = view.state.doc;

      for (const { from, to } of view.visibleRanges) {
        // 从可见范围中扫描文本
        const text = doc.sliceString(from, to);

        // 示例:高亮所有 TODO 注释
        const regex = /TODO:.*$/gm;
        let match;

        while ((match = regex.exec(text)) !== null) {
          const start = from + match.index;
          const end = start + match[0].length;
          builder.add(start, end, highlightDecoration);
        }
      }

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

export const myHighlightExtension = [highlightPlugin];

行装饰

typescript
const activeLineDecoration = Decoration.line({
  class: "cm-active-line-highlight",
});

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

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

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

    buildDecorations(view: EditorView): DecorationSet {
      const builder = new RangeSetBuilder<Decoration>();
      const cursorLine = view.state.doc.lineAt(
        view.state.selection.main.head
      );

      builder.add(cursorLine.from, cursorLine.from, activeLineDecoration);
      return builder.finish();
    }
  },
  {
    decorations: (v) => v.decorations,
  }
);

替换装饰

将文本替换为自定义显示(不影响实际内容):

typescript
const hideMarkerDecoration = Decoration.replace({
  widget: new HiddenMarkerWidget(),
});

class HiddenMarkerWidget extends WidgetType {
  toDOM(): HTMLElement {
    const span = document.createElement("span");
    span.className = "cm-hidden-marker";
    span.textContent = "···";
    span.style.cssText = "color: var(--text-faint); font-style: italic;";
    return span;
  }

  get estimatedHeight(): number {
    return -1;
  }

  ignoreEvent(): boolean {
    return false;
  }
}

状态字段

StateField 用于在编辑器中存储自定义状态。

基本用法

typescript
import { StateField, StateEffect } from "@codemirror/state";

// 定义效果(用于修改状态)
const setCounter = StateEffect.define<number>();

// 定义状态字段
const counterField = StateField.define<number>({
  create: () => 0,
  update: (value, transaction) => {
    for (const effect of transaction.effects) {
      if (effect.is(setCounter)) {
        return effect.value;
      }
    }
    return value;
  },
});

// 使用效果修改状态
function incrementCounter(view: EditorView) {
  const current = view.state.field(counterField);
  view.dispatch({
    effects: setCounter.of(current + 1),
  });
}

在视图中使用状态字段

typescript
const counterDisplay = ViewPlugin.fromClass(
  class {
    dom: HTMLElement;

    constructor(readonly view: EditorView) {
      this.dom = document.createElement("div");
      this.dom.className = "cm-counter-display";
      this.updateDisplay();
    }

    update(update: ViewUpdate) {
      if (update.state.field(counterField) !== update.startState.field(counterField)) {
        this.updateDisplay();
      }
    }

    updateDisplay() {
      const count = this.view.state.field(counterField);
      this.dom.textContent = `Count: ${count}`;
    }

    destroy() {
      this.dom.remove();
    }
  }
);

键盘快捷键

添加键绑定

typescript
import { keymap, EditorView } from "@codemirror/view";
import { Prec } from "@codemirror/state";

const myKeymap = keymap.of([
  {
    key: "Mod-Shift-h",
    run: (view) => {
      // 在当前光标位置插入文本
      const cursor = view.state.selection.main.head;
      view.dispatch({
        changes: { from: cursor, insert: "Hello World" },
      });
      return true;
    },
  },
  {
    key: "Mod-Shift-t",
    run: (view) => {
      // 在当前行添加 TODO 标记
      const line = view.state.doc.lineAt(view.state.selection.main.head);
      view.dispatch({
        changes: { from: line.from, insert: "TODO: " },
      });
      return true;
    },
  },
]);

// 使用高优先级确保快捷键生效
export const myKeymapExtension = Prec.high(myKeymap);

常用快捷键修饰符

修饰符含义
ModmacOS: Cmd, 其他: Ctrl
AltmacOS: Option, 其他: Alt
ShiftShift
CtrlCtrl
CmdCmd (macOS)

快捷键冲突处理

typescript
// 使用 Prec 控制优先级
import { Prec } from "@codemirror/state";

// 最高优先级
const highPriority = Prec.high(keymap.of([...]));
// 默认优先级
const normalPriority = keymap.of([...]);
// 最低优先级
const lowPriority = Prec.low(keymap.of([...]));

语法高亮

使用 Lezer 解析器

CodeMirror 6 使用 Lezer 解析器系统进行语法分析:

typescript
import { syntaxTree } from "@codemirror/language";
import { RangeSetBuilder } from "@codemirror/state";

const syntaxHighlightPlugin = 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 doc = view.state.doc;

      for (const { from, to } of view.visibleRanges) {
        syntaxTree(view.state).iterate({
          from,
          to,
          enter: (node) => {
            // 根据语法节点类型添加装饰
            switch (node.name) {
              case "HeaderMark":
                builder.add(
                  node.from,
                  node.to,
                  Decoration.mark({ class: "cm-header-mark" })
                );
                break;
              case "Emphasis":
                builder.add(
                  node.from,
                  node.to,
                  Decoration.mark({ class: "cm-emphasis" })
                );
                break;
            }
          },
        });
      }

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

完整示例:TODO 高亮插件

typescript
// src/codemirror/todoHighlight.ts
import {
  Decoration,
  DecorationSet,
  EditorView,
  ViewPlugin,
  ViewUpdate,
} from "@codemirror/view";
import { RangeSetBuilder } from "@codemirror/state";

// 定义装饰类型
const todoDecoration = Decoration.mark({
  class: "cm-todo-highlight",
});
const doneDecoration = Decoration.mark({
  class: "cm-done-highlight",
});

// 创建插件
const todoHighlightPlugin = 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 doc = view.state.doc;

      for (const { from, to } of view.visibleRanges) {
        const text = doc.sliceString(from, to);
        const lines = text.split("\n");

        let offset = 0;
        for (const line of lines) {
          const lineStart = from + offset;

          // 匹配未完成的 TODO
          const todoMatch = line.match(/TODO:/);
          if (todoMatch) {
            const start = lineStart + todoMatch.index!;
            builder.add(start, lineStart + line.length, todoDecoration);
          }

          // 匹配已完成的 DONE
          const doneMatch = line.match(/DONE:/);
          if (doneMatch) {
            const start = lineStart + doneMatch.index!;
            builder.add(start, lineStart + line.length, doneDecoration);
          }

          offset += line.length + 1; // +1 for newline
        }
      }

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

export const todoHighlightExtension = [
  todoHighlightPlugin,
  // 配套 CSS
  EditorView.baseTheme({
    ".cm-todo-highlight": {
      backgroundColor: "rgba(255, 200, 0, 0.15)",
      borderLeft: "3px solid #ffc800",
    },
    ".cm-done-highlight": {
      backgroundColor: "rgba(0, 200, 100, 0.1)",
      borderLeft: "3px solid #00c864",
      textDecoration: "line-through",
      opacity: "0.7",
    },
  }),
];

在插件中注册:

typescript
// main.ts
import { Plugin } from "obsidian";
import { todoHighlightExtension } from "./codemirror/todoHighlight";

export default class TodoHighlightPlugin extends Plugin {
  async onload() {
    this.registerEditorExtension(todoHighlightExtension);
  }
}

调试技巧

查看语法树

typescript
// 在开发者控制台中
const view = app.workspace.activeLeaf.view;
const tree = syntaxTree(view.editor.cm.state);
tree.iterate({
  enter: (node) => console.log(node.name, node.from, node.to),
});

检查装饰集

typescript
// 查看当前装饰
const view = app.workspace.activeLeaf.view;
const decorations = view.editor.cm.plugin(highlightPlugin)?.decorations;
decorations?.between(0, view.editor.cm.state.doc.length, (from, to, dec) => {
  console.log(from, to, dec);
});

性能建议

  1. 只处理可见范围:使用 view.visibleRanges 而非整个文档
  2. 缓存装饰:只在文档变化或视口变化时重建
  3. 使用 RangeSetBuilder:比手动创建 DecorationSet 更高效
  4. 避免频繁 dispatch:合并多个变更在一次 dispatch 中
  5. 精简 DOM 操作:WidgetType 的 toDOM 应尽量简单

相关资源