/** * DOM Renderer - Clean separation between business logic and DOM manipulation * * This class handles all DOM operations and UI events, while delegating * business logic to the SectionManager. */ // Import EditState if available, otherwise define locally const EditState = (typeof window !== 'undefined' && window.MarkitectEditor?.EditState) ? window.MarkitectEditor.EditState : { ORIGINAL: 'original', EDITING: 'editing', MODIFIED: 'modified', SAVED: 'saved' }; /** * DOMRenderer class - Handles DOM rendering and UI interactions * * Responsibilities: * - Render sections to DOM elements * - Handle UI events (clicks, keyboard) * - Manage textarea creation and styling * - Sync DOM state with Section objects * - Provide visual feedback for different states */ class DOMRenderer { constructor(sectionManager, containerElement) { this.sectionManager = sectionManager; this.container = containerElement; this.currentTextarea = null; this.currentSection = null; // Bind event handlers this.handleSectionClick = this.handleSectionClick.bind(this); this.handleAccept = this.handleAccept.bind(this); this.handleCancel = this.handleCancel.bind(this); this.handleReset = this.handleReset.bind(this); this.handleKeydown = this.handleKeydown.bind(this); // Listen to section manager events this.setupEventListeners(); } /** * Setup event listeners for section manager events */ setupEventListeners() { this.sectionManager.on('sections-created', (data) => { this.renderAllSections(data.sections); }); this.sectionManager.on('edit-started', (data) => { this.showEditor(data.sectionId, data.content); }); this.sectionManager.on('edit-stopped', (data) => { this.hideEditor(data.sectionId); this.updateSectionVisual(data.sectionId, data.section); }); this.sectionManager.on('changes-accepted', (data) => { this.hideEditor(data.sectionId); this.updateSectionContent(data.sectionId, data.content); this.updateSectionVisual(data.sectionId, data.section); }); this.sectionManager.on('changes-cancelled', (data) => { this.hideEditor(data.sectionId); this.updateSectionContent(data.sectionId, data.content); this.updateSectionVisual(data.sectionId, data.section); }); this.sectionManager.on('section-reset', (data) => { this.updateTextareaContent(data.content); }); this.sectionManager.on('content-updated', (data) => { this.updateSectionVisual(data.sectionId, data.section); }); } /** * Render all sections to the DOM * @param {Array
} sections - Sections to render */ renderAllSections(sections) { this.container.innerHTML = ''; sections.forEach(section => { const element = this.createSectionElement(section); section.domElement = element; this.container.appendChild(element); }); // Add click handlers to all sections this.container.addEventListener('click', this.handleSectionClick); } /** * Create a DOM element for a section * @param {Section} section - The section to create element for * @returns {HTMLElement} The created DOM element */ createSectionElement(section) { const element = document.createElement('div'); element.className = 'markitect-section-editable'; element.setAttribute('data-section-id', section.id); element.setAttribute('data-section-type', section.sectionType); // Parse markdown to HTML - use pending changes if they exist and section isn't being edited const contentToRender = (section.state === EditState.MODIFIED && section.pendingMarkdown) ? section.pendingMarkdown : section.currentMarkdown; if (typeof marked !== 'undefined') { element.innerHTML = marked.parse(contentToRender); } else { // Fallback for testing environment element.innerHTML = `

${contentToRender}

`; } this.updateSectionVisual(section.id, section.getStatus()); return element; } /** * Update section visual state based on its status * @param {string} sectionId - The section ID * @param {Object} status - The section status object */ updateSectionVisual(sectionId, status) { const element = this.findSectionElement(sectionId); if (!element) return; // Remove all state classes element.classList.remove('section-original', 'section-editing', 'section-modified', 'section-saved'); // Add appropriate state class element.classList.add(`section-${status.state}`); // Add visual indicators for modified sections if (status.isModified) { element.style.backgroundColor = 'rgba(255, 235, 59, 0.1)'; // Light yellow element.style.borderLeft = '3px solid #ffc107'; // Orange border } else if (status.state === 'saved') { element.style.backgroundColor = 'rgba(76, 175, 80, 0.1)'; // Light green element.style.borderLeft = '3px solid #4caf50'; // Green border } else { element.style.backgroundColor = ''; element.style.borderLeft = ''; } } /** * Find DOM element for a section * @param {string} sectionId - The section ID * @returns {HTMLElement|null} The DOM element or null */ findSectionElement(sectionId) { return this.container.querySelector(`[data-section-id="${sectionId}"]`); } /** * Handle section click events * @param {Event} event - The click event */ handleSectionClick(event) { const sectionElement = event.target.closest('.markitect-section-editable'); if (!sectionElement) return; const sectionId = sectionElement.getAttribute('data-section-id'); if (!sectionId) return; // Don't start editing if already editing this section if (this.currentSection === sectionId) return; try { this.sectionManager.startEditing(sectionId); } catch (error) { console.error('Failed to start editing:', error); this.showError(error.message); } } /** * Show editor for a section * @param {string} sectionId - The section ID * @param {string} content - The content to edit */ showEditor(sectionId, content) { const element = this.findSectionElement(sectionId); if (!element) return; // Hide any existing editor first this.hideCurrentEditor(); // Create editor container const editorContainer = this.createEditorContainer(content, sectionId); // Replace section content with editor element.innerHTML = ''; element.appendChild(editorContainer); // Focus the textarea const textarea = editorContainer.querySelector('textarea'); if (textarea) { textarea.focus(); textarea.setSelectionRange(0, 0); this.currentTextarea = textarea; } this.currentSection = sectionId; } /** * Create editor container with textarea and controls * @param {string} content - The initial content * @param {string} sectionId - The section ID * @returns {HTMLElement} The editor container */ createEditorContainer(content, sectionId) { const container = document.createElement('div'); container.className = 'markitect-edit-container'; // Create textarea wrapper const textareaWrapper = document.createElement('div'); textareaWrapper.className = 'markitect-textarea-wrapper'; // Create textarea const textarea = document.createElement('textarea'); textarea.className = 'edit-mode'; textarea.value = content; textarea.addEventListener('input', () => { try { this.sectionManager.updateContent(sectionId, textarea.value); } catch (error) { console.error('Failed to update content:', error); } }); // Add keyboard shortcuts textarea.addEventListener('keydown', this.handleKeydown); // Auto-resize functionality this.setupAutoResize(textarea); textareaWrapper.appendChild(textarea); // Create controls const controls = this.createControlButtons(sectionId); container.appendChild(textareaWrapper); container.appendChild(controls); return container; } /** * Create control buttons for section * @param {string} sectionId - The section ID * @returns {HTMLElement} The controls container */ createControlButtons(sectionId) { const controls = document.createElement('div'); controls.className = 'markitect-section-controls'; // Accept button const acceptBtn = document.createElement('button'); acceptBtn.className = 'markitect-section-btn accept'; acceptBtn.innerHTML = ' Accept'; acceptBtn.title = 'Accept changes and save this section'; acceptBtn.addEventListener('click', () => this.handleAccept(sectionId)); // Cancel button const cancelBtn = document.createElement('button'); cancelBtn.className = 'markitect-section-btn cancel'; cancelBtn.innerHTML = ' Cancel'; cancelBtn.title = 'Cancel editing and revert to state before editing started'; cancelBtn.addEventListener('click', () => this.handleCancel(sectionId)); // Reset button const resetBtn = document.createElement('button'); resetBtn.className = 'markitect-section-btn reset'; resetBtn.innerHTML = '🔄 Reset'; resetBtn.title = 'Reset to original content from render time (discards all changes)'; resetBtn.addEventListener('click', () => this.handleReset(sectionId)); controls.appendChild(acceptBtn); controls.appendChild(cancelBtn); controls.appendChild(resetBtn); return controls; } /** * Setup auto-resize for textarea * @param {HTMLTextAreaElement} textarea - The textarea element */ setupAutoResize(textarea) { const autoResize = () => { const transition = textarea.style.transition; textarea.style.transition = 'none'; textarea.style.height = 'auto'; const contentHeight = textarea.scrollHeight; const padding = 24; const lineCount = textarea.value.split('\n').length; const minHeight = Math.max(60, lineCount * 24 + padding); const maxHeight = 360; const newHeight = Math.max(60, Math.min(maxHeight, Math.max(minHeight, contentHeight + 4))); textarea.style.height = newHeight + 'px'; textarea.style.transition = transition; }; textarea.addEventListener('input', autoResize); textarea.addEventListener('paste', () => setTimeout(autoResize, 10)); // Initial sizing setTimeout(autoResize, 20); } /** * Hide current editor */ hideCurrentEditor() { if (this.currentSection) { this.hideEditor(this.currentSection); } } /** * Hide editor for specific section * @param {string} sectionId - The section ID */ hideEditor(sectionId) { // Section manager will trigger re-render through events this.currentTextarea = null; this.currentSection = null; } /** * Update section content in DOM * @param {string} sectionId - The section ID * @param {string} content - The new content */ updateSectionContent(sectionId, content) { const element = this.findSectionElement(sectionId); if (!element) return; // Parse markdown to HTML if (typeof marked !== 'undefined') { element.innerHTML = marked.parse(content); } else { element.innerHTML = `

${content}

`; } } /** * Update textarea content * @param {string} content - The new content */ updateTextareaContent(content) { if (this.currentTextarea) { this.currentTextarea.value = content; } } /** * Handle accept button click * @param {string} sectionId - The section ID */ handleAccept(sectionId) { try { this.sectionManager.acceptChanges(sectionId); } catch (error) { console.error('Failed to accept changes:', error); this.showError(error.message); } } /** * Handle cancel button click * @param {string} sectionId - The section ID */ handleCancel(sectionId) { try { this.sectionManager.cancelChanges(sectionId); } catch (error) { console.error('Failed to cancel changes:', error); this.showError(error.message); } } /** * Handle reset button click * @param {string} sectionId - The section ID */ handleReset(sectionId) { try { this.sectionManager.resetToOriginal(sectionId); } catch (error) { console.error('Failed to reset section:', error); this.showError(error.message); } } /** * Handle keyboard shortcuts * @param {KeyboardEvent} event - The keyboard event */ handleKeydown(event) { if (!this.currentSection) return; if (event.ctrlKey || event.metaKey) { switch (event.key) { case 'Enter': event.preventDefault(); this.handleAccept(this.currentSection); break; case 'Escape': event.preventDefault(); this.handleCancel(this.currentSection); break; } } if (event.key === 'Escape') { event.preventDefault(); this.handleCancel(this.currentSection); } } /** * Show error message to user * @param {string} message - The error message */ showError(message) { // Simple error display - could be enhanced with better UI console.error('Section Editor Error:', message); // Create temporary error message const errorDiv = document.createElement('div'); errorDiv.className = 'markitect-error'; errorDiv.textContent = message; errorDiv.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #f44336; color: white; padding: 12px 20px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); z-index: 10000; font-size: 14px; max-width: 300px; `; document.body.appendChild(errorDiv); // Remove after 5 seconds setTimeout(() => { if (errorDiv.parentNode) { errorDiv.parentNode.removeChild(errorDiv); } }, 5000); } /** * Reset all sections to original state */ resetAllSections() { try { this.sectionManager.resetAllToOriginal(); this.hideCurrentEditor(); // Re-render all sections const sections = this.sectionManager.getAllSections(); this.renderAllSections(sections); } catch (error) { console.error('Failed to reset all sections:', error); this.showError(error.message); } } /** * Get document status for UI display * @returns {Object} Document status */ getDocumentStatus() { return this.sectionManager.getDocumentStatus(); } /** * Get complete document markdown * @returns {string} The markdown document */ getDocumentMarkdown() { return this.sectionManager.getDocumentMarkdown(); } /** * Destroy the renderer and clean up event listeners */ destroy() { this.container.removeEventListener('click', this.handleSectionClick); this.hideCurrentEditor(); this.container.innerHTML = ''; } } // Export for testing and usage if (typeof module !== 'undefined' && module.exports) { module.exports = { DOMRenderer }; } else { window.MarkitectEditor = window.MarkitectEditor || {}; window.MarkitectEditor.DOMRenderer = DOMRenderer; }