ProseMirror Example For NodeView

prosemirror

ProseMirror Example For NodeView

ProseMirror 支持通过 NodeView 节点视图为编辑器添加自定义视图

自定义视图

在 ProseMirror 中可以通过以下几种方式添加自定义视图:

  • 在实例化 schema 时,通过配置对象的字段 nodes(或 marks)的属性 toDOM,设置不同的节点类型(或样式标记)渲染到页面上的 HTML 结构,适用于构建一些简单的静态视图(交互功能由 ProseMirror 实现处理)
  • 在实例化 EditorView 时,通过配置对象的属性 nodeViews 为特定的节点类型自定义视图构建函数,适用于构建复杂的视图(可以接管交互功能)
  • 使用 decoration 装饰器,在文档的特定部分添加自定义视图或样式,适用于进行轻量级的 UI 定制,它只是为页面添加一些「点缀物」,例如语法高亮,它只影响渲染输出效果而不会修改编辑器的文档内容
  • 使用 plugin 插件为编辑器添加自定义视图(在实例化 plugin 时,通过配置对象的属性 view 进行设置),适用于为编辑器创建全局通用的视图(而前面的方法,则适用于针对特定节点类型,即自定义视图会根据文档具体结构而嵌入到相应位置),例如菜单栏

可以参考的相关示例:

  • 在官方的介绍文档中给出了两个示例,分别是为 image 图像节点和 paragraph 段落节点设置节点视图
  • 官方提供了一个示例,演示了如何创建可交互的 footnote 脚注,完整的源码可以查看相关的 Github 仓库
    由于 footnote 是作为一个节点插入到文档中的,自定义的视图与特定节点类型相关,所以应该通过 nodeView 节点视图来创建

在实例化 EditorView 编辑器视图时 new EditorView(place, props),通过设置第二个参数 props 配置对象的字段 nodeViews,以键值对的形式 {nodeName: NodeViewConstructor}(其中 nodeName 是节点的名称;NodeViewConstructor(node, view, getPos, decorations, innerDecorations) 是一个函数用于构建节点视图,即它的返回值是一个 nodeView 对象)为相应类型的节点设置节点视图

ts
// 该类的实例化是一个 nodeView 对象
class ImageView {
  constructor(node) {
    // 属性 dom 用于设置该节点视图在页面渲染为什么元素
    this.dom = document.createElement("img")
    this.dom.src = node.attrs.src
    this.dom.addEventListener("click", e => {
      console.log("You clicked me!")
      e.preventDefault()
    })
  }
  // 方法 stopEvent 用于阻止某些/全部来自该节点视图的事件,让它们不被编辑器所处理
  // 该方法最后返回值是一个布尔值,如果为 true 则阻止事件冒泡
  stopEvent() { return true }
}

let view = new EditorView({
  state,
  // 在实例化编辑器视图时,使用以下属性为相应类型的节点设置节点视图
  nodeViews: {
    // 为 image 图像节点设置节点视图
    image(node) { return new ImageView(node) }
  }
})
注意

不同类型的节点可以对应同一种 nodeView

通过 nodeView 的方法 update(node, decorations, innerDecorations) 控制节点视图如何更新时,该方法的第一个参数 node 表示该 nodeView 所对应节点的新状态,则应该注意在新状态里,甚至连节点的类型也可以与原节点类型不同

内外容器

函数 NodeViewConstructor 返回一个 nodeView 节点视图对象,它具有一些属性和方法,用于描述该节点视图在页面的 UI 渲染形式和交互方式等

其中属性 domcontentDOM 分别用于设置(该节点在页面所对应的)outer DOM 外层容器和 inner DOM 内层容器

属性 dom 所设置的 DOM 元素作为外层容器,即表示该 nodeView 所对应的节点渲染到页面的形式

外层容器

默认情况下当节点(内容)更新时,它所对应的 outer DOM 外层容器元素是保持不变的,只是对其子元素进行变动

可以通过 nodeView 的属性 update 设置自定义的更新策略,例如根据节点内容为外层 DOM 元素添加 CSS class 类名

属性 contentDOM 所设置的 DOM 元素作为内层容器,即该 nodeView 所对应的节点的 内容(子节点) 会渲染到该 DOM 元素里

内层容器

