ep_headings2:为Etherpad实现标题层级与语义化导出
简介这是一份面向 Etherpad 用户与插件开发者的标题插件源码包。ep_headings2 可在在线协作文档的编辑页中直接应用 h1、h2 等标题样式并具备活动标题高亮、复制粘贴、导入导出等功能在多人实时协作排版场景中尤其实用适合需要增强 Etherpad 编辑能力或研究官方插件机制的中级 JavaScript 开发者。压缩包内含 54 个文件以 JSON 语言包和 JavaScript 核心逻辑为主JSON 部分覆盖中文、英文、法文、德文等多语言翻译JS 文件负责标题按钮与编辑操作另有 YAML 工作流配置、EJS 按钮模板、CSS 样式、Markdown 文档和测试文件完整呈现从界面到导入导出处理的实现路径整体仅 86KB结构紧凑、便于通读与改造。插件由 Etherpad 基金会维护代码规范且有测试覆盖可作为学习插件开发、多语言适配和前端工具栏扩展的参考案例。目前已有 290 人学习下载适合正在维护协作文档站点或希望定制编辑器功能的开发团队。1. 从“全文加粗”到真正的标题层级ep_headings2 解决了 Etherpad 协作里的排版混乱如果你用 Etherpad 写过超过二十页的方案八成遇到过这种尴尬想让某个段落成为一级标题最顺手的操作是选中它点一下加粗再手动把字号调大。每个人交上来的文档标题层级全靠肉眼对齐导出 PDF 时连目录都生成不了。ep_headings2 这个标题插件就是来收拾这个局面的。它给 Etherpad 增加了一组标准的标题下拉菜单支持 H1 到 H6点击即给段落打上结构化的标题标记样式、导出、大纲提取都能跟着走。整套实现基于 JavaScript走的是 Etherpad 的插件钩子机制不需要改 Etherpad 内核装完即用。适合所有把 Etherpad 当团队文档工具的开发者也适合想研究 Etherpad 插件怎么写的同学。2. 项目解剖Etherpad 插件机制与 ep_headings2 的目录分工2.1 先理解钩子Etherpad 给插件留下的扩展口Etherpad 本身是一个 Node.js 应用前端有大块代码是浏览器端 JavaScript后端也有服务端逻辑。它不是把所有功能写成插件而是定义了一堆“钩子”hooks第三方插件通过注册这些钩子在特定时机执行自己的代码。这是理解 ep_headings2 的关键。插件为了实现“给编辑器加标题”这个功能至少要碰四个钩子eejsBlock_editorBarAfter把标题下拉按钮插到编辑器的工具栏后面。aceEditorCSS往编辑器里注入一份自定义 CSS让标题级别有视觉区分。collectContentPre/collectContentPost编辑时同步光标段落的标题属性让标题跟着光标走。exportHtml/getLineHTMLForExport在导出时把标题标记翻译成h1这种带语义的标签。这些钩子的名字和时机多半写在 Etherpad 的插件文档里但你看 ep_headings2 的代码顺序基本就是“注入界面 → 样式渲染 → 内容同步 → 导出语义化”这条链路。参数说明钩子函数一般接收一个hook_name和context对象context里带编辑器实例、当前行号、行属性等。比如aceEditorCSS只需要返回一个 CSS 字符串eejsBlock_editorBarAfter返回 HTML 片段。理解每个钩子拿到的参数是改插件的第一步。2.2 ep_headings2 的文件布局每个文件在替谁干活安装插件后典型的目录结构长这样ep_headings2/ ├── ep.json ├── index.js ├── package.json └── static/ ├── js/ │ └── main.js └── css/ └── headings.cssep.json插件的“身份证”声明了这个插件订阅了哪些钩子、前端资源加载进哪个页面。index.js服务端入口注册那些需要在 Node.js 进程里跑的钩子比如导出时处理 HTML。static/js/main.js浏览器端代码负责工具栏按钮的渲染、点击事件、标题标记的读写。static/css/headings.css标题级别的样式定义H1 ~ H6 的颜色、字号、边框之类的。ep.json里一般会有这样一个片段{ parts: [ { name: ep_headings2, hooks: { eejsBlock_editorBarAfter: ep_headings2/index:eejsBlock_editorBarAfter, aceEditorCSS: ep_headings2/static/js/main:aceEditorCSS, collectContentPre: ep_headings2/static/js/main:collectContentPre, exportHtml: ep_headings2/index:exportHtml }, client_hooks: { aceEditorCSS: ep_headings2/static/js/main:aceEditorCSS, collectContentPre: ep_headings2/static/js/main:collectContentPre, collectContentPost: ep_headings2/static/js/main:collectContentPost } } ] }这段 JSON 说明了插件不是“一个文件把事干完”而是把不同的回调函数分配到不同的运行环境。服务端跑index.js里的函数浏览器端跑static/js/main.js里的函数。前端文件在浏览器里没有 CommonJS 的requireEtherpad 会通过client_hooks把你的函数挂到它的客户端事件流上。我一般拿到一个不认识的 Etherpad 插件第一件事是先翻ep.json看它挂在哪几个钩子上基本能猜到功能是怎么实现的。ep_headings2 的钩子数量不算多覆盖面却很完整从 UI 到导出都顾到了。2.3 配置项哪些行为可以不用改代码就调整package.json里有个ep_headings2字段用来存放插件的默认设置。常见做法是定义支持的标题级别和默认是否显示下拉菜单{ name: ep_headings2, version: 1.0.0, description: Headings plugin for Etherpad, dependencies: { etherpad: 1.8.0 }, ep_headings2: { levels: [h1, h2, h3, h4], defaultLevel: h2, showOnToolbar: true } }levels数组决定下拉菜单里出现哪些标题级别删掉h4就不会出现四级标题。defaultLevel是当用户没有选择级别时默认给段落套用的级别通常设为h2因为文档的一级标题往往已经由 pad 标题承担了。showOnToolbar是开关有些团队希望只在 slash 命令里用标题不想要工具栏按钮这时可以设为false。具体读取配置的代码在index.js里往往长这样exports.aceEditorCSS function (hookName, context) { const settings require(../package.json)[ep_headings2] || {}; const levels settings.levels || [h1, h2, h3]; // 为每一个标题级别生成一条 CSS 规则 return levels.map((level) { const size { h1: 2em, h2: 1.5em, h3: 1.17em, h4: 1em }[level]; return .heading-${level} { font-size: ${size}; font-weight: bold; }; }).join(\n); };这里通过读取package.json里的插件配置来决定生成哪些样式好处是用户改配置后不用重新打包前端资源只需要重启 Etherpad 服务就能生效。注意aceEditorCSS返回的是一个 CSS 字符串Etherpad 会把它拼接到编辑区的样式表里。在真实调试中你还会遇到一个概念Etherpad 把编辑器内容按行存储每一行有一组“行属性”line attributes。标题标记本质上是给某一整行打上一个特殊的行属性比如heading: h2。ep_headings2 的collectContentPre就是往行属性里写标记aceEditorCSS根据这个标记渲染样式。理解了这个后面看代码就顺了。还要提一个点Etherpad 的插件钩子分hooks服务端和client_hooks客户端。aceEditorCSS既可以在服务端注册也可以在客户端注册。ep_headings2 选择两个都注册是因为服务端导出时也需要知道标题级别对应的 CSS 语义。但这种双注册不是必须的具体看你要在哪个环节让样式生效。3. 核心模块拆解从点击标题按钮到样式生效的完整链路3.1 工具栏按钮注入eejsBlock_editorBarAfter 里做了什么Etherpad 的编辑区顶部有一条工具栏它通过eejsBlock_editorBarAfter这个钩子允许第三方在工具条末尾追加 DOM。ep_headings2 在这里返回一段包含select或按钮组的 HTML。典型实现// 服务端 index.js exports.eejsBlock_editorBarAfter function (hookName, context) { return [ select idep-headings2-select>// static/js/main.js window.onload function () { const select document.getElementById(ep-headings2-select); if (!select) return; select.addEventListener(change, function () { const level this.value; if (!level) return; // 调用 Etherpad 的编辑器 API 给当前段落设置标题属性 const pad window.pad; if (pad pad.ace) { pad.ace.setAttributeOnSelection(heading, level); } }); };这里pad.ace.setAttributeOnSelection是 Etherpad 客户端内部 API常见写法是pad.ace.callWithAce再操作rep不同版本 API 有差异。如果直接调用不存在的方法点击下拉框就不会有任何反应这是新手最容易踩的坑。3.2 aceEditorCSS 与样式映射标题是怎么从纯文本变大变粗的标题与普通段落本质区别在行属性而不是直接改 DOM 样式。Etherpad 编辑器基于 CodeMirror 或自研的行渲染每行内容根据行属性自动套用 CSS。ep_headings2 的aceEditorCSS钩子就是提供这些 CSS 规则。exports.aceEditorCSS function () { return [ .heading-h1 { font-size: 2em; font-weight: bold; }, .heading-h2 { font-size: 1.5em; font-weight: 600; }, .heading-h3 { font-size: 1.17em; font-weight: 600; }, .heading-h4 { font-size: 1em; font-style: italic; } ].join(\n); };这里的类名.heading-h2是和行属性里的heading对应的。也就是说点在那一行的heading属性值为h2时Etherpad 会给这行的外层 DOM 加上heading-h2类名浏览器再根据这条 CSS 渲染。这种做法的优点是把内容和样式解耦二次开发时可以只改 CSS 换主题不用碰 JavaScript 逻辑。如果你希望标题前面出现小圆点、下划线或左边框也只要在这个钩子里追加 CSS。我一般会在aceEditorCSS里顺手把margin和line-height一起调不然 H1 看着和加粗没区别。3.3 collectContentPre让标题标记随光标和复制而保留工具栏选择好标题后如果用户把那段文字复制到 pad 的其他位置标题属性能不能跟着走取决于collectContentPre的实现。这个钩子在每次行内容变化时被调用用于收集和写回行属性。exports.collectContentPre function (hookName, context) { const heading context.cls || ; const level heading.match(/heading-(h[1-6])/); if (level) { context.cc.doAttrib(context.state, heading:${level[1]}); } };context.cls是当前行已有的样式类名context.cc.doAttrib是把某个属性写进文档的请求头。这段代码的意思是如果这一行已经带有heading-h2之类的类名那么就把对应的heading: h2属性写进行的状态里。参数说明context.state是行属性状态对象多次调用doAttrib会叠加或覆盖属性context.cls可能是多个类名的空格拼接所以用正则匹配而不是直接比较。在 Chrome DevTools 里查看编辑器 DOM你会看到标题行有heading-h2类名这正是这段代码匹配的目标。没写collectContentPre的话标题行在复制、删除、撤销时属性很容易丢。很多 Etherpad 文档协作的格式错乱都是因为“视觉上是标题属性上没有标题”。3.4 导出环节exportHtml 钩子里怎么把标题翻译成语义化标签导出 PDF 或 Word 时Etherpad 默认会按纯文本处理行。如果标题属性还留在行属性里导出的 HTML 不会自动变成h1。ep_headings2 需要拦截导出钩子把heading属性翻译成对应标签。// index.js exports.exportHtml function (hookName, context) { const heading context.attribLine.attribs.filter(a a[0] heading); if (heading.length) { const level heading[0][1]; context.lineContent ${level}${context.lineContent}/${level}; } return context.lineContent; };context.attribLine.attribs是一个数组元素是[key, value]对。取出heading键对应的值比如h2然后把这一行包进h2标签。这样导出的 HTML 才能在 Word 里自动识别出标题结构生成目录时才不会乱。我们拿到的导出结果HTML 结构是h2第二章 核心链路/h2而不是pb第二章 核心链路/b/p这就是导出钩子的意义。你在改代码时务必同时查看导出 HTML 和线上文字的格式两个表现一致才算改对。整个链路串起来就是用户点下拉选 H2 →main.js把heading: h2写进光标所在行的行属性 → 编辑器视觉层发现该行有heading-h2类名 →aceEditorCSS里的样式生效 → 用户复制或导出时collectContentPre/exportHtml再把heading属性还原为语义标签。每一段都基于 JavaScript 对象和字符串操作没有黑魔法但缺一个环节标题就“看起来是标题实际上是文本”。4. 跑起来再说本地安装、配置和验证4.1 最小安装路径npm 安装、启动、验证Etherpad 插件本质是一个 npm 包安装方式非常直接。假设你已经有一套可以运行的 Etherpad 服务源码目录在etherpad/下cd /path/to/etherpad npm install ep_headings2安装完成后需要重启 Etherpad 进程。Etherpad 的插件系统会在启动时扫描node_modules下所有带ep_前缀的包读取里面的ep.json注册钩子。所以安装完不重启插件不会被加载。重启后打开任意 pad编辑区顶部工具栏右侧应该出现标题下拉框。如果没出现先看日志里有没有插件加载报错。Etherpad 启动日志中会打印类似Found plugin: ep_headings2的提示只要出现这一行就说明注册成功。验证标题功能是否生效我一般做三步在 pad 中输入一行文字点击下拉框选 H2看这行文字是否变大加粗。把光标移到这行在浏览器开发者工具里输入window.pad.ace.editorInfo.ace_getAttributeOnSelection(heading)看返回值是不是h2。点击导出 HTML查看h2标签是否出现。这三步分别验证 UI、属性、导出三个环节任何一步失败都能快速定位问题在哪个钩子。4.2 自定义标题级别改 levels 配置而不是改代码如果你只需要 H1、H2、H3不想要 H4 以下的标题打开node_modules/ep_headings2/package.json找到ep_headings2配置块ep_headings2: { levels: [h1, h2, h3], defaultLevel: h2 }改完保存重启 Etherpad。不需要清缓存因为配置在服务端读取重启后新的levels会同时影响下拉菜单和 CSS。注意不要删掉defaultLevel里指定的级别否则初始化时会拿不到默认值可能会导致控制台报错。也可以直接改index.js里的aceEditorCSS临时加一个.heading-h5 { font-size: 1em; color: #666; }来验证样式规则是否被正确加载。不过这种改法会在下次插件升级时被覆盖我一般只在调试时用。4.3 修改样式文件换一套团队自己的标题主题标题样式有三种改法按持久性排序在管理员设置面板的自定义 CSS 里覆盖Etherpad 支持 admin 设置里的custom_css字段把.heading-h2 { border-bottom: 1px solid #ccc; }写进去不用动插件。直接改插件的static/css/headings.css改动跟随插件升级会丢。在aceEditorCSS钩子里追加样式改动跟随插件代码升级也会丢但可以配合自定义配置。前两种方法各有取舍。团队临时想给标题加个红色下划线用管理员自定义 CSS 最合适想彻底设计一套标题主题还得把aceEditorCSS里的规则全部重写。注意aceEditorCSS返回的 CSS 会插入到 Etherpad 编辑区的内联样式表里它的优先级高于外部custom_css所以如果你想用管理员设置覆盖插件样式需要写!important或者把选择器写得更具体。/* Etherpad 管理员自定义 CSS 里的覆盖写法 */ .ace-line .heading-h2 { font-size: 1.8em !important; border-bottom: 1px solid #d0d7de; padding-bottom: 4px; }这里用.ace-line前缀提升优先级!important则确保覆盖来自插件钩子的样式。改完后回到 pad 刷新页面标题样式应该立刻变化因为 CSS 是前端加载的通常不需要重启服务。Etherpad 也可以把插件配置写到settings.json的ep_headings2字段而不是package.json。实际很多插件支持两种方式。代码里读配置时通常用ep.json读取器去合并。我做项目时更喜欢把可调参数放在settings.json里因为升级插件不会覆盖配置文件。ep_headings2 如果同时支持这两种读取说明它已经考虑到了生产环境的需求。检查你的版本支持哪种只需要看源码里getPluginSettings之类的函数。5. 常见问题与排查四类真实报错和玄学现场5.1 问题一插件装完工具栏上死活没有下拉框现象重启服务后Pad 页面正常但工具栏右侧空荡荡日志里也没有报错。原因最常见的是eejsBlock_editorBarAfter返回的 HTML 被 Etherpad 的 HTML 转义机制过滤了比如某些字符写成了 HTML 实体或者返回值不是字符串数组。另外如果你安装的 Etherpad 版本较新eejsBlock_editorBarAfter钩子的上下文里可能要求返回对象{html: }而不是裸字符串。解决先用一个简单测试确认钩子是否被调用。在index.js的对应函数里加一条console.logexports.eejsBlock_editorBarAfter function (hookName, context) { console.log(ep_headings2 toolbar hook called); return [{ html: select/select }]; };重启后日志出现这行说明钩子触发了。再看返回格式是否匹配当前 Etherpad 版本。多数情况下把返回值改成对象结构{html: ...}就能解决问题。5.2 问题二标题在导出 PDF 时变回了纯文本现象pad 里 H2 显示正常但导出 PDF 或 Word 后标题没有任何格式和正文混在一起。原因很多团队导出 PDF 并不是走 Etherpad 的 exportHtml而是经过第三方工具或反向代理先拿到 HTML。如果中介工具做的是“去掉所有样式”处理那即使exportHtml输出了h2也会被剥掉。另一个常见原因是getLineHTMLForExport钩子没有实现只实现了exportHtml而 Etherpad 某些导出路径走的是前者。解决先打开导出的 HTML 源码搜索h2是否存在。如果不存在说明导出钩子没生效检查ep.json里是否注册了getLineHTMLForExport或exportHtml。如果存在h2但 PDF 无样式那是导出工具配置问题和插件无关。可以换用浏览器自带打印来验证打开导出的 HTML 文件按打印转 PDF标题样式应该还在。5.3 问题三标题下拉框被另一个插件挤到了下一行现象工具栏按钮排得很乱标题下拉框单独跑到第二行或者被别的插件按钮挡住。原因Etherpad 的工具栏容器是固定高度多个插件通过eejsBlock_editorBarAfter追加元素内容总宽度超过容器宽度就会换行或溢出。这本质是工具栏布局冲突不是 ep_headings2 自身问题。解决给下拉框加上紧凑的样式让它和旁边的按钮宽度一致。常见做法是在aceEditorCSS或插件自己的 CSS 文件里设置固定宽度和浮动#ep-headings2-select { width: 80px; height: 30px; float: left; margin-right: 4px; font-size: 13px; }如果还挤可以考虑和相邻插件的开发者协调把各自的图标缩小。项目里我一般会在工具栏这样的有限空间里优先使用图标而不是文字下拉框可以改成只有一个按钮加弹出菜单但这属于二次开发的范畴了。5.4 问题四改了代码之后样式不刷新怎么看都还是旧样式现象修改了aceEditorCSS返回的 CSS 字符串重启了服务也强刷了浏览器但标题样式没有变化。原因Etherpad 会把客户端静态资源打包缓存修改static/js/main.js或static/css/headings.css后浏览器可能还在用旧的 bundle。另外有些反向代理服务器对 JS/CSS 做了长缓存设置了Cache-Control: max-age也会导致老资源一直命中。解决先在浏览器开发者工具里勾选 Disable cache硬刷新。如果还没变化检查 Etherpad 是否开启了minify。开发阶段可以在settings.json里把minify: false让 Etherpad 输出未压缩源码同时每次修改都需要重启 Etherpad 才能重新打包。生产环境可以通过在 URL 后加?v时间戳来避开缓存但更好的办法是让插件版本号变化Etherpad 的资源 URL 会自动带上版本。避坑章节说的都是我在真实项目里遇到过的场景前两个偏插件逻辑后两个偏集成环境。排查的时候不要只看插件本身还要看它嵌入的宿主环境。6. 二次开发把 ep_headings2 扩成大纲导出工具标题是文档结构的骨架ep_headings2 只负责给线上文档打骨架。更常见的需求是把这副骨架拿出来生成大纲目录甚至汇成一篇独立的文档。这一章我会带你把 ep_headings2 扩展成一个“导出大纲”功能。6.1 先从 pad 里把标题数据捞出来修改static/js/main.js加一个遍历 pad 内容的方法。Etherpad 的pad.ace提供了getDocument或类似方法来访问行属性。我们可以用attribs找到所有行中带有heading:属性的行。function collectOutline() { const pad window.pad; if (!pad) return []; const document pad.ace.getDocument(); const lines []; document.forEachLine(function (lineText, lineAttrs) { const heading lineAttrs.attribs.find(attr attr[0] heading); if (heading) { lines.push({ level: heading[1], text: lineText }); } }); return lines; }遍历结果是一个数组元素是{level: h2, text: 第三章 核心链路}。这样我们就把标题行从所有行里抽出来了。实际接口可能因 Etherpad 版本而异但只要拿到行级属性思路不变。如果你只是想快速验证也可以在控制台输出 JSON再决定怎么渲染成目录。6.2 给工具栏加一个「导出大纲」按钮有了数据下一步就是输出。可以拼接一个 HTML 字符串生成新窗口打印也可以复制到剪贴板。最简单的是弹出一个模态框展示大纲。在eejsBlock_editorBarAfter返回的 HTML 里追加一个按钮button idep-headings2-outline-btn大纲/button然后在main.js里绑定点击const btn document.getElementById(ep-headings2-outline-btn); btn.addEventListener(click, function () { const outline collectOutline(); const html outline.map(item { const indent { h1: 0, h2: 1, h3: 2, h4: 3 }[item.level] || 0; return ${ .repeat(indent)}${item.text}; }).join(\n); alert(html); });这段代码把大纲按层级缩进后显示出来。注意collectOutline依赖的是行属性heading而这一属性正是 ep_headings2 的collectContentPre写入的所以你不需要去解析 DOM 类名也不需要在导出时二次判断这正说明插件把数据模型建得比较干净。每当我需要给这类插件扩展新功能我都会走一遍同样的流程先确认行属性在哪写入、再确认我怎么读取、最后确认如何呈现。ep_headings2 的结构让这三点清楚分开所以二次开发成本不高。从那以后我每次改完 Etherpad 插件都会强制自己在导出的 HTML 和控制台里同时验证一遍避免只盯着编辑区看格式。希望这次拆解和扩展思路能帮到你跑通了记得回来分享你的二次开发玩法。本文还有配套的精品资源点击获取