CodeMirror:System Guide

Architecture Overview

Modularity

CM6将自己分隔成比较细碎的模块。核心模块包括:

  • @codemirror/text,提供编辑器的文档表示
  • @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
})

@codemirror/basic-setup提供了一个初始设置,方便开始使用CM6。

这些模块包都是以ES6模块的方式发布,理论上可以不通过打包器直接使用。

Functional Core, Imperative Shell

采用函数式设计方法,避免副作用。但是DOM本身是一个从全局传递状态的命令式结构。为了解决这个问题,CM库的状态表示是完全函数式的,表征文档和状态的数据结构是不可更改的,其上的操作是完全函数式的。这些通过视图途径以及命令接口包装成命令式接口。

这意味着状态更新后,依然可以访问旧的状态。同时也意味着不能直接对状态做修改。通过Typescript,这些不可修改的数据被标志成readonly。

State and Updates

此库处理更新的方式受Redux和Elm启发。除了个别例外(比如打字或者拖放处理),视图的状态整个都由EditorState的子辖属state处理。

在函数式代码中,通过创建一个事务来描述对文档,取选,以及其他状态属域的修改。创建的事务可以被调派给视图以更新状态,此过程中会将状态同步到编辑其的DOM表示层。

典型的数据流向是

 view -> DOM event -> transaction
  ^                      |
  |                      |
  +---- new state -------+

Extension

核心库设计得小而通用,于是很多功能要在扩展中实现。扩展可以是任意东西,从仅对配置做一些修改,到在状态标的中定义新的属域,到给编辑器加上样式,到注入自定义的强制式(imperative)组件到视图中,等等。

活跃的扩展集合会被保持在编辑器状态内(可以通过一个事务来改变)。扩展通常的提供方式为,从某个帕包中导入的值(可以为连串)。扩展可以任意嵌套(即便是连串的连串),重复的部分会在配置过程中去掉。一个扩展可以引入其他扩展,即便一个扩展可以被引用多次,也只会生效一次。

如何决定扩展的优先级?一看是否显示设置,二看摊平后的扩展在合集中的次序。

Document Offsets

CM6使用普通的数字来致认文档中的位置。数字用来计数文档中UTF16字符的码点单元数。断行永远只算一个单元,即便表示上配置得更长。

这些数字偏移量用于跟踪取选,定位改动,修饰内容,等等。

CM库提供位置映射,给定一个起始位置针对于某个事务的生效之后的新位置。

文档的数据结构也有按行的索引,因此按行数查找也不是太费时的操作。

Data Model

作为一个文本编辑器,CM将文档作为一个平铺字符串看待,并将其储存在树状的数据结构中,以方便更新文档,以及按行索引文档。

Document Changes

文档改动本身是量值,内容包括旧文档的哪些部分被新文本替换了。这让扩展可以跟踪改动,从而使撤消历史以及协同编辑可以在核心库之外实现。

当创建改动集的时候,所欲改动都按照原有文档表述,也就是说改动集中的所有改动就像是针对于同一个文档的改动。如果要让这些改动看起来一个接一个,需要使用改动集的compose方法获得。

Selection

除了文档之外,编辑器状态中还保存了当前取选。取选可以由多个行程构成,每个行程可以是一个空光标,或者从锚尾到头尖的行程。行程间若有重合的部分,会被自动合并。行程会被排序。

取选中的行程中的某个会被标为主行程,会反映在DOM取选中。其他的取选完全由CM库处理。

默认情况下状态中的取选只能划定一个行程。必须通过扩展才能在取选中启用更多行程。

状态标的有一个方便的方法,changeByRange,用于给每个取选行程应用一个操作。

Configuration

每个编辑器状态都有一个到当前配置的引用。当前配置由当前活跃的扩展决定。常规的事务不会改变配置,但是可以通过reconfigure事务指示来重配置当前状态。

状态的配置的主要效果是会产生并存储一些属域,并且产生并存储一些状态的细侧(facet)。

Facets

