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>
268 lines
7.7 KiB
JavaScript
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;
|
|
}
|