384 lines
11 KiB
JavaScript
384 lines
11 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) => {
|
|
const target = event.target;
|
|
|
|
// Early exit - check mf-no-tooltip on the registered element OR any ancestor of the target
|
|
if (element.hasAttribute("mf-no-tooltip") || target.closest("[mf-no-tooltip]")) {
|
|
return;
|
|
}
|
|
|
|
// OPTIMIZATION C: Throttle mouseenter events (max 1 per frame)
|
|
if (tooltipRafScheduled) {
|
|
return;
|
|
}
|
|
|
|
const cell = 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") || target.closest("[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 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");
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
//console.debug(`Triggering HTMX action for element ${elementId}: ${method} ${url}`, htmxOptions);
|
|
htmx.ajax(method, url, htmxOptions);
|
|
}
|
|
|