/** * ContentsControl - Table of Contents Display Control * * Provides an interactive table of contents for document navigation. * Extracts headings from the document and displays them in a hierarchical * structure with clickable links for quick navigation. * * Features: * - Automatic heading extraction from document * - Hierarchical display with proper indentation * - Clickable navigation links with smooth scrolling * - Real-time updates when document structure changes * - Collapsible sections for better organization * - Search functionality within the table of contents * * Dependencies: * - ControlBase (base control functionality) */ /** * ContentsControl - Interactive table of contents control * * This control scans the document for headings (h1-h6) and presents them * in a navigable tree structure. Users can click on any heading to jump * directly to that section with smooth scrolling. */ class ContentsControl extends ControlBase { constructor() { super(); // Configure for contents functionality this.config = { icon: '📋', title: 'Contents', className: 'contents-control', defaultContent: 'Loading table of contents...', ariaLabel: 'Table of Contents Control', position: 'w' // West positioning }; // Contents-specific state this.headings = []; this.lastScanTime = null; this.updateInterval = null; this.searchQuery = ''; } /** * Extract all headings from the document * Creates a hierarchical structure of the document's heading elements */ extractHeadings() { return this.safeOperation(() => { const headingSelectors = 'h1, h2, h3, h4, h5, h6'; const headingElements = document.querySelectorAll(headingSelectors); const extractedHeadings = []; headingElements.forEach((heading, index) => { const level = parseInt(heading.tagName.charAt(1)); const text = heading.textContent.trim(); // Generate or use existing ID for anchor links let id = heading.id; if (!id) { id = text.toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-') .substring(0, 50); // Ensure uniqueness let counter = 1; let uniqueId = id; while (document.getElementById(uniqueId)) { uniqueId = `${id}-${counter}`; counter++; } heading.id = uniqueId; id = uniqueId; } extractedHeadings.push({ id, text, level, element: heading, index }); }); this.headings = extractedHeadings; this.lastScanTime = Date.now(); return extractedHeadings; }, [], 'extractHeadings'); } /** * Filter headings based on search query */ filterHeadings(headings, query) { if (!query || query.trim() === '') { return headings; } const normalizedQuery = query.toLowerCase().trim(); return headings.filter(heading => heading.text.toLowerCase().includes(normalizedQuery) ); } /** * Generate HTML for the table of contents */ generateContentsHTML(headings = null) { return this.safeOperation(() => { const displayHeadings = headings || this.headings; if (displayHeadings.length === 0) { return `

No headings found in document

`; } const searchHTML = `
`; const contentsHTML = displayHeadings.map(heading => { const indentLevel = Math.max(0, heading.level - 1); const indentPx = indentLevel * 15; return `
H${heading.level} ${heading.text}
`; }).join(''); return `
${searchHTML}
Found ${displayHeadings.length} heading${displayHeadings.length !== 1 ? 's' : ''}
${contentsHTML}
`; }, '

Error generating contents

', 'generateContentsHTML'); } /** * Navigate to a specific heading with smooth scrolling */ navigateToHeading(headingId) { return this.safeOperation(() => { const targetElement = document.getElementById(headingId); if (targetElement) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Highlight the target temporarily const originalStyle = targetElement.style.backgroundColor; targetElement.style.backgroundColor = '#fff3cd'; targetElement.style.transition = 'background-color 0.3s ease'; setTimeout(() => { targetElement.style.backgroundColor = originalStyle; setTimeout(() => { targetElement.style.transition = ''; }, 300); }, 1500); return true; } return false; }, false, 'navigateToHeading'); } /** * Handle search input */ handleSearch(query) { this.searchQuery = query; const filteredHeadings = this.filterHeadings(this.headings, query); this.updateContentsDisplay(filteredHeadings); } /** * Update the contents display with new headings */ updateContentsDisplay(headings) { return this.safeOperation(() => { const content = this.element?.querySelector('.control-content'); if (content) { content.innerHTML = this.generateContentsHTML(headings); } }, null, 'updateContentsDisplay'); } /** * Refresh the contents by re-scanning the document */ refreshContents() { return this.safeOperation(() => { this.extractHeadings(); const filteredHeadings = this.filterHeadings(this.headings, this.searchQuery); this.updateContentsDisplay(filteredHeadings); // Show success feedback const refreshBtn = this.element?.querySelector('button'); if (refreshBtn) { const originalText = refreshBtn.innerHTML; refreshBtn.innerHTML = '✅ Updated'; refreshBtn.style.background = '#28a745'; setTimeout(() => { refreshBtn.innerHTML = originalText; refreshBtn.style.background = '#28a745'; }, 1000); } }, null, 'refreshContents'); } /** * Build the control content * Override of base class method to provide contents-specific functionality */ buildContent() { return this.safeOperation(() => { // Extract headings on first build this.extractHeadings(); // Generate and set content const content = this.element?.querySelector('.control-content'); if (content) { content.innerHTML = this.generateContentsHTML(); // Store reference to this control for onclick handlers this.element.contentsControl = this; } // Set up auto-refresh for dynamic content if (this.updateInterval) { clearInterval(this.updateInterval); } this.updateInterval = setInterval(() => { const currentHeadingCount = document.querySelectorAll('h1, h2, h3, h4, h5, h6').length; if (currentHeadingCount !== this.headings.length) { this.refreshContents(); } }, 5000); // Check every 5 seconds }, null, 'buildContent'); } /** * Get statistics about the document structure */ getDocumentStats() { return this.safeOperation(() => { const stats = { totalHeadings: this.headings.length, byLevel: {}, deepestLevel: 1, structure: 'flat' }; // Count headings by level this.headings.forEach(heading => { stats.byLevel[heading.level] = (stats.byLevel[heading.level] || 0) + 1; stats.deepestLevel = Math.max(stats.deepestLevel, heading.level); }); // Determine structure type const levels = Object.keys(stats.byLevel).map(Number).sort(); if (levels.length > 1) { const hasSequentialLevels = levels.every((level, index) => index === 0 || level <= levels[index - 1] + 1 ); stats.structure = hasSequentialLevels ? 'hierarchical' : 'mixed'; } return stats; }, {}, 'getDocumentStats'); } /** * Clean up resources when control is destroyed */ destroy() { if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = null; } super.destroy(); } } // Export for module systems or attach to global for direct usage if (typeof module !== 'undefined' && module.exports) { module.exports = ContentsControl; } else { window.ContentsControl = ContentsControl; }