feat: 用 wangEditor 5 替换 CodeMirror 6
This commit is contained in:
@ -200,123 +200,7 @@
|
||||
color: #c4c4c4;
|
||||
}
|
||||
|
||||
/* ---- 工具栏 ---- */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 16px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
min-height: 38px;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tb-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.tb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-btn:hover {
|
||||
background: #e5e7eb;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.tb-btn:active {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.tb-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.tb-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #e5e7eb;
|
||||
margin: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tb-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tb-toggle.active {
|
||||
background: #e0e7ff;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
/* ---- 代码块语言选择器 ---- */
|
||||
.lang-picker {
|
||||
position: absolute;
|
||||
top: 38px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lang-picker-search input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lang-picker-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.lang-picker-item {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lang-picker-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.lang-picker-item.selected {
|
||||
background: #e0e7ff;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
/* ---- 编辑器主体(编辑区 + 预览区) ---- */
|
||||
.editor-main {
|
||||
@ -332,24 +216,167 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-pane .cm-editor {
|
||||
height: 100%;
|
||||
/* ================================================================
|
||||
wangEditor 风格覆盖 — 匹配 MetaLab 靛蓝色系
|
||||
================================================================ */
|
||||
|
||||
/* 编辑器整体容器 */
|
||||
#editor-container {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.editor-pane .cm-editor .cm-scroller {
|
||||
font-family: 'Menlo', 'Consolas', 'Monaco', 'Liberation Mono', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
/* 编辑区域 */
|
||||
#editor-container [data-slate-editor] {
|
||||
padding: 16px 24px;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
color: #1f2937;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.editor-pane .cm-editor .cm-content {
|
||||
padding: 16px 20px;
|
||||
/* 工具栏容器 */
|
||||
#toolbar-container .w-e-toolbar {
|
||||
background: #fafafa !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid #e5e7eb !important;
|
||||
padding: 4px 12px !important;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-pane .cm-editor .cm-gutters {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
background: #fafafa;
|
||||
color: #9ca3af;
|
||||
/* 工具栏按钮 */
|
||||
#toolbar-container .w-e-bar-item button {
|
||||
color: #4b5563 !important;
|
||||
padding: 4px 6px !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
#toolbar-container .w-e-bar-item button:hover {
|
||||
background: #e5e7eb !important;
|
||||
color: #111 !important;
|
||||
}
|
||||
|
||||
/* 工具栏按钮激活态 */
|
||||
#toolbar-container .w-e-bar-item button.active {
|
||||
background: #e0e7ff !important;
|
||||
color: #6366f1 !important;
|
||||
}
|
||||
|
||||
/* 工具栏分隔线 */
|
||||
#toolbar-container .w-e-bar-divider {
|
||||
background: #e5e7eb !important;
|
||||
margin: 4px 6px !important;
|
||||
}
|
||||
|
||||
/* 下拉菜单面板 */
|
||||
.w-e-panel-container {
|
||||
background: #fff !important;
|
||||
border: 1px solid #d1d5db !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
|
||||
.w-e-panel-container .w-e-panel-content {
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
/* 下拉菜单项 */
|
||||
.w-e-dropdown-item {
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
.w-e-dropdown-item:hover {
|
||||
background: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
.w-e-dropdown-item.active {
|
||||
background: #e0e7ff !important;
|
||||
color: #6366f1 !important;
|
||||
}
|
||||
|
||||
/* 下拉菜单 */
|
||||
.w-e-bar-item .w-e-bar-item-menus-container {
|
||||
background: #fff !important;
|
||||
border: 1px solid #d1d5db !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
|
||||
/* 链接/图片输入弹窗 */
|
||||
.w-e-modal {
|
||||
background: #fff !important;
|
||||
border: 1px solid #d1d5db !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
|
||||
.w-e-modal input,
|
||||
.w-e-modal textarea {
|
||||
border: 1px solid #d1d5db !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 8px 12px !important;
|
||||
font-size: 14px !important;
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
.w-e-modal input:focus,
|
||||
.w-e-modal textarea:focus {
|
||||
border-color: #6366f1 !important;
|
||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.1) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.w-e-modal button {
|
||||
border-radius: 6px !important;
|
||||
font-size: 13px !important;
|
||||
padding: 6px 16px !important;
|
||||
}
|
||||
|
||||
.w-e-modal .w-e-modal-header {
|
||||
border-bottom: 1px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
.w-e-modal .w-e-modal-footer {
|
||||
border-top: 1px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
/* 确认按钮颜色覆盖为靛蓝 */
|
||||
.w-e-modal .w-e-modal-footer button:last-child {
|
||||
background: #6366f1 !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.w-e-modal .w-e-modal-footer button:last-child:hover {
|
||||
background: #4f46e5 !important;
|
||||
}
|
||||
|
||||
/* 表格创建选择器 */
|
||||
.w-e-table-panel {
|
||||
background: #fff !important;
|
||||
border: 1px solid #d1d5db !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.w-e-table-panel .w-e-table-panel-item {
|
||||
border-color: #e5e7eb !important;
|
||||
background: #f9fafb !important;
|
||||
}
|
||||
|
||||
.w-e-table-panel .w-e-table-panel-item.active {
|
||||
background: #e0e7ff !important;
|
||||
border-color: #6366f1 !important;
|
||||
}
|
||||
|
||||
/* 选中文本背景色 */
|
||||
#editor-container [data-slate-editor] ::selection {
|
||||
background: #c7d2fe !important;
|
||||
}
|
||||
|
||||
/* placeholder */
|
||||
#editor-container [data-slate-editor] [data-slate-placeholder] {
|
||||
color: #c4c4c4 !important;
|
||||
font-style: normal !important;
|
||||
}
|
||||
|
||||
/* 预览区 */
|
||||
|
||||
@ -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 = '';
|
||||
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');
|
||||
})();
|
||||
|
||||
27
templates/MetaLab-2026/static/lib/wangeditor.css
Normal file
27
templates/MetaLab-2026/static/lib/wangeditor.css
Normal file
File diff suppressed because one or more lines are too long
24129
templates/MetaLab-2026/static/lib/wangeditor.js
Normal file
24129
templates/MetaLab-2026/static/lib/wangeditor.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user