1465 lines
43 KiB
JavaScript
1465 lines
43 KiB
JavaScript
/**
|
|
* 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');
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Add a single mouseenter and mouseleave listener to the parent element
|
|
element.addEventListener("mouseenter", (event) => {
|
|
//console.debug("Entering element", event.target)
|
|
|
|
const cell = event.target.closest("[data-tooltip]");
|
|
if (!cell) {
|
|
// console.debug(" No 'data-tooltip' attribute found. Stopping.");
|
|
return;
|
|
}
|
|
|
|
const no_tooltip = element.hasAttribute("mf-no-tooltip");
|
|
if (no_tooltip) {
|
|
// console.debug(" Attribute 'mmt-no-tooltip' found. Cancelling.");
|
|
return;
|
|
}
|
|
|
|
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 for tooltip positioning
|
|
requestAnimationFrame(() => {
|
|
tooltipContainer.textContent = tooltipText;
|
|
tooltipContainer.setAttribute("data-visible", "true");
|
|
tooltipContainer.style.top = `${top}px`;
|
|
tooltipContainer.style.left = `${left}px`;
|
|
});
|
|
}
|
|
}
|
|
}, true); // Use capture phase for better delegation if needed
|
|
|
|
element.addEventListener("mouseleave", (event) => {
|
|
const cell = event.target.closest("[data-tooltip]");
|
|
if (cell) {
|
|
tooltipContainer.setAttribute("data-visible", "false");
|
|
}
|
|
}, true); // Use capture phase for better delegation if needed
|
|
}
|
|
|
|
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) {
|
|
console.debug("Global click detected");
|
|
|
|
// 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;
|
|
|
|
for (const [elementId, data] of MouseRegistry.elements) {
|
|
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 = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
};
|
|
})();
|
|
|