Files
mce/templates/MetaLab-2026/static/js/editor.js
Victor_Jay 799ac3c95c fix: 放宽 CSP 允许 esm.sh CDN 加载 CodeMirror 6 ESM 模块
- script-src 添加 https://esm.sh,允许 CodeMirror 6 的 ESM import 加载
- connect-src 显式设为 self,确保 Markdown 预览 fetch 请求正常
- img-src 添加 blob:,支持粘贴图片场景
2026-05-27 19:44:59 +08:00

588 lines
21 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 版
*
* 功能:
* - CodeMirror 6 编辑器Markdown 语法高亮
* - 工具栏:加粗/斜体/删除线/行内代码/标题/链接/图片/引用/代码块/列表/表格/分割线
* - 快捷键Ctrl+B/I/K/` Shift+I Tab Shift+Tab Ctrl+S Ctrl+Shift+P F11
* - 粘贴图片 / 拖拽上传 / 进度反馈
* - 分屏实时预览Ctrl+Shift+P 切换)
* - 代码块语言下拉选择器
* - 自动保存草稿localStorage + 后端)
* - 字数/行数/阅读时间统计
* - 全屏编辑模式F11
* - 滚动同步(分屏模式下)
*
* CodeMirror 6 通过 esm.sh bundle 加载,所有依赖自动解析。
*/
import { EditorState } from 'https://esm.sh/@codemirror/state@6.5.2';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from 'https://esm.sh/@codemirror/view@6.36.4';
import { defaultKeymap, history, historyKeymap, indentWithTab } from 'https://esm.sh/@codemirror/commands@6.8.1';
import { markdown, markdownLanguage } from 'https://esm.sh/@codemirror/lang-markdown@6.3.2';
import { languages } from 'https://esm.sh/@codemirror/language-data@6.5.1';
import { syntaxHighlighting, defaultHighlightStyle } from 'https://esm.sh/@codemirror/language@6.11.0';
import { searchKeymap } from 'https://esm.sh/@codemirror/search@6.5.10';
import { autocompletion } from 'https://esm.sh/@codemirror/autocomplete@6.18.6';
/* ================================================================
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');