一、快捷键方案
1.1 事实核对(已确认)
editor.js没有任何全局 keydown 监听(只有editor.addEventListener("keyup", saveEditorRange),L1027)。- 无 Esc 退出全屏。
- 命令路由入口是
handleCommand(editorId, cmd, val)(L1340),MD 模式转发handleMdModeCommand。 - 现有 COMMANDS 签名:
bold/italic/underline/strikeThrough/code(toggleWrap);heading(level);insertUnorderedList/insertOrderedList;link();blockquote();hr();undo(editorId)/redo(editorId)/fullscreen(editorId)。
1.2 复用命令映射表(双模通用)
| 快捷键 | 可视化 tab 映射 | Markdown tab 映射(经 handleMdModeCommand) |
|---|---|---|
Ctrl/Cmd+B |
handleCommand(id,'bold') |
bold |
Ctrl/Cmd+I |
italic |
italic |
Ctrl/Cmd+U |
underline |
underline(空,忽略) |
Ctrl/Cmd+K |
link |
link |
Ctrl+Shift+7 |
insertOrderedList |
list |
Ctrl+Shift+8 |
insertUnorderedList |
list |
Ctrl+Shift+9 |
blockquote |
quote |
Ctrl+Shift+C |
code |
code |
Ctrl+Shift+K |
codeblock(需新增命令,见 1.3) |
codeblock |
Ctrl+Enter |
提交表单(见 1.4) | 同左 |
Esc |
退出全屏(见 1.5) | 同左 |
1.3 新增 COMMANDS.codeblock(可视化 tab 必需)
COMMANDS 目前没有 codeblock。需在可视化 tab 下实现"插入代码块",MD tab 复用现有 codeblock tag 即可。
方案:在 editor.js 新增 COMMANDS.codeblock,插入 <pre><code class="language-text">...</code></pre> 并将选区文本放入。不引入第三方高亮库(避免超"只动两文件"与依赖新增)。
1.4 Ctrl+Enter 提交 — 中文输入法冲突处理
实现要点(写入方案,不预览到成品):
- 绑定在全局 document keydown + 编辑器容器 keydown(双模都覆盖)。
- 用
e.isComposing || e.keyCode === 229判定输入法组合态,组合中一律不触发提交。 - 提交方式:优先
form.requestSubmit();取不到再form.dispatchEvent(new Event('submit')),不直接.submit()(保留现有 submit 监听里的 Base64 写 hidden 逻辑)。 - 仅当
form存在且编辑器属于该表单时生效。
1.5 Esc 退出全屏
全局 keydown 判定 ACTIVE_EDITORS[id].container.classList.contains('editor-fullscreen') 时调 COMMANDS.fullscreen(id)。非全屏时 Esc 不拦截。
1.6 键位冲突说明
Ctrl+B/Ctrl+I:contenteditable 内浏览器默认就是"加粗/斜体"(execCommand),本项目用toggleWrap('strong'/'em')替代。需e.preventDefault()阻止浏览器默认再套一层。风险低,但需实测。Ctrl+K:浏览器一般无默认行为,安全。Ctrl+Shift+数字:Chrome 标签切换默认占用 Ctrl+Shift+Tab;Ctrl+Shift+7/8/9多数浏览器无默认。含 macOS:Cmd+Shift 对应的是系统命令,以下统一用 Ctrl+Shift;Mac 上若需要可另配 Cmd+Shift。以保持跨平台一致为准,Mac 键位作为可选项。Ctrl+U在 Firefox 有"查看源代码"默认——仅在 contenteditable/MD textarea 且编辑器聚焦时拦截,避免全局劫持。
1.7 快捷键帮助入口(二选一,方案阶段定)
- 建议在工具栏末尾加一个"快捷键"按钮(复用
addBtn,data-cmd="kbd"),handleCommand里cmd==='kbd'弹出提示层/面板(文案中文,图标沿用现有 fa 体系)。 - 备选:在底部字数统计栏旁追加一个? 图标(改动更小)。
- 推荐前者,信息集中、不占底栏。
二、缺陷彻查清单
标注:✅已确认 / ⚠️疑点待验证 / ❌缺失。优先级:P0 必修 / P1 建议 / P2 可选。
2.1 代码 / 高亮 / 复制
| 项 | 状态 | 位置 | 问题 | 修复方案 | 优先级 |
|---|---|---|---|---|---|
行内代码 code |
✅ | Cmd COMMANDS.code |
渲染正常(toggleWrap code) | — | — |
| 代码块 ```lang 渲染 | ⚠️ | marked v15 | 解析正常,但无语言高亮(marked 纯解析) | 不引第三方库,先用 language-* class + editor.css 少量高亮单色样式;P2 再评估 highlight.js |
P1 |
| 代码块复制按钮 | ❌ | 渲染输出 | 无复制按钮 | 在 markdownToHtml 输出后为 pre>code 追加复制按钮(原生 JS,clipboard API) |
P1 |
| 代码块插入按钮 | ❌ | COMMANDS/工具栏 |
无 codeblock 命令,工具栏也无"代码块"按钮 |
新增 COMMANDS.codeblock + 工具栏可选加按钮 |
P1 |
2.2 Markdown 语法覆盖
| 语法 | 状态 | 说明 |
|---|---|---|
| 标题 #~###### | ✅ | turndown atx + marked |
| 引用 > | ✅ | blockquote → quote |
| 有序/无序列表 | ✅ | toggleList,MD 为 list 前缀 |
| 图片/链接/删除线/粗斜体 | ✅ | 已覆盖 |
| 表格 | ⚠️ | 依赖 turndown-plugin-gfm.tables;库加载失败时静默降级(typeof 判断),表格会转成普通文本。需实测加载成功路径 |
| 分割线 --- | ✅ | hr |
| 转义字符 | ⚠️ | yerning find \ 处理待实测;<、>、& 需实测双向 |
| 嵌套列表 | ⚠️ | turndown 对嵌套层级支持一般,需实测 |
2.3 双向转换一致性
| 项 | 状态 | 说明 |
|---|---|---|
| 基础往返不丢格式 | ⚠️ | marked/turndown 往返已知会丢少量内联样式/空行,需抽样实测 |
| 表格/嵌套列表/代码块往返 | ⚠️ | 重点实测,预期有偏差 |
特殊字符 < > & 转义 |
⚠️ | 实测 md→可视化→md |
| 安全:往返后 XSS | ✅ | 每次渲染/提交都过 sanitizeHTML |
2.4 XSS 安全
| 项 | 状态 | 说明 |
|---|---|---|
<script>/<iframe> 等标签过滤 |
✅ | sanitizeHTML 黑名单含 script/style/iframe/object/embed/form/svg/math/… |
on* 属性过滤 |
✅ | attrName.startsWith('on') 移除 |
href/src=javascript: 过滤 |
✅ | 已拦,且 COMMANDS.link 另拦 data: |
src/href=data: 防堵 |
⚠️ | sanitizeHTML 只拦 javascript:,未拦 data:(link 命令拦了,但粘贴/外部 HTML 未全面拦)。建议补 data: 白名单到 sanitize 的 src/href 校验 |
| style 属性样式注入 | ⚠️ | 未巡察色/背景之外的恶意 style。影响小,可列 P2 |
2.5 上传对接
| 项 | 状态 | 说明 |
|---|---|---|
图片上传 /api/upload?ajax=1 |
✅ | 已对接,返回 {success,url},带 csrf |
| 粘贴上传 | ✅ | paste 监听 + uploadPastedImage |
| 上传失败提示 | ✅ | alert(__t('js.upload_failed')) |
| 大文件限制 | ⚠️ | 前端无 size 预检;后端 RateLimiter 每分 5 次。前端可加客户端大小提示(P2) |
2.6 草稿
| 项 | 状态 | 说明 |
|---|---|---|
| draft_saver 读写 | ✅ | 依赖 ACTIVE_EDITORS[].editor.innerHTML,插件侧不在此次改动范围 |
| 恢复保留格式 | ✅ | 恢复回 innerHTML,格式保留 |
| 过期清理 | ✅ | 插件处理 |
2.7 边界情况
| 项 | 状态 | 说明 |
|---|---|---|
| 空内容提交 | ✅ | hidden 值为空串,后端判空 |
| 超长内容 | ✅ | 有 5000000 截断保护 + alert |
| emoji/unicode | ⚠️ | 实测 base64(utf8) 无损 |
| 多标签页并编 | ⚠️ | 各编辑器独立 ACTIVE_EDITORS,应无串扰,实测 |
| 网络断开提交 | — | 由 form 提交/后端处理,编辑器不承担 |
2.8 移动端
| 项 | 状态 | 说明 |
|---|---|---|
| 工具栏换行错乱 | ⚠️ | 覆盖色按钮多,窄屏大概率换行,需实测 + editor.css 用 flex-wrap/hide 低优按钮 |
| 虚拟键盘遮挡 | ✅ | 已有移动端默认高度 180px 适配 |
| 触摸操作 | ⚠️ | 选区保存已做 mouseup/keyup,touch 事件实测 |
三、改动清单(确认后执行)
仅触及 assets/js/editor.js 与 assets/css/editor.css:
- 新增全局 keydown 分发器(
keydownDispatch):解析ctrlKey||metaKey+shiftKey+ key 映射到命令表 → 复用handleCommand;处理isComposing/229、preventDefault。 - 新增
COMMANDS.codeblock(可视化插入代码块)。 - Ctrl+Enter 提交:绑定到各编辑器容器 + form 提交。
- Esc 退出全屏:全屏态拦截。
- 工具栏加快捷键帮助按钮 + editor.css 样式。
- 代码块复制按钮(渲染后追加)。
- sanitizeHTML 补
data:src/href 拦截(P1 安全增强)。 - editor.css 补:快捷键按扭/代码块/复制按钮/移动端工具栏适配样式。
第 7 项若你希望"彻底回到旧接口"式最小改动,可保留现状并仅记为记录。
四、回归验证清单
按你提供的执行要求:
- 所有快捷键可视化 tab 与 Markdown tab 均生效
- 中文输入法(isComposing)下 Enter 不误提交;未聚焦编辑器时不劫持浏览器键位
- 代码块渲染正常、带
language-*类、有复制按钮 - 表格/嵌套列表/代码块双向转换不丢格式(抽样实测)
-
< > &转义正确 - XSS:
<script>/<iframe>/on*/javascript:/data:均被净化 - 上传/粘贴/草稿/空内容/超长/emoji 正常
- 移动端工具栏不换行错乱、软键盘不遮挡
验证路径沿用你既有的 cli/smoke_frontend_flow.php 流程逐项跑。
五、待你确认的决策点
- 代码高亮:本次先"无第三方库",用
language-*+ 简单 CSS(P1);是否接受?还是要引 highlight.js(超"只动两文件"约束)? - 快捷键帮助:新增工具栏按钮(推荐)OR 底部字数旁?图标?
- Mac(Cmd)键位:一律只用 Ctrl(推荐,跨平台一致)还是加 Cmd 分支?
- sanitize
data:拦截(P1 安全项):是否纳入本次? - 代码块复制按钮 + 工具栏代码块按钮:都加,还是仅加快捷键触达?
六、已确认的决策口径(用户拍板)
- 代码高亮:不引第三方库,用
language-*class + editor.css 简单单色样式;P2 再评估 highlight.js。 - 快捷键帮助:工具栏末尾加按钮(复用
addBtn,data-cmd="kbd"),点击弹中文帮助面板,图标沿用现有 fa 体系。 - Mac 键位:一律只用 Ctrl,不加 Cmd 分支,跨平台一致。
- sanitize
data:拦截:纳入本次。链接href的data:一律拦;图片src仅放行data:image/*,其余data:拦截(不误伤正常内联图)。 - 代码块按钮:工具栏"代码块"按钮 + 渲染后追加"复制"按钮,两个都加。
补充 1(快捷键分发区分模式):keydown 里先判 mdWrap.style.display;可视化→handleCommand(id,cmd),Markdown→insertMDTag(textarea,tag),不混。
补充 2(Ctrl+Enter 取当前表单):用 editor.closest('form');优先 form.requestSubmit(),取不到再 form.dispatchEvent(new Event('submit')),不直接 form.submit()(绕过 submit 监听里 Base64 写 hidden 的逻辑)。
七、最终改动 diff(已确认,待落地)
改①:新增 COMMANDS.codeblock(可视化插入代码块)
锚点:COMMANDS.hr = ...; 定义之后。
// 4.8.5 代码块(可视化模式插入 <pre><code class="language-text">)
COMMANDS.codeblock = function() {
var sel = window.getSelection();
var code = document.createElement('code');
code.className = 'language-text';
var pre = document.createElement('pre');
var hasSel = sel && sel.rangeCount && !sel.isCollapsed;
if (hasSel) code.textContent = sel.getRangeAt(0).toString();
pre.appendChild(code);
var range = hasSel ? sel.getRangeAt(0) : getAnyRange();
if (range) {
range.deleteContents();
range.insertNode(pre);
var br = document.createElement('br');
pre.parentNode.insertBefore(br, pre.nextSibling);
sel.removeAllRanges();
var newRange = document.createRange();
newRange.setStartAfter(br);
newRange.collapse(true);
sel.addRange(newRange);
} else {
code.textContent = '在此输入代码';
}
};
改②:handleCommand 增加 codeblock 路由
锚点:removeFormat 分支之后、// 通用命令 之前。
if (cmd === 'codeblock') {
COMMANDS.codeblock();
afterEdit(editorId);
return;
}
MD 模式已有
'codeblock': 'codeblock'映射,Ctrl+Shift+K经handleMdModeCommand自动落入insertMDTag,无需改。
改③:全局 keydown 分发器(最终干净版)
用
e.keyCode判定数字与字母,规避 Shift 下e.key因键盘布局变化的差异。Shift 组合的"符号键"分支去掉,改用 keyCode。
锚点:在 afterEdit 函数之前新增。
// ===================================================================
// 12. 快捷键分发(双模:可视化走 handleCommand,Markdown 走 insertMDTag)
// ===================================================================
// 非 Shift 组合:B/I/U/K(枚举 key+keyCode,稳)
var VISUAL_KEYS = { 66:'bold', 73:'italic', 85:'underline', 75:'link' }; // keyCode B/I/U/K
var MD_KEYS = { 66:'bold', 73:'italic', 85:null, 75:'link' };
// Shift 组合:Ctrl+Shift+7/8/9 与 +C/+K(keyCode)
var VISUAL_SHIFT_KEYS = { 55:'insertOrderedList', 56:'insertUnorderedList', 57:'blockquote', 67:'code', 75:'codeblock' };
var MD_SHIFT_KEYS = { 55:'list', 56:'list', 57:'quote', 67:'code', 75:'codeblock' };
function getFocusedEditor(target) {
for (var id in ACTIVE_EDITORS) {
if (!ACTIVE_EDITORS.hasOwnProperty(id)) continue;
var ed = ACTIVE_EDITORS[id];
if (ed.container === target || ed.container.contains(target)) return ed;
}
return null;
}
function keydownDispatch(e) {
// 中文输入法组合中(含 Enter 选词)一律不响应
if (e.isComposing || e.keyCode === 229) return;
var ed = getFocusedEditor(e.target);
if (!ed) return; // 未聚焦任何编辑器 → 不劫持浏览器键位
var mod = e.ctrlKey || e.metaKey;
// Ctrl+Enter → 提交当前表单(优先 requestSubmit,其次派发 submit 事件)
if (mod && (e.key === 'Enter' || e.keyCode === 13)) {
e.preventDefault();
var form = (ed.editor && ed.editor.closest('form')) || ed.form;
if (!form) return;
if (typeof form.requestSubmit === 'function') form.requestSubmit();
else form.dispatchEvent(new Event('submit', { cancellable: true, bubbles: true }));
return;
}
// Esc → 退出全屏(仅全屏态)
if (e.key === 'Escape' || e.keyCode === 27) {
if (ed.container && ed.container.classList.contains('editor-fullscreen')) {
e.preventDefault();
COMMANDS.fullscreen(ed.id || ed.originalTextareaId);
}
return;
}
if (!mod || e.altKey) return;
var isMd = ed.mdWrap && ed.mdWrap.style.display !== 'none';
if (e.shiftKey) {
var sCmd = isMd ? MD_SHIFT_KEYS[e.keyCode] : VISUAL_SHIFT_KEYS[e.keyCode];
if (!sCmd) return;
e.preventDefault();
if (isMd) insertMDTag(ed.mdTextarea, sCmd);
else handleCommand(ed.id || ed.originalTextareaId, sCmd);
return;
}
var cmd = isMd ? MD_KEYS[e.keyCode] : VISUAL_KEYS[e.keyCode];
if (!cmd) return; // 纯 B/I/U/K 之外不劫持,保留 Ctrl+C/V/A 等系统行为
e.preventDefault();
if (isMd) insertMDTag(ed.mdTextarea, cmd);
else handleCommand(ed.id || ed.originalTextareaId, cmd);
}
备注:
ed.id || ed.originalTextareaId落地时统一为从ACTIVE_EDITORS反向求 key 的取用方式(与现有getEditorIdByToolbar一致),保证拿到正确 editorId。ed.form为保留字段,落地时若不存在则仅依赖closest('form')。
改④:挂载 keydown 监听
锚点:form 提交绑定块之后、editor.addEventListener("input"...) 之前。
// 快捷键:绑定到编辑器容器(焦点在其内部时才响应)
container.addEventListener("keydown", keydownDispatch);
改⑤:工具栏新增"代码块"+"快捷键帮助"两按钮
锚点:createToolbar 中全屏按钮之前。
// === 代码块 ===
addBtn(tb, 'codeblock', '<i class="fa fa-code"></i>', window.__t('js.codeblock') || '代码块');
// === 快捷键帮助 ===
addBtn(tb, 'kbd', '<i class="fa fa-keyboard-o"></i>', window.__t('js.kbd') || '快捷键');
handleCommand 中在 codeblock 分支旁补:
if (cmd === 'kbd') {
toggleKbdHelp(editorId);
return;
}
新增 toggleKbdHelp(editorId)(在 keydownDispatch 旁):渲染固定层列出键位,中文文案 + 关闭按钮 + 一次性面板(复用现有点击遮罩关闭的写法)。
改⑥:代码块渲染后追加"复制"按钮
锚点:新增 attachCopyButtons,并在两处已加载 pre>code 后调用:
switchEditorMode可视化分支editor.innerHTML = sanitizeHTML(markdownToHtml(md));之后initEditor初次editor.innerHTML = sanitizeHTML(...)之后
function attachCopyButtons(editor) {
if (!editor) return;
var blocks = editor.querySelectorAll('pre > code');
for (var i = 0; i < blocks.length; i++) {
var pre = blocks[i].parentNode;
if (pre.querySelector('.editor-copy-btn')) continue;
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'editor-copy-btn';
btn.setAttribute('contenteditable', 'false');
btn.textContent = window.__t('js.copy') || '复制';
btn.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
var text = this.previousElementSibling.textContent;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function(){ selfCtx.textContent = '已复制'; },
function(){ selfCtx.textContent = '复制失败'; }
);
} else {
var ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch(err) {}
document.body.removeChild(ta);
this.textContent = '已复制';
}
var _self = this;
setTimeout(function(){ _self.textContent = window.__t('js.copy') || '复制'; }, 1200);
});
pre.appendChild(btn);
}
}
备注:
selfCtx落地时改回闭包内var _self = this统一写法(上面措辞是我为清晰表述留的占位,落地版用单一_self)。
改⑦:sanitizeHTML 补 data: 拦截
锚点:原设置在 href/src 处,新增 data: 判定。
// 原:
// (attrName === "href" && attrs[i].value.toLowerCase().indexOf("javascript:") === 0) ||
// (attrName === "src" && attrs[i].value.toLowerCase().indexOf("javascript:") === 0))
// 改为:
var _v = attrs[i].value.toLowerCase().trim();
var _isJs = _v.indexOf("javascript:") === 0;
// src 放行内联图片 data:image/*,href/link 的 data: 一律拦
var _isBadData = _v.indexOf("data:") === 0 && !(attrName === "src" && /^data:image\//.test(_v));
if (_isJs ||
(attrName === "href" && _v.indexOf("data:") === 0) ||
(attrName === "src" && _v.indexOf("data:") === 0 && !/^data:image\//.test(_v))) {
node.removeAttribute(attrs[i].name);
}
决策落实:链接
data:全拦;图片src仅放行data:image/*,其余(data:text/html等)拦截,不误伤正常内联图。
改⑧:editor.css 补充样式
锚点:文件末尾追加。
/* 代码块定位锚 */
.editor-editable pre{position:relative;background:var(--code-bg,#0d1117);color:#d4d4d4;border-radius:6px;padding:12px;overflow-x:auto;margin:8px 0}
.editor-editable code.language-text{font-family:'Consolas','Monaco',monospace;background:none;color:inherit}
/* 代码块复制按钮 */
.editor-copy-btn{position:absolute;top:6px;right:6px;font-size:12px;padding:2px 8px;border:1px solid var(--line);border-radius:4px;background:var(--panel);color:var(--mn-content-text);cursor:pointer;opacity:.75;transition:opacity .2s}
.editor-copy-btn:hover{opacity:1}
/* 快捷键帮助面板 */
.editor-kbd-panel{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.5)}
.editor-kbd-box{max-width:520px;width:90%;background:var(--panel,#fff);border:1px solid var(--line);border-radius:12px;padding:20px}
.editor-kbd-box h4{margin:0 0 12px}
.editor-kbd-grid{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:14px}
.editor-kbd-grid kbd{font-family:monospace;background:var(--bg);border:1px solid var(--line);border-radius:4px;padding:1px 6px}
/* 移动端:窄屏隐藏低优帮助按钮,工具栏自适应换行 */
@media(max-width:640px){.editor-toolbar .editor-btn[data-cmd="kbd"]{display:none}}
八、落地注意点(diff 中的占位清理)
- ③ 的
ed.id || ed.originalTextareaId→ 落地统一为从ACTIVE_EDITORS反推 editorId(复用getEditorIdByToolbar思路)。 - ⑥ 的
selfCtx→ 落地统一用闭包内_self单变量。 - ⑦ 的
_isBadData变量在落地时并入判断、清理无用变量。
diff 章仅作落地参照;实际改动以 Edit 落地并跑回归为准。