state 示例:三个插件的实现

📅
2 分钟阅读
·

state 阶段已介绍 EditorState 的装配与应用、Selection、Transaction,以及两篇 Plugin 系统文章。本篇通过三个插件示例验证这些接口的使用方式:字符统计使用 StateField,只读开关使用 filterTransaction,选区上报使用 view 规格。这些需求分别对应插件保存自身数据、拦截文档修改和响应状态更新的场景,实际插件通常组合其中一种或多种能力。参考代码是 prosemirror-state 的 ffad5d9。字符统计使用 prosemirror-model 的 descendants 与 textContent,参考代码是 prosemirror-model 的 6264de0。装配示例使用 prosemirror-view 的 EditorView(ca4c78e),这里只使用其 dispatch 与 updateState;schema 使用 prosemirror-schema-basic 的 756726f。

系列目录

插件一:字符统计与 StateField

需求:在编辑器页脚显示当前文档字符数。字符数由文档派生,适合存入 StateField。代码如下:

import {Plugin, PluginKey} from "prosemirror-state"

const charCountKey = new PluginKey("charCount")

function countChars(doc) {
  let count = 0
  doc.descendants(node => {
    if (node.isText) count += node.text.length
  })
  return count
}

const charCountPlugin = new Plugin({
  key: charCountKey,

  state: {
    init(config, instance) {
      return {count: countChars(instance.doc)}
    },

    apply(tr, value, oldState, newState) {
      if (!tr.docChanged) return value
      return {count: countChars(newState.doc)}
    },

    toJSON(value) {
      return value.count
    },

    fromJSON(config, value) {
      return {count: value}
    }
  }
})

外部读数走 key:charCountKey.getState(state).count

countChars 用 doc.descendants 遍历整棵树,遇到文本节点就累加 text.length。这是 prosemirror-model 里 Node 上的遍历方法,回调返回 false 可以跳过某棵子树,这里全部走到底。同一件事也可以用 doc.textContent 一行做完,那是把所有文本拼成字符串。两种写法数出来的结果一样,选 descendants 是因为以后想排除某类节点(比如代码块不算字数)时,回调里加个判断就行,textContent 没有这个机会。

这份代码使用了 StateField 的四个方法,对应 src/plugin.ts 中的完整接口签名。

init 在 EditorState.create 中执行:src/state.ts 的 create 先创建 Configuration,再按 fields 数组顺序调用 init,并将返回值写入 state 实例的同名属性。doc、selection、storedMarks、scrollToSelection 位于 baseFields 的开头,插件字段随后装配,因此 init 可以读取 instance.doc。插件 init 不能读取排在后面的插件字段,因为 instance 此时尚未完成装配。apply 的第四个参数 newState 也遵循相同限制,只能依赖内置字段和排在自己前面的插件字段。字符统计只依赖 doc;跨插件读取新值时,需要调整 plugins 数组的顺序。

apply 中使用 tr.docChanged 可避免无关重算。移动光标、设置 meta 都会产生 transaction,但不会修改文档。字段值遵循不可变更新;未变化时返回旧引用,新旧 state 可以共享同一字段值。

这里采用全量重算:文档变化后重新遍历文档。字符统计的计算成本较低,适合这一策略。字段维护全文搜索结果等计算成本较高的数据时,可在 apply 中通过 tr.mapping 映射旧结果的位置,只重新扫描受影响区间。prosemirror-search(参考代码 647a36f)的 SearchState.apply 先用 tr.mapping.map 映射 activeRange,再在区间内重新匹配并重建装饰。StateField 的 apply 不限制更新策略,只要求返回新值。

toJSON 和 fromJSON 是可选项,不配这对方法,字段就不参与序列化。配上之后的用法:

let json = state.toJSON({charCount: charCountPlugin})
let restored = EditorState.fromJSON(
  {schema, plugins: [charCountPlugin]},
  json,
  {charCount: charCountPlugin}
)

state.toJSON 的参数是「JSON 属性名到插件实例」的映射,src/state.ts 的 toJSON 会拿映射里的插件去找它 spec.state 上的 toJSON 方法,把字段值写进指定的属性名。doc 和 selection 两个名字被保留,映射里写了会抛 RangeError。fromJSON 一侧靠 plugin.key == field.name 对号,把 JSON 属性读回来交给字段的 fromJSON。要在 fromJSON 时定位并恢复插件字段,插件需要配置稳定的 key;toJSON 则通过映射中提供的插件实例读取字段。key 也用于对外通过 getState 取数。传给 PluginKey 的名字无需全局唯一:src/plugin.ts 的 createKey 遇到重名会追加 $ 和序号,因此同名的两个 “charCount” key 仍对应不同字段。