一个细侧是一个扩展点。不同的扩展可以尾细侧提供不同的量值。任何人只要能访问状态以及某细侧,就可以获取细侧上综合而得的值。根据细侧的不同,所得的值可以是一个连串,或者只是单个量值。

细侧背后的想法是,多数类型的扩展允许多重输入,但是希望从多重输入中的到综合的值。不同的细侧对值进行综合的办法不尽相同。

Transactions

事务通过状态的update方法创建,可以用于综合一些列效果(均是可选的)

  • 可以应用文档改动
  • 可以显示移动取选
  • 可以设置一个标志来吩咐视图来将取选滚动到视图中
  • 可以任意评注,用于存储额外的元数据
  • 可以有效应,即自包含的额外效果,用于某些扩展的状态
  • 可以影响状态的配置,通过提供一套完整的新扩展,或替换配置中特定的部分

重置一个状态,比如装载一个新文档,最好创建一个新状态,而不是使用一个事务。

The View

视图作为一个在状态之上的透明层。不幸的是,有一些部分有一些方面的问题只有状态上的数据是无法解决的

  • 处理屏幕坐标时需要对视图层的访问,以及浏览器DOM
  • 编辑器从周遭文档接收文本走向(或者从自身的CSS)
  • 光标移动会依赖于视图层以及文本走向,因此视图提供了一系列助力方法用于计算不同类型的移动
  • 一些状态,比如焦点或者滚动位置,没有存储在函数式的状态,而是留存在DOM中。

CM库不期待用户代码加工处理编辑器所处理的DOM结构。可以使用饰装(decoration)来影响内容的显示方式。

Viewport

有一个值得注意的问题是,CM并不渲染整个文档。而是在更新的时候检测需要显示的那部分内容,然后以多出一部分的范围来渲染之。这个所渲染的部分叫做视框。

查询视框之外的位置的坐标将无法正常工作。视图不会跟踪整个文档,或者视框之外部分的高度信息。

没有折行的长文本行,或者折叠的代码块可以让视框变得非常大。编辑器还提供了一各可视行程列表,不会包含哪些不可见的内容。

Update Cycle

CM的视图努力减小所导致的的DOM的重排布。派发一个事务总的来说只会导致编辑器写入DOM,而不会去读布局信息。对于布局相关的信息,比如视框是否有效,光标是否需要滚入视图,等信息是在一个独立的测量阶段完成的,通过requestAnimationFrame调度。此测量阶段,如果有必要,会触发另一个写入阶段。

可以使用requestMeasure调度自定义的测量代码。

为了避免怪异的重入性问题,视图会丢出一个错误,一旦一个新的更新被触发的时候,另一个更新还在应用过程中。多个更新可以同时应用,只要它们的测量阶段还没发生,此时多个测量阶段会合并。

视图使用完了,可以调用其destroy方法丢弃之。

DOM Structure

编辑器的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。面板和工具提示可以由扩展放至此处。

如果编辑器自己有滚动条,那么schooler应该设置为overflow: auto。但是也不一定,编辑器支持将内容扩展到一定高度。

scroller是一个横向flexbox,槽位会被添加到起始处。

content元素被设置成可编辑,之上注册有一个DOM变更观察器,DOM上的任意改动会触发编辑器将他们解析成文档改动,然后重新绘制受影响的节点。此容易持有line元素用于表示视框中的每一行,其中持有具体的文档文本(有可能带修饰有样式和部件)。

Styles and Themes

CM允许在细侧上注册样式,视图会保证这些样式可得。

需要元素的类采用前缀cm-。这些可以在本地的CSS中直接上样式。也可以在主题中上样式。主题是通过EditorView.theme创建的扩展。有自己的CSS类,其内可以定义样式。

一个主题可以通过style-mod定义任意数目的CSS规则。

扩展可以定义基础主题,用于提供所创建元素的默认样式。基础主题可以使用默认的&light,或者&dark占位符。

Commands

号令是具有特殊签名的函数。它们最主要用于键绑定,但是也可以用于菜单项或者号令选牌。一个命令函数代表一个用户行动。它接收一个视图并返回一个布尔值,false表示号令不适合当前视图,true表示号令可用于当前视图,并被成功执行。号令的效应是令行性的(imperatively),通常通过派发一个事务来完成。

