☰
ep_headings2:为Etherpad实现标题层级与语义化导出
2026/10/17 9:17:16 网站建设 项目流程

简介:这是一份面向 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.css
  • ep.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 的require,Etherpad 会通过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 id="ep-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>而不是<p><b>第二章 核心链路</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的提示,只要出现这一行就说明注册成功。

验证标题功能是否生效,我一般做三步:

  1. 在 pad 中输入一行文字,点击下拉框选 H2,看这行文字是否变大加粗。
  2. 把光标移到这行,在浏览器开发者工具里输入window.pad.ace.editorInfo.ace_getAttributeOnSelection('heading'),看返回值是不是h2。
  3. 点击导出 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.log:

exports.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 id="ep-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 和控制台里同时验证一遍,避免只盯着编辑区看格式。希望这次拆解和扩展思路能帮到你,跑通了记得回来分享你的二次开发玩法。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询