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 69ba2fb01b refactor: ByteMD 迁移至 Tiptap WYSIWYG 编辑器 + Token 刷新优化
- 移除 ByteMD CDN 资源和 goldmark 依赖,替换为 Tiptap ESM 模块
- 编辑器从源码编辑模式改为 WYSIWYG 所见即所得
- 移除 /api/posts/preview 预览接口(Tiptap 直接输出 HTML 无需后端渲染)
- 保留 UploadImage 图片上传接口和 bluemonday HTML 消毒
- 工具栏支持加粗、斜体、删除线、代码、标题、列表、引用、代码块、分割线、撤销/重做
- 支持图片粘贴/拖拽上传,Ctrl+S 快捷提交,F11 全屏,字数统计
- 优化 common.js:自动 401 拦截 Token 刷新,修复刷新死循环
- 草稿 localStorage 键名更新为 draft_post_body_html
2026-05-28 11:21:53 +08:00

330 lines
11 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 'https://esm.sh/@tiptap/core@3.13.0'
import StarterKit from 'https://esm.sh/@tiptap/starter-kit@3.13.0'
import Placeholder from 'https://esm.sh/@tiptap/extension-placeholder@3.13.0'
import Link from 'https://esm.sh/@tiptap/extension-link@3.13.0'
import Image from 'https://esm.sh/@tiptap/extension-image@3.13.0'
// ---- 状态 ----
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] },
}),
Placeholder.configure({
placeholder: '在此输入正文...',
}),
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
}),
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()
}
})
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('请求失败,请重试')
}
}
}