350 lines
11 KiB
JavaScript
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();
|
|
}
|
|
|
|
})();
|