【CodeMirror】系列(一)官网文档学习(一)系统指南

发布于:2025-03-16 ⋅ 阅读:(13) ⋅ 点赞:(0)

这个系列来学习一下 CodeMirror 编辑器。这篇文章主要是记录一下官方文档的学习,先把官方文档大致浏览一遍。
CodeMirror 是一个 Web 端的代码编辑器,和前面学习的 Monaco Editor 一样。应该比 Monaco Editor 轻便一些,而且有 Vue 版本,对 Vue 用户友好。另外可以将代码显示成 DOM 元素,对于代码和 DOM 元素的转换这块比较简单,在 Monaco Editor 中就没有这么方便,Monaco Editor 适合专业级的代码编辑器,CodeMirror 适合嵌入式代码编辑以及低代码平台、强交互代码编辑,可能非专业的程序员也能比较方便的编代码,大概。这篇文章有很多是AI翻译的,然后我整合一下,减少了很多不必要的文字,咱们就是说,都是搞前端的,废话就不写了。感觉翻译的还挺好的,能看懂哈哈哈,极大地提高学习效率,要不然光看官网文档得一星期了。但是也还是有很多很奇怪的翻译嘤嘤嘤,我把它们改成人类可理解语言。

系统指南

(一)基础架构

CodeMirror 是模块化的,各个模块可以分开使用,创建编辑器的时候需要导入三个核心包

  • @codemirror/state:定义表示编辑器状态及其变化的数据结构。
  • @codemirror/view:一个显示组件,知道如何将编辑器状态显示给用户,并将基本编辑操作转换为状态更新。
  • @codemirror/commands:定义了许多编辑命令及其键绑定。

以下是一个最小可用编辑器的示例:

import {EditorState} from "@codemirror/state"
import {EditorView, keymap} from "@codemirror/view"
import {defaultKeymap} from "@codemirror/commands"

let startState = EditorState.create({
  doc: "Hello World",
  extensions: [keymap.of(defaultKeymap)]
})

let view = new EditorView({
  state: startState,
  parent: document.body
})

其他的锦上添花的功能(如行号边距或撤销历史记录)是通过扩展实现的,不知道大家用没用过 Tiptap,这点跟 Tiptap 很像,例如下面代码:

import {EditorView, basicSetup} from "codemirror"
import {javascript} from "@codemirror/lang-javascript"

let view = new EditorView({
  extensions: [basicSetup, javascript()],
  parent: document.body
})

(二)纯函数

在 CodeMirror 中,文档和状态数据结构是不可变的,对它们的操作是纯函数,而视图组件和命令接口则将其包装在命令式接口中。使用库中创建的对象时,不要重新赋值,而是要通过纯函数创建新的变量记录,这样子也更有利于处理状态变化。

let state = EditorState.create({doc: "123"})
// 错误代码:
state.doc = Text.of("abc") // <- 不要这样做

(三)状态和更新

这部分又是跟 ProseMirror 的实现特别类似,怪不得它俩都叫 xxMirror 呢。视图的状态完全由其状态属性中的 EditorState 值决定。对该状态的更改发生在功能代码中,通过创建一个事务来描述对文档、选择或其他状态字段的更改。然后可以调度该事务,告知视图更新其状态,此时它将用新状态同步其 DOM 表示。

// (假设 view 是持有文档 "123" 的 EditorView 实例。)
let transaction = view.state.update({changes: {from: 0, insert: "0"}})
console.log(transaction.state.doc.toString()) // "0123"
// 此时视图仍然显示旧状态。
view.dispatch(transaction)
// 现在它显示新状态。

视图会监听例如文本输入、按键或鼠标交互等事件。它将这些转换为对编辑器状态进行适当更改的事务,并调度这些事务,计算新状态并与其显示的新状态同步。

(四)扩展

CodeMirror 的核心库的功能比较简单,很多功能通过扩展实现,例如配置选项、定义state中的新字段、修改样式、自定义命令等。扩展和扩展之间没有任何的冲突,可以任意组合,并且可以互相嵌套。同一个扩展如果多次引入,只会生效一次,默认最先定义的扩展生效,但是可以给扩展增加显式的优先级,来设置使哪个扩展生效,如下面代码,同一个扩展设置了三次,但是通过显示设置优先级,最后一个扩展会生效:

