- 编辑器 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 显示,移除管理按钮
417 lines
14 KiB
JavaScript
417 lines
14 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
|
||
})();
|