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;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
#toolbar-container {
|
#toolbar-container {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@ -16,6 +17,13 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
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>
|
</style>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -104,15 +104,63 @@
|
|||||||
margin-bottom: 12px;
|
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 {
|
.post-detail-body pre {
|
||||||
|
position: relative;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
padding-top: 36px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
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 {
|
.post-detail-body code {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@ -223,6 +271,12 @@
|
|||||||
/* 编辑器整体容器 */
|
/* 编辑器整体容器 */
|
||||||
#editor-container {
|
#editor-container {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* wangEditor 内部文本容器撑满 */
|
||||||
|
#editor-container .w-e-text-container {
|
||||||
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑区域 */
|
/* 编辑区域 */
|
||||||
@ -379,6 +433,42 @@
|
|||||||
font-style: normal !important;
|
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 {
|
.preview-pane {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|||||||
@ -31,12 +31,17 @@ let previewVisible = false;
|
|||||||
let syncTimer = null;
|
let syncTimer = null;
|
||||||
let saveTimer = null;
|
let saveTimer = null;
|
||||||
let splitTimer = null;
|
let splitTimer = null;
|
||||||
|
let draftCharCount = 0;
|
||||||
let editor = null;
|
let editor = null;
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
CSRF Token
|
CSRF Token
|
||||||
================================================================ */
|
================================================================ */
|
||||||
function getCSRFToken() {
|
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"]');
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
return meta ? meta.getAttribute('content') : '';
|
return meta ? meta.getAttribute('content') : '';
|
||||||
}
|
}
|
||||||
@ -56,35 +61,58 @@ function initEditor() {
|
|||||||
const editorConfig = {
|
const editorConfig = {
|
||||||
placeholder: '在此输入正文...',
|
placeholder: '在此输入正文...',
|
||||||
onChange() {
|
onChange() {
|
||||||
|
// 同步更新代码块语言标签(立即生效)
|
||||||
|
updateCodeLabels();
|
||||||
|
|
||||||
clearTimeout(syncTimer);
|
clearTimeout(syncTimer);
|
||||||
syncTimer = setTimeout(() => {
|
syncTimer = setTimeout(() => {
|
||||||
updateStats();
|
updateStats();
|
||||||
if (previewVisible) updatePreview();
|
if (previewVisible) updatePreview();
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
|
// 字数触发:每新增 300 字符保存
|
||||||
|
// 时间触发:停止输入 10 秒后自动保存(防止末尾内容丢失)
|
||||||
|
const currentLen = (editor ? editor.getText().length : 0);
|
||||||
|
if (currentLen - draftCharCount >= 300) {
|
||||||
|
draftCharCount = currentLen;
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(saveDraft, 30000);
|
saveTimer = setTimeout(saveDraft, 10000);
|
||||||
},
|
},
|
||||||
MENU_CONF: {},
|
MENU_CONF: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 配置上传图片
|
// 配置上传图片
|
||||||
editorConfig.MENU_CONF['uploadImage'] = {
|
editorConfig.MENU_CONF['uploadImage'] = {
|
||||||
server: '/api/posts/upload-image',
|
maxFileSize: 5 * 1024 * 1024,
|
||||||
fieldName: 'file',
|
|
||||||
maxFileSize: 10 * 1024 * 1024,
|
|
||||||
maxNumberOfFiles: 10,
|
|
||||||
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||||
headers: {
|
// 自定义上传逻辑,适配后端 API
|
||||||
'X-CSRF-Token': getCSRFToken(),
|
customUpload(file, insertFn) {
|
||||||
},
|
const fd = new FormData();
|
||||||
customInsert(res, insertFn) {
|
fd.append('file', file);
|
||||||
if (res.success && res.data && res.data.url) {
|
|
||||||
insertFn(res.data.url, res.data.url, res.data.url);
|
const csrf = getCSRFToken();
|
||||||
}
|
|
||||||
},
|
fetch('/api/posts/upload-image', {
|
||||||
onFailed(xhr) {
|
method: 'POST',
|
||||||
console.error('图片上传失败', xhr);
|
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();
|
updateStats();
|
||||||
|
updateCodeLabels();
|
||||||
loadDraft();
|
loadDraft();
|
||||||
saveTimer = setTimeout(saveDraft, 30000);
|
|
||||||
|
|
||||||
titleEl.addEventListener('input', () => {
|
titleEl.addEventListener('input', () => {
|
||||||
clearTimeout(saveTimer);
|
saveDraft();
|
||||||
saveTimer = setTimeout(saveDraft, 30000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 滚动同步
|
// 滚动同步
|
||||||
@ -134,6 +161,48 @@ function getEditorText() {
|
|||||||
return editor ? editor.getText() : '';
|
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() {
|
function updateStats() {
|
||||||
const text = getEditorText();
|
const text = getEditorText();
|
||||||
const chars = text.length;
|
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() {
|
function updatePreview() {
|
||||||
if (!previewVisible) return;
|
if (!previewVisible) return;
|
||||||
const md = getEditorText();
|
const html = editor ? editor.getHtml() : '';
|
||||||
if (!md.trim()) {
|
if (html === '<p><br></p>' || !html.trim()) {
|
||||||
previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>';
|
previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearTimeout(splitTimer);
|
clearTimeout(splitTimer);
|
||||||
splitTimer = setTimeout(() => {
|
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);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,27 +279,17 @@ function syncScrollToEditor() {
|
|||||||
================================================================ */
|
================================================================ */
|
||||||
function saveDraft() {
|
function saveDraft() {
|
||||||
const title = titleEl.value.trim();
|
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));
|
localStorage.setItem('mlb_draft_' + (isEdit ? postIdEl.value : 'new'), JSON.stringify(draft));
|
||||||
|
|
||||||
draftStatus.style.display = '';
|
draftStatus.style.display = '';
|
||||||
draftStatus.textContent = '草稿已保存 ' + new Date().toLocaleTimeString();
|
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() {
|
function loadDraft() {
|
||||||
@ -237,11 +299,15 @@ function loadDraft() {
|
|||||||
try {
|
try {
|
||||||
const draft = JSON.parse(raw);
|
const draft = JSON.parse(raw);
|
||||||
if (draft.body || draft.title) {
|
if (draft.body || draft.title) {
|
||||||
if (!isEdit || !getEditorText()) {
|
// 如果编辑器已有内容(编辑模式从 DB 加载),跳过草稿恢复
|
||||||
if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) {
|
if (isEdit && getEditorText().trim()) return;
|
||||||
if (editor) editor.setHtml(draft.body);
|
|
||||||
if (draft.title && !titleEl.value) titleEl.value = draft.title;
|
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); }
|
} catch (_) { localStorage.removeItem(key); }
|
||||||
|
|||||||
Reference in New Issue
Block a user