This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/templates/MetaLab-2026/static/js/editor.js
Victor_Jay fe81c879b0 fix: 修复代码块语法高亮丢失 + 语言标签注入 + 安全性增强
- 新增 cdnjs.cloudflare.com CSP 白名单,允许加载 highlight.js
- 消毒策略放行 span 元素,保留 highlight.js 高亮 class
- 详情页和新帖子页引入 highlight.js 库和 GitHub 主题
- 代码块语言选择器默认 text,支持 prev/next 循环切换语言
- 代码块语言标签注入兼容 data-language 和 code class 双来源
- 修复 utils.js defer 导致 common.js 执行时 getCSRFToken 未定义
2026-05-28 15:21:44 +08:00

415 lines
14 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 — Tiptap 所见即所得编辑器
*
* Tiptap 基于 ProseMirror输出 HTML无需 Markdown 知识即可使用。
* 后端存储 HTML通过 bluemonday 消毒确保安全。
*
* 安全模型Tiptap → HTML富文本→ 后端 bluemonday 消毒 → 存储
*/
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { common, createLowlight } from 'lowlight'
// ---- 状态 ----
let editor = null
let currentHTML = ''
let draftTimer = null
let submitting = false
// ---- 初始化 ----
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('editor-container')
if (!container) return
const postId = document.getElementById('postId')?.value
const isEdit = !!postId
// 从容器 data-content 属性获取初始 HTML 内容(由后端 SSR 渲染)
const initialContent = container.dataset.content || ''
currentHTML = initialContent
editor = new Editor({
element: container,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: {
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
},
codeBlock: false,
}),
CodeBlockLowlight.configure({
lowlight: createLowlight(common),
defaultLanguage: null,
}),
Placeholder.configure({
placeholder: '在此输入正文...',
}),
Image.configure({
inline: true,
}),
],
content: initialContent,
onUpdate: ({ editor }) => {
const html = editor.getHTML()
// 忽略空内容(<p></p>
currentHTML = html === '<p></p>' ? '' : html
updateWordCount(editor.getText())
scheduleDraft(currentHTML)
},
})
// 初始字数统计
updateWordCount(editor.getText())
// 恢复草稿(延迟确保编辑器完全初始化)
if (!isEdit) {
setTimeout(() => restoreDraft(), 100)
}
// 工具栏按钮绑定
setupToolbar()
// 全屏切换
document.getElementById('btnFullscreen')?.addEventListener('click', toggleFullscreen)
// 表单提交
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
// Ctrl+S / F11 快捷键
document.addEventListener('keydown', handleKeyboard)
})
// ---- 工具栏 ----
function setupToolbar() {
const toolbar = document.getElementById('toolbar')
if (!toolbar) return
const actionMap = {
bold: () => editor.chain().focus().toggleBold().run(),
italic: () => editor.chain().focus().toggleItalic().run(),
strike: () => editor.chain().focus().toggleStrike().run(),
code: () => editor.chain().focus().toggleCode().run(),
h1: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
h2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
h3: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
bulletList: () => editor.chain().focus().toggleBulletList().run(),
orderedList: () => editor.chain().focus().toggleOrderedList().run(),
blockquote: () => editor.chain().focus().toggleBlockquote().run(),
codeBlock: () => editor.chain().focus().toggleCodeBlock().run(),
horizontalRule: () => editor.chain().focus().setHorizontalRule().run(),
undo: () => editor.chain().focus().undo().run(),
redo: () => editor.chain().focus().redo().run(),
}
const activeCheck = {
bold: () => editor.isActive('bold'),
italic: () => editor.isActive('italic'),
strike: () => editor.isActive('strike'),
code: () => editor.isActive('code'),
h1: () => editor.isActive('heading', { level: 1 }),
h2: () => editor.isActive('heading', { level: 2 }),
h3: () => editor.isActive('heading', { level: 3 }),
bulletList: () => editor.isActive('bulletList'),
orderedList: () => editor.isActive('orderedList'),
blockquote: () => editor.isActive('blockquote'),
codeBlock: () => editor.isActive('codeBlock'),
}
function updateActive() {
toolbar.querySelectorAll('[data-action]').forEach(btn => {
const action = btn.dataset.action
const check = activeCheck[action]
btn.classList.toggle('is-active', check ? check() : false)
})
}
toolbar.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const action = btn.dataset.action
if (actionMap[action]) {
actionMap[action]()
updateActive()
}
})
// ---- 代码块语言选择器 ----
const langSelect = document.getElementById('codeLangSelect')
if (langSelect) {
// 阻止 select 获取焦点导致编辑器失焦
langSelect.addEventListener('mousedown', (e) => {
e.preventDefault()
// 手动切换选项
const opts = langSelect.options
let nextIdx = langSelect.selectedIndex + 1
if (nextIdx >= opts.length) nextIdx = 0
langSelect.selectedIndex = nextIdx
const lang = opts[nextIdx].value
// 在编辑器内更新代码块语言属性
editor.chain().focus().updateAttributes('codeBlock', { language: lang === 'text' ? null : lang }).run()
})
// 当光标在代码块内时显示语言选择器并同步当前语言,移出时隐藏
editor.on('selectionUpdate', () => {
if (editor.isActive('codeBlock')) {
langSelect.style.display = ''
const attrs = editor.getAttributes('codeBlock')
langSelect.value = attrs.language || 'text'
} else {
langSelect.style.display = 'none'
}
})
}
// ---- 代码块语言标签注入 ----
// 为每个 <pre> 注入语言标签 DOM 元素(因为 CSS ::before 在 overflow:auto 容器中不可靠)
// Tiptap CodeBlockLowlight 在 pre 上输出 data-language 属性,同时 code 上有 language-xxx class
function injectCodeLangLabels() {
const editorEl = document.querySelector('#editor-container .tiptap')
if (!editorEl) return
editorEl.querySelectorAll('pre').forEach(pre => {
// 移除旧标签
const old = pre.querySelector('.code-lang-label')
if (old) old.remove()
// 从 pre 的 data-language 或 code 的 class 中获取语言
let lang = pre.getAttribute('data-language')
if (!lang) {
const code = pre.querySelector('code')
if (code) {
const match = code.className.match(/language-(\w+)/)
if (match) lang = match[1]
}
}
lang = lang || 'text'
const label = document.createElement('div')
label.className = 'code-lang-label'
label.textContent = lang
pre.insertBefore(label, pre.firstChild)
pre.style.paddingTop = '32px'
})
}
editor.on('update', injectCodeLangLabels)
// 初始注入
setTimeout(injectCodeLangLabels, 50)
editor.on('selectionUpdate', updateActive)
editor.on('update', updateActive)
}
// ---- 图片上传(拖拽/粘贴) ----
// Tiptap 默认支持图片拖入,但我们需要上传到服务器并替换 src
// 通过监听 editor 的 drop/paste 事件处理图片
document.addEventListener('DOMContentLoaded', () => {
// 延迟绑定,等 editor 初始化
setTimeout(() => {
const editorEl = document.querySelector('#editor-container .tiptap')
if (!editorEl) return
// 粘贴图片
editorEl.addEventListener('paste', async (e) => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
const url = await uploadSingleImage(file)
if (url) {
editor.chain().focus().setImage({ src: url }).run()
}
break
}
}
})
// 拖入图片
editorEl.addEventListener('drop', async (e) => {
const files = e.dataTransfer?.files
if (!files || files.length === 0) return
const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'))
if (imageFiles.length === 0) return
e.preventDefault()
for (const file of imageFiles) {
const url = await uploadSingleImage(file)
if (url) {
editor.chain().focus().setImage({ src: url }).run()
}
}
})
}, 200)
})
async function uploadSingleImage(file) {
const csrfMeta = document.querySelector('meta[name="csrf-token"]')
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
const formData = new FormData()
formData.append('file', file)
try {
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) {
return data.data?.url || data.url || ''
}
} catch (e) {
// 上传失败,静默忽略
}
return ''
}
// ---- 字数统计 ----
function updateWordCount(text) {
const el = document.getElementById('wordCount')
if (!el) return
const len = (text || '').replace(/\s/g, '').length
el.textContent = len + ' 字'
}
// ---- 草稿保存/恢复 ----
function scheduleDraft(html) {
clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraft(html), 2000)
}
function saveDraft(html) {
const title = document.getElementById('postTitle')?.value || ''
if (!html.trim() && !title.trim()) return
try {
localStorage.setItem('draft_post_title', title)
localStorage.setItem('draft_post_body_html', html)
const statusEl = document.getElementById('draftStatus')
if (statusEl) {
statusEl.style.display = ''
setTimeout(() => { statusEl.style.display = 'none' }, 2000)
}
} catch (e) {
// localStorage 不可用,静默忽略
}
}
function restoreDraft() {
try {
const title = localStorage.getItem('draft_post_title')
const html = localStorage.getItem('draft_post_body_html')
if (title) {
document.getElementById('postTitle').value = title
}
if (html && editor) {
editor.commands.setContent(html)
currentHTML = html
updateWordCount(editor.getText())
}
} catch (e) {
// 静默忽略
}
}
function clearDraft() {
try {
localStorage.removeItem('draft_post_title')
localStorage.removeItem('draft_post_body_html')
} 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()
if (submitting) return
submitting = true
const submitBtn = document.querySelector('.btn-submit')
if (submitBtn) {
submitBtn.disabled = true
submitBtn.textContent = '提交中...'
}
try {
const postId = document.getElementById('postId')?.value
const title = document.getElementById('postTitle')?.value.trim()
if (!title) { alert('请输入标题'); return }
// 获取编辑器 HTML 内容
const body = editor ? editor.getHTML() : ''
// 忽略空段落
const cleanBody = body === '<p></p>' ? '' : body
if (!cleanBody.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') : ''
const resp = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify({ title, body: cleanBody }),
})
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) {
if (err && err.message === 'refresh_failed') {
alert('登录已过期,请重新登录')
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
} else {
alert('请求失败,请重试')
}
} finally {
submitting = false
if (submitBtn) {
submitBtn.disabled = false
submitBtn.textContent = submitBtn.textContent === '提交中...' ?
(document.getElementById('postId')?.value ? '保存修改' : '发布帖子') : submitBtn.textContent
}
}
}