Files
MyFastHtml/src/myfasthtml/assets/core/mouse.js
2026-03-16 21:16:21 +01:00

1127 lines
37 KiB
JavaScript

/**
* 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();
}
};
})();