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

2427 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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');
// Disable transition during manual resize
currentItem.classList.add('no-transition');
}
/**
* 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');
// Re-enable transition after manual resize
currentItem.classList.remove('no-transition');
// 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;
}
// OPTIMIZATION C: Throttling flag to limit mouseenter processing
let tooltipRafScheduled = false;
// Add a single mouseenter and mouseleave listener to the parent element
element.addEventListener("mouseenter", (event) => {
// Early exit - check mf-no-tooltip FIRST (before any DOM work)
if (element.hasAttribute("mf-no-tooltip")) {
return;
}
// OPTIMIZATION C: Throttle mouseenter events (max 1 per frame)
if (tooltipRafScheduled) {
return;
}
const cell = event.target.closest("[data-tooltip]");
if (!cell) {
return;
}
// OPTIMIZATION C: Move ALL DOM reads into RAF to avoid forced synchronous layouts
tooltipRafScheduled = true;
requestAnimationFrame(() => {
tooltipRafScheduled = false;
// Check again in case tooltip was disabled during RAF delay
if (element.hasAttribute("mf-no-tooltip")) {
return;
}
// All DOM reads happen here (batched in RAF)
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 (already in RAF)
tooltipContainer.textContent = tooltipText;
tooltipContainer.setAttribute("data-visible", "true");
tooltipContainer.style.top = `${top}px`;
tooltipContainer.style.left = `${left}px`;
}
}
});
}, true); // Capture phase required: mouseenter doesn't bubble
element.addEventListener("mouseleave", (event) => {
const cell = event.target.closest("[data-tooltip]");
if (cell) {
tooltipContainer.setAttribute("data-visible", "false");
}
}, true); // Capture phase required: mouseleave doesn't bubble
}
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'
});
}
}
}
/**
* Find the parent element with .dt2-cell class and return its id.
* Used with hx-vals="js:getCellId()" for DataGrid cell identification.
*
* @param {MouseEvent} event - The mouse event
* @returns {Object} Object with cell_id property, or empty object if not found
*/
function getCellId(event) {
const cell = event.target.closest('.dt2-cell');
if (cell && cell.id) {
return {cell_id: cell.id};
}
return {cell_id: null};
}
/**
* Check if the click was on a dropdown button element.
* Used with hx-vals="js:getDropdownExtra()" for Dropdown toggle behavior.
*
* @param {MouseEvent} event - The mouse event
* @returns {Object} Object with is_button boolean property
*/
function getDropdownExtra(event) {
const button = event.target.closest('.mf-dropdown-btn');
return {is_button: button !== null};
}
/**
* Shared utility function for triggering HTMX actions from keyboard/mouse bindings.
* Handles dynamic hx-vals with "js:functionName()" syntax.
*
* @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/click is inside the element
* @param {Event} event - The event that triggered this action (KeyboardEvent or MouseEvent)
*/
function triggerHtmxAction(elementId, config, combinationStr, isInside, event) {
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 = {};
// 1. Merge static hx-vals from command (if present)
if (config['hx-vals'] && typeof config['hx-vals'] === 'object') {
Object.assign(values, config['hx-vals']);
}
// 2. Merge hx-vals-extra (user overrides)
if (config['hx-vals-extra']) {
const extra = config['hx-vals-extra'];
// Merge static dict values
if (extra.dict && typeof extra.dict === 'object') {
Object.assign(values, extra.dict);
}
// Call dynamic JS function and merge result
if (extra.js) {
try {
const func = window[extra.js];
if (typeof func === 'function') {
const dynamicValues = func(event, element, combinationStr);
if (dynamicValues && typeof dynamicValues === 'object') {
Object.assign(values, dynamicValues);
}
} else {
console.error(`Function "${extra.js}" not found on window`);
}
} catch (e) {
console.error('Error calling dynamic hx-vals function:', e);
}
}
}
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);
}
/**
* 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;
}
/**
* 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) {
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
}
// 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;
const savedEvent = event; // Save event for timeout callback
KeyboardRegistry.pendingTimeout = setTimeout(() => {
// 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) {
// 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;
}
/**
* 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) {
// DEBUG: Measure click handler performance
const clickStart = performance.now();
const elementCount = MouseRegistry.elements.size;
//console.warn(`🖱️ Click handler START: processing ${elementCount} registered elements`);
// Create a snapshot of current mouse action with modifiers
const snapshot = createSnapshot(event, 'click');
// Add snapshot to history
MouseRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
MouseRegistry.pendingMatches = [];
}
// Collect match information for ALL registered elements
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
let iterationCount = 0;
for (const [elementId, data] of MouseRegistry.elements) {
iterationCount++;
const element = document.getElementById(elementId);
if (!element) continue;
// Check if click was inside this element
const isInside = element.contains(event.target);
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree
continue;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has config)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: isInside
});
}
}
// Prevent default only if click was INSIDE a registered element
// Clicks outside should preserve native behavior (checkboxes, buttons, etc.)
const anyMatchInside = currentMatches.some(match => match.isInside);
if (currentMatches.length > 0 && anyMatchInside && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
}
// Clear history after triggering
MouseRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but longer sequences are possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches;
const savedEvent = event; // Save event for timeout callback
MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) {
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent);
}
// Clear state
MouseRegistry.snapshotHistory = [];
MouseRegistry.pendingMatches = [];
MouseRegistry.pendingTimeout = null;
}, MouseRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
MouseRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
if (!foundAnyMatch) {
MouseRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (MouseRegistry.snapshotHistory.length > 10) {
MouseRegistry.snapshotHistory = [];
}
// Warn if click handler is slow
const clickDuration = performance.now() - clickStart;
if (clickDuration > 100) {
console.warn(`⚠️ SLOW CLICK HANDLER: ${clickDuration.toFixed(2)}ms for ${elementCount} elements`);
}
}
/**
* Handle right-click events (triggers only for clicked element)
* @param {MouseEvent} event - The mouse event
*/
function handleElementRightClick(event) {
// Find which registered element was clicked
const elementId = findRegisteredElement(event.target);
if (!elementId) {
// Right-click wasn't on a registered element - don't prevent default
// This allows browser context menu to appear
return;
}
//console.debug("Right-click on registered element", elementId);
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
const clickedInside = true;
// Create a snapshot of current mouse action with modifiers
const snapshot = createSnapshot(event, 'right_click');
// Add snapshot to history
MouseRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
MouseRegistry.pendingMatches = [];
}
// Collect match information for this element
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
const data = MouseRegistry.elements.get(elementId);
if (!data) return;
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree
//console.debug("No match in tree for right-click");
// Clear history for invalid sequences
MouseRegistry.snapshotHistory = [];
return;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has config)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: true // Right-click only triggers when clicking on element
});
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
}
// Clear history after triggering
MouseRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but longer sequences are possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches;
const savedEvent = event; // Save event for timeout callback
MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) {
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent);
}
// Clear state
MouseRegistry.snapshotHistory = [];
MouseRegistry.pendingMatches = [];
MouseRegistry.pendingTimeout = null;
}, MouseRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
MouseRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
if (!foundAnyMatch) {
MouseRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (MouseRegistry.snapshotHistory.length > 10) {
MouseRegistry.snapshotHistory = [];
}
}
/**
* 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();
}
};
})();
function initDataGrid(gridId) {
initDataGridScrollbars(gridId);
initDataGridMouseOver(gridId);
makeDatagridColumnsResizable(gridId);
makeDatagridColumnsMovable(gridId);
updateDatagridSelection(gridId)
}
/**
* Initialize DataGrid hover effects using event delegation.
*
* Optimizations:
* - Event delegation: 1 listener instead of N×2 (where N = number of cells)
* - Row mode: O(1) via class toggle on parent row
* - Column mode: RAF batching + cached cells for efficient class removal
* - Works with HTMX swaps: listener on stable parent, querySelectorAll finds new cells
* - No mouseout: hover selection stays visible when leaving the table
*
* @param {string} gridId - The DataGrid instance ID
*/
function initDataGridMouseOver(gridId) {
const table = document.getElementById(`t_${gridId}`);
if (!table) {
console.error(`Table with id "t_${gridId}" not found.`);
return;
}
const wrapper = document.getElementById(`tw_${gridId}`);
// Track hover state
let currentHoverRow = null;
let currentHoverColId = null;
let currentHoverColCells = null;
table.addEventListener('mouseover', (e) => {
// Skip hover during scrolling
if (wrapper?.hasAttribute('mf-no-hover')) return;
const cell = e.target.closest('.dt2-cell');
if (!cell) return;
const selectionModeDiv = document.getElementById(`tsm_${gridId}`);
const selectionMode = selectionModeDiv?.getAttribute('selection-mode');
if (selectionMode === 'row') {
const rowElement = cell.parentElement;
if (rowElement !== currentHoverRow) {
if (currentHoverRow) {
currentHoverRow.classList.remove('dt2-hover-row');
}
rowElement.classList.add('dt2-hover-row');
currentHoverRow = rowElement;
}
} else if (selectionMode === 'column') {
const colId = cell.dataset.col;
// Skip if same column
if (colId === currentHoverColId) return;
requestAnimationFrame(() => {
// Remove old column highlight
if (currentHoverColCells) {
currentHoverColCells.forEach(c => c.classList.remove('dt2-hover-column'));
}
// Query and add new column highlight
currentHoverColCells = table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`);
currentHoverColCells.forEach(c => c.classList.add('dt2-hover-column'));
currentHoverColId = colId;
});
}
});
// Clean up when leaving the table entirely
table.addEventListener('mouseout', (e) => {
if (!table.contains(e.relatedTarget)) {
if (currentHoverRow) {
currentHoverRow.classList.remove('dt2-hover-row');
currentHoverRow = null;
}
if (currentHoverColCells) {
currentHoverColCells.forEach(c => c.classList.remove('dt2-hover-column'));
currentHoverColCells = null;
currentHoverColId = null;
}
}
});
}
/**
* Initialize DataGrid with CSS Grid layout + Custom Scrollbars
*
* Adapted from previous custom scrollbar implementation to work with CSS Grid.
* - Grid handles layout (no height calculations needed)
* - Custom scrollbars for visual consistency and positioning control
* - Vertical scroll: on body container (.dt2-body-container)
* - Horizontal scroll: on table (.dt2-table) to scroll header, body, footer together
*
* @param {string} gridId - The ID of the DataGrid instance
*/
function initDataGridScrollbars(gridId) {
const wrapper = document.getElementById(`tw_${gridId}`);
if (!wrapper) {
console.error(`DataGrid wrapper "tw_${gridId}" not found.`);
return;
}
// Cleanup previous listeners if any
if (wrapper._scrollbarAbortController) {
wrapper._scrollbarAbortController.abort();
}
wrapper._scrollbarAbortController = new AbortController();
const signal = wrapper._scrollbarAbortController.signal;
const verticalScrollbar = wrapper.querySelector(".dt2-scrollbars-vertical");
const verticalWrapper = wrapper.querySelector(".dt2-scrollbars-vertical-wrapper");
const horizontalScrollbar = wrapper.querySelector(".dt2-scrollbars-horizontal");
const horizontalWrapper = wrapper.querySelector(".dt2-scrollbars-horizontal-wrapper");
const bodyContainer = wrapper.querySelector(".dt2-body-container");
const table = wrapper.querySelector(".dt2-table");
if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !bodyContainer || !table) {
console.error("Essential scrollbar or content elements are missing in the datagrid.");
return;
}
// OPTIMIZATION: Cache element references to avoid repeated querySelector calls
const header = table.querySelector(".dt2-header");
const body = table.querySelector(".dt2-body");
// OPTIMIZATION: RequestAnimationFrame flags to throttle visual updates
let rafScheduledVertical = false;
let rafScheduledHorizontal = false;
let rafScheduledUpdate = false;
// OPTIMIZATION: Pre-calculated scroll ratios (updated in updateScrollbars)
// Allows instant mousedown with zero DOM reads
let cachedVerticalScrollRatio = 0;
let cachedHorizontalScrollRatio = 0;
// OPTIMIZATION: Cached scroll positions to avoid DOM reads in mousedown
// Initialized once at setup, updated in RAF handlers after each scroll change
let cachedBodyScrollTop = bodyContainer.scrollTop;
let cachedTableScrollLeft = table.scrollLeft;
/**
* OPTIMIZED: Batched update function
* Phase 1: Read all DOM properties (no writes)
* Phase 2: Calculate all values
* Phase 3: Write all DOM properties in single RAF
*/
const updateScrollbars = () => {
if (rafScheduledUpdate) return;
rafScheduledUpdate = true;
requestAnimationFrame(() => {
rafScheduledUpdate = false;
// PHASE 1: Read all DOM properties
const metrics = {
bodyScrollHeight: bodyContainer.scrollHeight,
bodyClientHeight: bodyContainer.clientHeight,
bodyScrollTop: bodyContainer.scrollTop,
tableClientWidth: table.clientWidth,
tableScrollLeft: table.scrollLeft,
verticalWrapperHeight: verticalWrapper.offsetHeight,
horizontalWrapperWidth: horizontalWrapper.offsetWidth,
headerScrollWidth: header ? header.scrollWidth : 0,
bodyScrollWidth: body ? body.scrollWidth : 0
};
// PHASE 2: Calculate all values
const contentWidth = Math.max(metrics.headerScrollWidth, metrics.bodyScrollWidth);
// Visibility
const isVerticalRequired = metrics.bodyScrollHeight > metrics.bodyClientHeight;
const isHorizontalRequired = contentWidth > metrics.tableClientWidth;
// Scrollbar sizes
let scrollbarHeight = 0;
if (metrics.bodyScrollHeight > 0) {
scrollbarHeight = (metrics.bodyClientHeight / metrics.bodyScrollHeight) * metrics.verticalWrapperHeight;
}
let scrollbarWidth = 0;
if (contentWidth > 0) {
scrollbarWidth = (metrics.tableClientWidth / contentWidth) * metrics.horizontalWrapperWidth;
}
// Scrollbar positions
const maxScrollTop = metrics.bodyScrollHeight - metrics.bodyClientHeight;
let verticalTop = 0;
if (maxScrollTop > 0) {
const scrollRatio = metrics.verticalWrapperHeight / metrics.bodyScrollHeight;
verticalTop = metrics.bodyScrollTop * scrollRatio;
}
const maxScrollLeft = contentWidth - metrics.tableClientWidth;
let horizontalLeft = 0;
if (maxScrollLeft > 0 && contentWidth > 0) {
const scrollRatio = metrics.horizontalWrapperWidth / contentWidth;
horizontalLeft = metrics.tableScrollLeft * scrollRatio;
}
// OPTIMIZATION: Pre-calculate and cache scroll ratios for instant mousedown
// Vertical scroll ratio
if (maxScrollTop > 0 && scrollbarHeight > 0) {
cachedVerticalScrollRatio = maxScrollTop / (metrics.verticalWrapperHeight - scrollbarHeight);
} else {
cachedVerticalScrollRatio = 0;
}
// Horizontal scroll ratio
if (maxScrollLeft > 0 && scrollbarWidth > 0) {
cachedHorizontalScrollRatio = maxScrollLeft / (metrics.horizontalWrapperWidth - scrollbarWidth);
} else {
cachedHorizontalScrollRatio = 0;
}
// PHASE 3: Write all DOM properties (already in RAF)
verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
verticalScrollbar.style.height = `${scrollbarHeight}px`;
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
verticalScrollbar.style.top = `${verticalTop}px`;
horizontalScrollbar.style.left = `${horizontalLeft}px`;
});
};
// Consolidated drag management
let isDraggingVertical = false;
let isDraggingHorizontal = false;
let dragStartY = 0;
let dragStartX = 0;
let dragStartScrollTop = 0;
let dragStartScrollLeft = 0;
// Vertical scrollbar mousedown
verticalScrollbar.addEventListener("mousedown", (e) => {
isDraggingVertical = true;
dragStartY = e.clientY;
dragStartScrollTop = cachedBodyScrollTop;
wrapper.setAttribute("mf-no-tooltip", "");
wrapper.setAttribute("mf-no-hover", "");
}, {signal});
// Horizontal scrollbar mousedown
horizontalScrollbar.addEventListener("mousedown", (e) => {
isDraggingHorizontal = true;
dragStartX = e.clientX;
dragStartScrollLeft = cachedTableScrollLeft;
wrapper.setAttribute("mf-no-tooltip", "");
wrapper.setAttribute("mf-no-hover", "");
}, {signal});
// Consolidated mousemove listener
document.addEventListener("mousemove", (e) => {
if (isDraggingVertical) {
const deltaY = e.clientY - dragStartY;
if (!rafScheduledVertical) {
rafScheduledVertical = true;
requestAnimationFrame(() => {
rafScheduledVertical = false;
const scrollDelta = deltaY * cachedVerticalScrollRatio;
bodyContainer.scrollTop = dragStartScrollTop + scrollDelta;
cachedBodyScrollTop = bodyContainer.scrollTop;
updateScrollbars();
});
}
} else if (isDraggingHorizontal) {
const deltaX = e.clientX - dragStartX;
if (!rafScheduledHorizontal) {
rafScheduledHorizontal = true;
requestAnimationFrame(() => {
rafScheduledHorizontal = false;
const scrollDelta = deltaX * cachedHorizontalScrollRatio;
table.scrollLeft = dragStartScrollLeft + scrollDelta;
cachedTableScrollLeft = table.scrollLeft;
updateScrollbars();
});
}
}
}, {signal});
// Consolidated mouseup listener
document.addEventListener("mouseup", () => {
if (isDraggingVertical) {
isDraggingVertical = false;
wrapper.removeAttribute("mf-no-tooltip");
wrapper.removeAttribute("mf-no-hover");
} else if (isDraggingHorizontal) {
isDraggingHorizontal = false;
wrapper.removeAttribute("mf-no-tooltip");
wrapper.removeAttribute("mf-no-hover");
}
}, {signal});
// Wheel scrolling - OPTIMIZED with RAF throttling
let rafScheduledWheel = false;
let pendingWheelDeltaX = 0;
let pendingWheelDeltaY = 0;
let wheelEndTimeout = null;
const handleWheelScrolling = (event) => {
// Disable hover and tooltip during wheel scroll
wrapper.setAttribute("mf-no-hover", "");
wrapper.setAttribute("mf-no-tooltip", "");
// Clear previous timeout and re-enable after 150ms of no wheel events
if (wheelEndTimeout) clearTimeout(wheelEndTimeout);
wheelEndTimeout = setTimeout(() => {
wrapper.removeAttribute("mf-no-hover");
wrapper.removeAttribute("mf-no-tooltip");
}, 150);
// Accumulate wheel deltas
pendingWheelDeltaX += event.deltaX;
pendingWheelDeltaY += event.deltaY;
// Schedule update in next animation frame (throttle)
if (!rafScheduledWheel) {
rafScheduledWheel = true;
requestAnimationFrame(() => {
rafScheduledWheel = false;
// Apply accumulated scroll
bodyContainer.scrollTop += pendingWheelDeltaY;
table.scrollLeft += pendingWheelDeltaX;
// Update caches with clamped values (read back from DOM in RAF - OK)
cachedBodyScrollTop = bodyContainer.scrollTop;
cachedTableScrollLeft = table.scrollLeft;
// Reset pending deltas
pendingWheelDeltaX = 0;
pendingWheelDeltaY = 0;
// Update all scrollbars in a single batched operation
updateScrollbars();
});
}
event.preventDefault();
};
wrapper.addEventListener("wheel", handleWheelScrolling, {passive: false, signal});
// Initialize scrollbars with single batched update
updateScrollbars();
// Recompute on window resize with RAF throttling
let resizeScheduled = false;
window.addEventListener("resize", () => {
if (!resizeScheduled) {
resizeScheduled = true;
requestAnimationFrame(() => {
resizeScheduled = false;
updateScrollbars();
});
}
}, {signal});
}
function makeDatagridColumnsResizable(datagridId) {
//console.debug("makeResizable on element " + datagridId);
const tableId = 't_' + datagridId;
const table = document.getElementById(tableId);
const resizeHandles = table.querySelectorAll('.dt2-resize-handle');
const MIN_WIDTH = 30; // Prevent columns from becoming too narrow
// Attach event listeners using delegation
resizeHandles.forEach(handle => {
handle.addEventListener('mousedown', onStartResize);
handle.addEventListener('touchstart', onStartResize, {passive: false});
handle.addEventListener('dblclick', onDoubleClick); // Reset column width
});
let resizingState = null; // Maintain resizing state information
function onStartResize(event) {
event.preventDefault(); // Prevent unintended selections
const isTouch = event.type === 'touchstart';
const startX = isTouch ? event.touches[0].pageX : event.pageX;
const handle = event.target;
const cell = handle.parentElement;
const colIndex = cell.getAttribute('data-col');
const commandId = handle.dataset.commandId;
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
// Store initial state
const startWidth = cell.offsetWidth + 8;
resizingState = {startX, startWidth, colIndex, commandId, cells};
// Attach event listeners for resizing
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onResize);
document.addEventListener(isTouch ? 'touchend' : 'mouseup', onStopResize);
}
function onResize(event) {
if (!resizingState) {
return;
}
const isTouch = event.type === 'touchmove';
const currentX = isTouch ? event.touches[0].pageX : event.pageX;
const {startX, startWidth, cells} = resizingState;
// Calculate new width and apply constraints
const newWidth = Math.max(MIN_WIDTH, startWidth + (currentX - startX));
cells.forEach(cell => {
cell.style.width = `${newWidth}px`;
});
}
function onStopResize(event) {
if (!resizingState) {
return;
}
const {colIndex, commandId, cells} = resizingState;
const finalWidth = cells[0].offsetWidth;
// Send width update to server via HTMX
if (commandId) {
htmx.ajax('POST', '/myfasthtml/commands', {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
swap: 'none',
values: {
c_id: commandId,
col_id: colIndex,
width: finalWidth
}
});
}
// Clean up
resizingState = null;
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', onStopResize);
document.removeEventListener('touchmove', onResize);
document.removeEventListener('touchend', onStopResize);
}
function onDoubleClick(event) {
const handle = event.target;
const cell = handle.parentElement;
const colIndex = cell.getAttribute('data-col');
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
// Reset column width
cells.forEach(cell => {
cell.style.width = ''; // Use CSS default width
});
// Emit reset event
const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}});
table.dispatchEvent(resetEvent);
}
}
/**
* Enable column reordering via drag and drop on a DataGrid.
* Columns can be dragged to new positions with animated transitions.
* @param {string} gridId - The DataGrid instance ID
*/
function makeDatagridColumnsMovable(gridId) {
const table = document.getElementById(`t_${gridId}`);
const headerRow = document.getElementById(`th_${gridId}`);
if (!table || !headerRow) {
console.error(`DataGrid elements not found for ${gridId}`);
return;
}
const moveCommandId = headerRow.dataset.moveCommandId;
const headerCells = headerRow.querySelectorAll('.dt2-cell:not(.dt2-col-hidden)');
let sourceColumn = null; // Column being dragged (original position)
let lastMoveTarget = null; // Last column we moved to (for persistence)
let hoverColumn = null; // Current hover target (for delayed move check)
headerCells.forEach(cell => {
cell.setAttribute('draggable', 'true');
// Prevent drag when clicking resize handle
const resizeHandle = cell.querySelector('.dt2-resize-handle');
if (resizeHandle) {
resizeHandle.addEventListener('mousedown', () => cell.setAttribute('draggable', 'false'));
resizeHandle.addEventListener('mouseup', () => cell.setAttribute('draggable', 'true'));
}
cell.addEventListener('dragstart', (e) => {
sourceColumn = cell.getAttribute('data-col');
lastMoveTarget = null;
hoverColumn = null;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', sourceColumn);
cell.classList.add('dt2-dragging');
});
cell.addEventListener('dragenter', (e) => {
e.preventDefault();
const targetColumn = cell.getAttribute('data-col');
hoverColumn = targetColumn;
if (sourceColumn && sourceColumn !== targetColumn) {
// Delay to skip columns when dragging fast
setTimeout(() => {
if (hoverColumn === targetColumn) {
moveColumn(table, sourceColumn, targetColumn);
lastMoveTarget = targetColumn;
}
}, 50);
}
});
cell.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
cell.addEventListener('drop', (e) => {
e.preventDefault();
// Persist to server
if (moveCommandId && sourceColumn && lastMoveTarget) {
htmx.ajax('POST', '/myfasthtml/commands', {
headers: {"Content-Type": "application/x-www-form-urlencoded"},
swap: 'none',
values: {
c_id: moveCommandId,
source_col_id: sourceColumn,
target_col_id: lastMoveTarget
}
});
}
});
cell.addEventListener('dragend', () => {
headerCells.forEach(c => c.classList.remove('dt2-dragging'));
sourceColumn = null;
lastMoveTarget = null;
hoverColumn = null;
});
});
}
/**
* Move a column to a new position with animation.
* All columns between source and target shift to fill the gap.
* @param {HTMLElement} table - The table element
* @param {string} sourceColId - Column ID to move
* @param {string} targetColId - Column ID to move next to
*/
function moveColumn(table, sourceColId, targetColId) {
const ANIMATION_DURATION = 300; // Must match CSS transition duration
const sourceHeader = table.querySelector(`.dt2-cell[data-col="${sourceColId}"]`);
const targetHeader = table.querySelector(`.dt2-cell[data-col="${targetColId}"]`);
if (!sourceHeader || !targetHeader) return;
if (sourceHeader.classList.contains('dt2-moving')) return; // Animation in progress
const headerCells = Array.from(sourceHeader.parentNode.children);
const sourceIdx = headerCells.indexOf(sourceHeader);
const targetIdx = headerCells.indexOf(targetHeader);
if (sourceIdx === targetIdx) return;
const movingRight = sourceIdx < targetIdx;
const sourceCells = table.querySelectorAll(`.dt2-cell[data-col="${sourceColId}"]`);
// Collect cells that need to shift (between source and target)
const cellsToShift = [];
let shiftWidth = 0;
const [startIdx, endIdx] = movingRight
? [sourceIdx + 1, targetIdx]
: [targetIdx, sourceIdx - 1];
for (let i = startIdx; i <= endIdx; i++) {
const colId = headerCells[i].getAttribute('data-col');
cellsToShift.push(...table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`));
shiftWidth += headerCells[i].offsetWidth;
}
// Calculate animation distances
const sourceWidth = sourceHeader.offsetWidth;
const sourceDistance = movingRight ? shiftWidth : -shiftWidth;
const shiftDistance = movingRight ? -sourceWidth : sourceWidth;
// Animate source column
sourceCells.forEach(cell => {
cell.classList.add('dt2-moving');
cell.style.transform = `translateX(${sourceDistance}px)`;
});
// Animate shifted columns
cellsToShift.forEach(cell => {
cell.classList.add('dt2-moving');
cell.style.transform = `translateX(${shiftDistance}px)`;
});
// After animation: reset transforms and update DOM
setTimeout(() => {
[...sourceCells, ...cellsToShift].forEach(cell => {
cell.classList.remove('dt2-moving');
cell.style.transform = '';
});
// Move source column in DOM
table.querySelectorAll('.dt2-row').forEach(row => {
const sourceCell = row.querySelector(`[data-col="${sourceColId}"]`);
const targetCell = row.querySelector(`[data-col="${targetColId}"]`);
if (sourceCell && targetCell) {
movingRight ? targetCell.after(sourceCell) : targetCell.before(sourceCell);
}
});
}, ANIMATION_DURATION);
}
/**
* Initialize DslEditor with CodeMirror 5
*
* Features:
* - DSL-based autocompletion
* - Line numbers
* - Readonly support
* - Placeholder support
* - Textarea synchronization
* - Debounced HTMX server update via updateCommandId
*
* Required CodeMirror addons:
* - addon/hint/show-hint.js
* - addon/hint/show-hint.css
* - addon/display/placeholder.js
*
* Requires:
* - htmx loaded globally
*
* @param {Object} config
*/
function initDslEditor(config) {
const {
elementId,
textareaId,
lineNumbers,
autocompletion,
linting,
placeholder,
readonly,
updateCommandId,
dslId,
dsl
} = config;
const wrapper = document.getElementById(elementId);
const textarea = document.getElementById(textareaId);
const editorContainer = document.getElementById(`cm_${elementId}`);
if (!wrapper || !textarea || !editorContainer) {
console.error(`DslEditor: Missing elements for ${elementId}`);
return;
}
if (typeof CodeMirror === "undefined") {
console.error("DslEditor: CodeMirror 5 not loaded");
return;
}
/* --------------------------------------------------
* DSL autocompletion hint (async via server)
* -------------------------------------------------- */
// Characters that trigger auto-completion
const AUTO_TRIGGER_CHARS = [".", "(", '"', " "];
function dslHint(cm, callback) {
const cursor = cm.getCursor();
const text = cm.getValue();
// Build URL with query params
const params = new URLSearchParams({
e_id: dslId,
text: text,
line: cursor.line,
ch: cursor.ch
});
fetch(`/myfasthtml/completions?${params}`)
.then(response => response.json())
.then(data => {
if (!data || !data.suggestions || data.suggestions.length === 0) {
callback(null);
return;
}
callback({
list: data.suggestions.map(s => ({
text: s.label,
displayText: s.detail ? `${s.label} - ${s.detail}` : s.label
})),
from: CodeMirror.Pos(data.from.line, data.from.ch),
to: CodeMirror.Pos(data.to.line, data.to.ch)
});
})
.catch(err => {
console.error("DslEditor: Completion error", err);
callback(null);
});
}
// Mark hint function as async for CodeMirror
dslHint.async = true;
/* --------------------------------------------------
* DSL linting (async via server)
* -------------------------------------------------- */
function dslLint(text, updateOutput, options, cm) {
const cursor = cm.getCursor();
const params = new URLSearchParams({
e_id: dslId,
text: text,
line: cursor.line,
ch: cursor.ch
});
fetch(`/myfasthtml/validations?${params}`)
.then(response => response.json())
.then(data => {
if (!data || !data.errors || data.errors.length === 0) {
updateOutput([]);
return;
}
// Convert server errors to CodeMirror lint format
// Server returns 1-based positions, CodeMirror expects 0-based
const annotations = data.errors.map(err => ({
from: CodeMirror.Pos(err.line - 1, Math.max(0, err.column - 1)),
to: CodeMirror.Pos(err.line - 1, err.column),
message: err.message,
severity: err.severity || "error"
}));
updateOutput(annotations);
})
.catch(err => {
console.error("DslEditor: Linting error", err);
updateOutput([]);
});
}
// Mark lint function as async for CodeMirror
dslLint.async = true;
/* --------------------------------------------------
* Register Simple Mode if available and config provided
* -------------------------------------------------- */
let modeName = null;
if (typeof CodeMirror.defineSimpleMode !== "undefined" && dsl && dsl.simpleModeConfig) {
// Generate unique mode name from DSL name
modeName = `dsl-${dsl.name.toLowerCase().replace(/\s+/g, '-')}`;
// Register the mode if not already registered
if (!CodeMirror.modes[modeName]) {
try {
CodeMirror.defineSimpleMode(modeName, dsl.simpleModeConfig);
} catch (err) {
console.error(`Failed to register Simple Mode for ${dsl.name}:`, err);
modeName = null;
}
}
}
/* --------------------------------------------------
* Create CodeMirror editor
* -------------------------------------------------- */
const enableCompletion = autocompletion && dslId;
// Only enable linting if the lint addon is loaded
const lintAddonLoaded = typeof CodeMirror.lint !== "undefined" ||
(CodeMirror.defaults && "lint" in CodeMirror.defaults);
const enableLinting = linting && dslId && lintAddonLoaded;
const editorOptions = {
value: textarea.value || "",
mode: modeName || undefined, // Use Simple Mode if available
theme: "daisy", // Use DaisyUI theme for automatic theme switching
lineNumbers: !!lineNumbers,
readOnly: !!readonly,
placeholder: placeholder || "",
extraKeys: enableCompletion ? {
"Ctrl-Space": "autocomplete"
} : {},
hintOptions: enableCompletion ? {
hint: dslHint,
completeSingle: false
} : undefined
};
// Add linting options if enabled and addon is available
if (enableLinting) {
// Include linenumbers gutter if lineNumbers is enabled
editorOptions.gutters = lineNumbers
? ["CodeMirror-linenumbers", "CodeMirror-lint-markers"]
: ["CodeMirror-lint-markers"];
editorOptions.lint = {
getAnnotations: dslLint,
async: true
};
}
const editor = CodeMirror(editorContainer, editorOptions);
/* --------------------------------------------------
* Auto-trigger completion on specific characters
* -------------------------------------------------- */
if (enableCompletion) {
editor.on("inputRead", function (cm, change) {
if (change.origin !== "+input") return;
const lastChar = change.text[change.text.length - 1];
const lastCharOfInput = lastChar.slice(-1);
if (AUTO_TRIGGER_CHARS.includes(lastCharOfInput)) {
cm.showHint({completeSingle: false});
}
});
}
/* --------------------------------------------------
* Debounced update + HTMX transport
* -------------------------------------------------- */
let debounceTimer = null;
const DEBOUNCE_DELAY = 300;
editor.on("change", function (cm) {
const value = cm.getValue();
textarea.value = value;
if (!updateCommandId) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
wrapper.dispatchEvent(
new CustomEvent("dsl-editor-update", {
detail: {
commandId: updateCommandId,
value: value
}
})
);
}, DEBOUNCE_DELAY);
});
/* --------------------------------------------------
* HTMX listener (LOCAL to wrapper)
* -------------------------------------------------- */
if (updateCommandId && typeof htmx !== "undefined") {
wrapper.addEventListener("dsl-editor-update", function (e) {
htmx.ajax("POST", "/myfasthtml/commands", {
target: wrapper,
swap: "none",
values: {
c_id: e.detail.commandId,
content: e.detail.value
}
});
});
}
/* --------------------------------------------------
* Public API
* -------------------------------------------------- */
wrapper._dslEditor = {
editor: editor,
getContent: () => editor.getValue(),
setContent: (content) => editor.setValue(content)
};
//console.debug(`DslEditor initialized: ${elementId}, DSL=${dsl?.name || "unknown"}, dsl_id=${dslId}, completion=${enableCompletion ? "enabled" : "disabled"}, linting=${enableLinting ? "enabled" : "disabled"}`);
}
function updateDatagridSelection(datagridId) {
const selectionManager = document.getElementById(`tsm_${datagridId}`);
if (!selectionManager) return;
// Clear previous selections
document.querySelectorAll('.dt2-selected-focus, .dt2-selected-cell, .dt2-selected-row, .dt2-selected-column').forEach((element) => {
element.classList.remove('dt2-selected-focus', 'dt2-selected-cell', 'dt2-selected-row', 'dt2-selected-column');
element.style.userSelect = 'none';
});
// Loop through the children of the selection manager
Array.from(selectionManager.children).forEach((selection) => {
const selectionType = selection.getAttribute('selection-type');
const elementId = selection.getAttribute('element-id');
if (selectionType === 'focus') {
const cellElement = document.getElementById(`${elementId}`);
if (cellElement) {
cellElement.classList.add('dt2-selected-focus');
cellElement.style.userSelect = 'text';
}
} else if (selectionType === 'cell') {
const cellElement = document.getElementById(`${elementId}`);
if (cellElement) {
cellElement.classList.add('dt2-selected-cell');
cellElement.style.userSelect = 'text';
}
} else if (selectionType === 'row') {
const rowElement = document.getElementById(`${elementId}`);
if (rowElement) {
rowElement.classList.add('dt2-selected-row');
}
} else if (selectionType === 'column') {
// Select all elements in the specified column
document.querySelectorAll(`[data-col="${elementId}"]`).forEach((columnElement) => {
columnElement.classList.add('dt2-selected-column');
});
}
});
}