属性 contentDOM 是可选的,如果 nodeView 所对应的节点是叶子节点,由于它不包含内容,也就不需要设置内层容器来包裹不存在的子节点

相应地,需要先设置了属性 dom,且该 nodeView 所对应的节点不是叶子节点时,属性 contentDOM 才生效

当设置了属性 contentDOM 时,Prosemirror 会自动将节点的内容/子节点渲染到该属性所指定的 DOM 元素里;而如果 nodeView 所对应的节点具有内容/子节点,但是又没有设置属性 contentDOM,(虽然在编辑器的状态 state 里会记录该节点的完整内容)则该 nodeView 所对应的节点的内容就不会显示在页面上,需要开发者(构建节点视图时)手动设置如何渲染该节点的内容

如果将属性 domcontentDOM 指向同一个 DOM 元素(让外层容器和内层容器相同),即让 nodeView 所对应的节点的内容/子节点直接渲染在外层容器里,(节点视图里直接包含可编辑器的内容)这样就可以让 nodeView 和普通的 prosemirror node 节点的行为模式相似

js
let view = new EditorView({
  state,
  nodeViews: {
    paragraph(node) { return new ParagraphView(node) }
  }
})

class ParagraphView {
  constructor(node) {
    // 将属性 contentDOM 和属性 dom 指向同一个 HTML 元素
    // 则 paragraph 节点的内容会直接渲染在外层容器里
    // 让 nodeView 的行为和普通的段落节点类似
    this.dom = this.contentDOM = document.createElement("p")
    if (node.content.size == 0) this.dom.classList.add("empty")
  }
}

复用元素

函数 NodeViewConstructor 返回一个 nodeView 节点视图对象,它具有一些属性和方法,用于描述该节点视图在页面的 UI 渲染形式和交互方式等

可通过方法 update 以更精细地控制 nodeView 如何更新(例如在特定的场景可以复用当前的 nodeView 实例,以提高编辑器的性能),该函数最后返回一个布尔值以表示是否更新完成

  • 如果该方法返回 true 就指当前的 nodeView 可以通过更新,来表示它所对应的节点的新状态,即该 nodeView 实例可以复用(一般该 nodeView 在页面所对应的 DOM 元素也得以复用)
  • 如果返回 false 表示无法通过更新当前的 nodeView 来表示它所对应的节点的新状态(则需要重新调用构造函数创建一个新的 nodeView 实例来表示它所对应的节点的新状态,一般该 nodeView 在页面所对应的 DOM 元素也需要重绘)
ts
update?: fn(
  // 第一个参数是该 nodeView 所对应(新的)节点
  // ⚠️ 在新的状态下,甚至连节点类型也可以不同,只要新旧两种节点类型都对应同一种 nodeView 就可以
  node: Node,
  // 第二个参数是一个数组,它的元素是 decoration 装饰器对象,以表示应用到该节点的一系列装饰器
  // 这些装饰器可以是 node decoration 节点装饰器对象或 inline decoration 内联装饰器对象
  decorations: readonly Decoration[],
  // 第三个参数是一个对象,表示应用于节点内容/子节点上的装饰器
  innerDecorations: DecorationSource
) → boolean
子节点更新

如果 nodeView 设置了属性 contentDOM(或者没有设置属性 dom),则该 nodeView 所对应的节点的内容/子节点的更新会由 ProseMirror(根据编辑器的通用处理逻辑)自行处理

js
let view = new EditorView({
  state,
  nodeViews: {
    paragraph(node) { return new ParagraphView(node) }
  }
})

class ParagraphView {
  constructor(node) {
    this.dom = this.contentDOM = document.createElement("p")
    if (node.content.size == 0) this.dom.classList.add("empty")
  }
  // 更精细地控制 nodeView 如何更新
  update(node) {
    // 如果 nodeView 所对应的 node 节点类型(与原节点类型)不同,则返回 false
    // 表示原来的 nodeView 实例无法复用,需要重新调用构建函数创建一个新的 nodeView 实例
    // 出现这种是因为存在其他类型的节点和 paragraph 段落节点都对应 paragraphView 节点视图
    // 一般不同的节点类型所对应 paragraphView 节点视图最终在页面上渲染的 DOM 元素也不同,所以原有的 nodeView 不能复用,需要创建一个新的实例
    if (node.type.name != "paragraph") return false

    // 根据 paragraph 节点是否包含内容,为外层容器设置不同的 css class 类名
    if (node.content.size > 0) this.dom.classList.remove("empty")
    else this.dom.classList.add("empty")

    // 返回 true 表示原来的 nodeView 可以复用,相应地在页面上渲染的 DOM 元素也可以复用(在该例子中只是更改了 CSS class 类名)
    return true
  }
}

