Files
mce/templates/MetaLab-2026/static/js/editor.js

361 lines
14 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;
// ===================== 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':
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;
}
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': 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 ? 0 : replacement.length - wrap.b.length - wrap.a.length));
// 分屏模式下立刻更新预览
if (currentMode === 'split') updateSplitPreview();
}
}
// ==================== 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 ====================
if (bodyEl.value.trim()) {
// 如果已有内容(编辑帖子),渲染到 WYSIWYG 但不写入 textarea
syncMarkdownToWysiwyg();
}
})();