Spaces:
Running
Running
| /* | |
| ELYSIA MARKDOWN STUDIO v1.0 - Editor Module | |
| Markdown editor with toolbar actions | |
| */ | |
| import Utils from "./utils.js"; | |
| const Editor = { | |
| textarea: null, | |
| currentDoc: null, | |
| autoSaveInterval: null, | |
| autoSaveInProgress: false, // Prevent race conditions | |
| init() { | |
| this.textarea = document.getElementById("markdown-editor"); | |
| this.setupEventListeners(); | |
| this.setupToolbar(); | |
| // Don't start auto-save yet - wait for app to be fully initialized | |
| }, | |
| setupEventListeners() { | |
| // Input event for stats update | |
| this.textarea.addEventListener( | |
| "input", | |
| Utils.debounce(() => { | |
| this.updateStats(); | |
| // Check if live preview is enabled | |
| const livePreview = Utils.storage.get("livePreview", true); | |
| if (livePreview && window.app?.preview) { | |
| window.app.preview.update(); | |
| } | |
| // Mark unsaved changes | |
| if (window.app) { | |
| window.app.unsavedChanges = true; | |
| } | |
| }, 300) | |
| ); | |
| // Drag & drop for images | |
| this.textarea.addEventListener("dragover", e => { | |
| e.preventDefault(); | |
| this.textarea.classList.add("drag-over"); | |
| }); | |
| this.textarea.addEventListener("dragleave", e => { | |
| e.preventDefault(); | |
| this.textarea.classList.remove("drag-over"); | |
| }); | |
| this.textarea.addEventListener("drop", e => { | |
| e.preventDefault(); | |
| this.textarea.classList.remove("drag-over"); | |
| this.handleImageDrop(e); | |
| }); | |
| // Paste images from clipboard | |
| this.textarea.addEventListener("paste", e => { | |
| const items = e.clipboardData?.items; | |
| if (!items) return; | |
| for (const item of items) { | |
| if (item.type.startsWith("image/")) { | |
| e.preventDefault(); | |
| this.handleImagePaste(item); | |
| break; | |
| } | |
| } | |
| }); | |
| // Keyboard shortcuts | |
| this.textarea.addEventListener("keydown", e => { | |
| if (e.ctrlKey || e.metaKey) { | |
| switch (e.key.toLowerCase()) { | |
| case "s": | |
| e.preventDefault(); | |
| window.app?.saveDocument(); | |
| break; | |
| case "b": | |
| e.preventDefault(); | |
| this.wrapSelection("**", "**"); | |
| break; | |
| case "i": | |
| e.preventDefault(); | |
| this.wrapSelection("*", "*"); | |
| break; | |
| } | |
| } | |
| }); | |
| }, | |
| setupToolbar() { | |
| document.querySelectorAll(".toolbar-btn").forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| const action = btn.getAttribute("data-action"); | |
| this.handleToolbarAction(action); | |
| }); | |
| }); | |
| }, | |
| handleToolbarAction(action) { | |
| switch (action) { | |
| case "bold": | |
| this.wrapSelection("**", "**"); | |
| break; | |
| case "italic": | |
| this.wrapSelection("*", "*"); | |
| break; | |
| case "strikethrough": | |
| this.wrapSelection("~~", "~~"); | |
| break; | |
| case "heading1": | |
| this.insertAtLineStart("# "); | |
| break; | |
| case "heading2": | |
| this.insertAtLineStart("## "); | |
| break; | |
| case "heading3": | |
| this.insertAtLineStart("### "); | |
| break; | |
| case "link": | |
| this.insertLink(); | |
| break; | |
| case "image": | |
| this.insertImage(); | |
| break; | |
| case "code": | |
| this.wrapSelection("`", "`"); | |
| break; | |
| case "quote": | |
| this.insertAtLineStart("> "); | |
| break; | |
| case "ul": | |
| this.insertAtLineStart("- "); | |
| break; | |
| case "ol": | |
| this.insertAtLineStart("1. "); | |
| break; | |
| case "task": | |
| this.insertAtLineStart("- [ ] "); | |
| break; | |
| case "table": | |
| this.insertTable(); | |
| break; | |
| case "hr": | |
| this.insertLine("\n---\n"); | |
| break; | |
| } | |
| this.textarea.focus(); | |
| }, | |
| wrapSelection(before, after) { | |
| const start = this.textarea.selectionStart; | |
| const end = this.textarea.selectionEnd; | |
| const text = this.textarea.value; | |
| const selected = text.substring(start, end); | |
| const wrapped = before + (selected || "text") + after; | |
| this.textarea.setRangeText(wrapped, start, end, "select"); | |
| this.textarea.dispatchEvent(new Event("input")); | |
| }, | |
| insertAtLineStart(prefix) { | |
| const start = this.textarea.selectionStart; | |
| const text = this.textarea.value; | |
| // Find line start | |
| let lineStart = start; | |
| while (lineStart > 0 && text[lineStart - 1] !== "\n") { | |
| lineStart--; | |
| } | |
| this.textarea.setRangeText(prefix, lineStart, lineStart, "end"); | |
| this.textarea.dispatchEvent(new Event("input")); | |
| }, | |
| insertLine(text) { | |
| const start = this.textarea.selectionStart; | |
| this.textarea.setRangeText(text, start, start, "end"); | |
| this.textarea.dispatchEvent(new Event("input")); | |
| }, | |
| insertLink() { | |
| const url = prompt("Enter URL:"); | |
| if (!url) return; | |
| const text = prompt("Link text (optional):") || url; | |
| this.wrapSelection(`[${text}](`, `)`); | |
| }, | |
| insertImage() { | |
| const url = prompt("Enter image URL:"); | |
| if (!url) return; | |
| const alt = prompt("Alt text (optional):") || "image"; | |
| const markdown = ``; | |
| const start = this.textarea.selectionStart; | |
| this.textarea.setRangeText(markdown, start, start, "end"); | |
| this.textarea.dispatchEvent(new Event("input")); | |
| }, | |
| insertTable() { | |
| const table = `\n| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |\n`; | |
| this.insertLine(table); | |
| }, | |
| updateStats() { | |
| const content = this.textarea.value; | |
| const wordCount = Utils.countWords(content); | |
| const charCount = Utils.countChars(content); | |
| const lineCount = Utils.countLines(content); | |
| const readingTime = Utils.readingTime(wordCount); | |
| document.getElementById("word-count").textContent = `${wordCount} words`; | |
| document.getElementById("char-count").textContent = `${charCount} chars`; | |
| document.getElementById("line-count").textContent = `${lineCount} lines`; | |
| // Add reading time if element exists | |
| const readingTimeEl = document.getElementById("reading-time"); | |
| if (readingTimeEl) { | |
| readingTimeEl.textContent = readingTime; | |
| } | |
| // Update current doc stats if exists | |
| if (this.currentDoc) { | |
| this.currentDoc.wordCount = wordCount; | |
| this.currentDoc.charCount = charCount; | |
| } | |
| }, | |
| getContent() { | |
| return this.textarea.value; | |
| }, | |
| setContent(content) { | |
| this.textarea.value = content || ""; | |
| this.updateStats(); | |
| window.app?.preview.update(); | |
| }, | |
| clear() { | |
| this.setContent(""); | |
| }, | |
| startAutoSave() { | |
| // Stop any existing interval | |
| this.stopAutoSave(); | |
| const autoSaveEnabled = Utils.storage.get("autoSave", true); | |
| if (!autoSaveEnabled) return; | |
| // Only start if app is fully initialized | |
| if (!window.app) { | |
| console.warn("Auto-save deferred - app not initialized yet"); | |
| return; | |
| } | |
| this.autoSaveInterval = setInterval(async () => { | |
| // Prevent concurrent auto-saves | |
| if (this.autoSaveInProgress) { | |
| console.log("⏭️ Skipping auto-save - already in progress"); | |
| return; | |
| } | |
| if (window.app?.unsavedChanges && this.textarea.value) { | |
| try { | |
| this.autoSaveInProgress = true; | |
| await window.app.saveDocument(true); // Silent save | |
| console.log("💾 Auto-saved"); | |
| } catch (err) { | |
| console.error("Auto-save failed:", err); | |
| } finally { | |
| this.autoSaveInProgress = false; | |
| } | |
| } | |
| }, 30000); // 30 seconds | |
| console.log("✅ Auto-save enabled (every 30s)"); | |
| }, | |
| stopAutoSave() { | |
| if (this.autoSaveInterval) { | |
| clearInterval(this.autoSaveInterval); | |
| this.autoSaveInterval = null; | |
| } | |
| }, | |
| // Handle image drop | |
| handleImageDrop(e) { | |
| const files = e.dataTransfer?.files; | |
| if (!files || files.length === 0) return; | |
| for (const file of files) { | |
| if (file.type.startsWith("image/")) { | |
| this.insertImageFromFile(file); | |
| } | |
| } | |
| }, | |
| // Handle image paste | |
| handleImagePaste(item) { | |
| const file = item.getAsFile(); | |
| if (file) { | |
| this.insertImageFromFile(file); | |
| } | |
| }, | |
| // Insert image from file (convert to base64 data URL) | |
| insertImageFromFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = e => { | |
| const dataUrl = e.target.result; | |
| const altText = file.name.replace(/\.[^/.]+$/, ""); // Remove extension | |
| const markdown = `\n\n`; | |
| const start = this.textarea.selectionStart; | |
| this.textarea.setRangeText(markdown, start, start, "end"); | |
| this.textarea.dispatchEvent(new Event("input")); | |
| Utils.toast.success(`Image "${file.name}" inserted!`); | |
| }; | |
| reader.onerror = () => { | |
| Utils.toast.error("Failed to read image file"); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }; | |
| export default Editor; | |