Files
Azornes-Comfyui-LayerForge/js/utils/tooltip_manager.js
T
Dariusz L 0e636ed99b feat: improve tooltip behavior with persistent and interactive modes
- Convert keyboard shortcuts help button to persistent click-toggle tooltip
- Add interactive, persistent, and onDismiss options to TooltipManager
- Dismiss persistent tooltips on outside pointerdown or Escape key
- Add delayed hide for interactive tooltips when leaving the target
- Update tooltip CSS for scrolling, interactive pointer-events, and text mode
2026-08-14 21:09:13 +02:00

340 lines
14 KiB
JavaScript

const TOOLTIP_SELECTOR = '[data-tooltip], [title]';
const TOOLTIP_BOUND_ATTRIBUTE = 'lfTooltipBound';
/**
* Owns the single LayerForge tooltip and binds tooltip metadata added to
* registered UI roots. Native `title` attributes are normalized to
* `data-tooltip` so the browser cannot display a second tooltip alongside it.
*/
export class TooltipManager {
constructor() {
this.tooltipElement = null;
this.tooltipTarget = null;
this.observedRoots = new Map();
this.viewportFrame = null;
this.hideTimer = null;
this.tooltipDismissCallback = null;
this.viewportListenersAttached = false;
this.tooltipInteractionListenersAttached = false;
this.documentListenersAttached = false;
this.handleTooltipMouseEnter = () => {
this.cancelScheduledHide();
};
this.handleTooltipMouseLeave = () => {
if (this.tooltipElement?.getAttribute('data-persistent') === 'true')
return;
this.scheduleHide();
};
this.handleDocumentPointerDown = (event) => {
if (!this.tooltipTarget
|| this.tooltipElement?.getAttribute('data-persistent') !== 'true') {
return;
}
const eventTarget = event.target;
if (eventTarget instanceof Node
&& (this.tooltipTarget.contains(eventTarget) || this.tooltipElement?.contains(eventTarget))) {
return;
}
this.hideTooltip();
};
this.handleDocumentKeyDown = (event) => {
if (event.key !== 'Escape'
|| !this.tooltipTarget
|| this.tooltipElement?.getAttribute('data-persistent') !== 'true') {
return;
}
event.preventDefault();
event.stopPropagation();
this.hideTooltip();
};
this.handleViewportChange = () => {
if (!this.tooltipTarget || !this.tooltipElement)
return;
if (this.viewportFrame !== null || typeof window === 'undefined')
return;
this.viewportFrame = window.requestAnimationFrame(() => {
this.viewportFrame = null;
if (this.tooltipTarget) {
this.positionTooltip(this.tooltipTarget);
}
});
};
}
observeRoot(root) {
this.ensureTooltipElement();
this.bindTooltips(root);
if (!this.observedRoots.has(root) && typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
const target = mutation.target;
if (target instanceof HTMLElement) {
this.bindTooltips(target);
}
continue;
}
for (const node of mutation.addedNodes) {
if (node instanceof HTMLElement) {
this.bindTooltips(node);
}
}
}
});
observer.observe(root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['title', 'data-tooltip'],
});
this.observedRoots.set(root, observer);
}
return () => this.unobserveRoot(root);
}
unobserveRoot(root) {
this.observedRoots.get(root)?.disconnect();
this.observedRoots.delete(root);
this.hideTooltip(root);
}
bindTooltips(container) {
const targets = [];
if (container.matches?.(TOOLTIP_SELECTOR)) {
targets.push(container);
}
container.querySelectorAll(TOOLTIP_SELECTOR).forEach((target) => {
targets.push(target);
});
targets.forEach((target) => {
this.normalizeTooltipTarget(target);
if (target.dataset[TOOLTIP_BOUND_ATTRIBUTE] === '1')
return;
target.dataset[TOOLTIP_BOUND_ATTRIBUTE] = '1';
target.addEventListener('mouseenter', () => {
this.cancelScheduledHide();
this.showTooltip(target);
});
target.addEventListener('focus', () => this.showTooltip(target));
target.addEventListener('mouseleave', () => this.handleTargetLeave(target));
target.addEventListener('blur', () => this.handleTargetLeave(target));
});
}
handleTargetLeave(target) {
if (this.tooltipTarget === target
&& this.tooltipElement?.getAttribute('data-persistent') === 'true') {
return;
}
if (this.tooltipTarget === target
&& this.tooltipElement?.getAttribute('data-interactive') === 'true') {
this.scheduleHide(target);
return;
}
this.hideTooltip(target);
}
scheduleHideTooltip(scope) {
this.scheduleHide(scope);
}
scheduleHide(scope) {
this.cancelScheduledHide();
if (typeof window === 'undefined') {
this.hideTooltip(scope);
return;
}
this.hideTimer = window.setTimeout(() => {
this.hideTimer = null;
this.hideTooltip(scope);
}, 120);
}
cancelScheduledHide() {
if (this.hideTimer === null)
return;
if (typeof window !== 'undefined') {
window.clearTimeout(this.hideTimer);
}
this.hideTimer = null;
}
normalizeTooltipTarget(target) {
const title = target.getAttribute('title');
if (title !== null && !target.hasAttribute('data-tooltip')) {
target.setAttribute('data-tooltip', title);
}
if (target.hasAttribute('title')) {
target.removeAttribute('title');
}
}
setTooltip(target, text, options = {}) {
if (!target)
return;
if (text) {
target.setAttribute('data-tooltip', text);
}
else {
target.removeAttribute('data-tooltip');
}
if (options.html) {
target.setAttribute('data-tooltip-html', 'true');
}
else {
target.removeAttribute('data-tooltip-html');
}
target.removeAttribute('title');
this.bindTooltips(target);
}
removeTooltip(target) {
target.removeAttribute('data-tooltip');
target.removeAttribute('data-tooltip-html');
target.removeAttribute('title');
this.hideTooltip(target);
}
showTooltip(target, contentOverride, options = {}) {
this.cancelScheduledHide();
if (!target || !this.ensureTooltipElement())
return;
if ('isConnected' in target && !target.isConnected)
return;
if (this.tooltipTarget && this.tooltipTarget !== target) {
this.hideTooltip();
}
this.normalizeTooltipTarget(target);
const content = contentOverride !== undefined
? contentOverride
: target.getAttribute('data-tooltip');
if (!content)
return;
const tooltip = this.tooltipElement;
if (!tooltip)
return;
this.tooltipTarget = target;
tooltip.replaceChildren();
const renderAsHtml = options.html || target.getAttribute('data-tooltip-html') === 'true';
const isInteractive = renderAsHtml && options.interactive !== false;
tooltip.setAttribute('data-content-mode', renderAsHtml ? 'html' : 'text');
if (isInteractive) {
tooltip.setAttribute('data-interactive', 'true');
}
else {
tooltip.removeAttribute('data-interactive');
}
if (options.persistent) {
tooltip.setAttribute('data-persistent', 'true');
}
else {
tooltip.removeAttribute('data-persistent');
}
this.tooltipDismissCallback = options.onDismiss ?? null;
if (renderAsHtml) {
tooltip.innerHTML = content;
}
else {
tooltip.textContent = content;
}
tooltip.style.display = 'block';
tooltip.setAttribute('data-visible', 'true');
tooltip.setAttribute('aria-hidden', 'false');
this.positionTooltip(target);
}
isVisibleFor(target) {
return this.tooltipTarget === target && this.tooltipElement?.style.display === 'block';
}
hideTooltip(scope) {
if (scope && this.tooltipTarget && scope !== this.tooltipTarget && !scope.contains(this.tooltipTarget)) {
return;
}
this.cancelScheduledHide();
this.tooltipTarget = null;
const dismissCallback = this.tooltipDismissCallback;
this.tooltipDismissCallback = null;
if (!this.tooltipElement) {
dismissCallback?.();
return;
}
this.tooltipElement.style.display = 'none';
this.tooltipElement.style.maxHeight = '';
this.tooltipElement.replaceChildren();
this.tooltipElement.removeAttribute('data-visible');
this.tooltipElement.removeAttribute('data-content-mode');
this.tooltipElement.removeAttribute('data-interactive');
this.tooltipElement.removeAttribute('data-persistent');
this.tooltipElement.setAttribute('aria-hidden', 'true');
dismissCallback?.();
}
destroy() {
this.observedRoots.forEach((observer) => observer.disconnect());
this.observedRoots.clear();
this.hideTooltip();
if (this.tooltipInteractionListenersAttached && this.tooltipElement) {
this.tooltipElement.removeEventListener('mouseenter', this.handleTooltipMouseEnter);
this.tooltipElement.removeEventListener('mouseleave', this.handleTooltipMouseLeave);
this.tooltipInteractionListenersAttached = false;
}
if (this.viewportFrame !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(this.viewportFrame);
this.viewportFrame = null;
}
if (this.viewportListenersAttached && typeof window !== 'undefined') {
window.removeEventListener('resize', this.handleViewportChange);
window.removeEventListener('scroll', this.handleViewportChange, true);
this.viewportListenersAttached = false;
}
if (this.documentListenersAttached && typeof document !== 'undefined') {
document.removeEventListener('pointerdown', this.handleDocumentPointerDown, true);
document.removeEventListener('keydown', this.handleDocumentKeyDown, true);
this.documentListenersAttached = false;
}
this.tooltipElement?.remove();
this.tooltipElement = null;
}
ensureTooltipElement() {
if (typeof document === 'undefined' || !document.body)
return null;
if (this.tooltipElement?.isConnected)
return this.tooltipElement;
const existing = document.getElementById('lf-global-tooltip');
this.tooltipElement = existing instanceof HTMLDivElement
? existing
: document.createElement('div');
this.tooltipElement.id = 'lf-global-tooltip';
this.tooltipElement.className = 'lf-global-tooltip';
this.tooltipElement.setAttribute('role', 'tooltip');
this.tooltipElement.setAttribute('aria-hidden', 'true');
if (!this.tooltipElement.isConnected) {
document.body.appendChild(this.tooltipElement);
}
if (!this.tooltipInteractionListenersAttached) {
this.tooltipElement.addEventListener('mouseenter', this.handleTooltipMouseEnter);
this.tooltipElement.addEventListener('mouseleave', this.handleTooltipMouseLeave);
this.tooltipInteractionListenersAttached = true;
}
if (!this.documentListenersAttached) {
document.addEventListener('pointerdown', this.handleDocumentPointerDown, true);
document.addEventListener('keydown', this.handleDocumentKeyDown, true);
this.documentListenersAttached = true;
}
if (!this.viewportListenersAttached && typeof window !== 'undefined') {
window.addEventListener('resize', this.handleViewportChange);
window.addEventListener('scroll', this.handleViewportChange, true);
this.viewportListenersAttached = true;
}
return this.tooltipElement;
}
positionTooltip(target) {
if (!this.tooltipElement || this.tooltipTarget !== target)
return;
const rect = target.getBoundingClientRect();
this.tooltipElement.style.maxHeight = '';
const tooltipRect = this.tooltipElement.getBoundingClientRect();
const margin = 12;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const maxLeft = Math.max(margin, viewportWidth - tooltipRect.width - margin);
const maxTop = Math.max(margin, viewportHeight - tooltipRect.height - margin);
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
left = Math.min(Math.max(margin, left), maxLeft);
let top = rect.top - tooltipRect.height - 10;
if (top < margin) {
top = Math.min(maxTop, rect.bottom + 10);
}
top = Math.min(Math.max(margin, top), maxTop);
this.tooltipElement.style.left = `${Math.round(left)}px`;
this.tooltipElement.style.top = `${Math.round(top)}px`;
}
}
export const tooltipManager = new TooltipManager();