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

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);
}