嵌套编辑器

官方示例 footnote 脚注 演示了如何通过 NodeView 插入嵌套编辑器,而且实现了内外编辑器同步更新。

内外编辑器

节点 footnote 是可以包含内容(纯文本)的,但是它的 nodeView 并没有设置属性 contentDOM,所以在外部编辑器的状态中是保留了 footnote 节点的内容,但是在外部编辑器的视图中(即在页面上)并没有显示出来

当用户点击 footnote 节点时,会弹出 tooltip,在其中创建一个内嵌编辑器,它展示的初始内容就是相应的 footnote 节点的内容。然后用户在内嵌编辑器所进行的修改会同步到外部编辑器

所以可以将外部编辑器看待为保留 footnote 节点的内容,但不提供交互界面;而内嵌编辑器是(在用户点击时)按需创建的一个交互界面,用户在内嵌编辑器的修改操作,会同步到外部编辑器中保存

实现内外编辑器的同步,主要是通过设置 nodeView 的方法 update 方法,以及内嵌编辑器的视图的 dispatchTransaction 方法,相关代码如下

ts
// 内嵌编辑器分发事务前,所需执行的一些额外处理
dispatchInner(this: FootnoteView, tr: Transaction) {
  // 将事务应用于内嵌编辑器中
  // 💡 this 指向 nodeView 对象;this.innerView 是嵌套编辑器的视图对象
  // 使用方法 applyTransaction 将事务应用到编辑器当前状态上
  // 该方法返回一个对象 { state: EditorState, transactions: [Transaction] } 包含(关于这次状态更新的)详细信息
  // 其中属性 state 是新的编辑器状态,属性 transactions 是在此过程中(包括通过插件)所应用的诸多事务
  const {state, transactions} = this.innerView!.state.applyTransaction(tr);
  // 直接基于新的编辑器状态来更新编辑器的视图
  this.innerView!.updateState(state);

  // 判断内嵌编辑器当前所接收到的事务 tr 是否来自于/起源于外层编辑器
  if (!tr.getMeta("fromOutside")) {
    // 如果 tr 不是由外层编辑器引起的,即当前事务由内嵌编辑器分发的,例如用户在内嵌编辑器上进行操作(通过敲击键盘输入/删除文档内容)
    // ⚠️ 但并不包含通过按 Mod-z 或 Mod-y 快捷键对文档进行修改的操作,因为在前面配置插件时,已经将按下 Mod-z 或 Mod-y 快捷键分发指令的主体设置为外层编辑器
    // 则需要继续执行后续操作,将内嵌编辑器的修改同步到外层编辑器上

    // 为外层编辑器创建一个空事务
    let outerTr = this.outerView.state.tr;

    // 创建一个位置映射器 stepMap 实例,用于将当前事务所包含的各个操作步骤 step 进行偏移,让这些 step 原本只适用于操作内嵌编辑器的文档,变得适用于外层的编辑器
    // 所需偏移量是 this.getPos() + 1,即 nodeView 所对应的 node 节点在外层编辑器的位置,再加上 1(这 1 个 token 表示 node 的起始标签)
    // 原来的事务适用于嵌套编辑器,而内嵌编辑器的起始位置为 0;那么经过偏移后,起始位置变成了 node 内容的开始位置
    // 所以将这些步骤作用于外层编辑器时,就是对 node 的内容进行修改
    // ⚠️ 可以将原本适用于内嵌编辑器的步骤,应用于外层编辑器(对 node 的内容进行修改)除了要进行适当的位置偏移,还需要满足一个隐含条件,就是内层编辑器的内容和(外层编辑器)node 节点的内容(在修改前)是同步的(当然它们的 schema 也需要是相同的)
    // 这样就可以实现内嵌编辑器的修改,同步对(外层编辑器)node 节点的内容进行修改
    // 关于 StepMap 的作用可以参考 https://discuss.prosemirror.net/t/appending-one-block-to-another-preserving-cursor-position/942
    let offsetMap = StepMap.offset(this.getPos()! + 1);

    // 遍历已经应用到内嵌编辑器的诸多事务
    for (let i = 0; i < transactions.length; i++) {
      // 获取每个事务中所包含的操作步骤 steps
      let steps = transactions[i].steps;

      // 遍历各个操作步骤
      for (let j = 0; j < steps.length; j++) {
        // 将各个操作步骤进行适当的位置偏移,以便适用于外部编辑器
        const offsetStep = steps[j].map(offsetMap);

        if(offsetStep) {
          // 将经过位置偏移后的步骤添加到(即将应用到外部编辑器的)事务 outerTr 上
          outerTr.step(offsetStep);
        }
      }
    }

    // 如果事务 outerTr 含有操作步骤,就将其分发/应用到外层编辑器上
    if (outerTr.docChanged) {
      this.outerView.dispatch(outerTr);
    }
  }
}
ts
// 通过节点视图对象的属性 nodeView.update()
// 以更精细地控制 nodeView 如何更新(是否可以复用该实例来表示最新的 node 状态,还是需要创建一个新的实例)
// 入参 node 是节点视图所对应(具有最新状态的)节点
update(node: ProseMirrorNode) {
  if (!node.sameMarkup(this.node)) {
    // 如果最新状态的节点的类型和原本节点 this.node 的类型不同,则返回 false,表示需要创建一个新的 nodeView 实例(不能复用当前的实例)
    return false;
  }

  // 如果最新状态的节点和原节点类型(所具有的样式标记)相同,则将将其赋值为 this.node 以更新 nodeView 所对应的节点
  this.node = node;

  // 若此时内部编辑器视图存在(footnote 被点开时)
  if (this.innerView) {
    // 获取内嵌编辑器的状态(未更新前)
    let state = this.innerView.state;

    // 对比(在外部编辑器的)node 节点的内容与内嵌编辑器的内容(两者都是 fragment 片段)
    // fragment.findDiffStart(otherFragment) 从左往右找出两个片段之间的差异的起始位置(遵循 index schema 规则)
    let start = node.content.findDiffStart(state.doc.content);

    // 如果两个片段存在差异,则继续执行后续操作
    // 需要进行内外编辑器的内容同步,基于(外部编辑器)node 节点的内容,对内部编辑器的内容进行修改
    if (start != null) {
      // fragment.findDiffEnd(otherFragment) 从左往右找出两个片段之间的差异的结束位置(遵循 index schema 规则)
      const endResult = node.content.findDiffEnd(state.doc.content);

      if (endResult) {
        let {a: endA, b: endB} = endResult;
        // 为了提高查找的效率,寻找差异时起始位置是从左往右查找,而结束位置是从右往左查找
        // 但是在特定的场景下,可能存在倒置的情况,即 start 比 end 更大,则需要进行校正
        // 具体可以参考 https://discuss.prosemirror.net/t/overlap-handling-of-finddiffstart-and-finddiffend/2856/4
        let overlap = start - Math.min(endA, endB);

        if (overlap > 0) {
          endA += overlap;
          endB += overlap
        }

        // 使用 node.slice(start, endA) 切片(基于外部编辑器 node 节点的内容构建)替换内嵌编辑器的相应部分,实现内外编辑器内容的同步
        this.innerView.dispatch(
          state.tr
            .replace(start, endB, node.slice(start, endA))
            // 并为该事务 tr 添加元信息,避免触发方法 dispatchTransaction 相应代码而陷入无限循环
            .setMeta("fromOutside", true));
      }
    }
  }
  // 返回 true 表示节点视图更新完成
  return true;
}
简化版本

