|
|
class MarkdownEditor {
|
|
|
constructor() {
|
|
|
this.initializeElements();
|
|
|
this.initializeEventListeners();
|
|
|
this.initializeAutoSave();
|
|
|
this.initializeToolbar();
|
|
|
this.initializeDragAndDrop();
|
|
|
this.initializeLocalBackup();
|
|
|
this.lastSaveTime = null;
|
|
|
}
|
|
|
|
|
|
initializeElements() {
|
|
|
this.titleInput = document.getElementById('titleInput');
|
|
|
this.contentInput = document.getElementById('contentInput');
|
|
|
this.preview = document.getElementById('preview');
|
|
|
this.imageInput = document.getElementById('imageInput');
|
|
|
this.saveButton = document.querySelector('.save-button');
|
|
|
this.wordCount = document.querySelector('.word-count');
|
|
|
this.toolbar = document.querySelector('.editor-toolbar');
|
|
|
|
|
|
this.isDirty = false;
|
|
|
this.lastSavedContent = '';
|
|
|
this.autoSaveInterval = null;
|
|
|
|
|
|
|
|
|
const isNewArticle = window.location.pathname.endsWith('/editor');
|
|
|
if (isNewArticle) {
|
|
|
localStorage.removeItem('editor-content');
|
|
|
localStorage.removeItem('editor-title');
|
|
|
|
|
|
this.titleInput.value = '';
|
|
|
this.contentInput.value = '';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
initializeEventListeners() {
|
|
|
this.contentInput.addEventListener('input', () => {
|
|
|
this.handleContentChange();
|
|
|
this.updateWordCount();
|
|
|
this.isDirty = true;
|
|
|
});
|
|
|
|
|
|
this.titleInput.addEventListener('input', () => {
|
|
|
this.isDirty = true;
|
|
|
});
|
|
|
|
|
|
this.imageInput.addEventListener('change', (event) => {
|
|
|
this.handleImageUpload(event);
|
|
|
});
|
|
|
|
|
|
this.contentInput.addEventListener('keydown', (event) => {
|
|
|
this.handleShortcuts(event);
|
|
|
});
|
|
|
|
|
|
this.saveButton.addEventListener('click', () => {
|
|
|
this.saveArticle();
|
|
|
});
|
|
|
|
|
|
window.addEventListener('beforeunload', (event) => {
|
|
|
if (this.isDirty) {
|
|
|
event.preventDefault();
|
|
|
event.returnValue = '您有未保存的更改,确定要离开吗?';
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
handleContentChange() {
|
|
|
this.updatePreview();
|
|
|
}
|
|
|
|
|
|
updatePreview() {
|
|
|
try {
|
|
|
const content = this.contentInput.value;
|
|
|
const markedInstance = marked.parse || marked;
|
|
|
this.preview.innerHTML = markedInstance(content, {
|
|
|
breaks: true,
|
|
|
gfm: true,
|
|
|
highlight: function(code, lang) {
|
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
|
return hljs.highlight(code, { language: lang }).value;
|
|
|
}
|
|
|
return code;
|
|
|
}
|
|
|
});
|
|
|
|
|
|
this.preview.querySelectorAll('pre code').forEach(block => {
|
|
|
hljs.highlightElement(block);
|
|
|
});
|
|
|
} catch (error) {
|
|
|
console.error('预览渲染错误:', error);
|
|
|
this.preview.innerHTML = '<div class="error">预览渲染失败</div>';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
initializeAutoSave() {
|
|
|
this.autoSaveInterval = setInterval(() => {
|
|
|
if (this.isDirty && this.lastSaveTime &&
|
|
|
(Date.now() - this.lastSaveTime) >= 600000) {
|
|
|
this.autoSave();
|
|
|
}
|
|
|
}, 60000);
|
|
|
}
|
|
|
|
|
|
async autoSave() {
|
|
|
if (!this.isDirty) return;
|
|
|
|
|
|
const content = this.contentInput.value;
|
|
|
const title = this.titleInput.value;
|
|
|
|
|
|
if (content === this.lastSavedContent || !title.trim() || !content.trim()) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
const response = await this.saveArticle(true);
|
|
|
if (response && response.ok) {
|
|
|
this.lastSavedContent = content;
|
|
|
this.showNotification('自动保存成功', 'success');
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error('自动保存失败:', error);
|
|
|
this.showNotification('自动保存失败', 'error');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
insertText(before, after, defaultText = '') {
|
|
|
const start = this.contentInput.selectionStart;
|
|
|
const end = this.contentInput.selectionEnd;
|
|
|
const content = this.contentInput.value;
|
|
|
|
|
|
const selectedText = content.substring(start, end) || defaultText;
|
|
|
const replacement = before + selectedText + after;
|
|
|
|
|
|
this.contentInput.value = content.substring(0, start) +
|
|
|
replacement +
|
|
|
content.substring(end);
|
|
|
|
|
|
this.contentInput.focus();
|
|
|
const newCursorPos = start + before.length + selectedText.length;
|
|
|
this.contentInput.setSelectionRange(newCursorPos, newCursorPos);
|
|
|
|
|
|
this.updatePreview();
|
|
|
this.isDirty = true;
|
|
|
}
|
|
|
|
|
|
async handleImageUpload(event) {
|
|
|
const file = event.target.files[0];
|
|
|
if (!file) return;
|
|
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
|
this.showNotification('请选择图片文件', 'error');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const formData = new FormData();
|
|
|
formData.append('file', file);
|
|
|
|
|
|
try {
|
|
|
this.showNotification('正在上传图片...', 'info');
|
|
|
const response = await fetch('/api/upload', {
|
|
|
method: 'POST',
|
|
|
body: formData
|
|
|
});
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
if (data.url) {
|
|
|
this.insertText(``, '');
|
|
|
this.showNotification('图片上传成功', 'success');
|
|
|
} else {
|
|
|
throw new Error(data.error || '上传失败');
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error('图片上传错误:', error);
|
|
|
this.showNotification('图片上传失败', 'error');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
handleShortcuts(event) {
|
|
|
if (event.ctrlKey || event.metaKey) {
|
|
|
switch (event.key.toLowerCase()) {
|
|
|
case 's':
|
|
|
event.preventDefault();
|
|
|
this.saveArticle();
|
|
|
break;
|
|
|
case 'b':
|
|
|
event.preventDefault();
|
|
|
this.insertText('**', '**', '粗体文本');
|
|
|
break;
|
|
|
case 'i':
|
|
|
event.preventDefault();
|
|
|
this.insertText('*', '*', '斜体文本');
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async saveArticle(isAutoSave = false) {
|
|
|
const title = this.titleInput.value.trim();
|
|
|
const content = this.contentInput.value.trim();
|
|
|
this.lastSaveTime = Date.now();
|
|
|
|
|
|
if (!title || !content) {
|
|
|
this.showNotification('标题和内容不能为空', 'error');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const articleSlug = window.location.pathname.split('/').pop();
|
|
|
const isEdit = articleSlug !== 'editor';
|
|
|
|
|
|
try {
|
|
|
if (!isAutoSave) this.showNotification('正在保存...', 'info');
|
|
|
|
|
|
const response = await fetch(`/api/articles${isEdit ? '/' + articleSlug : ''}`, {
|
|
|
method: isEdit ? 'PUT' : 'POST',
|
|
|
headers: {
|
|
|
'Content-Type': 'application/json'
|
|
|
},
|
|
|
body: JSON.stringify({
|
|
|
title,
|
|
|
content
|
|
|
})
|
|
|
});
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
if (response.ok) {
|
|
|
this.isDirty = false;
|
|
|
if (!isAutoSave) {
|
|
|
this.showNotification('保存成功', 'success');
|
|
|
window.location.href = `/article/${data.slug || articleSlug}`;
|
|
|
}
|
|
|
return response;
|
|
|
} else {
|
|
|
throw new Error(data.error || '保存失败');
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error('保存文章错误:', error);
|
|
|
this.showNotification(error.message, 'error');
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
updateWordCount() {
|
|
|
const content = this.contentInput.value;
|
|
|
const wordCount = content.length;
|
|
|
if (this.wordCount) {
|
|
|
this.wordCount.textContent = `字数:${wordCount}`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
showNotification(message, type = 'info') {
|
|
|
const notification = document.createElement('div');
|
|
|
notification.className = `notification ${type}`;
|
|
|
notification.innerHTML = `
|
|
|
<div class="notification-content">
|
|
|
<span class="notification-message">${message}</span>
|
|
|
<button class="notification-close">×</button>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
|
|
const closeButton = notification.querySelector('.notification-close');
|
|
|
closeButton.addEventListener('click', () => {
|
|
|
notification.remove();
|
|
|
});
|
|
|
|
|
|
setTimeout(() => {
|
|
|
notification.classList.add('fade-out');
|
|
|
setTimeout(() => {
|
|
|
notification.remove();
|
|
|
}, 300);
|
|
|
}, 3000);
|
|
|
}
|
|
|
|
|
|
initializeToolbar() {
|
|
|
const tools = [
|
|
|
{
|
|
|
name: 'bold',
|
|
|
icon: '<i class="fas fa-bold"></i>',
|
|
|
title: '粗体 (Ctrl+B)',
|
|
|
action: () => this.insertText('**', '**', '粗体文本')
|
|
|
},
|
|
|
{
|
|
|
name: 'italic',
|
|
|
icon: '<i class="fas fa-italic"></i>',
|
|
|
title: '斜体 (Ctrl+I)',
|
|
|
action: () => this.insertText('*', '*', '斜体文本')
|
|
|
},
|
|
|
{
|
|
|
name: 'heading1',
|
|
|
icon: '<i class="fas fa-heading"></i>',
|
|
|
title: '一级标题',
|
|
|
action: () => this.insertText('\n# ', '', '标题')
|
|
|
},
|
|
|
{
|
|
|
name: 'heading2',
|
|
|
icon: '<i class="fas fa-heading fa-sm"></i>',
|
|
|
title: '二级标题',
|
|
|
action: () => this.insertText('\n## ', '', '标题')
|
|
|
},
|
|
|
{
|
|
|
name: 'code',
|
|
|
icon: '<i class="fas fa-code"></i>',
|
|
|
title: '代码块',
|
|
|
action: () => this.insertText('\n```\n', '\n```\n', '在此输入代码')
|
|
|
},
|
|
|
{
|
|
|
name: 'link',
|
|
|
icon: '<i class="fas fa-link"></i>',
|
|
|
title: '链接',
|
|
|
action: () => this.insertText('[', '](https://)', '链接文本')
|
|
|
},
|
|
|
{
|
|
|
name: 'image',
|
|
|
icon: '<i class="fas fa-image"></i>',
|
|
|
title: '图片',
|
|
|
action: () => this.imageInput.click()
|
|
|
},
|
|
|
{
|
|
|
name: 'list',
|
|
|
icon: '<i class="fas fa-list-ul"></i>',
|
|
|
title: '无序列表',
|
|
|
action: () => this.insertText('\n- ', '', '列表项')
|
|
|
},
|
|
|
{
|
|
|
name: 'numbered-list',
|
|
|
icon: '<i class="fas fa-list-ol"></i>',
|
|
|
title: '有序列表',
|
|
|
action: () => this.insertText('\n1. ', '', '列表项')
|
|
|
},
|
|
|
{
|
|
|
name: 'quote',
|
|
|
icon: '<i class="fas fa-quote-right"></i>',
|
|
|
title: '引用',
|
|
|
action: () => this.insertText('\n> ', '', '引用文本')
|
|
|
},
|
|
|
{
|
|
|
name: 'divider',
|
|
|
icon: '<i class="fas fa-minus"></i>',
|
|
|
title: '分隔线',
|
|
|
action: () => this.insertText('\n---\n', '', '')
|
|
|
}
|
|
|
];
|
|
|
|
|
|
tools.forEach(tool => {
|
|
|
const button = document.createElement('button');
|
|
|
button.className = `toolbar-button ${tool.name}`;
|
|
|
button.innerHTML = tool.icon;
|
|
|
button.title = tool.title;
|
|
|
button.addEventListener('click', (e) => {
|
|
|
e.preventDefault();
|
|
|
tool.action();
|
|
|
});
|
|
|
this.toolbar.appendChild(button);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
initializeDragAndDrop() {
|
|
|
const dropZone = this.contentInput;
|
|
|
|
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
|
dropZone.addEventListener(eventName, (e) => {
|
|
|
e.preventDefault();
|
|
|
e.stopPropagation();
|
|
|
});
|
|
|
});
|
|
|
|
|
|
['dragenter', 'dragover'].forEach(eventName => {
|
|
|
dropZone.addEventListener(eventName, () => {
|
|
|
dropZone.classList.add('drag-over');
|
|
|
});
|
|
|
});
|
|
|
|
|
|
['dragleave', 'drop'].forEach(eventName => {
|
|
|
dropZone.addEventListener(eventName, () => {
|
|
|
dropZone.classList.remove('drag-over');
|
|
|
});
|
|
|
});
|
|
|
|
|
|
dropZone.addEventListener('drop', (e) => {
|
|
|
const files = e.dataTransfer.files;
|
|
|
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
|
|
this.imageInput.files = files;
|
|
|
this.handleImageUpload({ target: this.imageInput });
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
initializeLocalBackup() {
|
|
|
|
|
|
const isNewArticle = window.location.pathname.endsWith('/editor');
|
|
|
if (!isNewArticle) {
|
|
|
const savedContent = localStorage.getItem('editor-content');
|
|
|
const savedTitle = localStorage.getItem('editor-title');
|
|
|
|
|
|
if (savedContent && !this.contentInput.value) {
|
|
|
this.contentInput.value = savedContent;
|
|
|
this.updatePreview();
|
|
|
}
|
|
|
|
|
|
if (savedTitle && !this.titleInput.value) {
|
|
|
this.titleInput.value = savedTitle;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
setInterval(() => {
|
|
|
if (this.isDirty) {
|
|
|
localStorage.setItem('editor-content', this.contentInput.value);
|
|
|
localStorage.setItem('editor-title', this.titleInput.value);
|
|
|
}
|
|
|
}, 10000);
|
|
|
}
|
|
|
|
|
|
destroy() {
|
|
|
clearInterval(this.autoSaveInterval);
|
|
|
localStorage.removeItem('editor-content');
|
|
|
localStorage.removeItem('editor-title');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
const editor = new MarkdownEditor();
|
|
|
}); |