// Clean Editor Architecture /** * Test-Driven Section Editor Implementation * * A clean, object-oriented approach to handling section editing * that can be tested independently of the DOM. */ // Debug system - choose one of: 'off', 'console', 'alerts' const DEBUG_MODE = 'console'; // Advanced State Management const EditState = Object.freeze({ ORIGINAL: 'original', EDITING: 'editing', MODIFIED: 'modified', SAVED: 'saved' }); function debug(message, category = 'INFO') { const prefix = `DEBUG ${category}:`; switch (DEBUG_MODE) { case 'off': // No debugging output break; case 'console': console.log(prefix, message); break; case 'alerts': alert(`${prefix} ${message}`); console.log(prefix, message); // Also log to console for reference break; default: console.warn('Invalid DEBUG_MODE. Use: off, console, or alerts'); } } // Enums for clear state management (already defined above) const SectionType = Object.freeze({ HEADING: 'heading', PARAGRAPH: 'paragraph', LIST: 'list', CODE: 'code', QUOTE: 'quote', IMAGE: 'image', OTHER: 'other' }); /** * Section class - Represents a single editable section */ class Section { constructor(id, markdown, type) { this.id = id; this.originalMarkdown = markdown; this.currentMarkdown = markdown; this.editingMarkdown = markdown; this.pendingMarkdown = null; this.type = type; this.state = EditState.ORIGINAL; this.domElement = null; this.lastSaved = null; this.created = new Date(); } static generateId(markdown, position) { const cleanText = markdown.replace(/[^a-zA-Z0-9]/g, ''); const hash = cleanText.substring(0, 8) + position; return `section-${hash}`; } static detectType(markdown) { const trimmed = markdown.trim(); if (trimmed.startsWith('#')) return SectionType.HEADING; if (trimmed.startsWith('```')) return SectionType.CODE; if (trimmed.startsWith('>')) return SectionType.QUOTE; if (trimmed.includes('![')) return SectionType.IMAGE; if (trimmed.startsWith('-') || trimmed.startsWith('*') || trimmed.startsWith('1.')) return SectionType.LIST; return SectionType.PARAGRAPH; } startEdit() { if (this.state === EditState.EDITING) { throw new Error(`Section ${this.id} is already being edited`); } this.editingMarkdown = this.pendingMarkdown || this.currentMarkdown; this.state = EditState.EDITING; return this.editingMarkdown; } updateContent(markdown) { if (this.state !== EditState.EDITING) { throw new Error(`Section ${this.id} is not in editing state`); } this.editingMarkdown = markdown; } acceptChanges() { if (this.state !== EditState.EDITING) { throw new Error(`Section ${this.id} is not in editing state`); } this.currentMarkdown = this.editingMarkdown; this.editingMarkdown = null; this.pendingMarkdown = null; this.state = EditState.SAVED; this.lastSaved = new Date(); return this.currentMarkdown; } cancelChanges() { if (this.state !== EditState.EDITING) { throw new Error(`Section ${this.id} is not in editing state`); } this.editingMarkdown = null; if (this.pendingMarkdown !== null) { this.state = EditState.MODIFIED; return this.pendingMarkdown; } else if (this.lastSaved !== null) { this.state = EditState.SAVED; return this.currentMarkdown; } else { this.state = this.hasChanges() ? EditState.MODIFIED : EditState.ORIGINAL; return this.currentMarkdown; } } stopEditing() { if (this.state !== EditState.EDITING) { return this.state; } // If we have editing changes that differ from current content, preserve them as pending if (this.editingMarkdown && this.editingMarkdown !== this.currentMarkdown) { this.pendingMarkdown = this.editingMarkdown; this.state = EditState.MODIFIED; // Has pending changes } else { // No changes made during this edit session this.pendingMarkdown = null; if (this.lastSaved !== null) { this.state = EditState.SAVED; } else { this.state = this.hasChanges() ? EditState.MODIFIED : EditState.ORIGINAL; } } this.editingMarkdown = null; return this.state; } resetToOriginal() { this.currentMarkdown = this.originalMarkdown; this.editingMarkdown = this.originalMarkdown; this.pendingMarkdown = null; this.state = EditState.ORIGINAL; return this.originalMarkdown; } isEditing() { return this.state === EditState.EDITING; } hasChanges() { return this.currentMarkdown !== this.originalMarkdown; } getStatus() { return { id: this.id, state: this.state, hasChanges: this.hasChanges(), isEditing: this.isEditing(), contentLength: this.currentMarkdown.length, lastSaved: this.lastSaved, sectionType: this.sectionType, // Legacy compatibility type: this.type, originalLength: this.originalMarkdown.length, currentLength: this.currentMarkdown.length }; } isImage() { return this.type === SectionType.IMAGE; } isProtectedHeading() { // In insert mode, headings 1-3 are protected if (this.type === SectionType.HEADING) { const headingMatch = this.originalMarkdown.match(/^(#{1,3})\s/); if (headingMatch) { this.headingLevel = headingMatch[1].length; return this.headingLevel <= 3; } } return false; } getHeadingContent() { if (this.type === SectionType.HEADING) { const lines = this.currentMarkdown.split('\n'); return lines.slice(1).join('\n').trim(); } return this.currentMarkdown; } } /** * SectionManager class - Manages the collection of sections */ class SectionManager { constructor() { this.sections = new Map(); this.listeners = new Map(); this.statusInterval = null; this.lastStatusUpdate = new Date().toISOString(); } on(event, callback) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(callback); } emit(event, data) { if (this.listeners.has(event)) { this.listeners.get(event).forEach(callback => callback(data)); } } createSectionsFromMarkdown(markdownContent) { const lines = markdownContent.split('\n'); const sections = []; let currentSection = ''; let position = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const isHeading = /^#{1,6}\s/.test(line); const isNewParagraph = line.trim() && i > 0 && !lines[i-1].trim(); const isNewSection = isHeading || isNewParagraph; if (isNewSection && currentSection.trim()) { const sectionId = Section.generateId(currentSection, position); const sectionType = Section.detectType(currentSection); const section = new Section(sectionId, currentSection.trim(), sectionType); sections.push(section); this.sections.set(sectionId, section); position++; currentSection = line; } else { if (currentSection) currentSection += '\n'; currentSection += line; } } if (currentSection.trim()) { const sectionId = Section.generateId(currentSection, position); const sectionType = Section.detectType(currentSection); const section = new Section(sectionId, currentSection.trim(), sectionType); sections.push(section); this.sections.set(sectionId, section); } this.emit('sections-created', { sections, count: sections.length }); return sections; } startEditing(sectionId) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } if (section.isEditing()) { console.log('Section already in editing state:', sectionId); return section.editingMarkdown; } const content = section.startEdit(); this.emit('edit-started', { sectionId, content, section: section.getStatus() }); return content; } updateContent(sectionId, markdown) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } section.updateContent(markdown); this.emit('content-updated', { sectionId, markdown, section: section.getStatus() }); } acceptChanges(sectionId) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } const content = section.acceptChanges(); this.emit('changes-accepted', { sectionId, content, section: section.getStatus() }); return content; } cancelChanges(sectionId) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } const content = section.cancelChanges(); this.emit('changes-cancelled', { sectionId, content, section: section.getStatus() }); return content; } resetSection(sectionId) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } const content = section.resetToOriginal(); this.emit('section-reset', { sectionId, content, section: section.getStatus() }); return content; } getDocumentMarkdown() { const sortedSections = Array.from(this.sections.values()) .sort((a, b) => a.created - b.created); return sortedSections.map(section => section.currentMarkdown).join('\n\n'); } getAllSections() { return Array.from(this.sections.values()); } getSectionStatus() { return Array.from(this.sections.values()).map(section => section.getStatus()); } escapeRegex(str) { return str.replace(/[.*+?^${}()|\[\]\\]/g, '\\\\$&'); } /** * Check if new content contains new headings that would require section splitting * @param {string} newContent - The new content to check * @param {string} originalContent - The original content to compare against * @returns {boolean} True if section splitting is needed */ checkForSectionSplits(newContent, originalContent) { const originalHeadings = this.extractHeadings(originalContent); const newHeadings = this.extractHeadings(newContent); // If new content has more headings than original, we need to split return newHeadings.length > originalHeadings.length; } /** * Extract heading lines from markdown content * @param {string} content - Markdown content * @returns {Array} Array of heading lines */ extractHeadings(content) { if (!content) return []; const lines = content.split('\n'); return lines.filter(line => /^#{1,6}\s/.test(line.trim())); } /** * Handle splitting a section when new headings are detected * @param {string} sectionId - ID of the section to split * @param {string} newContent - New content with multiple headings */ handleSectionSplit(sectionId, newContent) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } // Remove the original section this.sections.delete(sectionId); // Create new sections from the content const newSections = this.createSectionsFromContent(newContent); // Emit section-split event this.emit('section-split', { originalSectionId: sectionId, newSections: newSections, count: newSections.length }); return newSections; } /** * Create sections from content (alias for createSectionsFromMarkdown) * @param {string} content - Markdown content * @returns {Array} Array of created sections */ createSectionsFromContent(content) { return this.createSectionsFromMarkdown(content); } /** * Get global status information about all sections * @returns {Object} Status object with global information */ getGlobalStatus() { const sections = this.getAllSections(); const editingSections = sections.filter(s => s.isEditing()).map(s => s.id); const modifiedSections = sections.filter(s => s.hasChanges()); const hasModifications = modifiedSections.length > 0 || editingSections.length > 0; let state = 'ready'; if (editingSections.length > 0) { state = 'editing'; } else if (hasModifications) { state = 'modified'; } return { totalSections: sections.length, editingSections: editingSections, modifiedSections: modifiedSections.length, hasModifications: hasModifications, state: state, lastUpdate: this.lastStatusUpdate }; } /** * Update global status and emit status-updated event */ updateGlobalStatus() { this.lastStatusUpdate = new Date().toISOString(); const status = this.getGlobalStatus(); this.emit('status-updated', status); return status; } /** * Start periodic status tracking * @param {number} intervalMs - Update interval in milliseconds (default: 2000) */ startStatusTracking(intervalMs = 2000) { if (this.statusInterval) { clearInterval(this.statusInterval); } this.statusInterval = setInterval(() => { this.updateGlobalStatus(); }, intervalMs); // Emit initial status this.updateGlobalStatus(); } /** * Stop periodic status tracking */ stopStatusTracking() { if (this.statusInterval) { clearInterval(this.statusInterval); this.statusInterval = null; } } } /** * DOM Renderer - Handles DOM interactions */ class DOMRenderer { constructor(sectionManager, container) { this.sectionManager = sectionManager; this.container = container; this.editingSections = new Set(); 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); this.setupEventListeners(); } 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.sectionManager.on('changes-accepted', (data) => { this.hideEditor(data.sectionId); this.updateSectionContent(data.sectionId, data.content); }); this.sectionManager.on('changes-cancelled', (data) => { this.hideEditor(data.sectionId); this.updateSectionContent(data.sectionId, data.content); }); this.sectionManager.on('section-reset', (data) => { this.updateTextareaContent(data.content, data.sectionId); }); } renderAllSections(sections) { debug('21: renderAllSections called with ' + sections.length + ' sections', 'RENDER'); this.container.innerHTML = ''; debug('22: Container cleared', 'RENDER'); sections.forEach((section, index) => { debug('23: Creating element for section ' + (index + 1) + ': ' + section.id, 'RENDER'); const element = this.createSectionElement(section); section.domElement = element; this.container.appendChild(element); }); debug('24: All section elements added to container', 'RENDER'); this.container.addEventListener('click', this.handleSectionClick); debug('25: Click listener attached - container content length: ' + this.container.innerHTML.length, 'RENDER'); } createSectionElement(section) { const element = document.createElement('div'); element.setAttribute('data-section-id', section.id); debug('SECTION: Creating element for section ' + section.id + ' with content: ' + section.currentMarkdown.substring(0, 50) + '...', 'SECTION'); if (typeof marked !== 'undefined') { const html = marked.parse(section.currentMarkdown); const htmlWithTargetBlank = html.replace(/]*)>/g, ''); element.innerHTML = htmlWithTargetBlank; } else { element.innerHTML = `

