/** * DocumentNavigator Widget * * Substack-style floating document navigation widget that displays a hierarchical * table of contents based on document headings. Supports smooth scrolling, * scroll spy, expand/collapse, and responsive behavior. */ import { UIWidget } from '../base/UIWidget.js'; export class DocumentNavigator extends UIWidget { constructor(options = {}) { super(options); // Navigation state this.isCollapsed = this.config.collapsed; this.currentSection = null; this.headings = []; this.navigationTree = []; // Scroll spy state this.scrollSpyEnabled = this.config.enableScrollSpy; this.scrollThrottle = null; // Event bindings this.boundScrollHandler = this.handleScroll.bind(this); this.boundResizeHandler = this.handleResize.bind(this); // Initialize responsive behavior this.mediaQuery = window.matchMedia('(max-width: 768px)'); } getDefaultConfig() { return { ...super.getDefaultConfig(), position: 'left', // 'left' or 'right' collapsed: true, // Start collapsed autoHide: true, // Hide on mobile maxHeadingLevel: 3, // H1, H2, H3 enableScrollSpy: true, // Highlight current section smoothScroll: true, // Smooth scroll behavior animationDuration: 300, // Animation timing minHeadings: 2, // Min headings to show navigator theme: 'default', // Theme support // Styling options width: '280px', collapsedWidth: '40px', offset: { top: '80px', side: '20px' }, // Accessibility enableKeyboard: true, ariaLabel: 'Document Navigation' }; } async initialize() { await super.initialize(); // Extract headings from container this.extractHeadings(); this.buildNavigationTree(); // Set up event listeners if (this.scrollSpyEnabled) { window.addEventListener('scroll', this.boundScrollHandler, { passive: true }); } if (this.config.autoHide) { window.addEventListener('resize', this.boundResizeHandler); this.handleResize(); // Initial check } return this; } async render() { if (this.isRendered) { return this.element; } // Check if we have enough headings if (this.headings.length < this.config.minHeadings) { this.isRendered = true; return null; // Don't render if too few headings } // Create main container this.element = this.createElement('nav', { className: 'document-navigator markitect-widget', attributes: { 'aria-label': this.config.ariaLabel, 'role': 'navigation' }, style: this.getNavigatorStyle() }); // Apply CSS classes this.applyCSSClasses(); this.addClass('theme-' + this.theme); this.addClass('position-' + this.config.position); // Create toggle button (always visible) this.createToggleButton(); // Create navigation list (hidden when collapsed) this.createNavigationList(); // Set initial visibility state if (this.isCollapsed) { await this.collapse({ immediate: true }); } else { await this.expand({ immediate: true }); } // Append to container this.container.appendChild(this.element); // Initialize scroll spy if (this.scrollSpyEnabled) { this.updateCurrentSection(); } this.isRendered = true; this.emit('rendered'); return this.element; } createToggleButton() { this.toggleButton = this.createElement('button', { className: 'navigator-toggle', attributes: { 'type': 'button', 'aria-label': this.isCollapsed ? 'Expand navigation' : 'Collapse navigation', 'aria-expanded': !this.isCollapsed }, innerHTML: this.getToggleIcon(), style: this.getToggleStyle() }); // Toggle on click this.toggleButton.addEventListener('click', async () => { await this.toggle(); }); // Keyboard support if (this.config.enableKeyboard) { this.toggleButton.addEventListener('keydown', this.handleKeyboard.bind(this)); } this.element.appendChild(this.toggleButton); } createNavigationList() { this.navigationList = this.createElement('div', { className: 'navigator-list', style: this.getListStyle() }); if (this.headings.length === 0) { this.createEmptyState(); } else { this.populateNavigationList(); } this.element.appendChild(this.navigationList); } createEmptyState() { const emptyMessage = this.createElement('div', { className: 'navigator-empty', textContent: 'No headings found', style: { padding: '1rem', textAlign: 'center', color: '#666', fontStyle: 'italic' } }); this.navigationList.appendChild(emptyMessage); } populateNavigationList() { // Create header const header = this.createElement('div', { className: 'navigator-header', innerHTML: `

Contents

`, style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1rem 0.5rem', borderBottom: '1px solid #eee', marginBottom: '0.5rem' } }); // Close button functionality const closeButton = header.querySelector('.navigator-close'); closeButton.addEventListener('click', async () => { await this.collapse(); }); this.navigationList.appendChild(header); // Create navigation items const navContainer = this.createElement('div', { className: 'navigator-items', style: { maxHeight: '70vh', overflowY: 'auto', padding: '0 0.5rem 1rem' } }); this.renderNavigationTree(navContainer, this.navigationTree); this.navigationList.appendChild(navContainer); } renderNavigationTree(container, items, level = 0) { items.forEach(item => { const navItem = this.createElement('div', { className: `navigator-item level-${level}`, style: { marginLeft: `${level * 1}rem`, marginBottom: '0.25rem' } }); // Create clickable link const link = this.createElement('a', { className: 'navigator-link', textContent: item.text, attributes: { 'href': `#${item.id}`, 'data-target': item.id, 'data-level': item.level, 'role': 'button', 'tabindex': '0' }, style: { display: 'block', padding: '0.5rem 0.75rem', textDecoration: 'none', color: '#333', borderRadius: '4px', fontSize: level === 0 ? '0.9rem' : '0.8rem', fontWeight: level === 0 ? '600' : '400', transition: 'all 0.2s ease', cursor: 'pointer' } }); // Hover effects link.addEventListener('mouseenter', () => { link.style.backgroundColor = '#f0f0f0'; }); link.addEventListener('mouseleave', () => { if (!link.classList.contains('active')) { link.style.backgroundColor = ''; } }); // Click navigation link.addEventListener('click', (e) => { e.preventDefault(); this.navigateToHeading(item.id); }); navItem.appendChild(link); // Render children recursively if (item.children && item.children.length > 0) { this.renderNavigationTree(navItem, item.children, level + 1); } container.appendChild(navItem); }); } extractHeadings() { const headingSelectors = []; for (let i = 1; i <= this.config.maxHeadingLevel; i++) { headingSelectors.push(`h${i}`); } const headingElements = this.container.querySelectorAll(headingSelectors.join(', ')); this.headings = Array.from(headingElements).map((heading, index) => { // Ensure heading has an ID if (!heading.id) { heading.id = `heading-${index + 1}`; } return { element: heading, id: heading.id, text: heading.textContent.trim(), level: parseInt(heading.tagName.substring(1)), offset: heading.offsetTop }; }); return this.headings; } buildNavigationTree() { this.navigationTree = []; const stack = []; this.headings.forEach(heading => { const item = { ...heading, children: [] }; // Find correct parent based on heading level while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) { stack.pop(); } if (stack.length === 0) { // Top level item this.navigationTree.push(item); } else { // Child item stack[stack.length - 1].children.push(item); } stack.push(item); }); return this.navigationTree; } async toggle(options = {}) { return this.isCollapsed ? this.expand(options) : this.collapse(options); } async expand(options = {}) { if (!this.isCollapsed) { return this; } this.isCollapsed = false; if (this.toggleButton) { this.toggleButton.setAttribute('aria-expanded', 'true'); this.toggleButton.setAttribute('aria-label', 'Collapse navigation'); this.toggleButton.innerHTML = this.getToggleIcon(); } if (this.navigationList) { if (this.enableAnimations && !options.immediate) { await this.animateExpand(); } else { this.navigationList.style.display = ''; this.element.style.width = this.config.width; } } this.emit('toggle', { expanded: true }); return this; } async collapse(options = {}) { if (this.isCollapsed) { return this; } this.isCollapsed = true; if (this.toggleButton) { this.toggleButton.setAttribute('aria-expanded', 'false'); this.toggleButton.setAttribute('aria-label', 'Expand navigation'); this.toggleButton.innerHTML = this.getToggleIcon(); } if (this.navigationList) { if (this.enableAnimations && !options.immediate) { await this.animateCollapse(); } else { this.navigationList.style.display = 'none'; this.element.style.width = this.config.collapsedWidth; } } this.emit('toggle', { expanded: false }); return this; } async animateExpand() { return new Promise(resolve => { this.navigationList.style.opacity = '0'; this.navigationList.style.display = ''; // Animate width and opacity this.element.style.transition = `width ${this.animationDuration}ms ease-in-out`; this.navigationList.style.transition = `opacity ${this.animationDuration}ms ease-in-out`; // Force reflow this.element.offsetWidth; this.element.style.width = this.config.width; this.navigationList.style.opacity = '1'; setTimeout(() => { this.element.style.transition = ''; this.navigationList.style.transition = ''; resolve(); }, this.animationDuration); }); } async animateCollapse() { return new Promise(resolve => { this.element.style.transition = `width ${this.animationDuration}ms ease-in-out`; this.navigationList.style.transition = `opacity ${this.animationDuration}ms ease-in-out`; this.navigationList.style.opacity = '0'; this.element.style.width = this.config.collapsedWidth; setTimeout(() => { this.navigationList.style.display = 'none'; this.element.style.transition = ''; this.navigationList.style.transition = ''; resolve(); }, this.animationDuration); }); } navigateToHeading(headingId) { const targetElement = document.getElementById(headingId); if (!targetElement) { console.warn(`Heading with ID '${headingId}' not found`); return; } // Update active navigation item this.setActiveItem(headingId); // Scroll to target if (this.config.smoothScroll) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); } else { targetElement.scrollIntoView(); } // Emit navigation event this.emit('navigate', { target: headingId, element: targetElement }); // Optionally collapse after navigation on mobile if (this.mediaQuery.matches && this.config.autoHide) { setTimeout(() => this.collapse(), 500); } } setActiveItem(headingId) { // Remove previous active state const previousActive = this.findElement('.navigator-link.active'); if (previousActive) { previousActive.classList.remove('active'); previousActive.style.backgroundColor = ''; } // Set new active state const newActive = this.findElement(`[data-target="${headingId}"]`); if (newActive) { newActive.classList.add('active'); newActive.style.backgroundColor = '#e3f2fd'; newActive.style.color = '#1976d2'; } this.currentSection = headingId; } handleScroll() { if (!this.scrollSpyEnabled || !this.isRendered) { return; } // Throttle scroll events if (this.scrollThrottle) { return; } this.scrollThrottle = setTimeout(() => { this.updateCurrentSection(); this.scrollThrottle = null; }, 100); } updateCurrentSection() { const scrollPosition = window.pageYOffset + 100; // Offset for header let currentHeading = null; // Find the current heading based on scroll position for (let i = this.headings.length - 1; i >= 0; i--) { const heading = this.headings[i]; if (heading.element.offsetTop <= scrollPosition) { currentHeading = heading; break; } } if (currentHeading && currentHeading.id !== this.currentSection) { this.setActiveItem(currentHeading.id); } } getCurrentSection() { return this.currentSection; } handleResize() { if (!this.config.autoHide) { return; } if (this.mediaQuery.matches) { // Mobile: hide navigator if (this.element) { this.element.style.display = 'none'; } } else { // Desktop: show navigator if (this.element) { this.element.style.display = ''; } } } handleKeyboard(event) { switch (event.key) { case 'Enter': case ' ': event.preventDefault(); this.toggle(); break; case 'Escape': event.preventDefault(); this.collapse(); break; } } getNavigatorStyle() { const baseStyle = { position: 'fixed', top: this.config.offset.top, zIndex: '1000', backgroundColor: 'rgba(255, 255, 255, 0.95)', border: '1px solid #e1e5e9', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', backdropFilter: 'blur(8px)', width: this.isCollapsed ? this.config.collapsedWidth : this.config.width, maxHeight: '80vh', overflow: 'hidden', transition: 'width 0.3s ease-in-out' }; // Position-specific styling if (this.config.position === 'left') { baseStyle.left = this.config.offset.side; } else { baseStyle.right = this.config.offset.side; } return baseStyle; } getToggleStyle() { return { width: '100%', height: this.config.collapsedWidth, border: 'none', backgroundColor: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '16px', color: '#666', transition: 'color 0.2s ease' }; } getListStyle() { return { display: this.isCollapsed ? 'none' : '', opacity: this.isCollapsed ? '0' : '1' }; } getToggleIcon() { if (this.isCollapsed) { return this.config.position === 'left' ? '☰' : '☰'; } else { return '✕'; } } async destroy() { // Remove event listeners window.removeEventListener('scroll', this.boundScrollHandler); window.removeEventListener('resize', this.boundResizeHandler); // Clear throttle if (this.scrollThrottle) { clearTimeout(this.scrollThrottle); } await super.destroy(); } }