xiaoBlog / app /static /js /editor.js
mistpe's picture
Upload 2 files
d82405d verified
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;
// Clear localStorage if this is a new article
const isNewArticle = window.location.pathname.endsWith('/editor');
if (isNewArticle) {
localStorage.removeItem('editor-content');
localStorage.removeItem('editor-title');
// Clear inputs for new article
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) { // 10 minutes
this.autoSave();
}
}, 60000); // Check every minute
}
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(`![${file.name}](${data.url})`, '');
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">&times;</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() {
// Only restore from local storage if we're editing an existing article
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;
}
}
// Save to local storage periodically
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');
}
}
// Initialize editor when the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const editor = new MarkdownEditor();
});