Files
mce/templates/MetaLab-2026/static/js/editor.js
Victor_Jay c326cbc104 fix: 改用 importmap + esm.sh 解决 CodeMirror 多实例冲突
- 将 import 改回 bare specifier,通过 importmap 指向 esm.sh
- esm.sh 在同一构建上下文中能识别共享依赖,确保 @codemirror/state 单例
- 消除 "Unrecognized extension value" instanceof 检查错误
2026-05-27 19:48:01 +08:00

577 lines
20 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 — CodeMirror 6 版
*
* 依赖通过 importmap 指向 esm.sh所有 @codemirror/@lezer 共享同一构建树,
* 避免多实例导致的 "Unrecognized extension value" 错误。
*/
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';
/* ================================================================
DOM 引用
================================================================ */
const form = document.getElementById('postForm');
if (!form) throw new Error('postForm not found');
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);
let previewVisible = false;
let syncTimer = null;
let saveTimer = null;
let splitTimer = null;
let langPickerActive = false;
/* ================================================================
工具栏动作
================================================================ */
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();
}
/* ================================================================
图片上传(含进度反馈)
================================================================ */
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);
}
}
imageInput.addEventListener('change', () => {
const file = imageInput.files[0];
if (!file) return;
uploadImage(file);
imageInput.value = '';
});
function uploadImage(file) {
const fd = new FormData();
fd.append('file', file);
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);
}
};
xhr.onerror = () => showUploadToast('上传请求失败', true);
xhr.send(fd);
}
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();
}
/* ================================================================
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 = getView().state.doc.toString();
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);
btnToggle.classList.toggle('active', previewVisible);
if (previewVisible) updatePreview();
}
/* ================================================================
滚动同步
================================================================ */
let scrollSyncing = false;
function syncScrollToPreview() {
if (!previewVisible || scrollSyncing) return;
scrollSyncing = true;
const scroller = getView().scrollDOM;
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 = getView().scrollDOM;
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();
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 || !getView().state.doc.toString()) {
if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) {
getView().dispatch({
changes: { from: 0, to: getView().state.doc.length, insert: 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');
getView().focus();
}
/* ================================================================
自定义快捷键
================================================================ */
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);
});
// 预览切换
btnToggle.addEventListener('click', togglePreview);
// 全屏
btnFull.addEventListener('click', toggleFullscreen);
// 滚动同步
previewCont.addEventListener('scroll', syncScrollToEditor);
const cmScroller = editorView.scrollDOM;
cmScroller.addEventListener('scroll', syncScrollToPreview);
/* ================================================================
表单提交
================================================================ */
form.addEventListener('submit', e => {
e.preventDefault();
const title = titleEl.value.trim();
const body = editorView.state.doc.toString().trim();
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('请求失败'));
});
/* ================================================================
键盘事件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();
}
}
});
/* ================================================================
初始化
================================================================ */
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');