简介ep_headings2 是一个面向 Etherpad 的标题插件适合协作文档平台的普通用户、管理员和前端开发者。它通过编辑栏中的快捷按钮将标题层级应用到 pad 内容避免手工输入 HTML 标签提升长文档导航体验。资源为开源插件完整源码包采用 JavaScript 编写共 54 个文件压缩包仅 86KB。文件构成包括 38 个 JSON 本地化语言包、5 个 JS 核心模块以及 CSS 样式、EJS 模板、Markdown 说明、测试用例等另有 YAML 构建配置和许可证文件方便直接安装或二次开发。插件实现 h1 等标题插入、当前活动标题高亮、导入导出与复制粘贴支持并支持 i18n 国际化覆盖多种语言。项目由 Etherpad 基金会维护具备测试覆盖率、lint 检查和持续集成配置代码质量和维护性较有保障。适合学习 Etherpad 插件架构、编辑栏扩展和发布流程的开发者收藏目前已有 290 人学习浏览。1. 多人协作文档里的标题需求ep_headings2 到底补上了哪块缺口Etherpad 是一个用 Node.js 写成的多人实时网页编辑器开会记录、团队笔记、远程协作全靠它。但默认编辑界面只有加粗、斜体、列表这类基础格式没有标题分级。文档一旦超过三五页就变成一堵全是同字号文本的墙想快速跳到某个小节只能靠人工翻页。ep_headings2 就是冲着这个缺口来的一个用 JavaScript 写成的 Etherpad 标题插件在工具栏注入「格式」下拉支持普通段落和 H1-H6 六档标题并把标题作为行属性落进 pad 数据。对于维护 Etherpad 的开发者它让协作文档具备了结构对于每天用 pad 写协作纪要的团队它让长篇内容可扫读、可跳转、可导出。安装、配置、踩坑和验证方式照着走一遍基本就能用熟。2. Etherpad 插件的加载链路从 ep.json 到工具栏下拉ep_headings2 是怎么挂进来的2.1 先理解 Etherpad 的插件 hooks为什么一个插件能改编辑器Etherpad 的插件机制是「声明式」的每个插件目录下放一个 ep.jsonEtherpad 启动时扫描 node_modules 里所有含 ep.json 的包把里面注册的 hooks 挂到生命周期里。这个设计对标题类插件很合适因为标题要同时碰 DOM工具栏、编辑器ace2和样式CSS而 hooks 恰好是这些位置的入口。我一般管 ep.json 叫插件的「报关单」。它列出的内容有两类一类是 server hooks跑在 Node 端比如往页面模板里注入东西另一类是 client hooks跑在浏览器端比如 ace 编辑器的键鼠事件、选择变化事件。ep_headings2 这类前端交互重的插件大部分逻辑都挂在 client hooks 上。下面这段 JSON 是这类标题插件常见的 ep.json 结构不同版本命名不一定完全一样骨架就是这套{ name: ep_headings2, version: 1.0.0, parts: [ { name: headings2, client_hooks: { aceEditorCSS: ep_headings2/static/js/headings.css, aceEditEvent: ep_headings2/static/js/headings, aceKeyEvent: ep_headings2/static/js/headings, aceSelectionChanged: ep_headings2/static/js/headings }, hooks: { eejsBlock_styles: ep_headings2/index, eejsBlock_scripts: ep_headings2/index, eejsBlock_document: ep_headings2/index } } ] }这里说一下每个字段的作用。name 是插件名parts 表示这个插件占一个功能部件client_hooks 里的 aceEditorCSS 指向一个 CSS 文件负责告诉 ace 编辑器「标题行应该长什么样」aceEditEvent 和 aceSelectionChanged 用于在光标落在标题行时同步下拉框的选中值eejsBlock_document 则是在页面 HTML 模板的某个插槽里注入工具栏下拉的 DOM。整体思路是样式归样式交互归交互注入归注入互不掺和。你不需要改 Etherpad 的源码只需要在 hooks 声明的位置做增量这也是标题类插件能稳定落地的前提。如果某个新版本把某个 hook 改名插件的注册项就会失效——这个在避坑章节里会专门讲。2.2 安装 ep_headings2目录不对装完等于白装装这个插件的常见流程是先确认 Etherpad 的根目录。很多人栽在第一步在服务器任意目录下执行 npm install包是装上了但不在 Etherpad 的扫描范围内自然不会被加载。先在命令行进到 Etherpad 根目录用 pwd 确认里面有 bin、src、node_modules 这几个关键目录再执行cd /opt/etherpad-lite npm install ep_headings2如果你的 Etherpad 放在 /srv/etherpad、/home/user/etherpad-lite 之类的位置路径按实际来。这一步的核心是让包落进 Etherpad 的 node_modules因为 Etherpad 在启动时会遍历 node_modules 下所有含 ep.json 的包npm 装到别处等于没装。如果公司网络屏蔽默认 npm 源装不上会报 ETIMEDOUT 或者 ERESOLVE可以临时换源npm install ep_headings2 --registryhttps://registry.npmmirror.com这里的 --registry 参数只在这一次安装生效不会改全局 npm 配置影响面最小。装完以后 node_modules 下会多出一个 ep_headings2 目录里面带着 ep.json 和 static 目录static 下一般是 js 和 css 文件前端运行时就是靠这些文件工作的。接着重启 Etherpad。开发环境直接 CtrlC 停掉再跑 bin/run.sh生产环境如果是 systemd 托管的服务可以执行sudo systemctl restart etherpad重启这一步不能省。Etherpad 的插件扫描发生在启动阶段热更新对这个插件基本是玄学别在运行中尝试 reload 插件容易遇到缓存和状态不一致的问题。2.3 验证加载日志、插件页和工具栏三重确认装完之后不要急着开 pad 编辑先看三处痕迹第一处是启动日志。重启后日志里会出现一行类似Installed plugins: ep_headings2的记录如果日志中根本没有这个插件名基本可以断定包没装进正确的目录或者 ep.json 解析失败。此时回头检查 node_modules/ep_headings2 目录是否存在。第二处是管理页面。Etherpad 自带的 /admin/plugins 页面会列出当前实例扫描到的所有插件找到 ep_headings2确认它处于启用状态。某些情况下插件列表里能看到名字但状态是 disabled这往往是之前手动禁用过点击 enable 再重启一次即可。第三处是界面本身。打开任意一个 pad观察工具栏是否出现「格式」或「段落样式」下拉框里面一般有 Normal、Heading 1 到 Heading 6。下拉出现后随便选一行文字设成 H2看那一行是否变大加粗。到这里插件就算真正跑起来了。验证过程中最常见的误判是「npm install 成功 插件生效」。npm 的成功提示只代表依赖装好Etherpad 是否加载它取决于启动时扫描结果。所以三层验证里日志和插件页比「代码装没装」更接近事实。3. 配置与使用标题级别、段落语义与协同模式下的操作习惯3.1 六档标题与「行属性」机制为什么它是对整行生效ep_headings2 支持 Normal 和 H1-H6 共七种段落状态。许多从 Word 转过来的人第一次用都会不适应选中一个词点 H2结果整行都变成了标题而不是只有选中的文字变标题。这不是 bug而是 Etherpad 的底层数据模型决定的。Etherpad 的 pad 内容在存储层不是一段连续的 HTML而是按行存储的结构每行都带着自己的属性attributes。heading 是行属性的一种和加粗、斜体这类「字符级属性」不同标题是「行级属性」。插件在工具栏上选中 H3 的时候代码做的事情是把光标所在行的属性里写上一个 heading3 的标记前端 aceeditor 再根据这个属性决定怎么渲染。行属性机制带来两个直接结果。第一个结果是「标题行内不能只让部分文字是标题」。如果你想在一行里出现「前半段正文、后半段标题」这种混排需要拆成两行分别设置级别这是协作工具为了行级合并冲突做的取舍。第二个结果是「回车后新行默认继承当前行的属性」。给一行设了 H2按下回车开始打下一段新行也会带着 H2 的样式看起来就跟下面所有内容都变成标题了一样。这个继承行为在多人实时协作场景里不完全是坏事。当你连续写多个同级别标题时它反而省事比如枚举 1.1、1.2、1.3 这种小节标题。但你要记住打完最后一个标题需要再把下拉切回 Normal否则后续正文一路标题下去。这个习惯如果不建立后面所有导出内容的语义全是错的。3.2 工具栏排列与下拉细节顺序、宽度和显示文字ep_headings2 默认把下拉放在工具栏靠前的位置一般在加粗、斜体这些按钮附近。有些版本会让下拉显示为「段落」两个字点击展开才看到 Normal / Heading 1 / Heading 2。如果你觉得下拉宽度太大或者文字显示不合适可以调整插件的 CSS。常见做法是额外挂一段自己的样式覆盖文件通过 etherpad 的eejsBlock_styleshook 注入选择器直接命中工具栏里的 select 元素。具体写法看每个版本的实际 DOM 结构我一般用浏览器开发工具先看元素类名再针对性地写覆盖规则而不是去改插件源文件——因为升级插件后源文件改动会被覆盖。工具栏里各标题项显示的文字并不是完全固定的。有的版本显示「标题 1」「标题 2」有的直接显示「H1」「H2」甚至同一插件在不同语言包下文字也不同。如果你需要统一术语可以在插件的语言文件或者自定义翻译里改。在多数实现里下拉选择后的即时反馈是「当前行样式变化 下拉文本同步」。当你把光标移动到已有标题的不同位置时下拉应该始终显示那个标题级别。如果你的光标停在标题行但下拉显示 Normal要么是插件的事件监听没绑好要么是光标落在了标题行的行尾换行符上这种时候通常刷新一下页面就能恢复显示。3.3 多人协作的实际用法标题该由谁来打、什么时候打多人同时在一个 pad 上编辑时标题的冲突要比正文更敏感。因为标题影响的范围是整行如果两个人同时在相邻两行上设置不同级别的标题ETherpad 的行合并机制会尽量保留双方属性但偶然会出现一方覆盖另一方的现象。这不是 ep_headings2 独有的问题是行属性协同的通病。我自己在团队里定了两条规矩。第一条是标题结构尽量让一个人来打通常是会议里的记录人或者文档主笔。标题是骨架几个人同时往骨架上挂肉可以同时改骨架容易错位。第二条是打标题的时间点放在内容相对稳定之后。开会刚开始想法还在碰撞阶段先让大家往里面倒零散内容等到第一轮内容差不多齐了再整体梳理标题层级。这样比边写边改标题节省大量调整时间。如果确实需要多人各自负责不同章节一个用得顺手的操作是每个人先在自己负责的区域写正文最后合并阶段由同一个人统一下拉调整。因为标题级别的调整成本低但多人同时调整的冲突成本高交给一个人反而快。4. 避坑与排查标题不显示、样式丢失与协同错位的五个现场4.1 插件装完工具栏没有「格式」下拉现象npm install 成功、重启成功、日志里也能看到插件名但 pad 的工具栏没有任何变化。原因最常见的是浏览器端缓存了旧的工具栏 HTML或者服务器内存里还留着旧版本的页面模板。另一个可能是 settings.json 里的 toolbar 配置被改过Etherpad 支持通过 settings.json 的 toolbar 字段自定义工具栏布局如果这个配置里只罗列了固定几组按钮插件注入工具栏的插槽就被跳过了。解决先强制刷新浏览器CtrlShiftR排除静态资源缓存。再检查 settings.json 里的 toolbar 字段确保没有把自定义的工具栏按钮列表写死导致插件注入位置缺失。如果这两步都无效去 /admin/plugins 页面把 ep_headings2 先禁用再启用触发一次重新注入大概率能捞回来。4.2 标题在编辑页有样式导出的文档里没有标题标签现象pad 上 H1-H6 显示正常但通过 API 的 getHTML 或者导出 PDF/Word 后标题全变成了带某个类名的段落没有语义标签。原因在线编辑时标题依赖行属性渲染导出环节是另一条链路。getHTML 默认生成的 HTML 会把行属性映射成对应的标签但很多导出插件尤其是单页导出类型只认h1到h6标签不认行属性所以标题在导出结果里被打回普通段落。解决把行属性在导出前转换成真正的标题标签。常见做法是写一个 export 相关 hook 的小插件遍历 lines 的 attributes把 heading 属性映射为对应 hN 标签。或者直接用 getHTML 接口的结果作为导出源因为多数版本里 getHTML 能正确还原标题行这个接口的输出是可靠的中间格式。4.3 设置标题后后续所有输入都变成标题现象给一行设了 H2换到下一行开始打字新输入的内容也带着标题样式整个文档往下全是粗体大字。原因这是行属性继承机制。Etherpad 按下回车后新行会复制当前行的属性相当于「格式延续」。习惯 Word 操作的人第一次遇到都会愣一下。解决两个办法。一个是在打完标题后用下拉切回 Normal 再继续输入这是零成本解法。另一个是如果团队里反复有人踩这个坑可以在客户端写一个小 hook监听回车事件如果当前标题级别非空把新行的 heading 属性清掉。这个相当于把「自动回 Normal」做进自己的插件里改动量不大但对团队体验的提升很明显。4.4 升级 Etherpad 之后插件静默失效现象升级后重启日志没有报错插件列表里也还有 ep_headings2但工具栏下拉没了甚至个别 pad 打开后编辑器异常。原因Etherpad 升级会改动 hooks 的参数签名或事件对象结构。比如某个版本把 aceEditEvent 里传的参数结构调整了插件代码里取旧的属性名就拿到 undefined前端逻辑报错但不崩导致只有局部功能消失。也可能是 ep.json 里声明的某个 hook 名在新版本里被废弃Etherpad 扫描时直接忽略。解决升级前先确认插件有没有对应的兼容版本。升级后中招的话先回滚 Etherpad 版本恢复服务再更新 ep_headings2。这种事急不来和所有生态软件一样老插件对核心版本升级的响应总是慢半拍。4.5 自定义皮肤之后标题样式被全局 CSS 压掉现象默认主题下标题显示正常换了自定义皮肤或者加了 custom.css 之后标题字号回到普通段落大小只有颜色还隐约看得出区别。原因标题样式来自插件注入的 aceEditorCSS 文件这个文件在加载顺序上往往早于全局样式或者自定义样式表里的容器选择器把标题行也命中了。ace 编辑器的 DOM 结构相对特殊标题行渲染在特定 class 的容器里容易被写得很宽的全局选择器覆盖。解决不要直接改插件的 CSS 文件自己补一条高优先级规则通过自定义样式 hook 注入。选择器尽量具体沿着浏览器开发工具里看到的实际 DOM 路径来写比如针对特定容器下的标题行 class 设置 font-size 和 font-weight。必要时用 !important 也行但只在万不得已时用。调完之后回到 pad 上实际看一眼因为编辑器视区里的标题样式和预览区不完全是一套渲染逻辑。5. 进阶用 API 验证标题结构顺便做一个文档大纲5.1 用 getHTML 接口看标题是否真的落盘Etherpad 的 HTTP API 提供 getHTML 接口对一个 pad 调用它返回的 HTML 能反映当前 pad 内容以及行属性的标签映射。验证方式curl http://127.0.0.1:9001/api/1/getHTML?padIDmy-project-notes响应 JSObject 形如{ code: 0, data: { html: h2需求背景/h2p这里是正文/p } }注意里面几个参数padID 是 pad 名如果有特殊字符需要做 URL 编码默认端口是 9001以实际配置为准。返回结构里data.html是重建后的 HTML如果里面能看到h2标签说明标题行属性被正确映射到了语义标签这一层验证比肉眼看工具栏靠谱得多。如果看到的是span stylefont-size: large这类样式化输出说明导出链路的映射没走标题语义需要回到 4.2 的解法去补映射。5.2 行属性层面确认 heading 标记getHTML 足够应付日常验证。如果你在写维护脚本需要更接近原始数据可以用 ohapi 或者内部机制直接读 pad 的 atext 结构每一行记录的lineAttributes里会有heading键。我不把它当常用手段因为内部数据结构在不同版本之间变化较大维护成本高。大部分场景下「 getHTML 里有 h 标签」已经能说明问题。5.3 顺手做一个文档大纲如果 pad 承载的是周报或会议纪要长期累积后想快速生成目录索引可以写一个定时脚本调 getHTML再用正则把标题抽出来const fetch require(node-fetch); const padId encodeURIComponent(weekly-meeting-2024); const url http://127.0.0.1:9001/api/1/getHTML?padID${padId}; fetch(url) .then((res) res.json()) .then((json) { if (json.code ! 0) throw new Error(json.message); const html json.data.html; const toc []; const re /h([1-6])[^]*(.*?)\/h\1/gi; let m; while ((m re.exec(html)) ! null) { const level Number(m[1]); const text m[2].replace(/[^]/g, ); toc.push({ level, text }); } console.log(JSON.stringify(toc, null, 2)); }) .catch((err) console.error(err));逻辑说明先用 encodeURIComponent 处理 pad 名避免特殊字符打断 URL正则提取 h1 到 h6 的标签和内部文本level 存的是标题级别text 是去掉内部标签后的纯文本。跑完输出的是一个带层级的数组接入站内导航或者生成目录页都很顺手。从那以后我每次装完这类格式类插件都不急着用第一件事是调一遍 getHTML确认数据层真的把格式落进去了。这一步能提前暴露很多导出链路的坑。希望帮到你。本文还有配套的精品资源点击获取