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