import {keymap} from "@codemirror/view"
import {EditorState, Prec} from "@codemirror/state"

function dummyKeymap(tag) {
  return keymap.of([{
    key: "Ctrl-Space",
    run() { console.log(tag); return true }
  }])
}

let state = EditorState.create({extensions: [
  dummyKeymap("A"),
  dummyKeymap("B"),
  Prec.high(dummyKeymap("C"))
]})

(五)文档偏移

CodeMirror 使用普通数字来表示文档中的位置。这些数字表示字符计数,这些偏移量用于跟踪选择、位置变化、装饰内容等。
在文档的内容变更之后,我们往往需要获取旧文档中某个位置偏移到了新文档中的什么位置,库提供了一个位置映射功能,给定一个事务(或仅一个更改集)和一个起始位置,可以给出相应的新位置。

import {EditorState} from "@codemirror/state"

let state = EditorState.create({doc: "1234"})
// 删除 "23" 并在开头插入 "0"。
let tr = state.update({changes: [{from: 1, to: 3}, {from: 0, insert: "0"}]})
// 旧文档末尾的位置在新文档中为 3。
console.log(tr.changes.mapPos(4))

文档数据结构还按行索引,可以按行获取文档内容

import {Text} from "@codemirror/state"

let doc = Text.of(["line 1", "line 2", "line 3"])
// 获取第二行的信息
console.log(doc.line(2)) // {from: 7, to: 13, ...}
// 获取位置 15 附近的行
console.log(doc.lineAt(15)) // {from: 14, to: 20, ...}

(六)数据模型

CodeMirror 作为文本编辑器,将文档视为一个扁平字符串。它以树状数据结构存储该字符串,按行分割,以便在文档的任何位置进行高效的更新(并按行号高效索引)。

1、文档更改

文档更改精确描述了旧文档的哪些范围被新文本的哪些部分替换。这允许扩展精确跟踪文档发生了什么,支持诸如撤销历史和协作编辑等功能在库核心之外实现。

2、选择 selection

编辑器的 state 存储实时的 selectionselection 可以包含多个 rangerange 可能是光标也可能是一个范围,重叠的range 会自动合并,并且 range 会自动排序,因此 selection 的范围属性始终包含排序后的、无重叠的 range 数组。

import {EditorState, EditorSelection} from "@codemirror/state"

let state = EditorState.create({
  doc: "hello",
  selection: EditorSelection.create([
    EditorSelection.range(0, 4),  // 选中 'hell'
    EditorSelection.cursor(5)  // 光标放在 'o' 的后面
  ]),
  extensions: EditorState.allowMultipleSelections.of(true)
})
console.log(state.selection.ranges.length) // 2

let tr = state.update(state.replaceSelection("!"))
console.log(tr.state.doc.toString()) // "!o!"

多个 range 的时候会有一个主要的 range,即浏览器行为选中的范围。其他的 range 完全由库绘制和处理。
默认情况下, selection 包含单个 range。要支持多个 range,必须包含可以绘制它们的扩展,如 drawSelection,并设置选项以启用它们。
状态对象有一个方便的方法 changeByRange,用于对每个 range 单独应用操作(手动做这件事可能有点麻烦)。

import {EditorState, EditorSelection} from "@codemirror/state"

let state = EditorState.create({doc: "abcd", selection: {anchor: 1, head: 3}})
// 转换选择为大写
let tr = state.update(state.changeByRange(range => {
  let upper = state.sliceDoc(range.from, range.to).toUpperCase()
  return {
    changes: {from: range.from, to: range.to, insert: upper},
    range: EditorSelection.range(range.from, range.from + upper.length)
  }
}))
console.log(tr.state.doc.toString()) // "aBCd"

还有 replaceSelection,它创建一个事务,用于用某段文本替换所有选择范围。

3、配置 Configuration

