- 移除 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 均保留为直接依赖(纵深防御)
215 lines
6.2 KiB
JavaScript
215 lines
6.2 KiB
JavaScript
/**
|
||
* MetaLab Editor — ByteMD Markdown 编辑器
|
||
*
|
||
* ByteMD 基于 CodeMirror 5(纯 textarea 兜底,不依赖 contentEditable),
|
||
* 输出 Markdown 原文,由后端 goldmark 统一渲染 HTML。
|
||
*
|
||
* 安全模型:Markdown 文本 → goldmark → HTML(AST 生成,天然安全)
|
||
* 前端不再做 sanitize,XSS 风险由 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('请求失败,请重试')
|
||
}
|
||
}
|