diff --git a/markitect/clean_document_manager.py b/markitect/clean_document_manager.py new file mode 100644 index 00000000..85cb01f5 --- /dev/null +++ b/markitect/clean_document_manager.py @@ -0,0 +1,1430 @@ +""" +Clean Document Manager - Simplified version with only clean editor support +""" +import json +import re +from pathlib import Path +from typing import Dict, Any, Optional + + +class CleanDocumentManager: + """ + Simplified document manager that only supports the clean editor implementation. + All legacy code has been removed for clarity and maintainability. + """ + + def __init__(self, db_manager=None): + self.db_manager = db_manager + + def render_file(self, input_file: str, output_file: str, template: str = None, css: str = None, + edit_mode: bool = False, editor_theme: str = 'github', keyboard_shortcuts: bool = True) -> Dict[str, Any]: + """ + Render a markdown file to HTML with optional clean editing capabilities. + """ + input_path = Path(input_file) + output_path = Path(output_file) + + if not input_path.exists(): + raise FileNotFoundError(f"Input file not found: {input_file}") + + # Read markdown content + markdown_content = input_path.read_text(encoding='utf-8') + + # Extract title from markdown (first h1 heading) + title = self._extract_title_from_markdown(markdown_content) + + # Get original filename without extension + original_filename = input_path.stem + + # Get version information + version_info = self._get_version_info() + + # Generate HTML content + html_content = self._generate_html_template( + markdown_content=markdown_content, + title=title, + css=css, + template=template, + edit_mode=edit_mode, + editor_theme=editor_theme, + keyboard_shortcuts=keyboard_shortcuts, + original_filename=original_filename, + version_info=version_info + ) + + # Write HTML file + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(html_content, encoding='utf-8') + + return { + 'success': True, + 'input_file': str(input_path), + 'output_file': str(output_path), + 'edit_mode': edit_mode, + 'editor_theme': editor_theme + } + + def _extract_title_from_markdown(self, markdown_content: str) -> str: + """Extract title from first h1 heading in markdown.""" + match = re.search(r'^#\s+(.+)', markdown_content, re.MULTILINE) + if match: + return match.group(1).strip() + return "Markdown Document" + + def _get_version_info(self) -> dict: + """Get repository name and version information.""" + version_info = { + 'repo_name': 'Markitect', + 'version': '0.3.0', + 'git_info': '' + } + + try: + # Try to get version from package metadata + from importlib.metadata import version as get_version + version_info['version'] = get_version('markitect') + except Exception: + pass + + try: + # Try to get git information + import subprocess + from pathlib import Path + + # Get git commit hash and status + try: + git_hash = subprocess.check_output( + ['git', 'rev-parse', '--short', 'HEAD'], + cwd=Path(__file__).parent, + stderr=subprocess.DEVNULL + ).decode().strip() + + # Check if there are uncommitted changes + try: + subprocess.check_output( + ['git', 'diff-index', '--quiet', 'HEAD', '--'], + cwd=Path(__file__).parent, + stderr=subprocess.DEVNULL + ) + git_status = '' + except subprocess.CalledProcessError: + git_status = '-modified' + + version_info['git_info'] = f" (git:{git_hash}{git_status})" + except (subprocess.CalledProcessError, FileNotFoundError): + pass + except Exception: + pass + + return version_info + + def _generate_html_template(self, markdown_content: str, title: str, css: str = None, template: str = None, + edit_mode: bool = False, editor_theme: str = 'github', keyboard_shortcuts: bool = True, original_filename: str = 'document', version_info: dict = None) -> str: + """Generate clean HTML template.""" + + # Escape the markdown content for JavaScript + js_markdown_content = json.dumps(markdown_content) + + # Handle CSS styles + css_content = "" + if css: + try: + css_path = Path(css) + if css_path.exists(): + css_file_content = css_path.read_text(encoding='utf-8') + css_content = f"" + else: + css_content = f'' + except Exception: + css_content = f'' + + # Default CSS for basic styling + default_css = """ + + """ + + # Load clean editor JavaScript files + editor_scripts = "" + editor_config = "" + body_classes = "" + + if edit_mode: + body_classes = ' class="markitect-edit-mode"' + + # Configuration for clean editor + version_str = f"{version_info['repo_name']} v{version_info['version']}{version_info['git_info']}" if version_info else "Markitect v0.3.0" + editor_config = f""" + const MARKITECT_EDIT_MODE = true; + const MARKITECT_EDITOR_CONFIG = {{ + theme: '{editor_theme}', + keyboardShortcuts: {str(keyboard_shortcuts).lower()}, + autosave: false, + sections: true, + originalFilename: '{original_filename}', + version: '{version_str}', + repoName: '{version_info['repo_name'] if version_info else 'Markitect'}' + }}; + + // Make config available globally + window.editorConfig = MARKITECT_EDITOR_CONFIG;""" + + # Load clean editor architecture + editor_scripts = self._get_clean_editor_scripts() + + # Generate the complete HTML template + html_template = f""" + + + + + {title} + {css_content} + {default_css} + + + + + +
+ + + +""" + + return html_template + + def _get_clean_editor_scripts(self) -> str: + """Get the complete clean editor JavaScript code.""" + return """ + // 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. + */ + +// Enums for clear state management +const EditState = Object.freeze({ + ORIGINAL: 'original', + EDITING: 'editing', + MODIFIED: 'modified', + SAVED: 'saved' +}); + +const SectionType = Object.freeze({ + HEADING: 'heading', + PARAGRAPH: 'paragraph', + LIST: 'list', + CODE: 'code', + BLOCKQUOTE: 'blockquote' +}); + +/** + * Section class - Core business logic for a single editable section + */ +class Section { + constructor(id, originalMarkdown, sectionType = SectionType.PARAGRAPH) { + this.id = id; + this.originalMarkdown = originalMarkdown; + this.currentMarkdown = originalMarkdown; + this.editingMarkdown = null; + this.pendingMarkdown = null; + this.sectionType = sectionType; + this.state = EditState.ORIGINAL; + this.domElement = null; + this.lastSaved = null; + this.created = new Date(); + } + + 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; + } + } + + resetToOriginal() { + this.currentMarkdown = this.originalMarkdown; + this.editingMarkdown = null; + this.pendingMarkdown = null; + this.lastSaved = null; + this.state = EditState.ORIGINAL; + return this.originalMarkdown; + } + + 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; + } + + hasChanges() { + return this.currentMarkdown !== this.originalMarkdown; + } + + isEditing() { + return this.state === EditState.EDITING; + } + + getStatus() { + return { + id: this.id, + state: this.state, + hasChanges: this.hasChanges(), + isEditing: this.isEditing(), + contentLength: this.currentMarkdown.length, + lastSaved: this.lastSaved, + sectionType: this.sectionType + }; + } + + static generateId(content, position) { + const str = content.substring(0, 100) + position.toString(); + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return `section_${Math.abs(hash)}_${position}`; + } + + static detectType(markdown) { + const trimmed = markdown.trim(); + if (trimmed.startsWith('#')) return SectionType.HEADING; + if (trimmed.startsWith('```')) return SectionType.CODE; + if (trimmed.startsWith('>')) return SectionType.BLOCKQUOTE; + if (trimmed.startsWith('-') || trimmed.startsWith('*') || /^\\d+\\./.test(trimmed)) { + return SectionType.LIST; + } + return SectionType.PARAGRAPH; + } +} + +/** + * SectionManager class - Manages the collection of sections + */ +class SectionManager { + constructor() { + this.sections = new Map(); + // Note: Removed single editingSection tracking to allow multiple concurrent edits + this.listeners = new Map(); + } + + 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`); + } + + // Check if section is already being edited + if (section.isEditing()) { + console.log('Section already in editing state:', sectionId); + return section.editingMarkdown; + } + + const content = section.startEdit(); + // Note: No longer tracking single editingSection - allowing multiple + 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`); + } + + // Check if the edited content contains new headings that would create splits + const newContent = section.editingMarkdown; + const originalContent = section.originalMarkdown; + const shouldSplit = this.checkForSectionSplits(newContent, originalContent); + + if (shouldSplit) { + // Handle section splitting + this.handleSectionSplit(sectionId, newContent); + } else { + // Normal accept without splitting + const content = section.acceptChanges(); + // Note: No longer tracking single editingSection + this.emit('changes-accepted', { sectionId, content, section: section.getStatus() }); + } + + return section.currentMarkdown; + } + + checkForSectionSplits(content, originalContent) { + if (!content) return false; + + // Split by lines and check for headings + const lines = content.split('\\n'); + const originalLines = originalContent ? originalContent.split('\\n') : []; + + let newHeadingCount = 0; + let originalHeadingCount = 0; + + // Count headings in new content + for (const line of lines) { + if (/^#{1,6}\\s/.test(line.trim())) { + newHeadingCount++; + } + } + + // Count headings in original content + for (const line of originalLines) { + if (/^#{1,6}\\s/.test(line.trim())) { + originalHeadingCount++; + } + } + + // Split if: + // 1. We have multiple headings now, OR + // 2. We added headings where there were none before, OR + // 3. We have more headings than we started with + return newHeadingCount > 1 || + (originalHeadingCount === 0 && newHeadingCount > 0) || + newHeadingCount > originalHeadingCount; + } + + handleSectionSplit(originalSectionId, content) { + console.log('Splitting section:', originalSectionId); + + const originalSection = this.sections.get(originalSectionId); + if (!originalSection) return; + + // Accept the current changes first + originalSection.acceptChanges(); + + // Split the content into new sections + const newSections = this.createSectionsFromContent(content, originalSectionId); + + // Get all sections as an ordered array to maintain document order + const allSectionsArray = Array.from(this.sections.values()); + const originalIndex = allSectionsArray.findIndex(s => s.id === originalSectionId); + + // Clear the sections map and rebuild it with proper order + this.sections.clear(); + + // Add sections before the original + for (let i = 0; i < originalIndex; i++) { + const section = allSectionsArray[i]; + this.sections.set(section.id, section); + } + + // Add the new split sections + newSections.forEach(section => { + this.sections.set(section.id, section); + }); + + // Add sections after the original + for (let i = originalIndex + 1; i < allSectionsArray.length; i++) { + const section = allSectionsArray[i]; + this.sections.set(section.id, section); + } + + // Note: No longer tracking single editingSection + + // Emit event to trigger UI re-render + this.emit('section-split', { + originalSectionId, + newSections: newSections.map(s => s.getStatus()), + allSections: Array.from(this.sections.values()) + }); + } + + createSectionsFromContent(content, baseSectionId) { + const lines = content.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.trim()); + + if (isHeading) { + // When we encounter a heading, complete any previous section + if (currentSection.trim()) { + const sectionId = `${baseSectionId}_split_${position}`; + const sectionType = Section.detectType(currentSection); + const section = new Section(sectionId, currentSection.trim(), sectionType); + sections.push(section); + position++; + } + // Start new section with this heading + currentSection = line; + } else { + // Add content to current section + if (currentSection) currentSection += '\\n'; + currentSection += line; + } + } + + // Add the final section if it has content + if (currentSection.trim()) { + const sectionId = `${baseSectionId}_split_${position}`; + const sectionType = Section.detectType(currentSection); + const section = new Section(sectionId, currentSection.trim(), sectionType); + sections.push(section); + } + + return sections; + } + + cancelChanges(sectionId) { + const section = this.sections.get(sectionId); + if (!section) { + throw new Error(`Section ${sectionId} not found`); + } + const content = section.cancelChanges(); + // Note: No longer tracking single editingSection + this.emit('changes-cancelled', { sectionId, content, section: section.getStatus() }); + return content; + } + + resetToOriginal(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; + } + + stopEditing(sectionId) { + const section = this.sections.get(sectionId); + if (!section) { + throw new Error(`Section ${sectionId} not found`); + } + + const newState = section.stopEditing(); + // Note: No longer tracking single editingSection + + this.emit('edit-stopped', { sectionId, newState, section: section.getStatus() }); + return newState; + } + + getAllSections() { + return Array.from(this.sections.values()); + } + + getDocumentMarkdown() { + return this.getAllSections() + .map(section => section.currentMarkdown) + .join('\\n\\n'); + } +} + +/** + * DOM Renderer - Handles DOM interactions + */ +class DOMRenderer { + constructor(sectionManager, containerElement) { + this.sectionManager = sectionManager; + this.container = containerElement; + // Note: Removed single currentSection tracking to allow multiple concurrent edits + this.editingSections = new Set(); // Track multiple editing sections + + 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); + // Don't update content - let pending changes remain + }); + 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); + }); + this.sectionManager.on('section-split', (data) => { + console.log('Handling section split in UI'); + this.handleSectionSplit(data); + }); + } + + renderAllSections(sections) { + this.container.innerHTML = ''; + sections.forEach(section => { + const element = this.createSectionElement(section); + section.domElement = element; + this.container.appendChild(element); + }); + this.container.addEventListener('click', this.handleSectionClick); + } + + createSectionElement(section) { + const element = document.createElement('div'); + element.setAttribute('data-section-id', section.id); + + if (typeof marked !== 'undefined') { + element.innerHTML = marked.parse(section.currentMarkdown); + } else { + element.innerHTML = `

${section.currentMarkdown}

`; + } + + // Setup styling and event handlers + this.setupSectionElement(element); + + return element; + } + + handleSectionClick(event) { + // Don't handle clicks on form elements or buttons + if (event.target.closest('textarea, button, input')) { + return; + } + + const sectionElement = event.target.closest('.markitect-section-editable'); + if (!sectionElement) return; + + const sectionId = sectionElement.getAttribute('data-section-id'); + 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 editorContainer = document.createElement('div'); + editorContainer.style.cssText = ` + display: flex; + gap: 12px; + align-items: flex-start; + width: 100%; + `; + + const textarea = document.createElement('textarea'); + textarea.value = content; + textarea.style.cssText = ` + flex: 1; + min-height: 100px; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + border: 2px solid #007acc; + border-radius: 6px; + padding: 12px; + font-size: 14px; + line-height: 1.5; + resize: vertical; + `; + + textarea.addEventListener('input', () => { + this.sectionManager.updateContent(sectionId, textarea.value); + }); + textarea.addEventListener('keydown', this.handleKeydown); + + const controls = document.createElement('div'); + controls.style.cssText = ` + display: flex; + flex-direction: column; + gap: 6px; + `; + + const createButton = (text, color, handler) => { + const btn = document.createElement('button'); + btn.textContent = text; + btn.style.cssText = ` + padding: 8px 12px; + border: none; + border-radius: 4px; + color: white; + background: ${color}; + cursor: pointer; + font-size: 12px; + min-width: 70px; + `; + btn.addEventListener('click', handler); + return btn; + }; + + controls.appendChild(createButton('✓ Accept', '#4caf50', () => this.handleAccept(sectionId))); + controls.appendChild(createButton('✗ Cancel', '#f44336', () => this.handleCancel(sectionId))); + controls.appendChild(createButton('🔄 Reset', '#ff9800', () => this.handleReset(sectionId))); + + editorContainer.appendChild(textarea); + editorContainer.appendChild(controls); + + element.innerHTML = ''; + element.appendChild(editorContainer); + + textarea.focus(); + // Track this section as being edited + this.editingSections.add(sectionId); + } + + hideCurrentEditor() { + // This method is no longer needed since we support multiple editors + // Individual editors are hidden via hideEditor(sectionId) + } + + hideEditor(sectionId) { + // Remove from editing sections set + this.editingSections.delete(sectionId); + + // Force re-render the section to ensure it displays correctly + const section = this.sectionManager.sections.get(sectionId); + if (section) { + this.updateSectionContent(sectionId, section.currentMarkdown); + } + } + + updateSectionContent(sectionId, content) { + const element = this.findSectionElement(sectionId); + if (!element) return; + + if (typeof marked !== 'undefined') { + element.innerHTML = marked.parse(content); + } else { + element.innerHTML = `