${section.currentMarkdown}

`; } this.setupSectionElement(element); debug('SECTION: Section element setup complete for ' + section.id, 'SECTION'); return element; } handleSectionClick(event) { debug('CLICK: Click detected on target: ' + event.target.tagName + ' ' + (event.target.className || ''), 'CLICK'); // Don't handle clicks on form elements, buttons, or links if (event.target.closest('textarea, button, input, a')) { debug('CLICK: Ignoring click on form element', 'CLICK'); return; } const sectionElement = event.target.closest('.ui-edit-section'); debug('CLICK: Found section element: ' + (sectionElement ? sectionElement.getAttribute('data-section-id') : 'null'), 'CLICK'); if (!sectionElement) return; const sectionId = sectionElement.getAttribute('data-section-id'); debug('CLICK: Section ID: ' + sectionId, 'CLICK'); if (!sectionId) return; // Check if this section is already being edited const section = this.sectionManager.sections.get(sectionId); if (section && section.isEditing()) { console.log('Section already being edited:', sectionId); return; } try { console.log('Starting edit for section:', sectionId); this.sectionManager.startEditing(sectionId); } catch (error) { console.error('Failed to start editing:', error); } } showEditor(sectionId, content) { const element = this.findSectionElement(sectionId); if (!element) return; this.hideCurrentEditor(); const section = this.sectionManager.sections.get(sectionId); const isImageSection = section && section.isImage(); if (isImageSection) { this.showImageEditor(sectionId, section); return; } const editorContainer = document.createElement('div'); editorContainer.className = 'ui-edit-editor-container'; const textarea = document.createElement('textarea'); textarea.className = 'ui-edit-textarea ui-edit-textarea-main'; textarea.value = content; textarea.style.cssText = ` flex: 1; min-height: 100px; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 14px; line-height: 1.5; padding: 12px; border: 2px solid #007bff; border-radius: 6px; resize: vertical; outline: none; background: white; color: #333; `; // Setup auto-resize functionality this.setupAutoResize(textarea); const controls = document.createElement('div'); controls.className = 'ui-edit-controls'; controls.style.cssText = ` display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; `; const acceptBtn = this.createButton('Accept', 'ui-edit-button-accept', this.handleAccept); const cancelBtn = this.createButton('Cancel', 'ui-edit-button-cancel', this.handleCancel); controls.appendChild(acceptBtn); controls.appendChild(cancelBtn); editorContainer.appendChild(textarea); editorContainer.appendChild(controls); element.innerHTML = ''; element.appendChild(editorContainer); textarea.focus(); this.editingSections.add(sectionId); textarea.addEventListener('input', () => { this.sectionManager.updateContent(sectionId, textarea.value); }); // Add keyboard shortcuts textarea.addEventListener('keydown', this.handleKeydown); } /** * Show image editor with manipulation controls * @param {string} sectionId - The section ID * @param {Section} section - The section object */ showImageEditor(sectionId, section) { const element = this.findSectionElement(sectionId); if (!element) return; this.hideCurrentEditor(); const editorContainer = document.createElement('div'); editorContainer.className = 'ui-edit-image-editor-container'; editorContainer.style.cssText = ` display: flex; flex-direction: column; gap: 12px; margin-top: 12px; padding: 16px; background: #f8f9fa; border-radius: 8px; border: 2px solid #007bff; `; // Image preview const imagePreview = document.createElement('div'); imagePreview.className = 'ui-edit-image-preview'; imagePreview.style.cssText = ` max-width: 100%; text-align: center; background: white; padding: 16px; border-radius: 6px; border: 1px solid #dee2e6; `; // Parse markdown to extract image info const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/); if (imageMatch) { const [, altText, imageSrc] = imageMatch; const img = document.createElement('img'); img.src = imageSrc; img.alt = altText; img.style.cssText = ` max-width: 100%; max-height: 300px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); `; imagePreview.appendChild(img); } // Image controls const controlPanel = document.createElement('div'); controlPanel.className = 'ui-edit-image-controls'; controlPanel.style.cssText = ` display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; `; // Alt text editor const altTextContainer = document.createElement('div'); altTextContainer.style.cssText = `grid-column: 1 / -1; margin-bottom: 8px;`; const altTextLabel = document.createElement('label'); altTextLabel.textContent = 'Alt Text:'; altTextLabel.style.cssText = `display: block; margin-bottom: 4px; font-weight: bold;`; const altTextInput = document.createElement('input'); altTextInput.type = 'text'; altTextInput.value = imageMatch ? imageMatch[1] : ''; altTextInput.style.cssText = ` width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; `; // Add keyboard shortcuts to alt text input altTextInput.addEventListener('keydown', this.handleKeydown); altTextContainer.appendChild(altTextLabel); altTextContainer.appendChild(altTextInput); // Image manipulation buttons const buttons = [ { text: 'Replace Image', action: () => this.replaceImage(sectionId) }, { text: 'Resize', action: () => this.resizeImage(sectionId) }, { text: 'Add Caption', action: () => this.addImageCaption(sectionId) }, { text: 'Remove Image', action: () => this.removeImage(sectionId) } ]; buttons.forEach(({ text, action }) => { const btn = this.createButton(text, 'ui-edit-image-btn', action); btn.style.fontSize = '12px'; btn.style.padding = '6px 12px'; controlPanel.appendChild(btn); }); // Standard editor controls const editorControls = document.createElement('div'); editorControls.className = 'ui-edit-controls'; editorControls.style.cssText = ` display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; `; const acceptBtn = this.createButton('✓ Accept', 'ui-edit-accept', (e) => { // Update alt text if changed if (imageMatch && altTextInput.value !== imageMatch[1]) { const newMarkdown = section.currentMarkdown.replace( /!\[(.*?)\]/, `![${altTextInput.value}]` ); this.sectionManager.updateContent(sectionId, newMarkdown); } this.handleAccept(e); }); const cancelBtn = this.createButton('✗ Cancel', 'ui-edit-cancel', this.handleCancel); const resetBtn = this.createButton('↺ Reset', 'ui-edit-reset', this.handleReset); acceptBtn.style.background = '#28a745'; cancelBtn.style.background = '#dc3545'; resetBtn.style.background = '#fd7e14'; editorControls.appendChild(acceptBtn); editorControls.appendChild(cancelBtn); editorControls.appendChild(resetBtn); // Assemble the editor editorContainer.appendChild(imagePreview); editorContainer.appendChild(altTextContainer); editorContainer.appendChild(controlPanel); editorContainer.appendChild(editorControls); element.appendChild(editorContainer); altTextInput.focus(); this.editingSections.add(sectionId); } /** * Image manipulation methods */ replaceImage(sectionId) { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { const section = this.sectionManager.sections.get(sectionId); const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/); if (imageMatch) { const newMarkdown = section.currentMarkdown.replace( /!\[(.*?)\]\((.*?)\)/, `![${imageMatch[1]}](${event.target.result})` ); this.sectionManager.updateContent(sectionId, newMarkdown); this.hideEditor(sectionId); setTimeout(() => this.showImageEditor(sectionId, this.sectionManager.sections.get(sectionId)), 100); } }; reader.readAsDataURL(file); } }); input.click(); } resizeImage(sectionId) { const section = this.sectionManager.sections.get(sectionId); const size = prompt('Enter image width (e.g., 300px, 50%, or leave empty for auto):', ''); if (size !== null) { const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/); if (imageMatch) { const style = size ? ` style="width: ${size};"` : ''; const newMarkdown = section.currentMarkdown.replace( /!\[(.*?)\]\((.*?)\)/, `${imageMatch[1]}` ); this.sectionManager.updateContent(sectionId, newMarkdown); } } } addImageCaption(sectionId) { const section = this.sectionManager.sections.get(sectionId); const caption = prompt('Enter image caption:', ''); if (caption) { const newMarkdown = section.currentMarkdown + `\n\n*${caption}*`; this.sectionManager.updateContent(sectionId, newMarkdown); } } removeImage(sectionId) { if (confirm('Are you sure you want to remove this image?')) { this.sectionManager.updateContent(sectionId, ''); this.hideEditor(sectionId); } } createButton(text, className, handler) { const btn = document.createElement('button'); btn.textContent = text; btn.className = className; btn.style.cssText = ` background: #007bff; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; `; btn.addEventListener('click', handler); return btn; } handleAccept(event) { const sectionId = this.getCurrentEditingSectionId(event.target); if (sectionId) { this.sectionManager.acceptChanges(sectionId); } } handleCancel(event) { const sectionId = this.getCurrentEditingSectionId(event.target); if (sectionId) { this.sectionManager.cancelChanges(sectionId); } } handleReset(event) { const sectionId = this.getCurrentEditingSectionId(event.target); if (sectionId) { this.sectionManager.resetSection(sectionId); } } handleKeydown(event) { // Enhanced keyboard shortcuts for section editing const textarea = event.target.closest('textarea'); if (!textarea) return; // Find the section being edited const editorContainer = textarea.closest('.ui-edit-editor-container, .ui-edit-image-editor-container'); if (!editorContainer) return; const sectionElement = editorContainer.parentElement; const sectionId = sectionElement ? sectionElement.getAttribute('data-section-id') : null; if (!sectionId) return; // Handle keyboard shortcuts if (event.ctrlKey || event.metaKey) { switch (event.key) { case 'Enter': event.preventDefault(); this.sectionManager.acceptChanges(sectionId); debug('Keyboard shortcut: Ctrl+Enter - accepted changes for section ' + sectionId, 'KEYBOARD'); break; case 'Escape': event.preventDefault(); this.sectionManager.cancelChanges(sectionId); debug('Keyboard shortcut: Ctrl+Escape - cancelled changes for section ' + sectionId, 'KEYBOARD'); break; } } // Handle plain Escape (without Ctrl) if (event.key === 'Escape' && !event.ctrlKey && !event.metaKey) { event.preventDefault(); this.sectionManager.cancelChanges(sectionId); debug('Keyboard shortcut: Escape - cancelled changes for section ' + sectionId, 'KEYBOARD'); } } getCurrentEditingSectionId(button) { const editorContainer = button.closest('.ui-edit-editor-container'); if (!editorContainer) return null; const sectionElement = editorContainer.parentElement; return sectionElement ? sectionElement.getAttribute('data-section-id') : null; } hideEditor(sectionId) { const element = this.findSectionElement(sectionId); if (!element) return; const section = this.sectionManager.sections.get(sectionId); if (section) { this.updateSectionContent(sectionId, section.currentMarkdown); } this.editingSections.delete(sectionId); } hideCurrentEditor() { this.editingSections.forEach(sectionId => { const element = this.findSectionElement(sectionId); if (element && element.querySelector('.ui-edit-editor-container')) { this.hideEditor(sectionId); } }); } updateSectionContent(sectionId, content) { const element = this.findSectionElement(sectionId); if (!element) return; if (typeof marked !== 'undefined') { const html = marked.parse(content); const htmlWithTargetBlank = html.replace(/
]*)>/g, ''); element.innerHTML = htmlWithTargetBlank; } else { element.innerHTML = `

${content}

`; } this.setupSectionElement(element); } findSectionElement(sectionId) { return this.container.querySelector(`[data-section-id="${sectionId}"]`); } setupSectionElement(element) { element.className = 'ui-edit-section'; element.style.cssText = ` margin: 16px 0; padding: 12px; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; border: 2px solid transparent; `; element.removeEventListener('mouseenter', element._mouseenterHandler); element.removeEventListener('mouseleave', element._mouseleaveHandler); element._mouseenterHandler = () => { element.style.backgroundColor = 'rgba(0, 0, 0, 0.02)'; element.style.borderColor = 'rgba(0, 0, 0, 0.1)'; }; element._mouseleaveHandler = () => { element.style.backgroundColor = ''; element.style.borderColor = 'transparent'; }; element.addEventListener('mouseenter', element._mouseenterHandler); element.addEventListener('mouseleave', element._mouseleaveHandler); } /** * 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); } /** * Create status panel for real-time status display * @returns {HTMLElement} Status panel element */ createStatusPanel() { const panel = document.createElement('div'); panel.className = 'ui-edit-status-panel'; panel.style.cssText = ` position: fixed; top: 20px; right: 20px; background: white; border: 1px solid #ddd; border-radius: 8px; padding: 12px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; z-index: 1000; min-width: 180px; `; const statusText = document.createElement('div'); statusText.className = 'ui-edit-status-text'; statusText.textContent = 'Ready'; const detailsText = document.createElement('div'); detailsText.className = 'ui-edit-status-details'; detailsText.style.cssText = ` font-size: 12px; color: #666; margin-top: 4px; `; panel.appendChild(statusText); panel.appendChild(detailsText); return panel; } /** * Update status display with current status information * @param {Object} status - Status object from SectionManager */ updateStatusDisplay(status) { let statusPanel = document.querySelector('.ui-edit-status-panel'); if (!statusPanel) { statusPanel = this.createStatusPanel(); document.body.appendChild(statusPanel); } const statusText = statusPanel.querySelector('.ui-edit-status-text'); const detailsText = statusPanel.querySelector('.ui-edit-status-details'); // Update status text and color based on state switch (status.state) { case 'ready': statusText.textContent = '✓ Ready'; statusText.style.color = '#28a745'; break; case 'editing': statusText.textContent = '✏️ Editing'; statusText.style.color = '#007bff'; break; case 'modified': statusText.textContent = '⚠️ Modified'; statusText.style.color = '#ffc107'; break; default: statusText.textContent = 'Unknown'; statusText.style.color = '#6c757d'; } // Update details const details = []; details.push(`${status.totalSections} sections`); if (status.editingSections.length > 0) { details.push(`${status.editingSections.length} editing`); } if (status.modifiedSections > 0) { details.push(`${status.modifiedSections} modified`); } detailsText.textContent = details.join(' • '); // Update timestamp const now = new Date().toLocaleTimeString(); statusPanel.setAttribute('title', `Last update: ${now}`); } } /** * Main Editor Integration */ class MarkitectCleanEditor { constructor(markdownContent, containerElement, options = {}) { debug('10: MarkitectCleanEditor constructor called', 'EDITOR'); this.options = { theme: 'github', keyboardShortcuts: true, autosave: false, originalFilename: null, ...options }; debug('11: Creating SectionManager', 'EDITOR'); this.sectionManager = new SectionManager(); debug('12: Creating DOMRenderer', 'EDITOR'); this.domRenderer = new DOMRenderer(this.sectionManager, containerElement); this.originalMarkdown = markdownContent; this.cleanMarkdownContent = options.cleanMarkdownContent || markdownContent; this.dogtagContent = options.dogtagContent || ''; debug('13: About to call initialize()', 'EDITOR'); this.initialize(); debug('14: initialize() completed', 'EDITOR'); } initialize() { try { debug('15: Starting initialize() - markdown length: ' + this.originalMarkdown.length, 'INIT'); const sections = this.sectionManager.createSectionsFromMarkdown(this.originalMarkdown); debug('16: Created ' + sections.length + ' sections', 'INIT'); // Mark the dogtag section as protected if we have a dogtag if (this.dogtagContent) { debug('17: Marking dogtag section', 'INIT'); this.markDogtagSection(sections); } // Mark base64 reference sections as protected debug('18: Marking base64 reference sections', 'INIT'); this.markBase64ReferenceSections(sections); console.log(`✓ Initialized clean editor with ${sections.length} sections`); // Add global control panel debug('19: Adding global controls', 'INIT'); this.addGlobalControls(); // Setup status tracking debug('19.5: Setting up status tracking', 'INIT'); this.setupStatusTracking(); debug('20: Initialize completed successfully', 'INIT'); return true; } catch (error) { debug('ERROR in initialize: ' + error.message, 'ERROR'); console.error('Failed to initialize clean editor:', error); return false; } } markDogtagSection(sections) { // Find the section that contains the dogtag content const dogtagText = this.dogtagContent.trim(); if (!dogtagText) return; for (const section of sections) { if (section.currentMarkdown.includes(dogtagText)) { section.isDogtagSection = true; console.log('Marked dogtag section as protected:', section.id); break; } } } markBase64ReferenceSections(sections) { // Find sections that contain base64 image references if (!window.markitectBase64References) return; const refIds = Object.keys(window.markitectBase64References); if (refIds.length === 0) return; for (const section of sections) { const markdown = section.currentMarkdown; // Check if this section contains base64 reference syntax for (const refId of refIds) { if (markdown.includes(`[${refId}]:`)) { section.isBase64RefSection = true; console.log('Marked base64 reference section as protected:', section.id); break; } } } } addGlobalControls() { // Create a floating control panel const controlPanel = document.createElement('div'); controlPanel.id = 'markitect-global-controls'; controlPanel.style.cssText = ` position: fixed; top: 20px; right: 20px; background: rgba(248, 249, 250, 0.95); border: 1px solid #dee2e6; border-radius: 8px; padding: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000; backdrop-filter: blur(8px); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; min-width: 200px; `; const title = document.createElement('div'); title.style.cssText = ` font-weight: 600; margin-bottom: 8px; color: #495057; border-bottom: 1px solid #dee2e6; padding-bottom: 4px; `; title.textContent = 'Document Controls'; const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = ` display: flex; flex-direction: column; gap: 6px; `; // Save Document button const saveButton = document.createElement('button'); saveButton.id = 'save-document'; saveButton.textContent = '💾 Save Document'; saveButton.style.cssText = ` background: #28a745; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; transition: background-color 0.2s; `; // Reset All button const resetButton = document.createElement('button'); resetButton.id = 'reset-all'; resetButton.textContent = '🔄 Reset All'; resetButton.style.cssText = ` background: #ffc107; color: #212529; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; transition: background-color 0.2s; `; // Show Status button const statusButton = document.createElement('button'); statusButton.id = 'show-status'; statusButton.textContent = '📊 Show Status'; statusButton.style.cssText = ` background: #17a2b8; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; transition: background-color 0.2s; `; buttonContainer.appendChild(saveButton); buttonContainer.appendChild(resetButton); buttonContainer.appendChild(statusButton); controlPanel.appendChild(title); controlPanel.appendChild(buttonContainer); document.body.appendChild(controlPanel); // Add event listeners document.getElementById('save-document').addEventListener('click', () => this.saveDocument()); document.getElementById('reset-all').addEventListener('click', () => this.resetAllSections()); document.getElementById('show-status').addEventListener('click', () => this.showStatus()); } saveDocument() { try { const markdown = this.sectionManager.getDocumentMarkdown(); // Generate intelligent filename const filename = this.generateSaveFilename(); // Create a download link const blob = new Blob([markdown], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showMessage(`Document saved as ${filename}!`, 'success'); console.log('Document saved:', filename, markdown.length, 'characters'); } catch (error) { this.showMessage('Failed to save document: ' + error.message, 'error'); console.error('Save failed:', error); } } resetAllSections() { if (!confirm('Reset all sections to their original content? This will lose all changes and cannot be undone.')) { return; } try { const sections = this.sectionManager.getAllSections(); sections.forEach(section => { this.sectionManager.resetSection(section.id); }); this.showMessage('All sections reset to original content', 'info'); console.log('All sections reset'); } catch (error) { this.showMessage('Failed to reset sections: ' + error.message, 'error'); console.error('Reset failed:', error); } } showStatus() { const sections = this.sectionManager.getSectionStatus(); const totalSections = sections.length; const editedSections = sections.filter(s => s.hasChanges).length; const currentlyEditing = sections.filter(s => s.isEditing).length; const statusHtml = `

Document Status

Total Sections: ${totalSections}

Modified Sections: ${editedSections}

Currently Editing: ${currentlyEditing}


Section Details:

`; this.showModal('Document Status', statusHtml); } showMessage(message, type = 'info') { const messageDiv = document.createElement('div'); messageDiv.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: ${type === 'success' ? '#d4edda' : type === 'error' ? '#f8d7da' : '#d1ecf1'}; color: ${type === 'success' ? '#155724' : type === 'error' ? '#721c24' : '#0c5460'}; border: 1px solid ${type === 'success' ? '#c3e6cb' : type === 'error' ? '#f5c6cb' : '#bee5eb'}; border-radius: 6px; padding: 12px 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; z-index: 10001; box-shadow: 0 4px 12px rgba(0,0,0,0.15); `; messageDiv.textContent = message; document.body.appendChild(messageDiv); setTimeout(() => { if (messageDiv.parentNode) { messageDiv.parentNode.removeChild(messageDiv); } }, 3000); } showModal(title, content) { // Remove existing modal if present const existingModal = document.getElementById('markitect-modal'); if (existingModal) { existingModal.remove(); } const modalOverlay = document.createElement('div'); modalOverlay.id = 'markitect-modal'; modalOverlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10000; `; const modal = document.createElement('div'); modal.style.cssText = ` background: white; border-radius: 8px; padding: 24px; max-width: 600px; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.3); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; `; const closeBtn = document.createElement('button'); closeBtn.textContent = '×'; closeBtn.style.cssText = ` float: right; background: none; border: none; font-size: 24px; cursor: pointer; color: #6c757d; margin: -8px -8px 0 0; `; const modalContent = document.createElement('div'); modalContent.innerHTML = `

${title}

${content}`; function closeModal() { modalOverlay.remove(); } closeBtn.addEventListener('click', closeModal); modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); }); modal.appendChild(closeBtn); modal.appendChild(modalContent); modalOverlay.appendChild(modal); document.body.appendChild(modalOverlay); } getDocumentMarkdown() { return this.sectionManager.getDocumentMarkdown(); } escapeRegex(str) { return str.replace(/[.*+?^${}()|[]\]/g, '\\\\$&'); } convertDataUrlToReference(markdown) { if (!window.markitectBase64References) { return markdown; } let convertedMarkdown = markdown; Object.entries(window.markitectBase64References).forEach(([refId, refData]) => { const dataUrl = refData.full_data_url; const escapedDataUrl = this.escapeRegex(dataUrl); const dataUrlPattern = new RegExp(`!\\[([^\\]]*)\\]\\(${escapedDataUrl}\\)`, 'g'); convertedMarkdown = convertedMarkdown.replace(dataUrlPattern, `![$1][${refId}]`); }); return convertedMarkdown; } /** * Setup real-time status tracking */ setupStatusTracking() { // Listen for status updates from SectionManager this.sectionManager.on('status-updated', (status) => { this.domRenderer.updateStatusDisplay(status); }); // Start periodic status tracking this.sectionManager.startStatusTracking(2000); // Update every 2 seconds // Initial status display this.sectionManager.updateGlobalStatus(); console.log('✓ Real-time status tracking initialized'); } /** * Generate intelligent save filename using 4-method fallback system * @returns {string} Generated filename with .md extension */ generateSaveFilename() { // Method 1: Original filename from options if (this.options.originalFilename) { return this.sanitizeFilename(this.options.originalFilename); } // Method 2: Page title extraction const titleFilename = this.extractFilenameFromTitle(); if (titleFilename) { return this.sanitizeFilename(titleFilename + '.md'); } // Method 3: URL pathname analysis const urlFilename = this.extractFilenameFromUrl(); if (urlFilename) { return this.sanitizeFilename(urlFilename + '.md'); } // Method 4: First heading extraction const headingFilename = this.extractFilenameFromHeading(); if (headingFilename) { return this.sanitizeFilename(headingFilename + '.md'); } // Method 5: Timestamp generation (fallback) return this.generateTimestampFilename(); } /** * Sanitize filename to be filesystem-safe * @param {string} filename - Raw filename * @returns {string} Sanitized filename */ sanitizeFilename(filename) { if (!filename) return 'document.md'; // Remove or replace filesystem-unsafe characters let sanitized = filename .replace(/[\/\\:*?"<>|]/g, '-') // Replace unsafe chars with dashes .replace(/\s+/g, '-') // Replace spaces with dashes .replace(/-+/g, '-') // Replace multiple dashes with single dash .replace(/^-|-$/g, '') // Remove leading/trailing dashes .trim(); // Ensure it ends with .md if (!sanitized.endsWith('.md')) { sanitized += '.md'; } // Ensure it's not empty if (sanitized === '.md') { sanitized = 'document.md'; } return sanitized; } /** * Extract filename from page title * @returns {string|null} Filename or null if not suitable */ extractFilenameFromTitle() { if (typeof document === 'undefined' || !document.title) { return null; } let title = document.title.trim(); // Remove common website suffixes title = title .split('|')[0] // Remove " | Website" .split('-')[0] // Remove " - Website" .split('•')[0] // Remove " • Website" .trim(); if (title.length < 3 || title.length > 100) { return null; } return title; } /** * Extract filename from URL pathname * @returns {string|null} Filename or null if not suitable */ extractFilenameFromUrl() { if (typeof window === 'undefined' || !window.location) { return null; } const pathname = window.location.pathname; if (!pathname || pathname === '/') { return null; } // Get the last segment of the path const segments = pathname.split('/').filter(s => s.length > 0); if (segments.length === 0) { return null; } const lastSegment = segments[segments.length - 1]; // Remove file extensions const filenameBase = lastSegment.replace(/\.[^.]*$/, ''); if (filenameBase.length < 3 || filenameBase.length > 100) { return null; } return filenameBase; } /** * Extract filename from first heading in markdown * @returns {string|null} Filename or null if no heading found */ extractFilenameFromHeading() { if (!this.originalMarkdown) { return null; } const lines = this.originalMarkdown.split('\n'); // Find first heading line for (const line of lines) { const trimmed = line.trim(); if (/^#{1,6}\s/.test(trimmed)) { // Extract heading text (remove # symbols and trim) const headingText = trimmed.replace(/^#{1,6}\s*/, '').trim(); if (headingText.length >= 3 && headingText.length <= 100) { return headingText; } } } return null; } /** * Generate timestamp-based filename as final fallback * @returns {string} Timestamp-based filename */ generateTimestampFilename() { const now = new Date(); const timestamp = now.getFullYear().toString() + (now.getMonth() + 1).toString().padStart(2, '0') + now.getDate().toString().padStart(2, '0') + '-' + now.getHours().toString().padStart(2, '0') + now.getMinutes().toString().padStart(2, '0'); return `document-${timestamp}.md`; } /** * Cleanup method to stop status tracking */ destroy() { if (this.sectionManager) { this.sectionManager.stopStatusTracking(); } } } // Initialize the clean editor system let markitectCleanEditor; function initializeCleanEditor() { debug('1: initializeCleanEditor called', 'INIT'); const container = document.getElementById('markdown-content'); if (!container) { debug('2: FAILED - Markdown content container not found', 'ERROR'); return; } debug('3: Container found', 'INIT'); if (typeof window.MarkitectEditor === 'undefined') { debug('4: FAILED - MarkitectEditor not found', 'ERROR'); return; } debug('5: MarkitectEditor found', 'INIT'); debug('6: Creating editor with content length: ' + markdownContentWithDogtag.length, 'INIT'); try { markitectCleanEditor = new window.MarkitectEditor.MarkitectCleanEditor(markdownContentWithDogtag, container, { cleanMarkdownContent: markdownContent, dogtagContent: dogtagContent }); debug('7: Editor instance created', 'INIT'); window.markitectCleanEditor = markitectCleanEditor; // Make globally available debug('8: Editor made globally available', 'INIT'); const contentAfterInit = container.innerHTML; debug('9: Container has content: ' + (contentAfterInit.length > 0 ? 'YES (' + contentAfterInit.length + ' chars)' : 'NO'), 'INIT'); console.log('✅ Clean section editor initialized successfully'); } catch (error) { debug('ERROR in editor creation: ' + error.message, 'ERROR'); throw error; } } // Document scroll indicators function initializeScrollIndicators() { console.log('✅ Document scroll indicators initialized'); } // Export for module systems if (typeof module !== 'undefined' && module.exports) { module.exports = { EditState, SectionType, Section, SectionManager, DOMRenderer, MarkitectCleanEditor }; global.EditState = EditState; global.SectionType = SectionType; global.Section = Section; } else { window.MarkitectEditor = { EditState, SectionType, Section, SectionManager, DOMRenderer, MarkitectCleanEditor }; window.EditState = EditState; window.SectionType = SectionType; window.Section = Section; }