[已修复] 现有编辑器改造方案 — 补快捷键 + 彻查缺陷

👑Lv.11 元老 🌛 见习会员
2026-09-17 16:04:33

一、快捷键方案

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 / insertOrderedListlink()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+TabCtrl+Shift+7/8/9 多数浏览器无默认。含 macOS:Cmd+Shift 对应的是系统命令,以下统一用 Ctrl+Shift;Mac 上若需要可另配 Cmd+Shift。以保持跨平台一致为准,Mac 键位作为可选项。
  • Ctrl+U 在 Firefox 有"查看源代码"默认——仅在 contenteditable/MD textarea 且编辑器聚焦时拦截,避免全局劫持。

1.7 快捷键帮助入口(二选一,方案阶段定)

  • 建议在工具栏末尾加一个"快捷键"按钮(复用 addBtndata-cmd="kbd"),handleCommandcmd==='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.jsassets/css/editor.css

  1. 新增全局 keydown 分发器keydownDispatch):解析 ctrlKey||metaKey + shiftKey + key 映射到命令表 → 复用 handleCommand;处理 isComposing/229preventDefault
  2. 新增 COMMANDS.codeblock(可视化插入代码块)。
  3. Ctrl+Enter 提交:绑定到各编辑器容器 + form 提交。
  4. Esc 退出全屏:全屏态拦截。
  5. 工具栏加快捷键帮助按钮 + editor.css 样式。
  6. 代码块复制按钮(渲染后追加)。
  7. sanitizeHTML 补 data: src/href 拦截(P1 安全增强)。
  8. editor.css 补:快捷键按扭/代码块/复制按钮/移动端工具栏适配样式。

第 7 项若你希望"彻底回到旧接口"式最小改动,可保留现状并仅记为记录。


四、回归验证清单

按你提供的执行要求:

  • 所有快捷键可视化 tab 与 Markdown tab 均生效
  • 中文输入法(isComposing)下 Enter 不误提交;未聚焦编辑器时不劫持浏览器键位
  • 代码块渲染正常、带 language-* 类、有复制按钮
  • 表格/嵌套列表/代码块双向转换不丢格式(抽样实测)
  • < > & 转义正确
  • XSS:<script>/<iframe>/on*/javascript:/data: 均被净化
  • 上传/粘贴/草稿/空内容/超长/emoji 正常
  • 移动端工具栏不换行错乱、软键盘不遮挡

验证路径沿用你既有的 cli/smoke_frontend_flow.php 流程逐项跑。


五、待你确认的决策点

  1. 代码高亮:本次先"无第三方库",用 language-* + 简单 CSS(P1);是否接受?还是要引 highlight.js(超"只动两文件"约束)?
  2. 快捷键帮助:新增工具栏按钮(推荐)OR 底部字数旁?图标?
  3. Mac(Cmd)键位:一律只用 Ctrl(推荐,跨平台一致)还是加 Cmd 分支?
  4. sanitize data: 拦截(P1 安全项):是否纳入本次?
  5. 代码块复制按钮 + 工具栏代码块按钮:都加,还是仅加快捷键触达?

六、已确认的决策口径(用户拍板)

  1. 代码高亮:不引第三方库,用 language-* class + editor.css 简单单色样式;P2 再评估 highlight.js。
  2. 快捷键帮助:工具栏末尾加按钮(复用 addBtndata-cmd="kbd"),点击弹中文帮助面板,图标沿用现有 fa 体系。
  3. Mac 键位:一律只用 Ctrl,不加 Cmd 分支,跨平台一致。
  4. sanitize data: 拦截:纳入本次。链接 hrefdata: 一律拦;图片 src 仅放行 data:image/*,其余 data: 拦截(不误伤正常内联图)。
  5. 代码块按钮:工具栏"代码块"按钮 + 渲染后追加"复制"按钮,两个都加。

补充 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+KhandleMdModeCommand 自动落入 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 后调用:

  1. switchEditorMode 可视化分支 editor.innerHTML = sanitizeHTML(markdownToHtml(md)); 之后
  2. 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 落地并跑回归为准

我喜欢在我的自留地里瞎逛,FlintHub!
| 瀏覽 0 | 回覆 1

全部回覆 (1)

🌳Lv.4 中级 🌛 见习会员
2026-09-17 16:29:18
勤奋的大佬,终于可以回帖了
| 引用 #1 樓

登入

×