ProseMirror Schema List 模块

prosemirror

ProseMirror Schema List 模块

prosemirror-schema-list 模块提供了一些与列表节点相关的属性和方法,以便为编辑器提供有序列表和无序列表的支持

提示

可以直接使用该模块让编辑器支持创建简单的列表节点,也可以基于它进行扩展,定制出符合自己编辑器使用场景的列表节点

该模块导出了一些 commands 指令,便于通过编程式的方式来控制列表相关节点,但是该模块所实现的列表(列表项)默认是需要满足一些前提,才可以正常使用该模块所导出的一系列 commands 指令:

  • 所定义的列表节点是支持嵌套
  • 列表项的第一个子节点必须是 plain paragraph 普通的段落节点,即列表项的内容表达式需要是 "paragraph block*""paragraph (ordered_list | bullet_list)*"

辅助函数

该模块导出了一个方法 addListNodes(nodeSpec, itemContent, listGroup) 便于将列表相关的节点配置对象快速整合到编辑器原有的 schema 数据约束对象中,让编辑器支持创建列表节点

各参数的具体说明如下:

  • 第一个参数 nodeSpec 是一个对象,它是编辑器原有的 schema 数据元素对象中的属性 nodes 的值(用于对不同类型的节点进行描述)
  • 第二个参数 itemContent 是一个字符串,它是 content expression 内容表达式,包含一些特殊符号的(类似于正则表达式),以描述列表项节点可以容纳哪些内容/子节点
    注意

    如果希望可以使用该模块所导出的一系列 commands 指令,则对以上参数进行配置时,要确保列表项节点的第一个子节点是 paragraph 段落节点,例如 "paragraph block*""paragraph (ordered_list | bullet_list)*"

  • 第三个参数 listGroup 是一个字符串,表示将列表节点(包括有序列表节点和无序列表节点)归类到什么 group 组别

该方法的使用示例

ts
const mySchema = new Schema({
  // baseSchema 是编辑器原有的 schema 数据约束对象
  // baseSchema.spec.nodes 就是原有的节点配置对象
  nodes: addListNodes(baseSchema.spec.nodes, "paragraph block*", "block"),
  // baseSchema.spec.marks 就是原有的样式标记配置对象
  marks: baseSchema.spec.marks
})
说明

使用以上方法为编辑器原有的 schema 添加了三个类型的节点,其名称如下

  • ordered_list 表示有序列表节点
  • bullet_list 表示无序列表节点
  • list_item 表示列表项节点

相关源码如下

ts
function add(obj: {[prop: string]: any}, props: {[prop: string]: any}) {
  // 创建一个空对象 copy,用于传入的参数 obj 和 props 中的属性整合起来
  let copy: {[prop: string]: any} = {}
  // 先拷贝 obj 对象的属性
  for (let prop in obj) copy[prop] = obj[prop]
  // 再拷贝 props 对象的属性(如果存在与 obj 对象相同的属性,则会进行覆盖,以 props 对象的属性为准)
  for (let prop in props) copy[prop] = props[prop]
  return copy
}

export function addListNodes(nodes: OrderedMap<NodeSpec>, itemContent: string, listGroup?: string): OrderedMap<NodeSpec> {
  return nodes.append({
    // ordered_list 有序列表节点的内容(即子节点)只允许是 list_item 列表项节点,数量为一个或多个
    ordered_list: add(orderedList, {content: "list_item+", group: listGroup}),
    // bullet_list 无序列表节点的内容(即子节点)只允许是 list_item 列表项节点,数量为一个或多个
    bullet_list: add(bulletList, {content: "list_item+", group: listGroup}),
    // list_item 列表项节点的内容(即子节点)根据函数的参数 `itemContent` 进行配置
    list_item: add(listItem, {content: itemContent})
  })
}

nodes 配置对象

该模块导出了一系列变量(它们都是对象,需要符合一种 TypeScript interface NodeSpec,规定了节点可接受哪些配置参数),对不同类型的列表节点(及列表项)进行描述

如何使用

在使用方法 new Schema(spec) 实例化 Schema 类时,该模块导出的这些 node specs,可以作为配置参数 spec(它是一个对象,符合一种 TypeScript interface SchemaSpec)的属性 spec.nodes(它也是一个对象)的相应属性的值

  • 变量 orderedList:一个 node spec 对象,用于配置有序列表节点
    ts
    // 有序列表节点
    // 💡 在使用前文所述的方法 addListNodes 为编辑器原有的 schema 添加该类型节点时,还会往该节点的添加对象中添加 content 和 group 属性
    export const orderedList = {
      // 设置该类型的节点所拥有的 attributes 以添加额外的信息
      attrs: {
        // 属性 order 表示有序列表的开始序号,默认从 1 开始
        // 该属性的值的类型是数字
        order: {
          default: 1,
          validate: "number"
        }
      },
      // 定义了如何将 DOM 元素解析为该类型节点的一些规则
      // 这里只设置了一条规则
      parseDOM: [
        // 基于 DOM 元素的名称进行匹配,即将 HTML 中的 `<ol>` 元素解析为有序列表节点
        {
          tag: "ol",
          // 使用 getAttrs 属性更精细地设置 attributes
          getAttrs(dom: HTMLElement) {
            // 从 DOM 元素的相应属性获取值,以设置节点的属性 order
            // 如果 DOM 元素没有设置相应的属性,则采用默认值 1
            return {order: dom.hasAttribute("start") ? +dom.getAttribute("start")! : 1}
          }
        }
      ],
      // 定义了如何将该类型的节点转换为相应的 DOM 元素
      toDOM(node) {
        return node.attrs.order == 1 ? olDOM : ["ol", {start: node.attrs.order}, 0]
      }
    } as NodeSpec;
    
    const olDOM: DOMOutputSpec = ["ol", 0]; // olDOM 描述了有序列表节点如何转换为 DOM 元素
    
  • bulletList 变量:一个 node spec 对象,用于配置无序列表节点
    ts
    // 无序列表节点
    // 💡 在使用前文所述的方法 addListNodes 为编辑器原有的 schema 添加该类型节点时,还会往该节点的添加对象中添加 content 和 group 属性
    export const bulletList: NodeSpec = {
      // 定义了如何将 DOM 元素解析为该类型节点的一些规则
      // 这里只设置了一条规则
      parseDOM: [
        // 基于 DOM 元素的名称进行匹配,即将 HTML 中的 `<ul>` 元素解析为无序列表节点
        {tag: "ul"}
      ],
      // 定义了如何将该类型的节点转换为相应的 DOM 元素
      toDOM() { return ulDOM }
    };
    
    const ulDOM: DOMOutputSpec = ["ul", 0]; // ulDOM 描述了无序列表节点如何转换为 DOM 元素
    
  • listItem 变量:一个 node spec 对象,用于配置列表项节点
ts
// 列表项节点
// 💡 在使用前文所述的方法 addListNodes 为编辑器原有的 schema 添加该类型节点时,还会往该节点的添加对象中添加 content 属性
export const listItem: NodeSpec = {
  // 定义了如何将 DOM 元素解析为该类型节点的一些规则
  // 这里只设置了一条规则
  parseDOM: [
    // 基于 DOM 元素的名称进行匹配,即将 HTML 中的 `<li>` 元素解析为列表项节点
    {tag: "li"}
  ],
  // 定义了如何将该类型的节点转换为相应的 DOM 元素
  toDOM() { return liDOM },
  // 表示该类型的节点是否具有特殊的上下文语义,用于控制与该节点相关的复制粘贴行为
  // 如果设置为 true 时会同时将该节点的 definingAsContext 属性和 definingForContent 属性都设置为 true
  defining: true
};

const liDOM: DOMOutputSpec = ["li", 0]; // liDOM 描述了列表项节点如何转换为 DOM 元素

指令

该模块导出了一些 commands 指令,便于通过编程式的方式来控制列表相关节点

提示

如果希望可以使用该模块所导出的一系列 commands 指令来操作列表相关节点,要确保列表(包括列表项)满足一些前提:

  • 所定义的列表节点是支持嵌套
  • 列表项的第一个子节点必须是 plain paragraph 普通的段落节点
ts
// 该模块导出的 command 指令是针对特殊的列表数据结构
// 即列表需要满足以下最基本的结构
// list
//   ->list_item
//     ->paragraph(列表项的第一个子节点需要是段落节点)
//       ->text node(或其他 inline 节点)
//     ->block*(其他块节点)
command 指令

指令 command 是一个函数 fn(state, dispatch?, view?) 它可以接受三个参数:

  • 第一个参数 state 是一个编辑器对象
  • 第二个(可选)参数 dispatch 是一个函数,用来分发一个 transaction 事务对象(对文档进行操作)。当没有设置 dispatch 参数时(即该参数传递值为 null 时),表示该命令只是一个 dry run 「试运行」,即进行模拟或预演操作(以判断它是否应该被执行),但不对文档进行实际修改
  • (可选)参数 view 是一个编辑器视图对象

该函数最后应该返回一个布尔值,以表示该操作是否可以执行(即是否已经执行成功)

wrapInList 方法

方法 wrapInList(listType, attrs⁠?) 创建一个 command 指令,其作用是将用户选中的内容(一般是选区两侧位置所在的共享祖先节点,它是块节点)包裹进特定类型的列表节点 listType (有序列表节点或无序列表节点,该参数是一个 nodeType 对象)中,第二个(可选)参数 attrs 可用于设置列表节点的属性 attributes

