feat: wangEditor 替换为 ByteMD Markdown 编辑器

- 移除 wangEditor 5 库文件(1.27MB JS + 14KB CSS,含手动 hack)
- 集成 ByteMD 1.22.0(基于 CodeMirror 5,不依赖 contentEditable)
- 编辑器输出 Markdown 原文,后端 goldmark + bluemonday 双重防护渲染 HTML
- 删除 editor.js 中 scrollIntoView hack、code-lang-label DOM 注入、PreviewAPI 调用
- 清理 posts.css 中 ~200 行 wangEditor 样式覆盖(w-e-* 类名)
- 清理 show.html 中 code-lang-label JS 注入(goldmark 自带 language-xxx class)
- 更新 CSP 安全策略:移除 CodeMirror 注释,保留 esm.sh CDN
- go.mod:bluemonday 和 goldmark 均保留为直接依赖(纵深防御)
This commit is contained in:
2026-05-28 02:16:50 +08:00
parent 804fd50edf
commit 22a302315a
9 changed files with 222 additions and 24906 deletions

View File

@ -1,486 +1,214 @@
/**
* MetaLab Markdown Editor — wangEditor 5 版
* MetaLab Editor — ByteMD Markdown 编辑器
*
* 使用 wangEditor 所见即所得编辑器,通过 Markdown 语法输入实现快捷编辑。
* 提交时将内容转为 Markdown 字符串发送到后端
* ByteMD 基于 CodeMirror 5纯 textarea 兜底,不依赖 contentEditable
* 输出 Markdown 原文,由后端 goldmark 统一渲染 HTML
*
* 安全模型Markdown 文本 → goldmark → HTMLAST 生成,天然安全)
* 前端不再做 sanitizeXSS 风险由 goldmark 的 AST 输出消除。
*/
import { Editor } from 'https://esm.sh/bytemd@1.22.0'
import gfm from 'https://esm.sh/@bytemd/plugin-gfm@1.22.0'
import highlight from 'https://esm.sh/@bytemd/plugin-highlight@1.22.0'
(function () {
'use strict';
// ---- 状态 ----
let editor = null
let currentValue = ''
let draftTimer = null
/* ================================================================
DOM 引用
================================================================ */
const form = document.getElementById('postForm');
const postIdEl = document.getElementById('postId');
const titleEl = document.getElementById('postTitle');
const bodyEl = document.getElementById('postBody');
const editorPane = document.getElementById('editorPane');
const previewPane = document.getElementById('previewPane');
const previewCont = document.getElementById('previewContent');
const btnToggle = document.getElementById('btnTogglePreview');
const btnFull = document.getElementById('btnFullscreen');
const wordCountEl = document.getElementById('wordCount');
const lineCountEl = document.getElementById('lineCount');
const readTimeEl = document.getElementById('readTime');
const draftStatus = document.getElementById('draftStatus');
// ---- 初始化 ----
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('editor-container')
if (!container) return
const isEdit = !!(postIdEl && postIdEl.value);
const postId = document.getElementById('postId')?.value
const isEdit = !!postId
let previewVisible = false;
let syncTimer = null;
let saveTimer = null;
let splitTimer = null;
let draftCharCount = 0;
let editor = null;
// 从容器 data-value 属性获取初始内容(由后端 SSR 渲染)
const initialValue = container.dataset.value || ''
currentValue = initialValue
/* ================================================================
CSRF Token
================================================================ */
function getCSRFToken() {
// 优先从 cookie 读取(与 CSRF Double Submit Cookie 一致)
const match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/);
if (match) return match[1];
// fallback: 从 meta 标签读取
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
const plugins = [
gfm(),
highlight(),
]
/* ================================================================
创建 wangEditor 编辑器
================================================================ */
function initEditor() {
if (!window.wangEditor) {
console.error('wangEditor not loaded, retrying...');
setTimeout(initEditor, 200);
return;
editor = new Editor({
target: container,
props: {
value: initialValue,
plugins,
mode: 'split',
placeholder: '在此输入正文... (Markdown)',
uploadImages: uploadImage,
},
})
// 监听内容变化,同步 currentValue
editor.$on('change', (e) => {
currentValue = e.detail.value
updateWordCount(currentValue)
scheduleDraft(currentValue)
})
// 初始字数统计
updateWordCount(initialValue)
// 恢复草稿(延迟确保 ByteMD 完全初始化)
if (!isEdit) {
setTimeout(() => restoreDraft(), 100)
}
const { createEditor, createToolbar } = window.wangEditor;
// 全屏切换
document.getElementById('btnFullscreen')?.addEventListener('click', toggleFullscreen)
const editorConfig = {
placeholder: '在此输入正文...',
onChange() {
// 延迟更新代码块语言标签(同步操作 DOM 会干扰 wangEditor 内部同步,导致滚动跳动)
updateCodeLabels();
// 表单提交
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
// 替代 wangEditor 内置 scrollIntoView已禁用仅在光标不可见时微调滚动
ensureCursorVisible();
// Ctrl+S 快捷键
document.addEventListener('keydown', handleKeyboard)
})
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
updateStats();
if (previewVisible) updatePreview();
}, 200);
// ---- 图片上传 ----
async function uploadImage(files) {
const csrfMeta = document.querySelector('meta[name="csrf-token"]')
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
// 字数触发:每新增 300 字符保存
// 时间触发:停止输入 10 秒后自动保存(防止末尾内容丢失)
const currentLen = (editor ? editor.getText().length : 0);
if (currentLen - draftCharCount >= 300) {
draftCharCount = currentLen;
saveDraft();
}
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 10000);
},
MENU_CONF: {},
};
const results = []
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
// 配置上传图片
editorConfig.MENU_CONF['uploadImage'] = {
maxFileSize: 5 * 1024 * 1024,
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
// 自定义上传逻辑,适配后端 API
customUpload(file, insertFn) {
const fd = new FormData();
fd.append('file', file);
const resp = await fetch('/api/posts/upload-image', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf },
body: formData,
})
const data = await resp.json()
const csrf = getCSRFToken();
fetch('/api/posts/upload-image', {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-CSRF-Token': csrf,
},
body: fd,
})
.then(r => r.json())
.then(res => {
if (res.success && res.data && res.data.url) {
insertFn(res.data.url, res.data.url, res.data.url);
} else {
alert(res.message || '上传失败');
}
})
.catch(() => {
alert('上传请求失败,请检查网络');
});
},
};
// 创建编辑器
editor = createEditor({
selector: '#editor-container',
html: bodyEl ? bodyEl.value : '<p><br></p>',
config: editorConfig,
mode: 'default',
});
// 创建工具栏
createToolbar({
editor,
selector: '#toolbar-container',
config: {
excludeKeys: [
// 移除视频相关按钮:不允许视频上传,不在前端提供视频按钮
'group-video', 'insertVideo', 'uploadVideo',
// 移除"网络图片"功能,仅保留上传图片
'insertImage',
],
},
mode: 'default',
});
// 移除原始 textarea
if (bodyEl) bodyEl.remove();
// 初始化
updateStats();
updateCodeLabels();
loadDraft();
titleEl.addEventListener('input', () => {
saveDraft();
});
// 滚动同步(.w-e-scroll 是编辑区唯一滚动容器)
previewCont.addEventListener('scroll', syncScrollToEditor);
setTimeout(() => {
const scroller = (document.getElementById('editor-container') || editorPane).querySelector('.w-e-scroll');
if (scroller) scroller.addEventListener('scroll', syncScrollToPreview);
}, 500);
console.log('%c📝 MetaLab Editor (wangEditor) ready', 'color:#6366f1;font-weight:bold');
}
/* ================================================================
确保光标可见 — 替代 wangEditor 内置 scrollIntoView
wangEditor 默认使用 block:"end" 会在代码块换行时导致大幅滚动,
已在 wangeditor.js 中禁用该调用,此处用最小偏移量保证光标可见
================================================================ */
function ensureCursorVisible() {
if (!editor) return;
requestAnimationFrame(() => {
try {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
// .w-e-scroll 是编辑区滚动容器
const scroller = document.querySelector('#editor-container .w-e-scroll');
if (!scroller) return;
const cursorRect = range.getBoundingClientRect();
const scrollRect = scroller.getBoundingClientRect();
// 光标低于可见区 → 向下滚到光标可见
if (cursorRect.bottom > scrollRect.bottom) {
scroller.scrollTop += cursorRect.bottom - scrollRect.bottom + 8;
}
// 光标高于可见区 → 向上滚到光标可见
else if (cursorRect.top < scrollRect.top) {
scroller.scrollTop -= scrollRect.top - cursorRect.top + 8;
}
} catch (_) { /* 忽略 */ }
});
}
/* ================================================================
字数统计
================================================================ */
function getEditorText() {
return editor ? editor.getText() : '';
}
function getEditorHtml() {
return editor ? editor.getHtml() : '';
}
/* ================================================================
代码块语言标签 — 从 <code class="language-xxx"> 提取并注入标签
================================================================ */
// 代码块语言标签更新节流定时器
let codeLabelTimer = null;
function updateCodeLabels() {
if (!editor) return;
// 节流:避免 onChange 中频繁调用导致滚动跳动
clearTimeout(codeLabelTimer);
codeLabelTimer = setTimeout(_updateCodeLabelsImpl, 300);
}
function _updateCodeLabelsImpl() {
if (!editor) return;
// wangEditor 编辑区 <code> 不带 class="language-xxx"
// 但 getHtml() 输出带 class="language-xxx"。
// 用 DOMParser 解析 HTML 获取语言,再注入到编辑区对应 pre。
try {
const html = editor.getHtml();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const htmlPres = doc.querySelectorAll('pre');
const editorEl = document.getElementById('editor-container');
if (!editorEl) return;
const domPres = editorEl.querySelectorAll('pre');
// 保存当前滚动位置,防止 DOM 操作引起跳动
// .w-e-scroll 是 wangEditor 内部的滚动容器
const scroller = editorEl.querySelector('.w-e-scroll');
const savedScrollTop = scroller ? scroller.scrollTop : 0;
htmlPres.forEach((htmlPre, idx) => {
const domPre = domPres[idx];
if (!domPre || domPre.querySelector('.code-lang-label')) return;
const code = htmlPre.querySelector('code[class*="language-"]');
if (code) {
const match = code.className.match(/language-(\w+)/);
if (match && match[1]) {
const label = document.createElement('span');
label.className = 'code-lang-label';
label.textContent = match[1];
domPre.style.position = 'relative';
domPre.insertBefore(label, domPre.firstChild);
}
}
});
// 恢复滚动位置
if (scroller && scroller.scrollTop !== savedScrollTop) {
scroller.scrollTop = savedScrollTop;
if (data.success || data.code === 200) {
const url = data.data?.url || data.url
results.push({ url, alt: file.name, title: file.name })
}
} catch (_) { /* 忽略 */ }
}
function updateStats() {
const text = getEditorText();
const chars = text.length;
const lines = text.split('\n').length;
const words = text.trim() ? text.trim().split(/\s+/).length : 0;
const minutes = Math.max(1, Math.ceil(words / 200));
wordCountEl.textContent = chars + ' 字';
lineCountEl.textContent = lines + ' 行';
readTimeEl.textContent = '约 ' + minutes + ' 分钟';
}
/* ================================================================
预览wangEditor 直接展示 HTML无需服务端渲染
================================================================ */
function updatePreview() {
if (!previewVisible) return;
const html = editor ? editor.getHtml() : '';
if (html === '<p><br></p>' || !html.trim()) {
previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>';
return;
}
clearTimeout(splitTimer);
splitTimer = setTimeout(() => {
previewCont.innerHTML = html;
// 预览区也注入语言标签
previewCont.querySelectorAll('pre').forEach(pre => {
if (pre.querySelector('.code-lang-label')) return;
const code = pre.querySelector('code[class*="language-"]');
if (code) {
const match = code.className.match(/language-(\w+)/);
if (match) {
const label = document.createElement('span');
label.className = 'code-lang-label';
label.textContent = match[1];
pre.insertBefore(label, pre.firstChild);
}
}
});
}, 300);
return results
}
function togglePreview() {
previewVisible = !previewVisible;
previewPane.classList.toggle('visible', previewVisible);
if (previewVisible) updatePreview();
// ---- 字数统计 ----
function updateWordCount(text) {
const el = document.getElementById('wordCount')
if (!el) return
const len = (text || '').replace(/\s/g, '').length
el.textContent = len + ' 字'
}
/* ================================================================
滚动同步
================================================================ */
let scrollSyncing = false;
function getEditorScroller() {
const el = document.getElementById('editor-container') || editorPane;
return el ? (el.querySelector('.w-e-scroll') || el) : null;
// ---- 草稿保存/恢复 ----
function scheduleDraft(text) {
clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraft(text), 2000)
}
function syncScrollToPreview() {
if (!previewVisible || scrollSyncing) return;
scrollSyncing = true;
const scroller = getEditorScroller();
if (!scroller) { scrollSyncing = false; return; }
const ratio = scroller.scrollTop / Math.max(1, scroller.scrollHeight - scroller.clientHeight);
previewCont.scrollTop = ratio * Math.max(1, previewCont.scrollHeight - previewCont.clientHeight);
requestAnimationFrame(() => { scrollSyncing = false; });
}
function saveDraft(text) {
const title = document.getElementById('postTitle')?.value || ''
if (!text.trim() && !title.trim()) return
function syncScrollToEditor() {
if (!previewVisible || scrollSyncing) return;
scrollSyncing = true;
const scroller = getEditorScroller();
if (!scroller) { scrollSyncing = false; return; }
const ratio = previewCont.scrollTop / Math.max(1, previewCont.scrollHeight - previewCont.clientHeight);
scroller.scrollTop = ratio * Math.max(1, scroller.scrollHeight - scroller.clientHeight);
requestAnimationFrame(() => { scrollSyncing = false; });
}
/* ================================================================
自动保存草稿
================================================================ */
function saveDraft() {
const title = titleEl.value.trim();
const bodyHtml = getEditorHtml();
const bodyText = getEditorText().trim();
// 空编辑区不保存wangEditor 空内容 HTML 是 <p><br></p>
if (!bodyText && !title) return;
const draft = { title, body: bodyHtml, updatedAt: Date.now() };
localStorage.setItem('mlb_draft_' + (isEdit ? postIdEl.value : 'new'), JSON.stringify(draft));
draftStatus.style.display = '';
draftStatus.textContent = '草稿已保存 ' + new Date().toLocaleTimeString();
}
function loadDraft() {
const key = 'mlb_draft_' + (isEdit ? postIdEl.value : 'new');
const raw = localStorage.getItem(key);
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (draft.body || draft.title) {
// 如果编辑器已有内容(编辑模式从 DB 加载),跳过草稿恢复
if (isEdit && getEditorText().trim()) return;
if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) {
if (editor) editor.setHtml(draft.body);
if (draft.title && !titleEl.value) titleEl.value = draft.title;
} else {
// 用户拒绝恢复,清除旧草稿
clearDraft();
}
localStorage.setItem('draft_post_title', title)
localStorage.setItem('draft_post_body', text)
const statusEl = document.getElementById('draftStatus')
if (statusEl) {
statusEl.style.display = ''
setTimeout(() => { statusEl.style.display = 'none' }, 2000)
}
} catch (_) { localStorage.removeItem(key); }
} catch (e) {
// localStorage 不可用(隐私模式等),静默忽略
}
}
function restoreDraft() {
try {
const savedId = localStorage.getItem('draft_post_id')
if (savedId) return // 编辑模式不恢复草稿
const title = localStorage.getItem('draft_post_title')
const body = localStorage.getItem('draft_post_body')
if (title) {
document.getElementById('postTitle').value = title
}
if (body && editor) {
editor.$set({ value: body })
currentValue = body
updateWordCount(body)
}
} catch (e) {
// 静默忽略
}
}
function clearDraft() {
const key = 'mlb_draft_' + (isEdit ? postIdEl.value : 'new');
localStorage.removeItem(key);
try {
localStorage.removeItem('draft_post_title')
localStorage.removeItem('draft_post_body')
} catch (e) { /* ignore */ }
}
/* ================================================================
全屏编辑
================================================================ */
// ---- 全屏 ----
function toggleFullscreen() {
document.querySelector('.editor-layout').classList.toggle('fullscreen');
if (editor) editor.focus(true);
const layout = document.querySelector('.editor-layout')
if (!layout) return
layout.classList.toggle('fullscreen')
}
/* ================================================================
事件绑定
================================================================ */
if (btnToggle) btnToggle.addEventListener('click', togglePreview);
if (btnFull) btnFull.addEventListener('click', toggleFullscreen);
/* ================================================================
键盘快捷键
================================================================ */
document.addEventListener('keydown', e => {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 's') {
e.preventDefault();
saveDraft();
return;
}
if (mod && e.shiftKey && e.key === 'P') {
e.preventDefault();
togglePreview();
return;
}
// ---- 键盘快捷键 ----
function handleKeyboard(e) {
if (e.key === 'F11') {
e.preventDefault();
toggleFullscreen();
return;
e.preventDefault()
toggleFullscreen()
}
if (e.key === 'Escape') {
const layout = document.querySelector('.editor-layout');
if (layout && layout.classList.contains('fullscreen')) {
layout.classList.remove('fullscreen');
if (editor) editor.focus(true);
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
document.getElementById('postForm')?.dispatchEvent(new Event('submit'))
}
});
/* ================================================================
表单提交
================================================================ */
form.addEventListener('submit', e => {
e.preventDefault();
const title = titleEl.value.trim();
const body = getEditorHtml();
const bodyText = getEditorText();
if (!title) { alert('请输入标题'); return; }
if (!bodyText) { alert('请输入正文'); return; }
let url = '/api/posts';
let method = 'POST';
if (isEdit && postIdEl.value) {
url = '/api/posts/' + postIdEl.value;
method = 'PUT';
}
fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() },
body: JSON.stringify({ title, body })
})
.then(r => r.json())
.then(data => {
if (data.success) {
clearDraft();
if (data.data && data.data.id) {
window.location.href = '/posts/' + data.data.id;
} else {
window.location.href = '/posts';
}
} else {
alert(data.message || '操作失败');
}
})
.catch(() => alert('请求失败'));
});
// 启动:等 wangEditor 加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initEditor);
} else {
initEditor();
}
})();
// ---- 表单提交 ----
async function handleSubmit(e) {
e.preventDefault()
const postId = document.getElementById('postId')?.value
const title = document.getElementById('postTitle')?.value.trim()
if (!title) { alert('请输入标题'); return }
if (!currentValue.trim()) { alert('请输入正文'); return }
const isEdit = !!postId
const url = isEdit ? '/api/posts/' + postId : '/api/posts'
const method = isEdit ? 'PUT' : 'POST'
const csrfMeta = document.querySelector('meta[name="csrf-token"]')
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
try {
const resp = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify({ title, body: currentValue }),
})
const data = await resp.json()
if (data.success || data.code === 200) {
clearDraft()
const newPostId = data.data?.id || data.data?.ID || postId
window.location.href = newPostId ? '/posts/' + newPostId : '/posts'
} else {
alert(data.message || '操作失败')
}
} catch (err) {
alert('请求失败,请重试')
}
}