简介:Etherpad 是多人实时协作的在线文本编辑器,ep_headings2 由 Etherpad 基金会维护,是一套面向前端开发者与 Etherpad 管理员的标题增强插件,用于在协作文档中快速应用 h1~h3 等层级标题,有效解决编辑器中标题样式分散、难以统一管理的问题。插件覆盖标题的导入导出、复制粘贴、多语言 i18n,以及活动标题高亮显示等功能,并带有测试用例与 lint 检查,代码规范度高。压缩包共 54 个文件,大小约 86KB:json 文件承担配置与多语言翻译包,js 文件是核心逻辑,yml 驱动自动化测试流程,css 与 ejs 则分别处理样式和编辑器按钮模板,整体目录结构清晰,适合团队二次开发或直接部署;已有 290 人学习浏览。通过这份源码,可以完整看到 Etherpad 插件从注册按钮、挂载编辑器工具栏到处理文档标题状态的实现路径;对需要为协作平台扩展类似编辑能力的开发者而言,这一套含国际化与测试覆盖的范例值得直接参考。
1. ep_headings2 是什么:Etherpad 协作编辑里那个总让人抓狂的标题问题
用过 Etherpad 的人大多有这种体验:一群人同时在线改一份文档,内容越写越长,但所有人都在用加大加粗的字假装标题。有人把字号调成 18,有人改成 20,合作三天以后,文档结构基本靠肉眼猜。ep_headings2 就是干这个的——它让 Etherpad 像正经编辑器一样有真正的标题层级:一级标题、二级标题、三级标题,段落属性能被机器识别,而不是仅仅看起来像标题。解决了两个核心痛点:一是协作时标题样式统一,二是导出 HTML 或再加工时,结构信息不再丢失。适合所有用 Etherpad 搭知识库、写团队文档、甚至做在线出版流程的人。
2. 装到能用一把梭:ep_headings2 的最小闭环与三个隐藏按钮
2.1 安装路径与启动顺序:为什么装完还要手动挪目录
目前拿 ep_headings2 最常见的方式是直接走 Etherpad 的插件安装流程。你需要在运行 Etherpad 的服务器上,找到 Etherpad 的根目录,执行标准的 npm 安装命令。但有一个细节容易被忽略:Etherpad 插件有两种挂载方式,一种是全局安装再软链到 node_modules,另一种是直接把插件目录放进 plugins 目录。ep_headings2 这个插件由于要挂钩编辑器底层,我一般会放弃全局安装,直接把它放进 plugins 目录,理由后面避坑章节会展开。
安装完成后重点不是急着重启,而是先检查etherpad/src/package.json里是否已经带上这个依赖,以及settings.json的toolbar配置中有没有出现标题相关的按钮组。工具栏按钮是动态渲染的,如果按钮没出现,九成不是没装上,而是工具栏配置里没有挂载按钮组。
下面是常见的最小部署流程,以 Linux 服务器加普通用户权限为例:
cd /opt/etherpad # 假设 Etherpad 装在这里 ./bin/installPlugin.js --plugin ep_headings2 2>&1 | tee install.log grep -i "warning" install.log || echo "无警告信息"第一行命令依赖 Etherpad 自带的.bin/installPlugin.js脚本,这个脚本会做依赖版本匹配与软链,比手动npm install更稳。第二行是建议你养成的好习惯:安装输出里若出现peer dependency不匹配的警告,不要继续往后走,先解决版本冲突,否则后面按钮渲染会翻车。
装完以后检查引用是否被正确载入:
node -e "const p=require('ep_headings2/package.json'); console.log(p.version)"这一步适合用来确认插件确实被识别,而不是躺在磁盘角落吃灰。如果输出版本号,说明内网安装成功了一半,剩下的一半在于 Etherpad 是否真正加载了它。重启 Etherpad 服务后,在浏览器地址栏访问你的 pad 页面,看工具栏区域是否多出段落格式按钮。
2.2 工具栏按钮配置:把标题按钮从黑匣子里揪出来
Etherpad 的工具栏是模块化的,很多插件装了以后并不会有默认按钮让你直接看见。ep_headings2 默认提供了三个按钮:标题1、标题2、标题3,分别对应三级标题。但如果你用的是 3.x 以上的 Etherpad,可能有部分版本默认配置里压根没加这几个按钮,需要在settings.json的toolbar字段里显式加上。
这里有个参数说明:toolbar是按组定义的,每组之间用|隔开,组内按钮用,隔开。把标题按钮放在加粗、斜体那一组后面,用户的视觉习惯最容易接受。下面给一个常用的配置片段:
{ "toolbar": { "buttons": [ { "name": "ep_headings2", "group": "formatting", "items": [ { "name": "heading1", "title": "一级标题" }, { "name": "heading2", "title": "二级标题" }, { "name": "heading3", "title": "三级标题" } ] } ] } }注意这个items里的name不是随便起的,它对应 ep_headings2 插件内部定义的按钮名称。不同版本的插件内部名称有差异,装好以后我建议直接打开浏览器的开发者工具,定位到工具栏里的按钮元素,看它的>// 伪代码:ep_headings2 的核心处理思路 exports.aceEditEvent = function (context, cs) { const event = context.event; if (event.type === 'click' && event.target && event.target.getAttribute('data-key') === 'heading1') { const rep = context.rep; const line = rep.selStart?.[0] ?? 0; if (line !== undefined) { context.documentAttributeManager.setAttributeOnLine(line, 'heading', '1'); } } };
这段伪代码可以帮你看懂它的设计思路:首先判断用户点击的是不是标题按钮,然后拿到当前选区的行号,最后写入heading=1的行属性。真实实现会更精细,比如要处理选区跨多行的情况,以及点击两次同一按钮时应该取消标题。
3.2 样式驱动的利弊:为什么标题影响排版而非字体
需要强调一个很容易混淆的点:ep_headings2 不会直接把段落的font-size改成 2em,也不会在文字外面包一层h1标签。它只是写入了结构化属性,然后靠 CSS 对带这些属性的行做样式匹配。这种解耦的好处是,标题的视觉样式可以下放到主题层,不同团队可以给heading1定义不同的配色、字号、边距。
常见的做法是在 Etherpad 的content.css末尾追加如下规则:
#innerdocbody .ace-line[data-heading="1"] { font-size: 2em; font-weight: bold; border-bottom: 2px solid #2c3e50; margin-top: 1.2em; } #innerdocbody .ace-line[data-heading="2"] { font-size: 1.5em; font-weight: 600; color: #34495e; }注意>{ "ep_headings2": { "enable_nested": true, "button_order": ["heading1", "heading2", "heading3"] } }
参数的细则可以这么说:enable_nested打开后允许在标题下面继续用更低级别标题,自然形成层级;关闭后所有标题级别相互独立,更像“大字”而不是“章节”。对于长文档场景,务必打开;对于短文档,关掉反而更省心。
版本坑这里先打个预防针:Etherpad 的小版本升级,尤其 1.8 到 1.9、1.9 到 2.x 这类跨度,可能会让行属性模型从line索引改成row索引。如果插件更新不及时,轻则按钮无效,重则编辑器打开就白屏。所以升级 Etherpad 前,先查 ep_headings2 有没有对应版本的 release。
4. 把标题变成可用的结构:从工具栏样式到文档导航
4.1 标题编号与目录生成:你要的不只是变大字
工具标题按钮只是第一步,大多数人真正想要的是文档左侧目录、标题自动编号、导出时能生成 PDF 的层级目录。这要依靠额外的插件或脚本,与 ep_headings2 的属性配合。常用的是ep_headings2搭配ep_page_view或者ep_toc之类插件。
如果你不希望引入过多插件,我提供一个自写的简单目录生成思路:在 Etherpad 的客户端aceEditEvent里监听heading行属性的变化,收集所有带 heading 属性的行号与标题文本,然后渲染成一个固定浮层。这本质上是一次二次开发,但工作量不大,熟手一小时就能搞定。
// 伪代码:监听标题变化并输出目录结构 function collectHeadings() { const lines = document.querySelectorAll('#innerdocbody .ace-line'); const toc = []; lines.forEach((lineEl, idx) => { const hLevel = lineEl.getAttribute('data-heading'); if (hLevel) { toc.push({ level: parseInt(hLevel, 10), text: lineEl.innerText.slice(0, 30), line: idx }); } }); return toc; }这里的代价是每次编辑都会重新扫描整个文档,假如你的 pad 有几百行,性能还扛得住;如果文档上万行,就不建议这样做了。更好的方式是维护一份标题索引对象,在每次aceEditEvent时只做增删改,不要全量重建。
4.2 导出 HTML 后标题去哪了:给后端接上结构信号
使用 ep_headings2 的另一大价值在于,导出 HTML 时标题结构还留在 DOM 里。常见的做法是直接在 Etherpad 的/p/:pad/export/html端点拿成品 HTML,再用正则或遍历器提取h1或自定义标签。比如写一个简单的 Python 脚本,用 BeautifulSoup 把标题捞出来:
from bs4 import BeautifulSoup with open('exported.html', 'r') as f: soup = BeautifulSoup(f.read(), 'html.parser') for level in range(1, 4): tag = f'h{level}' for h in soup.find_all(tag): print(' ' * (level - 1), h.get_text(strip=True))这段脚本输出的就是文档的大纲。有了这个结构,你可以继续做成 PDF 书签、知识库侧边栏,或者直接导给生成式 AI 喂上下文。导出的 HTML 标签到底是h1还是<div>#innerdocbody .ace-line[data-heading], #innerdocbody .ace-line[data-heading] * { all: revert; }
上面的 CSS 是我在多数项目里会先往样式表塞的一段“后悔药”。等新样式写好了,再把这一段摘掉。这种“先清场、后布置新家具”的思路在多个 Etherpad 风格类插件里都通用。
5. ep_headings2 避坑手册:五个高频炸点与定位手法
5.1 按钮消失了:不是插件没装上,而是工具栏配置没挂载
现象:插件安装成功,重启也执行了,但 pad 界面看不出任何变化,工具栏样式跟裸奔一样。
原因:分离的概率最大。Etherpad 的工具栏插件按钮名称与插件代码内部注册的按钮名称不一致,或者settings.json里有个别字段名写成了heading1但真实名称是head1——这类名称差异经常在版本升级后出现,不同发行版命名风格不一样。
解决:打开 Etherpad 源码里node_modules/ep_headings2/static/js/main.js或者对应客户端入口文件,搜索toolbar或button关键字,找出插件自己注册的按钮 key,然后同步到settings.json的工具栏配置里。别相信 README,信代码。若你连代码都翻不到,直接在 pad 页面地址栏执行 JavaScript,把工具栏里的<button>元素全部打印出来,逐一比对title属性。
5.2 标题标记写上了,但样式没变化:CSS 优先级被覆盖
现象:检查 DOM 时能看到heading1属性,但标题显示效果和正文一模一样。
原因:Etherpad 编辑器内部有一套默认的行样式规则,部分主题里会把所有非文本样式都做了 reset。这时插件写入的属性存在,但没有任何选择器去匹配它。同时#innerdocbody这个前缀的选择器权重已经很高,普通类选择器覆盖不了。
解决:不要用.ace-line[data-heading],换成从#innerdocbody开始的多重复合选择器,同时加!important作为最后一根救命稻草:
#innerdocbody .ace-line[data-heading="1"] { font-size: 2em !important; font-weight: bold !important; }如果加了!important还不生效,打开开发者工具,看看这个元素实际命中哪一条样式规则,再用“高优先选择器 + 更高权重”的思路改。
5.3 导出 HTML 后标题变成光秃秃的文本:导出器不认自定义属性
现象:pad 里看一切正常,导出 HTML 后标题标签丢失,全是满满一片div或p。
原因:Etherpad 的默认导出逻辑只识别官方的行属性(bold、italic等),插件自定义属性要被导出成h1标签,必须有对应的导出钩子。版本越老的 Etherpad,对自定义属性的导出支持越差。
解决:导出前用自定义脚本把>import re with open('raw.html', 'r') as f: html = f.read() html = re.sub(r'<div[^>]*data-heading="1"[^>]*>(.*?)</div>', r'<h1>\1</h1>', html, flags=re.S)
这不算优雅,但确实是我用过最省事的招。如果想每次都能自动导出标准 HTML,就需要写一个小的 Etherpad 导出插件,把heading行属性提前翻译成h1或h2。
5.4 多人同时点标题按钮,行属性互相覆盖
现象:两名协作者同时选中不同的段落、同时设置标题,结果整篇文档的多个段落都变成同一个标题级别。
原因:Etherpad 的documentAttributeManager在处理并发属性写入时,默认最后写入的人获胜,没有按行合并再写回。如果两个操作倒腾的 attr 键相同,很容易属性错乱。
解决:不要靠人盯人,而是在团队规范里限定“同一段落不要多人同时操作标题”。真正技术侧的解法是把 pad 的版本升级到支持行级冲突合并的版本,或者给 ep_headings2 打一个补丁,把.setAttributeOnLine改成先cloneAttribs,再setAttributeOnLine,避免填写整行属性时把并发上下文污染了。
5.5 编辑器直接白屏:插件与 Etherpad 主版本不兼容
现象:重启服务后,所有 pad 页面全部白屏,控制台各种 JS 报错。
原因:可能是 ep_headings2 的客户端代码与你当前 Etherpad 版本里的require模块字段不一致,也可能插件里写死了某一个已删除的class接口。总之,这是典型的版本地雷。
解决:用 git 回滚 Etherpad 到上一个版本,确认是插件问题,然后等插件更新,或在插件package.json里用手动版本依赖钉住版本。方法是在 Etherpad 根目录的package.json中显式声明ep_headings2的版本,例如:
"dependencies": { "ep_headings2": "0.1.x" }这样至少能保证锁住已兼容的版本,避免某次npm install自动升到不兼容版。
6. 验证标题功能的三板斧:从属性检测到自动回归
有些同事觉得标题设好以后就完事了,其实还要做一遍系统性验证:一是前端用户操作链路,二是后端导出的结构完整性,三是并发场景稳定性。我的核心方式是写一个 Node.js 脚本,通过 Etherpad 的 HTTP API 创建 pad、调用真实按钮接口来模拟点击。虽然按钮点击是前端行为,但脚本可以直接操作数据库层面模拟设置标题属性,再读回来对比。
const http = require('http'); function setHeading(padId, lineNum, level) { const body = JSON.stringify({ apikey: 'your-api-key', padID: padId, line: lineNum, heading: String(level) }); const req = http.request({ host: 'localhost', port: 9001, path: '/api/1/setHeading?padID=' + padId + '&line=' + lineNum + '&heading=' + level, method: 'POST' }, (res) => { console.log('status', res.statusCode); }); req.write(body); req.end(); } setHeading('test-pad-1', 3, 1);这一步脚本的思路就是:用 API 强行写入标题属性,然后拉取 pad 的 HTML 内容,检查该行周围有没有对应的heading标记。注意,Etherpad HTTP API 原版是没有setHeading这个端点的,你必须自己是插件开发者才能加。我用这种写法是为了强调:验证时不妨自己写一个测试端点,把标题设置从 UI 层与数据层做解耦。
回归验证更贴近日常操作的方式是用浏览器自动化工具(Puppeteer 或 Playwright),模拟用户点按钮、选中段落、取消标题、重新设置标题的系列动作。我一般会跑这样的断言:第一次点heading1,段落属性包含heading=1;再点同一按钮,属性清空;再次点heading2,属性变为heading=2。
这个流程是用来防止插件在将来某次 Etherpad 升级后“悄悄失效”的。很多插件在升级后只是按钮不出现,不会报错,只有自动化回归才抓得出来。我习惯把它挂在 CI 的post-deploy流程里,每次 Etherpad 发版后自动跑一遍,翻车了第一时间能收到邮件,不用等同事手滑才发现。
说回标题这个事本身,我接过的 Etherpad 项目里,凡是没用结构化标题插件的,最后做文档迁移、做知识库导出、做 PDF 书签导航时全都后悔过。结构化的标题属性就是现代文档的骨架,前期多花十分钟配上 ep_headings2,后面省的时间是成倍的。希望这篇能帮你把它跑顺,也顺手避掉那些我把头撞破才明白的坑。
本文还有配套的精品资源,点击获取