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
This commit is contained in:
2026-05-28 11:21:53 +08:00
parent 22a302315a
commit 69ba2fb01b
11 changed files with 467 additions and 166 deletions

View File

@ -24,11 +24,59 @@
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
// Also intercept 401 responses to auto-refresh token
(function () {
var token = window.MetaLab.csrfToken();
if (!token) return;
var SAME_ORIGIN = /^\/(?!\/)/;
var isRefreshing = false;
var refreshPromise = null;
var pendingRetries = [];
// Patch XMLHttpRequest (used by register.js / login.js / common.js)
function resolveCSRFToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function getCSRFTokenHeader() {
var t = resolveCSRFToken();
return t || window.MetaLab.csrfToken();
}
// ---- Token refresh logic ----
function doRefresh() {
if (isRefreshing) return refreshPromise;
isRefreshing = true;
refreshPromise = fetch('/api/auth/refresh', { method: 'POST' })
.then(function (res) {
isRefreshing = false;
refreshPromise = null;
if (!res.ok) {
// Refresh failed — clear all pending retries
while (pendingRetries.length) {
var r = pendingRetries.shift();
r.reject(new Error('refresh_failed'));
}
return Promise.reject(new Error('refresh_failed'));
}
// Retry all pending requests
while (pendingRetries.length) {
var retry = pendingRetries.shift();
retry.retry();
}
return true;
})
.catch(function (err) {
isRefreshing = false;
refreshPromise = null;
while (pendingRetries.length) {
var r2 = pendingRetries.shift();
r2.reject(err);
}
return Promise.reject(err);
});
return refreshPromise;
}
// ---- Patch XMLHttpRequest ----
var origXHROpen = XMLHttpRequest.prototype.open;
var origXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
@ -36,31 +84,100 @@
this._url = url;
return origXHROpen.apply(this, arguments);
};
var SAME_ORIGIN = /^\/(?!\/)/;
XMLHttpRequest.prototype.send = function () {
if (SAME_ORIGIN.test(this._url) &&
this._method && !/^(GET|HEAD|OPTIONS)$/i.test(this._method)) {
this.setRequestHeader('X-CSRF-Token', token);
var xhr = this;
if (SAME_ORIGIN.test(xhr._url) &&
xhr._method && !/^(GET|HEAD|OPTIONS)$/i.test(xhr._method)) {
xhr.setRequestHeader('X-CSRF-Token', getCSRFTokenHeader());
}
return origXHRSend.apply(this, arguments);
// 401 interception for XHR
var origOnReady = xhr.onreadystatechange;
xhr.addEventListener('readystatechange', function () {
if (xhr.readyState === 4 && xhr.status === 401 &&
xhr._url !== '/api/auth/refresh' &&
!xhr._retried) {
xhr._retried = true;
// Abort the original response handling
xhr._blocked = true;
doRefresh().then(function () {
// Re-create and re-send the request
var newXhr = new XMLHttpRequest();
newXhr.open(xhr._method, xhr._url, true);
// Copy relevant properties
newXhr.onreadystatechange = origOnReady;
newXhr.setRequestHeader('X-CSRF-Token', getCSRFTokenHeader());
newXhr.send(xhr._body);
}).catch(function () {
// Refresh failed, trigger original error handler
xhr._blocked = false;
if (origOnReady) origOnReady.call(xhr);
});
}
if (!xhr._blocked && origOnReady) {
origOnReady.call(xhr);
}
});
// Store body for potential retry
if (xhr._method && !/^(GET|HEAD)$/i.test(xhr._method)) {
xhr._body = arguments[0];
}
return origXHRSend.apply(xhr, arguments);
};
// Patch fetch (used by auto-refresh)
// ---- Patch fetch with 401 interceptor ----
var origFetch = window.fetch;
window.fetch = function (url, options) {
options = options || {};
var headers = options.headers || {};
var method = (options.method || 'GET').toUpperCase();
var urlStr = (typeof url === 'string') ? url : '';
if (SAME_ORIGIN.test(urlStr) && !/^(GET|HEAD|OPTIONS)$/i.test(method)) {
if (headers instanceof Headers) {
headers.set('X-CSRF-Token', token);
headers.set('X-CSRF-Token', getCSRFTokenHeader());
} else {
headers['X-CSRF-Token'] = token;
headers['X-CSRF-Token'] = getCSRFTokenHeader();
}
options.headers = headers;
}
return origFetch(url, options);
// Don't intercept the refresh request itself
if (urlStr === '/api/auth/refresh') {
return origFetch(url, options);
}
return origFetch(url, options).then(function (res) {
if (res.status === 401) {
// Clone the response so we can read it once for logging
var cloned = res.clone();
// Queue this request for retry after refresh
return new Promise(function (resolve, reject) {
pendingRetries.push({
retry: function () {
// Re-fetch with updated CSRF token
var retryOpts = Object.assign({}, options);
var retryHeaders = retryOpts.headers || {};
if (retryHeaders instanceof Headers) {
retryHeaders.set('X-CSRF-Token', getCSRFTokenHeader());
} else {
retryHeaders['X-CSRF-Token'] = getCSRFTokenHeader();
}
retryOpts.headers = retryHeaders;
resolve(origFetch(url, retryOpts));
},
reject: function (err) {
reject(err);
}
});
doRefresh();
});
}
return res;
});
};
})();
@ -162,7 +279,9 @@
// ---- Auto-refresh token (silent) ----
// 场景 A页面开着时每 10 分钟静默续期(保持 access token 不过期)
// 场景 B关浏览器 15+ min 后回来access token 过期但 refresh 仍有效
// → 页面渲染为未登录 → 刷新成功后 reload 一次获得登录态
// → 页面渲染为未登录 → 刷新成功后 reload 一次获得登录态
// → 仅当页面是未登录状态(无 logoutBtn时才触发 reload
// 场景 C编辑器等页面发 API 请求时遇到 401 → 由 fetch/XHR patch 自动刷新后重试
// 不在认证页面执行刷新(登录/注册页无需续期)
var onAuthPage = /^\/auth\//.test(window.location.pathname);
if (!onAuthPage) {
@ -190,23 +309,22 @@
});
}
// 仅当存在"记住我"标记 Cookie 时才尝试刷新(避免无需刷新时产生 401 控制台报错)
function hasCookie(name) {
return document.cookie.split(';').some(function (c) {
return c.trim().startsWith(name + '=');
});
}
if (hasCookie('mlb_rm')) {
// 启动定时刷新:只要用户已登录(有 logoutBtn 元素),就定期刷新
if (document.getElementById('logoutBtn')) {
// 页面已经是登录态,启动定时刷新即可,无需 reload
tryRefresh(function () {
refreshTimer = setInterval(function () { tryRefresh(null); }, REFRESH_INTERVAL);
if (!document.getElementById('logoutBtn') && !sessionStorage.getItem('mlb_refreshed')) {
sessionStorage.setItem('mlb_refreshed', '1');
window.location.reload();
}
});
sessionStorage.removeItem('mlb_refreshed');
} else {
// 场景 B页面渲染为未登录无 logoutBtn但 refresh token 可能仍有效
// 尝试刷新一次,成功后 reload 页面获取登录态
// 使用 sessionStorage 防止死循环reload 后再次进入此分支不会再 reload
if (!sessionStorage.getItem('mlb_refreshed')) {
sessionStorage.setItem('mlb_refreshed', '1');
tryRefresh(function () {
window.location.reload();
});
}
}
} // end if (!onAuthPage)

