/** * SectionManager Component * * Extracted from monolithic editor.js as part of architecture refactoring. * Manages the collection of sections and their state transitions. * * Dependencies: * - EditState enum (imported) * - SectionType enum (imported) * - Section class (imported) * - debug function (imported) */ // Import dependencies - these will be separate modules const EditState = Object.freeze({ ORIGINAL: 'original', EDITING: 'editing', MODIFIED: 'modified', SAVED: 'saved' }); const SectionType = Object.freeze({ HEADING: 'heading', PARAGRAPH: 'paragraph', LIST: 'list', CODE: 'code', QUOTE: 'quote', TABLE: 'table', HR: 'hr', IMAGE: 'image' }); // Debug function (will be extracted to utils) function debug(message, category = 'INFO') { // Simple console debug for now - will be enhanced later console.log(`DEBUG ${category}: ${message}`); } /** * Section Class - manages individual section state and content */ 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, strategy = 'hash', parentId = null) { return this.generateIdWithStrategy(markdown, position, strategy, parentId); } static generateIdWithStrategy(markdown, position, strategy = 'hash', parentId = null) { const sanitizedContent = this.sanitizeContentForId(markdown); const normalizedContent = this.normalizeContentForHashing(sanitizedContent); const sectionType = this.detectType(markdown); switch (strategy) { case 'timestamp': return this.generateTimestampId(normalizedContent, position, sectionType); case 'sequential': return this.generateSequentialId(normalizedContent, position, sectionType); case 'hierarchical': return this.generateHierarchicalId(normalizedContent, position, parentId); case 'hash': default: return this.generateAdvancedId(normalizedContent, position, sectionType); } } static generateAdvancedId(content, position, sectionType) { const contentHash = this.generateCryptoHash(content); const safeType = sectionType || 'paragraph'; const typePrefix = safeType.substring(0, 3); const positionHex = position.toString(16).padStart(2, '0'); return `section-${typePrefix}-${contentHash}-${positionHex}`; } static generateCryptoHash(content) { let hash = 0; if (content.length === 0) return '00000000'; for (let i = 0; i < content.length; i++) { const char = content.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } const hexHash = Math.abs(hash).toString(16).padStart(8, '0'); return hexHash.substring(0, 8); } static normalizeContentForHashing(content) { if (!content || typeof content !== 'string') { return ''; } return content .trim() .replace(/\s+/g, ' ') .replace(/\r\n/g, '\n') .toLowerCase(); } static sanitizeContentForId(content) { if (!content || typeof content !== 'string') { return ''; } return content .replace(/<[^>]*>/g, '') .replace(/javascript:/gi, '') .replace(/[^\w\s\-_.#]/g, '') .trim(); } static generateTimestampId(content, position = 0, sectionType = 'paragraph') { const timestamp = Date.now().toString(36); const contentSnippet = this.generateCryptoHash(content || '').substring(0, 4); const safeType = sectionType || 'paragraph'; const typePrefix = safeType.substring(0, 3); return `section-${typePrefix}-${contentSnippet}-${timestamp}`; } static generateSequentialId(content, position, sectionType = 'paragraph') { const safeType = sectionType || 'paragraph'; const typePrefix = safeType.substring(0, 3); const seqNumber = (position || 0).toString().padStart(3, '0'); const contentHash = this.generateCryptoHash(content || '').substring(0, 4); return `section-${typePrefix}-seq${seqNumber}-${contentHash}`; } static generateHierarchicalId(content, position, parentId = null) { const contentHash = this.generateCryptoHash(content || '').substring(0, 6); if (parentId) { const childIndex = (position || 0).toString().padStart(2, '0'); return `${parentId}-child-${childIndex}-${contentHash}`; } else { return `section-root-${position || 0}-${contentHash}`; } } static detectType(markdown) { if (!markdown || typeof markdown !== 'string') { return SectionType.PARAGRAPH; } const content = markdown.replace(/^\n+|\n+$/g, ''); if (!content) { return SectionType.PARAGRAPH; } const trimmed = content.trim(); // Detection order matters - most specific first if (this.isHeading(trimmed)) { return SectionType.HEADING; } if (this.isImage(trimmed)) { return SectionType.IMAGE; } if (this.isCodeBlock(trimmed)) { return SectionType.CODE; } return SectionType.PARAGRAPH; } static isHeading(trimmed) { const headingPattern = /^#{1,6}\s+.+/; return headingPattern.test(trimmed); } static isImage(trimmed) { const imagePattern = /!\[.*?\]\([^)]+\)/; return imagePattern.test(trimmed); } static isCodeBlock(trimmed) { if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) { return true; } if (trimmed.includes('```') || trimmed.includes('~~~')) { const codeBlockPattern = /```[\s\S]*?```|~~~[\s\S]*?~~~/; if (codeBlockPattern.test(trimmed)) { return true; } } return false; } 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 (this.editingMarkdown && this.editingMarkdown !== this.currentMarkdown) { this.pendingMarkdown = this.editingMarkdown; this.state = EditState.MODIFIED; } else { 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, type: this.type, originalLength: this.originalMarkdown.length, currentLength: this.currentMarkdown.length }; } isImage() { return this.type === SectionType.IMAGE; } redetectType(content = null) { const markdown = content || this.currentMarkdown; const oldType = this.type; this.type = Section.detectType(markdown); if (oldType !== this.type) { debug(`Section ${this.id} type changed from ${oldType} to ${this.type}`, 'TYPE_DETECTION'); } return this.type; } } /** * SectionManager - 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) { // Split content into blocks separated by double newlines const blocks = markdownContent.split(/\n\s*\n/); const sections = []; let position = 0; for (const block of blocks) { const trimmedBlock = block.trim(); if (!trimmedBlock) continue; // Check if this block should be split further const lines = trimmedBlock.split('\n'); let currentSection = ''; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const isHeading = /^#{1,6}\s/.test(line.trim()); const isImage = /^\s*!\[.*?\]\(.*?\)\s*$/.test(line); // Each heading or image starts a new section if ((isHeading || isImage) && currentSection.trim()) { // Save 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; } } // Save the final section from this block 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); position++; } } this.emit('sections-created', { sections, count: sections.length }); return sections; } startEditing(sectionId) { debug('MANAGER: startEditing called for: ' + sectionId, 'MANAGER'); const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } if (section.isEditing()) { debug('MANAGER: Section already in editing state: ' + sectionId, 'MANAGER'); return section.editingMarkdown; } debug('MANAGER: Starting edit for section: ' + sectionId, 'MANAGER'); const content = section.startEdit(); debug('MANAGER: About to emit edit-started event for: ' + sectionId, 'MANAGER'); this.emit('edit-started', { sectionId, content, section: section.getStatus() }); debug('MANAGER: Emitted edit-started event for: ' + sectionId, 'MANAGER'); return content; } updateContent(sectionId, markdown) { const section = this.sections.get(sectionId); if (!section) { throw new Error(`Section ${sectionId} not found`); } const oldType = section.type; section.updateContent(markdown); const newType = section.redetectType(markdown); const eventData = { sectionId, markdown, section: section.getStatus(), typeChanged: oldType !== newType, oldType, newType }; this.emit('content-updated', eventData); if (oldType !== newType) { this.emit('section-type-changed', { sectionId, oldType, newType, 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()); } getDocumentStatus() { const sections = Array.from(this.sections.values()); const editingSections = sections.filter(section => section.isEditing).length; return { totalSections: sections.length, editingSections: editingSections }; } extractHeadings(content) { if (!content) return []; const lines = content.split('\n'); return lines.filter(line => /^#{1,6}\s/.test(line.trim())); } 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.createSectionsFromMarkdown(newContent); // Emit section-split event this.emit('section-split', { originalSectionId: sectionId, newSections: newSections, count: newSections.length }); return newSections; } createSectionsFromContent(content) { return this.createSectionsFromMarkdown(content); } } // Export for use in tests and other modules if (typeof module !== 'undefined' && module.exports) { module.exports = { SectionManager, Section, EditState, SectionType }; } // Export for browser use if (typeof window !== 'undefined') { window.SectionManager = SectionManager; window.Section = Section; window.EditState = EditState; window.SectionType = SectionType; }