269 lines
9.9 KiB
JavaScript
269 lines
9.9 KiB
JavaScript
/**
|
|
* sidebar_actions.js - Hover Labels & Rich Shared Tooltip for Sidebar Actions
|
|
*
|
|
* Implements:
|
|
* 1. 100ms short label reveal (replaces icon with smooth slide/fade).
|
|
* 2. 600ms rich floating tooltip bubble (bold title, muted body, viewport bounds).
|
|
* 3. Immediate cleanup on leave/blur/drag.
|
|
* 4. Integration with tool_registry for all customizable and anchor tools.
|
|
*/
|
|
|
|
import { translate as t } from './locales.js';
|
|
import { getToolDefinition, getAllToolDefinitions } from './tool_registry.js';
|
|
|
|
let sharedTooltipEl = null;
|
|
let labelTimer = null;
|
|
let tooltipTimer = null;
|
|
let activeTargetButton = null;
|
|
let isDraggingActive = false;
|
|
|
|
export function setDraggingState(dragging) {
|
|
isDraggingActive = Boolean(dragging);
|
|
if (isDraggingActive) {
|
|
hideTooltipImmediately();
|
|
clearTimers();
|
|
if (activeTargetButton) {
|
|
activeTargetButton.classList.remove('anomalous-action-label-active');
|
|
activeTargetButton = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearTimers() {
|
|
if (labelTimer) {
|
|
clearTimeout(labelTimer);
|
|
labelTimer = null;
|
|
}
|
|
if (tooltipTimer) {
|
|
clearTimeout(tooltipTimer);
|
|
tooltipTimer = null;
|
|
}
|
|
}
|
|
|
|
function getOrCreateSharedTooltip() {
|
|
if (typeof document === 'undefined') return null;
|
|
if (!sharedTooltipEl) {
|
|
sharedTooltipEl = document.getElementById('anomalous-sidebar-tooltip-bubble');
|
|
if (!sharedTooltipEl) {
|
|
sharedTooltipEl = document.createElement('div');
|
|
sharedTooltipEl.id = 'anomalous-sidebar-tooltip-bubble';
|
|
sharedTooltipEl.className = 'anomalous-sidebar-tooltip-bubble';
|
|
sharedTooltipEl.setAttribute('role', 'tooltip');
|
|
sharedTooltipEl.setAttribute('aria-hidden', 'true');
|
|
sharedTooltipEl.style.display = 'none';
|
|
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'anomalous-tooltip-title';
|
|
const bodyEl = document.createElement('div');
|
|
bodyEl.className = 'anomalous-tooltip-body';
|
|
const arrowEl = document.createElement('div');
|
|
arrowEl.className = 'anomalous-tooltip-arrow';
|
|
|
|
sharedTooltipEl.appendChild(titleEl);
|
|
sharedTooltipEl.appendChild(bodyEl);
|
|
sharedTooltipEl.appendChild(arrowEl);
|
|
document.body.appendChild(sharedTooltipEl);
|
|
}
|
|
}
|
|
return sharedTooltipEl;
|
|
}
|
|
|
|
export function hideTooltipImmediately() {
|
|
clearTimers();
|
|
if (sharedTooltipEl) {
|
|
sharedTooltipEl.style.display = 'none';
|
|
sharedTooltipEl.style.opacity = '0';
|
|
}
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
window.AMB_hideTooltipImmediately = hideTooltipImmediately;
|
|
}
|
|
|
|
export function isBottomModalOpen() {
|
|
if (typeof document === 'undefined' || typeof document.getElementById !== 'function') return false;
|
|
const toolboxModal = document.getElementById('anomalous-toolbox-modal');
|
|
if (toolboxModal && toolboxModal.style && toolboxModal.style.display !== 'none') return true;
|
|
const settingsModal = document.getElementById('anomalous-settings-hub-modal');
|
|
if (settingsModal && settingsModal.style && settingsModal.style.display !== 'none') return true;
|
|
return false;
|
|
}
|
|
|
|
export function showSharedTooltip(button, title, desc) {
|
|
if (isDraggingActive || typeof document === 'undefined') return;
|
|
if (isBottomModalOpen()) return;
|
|
const tooltip = getOrCreateSharedTooltip();
|
|
if (!tooltip || !button || !button.isConnected) return;
|
|
|
|
const titleEl = tooltip.querySelector('.anomalous-tooltip-title');
|
|
const bodyEl = tooltip.querySelector('.anomalous-tooltip-body');
|
|
const arrowEl = tooltip.querySelector('.anomalous-tooltip-arrow');
|
|
|
|
if (titleEl) titleEl.textContent = title || '';
|
|
if (bodyEl) {
|
|
bodyEl.textContent = desc || '';
|
|
bodyEl.style.display = desc ? 'block' : 'none';
|
|
}
|
|
|
|
tooltip.style.display = 'flex';
|
|
tooltip.style.visibility = 'hidden';
|
|
tooltip.style.opacity = '0';
|
|
|
|
// Position calculation
|
|
const btnRect = button.getBoundingClientRect();
|
|
const tooltipRect = tooltip.getBoundingClientRect();
|
|
const sidebar = button.closest('#anomalous-sidebar') || button.closest('.anomalous-sidebar-container');
|
|
const sidebarRect = sidebar ? sidebar.getBoundingClientRect() : { left: 0, right: window.innerWidth, width: window.innerWidth };
|
|
|
|
const btnCenterX = btnRect.left + btnRect.width / 2;
|
|
let targetLeft = btnCenterX - tooltipRect.width / 2;
|
|
|
|
// Clamp within sidebar and viewport
|
|
const pad = 8;
|
|
const minLeft = Math.max(pad, sidebarRect.left + pad);
|
|
const maxLeft = Math.min(window.innerWidth - tooltipRect.width - pad, sidebarRect.right - tooltipRect.width - pad);
|
|
|
|
targetLeft = Math.max(minLeft, Math.min(maxLeft, targetLeft));
|
|
const targetTop = btnRect.top - tooltipRect.height - 8;
|
|
|
|
tooltip.style.left = `${Math.round(targetLeft)}px`;
|
|
tooltip.style.top = `${Math.round(targetTop)}px`;
|
|
|
|
if (arrowEl) {
|
|
const arrowX = btnCenterX - targetLeft;
|
|
arrowEl.style.left = `${Math.max(10, Math.min(tooltipRect.width - 10, arrowX))}px`;
|
|
}
|
|
|
|
tooltip.style.visibility = 'visible';
|
|
tooltip.style.opacity = '1';
|
|
}
|
|
|
|
export function suppressButtonText(button) {
|
|
clearTimers();
|
|
hideTooltipImmediately();
|
|
if (typeof document !== 'undefined') {
|
|
const btns = document.querySelectorAll('#anomalous-sidebar-actions button');
|
|
for (const b of btns) {
|
|
b.classList.remove('anomalous-action-label-active');
|
|
}
|
|
}
|
|
if (button) {
|
|
button.classList.remove('anomalous-action-label-active');
|
|
button.__suppress_text_until_leave = true;
|
|
if (typeof button.blur === 'function') {
|
|
button.blur();
|
|
}
|
|
}
|
|
}
|
|
|
|
function onButtonEnter(button, spec) {
|
|
if (isDraggingActive || button.__suppress_text_until_leave) return;
|
|
if (isBottomModalOpen()) return;
|
|
clearTimers();
|
|
activeTargetButton = button;
|
|
|
|
// 100ms short label reveal
|
|
labelTimer = setTimeout(() => {
|
|
if (activeTargetButton === button && !isDraggingActive && !button.__suppress_text_until_leave && !isBottomModalOpen()) {
|
|
button.classList.add('anomalous-action-label-active');
|
|
}
|
|
}, 100);
|
|
|
|
// 600ms rich tooltip reveal
|
|
tooltipTimer = setTimeout(() => {
|
|
if (activeTargetButton === button && !isDraggingActive && !button.__suppress_text_until_leave && !isBottomModalOpen()) {
|
|
showSharedTooltip(button, t(spec.nameKey), t(spec.hintKey));
|
|
}
|
|
}, 600);
|
|
}
|
|
|
|
function onButtonLeave(button) {
|
|
clearTimers();
|
|
if (activeTargetButton === button) {
|
|
activeTargetButton = null;
|
|
}
|
|
button.__suppress_text_until_leave = false;
|
|
button.classList.remove('anomalous-action-label-active');
|
|
hideTooltipImmediately();
|
|
}
|
|
|
|
export function configureSidebarAction(button, customSpec = null) {
|
|
if (!button) return;
|
|
const spec = customSpec || getToolDefinition(button.id) || getToolDefinition(button.getAttribute('data-tool-id'));
|
|
if (!spec) return;
|
|
|
|
let label = button.querySelector('.anomalous-action-label');
|
|
if (!label) {
|
|
label = document.createElement('span');
|
|
label.className = 'anomalous-action-label';
|
|
label.setAttribute('aria-hidden', 'true');
|
|
button.appendChild(label);
|
|
}
|
|
label.textContent = t(spec.labelKey);
|
|
button.setAttribute('aria-label', t(spec.nameKey));
|
|
button.setAttribute('data-tooltip', `${t(spec.nameKey)}\n${t(spec.hintKey)}`);
|
|
button.setAttribute('data-tooltip-pos', 'top');
|
|
button.setAttribute('data-tool-id', spec.id);
|
|
button.removeAttribute('title');
|
|
|
|
// Attach listeners once
|
|
if (!button.__anomalous_action_listeners_attached) {
|
|
button.__anomalous_action_listeners_attached = true;
|
|
|
|
button.addEventListener('mouseenter', () => {
|
|
const curSpec = getToolDefinition(button.getAttribute('data-tool-id')) || spec;
|
|
onButtonEnter(button, curSpec);
|
|
});
|
|
|
|
button.addEventListener('mouseleave', () => {
|
|
onButtonLeave(button);
|
|
});
|
|
|
|
// Immediately dismiss text and tooltip on click/press
|
|
const handleActionClick = () => suppressButtonText(button);
|
|
button.addEventListener('pointerdown', handleActionClick);
|
|
button.addEventListener('click', handleActionClick);
|
|
|
|
button.addEventListener('focus', (e) => {
|
|
if (isBottomModalOpen()) return;
|
|
if (button.matches(':focus-visible') && !button.__suppress_text_until_leave) {
|
|
button.classList.add('anomalous-action-label-active');
|
|
const curSpec = getToolDefinition(button.getAttribute('data-tool-id')) || spec;
|
|
clearTimers();
|
|
activeTargetButton = button;
|
|
tooltipTimer = setTimeout(() => {
|
|
if (activeTargetButton === button && !isDraggingActive && !button.__suppress_text_until_leave && !isBottomModalOpen()) {
|
|
showSharedTooltip(button, t(curSpec.nameKey), t(curSpec.hintKey));
|
|
}
|
|
}, 600);
|
|
}
|
|
});
|
|
|
|
button.addEventListener('blur', () => {
|
|
onButtonLeave(button);
|
|
});
|
|
}
|
|
}
|
|
|
|
export function configureSidebarActions(root) {
|
|
if (!root) return;
|
|
const candidates = [
|
|
...root.querySelectorAll('#anomalous-sidebar-actions button, button[data-tool-id], button[id^="anomalous-"]')
|
|
];
|
|
if (candidates.length === 0) {
|
|
// Fallback for mock fixtures where buttons are direct children without sidebar-actions wrapper
|
|
for (const def of getAllToolDefinitions()) {
|
|
const btn = root.querySelector(`#${def.domId}`);
|
|
if (btn && !candidates.includes(btn)) candidates.push(btn);
|
|
}
|
|
}
|
|
for (const btn of candidates) {
|
|
configureSidebarAction(btn);
|
|
}
|
|
}
|
|
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('pointerdown', () => {
|
|
hideTooltipImmediately();
|
|
}, { passive: true });
|
|
}
|