Skip to content

折叠块 ​

折叠范围是块的缩进子树,与块类型、标题级别都无关:块模型里每个块只有一个父块,子块靠缩进挂在父块下面,所以折叠一个块只带走缩进比它更深的块。

这一点与飞书一致,也是它与直觉最容易冲突的地方:

  • 任意块都能折叠:标题、普通段落、列表项(以及引用、高亮块等容器)只要下面挂了子块,左侧就出现折叠箭头;
  • H2 后面没有缩进的 H3 / H4 是它的兄弟块,折叠 H2 不会带走它们;
  • 缩进更深的普通段落、代码块、表格、列表都属于父块的子树,会被一起折叠;
  • 块只有真正挂上了子块才会出现折叠箭头(老文档里块之间都没有缩进,于是没有可折叠内容)。

典型的两类「非标题折叠」:

html
<!-- 段落 + 缩进代码块:折叠段落就收起代码块 -->
<p data-indent="0">你好</p>
<pre data-indent="1"><code>方法</code></pre>
<p data-indent="0">你好你好</p>

<!-- 列表项 + 内部代码块:折叠状态属于这个 item,不影响下一个 item -->
<ol>
  <li><p>b. 4</p><pre data-indent="1"><code>1</code></pre></li>
  <li><p>c. 5</p></li>
</ol>

目录(大纲)仍按标题级别排,和折叠范围是两套语义:H3 / H4 照样出现在 H2 下面。

折叠身份是块的稳定 Block ID(attrs.blockId),不是文档位置,也不是「层级 + 文本 + 同名序号」这类派生键。因此:

  • 编辑、拖动、重命名后,折叠状态跟着那个块走;
  • 两个同名块各自独立,折叠其中一个不会影响另一个;
  • 删除某个块只带走它自己的折叠状态,不会串到相邻的同名块上。

折叠偏好按文档(documentId,无则 local)写入 localStorage,刷新 / 重新打开后自动恢复。

配置示例 ​

ts
new LarkDoc({
  element: '#editor',
  features: { folding: true },   // 关闭后箭头与菜单项隐藏
  documentId: 'doc-42',          // 决定折叠偏好存哪个键;Web Component 用 document-id 属性
  // 子块靠 data-indent 缩进挂在父块下面(= 飞书块模型里的父子关系)
  content: '<h2>第一节</h2><p data-indent="1">…</p><h2>第二节</h2><p data-indent="1">…</p>',
})
ts
// 命令式(editor 是 Tiptap 实例)
const editor = doc.getEditor()

editor.commands.toggleLarkDocFold(pos)         // 按文档位置切换(内部换算成 blockId)
editor.commands.toggleLarkDocFoldById(id)      // 已知 blockId 时直接切换
editor.commands.resetLarkDocFolds()            // 全部展开
editor.commands.foldLarkDocByIds(['b1', 'b2']) // 按 id 批量折叠(宿主自建大纲 / 书签时用)

配置与用法 ​

配置项类型默认值说明
features.foldingbooleantrue关闭后折叠箭头与菜单项隐藏,已有折叠状态保留在 storage 里。
documentIdstring''折叠偏好的分键依据;不传时落到 local,同一浏览器共用一个键。

使用方式 ​

入口操作
块左侧箭头悬停任意有子块的块,点击左边缘的箭头展开 / 折叠(aria-expanded 同步状态)。
块句柄菜单「折叠 / 展开」。
查找替换命中隐藏内容时自动展开所在块。
大纲面板跳转到被祖先块折叠的标题时,先展开祖先块。

块标识(blockId) ​

块级节点统一带 attrs.blockId,随 JSON 一起保存 / 导入 / 导出:

json
{ "type": "heading", "attrs": { "level": 2, "blockId": "b7f3k2m1" } }
带 id 的节点不带 id 的节点(纯结构性容器)
paragraph heading blockquote callout columns details detailsSummary listItem taskItem bulletList orderedList taskList codeBlock image horizontalRule blockMath tabletableRow tableCell tableHeader column detailsContent

列表项是唯一的「既是骨架又能当块」的特例:飞书把 fold-block-id 直接挂在 item 上,折叠状态属于 item 而不是外层列表,所以 item 必须有独立 id。

其余结构性容器不编号是有意为之:父块已经携带 id,重复编号只会让 JSON 体积成倍增长。

补号时机(都发生在事务末尾,不进入撤销历史,也不会触发 change 事件):

时机说明
编辑器初始化 / setContent()老文档(无 id)一次性补齐。
任意改动文档的事务新增块(回车分段、粘贴、拖入、AI 插入)补号;重复 id(复制粘贴、split 继承 attrs)重新编号。

重复 id 的判定不靠「先出现的保留」:以「旧位置经事务 mapping 映射后的位置」为准,离它更近的那个块保住原 id —— 粘贴在文档前面的副本不会抢走原块的折叠状态。

偏好存储 ​

项值
localStorage 键lark-doc:folds:<documentId | local>
值blockId 数组,如 ["b7f3k2m1"](空数组代表「用户主动全部展开」)
写盘闸门内容替换期间不写;读到偏好但一条都没命中不写;文档里没有可折叠块不写

写盘闸门的第三条判定的是「有没有可折叠块」,不是「有没有标题」:段落 / 列表项挂了缩进子块也算可折叠块,没有标题的文档同样会恢复折叠位。

旧版本用「层级 + 文本 + 同名序号」派生键存储,读取时会自动换算成 blockId 并写回,无需手动迁移。

设计约定 ​

  • 与「目录面板收起」(outlineCollapsed)、「目录面板开关」(outlineOpen)是三套独立状态,互不影响。
  • 折叠与悬停高亮共用同一套范围算法(缩进子树),两者永远一致:高亮到哪,折叠就折到哪。
  • 列表项的折叠入口归 item 自己:item 内首个段落(列表的「标题行」)不再单独生成箭头,否则两个箭头会落在同一处、互相盖住。
  • 悬停高亮的遮罩覆盖整棵子树并合成一个矩形(不分块、不在块间距里露白缝),与飞书的 hovered-mask 一致。
  • 缩进层级每级 25px,硬上限 5 级(飞书最深第 6 层)。能不能再缩一级由前一个块决定(缩进 = 成为上一个块的子块):第一个块缩不动,前块 2 级则最多到 3 级;减少缩进不受此限,可以一路减回文档开头。导入的老数据会被压回硬上限 5 级。代码块、引用、表格等容器块同样带 indent,缩进时整框位移、宽度同步变窄。
  • 没有显式缩进的容器块(例如刚从缩进段落里插入的代码块,indent 仍是 0)沿用前一个块的层级,不会把父块的子树截断。
  • 折叠区域以 display:none + aria-hidden="true" 隐藏;折叠后光标若落在被隐藏的后代里,会迁回调用的块。

已知边界

折叠偏好只存在本机 localStorage,不进入文档 JSON,save() 的 payload 里也没有折叠信息。换设备 / 换浏览器不同步;要跨端一致,需要把折叠状态随文档一起落库(例如 doc.attrs.foldState)。