2427 lines
75 KiB
JavaScript
2427 lines
75 KiB
JavaScript
/**
|
||
* Generic Resizer
|
||
*
|
||
* Handles resizing of elements with drag functionality.
|
||
* Communicates with server via HTMX to persist width changes.
|
||
* Works for both Layout drawers and Panel sides.
|
||
*/
|
||
|
||
/**
|
||
* Initialize resizer functionality for a specific container
|
||
*
|
||
* @param {string} containerId - The ID of the container instance to initialize
|
||
* @param {Object} options - Configuration options
|
||
* @param {number} options.minWidth - Minimum width in pixels (default: 150)
|
||
* @param {number} options.maxWidth - Maximum width in pixels (default: 600)
|
||
*/
|
||
function initResizer(containerId, options = {}) {
|
||
|
||
const MIN_WIDTH = options.minWidth || 150;
|
||
const MAX_WIDTH = options.maxWidth || 600;
|
||
|
||
let isResizing = false;
|
||
let currentResizer = null;
|
||
let currentItem = null;
|
||
let startX = 0;
|
||
let startWidth = 0;
|
||
let side = null;
|
||
|
||
const containerElement = document.getElementById(containerId);
|
||
|
||
if (!containerElement) {
|
||
console.error(`Container element with ID "${containerId}" not found`);
|
||
return;
|
||
}
|
||
|
||
/**
|
||
* Initialize resizer functionality for this container instance
|
||
*/
|
||
function initResizers() {
|
||
const resizers = containerElement.querySelectorAll('.mf-resizer');
|
||
|
||
resizers.forEach(resizer => {
|
||
// Remove existing listener if any to avoid duplicates
|
||
resizer.removeEventListener('mousedown', handleMouseDown);
|
||
resizer.addEventListener('mousedown', handleMouseDown);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle mouse down event on resizer
|
||
*/
|
||
function handleMouseDown(e) {
|
||
e.preventDefault();
|
||
|
||
currentResizer = e.target;
|
||
side = currentResizer.dataset.side;
|
||
currentItem = currentResizer.parentElement;
|
||
|
||
if (!currentItem) {
|
||
console.error('Could not find item element');
|
||
return;
|
||
}
|
||
|
||
isResizing = true;
|
||
startX = e.clientX;
|
||
startWidth = currentItem.offsetWidth;
|
||
|
||
// Add event listeners for mouse move and up
|
||
document.addEventListener('mousemove', handleMouseMove);
|
||
document.addEventListener('mouseup', handleMouseUp);
|
||
|
||
// Add resizing class for visual feedback
|
||
document.body.classList.add('mf-resizing');
|
||
currentItem.classList.add('mf-item-resizing');
|
||
// 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');
|
||
});
|
||
}
|
||
});
|
||
} |