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 8a8d801187 fix: 修复帖子系统5项缺陷 + 代码块插入位置/language-class/分割线占位
- 作者显示 用户名(UID): 修正Post.AuthorName GORM标签从 -:all 改为 -:migration;<-:false;column:author_name
- 审核流程: 帖子详情页新增"提交审核"按钮(draft/rejected时作者可见)
- 图片上传: 新增 POST /api/posts/upload-image 端点,限制5MB/jpg/png/gif/webp
- 代码块语言标注: 工具栏插入代码块时弹出语言输入框,生成 language-xxx class
- 状态中文显示: 管理后台/帖子详情页状态改为 PostStatusDisplayNames 映射
- 修复代码块插入位置错误: init时调用switchMode同步DOM; 无选区时appendChild; 防<pre>嵌套
- 修复 bluemonday.UGCPolicy() 剥离 code/pre 的 class 属性,显式 AllowAttrs("class")
- 修复分割线工具栏插入多余text占位符: hr/link/image以外不强制填占位文本
2026-05-27 19:22:47 +08:00

489 lines
18 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.

(function() {
var form = document.getElementById('postForm');
if (!form) return;
var postId = document.getElementById('postId');
var titleEl = document.getElementById('postTitle');
var bodyEl = document.getElementById('postBody'); // textarea (唯一数据源)
var wysiwygEl = document.getElementById('wysiwygEditor'); // contenteditable
var splitPrev = document.getElementById('splitPreview');
var splitCont = splitPrev ? splitPrev.querySelector('.split-preview-content') : null;
var toolbar = document.getElementById('toolbar');
var modeTabs = document.querySelectorAll('.mode-tab');
var isEdit = postId && postId.value;
var currentMode = 'wysiwyg';
var wysiwygDirty = false; // WYSIWYG 是否已被用户编辑过(首次进入时不覆盖 textarea
var syncTimer = null;
var imageInput = document.getElementById('imageFileInput');
// ===================== HELPERS =====================
function getCSRFToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
// ================== MARKDOWN ↔ HTML ==================
// 通过 API 渲染 markdown 为 HTML服务端 Goldmark + bluemonday 消毒)
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(function(r) { return r.json(); })
.then(function(d) { callback(d.success ? d.data.html : md); })
.catch(function() { callback(md); });
}
// 将 contenteditable 的 HTML 转回 Markdown基本转换
function htmlToMarkdown(html) {
var el = document.createElement('div');
el.innerHTML = html;
function walk(node) {
if (node.nodeType === 3) return node.textContent;
if (node.nodeType !== 1) return '';
var tag = node.tagName.toLowerCase();
var children = '';
for (var i = 0; i < node.childNodes.length; i++) {
children += walk(node.childNodes[i]);
}
switch (tag) {
case 'br': return '\n';
case 'p': return children + '\n\n';
case 'strong': case 'b': return '**' + children + '**';
case 'em': case 'i': return '_' + children + '_';
case 'h1': return '# ' + children + '\n\n';
case 'h2': return '## ' + children + '\n\n';
case 'h3': return '### ' + children + '\n\n';
case 'h4': return '#### ' + children + '\n\n';
case 'h5': return '##### ' + children + '\n\n';
case 'h6': return '###### ' + children + '\n\n';
case 'a':
var href = node.getAttribute('href') || '';
return '[' + children + '](' + href + ')';
case 'pre':
var code = node.querySelector('code');
var lang = code ? (code.className.replace('language-','') || '') : '';
return '\n```' + lang + '\n' + node.textContent + '\n```\n';
case 'code':
if (node.parentNode.tagName.toLowerCase() !== 'pre')
return '`' + children + '`';
return children;
case 'li':
if (node.parentNode.tagName.toLowerCase() === 'ul') return '- ' + children + '\n';
if (node.parentNode.tagName.toLowerCase() === 'ol') return '1. ' + children + '\n';
return children + '\n';
case 'ul': case 'ol': return children + '\n';
case 'blockquote':
return '> ' + children.replace(/\n$/, '').replace(/\n/g, '\n> ') + '\n\n';
case 'hr': return '\n---\n';
case 'img':
return '![' + (node.getAttribute('alt') || '') + '](' + (node.getAttribute('src') || '') + ')';
case 'div':
case 'section':
return children;
default:
return children || node.textContent;
}
}
return walk(el).replace(/\n{3,}/g, '\n\n').trim();
}
// 将 markdown 从 textarea 渲染到 WYSIWYG
function syncMarkdownToWysiwyg() {
if (!bodyEl.value.trim()) {
wysiwygEl.innerHTML = '';
return;
}
renderMarkdown(bodyEl.value, function(html) {
wysiwygEl.innerHTML = html;
});
}
// WYSIWYG 编辑后延迟将 HTML 转为 markdown 保存回 textarea
function debounceSync() {
clearTimeout(syncTimer);
syncTimer = setTimeout(function() {
var md = htmlToMarkdown(wysiwygEl.innerHTML);
if (md !== bodyEl.value) {
bodyEl.value = md;
}
}, 600);
}
// markdown 保存回 textarea 后触发分屏预览更新
bodyEl.addEventListener('input', function() {
if (currentMode === 'split') {
clearTimeout(splitTimer);
splitTimer = setTimeout(updateSplitPreview, 300);
}
});
var splitTimer = null;
function updateSplitPreview() {
if (!splitCont || !bodyEl.value.trim()) {
if (splitCont) splitCont.innerHTML = '<em style="color:#9ca3af">输入正文后将在此处预览...</em>';
return;
}
renderMarkdown(bodyEl.value, function(html) {
splitCont.innerHTML = html;
});
}
// WYSIWYG 输入实时同步
wysiwygEl.addEventListener('input', function() {
if (currentMode === 'wysiwyg') {
wysiwygDirty = true;
debounceSync();
}
});
// ==================== MODE SWITCHING ====================
function switchMode(mode) {
// 仅在离开 WYSIWYG 时同步 WYSIWYG → textarea
if (currentMode === 'wysiwyg' && mode !== 'wysiwyg' && wysiwygDirty) {
var md = htmlToMarkdown(wysiwygEl.innerHTML);
bodyEl.value = md;
}
currentMode = mode;
wysiwygDirty = false; // 切换到新模式下重置 dirty 标记
// 更新标签
modeTabs.forEach(function(tab) {
tab.classList.toggle('active', tab.dataset.mode === mode);
});
// 切换布局 class
var editorBody = document.querySelector('.editor-body');
if (editorBody) {
editorBody.classList.toggle('split-mode', mode === 'split');
}
// 工具栏在 split 模式下也显示(源码风格操作)
if (toolbar) {
toolbar.style.display = (mode === 'source' || mode === 'split') ? '' : '';
}
// 切换面板
if (mode === 'wysiwyg') {
bodyEl.style.display = 'none';
wysiwygEl.style.display = '';
if (splitPrev) splitPrev.style.display = 'none';
syncMarkdownToWysiwyg();
wysiwygEl.focus();
} else if (mode === 'source') {
bodyEl.style.display = '';
wysiwygEl.style.display = 'none';
if (splitPrev) splitPrev.style.display = 'none';
bodyEl.focus();
} else if (mode === 'split') {
bodyEl.style.display = '';
wysiwygEl.style.display = 'none';
if (splitPrev) splitPrev.style.display = '';
bodyEl.focus();
updateSplitPreview();
}
}
modeTabs.forEach(function(tab) {
tab.addEventListener('click', function() {
switchMode(tab.dataset.mode);
});
});
// ==================== TOOLBAR ====================
if (toolbar) {
toolbar.addEventListener('click', function(e) {
var btn = e.target.closest('.tb-btn');
if (!btn) return;
var action = btn.dataset.action;
if (currentMode === 'wysiwyg') {
execWysiwygAction(action);
} else {
// source 和 split 模式都用源码工具栏(操作 textarea 中的 markdown
execSourceAction(action);
}
});
}
function execWysiwygAction(action) {
wysiwygEl.focus();
switch (action) {
case 'bold':
document.execCommand('bold', false, null);
break;
case 'italic':
document.execCommand('italic', false, null);
break;
case 'h2':
document.execCommand('formatBlock', false, 'h2');
break;
case 'h3':
document.execCommand('formatBlock', false, 'h3');
break;
case 'link':
var url = prompt('输入链接地址:', 'https://');
if (url) document.execCommand('createLink', false, url);
break;
case 'image':
imagePickerTarget = 'wysiwyg';
imageInput.click();
break;
case 'quote':
document.execCommand('formatBlock', false, 'blockquote');
break;
case 'code':
wrapSelectionWysiwyg('`', '`');
break;
case 'pre':
var lang = prompt('输入代码语言(如 python / go / text留空默认为 text', '');
lang = lang ? lang.trim() : 'text';
insertCodeBlockWysiwyg(lang);
break;
case 'ul':
document.execCommand('insertUnorderedList', false, null);
break;
case 'ol':
document.execCommand('insertOrderedList', false, null);
break;
case 'hr':
document.execCommand('insertHorizontalRule', false, null);
break;
}
wysiwygDirty = true;
}
// WYSIWYG 中包裹选中文本
function wrapSelectionWysiwyg(before, after) {
var sel = window.getSelection();
if (!sel.rangeCount) return;
var range = sel.getRangeAt(0);
var text = range.toString();
if (!text) text = 'code';
range.deleteContents();
range.insertNode(document.createTextNode(before + text + after));
wysiwygDirty = true;
}
// 源码/分屏模式下插入 markdown 语法
function execSourceAction(action) {
var start = bodyEl.selectionStart;
var end = bodyEl.selectionEnd;
var text = bodyEl.value.substring(start, end);
var wrap = {};
switch (action) {
case 'bold': wrap = { b: '**', a: '**' }; break;
case 'italic': wrap = { b: '_', a: '_' }; break;
case 'h2': wrap = { b: '## ', a: '' }; break;
case 'h3': wrap = { b: '### ',a: '' }; break;
case 'link': wrap = { b: '[', a: '](https://)' }; break;
case 'image':
// 源码/分屏模式下图片使用上传
imagePickerTarget = currentMode;
imageInput.click();
return;
case 'quote': wrap = { b: '> ', a: '' }; break;
case 'code': wrap = { b: '`', a: '`' }; break;
case 'pre':
var lang = prompt('输入代码语言(如 python / go / text留空默认为 text', '');
lang = lang ? lang.trim() : 'text';
wrap = { b: '\n```' + lang + '\n', a: '\n```\n' };
break;
case 'ul': wrap = { b: '- ', a: '' }; break;
case 'ol': wrap = { b: '1. ', a: '' }; break;
case 'hr': wrap = { b: '\n---\n', a: '' }; break;
}
if (wrap.b !== undefined) {
var placeholder = '';
if (!text) {
if (action === 'link') placeholder = 'link';
else if (action === 'image') placeholder = 'image';
// hr 和其他无内容操作不加占位文本
}
var replacement = wrap.b + placeholder + wrap.a;
bodyEl.value = bodyEl.value.substring(0, start) + replacement + bodyEl.value.substring(end);
bodyEl.focus();
var cursor = start + wrap.b.length + (text ? text.length : 0);
bodyEl.setSelectionRange(cursor, cursor + (text ? 0 : replacement.length - wrap.b.length - wrap.a.length));
// 分屏模式下立刻更新预览
if (currentMode === 'split') updateSplitPreview();
}
}
// WYSIWYG 中插入带语言标注的代码块
function insertCodeBlockWysiwyg(lang) {
if (wysiwygEl.style.display === 'none') {
// WYSIWYG 不可见时直接向 textarea 插入 markdown 代码块
var start = bodyEl.selectionStart;
var end = bodyEl.selectionEnd;
var text = bodyEl.value.substring(start, end);
var b = '\n```' + lang + '\n';
var a = '\n```\n';
bodyEl.value = bodyEl.value.substring(0, start) + b + (text || '') + a + bodyEl.value.substring(end);
bodyEl.focus();
var cursor = start + b.length + (text ? text.length : 0);
bodyEl.setSelectionRange(cursor, cursor + (text ? 0 : a.length));
if (currentMode === 'split') updateSplitPreview();
return;
}
wysiwygEl.focus();
var sel = window.getSelection();
var pre = document.createElement('pre');
var code = document.createElement('code');
if (lang && lang !== 'text') code.className = 'language-' + lang;
code.textContent = '\n';
pre.appendChild(code);
if (!sel.rangeCount) {
// 无选区:追加到编辑器末尾
wysiwygEl.appendChild(pre);
} else {
var range = sel.getRangeAt(0);
// 防止在已有 pre 内部嵌套插入:把插入点移到 pre 之后
var ancestor = range.commonAncestorContainer;
while (ancestor && ancestor !== wysiwygEl) {
if (ancestor.nodeType === 1 && ancestor.tagName === 'PRE') {
range.setStartAfter(ancestor);
range.setEndAfter(ancestor);
break;
}
ancestor = ancestor.parentNode;
}
// 确保插入点落在 WYSIWYG 内部
if (!wysiwygEl.contains(range.commonAncestorContainer)) {
wysiwygEl.appendChild(pre);
} else {
range.deleteContents();
range.insertNode(pre);
}
}
// 在代码块后加换行,方便继续输入
var br = document.createElement('br');
pre.parentNode.insertBefore(br, pre.nextSibling);
// 光标移到 code 内
var newRange = document.createRange();
newRange.selectNodeContents(code);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
wysiwygDirty = true;
}
// ==================== IMAGE UPLOAD ====================
var imagePickerTarget = null;
if (imageInput) {
imageInput.addEventListener('change', function() {
var file = imageInput.files[0];
if (!file) { imagePickerTarget = null; return; }
uploadImage(file, function(url) {
if (!url) { imagePickerTarget = null; return; }
if (imagePickerTarget === 'wysiwyg') {
wysiwygEl.focus();
document.execCommand('insertImage', false, url);
wysiwygDirty = true;
} else {
var start = bodyEl.selectionStart;
var end = bodyEl.selectionEnd;
var alt = bodyEl.value.substring(start, end) || 'image';
var md = '![' + alt + '](' + url + ')';
bodyEl.value = bodyEl.value.substring(0, start) + md + bodyEl.value.substring(end);
bodyEl.focus();
bodyEl.setSelectionRange(start + md.length, start + md.length);
if (currentMode === 'split') updateSplitPreview();
}
imagePickerTarget = null;
});
imageInput.value = '';
});
}
function uploadImage(file, callback) {
var fd = new FormData();
fd.append('file', file);
fetch('/api/posts/upload-image', {
method: 'POST',
headers: { 'X-CSRF-Token': getCSRFToken() },
body: fd
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.data && d.data.url) {
callback(d.data.url);
} else {
alert(d.message || '图片上传失败');
callback(null);
}
})
.catch(function() { alert('图片上传请求失败'); callback(null); });
}
// ==================== FORM SUBMISSION ====================
form.addEventListener('submit', function(e) {
e.preventDefault();
// WYSIWYG 模式下先同步到 textarea
if (currentMode === 'wysiwyg') {
var md = htmlToMarkdown(wysiwygEl.innerHTML);
bodyEl.value = md;
}
var title = titleEl.value.trim();
var body = bodyEl.value.trim();
if (!title) { alert('请输入标题'); return; }
if (!body) { alert('请输入正文'); return; }
var url = '/api/posts';
var method = 'POST';
if (isEdit) {
url = '/api/posts/' + postId.value;
method = 'PUT';
}
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken()
},
body: JSON.stringify({ title: title, body: body })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
if (data.data && data.data.id) {
window.location.href = '/posts/' + data.data.id;
} else {
window.location.href = '/posts';
}
} else {
alert(data.message || '操作失败');
}
})
.catch(function() { alert('请求失败'); });
});
// ==================== INIT ====================
// 同步 DOM 状态与默认 modetextarea 隐藏、WYSIWYG 可见),并渲染已有内容
switchMode('wysiwyg');
})();