08ca8e04a5140c74052299e34a1d811f9bcf4d3c chmalee Thu Dec 11 12:13:19 2025 -0800 Add message to trackDb hub document search that informs shift+enter can be used to jump backwards, refs #36831 diff --git src/hg/htdocs/goldenPath/help/trackDb/trackDbDoc.js src/hg/htdocs/goldenPath/help/trackDb/trackDbDoc.js index 13c46dddd65..2ff43e9da2c 100644 --- src/hg/htdocs/goldenPath/help/trackDb/trackDbDoc.js +++ src/hg/htdocs/goldenPath/help/trackDb/trackDbDoc.js @@ -1,954 +1,954 @@ // Used by trackDbDoc.html and friends function jumpTo(obj) { // jumps to an anchor based upon text of obj //alert("url:"+window.location+"#"+obj.text); obj.href="#"+obj.text; return true; } var tdbDoc = { sortNoCase: function (x,y) { // case insensitive sort routine // perhaps move this to utils.js // NOTE: localeCompare() is not supported by all browsers. var a = String(x).toUpperCase(); var b = String(y).toUpperCase(); if (a > b) return 1 if (a < b) return -1 return 0; }, library: { // separate namespace for library handling checkClasses: function (classes) { // trackDbTestBlurb.html only // Walks through the classes and ensures they are unique //warn("class count:"+classes.length); $(classes).each(function (ix) { var divs = $('div#library').find( 'div.'+this ); if (divs.length > 1) { warn("Found more than one DIV of class='"+this+"' in library."); return false; } }); return true; }, checkBlurb: function (div) { // trackDbTestBlurb.html only // check that a single div is conforming and return classes var classes = $(div).attr("class").split(" "); if (classes.length > 1) { warn('DIV with more than one class: '+ classes); } else if (classes.length === 1) { if (classes[0].indexOf('_intro') === -1) { var types = $(div).find('span.types'); if (types.length === 0) warn('DIV '+classes[0]+' missing "types" span.'); else if (types.length > 1) warn('DIV '+classes[0]+' more than one "types" span:'+types.length); var format = $(div).find('div.format'); if (format.length === 0) warn('DIV '+classes[0]+' missing "format" DIV.'); else if (format.length > 1) warn('DIV '+classes[0]+' more than one "format" DIV:'+format.length); } } return classes; }, checkup: function () { // trackDbTestBlurb.html only // This routine runs the library through unique class check var allClasses = []; $('div#library').find('div').not('.format,.hintBox').each(function (ix) { var classes = tdbDoc.library.checkBlurb(this); if ( classes.length === 0 ) return true; allClasses.push(classes); }); return tdbDoc.library.checkClasses(allClasses); }, lookup: function (id,verbose) { // Standard lookup for library blurbs var blurb = $('div#library').find('div.'+id); if (blurb.length > 1 && verbose) { warn("Non unique ID found in library: "+ id); } else if (blurb.length === 0) { // There can be several references to the same details blurb in // the document. So not finding it may mean it has already been // moved from the library. Find an clone that one. blurb = $("div."+id).first(); if (blurb.length === 0 && verbose) warn("Missing document blurb for ID: "+ id); else { var format = $(blurb).parent('td').find('.format'); blurb = $(blurb).clone(); $(blurb).attr('id',''); // cloned blurb cannot have unique ID if (format.length === 1) { format = $(format).clone(); $(blurb).append(format); } } } // Fill in tag 'level' if any from calling HTML doc var spec = $('div#specification').find('td.'+id); if (spec.length === 0) { return blurb; } var code = $(spec).find('code'); if (code.length === 0) { return blurb; } var level = $(code).attr('class'); if (level && level.length !== 0) { var start = $(blurb).find('p').first(); if ($(start).attr('class') !== 'level') { $(start).before('

Support level: ' + '' + level.replace('level-','') + '

'); } $(blurb).find('code').addClass(level); } return blurb; } }, toc: { // separate namespace for self-assembling table of contents blurbCells: function () { // returns list of cells that are to be filled with blurbs return $('table.settingsTable').find('td:has(div.format)'); }, blurblessCells: function () { // returns list of setting cells that are not in within library var tbls = $('table.settingsTable').not('#alphabetical,#toc,:has(div.format)'); if (tbls.length === 0) return []; return $(tbls).find("td:has(A)"); }, classesFromObjects: function (objs,onlyOne) { // converts objs array to classes array. Warns if obj does not have exactly 1 class var allClasses = []; $(objs).each(function (ix) { var classesTemp = this.getAttribute("class"); var classes = classesTemp !== null ? classesTemp.split(" ") : [""]; if (classes.length > 1 && onlyOne) warn('obj with more than one class: '+ classes); else if (classes.length === 0 && onlyOne) warn('obj ' + this.name + ' with no classes.'); else allClasses.push( classes ); // classes string array is [ [ str1], [str2] ] }); return allClasses; }, namesFromContainedAnchors: function (cells) { // Special case code for getting hidden settings that are only documented within // another settings blob (example viewLimitsMax) // returns list of named anchors within cells which should detect these hidden settings var allNames = []; $(cells).each(function (ix) { var cell = this; var anchors = $(cell).children("A[name!='']"); if (anchors.length > 1) { // one is expected and not interesting var aClass = String( $(cell).attr('class') ); // NEVER name var 'class' !!!!! $(anchors).each(function (ix) { var name = String( $(this).attr('name') ); if (name !== aClass) allNames.push( [ name ] ); // string array best as [ [ str1], [str2] ] }); } }); return allNames; }, classesExcludeSuffixes: function (classes, suffixes) { // Since class list will include "_example" classes... // returns classes after those with unwanted suffixes are removed var cleanClasses = []; while (classes.length > 0) { // unless string array is [ [ str1], [str2] ] var aClass = classes.shift(); // shift/pop gets lost! for(var ix=0;ix"; }); } } // Where documented (what table)? // TODO: Should rewrite classes array to also carry the table. var best = ''; var td = $('td.' + aClass); var tbl; if (td.length > 0) // Always chooses first tbl = $(td[0]).parents('table.settingsTable'); else tbl = $('table.settingsTable').has("a[name='"+aClass+"']"); if (tbl != undefined && tbl.length === 1) { var id = $(tbl[0]).attr('id'); if (id.length > 0) { best = "" +id.replace(/_/g," ") + "" //if (td.length > 1) // best += " found "+td.length; } } var row = "" + aClass + ""; if (tdbDoc.isHubDoc()) { // hide superfluous 'Required' paragraphs $('p.isRequired').hide(); //include level in the table if (level === null) { // settings w/o a 'blurb' can still have a level (e.g. deprecated settings) level = $(td[0]).find('code').attr('class'); if (typeof level === 'undefined') { var span = $('span.' + aClass); level = $(span[0]).find('code').attr('class'); if (typeof level === 'undefined') { level = 'level-'; } } } row += "" +level.replace('level-','') + ""; } row += "" + types + "" + best + ""; return row; }, excludeClasses: [], assemble: function () { // assembles (or extends) a table of contents if on is found (Launched by timer) var tocTable = $('table#toc'); var plainTable = $('#plainToc'); if (tocTable.length === 1) { var cells = tdbDoc.toc.blurbCells(); // Most documented settings are found here var names = tdbDoc.toc.namesFromContainedAnchors(cells);// settings in others' cells cells = $(cells).add( tdbDoc.toc.blurblessCells() ); // undocumented: losers var classes = tdbDoc.toc.classesFromObjects(cells,true);// classes are setting names classes = classes.concat( names ); // hidden names wanted to if (classes.length === 0) return; // clean up and sort classes = tdbDoc.toc.classesExcludeSuffixes(classes,['_intro','_example']); if (classes.length === 0) return; if (tdbDoc.toc.excludeClasses.length > 0) { aryRemove( classes, tdbDoc.toc.excludeClasses ); tdbDoc.toc.excludeClasses = []; } classes.sort( tdbDoc.sortNoCase ); // Should now have a full set of settings to add to TOC var cols = tdbDoc.isHubDoc() ? 4 : 3; if ($(tocTable).find('thead').length === 0) { $(tocTable).prepend( ""+ "

Table of Contents

" ); } $(tocTable).append(""); if ($(tocTable).find('th').length === 0) { var th = "Setting"; if (tdbDoc.isHubDoc()) { th += "Level"; } th += "For TypesDocumented"; $(tocTable).append(th); } var lastClass = ''; $(classes).each(function (ix) { if (lastClass !== String(this)) {// skip duplicates lastClass = String(this); $(tocTable).append(tdbDoc.toc.makeRow(this)); $(plainTable).append(tdbDoc.toc.makePlainRow(this)); } }); $(tocTable).append(""); // TODO: Nice to do: allow for seeding toc with user defined rows. // Currently they would be before the rows assembled, but we would want to sort! } } }, tocAssemble: function (timeout,excludeClasses) { // Launches tocAssembly on timer if (excludeClasses != undefined && excludeClasses.length > 0) tdbDoc.toc.excludeClasses = excludeClasses; else tdbDoc.toc.excludeClasses = []; if (timeout > 0) setTimeout(tdbDoc.toc.assemble, timeout); else tdbDoc.toc.assemble(); }, polishButton: function (obj,openUp) { // Called to polish the toggle buttons after an action if (openUp) { obj.src='/images/remove_sm.gif'; obj.alt='-'; obj.title='hide details'; } else { obj.src='/images/add_sm.gif'; obj.alt='+'; obj.title='show details'; } }, loadIntro: function (div) { // Called at startup to load each track type intro from the library var id = $(div).attr('id'); var intro = tdbDoc.library.lookup(id,true); if (intro.length === 1) { $(intro).addClass("intro"); $(intro).detach(); $(div).replaceWith(intro); } }, findDetails: function (td) { // Gets the setting description out of the library and puts it in place var id = $(td).attr('class'); var details = tdbDoc.library.lookup(id,true); if (details.length === 1) { //var detail = $(details).clone(); var detail = $(details).detach(); //$(detail).attr('id',''); // keep unique ID $(detail).addClass("details"); var format = $(detail).find("div.format"); if (format.length === 1) { $(format).detach(); $(td).find("div.format").replaceWith(format); } //$(details).clone().appendTo(td); //$(details).detatch(); $(td).append(detail); } return $(td).find('div.details'); }, toggleDetails: function (obj,openUp) { // Called to toggle open or closed a single detail div or a related set var td = $(obj).parents('td')[0]; var details = $(td).find('div.details'); if (details == undefined || details.length !== 1) { details = tdbDoc.findDetails(td); } if (details == undefined || details.length > 1) { warn("Can't find details for id:" + td.id); return false; } details = details[0]; // determine recursion var tr = $(obj).parents('tr')[0]; var set = $(tr).hasClass('related') || $(tr).hasClass('related1st'); if (openUp == undefined) openUp = ( obj.alt === '+' ); else set = false; // called for set only if openOp is undefined // Handle recursion if (set) { var setClass = $( obj ).attr("class").replace(/ /g,"."); var buttons = $(tr).parents('table').find("img." + setClass); $(buttons).each(function (ix) { tdbDoc.toggleDetails(this,openUp); }); return true; } // Just one if (openUp) $(details).show(); else $(details).hide(); tdbDoc.polishButton(obj,openUp); return true; }, toggleTable: function (obj,openUp) { // Called to toggle open or closed details in a table if (openUp == undefined) openUp = ( obj.alt === '+' ); var buttons = $(obj).parents('table').find('img.toggle.detail'); $(buttons).each(function (ix) { tdbDoc.toggleDetails(this,openUp); }); tdbDoc.polishButton(obj,openUp); return true; }, _toggleAll: function (obj,openUp) { // Called to toggle open or closed details in a table if (openUp == undefined) openUp = ( obj.alt === '+' ); var buttons = $('img.toggle.oneSection'); $(buttons).each(function (ix) { tdbDoc.toggleTable(this,openUp); }); tdbDoc.polishButton(obj,openUp); return true; }, toggleAll: function (obj,openUp) { waitOnFunction( tdbDoc._toggleAll, obj, openUp); }, isHubDoc: function () { return $('#trackDbHub_version').length !== 0; }, documentLoad: function () { // Called at $(document).ready() to load a trackDb document page var divIntros = $("div.intro").each( function (ix) { tdbDoc.loadIntro(this); }); $('div.details').hide(); $('img.toggle').each(function (ix) { var obj = this; tdbDoc.polishButton(obj,false); if ($(obj).hasClass('all')) { $(obj).click(function () { return tdbDoc.toggleAll(obj); }); } else if ($(obj).hasClass('oneSection')) { $(obj).click(function () { return tdbDoc.toggleTable(obj); }); } else { $(obj).click(function () { return tdbDoc.toggleDetails(obj); }); } }); if (document.URL.search('#allOpen') !== -1) tdbDoc.toggleAll($('img.toggle.all')[0],true); document.addEventListener("keydown", e => { if (e.key === "F3") { e.preventDefault(); jumpToResult(e.shiftKey ? -1 : 1); } }); // add the tdb setting to the img toggle classList let toggles = document.querySelectorAll("IMG.toggle.detail"); toggles.forEach( (toggle) => { toggle.classList.add(toggle.parentNode.className + "_imgToggle"); }); let inp = document.getElementById("tdbSearch"); inp.addEventListener("input", (e) => { let term = inp.value.trim(); if (term.length >= 2) { runSearch(term); } else if (term.length === 1) { // Show helpful message for single character runSearch(term); } else if (term.length === 0) { // Clear search when input is empty clearSearchHighlights(); hideSearchStatus(); currentResults = []; currentIndex = -1; } }); // Add keyboard shortcuts for search navigation inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); if (currentResults.length > 0) { jumpToResult(e.shiftKey ? -1 : 1); } } else if (e.key === "Escape") { e.preventDefault(); clearSearch(); } }); // Add clear search button functionality let clearButton = document.getElementById("clearSearch"); if (clearButton) { clearButton.addEventListener("click", () => { clearSearch(); }); } // Add jump to top functionality with smooth scrolling let jumpToTopButton = document.getElementById("jumpToTop"); if (jumpToTopButton) { jumpToTopButton.addEventListener("click", (e) => { e.preventDefault(); // Smooth scroll to top window.scrollTo({ top: 0, behavior: "smooth" }); // Also focus the search input for easy continued searching setTimeout(() => { inp.focus(); }, 500); }); } } } function searchHidden(term) { // Reset previous search clearSearchHighlights(); 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 => { // Skip if this element is inside the library (which is hidden) or already in details if (!element.closest('#library') && !element.closest('div.details')) { allElements.push(element); } }); // 2. Add standalone code and pre elements (not inside format divs or details) let codeElements = document.querySelectorAll('code, pre'); codeElements.forEach(element => { // Skip if inside library, details, or format divs (already searched above) if (!element.closest('#library') && !element.closest('div.details') && !element.closest('div.format')) { allElements.push(element); } }); // 3. Add other table content that might contain searchable text let tableCells = document.querySelectorAll('table.settingsTable td'); tableCells.forEach(element => { // Skip if inside library or if it contains details/format (avoid duplication) if (!element.closest('#library') && !element.querySelector('div.details') && !element.querySelector('div.format')) { allElements.push(element); } }); // 4. Add all already-opened details sections let existingDetails = document.querySelectorAll('div.details'); existingDetails.forEach(div => { allElements.push(div); }); // 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 => { 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; } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { 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) { 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) => { 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; } } results.splice(insertIndex, 0, ...newMatches); } } } } }); return results; } function escapeRegexSpecialChars(str) { // Escape special regex characters to treat search term as literal text return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function clearSearchHighlights() { // More robust cleanup of previous search highlights document.querySelectorAll("mark").forEach(mark => { let parent = mark.parentNode; if (parent) { // Replace mark with its text content let textNode = document.createTextNode(mark.textContent); parent.replaceChild(textNode, mark); // Normalize to merge adjacent text nodes parent.normalize(); } }); } function clearHighlightsInElement(element) { // Clear highlights only within a specific element 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"); // 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) { textNodes.push(node); } } // Process each text node textNodes.forEach(textNode => { let text = textNode.textContent; if (regex.test(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)) ); } // Add highlighted match let mark = document.createElement('mark'); mark.textContent = match[1]; fragment.appendChild(mark); results.push(mark); lastIndex = regex.lastIndex; // Prevent infinite loop on zero-length matches if (match.index === regex.lastIndex) { regex.lastIndex++; } } // Add remaining text after last match if (lastIndex < text.length) { fragment.appendChild( document.createTextNode(text.substring(lastIndex)) ); } // Replace the original text node with the fragment textNode.parentNode.replaceChild(fragment, textNode); } }); return results; } let currentIndex = -1; let currentResults = []; function jumpToResult(direction) { if (currentResults.length === 0) { return; } currentIndex += direction; if (currentIndex >= currentResults.length) { currentIndex = 0; } if (currentIndex < 0) { currentIndex = currentResults.length - 1; } // Clear previous active results currentResults.forEach( (r) => { if (r && r.classList) { r.classList.remove("active-result"); } }); let target = currentResults[currentIndex]; if (target && target.classList) { target.classList.add("active-result"); // Ensure the element is visible and scroll to it scrollToResult(target); } else { console.log("Target result element not found or invalid"); } } function scrollToResult(target) { if (!target) return; // Function to attempt scrolling with retries function attemptScroll(retries = 3) { if (retries <= 0) { console.log("Failed to scroll to result after retries"); return; } // Check if the target is visible in the DOM if (target.offsetParent !== null) { console.log("Scrolling to result"); target.scrollIntoView({behavior: "smooth", block: "center"}); } else { // Target might be hidden, try to find its visible container let container = target.closest('div.details'); if (container && container.offsetParent !== null) { console.log("Scrolling to result container"); container.scrollIntoView({behavior: "smooth", block: "start"}); } else { // Wait a bit and try again console.log(`Retrying scroll (${retries} retries left)`); setTimeout(() => attemptScroll(retries - 1), 50); } } } attemptScroll(); } function runSearch(term) { // Clear previous results currentResults = []; currentIndex = -1; 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); if (currentResults.length > 0) { // Small delay to ensure any new details sections are fully opened and rendered setTimeout(() => { jumpToResult(1); }, 150); } } function clearSearch() { let inp = document.getElementById("tdbSearch"); if (inp) { inp.value = ""; } clearSearchHighlights(); hideSearchStatus(); currentResults = []; currentIndex = -1; if (inp) { inp.blur(); } } function showSearchStatus(term, count) { let statusDiv = document.getElementById('searchStatus'); if (!statusDiv) { statusDiv = document.createElement('div'); statusDiv.id = 'searchStatus'; statusDiv.style.cssText = ` margin: 5px 0; padding: 5px 10px; background-color: #e8f4f8; border: 1px solid #bee5eb; border-radius: 3px; font-size: 12px; color: #0c5460; display: block; `; let searchDiv = document.getElementById('searchDiv'); if (searchDiv) { searchDiv.appendChild(statusDiv); } else { console.log('ERROR: Could not find searchDiv to append status'); } } // Ensure the status div is visible statusDiv.style.display = 'block'; if (count === 0) { if (term.length === 1) { statusDiv.textContent = `Type at least 2 characters to search`; statusDiv.style.backgroundColor = '#fff3cd'; statusDiv.style.borderColor = '#ffeaa7'; statusDiv.style.color = '#856404'; } else { statusDiv.textContent = `No results found for "${term}"`; statusDiv.style.backgroundColor = '#f8d7da'; statusDiv.style.borderColor = '#f5c6cb'; statusDiv.style.color = '#721c24'; } } else { - statusDiv.textContent = `Found ${count} result${count !== 1 ? 's' : ''} for "${term}" - Use F3/Enter to navigate`; + statusDiv.textContent = `Found ${count} result${count !== 1 ? 's' : ''} for "${term}" - Use F3/Enter (Shift+F3/Shift+Enter) to navigate`; statusDiv.style.backgroundColor = '#e8f4f8'; statusDiv.style.borderColor = '#bee5eb'; statusDiv.style.color = '#0c5460'; } } function hideSearchStatus() { let statusDiv = document.getElementById('searchStatus'); if (statusDiv) { statusDiv.style.display = 'none'; } }