feat: 用 wangEditor 5 替换 CodeMirror 6

This commit is contained in:
2026-05-27 20:24:56 +08:00
parent c326cbc104
commit 9d17185e11
5 changed files with 24508 additions and 631 deletions

View File

@ -1,4 +1,23 @@
{{template "layout/header.html" .}}
<link rel="stylesheet" href="/static/lib/wangeditor.css">
<style>
/* wangEditor 容器适配全高布局 */
#editor-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
#toolbar-container {
flex-shrink: 0;
}
#editor-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>
<body>
{{template "layout/nav.html" .}}
@ -15,89 +34,13 @@
maxlength="200" required autofocus>
</div>
{{/* 工具栏 */}}
<div class="editor-toolbar" id="toolbar">
<div class="tb-group">
<button type="button" class="tb-btn" data-action="bold" title="加粗 (Ctrl+B)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="italic" title="斜体 (Ctrl+I)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="strikethrough" title="删除线">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.85 7.08C6.85 4.37 9.45 3 12.24 3c1.64 0 3 .49 3.9 1.28.77.65 1.46 1.73 1.46 3.24h-3.01c0-.31-.05-.59-.15-.85-.29-.86-1.2-1.28-2.25-1.28-1.86 0-2.34 1.02-2.34 1.7 0 .48.25.88.74 1.21.38.25.77.48 1.41.7H7.39c-.21-.34-.54-.89-.54-1.92zM21 12v-2H3v2h9.62c1.15.45 1.96.75 1.96 1.97 0 1-.81 1.67-2.28 1.67-1.54 0-2.93-.54-2.93-2.51H6.4c0 2.21 1.79 4.21 4.4 4.21 3.61 0 5.2-2.48 5.2-4.3 0-1.43-.84-2.46-2.37-3.04H21z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="code" title="行内代码 (Ctrl+`)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 16.6 4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0 4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
</button>
</div>
<span class="tb-sep"></span>
<div class="tb-group">
<button type="button" class="tb-btn" data-action="h1" title="一级标题">H1</button>
<button type="button" class="tb-btn" data-action="h2" title="二级标题">H2</button>
<button type="button" class="tb-btn" data-action="h3" title="三级标题">H3</button>
<button type="button" class="tb-btn" data-action="h4" title="四级标题">H4</button>
</div>
<span class="tb-sep"></span>
<div class="tb-group">
<button type="button" class="tb-btn" data-action="link" title="插入链接 (Ctrl+K)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="image" title="插入图片 (Ctrl+Shift+I)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
</div>
<span class="tb-sep"></span>
<div class="tb-group">
<button type="button" class="tb-btn" data-action="quote" title="引用块">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="pre" title="代码块">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm4 2h6v2H9V7zm0 4h6v2H9v-2z"/></svg>
</button>
</div>
<span class="tb-sep"></span>
<div class="tb-group">
<button type="button" class="tb-btn" data-action="ul" title="无序列表">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="ol" title="有序列表">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="task" title="任务列表">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="table" title="插入表格">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h16a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 4v3h4V8H4zm6 0v3h4V8h-4zm6 0v3h4V8h-4zm-12 5v3h4v-3H4zm6 0v3h4v-3h-4zm6 0v3h4v-3h-4zM4 6h16V6H4v.01z"/></svg>
</button>
</div>
<span class="tb-sep"></span>
<button type="button" class="tb-btn" data-action="hr" title="分割线">&mdash;</button>
<div class="tb-spacer"></div>
{{/* 右侧:预览切换 + 全屏 */}}
<div class="tb-group tb-right">
<button type="button" class="tb-btn tb-toggle" id="btnTogglePreview" title="切换预览 (Ctrl+Shift+P)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
</button>
<button type="button" class="tb-btn" id="btnFullscreen" title="全屏编辑 (F11)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
</button>
</div>
</div>
{{/* 代码块语言选择器(下拉菜单) */}}
<div class="lang-picker" id="langPicker" style="display:none">
<div class="lang-picker-search">
<input type="text" id="langSearch" placeholder="搜索语言...">
</div>
<div class="lang-picker-list" id="langList"></div>
</div>
{{/* 编辑器主体:编辑区 + 预览区 */}}
<div class="editor-main">
<div class="editor-pane" id="editorPane">
<div id="editor-wrapper">
<div id="toolbar-container"></div>
<div id="editor-container"></div>
</div>
<textarea id="postBody" style="display:none;">{{if .Post}}{{.Post.Body}}{{end}}</textarea>
</div>
<div class="preview-pane" id="previewPane">
@ -114,37 +57,15 @@
<span class="status-item" id="readTime">约 1 分钟</span>
<span class="status-spacer"></span>
<span class="status-item status-draft" id="draftStatus" style="display:none">草稿已保存</span>
<span class="status-item" id="btnTogglePreview" style="cursor:pointer" title="切换预览 (Ctrl+Shift+P)">预览</span>
<span class="status-item" id="btnFullscreen" style="cursor:pointer" title="全屏编辑 (F11)">全屏</span>
<button type="submit" class="btn btn-primary btn-submit">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
</div>
</form>
{{/* 隐藏的图片上传控件 */}}
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none;">
</div>
{{template "layout/footer.html" .}}
{{/* CodeMirror 6 importmap — 所有 @codemirror/@lezer 指向 esm.sh 同一构建树,共享依赖单例 */}}
<script type="importmap">
{
"imports": {
"@codemirror/state": "https://esm.sh/@codemirror/state@6.5.2",
"@codemirror/view": "https://esm.sh/@codemirror/view@6.36.4",
"@codemirror/commands": "https://esm.sh/@codemirror/commands@6.8.1",
"@codemirror/language": "https://esm.sh/@codemirror/language@6.11.0",
"@codemirror/lang-markdown": "https://esm.sh/@codemirror/lang-markdown@6.3.2",
"@codemirror/language-data": "https://esm.sh/@codemirror/language-data@6.5.1",
"@codemirror/autocomplete": "https://esm.sh/@codemirror/autocomplete@6.18.6",
"@codemirror/search": "https://esm.sh/@codemirror/search@6.5.10",
"@lezer/common": "https://esm.sh/@lezer/common@1.2.3",
"@lezer/highlight": "https://esm.sh/@lezer/highlight@1.2.1",
"@lezer/lr": "https://esm.sh/@lezer/lr@1.4.2",
"@lezer/markdown": "https://esm.sh/@lezer/markdown@1.4.0",
"crelt": "https://esm.sh/crelt@1.0.6",
"style-mod": "https://esm.sh/style-mod@4.1.2",
"w3c-keyname": "https://esm.sh/w3c-keyname@2.2.8"
}
}
</script>
<script src="/static/lib/wangeditor.js"></script>
<script src="/static/js/common.js"></script>
<script type="module" src="/static/js/editor.js"></script>
<script src="/static/js/editor.js"></script>

View File

@ -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;
}
/* 预览区 */

View File

@ -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 = '![' + alt + '](' + url + ')';
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');
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long