fix: 修复 wangEditor 布局、预览、图片上传和链接样式
- 修复编辑区高度未撑满问题 - 预览改用 getHtml() 直接展示 - 图片上传改用 fetch + credentials:same-origin(修复 CSRF 403) - 添加帖子正文链接蓝色+下划线样式 - 草稿改用 getHtml() 保存,恢复时正确还原格式 - 草稿改为每 300 字 + 停笔 10 秒双重触发 - 空编辑区不保存草稿 - 代码块语言标签通过 DOMParser 注入(编辑器 + 预览)
This commit is contained in:
@ -8,6 +8,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
#toolbar-container {
|
||||
flex-shrink: 0;
|
||||
@ -16,6 +17,13 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.editor-pane .w-e-text-container {
|
||||
height: 100% !important;
|
||||
}
|
||||
.editor-pane .w-e-text-container [data-slate-editor] {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
|
||||
@ -104,15 +104,63 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.post-detail-body a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.post-detail-body a:hover {
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.post-detail-body pre {
|
||||
position: relative;
|
||||
background: #f3f4f6;
|
||||
padding: 16px;
|
||||
padding-top: 36px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 代码块语言标签(编辑器内 + 预览页通用) */
|
||||
.post-detail-body pre,
|
||||
#editor-container [data-slate-editor] pre {
|
||||
position: relative;
|
||||
background: #f3f4f6;
|
||||
padding: 16px;
|
||||
padding-top: 36px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 通过 JS 动态注入语言标签,因为 CSS attr() 无法从 class 中提取 */
|
||||
.code-lang-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 4px 16px;
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.post-detail-body pre code,
|
||||
#editor-container [data-slate-editor] pre code {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
.post-detail-body code {
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
@ -223,6 +271,12 @@
|
||||
/* 编辑器整体容器 */
|
||||
#editor-container {
|
||||
background: #fff;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* wangEditor 内部文本容器撑满 */
|
||||
#editor-container .w-e-text-container {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* 编辑区域 */
|
||||
@ -379,6 +433,42 @@
|
||||
font-style: normal !important;
|
||||
}
|
||||
|
||||
/* 编辑器内代码块样式 */
|
||||
#editor-container [data-slate-editor] pre {
|
||||
position: relative;
|
||||
background: #f3f4f6 !important;
|
||||
padding: 12px 16px !important;
|
||||
padding-top: 32px !important;
|
||||
border-radius: 8px !important;
|
||||
font-family: 'Menlo', 'Consolas', 'Monaco', 'Liberation Mono', monospace !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6 !important;
|
||||
overflow-x: auto !important;
|
||||
margin: 12px 0 !important;
|
||||
}
|
||||
|
||||
/* 编辑器内代码块语言标签 */
|
||||
#editor-container [data-slate-editor] pre::before {
|
||||
content: attr(data-language);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3px 16px;
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
#editor-container [data-slate-editor] pre code {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
/* 预览区 */
|
||||
.preview-pane {
|
||||
width: 0;
|
||||
|
||||
@ -31,12 +31,17 @@ let previewVisible = false;
|
||||
let syncTimer = null;
|
||||
let saveTimer = null;
|
||||
let splitTimer = null;
|
||||
let draftCharCount = 0;
|
||||
let editor = null;
|
||||
|
||||
/* ================================================================
|
||||
CSRF Token
|
||||
================================================================ */
|
||||
function getCSRFToken() {
|
||||
// 优先从 cookie 读取(与 CSRF Double Submit Cookie 一致)
|
||||
const match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/);
|
||||
if (match) return match[1];
|
||||
// fallback: 从 meta 标签读取
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
@ -56,35 +61,58 @@ function initEditor() {
|
||||
const editorConfig = {
|
||||
placeholder: '在此输入正文...',
|
||||
onChange() {
|
||||
// 同步更新代码块语言标签(立即生效)
|
||||
updateCodeLabels();
|
||||
|
||||
clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(() => {
|
||||
updateStats();
|
||||
if (previewVisible) updatePreview();
|
||||
}, 200);
|
||||
|
||||
// 字数触发:每新增 300 字符保存
|
||||
// 时间触发:停止输入 10 秒后自动保存(防止末尾内容丢失)
|
||||
const currentLen = (editor ? editor.getText().length : 0);
|
||||
if (currentLen - draftCharCount >= 300) {
|
||||
draftCharCount = currentLen;
|
||||
saveDraft();
|
||||
}
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveDraft, 30000);
|
||||
saveTimer = setTimeout(saveDraft, 10000);
|
||||
},
|
||||
MENU_CONF: {},
|
||||
};
|
||||
|
||||
// 配置上传图片
|
||||
editorConfig.MENU_CONF['uploadImage'] = {
|
||||
server: '/api/posts/upload-image',
|
||||
fieldName: 'file',
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
maxNumberOfFiles: 10,
|
||||
maxFileSize: 5 * 1024 * 1024,
|
||||
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
headers: {
|
||||
'X-CSRF-Token': getCSRFToken(),
|
||||
},
|
||||
customInsert(res, insertFn) {
|
||||
if (res.success && res.data && res.data.url) {
|
||||
insertFn(res.data.url, res.data.url, res.data.url);
|
||||
}
|
||||
},
|
||||
onFailed(xhr) {
|
||||
console.error('图片上传失败', xhr);
|
||||
// 自定义上传逻辑,适配后端 API
|
||||
customUpload(file, insertFn) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
|
||||
const csrf = getCSRFToken();
|
||||
|
||||
fetch('/api/posts/upload-image', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: fd,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success && res.data && res.data.url) {
|
||||
insertFn(res.data.url, res.data.url, res.data.url);
|
||||
} else {
|
||||
alert(res.message || '上传失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
alert('上传请求失败,请检查网络');
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -109,12 +137,11 @@ function initEditor() {
|
||||
|
||||
// 初始化
|
||||
updateStats();
|
||||
updateCodeLabels();
|
||||
loadDraft();
|
||||
saveTimer = setTimeout(saveDraft, 30000);
|
||||
|
||||
titleEl.addEventListener('input', () => {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveDraft, 30000);
|
||||
saveDraft();
|
||||
});
|
||||
|
||||
// 滚动同步
|
||||
@ -134,6 +161,48 @@ function getEditorText() {
|
||||
return editor ? editor.getText() : '';
|
||||
}
|
||||
|
||||
function getEditorHtml() {
|
||||
return editor ? editor.getHtml() : '';
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
代码块语言标签 — 从 <code class="language-xxx"> 提取并注入标签
|
||||
================================================================ */
|
||||
function updateCodeLabels() {
|
||||
if (!editor) return;
|
||||
|
||||
// wangEditor 编辑区 <code> 不带 class="language-xxx",
|
||||
// 但 getHtml() 输出带 class="language-xxx"。
|
||||
// 用 DOMParser 解析 HTML 获取语言,再注入到编辑区对应 pre。
|
||||
try {
|
||||
const html = editor.getHtml();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const htmlPres = doc.querySelectorAll('pre');
|
||||
|
||||
const editorEl = document.getElementById('editor-container');
|
||||
if (!editorEl) return;
|
||||
const domPres = editorEl.querySelectorAll('pre');
|
||||
|
||||
htmlPres.forEach((htmlPre, idx) => {
|
||||
const domPre = domPres[idx];
|
||||
if (!domPre || domPre.querySelector('.code-lang-label')) return;
|
||||
|
||||
const code = htmlPre.querySelector('code[class*="language-"]');
|
||||
if (code) {
|
||||
const match = code.className.match(/language-(\w+)/);
|
||||
if (match && match[1]) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'code-lang-label';
|
||||
label.textContent = match[1];
|
||||
domPre.style.position = 'relative';
|
||||
domPre.insertBefore(label, domPre.firstChild);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (_) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const text = getEditorText();
|
||||
const chars = text.length;
|
||||
@ -147,29 +216,32 @@ function updateStats() {
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Markdown 预览(服务端渲染)
|
||||
预览(wangEditor 直接展示 HTML,无需服务端渲染)
|
||||
================================================================ */
|
||||
function renderMarkdown(md, callback) {
|
||||
fetch('/api/posts/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() },
|
||||
body: JSON.stringify({ body: md })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => callback(d.success ? d.data.html : md))
|
||||
.catch(() => callback(md));
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
if (!previewVisible) return;
|
||||
const md = getEditorText();
|
||||
if (!md.trim()) {
|
||||
const html = editor ? editor.getHtml() : '';
|
||||
if (html === '<p><br></p>' || !html.trim()) {
|
||||
previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>';
|
||||
return;
|
||||
}
|
||||
clearTimeout(splitTimer);
|
||||
splitTimer = setTimeout(() => {
|
||||
renderMarkdown(md, html => { previewCont.innerHTML = html; });
|
||||
previewCont.innerHTML = html;
|
||||
// 预览区也注入语言标签
|
||||
previewCont.querySelectorAll('pre').forEach(pre => {
|
||||
if (pre.querySelector('.code-lang-label')) return;
|
||||
const code = pre.querySelector('code[class*="language-"]');
|
||||
if (code) {
|
||||
const match = code.className.match(/language-(\w+)/);
|
||||
if (match) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'code-lang-label';
|
||||
label.textContent = match[1];
|
||||
pre.insertBefore(label, pre.firstChild);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@ -207,27 +279,17 @@ function syncScrollToEditor() {
|
||||
================================================================ */
|
||||
function saveDraft() {
|
||||
const title = titleEl.value.trim();
|
||||
const body = getEditorText();
|
||||
const bodyHtml = getEditorHtml();
|
||||
const bodyText = getEditorText().trim();
|
||||
|
||||
if (!body && !title) return;
|
||||
// 空编辑区不保存(wangEditor 空内容 HTML 是 <p><br></p>)
|
||||
if (!bodyText && !title) return;
|
||||
|
||||
const draft = { title, body, updatedAt: Date.now() };
|
||||
const draft = { title, body: bodyHtml, updatedAt: Date.now() };
|
||||
localStorage.setItem('mlb_draft_' + (isEdit ? postIdEl.value : 'new'), JSON.stringify(draft));
|
||||
|
||||
draftStatus.style.display = '';
|
||||
draftStatus.textContent = '草稿已保存 ' + new Date().toLocaleTimeString();
|
||||
|
||||
if (isEdit && postIdEl.value) {
|
||||
silentSave(title, body);
|
||||
}
|
||||
}
|
||||
|
||||
function silentSave(title, body) {
|
||||
fetch('/api/posts/' + postIdEl.value, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() },
|
||||
body: JSON.stringify({ title, body })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
@ -237,11 +299,15 @@ function loadDraft() {
|
||||
try {
|
||||
const draft = JSON.parse(raw);
|
||||
if (draft.body || draft.title) {
|
||||
if (!isEdit || !getEditorText()) {
|
||||
if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) {
|
||||
if (editor) editor.setHtml(draft.body);
|
||||
if (draft.title && !titleEl.value) titleEl.value = draft.title;
|
||||
}
|
||||
// 如果编辑器已有内容(编辑模式从 DB 加载),跳过草稿恢复
|
||||
if (isEdit && getEditorText().trim()) return;
|
||||
|
||||
if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) {
|
||||
if (editor) editor.setHtml(draft.body);
|
||||
if (draft.title && !titleEl.value) titleEl.value = draft.title;
|
||||
} else {
|
||||
// 用户拒绝恢复,清除旧草稿
|
||||
clearDraft();
|
||||
}
|
||||
}
|
||||
} catch (_) { localStorage.removeItem(key); }
|
||||
|
||||
Reference in New Issue
Block a user