Refactored assets serving
This commit is contained in:
380
src/myfasthtml/assets/core/myfasthtml.js
Normal file
380
src/myfasthtml/assets/core/myfasthtml.js
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 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 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
|
||||
htmx.ajax(method, url, htmxOptions);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user