Files
mce/templates/MetaLab-2026/static/js/editor.js

350 lines
11 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 editor = null;
/* ================================================================
CSRF Token
================================================================ */
function getCSRFToken() {
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() {
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
updateStats();
if (previewVisible) updatePreview();
}, 200);
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 30000);
},
MENU_CONF: {},
};
// 配置上传图片
editorConfig.MENU_CONF['uploadImage'] = {
server: '/api/posts/upload-image',
fieldName: 'file',
maxFileSize: 10 * 1024 * 1024,
maxNumberOfFiles: 10,
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
headers: {
'X-CSRF-Token': getCSRFToken(),
},
customInsert(res, insertFn) {
if (res.success && res.data && res.data.url) {
insertFn(res.data.url, res.data.url, res.data.url);
}
},
onFailed(xhr) {
console.error('图片上传失败', xhr);
},
};
// 创建编辑器
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();
loadDraft();
saveTimer = setTimeout(saveDraft, 30000);
titleEl.addEventListener('input', () => {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 30000);
});
// 滚动同步
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 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 + ' 分钟';
}
/* ================================================================
Markdown 预览(服务端渲染)
================================================================ */
function renderMarkdown(md, callback) {
fetch('/api/posts/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() },
body: JSON.stringify({ body: md })
})
.then(r => r.json())
.then(d => callback(d.success ? d.data.html : md))
.catch(() => callback(md));
}
function updatePreview() {
if (!previewVisible) return;
const md = getEditorText();
if (!md.trim()) {
previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>';
return;
}
clearTimeout(splitTimer);
splitTimer = setTimeout(() => {
renderMarkdown(md, html => { previewCont.innerHTML = html; });
}, 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 body = getEditorText();
if (!body && !title) return;
const draft = { title, body, updatedAt: Date.now() };
localStorage.setItem('mlb_draft_' + (isEdit ? postIdEl.value : 'new'), JSON.stringify(draft));
draftStatus.style.display = '';
draftStatus.textContent = '草稿已保存 ' + new Date().toLocaleTimeString();
if (isEdit && postIdEl.value) {
silentSave(title, body);
}
}
function silentSave(title, body) {
fetch('/api/posts/' + postIdEl.value, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() },
body: JSON.stringify({ title, body })
}).catch(() => {});
}
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) {
if (!isEdit || !getEditorText()) {
if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) {
if (editor) editor.setHtml(draft.body);
if (draft.title && !titleEl.value) titleEl.value = draft.title;
}
}
}
} 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 = getEditorText();
if (!title) { alert('请输入标题'); return; }
if (!body) { 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();
}
})();