Files
mce/templates/MetaLab-2026/static/js/editor.js
Victor_Jay 36b18c0ffe feat: 代码块语法高亮 + 语言选择 + 样式统一
- 引入 @tiptap/extension-code-block-lowlight + lowlight 实现代码语法高亮
- 工具栏新增语言下拉选择器,光标在代码块内时显示,支持 27 种常用语言
- 默认语言 text,高亮主题适配 MetaLab 深蓝背景 (#1a2332)
- 代码块语言标签用 JS 动态注入 DOM 元素(避免 ::before 在 overflow 容器中失效)
- 详情页同步代码块高亮和语言标签显示
- 编辑器与详情页样式统一:代码块暗色背景、引用蓝色边框、链接 accent 蓝
- sanitizePolicy 放行 data-language 属性和 hljs-* class
- 修复 import map 避免 keyed plugin 重复实例
- 修复详情页代码块开头空白行(移除多余的 padding-top)
- 修复语法错误(多余的闭合括号)
2026-05-28 14:34:58 +08:00

385 lines
13 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
// ---- 初始化 ----
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) {
// 当光标在代码块内时显示语言选择器,移出时隐藏
editor.on('selectionUpdate', () => {
if (editor.isActive('codeBlock')) {
langSelect.style.display = ''
const attrs = editor.getAttributes('codeBlock')
langSelect.value = attrs.language || ''
} else {
langSelect.style.display = 'none'
}
})
// 语言切换
langSelect.addEventListener('change', () => {
const lang = langSelect.value
editor.chain().focus().updateAttributes('codeBlock', { language: lang || null }).run()
editor.commands.focus()
})
}
// ---- 代码块语言标签注入 ----
// 为每个 <pre> 注入语言标签 DOM 元素(因为 CSS ::before 在 overflow:auto 容器中不可靠)
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()
const lang = pre.getAttribute('data-language')
if (lang) {
const label = document.createElement('div')
label.className = 'code-lang-label'
label.textContent = lang
pre.insertBefore(label, pre.firstChild)
pre.style.paddingTop = '32px'
} else {
pre.style.paddingTop = ''
}
})
}
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()
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') : ''
try {
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('请求失败,请重试')
}
}
}