其中难点是选区位于列表项中的场景(需要按不同的情况执行不同操作):

  • 如果选区的一侧 $from 位置在一个列表的第一个列表项的第一个子节点(段落节点)里,则不能使用下述方法 wrapInList 将选区所在的块节点包裹进新的列表中
    说明

    由于方法 wrapInList 所适应的 schema 是对列表项的第一个子节点进行限制,只能是段落节点

    所以嵌套的列表只能位于列表项的第二个子节点,或索引值更靠后的字节地方

  • 如果选区的一侧 $from 位置在一个列表的第二个(或索引值更靠后的)列表项的第一个子节点(段落节点)里,则可以使用 wrapInList 将选区所在的块节点包裹进新的列表中,但是需要将新建的列表与前一个列表项「融合」,即构成一个嵌套列表结构
ts
export function wrapInList(listType: NodeType, attrs: Attrs | null = null): Command {
  // 返回一个 command 指令
  return function(state: EditorState, dispatch?: (tr: Transaction) => void) {
    // 解构选区,$from 和 $to 都是 resolvedPos 对象,表示选区两侧的位置
    let {$from, $to} = state.selection
    // 基于选区两侧的位置创建一个 nodeRange(该范围囊括选区)
    // 💡 该 nodeRange 所指向的最近节点也是 $from 的祖先节点
    let range = $from.blockRange($to);
    // 该变量用于表示新建的列表是否需要与前一个(如果存在)的列表项相融合
    let doJoin = false;
    // 另一个 nodeRange,范围可能比 range 更大(初始值与 range 相同)
    let outerRange = range;
    if (!range) return false // 如果无法基于选区的两侧位置构建一个 nodeRange(获取到囊括选区范围的块节点),则返回 false,即该指令 command 不进行响应

    // 💡 假设选区位于列表项里的(段落节点)文本中,则 range 所指向的节点(属性 parent)就会是列表项,以下的 if 语句要进行验证/判断

    // This is at the top of an existing list item
    // 如果选区是在列表项的直接子节点(段落节点)的内容里,则 nodeRange 的层级深度要等于 2;如果是在嵌套列表的列表项里,则 nodeRange 的层级深度要大于 2。所以如果选区在列表项里,nodeRange 的层级深度必须要满足 range.depth>=2(但反过来并不代表就一定在列表项中)
    // 通过 $from.node(range.depth - 1) 获取到的是 range 层级深度更浅一层的节点,如果选区是在列表项的直接子节点(段落节点)的内容里,则这里获取到的就是列表节点
    // nodeType.compatibleContent(listType) 表示前面获取的节点与 listType 在内容上是兼容的,所以 $from.node(range.depth - 1) 获取到的就是列表节点(可能是有序列表节点或无序列表节点)
    // 💡 直接判断 $from.node(range.depth) 的节点类型是否为 list_item 感觉会更直观易懂
    // 当 range 指向的节点是列表项,则 range.startIndex==0 用于判断 $from 所在的节点是否为列表项的第一个子节点(段落节点)
    // 🍀 首先处理特殊的情况:如果满足以下 if 条件,就表示选区位于(某个列表的)列表项的第一个子节点(段落节点)里
    if (range.depth >= 2 && $from.node(range.depth - 1).type.compatibleContent(listType) && range.startIndex == 0) {
      // Don't do anything if this is the top of the list
      // $from.index(range.depth - 1) 表示以 range 层级深度更浅一层的节点(即列表节点)作为容器,获取选区一侧 $from 位置所在的子节点(即列表项)的索引值
      // ⛔ 当 $from.index(range.depth - 1) == 0 成立,表示选区开头 $from 所在的列表项是列表的第一项,则此时不能对其进行包裹操作,所以返回 false
      if ($from.index(range.depth - 1) == 0) return false
      // 🍀 如果选区 $from 并不在第一个列表项中(而是在第二个列表项,或更靠后的列表项里),则可以继续执行后续的包裹操作
      // 构建一个范围更大的 nodeRange 对象,以便寻找合适的包裹方案
      // 首先创建 resolvedPos 对象(它是新建列表所需插入的位置)
      // 基于 range 所指向的节点作为容器(列表项),它包含一系列子节点,则 $from 所在的子节点的开头位置的偏移量就是 range.start,即列表项的第一个子节点(段落节点)的开头位置
      // range.start - 2 表示在段落节点的开头再向前 2 个 token,即表示前一个列表项的结束标签 </li> 之前的位置
      // 这表示新建的列表是追加到在当前选区的前一个列表项的末尾
      let $insert = state.doc.resolve(range.start - 2)
      // 基于位置 $insert 构建一个 nodeRange
      // 深度是 range.depth,即 outerRange 也是指向的列表项(但不是选区所在的列表项,而是前一个列表项)
      outerRange = new NodeRange($insert, $insert, range.depth)
      // 基于 range 所指向的节点作为容器(列表项),它包含一系列子节点,则 $end 后面的那个子节点(即使没有,也假设存在)的索引值就是 range.endIndex
      // range.parent 指选区所在的列表项
      // 当 range.endIndex < range.parent.childCount 成立,则表示选区的 $end 所在的子节点不是列表项的最后一个子节点
      // 💡 虽然 index 从 0 开始,childrenCount 从 1 开始,但由于 range.endIndex 是指 $end 后一个子节点,所以两者含义其实是一样的(可以直接进行比较)
      if (range.endIndex < range.parent.childCount)
        // 如果原来选区并不是囊括整个列表项的内容,则构建一个新的范围(以更新变量 range),让它包含整个列表项的内容
        // 选区的 $from 本来就在列表项的第一个子节点(段落节点)上,不需要更改
        // $to.end(range.depth) 表示获取指定层级深度 range.depth 的祖先节点(列表项)的内容的结尾的偏移量,然后 state.doc.resolve() 对其解释得到一个 resolvedPos 对象,即将范围的结尾进行扩展
        // 新构建的范围包含了整个列表项的内容,层级深度不变(即该范围所指 向的节点依然是列表项)
        range = new NodeRange($from, state.doc.resolve($to.end(range.depth)), range.depth)

      // 🍀 对于以上情况:选区位于列表项(但它不是所在列表的第一项)的第一个子节点(段落节点)里,进行包裹封装到一个列表后,需要将新建的列表与前一个列表项相融合(即将新建的列表追加到前一个列表项里),所以变量 doJoin 设置为 true
      doJoin = true
    }

    // 🍀 然后寻找合适的方式创建新列表,对范围 outRange 进行包裹(但是实际插入的内容是 range 的内容)
    // 💡 对于一般情况(选区不在列表项的第一个子节点里,即段落节点),则直接创建一个新列表节点包裹 range 的内容(不需要考虑节点融合等情况)
    // findWrapping 方法来自 prosemirror-transform 模块
    // 它会使用 listType 类型节点作为容器,包裹范围 range 所指的内容,然后用该容器节点替换掉范围 outerRange 所指的内容
    // 💡 该方法可能会在容器节点的内部和周围添加额外的节点,以让包裹生成的结构符合 schema 的约束
    // 💡 所以该方法返回的值是一个数组,它的元素都是一个对象 `{type: NodeType, attrs: Attrs}[]` 表示包装容器节点,从左往右的元素,依次表示从外到里的嵌套关系
    // 💡 在后面的方法 doWrapInList 时会遍历该数组,构建出一个完整的容器节点
    let wrap = findWrapping(outerRange!, listType, attrs, range)
    if (!wrap) return false // 如果没有找到合适的包裹方式,则返回 false,即该指令 command 不进行响应
    // 如果设置了 dispatch 则执行它
    // dispatch 接受一个参数,它是一个事务对象 tr,用于修改文档
    // 方法 doWrapInList 就是返回一个事务对象
    // 执行完成 doWrapInList 事务后,再调用 scrollIntoView 必要时滚动页面将选区/光标移入视图里
    if (dispatch) dispatch(doWrapInList(state.tr, range, wrap, doJoin, listType).scrollIntoView())
    return true
  }
}

// 该函数用于创建一个事务对象,以修改文档
// 该函数接受 5 个参数
// * 第一个参数 tr 是一个 transaction 事务对象(在前面调用当前方法时,使用 state.tr 基于编辑器当前的状态创建一个空的事务对象实例,作为初始化值)
// * 第二个参数 range 是一个 nodeRange 对象,表示要包裹的内容
// * 第三个参数 wrappers 是一个对象数组,即数组的每个元素都是一个对象,用于描述节点,表示容器节点的构成
// * 第四个参数 joinBefore 是一个布尔值,表示是否需要将新创建的列表节点与前一个(已存在的)列表项融合
// * 第五个参数 listType 表示需要创建的列表节点类型
function doWrapInList(tr: Transaction, range: NodeRange, wrappers: {type: NodeType, attrs?: Attrs | null}[], joinBefore: boolean, listType: NodeType) {
  // 先创建一个空的 fragment 对象
  let content = Fragment.empty
  // 从后往前遍历 wrappers 数组,构建完整的容器节点
  for (let i = wrappers.length - 1; i >= 0; i--)
    content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content))

  // 向事务对象 tr 中添加步骤 step
  // 使用 new ReplaceAroundStep() 创建一个 step 步骤实例
  // 该步骤使用一个 slice 切片对文档的特定范围的内容进行替换操作,但它可以保留原文档(被替代范围中的)部分/全部内容(将这些内容插入到 slice 切片的指定位置中),一般用作包裹 wrap around 选中的内容
  // 参数 joinBefore 为真,表示选区在一个列表项里,那么新建的列表就需要与前一个列表项相融合
  // 所以替换的范围需要扩大,向前 2 个 token,达到前一个列表项的结尾标签 </li> 的前面;而替换的结束位置(它位于当前选区所在的列表项的结尾标签 </li> 之前)并不需要改变
  // 相当于删掉了相邻的标签 </li></li>,然后前后两个列表项就可以融合为 1 个
  // 💡 该 step 步骤需要将 range.start(或 range.start-2)到 range.end 范围之间的内容进行替换
  // 💡 但是原有的内容 range.start 到 range.end 其实保留下来,插入到 slice 中
  // 替入的 slice 是基于 fragment 对象(容器节点)创建的
  // 然后在 slice 里的 wrappers.length 位置(即容器最里面的嵌套层)插入保留的内容
  // 由于并没有进行内容的覆盖,只是进行结构的调整,所以最后一个参数为 true
  tr.step(new ReplaceAroundStep(range.start - (joinBefore ? 2 : 0), range.end, range.start, range.end, new Slice(content, 0, 0), wrappers.length, true))

  // 前面新建的列表节点仅包含一个列表项,而在该列表项里可能包含多个块级节点(范围 range 所包含的一系列节点)
  // 💡 但是一般所期待的交互逻辑是:用户选中多个块级节点,使用列表将它们包裹,其中每一个块级节点(例如段落节点)分别封装为一个列表项
  // 🍀 所以后续代码对新建的列表节点的结构进行优化,尽可能多地分割出列表项

  // 因为在拆分列表项时,除了要对列表项的内容进行处理,还可能需要对祖先节点进行相应的拆分
  // 在后面会使用方法 tr.split(pos, depth) 在特定位置对节点进行分割,第二个参数是一个表示深度的数值,即需要从给定的位置向上回溯的深度,要同时对这些层级的祖先节点都进行拆分
  // 首先要在 wrappers 数组中找到新建的列表节点的索引值
  // 💡 由于索引值是从 0 开始(编码元素),而数组的长度是从 1 开始(表示元素),所以这里让列表节点所对应的索引值加一 found=i+1,这样两者含义就是一样的
  // 可以直接进行相减 splitDepth = wrappers.length - found 表示列表节点后还有多少层节点(它们是位于 wrappers 数组的后面的元素,构成容器节点),它们在拆分列表项时也需要同步拆分
  // 💡 根据 schema 对于列表节点的约束,可以知道 listNode 之后的节点必须是 listItem,所以这里 splitDepth 一般是 1,即分割列表项时,需要向上回溯一层,即对于列表项也同时进行分割
  let found = 0
  for (let i = 0; i < wrappers.length; i++) if (wrappers[i].type == listType) found = i + 1
  let splitDepth = wrappers.length - found

  // 对 range 内容进行分割
  // 由于前面发生了内容替换,所以需要重新计算范围 range 的内容的开始位置,然后再进行内容分割
  // 原本的范围的开始位置是 range.start,由于使用容器节点对其进行封装,所以范围的开始位置会变成 range.start + wrappers.length(向后移)
  // 另外还需要考虑新建的列表是否于前一个(已存在的)列表项发生融合 (joinBefore ? 2 : 0) 如果为真,则位置会向前移动 2 个 token
  let splitPos = range.start + wrappers.length - (joinBefore ? 2 : 0);
  // range.parent 表示范围所指向的节点,经过容器包裹后就变成列表项
  const parent = range.parent

  // 遍历 range 所包含的子节点(根据 schema 的约束,列表项里的子节点都是块级节点),对它们进行分割
  // i 和 e 都是索引值,i 的初始值是 0 表示范围 range 所包含的第一个子节点,e 表示最后一个子节点的索引值
  // 首次进行遍历时,变量 first 为 true,表示当前的子节点是第一个,不需要进行分割操作;下一次循环(及以后的循环)first 都会是 false
  for (let i = range.startIndex, e = range.endIndex, first = true; i < e; i++, first = false) {
    // 如果当前所遍历的节点不是 range 的第一个子节点(即 first 为 false)
    // 而且在当前位置 splitPos 可以进行分割 canSplit(tr.doc, splitPos, splitDepth) 返回 true,则执行分割操作
    // canSplit 方法来自 prosemirror-transform 模块,判断是否可以在给定的位置对节点进行分割操作
    // 当前传入的文档状态是上一个变换后生成的文档 tr.doc(以保证它是最新的)
    if (!first && canSplit(tr.doc, splitPos, splitDepth)) {
      // 调用 tr.split(pos, depth?) 方法在给定的位置 splitPos 对节点进行拆分
      // 并且还会向上回溯 splitDepth 层,对这些祖先节点也进行同步拆分
      tr.split(splitPos, splitDepth)
      // 由于每次分割完成后都会增加一些标签(例如增加 `</li><li>` 标签),这会响应 splitPos(它表示分割的位置)的更新
      // 需要将分割所新增的 token 记录到 splitPos 里(每往上回溯一个深度层级,即祖父节点同步分割,就会相应地增加 2 个 token,例如列表项分割后,会多出 `</li><li>`)
      splitPos += 2 * splitDepth
    }
    // 更新 splitPos 的值,定位到下一个需要分割的位置
    splitPos += parent.child(i).nodeSize
  }
  // 最后返回该事务
  return tr
}

splitListItem 方法

方法 splitListItem(itemType, itemAttrs⁠?) 创建一个 command 指令(它也是一个函数),它会通过分割列表项的直接子节点的内容(它是一个 textblock,而且内容是非空的),(原有的列表项会被分割)来创建一个新的列表项。

第一个参数 itemType 是一个 nodeType 对象,表示所分割的节点类型(一般就是列表项节点类型)

第二个(可选)参数 itemAttrs 可用于设置列表项节点的属性 attributes

该方法可处理在列表项的(直接子节点)内容上按回车的操作,一般会对当前的列表项节点进行分割(而不是单纯的换行)

比较特殊的场景是光标位于列表项的直接子节点中,而该子节点的内容为空(根据不同情况进行不同操作):

  • 如果它是列表项的最后一个子节点,则按下回车键应该跳出当前的列表(这种情况可以返回 false,因为这就是默认行为)
  • 否则就对当前列表项进行分割

另外还需要特别注意嵌套列表的场景,如果跳出的列表是嵌套列表,则还需要对外层(包裹着嵌套列表)列表项进行分割

ts
// 第一个参数 itemType 是列表项节点类型
// 第二个(可选)参数 itemAttrs 是一个对象,用于为分割生成的列表项节点设置 attributes
export function splitListItem(itemType: NodeType, itemAttrs?: Attrs): Command {
  // 返回一个 command 指令
  return function(state: EditorState, dispatch?: (tr: Transaction) => void) {
    // 解构选区,$from 和 $to 都是 resolvedPos 对象,表示选区前后两侧的位置(如果处于光标状态,则这两个位置可能相同)
    // 这里先假设选区可能为节点选区(但不一定是,后面需要基于进行额外的判断处理),所以还能解构出 node(而 TypeScript 不会报错)
    let {$from, $to, node} = state.selection as NodeSelection
    // 🍀 如果 node 不为空且它是块级节点,则表示选区是节点选区(而**不是**文本选区)
    // 🍀 或者选区的开始位置 $from 的深度小于 2,则表示**不在**列表项中
    // 🍀 或者选区的开始位置 $from 和结束位置 $to 并**不是**位于相同的父节点(隐含的前提是这两个位置解析结果对象的根节点需要是相同的)
    // 🍀 如果满足以上任何一个条件,都会返回 false,即该指令 command 不进行响应
    if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) return false
    // 通过 $from.node(-1) 获取到的是选区开始位置所在的祖父节点(所期待的值是一个列表项节点)
    let grandParent = $from.node(-1)
    // 如果祖父节点不是列表项节点,则返回 false,即该指令 command 不进行响应
    if (grandParent.type != itemType) return false

    // $from.parent.content.size == 0 判断选区的开始位置所在的父节点(一般是段落节点)的内容是否为空
    // $from.node(-1) 表示祖父节点(即列表项),则 $from.node(-1).childCount 表示列表项所拥有的(直接)子节点的数量(从 1 开始计算)
    // $from.indexAfter(-1) 表示以祖父节点(即列表项)作为容器,它包含一系列子节点,则该方法返回的值表示该位置所属的那个子节点的后一个相邻节点(在同级节点所构成的数组中)的索引值
    // 当 $from.node(-1).childCount == $from.indexAfter(-1) 判断选区的开始位置所在的子节点是列表项的最后一个子节点
    // 💡 虽然 indexAfter 从 0 开始,childrenCount 从 1 开始,但由于 $from.indexAfter 是指 $from 后一个子节点,所以两者含义其实是一样的(可以直接进行比较)。但是感觉通过比较 $from.node(-1).childCount == $from.index(-1) + 1 感觉会更直观易懂
    // 🍀 如果光标位于一个**内容为空**的节点,且它是列表项的最后一个(直接)子节点,则需要提升缩进,跳出该列表节点(而不是对该列表项进行分割),这个操作通过调用 liftListItem 指令来执行,所以对于这种情况该指令会返回 false
    // 但是如果在嵌套列表中,还需要进一步的处理,对外层的列表项进行分割,这些操作在以下 if 条件里的代码完成
    if ($from.parent.content.size == 0 && $from.node(-1).childCount == $from.indexAfter(-1)) {
      // In an empty block. If this is a nested list, the wrapping
      // list item should be split. Otherwise, bail out and let next
      // command handle lifting.
      // 🍀 如果以上 if 条件都成立,则表示选区的开始位置 $from 在列表项的最后一个直接子节点里,而且该子节点内容为空
      // 则需要将当前节点进行提升,跳出当前列表(可以通过返回 `false`,因为这就是默认行为)
      // 但是如果选区本来是在一个嵌套列表里,则还要将包裹当前嵌套列表的列表项进行分割(然后返回 `true` 表示完成了响应)

      // 判断是否位于嵌套列表中
      // (光标位置的深度至少为 5 才会在一级嵌套列表里)$from.depth == 3 表示光标不会位于嵌套列表里,则返回 false,该指令 command 不进行响应(这样就可以让其他 command 进行响应,例如调用后面的方法 `liftListItem` 对光标所在的文本块提升缩进,跳出当前的列表节点)
      // 如果从位置 $from 往上回溯 3 层,所在的祖先节点类型不是列表项节点 $from.node(-3).type != itemType 则表示光标不在嵌套列表的列表项的**直接子节点**内容中,则返回 false,即该指令 command 不进行响应
      // 当光标位于列表项的直接子节点的内容中,则 $from.index(-2) 表示以祖父节点(列表节点)作为容器,它包含一系列子节点,则该方法返回的值表示 $from 位置所属的那个子节点(即列表项)的索引值;而 $from.node(-2).childCount - 1 表示祖父节点(列表节点)所含有的直接子节点数量
      // 如果 $from.index(-2) != $from.node(-2).childCount - 1 成立,表示光标不在最后一个列表项,则返回 false,即该指令 command 不进行响应。因为当列表项不是嵌套列表的最后一项,则按下回车后只需要在中间将嵌套列表分割,并插入一个段落节点作为外层列表项的子节点(而不涉及外层列表项的分割)
      if ($from.depth == 3 || $from.node(-3).type != itemType ||
          $from.index(-2) != $from.node(-2).childCount - 1) return false

      // 🍀 根据上面条件的筛选,当光标位于**嵌套列表**最后一个列表项的**直接**子节点里,且该节点的内容为空,才会继续执行后面的代码

      // 如果设置了 dispatch 则执行它
      // dispatch 接受一个参数,它是一个事务对象 tr,用于修改文档
      if (dispatch) {
        // 先创建一个空的 fragment 对象
        let wrap = Fragment.empty

        // 🍀 depthBefore 表示构造 fragment 时采样开始的祖先节点(回溯)深度层级
        // $from.index(-1) 表示以祖父节点(列表项)作为容器,位置 $from 所在的子节点(段落节点)的索引值;$from.index(-2) 表示以在往上一层的祖先节点(列表)作为容器,位置 $from 所在的子节点(列表项节点)的索引值
        // 💡 前面的判断已知 $from.node(-1).childCount == $from.indexAfter(-1) 成立,即光标在列表项的最后一个直接子节点里
        // 💡 前面的判断已知 $from.index(-2) == $from.node(-2).childCount - 1 成立,即光标在最后一个的列表项里
        // 如果 $from.index(-1) 不为 0(同时结合前面的已知条件)表示该列表项有多个(两个或以上)子节点,则分割深度为 1
        // 如果 $from.index(-1) 为 0(同时结合前面的已知条件)表示该列表项只有一个子节点,则需要继续查看 $from.index(-2) 的情况,才可以决定分割深度。$from.index(-2) 为 0 (同时结合前面的已知条件)表示该列表只有一个列表项,则分割深度为 3;如果$from.index(-2) 不为 0,即该列表有多个(两个或以上)列表项,则分割深度为 2
        // 🍀 采样的开始深度
        // * 当列表项的子节点有多个:depthBefore = 1
        // * 当列表项的子节点仅有一个,但列表具有多个列表项:depthBefore = 2
        // * 当列表项的子节点仅有一个,且列表仅有一个列表项:depthBefore = 3
        let depthBefore = $from.index(-1) ? 1 : $from.index(-2) ? 2 : 3

        // Build a fragment containing empty versions of the structure
        // from the outer list item to the parent node of the cursor
        // 构建一个容器节点,它的结构仿照了当前嵌套列表的结构
        // 依次从光标所在的里层节点到外层对节点进行「采样」构建容器,起始节点由分割深度 depthBefore 而定,终止节点是位于 $from.depth - 3 深度层级的节点(即包裹着嵌套列表的外层列表项节点)
        // * 当列表项的子节点有多个,则按下回车键跳出嵌套列表时,所在的列表项需要保留,所以 depthBefore=1,表示从祖父节点(列表项)开始采样,直至包裹着嵌套列表的外层列表项节点(假设嵌套列表是无序列表,在后面基于 fragment 所得到的 slice 就是 `</li></ul></li>)
        // * 当列表项的子节点仅有一个,但列表具有多个列表项,则按下回车键跳出嵌套列表时,所在的列表项不需要保留,但是嵌套列表需要保留,所以 depthBefore=2,表示从嵌套列表节点开始采样,直至包裹着嵌套列表的外层列表项节点(假设嵌套列表是无序列表,在后面基于 fragment 所得到的 slice 就是 `</ul></li>)
        // * 当列表项的子节点仅有一个,且列表仅有一个列表项,则按下回车键跳出嵌套列表时,嵌套列表不需要保留,所以 depthBefore=3,表示容器节点仅是最外层(包含嵌套列表)的列表项节点(假设嵌套列表是无序列表,在后面基于 fragment 所得到的 slice 就是 `</li>)
        for (let d = $from.depth - depthBefore; d >= $from.depth - 3; d--)
          // 使用方法 node.copy() 复制当前所遍历的节点,并以迭代的前一步所构建的 wrap 作为该节点的内容
          wrap = Fragment.from($from.node(d).copy(wrap))
        // 最终 wrap 就是完整的容器节点

        // 🍀 depthAfter 表示进行文档替换操作时,替换结束位置是对应于哪一个深度层级的祖先节点(该节点的结尾位置就是替换的结束位置)
        // $from.indexAfter(-1) 表示以祖父节点(即列表项)作为容器,它包含一系列子节点,则该方法返回的值表示该位置所属的那个子节点的后一个相邻节点(在同级节点所构成的数组中)的索引值
        // $from.node(-2) 表示嵌套列表节点,则 $from.node(-2).childCount 表示嵌套列表所拥有的列表项的数量(从 1 开始计算)
        // 如果 $from.indexAfter(-1) < $from.node(-2).childCount 成立,表示列表项所拥有的子节点数量比列表项数量要少,则 depthAfter = 1
        // 如果列表项所拥有的子节点数量比列表项数量要多,则需要继续查看 $from.indexAfter(-2) 列表所拥有的列表项数量,与 $from.node(-3).childCount(包裹嵌套列表的)外层列表项所拥有的子节点数量进行对比。当列表所拥有的列表项数量更少,则 depthAfter = 2;当列表所拥有的列表项数量更多,则 depthAfter = 3
        // ❓❓❓ 这段代码似乎有 Bug
        // 原本 depthAfter 恒为 3
        // 即替换结束位置是外层列表项的结尾,但是根据 Github issue https://github.com/ProseMirror/prosemirror/issues/1146 报告称:如果在嵌套列表后有兄弟节点(即嵌套列表不是外层列表项的最后一个子节点),则采用 depthAfter=3 会导致后面的兄弟节点都删除掉
        // 所以提交了 git commit https://github.com/ProseMirror/prosemirror-schema-list/commit/151a725103363887d540158cf72ed9150ad2c813 来修复这个问题
        // 但根据这段源码和实际的交互操作,发现依然没有计算出替换结束的合理位置
        // 💡 应该根据嵌套列表是否为外层列表项的最后一个子节点,来决定替换结束位置所对应的是哪一个深度层级的祖先节点 const depthAfter = $from.index(-3) + 1 < $from.node(-3).childCount ? 2 : 3
        // 💡 而且 wrap 的构造也需要同步改变
        let depthAfter = $from.indexAfter(-1) < $from.node(-2).childCount ? 1
            : $from.indexAfter(-2) < $from.node(-3).childCount ? 2 : 3

        // Add a second list item with an empty default start node
        // 使用方法 itemType.createAndFill() 创建一个空列表项节点,该方法会自动检查是否需要在前面或后面增添一些节点,以满足 schema 的约束,这里应该会在列表项内部自动添加一个段落节点(因为根据 schema 约束,列表项里必须要有一个段落节点,且作为它的第一个子节点)
        // 然后使用方法 Fragment.from(node) 基于新建的列表项节点创建一个 fragment 对象
        // 最后将容器节点 wrap(它是一个 fragment 对象)与前面所创建的片段整合为一个新的片段
        wrap = wrap.append(Fragment.from(itemType.createAndFill()))

        // 计算替换的开始位置
        // 方法 $from.before(depth) 获取指定层级深度 depth 的祖先节点的开头相对于「根节点」的偏移量
        // $from.depth 表示光标所在的深度,那么 depthBefore - 1 就表示回溯的深度
        // * 当列表项的子节点有多个,则 depthBefore = 1,所以 depthBefore - 1 = 0,即替换的开始位置就是光标所在父节点(段落节点)的开头位置
        // * 当列表项的子节点仅有一个,但列表具有多个列表项,则 depthBefore = 2,所以 depthBefore - 1 = 1,即替换的开始位置就是祖父节点(列表项)的开头位置
        // * 当列表项的子节点仅有一个,且列表仅有一个列表项,则 depthBefore = 3,所以 depthBefore - 1 = 2,即替换的开始位置就是嵌套列表的开头位置
        let start = $from.before($from.depth - (depthBefore - 1))

        // 使用 state.tr 基于编辑器当前的状态创建一个空的事务对象实例
        // 调用 tr.replace(from, to?, slice?) 方法使用给定的 slice 替换文档的指定范围 from 到 to 的内容
        // 计算替换的结束位置是
        // 方法 $from.after(-depthAfter) 获取指定层级深度 depth 的祖先节点的结尾相对于「根节点」的偏移量,但是前面用于计算 depthAfter 的值的代码似乎有 Bug ❓❓❓
        // * 当列表项所拥有的子节点数量比列表项的数量要少,则 depthAfter = 1,即替换的结束位置是祖父节点(列表项)的结束位置
        // * 当嵌套列表所拥有的列表项数量比(包裹嵌套列表的)外层列表项所拥有的子节点数量更少,则 depthAfter = 2,即替换的结束位置是嵌套列表的结束位置
        // * 当嵌套列表所拥有的列表项数量比(包裹嵌套列表的)外层列表项所拥有的子节点数量更多,则 depthAfter = 3,即替换的结束位置是外层列表项的结束位置
        // slice 是由 wrap 构成的,左侧开放节点(相对于片段 wrap)的嵌套深度是 4 - depthBefore,右侧节点是完整的
        // 由于 wrap 的构造是如下(当 depthBefore=1,假设嵌套列表为无序列表)
        // <li>
        //   <ul>
        //      <li></li>
        //   </ul>
        // </li>
        // <li>
        //   <p>空内容<p>
        // </li>
        // 然后通过裁剪得到 Slice 如下
        //     </li>
        //   </ul>
        // </li>
        // <li>
        //   <p>空内容<p>
        // </li>
        let tr = state.tr.replace(start, $from.after(-depthAfter), new Slice(wrap, 4 - depthBefore, 0))

        // 更新选区,将光标移到新建列表项里
        let sel = -1
        // 根节点 doc 是基于上一个变换后生成的文档 tr.doc(以保证它是最新的)获取的
        // 调用方法 node.nodesBetween(from, to, fn, startPos) 遍历在该节点的特定范围内的子节点(包括它们的后代节点),并分别执行给定的回调函数 fn
        // 遍历的范围从替换的开始位置 start 到新文档的末尾 tr.doc.content.size
        tr.doc.nodesBetween(start, tr.doc.content.size, (node, pos) => {
          if (sel > -1) return false
          // 找到内容为空的节点(pos 是节点开始位置),将 sel 变量设置为 pos+1 即该节点的内容(该节点的开始标签后面的位置)
          if (node.isTextblock && node.content.size == 0) sel = pos + 1
        })
        // 如果 sel > -1 成立,则表示找到内容为空的节点
        // 调用方法 tr.setSelection() 更新该事务的选区(它会覆盖原有的映射行为,决定应用事务后编辑器所选中的内容)
        // Selection.near(resolvedPos) 在 sel 位置附近寻找一个合适的地方创建一个文本选区(该文本选区处于光标状态)或节点选区(该节点是叶子节点)
        if (sel > -1) tr.setSelection(Selection.near(tr.doc.resolve(sel)))
        // 执行完成以上事务后,再调用 scrollIntoView 必要时滚动页面将选区/光标移入视图里
        // 通过 dispatch() 分发事务,执行对文档的修改
        dispatch(tr.scrollIntoView())
      }
      // 🍀 针对光标在嵌套列表的最后一个列表项的最后一个子节点,且该节点**内容为空**的场景,(不管参数 dispatch 是否有设置)以上代码应该处理完成,所以返回 true
      return true
    }

    // 🍀 如果选区所在的节点内容并不是空白的,则需要对所在的列表项进行分割
    // nextType 表示分割节点的类型,一般拆分后的节点会继承原节点的类型和属性
    // 💡 前面的判断已知选区的开始位置 $from 和结束位置 $to 位于相同的父节点
    // 如果 $to.pos == $from.end() 成立,表示选区的结束位置 $to 在父节点(内容)的末尾,则分割所得的节点应该是「全新」的,即不应该继承原节点(一般是段落节点)的属性
    // 如果选区位置 $to 在父节点结尾,则使用 grandParent.contentMatchAt(0).defaultType 获取列表项的子节点类型,用于手动构建新列表项的子节点(避免继承原有节点的属性,一般是段落节点);否则(nextType 设置为 null)就继承原有列表项(及其子节点)的属性
    let nextType = $to.pos == $from.end() ? grandParent.contentMatchAt(0).defaultType : null
    // 删除选区的内容
    let tr = state.tr.delete($from.pos, $to.pos)
    // 在 $from.pos 位置拆分列表项
    // 先使用 canSplit(doc, pos, depth?, typesAfter) 方法(返回一个布尔值)判断给定位置 pos 的节点是否可以被分割
    // 其中第三个(可选)参数 depth 表示需要往上回溯的深度层级,这些祖先节点需要同步拆分
    // 第四个可选(可选)参数 typesAfter 是一个数组,它的元素都是一个对象 { type: NodeType, attrs? Attrs} 用于设置拆分后的节点的类型和属性,一般拆分后的节点会继承原节点的类型和属性,可以忽略该参数(或设置为 undefined)
    // 该方法是 prosemirror-transform 模块导出的辅助函数
    // 💡 根据 nextType 判断是否需要手动构建分割节点的类型
    // 如果要手动构建的列表项(包括其子节点)则设置为 [itemAttrs ? {type: itemType, attrs: itemAttrs} : null, {type: nextType}] 其中数组的第一个元素是设置列表项节点,并根据参数 itemAttrs 是否有设置,需要创建一个对象来覆盖原来的列表项(null 表示采用原来列表项的相关配置);第二个元素是设置段落节点
    // 如果要继承原有节点则设置为 undefined
    let types = nextType ? [itemAttrs ? {type: itemType, attrs: itemAttrs} : null, {type: nextType}] : undefined
    if (!canSplit(tr.doc, $from.pos, 2, types)) return false
    // 如果设置了 dispatch 则执行它
    // dispatch 接受一个参数,它是一个事务对象 tr,用于修改文档
    // 调用方法 tr.split(pos, depth?, typesAfter?) 在给定的位置 pos 对节点进行拆分,第二个(可选)参数 depth(默认值为 1)设置拆分的层级,第三个(可选)参数 typesAfter 是一个数组,它的元素都是一个对象 { type: NodeType, attrs? Attrs} 用于设置拆分后的节点的类型和属性
    // 最后再调用 scrollIntoView 必要时滚动页面将选区/光标移入视图里
    if (dispatch) dispatch(tr.split($from.pos, 2, types).scrollIntoView())
    // 返回 true 表示该指令对用户的操作已经作出响应
    return true
  }
}

splitListItemKeepMarks 方法

方法 splitListItemKeepMarks(itemType, itemAttrs⁠?) 创建一个 command 指令(它也是一个函数),它与方法 splitListItem 类似,也是用于分割列表项,不同的是它会保留光标所具有/激活的 marks 样式标记

第一个参数 itemType 是一个 nodeType 对象,表示所分割的节点类型(一般就是列表项节点类型)

第二个(可选)参数 itemAttrs 可用于设置列表项节点的属性 attributes

ts
// 该方法相当于对 splitListItem 进行二次封装
// 除了执行 splitListItem 对列表项节点进行分割
// 还会保留光标所带有的激活 marks 样式标记
export function splitListItemKeepMarks(itemType: NodeType, itemAttrs?: Attrs): Command {
  // 调用 splitListItem 方法,返回的是一个 command 指令
  let split = splitListItem(itemType, itemAttrs)
  // 返回一个 command 指令,在内部其实执行的是前面生成的 command
  return (state, dispatch) => {
    // 在调用 split 指令时,会判断是否传递了 dispatch
    // 如果没有设置 dispatch (即为空),则 `undefined`(或 `null` 会作为 split 指令的第二个参数
    // 如果设置了 dispatch,则会将运算符 && 后半部分的函数(该函数其实是对 dispatch 进行封装,并在其中保留光标所带有的激活 marks 样式标记)作为 split 指令的第二参数
    return split(state, dispatch && (tr => {
      // 读取需要保留的样式标记
      // 优先获取编辑器状态对象 state 的预设的样式标记 state.storedMarks;如果为空,再从选区中获取
      // 如果从选区中获取 marks,首先判断选区的结束位置 $to 相对于父节点(内容开头)的偏移量,如果为 0 则表示选区(处于光标状态)的结束位置是位于节点的开头,则不保留该位置的 marks ❓❓❓ 这样更符合用户的交互习惯;如果不是在节点的开头,则获取选区的开始位置 $from 所具有的样式标记
      // 💡 基于选区的开始位置 $from 设置光标的预设样式标记,由于按下回车键,分割列表项时会删除选中的内容,则选区最终会处于光标状态,继续输入的文本所具有的样式标记(即光标的预设样式标记)与前方位置(即 $from 位置)的样式标记一致
      let marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks())

      // 如果成功获取到样式标记,则变量 marks 就是一个数组(其中元素是一系列的 mark 对象)
      // 调用方法 tr.ensureMarks(marks) 确保该事务中的预设样式标记与参数 marks 一致
      if (marks) tr.ensureMarks(marks)

      // 最后通过 dispatch() 分发事务,执行对文档的修改
      dispatch(tr)
    }))
  }
}

liftListItem 方法

方法 liftListItem(itemType) 创建一个 command 指令(它也是一个函数),其作用是将选中的列表项的内容往上提升一级(跳出当前列表)

参数 itemType 是表示列表项的 nodeType 对象

ts
export function liftListItem(itemType: NodeType): Command {
  // 返回一个 command 指令
  return function(state: EditorState, dispatch?: (tr: Transaction) => void) {
    // 解构选区,$from 和 $to 都是 resolvedPos 对象,表示选区前后两侧的位置
    let {$from, $to} = state.selection
    // 基于选区两侧的位置创建一个 nodeRange(该范围囊括选区)
    // 💡 方法 $from.blockRange() 的第二个参数是一个指示函数(返回布尔值),用于判断在迭代回溯途中所得到的祖先节点是否可接受,如果不可接受,则继续沿着文档树向上回溯
    // 这里需要祖先节点含有子节点(即内容不为空),且它的第一个子节点的类型是列表项(所以满足条件的祖先节点就是列表节点)
    // 即 nodeRange 所指向的节点(nodeRange.parent 属性的值)是列表节点
    // 💡 根据指示函数,如果要成功创建 nodeRange,则选区两侧的共同祖先节点中,需要有列表类型的节点(那么提升就是将选区框中的节点跳出该列表节点)
    let range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild!.type == itemType)
    // 如果无法基于选区创建 nodeRange,则返回 false,即该指令 command 不进行响应
    if (!range) return false
    // 如果没有设置 dispatch,则表示该命令只是一个 dry run 「试运行」,即进行模拟或预演操作(以判断它是否应该被执行),但不对文档进行实际修改
    // 直接返回 true 表示该指令已经测试成功
    if (!dispatch) return true
    // range.depth 表示该范围所指向的节点(即列表节点,相对于根节点)的层级深度,则 range.depth - 1 就是列表节点的父节点所在的深度层级
    // 如果选区的开始位置 $from 在 range.depth - 1 深度层级(即 range 所指向节点的父节点)的节点类型是列表项 🍀 则表示选区位于一个嵌套列表里
    if ($from.node(range.depth - 1).type == itemType){
      // Inside a parent list
      // 则执行提升操作时,选中的列表项的内容跳出(range 所指向的)列表节点,然后(需要新建一些列表项节点)变成外层的列表项节点的内容
      // 具体的实现代码封装为函数 liftToOuterList
      return liftToOuterList(state, dispatch, itemType, range)
    }
    // 🍀 如果选区位于普通列表(非嵌套列表)里
    else {
      // Outer list node
      // 提升选中的列表项的内容(跳出列表节点),然后变成普通的节点
      // 具体的实现代码封装为函数 liftOutOfList
      return liftOutOfList(state, dispatch, range)
    }
  }
}

// 将内容提升到外层的列表(本来在嵌套列表里)
function liftToOuterList(state: EditorState, dispatch: (tr: Transaction) => void, itemType: NodeType, range: NodeRange) {
  // 使用 state.tr 基于编辑器当前的状态创建一个空的事务对象实例
  let tr = state.tr;
  // 以范围 range 所指向的节点作为容器,它包含一系列的子节点,则 range.end 表示该范围的结尾位置 $to 所在子节点的结尾(相对于根节点)的偏移量
  let end = range.end;
  // 获取范围 range 的结尾位置 $to 在指定层级深度的祖先节点其内容的结尾相对于「根节点」的偏移量
  // 💡 range.depth 表示范围 range 所指向的节点所在的层级,即列表节点所在的层级
  // 所以 endOfList 表示(range 指向的)列表节点的内容的末尾
  let endOfList = range.$to.end(range.depth);

  if (end < endOfList) {
    // 如果范围 range 的结束位置 $to 所在子节点(它是 range 所指向的节点的直接子节点)的结尾的偏移量 end 小于(range 指向的)列表节点的内容的末尾 endOfList
    // 🍀 则表示选区的结束位置并不是位于列表的最后一个列表项里
    // 🍀 则需要对**后面**剩余的列表项(本来是兄弟节点)进行特别处理
    // 🍀 新建一个列表节点,将这些剩余的列表项包裹起来,作为提升后所创建的一系列(外层)列表项的**最后一个列表项**的内容/子节点
    // There are siblings after the lifted items, which must become
    // children of the last item
    // 向事务对象 tr 中添加步骤 step
    // 使用 new ReplaceAroundStep() 创建一个 step 步骤实例
    // 该步骤使用一个 slice 切片对文档的特定范围的内容进行替换操作,但它可以保留原文档(被替代范围中的)部分/全部内容(将这些内容插入到 slice 切片的指定位置中),一般用作包裹 wrap around 选中的内容
    // 💡 该 step 步骤需要将 end - 1 到 endOfList 范围之间的内容进行替换
    // 替换的开始位置 end - 1 是选区右侧 $to 所在的列表项节点的内容的结尾(即列表项 </li> 结束标签前面的位置)
    // 替换的结束位置是 endOfList(range 指向的)列表节点的内容的末尾(假设 range 指向的是无序列表,则表示列表 </ul> 结束标签前面的位置)
    // 💡 但是原有的内容 end 到 endOfList(剩余的列表项) 其实保留下来,插入到 slice 中
    tr.step(new ReplaceAroundStep(end - 1, endOfList, end, endOfList,
      // 构建替入的 slice
      new Slice(
        Fragment.from(
          // 使用方法 nodeType.create(attrs, content) 创建一个节点
          // 第一个参数设置节点 attributes,这里传递 null,表示新建的列表项不需要设置特殊的属性
          // 第二个参数设置节点的内容(子节点),通过方法 node.copy() 复制拷贝 range 所指向的父节点(即列表节点),以创建一个类型相同的列表节点(有序列表或无序列表) ⚠️ 新建的列表节点内容为空
          itemType.create(null, range.parent.copy())
        ),
        // slice 左侧开放节点的嵌套深度是 1,右侧节点是完成的
        // 则 slice 符合以下结构(假设 range 原来指向的是无序列表)
        //   <ul>
        //   </ul>
        // </li>
        1, 0),
      // 设置所保留的内容应该插入到 slice 的什么位置(遵循 index schema)
      // 这里的 1 表示新建列表 <ul> 开始标签后面的位置
      1, true))
    // 🍀 以上的替换操作的最终效果:使用新建的列表节点包裹剩余的列表项,然后将该新建的列表节点作为前一个列表项的内容(构成嵌套列表)

    // 由于前面执行了替换操作,所以需要更新 range
    // (使用变换后生成的文档 tr.doc 获取根节点,以保证它是最新的)开始位置不变;结束位置进行扩展,覆盖到最后列表项(endOfList 位置)
    // 范围所指向的节点(相对于根节点)的层级深度不变,即 range 所指向的节点依然是原来列表节点
    range = new NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(endOfList), range.depth)
  }

  // 🍀 提升 range 内容
  // 使用方法 liftTarget(range) 为给定的 nodeRange 范围的内容寻找一个恰当的提升层级,返回的是一个数字,表示需要提升的层级
  // 该方法是 prosemirror-transform 模块导出的辅助函数
  const target = liftTarget(range)
  // 如果无法获取到合理的提升层级,则返回 false
  if (target == null) return false
  // 使用方法 tr.lift() 将一个给定的 range 范围移动到一个更浅的层级
  tr.lift(range, target)

  // 根据 Github issue https://github.com/ProseMirror/prosemirror/issues/1306 报告称:如果提升的列表项后面还有剩余的列表项,同时所提升的(选中的)最后一个列表项里包含嵌套列表,则完成提升后最后一个列表项就会同时存在两个嵌套列表,当它们类型相同时,应该将两者融合为同一个列表节点
  // 所以提交了 git commit https://github.com/ProseMirror/prosemirror-schema-list/commit/c1347017f0a3bc14a04b7afda790ae2150fc7ce9 来修复这个问题
  // tr.mapping 获取 tr 的映射管道,然后调用方法 mapping.map(pos, assoc?) 将旧文档中的位置 pos 映射为新文档中的相应位置
  // 由于在前面替换 step 里执行了内容插入的操作,且替换开始位置是 end -1,内容插入是在 slice 的 1 位置,相当于在原来的 end 位置执行了内容插入,所以需要设置第二个参数为 -1,以表示映射到左侧/前面(靠近选中列表项的一侧)
  // 所以 end 映射到新文档的位置是(假设构建的嵌套列表是无序列表)<ul> 开始标签后面,所以要在此位置基础上 -1,最后 after 表示的位置就是最后一个选中的列表项的内容最后的位置(即与所构建的列表节点之前的位置)
  let after = tr.mapping.map(end, -1) - 1
  // 使用方法 canJoin() 返回一个布尔值,以判断 after 位置前后两个节点能否相融合
  // 该方法是 prosemirror-transform 模块导出的辅助函数
  // 如果可以融合,则使用方法 tr.join(after) 将给定位置 pos 前后的节点连接起来
  if (canJoin(tr.doc, after)) tr.join(after)

  // 执行完成以上事务后,再调用 scrollIntoView 必要时滚动页面将选区/光标移入视图里
  // 通过 dispatch() 分发事务,执行对文档的修改
  dispatch(tr.scrollIntoView())
  return true
}

// 将内容提升出列表
function liftOutOfList(state: EditorState, dispatch: (tr: Transaction) => void, range: NodeRange) {
  // 使用 state.tr 基于编辑器当前的状态创建一个空的事务对象实例
  let tr = state.tr;
  // list 是范围 range 所指向的节点,它是一个列表节点
  let list = range.parent;
  // 从后往前遍历选中的列表项,并删掉它们之间的 </li><li> 标签,让这些列表项的内容整合在一起
  // ⚠️ 以下循环只有在选中了多个列表项才执行,如果选区仅在一个列表项里,则不需要执行标签删除操作(因为原本选中的内容就整合在一起)
  // Merge the list items into a single big item
  for (
    // 初始位置 pos 是 range.end,以范围 range 所指向的节点(列表节点)作为容器,它包含一系列的子节点(列表项),则 range.end 表示该范围的结尾位置 $to 所在子节点(列表项)的结尾(相对于根节点)的偏移量
    // 初始索引值 i 是 range.endIndex - 1,即该范围的结尾位置 $to 所在列表项的索引值 ⚠️ 注意这里的 range.endIndex 结尾位置 $to 所在列表项后一个兄弟节点(即使后面没有兄弟节点)的索引值,所以要基于 range.endIndex 减一
    // 变量 e 表示该范围的开始位置 $from 所在子节点(列表项)的索引值
    let pos = range.end, i = range.endIndex - 1, e = range.startIndex;
    i > e;
    i--
  ) {
    // 往前移动一个列表项节点大小,所以 pos 依然落于两个列表项之间
    pos -= list.child(i).nodeSize
    // 调用方法 tr.delete(from, to) 删除 pos 前后一个 token 的内容,即 </li><li> 标签
    tr.delete(pos - 1, pos + 1)
  }

  // 解析 range.start 位置,得到对应的 resolvedPos 对象
  // ⚠️ 由于在前面的事务中删除的内容是位于 range.start 位置的后面,所以该位置在事务执行的前后并没有变化,可以直接 resolve;如果要研究删除内容后面的位置,则先要进行 mapping 映射转换,具体看下面 👇 的步骤
  let $start = tr.doc.resolve(range.start);
  // 💡 由于在前面事务中删除了选中的列表项之间的 </li><li> 标签,所以选中的多个列表项的内容都整合到一个列表项
  // $start.nodeAfter 表示 $start 位置后面的节点(且该节点不为空),即 item 就指这个囊括所有选区内容的列表项(整合后)
  let item = $start.nodeAfter!

  // tr.mapping 获取 tr 的映射管道,通过调用方法 mapping.map(pos, assoc?) 可以将旧文档中位置 range.end(选区结尾所在列表项节点的结束位置)映射为新文档中的相应位置
  // range.start 是选区开头所在的列表项的开始位置
  // ⚠️ 由于在前面的事务中删除的内容是位于 range.start 位置的后面,所以该位置在事务执行的前后并没有变化,可以直接使用;而删除内容后面的位置 range.end 则先要进行 mapping 映射转换再使用
  // $start.nodeAfter!.nodeSize 就是囊括所有选区内容的列表项(整合后)的大小
  // 根据 Github issue https://github.com/ProseMirror/prosemirror/issues/1207#issuecomment-919368893 报告称:由于列表项的 schema 约束,前面事务的删除操作可能无法执行成功,如果继续执行后面的代码就会抛出错误
  // 所以提交了 git commit https://github.com/ProseMirror/prosemirror-schema-list/commit/38867345f6d97d6793655ed77c16f1a7b18f6846 来修复这个问题
  // 这里对比了 range.end(映射到新文档中的位置),与 range.start 和其后的列表节点大小之和,以判断前面的内容「整合」操作是否成功
  // 如果不相等就返回 false,不执行后续的代码
  // ❓ 这是防御性的代码,或许通过遍历选区选中列表项,依次提升它们的内容到列表外(而不是先「整合」到一个列表中再提升),这种方式应该能够更好地满足 schema 约束
  if (tr.mapping.map(range.end) != range.start + $start.nodeAfter!.nodeSize) return false

  // 判断选区开头所在的列表项是否为第一项
  let atStart = range.startIndex == 0;
  // 判断选区结尾所在的列表项是否为最后一项
  // ⚠️ 注意 range.endIndex 其实是指结尾位置 $to 所在列表项后一个兄弟节点(即使后面没有兄弟节点)的索引值,但是索引值是从 0 开始计数的,而 childCount 是从 1 开始计数的,所以两者表示的意思一样,可以直接比较
  let atEnd = range.endIndex == list.childCount

  // $start 是由选区开头所在的列表项的开始位置解析而得的 resolvedPos
  // 那么 $start.node(-1) 就表示该位置的祖父节点,即选区所在的列表节点的父节点(如果列表节点是 doc 的直接子节点,则 parent 就指向 doc)
  let parent = $start.node(-1);
  // 以 $start 位置的祖父节点作为容器,它包含一系列子节点,则该位置所属的那个子节点的索引值(即列表节点在其父节点里的索引值)
  let indexBefore = $start.index(-1);

  // 使用方法 node.canReplace(from, to, fragment) 返回一个布尔值,以表示是否能用 fragment 片段替换该节点特定范围的内容(参数 from 和 to 是数字,表示直接子节点的索引值),即替换后的节点是否还能够保证节点满足 schema 数据结构的约束。如果不满足 schema 的约束就返回 false,不执行后续的代码
  // 💡 由于只是检查提升列表项后,文档的结构是否满足 schema 的约束,所以这里所构成的 fragment 只是大致模拟出预期(节点嵌套)结构,而替换对于内容并没有严格要求(在后面 👇 具体替换步骤中,再使用 slice 基于复杂的条件构建出真实的替换内容)
  // 💡 提升列表项后所得的结构有两种可能:
  // 根据所提升的列表项在列表中的位置,可以将提升后的结构分为三类:
  // * 从列表的第一个列表项开始提升,则提升内容插在原来的列表节点前面,提升后的结构是 liftContent+list
  // * 所提升的列表项在列表的中间,则提升内容插在原来的列表节点中间(即原来的列表节点分割为两个),提升后的结构是 list+liftContent+list
  // * 第一个列表项并没有提升,但是所提升的列表项一直囊括到最后一个列表项,则提升内容插在原来的列表节点后面,提升后的结构是 list+liftContent
  // 💡 也可以所有列表项都提升(即原来的列表节点没有了),提升后的结构是 liftContent
  // 提升的内容 liftContent 就是指 item.content
  // 原始节点的索引值为 indexBefore
  if (!parent.canReplace(
    // 根据 atStart 是否为真(第一个列表节点是否被提升),来设置替换开始(节点)的索引值
    // * 如果 atStart 为 true,则提升后的结构是 liftContent+list(也可能只有 liftContent,当所有列表项都提升)
    //   即需要构造 liftContent+list(或 liftContent)结构
    //   需要先把原来的列表替换掉,则要把替换开始(节点)的索引值设置为 indexBefore(同时结束节点索引值为 indexAfter + 1),这样就可以把原始列表节点替换掉
    //   然后再使用方法 Fragment.from(list) 基于原始列表构造出列表节点(⚠️ 实际替换时当然不可能复用原始列表,而是要剔除提升的列表项,但是这里只是模拟出相同的结构,所以可以这样做),接着用方法 item.content.append.Fragment.from(list) 插回到 liftContent 的后面(⚠️ 具体还得根据最后一个列表项 atEnd 是否也提升,来判断是否对全部列表项进行了提升,从而决定是否在 liftContent 后面重新追加一个列表节点)形成 liftContent+list,这样提升的内容 liftContent 才可以位于原来列表的前面
    // * 如果 atStart 为 false,则提升后代结构是 list+liftContent+list(也可能只有 list+liftContent,所提升的列表项一直囊括到最后一个列表项)
    //   即需要构造 list+liftContent+list(或 list+liftContent)结构
    //   不需要替换原有的列表,则把替换开始(节点)的索引值设置为 indexBefore+1(同时结束节点索引值为 indexAfter + 1,跳过原始的列表节点),相当于没有对原来任何节点进行删除,只是在原始节点后面插入 liftContent,形成 list+liftContent 的结构
    //   同样还需要根据最后一个列表项 atEnd 是否也提升,来判断是否对全部列表项进行了提升,从而决定是否在 liftContent 后面重新追加一个列表节点,使用 Fragment.from(list) 来构建一个列表节点)形成 list+liftContent+list
    indexBefore + (atStart ? 0 : 1),
    // 替换结束(节点)的索引值为 indexBefore+1,即最多也只是把列表节点(索引值为 indexBefore)替换掉,该列表节点后续的兄弟节点并不变
    indexBefore + 1,
    // 替换的内容,包括提升的内容 item.content,以及根据 atEnd 所提升的列表项是否囊括最后一项,来判断后面是否需要追加(重构的)列表节点
    // 💡 方法 fragment.append(otherFragment) 将给定的其他片段 otherFragment 添加到后面,与当前片段整合为一个片段(最后返回的是一个新的片段)
    item.content.append(atEnd ? Fragment.empty : Fragment.from(list)))) return false

  // 选区左侧 $from 所在列表项节点(也就是「整合」后所得的列表项节点)的开头位置
  let start = $start.pos;
  // 「整合」后所得的列表项节点的结尾位置
  let end = start + item.nodeSize
  // Strip off the surrounding list. At the sides where we're not at
  // the end of the list, the existing list is closed. At sides where
  // this is the end, it is overwritten to its end.
  // 向事务对象 tr 中添加步骤 step
  // 使用 new ReplaceAroundStep() 创建一个 step 步骤实例
  // 该步骤使用一个 slice 切片对文档的特定范围的内容进行替换操作,但它可以保留原文档(被替代范围中的)部分/全部内容(将这些内容插入到 slice 切片的指定位置中),一般用作包裹 wrap around 选中的内容
  // 💡 该 step 步骤以替换的方式,实现(将「整合」所有内容的的列表项)提升的效果
  // 💡 假设该列表项位于无序列表中
  tr.step(new ReplaceAroundStep(
    // 在设置替换的开始位置时,本来应采用(所需提升的列表项节点的开头位置)start,但还需要根据提升列表项是否位于第一项(即 atStart 为 true),对替换的开始位置进行微调,以判断是否要囊括 <ul> 标签
    // 💡 假设并不是对所有列表项进行提升,则删掉的 <ul> 标签需要在替换内容 slice 的末尾补充回去,以封闭列表的开头,这样就可以让提升内容位于列表节点之前
    start - (atStart ? 1 : 0),
    // 在设置替换的结束位置时,本来应采用(所需提升的列表项节点的结束位置)end,但还需要根据提升的列表项是否位于最后一项(即 atEnd 为 true),对替换的结束位置进行微调,以判断是否要囊括 </ul> 标签
    // 💡 假如并不是对所有列表项进行提升,则删掉的 </ul> 标签需要在替换内容 slice 的开头补充回去,以封闭列表的结尾,这样就可以让提升内容位于列表节点之后
    end + (atEnd ? 1 : 0),
    // 💡 虽然该步骤(以替换的方式)删除所需提升的列表项节点,但是该列表项的内容(从 start + 1 到 end-1)其实被保留下来,插入到 slice 中
    start + 1, end - 1,
    // 构建替入的 slice
    new Slice(
      // 根据 atStart(提升列表项是否位于第一项)和 atEnd(升的列表项是否位于最后一项)构建 slice
      // 其中使用方法 list.copy(Fragment.empty) 创建一个内容为空的列表节点(类型与 list 一致)
      // 💡 其实使用方法 node.copy() 创建节点时,默认内容就是空的,所以可以省略参数的传递
      // 其中使用方法 fragment.append(otherFragment) 将给定的其他片段 otherFragment 添加到后面,与当前片段整合为一个片段(最后返回的是一个新的片段)
      // * 如果 ✅ atStart ❌ atEnd 则构建得到 fragment 是 <ul></ul>,然后根据 openStart = 0(左侧开放节点的嵌套深度)和 openEnd = 1 对 fragment 进行「裁剪」,得到的结构是 <ul>,然后所保留的内容在 slice 的开头插入,则最终构造出来的替换内容为 liftContent<ul> 💡 在后面添加 <ul> 标签用于封闭后面的(由剩余列表项构成的)列表节点
      // * 如果 ❌ atStart ❌ atEnd 则构建得到 fragment 是 <ul></ul><ul></ul>,然后根据 openStart = 1 和 openEnd = 1 对 fragment 进行「裁剪」,得到的结构是 </ul><ul>,然后所保留的内容在 slice 的位置 1 插入,则最终构造出来的替换内容为 </ul>liftContent<ul> 💡 在前面添加 </ul> 标签用于封闭前面的由剩余列表项构成的)列表节点;在后面添加 <ul> 标签用于封闭后面的(由剩余列表项构成的)列表节点
      // * 如果 ❌ atStart ✅ atEnd 则构建得到 fragment 是 <ul></ul>,然后根据 openStart = 1 和 openEnd = 0 对 fragment 进行「裁剪」,得到的结构是 </ul>,然后所保留的内容在 slice 的位置 1 插入,则最终构造出来的替换内容为 </ul>liftContent 💡 在前面添加 </ul> 标签用于封闭前面的由剩余列表项构成的)列表节点
      (atStart ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))).append(atEnd ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))),
      atStart ? 0 : 1,
      atEnd ? 0 : 1),
    // 设置所保留的内容应该插入到 slice 的什么位置(遵循 index schema)
    atStart ? 0 : 1))

  // 执行完成以上事务后,再调用 scrollIntoView 必要时滚动页面将选区/光标移入视图里
  // 通过 dispatch() 分发事务,执行对文档的修改
  dispatch(tr.scrollIntoView())
  return true
}

sinkListItem 方法

方法 sinkListItem(itemType) 创建一个 command 指令(它也是一个函数),其作用是将选中的列表项内容往内缩进一级(到一个嵌套列表中去)

参数 itemType 是表示列表项的 nodeType 对象

ts
export function sinkListItem(itemType: NodeType): Command {
  return function(state, dispatch) {
    // 解构选区,$from 和 $to 都是 resolvedPos 对象,表示选区前后两侧的位置
    let {$from, $to} = state.selection
    // 基于选区两侧的位置创建一个 nodeRange(该范围囊括选区)
    // 💡 方法 $from.blockRange() 的第二个参数是一个指示函数(返回布尔值),用于判断在迭代回溯途中所得到的祖先节点是否可接受,如果不可接受,则继续沿着文档树向上回溯
    // 这里需要祖先节点含有子节点(即内容不为空),且它的第一个子节点的类型是列表项(所以满足条件的祖先节点就是列表节点)
    // 即 nodeRange 所指向的节点(nodeRange.parent 属性的值)是列表节点
    // 💡 根据指示函数,如果要成功创建 nodeRange,则选区两侧的共同祖先节点中,需要有列表类型的节点。那么缩进就是将该列表节点(被选区框中)的列表项缩进一级
    let range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild!.type == itemType)
    // 如果无法基于选区创建 nodeRange,则返回 false,即该指令 command 不进行响应
    if (!range) return false

    // 以 range 所指向的节点(列表节点)作为容器,它包含一系列子节点(列表项),则 range.startIndex 表示该范围的开始位置 $from 所在子节点(列表项)的索引值
    let startIndex = range.startIndex

    // 🍀 如果选区开头所在的列表项为第一项,则无法进行缩进(由于当前列表节点至少要有第一项,才能满足 schema 约束),返回 false,即该指令 command 不进行响应
    if (startIndex == 0) return false

    // parent 是范围 range 所指向的节点,即列表节点
    let parent = range.parent;
    // 获取选区开头的前一个列表项( ⚠️ 由于前面保证了选区开头不会位于第一个列表项,所以 startIndex 大于或等于 1,即这里可以确保 startIndex - 1 是确保有意义)
    let nodeBefore = parent.child(startIndex - 1);

    // 这是防御性的代码,避免 nodeBefore 获取到的节点类型不是列表项,则返回 false
    if (nodeBefore.type != itemType) return false

    // 如果设置了 dispatch 则执行它
    // dispatch 接受一个参数,它是一个事务对象 tr,用于修改文档
    if (dispatch) {
      // 🍀 缩进的列表项需要使用一个嵌套列表进行包裹
      // nestedBefore 用于判断(选区开头)前一个列表项节点的最后一个子节点类型,如果它是列表,而且列表节点的类型与当前列表项的父节点相同(有序列表或无序列表),则缩进的列表项可以整合进这个嵌套列表
      let nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type == parent.type

      // inner 表示是否要(在后面构建 fragment 时)在嵌套列表里再添加一个列表项节点
      // 基于 nestedBefore 的不同情况构建不同结构的 fragment:
      // * 如果前一个列表项并不具有同类型的嵌套列表(作为最后一个子节点),则需要使用方法 itemType.create() 手动创建一个列表项,即在后面构建 fragment 时最里层的节点是该列表项
      // * 如果前一个列表项并具有同类型的嵌套列表(作为最后一个子节点),则不进行额外的添加,即 fragment 最里层的节点是列表项
      // 💡 不同的的 fragment 结构在后面会进行不同方式的「裁剪」,以得到合适的 slice,它的开放式标签可用于封闭(由于替换内容导致的)不完整列表
      let inner = Fragment.from(nestedBefore ? itemType.create() : null)
      // 构建替入的 slice
      let slice = new Slice(
        Fragment.from(
          // 使用方法 nodeType.create(attrs, content) 创建一个节点
          // 第一个参数设置节点 attributes,这里传递 null,表示新建的列表项不需要设置特殊的属性
          // 第二个参数设置节点的内容(子节点),通过方法 parent.type.create(null, inner)) 以创建一个(与缩进列表项所属的列表节点)类型相同的列表节点,作为容纳缩进列表项的嵌套列表
          // 该新建的嵌套列表节点的内容为 inner,它可能为列表项,也可能为空,由 nestedBefore 决定
          itemType.create(null, Fragment.from(parent.type.create(null, inner)))
          // 根据 nestedBefore 的不同情况构建不同结构的 fragment:
          // * 当存在「可用」的嵌套列表,所构建的 fragment 如下
          //   <li>
          //     <ul>
          //       <li></li>
          //     </ul>
          //   </li>
          // * 当不存在「可用」的嵌套列表,则所有键的 fragment 如下
          //   <li>
          //     <ul></ul>
          //   </li>
        ),
        // 根据 nestedBefore 的不同情况为 slice 设置不同的 openStart (左侧开放节点的嵌套深度,而右侧开放深度 openEnd 都为 0),即对 fragment 进行对应的「裁剪」
        // * 存在「可用」的嵌套列表,则 openStart=3,最终所构建的 slice 如下
        //      </li>
        //     </ul>
        //   </li>
        // * 当不存在「可用」的嵌套列表,则 openStart=1,最终所构建的 slice 如下
        //     <ul></ul>
        //   </li>
        nestedBefore ? 3 : 1, 0)

      // range.start 是选区开头所在的列表项的开始位置
      let before = range.start;
      // range.end 则是选区结尾所在的列表项的结尾位置
      let after = range.end;
      // 通过 dispatch() 分发事务,执行对文档的修改
      dispatch(
        // 使用 state.tr 基于编辑器当前的状态创建一个空的事务对象实例
        // 使用方法 tr.step() 向事务对象中添加步骤 step
        state.tr.step(
          // 使用 new ReplaceAroundStep() 创建一个 step 步骤实例
          // 该步骤使用一个 slice 切片对文档的特定范围的内容进行替换操作,但它可以保留原文档(被替代范围中的)部分/全部内容(将这些内容插入到 slice 切片的指定位置中),一般用作包裹 wrap around 选中的内容
          // 💡 该 step 步骤以替换的方式,实现(将选中的列表项)缩进的效果
          // 💡 假设该列表项位于无序列表中
          new ReplaceAroundStep(
            // 在设置替换的开始位置时,本来应采用(所需缩进的第一个列表项节点的开头位置)before,但还需要根据前一个列表项是否存在「可用」的嵌套列表(即 nestedBefore 为 true),对替换的开始位置进行微调,以判断是否要囊括前一个列表项(结束标签)以及它的子节点(嵌套列表的结束标签)还有该嵌套列表里的列表项(结束标签),即往前 3 个 token 以包含 </li></ul></li> 标签;如果不存在「可用」的嵌套列表(即 nestedBefore 为 false),则只需要囊括前一个列表项(结束标签),即往前 1 个 token 以包含 </li> 标签
            before - (nestedBefore ? 3 : 1),
            // 替换的结束位置是(所需缩进的最后一个列表项节点的结尾位置)after
            after,
            // 💡 虽然该步骤替换范围囊括了所需缩进的列表项(以替换的方式先删除所需缩进的列表项节点),但是这些列表项(从 before 到 after)其实被保留下来,插入到 slice 中
            before, after,
            // 将所保留的内容在 slice 的位置 1 插入
            // * 如果存在「可用」的嵌套列表最终得到的替换内容的结构如下
            //       </li>
            //       sinkContent
            //     </ul>
            //   </li>
            // 缩进的内容使用已存在的嵌套列表进行包裹
            // * 如果不存在「可用」的嵌套列表最终得到的替换内容的结构如下
            //     <ul>sinkContent</ul>
            //   </li>
            // 缩进的内容使用新建的嵌套列表进行包裹
            slice, 1,
            // 由于并没有进行内容的覆盖,只是进行结构的调整,所以最后一个参数为 true
            true)
        // 执行完成以上事务后,再调用 scrollIntoView 必要时滚动页面将选区/光标移入视图里
        ).scrollIntoView())
    }
    return true
  }
}

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes