/** * Layout Drawer Resizer * * Handles resizing of left and right drawers with drag functionality. * Communicates with server via HTMX to persist width changes. */ /** * Initialize drawer resizer functionality for a specific layout instance * * @param {string} layoutId - The ID of the layout instance to initialize */ function initLayoutResizer(layoutId) { 'use strict'; const MIN_WIDTH = 150; const MAX_WIDTH = 600; let isResizing = false; let currentResizer = null; let currentDrawer = null; let startX = 0; let startWidth = 0; let side = null; const layoutElement = document.getElementById(layoutId); if (!layoutElement) { console.error(`Layout element with ID "${layoutId}" not found`); return; } /** * Initialize resizer functionality for this layout instance */ function initResizers() { const resizers = layoutElement.querySelectorAll('.mf-layout-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; currentDrawer = currentResizer.closest('.mf-layout-drawer'); if (!currentDrawer) { console.error('Could not find drawer element'); return; } isResizing = true; startX = e.clientX; startWidth = currentDrawer.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-layout-resizing'); currentDrawer.classList.add('mf-layout-drawer-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 drawer width visually currentDrawer.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-layout-resizing'); currentDrawer.classList.remove('mf-layout-drawer-resizing'); // Get final width const finalWidth = currentDrawer.offsetWidth; const commandId = currentResizer.dataset.commandId; if (!commandId) { console.error('No command ID found on resizer'); return; } // Send width update to server saveDrawerWidth(commandId, finalWidth); // Reset state currentResizer = null; currentDrawer = null; side = null; } /** * Save drawer width to server via HTMX */ function saveDrawerWidth(commandId, width) { htmx.ajax('POST', '/myfasthtml/commands', { headers: { "Content-Type": "application/x-www-form-urlencoded" }, swap: "outerHTML", target: `#${currentDrawer.id}`, values: { c_id: commandId, width: width } }); } // Initialize resizers initResizers(); // Re-initialize after HTMX swaps within this layout layoutElement.addEventListener('htmx:afterSwap', function (event) { initResizers(); }); } 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 -> { trie, 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 trie node * @returns {Object} - New trie node */ function createTrieNode() { return { config: null, combinationStr: null, children: new Map() }; } /** * Build a trie from combinations * @param {Object} combinations - Map of combination strings to HTMX config objects * @returns {Object} - Root trie node */ function buildTrie(combinations) { const root = createTrieNode(); 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, createTrieNode()); } currentNode = currentNode.children.get(key); } // Mark as end of sequence and store config currentNode.config = config; currentNode.combinationStr = combinationStr; } return root; } /** * Traverse the trie with the current snapshot history * @param {Object} trieRoot - Root of the trie * @param {Array} snapshotHistory - Array of Sets representing pressed keys * @returns {Object|null} - Current node or null if no match */ function traverseTrie(trieRoot, snapshotHistory) { let currentNode = trieRoot; 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 */ function triggerAction(elementId, config, combinationStr) { 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 and has_focus const values = {}; if (config['hx-vals']) { Object.assign(values, config['hx-vals']); } values.combination = combinationStr; values.has_focus = hasFocus; 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); // 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; const trieRoot = data.trie; // Traverse the trie with current snapshot history const currentNode = traverseTrie(trieRoot, KeyboardRegistry.snapshotHistory); if (!currentNode) { // No match in this trie, continue to next element 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 }); } } // 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); } // 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); } // 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; } } /** * 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 trie for this element const trie = buildTrie(combinations); // Get element reference const element = document.getElementById(elementId); // Add to registry KeyboardRegistry.elements.set(elementId, { trie: trie, element: element }); // Attach global listener if not already attached attachGlobalListener(); }; })();