插件二:只读开关,filterTransaction 加 meta 通信

需求:编辑器有个只读模式,开着的时候任何文档修改都不生效,选区移动不受影响。开关本身存进一个 StateField,拦截交给 filterTransaction:

import {Plugin, PluginKey} from "prosemirror-state"

const readonlyKey = new PluginKey("readonly")

const readonlyPlugin = new Plugin({
  key: readonlyKey,

  state: {
    init() {
      return false
    },
    apply(tr, value) {
      let flag = tr.getMeta(readonlyKey)
      return flag === undefined ? value : flag
    }
  },

  filterTransaction(tr, state) {
    if (!tr.docChanged) return true
    return !readonlyKey.getState(state)
  }
})

通过 meta 修改开关:

view.dispatch(view.state.tr.setMeta(readonlyKey, true))

开关值是布尔 StateField,必须通过 meta 修改,不能直接赋值:state 不可变,插件字段的变化需要随 transaction 应用。meta 用于传递这类指令。src/transaction.ts 的 setMeta/getMeta 接受 string、Plugin、PluginKey 三种 key。PluginKey 可避免字符串冲突,也不要求调用方持有插件实例。meta 不写入文档或 toJSON 输出,并在 transaction 应用后失效,适合传递一次性指令。

filterTransaction 的签名是 (tr, state) => boolean,其中 state 是应用 transaction 前的 state。src/state.ts 的 applyTransaction 首先调用 this.filterTransaction(rootTr),因此 readonlyKey.getState(state) 读取的是修改前的开关值:先检查当前是否只读,再决定是否放行。若同一笔 transaction 先通过 setMeta 关闭只读、再插入文字,按 transaction 内的新值判断会产生歧义。

否决的粒度是整个 transaction。返回 false 后,applyTransaction 返回 {state: this, transactions: []},state 保持不变,transaction 携带的选区变化也不会生效。本插件已放行所有不修改文档的事务。若需求要求拦截文档修改但保留同一事务中的其他效果,filterTransaction 的粒度不合适,可改用 appendTransaction 追加修正事务。filterTransaction 适合整个事务要么应用、要么拒绝的场景,例如只读或权限控制。

有两个实现细节。判断使用 prosemirror-transform(参考代码 662b7a9)的 Transform 类提供的 tr.docChanged,仅修改选区或 meta 的 transaction 不会被拒绝,因此只读模式下仍可移动光标。切换开关的事务只携带 meta,也能通过过滤器。filterTransaction 同样会检查其他插件追加的 transaction:appendTransaction 每追加一笔事务都会调用 newState.filterTransaction(tr, i),第二个参数只豁免追加者。只读模式开启后,其他插件不能通过 appendTransaction 修改文档。

被否决后,applyTransaction 返回 {state: this, transactions: []},state 不变,事务列表为空。默认的 EditorView.dispatch 通过 state.apply(tr) 取得 state,不暴露事务列表。自定义 dispatchTransaction 若调用 applyTransaction,可根据 transactions 是否为空决定是否执行额外逻辑;本例使用 state.apply(tr) 后调用 updateState,接收到的仍是旧 state。要提示用户当前只读,需要在界面上监听开关字段的变化;filterTransaction 本身不提供通知渠道。

插件三:选区变化上报,view 规格

需求:在编辑器外部面板显示当前选区位置。该功能不修改文档或 state,只需在 state 更新后接收通知,因此使用 PluginSpec.view:

import {Plugin} from "prosemirror-state"

function reportSelection(sel) {
  console.log(`selection: ${sel.from} -> ${sel.to}, empty: ${sel.empty}`)
}

const selectionReporterPlugin = new Plugin({
  view(editorView) {
    reportSelection(editorView.state.selection)
    return {
      update(view, prevState) {
        let sel = view.state.selection
        if (!sel.eq(prevState.selection)) reportSelection(sel)
      },
      destroy() {
        console.log("reporter detached")
      }
    }
  }
})