若一个键绑定对应多个号令,那么这些号令会被逐一尝试,直到有一个号令执行成功。

作用在状态上,而不是整个视图上的号令,可以用StateCommand为类型。可以在不需要视图的情况下测试这个号令。

Extending CodeMirror

State Fields

出于各种目的,扩展可以定义额外的状态属域。这些个属域存在于函数式状态数据结构中,必须存储不可变更量值。

状态属域通过reducer与状态的其他部分同步。

通常你会想使用注解和效应来沟通当前发生在你定义的属域之上的操作。

Affecting the View

视图插件提供了一个让扩展同执行一个令行型的组件在视图中。对于时间处理器,以及DOM元素的操作,以及依赖于视框的操作很有帮助。

视图插件通常不应该持有状态。它们最好的用处是作为影子视图存在。

当一个状态需要重新配置,视图插件若新在的插件中不存在,就会被销毁。

当一个视图插件崩溃额时候,会被被禁用以避免摧毁整个视图。

视图插件可以提供属域,有点像视图层级上的细侧,多个插件可以贡献给通过插件属域。多数情况下,是在文档修饰中使用。

Decorating the Document

默认情况下,CM会将整个文档绘制成纯文本。饰装是一个机制让扩展可以影响到整个文档的外观。饰装有四种:

  • 记号饰装为文本的某个行程上增加样式和DOM属性
  • 部件饰装在文档的指定位置插入DOM元素
  • 替换饰装隐藏文档的一部分,并替换以指定DOM节点
  • 线条饰装可以添加属性到直线的周遭元素

饰装可以用两种方式提供。有一个状态细侧允许你在编辑器状态范围提供饰装,通常是以状态属域派生出来的。这种方法不允许你只饰装视框。这种做法最好的用处是折叠的区域或者线头注解。

另一种方法是通过视图插件进行饰装。通常用在措辞或者搜索匹配高亮,因为视图插件可以读取当前视框以避免当前不可见的内容。

饰装保存在集合中,这也是不可变更的数据类型。这些集合可以跨改动映射,或者在更新时重建。

Extension Architecture

通常一个编辑器功能需要捆绑多个类型的扩展:一个状态属域来保持状态,一个基本主题来提供样式,一个视图插件来管理输入和输出,一些号令,或许一些配置的细侧。

一个常见的模式时导出一个函数,这个函数返回必要的量值形式的扩展,以支持你的特性。以函数的形式提供,即便不接受任何参数,也是一个好主意,它让以后添加配置选项时,更容易保持向后兼容性。

扩展可以导入其他扩展。所以写扩展的时候可以考虑下被多重引用的时候该如何处理。如果扩展构建函数每次都返回相同的现例,那么不需要额外的操作,CM自带的去重功能就够了。

但是如果一个扩展允许配置和,而其他逻辑又要访问这些配置。那么一个扩展的不同现例有不同配置的时候应该怎么处理,是一个需要考虑的问题。

有时候,报告一个错误就可以了。但是,有时候可以采用一个策略来整合它们。细侧在这方面做得就很好。可以把配置放在一个模块专属的细侧,然后通过它的合并函数来整合不同配置,或者报告错误。需要访问这些配置的代码可以从细侧读取所需信息。

(完)

2022-04-18更新

  • ACE的光标是采用textarea来实现的,内容区域是普通的HTML元素。
  • CM5的光标也是用textarea来实现的,不过闪烁则是采用一个div模拟的。
  • Monaco的光标似乎没有采用textarea,那么输入的时候需要自己侦听每个DOM按键了
  • CM6采用的是contenteditable,光标是浏览器实现的。

https://blog.replit.com/code-editors看,ACE的方式能够在移动端工作,Monaco则不行,CM6则可以。 REPLIT的另一篇文章Betting on CodeMirror则提到了一些他们开源的CM6插件。

相关的HN上的讨论:

(更新完)