示例:装饰品

内容

CodeMirror 编辑器内部的 DOM 结构由编辑器本身管理。在 cm-content 元素内部,任何尝试添加属性或更改节点结构的行为通常会导致编辑器立即将内容重置回原来的状态。

因此,为了样式化内容、替换内容或在内容之间添加额外元素,我们必须告诉编辑器这样做。这就是装饰的用途。

装饰类型

您可以为您的内容添加四种不同类型的装饰。

  • 标记装饰 是最常见的。这些为内容片段添加一些属性或包装的 DOM 元素。例如,语法高亮是通过标记装饰实现的。
  • 小部件装饰 在编辑器内容中插入一个 DOM 元素。你可以用这个来,例如,在颜色代码旁边添加一个颜色选择器小部件。小部件可以是内联元素或
  • 替换装饰 隐藏 一段内容。这对于代码折叠或用其他内容替换文本中的元素非常有用。可以显示一个 小部件 来替代被替换的文本。
  • 行装饰,当位于行的开头时,可以影响包裹该行的DOM元素的属性。

调用这些函数会给你一个 Decoration 对象,它仅描述装饰的类型,并且你可以在装饰实例之间重复使用。这个对象上的 range 方法会给你一个实际的装饰范围,它包含了类型和一对 from/to 文档偏移量。

装饰来源

装饰通过使用 RangeSet 数据结构提供给编辑器,该数据结构存储了一组值(在这种情况下是装饰)及其相关的范围(起始和结束位置)。这个数据结构有助于在文档更改时高效地更新大量装饰中的位置。

装饰通过 提供给编辑器视图。提供装饰有两种方式——直接提供,或通过一个函数,该函数将在视图实例上被调用以生成一组装饰。显著改变编辑器垂直布局的装饰,例如通过替换换行符或插入块小部件,必须直接提供,因为间接装饰仅在视口计算后被检索。

间接装饰适用于语法高亮或搜索匹配高亮等情况,在这些情况下,您可能只想在视口或当前可见范围内渲染装饰,这可以大大提高性能。

让我们从一个示例开始,该示例将装饰保持在状态中,并直接提供它们。

下划线命令

假设我们想实现一个编辑器扩展,允许用户为文档的部分内容添加下划线。为此,我们可以定义一个state field,用于跟踪文档中哪些部分被下划线,并提供mark decoration,以绘制这些下划线。

为了保持代码简单,该字段仅存储设置的装饰范围。它不执行诸如合并重叠下划线之类的操作,而只是将任何新下划线区域直接放入其范围集合中。

import {EditorView, Decoration, DecorationSet} from "@codemirror/view"
import {StateField, StateEffect} from "@codemirror/state" const addUnderline = StateEffect.define<{from: number, to: number}>({ map: ({from, to}, change) => ({from: change.mapPos(from), to: change.mapPos(to)})
}) const underlineField = StateField.define<DecorationSet>({ create() { return Decoration.none }, update(underlines, tr) { underlines = underlines.map(tr.changes) for (let e of tr.effects) if (e.is(addUnderline)) { underlines = underlines.update({ add: [underlineMark.range(e.value.from, e.value.to)] }) } return underlines }, provide: f => EditorView.decorations.from(f)
}) const underlineMark = Decoration.mark({class: "cm-underline"})

请注意,update 方法首先通过 映射 其范围来处理事务的更改。旧集合指的是旧文档中的位置,而新状态必须获取一个在新文档中具有位置的集合,因此除非您完全重新计算装饰集合,否则通常希望通过文档更改进行映射。

然后它检查我们为添加下划线定义的 效果 是否存在于事务中,如果存在,则用更多范围扩展装饰集合。

接下来我们定义一个命令,如果选择了任何文本,就为其添加下划线。我们将使其自动启用状态字段(以及一个 基础主题),以便不需要进一步的配置。

const underlineTheme = EditorView.baseTheme({ ".cm-underline": { textDecoration: "underline 3px red" } }) export function underlineSelection(view: EditorView) { let effects: StateEffect<unknown>[] = view.state.selection.ranges .filter(r => !r.empty) .map(({from, to}) => addUnderline.of({from, to})) if (!effects.length) return false if (!view.state.field(underlineField, false)) effects.push(StateEffect.appendConfig.of([underlineField, underlineTheme])) view.dispatch({effects}) return true
}

最后,这个键位图将该命令绑定到 Ctrl-h(在 macOS 上为 Cmd-h)。preventDefault 字段的存在是因为即使命令不适用,我们也不希望浏览器的默认行为发生。

import {keymap} from "@codemirror/view" export const underlineKeymap = keymap.of([{ key: "Mod-h", preventDefault: true, run: underlineSelection
}])

接下来,我们将查看一个插件,它在布尔字面量旁边显示一个复选框小部件,并允许用户点击它来翻转字面量。

小部件装饰不直接包含它们的小部件 DOM。除了帮助将可变对象排除在编辑器状态之外,这一额外的间接层次还使得在不重新绘制 DOM 的情况下重建小部件成为可能。我们稍后将通过在文档更改时简单地重建我们的装饰集来使用这一点。

因此,我们必须首先定义一个WidgetType的子类来绘制小部件。

import {WidgetType} from "@codemirror/view" class CheckboxWidget extends WidgetType { constructor(readonly checked: boolean) { super() } eq(other: CheckboxWidget) { return other.checked == this.checked } toDOM() { let wrap = document.createElement("span") wrap.setAttribute("aria-hidden", "true") wrap.className = "cm-boolean-toggle" let box = wrap.appendChild(document.createElement("input")) box.type = "checkbox" box.checked = this.checked return wrap } ignoreEvent() { return false }
}

