简介ep_headings2 是一款面向 Etherpad 协作编辑平台的标题增强插件主要解决在线文档中 h1h6 多级标题快速排版与结构识别问题适合需要规范文档层级、提升多人协作效率的团队或开发者使用。压缩包共含 54 个文件以 JSON 语言包为主另有 JavaScript 逻辑文件、YAML 工作流与测试配置、Markdown 文档、CSS 样式及 EJS 模板整体仅 86KB结构紧凑便于阅读与二次开发。该插件支持多语言界面、导入导出与复制粘贴可实时显示当前活动标题并配有完整的测试覆盖与代码规范检查由 Etherpad 基金会长期维护。已有 290 人学习下载适合具备 JavaScript 基础、希望扩展或定制 Etherpad 编辑体验的开发者参考研究可直接获取插件源码、翻译资源及测试脚本等关键内容。1. 一次共同编辑长文档时最缺的其实是一个能被程序识别的标题结构在一次多人同时编辑的长文档协作里最不缺的是“看起来像标题”的句子最缺的是“整个文档能按标题重新组织”的结构。Etherpad 自带工具栏里那个 H 下拉只能把一行文字改大改粗它不会告诉大纲、目录、导入导出这一行属于第几级。ep_headings2解决的就是这个缝隙通过插件机制把 H1/H2/H3 存成文档属性而不是内联样式让标题既显示为层级又可以被脚本识别。这个方向也回答了ep_headings2怎么用它不是改编辑器源码而是以最小改动接入 Etherpad 的渲染链路。本文按一次实际接入的顺序把安装、配置、展开折叠跳转、避坑和最后验证一起讲清楚适合自托管 Etherpad 的管理员也适合给协作工具做二次开发的工程师。2. ep_headings2 的插件模型前端扩展与一对基础挂钩很多第一次接触 Etherpad 插件的人会以为“标题插件 给工具栏加个按钮”。实际拆开看按钮只是最外面的一层壳。ep_headings2这一类插件能跑起来靠的是 Etherpad 客户端插件体系里的两条输入输出接口一条告诉编辑器哪些元素是块结构一条告诉渲染端这些结构长什么样。理解这两条接口后面所有配置、排错都会顺很多。2.1 Etherpad 客户端插件靠一对输入输出接口存活块注册与类映射Etherpad 的客户端插件通过一个由启动器合并的 hooks 文件进入运行时。插件在plugin.json里声明哪些文件要被当客户端 hook 加载然后系统会在特定时机调用你导出的函数。对标题类插件来说最核心的两个 hook 是aceRegisterBlockElements和aceAttribsToClasses。// 这是标题插件的最小骨架注册块结构并把结构翻译成显示类 exports.aceRegisterBlockElements function () { return [heading1, heading2, heading3, heading4]; }; exports.aceAttribsToClasses function (hookName, attr, context) { var cls context.cls; // 这一行已有的类集合以空格分隔 var key context.attrib.key; // 例如 key heading2 var value context.attrib.value; if (key.indexOf(heading) 0 value ! ) { return [cls, key]; // 把 heading 类追加到行上 } };aceRegisterBlockElements返回的字符串不是 CSS 类名它决定的是“结构”。一旦这些块名被注册Etherpad 在转发行、换行、回退时会把这一行当成块来对待而不是当成一连串可分割的字符。aceAttribsToClasses则是给渲染端看的编辑器生成 DOM 时给这一行追加自定义类CSS 再用字体大小、缩进把这些类拉出层级感。两个 hook 缺一个都不叫完整的标题插件少了第一个换行和复制会把标题结构打散少了第二个Pad 数据虽然正确页面上却看不出任何标题层级。参数上需要注意context.attrib.key和context.attrib.value是 Ace 传入的原始属性键值很多插件在value ! 判断上偷懒直接返回导致空属性也被渲染成类。写条件判断时最好带上这个判断省去后面排查脏 class 的时间。2.2 标题的数据活在 documentAttribute 而不是文本样式里选型理由在 Etherpad 里给一段文字加粗、变色走的是“文本属性”通道它跟着字符走可以在一行里出现多种组合。标题不一样标题是整行的、结构性的、有层级的它不该只粘在几个字符上。ep_headings2处理标题时采取的做法是把它放进 document attribute 通道也就是文档级属性。我见过有人直接把标题做成文本属性当时看交互没问题等到做导出或者做目录跳转时才发现那一行的“标题”到底是停在第一个字还是最后一个字程序根本说不清。文档属性则干脆得多pad_editor.setDocumentAttribute(heading2, true)一行调用属性直接挂在整个 Pad 文档上行尾换行、插入空行都不会让标题从行身上掉下来。var pad_editor require(ep_etherpad-lite/static/js/pad_editor).pad_editor; // 设置标题时的常见顺序先清理同一行上可能存在的旧层级 function setHeadingLevel(level) { for (var i 1; i 4; i) { pad_editor.setDocumentAttribute(heading i, false); } pad_editor.setDocumentAttribute(heading level, true); }这段代码的逻辑很直白先全量清掉 1 到 4 级标题再设置想要的级别。之所以“先清理再设置”是因为 document attribute 是 Pad 级键值如果只把heading3设置成true而这一行之前还挂着heading2那编辑器会同时拿到两个标题状态渲染出来非常混乱。参数上heading1到heading4是插件自定义的键名键值必须用布尔值或可转为布尔值的字符串不能用数字 1、2、3 代替Etherpad 的序列化层在读取时对数字型的兼容不可控。2.3 和自带 H 下拉、其它标题插件的对比为什么社区大多数方案都有同一个边界Etherpad 默认工具栏里本来就有一个段落下拉里面有 H1/H2 等选项。很多人第一次装标题插件时会疑惑它和自带下拉有什么差别差别在数据层。自带下拉只是把字号改大、加粗导出的 HTML 里那一段仍然是普通文本而ep_headings2这一类插件会把“这是一个二级标题”写成结构化属性大纲、目录、导出器都能识别。对比项自带 H 下拉常见的简易标题插件ep_headings2数据模型内联文本样式行级块属性行级块属性 独立面板换行继承标题不继承按块规则继承可配置默认按块规则能否跳转/大纲不能一般只负责显示可做点击跳转与折叠导出语义丢失保留少量结构保留结构依赖导出器上手成本零低需读一次 hooks 文档这张表的最后一列是我个人对ep_headings2定位的理解它把“标题变成结构”这件事做完了但下一步“结构变成大纲、大纲变成导航”往往还要自己补几十行代码。这也是我推荐它的原因——边界清晰不自作聪明地把所有交互全塞进去适合二次开发。3. 把 ep_headings2 装进现有 Etherpad配置与部署的最小动作配置与部署这个环节实际踩坑的人比想象中多。大多数问题不是插件本身不能用而是 Etherpad 的启动顺序、缓存目录和 node_modules 位置没有配合好。下面按我通常会做的顺序讲照这个顺序操作基本能把失败率降到最低。3.1 插件源码里的 hook 和构建文件在哪从 npm 安装后进到node_modules/ep_headings2目录下你会看到它至少包含一个描述文件ep.json或plugin.json以及一个static/js目录。Etherpad 的客户端插件约定很简单在描述文件的client.hooks字段里指定了哪个文件是入口入口文件导出的函数就是上一章说的 hooks。二次开发时你真正要改的通常只有static/js下两三个文件而不是整个包。{ name: ep_headings2, version: 0.0.1, license: MIT, peerDependencies: { ep_etherpad-lite: 1.x }, client: { hooks: { aceRegisterBlockElements: ./static/js/hooks, aceAttribsToClasses: ./static/js/hooks, aceEditEvent: ./static/js/hooks } } }这是 Etherpad 官方插件规范里很常见的描述结构client.hooks指向同一个文件或不同文件都允许。重点理解一件事这些路径是相对插件包根目录解析的写错一个字母系统启动时不会直接崩而是静默跳过该 hook你会看到“插件列表里有它但页面没有任何效果”。参数上./static/js/hooks不要加.js后缀Etherpad 的模块加载器会自己补。3.2 安装命令与启动验证别跳过依赖重建我自己习惯把 Etherpad 装在/srv/etherpad这样的独立目录里所有插件安装命令都以这个目录为根执行。cd /srv/etherpad npm install ep_headings2 --save bin/installDeps.sh bin/run.sh /tmp/etherpad-heading-plugin.log 21 为什么这里不能像普通 Node 项目一样装完就跑因为 Etherpad 在启动时会重新生成一份客户端插件清单把 node_modules 里所有符合格式的插件路径写入自己的构建缓存。installDeps.sh负责把 npm 安装结果链接到 Etherpad 的依赖体系里跳过这一步插件可能能装进 node_modules但启动时根本不会被加载。参数上--save的作用是写入 package.json以后npm install不会丢如果你不想让插件跟着正式依赖入库也可以去掉--save改成--no-save --legacy-peer-deps但那样每次部署都要重新手动装一次。启动后看日志重点找有没有插件加载相关记录。打开管理后台的插件页面如果看到ep_headings2且状态不是报错红字说明安装成功。还有一个细节插件没有自动加到工具栏里是正常的因为标题按钮的样式和位置通常由插件的客户端脚本自行画出来能不能看到入口按钮取决于客户端 hook 是否正确执行。3.3 最小联动配置让标题按钮显示当前层级插件装上以后我一般会再写一小段自己的客户端脚本目的是让工具栏按钮的状态和文档属性保持同步。否则会出现一个很尴尬的情况光标已经在二级标题上按钮却看不出激活状态。var $ require(ep_etherpad-lite/static/js/rjquery).$; var pad_editor require(ep_etherpad-lite/static/js/pad_editor).pad_editor; function refreshToolbarState() { var activeLevel 0; for (var level 1; level 4; level) { if (pad_editor.getDocumentAttribute(heading level)) { activeLevel level; break; } } $(.ep-headings2-level-btn).removeClass(active); if (activeLevel) { $(.ep-headings2-level- activeLevel).addClass(active); } }这段逻辑很短但解决了 Ethepad UI 里一个常见的割裂工具按钮只管发起操作不管反馈状态。getDocumentAttribute读的是文档级属性所以即使光标在一个标题行的末尾、甚至标题行被折叠返回值都稳定。参数说明heading1到heading4的读取顺序不能乱因为我们要的是“第一个命中的级别”如果把 4 到 1 反着读三级标题会被误判成四级。3.4 部署时最容易忽略的权限和缓存问题如果 Etherpad 跑在容器里安装插件时有一个坑容器和宿主机的 Node 版本不一致时npm 安装会产生二进制兼容问题。我的做法是先用node -v确认两侧版本再用和镜像一致的 Node 版本执行安装。另外Etherpad 会在启动时生成静态资源缓存这个缓存位置通常在临时目录或数据目录里。插件更新后需要重启进程让缓存重建否则你改动的前端 hook 文件不会生效。4. 从标题到可点击大纲实施步骤与三个关键联动点当标题结构能在编辑器里稳定显示后真正的价值才开始兑现把标题汇集起来生成一篇文档的大纲。这一部分是我在ep_headings2上花的功夫最多的地方也是和一个标题插件从“能标层级”走向“被团队真正使用”的分界点。4.1 建一个大纲面板独立于编辑器但依赖标题类大纲面板最好的位置是编辑器旁边而不是编辑器内部。我的习惯是在页面里插入一个固定宽度的侧栏容器里面放一个空列表然后通过定时刷新把标题行填进去。!-- 这段结构可以在客户端 hook 初始化时插入到 #editorcontainerbox 之前 -- div idep-headings2-outline classep-headings2-panel div classep-headings2-panel-title文档大纲/div div classep-headings2-outline-list/div /div容器结构没必要做得复杂后续更新列表时只需要替换.ep-headings2-outline-list的内部 HTML。这一步骤关键不在结构本身而在于你选择哪个时机去刷新大纲。如果放在每次键盘输入都触发的处理器里多人协作时大纲会疯狂抖动体验极差。4.2 增量构建大纲每次编辑只更新受影响的行而不是重建全部Etherpad 的编辑器会在文本变化时抛出aceEditEvent。我选择在这个 hook 里做一次延迟刷新而不是监听 DOM 的input事件。因为aceEditEvent触发时Ace 内核的渲染可能还没完成直接读行内容会拿到旧数据所以在回调里包一层setTimeout(0)。exports.aceEditEvent function (hookName, context) { if (context.eventtype ! edit) return; setTimeout(synchronizeOutline, 0); }; function synchronizeOutline() { var list document.querySelector(.ep-headings2-outline-list); if (!list) return; var lines document.querySelectorAll(#outerdocbody .ace-line); var result []; lines.forEach(function (line) { var cls line.className; if (!cls) return; for (var i 1; i 4; i) { if (new RegExp((^|\\s)heading i (\\s|$)).test(cls)) { result.push( a>$(#editorcontainerbox).on(click, .ep-headings2-item, function () { var lineId this.getAttribute(data-line); var target document.querySelector(#outerdocbody [data-line lineId ]); if (target) { target.scrollIntoView({ block: start, behavior: smooth }); target.classList.add(flash-highlight); setTimeout(function () { target.classList.remove(flash-highlight); }, 1200); } });scrollIntoView的block: start保证标题行滚到容器顶部而不是只出现在视野边角。flash-highlight是我自己加的一个高亮视觉也会在 1.2 秒后去掉。点击跳转看着像浏览器行为实际上是在编辑器的 DOM 层做滚动所以不会打乱光标位置也不会让正在输入的人被强行拉走。4.4 折叠状态放哪localStorage 按 padId 分 key标题插件的展开折叠有两种理解。一种是把标题折叠放进文档本身这会影响所有人风险很大另一种是把面板的折叠状态存在本地只影响浏览者自己。我倾向于后者因为文档结构是公有的而阅读偏好是私有的。function persistOutlineState(padId, collapsed) { try { localStorage.setItem(ep_headings2: padId, collapsed ? 1 : 0); } catch (e) { // 隐私模式或禁用存储时静默失败不影响核心功能 } } function restoreOutlineState(padId) { try { return localStorage.getItem(ep_headings2: padId) 1; } catch (e) { return false; } }参数上key 里带上padId是为了避免不同 Pad 互串。如果你只有一个团队协作平台这个 padId 就是 URL 里的一长串标识别名。localStorage存不了复杂对象所以这里只用一个字符1和0表达状态。不要尝试在这里存大纲 HTML刷新后从新 DOM 里重建一手数据永远比拿旧缓存更可靠因为别的协作者可能已经改过结构。5. ep_headings2 安装与二次开发常见排查4 个避坑点按顺序查这一章节把我在 Etherpad 标题插件上反复翻车的记录按优先级排了个序。如果你照着前面步骤做还是不行从第一条开始往下查大概率能命中。5.1 现象插件出现在 admin 插件列表里但页面上没有任何标题按钮原因客户端 hook 没有被正确加载。常见于插件路径不对、plugin.json里的路径写错了、或者进程没重启导致静态资源缓存里存的还是旧清单。我不会第一时间去翻代码而是先去管理页面的插件详情里看状态再打开浏览器控制台看有没有报Cannot find module的请求错误。解决确认plugin.json里client.hooks的入口路径和小节 3.1 的示例一致然后重启 Etherpad 进程观察日志里插件初始化相关行有没有红色警告。如果依然没有入口打开浏览器开发者工具 Network 面板搜索插件名对应的静态 JS 是否被加载如果加载了但没执行就检查前面定义的 hooks 函数是否都被安全包裹。5.2 现象设置 H2 后整篇文档的标题级数全乱或者撤销一次后标题大面积消失原因setDocumentAttribute的调用顺序出了问题。很多人会在循环里一边读一边写比如先判断heading1是否开启再设置heading2结果heading1的状态被后续写入连带清掉。文档属性不像普通 JavaScript 对象它对每一次 set 都会生成一个可撤销的操作记录读取和写入夹杂在一起会让撤销栈里的步骤依赖出错。解决把读取和写入彻底分开先统一读取需要的旧值再做清理最后设置新值。这里最忌讳“为了省一次循环”在清理时顺便读旧状态。撤销问题我就会把设置过程包成一个独立函数避免其它 handler 打断操作顺序。5.3 现象标题样式生效但光标一离开标题行大纲里立刻少一条原因类名匹配写得太宽或太窄。太宽会把普通行误判为标题太窄则把真正的标题漏掉。比如直接用cls.indexOf(heading)判断heading2会被匹配但heading-text这种历史遗留类也会被匹配用判断又可能遇到类被合并成多个边界的字符串导致错过。解决使用小节 4.2 里的正则(^|\\s)heading([1-6])(\\s|$)。还有一个隐性问题Etherpad 的 DOM 类名可能同时包含编辑器加的ace-line、ace-line-number以及插件加的类顺序不稳定所以正则必须做全边界匹配不能依赖类名顺序。5.4 现象从旧 Pad 迁移来的文档里大纲出现一堆空条目或全部变成三级标题原因旧数据里可能直接写了h3之类的富文本标签但行属性里并没有heading3这个键。插件看到的是“一个包含标题标签的行”但行属性是正文。这类数据结构上的错位在迁移后特别常见尤其当旧编辑器不是 Etherpad 时。解决写一个一次性的数据清洗脚本把h1到h6标签替换成语义属性。替换时不要做正则全局替换先用解析库把 HTML 转成 DOM再逐行判断属于哪一级标题最后用pad_editor.setDocumentAttribute写入。这个脚本跑一次就够了不建议做成实时转换因为实时转换会把“真实标题结构”和“历史遗留外观”搅在一起后面没人敢再动数据。6. 加点私货用一份验收清单验证标题插件顺便把它变成大纲导航每次装完或改完标题插件我会用一套固定清单验收而不是只看“能不能点”、“能不能变粗”。这套清单按顺序过一遍基本能覆盖大多数真实使用场景第一在一个空白 Pad 里输入“第一章 背景”设置成 H1光标移到下一个空行新行不应该继承 H1而应该回到正文。第二把这一行再改成 H2再撤销一次应该回到 H1然后回到正文过程里大纲条目始终只有一条。第三打开刚完成的 Pad 并刷新页面标题层级仍然保留而不是只存在于浏览器内存里。第四同时开两个 Pad分别设置不同层级A Pad 的标题不能影响 B Pad 的状态。第五点击大纲条目编辑器能滚动到对应位置高亮至少持续半秒。这套清单看起来简单但它能把“插件自身没毛病、我们的接入代码写脏了”这类问题稳准狠地滤出来。我自己的习惯是接入任何 Etherpad 插件都不直接依赖“能看到 UI”这一层结果而是先用一段脚本读取文档属性验证数据结构真正写进 Pad 了再开始调样式。// 验证在当前 Pad 里heading2 属性是否真的持久化 var pad_editor require(ep_etherpad-lite/static/js/pad_editor).pad_editor; setInterval(function () { if (pad_editor.getDocumentAttribute(heading2)) { console.log([ep_headings2] heading2 属性仍然活跃); } }, 15 * 1000);这个 15 秒的定时器当然不会留在生产代码里它是我用来做验收的小工具。从这里出发最后一个能给到你且不被后端记住任何一个人的建议是功能做完后把大纲面板的样式交给团队里那个对极简最有执念的人改一版标题插件的代码质量和视觉克制几乎成正比。我在这条路上踩过最大的一个坑是急着把大纲面板做成“所见即所得”结果把所有标题状态都写进了文档最后文档结构被用户的随意操作搞得不可收拾。后来我把交互层和数据层分开插件只负责把属性写入文档大纲面板永远是从只读副本里生成。这个习惯让我后来在很长一段时间里没有因为标题插件收到过投诉。希望帮到你。本文还有配套的精品资源点击获取