/** * 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) => { // Early exit - check mf-no-tooltip FIRST (before any DOM work) if (element.hasAttribute("mf-no-tooltip")) { return; } // OPTIMIZATION C: Throttle mouseenter events (max 1 per frame) if (tooltipRafScheduled) { return; } const cell = event.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")) { 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 initLayout(elementId) { initResizer(elementId); bindTooltipsWithDelegation(elementId); } 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"); } function initBoundaries(elementId, updateUrl) { function updateBoundaries() { const container = document.getElementById(elementId); if (!container) { console.warn("initBoundaries : element " + elementId + " is not found !"); return; } const rect = container.getBoundingClientRect(); const width = Math.floor(rect.width); const height = Math.floor(rect.height); console.log("boundaries: ", rect) // Send boundaries to server htmx.ajax('POST', updateUrl, { target: '#' + elementId, swap: 'outerHTML', values: {width: width, height: height} }); } // Debounce function let resizeTimeout; function debouncedUpdate() { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(updateBoundaries, 250); } // Update on load setTimeout(updateBoundaries, 100); // Update on window resize const container = document.getElementById(elementId); container.addEventListener('resize', debouncedUpdate); // Cleanup on element removal if (container) { const observer = new MutationObserver(function (mutations) { mutations.forEach(function (mutation) { mutation.removedNodes.forEach(function (node) { if (node.id === elementId) { window.removeEventListener('resize', debouncedUpdate); } }); }); }); observer.observe(container.parentNode, {childList: true}); } } /** * Updates the tabs display by showing the active tab content and scrolling to make it visible. * This function is called when switching between tabs to update both the content visibility * and the tab button states. * * @param {string} controllerId - The ID of the tabs controller element (format: "{managerId}-controller") */ function updateTabs(controllerId) { const controller = document.getElementById(controllerId); if (!controller) { console.warn(`Controller ${controllerId} not found`); return; } const activeTabId = controller.dataset.activeTab; if (!activeTabId) { console.warn('No active tab ID found'); return; } // Extract manager ID from controller ID (remove '-controller' suffix) const managerId = controllerId.replace('-controller', ''); // Hide all tab contents for this manager const contentWrapper = document.getElementById(`${managerId}-content-wrapper`); if (contentWrapper) { contentWrapper.querySelectorAll('.mf-tab-content').forEach(content => { content.classList.add('hidden'); }); // Show the active tab content const activeContent = document.getElementById(`${managerId}-${activeTabId}-content`); if (activeContent) { activeContent.classList.remove('hidden'); } } // Update active tab button styling const header = document.getElementById(`${managerId}-header`); if (header) { // Remove active class from all tabs header.querySelectorAll('.mf-tab-button').forEach(btn => { btn.classList.remove('mf-tab-active'); }); // Add active class to current tab const activeButton = header.querySelector(`[data-tab-id="${activeTabId}"]`); if (activeButton) { activeButton.classList.add('mf-tab-active'); // Scroll to make active tab visible if needed activeButton.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); } } } /** * Create keyboard bindings */ (function () { /** * Global registry to store keyboard shortcuts for multiple elements */ const KeyboardRegistry = { elements: new Map(), // elementId -> { tree, element } listenerAttached: false, currentKeys: new Set(), snapshotHistory: [], pendingTimeout: null, pendingMatches: [], // Array of matches waiting for timeout sequenceTimeout: 500 // 500ms timeout for sequences }; /** * Normalize key names to lowercase for case-insensitive comparison * @param {string} key - The key to normalize * @returns {string} - Normalized key name */ function normalizeKey(key) { const keyMap = { 'control': 'ctrl', 'escape': 'esc', 'delete': 'del' }; const normalized = key.toLowerCase(); return keyMap[normalized] || normalized; } /** * Create a unique string key from a Set of keys for Map indexing * @param {Set} keySet - Set of normalized keys * @returns {string} - Sorted string representation */ function setToKey(keySet) { return Array.from(keySet).sort().join('+'); } /** * Parse a single element (can be a single key or a simultaneous combination) * @param {string} element - The element string (e.g., "a" or "Ctrl+C") * @returns {Set} - Set of normalized keys */ function parseElement(element) { if (element.includes('+')) { // Simultaneous combination return new Set(element.split('+').map(k => normalizeKey(k.trim()))); } // Single key return new Set([normalizeKey(element.trim())]); } /** * Parse a combination string into sequence elements * @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C") * @returns {Array} - Array of Sets representing the sequence */ function parseCombination(combination) { // Check if it's a sequence (contains space) if (combination.includes(' ')) { return combination.split(' ').map(el => parseElement(el.trim())); } // Single element (can be a key or simultaneous combination) return [parseElement(combination)]; } /** * Create a new tree node * @returns {Object} - New tree node */ function createTreeNode() { return { config: null, combinationStr: null, children: new Map() }; } /** * Build a tree from combinations * @param {Object} combinations - Map of combination strings to HTMX config objects * @returns {Object} - Root tree node */ function buildTree(combinations) { const root = createTreeNode(); for (const [combinationStr, config] of Object.entries(combinations)) { const sequence = parseCombination(combinationStr); let currentNode = root; for (const keySet of sequence) { const key = setToKey(keySet); if (!currentNode.children.has(key)) { currentNode.children.set(key, createTreeNode()); } currentNode = currentNode.children.get(key); } // Mark as end of sequence and store config currentNode.config = config; currentNode.combinationStr = combinationStr; } return root; } /** * Traverse the tree with the current snapshot history * @param {Object} treeRoot - Root of the tree * @param {Array} snapshotHistory - Array of Sets representing pressed keys * @returns {Object|null} - Current node or null if no match */ function traverseTree(treeRoot, snapshotHistory) { let currentNode = treeRoot; for (const snapshot of snapshotHistory) { const key = setToKey(snapshot); if (!currentNode.children.has(key)) { return null; } currentNode = currentNode.children.get(key); } return currentNode; } /** * Check if we're inside an input element where typing should work normally * @returns {boolean} - True if inside an input-like element */ function isInInputContext() { const activeElement = document.activeElement; if (!activeElement) return false; const tagName = activeElement.tagName.toLowerCase(); // Check for input/textarea if (tagName === 'input' || tagName === 'textarea') { return true; } // Check for contenteditable if (activeElement.isContentEditable) { return true; } return false; } /** * Trigger an action for a matched combination * @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 is inside the element */ function triggerAction(elementId, config, combinationStr, isInside) { 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 = {}; if (config['hx-vals']) { Object.assign(values, config['hx-vals']); } 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 htmx.ajax(method, url, htmxOptions); } /** * Handle keyboard events and trigger matching combinations * @param {KeyboardEvent} event - The keyboard event */ function handleKeyboardEvent(event) { const key = normalizeKey(event.key); // Add key to current pressed keys KeyboardRegistry.currentKeys.add(key); console.debug("Received key", key); // Create a snapshot of current keyboard state const snapshot = new Set(KeyboardRegistry.currentKeys); // Add snapshot to history KeyboardRegistry.snapshotHistory.push(snapshot); // Cancel any pending timeout if (KeyboardRegistry.pendingTimeout) { clearTimeout(KeyboardRegistry.pendingTimeout); KeyboardRegistry.pendingTimeout = null; KeyboardRegistry.pendingMatches = []; } // Collect match information for all elements const currentMatches = []; let anyHasLongerSequence = false; let foundAnyMatch = false; // Check all registered elements for matching combinations for (const [elementId, data] of KeyboardRegistry.elements) { const element = document.getElementById(elementId); if (!element) continue; // Check if focus is inside this element (element itself or any child) const isInside = element.contains(document.activeElement); const treeRoot = data.tree; // Traverse the tree with current snapshot history const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory); if (!currentNode) { // No match in this tree, continue to next element console.debug("No match in tree for event", key); continue; } // We found at least a partial match foundAnyMatch = true; // Check if we have a match (node has a URL) const hasMatch = currentNode.config !== null; // Check if there are longer sequences possible (node has children) const hasLongerSequences = currentNode.children.size > 0; // Track if ANY element has longer sequences possible if (hasLongerSequences) { anyHasLongerSequence = true; } // Collect matches if (hasMatch) { currentMatches.push({ elementId: elementId, config: currentNode.config, combinationStr: currentNode.combinationStr, isInside: isInside }); } } // Prevent default if we found any match and not in input context if (currentMatches.length > 0 && !isInInputContext()) { event.preventDefault(); } // Decision logic based on matches and longer sequences if (currentMatches.length > 0 && !anyHasLongerSequence) { // We have matches and NO element has longer sequences possible // Trigger ALL matches immediately for (const match of currentMatches) { triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); } // Clear history after triggering KeyboardRegistry.snapshotHistory = []; } else if (currentMatches.length > 0 && anyHasLongerSequence) { // We have matches but AT LEAST ONE element has longer sequences possible // Wait for timeout - ALL current matches will be triggered if timeout expires KeyboardRegistry.pendingMatches = currentMatches; KeyboardRegistry.pendingTimeout = setTimeout(() => { // Timeout expired, trigger ALL pending matches for (const match of KeyboardRegistry.pendingMatches) { triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); } // Clear state KeyboardRegistry.snapshotHistory = []; KeyboardRegistry.pendingMatches = []; KeyboardRegistry.pendingTimeout = null; }, KeyboardRegistry.sequenceTimeout); } else if (currentMatches.length === 0 && anyHasLongerSequence) { // No matches yet but longer sequences are possible // Just wait, don't trigger anything } else { // No matches and no longer sequences possible // This is an invalid sequence - clear history KeyboardRegistry.snapshotHistory = []; } // If we found no match at all, clear the history // This handles invalid sequences like "A C" when only "A B" exists if (!foundAnyMatch) { KeyboardRegistry.snapshotHistory = []; } // Also clear history if it gets too long (prevent memory issues) if (KeyboardRegistry.snapshotHistory.length > 10) { KeyboardRegistry.snapshotHistory = []; } } /** * Handle keyup event to remove keys from current pressed keys * @param {KeyboardEvent} event - The keyboard event */ function handleKeyUp(event) { const key = normalizeKey(event.key); KeyboardRegistry.currentKeys.delete(key); } /** * Attach the global keyboard event listener if not already attached */ function attachGlobalListener() { if (!KeyboardRegistry.listenerAttached) { document.addEventListener('keydown', handleKeyboardEvent); document.addEventListener('keyup', handleKeyUp); KeyboardRegistry.listenerAttached = true; } } /** * Detach the global keyboard event listener */ function detachGlobalListener() { if (KeyboardRegistry.listenerAttached) { document.removeEventListener('keydown', handleKeyboardEvent); document.removeEventListener('keyup', handleKeyUp); KeyboardRegistry.listenerAttached = false; // Clean up all state KeyboardRegistry.currentKeys.clear(); KeyboardRegistry.snapshotHistory = []; if (KeyboardRegistry.pendingTimeout) { clearTimeout(KeyboardRegistry.pendingTimeout); KeyboardRegistry.pendingTimeout = null; } KeyboardRegistry.pendingMatches = []; } } /** * Add keyboard support to an element * @param {string} elementId - The ID of the element * @param {string} combinationsJson - JSON string of combinations mapping */ window.add_keyboard_support = function (elementId, combinationsJson) { // Parse the combinations JSON const combinations = JSON.parse(combinationsJson); // Build tree for this element const tree = buildTree(combinations); // Get element reference const element = document.getElementById(elementId); if (!element) { console.error("Element with ID", elementId, "not found!"); return; } // Add to registry KeyboardRegistry.elements.set(elementId, { tree: tree, element: element }); // Attach global listener if not already attached attachGlobalListener(); }; /** * Remove keyboard support from an element * @param {string} elementId - The ID of the element */ window.remove_keyboard_support = function (elementId) { // Remove from registry if (!KeyboardRegistry.elements.has(elementId)) { console.warn("Element with ID", elementId, "not found in keyboard registry!"); return; } KeyboardRegistry.elements.delete(elementId); // If no more elements, detach global listeners if (KeyboardRegistry.elements.size === 0) { detachGlobalListener(); } }; })(); /** * Create mouse bindings */ (function () { /** * Global registry to store mouse shortcuts for multiple elements */ const MouseRegistry = { elements: new Map(), // elementId -> { tree, element } listenerAttached: false, snapshotHistory: [], pendingTimeout: null, pendingMatches: [], // Array of matches waiting for timeout sequenceTimeout: 500, // 500ms timeout for sequences clickHandler: null, contextmenuHandler: null }; /** * Normalize mouse action names * @param {string} action - The action to normalize * @returns {string} - Normalized action name */ function normalizeAction(action) { const normalized = action.toLowerCase().trim(); // Handle aliases const aliasMap = { 'rclick': 'right_click' }; return aliasMap[normalized] || normalized; } /** * Create a unique string key from a Set of actions for Map indexing * @param {Set} actionSet - Set of normalized actions * @returns {string} - Sorted string representation */ function setToKey(actionSet) { return Array.from(actionSet).sort().join('+'); } /** * Parse a single element (can be a simple click or click with modifiers) * @param {string} element - The element string (e.g., "click" or "ctrl+click") * @returns {Set} - Set of normalized actions */ function parseElement(element) { if (element.includes('+')) { // Click with modifiers return new Set(element.split('+').map(a => normalizeAction(a))); } // Simple click return new Set([normalizeAction(element)]); } /** * Parse a combination string into sequence elements * @param {string} combination - The combination string (e.g., "click right_click") * @returns {Array} - Array of Sets representing the sequence */ function parseCombination(combination) { // Check if it's a sequence (contains space) if (combination.includes(' ')) { return combination.split(' ').map(el => parseElement(el.trim())); } // Single element (can be a click or click with modifiers) return [parseElement(combination)]; } /** * Create a new tree node * @returns {Object} - New tree node */ function createTreeNode() { return { config: null, combinationStr: null, children: new Map() }; } /** * Build a tree from combinations * @param {Object} combinations - Map of combination strings to HTMX config objects * @returns {Object} - Root tree node */ function buildTree(combinations) { const root = createTreeNode(); for (const [combinationStr, config] of Object.entries(combinations)) { const sequence = parseCombination(combinationStr); console.log("Parsing mouse combination", combinationStr, "=>", sequence); let currentNode = root; for (const actionSet of sequence) { const key = setToKey(actionSet); if (!currentNode.children.has(key)) { currentNode.children.set(key, createTreeNode()); } currentNode = currentNode.children.get(key); } // Mark as end of sequence and store config currentNode.config = config; currentNode.combinationStr = combinationStr; } return root; } /** * Traverse the tree with the current snapshot history * @param {Object} treeRoot - Root of the tree * @param {Array} snapshotHistory - Array of Sets representing mouse actions * @returns {Object|null} - Current node or null if no match */ function traverseTree(treeRoot, snapshotHistory) { let currentNode = treeRoot; for (const snapshot of snapshotHistory) { const key = setToKey(snapshot); if (!currentNode.children.has(key)) { return null; } currentNode = currentNode.children.get(key); } return currentNode; } /** * Check if we're inside an input element where clicking should work normally * @returns {boolean} - True if inside an input-like element */ function isInInputContext() { const activeElement = document.activeElement; if (!activeElement) return false; const tagName = activeElement.tagName.toLowerCase(); // Check for input/textarea if (tagName === 'input' || tagName === 'textarea') { return true; } // Check for contenteditable if (activeElement.isContentEditable) { return true; } return false; } /** * Get the element that was actually clicked (from registered elements) * @param {Element} target - The clicked element * @returns {string|null} - Element ID if found, null otherwise */ function findRegisteredElement(target) { // Check if target itself is registered if (target.id && MouseRegistry.elements.has(target.id)) { return target.id; } // Check if any parent is registered let current = target.parentElement; while (current) { if (current.id && MouseRegistry.elements.has(current.id)) { return current.id; } current = current.parentElement; } return null; } /** * Create a snapshot from mouse event * @param {MouseEvent} event - The mouse event * @param {string} baseAction - The base action ('click' or 'right_click') * @returns {Set} - Set of actions representing this click */ function createSnapshot(event, baseAction) { const actions = new Set([baseAction]); // Add modifiers if present if (event.ctrlKey || event.metaKey) { actions.add('ctrl'); } if (event.shiftKey) { actions.add('shift'); } if (event.altKey) { actions.add('alt'); } return actions; } /** * Trigger an action for a matched combination * @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 click was inside the element */ function triggerAction(elementId, config, combinationStr, isInside) { 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 = {}; if (config['hx-vals']) { Object.assign(values, config['hx-vals']); } 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 htmx.ajax(method, url, htmxOptions); } /** * Handle mouse events and trigger matching combinations * @param {MouseEvent} event - The mouse event * @param {string} baseAction - The base action ('click' or 'right_click') */ function handleMouseEvent(event, baseAction) { // Different behavior for click vs right_click if (baseAction === 'click') { // Click: trigger for ALL registered elements (useful for closing modals/popups) handleGlobalClick(event); } else if (baseAction === 'right_click') { // Right-click: trigger ONLY if clicked on a registered element handleElementRightClick(event); } } /** * Handle global click events (triggers for all registered elements) * @param {MouseEvent} event - The mouse event */ function handleGlobalClick(event) { // DEBUG: Measure click handler performance const clickStart = performance.now(); const elementCount = MouseRegistry.elements.size; console.warn(`🖱️ Click handler START: processing ${elementCount} registered elements`); // Create a snapshot of current mouse action with modifiers const snapshot = createSnapshot(event, 'click'); // Add snapshot to history MouseRegistry.snapshotHistory.push(snapshot); // Cancel any pending timeout if (MouseRegistry.pendingTimeout) { clearTimeout(MouseRegistry.pendingTimeout); MouseRegistry.pendingTimeout = null; MouseRegistry.pendingMatches = []; } // Collect match information for ALL registered elements const currentMatches = []; let anyHasLongerSequence = false; let foundAnyMatch = false; let iterationCount = 0; for (const [elementId, data] of MouseRegistry.elements) { iterationCount++; const element = document.getElementById(elementId); if (!element) continue; // Check if click was inside this element const isInside = element.contains(event.target); const treeRoot = data.tree; // Traverse the tree with current snapshot history const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory); if (!currentNode) { // No match in this tree continue; } // We found at least a partial match foundAnyMatch = true; // Check if we have a match (node has config) const hasMatch = currentNode.config !== null; // Check if there are longer sequences possible (node has children) const hasLongerSequences = currentNode.children.size > 0; if (hasLongerSequences) { anyHasLongerSequence = true; } // Collect matches if (hasMatch) { currentMatches.push({ elementId: elementId, config: currentNode.config, combinationStr: currentNode.combinationStr, isInside: isInside }); } } // Prevent default if we found any match and not in input context if (currentMatches.length > 0 && !isInInputContext()) { event.preventDefault(); } // Decision logic based on matches and longer sequences if (currentMatches.length > 0 && !anyHasLongerSequence) { // We have matches and NO longer sequences possible // Trigger ALL matches immediately for (const match of currentMatches) { triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); } // Clear history after triggering MouseRegistry.snapshotHistory = []; } else if (currentMatches.length > 0 && anyHasLongerSequence) { // We have matches but longer sequences are possible // Wait for timeout - ALL current matches will be triggered if timeout expires MouseRegistry.pendingMatches = currentMatches; MouseRegistry.pendingTimeout = setTimeout(() => { // Timeout expired, trigger ALL pending matches for (const match of MouseRegistry.pendingMatches) { triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); } // Clear state MouseRegistry.snapshotHistory = []; MouseRegistry.pendingMatches = []; MouseRegistry.pendingTimeout = null; }, MouseRegistry.sequenceTimeout); } else if (currentMatches.length === 0 && anyHasLongerSequence) { // No matches yet but longer sequences are possible // Just wait, don't trigger anything } else { // No matches and no longer sequences possible // This is an invalid sequence - clear history MouseRegistry.snapshotHistory = []; } // If we found no match at all, clear the history if (!foundAnyMatch) { MouseRegistry.snapshotHistory = []; } // Also clear history if it gets too long (prevent memory issues) if (MouseRegistry.snapshotHistory.length > 10) { MouseRegistry.snapshotHistory = []; } // DEBUG: Log click handler performance const clickDuration = performance.now() - clickStart; console.warn(`🖱️ Click handler DONE: ${clickDuration.toFixed(2)}ms (${iterationCount} iterations, ${currentMatches.length} matches)`); if (clickDuration > 100) { console.warn(`⚠️ SLOW CLICK HANDLER: ${clickDuration.toFixed(2)}ms for ${elementCount} elements`); } } /** * Handle right-click events (triggers only for clicked element) * @param {MouseEvent} event - The mouse event */ function handleElementRightClick(event) { // Find which registered element was clicked const elementId = findRegisteredElement(event.target); if (!elementId) { // Right-click wasn't on a registered element - don't prevent default // This allows browser context menu to appear return; } console.debug("Right-click on registered element", elementId); // For right-click, clicked_inside is always true (we only trigger if clicked on element) const clickedInside = true; // Create a snapshot of current mouse action with modifiers const snapshot = createSnapshot(event, 'right_click'); // Add snapshot to history MouseRegistry.snapshotHistory.push(snapshot); // Cancel any pending timeout if (MouseRegistry.pendingTimeout) { clearTimeout(MouseRegistry.pendingTimeout); MouseRegistry.pendingTimeout = null; MouseRegistry.pendingMatches = []; } // Collect match information for this element const currentMatches = []; let anyHasLongerSequence = false; let foundAnyMatch = false; const data = MouseRegistry.elements.get(elementId); if (!data) return; const treeRoot = data.tree; // Traverse the tree with current snapshot history const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory); if (!currentNode) { // No match in this tree console.debug("No match in tree for right-click"); // Clear history for invalid sequences MouseRegistry.snapshotHistory = []; return; } // We found at least a partial match foundAnyMatch = true; // Check if we have a match (node has config) const hasMatch = currentNode.config !== null; // Check if there are longer sequences possible (node has children) const hasLongerSequences = currentNode.children.size > 0; if (hasLongerSequences) { anyHasLongerSequence = true; } // Collect matches if (hasMatch) { currentMatches.push({ elementId: elementId, config: currentNode.config, combinationStr: currentNode.combinationStr, isInside: true // Right-click only triggers when clicking on element }); } // Prevent default if we found any match and not in input context if (currentMatches.length > 0 && !isInInputContext()) { event.preventDefault(); } // Decision logic based on matches and longer sequences if (currentMatches.length > 0 && !anyHasLongerSequence) { // We have matches and NO longer sequences possible // Trigger ALL matches immediately for (const match of currentMatches) { triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); } // Clear history after triggering MouseRegistry.snapshotHistory = []; } else if (currentMatches.length > 0 && anyHasLongerSequence) { // We have matches but longer sequences are possible // Wait for timeout - ALL current matches will be triggered if timeout expires MouseRegistry.pendingMatches = currentMatches; MouseRegistry.pendingTimeout = setTimeout(() => { // Timeout expired, trigger ALL pending matches for (const match of MouseRegistry.pendingMatches) { triggerAction(match.elementId, match.config, match.combinationStr, match.isInside); } // Clear state MouseRegistry.snapshotHistory = []; MouseRegistry.pendingMatches = []; MouseRegistry.pendingTimeout = null; }, MouseRegistry.sequenceTimeout); } else if (currentMatches.length === 0 && anyHasLongerSequence) { // No matches yet but longer sequences are possible // Just wait, don't trigger anything } else { // No matches and no longer sequences possible // This is an invalid sequence - clear history MouseRegistry.snapshotHistory = []; } // If we found no match at all, clear the history if (!foundAnyMatch) { MouseRegistry.snapshotHistory = []; } // Also clear history if it gets too long (prevent memory issues) if (MouseRegistry.snapshotHistory.length > 10) { MouseRegistry.snapshotHistory = []; } } /** * Attach the global mouse event listeners if not already attached */ function attachGlobalListener() { if (!MouseRegistry.listenerAttached) { // Store handler references for proper removal MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click'); MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click'); document.addEventListener('click', MouseRegistry.clickHandler); document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler); MouseRegistry.listenerAttached = true; } } /** * Detach the global mouse event listeners */ function detachGlobalListener() { if (MouseRegistry.listenerAttached) { document.removeEventListener('click', MouseRegistry.clickHandler); document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler); MouseRegistry.listenerAttached = false; // Clean up handler references MouseRegistry.clickHandler = null; MouseRegistry.contextmenuHandler = null; // Clean up all state MouseRegistry.snapshotHistory = []; if (MouseRegistry.pendingTimeout) { clearTimeout(MouseRegistry.pendingTimeout); MouseRegistry.pendingTimeout = null; } MouseRegistry.pendingMatches = []; } } /** * Add mouse support to an element * @param {string} elementId - The ID of the element * @param {string} combinationsJson - JSON string of combinations mapping */ window.add_mouse_support = function (elementId, combinationsJson) { // Parse the combinations JSON const combinations = JSON.parse(combinationsJson); // Build tree for this element const tree = buildTree(combinations); // Get element reference const element = document.getElementById(elementId); if (!element) { console.error("Element with ID", elementId, "not found!"); return; } // Add to registry MouseRegistry.elements.set(elementId, { tree: tree, element: element }); // Attach global listener if not already attached attachGlobalListener(); }; /** * Remove mouse support from an element * @param {string} elementId - The ID of the element */ window.remove_mouse_support = function (elementId) { // Remove from registry if (!MouseRegistry.elements.has(elementId)) { console.warn("Element with ID", elementId, "not found in mouse registry!"); return; } MouseRegistry.elements.delete(elementId); // If no more elements, detach global listeners if (MouseRegistry.elements.size === 0) { detachGlobalListener(); } }; })(); function initDataGrid(gridId) { initDataGridScrollbars(gridId); makeDatagridColumnsResizable(gridId); makeDatagridColumnsMovable(gridId); } /** * Initialize DataGrid with CSS Grid layout + Custom Scrollbars * * Adapted from previous custom scrollbar implementation to work with CSS Grid. * - Grid handles layout (no height calculations needed) * - Custom scrollbars for visual consistency and positioning control * - Vertical scroll: on body container (.dt2-body-container) * - Horizontal scroll: on table (.dt2-table) to scroll header, body, footer together * * @param {string} gridId - The ID of the DataGrid instance */ function initDataGridScrollbars(gridId) { const wrapper = document.getElementById(`tw_${gridId}`); if (!wrapper) { console.error(`DataGrid wrapper "tw_${gridId}" not found.`); return; } const verticalScrollbar = wrapper.querySelector(".dt2-scrollbars-vertical"); const verticalWrapper = wrapper.querySelector(".dt2-scrollbars-vertical-wrapper"); const horizontalScrollbar = wrapper.querySelector(".dt2-scrollbars-horizontal"); const horizontalWrapper = wrapper.querySelector(".dt2-scrollbars-horizontal-wrapper"); const bodyContainer = wrapper.querySelector(".dt2-body-container"); const table = wrapper.querySelector(".dt2-table"); if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !bodyContainer || !table) { console.error("Essential scrollbar or content elements are missing in the datagrid."); return; } // OPTIMIZATION: Cache element references to avoid repeated querySelector calls const header = table.querySelector(".dt2-header"); const body = table.querySelector(".dt2-body"); // OPTIMIZATION: RequestAnimationFrame flags to throttle visual updates let rafScheduledVertical = false; let rafScheduledHorizontal = false; let rafScheduledUpdate = false; // OPTIMIZATION: Pre-calculated scroll ratios (updated in updateScrollbars) // Allows instant mousedown with zero DOM reads let cachedVerticalScrollRatio = 0; let cachedHorizontalScrollRatio = 0; // OPTIMIZATION: Cached scroll positions to avoid DOM reads in mousedown // Initialized once at setup, updated in RAF handlers after each scroll change let cachedBodyScrollTop = bodyContainer.scrollTop; let cachedTableScrollLeft = table.scrollLeft; /** * OPTIMIZED: Batched update function * Phase 1: Read all DOM properties (no writes) * Phase 2: Calculate all values * Phase 3: Write all DOM properties in single RAF */ const updateScrollbars = () => { if (rafScheduledUpdate) return; rafScheduledUpdate = true; requestAnimationFrame(() => { rafScheduledUpdate = false; // PHASE 1: Read all DOM properties const metrics = { bodyScrollHeight: bodyContainer.scrollHeight, bodyClientHeight: bodyContainer.clientHeight, bodyScrollTop: bodyContainer.scrollTop, tableClientWidth: table.clientWidth, tableScrollLeft: table.scrollLeft, verticalWrapperHeight: verticalWrapper.offsetHeight, horizontalWrapperWidth: horizontalWrapper.offsetWidth, headerScrollWidth: header ? header.scrollWidth : 0, bodyScrollWidth: body ? body.scrollWidth : 0 }; // PHASE 2: Calculate all values const contentWidth = Math.max(metrics.headerScrollWidth, metrics.bodyScrollWidth); // Visibility const isVerticalRequired = metrics.bodyScrollHeight > metrics.bodyClientHeight; const isHorizontalRequired = contentWidth > metrics.tableClientWidth; // Scrollbar sizes let scrollbarHeight = 0; if (metrics.bodyScrollHeight > 0) { scrollbarHeight = (metrics.bodyClientHeight / metrics.bodyScrollHeight) * metrics.verticalWrapperHeight; } let scrollbarWidth = 0; if (contentWidth > 0) { scrollbarWidth = (metrics.tableClientWidth / contentWidth) * metrics.horizontalWrapperWidth; } // Scrollbar positions const maxScrollTop = metrics.bodyScrollHeight - metrics.bodyClientHeight; let verticalTop = 0; if (maxScrollTop > 0) { const scrollRatio = metrics.verticalWrapperHeight / metrics.bodyScrollHeight; verticalTop = metrics.bodyScrollTop * scrollRatio; } const maxScrollLeft = contentWidth - metrics.tableClientWidth; let horizontalLeft = 0; if (maxScrollLeft > 0 && contentWidth > 0) { const scrollRatio = metrics.horizontalWrapperWidth / contentWidth; horizontalLeft = metrics.tableScrollLeft * scrollRatio; } // OPTIMIZATION: Pre-calculate and cache scroll ratios for instant mousedown // Vertical scroll ratio if (maxScrollTop > 0 && scrollbarHeight > 0) { cachedVerticalScrollRatio = maxScrollTop / (metrics.verticalWrapperHeight - scrollbarHeight); } else { cachedVerticalScrollRatio = 0; } // Horizontal scroll ratio if (maxScrollLeft > 0 && scrollbarWidth > 0) { cachedHorizontalScrollRatio = maxScrollLeft / (metrics.horizontalWrapperWidth - scrollbarWidth); } else { cachedHorizontalScrollRatio = 0; } // PHASE 3: Write all DOM properties (already in RAF) verticalWrapper.style.display = isVerticalRequired ? "block" : "none"; horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none"; verticalScrollbar.style.height = `${scrollbarHeight}px`; horizontalScrollbar.style.width = `${scrollbarWidth}px`; verticalScrollbar.style.top = `${verticalTop}px`; horizontalScrollbar.style.left = `${horizontalLeft}px`; }); }; // Consolidated drag management let isDraggingVertical = false; let isDraggingHorizontal = false; let dragStartY = 0; let dragStartX = 0; let dragStartScrollTop = 0; let dragStartScrollLeft = 0; // Vertical scrollbar mousedown verticalScrollbar.addEventListener("mousedown", (e) => { isDraggingVertical = true; dragStartY = e.clientY; dragStartScrollTop = cachedBodyScrollTop; wrapper.setAttribute("mf-no-tooltip", ""); }); // Horizontal scrollbar mousedown horizontalScrollbar.addEventListener("mousedown", (e) => { isDraggingHorizontal = true; dragStartX = e.clientX; dragStartScrollLeft = cachedTableScrollLeft; wrapper.setAttribute("mf-no-tooltip", ""); }); // Consolidated mousemove listener document.addEventListener("mousemove", (e) => { if (isDraggingVertical) { const deltaY = e.clientY - dragStartY; if (!rafScheduledVertical) { rafScheduledVertical = true; requestAnimationFrame(() => { rafScheduledVertical = false; const scrollDelta = deltaY * cachedVerticalScrollRatio; bodyContainer.scrollTop = dragStartScrollTop + scrollDelta; cachedBodyScrollTop = bodyContainer.scrollTop; updateScrollbars(); }); } } else if (isDraggingHorizontal) { const deltaX = e.clientX - dragStartX; if (!rafScheduledHorizontal) { rafScheduledHorizontal = true; requestAnimationFrame(() => { rafScheduledHorizontal = false; const scrollDelta = deltaX * cachedHorizontalScrollRatio; table.scrollLeft = dragStartScrollLeft + scrollDelta; cachedTableScrollLeft = table.scrollLeft; updateScrollbars(); }); } } }); // Consolidated mouseup listener document.addEventListener("mouseup", () => { if (isDraggingVertical) { isDraggingVertical = false; wrapper.removeAttribute("mf-no-tooltip"); } else if (isDraggingHorizontal) { isDraggingHorizontal = false; wrapper.removeAttribute("mf-no-tooltip"); } }); // Wheel scrolling - OPTIMIZED with RAF throttling let rafScheduledWheel = false; let pendingWheelDeltaX = 0; let pendingWheelDeltaY = 0; const handleWheelScrolling = (event) => { // Accumulate wheel deltas pendingWheelDeltaX += event.deltaX; pendingWheelDeltaY += event.deltaY; // Schedule update in next animation frame (throttle) if (!rafScheduledWheel) { rafScheduledWheel = true; requestAnimationFrame(() => { rafScheduledWheel = false; // Apply accumulated scroll bodyContainer.scrollTop += pendingWheelDeltaY; table.scrollLeft += pendingWheelDeltaX; // Update caches with clamped values (read back from DOM in RAF - OK) cachedBodyScrollTop = bodyContainer.scrollTop; cachedTableScrollLeft = table.scrollLeft; // Reset pending deltas pendingWheelDeltaX = 0; pendingWheelDeltaY = 0; // Update all scrollbars in a single batched operation updateScrollbars(); }); } event.preventDefault(); }; wrapper.addEventListener("wheel", handleWheelScrolling, {passive: false}); // Initialize scrollbars with single batched update updateScrollbars(); // Recompute on window resize with RAF throttling let resizeScheduled = false; window.addEventListener("resize", () => { if (!resizeScheduled) { resizeScheduled = true; requestAnimationFrame(() => { resizeScheduled = false; updateScrollbars(); }); } }); } function makeDatagridColumnsResizable(datagridId) { console.debug("makeResizable on element " + datagridId); const tableId = 't_' + datagridId; const table = document.getElementById(tableId); const resizeHandles = table.querySelectorAll('.dt2-resize-handle'); const MIN_WIDTH = 30; // Prevent columns from becoming too narrow // Attach event listeners using delegation resizeHandles.forEach(handle => { handle.addEventListener('mousedown', onStartResize); handle.addEventListener('touchstart', onStartResize, {passive: false}); handle.addEventListener('dblclick', onDoubleClick); // Reset column width }); let resizingState = null; // Maintain resizing state information function onStartResize(event) { event.preventDefault(); // Prevent unintended selections const isTouch = event.type === 'touchstart'; const startX = isTouch ? event.touches[0].pageX : event.pageX; const handle = event.target; const cell = handle.parentElement; const colIndex = cell.getAttribute('data-col'); const commandId = handle.dataset.commandId; const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`); // Store initial state const startWidth = cell.offsetWidth + 8; resizingState = {startX, startWidth, colIndex, commandId, cells}; // Attach event listeners for resizing document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onResize); document.addEventListener(isTouch ? 'touchend' : 'mouseup', onStopResize); } function onResize(event) { if (!resizingState) { return; } const isTouch = event.type === 'touchmove'; const currentX = isTouch ? event.touches[0].pageX : event.pageX; const {startX, startWidth, cells} = resizingState; // Calculate new width and apply constraints const newWidth = Math.max(MIN_WIDTH, startWidth + (currentX - startX)); cells.forEach(cell => { cell.style.width = `${newWidth}px`; }); } function onStopResize(event) { if (!resizingState) { return; } const {colIndex, commandId, cells} = resizingState; const finalWidth = cells[0].offsetWidth; // Send width update to server via HTMX if (commandId) { htmx.ajax('POST', '/myfasthtml/commands', { headers: { "Content-Type": "application/x-www-form-urlencoded" }, swap: 'none', values: { c_id: commandId, col_id: colIndex, width: finalWidth } }); } // Clean up resizingState = null; document.removeEventListener('mousemove', onResize); document.removeEventListener('mouseup', onStopResize); document.removeEventListener('touchmove', onResize); document.removeEventListener('touchend', onStopResize); } function onDoubleClick(event) { const handle = event.target; const cell = handle.parentElement; const colIndex = cell.getAttribute('data-col'); const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`); // Reset column width cells.forEach(cell => { cell.style.width = ''; // Use CSS default width }); // Emit reset event const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}}); table.dispatchEvent(resetEvent); } } /** * Enable column reordering via drag and drop on a DataGrid. * Columns can be dragged to new positions with animated transitions. * @param {string} gridId - The DataGrid instance ID */ function makeDatagridColumnsMovable(gridId) { const table = document.getElementById(`t_${gridId}`); const headerRow = document.getElementById(`th_${gridId}`); if (!table || !headerRow) { console.error(`DataGrid elements not found for ${gridId}`); return; } const moveCommandId = headerRow.dataset.moveCommandId; const headerCells = headerRow.querySelectorAll('.dt2-cell:not(.dt2-col-hidden)'); let sourceColumn = null; // Column being dragged (original position) let lastMoveTarget = null; // Last column we moved to (for persistence) let hoverColumn = null; // Current hover target (for delayed move check) headerCells.forEach(cell => { cell.setAttribute('draggable', 'true'); // Prevent drag when clicking resize handle const resizeHandle = cell.querySelector('.dt2-resize-handle'); if (resizeHandle) { resizeHandle.addEventListener('mousedown', () => cell.setAttribute('draggable', 'false')); resizeHandle.addEventListener('mouseup', () => cell.setAttribute('draggable', 'true')); } cell.addEventListener('dragstart', (e) => { sourceColumn = cell.getAttribute('data-col'); lastMoveTarget = null; hoverColumn = null; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', sourceColumn); cell.classList.add('dt2-dragging'); }); cell.addEventListener('dragenter', (e) => { e.preventDefault(); const targetColumn = cell.getAttribute('data-col'); hoverColumn = targetColumn; if (sourceColumn && sourceColumn !== targetColumn) { // Delay to skip columns when dragging fast setTimeout(() => { if (hoverColumn === targetColumn) { moveColumn(table, sourceColumn, targetColumn); lastMoveTarget = targetColumn; } }, 50); } }); cell.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }); cell.addEventListener('drop', (e) => { e.preventDefault(); // Persist to server if (moveCommandId && sourceColumn && lastMoveTarget) { htmx.ajax('POST', '/myfasthtml/commands', { headers: {"Content-Type": "application/x-www-form-urlencoded"}, swap: 'none', values: { c_id: moveCommandId, source_col_id: sourceColumn, target_col_id: lastMoveTarget } }); } }); cell.addEventListener('dragend', () => { headerCells.forEach(c => c.classList.remove('dt2-dragging')); sourceColumn = null; lastMoveTarget = null; hoverColumn = null; }); }); } /** * Move a column to a new position with animation. * All columns between source and target shift to fill the gap. * @param {HTMLElement} table - The table element * @param {string} sourceColId - Column ID to move * @param {string} targetColId - Column ID to move next to */ function moveColumn(table, sourceColId, targetColId) { const ANIMATION_DURATION = 300; // Must match CSS transition duration const sourceHeader = table.querySelector(`.dt2-cell[data-col="${sourceColId}"]`); const targetHeader = table.querySelector(`.dt2-cell[data-col="${targetColId}"]`); if (!sourceHeader || !targetHeader) return; if (sourceHeader.classList.contains('dt2-moving')) return; // Animation in progress const headerCells = Array.from(sourceHeader.parentNode.children); const sourceIdx = headerCells.indexOf(sourceHeader); const targetIdx = headerCells.indexOf(targetHeader); if (sourceIdx === targetIdx) return; const movingRight = sourceIdx < targetIdx; const sourceCells = table.querySelectorAll(`.dt2-cell[data-col="${sourceColId}"]`); // Collect cells that need to shift (between source and target) const cellsToShift = []; let shiftWidth = 0; const [startIdx, endIdx] = movingRight ? [sourceIdx + 1, targetIdx] : [targetIdx, sourceIdx - 1]; for (let i = startIdx; i <= endIdx; i++) { const colId = headerCells[i].getAttribute('data-col'); cellsToShift.push(...table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`)); shiftWidth += headerCells[i].offsetWidth; } // Calculate animation distances const sourceWidth = sourceHeader.offsetWidth; const sourceDistance = movingRight ? shiftWidth : -shiftWidth; const shiftDistance = movingRight ? -sourceWidth : sourceWidth; // Animate source column sourceCells.forEach(cell => { cell.classList.add('dt2-moving'); cell.style.transform = `translateX(${sourceDistance}px)`; }); // Animate shifted columns cellsToShift.forEach(cell => { cell.classList.add('dt2-moving'); cell.style.transform = `translateX(${shiftDistance}px)`; }); // After animation: reset transforms and update DOM setTimeout(() => { [...sourceCells, ...cellsToShift].forEach(cell => { cell.classList.remove('dt2-moving'); cell.style.transform = ''; }); // Move source column in DOM table.querySelectorAll('.dt2-row').forEach(row => { const sourceCell = row.querySelector(`[data-col="${sourceColId}"]`); const targetCell = row.querySelector(`[data-col="${targetColId}"]`); if (sourceCell && targetCell) { movingRight ? targetCell.after(sourceCell) : targetCell.before(sourceCell); } }); }, ANIMATION_DURATION); }