diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go index 21e4f78..28354e0 100644 --- a/internal/controller/post_controller.go +++ b/internal/controller/post_controller.go @@ -123,7 +123,6 @@ func (ctrl *PostController) NewPage(c *gin.Context) { c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{ "Title": "撰写帖子", "ExtraCSS": "/static/css/posts.css", - "ExtraJS": "/static/js/editor.js", })) } @@ -149,7 +148,6 @@ func (ctrl *PostController) EditPage(c *gin.Context) { "Title": "编辑帖子", "Post": post, "ExtraCSS": "/static/css/posts.css", - "ExtraJS": "/static/js/editor.js", })) } diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html index cffc210..fc176a7 100644 --- a/templates/MetaLab-2026/html/posts/new.html +++ b/templates/MetaLab-2026/html/posts/new.html @@ -3,78 +3,118 @@ {{template "layout/nav.html" .}} -
-

{{if .Post}}编辑帖子{{else}}撰写帖子{{end}}

- -
+
+ -
- - + {{/* 标题区域 */}} +
+
- {{/* 模式切换标签 */}} -
- - - -
- - {{/* 工具条 */}} + {{/* 工具栏 */}}
- - +
+ + + + +
- - +
+ + + + +
- - +
+ + +
- - - +
+ + +
- - - -
+
+ + + + +
+ + - {{/* 源码编辑器 */}} -
- +
- {{/* WYSIWYG 编辑器 */}} - - - {{/* 分屏预览 */}} - -
- + {{/* 代码块语言选择器(下拉菜单) */}} + + + {{/* 编辑器主体:编辑区 + 预览区 */}} +
+
+ +
+
+
+ 预览将在此处显示... +
+
+
+ + {{/* 状态栏 */}} +
+ 0 字 + 0 行 + 约 1 分钟 + + +
@@ -84,7 +124,28 @@ {{template "layout/footer.html" .}} -{{if .ExtraJS}}{{end}} +{{/* CodeMirror 6 import map (MIT License, from jsdelivr CDN) */}} + - - + +{{end}} diff --git a/templates/MetaLab-2026/static/css/posts.css b/templates/MetaLab-2026/static/css/posts.css index 79f2ae3..d213718 100644 --- a/templates/MetaLab-2026/static/css/posts.css +++ b/templates/MetaLab-2026/static/css/posts.css @@ -162,263 +162,259 @@ gap: 12px; } -/* ========== 编辑器 ========== */ -.editor-container { - max-width: 1024px; - margin: 0 auto; - padding: 32px 16px; -} - -.editor-title { - font-size: 28px; - font-weight: 700; - margin-bottom: 24px; -} - -.post-form .form-group { - margin-bottom: 16px; -} - -.post-form label { - display: block; - font-size: 14px; - font-weight: 500; - color: #374151; - margin-bottom: 6px; -} - -.form-input { - width: 100%; - padding: 10px 14px; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 16px; - box-sizing: border-box; -} - -/* ========== 模式标签 ========== */ -.editor-modes { +/* ========== 编辑器布局(全宽) ========== */ +.editor-layout { + height: calc(100vh - 56px); display: flex; - gap: 4px; - margin-bottom: 0; + flex-direction: column; + overflow: hidden; } -.mode-tab { - padding: 8px 18px; - border: 1px solid #d1d5db; - border-bottom: none; - border-radius: 8px 8px 0 0; - background: #f9fafb; - color: #6b7280; - font-size: 13px; - font-weight: 500; - cursor: pointer; - position: relative; - z-index: 1; +.editor-form { + display: flex; + flex-direction: column; + height: 100%; } -.mode-tab.active { +/* ---- 标题区域 ---- */ +.editor-header { + padding: 16px 24px 12px; + border-bottom: 1px solid #e5e7eb; background: #fff; - color: #6366f1; - border-color: #d1d5db; - z-index: 2; - margin-bottom: -1px; - padding-bottom: 9px; + flex-shrink: 0; } -/* ========== 工具条 ========== */ +.editor-title-input { + width: 100%; + padding: 6px 0; + border: none; + font-size: 26px; + font-weight: 700; + color: #111; + outline: none; + background: transparent; + line-height: 1.4; +} + +.editor-title-input::placeholder { + color: #c4c4c4; +} + +/* ---- 工具栏 ---- */ .editor-toolbar { display: flex; align-items: center; gap: 2px; - padding: 6px 8px; - background: #fff; - border: 1px solid #d1d5db; - border-top: none; - border-radius: 0 0 8px 8px; - min-height: 40px; - flex-wrap: wrap; + padding: 5px 16px; + background: #fafafa; + border-bottom: 1px solid #e5e7eb; + min-height: 38px; + flex-shrink: 0; + overflow-x: auto; } -/* 编辑区上边距 */ -.editor-toolbar + .editor-body, -.editor-modes + .editor-body { - margin-top: 0; +.tb-group { + display: flex; + align-items: center; + gap: 1px; } .tb-btn { display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 30px; + width: 30px; + height: 28px; border: none; border-radius: 4px; background: transparent; color: #4b5563; cursor: pointer; - font-size: 13px; + font-size: 12px; font-weight: 600; - transition: background 0.15s; + transition: background 0.12s, color 0.12s; + flex-shrink: 0; } .tb-btn:hover { - background: #f3f4f6; + background: #e5e7eb; color: #111; } +.tb-btn:active { + background: #d1d5db; +} + .tb-btn svg { - width: 18px; - height: 18px; + width: 16px; + height: 16px; } .tb-sep { width: 1px; height: 20px; background: #e5e7eb; - margin: 0 4px; + margin: 0 6px; + flex-shrink: 0; } -/* ========== 编辑区 ========== */ -.editor-body { - position: relative; - clear: both; +.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; } -.form-textarea { +.lang-picker-search input { width: 100%; - padding: 12px 14px; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 15px; - font-family: 'Menlo', 'Consolas', 'Monaco', monospace; - line-height: 1.7; - resize: vertical; - box-sizing: border-box; - min-height: 400px; -} - -/* WYSIWYG 编辑器 */ -.wysiwyg-editor { - min-height: 400px; - padding: 14px 18px; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 16px; - line-height: 1.8; - color: #1f2937; + padding: 10px 12px; + border: none; + border-bottom: 1px solid #e5e7eb; + font-size: 13px; outline: none; - word-wrap: break-word; - overflow-wrap: break-word; + box-sizing: border-box; } -.wysiwyg-editor:focus { - border-color: #6366f1; - box-shadow: 0 0 0 2px rgba(99,102,241,0.15); +.lang-picker-list { + max-height: 240px; + overflow-y: auto; + padding: 4px 0; } -.wysiwyg-editor h1 { font-size: 2em; margin: 0.5em 0 0.3em; } -.wysiwyg-editor h2 { font-size: 1.6em; margin: 0.5em 0 0.3em; } -.wysiwyg-editor h3 { font-size: 1.3em; margin: 0.4em 0 0.25em; } -.wysiwyg-editor p { margin: 0 0 0.5em; } -.wysiwyg-editor pre { +.lang-picker-item { + padding: 6px 12px; + font-size: 13px; + color: #374151; + cursor: pointer; +} + +.lang-picker-item:hover { background: #f3f4f6; - padding: 12px 16px; - border-radius: 6px; - font-family: 'Menlo','Consolas',monospace; - font-size: 14px; - overflow-x: auto; - margin: 0.5em 0; } -.wysiwyg-editor code { - background: #f3f4f6; - padding: 1px 5px; - border-radius: 3px; - font-family: 'Menlo','Consolas',monospace; - font-size: 0.9em; -} -.wysiwyg-editor blockquote { - border-left: 4px solid #6366f1; - padding: 2px 14px; - color: #6b7280; - margin: 0.5em 0; -} -.wysiwyg-editor ul, .wysiwyg-editor ol { - padding-left: 24px; - margin: 0.4em 0; -} -.wysiwyg-editor li { - margin-bottom: 2px; -} -.wysiwyg-editor img { - max-width: 100%; - border-radius: 6px; -} -.wysiwyg-editor a { + +.lang-picker-item.selected { + background: #e0e7ff; color: #6366f1; } -.wysiwyg-editor hr { - border: none; - border-top: 2px solid #e5e7eb; - margin: 1em 0; -} -.wysiwyg-editor table { - border-collapse: collapse; - width: 100%; - margin: 0.5em 0; -} -.wysiwyg-editor th, .wysiwyg-editor td { - border: 1px solid #d1d5db; - padding: 6px 12px; - text-align: left; + +/* ---- 编辑器主体(编辑区 + 预览区) ---- */ +.editor-main { + flex: 1; + display: flex; + overflow: hidden; + min-height: 0; } -/* ========== 分屏预览 ========== */ -.editor-body.split-mode .form-textarea { - width: 50%; - float: left; - border-radius: 8px 0 0 8px; - border-right: none; +.editor-pane { + flex: 1; + overflow: hidden; + min-width: 0; } -.split-preview { +.editor-pane .cm-editor { + height: 100%; +} + +.editor-pane .cm-editor .cm-scroller { + font-family: 'Menlo', 'Consolas', 'Monaco', 'Liberation Mono', monospace; + font-size: 14px; + line-height: 1.65; +} + +.editor-pane .cm-editor .cm-content { + padding: 16px 20px; +} + +.editor-pane .cm-editor .cm-gutters { + border-right: 1px solid #e5e7eb; + background: #fafafa; + color: #9ca3af; +} + +/* 预览区 */ +.preview-pane { + width: 0; + overflow: hidden; + border-left: 1px solid #e5e7eb; + background: #fff; + transition: width 0.2s ease; +} + +.preview-pane.visible { width: 50%; - float: right; - padding: 0; - box-sizing: border-box; + min-width: 320px; +} + +.preview-content { + height: 100%; overflow-y: auto; + padding: 20px 28px; } -.split-preview .split-preview-content { - padding: 14px 18px; - background: #f9fafb; - border: 1px solid #d1d5db; - border-radius: 0 8px 8px 0; - min-height: 400px; - font-size: 15px; - line-height: 1.8; +/* ---- 状态栏 ---- */ +.editor-statusbar { + display: flex; + align-items: center; + gap: 16px; + padding: 6px 20px; + background: #fafafa; + border-top: 1px solid #e5e7eb; + font-size: 12px; + color: #9ca3af; + flex-shrink: 0; + min-height: 34px; } -.split-preview .split-preview-content h1, -.split-preview .split-preview-content h2, -.split-preview .split-preview-content h3 { - margin-top: 18px; - margin-bottom: 8px; +.status-item { + white-space: nowrap; } -.split-preview .split-preview-content pre { - background: #e5e7eb; - padding: 12px; - border-radius: 6px; - overflow-x: auto; +.status-spacer { + flex: 1; +} + +.status-draft { + color: #059669; + font-weight: 500; +} + +.btn-submit { + padding: 5px 18px; font-size: 13px; + border-radius: 6px; } -.split-preview .split-preview-content blockquote { - border-left: 4px solid #6366f1; - padding-left: 14px; - color: #6b7280; +/* ---- 全屏模式 ---- */ +.editor-layout.fullscreen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + height: 100vh; + background: #fff; } /* ========== 404 ========== */ diff --git a/templates/MetaLab-2026/static/js/editor.js b/templates/MetaLab-2026/static/js/editor.js index 28befb1..1ed32e8 100644 --- a/templates/MetaLab-2026/static/js/editor.js +++ b/templates/MetaLab-2026/static/js/editor.js @@ -1,488 +1,585 @@ -(function() { - var form = document.getElementById('postForm'); - if (!form) return; +/** + * 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'; - var postId = document.getElementById('postId'); - var titleEl = document.getElementById('postTitle'); - var bodyEl = document.getElementById('postBody'); // textarea (唯一数据源) - var wysiwygEl = document.getElementById('wysiwygEditor'); // contenteditable - var splitPrev = document.getElementById('splitPreview'); - var splitCont = splitPrev ? splitPrev.querySelector('.split-preview-content') : null; - var toolbar = document.getElementById('toolbar'); - var modeTabs = document.querySelectorAll('.mode-tab'); +/* ================================================================ + DOM 引用 + ================================================================ */ +const form = document.getElementById('postForm'); +if (!form) throw new Error('postForm not found'); - var isEdit = postId && postId.value; - var currentMode = 'wysiwyg'; - var wysiwygDirty = false; // WYSIWYG 是否已被用户编辑过(首次进入时不覆盖 textarea) - var syncTimer = null; - var imageInput = document.getElementById('imageFileInput'); +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'); - // ===================== HELPERS ===================== +const isEdit = !!(postIdEl && postIdEl.value); +let previewVisible = false; +let syncTimer = null; +let saveTimer = null; +let splitTimer = null; +let langPickerActive = false; - function getCSRFToken() { - var meta = document.querySelector('meta[name="csrf-token"]'); - return meta ? meta.getAttribute('content') : ''; - } +/* ================================================================ + 工具栏动作 + ================================================================ */ +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(); }, +}; - // ================== MARKDOWN ↔ HTML ================== +function getView() { return editorView; } - // 通过 API 渲染 markdown 为 HTML(服务端 Goldmark + bluemonday 消毒) - 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(function(r) { return r.json(); }) - .then(function(d) { callback(d.success ? d.data.html : md); }) - .catch(function() { callback(md); }); - } - - // 将 contenteditable 的 HTML 转回 Markdown(基本转换) - function htmlToMarkdown(html) { - var el = document.createElement('div'); - el.innerHTML = html; - - function walk(node) { - if (node.nodeType === 3) return node.textContent; - if (node.nodeType !== 1) return ''; - var tag = node.tagName.toLowerCase(); - var children = ''; - for (var i = 0; i < node.childNodes.length; i++) { - children += walk(node.childNodes[i]); - } - switch (tag) { - case 'br': return '\n'; - case 'p': return children + '\n\n'; - case 'strong': case 'b': return '**' + children + '**'; - case 'em': case 'i': return '_' + children + '_'; - case 'h1': return '# ' + children + '\n\n'; - case 'h2': return '## ' + children + '\n\n'; - case 'h3': return '### ' + children + '\n\n'; - case 'h4': return '#### ' + children + '\n\n'; - case 'h5': return '##### ' + children + '\n\n'; - case 'h6': return '###### ' + children + '\n\n'; - case 'a': - var href = node.getAttribute('href') || ''; - return '[' + children + '](' + href + ')'; - case 'pre': - var code = node.querySelector('code'); - var lang = code ? (code.className.replace('language-','') || '') : ''; - return '\n```' + lang + '\n' + node.textContent + '\n```\n'; - case 'code': - if (node.parentNode.tagName.toLowerCase() !== 'pre') - return '`' + children + '`'; - return children; - case 'li': - if (node.parentNode.tagName.toLowerCase() === 'ul') return '- ' + children + '\n'; - if (node.parentNode.tagName.toLowerCase() === 'ol') return '1. ' + children + '\n'; - return children + '\n'; - case 'ul': case 'ol': return children + '\n'; - case 'blockquote': - return '> ' + children.replace(/\n$/, '').replace(/\n/g, '\n> ') + '\n\n'; - case 'hr': return '\n---\n'; - case 'img': - return '![' + (node.getAttribute('alt') || '') + '](' + (node.getAttribute('src') || '') + ')'; - case 'div': - case 'section': - return children; - default: - return children || node.textContent; - } - } - return walk(el).replace(/\n{3,}/g, '\n\n').trim(); - } - - // 将 markdown 从 textarea 渲染到 WYSIWYG - function syncMarkdownToWysiwyg() { - if (!bodyEl.value.trim()) { - wysiwygEl.innerHTML = ''; - return; - } - renderMarkdown(bodyEl.value, function(html) { - wysiwygEl.innerHTML = html; - }); - } - - // WYSIWYG 编辑后延迟将 HTML 转为 markdown 保存回 textarea - function debounceSync() { - clearTimeout(syncTimer); - syncTimer = setTimeout(function() { - var md = htmlToMarkdown(wysiwygEl.innerHTML); - if (md !== bodyEl.value) { - bodyEl.value = md; - } - }, 600); - } - - // markdown 保存回 textarea 后触发分屏预览更新 - bodyEl.addEventListener('input', function() { - if (currentMode === 'split') { - clearTimeout(splitTimer); - splitTimer = setTimeout(updateSplitPreview, 300); - } +/* ---- 选中文本包裹 ---- */ +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 } }); - var splitTimer = null; + view.focus(); +} - function updateSplitPreview() { - if (!splitCont || !bodyEl.value.trim()) { - if (splitCont) splitCont.innerHTML = '输入正文后将在此处预览...'; - return; - } - renderMarkdown(bodyEl.value, function(html) { - splitCont.innerHTML = html; - }); +/* ---- 行首添加前缀 ---- */ +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 }); } - - // WYSIWYG 输入实时同步 - wysiwygEl.addEventListener('input', function() { - if (currentMode === 'wysiwyg') { - wysiwygDirty = true; - debounceSync(); - } + view.dispatch({ + changes, + selection: { anchor: sel.from + prefix.length, head: sel.to + prefix.length * (toLine.number - fromLine.number + 1) } }); + view.focus(); +} - // ==================== MODE SWITCHING ==================== +/* ---- 插入块 ---- */ +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 switchMode(mode) { - // 仅在离开 WYSIWYG 时同步 WYSIWYG → textarea - if (currentMode === 'wysiwyg' && mode !== 'wysiwyg' && wysiwygDirty) { - var md = htmlToMarkdown(wysiwygEl.innerHTML); - bodyEl.value = md; - } +/* ---- 插入表格 ---- */ +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(); +} - currentMode = mode; - wysiwygDirty = false; // 切换到新模式下重置 dirty 标记 +/* ================================================================ + 代码块语言选择器 + ================================================================ */ +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', +]; - // 更新标签 - modeTabs.forEach(function(tab) { - tab.classList.toggle('active', tab.dataset.mode === mode); - }); +function showLangPicker(view) { + if (langPickerActive) { hideLangPicker(); return; } + langPickerActive = true; - // 切换布局 class - var editorBody = document.querySelector('.editor-body'); - if (editorBody) { - editorBody.classList.toggle('split-mode', mode === 'split'); - } + langList.innerHTML = LANGUAGES.map(l => + `
${l}
` + ).join(''); + langSearch.value = ''; - // 工具栏在 split 模式下也显示(源码风格操作) - if (toolbar) { - toolbar.style.display = (mode === 'source' || mode === 'split') ? '' : ''; - } + langPicker.style.display = 'block'; - // 切换面板 - if (mode === 'wysiwyg') { - bodyEl.style.display = 'none'; - wysiwygEl.style.display = ''; - if (splitPrev) splitPrev.style.display = 'none'; - syncMarkdownToWysiwyg(); - wysiwygEl.focus(); - } else if (mode === 'source') { - bodyEl.style.display = ''; - wysiwygEl.style.display = 'none'; - if (splitPrev) splitPrev.style.display = 'none'; - bodyEl.focus(); - } else if (mode === 'split') { - bodyEl.style.display = ''; - wysiwygEl.style.display = 'none'; - if (splitPrev) splitPrev.style.display = ''; - bodyEl.focus(); - updateSplitPreview(); - } - } - - modeTabs.forEach(function(tab) { - tab.addEventListener('click', function() { - switchMode(tab.dataset.mode); + langList.querySelectorAll('.lang-picker-item').forEach(el => { + el.addEventListener('click', () => { + insertCodeBlock(view, el.dataset.lang); + hideLangPicker(); }); }); - // ==================== TOOLBAR ==================== + langSearch.focus(); + setTimeout(() => document.addEventListener('click', onLangPickerOutside, { once: true }), 0); +} - if (toolbar) { - toolbar.addEventListener('click', function(e) { - var btn = e.target.closest('.tb-btn'); - if (!btn) return; - var action = btn.dataset.action; - if (currentMode === 'wysiwyg') { - execWysiwygAction(action); - } else { - // source 和 split 模式都用源码工具栏(操作 textarea 中的 markdown) - execSourceAction(action); - } - }); +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(); } +} - function execWysiwygAction(action) { - wysiwygEl.focus(); - switch (action) { - case 'bold': - document.execCommand('bold', false, null); - break; - case 'italic': - document.execCommand('italic', false, null); - break; - case 'h2': - document.execCommand('formatBlock', false, 'h2'); - break; - case 'h3': - document.execCommand('formatBlock', false, 'h3'); - break; - case 'link': - var url = prompt('输入链接地址:', 'https://'); - if (url) document.execCommand('createLink', false, url); - break; - case 'image': - imagePickerTarget = 'wysiwyg'; - imageInput.click(); - break; - case 'quote': - document.execCommand('formatBlock', false, 'blockquote'); - break; - case 'code': - wrapSelectionWysiwyg('`', '`'); - break; - case 'pre': - var lang = prompt('输入代码语言(如 python / go / text,留空默认为 text):', ''); - lang = lang ? lang.trim() : 'text'; - insertCodeBlockWysiwyg(lang); - break; - case 'ul': - document.execCommand('insertUnorderedList', false, null); - break; - case 'ol': - document.execCommand('insertOrderedList', false, null); - break; - case 'hr': - document.execCommand('insertHorizontalRule', false, null); - break; +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(); } - wysiwygDirty = true; + } else if (e.key === 'Escape') { + hideLangPicker(); } +}); - // WYSIWYG 中包裹选中文本 - function wrapSelectionWysiwyg(before, after) { - var sel = window.getSelection(); - if (!sel.rangeCount) return; - var range = sel.getRangeAt(0); - var text = range.toString(); - if (!text) text = 'code'; - range.deleteContents(); - range.insertNode(document.createTextNode(before + text + after)); - wysiwygDirty = true; +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); } +} - // 源码/分屏模式下插入 markdown 语法 - function execSourceAction(action) { - var start = bodyEl.selectionStart; - var end = bodyEl.selectionEnd; - var text = bodyEl.value.substring(start, end); - var wrap = {}; +imageInput.addEventListener('change', () => { + const file = imageInput.files[0]; + if (!file) return; + uploadImage(file); + imageInput.value = ''; +}); - switch (action) { - case 'bold': wrap = { b: '**', a: '**' }; break; - case 'italic': wrap = { b: '_', a: '_' }; break; - case 'h2': wrap = { b: '## ', a: '' }; break; - case 'h3': wrap = { b: '### ',a: '' }; break; - case 'link': wrap = { b: '[', a: '](https://)' }; break; - case 'image': - // 源码/分屏模式下图片使用上传 - imagePickerTarget = currentMode; - imageInput.click(); - return; - case 'quote': wrap = { b: '> ', a: '' }; break; - case 'code': wrap = { b: '`', a: '`' }; break; - case 'pre': - var lang = prompt('输入代码语言(如 python / go / text,留空默认为 text):', ''); - lang = lang ? lang.trim() : 'text'; - wrap = { b: '\n```' + lang + '\n', a: '\n```\n' }; - break; - case 'ul': wrap = { b: '- ', a: '' }; break; - case 'ol': wrap = { b: '1. ', a: '' }; break; - case 'hr': wrap = { b: '\n---\n', a: '' }; break; +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); } + }); - if (wrap.b !== undefined) { - var placeholder = ''; - if (!text) { - if (action === 'link') placeholder = 'link'; - else if (action === 'image') placeholder = 'image'; - // hr 和其他无内容操作不加占位文本 - } - var replacement = wrap.b + placeholder + wrap.a; - bodyEl.value = bodyEl.value.substring(0, start) + replacement + bodyEl.value.substring(end); - bodyEl.focus(); - var cursor = start + wrap.b.length + (text ? text.length : 0); - bodyEl.setSelectionRange(cursor, cursor + (text ? 0 : replacement.length - wrap.b.length - wrap.a.length)); - - // 分屏模式下立刻更新预览 - if (currentMode === 'split') updateSplitPreview(); - } - } - - // WYSIWYG 中插入带语言标注的代码块 - function insertCodeBlockWysiwyg(lang) { - if (wysiwygEl.style.display === 'none') { - // WYSIWYG 不可见时直接向 textarea 插入 markdown 代码块 - var start = bodyEl.selectionStart; - var end = bodyEl.selectionEnd; - var text = bodyEl.value.substring(start, end); - var b = '\n```' + lang + '\n'; - var a = '\n```\n'; - bodyEl.value = bodyEl.value.substring(0, start) + b + (text || '') + a + bodyEl.value.substring(end); - bodyEl.focus(); - var cursor = start + b.length + (text ? text.length : 0); - bodyEl.setSelectionRange(cursor, cursor + (text ? 0 : a.length)); - if (currentMode === 'split') updateSplitPreview(); - return; - } - wysiwygEl.focus(); - var sel = window.getSelection(); - - var pre = document.createElement('pre'); - var code = document.createElement('code'); - if (lang && lang !== 'text') code.className = 'language-' + lang; - code.textContent = '\n'; - pre.appendChild(code); - - if (!sel.rangeCount) { - // 无选区:追加到编辑器末尾 - wysiwygEl.appendChild(pre); - } else { - var range = sel.getRangeAt(0); - // 防止在已有 pre 内部嵌套插入:把插入点移到 pre 之后 - var ancestor = range.commonAncestorContainer; - while (ancestor && ancestor !== wysiwygEl) { - if (ancestor.nodeType === 1 && ancestor.tagName === 'PRE') { - range.setStartAfter(ancestor); - range.setEndAfter(ancestor); - break; - } - ancestor = ancestor.parentNode; - } - // 确保插入点落在 WYSIWYG 内部 - if (!wysiwygEl.contains(range.commonAncestorContainer)) { - wysiwygEl.appendChild(pre); - } else { - range.deleteContents(); - range.insertNode(pre); - } - } - - // 在代码块后加换行,方便继续输入 - var br = document.createElement('br'); - pre.parentNode.insertBefore(br, pre.nextSibling); - - // 光标移到 code 内 - var newRange = document.createRange(); - newRange.selectNodeContents(code); - newRange.collapse(true); - sel.removeAllRanges(); - sel.addRange(newRange); - - wysiwygDirty = true; - } - - // ==================== IMAGE UPLOAD ==================== - var imagePickerTarget = null; - - if (imageInput) { - imageInput.addEventListener('change', function() { - var file = imageInput.files[0]; - if (!file) { imagePickerTarget = null; return; } - uploadImage(file, function(url) { - if (!url) { imagePickerTarget = null; return; } - if (imagePickerTarget === 'wysiwyg') { - wysiwygEl.focus(); - document.execCommand('insertImage', false, url); - wysiwygDirty = true; - } else { - var start = bodyEl.selectionStart; - var end = bodyEl.selectionEnd; - var alt = bodyEl.value.substring(start, end) || 'image'; - var md = '![' + alt + '](' + url + ')'; - bodyEl.value = bodyEl.value.substring(0, start) + md + bodyEl.value.substring(end); - bodyEl.focus(); - bodyEl.setSelectionRange(start + md.length, start + md.length); - if (currentMode === 'split') updateSplitPreview(); - } - imagePickerTarget = null; - }); - imageInput.value = ''; - }); - } - - function uploadImage(file, callback) { - var fd = new FormData(); - fd.append('file', file); - - fetch('/api/posts/upload-image', { - method: 'POST', - headers: { 'X-CSRF-Token': getCSRFToken() }, - body: fd - }) - .then(function(r) { return r.json(); }) - .then(function(d) { + xhr.onload = () => { + try { + const d = JSON.parse(xhr.responseText); if (d.success && d.data && d.data.url) { - callback(d.data.url); + insertImageMarkdown(d.data.url); + showUploadToast('上传成功', false); } else { - alert(d.message || '图片上传失败'); - callback(null); + showUploadToast(d.message || '上传失败', true); } - }) - .catch(function() { alert('图片上传请求失败'); callback(null); }); + } 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 = '预览将在此处显示...'; + 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'; } - // ==================== FORM SUBMISSION ==================== - - form.addEventListener('submit', function(e) { - e.preventDefault(); - - // WYSIWYG 模式下先同步到 textarea - if (currentMode === 'wysiwyg') { - var md = htmlToMarkdown(wysiwygEl.innerHTML); - bodyEl.value = md; - } - - var title = titleEl.value.trim(); - var body = bodyEl.value.trim(); - if (!title) { alert('请输入标题'); return; } - if (!body) { alert('请输入正文'); return; } - - var url = '/api/posts'; - var method = 'POST'; - if (isEdit) { - url = '/api/posts/' + postId.value; - method = 'PUT'; - } - - fetch(url, { - method: method, - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': getCSRFToken() - }, - body: JSON.stringify({ title: title, body: body }) - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (data.success) { - if (data.data && data.data.id) { - window.location.href = '/posts/' + data.data.id; - } else { - window.location.href = '/posts'; - } + 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 { - alert(data.message || '操作失败'); + window.location.href = '/posts'; } - }) - .catch(function() { alert('请求失败'); }); - }); + } else { + alert(data.message || '操作失败'); + } + }) + .catch(() => alert('请求失败')); +}); - // ==================== INIT ==================== - // 同步 DOM 状态与默认 mode(textarea 隐藏、WYSIWYG 可见),并渲染已有内容 - switchMode('wysiwyg'); -})(); +/* ================================================================ + 键盘事件(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');