tractatus/public/js/components/toc.js
TheFlow 2298d36bed fix(submissions): restructure Economist package and fix article display
- Create Economist SubmissionTracking package correctly:
  * mainArticle = full blog post content
  * coverLetter = 216-word SIR— letter
  * Links to blog post via blogPostId
- Archive 'Letter to The Economist' from blog posts (it's the cover letter)
- Fix date display on article cards (use published_at)
- Target publication already displaying via blue badge

Database changes:
- Make blogPostId optional in SubmissionTracking model
- Economist package ID: 68fa85ae49d4900e7f2ecd83
- Le Monde package ID: 68fa2abd2e6acd5691932150

Next: Enhanced modal with tabs, validation, export

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 08:47:42 +13:00

268 lines
7.7 KiB
JavaScript

/**
* 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 = '<nav class="toc-nav" aria-label="Table of Contents">';
html += '<div class="toc-header flex items-center justify-between mb-4 md:mb-6">';
html += '<h2 class="text-sm font-semibold text-gray-900 uppercase">On This Page</h2>';
html += '<button id="toc-toggle" class="md:hidden p-2 text-gray-600 hover:text-gray-900" aria-label="Toggle table of contents">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>';
html += '</svg>';
html += '</button>';
html += '</div>';
html += '<ul class="toc-list space-y-2" id="toc-list">';
this.headings.forEach((heading) => {
const level = parseInt(heading.tagName.substring(1)); // h1 -> 1, h2 -> 2, etc.
const text = heading.textContent.trim();
const id = heading.id;
// Different indentation for different levels
const indent = level === 1 ? '' : level === 2 ? 'ml-3' : 'ml-6';
const fontSize = level === 1 ? 'text-sm' : level === 2 ? 'text-sm' : 'text-xs';
const fontWeight = level === 1 ? 'font-semibold' : level === 2 ? 'font-medium' : 'font-normal';
html += `<li class="${indent}">`;
html += `<a href="#${id}" class="toc-link block py-1 ${fontSize} ${fontWeight} text-gray-700 hover:text-blue-600 transition-colors border-l-2 border-transparent hover:border-blue-600 pl-2" data-target="${id}">`;
html += text;
html += '</a>';
html += '</li>';
});
html += '</ul>';
html += '</nav>';
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;
}