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 模式渲染已有内容
370 lines
13 KiB
JavaScript
370 lines
13 KiB
JavaScript
(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 '';
|
||
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: '' }; 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');
|
||
}
|
||
})();
|