根据官方示例编写的简化版本

实现内外编辑器的「双向」同步的思路:

  • 内嵌编辑器和(外部编辑器的)footnote 节点使用相同的 schema,确保两者可以容纳相同(类型的)子节点/内容。然后在外部编辑器保存 footnote 节点的内容,而内嵌编辑器按需创建提供一个交互界面给用户修改内容(完成后内嵌编辑器可以销毁,而修改内容会保留在外部编辑器里)
  • 通过内嵌编辑器视图对象 nodeView 的方法 dispatchTransaction 可以在分发事务前执行一些额外的处理,将应用于内部编辑器的操作步骤 steps,(通过 StepMap 经过适当的位置偏移)也应用于外部编辑器上(以实现同步修改)
  • 在配置内嵌编辑器的 history 插件时,将分发 undoredo 的主体设置为外部编辑器,相当于内外将其编辑器「共用」了同一个历史栈,让撤销和重做功能(可以横跨内外编辑器)更符合用户交互的直觉。
    需要在内嵌编辑器视图对象 nodeView 的方法 update 里进行相应的配置,将在外部编辑器对 footnote 节点内容所进行的修改同步到内嵌编辑器中
    提示

    虽然在外部编辑器中并没有显示 footnote 节点的内容(nodeView 节点视图对象没有设置属性 contentDOM) ,用户不能直接编辑修改

    但是还存在一种途径,即通过 Mod-zMod-y 快捷键,对外部编辑器的 footnote 节点的内容进行修改,所以需要在 nodeView 的方法 update 里进行相应的配置,确保在这种途径下对外部编辑器的修改同步到内部编辑器中

