[译]CodeMirror System Guide

·  阅读 575

原文

本文是 CodeMirror 编辑系统指南。简单描述了下系统功能。如果想了解更多详情,可以参考开发手册

架构预览

因为 CodeMirror 和平常使用的经典 JS 库是有所区别的,所以推荐先读完本章,否则可能会觉得和您预期不符而浪费时间

模块性

CodeMirror 是有一系列独立的模块组合在一起,提供富文本编辑功能和代码编辑功能的编辑器。好的一面是你可以自主选择需要的特性,甚至是按需替换核心功能。坏的方面是,需要把这一系列整合在一起才能配置好 CodeMirror.

整合代码不是很难,但是需要自己 install, 然后按需导入。下面的核心库是必须的,没有它们是配置不好 CodeMirror 的。

  • @codemirror/state 定义了代表 editor state 和对此 state 进行更新的操作
  • @codemirror/view 展示 editor state 的 UI 组件,可以把编辑操作转换成对应的 state 更新操作
  • @codemirror/commands 定义了大量的编辑命令及其快捷键绑定

以下就是一个最基本可用的 CodeMirror 配置

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

let startState = EditorState.create({
    doc: 'console.log("hello, javascript!")',
    extensions: [keymap.of(defaultKeymap)]
})
let view = new EditorView({
    state: startState,
    parent: document.body
})
复制代码

编辑器中其他操作,如行号分隔,撤回历史等,都是以扩展来实现的,需要明确添加进去才能在配置中开启。为了方便使用,@codemirror/basic-setup 库把一个编辑器中大多数能用到的功能都配置好了,可以开箱使用(除了语言支持)

import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'
import { javascript } from '@codemirror/lang-javascript'

let view = new EditorView({
    state: EditorState.create({ extensions: [basicSetup, javascript()]}),
    parent: document.body
})
复制代码

CodeMirror 的包都是以 ES6 模块的形式分发的。所以当前是不支持不使用打包器或者模块加载器而直接运行的。如果你是新手,推荐试试 RollupWebpack

函数式核心,命令为外壳

指导 CodeMirror 架构设计的核心观点是函数式代码(纯函数),它会创建一个没有副作用的新值,和命令式代码交互更方便。而浏览器 DOM 很明显也是命令式思维,和 CodeMirror 集成的大部分系统类似。

为了解决这个矛盾,库的 state 表现层是严格函数式的-即 document 和 state 数据结构都是不可变的,而能操作它们的都是纯函数,而 view 组件和命令接口将它们封装在一个命令式接口中.

这意味着即使 editor 已经转到了新的 state,而旧的 state 依然原封不动的存在.保存旧状态和新状态在面对处理 state 改变的情况下极为有利.这也意味着直接改变一个 state 值,或者写一个像添加额外 state 属性的命令式扩展都将面临未知的境况,更甚者会造成破坏后果.

TypeScript 在处理这种情况时很有利,只需要把 array 或者对象属性标记为 readonly 即可。而使用纯 JS 代码时可没有这种保证。除非文档明确指出,通常情况下,对一个由库创建的对象属性重新赋值都是不支持的,如下:

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

状态和更新

CodeMirror 处理状态更新的方式受ReduxElm 启发。除了极少数情况(如组合和拖拽处理),视图的状态完全是由 EditorState 里的 state 属性决定的。

通过创建一个描述改变文档,选择,或其他 state 属性的 transaction,以这种函数调用方式来更新 state。这个 transaction 之后可以通过 dispatched 分发,告诉 view 更新 state,更新新 state 对应的 DOM 展示。

let transaction = view.state.update({ changes: { from: 0, insert: "0" }})
console.log(transaction.state.doc.toString()) // "0123"
// 此刻视图依然显示的旧状态
view.dispatch(transaction)
// 现在显示新状态了
复制代码

典型的用户交互数据流如下图