src/plugin.ts 里 view 规格的签名是 (view: EditorView) => PluginView。插件状态第一次和某个 EditorView 关联时这个函数被调用,返回的对象就是 PluginView,类型上只有两个可选方法:update 和 destroy。这个时机比想象中早:prosemirror-view 的 src/index.ts 里,EditorView 构造函数收尾就调 updatePluginViews,编辑器一创建 view 规格就执行,不用等任何操作。view 函数拿到的 editorView 参数可以直接用,代码里一进来就先报了一次初始选区,免得面板在第一次操作之前是空的。

update(view, prevState) 在 view 每次更新 state 后都会被调到,包括文档没变、只有插件字段变了的情况。所以上报选区要自己去重,否则拖一次光标旁边的字数统计刷新也会跟着报一遍。去重用 src/selection.ts 的 Selection.eq,它做值比较:文本选区比 anchor 和 head,节点选区比选中的位置,和 === 不同,两个不同的 selection 对象内容一致也算相等。字段 from、to、empty 是 Selection 上的通用访问器,四种选区类型都有。

view 规格和 props 的职责不同。props 是声明式接口:插件提供 handleDOMEvents、decorations 等属性,由 view 层决定调用时机和同名 prop 的合并方式。view 规格允许插件取得 EditorView 实例,自行注册监听、维护 DOM,并决定 update 中的逻辑。状态上报、浮层和外部面板同步适合 view 规格;view 在事件点请求插件处理时使用 props。本插件只需接收状态更新,因此只使用 view 规格。

destroy 在两种时机被调:view 销毁,或者 view 收到一份插件集合不同的新 state(reconfigure 场景,旧插件的 view 会被摘除)。上报插件没有资源要清,留一行日志占位;真实场景里在这里解绑 DOM 监听、清定时器。

选区上报不应使用 appendTransaction。其签名为 (transactions, oldState, newState) => Transaction | null | undefined,用于在处理一批 transaction 后决定是否追加修改。任何插件追加事务后,src/state.ts 的 applyTransaction 循环都会再次调用各插件的 appendTransaction,若在此执行上报副作用,同一选区可能被重复上报。此外,appendTransaction 在 state 装配阶段执行,早于 view 的 updateState 和 PluginView.update,对面板而言不表示编辑已经完成。上报属于视图侧副作用,应放在 view 规格中;appendTransaction 应只返回需要应用的修改。

装配起来跑一遍

下面是三个插件的最小装配代码:

import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {schema} from "prosemirror-schema-basic"

let state = EditorState.create({
  schema,
  plugins: [charCountPlugin, readonlyPlugin, selectionReporterPlugin]
})

let view = new EditorView(document.querySelector("#editor"), {
  state,
  dispatchTransaction(tr) {
    let newState = view.state.apply(tr)
    view.updateState(newState)
    let {count} = charCountKey.getState(newState)
    document.querySelector("#counter").textContent = `${count}`
  }
})

dispatchTransaction 是 view 侧处理 transaction 的入口。state.apply(tr) 内部调用 applyTransaction:先执行 filterTransaction,再由 applyInner 逐字段应用,最后执行 appendTransaction 循环。三个插件分别在对应环节运行,PluginView.update 由后续的 updateState 触发:

三个插件在一次 dispatch 中的挂接点

可按以下方式验证:输入文字后页脚计数更新;dispatch setMeta(readonlyKey, true) 后输入不会修改文档或计数,但仍可移动光标;拖动选区时控制台输出 from 和 to;再 dispatch setMeta(readonlyKey, false) 后可继续输入。这些现象分别对应三个插件所使用的钩子。

两个装配细节如下。三个插件在 plugins 数组中的顺序不影响行为:它们不读取彼此的字段,两个带 StateField 的插件之间也没有依赖。示例将页脚计数直接写在 dispatchTransaction 中以减少代码;实际项目通常由页脚组件订阅编辑器更新事件,避免在 dispatch 中处理 UI 刷新。

state 阶段收尾

插件字段应通过 StateField 随 transaction 更新;影响 DOM 或外部系统的副作用放在 PluginView 生命周期中。filterTransaction 只能接受或拒绝整笔事务,无法保留其中一部分效果。props 的绑定机制已在第 22 篇说明,appendTransaction 的循环细节见第 23 篇。

下一阶段讨论 prosemirror-view 如何将 state 映射为 DOM,以及 PluginView.update 在 updateState 中的调用位置。


782 字 · 37 段落
ximing

Follow onGitHub

相关文章