feat: 用 wangEditor 5 替换 CodeMirror 6

This commit is contained in:
2026-05-27 20:24:56 +08:00
parent c326cbc104
commit 9d17185e11
5 changed files with 24508 additions and 631 deletions

View File

@ -1,274 +1,149 @@
/**
* MetaLab Markdown Editor — CodeMirror 6
* MetaLab Markdown Editor — wangEditor 5
*
* 依赖通过 importmap 指向 esm.sh所有 @codemirror/@lezer 共享同一构建树,
* 避免多实例导致的 "Unrecognized extension value" 错误
* 使用 wangEditor 所见即所得编辑器,通过 Markdown 语法输入实现快捷编辑。
* 提交时将内容转为 Markdown 字符串发送到后端
*/
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { languages } from '@codemirror/language-data';
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
import { searchKeymap } from '@codemirror/search';
import { autocompletion } from '@codemirror/autocomplete';
(function () {
'use strict';
/* ================================================================
DOM 引用
================================================================ */
const form = document.getElementById('postForm');
if (!form) throw new Error('postForm not found');
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 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 toolbar = document.getElementById('toolbar');
const langPicker = document.getElementById('langPicker');
const langList = document.getElementById('langList');
const langSearch = document.getElementById('langSearch');
const imageInput = document.getElementById('imageFileInput');
const btnToggle = document.getElementById('btnTogglePreview');
const btnFull = document.getElementById('btnFullscreen');
const wordCount = document.getElementById('wordCount');
const lineCount = document.getElementById('lineCount');
const readTime = document.getElementById('readTime');
const draftStatus = document.getElementById('draftStatus');
const isEdit = !!(postIdEl && postIdEl.value);
const isEdit = !!(postIdEl && postIdEl.value);
let previewVisible = false;
let syncTimer = null;
let saveTimer = null;
let splitTimer = null;
let langPickerActive = false;
let previewVisible = false;
let syncTimer = null;
let saveTimer = null;
let splitTimer = null;
let editor = null;
/* ================================================================
工具栏动作
================================================================ */
const toolActions = {
bold(view) { wrapSelection(view, '**', '**'); },
italic(view) { wrapSelection(view, '_', '_'); },
strikethrough(view) { wrapSelection(view, '~~', '~~'); },
code(view) { wrapSelection(view, '`', '`'); },
h1(view) { prefixLines(view, '# '); },
h2(view) { prefixLines(view, '## '); },
h3(view) { prefixLines(view, '### '); },
h4(view) { prefixLines(view, '#### '); },
link(view) { wrapSelection(view, '[', '](https://)'); },
quote(view) { prefixLines(view, '> '); },
ul(view) { prefixLines(view, '- '); },
ol(view) { prefixLines(view, '1. '); },
task(view) { prefixLines(view, '- [ ] '); },
hr(view) { insertBlock(view, '\n---\n'); },
table(view) { insertTable(view); },
pre(view) { showLangPicker(view); },
image(view) { imageInput.click(); },
};
function getView() { return editorView; }
/* ---- 选中文本包裹 ---- */
function wrapSelection(view, before, after) {
const sel = view.state.selection.main;
const text = view.state.sliceDoc(sel.from, sel.to) || (before === '[' ? 'link' : 'text');
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: before + text + after },
selection: { anchor: sel.from + before.length, head: sel.from + before.length + text.length }
});
view.focus();
}
/* ---- 行首添加前缀 ---- */
function prefixLines(view, prefix) {
const sel = view.state.selection.main;
const doc = view.state.doc;
const fromLine = doc.lineAt(sel.from);
const toLine = doc.lineAt(sel.to);
const changes = [];
for (let i = fromLine.number; i <= toLine.number; i++) {
const line = doc.line(i);
changes.push({ from: line.from, to: line.from, insert: prefix });
}
view.dispatch({
changes,
selection: { anchor: sel.from + prefix.length, head: sel.to + prefix.length * (toLine.number - fromLine.number + 1) }
});
view.focus();
}
/* ---- 插入块 ---- */
function insertBlock(view, text) {
const sel = view.state.selection.main;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: text },
selection: { anchor: sel.from + text.length }
});
view.focus();
}
/* ---- 插入表格 ---- */
function insertTable(view) {
const table = '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n';
const sel = view.state.selection.main;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: table },
selection: { anchor: sel.from + 3 }
});
view.focus();
}
/* ================================================================
代码块语言选择器
================================================================ */
const LANGUAGES = [
'bash', 'c', 'cpp', 'csharp', 'css', 'dockerfile', 'go', 'html',
'java', 'javascript', 'json', 'kotlin', 'makefile', 'markdown',
'nginx', 'php', 'python', 'ruby', 'rust', 'scss', 'shell',
'sql', 'swift', 'toml', 'typescript', 'xml', 'yaml', 'text',
];
function showLangPicker(view) {
if (langPickerActive) { hideLangPicker(); return; }
langPickerActive = true;
langList.innerHTML = LANGUAGES.map(l =>
`<div class="lang-picker-item" data-lang="${l}">${l}</div>`
).join('');
langSearch.value = '';
langPicker.style.display = 'block';
langList.querySelectorAll('.lang-picker-item').forEach(el => {
el.addEventListener('click', () => {
insertCodeBlock(view, el.dataset.lang);
hideLangPicker();
});
});
langSearch.focus();
setTimeout(() => document.addEventListener('click', onLangPickerOutside, { once: true }), 0);
}
function hideLangPicker() {
langPicker.style.display = 'none';
langPickerActive = false;
document.removeEventListener('click', onLangPickerOutside);
}
function onLangPickerOutside(e) {
if (!langPicker.contains(e.target) && e.target !== document.querySelector('[data-action="pre"]')) {
hideLangPicker();
}
}
langSearch.addEventListener('input', () => {
const q = langSearch.value.toLowerCase();
langList.querySelectorAll('.lang-picker-item').forEach(el => {
el.style.display = el.dataset.lang.includes(q) ? '' : 'none';
});
});
langSearch.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const visible = langList.querySelector('.lang-picker-item:not([style*="display: none"])');
if (visible) {
insertCodeBlock(getView(), visible.dataset.lang);
hideLangPicker();
}
} else if (e.key === 'Escape') {
hideLangPicker();
}
});
function insertCodeBlock(view, lang) {
const sel = view.state.selection.main;
const text = view.state.sliceDoc(sel.from, sel.to);
const block = '\n```' + (lang || 'text') + '\n' + (text || '') + '\n```\n';
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: block },
selection: { anchor: sel.from + 4 + lang.length, head: sel.from + 4 + lang.length + text.length }
});
view.focus();
}
/* ================================================================
图片上传(含进度反馈)
CSRF Token
================================================================ */
function getCSRFToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
let uploadToastTimer = null;
function showUploadToast(msg, isError) {
// 在状态栏中显示上传状态
if (draftStatus) {
clearTimeout(uploadToastTimer);
draftStatus.style.display = '';
draftStatus.textContent = msg;
draftStatus.style.color = isError ? '#dc2626' : '#059669';
uploadToastTimer = setTimeout(() => {
draftStatus.style.display = 'none';
draftStatus.style.color = '#059669';
}, 3000);
/* ================================================================
创建 wangEditor 编辑器
================================================================ */
function initEditor() {
if (!window.wangEditor) {
console.error('wangEditor not loaded, retrying...');
setTimeout(initEditor, 200);
return;
}
}
imageInput.addEventListener('change', () => {
const file = imageInput.files[0];
if (!file) return;
uploadImage(file);
imageInput.value = '';
});
const { createEditor, createToolbar } = window.wangEditor;
function uploadImage(file) {
const fd = new FormData();
fd.append('file', file);
const editorConfig = {
placeholder: '在此输入正文...',
onChange() {
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
updateStats();
if (previewVisible) updatePreview();
}, 200);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/posts/upload-image');
xhr.setRequestHeader('X-CSRF-Token', getCSRFToken());
showUploadToast('上传中...', false);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100);
showUploadToast('上传中 ' + pct + '%', false);
}
});
xhr.onload = () => {
try {
const d = JSON.parse(xhr.responseText);
if (d.success && d.data && d.data.url) {
insertImageMarkdown(d.data.url);
showUploadToast('上传成功', false);
} else {
showUploadToast(d.message || '上传失败', true);
}
} catch (_) {
showUploadToast('上传失败', true);
}
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 30000);
},
MENU_CONF: {},
};
xhr.onerror = () => showUploadToast('上传请求失败', true);
xhr.send(fd);
// 配置上传图片
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 insertImageMarkdown(url) {
const view = getView();
const sel = view.state.selection.main;
const alt = view.state.sliceDoc(sel.from, sel.to) || 'image';
const md = '![' + alt + '](' + url + ')';
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: md },
selection: { anchor: sel.from + md.length }
});
view.focus();
/* ================================================================
字数统计
================================================================ */
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 + ' 分钟';
}
/* ================================================================
@ -287,7 +162,7 @@ function renderMarkdown(md, callback) {
function updatePreview() {
if (!previewVisible) return;
const md = getView().state.doc.toString();
const md = getEditorText();
if (!md.trim()) {
previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>';
return;
@ -301,7 +176,6 @@ function updatePreview() {
function togglePreview() {
previewVisible = !previewVisible;
previewPane.classList.toggle('visible', previewVisible);
btnToggle.classList.toggle('active', previewVisible);
if (previewVisible) updatePreview();
}
@ -313,7 +187,7 @@ let scrollSyncing = false;
function syncScrollToPreview() {
if (!previewVisible || scrollSyncing) return;
scrollSyncing = true;
const scroller = getView().scrollDOM;
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; });
@ -323,32 +197,17 @@ function syncScrollToEditor() {
if (!previewVisible || scrollSyncing) return;
scrollSyncing = true;
const ratio = previewCont.scrollTop / Math.max(1, previewCont.scrollHeight - previewCont.clientHeight);
const scroller = getView().scrollDOM;
const scroller = document.getElementById('editor-container') || editorPane;
scroller.scrollTop = ratio * Math.max(1, scroller.scrollHeight - scroller.clientHeight);
requestAnimationFrame(() => { scrollSyncing = false; });
}
/* ================================================================
字数统计
================================================================ */
function updateStats() {
const text = getView().state.doc.toString();
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));
wordCount.textContent = chars + ' 字';
lineCount.textContent = lines + ' 行';
readTime.textContent = '约 ' + minutes + ' 分钟';
}
/* ================================================================
自动保存草稿
================================================================ */
function saveDraft() {
const title = titleEl.value.trim();
const body = getView().state.doc.toString();
const body = getEditorText();
if (!body && !title) return;
@ -378,11 +237,9 @@ function loadDraft() {
try {
const draft = JSON.parse(raw);
if (draft.body || draft.title) {
if (!isEdit || !getView().state.doc.toString()) {
if (!isEdit || !getEditorText()) {
if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) {
getView().dispatch({
changes: { from: 0, to: getView().state.doc.length, insert: draft.body }
});
if (editor) editor.setHtml(draft.body);
if (draft.title && !titleEl.value) titleEl.value = draft.title;
}
}
@ -400,114 +257,48 @@ function clearDraft() {
================================================================ */
function toggleFullscreen() {
document.querySelector('.editor-layout').classList.toggle('fullscreen');
getView().focus();
if (editor) editor.focus(true);
}
/* ================================================================
自定义快捷键
================================================================ */
const customKeymap = keymap.of([
{ key: 'Mod-b', run: (v) => { toolActions.bold(v); return true; } },
{ key: 'Mod-i', run: (v) => { toolActions.italic(v); return true; } },
{ key: 'Mod-k', run: (v) => { toolActions.link(v); return true; } },
{ key: 'Mod-Shift-i', run: (v) => { toolActions.image(v); return true; } },
{ key: 'Mod-`', run: (v) => { toolActions.code(v); return true; } },
{ key: 'Mod-s', run: () => { saveDraft(); return true; }, preventDefault: true },
{ key: 'Mod-Shift-p', run: () => { togglePreview(); return true; } },
{ key: 'F11', run: () => { toggleFullscreen(); return true; }, preventDefault: true },
]);
/* ================================================================
创建 CodeMirror 编辑器
================================================================ */
let editorView;
const extensions = [
lineNumbers(),
highlightActiveLine(),
drawSelection(),
markdown({
base: markdownLanguage,
codeLanguages: languages,
}),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
history(),
autocompletion(),
indentWithTab,
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
customKeymap,
EditorView.updateListener.of(update => {
if (update.docChanged) {
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
updateStats();
if (previewVisible) updatePreview();
}, 200);
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 30000);
}
}),
EditorView.domEventHandlers({
paste: (e, view) => {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
uploadImage(item.getAsFile());
return true;
}
}
return false;
},
drop: (e, view) => {
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].type.startsWith('image/')) {
e.preventDefault();
uploadImage(files[0]);
return true;
}
return false;
},
}),
];
// 获取初始内容
const initialBody = bodyEl ? bodyEl.value : '';
editorView = new EditorView({
state: EditorState.create({
doc: initialBody,
extensions,
}),
parent: editorPane,
});
// 隐藏原始 textarea
if (bodyEl) bodyEl.remove();
/* ================================================================
事件绑定
================================================================ */
// 工具栏点击
toolbar.addEventListener('click', e => {
const btn = e.target.closest('.tb-btn');
if (!btn || btn.dataset.action === 'image' || btn.dataset.action === 'pre') return;
const action = toolActions[btn.dataset.action];
if (action) action(editorView);
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);
}
}
});
// 预览切换
btnToggle.addEventListener('click', togglePreview);
// 全屏
btnFull.addEventListener('click', toggleFullscreen);
// 滚动同步
previewCont.addEventListener('scroll', syncScrollToEditor);
const cmScroller = editorView.scrollDOM;
cmScroller.addEventListener('scroll', syncScrollToPreview);
/* ================================================================
表单提交
================================================================ */
@ -515,7 +306,7 @@ form.addEventListener('submit', e => {
e.preventDefault();
const title = titleEl.value.trim();
const body = editorView.state.doc.toString().trim();
const body = getEditorText();
if (!title) { alert('请输入标题'); return; }
if (!body) { alert('请输入正文'); return; }
@ -548,29 +339,11 @@ form.addEventListener('submit', e => {
.catch(() => alert('请求失败'));
});
/* ================================================================
键盘事件ESC 退出全屏)
================================================================ */
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
const layout = document.querySelector('.editor-layout');
if (layout && layout.classList.contains('fullscreen')) {
layout.classList.remove('fullscreen');
editorView.focus();
}
}
});
// 启动:等 wangEditor 加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initEditor);
} else {
initEditor();
}
/* ================================================================
初始化
================================================================ */
updateStats();
loadDraft();
saveTimer = setTimeout(saveDraft, 30000);
titleEl.addEventListener('input', () => {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 30000);
});
console.log('%c📝 MetaLab Markdown Editor (CodeMirror 6) ready', 'color:#6366f1;font-weight:bold');
})();