/** * 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 * * Responsibilities: * - Track original and current content * - Manage edit state transitions * - Validate content changes * - Generate stable identifiers */ class Section { constructor(id, originalMarkdown, sectionType = SectionType.PARAGRAPH) { this.id = id; this.originalMarkdown = originalMarkdown; // Never changes - always original from render this.currentMarkdown = originalMarkdown; // Current "saved" state this.editingMarkdown = null; // Content being edited (only during editing) this.pendingMarkdown = null; // Unsaved pending changes (when not actively editing) this.sectionType = sectionType; this.state = EditState.ORIGINAL; this.domElement = null; // Will be set by DOM renderer this.lastSaved = null; this.created = new Date(); } /** * Start editing this section * @returns {string} The markdown content to populate the editor with */ startEdit() { if (this.state === EditState.EDITING) { throw new Error(`Section ${this.id} is already being edited`); } // Use pending changes if they exist, otherwise use current content this.editingMarkdown = this.pendingMarkdown || this.currentMarkdown; this.state = EditState.EDITING; return this.editingMarkdown; } /** * Update the content during editing * @param {string} markdown - The new markdown content */ updateContent(markdown) { if (this.state !== EditState.EDITING) { throw new Error(`Section ${this.id} is not in editing state`); } this.editingMarkdown = markdown; // State remains EDITING; we don't change it until an action is taken } /** * Accept the current changes and save them * @returns {string} The saved markdown content */ acceptChanges() { if (this.state !== EditState.EDITING) { throw new Error(`Section ${this.id} is not in editing state`); } // Make the edited content the new current content this.currentMarkdown = this.editingMarkdown; this.editingMarkdown = null; this.pendingMarkdown = null; // Clear any pending changes // Set to SAVED to indicate this section has been explicitly saved this.state = EditState.SAVED; this.lastSaved = new Date(); return this.currentMarkdown; } /** * Cancel editing and revert to state before editing started * @returns {string} The content that was current before editing */ cancelChanges() { if (this.state !== EditState.EDITING) { throw new Error(`Section ${this.id} is not in editing state`); } // Discard editing content and return to the state before editing started this.editingMarkdown = null; // Keep any existing pending changes from before this edit session // Return to the appropriate state if (this.pendingMarkdown !== null) { this.state = EditState.MODIFIED; // Has pending changes 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; } } /** * Reset content to original state from render time * Can be called in any state; returns section to completely original state * @returns {string} The original markdown content */ resetToOriginal() { // Reset everything to original state from render time this.currentMarkdown = this.originalMarkdown; this.editingMarkdown = null; this.pendingMarkdown = null; // Clear pending changes this.lastSaved = null; // Clear save timestamp this.state = EditState.ORIGINAL; return this.originalMarkdown; } /** * Stop editing without saving (preserves pending changes as modified) * @returns {EditState} The new state */ 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; } /** * Check if the section has unsaved changes * @returns {boolean} */ hasChanges() { return this.currentMarkdown !== this.originalMarkdown; } /** * Check if the section is currently being edited * @returns {boolean} */ isEditing() { return this.state === EditState.EDITING; } /** * Check if the section has been modified but not saved * @returns {boolean} */ isModified() { return this.state === EditState.MODIFIED; } /** * Get a summary of the section's current state * @returns {Object} */ getStatus() { return { id: this.id, state: this.state, hasChanges: this.hasChanges(), isEditing: this.isEditing(), isModified: this.isModified(), contentLength: this.currentMarkdown.length, lastSaved: this.lastSaved, sectionType: this.sectionType }; } /** * Generate a stable ID from content and position * @param {string} content - The section content * @param {number} position - The section position * @returns {string} A stable section ID */ static generateId(content, position) { // Create a simple hash from 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; // Convert to 32-bit integer } return `section_${Math.abs(hash)}_${position}`; } /** * Determine section type from markdown content * @param {string} markdown - The markdown content * @returns {SectionType} */ 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 * * Responsibilities: * - Track all sections in the document * - Handle section lifecycle (create, edit, save, delete) * - Manage edit state transitions between sections * - Provide document-level operations */ class SectionManager { constructor() { this.sections = new Map(); // id -> Section this.editingSection = null; // Currently editing section ID this.listeners = new Map(); // event -> [callbacks] } /** * Add event listener * @param {string} event - Event name * @param {Function} callback - Callback function */ on(event, callback) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(callback); } /** * Emit event to listeners * @param {string} event - Event name * @param {*} data - Event data */ emit(event, data) { if (this.listeners.has(event)) { this.listeners.get(event).forEach(callback => callback(data)); } } /** * Create sections from markdown content * @param {string} markdownContent - The full markdown document * @returns {Array
} Created sections */ 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]; // Check if this line starts a new section 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()) { // Complete the previous section 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; } } // Add the last section 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; } /** * Start editing a section * @param {string} sectionId - The section ID to edit * @returns {string} The content to populate the editor with */ startEditing(sectionId) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } // Stop editing any other section first if (this.editingSection && this.editingSection !== sectionId) { this.stopEditing(this.editingSection); } const content = section.startEdit(); this.editingSection = sectionId; this.emit('edit-started', { sectionId, content, section: section.getStatus() }); return content; } /** * Update content for the currently editing section * @param {string} sectionId - The section ID * @param {string} markdown - The new 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() }); } /** * Accept changes for a section * @param {string} sectionId - The section ID * @returns {string} The saved content */ acceptChanges(sectionId) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } const content = section.acceptChanges(); if (this.editingSection === sectionId) { this.editingSection = null; } this.emit('changes-accepted', { sectionId, content, section: section.getStatus() }); return content; } /** * Cancel changes for a section * @param {string} sectionId - The section ID * @returns {string} The original content */ cancelChanges(sectionId) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } const content = section.cancelChanges(); if (this.editingSection === sectionId) { this.editingSection = null; } this.emit('changes-cancelled', { sectionId, content, section: section.getStatus() }); return content; } /** * Reset section to original content * @param {string} sectionId - The section ID * @returns {string} The original 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; } /** * Stop editing a section (preserves changes as modified) * @param {string} sectionId - The section ID * @returns {EditState} The new state */ stopEditing(sectionId) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } const newState = section.stopEditing(); if (this.editingSection === sectionId) { this.editingSection = null; } this.emit('edit-stopped', { sectionId, newState, section: section.getStatus() }); return newState; } /** * Get the currently editing section * @returns {Section|null} */ getCurrentlyEditing() { return this.editingSection ? this.sections.get(this.editingSection) : null; } /** * Get all sections * @returns {Array
} */ getAllSections() { return Array.from(this.sections.values()); } /** * Get sections by state * @param {EditState} state - The state to filter by * @returns {Array
} */ getSectionsByState(state) { return this.getAllSections().filter(section => section.state === state); } /** * Get document status * @returns {Object} */ getDocumentStatus() { const sections = this.getAllSections(); const modified = sections.filter(s => s.isModified()).length; const editing = sections.filter(s => s.isEditing()).length; const saved = sections.filter(s => s.state === EditState.SAVED).length; return { totalSections: sections.length, modifiedSections: modified, editingSections: editing, savedSections: saved, hasUnsavedChanges: modified > 0 || editing > 0, currentlyEditing: this.editingSection }; } /** * Reset all sections to original state */ resetAllToOriginal() { for (const section of this.sections.values()) { section.resetToOriginal(); } this.editingSection = null; this.emit('all-sections-reset', { status: this.getDocumentStatus() }); } /** * Get the complete document markdown * @returns {string} */ getDocumentMarkdown() { return this.getAllSections() .map(section => section.currentMarkdown) .join('\n\n'); } } // Export for testing and usage if (typeof module !== 'undefined' && module.exports) { module.exports = { Section, SectionManager, EditState, SectionType }; } else { window.MarkitectEditor = { Section, SectionManager, EditState, SectionType }; }