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 804fd50edf fix: 修复编辑器代码块换行滚动跳动 + 移除视频和网络图片功能
- 修复 wangeditor 选区同步 scrollIntoView 导致代码块换行大幅跳动的问题
  - 移除 getBoundingClientRect 替换 hack,改用元素自身真实位置计算滚动
  - 将 block 参数从 end 改为 nearest,behavior 从 smooth 改为 instant
- 修复 #editor-container 与 .w-e-scroll 双重滚动容器冲突
  - #editor-container 和 .w-e-text-container 设为 overflow:hidden
  - .w-e-scroll 作为唯一滚动容器设 overflow-y:auto
- updateCodeLabels 改为节流执行并保存/恢复滚动位置
- 新增 ensureCursorVisible 替代内置滚动保证光标可见
- 滚动同步函数统一引用 .w-e-scroll 容器
- 工具栏排除视频相关按钮和网络图片功能
- .gitignore 添加 .codebuddy
2026-05-28 01:44:46 +08:00

487 lines
17 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 Markdown Editor — wangEditor 5 版
*
* 使用 wangEditor 所见即所得编辑器,通过 Markdown 语法输入实现快捷编辑。
* 提交时将内容转为 Markdown 字符串发送到后端。
*/
(function () {
'use strict';
/* ================================================================
DOM 引用
================================================================ */
const form = document.getElementById('postForm');
const postIdEl = document.getElementById('postId');
const titleEl = document.getElementById('postTitle');
const bodyEl = document.getElementById('postBody');
const editorPane = document.getElementById('editorPane');
const previewPane = document.getElementById('previewPane');
const previewCont = document.getElementById('previewContent');
const btnToggle = document.getElementById('btnTogglePreview');
const btnFull = document.getElementById('btnFullscreen');
const wordCountEl = document.getElementById('wordCount');
const lineCountEl = document.getElementById('lineCount');
const readTimeEl = document.getElementById('readTime');
const draftStatus = document.getElementById('draftStatus');
const isEdit = !!(postIdEl && postIdEl.value);
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') : '';
}
/* ================================================================
创建 wangEditor 编辑器
================================================================ */
function initEditor() {
if (!window.wangEditor) {
console.error('wangEditor not loaded, retrying...');
setTimeout(initEditor, 200);
return;
}
const { createEditor, createToolbar } = window.wangEditor;
const editorConfig = {
placeholder: '在此输入正文...',
onChange() {
// 延迟更新代码块语言标签(同步操作 DOM 会干扰 wangEditor 内部同步,导致滚动跳动)
updateCodeLabels();
// 替代 wangEditor 内置 scrollIntoView已禁用仅在光标不可见时微调滚动
ensureCursorVisible();
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, 10000);
},
MENU_CONF: {},
};
// 配置上传图片
editorConfig.MENU_CONF['uploadImage'] = {
maxFileSize: 5 * 1024 * 1024,
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
// 自定义上传逻辑,适配后端 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('上传请求失败,请检查网络');
});
},
};
// 创建编辑器
editor = createEditor({
selector: '#editor-container',
html: bodyEl ? bodyEl.value : '<p><br></p>',
config: editorConfig,
mode: 'default',
});
// 创建工具栏
createToolbar({
editor,
selector: '#toolbar-container',
config: {
excludeKeys: [
// 移除视频相关按钮:不允许视频上传,不在前端提供视频按钮
'group-video', 'insertVideo', 'uploadVideo',
// 移除"网络图片"功能,仅保留上传图片
'insertImage',
],
},
mode: 'default',
});
// 移除原始 textarea
if (bodyEl) bodyEl.remove();
// 初始化
updateStats();
updateCodeLabels();
loadDraft();
titleEl.addEventListener('input', () => {
saveDraft();
});
// 滚动同步(.w-e-scroll 是编辑区唯一滚动容器)
previewCont.addEventListener('scroll', syncScrollToEditor);
setTimeout(() => {
const scroller = (document.getElementById('editor-container') || editorPane).querySelector('.w-e-scroll');
if (scroller) scroller.addEventListener('scroll', syncScrollToPreview);
}, 500);
console.log('%c📝 MetaLab Editor (wangEditor) ready', 'color:#6366f1;font-weight:bold');
}
/* ================================================================
确保光标可见 — 替代 wangEditor 内置 scrollIntoView
wangEditor 默认使用 block:"end" 会在代码块换行时导致大幅滚动,
已在 wangeditor.js 中禁用该调用,此处用最小偏移量保证光标可见
================================================================ */
function ensureCursorVisible() {
if (!editor) return;
requestAnimationFrame(() => {
try {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
// .w-e-scroll 是编辑区滚动容器
const scroller = document.querySelector('#editor-container .w-e-scroll');
if (!scroller) return;
const cursorRect = range.getBoundingClientRect();
const scrollRect = scroller.getBoundingClientRect();
// 光标低于可见区 → 向下滚到光标可见
if (cursorRect.bottom > scrollRect.bottom) {
scroller.scrollTop += cursorRect.bottom - scrollRect.bottom + 8;
}
// 光标高于可见区 → 向上滚到光标可见
else if (cursorRect.top < scrollRect.top) {
scroller.scrollTop -= scrollRect.top - cursorRect.top + 8;
}
} catch (_) { /* 忽略 */ }
});
}
/* ================================================================
字数统计
================================================================ */
function getEditorText() {
return editor ? editor.getText() : '';
}
function getEditorHtml() {
return editor ? editor.getHtml() : '';
}
/* ================================================================
代码块语言标签 — 从 <code class="language-xxx"> 提取并注入标签
================================================================ */
// 代码块语言标签更新节流定时器
let codeLabelTimer = null;
function updateCodeLabels() {
if (!editor) return;
// 节流:避免 onChange 中频繁调用导致滚动跳动
clearTimeout(codeLabelTimer);
codeLabelTimer = setTimeout(_updateCodeLabelsImpl, 300);
}
function _updateCodeLabelsImpl() {
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');
// 保存当前滚动位置,防止 DOM 操作引起跳动
// .w-e-scroll 是 wangEditor 内部的滚动容器
const scroller = editorEl.querySelector('.w-e-scroll');
const savedScrollTop = scroller ? scroller.scrollTop : 0;
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);
}
}
});
// 恢复滚动位置
if (scroller && scroller.scrollTop !== savedScrollTop) {
scroller.scrollTop = savedScrollTop;
}
} catch (_) { /* 忽略 */ }
}
function updateStats() {
const text = getEditorText();
const chars = text.length;
const lines = text.split('\n').length;
const words = text.trim() ? text.trim().split(/\s+/).length : 0;
const minutes = Math.max(1, Math.ceil(words / 200));
wordCountEl.textContent = chars + ' 字';
lineCountEl.textContent = lines + ' 行';
readTimeEl.textContent = '约 ' + minutes + ' 分钟';
}
/* ================================================================
预览wangEditor 直接展示 HTML无需服务端渲染
================================================================ */
function updatePreview() {
if (!previewVisible) return;
const html = editor ? editor.getHtml() : '';
if (html === '<p><br></p>' || !html.trim()) {
previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>';
return;
}
clearTimeout(splitTimer);
splitTimer = setTimeout(() => {
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);
}
function togglePreview() {
previewVisible = !previewVisible;
previewPane.classList.toggle('visible', previewVisible);
if (previewVisible) updatePreview();
}
/* ================================================================
滚动同步
================================================================ */
let scrollSyncing = false;
function getEditorScroller() {
const el = document.getElementById('editor-container') || editorPane;
return el ? (el.querySelector('.w-e-scroll') || el) : null;
}
function syncScrollToPreview() {
if (!previewVisible || scrollSyncing) return;
scrollSyncing = true;
const scroller = getEditorScroller();
if (!scroller) { scrollSyncing = false; return; }
const ratio = scroller.scrollTop / Math.max(1, scroller.scrollHeight - scroller.clientHeight);
previewCont.scrollTop = ratio * Math.max(1, previewCont.scrollHeight - previewCont.clientHeight);
requestAnimationFrame(() => { scrollSyncing = false; });
}
function syncScrollToEditor() {
if (!previewVisible || scrollSyncing) return;
scrollSyncing = true;
const scroller = getEditorScroller();
if (!scroller) { scrollSyncing = false; return; }
const ratio = previewCont.scrollTop / Math.max(1, previewCont.scrollHeight - previewCont.clientHeight);
scroller.scrollTop = ratio * Math.max(1, scroller.scrollHeight - scroller.clientHeight);
requestAnimationFrame(() => { scrollSyncing = false; });
}
/* ================================================================
自动保存草稿
================================================================ */
function saveDraft() {
const title = titleEl.value.trim();
const bodyHtml = getEditorHtml();
const bodyText = getEditorText().trim();
// 空编辑区不保存wangEditor 空内容 HTML 是 <p><br></p>
if (!bodyText && !title) return;
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();
}
function loadDraft() {
const key = 'mlb_draft_' + (isEdit ? postIdEl.value : 'new');
const raw = localStorage.getItem(key);
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (draft.body || 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); }
}
function clearDraft() {
const key = 'mlb_draft_' + (isEdit ? postIdEl.value : 'new');
localStorage.removeItem(key);
}
/* ================================================================
全屏编辑
================================================================ */
function toggleFullscreen() {
document.querySelector('.editor-layout').classList.toggle('fullscreen');
if (editor) editor.focus(true);
}
/* ================================================================
事件绑定
================================================================ */
if (btnToggle) btnToggle.addEventListener('click', togglePreview);
if (btnFull) btnFull.addEventListener('click', toggleFullscreen);
/* ================================================================
键盘快捷键
================================================================ */
document.addEventListener('keydown', e => {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 's') {
e.preventDefault();
saveDraft();
return;
}
if (mod && e.shiftKey && e.key === 'P') {
e.preventDefault();
togglePreview();
return;
}
if (e.key === 'F11') {
e.preventDefault();
toggleFullscreen();
return;
}
if (e.key === 'Escape') {
const layout = document.querySelector('.editor-layout');
if (layout && layout.classList.contains('fullscreen')) {
layout.classList.remove('fullscreen');
if (editor) editor.focus(true);
}
}
});
/* ================================================================
表单提交
================================================================ */
form.addEventListener('submit', e => {
e.preventDefault();
const title = titleEl.value.trim();
const body = getEditorHtml();
const bodyText = getEditorText();
if (!title) { alert('请输入标题'); return; }
if (!bodyText) { alert('请输入正文'); return; }
let url = '/api/posts';
let method = 'POST';
if (isEdit && postIdEl.value) {
url = '/api/posts/' + postIdEl.value;
method = 'PUT';
}
fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() },
body: JSON.stringify({ title, body })
})
.then(r => r.json())
.then(data => {
if (data.success) {
clearDraft();
if (data.data && data.data.id) {
window.location.href = '/posts/' + data.data.id;
} else {
window.location.href = '/posts';
}
} else {
alert(data.message || '操作失败');
}
})
.catch(() => alert('请求失败'));
});
// 启动:等 wangEditor 加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initEditor);
} else {
initEditor();
}
})();