View File

@ -1,19 +1,20 @@
/**
* MetaLab Editor — ByteMD Markdown 编辑器
* MetaLab Editor — Tiptap 所见即所得编辑器
*
* ByteMD 基于 CodeMirror 5纯 textarea 兜底,不依赖 contentEditable
* 输出 Markdown 原文,由后端 goldmark 统一渲染 HTML
* Tiptap 基于 ProseMirror输出 HTML无需 Markdown 知识即可使用。
* 后端存储 HTML通过 bluemonday 消毒确保安全
*
* 安全模型:Markdown 文本 → goldmark → HTMLAST 生成,天然安全)
* 前端不再做 sanitizeXSS 风险由 goldmark 的 AST 输出消除。
* 安全模型:Tiptap → HTML富文本→ 后端 bluemonday 消毒 → 存储
*/
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'
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 currentValue = ''
let currentHTML = ''
let draftTimer = null
// ---- 初始化 ----
@ -24,74 +25,181 @@ document.addEventListener('DOMContentLoaded', () => {
const postId = document.getElementById('postId')?.value
const isEdit = !!postId
// 从容器 data-value 属性获取初始内容(由后端 SSR 渲染)
const initialValue = container.dataset.value || ''
currentValue = initialValue
const plugins = [
gfm(),
highlight(),
]
// 从容器 data-content 属性获取初始 HTML 内容(由后端 SSR 渲染)
const initialContent = container.dataset.content || ''
currentHTML = initialContent
editor = new Editor({
target: container,
props: {
value: initialValue,
plugins,
mode: 'split',
placeholder: '在此输入正文... (Markdown)',
uploadImages: uploadImage,
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)
},
})
// 监听内容变化,同步 currentValue
editor.$on('change', (e) => {
currentValue = e.detail.value
updateWordCount(currentValue)
scheduleDraft(currentValue)
})
// 初始字数统计
updateWordCount(initialValue)
updateWordCount(editor.getText())
// 恢复草稿(延迟确保 ByteMD 完全初始化)
// 恢复草稿(延迟确保编辑器完全初始化)
if (!isEdit) {
setTimeout(() => restoreDraft(), 100)
}
// 工具栏按钮绑定
setupToolbar()
// 全屏切换
document.getElementById('btnFullscreen')?.addEventListener('click', toggleFullscreen)
// 表单提交
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
// Ctrl+S 快捷键
// Ctrl+S / F11 快捷键
document.addEventListener('keydown', handleKeyboard)
})
// ---- 图片上传 ----
async function uploadImage(files) {
// ---- 工具栏 ----
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 results = []
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
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) {
const url = data.data?.url || data.url
results.push({ url, alt: file.name, title: file.name })
return data.data?.url || data.url || ''
}
} catch (e) {
// 上传失败,静默忽略
}
return results
return ''
}
// ---- 字数统计 ----
@ -103,43 +211,40 @@ function updateWordCount(text) {
}
// ---- 草稿保存/恢复 ----
function scheduleDraft(text) {
function scheduleDraft(html) {
clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraft(text), 2000)
draftTimer = setTimeout(() => saveDraft(html), 2000)
}
function saveDraft(text) {
function saveDraft(html) {
const title = document.getElementById('postTitle')?.value || ''
if (!text.trim() && !title.trim()) return
if (!html.trim() && !title.trim()) return
try {
localStorage.setItem('draft_post_title', title)
localStorage.setItem('draft_post_body', text)
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 不可用(隐私模式等),静默忽略
// 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')
const html = localStorage.getItem('draft_post_body_html')
if (title) {
document.getElementById('postTitle').value = title
}
if (body && editor) {
editor.$set({ value: body })
currentValue = body
updateWordCount(body)
if (html && editor) {
editor.commands.setContent(html)
currentHTML = html
updateWordCount(editor.getText())
}
} catch (e) {
// 静默忽略
@ -149,7 +254,7 @@ function restoreDraft() {
function clearDraft() {
try {
localStorage.removeItem('draft_post_title')
localStorage.removeItem('draft_post_body')
localStorage.removeItem('draft_post_body_html')
} catch (e) { /* ignore */ }
}
@ -181,7 +286,12 @@ async function handleSubmit(e) {
const title = document.getElementById('postTitle')?.value.trim()
if (!title) { alert('请输入标题'); return }
if (!currentValue.trim()) { 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'
@ -197,7 +307,7 @@ async function handleSubmit(e) {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
},
body: JSON.stringify({ title, body: currentValue }),
body: JSON.stringify({ title, body: cleanBody }),
})
const data = await resp.json()
@ -209,6 +319,11 @@ async function handleSubmit(e) {
alert(data.message || '操作失败')
}
} catch (err) {
alert('请求失败,请重试')
if (err && err.message === 'refresh_failed') {
alert('登录已过期,请重新登录')
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
} else {
alert('请求失败,请重试')
}
}
}