内嵌 CodeMirror 代码编辑器

官方还提供了另一个更复杂的内嵌编辑器示例 Embedded code editor 使用 CodeMirror v6 构建内嵌的编辑器,完整代码可以参考 Github

该实例也是使用 nodeView 节点视图在 ProseMirror 中插入内嵌编辑器,但是由于内嵌的代码编辑器始终显示,所以需要处理光标在内外编辑器之间的穿透,通过监听相应的按键事件,处理光标在节点边缘的特殊移动

在进行内外编辑器同步时,除了要对变更操作中关于位置的描述进行偏移,由于内嵌编辑器并不是采用 ProseMirror 来构建,所以还需要对更新操作进行转换处理(要使用 ProseMirror API 来重现 CodeMirror 的变更操作)

根据该示例编写的简化版本

状态与视图解耦

nodeView 自由度很大,除了可以包含 TypeScript interface NodeView 预设的属性和方法,还可以自定义其他属性和方法,以实现所需的功能

但是并不推荐将与节点相关的状态定义/保存为 nodeView 节点视图的属性,虽然可以通过 nodeView 的方法 update 更精细地控制它的更新,但是依然不能避免(在可复用该对象时)意外地对其重新进行实例化(例如所对应的 DOM 元素被意外地更改,或视图更新算法),导致保存在 nodeView 里的状态丢失

将与 nodeView 相关的状态与它自身解耦,可以在外部更方便地对该状态进行管理(例如查看和操作,或序列化保存下来)

官方示例 Folding Nodes 演示了如何通过 node decoration 节点型装饰器记录/保存 nodeView 的状态信息,相关代码如下

ts
// 定义一个类 SectionView,其实例化对象是一个 nodeView 节点视图
// 用于自定义 section 节点的渲染展示方式和交互行为方式
class SectionView implements NodeView {
  dom: HTMLElement;
  contentDOM: HTMLElement;
  private foldButton: HTMLElement;
  private folded: boolean = false; // 记录该 section 内容的折叠/展开状态

  constructor(_node: Node, view: EditorView, getPos: () => number | undefined, deco: readonly Decoration[]) {
    // 通过 nodeView 的属性 dom 自定义该节点视图渲染到页面的 DOM 元素
    // 其结构示例如下:
    //  <section>
    //    <header contenteditable="false">
    //      <button>▵</button>
    //    </header>
    //    <div>
    //      <!-- content -->
    //    </div>
    // </section>
    this.dom = document.createElement("section");
    // section 的顶栏,包含一个可交互的按钮,点击可以 toggle 折叠/展开该节点的内容
    const header = this.dom.appendChild(document.createElement("header"));
    header.contentEditable = "false"; // 顶栏是不可编辑的

    this.foldButton = header.appendChild(document.createElement("button"));
    this.foldButton.title = "Toggle section folding";

    // 在按钮上设置鼠标事件 mousedown 的监听器
    // 当用户点击该按钮时,执行回调函数 foldClick()
    this.foldButton.onmousedown = e => this.foldClick(view, getPos, e);

    // 通过 nodeView 的属性 contentDOM 设置该节点内容的容器
    // 一个在顶栏后面的 <div> 元素,该节点的内容会插入到该元素里
    this.contentDOM = this.dom.appendChild(document.createElement("div"));
    // 基于应用到该 nodeView 的装饰器 deco 初始化该 section 内容的折叠/展开状态
    // 如果应用到该节点的任意一个装饰器的配置项中,包含属性 d.spec.foldSection,且它的值为 true 时,就折叠该 section 的内容;否则就展开该 section 的内容
    this.setFolded(deco.some(d => d.spec.foldSection));
  }

