hy-editor
v0.4.0
Published
好运编辑器(hy-editor)—— Quill 2 内核的轻量块级富文本编辑器:表格、代码块、公式、Mermaid 图表、高亮块、步骤条、时间轴、批注开箱即用,落库语义 HTML,前台一行代码还原
Maintainers
Readme
hy-editor
轻量富文本编辑器(Quill 2 内核,仿 XNote)。专注「写文章 → 存 HTML → 前台还原」这条链路, 自带表格、代码块、公式、图表、高亮块、步骤条、时间轴、视频、批注等块,以及一套插件注册表, 二次开发加自定义块不用改库源码。
- 包:ESM(
dist/hy-editor.mjs)+ UMD(dist/hy-editor.js)+ 样式(dist/hy-editor.css)+ 类型(dist/hy-editor.d.ts) - 官网:
web/(首页 / 使用文档 / 开发文档,纯静态,含在线试写),本地npm run web:serve - 依赖:只依赖 quill 与 katex(打包进产物);mermaid / highlight.js 走 CDN 懒加载,可自托管
1. 安装与引入
npm / 打包器
npm i hy-editorimport { createEditor } from 'hy-editor'
import 'hy-editor/dist/hy-editor.css'
const ed = createEditor({ container: '#editor', theme: 'light' })无打包器(PHP / 传统后台页面直引)
<link rel="stylesheet" href="/vendor/hy-editor/hy-editor.css">
<div id="editor"></div>
<script src="/vendor/hy-editor/hy-editor.js"></script>
<script>
var ed = window.HYEditor.createEditor({ container: '#editor' })
</script>UMD 直引时插件要用的 Quill 基类从 window.HYEditor.Quill 取:
var BlockEmbed = window.HYEditor.Quill.import('blots/block/embed')完整可运行示例见 examples/umd-php.html(运行时注册一个「评分卡」插件)。
2. 快速上手
const ed = createEditor({
container: '#editor',
theme: 'light',
content: '<h2>标题</h2><p>正文</p>', // 存储格式 HTML
toolbar: 'full', // 'full' | 'simple' | { items: [...] } | Quill 原生数组
upload: async (file) => { // 图片上传必须自己接
const fd = new FormData(); fd.append('file', file)
const r = await fetch('/admin/upload', { method: 'POST', body: fd }).then(r => r.json())
return r.url // 返回可访问的图片地址(相对路径也可以)
},
// 副工具栏:业务动作(主工具栏下的第二行)
subToolbar: [
{ label: '封面图', onClick: (q, ed) => console.log(ed.getWordCount()) },
'|',
{ builtin: 'toc', align: 'right' }, // 内建:目录面板
{ builtin: 'preview', title: '预览', align: 'right' } // 内建:预览浮层
],
statusBar: { wordCount: true, actions: [{ label: '发布', variant: 'primary', onClick: () => save(ed.getHTML()) }] }
})toolbarSlot / subToolbarSlot / statusBarSlot 可把这三行渲染到页面级容器(页面顶栏、底部操作条)。
3. 编辑器 API
| 方法 | 说明 |
|---|---|
| getHTML() / setHTML(html) | 取 / 回填存储格式 HTML(自定义块是 <hy-xxx> 语义标签) |
| getQuill() | 底层 Quill 实例(逃生口,原生能力全都能用) |
| setTheme(t) / getTheme() | 亮暗切换(联动 mermaid 与外挂容器) |
| getWordCount() | 字数 |
| getTOC() | 目录 JSON(H1 = 标题,H2 = 一级节点) |
| showOutline() / hideOutline() / toggleOutline() / isOutlineOpen() / onTOCChange(fn) | 目录面板 |
| openPreview(opts) / getPreviewHTML(opts) / mountPreview(el, opts) | 预览:全屏浮层 / 静态字符串 / 渲染进任意容器 |
| getComments() / ed.comments.* | 批注:列表 / 改派处理人 / 标记完成 / 删除 |
| getBranding() / setBranding(v) | 官方署名:读当前配置 / 运行时隐藏、换文案(见 §8.4) |
| on(event, fn) / off(event, fn) | 事件订阅(见下) |
| destroy() / isDestroyed() | 销毁:解绑全局监听、清浮层、清容器、释放实例级预设 / 语言作用域 |
| closeSlash() / isSlashOn() | 收起斜杠菜单 / 是否开启了斜杠菜单 |
| debug() | 调试:这一例回收了多少全局监听、挂了哪些插件 |
事件:change(内容变更)、selection、upload:start / upload:done / upload:error、
destroy,其余名字透传给 Quill。on() 返回取消订阅函数。
const off = ed.on('upload:done', ({ url }) => console.log('已插入', url))
ed.on('change', () => autosave(ed.getHTML()))以前换
toolbar/sizeMode这类「构造期才生效」的选项只能整容器重建,现在destroy()后重新createEditor()即可。
4. 前台(详情页)怎么用
文章页不需要编辑器实例,两个静态函数就够:
import { toPreviewHTML, getTOCFromHTML } from 'hy-editor'
document.querySelector('#article').innerHTML = toPreviewHTML(htmlFromDB, { theme: 'light' })
const toc = getTOCFromHTML(htmlFromDB) // 侧边目录(id 与正文锚点一致)两种落地方式:
- 后端渲染:保存时额外存一份
getPreviewHTML()的结果,前台直接输出,零 JS(公式 / 图表需要 mermaid、katex 的 JS,见 §7)。 - 前端渲染:前台
mountPreview(el, html),公式 / 图表会在挂载后异步补渲染。
CDN 依赖(都是懒加载 + 三级降级 jsDelivr → npmmirror → unpkg):mermaid 图表、KaTeX 公式、highlight.js 代码高亮。
内网 / 离线部署请自托管并指定:createEditor({ mermaidUrl: '/vendor/mermaid.min.js', katexUrl: '/vendor/katex.min.js' })。
5. 存储格式
落库 HTML 里,自定义块是干净语义标签(不含渲染产物):
| 标签 | 内容 |
|---|---|
| <hy-mermaid data-src> | 图表源码 |
| <hy-formula data-src data-display data-color> | 公式(LaTeX) |
| <hy-highlight data-type data-title data-icon data-color>…</hy-highlight> | 高亮块(容器,内部可嵌套) |
| <hy-quote data-style data-cite>…</hy-quote> | 增强引用块 |
| <hy-steps data-dir data-color> / <hy-timeline data-color> | 步骤条 / 时间轴 |
| <hy-video data-src data-type data-ratio data-autoplay> | 视频 |
| <hy-toc> | 目录占位(前台在此处渲染目录) |
| <hy-icon data-name data-color data-size> | 内联图标 |
| <pre data-language data-ln data-wrap data-em> | 代码块 |
| <span class="hy-commented" data-cid data-ctext data-ctime data-cowner data-cdone> | 批注 |
| <hy-vote …> 等 | 宿主自定义块(见 §6) |
净化注意:PHP 侧如果用 HTMLPurifier 之类的白名单净化器,必须放行上表标签与该标签上的 data-*
属性 —— 默认配置会把自定义标签和 data 属性全部删掉,表现是「前台块全没了 / 颜色丢了」。
改白名单后记得 bump HTML.DefinitionRev(它有缓存)。详见 §7。
6. 二次开发:加一个自定义块
以前要改五处源码(blot 注册、存储往返、预览渲染、插入菜单、动作表),现在一次 register() 全接通:
import { register, createEditor } from 'hy-editor'
register({
name: 'vote-card',
// ① Quill blot:编辑态长什么样、value 怎么存
formats: [VoteBlock],
// ② 存储标签 ↔ 编辑器内 class(getHTML / setHTML / 粘贴 往返)
embed: { tag: 'hy-vote', cls: 'hy-vote-block' },
// ③ 前台渲染(toPreviewHTML / mountPreview / openPreview)
preview: { tag: 'hy-vote', render: (el) => buildVoteCard(el) },
// ④ 动作(进 ACTIONS:工具栏 / 插入菜单 / 块手柄 / 气泡都查这张表)
actions: { vote: (quill) => insertVote(quill) },
// ⑤ 插入菜单条目(key 对应 ④;追加在内建条目之后 —— 同时进斜杠菜单)
menu: { key: 'vote', label: '投票卡', icon: '<svg …/>' },
// ⑥ 工具栏按钮(自定义 token;宿主写进 toolbar.items 即可,见 §8.3)
toolbar: { vote: { title: '投票卡', render: (opts) => buildVoteButton(opts) } },
// ⑦ 插件自己的交互(双击编辑等),每个编辑器实例创建后调用一次
setup: (quill, { editor, emit }) => { /* … */ },
// ⑧ 插件样式(自动注入 <style>,unregister 时移除)
css: '.hy-vote-block{…}'
})
const ed = createEditor({ container: '#editor' }) // register 前后调用都行字段说明与可选形态:
| 字段 | 用途 | 备注 |
|---|---|---|
| formats | 注册 blot | 数组;容器型块也放这里(会自动进表格单元格白名单) |
| embed | 原子块的标签映射 | { tag, cls, attrs?, drop? };属性默认同名透传 |
| convert | 容器块的自定义往返 | { tag, cls, toTag?(el), fromTag?(el) },内部还有可编辑内容时用 |
| preview | 前台渲染 | { tag, render(el, ctx) } 或数组;返回 Element 则替换,返回空表示已就地改完 |
| actions | 动作表 | key 与 menu.key 对应;覆盖内建同名动作会打 warn |
| menu | 插入菜单条目 | 单个或数组;同时进斜杠菜单 |
| toolbar | 工具栏按钮 | { token: { title, render(opts) } },unregister 时一并移除 |
| setup | 实例级交互 | (quill, { editor, emit }) => void |
| css | 样式 | 字符串,按插件名注入 |
| dispose | 卸载回调 | unregister(name) 时调用 |
其他入口:
registerAll([p1, p2])、unregister(name)、listPlugins()- 只想给某一个实例加:
createEditor({ extensions: [plugin] })(注意 blot 注册是全局的,Quill 格式表不分实例) - 参考实现:
src/plugins/vote-card.js(投票卡,含弹窗编辑、双击改、预览渲染、样式)
7. PHP 后台接入
7.1 页面
后台编辑页直引 UMD(见 §1),把配置(上传地址、初始内容、主题)由模板变量注入即可:
<script>
window.__ed = HYEditor.createEditor({
container: '#editor',
theme: <?= $theme === 'dark' ? "'dark'" : "'light'" ?>,
content: <?= json_encode($articleHtml, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
upload: async (file) => {
const fd = new FormData(); fd.append('file', file)
const r = await fetch('/admin/upload', { method: 'POST', body: fd, headers: { 'X-CSRF': '<?= $csrf ?>' } }).then(r => r.json())
if (!r.url) throw new Error(r.msg || '上传失败')
return r.url
},
subToolbar: [
{ label: '封面图', onClick: (q, ed) => pickCover(ed) },
'|',
{ builtin: 'toc', align: 'right' },
{ builtin: 'preview', align: 'right' },
{ label: '保存', variant: 'primary', align: 'right', onClick: () => save(window.__ed.getHTML()) }
]
})
</script>外部依赖自托管(内网必做):把 mermaid / katex / highlight 放到自己的静态目录,
createEditor({ mermaidUrl: '/vendor/mermaid.min.js', katexUrl: '/vendor/katex.min.js' })。
7.2 上传接口(示例)
// POST /admin/upload —— 返回 { url };编辑器拿到 url 后 insertEmbed
$file = $_FILES['file'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); exit(json_encode(['msg' => '上传失败'])); }
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allow = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
if (!isset($allow[$mime])) { http_response_code(415); exit(json_encode(['msg' => '不支持的图片类型'])); }
$name = date('Ymd') . '/' . bin2hex(random_bytes(8)) . '.' . $allow[$mime];
$dir = __DIR__ . '/../public/uploads/';
@mkdir($dir . dirname($name), 0755, true);
move_uploaded_file($file['tmp_name'], $dir . $name);
header('Content-Type: application/json');
echo json_encode(['url' => '/uploads/' . $name]); // 相对路径也行:库已放宽图片 URL 白名单要点:
- 校验放服务端(类型、大小、频次),前端
upload只负责传; - 返回相对路径没问题(Quill 默认只放行 http/https/data,这里已放宽到相对路径与 blob);
- 上传失败请
throw/ reject,编辑器会走upload:error事件,可以拿它做进度条与失败提示。
7.3 落库与输出
- 字段:
MEDIUMTEXT、utf8mb4(emoji 与特殊符号);目录可以另存一份getTOC()的 JSON,前台省一次计算。 - XSS:作者 HTML 直接输出到前台等于 XSS 入口。两种策略:
严格白名单(HTMLPurifier 等)——必须放行 §5 的标签与其
data-*属性,否则块和颜色会被静默清掉; 改配置后 bumpHTML.DefinitionRev/Cache.DefinitionImpl(定义有缓存)。示意:$config = HTMLPurifier_Config::createDefault(); $config->set('HTML.DefinitionID', 'hy-editor'); $config->set('HTML.DefinitionRev', 3); // 改白名单时 +1 $config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]); if ($def = $config->maybeGetRawHTMLDefinition()) { $def->addElement('hy-mermaid', 'Block', 'Empty', 'Common', ['data-src' => 'Text']); $def->addElement('hy-formula', 'Block', 'Empty', 'Common', ['data-src' => 'Text', 'data-display' => 'Text', 'data-color' => 'Text']); $def->addElement('hy-highlight', 'Block', 'Flow', 'Common', ['data-type' => 'Text', 'data-title' => 'Text', 'data-icon' => 'Text', 'data-color' => 'Text']); // … 其余 hy-* 与 <span class="hy-commented"> 的 data-* 同理 } $clean = (new HTMLPurifier($config))->purify($html);或信任后台作者(多数 CMS 的做法),只做「去掉
<script>/on*事件属性 /javascript:协议」的窄过滤 —— 风险是内网多作者场景下的横向越权,取舍自己定。
- 版本升级:编辑器升级后旧 HTML 由
deserializeHTML兼容解析(含 XNote 旧标签),但净化白名单要跟着版本同步,否则新块在前台被净化器吃掉。
7.4 扩展后台工具
- 加按钮 / 业务动作:副工具栏 / 状态栏用
subToolbar/statusBar配置(封面图、AI 助手、一键排版、保存草稿、发布…),onClick(quill, ed)里能拿到实例;要放进主工具栏就用registerToolbarItem()(§8.3)。 - 加内容块:
register()插件(§6),UMD 场景用HYEditor.Quill取基类;插件可以随自己的业务仓库维护,不必 fork 编辑器。 - 改预设数据(字号档位、色板、插入菜单条目、图标库):用
presets(§8),不必 fork。 - 改文案 / 做英文版:用
i18n(§8),不必全局搜字符串。
8. 预设数据与文案(presets / i18n)
8.1 presets —— 色板、字号档位、字体、图标库、菜单条目
同一套 35 色以前在三个文件里各写了一份,字号档位与字体列表是写死的常量。现在统一在 presets:
import HYEditor from 'hy-editor'
// ① 全局(影响之后创建的所有实例)
HYEditor.presets.set('colors', ['#000000', '#e60000', '#0066cc']) // 替换
HYEditor.presets.extend('pxSizes', ['144px', '192px']) // 追加档位
HYEditor.presets.addIcons({ // 追加图标
'my-logo': { label: '我的图标', cat: '品牌', body: '<circle cx="12" cy="12" r="9"/>' }
})
// ② 实例级(只影响这一个编辑器)
createEditor({ container, presets: { colors: [...], fonts: [...], insertMenu: [{ key: 'x', label: 'X' }] } })可覆盖的键(HYEditor.presets.PRESET_KEYS):
| 键 | 默认 | 覆盖方式 |
|---|---|---|
| colors | Quill 35 色 | 替换 |
| iconColors | 11 色(首项空 = 跟随文字色) | 替换 |
| pxSizes | 8px–120px 共 20 档 | 追加 / 替换 |
| fonts | 宋体 / 黑体 / 楷体 / 仿宋 / 雅黑 / 等宽 | 替换 |
| headers | 正文 + H1-H6 | 替换 |
| icons hlIcons | 内建图标库 | 追加(merge) |
| emojiGroups | 7 类 205 个 | 追加 |
| insertMenu | 内建 12 条 | 追加 |
| slashMenu | 与插入菜单同源 | 追加 |
| toolbarPresets | full / simple 两档 | 追加 / 改档(merge) |
| branding | 官方署名(好运编辑器) | 改字段(merge)/ 传 false 全局关闭 |
| mermaidThemes | 5 套 | 替换 |
实例级覆盖(createEditor({ presets }))分两层生效:
- 构建期(同步作用域):工具栏、菜单、色板的同步构建 —— 一直如此;
- 存活期(持久作用域):运行时才点开的浮层(颜色选择器、图标面板…)也读本实例的值,
编辑器被交互时成为「当前实例」,多实例互不干扰,
destroy()时释放。
不经过编辑器的调用(比如你自己页面里的浮层)读的是全局值 —— 那种场景用 HYEditor.presets.set()。
8.2 i18n —— 多语言与改措辞
HYEditor.i18n.setLocale('en') // 切英文(内置 en 包)
HYEditor.i18n.addMessages('zh-CN', { 'cmt.title': '评论' }) // 只改中文措辞
createEditor({ container, locale: 'en', messages: { 'menu.table': 'Table' } })设计上是渐进式的:t(key, fallback) 在文案表里查不到就用调用点写的中文兜底,
所以没接 i18n 的旧文案照常显示,不会因为漏配就变成空白或 key。
内置 en 覆盖约 90 条核心 UI 文案(菜单、工具栏按钮、对齐缩进、斜杠菜单、批注、
预览、表格工具、图标选择器);其余未接的文案仍是中文,想翻就 addMessages('en', {...}) 补。
8.3 工具栏:加按钮 / 换预设 / 斜杠菜单
工具栏以前是一张写死的清单(想加按钮只能 fork)。现在三个口子都是开放的:
import HYEditor, { registerToolbarItem } from 'hy-editor'
// ① 自定义按钮:注册一个 token,之后与内建 token 完全等价
registerToolbarItem('ai-polish', {
title: 'AI 润色',
render(opts) {
const b = document.createElement('button')
b.type = 'button'
b.className = 'hy-tb-btn'
b.innerHTML = '<svg viewBox="0 0 16 16">…</svg>'
b.addEventListener('mousedown', (e) => e.preventDefault()) // 保住编辑器选区
b.addEventListener('click', () => opts.actions.aiPolish?.())
return b
}
})
// ② 自定义一档预设(不用改源码)
HYEditor.presets.set('toolbarPresets', { minimal: ['bold', 'italic', '|', 'ai-polish'] })
createEditor({ container, toolbar: 'minimal' })
// 或者直接给数组:createEditor({ toolbar: { items: ['undo','redo','|','ai-polish'] } })插件也能声明按钮(register({ toolbar: { token: { title, render } } })),
卸载插件时 token 一并移除 —— 见 §6。
斜杠菜单:行首输入 / 唤出插入菜单(↑↓ 选择、Enter 执行、Esc 关闭),
条目与工具栏「插入」下拉同源(内建 + presets.slashMenu 追加 + 插件 menu 条目),
所以加一个块,四个入口同时出现。不要就 createEditor({ slash: false })。
HYEditor.presets.extend('slashMenu', [
{ key: 'slogan', label: '标语', run: (quill) => insertSlogan(quill) } // 自带 run 时优先
])8.4 官方署名(branding)
编辑器默认在底部带一行官方署名:「好运编辑器 · hyeditor.haoyundada.com」+ 官方 logo,点击新窗口打开官网。
它是编辑器外壳:不进 getHTML() 的存储内容,也不随内容回填。
// ① 默认:什么都不用写(配了 statusBar 就并进状态栏右侧,没配则独立一条)
createEditor({ container })
// ② 关掉这一个实例
createEditor({ container, branding: false })
// ③ 换文案 / 换 logo / 换链接(只写要改的字段,其余沿用默认)
createEditor({
container,
branding: { name: 'XX 编辑器', url: 'https://writer.example.com', logo: '/static/logo.png' }
})
// ④ 白标:全局关掉(建实例之前调用;实例传对象也翻不出来)
HYEditor.presets.set('branding', false)运行时也能改:
ed.setBranding(false) // 隐藏
ed.setBranding({ name: '集团编辑器' }) // 换文案(与当前配置合并)
ed.getBranding() // 当前生效配置,null = 未显示| 字段 | 默认 | 说明 |
|---|---|---|
| name | 好运编辑器 | 产品名 |
| vendor | 好运哒哒 | 出品方(进 hover 提示) |
| url | https://hyeditor.haoyundada.com | 官网,决定 href 与显示域名 |
| logo | 官方 logo(pay.haoyundada.com) | 传空字符串即不显示图;加载失败会自动撤掉不留破图 |
| text | — | 整条文案覆盖(给了就不再拼 name + 域名) |
| position | auto | auto / statusbar / bar —— 强制放状态栏或独立成条 |
9. 已知边界与后续
| 项 | 现状 | 建议 |
|---|---|---|
| 块工具栏(块手柄菜单)的行为定义 | 仍是模块常量 | 想改条目顺序 / 增删动作,目前要改 block-toolbar.js |
| i18n 覆盖率 | 核心 UI 约 90 条 key,未全覆盖 | 按模块继续接(未接的仍是中文) |
| 全局副作用 | 字体白名单、图片 sanitize 补丁、sizeMode 打在 Quill 全局 | 同页多实例请统一 sizeMode |
| 测试 | 纯逻辑层(预设 / 文案 / 注册表 / 目录) | 编辑器交互仍靠 demo 页浏览器冒烟,可补 E2E |
| 编辑器与预览的功能差 | 预览是只读(批注可查看、不可回批) | 需要「读者回批 / 批注侧栏」再开 |
10. 开发
npm i
npm run dev # 开发(demo 页)
npm run build # 库产物:dist/(ESM + UMD + CSS + d.ts + sourcemap)
npm run build:demo # demo 产物:demo/(可直接静态托管)
npm test # 单测(vitest + jsdom):预设 / 文案 / 插件注册表 / 目录
npm run web:sync # 把 dist 产物同步进官网 web/vendor/(在线试写用)
npm run web:serve # 本地预览官网:http://localhost:5200目录:src/hy-editor.js 主入口与装配,src/modules/* 各功能块,src/bridge/html-bridge.js 存储往返,
src/modules/preview.js 前台渲染,src/toolbar/* 工具栏,src/extensions.js 插件注册表,
src/plugins/* 示例插件,src/styles/* 样式。
11. 官网(web/)
web/ 是一个纯静态站点,可直接托管(也是库的自述页):
| 文件 | 内容 |
|---|---|
| web/index.html | 首页:能力、真机截图、在线试写(引 web/vendor/ 里的 UMD 产物起真实例)、存储格式、快速开始、打赏赞助(赞赏码可点击放大)、已知边界 |
| web/docs.html | 使用文档:安装引入 / 配置全表 / API 与事件 / 内容块 / 上传 / 存储 / 前台渲染 / 主题 / 预设 / i18n / 官方署名 / PHP 接入 / FAQ |
| web/dev.html | 开发文档:架构、源码结构、往返链路、插件开发、工具栏 token、presets / i18n 机制、构建测试、版本约定、Quill 踩坑记录 |
三页共用同一套 header / footer(手写重复,不靠 JS 注入,保证无 JS 也能读),页脚带出品方
(好运哒哒)与备案号。web/sponsor/wxpay.png 是赞赏码、web/assets/logo.png 是官方 logo
(同时用作 favicon),换图直接替换同名文件即可。
改完 src/ 记得 npm run build && npm run web:sync(官网在线试写读的是 web/vendor/ 的副本);
改 web/assets/*.css|js 后要把三个 HTML 里的 ?v= 缓存串加一档,否则浏览器会用旧缓存。
