Files
MyFastHtml/src/myfasthtml/assets/myfasthtml.js

667 lines
19 KiB
JavaScript

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