Files
mce/templates/MetaLab-2026/static/js/editor.js
Victor_Jay 2917562bf0 fix: 修复编辑器代码块样式不一致 + 提交 HTML 保留格式 + 详情页布局优化
- 编辑器 pre code 移除 wangEditor 内置 border,与预览区样式统一
- 代码块语言标签 padding 加大并用 !important 对抗 wangEditor 全局重置
- 编辑器 ::before 伪元素仅作降级方案,避免与 JS 注入的 .code-lang-label 冲突
- 编辑器 pre padding-top 统一为 36px,font-size/line-height 对齐预览区
- 提交改用 editor.getHtml(),后端 sanitizeHTML 替代 renderMarkdown,保留富文本格式
- 详情页 post-detail-header 覆盖 common.css sticky,固定在文章卡片内
- 详情页增加白色卡片背景、圆角、阴影和内边距
- 详情页添加代码块语言标签 JS 注入
- 详情页用户名移除括号 UID 显示,移除管理按钮
2026-05-28 00:53:02 +08:00

417 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* MetaLab Markdown Editor — wangEditor 5 版
*
* 使用 wangEditor 所见即所得编辑器,通过 Markdown 语法输入实现快捷编辑。
* 提交时将内容转为 Markdown 字符串发送到后端。
*/
(function () {
'use strict';
/* ================================================================
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');
const isEdit = !!(postIdEl && postIdEl.value);
let previewVisible = false;
let syncTimer = null;
let saveTimer = null;
let splitTimer = null;
let draftCharCount = 0;
let editor = null;
/* ================================================================
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') : '';
}
/* ================================================================
创建 wangEditor 编辑器
================================================================ */
function initEditor() {
if (!window.wangEditor) {
console.error('wangEditor not loaded, retrying...');
setTimeout(initEditor, 200);
return;
}
const { createEditor, createToolbar } = window.wangEditor;
const editorConfig = {
placeholder: '在此输入正文...',
onChange() {
// 同步更新代码块语言标签(立即生效)
updateCodeLabels();
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
updateStats();
if (previewVisible) updatePreview();
}, 200);
// 字数触发:每新增 300 字符保存
// 时间触发:停止输入 10 秒后自动保存(防止末尾内容丢失)
const currentLen = (editor ? editor.getText().length : 0);
if (currentLen - draftCharCount >= 300) {
draftCharCount = currentLen;
saveDraft();
}
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 10000);
},
MENU_CONF: {},
};
// 配置上传图片
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 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: {},
mode: 'default',
});
// 移除原始 textarea
if (bodyEl) bodyEl.remove();
// 初始化
updateStats();
updateCodeLabels();
loadDraft();
titleEl.addEventListener('input', () => {
saveDraft();
});
// 滚动同步
previewCont.addEventListener('scroll', syncScrollToEditor);
setTimeout(() => {
const scroller = document.getElementById('editor-container') || editorPane;
scroller.addEventListener('scroll', syncScrollToPreview);
}, 500);
console.log('%c📝 MetaLab Editor (wangEditor) ready', 'color:#6366f1;font-weight:bold');
}
/* ================================================================
字数统计
================================================================ */
function getEditorText() {
return editor ? editor.getText() : '';
}
function getEditorHtml() {
return editor ? editor.getHtml() : '';
}
/* ================================================================
代码块语言标签 — 从 <code class="language-xxx"> 提取并注入标签
================================================================ */
function updateCodeLabels() {
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');
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);
}
}
});
} 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);
}
function togglePreview() {
previewVisible = !previewVisible;
previewPane.classList.toggle('visible', previewVisible);
if (previewVisible) updatePreview();
}
/* ================================================================
滚动同步
================================================================ */
let scrollSyncing = false;
function syncScrollToPreview() {
if (!previewVisible || scrollSyncing) return;
scrollSyncing = true;
const scroller = document.getElementById('editor-container') || editorPane;
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 syncScrollToEditor() {
if (!previewVisible || scrollSyncing) return;
scrollSyncing = true;
const ratio = previewCont.scrollTop / Math.max(1, previewCont.scrollHeight - previewCont.clientHeight);
const scroller = document.getElementById('editor-container') || editorPane;
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();
}
}
} catch (_) { localStorage.removeItem(key); }
}
function clearDraft() {
const key = 'mlb_draft_' + (isEdit ? postIdEl.value : 'new');
localStorage.removeItem(key);
}
/* ================================================================
全屏编辑
================================================================ */
function toggleFullscreen() {
document.querySelector('.editor-layout').classList.toggle('fullscreen');
if (editor) editor.focus(true);
}
/* ================================================================
事件绑定
================================================================ */
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;
}
if (e.key === 'F11') {
e.preventDefault();
toggleFullscreen();
return;
}
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);
}
}
});
/* ================================================================
表单提交
================================================================ */
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();
}
})();