${content}

`; + } + + // Restore the section styling and click behavior + this.setupSectionElement(element); + } + + setupSectionElement(element) { + element.className = 'markitect-section-editable'; + element.style.cssText = ` + margin: 16px 0; + padding: 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + border: 2px solid transparent; + `; + + // Remove any existing event listeners to avoid duplicates + element.removeEventListener('mouseenter', element._mouseenterHandler); + element.removeEventListener('mouseleave', element._mouseleaveHandler); + + // Create new handlers and store references + element._mouseenterHandler = () => { + element.style.backgroundColor = 'rgba(33, 150, 243, 0.05)'; + element.style.borderColor = 'rgba(33, 150, 243, 0.2)'; + }; + + element._mouseleaveHandler = () => { + element.style.backgroundColor = ''; + element.style.borderColor = 'transparent'; + }; + + element.addEventListener('mouseenter', element._mouseenterHandler); + element.addEventListener('mouseleave', element._mouseleaveHandler); + } + + updateTextareaContent(content, sectionId) { + // Find the specific textarea for this section + const element = this.findSectionElement(sectionId); + if (element) { + const textarea = element.querySelector('textarea'); + if (textarea) { + textarea.value = content; + } + } + } + + handleSectionSplit(data) { + // Clear the editor state for the original section + this.editingSections.delete(data.originalSectionId); + + // Find the original section element and its position + const originalElement = this.findSectionElement(data.originalSectionId); + if (!originalElement) { + console.error('Original section element not found'); + return; + } + + // Get the position of the original element + const originalPosition = Array.from(this.container.children).indexOf(originalElement); + + // Create new section elements for the split sections + const newElements = []; + data.newSections.forEach(sectionData => { + const section = this.sectionManager.sections.get(sectionData.id); + if (section) { + const element = this.createSectionElement(section); + section.domElement = element; + newElements.push(element); + } + }); + + // Remove the original element + originalElement.remove(); + + // Insert new elements at the original position + if (originalPosition < this.container.children.length) { + const nextElement = this.container.children[originalPosition]; + newElements.forEach(element => { + this.container.insertBefore(element, nextElement); + }); + } else { + // If original was at the end, just append + newElements.forEach(element => { + this.container.appendChild(element); + }); + } + + // Show success message + console.log(`Section split into ${data.newSections.length} sections`); + + // Notify the main editor about the split + if (window.markitectCleanEditor) { + window.markitectCleanEditor.showMessage( + `✂️ Section split into ${data.newSections.length} sections!`, + 'success' + ); + } + } + + findSectionElement(sectionId) { + return this.container.querySelector(`[data-section-id="${sectionId}"]`); + } + + handleAccept(sectionId) { + try { + console.log('Accepting changes for section:', sectionId); + this.sectionManager.acceptChanges(sectionId); + console.log('Changes accepted successfully'); + } catch (error) { + console.error('Failed to accept changes:', error); + } + } + + handleCancel(sectionId) { + try { + console.log('Canceling changes for section:', sectionId); + this.sectionManager.cancelChanges(sectionId); + console.log('Changes canceled successfully'); + } catch (error) { + console.error('Failed to cancel changes:', error); + } + } + + handleReset(sectionId) { + try { + this.sectionManager.resetToOriginal(sectionId); + } catch (error) { + console.error('Failed to reset section:', error); + } + } + + 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); + } + } +} + +/** + * Main Editor Integration + */ +class MarkitectCleanEditor { + constructor(markdownContent, containerElement, options = {}) { + this.options = { + theme: 'github', + keyboardShortcuts: true, + autosave: false, + ...options + }; + + this.sectionManager = new SectionManager(); + this.domRenderer = new DOMRenderer(this.sectionManager, containerElement); + this.originalMarkdown = markdownContent; + this.initialize(); + } + + initialize() { + try { + const sections = this.sectionManager.createSectionsFromMarkdown(this.originalMarkdown); + console.log(`✓ Initialized clean editor with ${sections.length} sections`); + + // Add global control panel + this.addGlobalControls(); + + return true; + } catch (error) { + console.error('Failed to initialize clean editor:', error); + return false; + } + } + + addGlobalControls() { + // Create floating control panel + const panel = document.createElement('div'); + panel.id = 'markitect-global-controls'; + panel.innerHTML = ` +
+

