/** * Table of Contents Component * Tractatus Framework - Phase 3: Interactive Documentation * * Creates sticky TOC sidebar on desktop, collapsible on mobile * Highlights current section on scroll * Smooth scroll to sections */ class TableOfContents { constructor(options = {}) { this.contentSelector = options.contentSelector || '#document-viewer'; this.tocSelector = options.tocSelector || '#table-of-contents'; this.headingSelector = options.headingSelector || 'h1, h2, h3'; this.activeClass = 'toc-active'; this.collapsedClass = 'toc-collapsed'; this.headings = []; this.tocLinks = []; this.currentActiveIndex = -1; this.init(); } init() { // Wait for DOM to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.build()); } else { this.build(); } console.log('[TOC] Initialized'); } build() { const tocContainer = document.querySelector(this.tocSelector); if (!tocContainer) { console.warn('[TOC] TOC container not found:', this.tocSelector); return; } const content = document.querySelector(this.contentSelector); if (!content) { console.warn('[TOC] Content container not found:', this.contentSelector); return; } // Find all headings in content this.headings = Array.from(content.querySelectorAll(this.headingSelector)); if (this.headings.length === 0) { console.log('[TOC] No headings found, hiding TOC'); tocContainer.style.display = 'none'; return; } console.log(`[TOC] Found ${this.headings.length} headings`); // Generate IDs for headings if they don't have them this.headings.forEach((heading, index) => { if (!heading.id) { heading.id = `toc-heading-${index}`; } }); // Build TOC HTML const tocHTML = this.buildTOCHTML(); tocContainer.innerHTML = tocHTML; // Store TOC links this.tocLinks = Array.from(tocContainer.querySelectorAll('a')); // Add scroll spy this.initScrollSpy(); // Add smooth scroll this.initSmoothScroll(); // Add mobile toggle functionality this.initMobileToggle(); } buildTOCHTML() { let html = ''; return html; } initScrollSpy() { // Use Intersection Observer for better performance const observerOptions = { rootMargin: '-20% 0px -35% 0px', threshold: 0 }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const id = entry.target.id; this.setActiveLink(id); } }); }, observerOptions); // Observe all headings this.headings.forEach(heading => { observer.observe(heading); }); console.log('[TOC] Scroll spy initialized'); } setActiveLink(targetId) { // Remove active class from all links this.tocLinks.forEach(link => { link.classList.remove(this.activeClass); link.classList.remove('border-blue-600', 'text-blue-600', 'font-semibold'); link.classList.add('border-transparent', 'text-gray-700'); }); // Add active class to current link const activeLink = this.tocLinks.find(link => link.dataset.target === targetId); if (activeLink) { activeLink.classList.add(this.activeClass); activeLink.classList.remove('border-transparent', 'text-gray-700'); activeLink.classList.add('border-blue-600', 'text-blue-600', 'font-semibold'); } } initSmoothScroll() { this.tocLinks.forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); const targetId = link.dataset.target; const targetElement = document.getElementById(targetId); if (targetElement) { // Smooth scroll to target targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Update URL hash without jumping if (history.pushState) { history.pushState(null, null, `#${targetId}`); } else { window.location.hash = targetId; } // On mobile, collapse TOC after clicking if (window.innerWidth < 768) { this.collapseTOC(); } } }); }); console.log('[TOC] Smooth scroll initialized'); } initMobileToggle() { const toggleButton = document.getElementById('toc-toggle'); const tocList = document.getElementById('toc-list'); if (!toggleButton || !tocList) return; toggleButton.addEventListener('click', () => { const isCollapsed = tocList.classList.contains('hidden'); if (isCollapsed) { this.expandTOC(); } else { this.collapseTOC(); } }); // Start collapsed on mobile if (window.innerWidth < 768) { this.collapseTOC(); } console.log('[TOC] Mobile toggle initialized'); } collapseTOC() { const tocList = document.getElementById('toc-list'); const toggleButton = document.getElementById('toc-toggle'); if (tocList) { tocList.classList.add('hidden'); } if (toggleButton) { const svg = toggleButton.querySelector('svg'); if (svg) { svg.style.transform = 'rotate(0deg)'; } } } expandTOC() { const tocList = document.getElementById('toc-list'); const toggleButton = document.getElementById('toc-toggle'); if (tocList) { tocList.classList.remove('hidden'); } if (toggleButton) { const svg = toggleButton.querySelector('svg'); if (svg) { svg.style.transform = 'rotate(180deg)'; } } } // Public method to rebuild TOC (useful for dynamically loaded content) rebuild() { this.build(); } } // Auto-initialize when script loads (if TOC container exists) if (typeof window !== 'undefined') { window.tocInstance = new TableOfContents(); // Listen for custom event from document viewer for dynamic content document.addEventListener('documentLoaded', () => { console.log('[TOC] Document loaded, rebuilding TOC'); window.tocInstance.rebuild(); }); } // Export for module usage if (typeof module !== 'undefined' && module.exports) { module.exports = TableOfContents; }