diff --git a/layouts/partials/custom_js.html b/layouts/partials/custom_js.html new file mode 100644 index 0000000000..6483386262 --- /dev/null +++ b/layouts/partials/custom_js.html @@ -0,0 +1,6 @@ +{{/* Custom JavaScript for FORRT site */}} + +{{/* Include clusters search functionality on clusters page */}} +{{ if eq .RelPermalink "/clusters/" }} + +{{ end }} diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js new file mode 100644 index 0000000000..12ea57b95a --- /dev/null +++ b/static/js/clusters-search.js @@ -0,0 +1,582 @@ +/** + * Clusters Page Search Functionality + * Enables searching within Bootstrap tab content that would otherwise be hidden from Ctrl-F + */ +(function() { + 'use strict'; + + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + function init() { + // Only run on clusters page + if (!window.location.pathname.includes('/clusters')) { + return; + } + + createSearchInterface(); + setupSearchHandlers(); + } + + function createSearchInterface() { + // Create sidebar container + const sidebar = document.createElement('div'); + sidebar.id = 'clusterSearchSidebar'; + sidebar.className = 'cluster-search-sidebar'; + sidebar.innerHTML = ` + +
+
+

Search Clusters

+ +
+
+
+ +
+ +
+
+ +
+
+
+ `; + + // Insert at beginning of body + document.body.insertBefore(sidebar, document.body.firstChild); + } + + function setupSearchHandlers() { + const searchInput = document.getElementById('clusterSearchInput'); + const searchBtn = document.getElementById('clusterSearchBtn'); + const clearBtn = document.getElementById('clusterClearBtn'); + const resultsDiv = document.getElementById('clusterSearchResults'); + const toggleBtn = document.getElementById('clusterSearchToggle'); + const closeBtn = document.getElementById('clusterSearchClose'); + const panel = document.getElementById('clusterSearchPanel'); + + if (!searchInput || !searchBtn || !clearBtn || !toggleBtn || !closeBtn || !panel) return; + + // Toggle sidebar + toggleBtn.addEventListener('click', function() { + panel.classList.toggle('open'); + if (panel.classList.contains('open')) { + searchInput.focus(); + } + }); + + // Close sidebar + closeBtn.addEventListener('click', function() { + panel.classList.remove('open'); + }); + + // Close on escape key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && panel.classList.contains('open')) { + panel.classList.remove('open'); + } + }); + + // Search on button click + searchBtn.addEventListener('click', performSearch); + + // Search on Enter key + searchInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + performSearch(); + } + }); + + // Live search as user types (debounced) + var debounceTimer; + searchInput.addEventListener('input', function() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(performSearch, 300); + }); + + // Clear search + clearBtn.addEventListener('click', function() { + searchInput.value = ''; + resultsDiv.innerHTML = ''; + clearBtn.style.display = 'none'; + removeAllHighlights(); + collapseAllTabs(); + }); + } + + function performSearch() { + const searchInput = document.getElementById('clusterSearchInput'); + const clearBtn = document.getElementById('clusterClearBtn'); + const resultsDiv = document.getElementById('clusterSearchResults'); + const query = searchInput.value.trim(); + + if (!query || query.length < 2) { + resultsDiv.innerHTML = query.length > 0 ? '
Please enter at least 2 characters to search.
' : ''; + clearBtn.style.display = 'none'; + removeAllHighlights(); + return; + } + + // Remove previous highlights + removeAllHighlights(); + + // Search through all tab content + const results = searchAllTabs(query); + + // Display results + displayResults(results, query); + + // Show clear button + clearBtn.style.display = 'inline-block'; + } + + function searchAllTabs(query) { + const results = []; + const queryLower = query.toLowerCase(); + + // Find all cluster sections + const clusterSections = document.querySelectorAll('section[id^="cluster"]'); + + clusterSections.forEach(function(section) { + // Use h1 to get the actual cluster title (h3 is "Description" in the content) + const clusterTitle = section.querySelector('h1, .home-section-title'); + const clusterName = clusterTitle ? clusterTitle.textContent.trim() : 'Unknown Cluster'; + + // Find all tab panes in this cluster + const tabPanes = section.querySelectorAll('.tab-pane'); + + tabPanes.forEach(function(tabPane) { + const tabId = tabPane.id; + + // Get tab label + const tabLink = section.querySelector('a[href="#' + tabId + '"]'); + const tabLabel = tabLink ? tabLink.textContent.trim() : tabId; + const tabLabelLower = tabLabel.toLowerCase(); + + // If the tab label itself matches, add a result for it + if (tabLabelLower.includes(queryLower)) { + var regex = new RegExp('(' + escapeRegExp(query) + ')', 'gi'); + results.push({ + cluster: clusterName, + tab: tabLabel, + tabId: tabId, + snippet: 'Tab: ' + tabLabel.replace(regex, '$1'), + section: section, + tabPane: tabPane, + tabLink: tabLink, + element: tabPane + }); + } + + // Search each block-level element individually for per-paragraph results + // Filter out nested blocks (e.g.

inside

  • ) to avoid duplicates + var allBlocks = tabPane.querySelectorAll('li, p, h2, h3, h4, h5, h6, blockquote'); + var blocks = Array.from(allBlocks).filter(function(block) { + var parent = block.parentElement; + while (parent && parent !== tabPane) { + if (parent.matches('li, p, blockquote')) return false; + parent = parent.parentElement; + } + return true; + }); + + blocks.forEach(function(block) { + var text = block.textContent || ''; + if (text.toLowerCase().includes(queryLower)) { + var snippetText = text.trim(); + if (snippetText.length > 200) { + snippetText = snippetText.substring(0, 200) + '...'; + } + var highlightRegex = new RegExp('(' + escapeRegExp(query) + ')', 'gi'); + var snippet = escapeHtml(snippetText).replace(highlightRegex, '$1'); + + results.push({ + cluster: clusterName, + tab: tabLabel, + tabId: tabId, + snippet: snippet, + section: section, + tabPane: tabPane, + tabLink: tabLink, + element: block + }); + } + }); + }); + }); + + return results; + } + + function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function displayResults(results, query) { + const resultsDiv = document.getElementById('clusterSearchResults'); + + if (results.length === 0) { + resultsDiv.innerHTML = '
    No results found for "' + escapeHtml(query) + '".
    '; + return; + } + + var html = '
    Found ' + results.length + ' result(s)
    '; + html += '
    '; + + results.forEach(function(result, index) { + html += '
    ' + + '
    ' + escapeHtml(result.tab) + '
    ' + + '
    ' + escapeHtml(result.cluster) + '
    ' + + '
    ' + result.snippet + '
    ' + + '
    '; + }); + + html += '
    '; + resultsDiv.innerHTML = html; + + // Add click handlers to results + var resultItems = resultsDiv.querySelectorAll('.search-result-item'); + resultItems.forEach(function(item) { + item.addEventListener('click', function() { + var index = parseInt(this.getAttribute('data-result-index')); + var result = results[index]; + if (!result) return; + + removeAllHighlights(); + activateTab(result); + highlightMatches(result.tabPane, query); + + // Scroll to the specific element (paragraph/reference) + setTimeout(function() { + scrollToElement(result.element); + }, 100); + + // Mark as active + resultItems.forEach(function(r) { r.classList.remove('active'); }); + this.classList.add('active'); + // Auto-hide the search panel + document.getElementById('clusterSearchPanel').classList.remove('open'); + }); + }); + + // Auto-expand first result + if (results.length > 0) { + resultItems[0].classList.add('active'); + activateTab(results[0]); + highlightMatches(results[0].tabPane, query); + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function activateTab(result) { + // Collapse all tabs first + collapseAllTabs(); + + // Activate the target tab + if (result.tabLink) { + result.tabLink.click(); + } + } + + function collapseAllTabs() { + document.querySelectorAll('.tab-pane.show.active').forEach(function(pane) { + pane.classList.remove('show', 'active'); + }); + document.querySelectorAll('.nav-link.active').forEach(function(link) { + link.classList.remove('active'); + }); + } + + function scrollToElement(element) { + // Get navbar height to offset the scroll + const navbar = document.querySelector('.navbar-fixed-top, .fixed-top, nav.navbar'); + const navbarHeight = navbar ? navbar.offsetHeight : 70; + + // Calculate the position to scroll to + const elementPosition = element.getBoundingClientRect().top + window.pageYOffset; + const offsetPosition = elementPosition - navbarHeight - 20; // Extra 20px padding + + // Scroll to the calculated position + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + } + + function highlightMatches(tabPane, query) { + if (!tabPane) return; + + // Remove existing highlights first + removeHighlightsInElement(tabPane); + + // Use mark.js-like approach to highlight matches + const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); + + // Get all text nodes + const walker = document.createTreeWalker( + tabPane, + NodeFilter.SHOW_TEXT, + null, + false + ); + + const textNodes = []; + let node; + while ((node = walker.nextNode())) { + // Skip if parent is already a mark + if (node.parentElement.tagName !== 'MARK') { + textNodes.push(node); + } + } + + textNodes.forEach(function(textNode) { + const text = textNode.textContent; + const testRegex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); + if (testRegex.test(text)) { + const fragment = document.createDocumentFragment(); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = text.replace(regex, '$1'); + while (tempDiv.firstChild) { + fragment.appendChild(tempDiv.firstChild); + } + textNode.parentNode.replaceChild(fragment, textNode); + } + }); + } + + function removeAllHighlights() { + document.querySelectorAll('.cluster-highlight').forEach(function(mark) { + const parent = mark.parentNode; + parent.replaceChild(document.createTextNode(mark.textContent), mark); + parent.normalize(); + }); + } + + function removeHighlightsInElement(element) { + element.querySelectorAll('.cluster-highlight').forEach(function(mark) { + const parent = mark.parentNode; + parent.replaceChild(document.createTextNode(mark.textContent), mark); + parent.normalize(); + }); + } + + // Add CSS for sidebar and highlighting + const style = document.createElement('style'); + style.textContent = ` + /* Highlight styling */ + .cluster-highlight { + background-color: #ffeb3b; + padding: 2px 0; + font-weight: bold; + } + + /* Sidebar toggle button */ + .cluster-search-toggle { + position: fixed; + left: 0; + top: 200px; + background: #007bff; + color: white; + border: none; + border-radius: 0 5px 5px 0; + padding: 12px 15px; + cursor: pointer; + z-index: 1034; + box-shadow: 2px 2px 5px rgba(0,0,0,0.2); + font-size: 14px; + transition: background 0.3s; + } + + .cluster-search-toggle:hover { + background: #0056b3; + } + + .cluster-search-toggle i { + margin-right: 5px; + } + + /* Sidebar panel */ + .cluster-search-panel { + position: fixed; + left: -350px; + top: 70px; + width: 350px; + height: calc(100vh - 70px); + background: white; + box-shadow: 2px 0 10px rgba(0,0,0,0.1); + z-index: 1035; + transition: left 0.3s ease; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .cluster-search-panel.open { + left: 0; + } + + /* Sidebar header */ + .cluster-search-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #dee2e6; + background: #f8f9fa; + } + + .cluster-search-header h4 { + margin: 0; + font-size: 18px; + color: #333; + } + + .cluster-search-close { + background: none; + border: none; + font-size: 20px; + color: #666; + cursor: pointer; + padding: 5px; + line-height: 1; + } + + .cluster-search-close:hover { + color: #333; + } + + /* Sidebar body */ + .cluster-search-body { + padding: 20px; + flex: 1; + overflow-y: auto; + } + + /* Search results summary */ + .search-summary { + background: #e7f3ff; + padding: 10px; + border-radius: 5px; + margin-bottom: 15px; + font-size: 0.9rem; + color: #004085; + } + + /* Search results list */ + .search-results-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + /* Individual search result */ + .search-result-item { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 5px; + padding: 12px; + cursor: pointer; + transition: all 0.2s; + } + + .search-result-item:hover { + background: #e9ecef; + border-color: #007bff; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + + .search-result-item.active { + background: #e7f3ff; + border-color: #007bff; + box-shadow: inset 0 0 0 1px #007bff; + } + + .search-result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + } + + .search-result-header strong { + font-size: 0.95rem; + color: #333; + flex: 1; + margin-right: 10px; + } + + .search-result-header .badge { + font-size: 0.75rem; + } + + .search-result-cluster { + font-size: 0.8rem; + color: #666; + margin-bottom: 8px; + } + + .search-result-snippet { + font-size: 0.8rem; + color: #555; + line-height: 1.4; + } + + .search-result-snippet mark { + background-color: #ffeb3b; + padding: 1px 2px; + font-weight: 600; + } + + /* Mobile responsive */ + @media (max-width: 768px) { + .cluster-search-panel { + width: 70%; + left: -70%; + } + + .cluster-search-panel.open { + left: 0; + } + + .cluster-search-toggle { + top: 170px; + font-size: 14px; + padding: 10px; + } + + .cluster-search-toggle-text { + display: none; + } + } + + /* Alert styling in sidebar */ + .cluster-search-body .alert { + font-size: 0.85rem; + padding: 8px 12px; + } + `; + document.head.appendChild(style); + +})();