CodeMirror相关
CodeMirror相关
CodeMirror相关
学习顺序:
- 状态管理
- 状态字段、视图插件
- 装饰
编辑器扩展 registerEditorExtension()
编辑器扩展可以让您自行改变在 Obsidian 中编辑文档的体验。本页解释了什么是编辑器扩展,以及在何时使用它们。
Obsidian 中的 Markdown 编辑器底层使用的是 CodeMirror 6 (CM6)。
跟 Obsidian 一样,CM6 也有自己的插件,称之为 扩展(extensions)。
换句话说,Obsidian 的 编辑器扩展 和 CodeMirror 6 的扩展 是一回事。
构建编辑器扩展所用到的 API 有些不合常规,因此在您开始使用之前需要您对其架构有基本的认知。本篇文档旨在为您提供足够的背景信息以及实例以供您入门。如果您想要了解更多关于构建编辑器扩展的内容,可以查阅 CodeMirror 6 documentation 这篇文档。
我是否需要一个编辑器扩展?
构建编辑器扩展可能会是个挑战,因此在您开始构建之前,您需要考虑是否真的需要它。
- 如果您想改变阅读视图下如何将 Markdown 转换为 HTML,可以考虑构建一个 Markdown post processor(Markdown后处理器)。
- 如果您想改变文档在实时预览时的外观和感觉,您需要构建一个编辑器扩展。
注册编辑器扩展
CodeMirror 6 (CM6) 是使用 web 技术编辑代码的强大引擎。作为它的核心,编辑器本身具有最少的功能集。任何您期望在流行的编辑器上可以获得功能都可以作为 扩展 供您挑选。尽管 Obsidian 附带了许多开箱即用的扩展,您依旧可以注册属于您自己的。
要想注册一个编辑器扩展,需要在您的 Obsidian 插件的 onload
方法中使用 registerEditorExtension。
onload() {
this.registerEditorExtension([examplePlugin, exampleField]);
}
尽管 CM6 支持多种扩展,但其中两个最常见的分别是 View plugins 以及 State fields。
// 这TM又是个什么代码
import DocCardList from '@theme/DocCardList';
import {useCurrentSidebarCategory} from '@docusaurus/theme-common';
<DocCardList items={useCurrentSidebarCategory().items}/>
与编辑器扩展通信 EditorView
您可以从 Markdown 视图中访问 CodeMirror 6 编辑器。
但是,由于 Obsidian API 实际上并未公开编辑器
(view.editor.cm会报错:Editor上不存在属性“cm”,因此您需要使用 @ts-expect-error
绕过 Typescript 的验证)
import { EditorView } from "@codemirror/view";
// @ts-expect-error, not typed
const editorView = view.editor.cm as EditorView;
视图插件1
您可以通过 EditorView.plugin()
方法访问视图插件实例。
this.addCommand({
id: "example-editor-command",
name: "Example editor command",
editorCallback: (editor, view) => {
// @ts-expect-error, not typed
const editorView = view.editor.cm as EditorView;
const plugin = editorView.plugin(examplePlugin);
if (plugin) {
plugin.addPointerToSelection(editorView);
}
},
});
状态字段
您可以在编辑器视图内直接派发变动以及state effects。
this.addCommand({
id: "example-editor-command",
name: "Example editor command",
editorCallback: (editor, view) => {
// @ts-expect-error, not typed
const editorView = view.editor.cm as EditorView;
editorView.dispatch({
effects: [
// ...
]
});
},
});
状态管理
状态管理 view.dispatch
TIP
本页旨在为 Obsidian 插件开发者们精炼 CodeMirror 6 官方文档。要想获取更多关于状态管理的详细信息,请查阅 State and Updates 这篇文档。
状态变化
在大部分的应用中,您可能会通过为一个属性或者变量分配一个新值的方式来更新状态。这样一来,原先的值就会永远丢失。
let note = "";
note = "Heading"
note = "# Heading"
note = "## Heading" // How to undo this?
为了支持对用户工作区的类似撤销以及重置更改的功能,诸如 Obsidian 的应用会保留所有的历史改动。要撤回改动,您可以返回改动前的时间点。
在 TypeScript 中,您会得到这样的结果:
const changes: ChangeSpec[] = [];
changes.push({ from: 0, insert: "Heading" });
changes.push({ from: 0, insert: "# " });
changes.push({ from: 0, insert: "#" });
举例 Transactions
试想一个在按下双引号 "
后会在选中文本前后加上该标点符号的功能。实现该功能的一种方式是:
- 在选中的文本前添加
"
。 - 在选中的文本后添加
"
。
注意这种实现包含了 两次 操作。如果您将这两个操作添加到了撤销历史记录中,那么用户将需要撤回 两次,每次撤回一个双引号。为了避免这个问题,是否可以将这两次改动合并成一次?
在编辑器扩展中,一组发生在一起的状态变化被称之为 transaction。
如果结合您到目前为止所学的知识,如果允许 transaction 只包含单个状态更改, 那么可以将状态视为 transaction 的 历史。
在编辑器扩展中,将所有这些功能放在一起来实现环绕特性,下面是你如何添加或分派事务到编辑器视图的方法:
view.dispatch({
changes: [
{ from: selectionStart, insert: `"` },
{ from: selectionEnd, insert: `"` }
]
});
状态字段 StateField
- OB
状态字段
作用
状态字段是一个可以让您管理自定义编辑器状态的 编辑器扩展。此页面将引导您通过实现计算器扩展来构建状态字段。
该计算器需要支持从当前状态中加减数字,并在您想要重新开始时重置状态。
在本页最后,您将会理解构建状态字段的基本概念。
TIP
本页旨在为 Obsidian 插件开发者们精炼 CodeMirror 6 的官方文档。要想获取更多关于状态字段的详细信息,可以参考 State Fields 这篇文档
插件
前置准备
Basic understanding of State management.
对于 状态管理 有基本了解。
定义状态效果 StateEffect
State effect 描述了您想要做出的状态变化。您可以将它们想象成 class 中的 方法。
在计算器的例子中,您需要为每个计算操作定义一个 state effect:
const addEffect = StateEffect.define<number>(); // 加
const subtractEffect = StateEffect.define<number>(); // 减
const resetEffect = StateEffect.define(); // 结果
<>
之间的类型定义了 effect 的输入类型。比如您想要加或减的数字。Reset effect 不需要任何输入,因此您可以不用管它。
定义状态字段 StateField
与预料相反的是,状态字段实际上并不 存储 状态。而是 管理 状态。状态字段获取当前状态,应用任何 state effects, 并返回新的状态。
状态字段包含根据 transaction 中的效果应用数学操作的计算器逻辑。一个 transaction 可以包含多个 effects, 比如两次相加,状态字段需要一个接一个的应用它们。
export const calculatorField = StateField.define<number>({
create(state: EditorState): number {
return 0;
},
update(oldState: number, transaction: Transaction): number {
let newState = oldState;
for (let effect of transaction.effects) {
if (effect.is(addEffect)) {
newState += effect.value;
} else if (effect.is(subtractEffect)) {
newState -= effect.value;
} else if (effect.is(resetEffect)) {
newState = 0;
}
}
return newState;
},
});
create
方法返回计算器的初始值。update
包含应用 effects 的逻辑。effect.is()
使您可以在使用 effect 之前检查它的类型。
派发 view.dispatch
原型
view.dispatch(TransactionSpec[])
// 实例中可以是
let effects: StateEffect<unknown>[]
view.despatch({effects})
要想将 state effect 应用到一个状态字段中,您需要将它作为 transaction 的一部分派发到编辑器窗口。
view.dispatch({
effects: [addEffect.of(num)],
});
您甚至可以定义一组提供更熟悉的 API 的辅助函数:
export function add(view: EditorView, num: number) {
view.dispatch({
effects: [addEffect.of(num)],
});
}
export function subtract(view: EditorView, num: number) {
view.dispatch({
effects: [subtractEffect.of(num)],
});
}
export function reset(view: EditorView) {
view.dispatch({
effects: [resetEffect.of(null)],
});
}
视图插件 PluginValue
- OB
视窗 View
- ViewPort
- State Field & View Plugin
该原理可以解释:
- 为什么不能用CSS计数器实现标题计数
- 为什么块装饰需要使用StateField而不能用ViewPlugin
Obsidian 编辑器支持上万行的 庞大文件。其中一个可能的原因是因为编辑器只渲染可见的(多一点点)的内容。
试想下您想编辑一个内容多到一屏无法展示完整的大型文档。Obsidian 编辑器创建了一个可以在文档中移动的 “窗口”,只渲染在这个“窗口”里的内容(而忽略窗口以外的内容)。这个窗口被称之为编辑器的 视窗。
无论何时当用户滚动浏览文档,或者是文档内容发生了变化,视图就会过期,需要重新计算。
如果您想构建一个基于视图的编辑器扩展,可以查阅视图插件这篇文档。
视图插件2
前置准备:对视窗有基本了解。
% TIP
本页旨在为 Obsidian 插件开发者们精炼 CodeMirror 6 的官方文档。要想获取更多关于状态管理的详细信息,请查阅 Affecting the View 这篇文档。
创建一个视图插件
视图插件是在视窗被重新计算后执行的编辑器扩展。这意味着它们可以访问视窗,这也意味着视图插件不能对视窗做出任何有巨大影响的改动。比如在文档中插入块或者换行符。
区别 %%LT
- 后处理器:在 Markdown 被处理成 HTML 之后 运行
- 视图插件:在视窗被重新计算 之后 运行
TIP %%
如果您想做出影响编辑器垂直布局的变动,比如插入块或者换行符,您需要使用状态字段。
要想创建一个视图插件,需要创建一个继承自 PluginValue 的类,并将它传给 ViewPlugin.fromClass() 方法。
import {
ViewUpdate,
PluginValue,
EditorView,
ViewPlugin,
} from "@codemirror/view";
class ExamplePlugin implements PluginValue {
constructor(view: EditorView) {
// ...
}
update(update: ViewUpdate) {
// ...
}
destroy() {
// ...
}
}
export const examplePlugin = ViewPlugin.fromClass(ExamplePlugin);
以下三个视图插件的方法控制它的生命周期:
constructor()
方法用于插件的初始化。update()
方法在发生改变时更新您的插件,比如在用户输入或者选择一些文本时。destroy()
方法在插件卸载后进行清理操作。
虽然例子中的视图插件生效了,但是它做的事情并不多。如果您想更好地理解导致插件更新的原因,您可以在 update()
方法中添加 console.log(update);
这行代码以在控制台中打印所有的更新内容。
装饰 Decoration
- OB
(CM版的会详细一点,推荐看那个)
先决条件
- 基本了解 状态字段。
- 基本了解 视图插件。
装饰让您控制在编辑器扩展中如何绘制或者展示内容。如果您打算通过在编辑器中添加,替换或者样式化标签来更改外观,您很可能需要使用装饰。
阅读完此指南后,您将会:
- 理解如何使用装饰去改变编辑器的外观。
- 理解使用状态字段以及视图插件提供装饰之间的区别。
何时使用视图插件或者状态字段
视图插件以及状态字段都可以为编辑器提供装饰,但是他们之间有些区别:
- 如果您可以根据视图内部的内容决定装饰,此时可以使用视图插件。
- 如果您需要在视图外部管理装饰,此时可以使用状态字段。
- 如果您想做出可能改变视图内容的修改,比如添加分割线,此时可以使用状态字段。
如果您可以使用任何一种方法来实现您的扩展, 那么视图插件往往能带来更好的性能。比如,试想下您打算实现一个用来检查文档拼写的编辑器扩展。
一种方法是将整个文档传递给外部拼写检查器,然后返回错误列表。在此情况下,不管当前视口中有什么,您需要将每条错误映射到装饰,并使用状态字段来管理装饰。
另一种方式是仅仅只检查展示在视口中的内容。在用户滚动浏览文档时,改扩展需要不断地执行拼写检查,但您可以拼写检查包含数百万行文本的文档。
提供装饰
想象一下,您想构建一个编辑器扩展,用表情符号替换项目符号列表项。 您可以使用视图插件或状态字段来完成此操作,但有一些区别。 在本节中,您将看到如何使用这两种类型的扩展来实现它。
两种实现共享相同的核心逻辑:
- 使用 syntaxTree 查找列表项。
- 将每个列表项的前导连字符
-
替换为小部件。
小部件 WidgetType
小部件是您添加到编辑器中的自定义 HTML 标签。您可以在文档中的特定位置插入一个小部件,或者用一个小部件替换一段内容。
下例中定义了一个返回 <span>👉</span>
HTML 标签的小部件。您将在稍后使用到它。
import { EditorView, WidgetType } from "@codemirror/view";
export class EmojiWidget extends WidgetType {
toDOM(view: EditorView): HTMLElement {
const div = document.createElement("span");
div.innerText = "👉";
return div;
}
}
要想使用 emoji 小部件替换您文档中的一部分内容,可以使用替换装饰。
import { Decoration } from "@codemirror/view" // 这个居然被我蒙对了
const decoration = Decoration.replace({ // replace方法不能换行
widget: new EmojiWidget()
});
状态字段
提供来自状态字段的装饰:
- 使用
DecorationSet
类型定义状态字段 - 将
provide
属性添加到状态字段中。
provide(field: StateField<DecorationSet>): Extension {
return EditorView.decorations.from(field);
},
_
import { syntaxTree } from "@codemirror/language";
import {
Extension,
RangeSetBuilder,
StateField,
Transaction,
} from "@codemirror/state";
import {
Decoration,
DecorationSet,
EditorView,
WidgetType,
} from "@codemirror/view";
import { EmojiWidget } from "emoji";
export const emojiListField = StateField.define<DecorationSet>({
create(state): DecorationSet {
return Decoration.none;
},
update(oldState: DecorationSet, transaction: Transaction): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
syntaxTree(transaction.state).iterate({
enter(node) {
if (node.type.name.startsWith("list")) {
// Position of the '-' or the '*'.
const listCharFrom = node.from - 2;
builder.add(
listCharFrom,
listCharFrom + 1,
Decoration.replace({
widget: new EmojiWidget(),
})
);
}
},
});
return builder.finish();
},
provide(field: StateField<DecorationSet>): Extension {
return EditorView.decorations.from(field);
},
});
视图插件
To manage your decorations using a view plugin:
使用视图插件管理您的装饰:
- 创建一个视图插件.
- 在您的插件中添加
DecorationSet
成员属性。 - 在
constructor()
方法中初始化装饰。 - 在
update()
中重新构建装饰。
以下示例仅在基础文档或视口更改时重建装饰。以下示例仅在基础文档或视口更改时重建装饰。
import { syntaxTree } from "@codemirror/language";
import { RangeSetBuilder } from "@codemirror/state";
import {
Decoration,
DecorationSet,
EditorView,
PluginSpec,
PluginValue,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "@codemirror/view";
import { EmojiWidget } from "emoji";
class EmojiListPlugin implements PluginValue {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
destroy() {}
buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (let { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter(node) {
if (node.type.name.startsWith("list")) {
// Position of the '-' or the '*'.
const listCharFrom = node.from - 2;
builder.add(
listCharFrom,
listCharFrom + 1,
Decoration.replace({
widget: new EmojiWidget(),
})
);
}
},
});
}
return builder.finish();
}
}
const pluginSpec: PluginSpec<EmojiListPlugin> = {
decorations: (value: EmojiListPlugin) => value.decorations,
};
export const emojiListPlugin = ViewPlugin.fromClass(
EmojiListPlugin,
pluginSpec
);
buildDecorations()
是一个辅助方法,它基于编辑器视图构建一整套装饰。
注意传入 ViewPlugin.fromClass()
的第二个参数。PluginSpec
中的 decorations
属性指定视图插件如何向编辑器提供装饰。
由于视图插件知道什么对用户可见,因此您可以使用 view.visibleRanges
来限制要访问的语法树的哪些部分。