| 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();
|
| }); |