  // 折叠/展开状态 section
  setFolded(folded: boolean) {
    // 根据参数 folded,更新该 nodeView 的 folded 属性和按钮图标
    this.folded = folded;
    this.foldButton.textContent = folded ? "▿" : "▵";
    // 通过设置节点视图的属性 contentDOM 所对应的 <div> 元素的 CSS 样式 display 的值,来展开/折叠该 section 的内容
    this.contentDOM.style.display = folded ? "none" : "";
  }

  // 更精细地控制 nodeView 如何更新
  update(node: Node, deco: readonly Decoration[]) {
    // 如果 nodeView 所对应的 node 节点类型(与原节点类型)不同,则返回 false
    // 表示原来的 nodeView 实例无法复用,需要重新调用构建函数创建一个新的 nodeView 实例
    // 一般不同的节点类型所对应 paragraphView 节点视图最终在页面上渲染的 DOM 元素也不同,所以原有的 nodeView 不能复用,需要创建一个新的实例
    if (node.type.name != "section") return false;

    // 查看应用到该节点的任意一个装饰器的配置项中,是否包含属性 d.spec.foldSection,且它的值为 true
    let folded = deco.some(d => d.spec.foldSection);
    // 如果变量 folded 的值和当前 nodeView 属性 this.folded 不同
    // 则调用方法 setFolded() 设置该 section 内容的折叠/展开状态
    if (folded != this.folded) this.setFolded(folded);
    // 返回 true 表示原来的 nodeView 可以复用,相应地在页面上渲染的 DOM 元素也可以复用(在该例子中只是更改了 CSS 样式,以显示/隐藏该节点的内容)
    return true;
  }

  // 用户点击 section 顶栏的按钮时,执行以下回调函数
  foldClick(view: EditorView, getPos: () => number | undefined, event: MouseEvent) {
    // console.log(event);

    // 阻止该事件的默认行为
    event.preventDefault();
    // 调用方法 setFolding() 以响应用户的操作
    setFolding(view, getPos(), !this.folded);
  }
}

// toggle 切换(位于给定位置 pos 的)section 节点的内容的展开/折叠状态
function setFolding(view: EditorView, pos: number | undefined, fold: boolean) {
  // console.log(pos);

  if(pos===undefined) return; // 如果未给定位置,则不执行后续操作

  // 根据给定的位置 pos 获取相应的节点
  let section = view.state.doc.nodeAt(pos);

  // 如果该节点是 section 节点
  if (section && section.type.name == "section") {
    // 创建一个事务 tr,并为其添加元信息(采用键值对的方式)
    // 以插件对象 foldPlugin 作为键名,值是一个对象 {pos, fold} 包含该 section 节点的相关信息
    // 属性 pos 是一个数值,表示该 section 节点在文档中的位置
    // 属性 fold 是一个布尔值,表示该 section 所需变更的折叠/展开的最终目标状态
    let tr = view.state.tr.setMeta(foldPlugin, {pos, fold})

    // 如果选区本来是在该 section 里,则该 section 折叠后会隐藏调选区,所以可能需要在该事务 tr 中同时调整选区的位置,让选区/光标跳出该 section 的内容
    let {from, to} = view.state.selection;// 获取当前选区的两端位置
    let endPos = pos + section.nodeSize; // 该节点结尾位置

    // 判断选区是否位于该 section 节点里
    if (from < endPos && to > pos) {
      // 首先在节点的前面寻找合适位置插件一个文本选区;如果找不到就在节点的后面寻找
      let newSel = Selection.findFrom(view.state.doc.resolve(endPos), 1) ||
        Selection.findFrom(view.state.doc.resolve(pos), -1);
      // 在事务 tr 里手动调整选区
      if (newSel) tr.setSelection(newSel);
    }

    // 分发事务 tr,以更新编辑器的视图(toggle 切换相应 section 节点的展开/折叠状态)
    view.dispatch(tr);
  }
}