📝 Editor

+
Ready
+
+
+ + + +
+ `; + + // Style the panel + panel.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: white; + border: 2px solid #007acc; + border-radius: 8px; + padding: 16px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 1000; + font-family: system-ui, -apple-system, sans-serif; + min-width: 200px; + max-width: 250px; + `; + + // Add internal styling + const style = document.createElement('style'); + style.textContent = ` + #markitect-global-controls .control-header h3 { + margin: 0 0 8px 0; + font-size: 16px; + color: #007acc; + } + #markitect-global-controls .control-status { + font-size: 12px; + color: #666; + margin-bottom: 12px; + } + #markitect-global-controls .control-btn { + display: block; + width: 100%; + margin: 6px 0; + padding: 10px 12px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s; + } + #markitect-global-controls .control-btn.primary { + background: #007acc; + color: white; + } + #markitect-global-controls .control-btn.primary:hover { + background: #005a9f; + } + #markitect-global-controls .control-btn.warning { + background: #ff9800; + color: white; + } + #markitect-global-controls .control-btn.warning:hover { + background: #f57c00; + } + #markitect-global-controls .control-btn.secondary { + background: #6c757d; + color: white; + } + #markitect-global-controls .control-btn.secondary:hover { + background: #545b62; + } + `; + document.head.appendChild(style); + + document.body.appendChild(panel); + + // 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()); + + // Update status periodically + this.statusInterval = setInterval(() => this.updateGlobalStatus(), 2000); + } + + updateGlobalStatus() { + const statusEl = document.getElementById('editor-status'); + if (!statusEl) return; + + const sections = this.sectionManager.getAllSections(); + const modified = sections.filter(s => s.hasChanges()).length; + const editing = sections.filter(s => s.isEditing()).length; + + if (editing > 0) { + statusEl.textContent = `Editing ${editing} section${editing !== 1 ? 's' : ''}`; + statusEl.style.color = '#007acc'; + } else if (modified > 0) { + statusEl.textContent = `${modified} section${modified !== 1 ? 's' : ''} modified`; + statusEl.style.color = '#ff9800'; + } else { + statusEl.textContent = 'All sections saved'; + statusEl.style.color = '#28a745'; + } + } + + saveDocument() { + const markdown = this.getDocumentMarkdown(); + const blob = new Blob([markdown], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + + // Generate intelligent filename + const filename = this.generateSaveFilename(); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log('📄 Document saved as:', filename); + this.showMessage(`Document saved as: ${filename}`, 'success'); + } + + generateSaveFilename() { + // Try to get original filename from config + let baseName = 'document'; + + // Method 1: Use original filename from config if available + if (typeof MARKITECT_EDITOR_CONFIG !== 'undefined' && MARKITECT_EDITOR_CONFIG.originalFilename) { + baseName = MARKITECT_EDITOR_CONFIG.originalFilename; + } + + // Method 2: Try to extract from page title + if (baseName === 'document') { + const title = document.title; + if (title && title !== 'Markdown Document' && !title.includes('Debug') && !title.includes('Test')) { + baseName = title.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') // Remove special chars + .replace(/\s+/g, '-') // Replace spaces with dashes + .replace(/-+/g, '-') // Collapse multiple dashes + .replace(/^-|-$/g, ''); // Remove leading/trailing dashes + } + } + + // Method 3: Try to extract from URL pathname + if (baseName === 'document') { + const urlPath = window.location.pathname; + const match = urlPath.match(/\/([^\/]+)\.html?$/); + if (match) { + const urlBaseName = match[1]; + if (!urlBaseName.includes('debug') && !urlBaseName.includes('test')) { + baseName = urlBaseName.replace(/_/g, '-'); + } + } + } + + // Method 4: Try to extract from first heading + if (baseName === 'document') { + const firstHeading = this.sectionManager.getAllSections() + .find(section => section.sectionType === 'heading'); + if (firstHeading) { + baseName = firstHeading.originalMarkdown + .replace(/^#+\s*/, '') // Remove markdown heading syntax + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 30); // Limit length + } + } + + // Generate timestamp + const now = new Date(); + const timestamp = now.toISOString() + .replace(/T/, '-') + .replace(/:/g, '-') + .replace(/\.\d{3}Z$/, ''); + + // Check if there are modifications + const hasModifications = this.sectionManager.getAllSections() + .some(section => section.hasChanges()); + + if (hasModifications) { + return `${baseName}-edited-${timestamp}.md`; + } else { + return `${baseName}-${timestamp}.md`; + } + } + + resetAllSections() { + if (confirm('Reset all content to original markdown? This will lose all edits and remove split sections.')) { + // Clear the section manager completely + this.sectionManager.sections.clear(); + // Note: No longer tracking single editingSection + + // Recreate sections from original markdown + const sections = this.sectionManager.createSectionsFromMarkdown(this.originalMarkdown); + + console.log('🔄 All sections reset to original structure'); + this.showMessage('Document reset to original structure', 'info'); + } + } + + showStatus() { + const sections = this.sectionManager.getAllSections(); + const total = sections.length; + const modified = sections.filter(s => s.hasChanges()).length; + const editing = sections.filter(s => s.isEditing()).length; + + // Get the actual save filename that will be used + const saveFilename = this.generateSaveFilename(); + + const message = `${window.editorConfig.repoName} ${window.editorConfig.version} +Save file: ${saveFilename} +Source: ${window.editorConfig.originalFilename} +${window.location.protocol}//${window.location.host}${window.location.pathname} + +Document Status: +• Total sections: ${total} +• Modified sections: ${modified} +• Currently editing: ${editing} +• Unsaved changes: ${modified > 0 || editing > 0 ? 'Yes' : 'No'} + +SECTION BEHAVIOR: +• Each section is a logical unit (heading + content until next heading) +• Content with line breaks stays in one section +• To split content: Create new headings (# ## ###) +• Sections don't auto-split on line breaks + +EDITING CONTROLS: +• Click any section to edit its content +• Accept (✓) - Save changes to that section +• Cancel (✗) - Discard changes, return to previous state +• Reset (🔄) - Restore original content for that section +• Save Document - Download all current content +• Reset All - Restore entire document to original state`; + + alert(message); + } + + showMessage(message, type = 'info') { + const messageDiv = document.createElement('div'); + messageDiv.textContent = message; + + const colors = { + 'success': '#28a745', + 'error': '#dc3545', + 'info': '#007acc' + }; + + messageDiv.style.cssText = ` + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: ${colors[type] || colors.info}; + color: white; + padding: 12px 20px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + z-index: 10001; + font-size: 14px; + max-width: 400px; + text-align: center; + `; + + document.body.appendChild(messageDiv); + + setTimeout(() => { + if (messageDiv.parentNode) { + messageDiv.parentNode.removeChild(messageDiv); + } + }, 3000); + } + + getDocumentMarkdown() { + return this.sectionManager.getDocumentMarkdown(); + } +} + +// Initialize the clean editor system +let markitectCleanEditor; + +function initializeCleanEditor() { + const container = document.getElementById('markdown-content'); + if (!container) { + console.error('Markdown content container not found'); + return; + } + + if (typeof window.MarkitectEditor === 'undefined') { + console.error('MarkitectEditor not found'); + return; + } + + markitectCleanEditor = new window.MarkitectEditor.MarkitectCleanEditor(markdownContent, container); + window.markitectCleanEditor = markitectCleanEditor; // Make globally available + console.log('✅ Clean section editor initialized successfully'); +} + +// Export for testing and usage +if (typeof module !== 'undefined' && module.exports) { + module.exports = { Section, SectionManager, DOMRenderer, MarkitectCleanEditor }; +} else { + window.MarkitectEditor = { Section, SectionManager, DOMRenderer, MarkitectCleanEditor }; +} + """ \ No newline at end of file diff --git a/markitect/cli.py b/markitect/cli.py index e7a4d033..9ae11195 100644 --- a/markitect/cli.py +++ b/markitect/cli.py @@ -101,7 +101,7 @@ def detect_execution_mode(): def should_use_associated_files(): """Determine if commands should use associated files behavior.""" return detect_execution_mode() == 'interactive' -from .document_manager import DocumentManager +# DocumentManager removed - using CleanDocumentManager directly in commands from .serializer import ASTSerializer from .cache_service import CacheDirectoryService from .ast_service import ASTService diff --git a/markitect/plugins/builtin/markdown_commands.py b/markitect/plugins/builtin/markdown_commands.py index ca33248c..502e3fcd 100644 --- a/markitect/plugins/builtin/markdown_commands.py +++ b/markitect/plugins/builtin/markdown_commands.py @@ -16,7 +16,7 @@ from typing import Dict, Any from markitect.plugins.base import CommandPlugin, PluginMetadata, PluginType from markitect.plugins.decorators import register_plugin -from markitect.document_manager import DocumentManager +# DocumentManager removed - using CleanDocumentManager directly from markitect.serializer import ASTSerializer @@ -1659,7 +1659,7 @@ def md_list_command(ctx, output_format, names_only): @click.option('--css', type=click.Path(), help='Custom CSS file to include') @click.option('--edit', is_flag=True, - help='Open in live edit mode with preview') + help='Open in interactive edit mode with stable section editing') @click.option('--editor-theme', default='github', type=click.Choice(['github', 'monokai', 'tomorrow', 'dark']), help='Editor theme for live edit mode (default: github)') @@ -1704,17 +1704,20 @@ def md_render_command(ctx, input_file, output, template, css, edit, editor_theme ensure_publication_directory(pub_dir) output_path = pub_dir / get_output_filename(input_path) - # Initialize document manager - doc_manager = DocumentManager(config.get('db_manager')) + # Initialize clean document manager + from markitect.clean_document_manager import CleanDocumentManager + doc_manager = CleanDocumentManager(config.get('db_manager')) # Render the file if edit: - # Live edit mode - generate HTML with editing capabilities + # Edit mode - generate HTML with editing capabilities result = doc_manager.render_file(input_file, str(output_path), template=template, css=css, - edit_mode=True, editor_theme=editor_theme, + edit_mode=True, + editor_theme=editor_theme, keyboard_shortcuts=keyboard_shortcuts) - click.echo(f"✓ Rendered with editing capabilities to: {output_path}") + + click.echo(f"✓ Rendered with interactive editing capabilities to: {output_path}") if config.get('verbose', False): click.echo(f"Editor theme: {editor_theme}")