ca790256fe78136e840f27bd251e932fd0658e0c chmalee Thu Dec 11 12:39:30 2025 -0800 Fix bug in trackDbHub doc search: we were re-doing the search after marking up the first set of found elements, causing multiple highlights for a single match, which made cycling through found elements behave oddly, refs #36831 diff --git src/hg/htdocs/goldenPath/help/trackDb/trackDbDoc.js src/hg/htdocs/goldenPath/help/trackDb/trackDbDoc.js index 2ff43e9da2c..08083e94484 100644 --- src/hg/htdocs/goldenPath/help/trackDb/trackDbDoc.js +++ src/hg/htdocs/goldenPath/help/trackDb/trackDbDoc.js @@ -537,30 +537,35 @@ behavior: "smooth" }); // Also focus the search input for easy continued searching setTimeout(() => { inp.focus(); }, 500); }); } } } function searchHidden(term) { // Reset previous search clearSearchHighlights(); + // track elements already processed to prevent repeated ing + if (!window.__searchedElements) { + window.__searchedElements = new WeakSet(); + } + if (!term || term.length < 2) { return []; } let results = []; let escapedTerm = escapeRegexSpecialChars(term); // Strategy: Search ALL content and sort results by document position // First, collect all searchable elements let allElements = []; // 1. Add visible format divs (which contain code blocks with setting names) let formatDivs = document.querySelectorAll('div.format'); formatDivs.forEach(element => { @@ -595,30 +600,41 @@ }); // Sort elements by their position in the document (top to bottom) allElements.sort((a, b) => { let position = a.compareDocumentPosition(b); if (position & Node.DOCUMENT_POSITION_FOLLOWING) { return -1; // a comes before b } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { return 1; // a comes after b } return 0; // same position }); // Now search through elements in document order allElements.forEach(element => { + // skip elements already searched + if (window.__searchedElements.has(element)) { + return; + } + window.__searchedElements.add(element); + + // skip anything inside an existing + if (element.closest && element.closest("mark")) { + return; + } + let foundMatches = searchAndHighlightInElement(element, escapedTerm); if (foundMatches.length > 0) { results.push(...foundMatches); } }); // 5. Find all toggle elements that might have hidden content not yet opened let toggles = [...document.querySelectorAll("[class]")].filter(el => [...el.classList].some(c => c.endsWith("_imgToggle"))); // Sort toggles by document position too toggles.sort((a, b) => { let position = a.compareDocumentPosition(b); if (position & Node.DOCUMENT_POSITION_FOLLOWING) { return -1; @@ -626,49 +642,64 @@ return 1; } return 0; }); toggles.forEach((toggle) => { let detailsId = toggle.parentNode.className; if (!detailsId) return; let td = toggle.parentNode; let existingDetails = $(td).find('div.details'); // Only search library content if details are not already visible if (existingDetails.length === 0) { + if (window.__searchedElements.has(td)) { + return; + } + window.__searchedElements.add(td); + let blurb = tdbDoc.library.lookup(detailsId, false); if (blurb && blurb.length > 0) { // Check if library content contains the search term let hasMatch = false; blurb.each((ix, div) => { if (div.textContent && div.textContent.toLowerCase().includes(term.toLowerCase())) { hasMatch = true; return false; // break out of jQuery each } }); if (hasMatch) { // Show the details to move content from library to document tdbDoc.toggleDetails(toggle, true); // Now search the newly visible content and insert results in correct position let newDetails = $(td).find('div.details'); let newMatches = []; newDetails.each((ix, docDiv) => { + // avoid re-searching if run multiple times + if (window.__searchedElements.has(docDiv)) { + return; + } + window.__searchedElements.add(docDiv); + + // skip elements inside + if (docDiv.closest && docDiv.closest("mark")) { + return; + } let docMatches = searchAndHighlightInElement(docDiv, escapedTerm); newMatches.push(...docMatches); }); // Insert new matches in the correct position relative to existing results if (newMatches.length > 0) { // Find where to insert these results to maintain document order let insertIndex = results.length; for (let i = 0; i < results.length; i++) { let position = newMatches[0].compareDocumentPosition(results[i]); if (position & Node.DOCUMENT_POSITION_FOLLOWING) { insertIndex = i; break; } } @@ -707,54 +738,69 @@ if (!element) return; let marks = element.querySelectorAll("mark"); marks.forEach(mark => { let parent = mark.parentNode; if (parent) { let textNode = document.createTextNode(mark.textContent); parent.replaceChild(textNode, mark); parent.normalize(); } }); } function searchAndHighlightInElement(element, escapedTerm) { let results = []; - let regex = new RegExp(`(${escapedTerm})`, "gi"); + let regexFactory = () => new RegExp(`(${escapedTerm})`, "gi"); + + // track processed text nodes so they don't get re-ed + if (!window.__processedTextNodes) { + window.__processedTextNodes = new WeakSet(); + } // Create a tree walker to traverse only text nodes let walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, null, false ); let textNodes = []; let node; // Collect all text nodes first to avoid modifying tree during traversal while (node = walker.nextNode()) { if (node.textContent.trim().length > 0) { + // skip already processed nodes + if (window.__processedTextNodes.has(node)) continue; + // skip anything inside a + if (node.parentNode.closest && node.parentNode.closest("mark")) continue; + window.__processedTextNodes.add(node); + textNodes.push(node); } } // Process each text node textNodes.forEach(textNode => { let text = textNode.textContent; - if (regex.test(text)) { + + // create a fresh regex to avoid lastIndex corruption + const regex = regexFactory(); + + if (regex.exec(text)) { // Create a document fragment to hold the new nodes let fragment = document.createDocumentFragment(); let lastIndex = 0; let match; // Reset regex lastIndex for global search regex.lastIndex = 0; while ((match = regex.exec(text)) !== null) { // Add text before match if (match.index > lastIndex) { fragment.appendChild( document.createTextNode(text.substring(lastIndex, match.index)) ); } @@ -848,30 +894,33 @@ console.log(`Retrying scroll (${retries} retries left)`); setTimeout(() => attemptScroll(retries - 1), 50); } } } attemptScroll(); } function runSearch(term) { // Clear previous results currentResults = []; currentIndex = -1; + // Reset processed element tracking on each search + window.__searchedElements = new WeakSet(); + if (!term || term.length < 2) { // Show message for short terms if (term.length === 1) { showSearchStatus(term, 0); } else { hideSearchStatus(); } return; } currentResults = searchHidden(term); // Always show search results count for valid searches showSearchStatus(term, currentResults.length);