简介:Etherpad 是一款常用于团队协作、在线文档共创的实时编辑器,ep_headings2 是基于 JavaScript 的官方标题插件,为 pad 提供 h1、h2 等不同层级标题,适合需要规范长文结构、提升排版效率的 Etherpad 使用者、维护者及插件开发者。除基础插入功能外,它支持活动标题高亮、复制/粘贴、导入/导出,并内置测试与 lint 检查,由 Etherpad 基金会维护,还配有覆盖多种语言的翻译文件,可直接用于生产环境。资源压缩包共 54 个文件、约 86KB;38 个 json 主要是多语言 locale 文案,js 是核心逻辑,css、ejs 负责编辑栏按钮和界面样式,yml、md 则用于 CI 配置与使用说明,整体结构清晰、扩展友好。目前已有 290 人学习。通过该资源既可以快速部署体验,也可以作为 Etherpad 插件开发范本,研究标题插入、工具栏定制、语言包组织及自动化测试等关键实现,为协同编辑平台扩展文档排版能力提供参考。
1. Etherpad 的「长条文档」困局:ep_headings2 标题插件能救到什么程度
Etherpad 的协作文档写到三十页时,最崩溃的不是观点不合,而是滚轮滑半天找不到「这一节从哪开始」。默认编辑器把每个段落渲染成同一种样式,Ctrl+F 成了唯一的导航工具。ep_headings2 这个标题插件就是为这个场景设计的:它给 Etherpad 编辑区加上 H1-H3 标题按钮,用一小段 JavaScript 把「我这行是标题」变成文档的行属性,渲染时呈现层级,导出时还原成真正的 h1 标签。
它适合不想迁移协作工具、只想给现有 pad 流程补上结构感的团队,也适合对文本结构有执念的写作者。部署它本身不复杂,但插件机制、工具栏配置、样式重写、导出还原这几个环节各有各的坑。下面按我实际部署的顺序,从钩子机制讲起,一路到常见翻车现场和二次开发,每个步骤都给出可复现的命令和参数。
2. 它改的不是编辑器而是文档模型:hook 机制与标题属性链路
2.1 插件不是改源码:ep.json 与客户端钩子
Etherpad 的插件体系和「改 vendor 源码」完全是两条路。官方提供一套 hook 机制,编辑器初始化、每次按键、每行内容渲染、导出 HTML,这些生命周期都留了回调口子。插件要做的,只是把自己的代码挂到对应的钩子上。所以 ep_headings2 的代码量其实很小,难的不是写插件,是理解钩子的时序和参数形状。
解开 ep_headings2 的插件包,根目录必有 ep.json,这就是整个插件与 Etherpad 之间的接线图。按目前主流版本的常见写法,结构是这样:
{ "parts": [ { "name": "ep_headings2", "client_hooks": { "aceEditorCSS": ["ep_headings2/static/css/headings.css"], "aceInitInnerdocbody": "ep_headings2/static/js/hooks.js", "aceAttribsToClasses": "ep_headings2/static/js/hooks.js", "aceCreateDomLine": "ep_headings2/static/js/hooks.js", "aceEditEvent": "ep_headings2/static/js/hooks.js", "acePostWriter": "ep_headings2/static/js/hooks.js" }, "hooks": { "eejsBlock_editorContainer": "ep_headings2/index.js" } } ] }重点看client_hooks这一段。aceEditorCSS注入插件自己的样式表;aceAttribsToClasses把行属性翻译成 CSS class;aceCreateDomLine决定这行内容被包裹成什么标签;aceEditEvent监听编辑动作,比如你点工具栏的 H1 按钮,就是在这里把heading属性写到当前行;aceInitInnerdocbody在编辑器 DOM 初始化时执行一次,用来注册一些启动逻辑。hooks里的eejsBlock_editorContainer是服务端钩子,页面渲染时把标题按钮拼进编辑区容器。
所以「装完插件为什么没有按钮」这个问题,先看 ep.json 里有没有服务端钩子,再看前端文件路径有没有写错。路径写错时按钮区域通常是一片空白,但不报任何错误,查起来比较费劲。
不同 release 的钩子名会有差异。老一点的 ep_headings 可能没有acePostWriter,新版本则依赖它处理行内容的后置修正。如果你下载的包里 ep.json 和这里不一致,以包内实际内容为准。我判断一个标题插件是否成熟有三个标准:aceAttribsToClasses和aceCreateDomLine必须都在、导出 HTML 的钩子必须存在、ep.json 里不能出现指向不存在文件的路径。满足这三点,基本不会出大岔子。
2.2 从属性到样式:标题在每行文本里的完整链路
Etherpad 的底层模型不是 HTML 文档,而是「行 + 行属性」的文本模型。每行文本可以挂一个属性集合,比如heading: "1"。你点工具栏的 H1,本质上是对当前行做一次属性写入,这个属性随 changeset 广播给所有协作者,也随 pad 内容持久化。标题因此不是某种皮肤层的视觉装饰,而是数据的一部分。这也是为什么重新打开 pad 标题还在、其他协作者端也会同步更新。
ep_headings2 的前端 hooks.js 里最核心的代码,可以浓缩为两段 JavaScript 逻辑。第一段把属性映射成 class:
// 行属性 -> DOM class 的映射 function aceAttribsToClasses(hook, context) { if (context.attribs["heading"]) { // 注意拼出来的是 heading1,不是 heading-1 return [{ key: "heading" + context.attribs["heading"] }]; } return []; }第二段在行渲染时用这个 class 决定包什么标签:
// 渲染行时,把 heading class 变成带标题语义的标签 function aceCreateDomLine(hook, context) { const result = { extraOpenTags: "", extraCloseTags: "" }; if (context.cls.indexOf("heading1") !== -1) { result.extraOpenTags = "<h1>"; result.extraCloseTags = "</h1>"; } return result; }第一段里context.attribs是当前行属性对象,heading键对应的值来自属性池,类型是字符串。返回的数组会成为该行 DOM 节点的附加 class,所以标题行的 DOM class 里必然能看到heading1之类的值。第二段里context.cls是编辑器组装完的完整 class 字符串,extraOpenTags和extraCloseTags是渲染的包裹标签。这里写成<h1>,导出路径和复制路径拿到的是语义标签,而不是一串自定义 div。这也是我推荐用它、而不是自己写「加粗换颜色」当标题插件的原因。
链路走通之后,其他协作者端会通过 changeset 自动拿到标题属性,不需要重新加载页面。你在这边把某行改成 H2,对方屏幕上的那一行在几百毫秒内就变成 H2 样式。网络环境一般的时候这个反馈有延迟,那是 Etherpad 同步机制本身的行为,不是插件的锅。
还要提一个容易忽略的边界:Etherpad 的行是「一行一个属性集合」,同一行可能同时存在列表属性和标题属性,class 上会出现bullet和heading1共存。ep_headings2 不处理这种组合冲突。实际协作里我一般要求团队不要对标题行同时套列表缩进,否则大纲层级和视觉缩进会互相误导。这不是 bug,是模型本身的特性。
我自己拿到插件后,第一步不是改样式,而是开浏览器开发者工具,选中一个标题行看它的 DOM class。如果 class 里带heading1,说明属性和前端链路通了;如果只有别的 class,问题大概率出在aceAttribsToClasses的返回格式或属性名拼写。这种检查方式比对着日志猜快得多。
3. 跑通最小可用配置:安装、按钮布局与层级取舍
3.1 标准安装流程与依赖落地
安装本身不复杂,坑集中在依赖。先看代码:
# 在 Etherpad 根目录执行,注意不要在皮肤目录里装 cd /opt/etherpad-lite # 官方推荐走插件安装脚本 ./bin/installPlugin.sh --plugin ep_headings2如果你在本地临时验证,也可以直接npm install ep_headings2。两者的差别在于 installPlugin.sh 会顺带走一遍 Etherpad 的依赖检查,并更新插件缓存列表;npm 直装适合验证目录结构,最后同样要重启节点才能生效。
决定用 npm 安装的话,要关注这些依赖落地问题。ep_headings2 依赖async、jsdom这一类的包,其中 jsdom 在较老版本的 Node 上会触发 node-gyp 编译,编译失败就不会完整装进 node_modules。我建议 Etherpad 节点保持在 Node 14 LTS 或更高版本,安装前后检查node_modules/ep_headings2和node_modules/jsdom是否存在。安装完重启节点,访问/admin/plugins,看到 ep_headings2 的状态是 Active,说明插件已经被正确扫描到。
装好后可以顺手做个冒烟测试:新建 pad,输入正文,点 H1 按钮,看当前行是否变成标题样式,再按两次回车观察后续行是否恢复普通文本。这个操作 30 秒内能验证主链路,值得养成习惯。后面所有排障都要以「新 pad 能复现」为基准,不要拿旧 pad 的缓存状态当结论。
3.2 工具栏按钮配置:三档标题还是六档
ep_headings2 默认提供 H1 到 H6 的能力,但工具栏上按钮太多会挤压真正的编辑空间。我实际部署过的团队里,最终长期存活的配置基本都是 H1-H3。把需要显示的按钮列在 toolbar 的按钮集合里:
{ "toolbar": { "buttons": { "heading1": { "command": "h1", "class": "buttonHeading1" }, "heading2": { "command": "h2", "class": "buttonHeading2" }, "heading3": { "command": "h3", "class": "buttonHeading3" } } } }这个配置的意思是:编辑器工具栏只暴露三个标题按钮,点击后触发h1、h2、h3指令,指令最终在aceEditEvent里写成heading: "1"这样的属性。按钮的class字段控制按钮本身的样式类,一般不需要动。如果某一项被移除,对应按钮就不渲染,但属性层仍然可以写进文档,区别只在 UI 入口。已经存在的 H4-H6 内容不受影响,只是没有入口可点。
标题层级的取舍直接决定协作体验。下面是我实际在用的标准:
| 层级 | class 名 | 典型用途 | 我的建议 |
|---|---|---|---|
| H1 | heading1 | pad 主标题、文章标题 | 保留 |
| H2 | heading2 | 章节标题 | 保留 |
| H3 | heading3 | 小节标题 | 保留 |
| H4-H6 | heading4~6 | 非常深的内容结构 | 隐藏 |
有人会问 H4 为什么不留着备而不用。实际操作里,超过三级的文档在多人协作中很难维持一致性——第一个人把某段设成 H4,第二个人认为它应该归到 H3,整个文档的层级就开始漂移。限制到三档的团队,通常三个月后标题结构仍然可维护;放开六档的团队,大部分文档最后只剩 H1 和 H2。这不算严谨的统计数据,但在多支团队实操里表现稳定。
除了按钮数量,工具栏的空间占用同样值得留意。Etherpad 的工具栏没有折叠分栏,按钮越多,编辑行越矮。我部署过一版开了 H1-H6 的配置,团队反馈里出现频率最高的一句话是「按钮那排太长了」。改回三档后,pad 版面清爽很多。如果你确实需要深层级,建议给团队成员发一份层级规则,并在 pad 开头用 H1 写明「本文档标题仅用到第三层」,这比任何配置都有效。
4. 把样式换成本团队的皮肤:CSS 重写与导出还原
4.1 CSS 重写与 Ace 编辑器内的即时反馈
ep_headings2 自带的标题样式属于「能看但未必符合团队规范」,基本都要重写。样式文件在 static/css/headings.css,但实际生效的选择器要带编辑器容器上下文,否则改了半天没反应:
/* 覆盖 ep_headings2 的标题样式 */ #innerdocbody .heading1 { font-size: 26px; font-weight: 700; border-bottom: 1px solid #d0d7de; margin-top: 18px; } #innerdocbody .heading2 { font-size: 21px; font-weight: 600; margin-top: 14px; } #innerdocbody .heading3 { font-size: 17px; font-weight: 600; color: #1f2328; }#innerdocbody是 Etherpad 编辑区内部容器的 id,加这个限定才能命中编辑器里的标题行。三个选择器分别对应 H1、H2、H3。改完样式后不需要重新编译,但浏览器大概率要硬刷新才会加载新的 CSS,因为静态资源带 hash,缓存的旧文件不会自动失效。
这里有个容易踩的坑:编辑区、只读视图、导出视图是三条不同的渲染通道。有些版本里只读模式没有#innerdocbody这个容器,需要单独为只读端写一套选择器。我自己通常是先改编辑区,再用 pad 的只读链接打开核对一遍,避免一边好看一边返工。
经验之谈:不要用!important打天下。Etherpad 的 CSS 加载顺序和插件注册顺序有关,老老实实把选择器写精确,比一票否决式覆盖更稳定。如果只想调整字号,建议用相对单位,比如1.25em,因为 pad 页面有缩放,绝对像素在投屏时会显得过小或过大。还有一点,标题行不是块级元素,它是行容器里套了一层包裹标签,H1 的 margin 会直接影响行模型计算。margin 改得过大,跨行选择文本时会跳出奇怪的空白,我实测下来 H1 的 margin-top 控制在 0.4em 到 0.8em 之间比较稳。
4.2 导出 HTML 与 PDF 时的标题还原
导出是标题插件最容易掉链子的环节,也最值得提前验收。pad 的 HTML 导出依赖exportHtml这条路径,文档行内容经渲染层生成 DOM,再序列化成 HTML。ep_headings2 的aceCreateDomLine在这里浮现用武之地——把heading1包成<h1>。但我遇到过插件状态正常、编辑区正常、导出结果却是纯文本的怪事,排查后确认是导出端根本没使用aceCreateDomLine返回的包裹标签,而是直接输出了行内容的裸文本。
验收导出结果用一句命令就够:
# 导出 HTML 后检查标题标签是否存在 grep -E '<h[1-3]' exported.html | head -20如果 grep 不到h1到h3,先看导出的 HTML 里行 div 的 class,确认有没有heading1。如果有 class 但没有 h 标签,说明包裹逻辑没有在导出路径生效。解决方法是回到 ep.json 确认导出相关钩子存在,或者看导出模板是否过滤了 extraOpenTags。我再补一句经验:PDF 导出一般是借 headless 浏览器把 HTML 渲染成 PDF,标题视觉上正常,但生成的 PDF 书签里很可能没有对应条目,因为书签依赖真正的 heading 大纲,而不是「长得像标题」的样式。要 PDF 有目录,前提是 HTML 导出阶段就把 h 标签还原对。这个链路我是在部署第二周才发现的,典型的「编辑区看着都对、交付文件全错」。
另外一个容易被当成 bug 的边界是外部内容粘贴。从网页复制一个<h2>章节一</h2>进 pad,Etherpad 的粘贴处理器会剥离大部分富文本格式,标题语义也不会自动转成 heading 属性。ep_headings2 不负责把外部标题转换成内部属性,你需要先粘贴纯文本,再手动选中该行点 H2 按钮。这是 Etherpad 的粘贴安全策略在起作用,不是插件缺陷。团队里如果有人反复踩这个点,把规则写进 pad 首页说明,比改代码有效。
导出模板里还有一个细节:标题行默认不会自动分页,导出 PDF 时经常出现 H1 落在页面底部、正文排到下一页的情况。需要在导出模板的 CSS 里给 heading1 加page-break-after: avoid,让标题与后续内容保持在同一页。这类样式写在导出模板中效果最直接。
5. 避坑:ep_headings2 的五个高频翻车现场
ep_headings2 的安装链路不算长,真正让人血压升高的问题,大多出现在「把它当成一键安装插件」之后。下面五条翻车记录来自真实环境,按现象、原因、解决三段写清楚。
5.1 按钮装了看不见
现象:插件在 /admin/plugins 里是 Active,服务也重启过,但编辑区上方就是没有标题按钮。
原因:多数情况是浏览器缓存了旧的编辑器脚本和样式。Etherpad 重启后静态资源文件名会变,但已经打开的长驻页面还在用旧版本。另一个常见原因是服务端注入按钮的模板块被其他插件覆盖,两个插件同时挤占eejsBlock_editorContainer这个注入点,渲染顺序冲突导致按钮消失。
解决:先开无痕窗口验证,排除缓存因素。无痕窗口下有按钮,回正常窗口硬刷新一次(Ctrl/Cmd + Shift + R)。无痕窗口下也没有按钮,检查 ep.json 里eejsBlock_editorContainer指向的 index.js 是否存在,再看是否有其他插件也注册了同一个钩子。两个插件写同一个钩子时,后加载的往往覆盖先加载的,这种问题查插件的加载顺序比改代码更快。
5.2 标题在编辑器里正常,导出后变纯文本
现象:编辑区里 H1 样式明显,导出的 HTML 却找不到任何<h1>,连heading1class 都没有。
原因:导出路径与编辑路径不完全重合。有些版本导出用的是独立的 exporter hook,没有走aceCreateDomLine,标题属性就没被翻译成标签和 class。
解决:先把导出文件存下来,用 grep 分别检查 class 和 h 标签,两步定位到底断在哪一环。修复方向有两种:补全导出 hook,或者覆盖导出模板,手动把 heading 属性映射到 h 标签。无论用哪种,改完都要新建一个 pad 重新走一遍导出验证,不要拿旧的导出文件判断。
提示:导出验收的标准动作是「先 grep class,再 grep h 标签」。class 有而标签没有,问题在导出模板;class 都没有,问题在 exporter 钩子。
5.3 多人同时改同一行,标题属性互相覆盖
现象:两个协作者几乎同时把各自的段落设成标题,结果一个人的标题生效,另一个人的没了,插件也没有任何报错。
原因:Etherpad 的 changeset 以光标位置为基准,两个并发属性写入落在同一行时,后者在属性池中的写入覆盖了前者。这是 Etherpad 属性合并的固有行为,不是插件逻辑错误。
解决:把它当协作规则处理,而不是代码缺陷。团队内部约定标题行由负责该章节的人单独维护,其他人不要在同一行做标题操作。真被覆盖了就让对方撤销重做,这比在代码里做补偿简单得多。想实现真正自动合并,需要自己写属性分发策略,成本远高于收益,小团队不建议碰。
5.4 安装时 jsdom 编译失败
现象:npm install 卡在 node-gyp rebuild,报错指向 jsdom 的编译步骤,随后安装中断,node_modules 里的 ep_headings2 目录不完整。
原因:ep_headings2 的依赖里有 jsdom,jsdom 在低版本 Node 或缺少编译工具链的环境(比如精简版 CI 镜像)下需要本地编译,一旦编译失败就中断整个安装流程。
解决:升级 Node 到 14 LTS 以上,确认系统里有 python3 和 make 工具。内网离线安装时,先把 npm registry 的依赖缓存整理好,保证 jsdom 相关的包能完整拉取。装完后立刻检查node_modules/ep_headings2目录里有没有完整的 hooks.js,如果只有 package.json 没有源码文件,说明依赖树不完整,需要先修复再重启 Etherpad。
5.5 只读视图里标题变成普通文本
现象:编辑区标题一切正常,但 pads 的只读链接打开后,标题和正文没有任何区别。
原因:只读视图和编辑视图共用文档内容模型,但使用的 CSS 命名空间不同。如果你覆盖样式时只写了#innerdocbody前缀,只读端没有这个容器,标题样式自然丢失。
解决:重写样式时把只读端的场景一起考虑。常见做法是额外加一层只读容器相关的选择器,或者把公共样式抽出来不依赖具体 id。改完之后,每次调整 CSS 都要在三个地方确认:编辑区、只读链接、导出 HTML。这个习惯能避免「改完编辑区忘了只读端」的低级失误。
6. 把标题属性变成你自己的小工具:二次开发与验证技巧
6.1 不点按钮也能读出标题层级
随着对插件机制越来越熟,我逐渐把标题属性当成一个数据接口来用。不点任何按钮,也能把当前 pad 的标题结构完整拉出来。下面这段 JavaScript 可以在 pad 页面的开发者工具里直接跑:
// 在 pad 页面开发者工具里运行,padClient 是当前 pad 客户端对象 function collectHeadings(padClient) { const text = padClient.getText().split("\n"); const list = []; for (let i = 0; i < text.length; i += 1) { const headingLevel = padClient.getAttributeOnLine(i, "heading"); if (headingLevel) { list.push({ line: i + 1, level: headingLevel, text: text[i] }); } } return list; }padClient.getText()拿到的文本行序和属性行序一一对应,getAttributeOnLine的第二个参数传属性名heading,返回"1"、"2"或空值。这个函数在不少场景下比工具栏更好用:一键输出所有标题行、生成 Markdown 大纲、把标题列表直接粘贴进会议纪要。我在团队里跑过一段时间,协作时用它把 pad 的标题提取出来,直接生成周报结构,省掉了很多手工整理的时间。注意行号从 0 开始,输出到表格时要加一,否则对不上原文。
6.2 我把部署验证固化成四步
插件这类资源,最大的风险不是代码不能跑,而是「以为它能跑」。从那以后,我每次部署完标题插件,都强制自己走一遍四步链路:新建 pad,依次在开头打出 H1、H2、H3 三个标题;换一个无痕窗口打开同一个 pad,确认协作者端标题样式同步;导出 HTML,用 grep 检查 h1-h3 标签真实存在;最后对着只读链接检查一遍样式是否与编辑区一致。这四步每次都要完整走通,省掉了后面无数次「标题又没生效」的血泪排查。这已经成了我接任何 Etherpad 插件的默认习惯。希望帮到你。
本文还有配套的精品资源,点击获取