graph TD;
    A(DOM event)-->B(transcation);
    B-->C(new state);
    C-->D(view);
    D-->A;

view 监听事件变化。当 DOM 事件发生时(或者快捷键触发的命令,或者由扩展注册的事件处理器),CodeMirror会把这些事件转换为新的状态 transcation,然后分发。此时生成一个新的 state,当接收到新 state 后就回去更新 DOM。

扩展

因为核心库需要尽量保持最小化和范型化,大量的功能都是以系统扩展的形式存在的。扩展可以做任何事,从配置选项到为 state 定义一个新属性,修改编辑器样式,向 view 中注入命令式组件等不一而足。CodeMirror 系统保证组合扩展而不会发生冲突。

激活的扩展保存在 editor state 中(可以通过 transcation 更新).扩展通常是以从包中导入的值或者数组的形式存在的.可以任意的嵌套(比如一个数组包含更多数组也是一个有效的扩展),在配置阶段会自动去重.所以一个扩展可以包含另一个扩展-如果一个扩展被多次加载,那么它只会生效一次.

如果多个扩展是相关的,那么它们的优先级必须明确声明,也就是按它们在扩展集合(拍平)中的排列顺序决定

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

funciton 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')
]}) 
复制代码

上面的代码最终会打印出 C。因为尽管它位于代码的最后,但是他的优先级确实最高的。如果不指定优先级,那么最终将输出 A,因为他的顺序是最靠前的。

后面的章节会详细介绍扩展的实现及使用

文档偏移

CodeMirror 使用纯数字标记文档中的位置。这些数字代表字符个数-更精确的说是UTF16 码元(因此星型字符看作是2个码元)。换行总是被视为1个码元(即使你主动设置了换行符也是1个码元)

这些偏移用来追踪 selection,更新位置,装饰内容等等。

有时候弄清文档被更新的内容起点在哪,终点在哪很重要。因此,CodeMirror提供了一个位置映射特性,即给定一个 transcation(或者只是一个更新集合)和一个起始位置,那么就能给出对应的新位置。

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'}]})
console.log(tr.changes.mapPos4))
复制代码

文档的数据结构同样可以按行遍历,所以按行查询也是非常轻量的操作

import { Text } from '@codemirror/state'
let doc = Text.of(['line1', 'lin2', 'line3'])
console.log(doc.line(2))
console.log(doc.lineAt(15))
复制代码

数据模型

CodeMirror作为一款文本编辑器,当然是把文档当成扁平字符串看待的.以树形的数据结构存贮,以便在文档的任意地方都可以廉价的更新(通过行号可以高效的查找)

文档更新

文档更新,也就是精确描述旧文档范围的值被替换为新文本的过程.允许扩展精确最终文档究竟发生了什么,允许如撤回历史和协同编辑这样的功能在核心库之外实现.

当创建一个更新集合时,所有的更新都是相对于最原始的旧文档存在的-理论上它们是同时执行的. (如果你确实需要在更新集合中,根据相对于最原始文档的上次更新再做更新时,可以使用文档更新集合的 compose 函数)

Selection

editor state 除了保存文档属性外,还保存了一份当前的 selection 属性。Selectrion 可能由多个 range 组成,每个都可能是一个 cursor 或者由 anchor 和 head 描述的 range。重叠的 range 会自动合并,而且 range 会被排序,这样,selection 的 range 属性总是一个有序的非重叠的 range 数组

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

let state = EditorState.create({
  doc: "hello",
  selection: EditorSelection.create([
    EditorSelection.range(0, 4),
    EditorSelection.cursor(5)
  ]),
  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 中的一个会被视为 main range。也就是浏览器 DOM selection 对应的那个。剩下的完全是由 CodeMirror 绘画和处理的。

默认情况下,state 只接受由一个 range 组成的 selection。只有通过扩展(如 drawSelection)才能启用多个 selection 并且访问它们。

state 对象有一个便捷的方法 changeByRange, 可以单独的对每个 selection 进行操作(如果手动执行可能有些难办)

import {EditorState} from "@codemirror/state"

let state = EditorState.create({doc: "abcd", selection: {anchor: 1, head: 3}})

// Upcase the selection
let tr = state.update(state.changeByRange(range => ({
  changes: {from: range.from, to: range.to,
            insert: state.sliceDoc(range.from, range.to).toUpperCase()},
  // The updated selection range—in this case it stays the same
  range
})))
console.log(tr.state.doc.toString()) // "aBCd"
复制代码

配置

每个 editor state 都有一个私有属性引用到它们的配置,这是由当前 state 激活的扩展决定的。如果是常规 transaction,那么配置都是一样的。但是可以通过使用 compartments 重新配置 state,或者添加替换当前配置。

state 配置的直接影响了它存储的属性,和通过 facets 关联的值。

Facets

facet 就是一个扩展点。不同的扩展值可以为 facet 提供不同的值。facet 可以读取到任何访问 state 后生成的合并值。通过 facet,就可以获取提供的数据集合或者由其计算而来的值。

facet 背后的哲学就是大多数类型的扩展允许多个输入,然后能通过输入计算出某些值来。这些组合方式可能有些许不同。

  • 对于如tab大小这样的情况来说,只需要一个输出值,所以 facet 会优先获取并使用这个值
  • 当提供 event handler 时,大多数时候都希望它是一个按优先级排列的数组,这样就可以按优先级顺序确定哪个 handler 来处理事件
  • 另一个常见的模式是逻辑计算或者对输入值(以 allowMultipleSelections 形式)计算或者以某种方式 reduce(如在要求的撤回历史深度中查找最大值)
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]
复制代码

Facets 需要明确定义,生成一个 facet 值。这个值可以被导出供其他代码重新导出、读写、甚至可以模块私有化存储,即只有那个模块能够访问它。后面如何写扩展部分会详细介绍。

在一个给定的配置中,大多数 facets 倾向于静态的只作为配置的一部分暴露出来。当然也可以从 state 的其他层面去计算 facets 值。

facets 值只在必要的时候重新计算,所以可以使用对象或者数组标识来廉价的测试 facets 是否发生了改变。

Transactions

Transactions 由 state 的 update 方法常见,把以下(所有都是可选的)的多种效果结合起来。

  • 实现文档更新
  • 显式移动 selection。注意,当有文档更新时,但是没有明确的新 selection 生成,此时 selection 会被完全映射到这些改变上
  • 设置 flag 指示 view 把(main)selection 滑动到当前视窗中
  • 可以设置任意数量的注释,用来存储描述整个 transcation 的元数据。例如,userEvent 注释可以用来识别某些确定的常见操作生成的 transcation 如 typing 或粘贴
  • 可以有副作用,即自包含的额外副作用,通常是在某些扩展的状态里。(如折叠代码或者开始自动补全功能等)
  • 可以影响 state 的配置。或者通过完全提供一个新的扩展集合,或者替换配置中的某个部分来生效。

为了完全重置一个 state-如加载一个新文档-推荐创建一个新 state 来代替使用 transcation。这样可以保证没有未知的 state 出现干扰(如撤回历史事件等)。

View

view 尽可能的相对于 state 保持透明。不幸的是,有些情况下,编辑器确实无法单纯的依赖 state 处理。

  • 当处理屏幕坐标时(如找到用户在哪点击或者找到给定位置的坐标),需要先访问布局,然后是浏览器DOM
  • 编辑器从周围的文档接收文本方向(如果被覆盖时,从它自己的 CSS 样式中获取)
  • 指针动画取决于布局和文本方向。所以,view 提供了许多工具方法来计算不同类型的动画
  • 某些 state,如 focus 和滑动位置,不是存储在函数式 state 中,而是留在 DOM 中。

CodeMirror 不希望用户代码去操作它管理的 DOM 结构。如果这样做了,那么就会看到 CodeMirror 立马 revert 了你的更新。查看装饰这章了解如何正确的修改显示的内容。

Viewport

需要注意的一件事是当文档很大时 CodeMirror 不会渲染整个文档。为了避免额外做工,CodeMirror 在更新时,会探测当前正在显示的内容(没有滑出视窗),只渲染这部分及其边缘部分。这就是 viewport

查询不在当前 viewport 中的位置坐标是无效的(因为它们还未被渲染)。view 不追踪整个文档的高度信息(初始化时大概估算,当内容被渲染时才会精确测量),即使这部分已经不在 viewport 了。

长行(没有折叠)或者一段折叠的代码依然会让 viewport 变得巨大。编辑器同时提供了一系列可视的 ranges,不会包含这些不可见的内容。当像高亮代码,或者不想处理不可见内容时就很有用。

更新周期

CodeMirror 视图会尽量减少它引起的 DOM 重绘。分发一个 transcation 通常只会导致编辑器渲染DOM,而不会读取布局信息。读取布局操作(检查 viewport 是否有效,指针是否需要滑动到视图等等)会在另外一个单独的测量阶段完成,使用 requestAnimationFrame 排期。此周期会在必要的时候跟进另外一个写入周期。

可以使用 requestMeasure 方法排期自己的测量代码。

为了避免怪异的重入场景,在一个更新正在同步执行,而另一个新更新初始化时,view会抛出一个错误。当测量周期依然在排队时多次更新不会造成什么问题-因为 CodeMirror 它们的测量周期会自动被合并。

当 view 的工作已经完成时,可以调用它的 destroy 方法丢弃它

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. 像来自扩展的 panels 和 tooltips 可以放在这儿。

在期内就是 scroller 元素。如果编辑器有自己的 scrollbar,那么这个元素的样式应该是overflow: atuo;。但其实不必这样做,因为编辑器支持根据内容自动适应高度,或者当滑动时增加 max-height 值.

scroller 是水平 flexbox 元素.如果有槽,会被放置在起点位置.

在 scroller 内部即是 content 元素,他是可编辑的.它注册了一个 mutation observer 在 DOM 上.content 中的任何改变都将导致编辑器把它们解析为文档更新,然后重新绘制受影响的节点.此容器为 viewport 中的每一行生成一个 line 元素,它里面就是文本,可能由样式或者组件装饰.

样式和主题

为了管理编辑器相关的样式, CodeMirror 使用 js 注入样式系统实现.样式可以通过 facet 注册,可以确保 view 一定会将他们显示出来.

许多编辑器中的元素的类名前缀都是 cm-.这些都可以在你本地的 CSS 进行覆盖.同样也可以针对主题来进行样式配置.一个主题就是由 EditorView.theme 创建的扩展.它有它自己的CSS类(当主题扩展激活时会被添加到编辑器),可以通过这个类名定义样式的范围.

主题声明使用 style-mod 表示法可以定义任意数量的 CSS 规则.编辑器使用的是 cm-开头的类名.下面的代码创建了一个修改默认文本颜色为orage 的主题

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" }
})
复制代码

