简介:ep_headings2是一款面向Etherpad协作编辑平台的标题插件,适合需要在长文档中快速设置h1、h2等层级标题的团队或个人用户。该插件在JavaScript基础上实现,支持测试覆盖率检查、代码规范校验、多语言翻译、文档导入导出、复制粘贴以及当前活动标题高亮显示,可显著提升多人协同编辑时的排版效率与结构可读性。压缩包中总计54个文件,体积仅86KB,主要由json格式的国际化语言包、js核心逻辑、yml项目配置、css样式与ejs模板组成,其中语言包覆盖数十个地区,能够满足不同语言环境下的使用需求。该资源已有290人学习浏览。获取此包后,可得到完整插件源码、测试用例、许可证文件以及项目配置文件,既可直接在Etherpad中安装启用,也能作为前端插件开发的参考范例,对于需要定制文档标题交互或深入了解Etherpad扩展机制的读者具有一定参考价值。
1. 项目定位:Etherpad 里最顺手的标题下拉插件
如果你的团队和我一样,把 Etherpad 当会议纪要、课程笔记或者联调文档的共享白板,大概率会撞上一个痛点:默认工具栏只有一个「标题」按钮,切来切去只能到 H1,想给一篇三千字的文档分出 H2/H3 层级,得反复手改。ep_headings2 解决的就是这件事——它在 Etherpad 编辑栏左侧插入一个标题下拉框,H1 到 H6 一键落位,选中哪级,光标所在的行就变成哪级标题。
项目本身是典型的 Etherpad 插件形态:纯 JavaScript 实现、无后端独立服务、通过 hooks 机制挂进编辑器。它适合两类人用:一类是 Etherpad 的部署者,想给团队一个更顺手的排版工具;另一类是准备做 Etherpad 插件二次开发的工程师,这个插件麻雀虽小,但把「编辑栏注入、编辑器属性操作、CSS 作用域隔离」这三件事串得很完整,是很好的入门样本。下面我按拆包顺序,把目录、核心代码、部署坑和改造点逐一讲清楚。
2. 插件架构:ep_headings2 的目录结构与 hooks 加载链路
2.1 Etherpad 插件系统的 hooks 机制
Etherpad 本身是一个 Node.js 实现的实时协作文本编辑器,它的插件协议很特别:任何一个 npm 包,只要根目录存在ep.json,就会被 Etherpad 扫描并加载。ep.json的核心是parts数组,每个 part 可以注册一串 hooks。hooks 可以理解为生命周期回调点:Etherpad 在特定时机调用插件 exports 出来的同名函数,插件不需要 import 任何东西,也不需要修改 Etherpad 核心代码。比如页面渲染时,会调用插件导出的eejsBlock_editbarMenuLeft,把返回值拼进工具栏左侧的 HTML 流里;编辑器准备样式时,会调用aceEditorCSS,把返回的 CSS 路径注入编辑器。这种设计让插件之间天然解耦,代价是出了问题要靠日志定位,没有一个统一的断点。
ep_headings2 用到的 hooks 集中在客户端,列成表格方便对照:
| hook 名 | 触发时机 | 作用 |
|---|---|---|
eejsBlock_editbarMenuLeft | 编辑栏渲染 | 把标题下拉框 HTML 注入工具栏 |
eejsBlock_document | 整个 pad 页面渲染 | 引入插件 JS 和 CSS |
aceEditorCSS | 编辑器 iframe 初始化 | 注入标题样式,作用域只限编辑区 |
handleAceInsert | 文本插入、属性变更 | 监听下拉框 change 并修改行属性 |
服务端一般不写逻辑,但可以放一个index.js读取settings.json的配置,把白名单传给模板。这个分工是 Etherpad 插件的通用套路:能走配置的不要写死在代码里,后面 3.4 会看到具体用法。
2.2 目录结构:每个文件在加载链路上的角色
拆开 ep_headings2 的安装包,目录大致是这样:
ep_headings2/ ├── ep.json # hooks 注册清单,插件能被发现的唯一凭据 ├── package.json # npm 包元信息 ├── index.js # 服务端入口,主要读配置 ├── templates/ │ └── headings2.ejs # 编辑栏下拉框模板 └── static/ ├── js/ │ └── main.js # 客户端核心逻辑,hook 函数都在这 └── css/ └── headings.css # 标题在编辑器内的视觉样式几个容易被忽略的细节。第一,ep.json里的name字段要和包名一致,否则 Etherpad 会以为装了另一个插件,/admin/plugins里会出现怪异的条目。第二,static/js/main.js是浏览器端模块,Etherpad 打包时会用自带模块系统去解析,所以文件里要用exports.xxx导出 hook 函数,不要写window.xxx = ...这种全局泄漏。第三,templates/headings2.ejs是服务端模板,先由 Node 渲染成 HTML 字符串,再拼到工具栏里,最后由浏览器解析。模板里不要内联复杂逻辑,选项循环交给 ejs 标签处理即可。
ep.json的内容长这样:
{ "name": "ep_headings2", "version": "0.1.0", "parts": [ { "name": "headings2", "hooks": { "eejsBlock_editbarMenuLeft": "ep_headings2/static/js/main", "eejsBlock_document": "ep_headings2/static/js/main", "aceEditorCSS": "ep_headings2/static/js/main" } } ] }注意一个常见误解:hooks 值写的是「模块路径」,不是「路径加函数名」。Etherpad 的约定是,运行时去这个模块里找同名导出函数,eejsBlock_editbarMenuLeft对应的就是main.js里的exports.eejsBlock_editbarMenuLeft。我见过有人写成"ep_headings2/static/js/main:init",结果函数名对不上,插件静默失效,连报错都没有。
提示:改完
ep.json一定要重启 Etherpad 进程,这个文件只在启动时读一次,热更新不生效。我在这上面浪费过一整晚。
2.3 从点击到标题落地的数据链路
把整条链路画出来,后面改代码就有了导航。一次完整的「选 H2 → 行变标题」是这样发生的:
- 浏览器请求 pad 页面,服务端扫描
node_modules里的ep.json,注册 ep_headings2 的 hooks。 - 渲染编辑栏时调用
eejsBlock_editbarMenuLeft,headings2.ejs生成一个<select>,选项由配置白名单决定。 - 渲染文档时调用
eejsBlock_document,把main.js和headings.css插入页面。 - 用户在下拉框选择「标题 2」,change 事件被
main.js捕获。 main.js通过pad.ace_getRep()拿当前光标行号,再调pad.ace_doInsertHeading('h2', line)给这一行打上 heading 属性。aceEditorCSS注入的 CSS 立刻让该行字号变大、加粗。- 其他协作者的客户端同步收到属性变更,也套上同样的样式。
第 5 步是整个插件的灵魂。Etherpad 的文档模型是一行行纯文本,每行可以挂一组自定义属性,标题本质上是行的一个 block 属性,底层形如tag: h2。编辑器内不是真的插入<h2>标签,而是靠属性驱动渲染。理解了这一点,后面所有避坑都顺了——因为标题是「属性」而不是「标签」,才会出现导出丢标题、属性互相覆盖这类隐蔽问题。
3. 核心实现拆解:标题下拉框从渲染到生效的完整链路
3.1 编辑栏下拉框的模板与静态资源注入
templates/headings2.ejs是一个很朴素的 select:
<select class="ep_headings2-select" title="切换标题等级"> <option value="">正文</option> <option value="h1">标题 1</option> <option value="h2">标题 2</option> <option value="h3">标题 3</option> <option value="h4">标题 4</option> </select>value 直接存h1~h4,后续逻辑省事。注意这个模板渲染时,Etherpad 工具栏的其他按钮也同时在渲染,所以class命名要带插件前缀ep_headings2-,避免和其他插件撞名。不同团队会在这个文件里改 option 文案,比如把「标题 1」改成「大标题」。
main.js负责把静态资源挂到页面上,常见写法:
exports.eejsBlock_document = function (hook_name, args, cb) { var css = '<link rel="stylesheet" href="/static/plugins/ep_headings2/static/css/headings.css">'; var js = '<script src="/static/plugins/ep_headings2/static/js/main.js"></script>'; cb(css + js); }; exports.aceEditorCSS = function () { return ["/static/plugins/ep_headings2/static/css/headings.css"]; };逻辑说明:一个插件会同时触发两个 hook。eejsBlock_document管的是外层页面(下拉框的样式、main.js 的加载),aceEditorCSS管的是编辑器 iframe 内部的样式。两条路都要走,因为标题样式必须作用在编辑区内,而下拉框的页面样式必须作用在外层文档。aceEditorCSS的返回值用数组是 Etherpad 的规范,数组里每个元素是一个 CSS 路径。
参数说明:hook_name是当前 hook 名,args是 Etherpad 传入的上下文对象,不同 hook 的args结构不一样,后面在handleAceInsert里能看到区别。cb是回调,把要插入的 HTML 字符串传进去即可。
3.2 change 事件如何把标题属性写进编辑器
handleAceInsert是这个插件的核心 hook。它的设计意图是「在编辑器有插入行为时给你一个干预机会」,但 ep_headings2 更聪明地把它当成了「页面已经就绪」的信号,在这个时间点去绑定下拉框事件:
exports.handleAceInsert = function (hook_name, args, cb) { var select = document.querySelector('.ep_headings2-select'); if (!select) return false; select.addEventListener('change', function () { var tag = this.value; if (tag) { setHeading(tag); } else { removeHeading(); } }); return false; }; function setHeading(tag) { var rep = pad.ace_getRep(); if (!rep.selStart) return; var line = rep.selStart[0]; pad.ace_doInsertHeading(tag, line); } function removeHeading() { var rep = pad.ace_getRep(); if (!rep.selStart) return; var line = rep.selStart[0]; pad.ace_doInsertHeading(null, line); }逻辑说明:document.querySelector找到工具栏里的下拉框,绑定change事件。pad是 Etherpad 暴露在页面上的编辑器对象引用,pad.ace_getRep()返回当前选区快照,结构是{selStart: [行, 列], selEnd: [行, 列]},其中「行」是二维数组的第一个元素,从 0 开始。拿到行号后,pad.ace_doInsertHeading(tag, line)把这一行设成对应标题;传null则是清除标题属性、恢复正文。
参数说明:tag接受h1~h6或null;line是数字行号。这个组合刚好覆盖「设置标题」和「取消标题」两个动作,不需要额外写 if/else 分支。如果你在源码里看到这段逻辑,会发现它刻意没有处理多行选区——只对光标所在行生效,这是 Etherpad 标题功能一贯的行为,避免拖选多行时把整段都变成标题。
有个细节值得单独强调:handleAceInsert返回false表示不拦截本次插入事件,这是必须的。Etherpad 的 hook 返回值有约定语义,返回false代表「我不处理,继续走默认流程」;返回其他值可能改变输入行为。某些版本里如果返回了数组或字符串,会出现打字时字符重复或丢失的诡异问题,排查这类问题首先检查 hook 返回值。
3.3 编辑器内标题样式的作用域隔离
static/css/headings.css的内容是整个插件最容易出彩也最容易翻车的地方:
.ace-line[data-heading="h1"] { font-size: 24px; font-weight: 700; line-height: 1.5; } .ace-line[data-heading="h2"] { font-size: 20px; font-weight: 700; } .ace-line[data-heading="h3"] { font-size: 17px; font-weight: 600; } .ep_headings2-select { margin-right: 4px; }选择器写成.ace-line[data-heading="h1"]而不是h1,是因为 Etherpad 编辑器 DOM 里没有原生h1标签,一行文本就是一个.ace-linediv,行的属性会同步成 DOM 的>exports.settings = { headings: ['h1', 'h2', 'h3', 'h4'] };
然后在settings.json里覆盖:
{ "ep_headings2": { "headings": ["h2", "h3"] } }模板渲染时,通过settings.ep_headings2.headings决定哪些 option 出现在下拉框里。Etherpad 的模板上下文会自动带上settings对象,所以 ejs 里可以直接遍历。逻辑说明:这个设计的价值在于,不同团队对标题层级的诉求完全不同。有的团队只想要两级,把白名单改成["h2", "h3"],下拉框里就只有两项;有的团队做长文档需要五级,填五项即可。管理员改配置文件就能收紧或放开功能,不需要碰代码。
参数说明:settings.json是 Etherpad 的全局 JSON 配置,插件自己的配置项放在顶层,键名必须是插件包名,不能是缩写或别名。这里如果写错了键名,配置会被静默忽略,下拉框会退回到默认的四级标题。我一般会先在index.js里对settings.headings做一次 Array 校验,保证配置项格式错了也不至于让模板循环崩溃。
4. 部署避坑:从安装到跑通的常见问题排查
4.1 安装与验证步骤
安装很简单,在 Etherpad 根目录执行:
npm install ep_headings2注意先停掉 Etherpad 服务再装,特别是 Windows 环境下,进程占用node_modules里的文件会导致EPERM报错,安装一半失败是很常见的事。装完重启 Etherpad 进程(pm2 restart etherpad或直接停掉再起),然后打开/admin/plugins,确认 ep_headings2 出现在已安装列表且状态正常。最后随便打开一个 pad,看工具栏左侧有没有出现「标题」下拉框。
验证插件静态资源是否被正确加载,最快的办法是打开浏览器开发者工具,切到 Network 面板,刷新 pad 页面,过滤headings。如果headings.css返回 404,说明ep.json里的静态资源路径配置错了,或者插件文件放错了目录。这一步能过滤掉一半的「不生效」问题。还有一个隐藏坑:Etherpad 页面的静态资源有缓存,F5 不一定能拿到新模板,验证改动建议开无痕窗口,或者直接 Ctrl+Shift+R 强刷。
4.2 常见问题排查记录
现象一:下拉框根本不出现。
原因:ep.json里eejsBlock_editbarMenuLeft的模块路径写错,或者插件没有被 Etherpad 扫描到。Etherpad 对这类错误是静默跳过的,不报错也不阻塞页面,只会在启动日志里留下一行很不起眼的 warning。
解决:先看/admin/plugins列表,排除「没装上」;再核对node_modules/ep_headings2/ep.json里的路径和文件是否存在;最后重启进程并强刷页面。记住这个排查顺序:安装列表 → 文件路径 → 缓存,不要一上来就改代码。
现象二:下拉框出现了,但选 H2 后文字没有任何变化。
原因:handleAceInsert没有被触发,或当前 Etherpad 版本里pad.ace_doInsertHeading行为有变。编辑器的属性 API 在不同版本间有过调整,直接调这个内部方法存在兼容性风险,这是新手最容易卡住的地方。
解决:在handleAceInsert第一行加console.log,看浏览器 console 有没有输出。如果 hook 没触发,把事件绑定挪到eejsBlock_document里用setTimeout延迟执行,等工具栏 DOM 就绪。如果 API 不可用,改用pad.ace_callWithAce手动操作属性,这是 Etherpad 官方推荐的通用属性写入通道,代码大概是这样:
pad.ace_callWithAce(function () { var rep = pad.ace_getRep(); var line = rep.selStart[0]; pad.ace_doInsertHeading('h2', line); });现象三:标题在编辑区显示正常,但导出 HTML 后标题标签全丢了。
原因:导出链路走的是另一套 hook,比如getLineHTMLForExport,ep_headings2 默认没注册这个 hook,导出器看到 heading 属性就直接跳过,等于把层级信息扔掉了。
解决:在main.js里补一个导出转换,检测到><option value="h5">标题 5</option>
.ace-line[data-heading="h5"] { font-size: 15px; font-weight: 600; }注意改完要重启 Etherpad,模板和 CSS 都是启动时打包的,不是热更新。验证时务必开一个新 pad,别在旧 pad 上测,旧页面的缓存会骗人。
5.2 绑定快捷键的坑
想给标题加快捷键(比如 Ctrl+2 变 H2),不要直接在main.js里用document.addEventListener监听键盘,这样做在浏览器环境可能生效,但用户在移动端或特殊键盘布局下会失灵,而且会绕过 Etherpad 的事件优先级。正确路线是注册handleAceKeyEventhook,注意它返回true表示「事件已被处理」,返回false才放行给编辑器继续走默认逻辑。快捷键的注册还要和下拉框联动,否则会出现下拉框显示「标题 2」、实际行属性还是正文的状态错位——说白了就是「UI 状态和文档状态不同步」,这是所有编辑器插件最要命的体验问题。
我的习惯是:改完插件后强制走一遍完整流程——清缓存、开新 pad、依次验证每一级标题的设置与取消、导出 HTML、再让另一个浏览器进同一个 pad 看协同样式。这套流程跑下来,大部分坑都能在交给团队之前暴露。希望帮到你。
本文还有配套的精品资源,点击获取