/** * 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, dblclickHandler: null, // Handler reference for dblclick contextmenuHandler: null, mousedownState: null, // Active drag state (only after movement detected) suppressNextClick: false, // Prevents click from firing after mousedown>mouseup mousedownHandler: null, // Handler reference for cleanup mouseupHandler: null, // Handler reference for cleanup mousedownSafetyTimeout: null, // Safety timeout if mouseup never arrives mousedownPending: null, // Pending mousedown data (before movement detected) mousemoveHandler: null, // Handler for detecting drag movement dragThreshold: 5 // Minimum pixels to consider it a drag (not a click) }; /** * 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', 'double_click': 'dblclick', 'dclick': 'dblclick' }; return aliasMap[normalized] || normalized; } /** * Check if an action Set contains a mousedown>mouseup action * @param {Set} actionSet - Set of normalized actions * @returns {boolean} - True if contains mousedown>mouseup or rmousedown>mouseup */ function isMousedownUpAction(actionSet) { return actionSet.has('mousedown>mouseup') || actionSet.has('rmousedown>mouseup'); } /** * Get the expected mouse button for a mousedown>mouseup action * @param {Set} actionSet - Set of normalized actions * @returns {number} - 0 for left button, 2 for right button */ function getMousedownUpButton(actionSet) { return actionSet.has('rmousedown>mouseup') ? 2 : 0; } /** * Scan all registered element trees to find mousedown>mouseup candidates * that are the next expected step given the current snapshot history. * @param {Array} snapshotHistory - Current snapshot history * @returns {Array} - Array of { elementId, node, actionSet, button } candidates */ function checkTreesForMousedownUp(snapshotHistory) { const candidates = []; for (const [elementId, data] of MouseRegistry.elements) { const element = document.getElementById(elementId); if (!element) continue; // Traverse tree to current position in history const currentNode = traverseTree(data.tree, snapshotHistory); if (!currentNode) continue; // Check children for mousedown>mouseup actions for (const [key, childNode] of currentNode.children) { const actionSet = new Set(key.split('+')); if (isMousedownUpAction(actionSet)) { candidates.push({ elementId: elementId, node: childNode, actionSet: actionSet, button: getMousedownUpButton(actionSet) }); } } } return candidates; } /** * 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; } /** * 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) { // Suppress click that fires after mousedown>mouseup drag if (MouseRegistry.suppressNextClick) { MouseRegistry.suppressNextClick = false; return; } // Don't process clicks during active drag mode (movement detected) // But DO process clicks if only mousedownPending (no movement = normal click) if (MouseRegistry.mousedownState) return; // 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 only if click was INSIDE a registered element // Clicks outside should preserve native behavior (checkboxes, buttons, etc.) const anyMatchInside = currentMatches.some(match => match.isInside); if (currentMatches.length > 0 && anyMatchInside && !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) { triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); } // 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; const savedEvent = event; // Save event for timeout callback MouseRegistry.pendingTimeout = setTimeout(() => { // Timeout expired, trigger ALL pending matches for (const match of MouseRegistry.pendingMatches) { triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent); } // 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 = []; } // Warn if click handler is slow const clickDuration = performance.now() - clickStart; 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) { // Don't process right-clicks during active drag with right button // But DO process if only pending (no movement = normal right-click) if (MouseRegistry.mousedownState && MouseRegistry.mousedownState.button === 2) return; // 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) { triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); } // 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; const savedEvent = event; // Save event for timeout callback MouseRegistry.pendingTimeout = setTimeout(() => { // Timeout expired, trigger ALL pending matches for (const match of MouseRegistry.pendingMatches) { triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent); } // 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 = []; } } /** * Handle dblclick events (triggers for all registered elements). * Uses a fresh single-step history so it never conflicts with click sequences. * @param {MouseEvent} event - The dblclick event */ function handleDblClick(event) { const snapshot = createSnapshot(event, 'dblclick'); const dblclickHistory = [snapshot]; const currentMatches = []; for (const [elementId, data] of MouseRegistry.elements) { const element = document.getElementById(elementId); if (!element) continue; const isInside = element.contains(event.target); const currentNode = traverseTree(data.tree, dblclickHistory); if (!currentNode || !currentNode.config) continue; currentMatches.push({ elementId: elementId, config: currentNode.config, combinationStr: currentNode.combinationStr, isInside: isInside }); } const anyMatchInside = currentMatches.some(m => m.isInside); if (anyMatchInside && !isInInputContext()) { event.preventDefault(); } for (const match of currentMatches) { triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); } } /** * Clean up mousedown state and safety timeout */ function clearMousedownState() { if (MouseRegistry.mousedownSafetyTimeout) { clearTimeout(MouseRegistry.mousedownSafetyTimeout); MouseRegistry.mousedownSafetyTimeout = null; } // Remove mousemove listener if attached if (MouseRegistry.mousemoveHandler) { document.removeEventListener('mousemove', MouseRegistry.mousemoveHandler); MouseRegistry.mousemoveHandler = null; } MouseRegistry.mousedownState = null; MouseRegistry.mousedownPending = null; } /** * Handle global mousedown events for mousedown>mouseup actions. * Stores pending state and waits for movement to activate drag mode. * @param {MouseEvent} event - The mousedown event */ function handleMouseDown(event) { // Exit early if already in mousedown state or pending if (MouseRegistry.mousedownState || MouseRegistry.mousedownPending) return; // Check if any registered tree has a mousedown>mouseup action as the next step const candidates = checkTreesForMousedownUp(MouseRegistry.snapshotHistory); if (candidates.length === 0) return; // Filter candidates by button match const matchingCandidates = candidates.filter(c => c.button === event.button); if (matchingCandidates.length === 0) return; // Check if mousedown target is inside any registered element const hasFocusMap = {}; for (const candidate of matchingCandidates) { const element = document.getElementById(candidate.elementId); if (element) { hasFocusMap[candidate.elementId] = document.activeElement === element; } } // Store PENDING mousedown state (not activated yet) MouseRegistry.mousedownPending = { button: event.button, ctrlKey: event.ctrlKey || event.metaKey, shiftKey: event.shiftKey, altKey: event.altKey, startX: event.clientX, startY: event.clientY, mousedownEvent: event, hasFocusMap: hasFocusMap, candidates: matchingCandidates }; // Add mousemove listener to detect drag MouseRegistry.mousemoveHandler = (moveEvent) => { if (!MouseRegistry.mousedownPending) return; const deltaX = Math.abs(moveEvent.clientX - MouseRegistry.mousedownPending.startX); const deltaY = Math.abs(moveEvent.clientY - MouseRegistry.mousedownPending.startY); // If moved beyond threshold, activate drag mode if (deltaX > MouseRegistry.dragThreshold || deltaY > MouseRegistry.dragThreshold) { activateDragMode(MouseRegistry.mousedownPending); } }; document.addEventListener('mousemove', MouseRegistry.mousemoveHandler); // Safety timeout: clean up if mouseup never arrives (5 seconds) MouseRegistry.mousedownSafetyTimeout = setTimeout(() => { clearMousedownState(); }, 5000); } /** * Activate drag mode after movement detected. * Called by mousemove handler when threshold exceeded. * @param {Object} pendingData - The pending mousedown data */ function activateDragMode(pendingData) { if (!pendingData) return; const event = pendingData.mousedownEvent; // Suspend sequence timeout (keep pendingMatches but stop the timer) if (MouseRegistry.pendingTimeout) { clearTimeout(MouseRegistry.pendingTimeout); MouseRegistry.pendingTimeout = null; } // Call hx-vals-extra JS functions at mousedown time, suffix results with _mousedown const mousedownJsValues = {}; for (const candidate of pendingData.candidates) { const config = candidate.node.config; if (!config) continue; const extra = config['hx-vals-extra']; if (extra && extra.js) { try { const func = window[extra.js]; if (typeof func === 'function') { const element = document.getElementById(candidate.elementId); const dynamicValues = func(event, element, candidate.node.combinationStr); if (dynamicValues && typeof dynamicValues === 'object') { // Store raw values - _mousedown suffix added at mouseup time mousedownJsValues[candidate.elementId] = dynamicValues; } } } catch (e) { console.error('Error calling dynamic hx-vals function at mousedown:', e); } } } // Activate drag state MouseRegistry.mousedownState = { button: pendingData.button, ctrlKey: pendingData.ctrlKey, shiftKey: pendingData.shiftKey, altKey: pendingData.altKey, hasFocusMap: pendingData.hasFocusMap, candidates: pendingData.candidates, mousedownJsValues: mousedownJsValues }; // Clear pending state MouseRegistry.mousedownPending = null; // Remove mousemove listener (threshold detection no longer needed) if (MouseRegistry.mousemoveHandler) { document.removeEventListener('mousemove', MouseRegistry.mousemoveHandler); MouseRegistry.mousemoveHandler = null; } // Attach on_move handler if any candidate has 'on-move' config const onMoveCandidates = MouseRegistry.mousedownState.candidates.filter( c => c.node.config && c.node.config['on-move'] ); if (onMoveCandidates.length > 0) { let rafId = null; MouseRegistry.mousemoveHandler = (moveEvent) => { if (rafId) return; rafId = requestAnimationFrame(() => { for (const candidate of onMoveCandidates) { const funcName = candidate.node.config['on-move']; try { const func = window[funcName]; if (typeof func === 'function') { const mousedownValues = mousedownJsValues[candidate.elementId] || null; func(moveEvent, candidate.node.combinationStr, mousedownValues); } } catch (e) { console.error('Error calling on_move function:', e); } } rafId = null; }); }; document.addEventListener('mousemove', MouseRegistry.mousemoveHandler); } // For right button: prevent context menu during drag if (pendingData.button === 2) { const preventContextMenu = (e) => { e.preventDefault(); }; document.addEventListener('contextmenu', preventContextMenu, { capture: true, once: true }); } // Prevent default if on a registered element and not in input context const targetElementId = findRegisteredElement(event.target); if (targetElementId && !isInInputContext()) { event.preventDefault(); } } /** * Handle global mouseup events for mousedown>mouseup actions. * Resolves the mousedown>mouseup action when mouseup fires (only if drag was activated). * @param {MouseEvent} event - The mouseup event */ function handleMouseUp(event) { // Case 1: Drag mode was activated (movement detected) if (MouseRegistry.mousedownState) { const mdState = MouseRegistry.mousedownState; // Verify button matches mousedown button if (event.button !== mdState.button) return; // Build action name based on button const actionName = mdState.button === 2 ? 'rmousedown>mouseup' : 'mousedown>mouseup'; // Build snapshot Set with modifiers from mousedown time const snapshot = new Set([actionName]); if (mdState.ctrlKey) snapshot.add('ctrl'); if (mdState.shiftKey) snapshot.add('shift'); if (mdState.altKey) snapshot.add('alt'); // Add snapshot to history MouseRegistry.snapshotHistory.push(snapshot); // Clear mousedown state clearMousedownState(); // Suppress next click for left button (click fires after mouseup) if (mdState.button === 0) { MouseRegistry.suppressNextClick = true; } // Resolve with tree-traversal matching resolveAfterMouseUp(event, mdState); return; } // Case 2: Mousedown pending but no movement (it's a click, not a drag) if (MouseRegistry.mousedownPending) { const pending = MouseRegistry.mousedownPending; // Verify button matches if (event.button !== pending.button) return; // Clean up pending state - let the normal click handler process it clearMousedownState(); // Don't suppress click - we want it to fire normally return; } } /** * Resolve mousedown>mouseup action using tree-traversal matching. * Same logic as handleGlobalClick but uses triggerMousedownUpAction. * @param {MouseEvent} mouseupEvent - The mouseup event * @param {Object} mdState - The mousedown state captured earlier */ function resolveAfterMouseUp(mouseupEvent, mdState) { const currentMatches = []; let anyHasLongerSequence = false; let foundAnyMatch = false; for (const [elementId, data] of MouseRegistry.elements) { const element = document.getElementById(elementId); if (!element) continue; // isInside is based on mouseup target position const isInside = element.contains(mouseupEvent.target); const treeRoot = data.tree; const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory); if (!currentNode) continue; foundAnyMatch = true; const hasMatch = currentNode.config !== null; const hasLongerSequences = currentNode.children.size > 0; if (hasLongerSequences) { anyHasLongerSequence = true; } if (hasMatch) { currentMatches.push({ elementId: elementId, config: currentNode.config, combinationStr: currentNode.combinationStr, isInside: isInside, hasFocus: mdState.hasFocusMap[elementId] || false, mousedownJsValues: mdState.mousedownJsValues[elementId] || {} }); } } // Clear pending matches from before mousedown MouseRegistry.pendingMatches = []; // Decision logic (same pattern as handleGlobalClick) if (currentMatches.length > 0 && !anyHasLongerSequence) { for (const match of currentMatches) { triggerMousedownUpAction(match, mouseupEvent); } MouseRegistry.snapshotHistory = []; } else if (currentMatches.length > 0 && anyHasLongerSequence) { MouseRegistry.pendingMatches = currentMatches.map(m => ({ ...m, mouseupEvent: mouseupEvent })); MouseRegistry.pendingTimeout = setTimeout(() => { for (const match of MouseRegistry.pendingMatches) { triggerMousedownUpAction(match, match.mouseupEvent); } MouseRegistry.snapshotHistory = []; MouseRegistry.pendingMatches = []; MouseRegistry.pendingTimeout = null; }, MouseRegistry.sequenceTimeout); } else if (currentMatches.length === 0 && anyHasLongerSequence) { // Wait for more input } else { MouseRegistry.snapshotHistory = []; } if (!foundAnyMatch) { MouseRegistry.snapshotHistory = []; } if (MouseRegistry.snapshotHistory.length > 10) { MouseRegistry.snapshotHistory = []; } } /** * Trigger an HTMX action for a mousedown>mouseup match. * Similar to triggerHtmxAction but merges suffixed JS values from both phases. * @param {Object} match - The match object with config, elementId, etc. * @param {MouseEvent} mouseupEvent - The mouseup event */ function triggerMousedownUpAction(match, mouseupEvent) { const element = document.getElementById(match.elementId); if (!element) return; const config = match.config; // Extract HTTP method and URL let method = 'POST'; 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 = {}; if (config['hx-target']) { htmxOptions.target = config['hx-target']; } if (config['hx-swap']) { htmxOptions.swap = config['hx-swap']; } // Build values const values = {}; // 1. Merge static hx-vals from command if (config['hx-vals'] && typeof config['hx-vals'] === 'object') { Object.assign(values, config['hx-vals']); } // 2. Merge mousedown JS values with _mousedown suffix if (match.mousedownJsValues) { for (const [key, value] of Object.entries(match.mousedownJsValues)) { values[key + '_mousedown'] = value; } } // 3. Call JS function at mouseup time, suffix with _mouseup 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 suffix with _mouseup if (extra.js) { try { const func = window[extra.js]; if (typeof func === 'function') { const dynamicValues = func(mouseupEvent, element, match.combinationStr); if (dynamicValues && typeof dynamicValues === 'object') { for (const [key, value] of Object.entries(dynamicValues)) { values[key + '_mouseup'] = value; } } } } catch (e) { console.error('Error calling dynamic hx-vals function at mouseup:', e); } } } // 4. Add auto params values.combination = match.combinationStr; values.has_focus = match.hasFocus; values.is_inside = match.isInside; htmxOptions.values = values; // Add any other hx-* attributes 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', 'hx-vals-extra'].includes(key)) { const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); htmxOptions[optionKey] = value; } } htmx.ajax(method, url, htmxOptions); } /** * 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.dblclickHandler = (e) => handleDblClick(e); MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click'); MouseRegistry.mousedownHandler = (e) => handleMouseDown(e); MouseRegistry.mouseupHandler = (e) => handleMouseUp(e); document.addEventListener('click', MouseRegistry.clickHandler); document.addEventListener('dblclick', MouseRegistry.dblclickHandler); document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler); document.addEventListener('mousedown', MouseRegistry.mousedownHandler); document.addEventListener('mouseup', MouseRegistry.mouseupHandler); MouseRegistry.listenerAttached = true; } } /** * Detach the global mouse event listeners */ function detachGlobalListener() { if (MouseRegistry.listenerAttached) { document.removeEventListener('click', MouseRegistry.clickHandler); document.removeEventListener('dblclick', MouseRegistry.dblclickHandler); document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler); document.removeEventListener('mousedown', MouseRegistry.mousedownHandler); document.removeEventListener('mouseup', MouseRegistry.mouseupHandler); MouseRegistry.listenerAttached = false; // Clean up handler references MouseRegistry.clickHandler = null; MouseRegistry.dblclickHandler = null; MouseRegistry.contextmenuHandler = null; MouseRegistry.mousedownHandler = null; MouseRegistry.mouseupHandler = null; // Clean up all state MouseRegistry.snapshotHistory = []; if (MouseRegistry.pendingTimeout) { clearTimeout(MouseRegistry.pendingTimeout); MouseRegistry.pendingTimeout = null; } MouseRegistry.pendingMatches = []; clearMousedownState(); MouseRegistry.suppressNextClick = false; MouseRegistry.mousedownPending = null; } } /** * 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(); } }; })();