/** * Generic Resizer * * Handles resizing of elements with drag functionality. * Communicates with server via HTMX to persist width changes. * Works for both Layout drawers and Panel sides. */ /** * Initialize resizer functionality for a specific container * * @param {string} containerId - The ID of the container instance to initialize * @param {Object} options - Configuration options * @param {number} options.minWidth - Minimum width in pixels (default: 150) * @param {number} options.maxWidth - Maximum width in pixels (default: 600) */ function initResizer(containerId, options = {}) { const MIN_WIDTH = options.minWidth || 150; const MAX_WIDTH = options.maxWidth || 600; let isResizing = false; let currentResizer = null; let currentItem = null; let startX = 0; let startWidth = 0; let side = null; const containerElement = document.getElementById(containerId); if (!containerElement) { console.error(`Container element with ID "${containerId}" not found`); return; } /** * Initialize resizer functionality for this container instance */ function initResizers() { const resizers = containerElement.querySelectorAll('.mf-resizer'); resizers.forEach(resizer => { // Remove existing listener if any to avoid duplicates resizer.removeEventListener('mousedown', handleMouseDown); resizer.addEventListener('mousedown', handleMouseDown); }); } /** * Handle mouse down event on resizer */ function handleMouseDown(e) { e.preventDefault(); currentResizer = e.target; side = currentResizer.dataset.side; currentItem = currentResizer.parentElement; if (!currentItem) { console.error('Could not find item element'); return; } isResizing = true; startX = e.clientX; startWidth = currentItem.offsetWidth; // Add event listeners for mouse move and up document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); // Add resizing class for visual feedback document.body.classList.add('mf-resizing'); currentItem.classList.add('mf-item-resizing'); // Disable transition during manual resize currentItem.classList.add('no-transition'); } /** * Handle mouse move event during resize */ function handleMouseMove(e) { if (!isResizing) return; e.preventDefault(); let newWidth; if (side === 'left') { // Left drawer: increase width when moving right newWidth = startWidth + (e.clientX - startX); } else if (side === 'right') { // Right drawer: increase width when moving left newWidth = startWidth - (e.clientX - startX); } // Constrain width between min and max newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth)); // Update item width visually currentItem.style.width = `${newWidth}px`; } /** * Handle mouse up event - end resize and save to server */ function handleMouseUp(e) { if (!isResizing) return; isResizing = false; // Remove event listeners document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); // Remove resizing classes document.body.classList.remove('mf-resizing'); currentItem.classList.remove('mf-item-resizing'); // Re-enable transition after manual resize currentItem.classList.remove('no-transition'); // Get final width const finalWidth = currentItem.offsetWidth; const commandId = currentResizer.dataset.commandId; if (!commandId) { console.error('No command ID found on resizer'); return; } // Send width update to server saveWidth(commandId, finalWidth); // Reset state currentResizer = null; currentItem = null; side = null; } /** * Save width to server via HTMX */ function saveWidth(commandId, width) { htmx.ajax('POST', '/myfasthtml/commands', { headers: { "Content-Type": "application/x-www-form-urlencoded" }, swap: "outerHTML", target: `#${currentItem.id}`, values: { c_id: commandId, width: width } }); } // Initialize resizers initResizers(); // Re-initialize after HTMX swaps within this container containerElement.addEventListener('htmx:afterSwap', function (event) { initResizers(); }); } function bindTooltipsWithDelegation(elementId) { // To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip // Then // the 'truncate' to show only when the text is truncated // the class 'mmt-tooltip' for force the display console.info("bindTooltips on element " + elementId); const element = document.getElementById(elementId); const tooltipContainer = document.getElementById(`tt_${elementId}`); if (!element) { console.error(`Invalid element '${elementId}' container`); return; } if (!tooltipContainer) { console.error(`Invalid tooltip 'tt_${elementId}' container.`); return; } // OPTIMIZATION C: Throttling flag to limit mouseenter processing let tooltipRafScheduled = false; // Add a single mouseenter and mouseleave listener to the parent element element.addEventListener("mouseenter", (event) => { const target = event.target; // Early exit - check mf-no-tooltip on the registered element OR any ancestor of the target if (element.hasAttribute("mf-no-tooltip") || target.closest("[mf-no-tooltip]")) { return; } // OPTIMIZATION C: Throttle mouseenter events (max 1 per frame) if (tooltipRafScheduled) { return; } const cell = target.closest("[data-tooltip]"); if (!cell) { return; } // OPTIMIZATION C: Move ALL DOM reads into RAF to avoid forced synchronous layouts tooltipRafScheduled = true; requestAnimationFrame(() => { tooltipRafScheduled = false; // Check again in case tooltip was disabled during RAF delay if (element.hasAttribute("mf-no-tooltip") || target.closest("[mf-no-tooltip]")) { return; } // All DOM reads happen here (batched in RAF) const content = cell.querySelector(".truncate") || cell; const isOverflowing = content.scrollWidth > content.clientWidth; const forceShow = cell.classList.contains("mf-tooltip"); if (isOverflowing || forceShow) { const tooltipText = cell.getAttribute("data-tooltip"); if (tooltipText) { const rect = cell.getBoundingClientRect(); const tooltipRect = tooltipContainer.getBoundingClientRect(); let top = rect.top - 30; // Above the cell let left = rect.left; // Adjust tooltip position to prevent it from going off-screen if (top < 0) top = rect.bottom + 5; // Move below if no space above if (left + tooltipRect.width > window.innerWidth) { left = window.innerWidth - tooltipRect.width - 5; // Prevent overflow right } // Apply styles (already in RAF) tooltipContainer.textContent = tooltipText; tooltipContainer.setAttribute("data-visible", "true"); tooltipContainer.style.top = `${top}px`; tooltipContainer.style.left = `${left}px`; } } }); }, true); // Capture phase required: mouseenter doesn't bubble element.addEventListener("mouseleave", (event) => { const cell = event.target.closest("[data-tooltip]"); if (cell) { tooltipContainer.setAttribute("data-visible", "false"); } }, true); // Capture phase required: mouseleave doesn't bubble } function disableTooltip() { const elementId = tooltipElementId // console.debug("disableTooltip on element " + elementId); const element = document.getElementById(elementId); if (!element) { console.error(`Invalid element '${elementId}' container`); return; } element.setAttribute("mmt-no-tooltip", ""); } function enableTooltip() { const elementId = tooltipElementId // console.debug("enableTooltip on element " + elementId); const element = document.getElementById(elementId); if (!element) { console.error(`Invalid element '${elementId}' container`); return; } element.removeAttribute("mmt-no-tooltip"); } /** * Shared utility function for triggering HTMX actions from keyboard/mouse bindings. * Handles dynamic hx-vals with "js:functionName()" syntax. * * @param {string} elementId - ID of the element * @param {Object} config - HTMX configuration object * @param {string} combinationStr - The matched combination string * @param {boolean} isInside - Whether the focus/click is inside the element * @param {Event} event - The event that triggered this action (KeyboardEvent or MouseEvent) */ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) { const element = document.getElementById(elementId); if (!element) return; const hasFocus = document.activeElement === element; // Extract HTTP method and URL from hx-* attributes let method = 'POST'; // default let url = null; const methodMap = { 'hx-post': 'POST', 'hx-get': 'GET', 'hx-put': 'PUT', 'hx-delete': 'DELETE', 'hx-patch': 'PATCH' }; for (const [attr, httpMethod] of Object.entries(methodMap)) { if (config[attr]) { method = httpMethod; url = config[attr]; break; } } if (!url) { console.error('No HTTP method attribute found in config:', config); return; } // Build htmx.ajax options const htmxOptions = {}; // Map hx-target to target if (config['hx-target']) { htmxOptions.target = config['hx-target']; } // Map hx-swap to swap if (config['hx-swap']) { htmxOptions.swap = config['hx-swap']; } // Map hx-vals to values and add combination, has_focus, and is_inside const values = {}; // 1. Merge static hx-vals from command (if present) if (config['hx-vals'] && typeof config['hx-vals'] === 'object') { Object.assign(values, config['hx-vals']); } // 2. Merge hx-vals-extra (user overrides) if (config['hx-vals-extra']) { const extra = config['hx-vals-extra']; // Merge static dict values if (extra.dict && typeof extra.dict === 'object') { Object.assign(values, extra.dict); } // Call dynamic JS function and merge result if (extra.js) { try { const func = window[extra.js]; if (typeof func === 'function') { const dynamicValues = func(event, element, combinationStr); if (dynamicValues && typeof dynamicValues === 'object') { Object.assign(values, dynamicValues); } } else { console.error(`Function "${extra.js}" not found on window`); } } catch (e) { console.error('Error calling dynamic hx-vals function:', e); } } } values.combination = combinationStr; values.has_focus = hasFocus; values.is_inside = isInside; htmxOptions.values = values; // Add any other hx-* attributes (like hx-headers, hx-select, etc.) for (const [key, value] of Object.entries(config)) { if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) { // Remove 'hx-' prefix and convert to camelCase const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); htmxOptions[optionKey] = value; } } // Make AJAX call with htmx //console.debug(`Triggering HTMX action for element ${elementId}: ${method} ${url}`, htmxOptions); htmx.ajax(method, url, htmxOptions); }