每一个编辑器状态 state 对象都需要引入通过扩展激活的配置。通常配置一旦设置就不会修改,但是可以使用 Compartmenteffects 重新配置 state,添加到或者代替当前状态的配置,compartment 是给配置创建的隔间,用来动态设置配置对象;effects 是表示与事务相关联的附加效应。
状态配置的直接影响是状态存储的字段和与该状态相关的特征(facets)的值。

4、 特征(Facets)

特征是一个扩展点。不同的扩展可以为特征提供输入值,任何能够访问状态和特征的人都可以读取其输出值。特征可能是扩展提供的值的数组集合,也可能是通过多个扩展的输入值计算出来的输出值。
特征的作用就是对多个扩展提供的输入值进行组合,计算出一个连贯的组合值,组合的方式会有所不同。例如下述:

  • 关于制表符的大小,需要一个单一的输出值,所以特征会选用优先级最高的值来使用
  • 当提供事件处理程序的时候,事件处理程序就需要按顺序组合成一个数组,这样在处理事件的时候会按顺序尝试,不会遗漏
  • 另一个常见的模式是计算输入值的逻辑“或”关系(比如在允许多个选择的情况下),或者以其他方式归约这些值(例如,取请求的撤销历史深度的最大值)

以下是或许特征的写法:

import {EditorState} from "@codemirror/state"

let state = EditorState.create({
  extensions: [
    EditorState.tabSize.of(16),
    EditorState.changeFilter.of(() => true)
  ]
})
console.log(state.facet(EditorState.tabSize)) // 16
console.log(state.facet(EditorState.changeFilter)) // [() => true]

特征是通过 Facet.define 定义的,这个方法会返回一个特征值。这个特征值可以被导出,允许其他代码提供和读取它,或者可以保持模块私有,这样只有该模块可以访问。
在某个特定的配置中,大多数特征通常是静态的,直接作为配置的一部分提供。但也可以从状态的其他方面计算出特征值。

let info = Facet.define<string>()
let state = EditorState.create({
  doc: "abc\ndef",
  extensions: [
    info.of("hello"), // 可以直接定义成字符串
    info.compute(["doc"], state => `lines: ${state.doc.lines}`)  // 也可以使用计算属性
  ]
})
console.log(state.facet(info))
// ["hello", "lines: 2"]

当输入值改变的时候,特征值会自动重新计算。特征值只有在必要时才会重新计算,因此你可以通过对象或数组的测试,快速检查特征是否发生了变化。

5、事务(Transactions)

通过状态的 update 方法创建的事务可以组合多种效果(都是可选的):

  • 应用文档更改:可以对文档进行修改。
  • 移动选择:可以显式地移动光标。需要注意的是,如果有文档更改但没有明确的新选择,光标会随着这些更改自动调整。
  • 设置滚动标志:可以设置一个标志,指示视图将主要的选择光标滚动到可见区域。
  • 添加注释:可以有任意数量的注释,用于存储描述整个事务的额外元数据。例如,userEvent 注释可以用于识别由某些常见操作(如输入或粘贴)生成的事务。
  • 执行效果:例如折叠代码或启动自动补全。
  • 影响状态配置:可以通过提供一整套新的扩展,或替换配置的特定部分来影响状态的配置。
    事务通过规格(specs)来描述,通常写成对象字面量形式,尽管某些方法(如 changeByRange)也会返回它们。这些规格可以直接传递给 EditorView.dispatch 来立即派发事务,或者传递给 EditorState.update 仅用于创建事务。
    当多个规格被传递给这些方法时,它们会被合并为一个单一的事务。这对于向某个助手函数创建的规格添加额外字段非常有用。
    更改通过 {from, to, insert} 对象(其中 to 和 insert 是可选的)或这些对象的嵌套数组来描述。你也可以传入一个 ChangeSet 对象,这是在事务对象中表示更改的形式。给定的更改位置是指事务开始时的文档,即使有多个更改。
    新选择或状态效果中使用的位置是指更改后的新文档。
    要完全重置状态,例如加载新文档,建议创建一个新状态,而不是使用事务。这可以确保不会留下任何不必要的状态(例如撤销历史事件)。

