/** * Editor Module * Handles CodeMirror editor and markdown preview */ class MarkdownEditor { constructor(editorId, previewId, filenameInputId, readOnly = false) { this.editorElement = document.getElementById(editorId); this.previewElement = document.getElementById(previewId); this.filenameInput = document.getElementById(filenameInputId); this.currentFile = null; this.webdavClient = null; this.macroProcessor = new MacroProcessor(null); // Will be set later this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page this.readOnly = readOnly; // Whether editor is in read-only mode this.editor = null; // Will be initialized later this.isShowingCustomPreview = false; // Flag to prevent auto-update when showing binary files // Initialize loading spinners (will be created lazily when needed) this.editorSpinner = null; this.previewSpinner = null; // Only initialize CodeMirror if not in read-only mode (view mode) if (!readOnly) { this.initCodeMirror(); } this.initMarkdown(); this.initMermaid(); } /** * Initialize CodeMirror */ initCodeMirror() { // Determine theme based on dark mode const isDarkMode = document.body.classList.contains('dark-mode'); const theme = isDarkMode ? 'monokai' : 'default'; this.editor = CodeMirror(this.editorElement, { mode: 'markdown', theme: theme, lineNumbers: true, lineWrapping: true, autofocus: !this.readOnly, // Don't autofocus in read-only mode readOnly: this.readOnly, // Set read-only mode extraKeys: this.readOnly ? {} : { 'Ctrl-S': () => this.save(), 'Cmd-S': () => this.save() } }); // Update preview on change with debouncing this.editor.on('change', TimingUtils.debounce(() => { this.updatePreview(); }, Config.DEBOUNCE_DELAY)); // Initial preview render setTimeout(() => { this.updatePreview(); }, 100); // Sync scroll this.editor.on('scroll', () => { this.syncScroll(); }); // Listen for dark mode changes this.setupThemeListener(); } /** * Setup listener for dark mode changes */ setupThemeListener() { // Watch for dark mode class changes const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === 'class') { const isDarkMode = document.body.classList.contains('dark-mode'); const newTheme = isDarkMode ? 'monokai' : 'default'; this.editor.setOption('theme', newTheme); } }); }); observer.observe(document.body, { attributes: true }); } /** * Initialize markdown parser */ initMarkdown() { if (window.marked) { this.marked = window.marked; // Create custom renderer for images const renderer = new marked.Renderer(); renderer.image = (token) => { // Handle both old API (string params) and new API (token object) let href, title, text; if (typeof token === 'object' && token !== null) { // New API: token is an object href = token.href || ''; title = token.title || ''; text = token.text || ''; } else { // Old API: separate parameters (href, title, text) href = arguments[0] || ''; title = arguments[1] || ''; text = arguments[2] || ''; } // Ensure all are strings href = String(href || ''); title = String(title || ''); text = String(text || ''); Logger.debug(`Image renderer called with href="${href}", title="${title}", text="${text}"`); // Check if href contains binary data (starts with non-printable characters) if (href && href.length > 100 && /^[\x00-\x1F\x7F-\xFF]/.test(href)) { Logger.error('Image href contains binary data - this should not happen!'); Logger.error('First 50 chars:', href.substring(0, 50)); // Return a placeholder image return `
⚠️ Invalid image data detected. Please re-upload the image.
`; } // Fix relative image paths to use WebDAV base URL if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) { // Get the directory of the current file const currentDir = this.currentFile ? PathUtils.getParentPath(this.currentFile) : ''; // Resolve relative path let imagePath = href; if (href.startsWith('./')) { // Relative to current directory imagePath = PathUtils.joinPaths(currentDir, href.substring(2)); } else if (href.startsWith('../')) { // Relative to parent directory imagePath = PathUtils.joinPaths(currentDir, href); } else if (!href.startsWith('/')) { // Relative to current directory (no ./) imagePath = PathUtils.joinPaths(currentDir, href); } else { // Absolute path from collection root imagePath = href.substring(1); // Remove leading / } // Build WebDAV URL - ensure no double slashes if (this.webdavClient && this.webdavClient.currentCollection) { // Remove trailing slash from baseUrl if present const baseUrl = this.webdavClient.baseUrl.endsWith('/') ? this.webdavClient.baseUrl.slice(0, -1) : this.webdavClient.baseUrl; // Ensure imagePath doesn't start with / const cleanImagePath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath; href = `${baseUrl}/${this.webdavClient.currentCollection}/${cleanImagePath}`; Logger.debug(`Resolved image URL: ${href}`); } } // Generate HTML directly const titleAttr = title ? ` title="${title}"` : ''; const altAttr = text ? ` alt="${text}"` : ''; return ``; }; this.marked.setOptions({ breaks: true, gfm: true, renderer: renderer, highlight: (code, lang) => { if (lang && window.Prism.languages[lang]) { return window.Prism.highlight(code, window.Prism.languages[lang], lang); } return code; } }); } else { console.error('Marked library not found.'); } } /** * Initialize Mermaid */ initMermaid() { if (window.mermaid) { window.mermaid.initialize({ startOnLoad: false, theme: document.body.classList.contains('dark-mode') ? 'dark' : 'default' }); } } /** * Set WebDAV client */ setWebDAVClient(client) { this.webdavClient = client; // Update macro processor with client if (this.macroProcessor) { this.macroProcessor.webdavClient = client; } } /** * Initialize loading spinners (lazy initialization) */ initLoadingSpinners() { if (!this.editorSpinner && !this.readOnly && this.editorElement) { this.editorSpinner = new LoadingSpinner(this.editorElement, 'Loading file...'); } if (!this.previewSpinner && this.previewElement) { this.previewSpinner = new LoadingSpinner(this.previewElement, 'Rendering preview...'); } } /** * Load file */ async loadFile(path) { try { // Initialize loading spinners if not already done this.initLoadingSpinners(); // Show loading spinners if (this.editorSpinner) { this.editorSpinner.show('Loading file...'); } if (this.previewSpinner) { this.previewSpinner.show('Loading preview...'); } // Reset custom preview flag when loading text files this.isShowingCustomPreview = false; const content = await this.webdavClient.get(path); this.currentFile = path; // Update filename input if it exists if (this.filenameInput) { this.filenameInput.value = path; } // Update editor if it exists (edit mode) if (this.editor) { this.editor.setValue(content); } // Update preview with the loaded content await this.renderPreview(content); // Save as last viewed page this.saveLastViewedPage(path); // Hide loading spinners if (this.editorSpinner) { this.editorSpinner.hide(); } if (this.previewSpinner) { this.previewSpinner.hide(); } // No notification for successful file load - it's not critical } catch (error) { // Hide loading spinners on error if (this.editorSpinner) { this.editorSpinner.hide(); } if (this.previewSpinner) { this.previewSpinner.hide(); } console.error('Failed to load file:', error); if (window.showNotification) { window.showNotification('Failed to load file', 'danger'); } } } /** * Save the last viewed page to localStorage * Stores per collection so different collections can have different last viewed pages */ saveLastViewedPage(path) { if (!this.webdavClient || !this.webdavClient.currentCollection) { return; } const collection = this.webdavClient.currentCollection; const storageKey = `${this.lastViewedStorageKey}:${collection}`; localStorage.setItem(storageKey, path); } /** * Get the last viewed page from localStorage * Returns null if no page was previously viewed */ getLastViewedPage() { if (!this.webdavClient || !this.webdavClient.currentCollection) { return null; } const collection = this.webdavClient.currentCollection; const storageKey = `${this.lastViewedStorageKey}:${collection}`; return localStorage.getItem(storageKey); } /** * Save file */ async save() { const path = this.filenameInput.value.trim(); if (!path) { if (window.showNotification) { window.showNotification('Please enter a filename', 'warning'); } return; } const content = this.editor.getValue(); try { await this.webdavClient.put(path, content); this.currentFile = path; if (window.showNotification) { window.showNotification('✅ Saved', 'success'); } // Dispatch event to reload file tree if (window.eventBus) { window.eventBus.dispatch('file-saved', path); } } catch (error) { console.error('Failed to save file:', error); if (window.showNotification) { window.showNotification('Failed to save file', 'danger'); } } } /** * Create new file */ newFile() { this.currentFile = null; this.filenameInput.value = ''; this.filenameInput.focus(); this.editor.setValue('# New File\n\nStart typing...\n'); this.updatePreview(); // No notification needed - UI is self-explanatory } /** * Delete current file */ async deleteFile() { if (!this.currentFile) { window.showNotification('No file selected', 'warning'); return; } const confirmed = await window.ConfirmationManager.confirm(`Are you sure you want to delete ${this.currentFile}?`, 'Delete File', true); if (confirmed) { try { await this.webdavClient.delete(this.currentFile); window.showNotification(`Deleted ${this.currentFile}`, 'success'); this.newFile(); window.eventBus.dispatch('file-deleted'); } catch (error) { console.error('Failed to delete file:', error); window.showNotification('Failed to delete file', 'danger'); } } } /** * Convert JSX-style attributes to HTML attributes * Handles style={{...}} and boolean attributes like allowFullScreen={true} */ convertJSXToHTML(content) { Logger.debug('Converting JSX to HTML...'); // Convert style={{...}} to style="..." // This regex finds style={{...}} and converts the object notation to CSS string content = content.replace(/style=\{\{([^}]+)\}\}/g, (match, styleContent) => { Logger.debug(`Found JSX style: ${match}`); // Parse the object-like syntax and convert to CSS const cssRules = styleContent .split(',') .map(rule => { const colonIndex = rule.indexOf(':'); if (colonIndex === -1) return ''; const key = rule.substring(0, colonIndex).trim(); const value = rule.substring(colonIndex + 1).trim(); if (!key || !value) return ''; // Convert camelCase to kebab-case (e.g., paddingTop -> padding-top) const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase(); // Remove quotes from value let cssValue = value.replace(/^['"]|['"]$/g, ''); return `${cssKey}: ${cssValue}`; }) .filter(rule => rule) .join('; '); Logger.debug(`Converted to CSS: style="${cssRules}"`); return `style="${cssRules}"`; }); // Convert boolean attributes like allowFullScreen={true} to allowfullscreen content = content.replace(/(\w+)=\{true\}/g, (match, attrName) => { Logger.debug(`Found boolean attribute: ${match}`); // Convert camelCase to lowercase for HTML attributes const htmlAttr = attrName.toLowerCase(); Logger.debug(`Converted to: ${htmlAttr}`); return htmlAttr; }); // Remove attributes set to {false} content = content.replace(/\s+\w+=\{false\}/g, ''); return content; } /** * Render preview from markdown content * Can be called with explicit content (for view mode) or from editor (for edit mode) */ async renderPreview(markdownContent = null) { // Get markdown content from editor if not provided const markdown = markdownContent !== null ? markdownContent : (this.editor ? this.editor.getValue() : ''); const previewDiv = this.previewElement; if (!markdown || !markdown.trim()) { previewDiv.innerHTML = `

Start typing to see preview...

`; return; } try { // Initialize loading spinners if not already done this.initLoadingSpinners(); // Show preview loading spinner (only if not already shown by loadFile) if (this.previewSpinner && !this.previewSpinner.isVisible()) { this.previewSpinner.show('Rendering preview...'); } // Step 0: Convert JSX-style syntax to HTML let processedContent = this.convertJSXToHTML(markdown); // Step 1: Process macros if (this.macroProcessor) { const processingResult = await this.macroProcessor.processMacros(processedContent); processedContent = processingResult.content; } // Step 2: Parse markdown to HTML if (!this.marked) { console.error("Markdown parser (marked) not initialized."); previewDiv.innerHTML = `
Preview engine not loaded.
`; if (this.previewSpinner) { this.previewSpinner.hide(); } return; } let html = this.marked.parse(processedContent); // Replace mermaid code blocks html = html.replace( /
([\s\S]*?)<\/code><\/pre>/g,
                (match, code) => {
                    const id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
                    return `
${code.trim()}
`; } ); previewDiv.innerHTML = html; // Apply syntax highlighting const codeBlocks = previewDiv.querySelectorAll('pre code'); codeBlocks.forEach(block => { const languageClass = Array.from(block.classList) .find(cls => cls.startsWith('language-')); if (languageClass && languageClass !== 'language-mermaid') { if (window.Prism) { window.Prism.highlightElement(block); } } }); // Render mermaid diagrams const mermaidElements = previewDiv.querySelectorAll('.mermaid'); if (mermaidElements.length > 0 && window.mermaid) { try { window.mermaid.contentLoaded(); } catch (error) { console.warn('Mermaid rendering error:', error); } } // Hide preview loading spinner after a small delay to ensure rendering is complete setTimeout(() => { if (this.previewSpinner) { this.previewSpinner.hide(); } }, 100); } catch (error) { console.error('Preview rendering error:', error); previewDiv.innerHTML = ` `; // Hide loading spinner on error if (this.previewSpinner) { this.previewSpinner.hide(); } } } /** * Update preview (backward compatibility wrapper) * Calls renderPreview with content from editor */ async updatePreview() { // Skip auto-update if showing custom preview (e.g., binary files) if (this.isShowingCustomPreview) { Logger.debug('Skipping auto-update: showing custom preview'); return; } if (this.editor) { await this.renderPreview(); } } /** * Sync scroll between editor and preview */ syncScroll() { if (!this.editor) return; // Skip if no editor (view mode) const scrollInfo = this.editor.getScrollInfo(); const scrollPercent = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight); const previewHeight = this.previewElement.scrollHeight - this.previewElement.clientHeight; this.previewElement.scrollTop = previewHeight * scrollPercent; } /** * Handle image upload */ async uploadImage(file) { try { const filename = await this.webdavClient.uploadImage(file); const imageUrl = `/fs/${this.webdavClient.currentCollection}/images/${filename}`; const markdown = `![${file.name}](${imageUrl})`; // Insert at cursor this.editor.replaceSelection(markdown); if (window.showNotification) { window.showNotification('Image uploaded', 'success'); } } catch (error) { console.error('Failed to upload image:', error); if (window.showNotification) { window.showNotification('Failed to upload image', 'danger'); } } } /** * Get editor content */ getValue() { return this.editor.getValue(); } insertAtCursor(text) { const doc = this.editor.getDoc(); const cursor = doc.getCursor(); doc.replaceRange(text, cursor); } /** * Set editor content */ setValue(content) { this.editor.setValue(content); } // Debounce function moved to TimingUtils in utils.js } // Export for use in other modules window.MarkdownEditor = MarkdownEditor;