命令

命令就是有特殊签名的函数.它们主要用来绑定快捷键,但其实也可以用在像菜单条目或命令盘等场景.一个命令函数就代表了一个用户操作.接收一个view,返回boolean, false 表示不适用当前场景, true表示当前场景可以执行.命令的就是生成命令式,通常是分发 transcation.

当多个命令被绑定到同一个快捷键时,只有那个返回true的会得到执行.

命令只能影响 state, 而不是整个 view, 可以使用 StateCommand 类型代替,它是 Command 的一个子类型,只是它的参数必须有 state 和 dispatch 属性.通常可以用来在没有 view 的情况下测试命令.

扩展 CodeMirror

扩展 CodeMirror 的方式有很多种,选择哪个最合适不太容易.下面的章节会把这些方案都介绍一下,以便在写扩展时可以参考.

state fields

扩展通常需要在 state 中保存额外的信息。撤回历史需要保存可撤回的更新,代码折叠扩展需要追踪哪些行已经被折叠等等。

扩展可以通过自定义额外的 state 属性来实现这些需求。state 属性以纯函数的形式存在 state 数据结构中,保存的必须是不可变值。

state 属性和 state 状态以类似 reducer 的形式保持一致。每次 state 更新时,就会以这个属性和 transaction 作为参数调用一个函数,然后返回另一个新的属性值。

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
复制代码

