- 引入 CodeMirror 6 (MIT) 替代 contenteditable WYSIWYG 编辑器,消除 execCommand 废弃 API 风险 - 全新全宽三栏布局:标题栏、工具栏、编辑区+可切换预览、状态栏 - 工具栏新增删除线/H1/H4/任务列表/表格/预览切换/全屏按钮 - 支持粘贴图片自动上传 (Ctrl+V)、拖拽上传、上传进度反馈 - 代码块语言下拉选择器替代 prompt() 弹窗,支持搜索和键盘导航 - 自动保存草稿 (30s 防抖,localStorage + 后端静默保存) - 分屏实时预览 (Ctrl+Shift+P),编辑器与预览区双向滚动同步 - 字数/行数/阅读时间统计、全屏编辑 (F11) - 快捷键:Ctrl+B/I/K/`/Shift+I/S/Shift+P + Tab/Shift+Tab - 删除旧的 htmlToMarkdown、syncMarkdownToWysiwyg、mode-tab 等废弃代码 - 后端 API 零改动,仅移除控制器中不再需要的 ExtraJS 字段
586 lines
20 KiB
JavaScript
586 lines
20 KiB
JavaScript
/**
|
||
* 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)
|
||
* - 滚动同步(分屏模式下)
|
||
*/
|
||
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 = '';
|
||
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');
|