用 Cherry Markdown(腾讯开源的 Markdown 编辑器)替换 FlintHub 核心富文本编辑器。
纯插件实现,零核心改动(本版唯一的核心改动是给正文净化白名单补了 mark 标签,见「四」)。
- 版本:1.0.0
- 依赖:Cherry 内核 0.11.10(构建产物已随包,运行期不请求任何外网)
- 最低核心版本:1.2.1
一、功能简介
| 能力 | 说明 |
|---|---|
| 编辑 / 预览双栏 | 三档初始模式:纯编辑 / 双栏 / 纯预览,用户可在工具栏随时切换 |
| 工具栏 | 加粗、斜体、删除线、上下标、颜色 + 背景色、字号、标题(h2~h4)、列表、有序列表、复选、引用、对齐、插入(图片/链接/表格/分割线/换行/代码块/目录)、撤销重做、全屏、字数、模式、搜索 |
| 颜色与背景色 | 产出 <span style="color:#…"> / <span style="background-color:#…">,能完整过核心白名单(背景色即事实上的「高亮」) |
| 字号 / 对齐 | font-size / text-align,同样在白名单内 |
| 代码高亮 | Prism 内置,前台由 cherry-content.min.css 提供配色 |
| 图片上传 | 走核心 POST /api/upload?ajax=1,自动带 CSRF 与 category_id |
| 草稿 / 引用 | 提供 window.ACTIVE_EDITORS 兼容层,核心 draft_saver 与引用按钮照常工作 |
| 格式损失提示 | 编辑旧帖时,Markdown 保留不了的格式会提示,并给「源码模式」出口(逐字节原样提交) |
| 前台正文样式 | 只在正文展示页额外注入 9.2 KB 的 cherry-content.min.css(只补 Prism 配色 / 图标字体 / 表格滚动,不动核心排版) |
二、入口与交互
- 后台 插件 → Cherry 编辑器 → 打开「总开关」并保存,前台立即生效。
- 支持按页面分别开关:发帖、回复、博客、私信、后台编辑、插件页面。
- 编辑器上方出现工具条;
数据岛(#cherryCfg)+boot.js在 body 末尾同步加载,无二次请求。
三、目录结构
plugins/cherry/
├── plugin.json # 4 个钩子 + route:admin / system:settings 权限
├── Plugin.php # 主类:配置 / 生效判定 / defaults 补丁 / 生命周期
├── ConfigStore.php # 配置读写收敛层(校验 → 归一化 → 整体落库)
├── AdminController.php # 后台设置页 + 保存(继承 Admin\BaseController)
├── hook/
│ ├── admin_route_register.php # /admin/cherry、/admin/cherry/save
│ ├── controller_view_before.php # 关核心编辑器 + 标记接管 / 标记正文页
│ ├── layout_head_end.php # 注入 CSS / JS(编辑器页与正文页分开判断)
│ └── layout_body_end.php # 数据岛 + boot.js
├── views/admin.php # 设置页(自包 admin-container + _sidebar + admin-content)
├── assets/
│ ├── boot.js # 前端挂载(本插件最核心的文件)
│ ├── style.css # 宿主容器 / 提示条 / 源码模式 / 工具栏裁剪 / 后台页
│ └── lib/cherry/ # 构建产物(勿手改)
│ ├── cherry-markdown.js 2.34 MB UMD(terser 压缩)
│ ├── cherry-markdown.css 241 KB 编辑器皮肤
│ ├── cherry-content.min.css 9.2 KB 前台正文专用
│ └── fonts/ch-icon.woff2|woff 47 KB 图标字体
└── lang/{zh,zh_tw,en}.php # 79 键 × 3 语,键集合与键序完全一致
四、核心改动(全仓仅一处)
app/Helpers/Content.php 的 $allowedTags 增加 mark。
原因:Cherry 的 ==文字== 语法与 Vditor 的高亮按钮都产出 <mark>;不放行会被净化层
unwrap 成纯文字(写了高亮、发出来没有)。mark 无属性、无脚本能力、浏览器默认样式即可,
放行不扩大攻击面。
- 属性白名单未放宽:
<mark onclick=… style="background:url(javascript:…)">里的危险属性仍被清空。 - 三轮净化逐字节幂等。
- 回归:
test_p5_content_img.php65/0、test_p8d_img_group.php66/0、cherry_mark_verify.php15/0。
五、六条硬约束(改 boot.js / Plugin.php 前必读)
1. ⚠️⚠️ 所有子配置只能改 Cherry.config.defaults,不能作为构造参数传
mergeWith.js:51 只在「两边都是 plain object」时下钻合并,而实测
defaults.engine / .toolbars / .editor / .callback / .previewer 全部不是 plain object,
传任何局部配置都会整块替换掉默认值,导致编辑器根本起不来:
{engine:{syntax:{…}}} → TypeError: Cannot read properties of undefined (reading 'urlProcessor')
{toolbars:{…}} → TypeError: Cannot read properties of undefined (reading 'isReplace')
唯一正确姿势(boot.js 的 applyPatches()):改 defaults → new Cherry() → 立即还原。
构造时会 cloneDeep,还原不影响已建实例;不还原会污染同页其它实例。
2. ⚠️⚠️ locale 必须白名单化
src/locales/ 只有 zh_CN / en_US / ru_RU。传 zh_tw 或任意未知值会抛
TypeError: Cannot read properties of undefined (reading 'colorPickerText') —— 不是降级,是起不来。
所以繁体强制映射到简体(Plugin::cherryLocale() + boot.js 双重兜底)。
3. ⚠️⚠️ 内部 textarea 必须彻底干掉 name
Cherry 自己造 <textarea id="code" name="code"> 再 display:none,在表单里会多提交一个 code 字段。
两道保险:editor.name = ''(空串会被尊重)+ 挂载后 removeAttribute('name')(空 name 仍会提交空字段)。
4. ⚠️ 提交取 HTML 用 engine.makeHtml(getValue()),不要依赖 getHtml()
getHtml() 在纯编辑模式下读的是 previewerCache.html,新鲜度依赖预览器是否更新过 DOM;
engine.makeHtml(md) 是同步纯函数,恒定正确。getHtml(false) 只作兜底。
5. ⚠️ ACTIVE_EDITORS[x].editor 不能是真实 DOM
draft.js 会 ed.editor.innerHTML = … 直接赋值,绕过 Cherry 内部模型 → 表面有字、getValue() 还是旧的。
故给一个 detached 假节点,用 Object.defineProperty 接管 innerHTML。
6. ⚠️ 代码块行号必须关
engine.syntax.codeBlock.lineNumber 默认 true,会把 <span class="code-line"> 写进存库 HTML。
已随补丁关闭(同时关 copyCode / editCode / changeLang / expandCode,减少 data-* 噪音)。
六、设置项(13 个,前缀 cherry_)
| 键 | 默认 | 说明 |
|---|---|---|
enabled |
0 |
总开关。关闭时前台完全无变化(退回核心编辑器) |
on_thread |
1 |
发帖 / 编辑主题 |
on_reply |
1 |
回帖框 |
on_blog |
0 |
博客编辑 |
on_message |
0 |
私信编辑 |
on_admin |
0 |
后台主题编辑 |
on_plugin_pages |
0 |
其它插件自带的编辑页(问答 / 网盘求助 / 单页 / 市场投稿 / 公告) |
default_mode |
editOnly |
editOnly / edit&preview / previewOnly |
height_thread |
520 |
发帖框高度(160~2000) |
height_reply |
300 |
回帖框高度(160~2000) |
theme |
auto |
auto / light / dark;auto 跟随站点皮肤 |
lossy_threshold |
15 |
格式损失提示阈值(%),0 = 关闭提示 |
keep_h1 |
0 |
保留 h1(默认降级为 h2,因核心白名单只有 h2~h4) |
七、路由
| 方法 | 路径 | 动作 |
|---|---|---|
| GET | /admin/cherry |
设置页 |
| POST | /admin/cherry/save |
保存全部字段(PRG 302 + 会话 flash) |
必须继承 app\Controllers\Admin\BaseController(自动获得管理员校验 + 二次验证门禁)。
八、技术要点
- Fail-Safe 而非 Fail-Closed:产物缺失 / 总开关关闭 / 本页未启用 → 一律不动
needsEditor,
核心编辑器照常工作。boot.js里new Cherry()失败会完整回滚(移除宿主、恢复 textarea 的name),
保证用户至少能发帖。 - 零外链:产物 CSS 里 0 个
@import、0 个外域url();只有本地字体。
不含blob:/wasm-unsafe-eval依赖,现有 CSP 直接可用。 - 零内联:参数走
<div hidden data-cfg="…">(JSON_HEX_*+ENT_QUOTES双保险),
视图/钩子里没有任何 JS 字面量或style=。 - 破缓存:所有资源 URL 带
?v=filemtime。 - 请求级状态用类静态属性(
Plugin::runtime())—— 钩子在闭包作用域执行,变量传不出去。 - 后台设置页与 vditor 插件共用同一套视觉规范:盒子全部交给核心
(.admin-content本身是白卡、.admin-section只给间距),标题/描述用核心
tab-pane-head/tab-pane-desc,语义色用--mn-alert-success-*/--mn-alert-error-*/
--mn-comp-alert-warning-*。
⚠️ 改这两个插件的设置页样式时要同步改,否则两个编辑器插件看起来像两套产品。
核心后台布局类在assets/css/admin.css(不在modern.css),且用的是--c-*变量。 - 正文样式判据是
activated而非enabled:关掉编辑器后,用 Cherry 写过的老帖子仍要能正常显示。
九、已知取舍(有意为之,不是缺陷)
- 一级标题在提交时降为二级 —— 核心白名单只有
h2~h4,不降级会被 unwrap 成段落。 - 没有下划线按钮 —— 下划线靠
text-decoration,核心样式白名单不放行,加了也看不见。 - 高亮用「背景色」实现 —— 输出
span + background-color,能完整保留;==x==语法已关闭。 - 公式 / 图表 / 音视频 / PDF-Word 导入已移除 —— 会请求外网,或产出白名单外的标签。
- 编辑旧帖时格式可能损失 —— 提示 + 「源码模式」出口(源码模式逐字节原样提交,不降级 h1)。
- 前台正文样式只补三样 —— Prism 配色 / 图标字体 / 表格滚动;核心
.post-text-content的排版一概不动。
十、安装与启用
- 把
plugins/cherry/整个目录放到站点plugins/下(产物已随包,无需构建)。 - 后台「插件」列表找到 Cherry 编辑器 → 启用。
- 进设置页 → 打开总开关 → 保存。
- 打开
/post/new验证:工具栏出现、内容能发出去。
产物缺失时插件完全不生效(后台设置页会红字列出缺失文件),前台退回核心编辑器,不会白屏。