(七)视图

视图尽量在状态周围保持透明层。不过,有些编辑器的操作不能仅通过状态中的数据来处理,而是需要通过视图进行处理,如下:

  • 屏幕坐标:当需要确定用户点击的位置或查找某个位置的坐标时,需要访问布局,因此需要使用浏览器的 DOM。
  • 文本方向:编辑器根据周围文档的文本方向(如果被覆盖,则根据自己的 CSS 样式)来确定文本的方向。
  • 光标移动:光标的移动可能依赖于布局和文本方向。因此,视图提供了多个辅助方法来计算不同类型的移动。
  • 焦点和滚动位置:某些状态(如焦点和滚动位置)并不存储在功能状态中,而是保留在 DOM 中。
    该库不期望用户代码直接操作它管理的 DOM 结构。如果你尝试这样做,可能会发现库会立即撤销你的更改。
1、视口

需要注意的是,CodeMirror 在文档较大时不会渲染整个文档。为了保持编辑器的响应速度和低资源使用,它在更新时会检测当前可见的内容(没有滚出视野的部分),并只渲染这一部分以及周围的一些边距。这被称为视口(viewport)。
在编辑器中没有办法查询视口外的位置信息(因为这些位置没有被渲染,因此没有布局信息)。不过,视图会跟踪整个文档的高度信息(最初估算,内容绘制时准确测量),即使是视口之外的部分。
长行文本(未换行时)或折叠的代码块仍可能使视口变得非常大。编辑器还提供了一个可见范围 [visibleRanges] (https://codemirror.net/docs/ref/#view.EditorView.visibleRanges),其中只包含可见内容。这在一些情况下很有用,比如高亮代码时,只需要处理可见内容即可。

2、更新周期

CodeMirror 的视图非常努力地减少对 DOM 的重绘次数。发起一个事务通常只会导致编辑器对 DOM 进行写操作,而不会读取布局信息。读取操作(例如检查视口是否仍然有效、光标是否需要滚动到可见区域等)是在一个单独的测量阶段完成的,这个阶段使用 requestAnimationFrame 进行调度。如果有必要,这个阶段会跟进另一个写入阶段。
你可以使用 requestMeasure 方法调度自己的测量代码,确保其他组件完成的测量和绘制是同步的,从而避免不必要的DOM布局计算。
更新不能同时进行,但是在测量之前可以进行多次更新,测量阶段会合并所有的更新结果。
当你完成一个视图实例的使用后,必须调用它的 destroy 方法来释放它所占用的资源(如全局事件处理程序和变更观察者)。

3、DOM 结构

编辑器的 DOM 结构如下:

<div class="cm-editor [theme scope classes]">
  <div class="cm-scroller">
    <div class="cm-content" contenteditable="true">
      <div class="cm-line">Content goes here</div>
      <div class="cm-line">...</div>
    </div>
  </div>
</div>

外层(包装)元素是一个垂直的 flexbox。像面板和工具提示这样的组件可以通过扩展加入到 DOM 中。
在其内部是滚动元素。如果编辑器有自己的滚动条,这个元素应该设置为 overflow: auto。不过也不是必须的——编辑器还支持根据内容自动扩展,或者在达到一定的最大高度后再滚动。
滚动元素是一个水平的 flexbox 元素。
在里面是可编辑的内容元素。这个元素上注册了一个 DOM 变更观察者,任何在这里进行的更改都会导致编辑器将其解析为文档更改,并重新绘制受影响的节点。这个容器为视口中的每一行都持有一个行元素,这些行元素又包含文档文本(可能会加上样式或小部件的装饰)。

4、样式和主题

为了管理与编辑器相关的样式,CodeMirror 使用了一种从 JavaScript 注入样式的系统。样式可以通过一个特征 facet 进行注册,这样视图就会确保它们是可用的。
编辑器中的许多元素都被赋予了以 cm- 开头的类名。这些类名可以直接在你的本地 CSS 中进行样式设置,也可以通过主题来定位。主题是通过 EditorView.theme 创建的扩展,它会生成一个独特的 CSS 类(当主题扩展处于激活状态时,这个类会被添加到编辑器中),并定义由该类作用域的样式。
主题声明可以使用样式修改符(style-mod)语法定义任意数量的 CSS 规则。以下代码创建了一个简单的主题,使编辑器中的默认文本颜色变为橙色:

import {EditorView} from "@codemirror/view"

let view = new EditorView({
  extensions: EditorView.theme({
    ".cm-content": {color: "darkorange"},
    "&.cm-focused .cm-content": {color: "orange"}
  })
})

为了正确地实现自动类前缀,规则中第一个元素如果针对编辑器的包装元素(即主题的独特类将被添加的位置),例如示例中的 .cm-focused 规则,必须使用 & 字符来指示包装元素的位置。
扩展可以定义基础主题,为它们创建的元素提供默认样式。基础主题可以使用 &light(默认)和 &dark(在启用暗主题时生效)占位符,这样即使它们没有被主题覆盖,也不会显得太突兀。

import {EditorView} from "@codemirror/view"

// This again produces an extension value
let myBaseTheme = EditorView.baseTheme({
  "&dark .cm-mySelector": { background: "dimgrey" },
  "&light .cm-mySelector": { background: "ghostwhite" }
})

在常规 CSS 中定义编辑器样式时,应该在规则中包含 .cm-editor,这样优先级才不会太低,才能正确生效。

.cm-editor .cm-content { color: purple; }
5、命令 Commands

命令是具有特定签名的函数:(view: EditorView) => boolean。它们主要用于键盘绑定,但也可以用于菜单项或命令面板等。命令函数代表用户的一个操作。它接受一个视图参数并返回一个布尔值,返回 false 表示当前情况不适用,返回 true 表示成功执行。命令的效果通常通过调度一个事务来以命令式的方式产生。
当多个命令绑定到一个特定的键时,它们会按优先级一个一个地尝试,直到其中一个返回 true
仅对状态操作而不对整个视图操作的命令可以使用 StateCommand 类型,它是 Command 的子类型,只需要其参数具有状态和调度属性。这主要用于能够在不创建视图的情况下测试这些命令。

@codemirror/commands 包导出了许多不同的编辑命令,以及一些键盘映射。键盘映射是 KeyBinding 对象的数组,这些对象被提供给键盘映射功能,以便在编辑器中启用它们。

let myKeyExtension = keymap.of([
  {
    key: "Alt-c",
    run: view => {
      view.dispatch(view.state.replaceSelection("?"))
      return true
    }
  }
])

(八)扩展CodeMirror

扩展 CodeMirror 有多种不同的方法。本节将介绍你需要熟悉的各种概念,以编写编辑器扩展。

1、状态字段

扩展通常需要在状态中存储额外的信息。例如,撤销历史需要存储可撤销的更改,而代码折叠扩展需要跟踪已折叠的内容等。
为此,扩展可以定义额外的状态字段。状态字段存在于纯函数状态数据结构中,必须存储不可变值。
状态字段使用类似reducer的东西与状态的其余部分保持同步。每次状态更新时,都会使用字段的当前值和事务调用一个函数,该函数应该返回字段的新值。

import {EditorState, StateField} from "@codemirror/state"

let countDocChanges = StateField.define({
  create() { return 0 },
  update(value, tr) { return tr.docChanged ? value + 1 : value }
})

let state = EditorState.create({extensions: countDocChanges})
state = state.update({changes: {from: 0, insert: "."}}).state
console.log(state.field(countDocChanges)) // 1

在使用状态字段时,通常需要通过注释或效果来传达状态字段的变化或当前状态。这些工具可以帮助你更好地管理和理解状态的变化。

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

let setFullScreenMode = StateEffect.define<boolean>()

let fullScreenMode = StateField.define({
  create() { return false },
  update(value, tr) {
    for (let e of tr.effects)
      if (e.is(setFullScreenMode)) value = e.value
    return value
  }
})

将状态绑定到编辑器的全局状态更新周期中是一个非常好的主意,因为这使得与编辑器其他状态保持同步变得更容易。

2、影响视图

视图插件为扩展提供了一种在视图内部运行命令式组件的方式。这对于事件处理程序、添加和管理 DOM 元素以及处理依赖于当前视口的操作非常有用。
以下是一个简单的插件示例,它在编辑器的角落显示文档大小:

import {ViewPlugin} from "@codemirror/view"

const docSizePlugin = ViewPlugin.fromClass(class {
  constructor(view) {
    this.dom = view.dom.appendChild(document.createElement("div"))
    this.dom.style.cssText =
      "position: absolute; inset-block-start: 2px; inset-inline-end: 5px"
    this.dom.textContent = view.state.doc.length
  }

  update(update) {
    if (update.docChanged)
      this.dom.textContent = update.state.doc.length
  }

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

视图插件通常不应持有(非派生)状态。它们最适合充当编辑器状态中数据的浅层视图。
当状态重新配置时,不在新配置中的视图插件将被销毁(这就是为什么如果它们对编辑器进行了更改,应定义一个 destroy 方法以撤销这些更改)。
如果视图插件崩溃,它会被自动禁用,以避免影响整个视图的稳定性。

3、装饰文档

在没有其他指示的情况下,CodeMirror 将文档绘制为纯文本。装饰是扩展影响文档外观的机制,主要有四种类型:

  • 标记装饰(Mark Decorations):为给定范围内的文本添加样式或 DOM 属性。
  • 小部件装饰(Widget Decorations):在文档中指定位置插入 DOM 元素。
  • 替换装饰(Replace Decorations):隐藏文档的一部分或用给定的 DOM 节点替换它。
  • 行装饰(Line Decorations):为行的包装元素添加属性。

装饰的管理
装饰通过一个特征(facet)提供。每次视图更新时,此特征中的内容用于样式化可见内容。
装饰以不可变数据结构的集合形式存在。这些集合可以在变化中进行映射(调整内容的位置以补偿变化)或在更新时重建,具体取决于使用场景。
装饰的提供方式
直接提供:将范围集值放入特征中(通常通过从字段派生)。
间接提供:通过从视图提供函数到范围集。
限制
只有直接提供的装饰集可以影响编辑器的垂直块结构,而只有间接提供的装饰集可以读取编辑器的视口(这在只想装饰可见内容时非常有用)。这种限制的原因是视口是从块结构计算得出的,因此必须先知道块结构才能读取视口。
示例
有一个装饰示例实现了一些常见的用例,可以参考以了解如何有效使用装饰。

4、扩展结构

要创建特定的编辑器功能,通常需要组合不同类型的扩展:一个状态字段用于保持状态,基本主题提供样式,视图插件管理输入和输出,一些命令,可能还有一个用于配置的特征。
设计模式
一个常见的模式是导出一个函数,该函数返回实现特性所需的扩展值。即使该函数尚未接受参数,这样做也是一个好主意——这使得以后可以添加配置选项,而不会破坏向后兼容性。
多次包含扩展
由于扩展可以引入其他扩展,考虑当你的扩展被多次包含时会发生什么是很有用的。例如,对于某些类型的扩展(如键盘映射),多次执行同样的操作是合适的。然而,通常这会浪费资源,甚至导致问题。
去重机制
通常,通过依赖于相同扩展值的去重,可以使扩展的多次使用正确地执行。如果确保只创建一次静态扩展值(主题、状态字段、视图插件等),并始终从扩展构造函数返回相同的实例,就只会在编辑器中得到一个副本。
配置管理
当扩展允许配置时,其他逻辑可能需要访问这些配置。当不同实例的扩展具有不同配置时,该如何处理?
错误处理:有时,这只是一个错误。
协调策略:但通常,可以定义一种协调配置的策略。特征特别适合这种策略。可以将配置放入模块私有的特征中,并让其组合函数协调配置,或在无法协调时抛出错误。需要访问当前配置的代码可以读取该面。
示例
可以参考斑马条纹示例,以说明这种方法的实施
哎哟妈呀真多呀,但是比 Monaco Editor 好点,好歹英文文档还是挺全的。