tractatus/public/js/components/toc.js
TheFlow 8cc2c0c289 feat(phase3): add code snippet copy buttons and collapsible TOC
SUMMARY:
Implemented Phase 3 Tasks 3.6.1 and 3.6.2: Enhanced documentation
with interactive code copy buttons and collapsible table of contents.

CHANGES:

1. Created code-copy-button.js (new):
   - Auto-detects all <pre> code blocks
   - Adds "Copy" button to top-right of each block
   - Clipboard API with fallback for older browsers
   - Visual feedback: "✓ Copied!" on success
   - Styled to work on dark code backgrounds
   - Listens for 'documentLoaded' event for dynamic content
   - Accessible with aria-label

2. Created toc.js (new):
   - Automatically builds TOC from h1, h2, h3 headings
   - Sticky sidebar on desktop (lg:block)
   - Collapsible on mobile with toggle button
   - Scroll spy with Intersection Observer
   - Highlights current section
   - Smooth scroll to sections
   - Updates URL hash on navigation
   - Auto-collapses on mobile after clicking link

3. Updated docs-viewer.html:
   - Added TOC sidebar (sticky, desktop-only)
   - Improved layout with flex containers
   - Added both components to script imports
   - Maintained existing document viewer functionality

FEATURES:

Code Copy Buttons:
- Button text: "Copy" → "✓ Copied!" → "Copy"
- 2-second success/error feedback
- Works on all <pre><code> blocks
- Respects code indentation

Table of Contents:
- Auto-generated from headings with IDs
- 3-level hierarchy (h1, h2, h3)
- Visual active indicator (blue border + bold)
- Mobile toggle with chevron icon
- Sticky positioning on desktop
- Smooth scroll behavior

ACCESSIBILITY:
✓ Zero CSP violations maintained
✓ Keyboard navigation supported
✓ ARIA labels on interactive elements
✓ Semantic HTML (nav, aside)
✓ Focus indicators

PERFORMANCE:
- Intersection Observer for scroll spy (better than scroll listeners)
- Minimal DOM manipulation
- CSS transitions for smooth UX
- Lazy initialization

UI_TRANSFORMATION_PROJECT_PLAN.md:
✓ Phase 3 Task 3.6.1: Code snippet copy buttons
✓ Phase 3 Task 3.6.2: Collapsible table of contents

NEXT STEPS:
- Deploy to production for testing
- Phase 3 Task 3.2: Interactive architecture diagram (complex, deferred)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 15:27:33 +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;
}