装饰包含此类的实例(创建成本低)。当视图更新自身时,如果发现它在小部件出现的位置已经有一个绘制的实例(使用 eq 方法来确定等价性),它将简单地重用该实例。

通过定义一个updateDOM 方法,也可以优化相同类型但内容不同的组件的DOM结构更新。但在这里帮助不大。

生成的 DOM 将复选框包装在一个 <span> 元素中,主要是因为 Firefox 对 contenteditable=false 的复选框处理不佳(在 contenteditable 的边缘遇到浏览器怪癖是很常见的)。我们还会告诉屏幕阅读器忽略它,因为这个功能在没有指点设备的情况下实际上并不能正常工作。

最后,部件的 ignoreEvents 方法告诉编辑器不要忽略在部件中发生的事件。这是必要的,以允许一个全局的事件处理程序(稍后定义)来处理与它的交互。

接下来,这个函数使用编辑器的语法树(假设启用了JavaScript语言)来定位编辑器可见部分中的布尔字面量,并为它们创建小部件。

import {EditorView, Decoration} from "@codemirror/view"
import {syntaxTree} from "@codemirror/language" function checkboxes(view: EditorView) { let widgets = [] for (let {from, to} of view.visibleRanges) { syntaxTree(view.state).iterate({ from, to, enter: (node) => { if (node.name == "BooleanLiteral") { let isTrue = view.state.doc.sliceString(node.from, node.to) == "true" let deco = Decoration.widget({ widget: new CheckboxWidget(isTrue), side: 1 }) widgets.push(deco.range(node.to)) } } }) } return Decoration.set(widgets)
}

该函数被一个 视图插件 使用,该插件在文档或视口变化时保持最新的装饰集合。

import {ViewUpdate, ViewPlugin, DecorationSet} from "@codemirror/view" const checkboxPlugin = ViewPlugin.fromClass(class { decorations: DecorationSet constructor(view: EditorView) { this.decorations = checkboxes(view) } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged || syntaxTree(update.startState) != syntaxTree(update.state)) this.decorations = checkboxes(update.view) }
}, { decorations: v => v.decorations, eventHandlers: { mousedown: (e, view) => { let target = e.target as HTMLElement if (target.nodeName == "INPUT" && target.parentElement!.classList.contains("cm-boolean-toggle")) return toggleBoolean(view, view.posAtDOM(target)) } }
})

插件提供的选项告诉编辑器,首先,它可以 获取 来自该插件的装饰,其次,只要插件处于活动状态,给定的 mousedown 处理程序应该被注册。该处理程序检查事件目标以识别对复选框的点击,并使用以下助手来实际切换布尔值。

function toggleBoolean(view: EditorView, pos: number) { let before = view.state.doc.sliceString(Math.max(0, pos - 5), pos) let change if (before == "false") change = {from: pos - 5, to: pos, insert: "true"} else if (before.endsWith("true")) change = {from: pos - 4, to: pos, insert: "false"} else return false view.dispatch({changes: change}) return true
}

将插件作为扩展添加到(JavaScript)编辑器后,您会得到类似这样的内容:

要查看线条装饰的示例,请查看zebra stripe example

原子范围

在某些情况下,例如对于大于单个字符的大多数替换装饰,您希望编辑操作将这些范围视为原子元素,在光标移动时跳过它们,并在一步中删除它们。

EditorView.atomicRanges 方面可以提供范围集(通常是我们用于装饰的相同集合),并将确保光标移动跳过该集合中的范围。

让我们实现一个扩展,将占位符名称如 [[this]] 替换为小部件,并使编辑器将它们视为原子。

MatchDecorator 是一个辅助类,可以用来快速设置装饰给定正则表达式在视口中所有匹配项的视图插件。

import {MatchDecorator} from "@codemirror/view" const placeholderMatcher = new MatchDecorator({ regexp: /\[\[(\w+)\]\]/g, decoration: match => Decoration.replace({ widget: new PlaceholderWidget(match[1]), })

(PlaceholderWidgetWidgetType 的一个简单子类,它在一个样式化元素中渲染给定的名称。)

我们将使用匹配器来创建和维护我们插件中的装饰。它还 提供 装饰集作为原子范围。

import {Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate} from "@codemirror/view" const placeholders = ViewPlugin.fromClass(class { placeholders: DecorationSet constructor(view: EditorView) { this.placeholders = placeholderMatcher.createDeco(view) } update(update: ViewUpdate) { this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders) }
}, { decorations: instance => instance.placeholders, provide: plugin => EditorView.atomicRanges.of(view => { return view.plugin(plugin)?.placeholders || Decoration.none })
})

如果需要,可以通过事务过滤器以自定义方式实现类似的功能。

总结
CodeMirror 编辑器内部的 DOM 结构由编辑器自身管理,直接修改内容通常会导致编辑器重置。为了样式化内容或添加元素,需要使用装饰(decorations)。装饰分为四种类型:标记装饰(mark decorations)用于添加属性或包裹 DOM 元素,常用于语法高亮;小部件装饰(widget decorations)用于在内容中插入 DOM 元素;替换装饰(replacing decorations)用于隐藏内容,适合代码折叠;行装饰(line decorations)影响行的 DOM 元素属性。装饰通过 RangeSet 数据结构提供给编辑器,支持高效更新。示例中实现了一个下划线命令,通过状态字段跟踪下划线区域,并在选中文本时添加下划线。另一个示例展示了如何创建复选框小部件,允许用户点击以切换布尔字面量。通过语法树定位布尔字面量并创建小部件,确保在文档或视口变化时更新装饰。