fix: tooltip positioning without rAF — measure synchronously via ready class

This commit is contained in:
Falkan
2026-03-19 00:16:49 -04:00
parent 65f760a254
commit 15b5871eb9
3 changed files with 50 additions and 29 deletions

View File

@@ -272,7 +272,7 @@
"id": "lengths-and-distance", "id": "lengths-and-distance",
"label": "Lengths and Distance", "label": "Lengths and Distance",
"baseUnitId": "mm", "baseUnitId": "mm",
"toUniversal": 14.2332177486, "toUniversal": 1,
"alwaysShowLabel": false, "alwaysShowLabel": false,
"hidden": false "hidden": false
} }

View File

@@ -469,6 +469,10 @@
.hu-tooltip--visible { .hu-tooltip--visible {
opacity: 1; opacity: 1;
} }
.hu-tooltip--ready {
opacity: 1;
transition: none;
}
[data-theme=dan] .hu-tooltip { [data-theme=dan] .hu-tooltip {
background: #000; background: #000;
color: #ffff00; color: #ffff00;

View File

@@ -8,6 +8,8 @@
*/ */
const TOOLTIP_CLASS = 'hu-tooltip'; const TOOLTIP_CLASS = 'hu-tooltip';
const TOOLTIP_VISIBLE_CLASS = 'hu-tooltip--visible';
const TOOLTIP_READY_CLASS = 'hu-tooltip--ready';
// Shared tooltip element — one per page, repositioned as needed. // Shared tooltip element — one per page, repositioned as needed.
let tooltipEl: HTMLDivElement | null = null; let tooltipEl: HTMLDivElement | null = null;
@@ -21,48 +23,55 @@ function getTooltipEl(): HTMLDivElement {
return tooltipEl; return tooltipEl;
} }
/**
* Position the tooltip near (cx, cy), shifting up only as much as needed
* to keep it within the viewport. Requires the element to already be in
* the DOM with its content set so offsetWidth/offsetHeight are accurate.
*/
function position(el: HTMLDivElement, cx: number, cy: number) {
const margin = 8;
const w = el.offsetWidth;
const h = el.offsetHeight;
const x = Math.min(cx + 12, window.innerWidth - w - margin);
let y = cy + 20;
const overflow = y + h + margin - window.innerHeight;
if (overflow > 0) y -= overflow;
y = Math.max(margin, y);
el.style.left = `${x}px`;
el.style.top = `${y}px`;
}
export function tooltip(node: HTMLElement, text: string) { export function tooltip(node: HTMLElement, text: string) {
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
let currentText = text; let currentText = text;
function show(e: MouseEvent) { function show(cx: number, cy: number) {
if (!currentText) return; if (!currentText) return;
const el = getTooltipEl(); const el = getTooltipEl();
el.textContent = currentText; el.textContent = currentText;
// Position off-screen first so we can measure dimensions after render // Make visible but suppress the opacity transition while we measure+position.
el.style.left = '-9999px'; // --ready class sets opacity:1 with no transition; --visible adds the fade-in.
el.style.top = '-9999px'; el.classList.remove(TOOLTIP_VISIBLE_CLASS);
el.classList.add('hu-tooltip--visible'); el.classList.add(TOOLTIP_READY_CLASS);
// Use rAF to let the browser lay out the element before measuring // offsetWidth/offsetHeight are now accurate (element is in DOM, display not none)
requestAnimationFrame(() => { position(el, cx, cy);
position(el, e.clientX, e.clientY);
});
}
function position(el: HTMLDivElement, cx: number, cy: number) { // Swap to the visible class to re-enable the fade transition
const margin = 8; el.classList.remove(TOOLTIP_READY_CLASS);
const w = el.offsetWidth; el.classList.add(TOOLTIP_VISIBLE_CLASS);
const h = el.offsetHeight;
const x = Math.min(cx + 12, window.innerWidth - w - margin);
// Place below cursor; shift up only as much as needed to fit
let y = cy + 20;
const overflow = y + h + margin - window.innerHeight;
if (overflow > 0) y -= overflow;
y = Math.max(margin, y);
el.style.left = `${x}px`;
el.style.top = `${y}px`;
} }
function enter(e: MouseEvent) { function enter(e: MouseEvent) {
if (!currentText) return; if (!currentText) return;
timer = setTimeout(() => show(e), 500); const cx = e.clientX;
const cy = e.clientY;
timer = setTimeout(() => show(cx, cy), 500);
} }
function move(e: MouseEvent) { function move(e: MouseEvent) {
// Keep tooltip near cursor while hovering if (!tooltipEl?.classList.contains(TOOLTIP_VISIBLE_CLASS)) return;
if (!tooltipEl?.classList.contains('hu-tooltip--visible')) return;
position(getTooltipEl(), e.clientX, e.clientY); position(getTooltipEl(), e.clientX, e.clientY);
} }
@@ -71,7 +80,11 @@ export function tooltip(node: HTMLElement, text: string) {
clearTimeout(timer); clearTimeout(timer);
timer = null; timer = null;
} }
tooltipEl?.classList.remove('hu-tooltip--visible'); const el = tooltipEl;
if (el) {
el.classList.remove(TOOLTIP_VISIBLE_CLASS);
el.classList.remove(TOOLTIP_READY_CLASS);
}
} }
node.addEventListener('mouseenter', enter); node.addEventListener('mouseenter', enter);
@@ -81,7 +94,7 @@ export function tooltip(node: HTMLElement, text: string) {
return { return {
update(newText: string) { update(newText: string) {
currentText = newText; currentText = newText;
if (tooltipEl?.classList.contains('hu-tooltip--visible')) { if (tooltipEl?.classList.contains(TOOLTIP_VISIBLE_CLASS)) {
tooltipEl.textContent = newText; tooltipEl.textContent = newText;
} }
}, },
@@ -90,7 +103,11 @@ export function tooltip(node: HTMLElement, text: string) {
node.removeEventListener('mouseenter', enter); node.removeEventListener('mouseenter', enter);
node.removeEventListener('mousemove', move); node.removeEventListener('mousemove', move);
node.removeEventListener('mouseleave', leave); node.removeEventListener('mouseleave', leave);
tooltipEl?.classList.remove('hu-tooltip--visible'); const el = tooltipEl;
if (el) {
el.classList.remove(TOOLTIP_VISIBLE_CLASS);
el.classList.remove(TOOLTIP_READY_CLASS);
}
} }
}; };
} }