422 lines
13 KiB
JavaScript
422 lines
13 KiB
JavaScript
/**
|
|
* Create keyboard bindings
|
|
*/
|
|
|
|
// Set window.KEYBOARD_DEBUG = true in the browser console to enable traces
|
|
window.KEYBOARD_DEBUG = false;
|
|
|
|
(function () {
|
|
function kbLog(...args) {
|
|
if (window.KEYBOARD_DEBUG) console.debug('[Keyboard]', ...args);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// Create a snapshot of current keyboard state
|
|
const snapshot = new Set(KeyboardRegistry.currentKeys);
|
|
|
|
// Add snapshot to history
|
|
KeyboardRegistry.snapshotHistory.push(snapshot);
|
|
|
|
kbLog(`key="${key}" | history length=${KeyboardRegistry.snapshotHistory.length} | registeredElements=${KeyboardRegistry.elements.size}`);
|
|
|
|
// Cancel any pending timeout
|
|
if (KeyboardRegistry.pendingTimeout) {
|
|
kbLog(` cancelled pending timeout`);
|
|
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) {
|
|
kbLog(` element="${elementId}" → no match in tree`);
|
|
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;
|
|
|
|
kbLog(` element="${elementId}" | isInside=${isInside} | hasMatch=${hasMatch} | hasLongerSequences=${hasLongerSequences}`);
|
|
|
|
// Track if ANY element has longer sequences possible
|
|
if (hasLongerSequences) {
|
|
anyHasLongerSequence = true;
|
|
}
|
|
|
|
// Collect matches, respecting require_inside and enabled flags
|
|
if (hasMatch) {
|
|
const requireInside = currentNode.config["require_inside"] === true;
|
|
const enabled = isCombinationEnabled(data.controlDivId, currentNode.combinationStr);
|
|
kbLog(` combination="${currentNode.combinationStr}" | requireInside=${requireInside} | enabled=${enabled}`);
|
|
if (enabled && (!requireInside || isInside)) {
|
|
currentMatches.push({
|
|
elementId: elementId,
|
|
config: currentNode.config,
|
|
combinationStr: currentNode.combinationStr,
|
|
isInside: isInside
|
|
});
|
|
} else {
|
|
kbLog(` → skipped (requireInside=${requireInside} but isInside=${isInside}, or disabled)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
kbLog(` result: matches=${currentMatches.length} | anyHasLongerSequence=${anyHasLongerSequence}`);
|
|
|
|
// 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) {
|
|
kbLog(` → TRIGGER immediately`);
|
|
// We have matches and NO element has 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
|
|
KeyboardRegistry.snapshotHistory = [];
|
|
|
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
|
kbLog(` → WAITING ${KeyboardRegistry.sequenceTimeout}ms (longer sequence possible)`);
|
|
// 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;
|
|
const savedEvent = event; // Save event for timeout callback
|
|
|
|
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
|
kbLog(` → TRIGGER after timeout`);
|
|
// Timeout expired, trigger ALL pending matches
|
|
for (const match of KeyboardRegistry.pendingMatches) {
|
|
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent);
|
|
}
|
|
|
|
// Clear state
|
|
KeyboardRegistry.snapshotHistory = [];
|
|
KeyboardRegistry.pendingMatches = [];
|
|
KeyboardRegistry.pendingTimeout = null;
|
|
}, KeyboardRegistry.sequenceTimeout);
|
|
|
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
|
kbLog(` → WAITING (partial match, no full match yet)`);
|
|
// No matches yet but longer sequences are possible
|
|
// Just wait, don't trigger anything
|
|
|
|
} else {
|
|
kbLog(` → NO MATCH, clearing history`);
|
|
// 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) {
|
|
kbLog(` → foundAnyMatch=false, clearing history`);
|
|
KeyboardRegistry.snapshotHistory = [];
|
|
}
|
|
|
|
// Also clear history if it gets too long (prevent memory issues)
|
|
if (KeyboardRegistry.snapshotHistory.length > 10) {
|
|
kbLog(` → history too long, clearing`);
|
|
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 = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a combination is enabled via the control div
|
|
* @param {string} controlDivId - The ID of the keyboard control div
|
|
* @param {string} combinationStr - The combination string (e.g., "esc")
|
|
* @returns {boolean} - True if enabled (default: true if entry not found)
|
|
*/
|
|
function isCombinationEnabled(controlDivId, combinationStr) {
|
|
const controlDiv = document.getElementById(controlDivId);
|
|
if (!controlDiv) return true;
|
|
|
|
const entry = controlDiv.querySelector(`[data-combination="${combinationStr}"]`);
|
|
if (!entry) return true;
|
|
|
|
return entry.dataset.enabled !== 'false';
|
|
}
|
|
|
|
/**
|
|
* Add keyboard support to an element
|
|
* @param {string} elementId - The ID of the element
|
|
* @param {string} controlDivId - The ID of the keyboard control div
|
|
* @param {string} combinationsJson - JSON string of combinations mapping
|
|
*/
|
|
window.add_keyboard_support = function (elementId, controlDivId, 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,
|
|
controlDivId: controlDivId
|
|
});
|
|
|
|
// 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();
|
|
}
|
|
};
|
|
})();
|