// 通过插件为编辑器添加 node decoration 节点型装饰器
// 将 decoration 应用到相应的 section 节点(位置)上,则表示该 section 节点需要折叠
// 💡 相当于采用该插件(所包含的一系列装饰器)维护/记录着所有处于折叠状态的 section 节点(位置信息)
// 采用这种方式可以让 nodeView 的折叠状态与其自身(DOM 元素)解耦(采用装饰器来记录),即使 nodeView(不小心)触发更新(如果需要重新实例化,其内部的属性 this.folded 会被重置),也可以根据这些装饰器恢复其折叠/展开状态
// 💡 另一个更简单的方法是将折叠状态保存在 section 节点的 attribute 里,这样即使 nodeView 更新时需要被重新实例化,也不会丢失折叠状态
const foldPlugin: Plugin = new Plugin({
  // 该插件的专属 state 状态
  state: {
    // 初始化插件的状态值,一个空的 decorationSet 装饰器合集
    init() { return DecorationSet.empty },
    // 更新插件的状态值
    apply(tr, value) {
      // 使用 decorationSet.map(mapping, doc) 映射调整已存在的装饰器的位置(以复用这些装饰器,而不是全部都重新创建)
      value = value.map(tr.mapping, tr.doc);

      // 读取当前事务对象 tr 的特定(关于该插件的)元信息
      let update = tr.getMeta(foldPlugin);

      if (update && update.fold) {
        // 如果元信息存在,且 update.fold 为 true,则将相应的 section 节点进行折叠
        // 获取给定位置 update.pos 的节点
        let node = tr.doc.nodeAt(update.pos);
        // 如果该节点为 section 节点
        if (node && node.type.name == "section") {
          // 创建一个 node decoration 节点型装饰器(其范围是从 update.pos 到 update.pos + node.nodeSize,即应用到相应的 section 节点上)
          // 并将该装饰器添加到 decorationSet 里
          value = value.add(tr.doc, [Decoration.node(update.pos, update.pos + node.nodeSize, {}, {foldSection: true})]);
        }
      } else if (update) {
        // 如果元信息存在,但是 update.fold 为 false,则将相应的 section 节点进行展开,通过将 decorationSet 里相应的装饰器进行移除来实现
        // 💡 使用 decorationSet.find(from, to) 从装饰器合集中,筛选出在给定范围内的装饰器
        // 这里搜索范围的上下限都是 update.pos+1 因为该位置正好是在相应 section 节点里,就可以确保所找到的装饰器是应用于该 section 节点
        // ⚠️ 如果搜索范围是 update.pos(section 节点的边界)则获得的装饰器可能是作用于前一个节点(而不是该 section 节点),由于 section 节点不是叶子节点(即它会包含内容),所以搜索范围采用 update.pos 会更准确保险
        // 💡 如果需要寻找作用于该 section 节点里面(即作用于其内容上的)装饰器,则搜索范围应该设置为 (pos+1, pos+node.nodeSize)
        let found = value.find(update.pos + 1, update.pos + 1);
        // 将找到的符合条件的装饰器从 decorationSet 装饰器合集中移除(则相应的 section 节点就会展开)
        if (found.length) value = value.remove(found)
      }
      // 返回更新后的 decorationSet
      return value;
    }
  },
  // 插件的其他配置
  props: {
    // 设置装饰器集合,从插件的自带状态里读取
    decorations: state => foldPlugin.getState(state),
    // 为 section 节点设置节点视图
    nodeViews: {
      section: (node, view, getPos, decorations) => new SectionView(node, view, getPos, decorations)
    }
  }
})
简化版本

根据官方示例编写的简化版本

提示

除了使用 decoration,还可以将 nodeView 的状态信息直接记录在相应节点的 attributes 特性中,可以让逻辑关注点集中于节点及其 nodeView 中,而且可以让代码更简单

根据以上官方示例进行改写,具体源码可以查看 Github


Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes