可折叠容器

lexical
Created 2/22/2023
Updated 3/5/2023

可折叠容器

Lexical 官方制作了一个节点(插件)示例 Collapsible Container 可折叠容器,实现类似 HTML <details> 标签(折叠与展开文本)的功能

参考

⚠️ 官方文档的 codesandbox 示例有一些 bug,推荐参考 Playground 的相应源码进行学习

在 Playground 的相应源码中该插件由 5 个文件构成,但是这个插件是针对 React 前端框架的,所以这里先对其中的核心逻辑部分进行解读,再进行「改造」以 Vanilla JS 进行复现,以适用于任何框架。


index.ts 文件是插件的入口文件,导出了一些实用的方法,例如 CollapsiblePlugin() 用于注册插件,它会在编辑器上注册一系列相关的节点转换器、指令处理函数等

Key Takeaways

编辑器相关:

  • editor.hasNodes(nodesArr) 方法可以检查编辑器中是否已经注册了指定的节点类型,其中入参 nodesArr 是各种节点类型构成的数组
  • mergeRegister(...func) 方法可以将一系列的注册函数封装起来,而且该方法会返回一个函数,调用它可以一次性取消所有的注册

节点相关:

  • $getNodeByKey(key) 通过节点标识符 key 获取节点
  • $isElementNode(node) 判断节点 node 是否为 ElementNode 元素节点
  • node.isInline() 判断节点 node 是否为行内节点(表格的 cell 节点 ❓)
  • node.getParent() 获取父节点
  • node.getChildren() 方法可以获取元素节点 ElementNode 的子节点,而且返回的数组中,其元素的顺序和子节点在页面的顺序一致
  • node.getTopLevelElement() 从给定的节点 node 开始,沿着节点树分支往上回溯,获取最外层/最顶级的节点,默认情况下获取得到的节点是在 root 根节点下的元素节点(直接子节点)
  • node.getPreviousSibling() 获取 node 节点的前一个兄弟节点,node.getNextSibling() 获取 node 节点的后一个兄弟节点
  • $findMatchingParent(node,fn) 从给点的节点 node 开始,沿着节点树向上,寻找第一个满足特定条件 fn 的节点
    • 第一个参数是给定的节点,迭代寻找的起始节点
    • 第二个参数是判别函数,用于判断迭代的节点是否满足条件
  • node.insertBefore(targetNode)node.insertAfter(targetNode) 可以将给定的目标节点 targetNode 插入到节点 node 的前面或后面,作为兄弟节点
  • elementNode.append(targetNode) 将给定的目标节点 targetNode 作为子节点添加到 elementNode 元素节点中,并且是作为最后一个子节点插入
  • node.replace(targetNode) 用给定的目标节点 targetNode 取代节点 node
  • node.remove() 删除节点
  • $createParagraphNode() 创建一个段落节点

选区相关:

  • $getSelection() 获取当前选区,$getPreviousSelection() 获取编辑器在这一次更新之前的选区❓
  • $isRangeSelection(selection) 判断选区 selection 的类型是否为范围选区 RangeSelection
  • selection.isCollapsed() 判断选区 selection 是否「坍缩」,即范围选区的锚点和焦点位置相同,以光标的形式存在
  • selection.anchor.getNode() 获取范围选区的锚点所在的节点
  • $setSelection(targetSelection) 可以将当前的选区设置为给定的目标选区 targetSelection
  • selection.clone() 复制当前的选区,返回一个选区对象
  • elementNode.selectStart() 将选区设置在 elementNode 元素节点的第一个子节点的开头,相当于将焦点移到这个 elementNode 上,一般会在编辑器中插入/创建一个新的 elementNode 后调用该方法,便于直接进行输入

响应性相关:

  • editor._window 可以通过编辑器实例获取到页面的 window 对象,并通过 editor._window?.event 可以进一步获取原生的事件对象

以下是对核心代码的详细解读

index.ts
ts
useEffect(() => {
  if (
    // 先判断编辑器是否注册了与以下三种节点
    // 可折叠节点就是由以下三种节点构成
    // 其中 CollapsibleContainerNode 作为父节点
    // CollapsibleTitleNode 和 CollapsibleContentNode 依次为它的子节点
    !editor.hasNodes([
      CollapsibleContainerNode, // 可折叠节点的容器节点
      CollapsibleTitleNode, // 可折叠节点的标题(概要)节点
      CollapsibleContentNode, // 可折叠节点的具体内容节点
    ])
  ) {
    // 如果不满足以上条件则抛出错误
    throw new Error(
      'CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor',
    );
  }

  // 通过 mergeRegister 为节点注册一系列的节点转换器、指令处理函数等
  return mergeRegister(
    /**
     * 为 CollapsibleContentNode 节点(可折叠节点的具体内容节点)注册一个节点转换器
     */
    // 该转换器的目的是当该节点的父节点不再是 CollapsibleContainerNode 类型的时候
    // 将该节点 unwrap「解开」
    // 即可折叠节点不再具有 "Container > Title + Content" 嵌套结构时
    // 将其中的具体内容从可折叠节点中「释放」出来,变成普通的文本内容
    // 第二个参数是一个函数,其入参是(该类型节点中)发生改变的节点 node
    // 💡 可以在标题概要节点的尾部按下 `Delete` 键触发这个节点转换器
    // Structure enforcing transformers for each node type. In case nesting structure is not
    // "Container > Title + Content" it'll unwrap nodes and convert it back
    // to regular content.
    editor.registerNodeTransform(CollapsibleContentNode, (node) => {
      const parent = node.getParent(); // 获取该节点的父节点
      // 先判断父节点是否为 CollapsibleContentNode 类型
      if (!$isCollapsibleContainerNode(parent)) {
        // 如果不是,则获取该节点的子节点(内容)
        const children = node.getChildren();
        // 迭代这些子节点
        // 将它们依次(移到)插入到该节点的前面
        for (const child of children) {
          node.insertBefore(child);
        }
        // 最后删除该节点
        node.remove();
      }
    }),
    /**
     * 为 CollapsibleTitleNode 节点(可折叠节点的标题概要节点)注册一个节点转换器
     */
    // 💡 可以在标题概要节点的开头按下 `Backspace` 键触发这个节点转换器
    editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
      const parent = node.getParent();
      // 和前一个节点转换器类似,也是先判断父节点是否也是 CollapsibleContentNode 类型
      if (!$isCollapsibleContainerNode(parent)) {
        // 如果不是,则对将该节点替换为 ParagraphNode 段落节点
        // 使用方法 $createParagraphNode() 创建一个段落节点
        // 并将原节点的子节点(标题内容)「提取」出来
        // 作为新建的段落节点的子节点(用方法 append() 放到最后)
        node.replace($createParagraphNode().append(...node.getChildren()));
      }
    }),
    /**
     * 为 CollapsibleContainerNode 节点(可折叠节点的容器节点)注册一个节点转换器
     */
    editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
      const children = node.getChildren();
      // 类似地,判断 "Container > Title + Content" 的结构是否完整
      if (
        children.length !== 2 ||
        !$isCollapsibleTitleNode(children[0]) ||
        !$isCollapsibleContentNode(children[1])
      ) {
        // 判断该节点是否同时满足以下 3 点:
        // * 该节点的子节点数量是否为 2
        // * 第一个子节点是否为 CollapsibleTitleNode 类型
        // * 第二个子节点是否为 CollapsibleContentNode 类型
        // 如果不是,则将它的内容「提取」出来
        // 即把子节点「移动」到该节点的前面
        for (const child of children) {
          node.insertBefore(child);
        }
        // 并删除该节点
        node.remove();
      }
    }),
    /**
     * 为 DELETE_CHARACTER_COMMAND 指令注册一个处理函数
     */
    // 其作用是处理可折叠节点处于折叠状态时(通过 CSS 的 `display: none` 实现)特定场景
    // 如果在该折叠节点的后一个兄弟节点的开头按下 `Backspace` 键
    // 默认情况是会触发删除行为
    // 由于具体内容已经折叠了,所以它们都会被删除,并导致折叠节点 unwrap「解开」
    // 而这个处理函数就是修改了这种默认行为,将其变成展开可折叠节点的操作
    // 所以该处理函数的优先级采用 COMMAND_PRIORITY_LOW 相当于 1(比默认优先级 0 高)
    // This handles the case when container is collapsed and we delete its previous sibling
    // into it, it would cause collapsed content deleted (since it's display: none, and selection
    // swallows it when deletes single char). Instead we expand container, which is although
    // not perfect, but avoids bigger problem
    editor.registerCommand(
      DELETE_CHARACTER_COMMAND,
      () => {
        const selection = $getSelection();
        // 先进行一系列的条件判断,在分发 DELETE_CHARACTER_COMMAND 指令时
        // 光标的位置是否符合要求(位于可折叠节点的后面,即下一个兄弟节点的开头)
        if (
          !$isRangeSelection(selection) ||
          !selection.isCollapsed() ||
          selection.anchor.offset !== 0 // 选区的锚点是否位于节点的开头
        ) {
          return false;
        }

        // 这里是假设光标位于文本节点(开头)时
        // 首先通过 selection.anchor.getNode() 获取范围选区的锚点所在的文本节点
        const anchorNode = selection.anchor.getNode();

        // 然后获取它的最外层/最顶级的 ElementNode 元素节点
        // getTopLevelElement() 是祖先节点 LexicalNode 的方法,默认情况下获取得到的节点是在 root 根节点下的元素节点(直接子节点)
        // 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L302-L312
        // 但是查看方法 getTopLevelElement() 的源码,会发现在迭代获取嵌套结构外层的节点时
        // 会使用方法 $isRootOrShadowRoot(parent) 以判断当前所遍历的节点的父节点 parent是否为 root 根节点或 shadowRoot 「影子」根节点
        // 其中「影子」根节点标志着层次结构的结束,即应该把该节点从语义上当作根节点
        // 所以相对而言,其内部的子节点的顶级就是该节点了,getTopLevelElement() 所获取得到的就是该节点,而不是 RootNode
        // 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts#L572-L578
        // 可以将节点的方法 isShadowRoot() 的返回值设置为 true,将节点设置为 shadowRoot
        // 在 CollapsibleContentNode 中就设置为 true
        // https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContentNode.ts#L82-L84
        // 这就可以兼容可折叠节点嵌套的情况
        // 😃 那么调用 getTopLevelElement() 方法所获取到的节点,就是该文本节点所在的段落节点
        const topLevelElement = anchorNode.getTopLevelElement();

        if (topLevelElement === null) {
          return false;
        }

        // 再通过 getPreviousSibling 获取到前一个兄弟节点
        // 😃 对于嵌套的可折叠节点也可以正确地获取到
        const container = topLevelElement.getPreviousSibling();

        // 然后判断这个邻近的 ElementNode 是否为 CollapsibleContainerNode 容器节点
        // 而且它处于折叠状态
        if (!$isCollapsibleContainerNode(container) || container.getOpen()) {
        // 如果其中任一条件不满足,则不进行处理
        // 且返回 false,让其他的处理函数可以被调用(例如执行默认的删除操作)
          return false;
        }

        // 如果满足条件,则将这个可折叠节点展开
        container.setOpen(true);
        // 并返回 true,表示该指令已处理完成,其他处理函数不会被调用
        return true;
      },
      COMMAND_PRIORITY_LOW, // 处理函数的优先级,相当于 1
    ),
    /**
     * 为 KEY_ARROW_DOWN_COMMAND 指令注册一个处理函数
     */
    // 其作用是处理当可折叠节点是最后一个节点时(可能是位于整个编辑器的最后,或在嵌套的可折叠节点的最后),若此时按下向下键的场景
    // 在富文本编辑器的段落节点中,默认以按下回车键「跳出」当前的(段落)节点
    // 并在它后面新增一个段落节点
    // 那么如果在可折叠节点按下回车键,则会在 CollapsibleContentNode 节点中创建一个 ParagraphNode 段落节点,作为它的子节点
    // 并没有「跳出」可折叠节点
    // 而是通过按下向下键来「跳出」可折叠节点的
    // 但是还需要考虑到如果可折叠节点位于编辑器的最后一个节点
    // 在富文本编辑器中,按下向下键可以让光标往下游弋
    // 直到光标位于编辑器的最后一个段落,此时再按下向下键就无反应了(无法新建段落)
    // 如果可折叠节点位于编辑器的最后一个节点,如果采用默认行为,则无论按回车键还是向下键都无法「跳出」该节点
    // 而这个处理函数就是修改了这种默认行为
    // 如果可折叠节点位于编辑器的最后一个节点,光标位于这个可折叠节点中
    // 当按下向下键「跳出」该节点时,并在它的后面新建一个段落节点
    // 💡 其实在判断可折叠节点的位置时,并不是考虑它是否位于这个编辑器的最后一个节点
    // 而是考虑它是否位于父节点的最后一个子节点,这样就可以兼容嵌套可折叠节点的情况
    // 💡 该处理函数的作用相当于 DecoratorNode 装饰节点中的 $insertBlockNode() 方法
    // When collapsible is the last child pressing down arrow will insert paragraph
    // below it to allow adding more content. It's similar what $insertBlockNode
    // (mainly for decorators), except it'll always be possible to continue adding
    // new content even if trailing paragraph is accidentally deleted
    editor.registerCommand(
      KEY_ARROW_DOWN_COMMAND,
      () => {
        const selection = $getSelection();
        // 先判断按下向下键时,当前的选区类型是否为范围选区
        // 且 collapse 锚点和焦点是「坍缩」,即光标的形式存在
        // 因为如果有选中内容,则按下向下键的默认行为是将范围选区 collapsed 到焦点处
        if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
          // 如果不满足条件则返回 false
          // 不执行该处理函数后面的部分,但是允许其他处理函数被调用
          return false;
        }

        // 基于锚点所在的节点顺着节点树,往上寻找 CollapsibleContainerNode 容器节点
        // $findMatchingParent 方法用于从给点的节点开始,顺着节点树向上寻找第一个满足特定条件的节点
        // * 第一个参数是给定的节点,迭代寻找的起始节点
        // * 第二个参数是判别函数,用于判断迭代的节点是否满足条件,如果满足条件就会返回当前所迭代的节点;如果不满足条件,则调用 node.getParent() 获取上一级的节点,进入下一轮的迭代,直至达到 RootNode 根节点
        const container = $findMatchingParent(
          selection.anchor.getNode(),
          $isCollapsibleContainerNode,
        );

        if (container === null) {
          // 如果没有找到可折叠节点的容器节点,即光标不在可折叠节点内
          // 则返回 false
          return false;
        }

        // 获取该可折叠节点的父节点
        const parent = container.getParent();
        // 判断这个可折叠节点是否位于该父节点的最后
        if (parent !== null && parent.getLastChild() === container) {
          // 如果是,则在该父节点最后新增一个段落节点
          // 这样光标可以「跳出」可折叠节点,进入这个新增的段落节点
          parent.append($createParagraphNode());
        }
        // 💡 判断光标原来的位置,并没有仔细区分它是否位于 CollapsibleTitleNode 可折叠节点的标题节点,还是位于 CollapsibleContentNode 可折叠节点的具体内容节点上
        // 因为可折叠节点的状态可能时折叠的,也可以是展开的
        // 😞 这可能会导致一个「奇异」 weird 的操作反应
        // 即如果在编辑器或内嵌的可折叠节点的最后插入一个新的可折叠节点
        // 编辑完标题后,按下向下键光标会跳转到可折叠节点的内容框
        // 但是同时会在可折叠节点的后面创建一个段落节点,但是此时光标并不是打算「跳出」这个可折叠节点
        // 最后返回 false,可以让其他处理函数被调用(默认行为是移动光标)
        return false;
      },
      COMMAND_PRIORITY_LOW, // 处理函数的优先级,相当于 1
    ),
    /**
     * 为 INSERT_PARAGRAPH_COMMAND 指令注册一个处理函数
     */
    // 富文本编辑器会在按下 `Enter` 键时分发 INSERT_PARAGRAPH_COMMAND 指令,其默认行为时插入一个段落节点
    // 这个处理函数的作用是针对在可折叠节点的标题同时按下 `Ctrl`(或 `CMD`)和 `Enter` 键的场景
    // 修改了默认的行为,变成切换可折叠节点的折叠与展开状态
    // Handling CMD+Enter to toggle collapsible element collapsed state
    editor.registerCommand(
      INSERT_PARAGRAPH_COMMAND,
      () => {
        // 由于富文本编辑器在按下 `Enter` 键分发 INSERT_PARAGRAPH_COMMAND 指令时
        // 并没有同时传递原生的事件对象作为参数
        // 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-rich-text/src/index.ts#L843-L872
        // 所以在这里通过 editor._window?.event 来获取按键事件
        // @ts-ignore
        const windowEvent: KeyboardEvent | undefined = editor._window?.event;

        if (
          windowEvent &&
          (windowEvent.ctrlKey || windowEvent.metaKey) &&
          windowEvent.key === 'Enter'
        ) {
          // 如果分发 INSERT_PARAGRAPH_COMMAND 指令时
          // 用户同时按下了 `Ctrl`(或 `CMD`)键和 `Enter` 键
          // ❓ 这里为什么使用 $getPreviousSelection() 而不是 $getSelection() 获取选区
          const selection = $getPreviousSelection();
          // const selection = $getSelection();

          // 判断选区类型是否为范围选区,且选区是光标形式
          if ($isRangeSelection(selection) && selection.isCollapsed()) {
            // 如果选区都满足条件,则从锚点所在的节点开始,顺着节点树向上寻找最近的 ElementNode 元素节点(父节点)
            // 一般光标位于文本节点
            // 那么如果光标是在可折叠节点的标题节点中的文本节点里,最后返回的的就是这个可折叠节点的标题节点
            const parent = $findMatchingParent(
              selection.anchor.getNode(),
              (node) => $isElementNode(node) && !node.isInline(),
            );

            // 如果这个节点是可折叠节点的标题节点
            if ($isCollapsibleTitleNode(parent)) {
              // 则获取上一级的节点,即可折叠节点的容器节点
              const container = parent.getParent();
              if ($isCollapsibleContainerNode(container)) {
                // 并切换该可折叠节点的折叠/展开状态
                container.toggleOpen();
                $setSelection(selection.clone()); // ❓ 并重用旧的选区
                // 最后返回 true,表示该指令已经处理完成
                // 即不会执行默认的插入新段落的行为
                return true;
                // 🙋 这里可以分发 TOGGLE_COLLAPSIBLE_COMMAND 来实现折叠/展开的切换
                // const key = container.getKey()
                // return editor.dispatchCommand(TOGGLE_COLLAPSIBLE_COMMAND, key)
              }
            }
          }
        }
        return false;
      },
      COMMAND_PRIORITY_LOW, // 处理函数的优先级,相当于 1
    ),
    /**
     * 为 INSERT_COLLAPSIBLE_COMMAND 指令注册一个处理函数
     */
    // 这是为了响应点击工具栏的相应按钮的点击操作所分发的 INSERT_COLLAPSIBLE_COMMAND 指令,在编辑器中插入一个可折叠节点
    editor.registerCommand(
      INSERT_COLLAPSIBLE_COMMAND,
      () => {
        // 🙋 由于这里会触发页面的更改,所以用 editor.update() 进行封装
        editor.update(() => {
          const selection = $getSelection();

          if (!$isRangeSelection(selection)) {
            return;
          }

          // 调用相应的方法创建可折叠节点
          const title = $createCollapsibleTitleNode();
          const content = $createCollapsibleContentNode().append(
            $createParagraphNode(),
          );
          const container = $createCollapsibleContainerNode(true).append(
            title,
            content,
          );
          // 然后在选区的位置插入新建的节点
          selection.insertNodes([container]);
          // 最后调用父类 ElementNode 的方法 selectStart() 将选区设置在标题的第一个子节点的开头
          // 即将焦点移到标题上,方便插入可折叠节点后,直接输入标题
          // 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts#L340-L350
          title.selectStart();
        });

        return true;
      },
      COMMAND_PRIORITY_EDITOR, // 处理函数的优先级,相当于 0
    ),
    /**
     * 为 TOGGLE_COLLAPSIBLE_COMMAND 指令注册一个处理函数
     */
    // 这是为了实现可折叠节点的折叠/展开状态的切换
    // 分发 TOGGLE_COLLAPSIBLE_COMMAND 会同时传递节点标识符 key
    editor.registerCommand(
      TOGGLE_COLLAPSIBLE_COMMAND,
      (key: NodeKey) => {
        // 🙋 这里可以不使用 editor.update() 方法进行封装吗 ❓
        editor.update(() => {
          // 根据 key 可以找到相应的可折叠节点(容器节点)
          const containerNode = $getNodeByKey(key);
          if ($isCollapsibleContainerNode(containerNode)) {
            // 切换该节点的折叠/展开状态的切换
            containerNode.toggleOpen();
          }
        });

        return true;
      },
      COMMAND_PRIORITY_EDITOR, // 处理函数的优先级,相当于 0
    ),
  );
}, [editor]);

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes