ddfed922f22d471b014be01c3b8cc9df3ef68029 chmalee Tue Jun 24 10:24:35 2025 -0700 Significantly simplify the mouseover code for showing tooltips on hgTracks. Use a mouseenter/leave event on each item in the image with a timer to delay showing/hiding the tooltip. The delay on the hide allows moving the mouse to the tooltip itself. Also add a mouseenter/leave event on the tooltip itself to prevent race conditions with the item events, refs #34462 diff --git src/hg/js/utils.js src/hg/js/utils.js index ef3599683a1..35b0ffa8fbb 100644 --- src/hg/js/utils.js +++ src/hg/js/utils.js @@ -4053,401 +4053,206 @@ leftOffset = mouseX - popUpRect.width; } // the page is scrolled or otherwise off the screen if (topOffset <= 0) { topOffset = mouseY - window.scrollY; } if (leftOffset < 0) { throw new Error("trying to position off of screen to left"); } popUpEl.style.left = leftOffset + "px"; popUpEl.style.top = topOffset + "px"; } -// the current mouseover timer, for showing the mouseover after a delay -let mouseoverTimer; -// the timer for when a user is moving the mouse after already bringing up -// a pop up, there may be many items close together and we want the user -// to bring up those mouseovers -let mousemoveTimer; -// flags to help figure out what state the users mouse is in: -// hovered an item, moving to new item, moving to popup, moving away from popup/item -let mousedNewItem = false; -let canShowNewMouseover = true; -// signal handler for when mousemove has gone far enough away from the pop up -// we can't use removeEventListener because the function call is hard to keep -// track of because of a bounded this keyword -let mousemoveController; // The div that moves around the users screen with the visible mouseover text let mouseoverContainer; -// the last element that triggered a mouseover event -let lastMouseoverEle; - -function tooltipIsVisible() { - /* Is the tooltip visible on the screen right now? */ - return mouseoverContainer.style.visibility !== "hidden"; -} - -function hideMouseoverText(ele) { - /* Actually hides the tooltip text */ - let tooltipTarget = ele; - tooltipTarget.classList.remove("isShown"); - tooltipTarget.style.opacity = "0"; - tooltipTarget.style.visibility = "hidden"; -} - -function mouseIsOverPopup(ev, ele, fudgeFactor=25) { - /* Is the mouse positioned over the popup? */ - let targetBox = ele.getBoundingClientRect(); - let mouseX = ev.clientX; - let mouseY = ev.clientY; - if ( (mouseX >= (targetBox.left - fudgeFactor) && mouseX <= (targetBox.right + fudgeFactor) && - mouseY >= (targetBox.top - fudgeFactor) && mouseY <= (targetBox.bottom + fudgeFactor)) ) { - return true; - } - return false; -} - -function mouseIsOverItem(ev, ele, fudgeFactor=25) { - /* Is the mouse positioned over the item that triggered the popup? */ - let origName = ele.getAttribute("origItemMouseoverId"); - if (origName === null) {origName = ele.getAttribute("mouseoverid");} - let origTargetBox = boundingRect($("[mouseoverid='"+origName+"']")[0]); - let mouseX = ev.clientX; - let mouseY = ev.clientY; - if ( (mouseX >= (origTargetBox.left - fudgeFactor) && mouseX <= (origTargetBox.right + fudgeFactor) && - mouseY >= (origTargetBox.top - fudgeFactor) && mouseY <= (origTargetBox.bottom + fudgeFactor)) ) { - return true; - } - return false; -} - -function mousemoveTimerHelper(triggeringMouseMoveEv, currTooltip) { - /* Called after 500ms of the mouse being stationary, show a new tooltip - * if we are over a new mouseover-able element */ - e = triggeringMouseMoveEv; - if (mousedNewItem && !(mouseIsOverPopup(e, currTooltip, 0))) { - mousemoveController.abort(); - hideMouseoverText(currTooltip); - showMouseoverText(triggeringMouseMoveEv); - } -} - -function mousemoveHelper(e) { - /* Helper function for deciding whether to keep a tooltip visible upon a mousemove event */ - - // if a tooltip is not visible and we are not currently over the item that triggered - // the mouseover, then we want to stop the mouseover event and wait for a new mouseover - // event - if (!tooltipIsVisible()) { - if (!(mouseIsOverItem(e, lastMouseoverEle, 0))) { - // we have left the item boundaries, cancel any timers - rect = boundingRect(lastMouseoverEle); - clearTimeout(mouseoverTimer); - clearTimeout(mousemoveTimer); - // we can safely stop listening for mousemove now, because we need a new mouseover event - // to start a new mousemove event: - mousemoveController.abort(); - } - return; - } - - // otherwise, a tooltip is visible or we are over the item that triggered the mouseover - // but we have just moved the mouse a little, reset the timer - if (mousemoveTimer) { - clearTimeout(mousemoveTimer); - } - // for the currently shown tooltip if any: - let currTooltipItem = this.getAttribute("origItemMouseoverId"); - let currTooltipDelayedSetting = this.getAttribute("isDelayedTooltip"); - let currTooltipIsDelayed = currTooltipDelayedSetting === "delayed"; - - // the tooltip that just triggered a mouseover event (not mousemove!) - let isDelayedTooltip = lastMouseoverEle.getAttribute("tooltipDelay"); - - // if the currently shown tooltip is a delayed one, hide the tooltip because, - // the mouse has moved, regardless how much time has passed - if (currTooltipIsDelayed || !(mouseIsOverItem(e, this) || mouseIsOverPopup(e, this))) { - mousemoveController.abort(); - hideMouseoverText(this); - return; - } - - // wait for the mouse to stop moving: - if (currTooltipIsDelayed) { - mousemoveTimer = setTimeout(mousemoveTimerHelper, 1500, e, this); - mousedNewItem = true; - } else { - mousemoveTimer = setTimeout(mousemoveTimerHelper, 500, e, this); - if (!(mouseIsOverPopup(e, this) || mouseIsOverItem(e, this))) { - return; - } - } -} - -function getRandomInt() { - // get a random integer between 1 and 1000000 - let min = 1; - let max = 1000000; - return Math.floor(Math.random() * (max + min) + min); -} - -function showMouseoverText(ev) { - /* If a tooltip is not visible, show the tooltip text right away. If a tooltip - * is visble, do nothing as the mousemove event helper will re-call us - * after hiding the tooltip that is shown */ - ev.preventDefault(); - let referenceElement = lastMouseoverEle; - - if (!tooltipIsVisible() && - // wiggle mouseovers have special code, don't use these tooltips for those: - (typeof mouseOver === "undefined" || !mouseOver.visible)) { - let tooltipDivId = "#" + referenceElement.getAttribute("mouseoverid"); - let tooltipDiv = $(tooltipDivId)[0]; - if (!tooltipDiv) { - return; - } - mouseoverContainer.replaceChildren(); - let divCpy = tooltipDiv.cloneNode(true); - divCpy.childNodes.forEach(function(n) { - mouseoverContainer.appendChild(n); - }); - positionMouseover(ev, referenceElement, mouseoverContainer, ev.pageX, ev.pageY); - mouseoverContainer.classList.add("isShown"); - mouseoverContainer.style.opacity = "1"; - mouseoverContainer.style.visibility = "visible"; - mouseoverContainer.setAttribute("origItemMouseoverId", referenceElement.getAttribute("mouseoverid")); - - // some tooltips are special and have a long delay, make sure the container knows - // that too - let isDelayed = referenceElement.getAttribute("tooltipDelay"); - if (isDelayed !== null && isDelayed === "delayed") { - mouseoverContainer.setAttribute("isDelayedTooltip", "delayed"); - } else { - mouseoverContainer.setAttribute("isDelayedTooltip", "normal"); - } - // Events all get their own unique id but they are tough to keep track of if we - // want to remove one. We can use the AbortController interface to let the - // web browser automatically raise a signal when the event is fired and remove - // appropriate event - let callback = mousemoveHelper.bind(mouseoverContainer); - mousedNewItem = false; - clearTimeout(mouseoverTimer); - mouseoverTimer = undefined; - } -} - -function showMouseover(e) { - /* Helper function for showing a mouseover. Uses a timeout function to allow - * user to not immediately see all available tooltips. */ - e.preventDefault(); - - // make the mouseover div: - let ele1 = e.currentTarget; - let text = ele1.getAttribute("mouseoverText"); - if (ele1.getAttribute("mouseoverid") === null) { - if (text.length > 0) { - let newEl = document.createElement("span"); - newEl.style = "max-width: 400px"; // max width of the mouseover text - newEl.innerHTML = text; - - let newDiv = document.createElement("div"); - newDiv.className = "tooltip"; - newDiv.style.position = "fixed"; - newDiv.style.display = "inline-block"; - if (ele1.title) { - newDiv.id = getRandomInt(); - ele1.setAttribute("originalTitle", ele1.title); - ele1.title = ""; - } else { - newDiv.id = getRandomInt(); - } - ele1.setAttribute("mouseoverid", newDiv.id); - newDiv.append(newEl); - ele1.parentNode.append(newDiv); - } else { - // shouldn't show a mouseover for something that doesn't have a mouseoverText attr, - // meaning we got here without calling addMouseover(), this should not happen - // but catch it to be safe - return; - } - } - - // if a tooltip is currently visible, we need to wait for its mousemove - // event to clear it before we can show this one, ie a user "hovers" - // this element on their way to mousing over the shown mouseover - mousedNewItem = true; - lastMouseoverEle = ele1; - if (mouseoverTimer) { - // user is moving their mouse around, make sure where they stop is what we show - clearTimeout(mouseoverTimer); - } - if (mousemoveTimer) { - // user is moving their mouse around and has potentially triggered - // a new pop up, clear the move timeout - clearTimeout(mousemoveTimer); - } - // If there is no tooltip present, we want a small but noticeable delay - // before showing a tooltip - if (canShowNewMouseover) { - // set up the mousemove handlers to prevent showing a tooltip if we have - // already moved on from this item by the time the below delay passes: - if (mousemoveController) { - // a previous mouseover event has fired and it waiting, clear it before - // setting up this one - mousemoveController.abort(); - } - mousemoveController = new AbortController(); - let callback = mousemoveHelper.bind(mouseoverContainer); - document.addEventListener("mousemove", callback, {signal: mousemoveController.signal}); - document.addEventListener("scroll", function(e) { - hideMouseoverText(mouseoverContainer); - clearTimeout(mousemoveTimer); - mousemoveController.abort(); - canShowNewMouseover = true; - }); - - // some tooltips are special and have a longer delay - let isDelayedTooltip = ele1.getAttribute("tooltipDelay"); - if (isDelayedTooltip !== null && isDelayedTooltip === "delayed") { - mouseoverTimer = setTimeout(showMouseoverText, 1500, e); - } else { - mouseoverTimer = setTimeout(showMouseoverText, 500, e); - } - } -} function addMouseover(ele1, text = null, ele2 = null) { - /* Adds wrapper elements to control various mouseover events */ + /* Adds wrapper elements to control various mouseover events using mouseenter/mouseleave. */ if (!mouseoverContainer) { mouseoverContainer = document.createElement("div"); mouseoverContainer.className = "tooltip"; mouseoverContainer.style.position = "fixed"; mouseoverContainer.style.display = "inline-block"; mouseoverContainer.style.visibility = "hidden"; mouseoverContainer.style.opacity = "0"; mouseoverContainer.id = "mouseoverContainer"; let tooltipTextSize = localStorage.getItem("tooltipTextSize"); if (tooltipTextSize === null) {tooltipTextSize = window.browserTextSize;} mouseoverContainer.style.fontSize = tooltipTextSize + "px"; document.body.append(mouseoverContainer); } if (ele1) { ele1.setAttribute("mouseoverText", text); - ele1.addEventListener("mouseover", showMouseover, {capture: true}); + // Remove title attribute to prevent default browser tooltip + if (ele1.title) { + ele1.setAttribute("originalTitle", ele1.title); + ele1.title = ""; + } + // Remove previous listeners if any + ele1.removeEventListener("mouseenter", ele1._mouseenterHandler); + ele1.removeEventListener("mouseleave", ele1._mouseleaveHandler); + // Show tooltip on mouseenter with delay + ele1._mouseenterHandler = function(e) { + // Clear any existing hide timeout + if (ele1._tooltipHideTimeout) { + clearTimeout(ele1._tooltipHideTimeout); + ele1._tooltipHideTimeout = null; + } + // Determine delay based on tooltip type + let isDelayedTooltip = ele1.getAttribute("tooltipDelay") === "delayed"; + let delay = isDelayedTooltip ? 1500 : 500; + ele1._tooltipShowTimeout = setTimeout(function() { + showTooltipForElement(ele1, e); + }, delay); + }; + ele1._mouseleaveHandler = function(e) { + // Clear show timeout if mouse leaves before delay + if (ele1._tooltipShowTimeout) { + clearTimeout(ele1._tooltipShowTimeout); + ele1._tooltipShowTimeout = null; + } + // Use a grace period to allow moving to the tooltip itself + ele1._tooltipHideTimeout = setTimeout(function() { + if (!mouseoverContainer._isMouseOver) { + hideMouseoverText(mouseoverContainer); + } + }, 500); // 500ms grace period + }; + ele1.addEventListener("mouseenter", ele1._mouseenterHandler); + ele1.addEventListener("mouseleave", ele1._mouseleaveHandler); + } + + // Tooltip mouseenter/mouseleave + mouseoverContainer.addEventListener("mouseenter", function() { + mouseoverContainer._isMouseOver = true; + // Clear any hide timeout when entering tooltip + if (mouseoverContainer._hideTimeout) { + clearTimeout(mouseoverContainer._hideTimeout); + mouseoverContainer._hideTimeout = null; + } + }); + mouseoverContainer.addEventListener("mouseleave", function() { + mouseoverContainer._isMouseOver = false; + // Hide after a short delay to allow for quick mouse movements + mouseoverContainer._hideTimeout = setTimeout(function() { + hideMouseoverText(mouseoverContainer); + }, 100); + }); +} + +function showTooltipForElement(ele, ev) { + // Show the tooltip for the given element + let text = ele.getAttribute("mouseoverText"); + if (!text) return; + mouseoverContainer.replaceChildren(); + let newEl = document.createElement("span"); + newEl.style = "max-width: 400px"; + newEl.innerHTML = text; + mouseoverContainer.appendChild(newEl); + positionMouseover(ev, ele, mouseoverContainer, ev.pageX, ev.pageY); + mouseoverContainer.classList.add("isShown"); + mouseoverContainer.style.opacity = "1"; + mouseoverContainer.style.visibility = "visible"; + mouseoverContainer.setAttribute("origItemMouseoverId", ele.getAttribute("mouseoverid")); } + +function hideMouseoverText(ele) { + /* Actually hides the tooltip text */ + ele.classList.remove("isShown"); + ele.style.opacity = "0"; + ele.style.visibility = "hidden"; } function titleTagToMouseover(mapEl) { /* for a given area tag, extract the title text into a div that can be positioned * like a standard tooltip mouseover next to the item */ addMouseover(mapEl, mapEl.title); } function convertTitleTagsToMouseovers() { /* make all the title tags in the document have mouseovers */ document.querySelectorAll("[title]").forEach(function(a, i) { if (a.id !== "" && (a.id === "hotkeyHelp" || a.id.endsWith("Dialog") || a.id.endsWith("Popup"))) { // these divs are populated by ui-dialog, they should not have tooltips return; } if (a.title !== undefined && (a.title.startsWith("click & drag to scroll") || a.title.startsWith("drag select or click to zoom"))) a.title = ""; else if (a.title !== undefined && a.title.length > 0) { if (a.title.startsWith("Click to alter the display density")) { // these tooltips have a longer delay: a.setAttribute("tooltipDelay", "delayed"); } titleTagToMouseover(a); } }); /* Mouseover should clear if you leave the document window altogether */ document.body.addEventListener("mouseleave", (ev) => { - clearTimeout(mouseoverTimer); - if (mousemoveController) { mousemoveController.abort(); } hideMouseoverText(mouseoverContainer); - canShowNewMouseover = false; // let mouseovers show up again upon moving back in to the window // but only need the event once // use capture: true to force this event to happen // before the regular mouseover event document.body.addEventListener("mouseover", (evt) => { - canShowNewMouseover = true; }, {capture: true, once: true}); }); /* make the mouseovers go away if we are in an input */ const inps = document.getElementsByTagName("input"); for (let inp of inps) { if (!(inp.type == "hidden" || inp.type == "HIDDEN")) { if (inp.type !== "submit") { inp.addEventListener("focus", (ev) => { - if (mousemoveController) {mousemoveController.abort();} - clearTimeout(mouseoverTimer); - clearTimeout(mousemoveTimer); hideMouseoverText(mouseoverContainer); - canShowNewMouseover = false; inp.addEventListener("blur", (evt) => { - canShowNewMouseover = true; }, {once: true}); }); } else { // the buttons are inputs that don't blur right away (or ever? I can't tell), so // be sure to restore the tooltips when they are clicked inp.addEventListener("click", (ev) => { - if (mousemoveController) {mousemoveController.abort();} - clearTimeout(mouseoverTimer); - clearTimeout(mousemoveTimer); hideMouseoverText(mouseoverContainer); - canShowNewMouseover = true; }); } } } /* on a select, we can hide the tooltip on focus, but don't disable them * altogether, because it's easy to click out of a select without actually * losing focus, and we can't detect that because the web browser handles * that click separately */ const sels = document.getElementsByTagName("select"); for (let sel of sels) { sel.addEventListener("focus", (ev) => { - if (mousemoveController) {mousemoveController.abort();} - clearTimeout(mouseoverTimer); hideMouseoverText(mouseoverContainer); - canShowNewMouseover = true; }); for (let opt of sel.options) { opt.addEventListener("click", (evt) => { - if (mousemoveController) {mousemoveController.abort();} - clearTimeout(mouseoverTimer); hideMouseoverText(mouseoverContainer); - canShowNewMouseover = true; }); } } /* Make the ESC key hide tooltips */ document.body.addEventListener("keyup", (ev) => { if (ev.keyCode === 27) { - clearTimeout(mouseoverTimer); hideMouseoverText(mouseoverContainer); - canShowNewMouseover = true; } }); } function parseUrl(url) { // turn a url into some of it's components like server, query-string, etc let protocol, serverName, pathInfo, queryString, queryArgs = {}; let temp; temp = url.split("?"); if (temp.length > 1) queryString = temp.slice(1).join("?"); temp = temp[0].split("/"); protocol = temp[0]; // "https:" serverName = temp[2]; // "genome-test.gi.ucsc.edu" pathInfo = temp.slice(3).join("/"); // "cgi-bin/hgTracks"