Files
mce/templates/MetaLab-2026/static/js/editor.js
Victor_Jay 174d715e50 fix: 修复 CSRF 缺失 + 重写编辑器为三模式(所见即所得/源码/分屏)
CSRF 修复:
- /posts/new 和 /posts/:id/edit 原在独立 postPages group 中,仅含 Required 中间件,缺少 CSRF 下发
- 移动到 pages group(含 CSRF 中间件),用 per-route d.authMdw.Required() 弥补登录校验
- 同时消除 /posts/:id 与 /posts/new 潜在的路由冲突

编辑器重写:
- 添加三种编辑模式切换标签:所见即所得 / 源码 / 分屏预览
- 所见即所得(默认):contenteditable 编辑区 + 工具条(execCommand)
  支持加粗、斜体、H2/H3、链接、图片、引用、代码、列表、分割线
- 源码:纯 textarea,textarea 作为数据源始终保存 markdown 原文
- 分屏:左 textarea 编辑 + 右实时渲染预览(API 渲染)
- 模式切换时自动同步:WYSIWYG → HTML → Markdown → textarea
- 工具条在 WYSIWYG 模式用 execCommand,源码模式用文本包裹语法

实现细节:
- contenteditable ← textarea 双向同步(WYSIWYG 模式)
- 基本 HTML→Markdown 转换器(strong/em/h1-h6/a/pre/code/li/blockquote/img)
- 分屏布局用 JS toggled .split-mode class(非 :has(),兼容 Firefox)
- 编辑器初始时自动进 WYSIWYG 模式渲染已有内容
2026-05-27 18:35:13 +08:00

370 lines
13 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 (source of truth)
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 syncTimer = null;
// ===================== HELPERS =====================
function getCSRFToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function getBodyValue() {
return currentMode === 'wysiwyg' ? bodyEl.value : bodyEl.value;
}
// 保存到 textarea 并同步 WYSIWYG
function saveToTextarea(md) {
bodyEl.value = md;
if (currentMode === 'wysiwyg') syncMarkdownToWysiwyg();
}
// ================== MARKDOWN ↔ HTML ==================
// 通过 API 渲染 markdown 为 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(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) {
var md = '';
if (node.nodeType === 3) { // text
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-','') || '') : '';
var text = node.textContent;
return '\n```' + lang + '\n' + text + '\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':
var src = node.getAttribute('src') || '';
var alt = node.getAttribute('alt') || '';
return '![' + alt + '](' + src + ')';
case 'div':
case 'section':
return children;
default:
return children || node.textContent;
}
}
return walk(el).replace(/\n{3,}/g, '\n\n').trim();
}
// 将 markdown 渲染到 WYSIWYG
function syncMarkdownToWysiwyg() {
if (!bodyEl.value.trim()) {
wysiwygEl.innerHTML = '';
return;
}
renderMarkdown(bodyEl.value, function(html) {
var cur = wysiwygEl.innerHTML;
// 只在真的变化时更新,避免闪烁
if (cur !== html) {
wysiwygEl.innerHTML = html;
}
});
}
// WYSIWYG 编辑后延迟同步回 textarea
function debounceSync() {
clearTimeout(syncTimer);
syncTimer = setTimeout(function() {
var md = htmlToMarkdown(wysiwygEl.innerHTML);
bodyEl.value = md;
}, 500);
}
// ==================== MODE SWITCHING ====================
function switchMode(mode) {
// 先同步当前状态
if (currentMode === 'wysiwyg') {
var md = htmlToMarkdown(wysiwygEl.innerHTML);
bodyEl.value = md;
}
currentMode = mode;
// 更新标签
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');
}
// 切换面板
if (mode === 'wysiwyg') {
bodyEl.style.display = 'none';
wysiwygEl.style.display = '';
if (splitPrev) splitPrev.style.display = 'none';
if (toolbar) toolbar.style.display = '';
syncMarkdownToWysiwyg();
} else if (mode === 'source') {
bodyEl.style.display = '';
wysiwygEl.style.display = 'none';
if (splitPrev) splitPrev.style.display = 'none';
if (toolbar) toolbar.style.display = '';
} else if (mode === 'split') {
bodyEl.style.display = '';
wysiwygEl.style.display = 'none';
if (splitPrev) splitPrev.style.display = '';
if (toolbar) toolbar.style.display = 'none';
updateSplitPreview();
}
}
modeTabs.forEach(function(tab) {
tab.addEventListener('click', function() {
switchMode(tab.dataset.mode);
});
});
// 分屏实时预览(防抖)
var splitTimer = null;
bodyEl.addEventListener('input', function() {
if (currentMode === 'split') {
clearTimeout(splitTimer);
splitTimer = setTimeout(updateSplitPreview, 300);
}
});
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') debounceSync();
});
// ==================== 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 if (currentMode === 'source') {
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':
var imgUrl = prompt('输入图片链接:', 'https://');
if (imgUrl) document.execCommand('insertImage', false, imgUrl);
break;
case 'quote':
document.execCommand('formatBlock', false, 'blockquote');
break;
case 'code':
wrapSelectionWysiwyg('`', '`');
break;
case 'pre':
document.execCommand('formatBlock', false, 'pre');
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;
}
debounceSync();
}
// 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));
} else {
range.deleteContents();
range.insertNode(document.createTextNode(before + text + after));
}
debounceSync();
}
// 源码模式下插入 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': wrap = { b: '![', a: '](https://)' }; break;
case 'quote': wrap = { b: '> ', a: '' }; break;
case 'code': wrap = { b: '`', a: '`' }; break;
case 'pre': wrap = { b: '\n```\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 replacement = wrap.b + (text || (action === 'link' ? 'link' : action === 'image' ? 'image' : 'text')) + 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 ? wrap.a.length : 0));
}
}
// ==================== FORM SUBMISSION ====================
form.addEventListener('submit', function(e) {
e.preventDefault();
// WYSIWYG 模式下先同步
if (currentMode === 'wysiwyg') {
var md = htmlToMarkdown(wysiwygEl.innerHTML);
bodyEl.value = md;
currentMode = 'source';
}
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 ====================
if (bodyEl.value.trim()) {
switchMode('wysiwyg');
}
})();