通常可以使用 注释或 effect 来观察 state 属性。

看起来可以把一个 state 赋值给一个 state 属性,但是当 transaction 触发更新时就会有些冗余繁杂了(不要这样做)大多数情况下,把你的 state 绑定到编辑器是很有用的-可以在扩展 state 的更新周期,因为保持同步是一件非常简单的事。

影响 View

view 插件为扩展提供了一条在view内运行命令式组件的思路。比如事件处理、添加管理DOM元素、或者依赖于当前 viewport 的操作等都是很有用。

下面的插件简单地在编辑器角落显示文档大小

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() }
})
复制代码

view 插件最好不要保存状态(自己生成的),应该作为 editor state 保存的数据的浅层视图存在。

当 state 重新配置时,不属于新配置的 view 插件都会被销毁(也就是如果他们对编辑器进行了更新操作,那么在他们的 destroy 方法里撤回这些操作)

当 view 插件 crash 时,它会自动被禁用以防拖垮整个试图。

装饰文档

如果不是特别声明,CodeMirror 通常会把文档作为普通文本来渲染。装饰就是那些扩展可以影响文档样式的机制。通常有四类

  • 标记装饰:为给定范围内的文本添加样式或 DOM 属性
  • 组件装饰:在文档的给定位置插入 DOM 元素
  • 替换装饰:隐藏文档或者以给定 DOM 节点替换它
  • 行装饰:为行封装元素添加属性

装饰以两种方式存在。一种是 state facet,可以在 editor state 层面上提供装饰,通常是以 state 属性的形式操作。它不允许装饰 viewport(因为 state 不知道 viewport)所以如折叠区域或者lint 提示等会比较好。

第二种是 view 插件。用来做语法或者搜索匹配高亮等特性,因为 view 插件可以读取当前 viewport,以避免操作不可见的内容。

装饰都是以集合存在的不可变数据集。依据使用情况,可以跨更新映射(调整内容位置以弥补更新变化)或者在更新时重建等。

扩展架构

为了创建一个可用的编辑器,通常都得需要扩展的辅助:state 属性保存状态,基础主题提供样式,view插件管理输入输出,命令/facet 处理配置等等。

常见的扩展模式是导出一个可以返回需要特性的扩展值。这个函数即使没有参数也是可以的-它让延迟添加配置选项而不打破向后兼容成为可能。

因为扩展可以引入其他扩展,当你的扩展被导入多次时就需要考虑下了。对于某些扩展来说,如keymap,多次导入是合理的。但其他情况下就是多余浪费且有问题的。

通常可以依赖解重复扩展值的方式来处理多次引入的问题-如果你确定创建的静态扩展值(主题,state 属性,view 插件等)只创建一次,而且扩展构造函数总是返回相同的对象,那么在编辑器中你将只获得它的拷贝。

但是当您的扩展允许配置时,您的其他逻辑可能需要访问它。当扩展的不同实例有不同的配置时,你会怎么做?

有时,这只是一个错误。但通常,可以定义一个协调它们的策略。facet 对此很有效。您可以将配置放在模块私有 facet,并使其 combining 函数协调配置或在不可能时出错。然后需要访问当前配置的代码可以读取该方面。

查看 zebra stripes 例子详细介绍这个问题。

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改