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 <mark>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 <mark>
+        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 <mark>
+                        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-<mark>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 <mark>
+            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);