Files
mce/templates/MetaLab-2026/static/js/editor.js
Victor_Jay 22a302315a feat: wangEditor 替换为 ByteMD Markdown 编辑器
- 移除 wangEditor 5 库文件(1.27MB JS + 14KB CSS,含手动 hack)
- 集成 ByteMD 1.22.0(基于 CodeMirror 5,不依赖 contentEditable)
- 编辑器输出 Markdown 原文,后端 goldmark + bluemonday 双重防护渲染 HTML
- 删除 editor.js 中 scrollIntoView hack、code-lang-label DOM 注入、PreviewAPI 调用
- 清理 posts.css 中 ~200 行 wangEditor 样式覆盖(w-e-* 类名)
- 清理 show.html 中 code-lang-label JS 注入(goldmark 自带 language-xxx class)
- 更新 CSP 安全策略:移除 CodeMirror 注释,保留 esm.sh CDN
- go.mod:bluemonday 和 goldmark 均保留为直接依赖(纵深防御)
2026-05-28 02:16:50 +08:00

215 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* MetaLab Editor — ByteMD Markdown 编辑器
*
* ByteMD 基于 CodeMirror 5纯 textarea 兜底,不依赖 contentEditable
* 输出 Markdown 原文,由后端 goldmark 统一渲染 HTML。
*
* 安全模型Markdown 文本 → goldmark → HTMLAST 生成,天然安全)
* 前端不再做 sanitizeXSS 风险由 goldmark 的 AST 输出消除。
*/
import { Editor } from 'https://esm.sh/bytemd@1.22.0'
import gfm from 'https://esm.sh/@bytemd/plugin-gfm@1.22.0'
import highlight from 'https://esm.sh/@bytemd/plugin-highlight@1.22.0'
// ---- 状态 ----
let editor = null
let currentValue = ''
let draftTimer = null
// ---- 初始化 ----
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('editor-container')
if (!container) return
const postId = document.getElementById('postId')?.value
const isEdit = !!postId
// 从容器 data-value 属性获取初始内容(由后端 SSR 渲染)
const initialValue = container.dataset.value || ''
currentValue = initialValue
const plugins = [
gfm(),
highlight(),
]
editor = new Editor({
target: container,
props: {
value: initialValue,
plugins,
mode: 'split',
placeholder: '在此输入正文... (Markdown)',
uploadImages: uploadImage,
},
})
// 监听内容变化,同步 currentValue
editor.$on('change', (e) => {
currentValue = e.detail.value
updateWordCount(currentValue)
scheduleDraft(currentValue)
})
// 初始字数统计
updateWordCount(initialValue)
// 恢复草稿(延迟确保 ByteMD 完全初始化)
if (!isEdit) {
setTimeout(() => restoreDraft(), 100)
}
// 全屏切换
document.getElementById('btnFullscreen')?.addEventListener('click', toggleFullscreen)
// 表单提交
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
// Ctrl+S 快捷键
document.addEventListener('keydown', handleKeyboard)
})
// ---- 图片上传 ----
async function uploadImage(files) {
const csrfMeta = document.querySelector('meta[name="csrf-token"]')
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
const results = []
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
const resp = await fetch('/api/posts/upload-image', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf },
body: formData,
})
const data = await resp.json()
if (data.success || data.code === 200) {
const url = data.data?.url || data.url
results.push({ url, alt: file.name, title: file.name })
}
}
return results
}
// ---- 字数统计 ----
function updateWordCount(text) {
const el = document.getElementById('wordCount')
if (!el) return
const len = (text || '').replace(/\s/g, '').length
el.textContent = len + ' 字'
}
// ---- 草稿保存/恢复 ----
function scheduleDraft(text) {
clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraft(text), 2000)
}
function saveDraft(text) {
const title = document.getElementById('postTitle')?.value || ''
if (!text.trim() && !title.trim()) return
try {
localStorage.setItem('draft_post_title', title)
localStorage.setItem('draft_post_body', text)
const statusEl = document.getElementById('draftStatus')
if (statusEl) {
statusEl.style.display = ''
setTimeout(() => { statusEl.style.display = 'none' }, 2000)
}
} catch (e) {
// localStorage 不可用(隐私模式等),静默忽略
}
}
function restoreDraft() {
try {
const savedId = localStorage.getItem('draft_post_id')
if (savedId) return // 编辑模式不恢复草稿
const title = localStorage.getItem('draft_post_title')
const body = localStorage.getItem('draft_post_body')
if (title) {
document.getElementById('postTitle').value = title
}
if (body && editor) {
editor.$set({ value: body })
currentValue = body
updateWordCount(body)
}
} catch (e) {
// 静默忽略
}
}
function clearDraft() {
try {
localStorage.removeItem('draft_post_title')
localStorage.removeItem('draft_post_body')
} catch (e) { /* ignore */ }
}
// ---- 全屏 ----
function toggleFullscreen() {
const layout = document.querySelector('.editor-layout')
if (!layout) return
layout.classList.toggle('fullscreen')
}
// ---- 键盘快捷键 ----
function handleKeyboard(e) {
if (e.key === 'F11') {
e.preventDefault()
toggleFullscreen()
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
document.getElementById('postForm')?.dispatchEvent(new Event('submit'))
}
}
// ---- 表单提交 ----
async function handleSubmit(e) {
e.preventDefault()
const postId = document.getElementById('postId')?.value
const title = document.getElementById('postTitle')?.value.trim()
if (!title) { alert('请输入标题'); return }
if (!currentValue.trim()) { alert('请输入正文'); return }
const isEdit = !!postId
const url = isEdit ? '/api/posts/' + postId : '/api/posts'
const method = isEdit ? 'PUT' : 'POST'
const csrfMeta = document.querySelector('meta[name="csrf-token"]')
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
try {
const resp = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify({ title, body: currentValue }),
})
const data = await resp.json()
if (data.success || data.code === 200) {
clearDraft()
const newPostId = data.data?.id || data.data?.ID || postId
window.location.href = newPostId ? '/posts/' + newPostId : '/posts'
} else {
alert(data.message || '操作失败')
}
} catch (err) {
alert('请求失败,请重试')
}
}