/** * toc-sidebar.js - Wiki-Style TOC Sidebar * * For The Building Coder Archive * Author: GitHub Copilot * Date: January 2, 2026 * * Features: * - Dynamic sidebar generation from JSON data * - Topic-based navigation with collapsible groups * - Real-time search with highlighting * - Drag-to-resize functionality * - Current page detection and highlighting * - Mobile hamburger menu * - State persistence in localStorage */ (function() { 'use strict'; // ================================ // Configuration // ================================ const CONFIG = { tocDataUrl: 'toc/toc-data.json', pagefindPath: 'pagefind/pagefind.js', defaultWidth: 280, minWidth: 180, maxWidth: 500, searchDebounce: 150, storageKeys: { width: 'tbc-sidebar-width', expanded: 'tbc-expanded-topics', scroll: 'tbc-sidebar-scroll' } }; // ================================ // State // ================================ const state = { tocData: null, currentPage: null, expandedTopics: new Set(), isResizing: false, isMobileOpen: false, searchQuery: '' }; // Pagefind instance (loaded dynamically) let pagefind = null; // ================================ // Pagefind Integration // ================================ /** * Get base path for Pagefind assets based on current page location * Works for both GitHub Pages (/tbc/a/...) and local server (/a/...) * @returns {string} Base path ending with /a/ */ function getPagefindBasePath() { const currentPath = window.location.pathname; // Find the /a/ directory in the path to handle both: // - GitHub Pages: /tbc/a/index.html -> /tbc/a/ // - Local server: /a/index.html -> /a/ const aIndex = currentPath.indexOf('/a/'); if (aIndex !== -1) { return currentPath.substring(0, aIndex + 3); // Include '/a/' } // Fallback for root level access return '/a/'; } /** * Initialize Pagefind search library * Loads Pagefind dynamically and initializes it */ async function initPagefind() { try { const basePath = getPagefindBasePath(); const pagefindUrl = basePath + CONFIG.pagefindPath; console.log('Loading Pagefind from:', pagefindUrl); // Dynamic import of Pagefind pagefind = await import(pagefindUrl); await pagefind.init(); console.log('Pagefind initialized successfully'); return true; } catch (error) { console.warn('Pagefind not available, using fallback search:', error.message); console.warn('Attempted URL:', getPagefindBasePath() + CONFIG.pagefindPath); pagefind = null; return false; } } /** * Perform search using Pagefind * Falls back to title-only search if Pagefind unavailable * @param {string} query - Search query */ async function performPagefindSearch(query) { if (!pagefind) { // Fallback to title-only TOC search performTitleSearch(query); return; } const resultsDiv = document.getElementById('tbc-search-results'); if (!resultsDiv) return; try { // Show loading state resultsDiv.innerHTML = '
Searching...
'; resultsDiv.classList.remove('no-results'); // Perform debounced search const search = await pagefind.debouncedSearch(query, {}, CONFIG.searchDebounce); // If null, a newer search superseded this one if (search === null) return; if (search.results.length === 0) { resultsDiv.innerHTML = `
No results for "${escapeHtml(query)}"
`; resultsDiv.classList.add('no-results'); // Also hide topics hideTopicsForSearch(); return; } // Load first 30 results for display const maxResults = 30; const resultsToLoad = search.results.slice(0, maxResults); const loadedResults = await Promise.all(resultsToLoad.map(r => r.data())); // Build results HTML let html = `
${search.results.length} result${search.results.length !== 1 ? 's' : ''}
`; html += ''; if (search.results.length > maxResults) { html += `
Showing ${maxResults} of ${search.results.length} results
`; } resultsDiv.innerHTML = html; resultsDiv.classList.remove('no-results'); // Hide topic navigation during search hideTopicsForSearch(); } catch (error) { console.error('Pagefind search error:', error); resultsDiv.innerHTML = '
Search error. Trying fallback...
'; // Fall back to title search setTimeout(() => performTitleSearch(query), 100); } } /** * Get post title from TOC data by URL * @param {string} url - Post URL (e.g., "/1807_createviaoffset.html") * @returns {string|null} Post title or null if not found */ function getTitleFromUrl(url) { if (!state.tocData || !state.tocData.topics) return null; // Extract filename from URL const filename = url.split('/').pop(); if (!filename) return null; // Search through all topics and posts for (const topic of state.tocData.topics) { if (topic.posts) { for (const post of topic.posts) { if (post.file === filename) { return post.title; } } } } return null; } /** * Hide topic navigation when showing Pagefind results */ function hideTopicsForSearch() { const topicsContainer = document.getElementById('tbc-topics-container'); if (topicsContainer) { topicsContainer.style.display = 'none'; } } /** * Show topic navigation (restore after clearing search) */ function showTopicsAfterSearch() { const topicsContainer = document.getElementById('tbc-topics-container'); if (topicsContainer) { topicsContainer.style.display = ''; } } // ================================ // Utility Functions // ================================ function debounce(func, wait) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function getCurrentPageFile() { const path = window.location.pathname; const file = path.split('/').pop(); return file || 'index.html'; } // ================================ // Data Loading // ================================ async function loadTocData() { // Try to load from cache first const cached = localStorage.getItem('tbc-toc-data'); const cacheTime = localStorage.getItem('tbc-toc-cache-time'); const ONE_HOUR = 60 * 60 * 1000; if (cached && cacheTime && (Date.now() - parseInt(cacheTime)) < ONE_HOUR) { try { return JSON.parse(cached); } catch (e) { console.warn('Failed to parse cached TOC data'); } } // Determine the base path for the JSON file const currentPath = window.location.pathname; let basePath = ''; // If we're in the a/ directory viewing a post (has /a/ in path or served directly from a/) if (currentPath.includes('/a/') && !currentPath.endsWith('/a/') && !currentPath.endsWith('/a/index.html')) { basePath = ''; // toc/ is in same directory } else if (currentPath.endsWith('/a/') || currentPath.endsWith('/a/index.html')) { basePath = ''; // we're in a/ } else if (currentPath === '/index.html' || currentPath === '/' || currentPath.match(/^\/\d{4}_/)) { // Serving directly from a/ directory (local dev or subdomain). // Post filenames are always 4-digit zero-padded (e.g. /0001_title.html), // so /^\/\d{4}_/ intentionally matches those post URLs at the root. basePath = ''; } else { basePath = 'a/'; // we're at root } const url = basePath + CONFIG.tocDataUrl; try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); // Cache the data try { localStorage.setItem('tbc-toc-data', JSON.stringify(data)); localStorage.setItem('tbc-toc-cache-time', Date.now().toString()); } catch (e) { console.warn('Failed to cache TOC data'); } return data; } catch (error) { console.error('Failed to load TOC data:', error); throw error; } } // ================================ // State Persistence // ================================ function loadPersistedState() { // Load expanded topics try { const expanded = localStorage.getItem(CONFIG.storageKeys.expanded); if (expanded) { state.expandedTopics = new Set(JSON.parse(expanded)); } } catch (e) { console.warn('Failed to load expanded topics'); } // Load sidebar width const savedWidth = localStorage.getItem(CONFIG.storageKeys.width); if (savedWidth) { return parseInt(savedWidth) || CONFIG.defaultWidth; } return CONFIG.defaultWidth; } function saveExpandedTopics() { try { localStorage.setItem( CONFIG.storageKeys.expanded, JSON.stringify([...state.expandedTopics]) ); } catch (e) { console.warn('Failed to save expanded topics'); } } function saveSidebarWidth(width) { try { localStorage.setItem(CONFIG.storageKeys.width, width.toString()); } catch (e) { console.warn('Failed to save sidebar width'); } } function saveScrollPosition() { const container = document.getElementById('tbc-topics-container'); if (container) { try { localStorage.setItem(CONFIG.storageKeys.scroll, container.scrollTop.toString()); } catch (e) {} } } function restoreScrollPosition() { const container = document.getElementById('tbc-topics-container'); const saved = localStorage.getItem(CONFIG.storageKeys.scroll); if (container && saved) { container.scrollTop = parseInt(saved) || 0; } } // ================================ // Sidebar HTML Generation // ================================ function generateSidebarHTML() { return `
The Building Coder
๐Ÿ”
`; } function generateNavLinksHTML(navigation) { if (!navigation || !navigation.length) return ''; return navigation.map(link => { const isActive = state.currentPage === 'index.html' && window.location.hash === link.href.replace('index.html', ''); return `${escapeHtml(link.label)}`; }).join(''); } function generateTopicsHTML(topics) { if (!topics || !topics.length) { return '
No topics found
'; } return topics.map(topic => generateTopicHTML(topic)).join(''); } function generateTopicHTML(topic, isSubTopic = false) { const isExpanded = state.expandedTopics.has(topic.id); const postCount = topic.posts ? topic.posts.length : 0; const hasSubTopics = topic.subTopics && topic.subTopics.length > 0; const totalCount = postCount + (hasSubTopics ? topic.subTopics.reduce((sum, st) => sum + (st.posts?.length || 0), 0) : 0); const topicClass = isSubTopic ? 'tbc-topic tbc-subtopic' : 'tbc-topic'; const expandedClass = isExpanded ? ' expanded' : ''; let postsHTML = ''; if (topic.posts && topic.posts.length) { postsHTML = topic.posts.map(post => { const isCurrent = isCurrentPost(post.file); const currentClass = isCurrent ? ' current' : ''; return `${escapeHtml(post.title)}`; }).join(''); } let subTopicsHTML = ''; if (hasSubTopics) { subTopicsHTML = topic.subTopics.map(st => generateTopicHTML(st, true)).join(''); } return `
โ–ถ ${escapeHtml(topic.id)} ${escapeHtml(topic.title)} (${totalCount})
${postsHTML} ${subTopicsHTML}
`; } function isCurrentPost(postFile) { if (!postFile || !state.currentPage) return false; // Handle URLs with anchors const postFileBase = postFile.split('#')[0]; const currentBase = state.currentPage.split('#')[0]; return postFileBase === currentBase || postFileBase === state.currentPage || postFile === state.currentPage; } // ================================ // Sidebar Initialization // ================================ async function initSidebar() { // Check if sidebar already exists if (document.getElementById('tbc-sidebar')) { return; } // Detect current page state.currentPage = getCurrentPageFile(); // Load persisted state const savedWidth = loadPersistedState(); // Create sidebar container const sidebar = document.createElement('div'); sidebar.id = 'tbc-sidebar'; sidebar.className = 'loading'; sidebar.innerHTML = generateSidebarHTML(); // Create mobile toggle button const mobileToggle = document.createElement('button'); mobileToggle.id = 'tbc-mobile-toggle'; mobileToggle.innerHTML = 'โ˜ฐ'; mobileToggle.setAttribute('aria-label', 'Open navigation'); // Create overlay for mobile const overlay = document.createElement('div'); overlay.id = 'tbc-overlay'; // Wrap existing content const body = document.body; const existingContent = Array.from(body.childNodes); const contentWrapper = document.createElement('div'); contentWrapper.id = 'tbc-content'; existingContent.forEach(node => { if (node.id !== 'tbc-sidebar' && node.id !== 'tbc-mobile-toggle' && node.id !== 'tbc-overlay') { contentWrapper.appendChild(node); } }); // Add elements to body body.innerHTML = ''; body.appendChild(sidebar); body.appendChild(overlay); body.appendChild(mobileToggle); body.appendChild(contentWrapper); body.classList.add('tbc-has-sidebar'); // Set initial width sidebar.style.width = savedWidth + 'px'; contentWrapper.style.marginLeft = savedWidth + 'px'; // Load TOC data try { state.tocData = await loadTocData(); renderSidebar(); sidebar.classList.remove('loading'); // Initialize Pagefind (don't block UI) initPagefind().catch(err => { console.warn('Pagefind unavailable, using title-only search:', err); }); } catch (error) { renderError(error); sidebar.classList.remove('loading'); } // Initialize interactions initResizeHandle(); initSearch(); initTopicToggles(); initMobileMenu(); initKeyboardShortcuts(); // Expand topic containing current page expandCurrentTopic(); // Restore scroll position setTimeout(restoreScrollPosition, 100); // Save scroll position on scroll const topicsContainer = document.getElementById('tbc-topics-container'); if (topicsContainer) { topicsContainer.addEventListener('scroll', debounce(saveScrollPosition, 500)); } } function renderSidebar() { if (!state.tocData) return; // Render navigation links const navLinksContainer = document.getElementById('tbc-nav-links'); if (navLinksContainer) { navLinksContainer.innerHTML = generateNavLinksHTML(state.tocData.navigation); } // Render topics and archive const topicsContainer = document.getElementById('tbc-topics-container'); if (topicsContainer) { let html = ''; // Section header for topics html += `
๐Ÿ“‘ Topic Groups (${state.tocData.totalPostLinks || 0} posts)
${generateTopicsHTML(state.tocData.topics)}
`; topicsContainer.innerHTML = html; } // Re-init topic toggles after rendering initTopicToggles(); } function renderError(error) { const topicsContainer = document.getElementById('tbc-topics-container'); if (topicsContainer) { topicsContainer.innerHTML = `
โš ๏ธ
Failed to load table of contents
`; } } // ================================ // Resize Handle // ================================ function initResizeHandle() { const handle = document.getElementById('tbc-resize-handle'); const sidebar = document.getElementById('tbc-sidebar'); const content = document.getElementById('tbc-content'); if (!handle || !sidebar || !content) return; let startX, startWidth; function onMouseDown(e) { state.isResizing = true; startX = e.clientX; startWidth = sidebar.offsetWidth; handle.classList.add('dragging'); document.body.classList.add('tbc-resizing'); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); e.preventDefault(); } function onMouseMove(e) { if (!state.isResizing) return; const diff = e.clientX - startX; let newWidth = startWidth + diff; // Enforce constraints newWidth = Math.max(CONFIG.minWidth, Math.min(CONFIG.maxWidth, newWidth)); sidebar.style.width = newWidth + 'px'; content.style.marginLeft = newWidth + 'px'; // Update CSS variable document.documentElement.style.setProperty('--tbc-sidebar-width', newWidth + 'px'); } function onMouseUp() { if (!state.isResizing) return; state.isResizing = false; handle.classList.remove('dragging'); document.body.classList.remove('tbc-resizing'); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); // Save width saveSidebarWidth(sidebar.offsetWidth); } handle.addEventListener('mousedown', onMouseDown); // Double-click to reset handle.addEventListener('dblclick', () => { sidebar.style.width = CONFIG.defaultWidth + 'px'; content.style.marginLeft = CONFIG.defaultWidth + 'px'; document.documentElement.style.setProperty('--tbc-sidebar-width', CONFIG.defaultWidth + 'px'); saveSidebarWidth(CONFIG.defaultWidth); }); // Touch support handle.addEventListener('touchstart', (e) => { state.isResizing = true; startX = e.touches[0].clientX; startWidth = sidebar.offsetWidth; handle.classList.add('dragging'); e.preventDefault(); }); document.addEventListener('touchmove', (e) => { if (!state.isResizing) return; const touch = e.touches[0]; const diff = touch.clientX - startX; let newWidth = startWidth + diff; newWidth = Math.max(CONFIG.minWidth, Math.min(CONFIG.maxWidth, newWidth)); sidebar.style.width = newWidth + 'px'; content.style.marginLeft = newWidth + 'px'; }); document.addEventListener('touchend', () => { if (state.isResizing) { state.isResizing = false; handle.classList.remove('dragging'); saveSidebarWidth(sidebar.offsetWidth); } }); } // ================================ // Search // ================================ function initSearch() { const input = document.getElementById('tbc-search-input'); const clearBtn = document.getElementById('tbc-search-clear'); if (!input) return; const performSearchDebounced = debounce(performSearch, CONFIG.searchDebounce); input.addEventListener('input', () => { state.searchQuery = input.value; clearBtn.classList.toggle('hidden', !input.value); performSearchDebounced(input.value); }); clearBtn.addEventListener('click', () => { input.value = ''; state.searchQuery = ''; clearBtn.classList.add('hidden'); resetSearch(); input.focus(); }); } function performSearch(query) { query = query.toLowerCase().trim(); if (!query) { resetSearch(); return; } // Use Pagefind for full-text search, fallback to title search if (pagefind) { performPagefindSearch(query); } else { performTitleSearch(query); } } function performTitleSearch(query) { const resultsDiv = document.getElementById('tbc-search-results'); const topics = document.querySelectorAll('.tbc-topic'); const posts = document.querySelectorAll('.tbc-post-link'); let matchCount = 0; // Search posts posts.forEach(post => { const title = post.textContent.toLowerCase(); const matches = title.includes(query); post.classList.toggle('tbc-search-no-match', !matches); if (matches) { matchCount++; highlightText(post, query); // Expand parent topic const topic = post.closest('.tbc-topic'); if (topic) { topic.classList.add('expanded'); state.expandedTopics.add(topic.dataset.topicId); } } else { removeHighlight(post); } }); // Search and show/hide topics topics.forEach(topic => { const topicTitle = topic.querySelector('.tbc-topic-title'); const topicTitleText = topicTitle ? topicTitle.textContent.toLowerCase() : ''; const topicMatches = topicTitleText.includes(query); const hasVisiblePosts = topic.querySelector('.tbc-post-link:not(.tbc-search-no-match)'); if (topicMatches) { topic.classList.remove('tbc-search-no-match'); topic.classList.add('expanded'); state.expandedTopics.add(topic.dataset.topicId); // Show all posts in matching topic topic.querySelectorAll('.tbc-post-link').forEach(p => { p.classList.remove('tbc-search-no-match'); matchCount++; }); if (topicTitle) highlightText(topicTitle, query); } else { topic.classList.toggle('tbc-search-no-match', !hasVisiblePosts); if (topicTitle) removeHighlight(topicTitle); } }); // Update results count updateResultsCount(matchCount, resultsDiv); } function updateResultsCount(matchCount, resultsDiv) { if (resultsDiv) { if (matchCount === 0) { resultsDiv.textContent = 'No posts found'; resultsDiv.classList.add('no-results'); } else { resultsDiv.textContent = `${matchCount} result${matchCount === 1 ? '' : 's'}`; resultsDiv.classList.remove('no-results'); } } } function resetSearch() { const resultsDiv = document.getElementById('tbc-search-results'); const topics = document.querySelectorAll('.tbc-topic'); const posts = document.querySelectorAll('.tbc-post-link'); // Show topics again (hidden during Pagefind search) showTopicsAfterSearch(); posts.forEach(post => { post.classList.remove('tbc-search-no-match'); removeHighlight(post); }); topics.forEach(topic => { topic.classList.remove('tbc-search-no-match'); const topicTitle = topic.querySelector('.tbc-topic-title'); if (topicTitle) removeHighlight(topicTitle); }); if (resultsDiv) { resultsDiv.innerHTML = ''; resultsDiv.classList.remove('no-results'); } } function highlightText(element, query) { const originalText = element.getAttribute('data-original-text') || element.textContent; element.setAttribute('data-original-text', originalText); const regex = new RegExp(`(${escapeRegex(query)})`, 'gi'); element.innerHTML = escapeHtml(originalText).replace( regex, '$1' ); } function removeHighlight(element) { const originalText = element.getAttribute('data-original-text'); if (originalText) { element.textContent = originalText; } } // ================================ // Topic Toggles // ================================ function initTopicToggles() { const topicHeaders = document.querySelectorAll('.tbc-topic-header'); topicHeaders.forEach(header => { // Remove existing listeners header.replaceWith(header.cloneNode(true)); }); // Re-query and add listeners document.querySelectorAll('.tbc-topic-header').forEach(header => { header.addEventListener('click', (e) => { const topic = header.closest('.tbc-topic'); if (topic) { toggleTopic(topic); } }); header.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const topic = header.closest('.tbc-topic'); if (topic) { toggleTopic(topic); } } }); }); // Toggle-all buttons document.querySelectorAll('.tbc-toggle-all').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const section = btn.closest('.tbc-topics-section, .tbc-archive-section'); if (section) { toggleAllInSection(section); } }); }); } function toggleAllInSection(section) { const topics = section.querySelectorAll('.tbc-topic'); const expandedCount = section.querySelectorAll('.tbc-topic.expanded').length; const shouldExpand = expandedCount < topics.length / 2; topics.forEach(topic => { const topicId = topic.dataset.topicId; if (shouldExpand) { topic.classList.add('expanded'); if (topicId) state.expandedTopics.add(topicId); } else { topic.classList.remove('expanded'); if (topicId) state.expandedTopics.delete(topicId); } const header = topic.querySelector('.tbc-topic-header'); if (header) { header.setAttribute('aria-expanded', shouldExpand); } }); saveExpandedTopics(); } function toggleTopic(topicElement) { const topicId = topicElement.dataset.topicId; const isExpanded = topicElement.classList.toggle('expanded'); const header = topicElement.querySelector('.tbc-topic-header'); if (header) { header.setAttribute('aria-expanded', isExpanded); } if (isExpanded) { state.expandedTopics.add(topicId); } else { state.expandedTopics.delete(topicId); } saveExpandedTopics(); } function expandCurrentTopic() { if (!state.currentPage) return; // Find the post link that matches current page const currentLink = document.querySelector('.tbc-post-link.current'); if (currentLink) { // Expand all parent topics let parent = currentLink.closest('.tbc-topic'); while (parent) { parent.classList.add('expanded'); const topicId = parent.dataset.topicId; if (topicId) { state.expandedTopics.add(topicId); } parent = parent.parentElement.closest('.tbc-topic'); } // Scroll to current link setTimeout(() => { currentLink.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 200); } } // ================================ // Mobile Menu // ================================ function initMobileMenu() { const toggle = document.getElementById('tbc-mobile-toggle'); const sidebar = document.getElementById('tbc-sidebar'); const overlay = document.getElementById('tbc-overlay'); const closeBtn = document.getElementById('tbc-sidebar-close'); if (!toggle || !sidebar) return; function openSidebar() { state.isMobileOpen = true; sidebar.classList.add('open'); if (overlay) overlay.classList.add('active'); toggle.innerHTML = 'ร—'; toggle.setAttribute('aria-label', 'Close navigation'); } function closeSidebar() { state.isMobileOpen = false; sidebar.classList.remove('open'); if (overlay) overlay.classList.remove('active'); toggle.innerHTML = 'โ˜ฐ'; toggle.setAttribute('aria-label', 'Open navigation'); } toggle.addEventListener('click', () => { if (state.isMobileOpen) { closeSidebar(); } else { openSidebar(); } }); if (overlay) { overlay.addEventListener('click', closeSidebar); } if (closeBtn) { closeBtn.addEventListener('click', closeSidebar); } // Close on navigation sidebar.addEventListener('click', (e) => { if (e.target.classList.contains('tbc-post-link') || e.target.closest('#tbc-nav-links a')) { closeSidebar(); } }); } // ================================ // Keyboard Shortcuts // ================================ function initKeyboardShortcuts() { document.addEventListener('keydown', (e) => { const input = document.getElementById('tbc-search-input'); // Press '/' to focus search (like GitHub) if (e.key === '/' && document.activeElement !== input) { e.preventDefault(); if (input) input.focus(); } // Press Escape to clear and blur if (e.key === 'Escape') { if (document.activeElement === input) { input.value = ''; state.searchQuery = ''; resetSearch(); input.blur(); const clearBtn = document.getElementById('tbc-search-clear'); if (clearBtn) clearBtn.classList.add('hidden'); } // Close mobile menu if (state.isMobileOpen) { const sidebar = document.getElementById('tbc-sidebar'); const overlay = document.getElementById('tbc-overlay'); const toggle = document.getElementById('tbc-mobile-toggle'); if (sidebar) sidebar.classList.remove('open'); if (overlay) overlay.classList.remove('active'); if (toggle) { toggle.innerHTML = 'โ˜ฐ'; toggle.setAttribute('aria-label', 'Open navigation'); } state.isMobileOpen = false; } } }); } // ================================ // Initialize on DOM Ready // ================================ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initSidebar); } else { initSidebar(); } })(); /** * Chronological Timeline Column * * Adds an integrated right-side timeline navigation with: * - Previous/Next post links * - Year browser with expandable post lists */ (function() { 'use strict'; // ================================ // Configuration // ================================ const CHRONO_CONFIG = { dataUrl: 'toc/chrono-data.json', storageKeys: { expandedYears: 'tbc-chrono-expanded-years' } }; // Mobile Sheet Configuration const MOBILE_CONFIG = { breakpoint: 768, landscapeBreakpoint: 896, storageKeys: { sheetState: 'tbc-chrono-sheet-state', selectedYear: 'tbc-chrono-selected-year', selectedMonth: 'tbc-chrono-selected-month' }, swipeThreshold: 50, animationDuration: 250 }; // ================================ // State // ================================ const chronoState = { data: null, currentPostNum: null, expandedYears: new Set() }; // Mobile Sheet State const mobileState = { mode: 'collapsed', // 'collapsed' | 'years' | 'months' selectedYear: null, selectedMonth: null, touchStartY: 0, isLandscape: false, scrollPosition: 0 }; // Feature flag for mobile sheet (configurable at runtime) // Priority: // 1. URL query parameter: ?mobileSheet=true|false // 2. localStorage key: 'tbc-enable-mobile-sheet' ("true" | "false") // 3. Default: true const ENABLE_MOBILE_SHEET = (function() { try { if (typeof window !== 'undefined') { // URL query parameter override if (window.location && window.location.search) { const params = new URLSearchParams(window.location.search); const param = params.get('mobileSheet'); if (param === 'true' || param === '1') return true; if (param === 'false' || param === '0') return false; } // localStorage override if (window.localStorage) { const stored = window.localStorage.getItem('tbc-enable-mobile-sheet'); if (stored === 'true') return true; if (stored === 'false') return false; } } } catch (e) { // Ignore configuration errors and fall back to default } return true; })(); // ================================ // Utility Functions // ================================ function getCurrentPostNumber() { const file = window.location.pathname.split('/').pop(); const match = file.match(/^(\d{4})_/); return match ? parseInt(match[1]) : null; } function truncateTitle(title, maxLength = 50) { if (title.length <= maxLength) return title; return title.substring(0, maxLength - 3) + '...'; } // ================================ // Data Loading // ================================ async function loadChronoData() { // Try cache first const cached = localStorage.getItem('tbc-chrono-data'); const cacheTime = localStorage.getItem('tbc-chrono-cache-time'); const ONE_HOUR = 60 * 60 * 1000; if (cached && cacheTime && (Date.now() - parseInt(cacheTime)) < ONE_HOUR) { try { return JSON.parse(cached); } catch (e) { console.warn('Failed to parse cached chrono data'); } } // Determine base path const currentPath = window.location.pathname; let basePath = ''; if (currentPath.includes('/a/') && !currentPath.endsWith('/a/') && !currentPath.endsWith('/a/index.html')) { basePath = ''; } else if (currentPath.endsWith('/a/') || currentPath.endsWith('/a/index.html')) { basePath = ''; } else if (currentPath === '/index.html' || currentPath === '/' || currentPath.match(/^\/\d{4}_/)) { // Serving directly from a/ directory (local dev or subdomain) basePath = ''; } else { basePath = 'a/'; } const url = basePath + CHRONO_CONFIG.dataUrl; try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); // Cache the data try { localStorage.setItem('tbc-chrono-data', JSON.stringify(data)); localStorage.setItem('tbc-chrono-cache-time', Date.now().toString()); } catch (e) { console.warn('Failed to cache chrono data'); } return data; } catch (error) { console.error('Failed to load chrono-data.json:', error); return null; } } // ================================ // Navigation Helpers // ================================ function findPostByNum(posts, num) { return posts.find(p => p.num === num); } function findPrevNext(posts, currentNum) { const index = posts.findIndex(p => p.num === currentNum); if (index === -1) return { prev: null, next: null }; return { prev: index > 0 ? posts[index - 1] : null, next: index < posts.length - 1 ? posts[index + 1] : null }; } // ================================ // Render Timeline Column // ================================ function renderChronoColumn() { if (!chronoState.data) return; const currentNum = chronoState.currentPostNum; const { prev, next } = findPrevNext(chronoState.data.posts, currentNum); const currentPost = findPostByNum(chronoState.data.posts, currentNum); // Build HTML let html = ` `; // Create column element const column = document.createElement('aside'); column.className = 'tbc-chrono-column'; column.innerHTML = html; return column; } // ================================ // Year Expansion // ================================ function toggleYear(year) { const postsList = document.querySelector(`.tbc-chrono-year-posts[data-year="${year}"]`); if (!postsList) return; const isExpanded = postsList.classList.contains('expanded'); if (isExpanded) { postsList.classList.remove('expanded'); chronoState.expandedYears.delete(year); } else { // Load posts for this year if not already loaded if (!postsList.hasChildNodes() || postsList.children.length === 0) { loadYearPosts(year, postsList); } postsList.classList.add('expanded'); chronoState.expandedYears.add(year); } // Save state saveExpandedYears(); } function loadYearPosts(year, container) { const posts = chronoState.data.posts.filter(p => p.year === year); const currentNum = chronoState.currentPostNum; let html = ''; for (const post of posts.slice().reverse()) { // Show newest first within year const isCurrent = post.num === currentNum; html += `
  • ${String(post.num).padStart(4, '0')}: ${truncateTitle(post.title, 35)}
  • `; } container.innerHTML = html; } function saveExpandedYears() { try { const years = Array.from(chronoState.expandedYears); localStorage.setItem(CHRONO_CONFIG.storageKeys.expandedYears, JSON.stringify(years)); } catch (e) { console.warn('Failed to save expanded years'); } } function loadExpandedYears() { try { const stored = localStorage.getItem(CHRONO_CONFIG.storageKeys.expandedYears); if (stored) { const years = JSON.parse(stored); chronoState.expandedYears = new Set(years); } } catch (e) { console.warn('Failed to load expanded years'); } } // ================================ // Event Handlers // ================================ function initYearClickHandlers(column) { column.addEventListener('click', (e) => { const yearLink = e.target.closest('.tbc-chrono-year-link'); if (yearLink) { e.preventDefault(); const year = parseInt(yearLink.dataset.year); toggleYear(year); } }); } // ================================ // Keyboard Navigation // ================================ function initKeyboardNav() { document.addEventListener('keydown', (e) => { // Skip if user is typing in an input if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') { return; } const { prev, next } = findPrevNext(chronoState.data.posts, chronoState.currentPostNum); // '[' or left arrow for previous post if ((e.key === '[' || (e.key === 'ArrowLeft' && e.altKey)) && prev) { window.location.href = prev.file; } // ']' or right arrow for next post if ((e.key === ']' || (e.key === 'ArrowRight' && e.altKey)) && next) { window.location.href = next.file; } }); } // ================================ // DOM Integration // ================================ function waitForSidebar(maxWait = 5000) { return new Promise((resolve) => { const content = document.getElementById('tbc-content'); if (content) { resolve(content); return; } const startTime = Date.now(); const interval = setInterval(() => { const content = document.getElementById('tbc-content'); if (content) { clearInterval(interval); resolve(content); } else if (Date.now() - startTime > maxWait) { clearInterval(interval); resolve(null); } }, 50); }); } function wrapContentWithColumn(column, content) { if (!content) { console.warn('tbc-content not found, skipping chrono column'); return false; } // Create wrapper const wrapper = document.createElement('div'); wrapper.className = 'tbc-content-wrapper'; // Create content container const contentContainer = document.createElement('div'); contentContainer.className = 'tbc-blog-content'; // Move existing content into container while (content.firstChild) { contentContainer.appendChild(content.firstChild); } // Assemble wrapper wrapper.appendChild(contentContainer); wrapper.appendChild(column); // Add wrapper to content content.appendChild(wrapper); return true; } // ================================ // Mobile Sheet - Viewport Detection // ================================ function isMobileViewport() { return window.matchMedia(`(max-width: ${MOBILE_CONFIG.breakpoint}px)`).matches; } function isLandscape() { return window.matchMedia('(orientation: landscape)').matches; } // ================================ // Mobile Sheet - Body Scroll Lock // ================================ function setBodyScrollLock(locked) { if (locked) { mobileState.scrollPosition = window.scrollY; document.body.classList.add('tbc-sheet-open'); document.body.style.top = `-${mobileState.scrollPosition}px`; } else { document.body.classList.remove('tbc-sheet-open'); document.body.style.top = ''; window.scrollTo(0, mobileState.scrollPosition || 0); } } // ================================ // Mobile Sheet - Overlay Management // ================================ function setOverlayVisible(visible) { const overlay = document.querySelector('.tbc-chrono-overlay'); if (!overlay) return; if (visible) { overlay.classList.add('visible'); } else { overlay.classList.remove('visible'); } } // ================================ // Mobile Sheet - State Management // ================================ function setSheetMode(mode) { const sheet = document.querySelector('.tbc-chrono-mobile-sheet'); if (!sheet) return; sheet.classList.remove('collapsed', 'partial', 'expanded'); mobileState.mode = mode; switch (mode) { case 'collapsed': sheet.classList.add('collapsed'); setBodyScrollLock(false); setOverlayVisible(false); break; case 'years': sheet.classList.add('partial'); setBodyScrollLock(true); setOverlayVisible(true); renderYearGrid(); break; case 'months': sheet.classList.add('expanded'); setBodyScrollLock(true); setOverlayVisible(true); renderMonthView(); break; } saveMobileSheetState(); } // ================================ // Mobile Sheet - Year Grid Rendering // ================================ function renderYearGrid() { const container = document.querySelector('.tbc-sheet-content'); if (!container) return; if (!chronoState.data || !chronoState.data.years) { container.innerHTML = `
    ๐Ÿ“…
    Navigation unavailable
    Unable to load post data
    `; return; } const years = chronoState.data.years || []; if (years.length === 0) { container.innerHTML = `
    ๐Ÿ“
    No posts found
    `; return; } const currentYear = chronoState.currentPostNum ? findPostByNum(chronoState.data.posts, chronoState.currentPostNum)?.year : null; let html = `
    Browse by Year
    `; for (const yearData of years) { const isCurrent = yearData.year === currentYear; html += ` `; } html += '
    '; container.innerHTML = html; // Add click handlers container.querySelectorAll('.tbc-year-chip').forEach(chip => { chip.addEventListener('click', () => { mobileState.selectedYear = parseInt(chip.dataset.year); // Find the newest month with posts for this year const yearPosts = chronoState.data.posts.filter(p => p.year === mobileState.selectedYear); const months = [...new Set(yearPosts.map(p => p.month))]; mobileState.selectedMonth = Math.max(...months); setSheetMode('months'); }); }); } // ================================ // Mobile Sheet - Month View Rendering // ================================ function renderMonthView() { const container = document.querySelector('.tbc-sheet-content'); if (!container || !chronoState.data || !mobileState.selectedYear) return; const year = mobileState.selectedYear; const posts = chronoState.data.posts.filter(p => p.year === year); // Group by month const monthGroups = {}; for (const post of posts) { if (!monthGroups[post.month]) { monthGroups[post.month] = []; } monthGroups[post.month].push(post); } // Sort months descending const months = Object.keys(monthGroups) .map(m => parseInt(m)) .sort((a, b) => b - a); const selectedMonth = mobileState.selectedMonth || months[0]; const monthNames = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const isLandscapeMode = isLandscape() && window.matchMedia(`(max-width: ${MOBILE_CONFIG.landscapeBreakpoint}px)`).matches; let html = `
    ${year} ${posts.length} posts
    `; if (isLandscapeMode) { // Landscape: side-by-side layout html += '
    '; html += '
    '; for (const month of months) { const isActive = month === selectedMonth; const count = monthGroups[month].length; html += ` `; } html += '
    '; } else { // Portrait: horizontal tabs html += '
    '; for (const month of months) { const isActive = month === selectedMonth; const count = monthGroups[month].length; html += ` `; } html += '
    '; } html += '
    '; // Render posts for selected month (newest first) const monthPosts = monthGroups[selectedMonth] || []; const currentNum = chronoState.currentPostNum; for (const post of monthPosts.slice().reverse()) { const isCurrent = post.num === currentNum; const escapedTitle = escapeHtml(post.title); html += ` #${String(post.num).padStart(4, '0')} ${escapedTitle} `; } html += '
    '; if (isLandscapeMode) { html += '
    '; // Close post-list-main and content-landscape } container.innerHTML = html; // Add event handlers container.querySelector('.tbc-sheet-back').addEventListener('click', () => { setSheetMode('years'); }); container.querySelector('.tbc-sheet-close').addEventListener('click', () => { setSheetMode('collapsed'); }); container.querySelectorAll('.tbc-month-tab').forEach(tab => { tab.addEventListener('click', () => { mobileState.selectedMonth = parseInt(tab.dataset.month); renderMonthView(); }); }); // Scroll active month tab into view const activeTab = container.querySelector('.tbc-month-tab.active'); if (activeTab && !isLandscapeMode) { const prefersReducedMotion = typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; const scrollBehavior = prefersReducedMotion ? 'auto' : 'smooth'; activeTab.scrollIntoView({ behavior: scrollBehavior, block: 'nearest', inline: 'center' }); } } // Helper to escape HTML function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ================================ // Mobile Sheet - Touch Gesture Handling // ================================ function initTouchGestures(sheet) { let startY = 0; let currentY = 0; sheet.addEventListener('touchstart', (e) => { startY = e.touches[0].clientY; }, { passive: true }); sheet.addEventListener('touchmove', (e) => { currentY = e.touches[0].clientY; }, { passive: true }); sheet.addEventListener('touchend', () => { const deltaY = startY - currentY; if (Math.abs(deltaY) > MOBILE_CONFIG.swipeThreshold) { if (deltaY > 0) { // Swipe up - expand if (mobileState.mode === 'collapsed') { setSheetMode('years'); } } else { // Swipe down - collapse if (mobileState.mode === 'years') { setSheetMode('collapsed'); } else if (mobileState.mode === 'months') { setSheetMode('years'); } } } }); } // ================================ // Mobile Sheet - Focus Trap // ================================ function initFocusTrap(container) { const focusableSelector = 'button, a[href], input, [tabindex]:not([tabindex="-1"])'; container.addEventListener('keydown', (e) => { // Escape key closes sheet if (e.key === 'Escape') { e.preventDefault(); setSheetMode('collapsed'); return; } // Tab trap if (e.key === 'Tab') { const focusable = container.querySelectorAll(focusableSelector); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }); } // ================================ // Mobile Sheet - State Persistence // ================================ function saveMobileSheetState() { try { localStorage.setItem(MOBILE_CONFIG.storageKeys.sheetState, mobileState.mode); if (mobileState.selectedYear) { localStorage.setItem(MOBILE_CONFIG.storageKeys.selectedYear, mobileState.selectedYear.toString()); } if (mobileState.selectedMonth) { localStorage.setItem(MOBILE_CONFIG.storageKeys.selectedMonth, mobileState.selectedMonth.toString()); } } catch (e) { console.warn('Failed to save mobile sheet state to localStorage'); console.warn('Failed to save mobile sheet state to localStorage:', e); } } function loadMobileSheetState() { try { const mode = localStorage.getItem(MOBILE_CONFIG.storageKeys.sheetState); const year = localStorage.getItem(MOBILE_CONFIG.storageKeys.selectedYear); const month = localStorage.getItem(MOBILE_CONFIG.storageKeys.selectedMonth); if (year) mobileState.selectedYear = parseInt(year); if (month) mobileState.selectedMonth = parseInt(month); if (mode && ['collapsed', 'years', 'months'].includes(mode)) { return mode; } } catch (e) { console.warn('Failed to load mobile sheet state from localStorage:', e); } return 'collapsed'; } // ================================ // Mobile Sheet - Context Label Update // ================================ function updateContextLabel() { const label = document.querySelector('.tbc-sheet-context'); if (!label || !chronoState.data) return; const currentNum = chronoState.currentPostNum; if (currentNum) { const post = findPostByNum(chronoState.data.posts, currentNum); if (post) { const yearPosts = chronoState.data.posts.filter(p => p.year === post.year); const yearIndex = yearPosts.findIndex(p => p.num === currentNum) + 1; label.textContent = `${post.year} ยท Post ${yearIndex} of ${yearPosts.length}`; return; } } label.textContent = `${chronoState.data.posts.length} posts ยท Tap to browse`; } // ================================ // Mobile Sheet - Initialization // ================================ function initMobileSheet() { if (!isMobileViewport()) return; // Mark body for CSS targeting document.body.classList.add('tbc-has-chrono-sheet'); // Create overlay const overlay = document.createElement('div'); overlay.className = 'tbc-chrono-overlay'; overlay.addEventListener('click', () => setSheetMode('collapsed')); // Create sheet const sheet = document.createElement('div'); sheet.className = 'tbc-chrono-mobile-sheet collapsed'; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Chronological post navigation'); sheet.innerHTML = `
    `; document.body.appendChild(overlay); document.body.appendChild(sheet); // Initialize touch gestures initTouchGestures(sheet); // Initialize focus trap initFocusTrap(sheet); // Set up collapsed bar click sheet.querySelector('.tbc-sheet-collapsed-bar').addEventListener('click', () => { setSheetMode('years'); }); // Update context label updateContextLabel(); // Restore state (but start collapsed to avoid jarring experience) const savedMode = loadMobileSheetState(); // Only restore non-collapsed state if there was user selection if (savedMode !== 'collapsed' && mobileState.selectedYear) { if (savedMode === 'months' && !mobileState.selectedMonth) { // Months view requires both a year and a month; fall back to years view if month is missing setSheetMode('years'); } else { setSheetMode(savedMode); } } // Handle orientation changes window.matchMedia('(orientation: landscape)').addEventListener('change', () => { mobileState.isLandscape = isLandscape(); if (mobileState.mode === 'months') { renderMonthView(); // Re-render for layout change } }); // Handle viewport resize let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (!isMobileViewport() && mobileState.mode !== 'collapsed') { setSheetMode('collapsed'); } }, 150); }); console.log('Mobile chrono sheet initialized'); } // ================================ // Initialize // ================================ async function initChronoColumn() { // Wait for sidebar to create #tbc-content const content = await waitForSidebar(); if (!content) { console.warn('Sidebar not initialized, skipping chrono column'); return; } // Get current post number (null for index.html) chronoState.currentPostNum = getCurrentPostNumber(); // Load saved state loadExpandedYears(); // Load data chronoState.data = await loadChronoData(); if (!chronoState.data) { console.warn('Failed to load chronological data'); return; } // Render column (works for both index and post pages) const column = renderChronoColumn(); if (!column) return; // Integrate into DOM const success = wrapContentWithColumn(column, content); if (!success) return; // Initialize event handlers initYearClickHandlers(column); // Only init keyboard nav on post pages if (chronoState.currentPostNum) { initKeyboardNav(); // Auto-expand current year const currentPost = findPostByNum(chronoState.data.posts, chronoState.currentPostNum); if (currentPost && !chronoState.expandedYears.has(currentPost.year)) { toggleYear(currentPost.year); } } else { // On index page, expand the most recent year const years = chronoState.data.years || []; if (years.length > 0 && !chronoState.expandedYears.has(years[0].year)) { toggleYear(years[0].year); } } // Initialize mobile sheet if enabled and on mobile viewport if (ENABLE_MOBILE_SHEET && isMobileViewport()) { initMobileSheet(); } console.log('Chrono column initialized'); } // ================================ // Run on DOM Ready // ================================ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initChronoColumn); } else { initChronoColumn(); } })(); // ================================ // In-Page Content Highlighting (Independent Module) // ================================ (function initContentHighlightModule() { 'use strict'; const HIGHLIGHT_CONFIG = { paramName: 'highlight', className: 'tbc-content-highlight', maxHighlights: 100, scrollToFirst: true, animateDuration: 2000 }; /** * Initialize in-page content highlighting from URL parameter */ function initContentHighlighting() { const urlParams = new URLSearchParams(window.location.search); const highlightTerm = urlParams.get(HIGHLIGHT_CONFIG.paramName); if (!highlightTerm || highlightTerm.trim().length < 2) return; const term = highlightTerm.trim(); console.log(`Highlighting content for: "${term}"`); // Wait for content to be ready requestAnimationFrame(() => { highlightContentMatches(term); }); } /** * Escape special regex characters */ function escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Highlight all matches of term in the page content */ function highlightContentMatches(term) { // Target the main content area (avoid sidebar, nav, code blocks) const contentArea = document.querySelector('#tbc-content .tbc-blog-content') || document.querySelector('#tbc-content article') || document.querySelector('#tbc-content') || document.querySelector('article') || document.body; if (!contentArea) { console.warn('No content area found for highlighting'); return; } // Elements to skip const skipSelectors = [ 'script', 'style', 'noscript', 'iframe', 'pre', 'code', // Skip code blocks '.tbc-sidebar', '#tbc-sidebar', '.tbc-chrono-column', '.tbc-chrono-nav', '.tbc-mobile-sheet', '.tbc-highlight-counter' ]; // Walk text nodes and highlight matches const treeWalker = document.createTreeWalker( contentArea, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { // Skip empty nodes if (!node.textContent.trim()) { return NodeFilter.FILTER_REJECT; } // Skip nodes inside excluded elements let parent = node.parentElement; while (parent) { if (skipSelectors.some(sel => parent.matches && parent.matches(sel))) { return NodeFilter.FILTER_REJECT; } parent = parent.parentElement; } return NodeFilter.FILTER_ACCEPT; } } ); const nodesToHighlight = []; const regex = new RegExp(`(${escapeRegex(term)})`, 'gi'); // Collect nodes first (can't modify DOM while walking) let node; while ((node = treeWalker.nextNode())) { if (regex.test(node.textContent)) { nodesToHighlight.push(node); regex.lastIndex = 0; // Reset regex } } if (nodesToHighlight.length === 0) { console.log('No matches found for highlighting'); return; } let totalHighlights = 0; const allMarks = []; // Apply highlights nodesToHighlight.forEach(textNode => { if (totalHighlights >= HIGHLIGHT_CONFIG.maxHighlights) return; const text = textNode.textContent; const parts = text.split(regex); if (parts.length <= 1) return; const fragment = document.createDocumentFragment(); parts.forEach(part => { if (part.match(regex)) { if (totalHighlights < HIGHLIGHT_CONFIG.maxHighlights) { const mark = document.createElement('mark'); mark.className = HIGHLIGHT_CONFIG.className; mark.textContent = part; fragment.appendChild(mark); allMarks.push(mark); totalHighlights++; } else { fragment.appendChild(document.createTextNode(part)); } } else { fragment.appendChild(document.createTextNode(part)); } }); textNode.parentNode.replaceChild(fragment, textNode); }); console.log(`Highlighted ${totalHighlights} matches`); // Create counter badge if (allMarks.length > 0) { createHighlightCounter(allMarks, term); // Scroll to first match if (HIGHLIGHT_CONFIG.scrollToFirst) { setTimeout(() => { allMarks[0].classList.add('tbc-highlight-pulse'); allMarks[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); } } } /** * Create floating counter badge with navigation */ function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function createHighlightCounter(allMarks, term) { const counter = document.createElement('div'); counter.className = 'tbc-highlight-counter'; const displayTerm = term.length > 20 ? term.substring(0, 20) + '...' : term; const escapedTerm = escapeHtml(displayTerm); counter.innerHTML = ` ${allMarks.length} matches for "${escapedTerm}" `; document.body.appendChild(counter); let currentIndex = 0; // Keyboard navigation function highlightKeyHandler(e) { if (e.key === 'Escape') { cleanup(); } else if (e.key === 'F3' || (e.ctrlKey && e.key === 'g')) { e.preventDefault(); if (e.shiftKey) { currentIndex = (currentIndex - 1 + allMarks.length) % allMarks.length; } else { currentIndex = (currentIndex + 1) % allMarks.length; } scrollToHighlight(allMarks, currentIndex); } } document.addEventListener('keydown', highlightKeyHandler); // Cleanup function to remove highlights, counter, and event listener function cleanup() { clearContentHighlights(); counter.remove(); document.removeEventListener('keydown', highlightKeyHandler); // Remove highlight param from URL const url = new URL(window.location); url.searchParams.delete(HIGHLIGHT_CONFIG.paramName); window.history.replaceState({}, '', url); } // Clear button counter.querySelector('.tbc-highlight-clear').addEventListener('click', () => { cleanup(); }); // Navigation buttons counter.querySelector('.tbc-highlight-prev').addEventListener('click', () => { if (allMarks.length === 0) return; currentIndex = (currentIndex - 1 + allMarks.length) % allMarks.length; scrollToHighlight(allMarks, currentIndex); }); counter.querySelector('.tbc-highlight-next').addEventListener('click', () => { if (allMarks.length === 0) return; currentIndex = (currentIndex + 1) % allMarks.length; scrollToHighlight(allMarks, currentIndex); }); } /** * Scroll to a specific highlight */ function scrollToHighlight(allMarks, index) { // Remove active class from all document.querySelectorAll('.' + HIGHLIGHT_CONFIG.className + '.active') .forEach(m => m.classList.remove('active')); // Add active class and scroll const mark = allMarks[index]; if (mark) { mark.classList.add('active'); mark.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } /** * Clear all content highlights */ function clearContentHighlights() { const marks = document.querySelectorAll('.' + HIGHLIGHT_CONFIG.className); marks.forEach(mark => { const text = document.createTextNode(mark.textContent); mark.parentNode.replaceChild(text, mark); }); // Normalize to merge adjacent text nodes document.body.normalize(); } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initContentHighlighting); } else { initContentHighlighting(); } })();