diff --git a/README.md b/README.md index 164c99d..e2854e8 100644 --- a/README.md +++ b/README.md @@ -957,3 +957,4 @@ user.find_element("textarea[name='message']") * 0.1.0 : First release * 0.2.0 : Updated to myauth 0.2.0 * 0.3.0 : Added Bindings support +* 0.4.0 : First version with Datagrid + new static file server diff --git a/pyproject.toml b/pyproject.toml index 237a819..eb2b1c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "myfasthtml" -version = "0.3.0" +version = "0.4.0" description = "Set of tools to quickly create HTML pages using FastHTML." readme = "README.md" authors = [ @@ -74,10 +74,12 @@ dev = [ # ------------------------------------------------------------------- [tool.setuptools] package-dir = { "" = "src" } -packages = ["myfasthtml"] + +[tool.setuptools.packages.find] +where = ["src"] [tool.setuptools.package-data] myfasthtml = [ - "assets/*.css", - "assets/*.js" + "assets/**/*.css", + "assets/**/*.js" ] \ No newline at end of file diff --git a/src/myfasthtml/assets/codemirror.min.css b/src/myfasthtml/assets/codemirror/codemirror.min.css similarity index 100% rename from src/myfasthtml/assets/codemirror.min.css rename to src/myfasthtml/assets/codemirror/codemirror.min.css diff --git a/src/myfasthtml/assets/codemirror.min.js b/src/myfasthtml/assets/codemirror/codemirror.min.js similarity index 100% rename from src/myfasthtml/assets/codemirror.min.js rename to src/myfasthtml/assets/codemirror/codemirror.min.js diff --git a/src/myfasthtml/assets/lint.min.css b/src/myfasthtml/assets/codemirror/lint.min.css similarity index 100% rename from src/myfasthtml/assets/lint.min.css rename to src/myfasthtml/assets/codemirror/lint.min.css diff --git a/src/myfasthtml/assets/lint.min.js b/src/myfasthtml/assets/codemirror/lint.min.js similarity index 100% rename from src/myfasthtml/assets/lint.min.js rename to src/myfasthtml/assets/codemirror/lint.min.js diff --git a/src/myfasthtml/assets/placeholder.min.js b/src/myfasthtml/assets/codemirror/placeholder.min.js similarity index 100% rename from src/myfasthtml/assets/placeholder.min.js rename to src/myfasthtml/assets/codemirror/placeholder.min.js diff --git a/src/myfasthtml/assets/show-hint.min.css b/src/myfasthtml/assets/codemirror/show-hint.min.css similarity index 100% rename from src/myfasthtml/assets/show-hint.min.css rename to src/myfasthtml/assets/codemirror/show-hint.min.css diff --git a/src/myfasthtml/assets/show-hint.min.js b/src/myfasthtml/assets/codemirror/show-hint.min.js similarity index 100% rename from src/myfasthtml/assets/show-hint.min.js rename to src/myfasthtml/assets/codemirror/show-hint.min.js diff --git a/src/myfasthtml/assets/simple.min.js b/src/myfasthtml/assets/codemirror/simple.min.js similarity index 100% rename from src/myfasthtml/assets/simple.min.js rename to src/myfasthtml/assets/codemirror/simple.min.js diff --git a/src/myfasthtml/assets/core/boundaries.js b/src/myfasthtml/assets/core/boundaries.js new file mode 100644 index 0000000..20ab3f8 --- /dev/null +++ b/src/myfasthtml/assets/core/boundaries.js @@ -0,0 +1,50 @@ +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}); + } +} diff --git a/src/myfasthtml/assets/core/dropdown.css b/src/myfasthtml/assets/core/dropdown.css new file mode 100644 index 0000000..0abcc83 --- /dev/null +++ b/src/myfasthtml/assets/core/dropdown.css @@ -0,0 +1,57 @@ +.mf-dropdown-wrapper { + position: relative; /* CRUCIAL for the anchor */ +} + +.mf-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + z-index: 50; + min-width: 200px; + padding: 0.5rem; + box-sizing: border-box; + overflow-x: auto; + + /* DaisyUI styling */ + background-color: var(--color-base-100); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: 0 4px 6px -1px color-mix(in oklab, var(--color-neutral) 20%, #0000), + 0 2px 4px -2px color-mix(in oklab, var(--color-neutral) 20%, #0000); +} + +.mf-dropdown.is-visible { + display: block; + opacity: 1; +} + +/* Dropdown vertical positioning */ +.mf-dropdown-below { + top: 100%; + bottom: auto; +} + +.mf-dropdown-above { + bottom: 100%; + top: auto; +} + +/* Dropdown horizontal alignment */ +.mf-dropdown-left { + left: 0; + right: auto; + transform: none; +} + +.mf-dropdown-right { + right: 0; + left: auto; + transform: none; +} + +.mf-dropdown-center { + left: 50%; + right: auto; + transform: translateX(-50%); +} diff --git a/src/myfasthtml/assets/core/dropdown.js b/src/myfasthtml/assets/core/dropdown.js new file mode 100644 index 0000000..3b52fe0 --- /dev/null +++ b/src/myfasthtml/assets/core/dropdown.js @@ -0,0 +1,11 @@ +/** + * 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}; +} diff --git a/src/myfasthtml/assets/core/dsleditor.css b/src/myfasthtml/assets/core/dsleditor.css new file mode 100644 index 0000000..bf7b77c --- /dev/null +++ b/src/myfasthtml/assets/core/dsleditor.css @@ -0,0 +1,209 @@ +/* *********************************************** */ +/* ********** CodeMirror DaisyUI Theme *********** */ +/* *********************************************** */ + +/* Theme selector - uses DaisyUI variables for automatic theme switching */ +.cm-s-daisy.CodeMirror { + background-color: var(--color-base-100); + color: var(--color-base-content); + font-family: var(--font-mono, ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, 'Courier New', monospace); + font-size: 14px; + line-height: 1.5; + height: auto; + border-radius: 0.5rem; + border: 1px solid var(--color-border); +} + +/* Cursor */ +.cm-s-daisy .CodeMirror-cursor { + border-left-color: var(--color-primary); + border-left-width: 2px; +} + +/* Selection */ +.cm-s-daisy .CodeMirror-selected { + background-color: var(--color-selection) !important; +} + +.cm-s-daisy.CodeMirror-focused .CodeMirror-selected { + background-color: color-mix(in oklab, var(--color-primary) 30%, transparent) !important; +} + +/* Line numbers and gutters */ +.cm-s-daisy .CodeMirror-gutters { + background-color: var(--color-base-200); + border-right: 1px solid var(--color-border); +} + +.cm-s-daisy .CodeMirror-linenumber { + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); + padding: 0 8px; +} + +/* Active line */ +.cm-s-daisy .CodeMirror-activeline-background { + background-color: color-mix(in oklab, var(--color-base-content) 5%, transparent); +} + +.cm-s-daisy .CodeMirror-activeline-gutter { + background-color: var(--color-base-300); +} + +/* Matching brackets */ +.cm-s-daisy .CodeMirror-matchingbracket { + color: var(--color-success) !important; + font-weight: bold; +} + +.cm-s-daisy .CodeMirror-nonmatchingbracket { + color: var(--color-error) !important; +} + +/* *********************************************** */ +/* ******** CodeMirror Syntax Highlighting ******* */ +/* *********************************************** */ + +/* Keywords (column, row, cell, if, not, and, or, in, between, case) */ +.cm-s-daisy .cm-keyword { + color: var(--color-primary); + font-weight: bold; +} + +/* Built-in functions (style, format) */ +.cm-s-daisy .cm-builtin { + color: var(--color-secondary); + font-weight: 600; +} + +/* Operators (==, <, >, contains, startswith, etc.) */ +.cm-s-daisy .cm-operator { + color: var(--color-warning); +} + +/* Strings ("error", "EUR", etc.) */ +.cm-s-daisy .cm-string { + color: var(--color-success); +} + +/* Numbers (0, 100, 3.14) */ +.cm-s-daisy .cm-number { + color: var(--color-accent); +} + +/* Booleans (True, False, true, false) */ +.cm-s-daisy .cm-atom { + color: var(--color-info); +} + +/* Special variables (value, col, row, cell) */ +.cm-s-daisy .cm-variable-2 { + color: var(--color-accent); + font-style: italic; +} + +/* Cell IDs (tcell_*) */ +.cm-s-daisy .cm-variable-3 { + color: color-mix(in oklab, var(--color-base-content) 70%, transparent); +} + +/* Comments (#...) */ +.cm-s-daisy .cm-comment { + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); + font-style: italic; +} + +/* Property names (bold=, color=, etc.) */ +.cm-s-daisy .cm-property { + color: var(--color-base-content); + opacity: 0.8; +} + +/* Errors/invalid syntax */ +.cm-s-daisy .cm-error { + color: var(--color-error); + text-decoration: underline wavy; +} + +/* *********************************************** */ +/* ********** CodeMirror Autocomplete ************ */ +/* *********************************************** */ + +/* Autocomplete dropdown container */ +.CodeMirror-hints { + background-color: var(--color-base-100); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 13px; + max-height: 20em; + overflow-y: auto; +} + +/* Individual hint items */ +.CodeMirror-hint { + color: var(--color-base-content); + padding: 4px 8px; + cursor: pointer; +} + +/* Hovered/selected hint */ +.CodeMirror-hint-active { + background-color: var(--color-primary); + color: var(--color-primary-content); +} + +/* *********************************************** */ +/* ********** CodeMirror Lint Markers ************ */ +/* *********************************************** */ + +/* Lint gutter marker */ +.CodeMirror-lint-marker { + cursor: pointer; +} + +.CodeMirror-lint-marker-error { + color: var(--color-error); +} + +.CodeMirror-lint-marker-warning { + color: var(--color-warning); +} + +/* Lint tooltip */ +.CodeMirror-lint-tooltip { + background-color: var(--color-base-100); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + color: var(--color-base-content); + font-family: var(--font-sans, ui-sans-serif, system-ui); + font-size: 13px; + padding: 8px 12px; + max-width: 400px; +} + +.CodeMirror-lint-message-error { + color: var(--color-error); +} + +.CodeMirror-lint-message-warning { + color: var(--color-warning); +} + +/* *********************************************** */ +/* ********** DslEditor Wrapper Styles *********** */ +/* *********************************************** */ + +/* Wrapper container for DslEditor */ +.mf-dsl-editor-wrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Editor container */ +.mf-dsl-editor { + border-radius: 0.5rem; + overflow: hidden; +} diff --git a/src/myfasthtml/assets/core/dsleditor.js b/src/myfasthtml/assets/core/dsleditor.js new file mode 100644 index 0000000..6de0482 --- /dev/null +++ b/src/myfasthtml/assets/core/dsleditor.js @@ -0,0 +1,269 @@ +/** + * 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"}`); +} \ No newline at end of file diff --git a/src/myfasthtml/assets/core/keyboard.js b/src/myfasthtml/assets/core/keyboard.js new file mode 100644 index 0000000..ca85a1f --- /dev/null +++ b/src/myfasthtml/assets/core/keyboard.js @@ -0,0 +1,376 @@ +/** + * 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(); + } + }; +})(); diff --git a/src/myfasthtml/assets/core/layout.css b/src/myfasthtml/assets/core/layout.css new file mode 100644 index 0000000..7007a8b --- /dev/null +++ b/src/myfasthtml/assets/core/layout.css @@ -0,0 +1,270 @@ +/* + * MF Layout Component - CSS Grid Layout + * Provides fixed header/footer, collapsible drawers, and scrollable main content + * Compatible with DaisyUI 5 + */ + +/* Main layout container using CSS Grid */ +.mf-layout { + display: grid; + grid-template-areas: + "header header header" + "left-drawer main right-drawer" + "footer footer footer"; + grid-template-rows: 32px 1fr 32px; + grid-template-columns: auto 1fr auto; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +/* Header - fixed at top */ +.mf-layout-header { + grid-area: header; + display: flex; + align-items: center; + justify-content: space-between; /* put one item on each side */ + gap: 1rem; + padding: 0 1rem; + background-color: var(--color-base-300); + border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000); + z-index: 30; +} + +/* Footer - fixed at bottom */ +.mf-layout-footer { + grid-area: footer; + display: flex; + align-items: center; + padding: 0 1rem; + background-color: var(--color-neutral); + color: var(--color-neutral-content); + border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000); + z-index: 30; +} + +/* Main content area - scrollable */ +.mf-layout-main { + grid-area: main; + overflow-y: auto; + overflow-x: auto; + padding: 0.5rem; + background-color: var(--color-base-100); +} + +/* Drawer base styles */ +.mf-layout-drawer { + overflow-y: auto; + overflow-x: hidden; + background-color: var(--color-base-100); + transition: width 0.3s ease-in-out, margin 0.3s ease-in-out; + width: 250px; + padding: 1rem; +} + +/* Left drawer */ +.mf-layout-left-drawer { + grid-area: left-drawer; + border-right: 1px solid var(--color-border-primary); +} + +/* Right drawer */ +.mf-layout-right-drawer { + grid-area: right-drawer; + /*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/ + border-left: 1px solid var(--color-border-primary); +} + +/* Collapsed drawer states */ +.mf-layout-drawer.collapsed { + width: 0; + padding: 0; + border: none; + overflow: hidden; +} + +/* Toggle buttons positioning */ +.mf-layout-toggle-left { + margin-right: auto; +} + +.mf-layout-toggle-right { + margin-left: auto; +} + +/* Smooth scrollbar styling for webkit browsers */ +.mf-layout-main::-webkit-scrollbar, +.mf-layout-drawer::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.mf-layout-main::-webkit-scrollbar-track, +.mf-layout-drawer::-webkit-scrollbar-track { + background: var(--color-base-200); +} + +.mf-layout-main::-webkit-scrollbar-thumb, +.mf-layout-drawer::-webkit-scrollbar-thumb { + background: color-mix(in oklab, var(--color-base-content) 20%, #0000); + border-radius: 4px; +} + +.mf-layout-main::-webkit-scrollbar-thumb:hover, +.mf-layout-drawer::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklab, var(--color-base-content) 30%, #0000); +} + +/* Responsive adjustments for smaller screens */ +@media (max-width: 768px) { + .mf-layout-drawer { + width: 200px; + } + + .mf-layout-header, + .mf-layout-footer { + padding: 0 0.5rem; + } + + .mf-layout-main { + padding: 0.5rem; + } +} + +/* Handle layouts with no drawers */ +.mf-layout[data-left-drawer="false"] { + grid-template-areas: + "header header" + "main right-drawer" + "footer footer"; + grid-template-columns: 1fr auto; +} + +.mf-layout[data-right-drawer="false"] { + grid-template-areas: + "header header" + "left-drawer main" + "footer footer"; + grid-template-columns: auto 1fr; +} + +.mf-layout[data-left-drawer="false"][data-right-drawer="false"] { + grid-template-areas: + "header" + "main" + "footer"; + grid-template-columns: 1fr; +} + + +/** + * Layout Drawer Resizer Styles + * + * Styles for the resizable drawer borders with visual feedback + */ + +/* Ensure drawer has relative positioning and no overflow */ +.mf-layout-drawer { + position: relative; + overflow: hidden; +} + +/* Content wrapper handles scrolling */ +.mf-layout-drawer-content { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 1rem; +} + +/* Base resizer styles */ +.mf-layout-resizer { + position: absolute; + top: 0; + bottom: 0; + width: 4px; + background-color: transparent; + transition: background-color 0.2s ease; + z-index: 100; +} + +/* Resizer on the right side (for left drawer) */ +.mf-layout-resizer-right { + right: 0; + cursor: col-resize; +} + +/* Resizer on the left side (for right drawer) */ +.mf-layout-resizer-left { + left: 0; + cursor: col-resize; +} + +/* Hover state */ +.mf-layout-resizer:hover { + background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */ +} + +/* Active state during resize */ +.mf-layout-drawer-resizing .mf-layout-resizer { + background-color: rgba(59, 130, 246, 0.5); +} + +/* Disable transitions during resize for smooth dragging */ +.mf-layout-drawer-resizing { + transition: none !important; +} + +/* Prevent text selection during resize */ +.mf-layout-resizing { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +/* Cursor override for entire body during resize */ +.mf-layout-resizing * { + cursor: col-resize !important; +} + +/* Visual indicator for resizer on hover - subtle border */ +.mf-layout-resizer::before { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 40px; + background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */ + border-radius: 2px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.mf-layout-resizer-right::before { + right: 1px; +} + +.mf-layout-resizer-left::before { + left: 1px; +} + +.mf-layout-resizer:hover::before, +.mf-layout-drawer-resizing .mf-layout-resizer::before { + opacity: 1; +} + + +.mf-layout-group { + font-weight: bold; + /*font-size: var(--text-sm);*/ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 0.2rem; +} diff --git a/src/myfasthtml/assets/core/layout.js b/src/myfasthtml/assets/core/layout.js new file mode 100644 index 0000000..d276950 --- /dev/null +++ b/src/myfasthtml/assets/core/layout.js @@ -0,0 +1,4 @@ +function initLayout(elementId) { + initResizer(elementId); + bindTooltipsWithDelegation(elementId); +} diff --git a/src/myfasthtml/assets/core/mouse.js b/src/myfasthtml/assets/core/mouse.js new file mode 100644 index 0000000..a44d72e --- /dev/null +++ b/src/myfasthtml/assets/core/mouse.js @@ -0,0 +1,578 @@ + +/** + * 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(); + } + }; +})(); + diff --git a/src/myfasthtml/assets/core/myfasthtml.css b/src/myfasthtml/assets/core/myfasthtml.css new file mode 100644 index 0000000..843463a --- /dev/null +++ b/src/myfasthtml/assets/core/myfasthtml.css @@ -0,0 +1,164 @@ +:root { + --color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000); + --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000); + --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000); + --color-selection: color-mix(in oklab, var(--color-primary) 20%, #0000); + + --datagrid-resize-zindex: 1; + --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --spacing: 0.25rem; + --text-xs: 0.6875rem; + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --font-weight-medium: 500; + --radius-md: 0.375rem; + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + --properties-font-size: var(--text-xs); + --mf-tooltip-zindex: 10; +} + +.mf-icon-16 { + width: 16px; + min-width: 16px; + height: 16px; +} + +.mf-icon-20 { + width: 20px; + min-width: 20px; + height: 20px; +} + +.mf-icon-24 { + width: 24px; + min-width: 24px; + height: 24px; + +} + +.mf-icon-28 { + width: 28px; + min-width: 28px; + height: 28px; +} + +.mf-icon-32 { + width: 32px; + min-width: 32px; + height: 32px; +} + +.mf-button { + border-radius: 0.375rem; + transition: background-color 0.15s ease; +} + +.mf-button:hover { + background-color: var(--color-base-300); +} + +.mf-tooltip-container { + background: var(--color-base-200); + padding: 5px 10px; + border-radius: 4px; + pointer-events: none; /* Prevent interfering with mouse events */ + font-size: 12px; + white-space: nowrap; + opacity: 0; /* Default to invisible */ + visibility: hidden; /* Prevent interaction when invisible */ + transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */ + position: fixed; /* Keep it above other content and adjust position */ + z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */ +} + +.mf-tooltip-container[data-visible="true"] { + opacity: 1; + visibility: visible; /* Show tooltip */ + transition: opacity 0.3s ease; /* No delay when becoming visible */ +} + +/* *********************************************** */ +/* ********** Generic Resizer Classes ************ */ +/* *********************************************** */ + +/* Generic resizer - used by both Layout and Panel */ +.mf-resizer { + position: absolute; + width: 4px; + cursor: col-resize; + background-color: transparent; + transition: background-color 0.2s ease; + z-index: 100; + top: 0; + bottom: 0; +} + +.mf-resizer:hover { + background-color: rgba(59, 130, 246, 0.3); +} + +/* Active state during resize */ +.mf-resizing .mf-resizer { + background-color: rgba(59, 130, 246, 0.5); +} + +/* Prevent text selection during resize */ +.mf-resizing { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +/* Cursor override for entire body during resize */ +.mf-resizing * { + cursor: col-resize !important; +} + +/* Visual indicator for resizer on hover - subtle border */ +.mf-resizer::before { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 40px; + background-color: rgba(156, 163, 175, 0.4); + border-radius: 2px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.mf-resizer:hover::before, +.mf-resizing .mf-resizer::before { + opacity: 1; +} + +/* Resizer positioning */ +/* Left resizer is on the right side of the left panel */ +.mf-resizer-left { + right: 0; +} + +/* Right resizer is on the left side of the right panel */ +.mf-resizer-right { + left: 0; +} + +/* Position indicator for resizer */ +.mf-resizer-left::before { + right: 1px; +} + +.mf-resizer-right::before { + left: 1px; +} + +/* Disable transitions during resize for smooth dragging */ +.mf-item-resizing { + transition: none !important; +} diff --git a/src/myfasthtml/assets/core/myfasthtml.js b/src/myfasthtml/assets/core/myfasthtml.js new file mode 100644 index 0000000..839aac4 --- /dev/null +++ b/src/myfasthtml/assets/core/myfasthtml.js @@ -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); +} + diff --git a/src/myfasthtml/assets/core/panel.css b/src/myfasthtml/assets/core/panel.css new file mode 100644 index 0000000..f216723 --- /dev/null +++ b/src/myfasthtml/assets/core/panel.css @@ -0,0 +1,117 @@ +/* *********************************************** */ +/* *************** Panel Component *************** */ +/* *********************************************** */ + +.mf-panel { + display: flex; + width: 100%; + height: 100%; + overflow: hidden; + position: relative; +} + +/* Common properties for side panels */ +.mf-panel-left, +.mf-panel-right { + position: relative; + flex-shrink: 0; + width: 250px; + min-width: 150px; + max-width: 500px; + height: 100%; + overflow: auto; + transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease; + padding-top: 25px; +} + +/* Left panel specific */ +.mf-panel-left { + border-right: 1px solid var(--color-border-primary); +} + +/* Right panel specific */ +.mf-panel-right { + border-left: 1px solid var(--color-border-primary); + padding-left: 0.5rem; +} + +.mf-panel-main { + flex: 1; + height: 100%; + overflow: hidden; + min-width: 0; /* Important to allow the shrinking of flexbox */ +} + +/* Hidden state - common for both panels */ +.mf-panel-left.mf-hidden, +.mf-panel-right.mf-hidden { + width: 0; + min-width: 0; + max-width: 0; + overflow: hidden; + border: none; + padding: 0; +} + +/* No transition during manual resize - common for both panels */ +.mf-panel-left.no-transition, +.mf-panel-right.no-transition { + transition: none; +} + +/* Common properties for panel toggle icons */ +.mf-panel-hide-icon, +.mf-panel-show-icon { + position: absolute; + top: 0; + right: 0; + cursor: pointer; + z-index: 10; + border-radius: 0.25rem; +} + +.mf-panel-hide-icon:hover, +.mf-panel-show-icon:hover { + background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.05)); +} + +/* Show icon positioning */ +.mf-panel-show-icon-left { + left: 0.5rem; +} + +.mf-panel-show-icon-right { + right: 0.5rem; +} + +/* Panel with title - grid layout for header + scrollable content */ +.mf-panel-body { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + overflow: hidden; +} + +.mf-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.25rem 0.5rem; + background-color: var(--color-base-200); + border-bottom: 1px solid var(--color-border); +} + +/* Override absolute positioning for hide icon when inside header */ +.mf-panel-header .mf-panel-hide-icon { + position: static; +} + +.mf-panel-content { + overflow-y: auto; +} + +/* Remove padding-top when using title layout */ +.mf-panel-left.mf-panel-with-title, +.mf-panel-right.mf-panel-with-title { + padding-top: 0; +} diff --git a/src/myfasthtml/assets/core/properties.css b/src/myfasthtml/assets/core/properties.css new file mode 100644 index 0000000..ede443b --- /dev/null +++ b/src/myfasthtml/assets/core/properties.css @@ -0,0 +1,88 @@ +/* *********************************************** */ +/* ************* Properties Component ************ */ +/* *********************************************** */ + +/*!* Properties container *!*/ +.mf-properties { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/*!* Group card - using DaisyUI card styling *!*/ +.mf-properties-group-card { + background-color: var(--color-base-100); + border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent); + border-radius: var(--radius-md); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: auto; +} + +.mf-properties-group-container { + display: inline-block; /* important */ + min-width: max-content; /* important */ + width: 100%; +} + + +/*!* Group header - gradient using DaisyUI primary color *!*/ +.mf-properties-group-header { + background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%); + color: var(--color-primary-content); + padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75); + font-weight: 700; + font-size: var(--properties-font-size); +} + +/*!* Group content area *!*/ +.mf-properties-group-content { + display: flex; + flex-direction: column; + min-width: max-content; +} + +/*!* Property row *!*/ +.mf-properties-row { + display: grid; + grid-template-columns: 6rem 1fr; + + align-items: center; + padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75); + + border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent); + transition: background-color 0.15s ease; + + gap: calc(var(--properties-font-size) * 0.75); +} + +.mf-properties-row:last-child { + border-bottom: none; +} + +.mf-properties-row:hover { + background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent); +} + +/*!* Property key - normal font *!*/ +.mf-properties-key { + align-items: start; + font-weight: 600; + color: color-mix(in oklab, var(--color-base-content) 70%, transparent); + flex: 0 0 40%; + font-size: var(--properties-font-size); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/*!* Property value - monospace font *!*/ +.mf-properties-value { + font-family: var(--default-mono-font-family); + color: var(--color-base-content); + flex: 1; + text-align: left; + font-size: var(--properties-font-size); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/myfasthtml/assets/core/search.css b/src/myfasthtml/assets/core/search.css new file mode 100644 index 0000000..5aa049f --- /dev/null +++ b/src/myfasthtml/assets/core/search.css @@ -0,0 +1,5 @@ +.mf-search-results { + margin-top: 0.5rem; + /*max-height: 400px;*/ + overflow: auto; +} diff --git a/src/myfasthtml/assets/core/tabs.css b/src/myfasthtml/assets/core/tabs.css new file mode 100644 index 0000000..26d0058 --- /dev/null +++ b/src/myfasthtml/assets/core/tabs.css @@ -0,0 +1,107 @@ +/* *********************************************** */ +/* *********** Tabs Manager Component ************ */ +/* *********************************************** */ + +/* Tabs Manager Container */ +.mf-tabs-manager { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background-color: var(--color-base-200); + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); + border-radius: .5rem; +} + +/* Tabs Header using DaisyUI tabs component */ +.mf-tabs-header { + display: flex; + gap: 0; + flex-shrink: 1; + min-height: 25px; + + overflow-x: hidden; + overflow-y: hidden; + white-space: nowrap; +} + +.mf-tabs-header-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + /*overflow: hidden; important */ +} + +/* Individual Tab Button using DaisyUI tab classes */ +.mf-tab-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0 0.5rem 0 1rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.mf-tab-button:hover { + color: var(--color-base-content); /* Change text color on hover */ +} + +.mf-tab-button.mf-tab-active { + --depth: 1; + background-color: var(--color-base-100); + color: var(--color-base-content); + border-radius: .25rem; + border-bottom: 4px solid var(--color-primary); + box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000); +} + +/* Tab Label */ +.mf-tab-label { + user-select: none; + white-space: nowrap; + max-width: 150px; +} + +/* Tab Close Button */ +.mf-tab-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 0.25rem; + font-size: 1.25rem; + line-height: 1; + @apply text-base-content/50; + transition: all 0.2s ease; +} + +.mf-tab-close-btn:hover { + @apply bg-base-300 text-error; +} + +/* Tab Content Area */ +.mf-tab-content { + flex: 1; + overflow: auto; + height: 100%; +} + +.mf-tab-content-wrapper { + flex: 1; + overflow: auto; + background-color: var(--color-base-100); + padding: 0.5rem; + border-top: 1px solid var(--color-border-primary); +} + +/* Empty Content State */ +.mf-empty-content { + align-items: center; + justify-content: center; + height: 100%; + @apply text-base-content/50; + font-style: italic; +} diff --git a/src/myfasthtml/assets/core/tabs.js b/src/myfasthtml/assets/core/tabs.js new file mode 100644 index 0000000..cd041c4 --- /dev/null +++ b/src/myfasthtml/assets/core/tabs.js @@ -0,0 +1,59 @@ +/** + * 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' + }); + } + } +} diff --git a/src/myfasthtml/assets/core/treeview.css b/src/myfasthtml/assets/core/treeview.css new file mode 100644 index 0000000..e2922d0 --- /dev/null +++ b/src/myfasthtml/assets/core/treeview.css @@ -0,0 +1,78 @@ +/* *********************************************** */ +/* ************** TreeView Component ************* */ +/* *********************************************** */ + +/* TreeView Container */ +.mf-treeview { + width: 100%; + user-select: none; +} + +/* TreeNode Container */ +.mf-treenode-container { + width: 100%; +} + +/* TreeNode Element */ +.mf-treenode { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 2px 0.5rem; + cursor: pointer; + transition: background-color 0.15s ease; + border-radius: 0.25rem; +} + +/* Input for Editing */ +.mf-treenode-input { + flex: 1; + padding: 2px 0.25rem; + border: 1px solid var(--color-primary); + border-radius: 0.25rem; + background-color: var(--color-base-100); + outline: none; +} + + +.mf-treenode:hover { + background-color: var(--color-base-200); +} + +.mf-treenode.selected { + background-color: var(--color-primary); + color: var(--color-primary-content); +} + +/* Toggle Icon */ +.mf-treenode-toggle { + flex-shrink: 0; + width: 20px; + text-align: center; + font-size: 0.75rem; +} + +/* Node Label */ +.mf-treenode-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + +.mf-treenode-input:focus { + box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent); +} + +/* Action Buttons - Hidden by default, shown on hover */ +.mf-treenode-actions { + display: none; + gap: 0.1rem; + white-space: nowrap; + margin-left: 0.5rem; +} + +.mf-treenode:hover .mf-treenode-actions { + display: flex; +} diff --git a/src/myfasthtml/assets/daisyui-5-themes.css b/src/myfasthtml/assets/daisyui/daisyui-5-themes.css similarity index 100% rename from src/myfasthtml/assets/daisyui-5-themes.css rename to src/myfasthtml/assets/daisyui/daisyui-5-themes.css diff --git a/src/myfasthtml/assets/daisyui-5.css b/src/myfasthtml/assets/daisyui/daisyui-5.css similarity index 100% rename from src/myfasthtml/assets/daisyui-5.css rename to src/myfasthtml/assets/daisyui/daisyui-5.css diff --git a/src/myfasthtml/assets/tailwindcss-browser@4.js b/src/myfasthtml/assets/daisyui/tailwindcss-browser@4.js similarity index 100% rename from src/myfasthtml/assets/tailwindcss-browser@4.js rename to src/myfasthtml/assets/daisyui/tailwindcss-browser@4.js diff --git a/src/myfasthtml/assets/datagrid/datagrid.css b/src/myfasthtml/assets/datagrid/datagrid.css new file mode 100644 index 0000000..aa333b0 --- /dev/null +++ b/src/myfasthtml/assets/datagrid/datagrid.css @@ -0,0 +1,287 @@ + +/* ********************************************* */ +/* ************* Datagrid Component ************ */ +/* ********************************************* */ + +/* Header and Footer */ +.dt2-header, +.dt2-footer { + background-color: var(--color-base-200); + border-radius: 10px 10px 0 0; + min-width: max-content; /* Content width propagates to scrollable parent */ +} + +/* Body */ +.dt2-body { + overflow: hidden; + min-width: max-content; /* Content width propagates to scrollable parent */ +} + +/* Row */ +.dt2-row { + display: flex; + width: 100%; + height: 20px; +} + +/* Cell */ +.dt2-cell { + display: flex; + align-items: center; + justify-content: flex-start; + padding: 2px 8px; + position: relative; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 100px; + flex-grow: 0; + flex-shrink: 1; + box-sizing: border-box; + border-bottom: 1px solid var(--color-border); + user-select: none; +} + +/* Cell content types */ +.dt2-cell-content-text { + text-align: inherit; + width: 100%; + padding-right: 10px; +} + +.dt2-cell-content-checkbox { + display: flex; + width: 100%; + justify-content: center; + align-items: center; +} + +.dt2-cell-content-number { + text-align: right; + width: 100%; + padding-right: 10px; +} + +/* Footer cell */ +.dt2-footer-cell { + cursor: pointer; +} + +/* Resize handle */ +.dt2-resize-handle { + position: absolute; + right: 0; + top: 0; + width: 8px; + height: 100%; + cursor: col-resize; +} + +.dt2-resize-handle::after { + content: ''; + position: absolute; + z-index: var(--datagrid-resize-zindex); + display: block; + width: 3px; + height: 60%; + top: calc(50% - 60% * 0.5); + background-color: var(--color-resize); +} + +/* Hidden column */ +.dt2-col-hidden { + width: 5px; + border-bottom: 1px solid var(--color-border); +} + +/* Highlight */ +.dt2-highlight-1 { + color: var(--color-accent); +} + + +.dt2-selected-focus { + outline: 2px solid var(--color-primary); + outline-offset: -3px; /* Ensure the outline is snug to the cell */ +} + +.dt2-cell:hover, +.dt2-selected-cell { + background-color: var(--color-selection); +} + +.dt2-selected-row { + background-color: var(--color-selection); +} + +.dt2-selected-column { + background-color: var(--color-selection); +} + +.dt2-hover-row { + background-color: var(--color-selection); +} + +.dt2-hover-column { + background-color: var(--color-selection); +} + + +/* *********************************************** */ +/* ******** DataGrid Fixed Header/Footer ******** */ +/* *********************************************** */ + +/* + * DataGrid with CSS Grid + Custom Scrollbars + * - Wrapper takes 100% of parent height + * - Table uses Grid: header (auto) + body (1fr) + footer (auto) + * - Native scrollbars hidden, custom scrollbars overlaid + * - Vertical scrollbar: right side of entire table + * - Horizontal scrollbar: bottom, under footer + */ + +/* Main wrapper - takes full parent height, contains table + scrollbars */ +.dt2-table-wrapper { + height: 100%; + overflow: hidden; + position: relative; +} + +/* Table with Grid layout - horizontal scroll enabled, scrollbars hidden */ +.dt2-table { + --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000); + --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000); + height: 100%; + display: grid; + grid-template-rows: auto 1fr auto; /* header, body, footer */ + overflow-x: auto; /* Enable horizontal scroll */ + overflow-y: hidden; /* No vertical scroll on table */ + scrollbar-width: none; /* Firefox: hide scrollbar */ + -ms-overflow-style: none; /* IE/Edge: hide scrollbar */ + border: 1px solid var(--color-border); + border-radius: 10px; +} + +/* Chrome/Safari: hide scrollbar */ +.dt2-table::-webkit-scrollbar { + display: none; +} + +/* Header - no scroll, takes natural height */ +.dt2-header-container { + overflow: hidden; + min-width: max-content; /* Force table to be as wide as content */ +} + +/* Body - scrollable vertically via JS, scrollbars hidden */ +.dt2-body-container { + overflow: hidden; /* Scrollbars hidden, scroll via JS */ + min-height: 0; /* Important for Grid to allow shrinking */ + min-width: max-content; /* Force table to be as wide as content */ +} + +/* Footer - no scroll, takes natural height */ +.dt2-footer-container { + overflow: hidden; + min-width: max-content; /* Force table to be as wide as content */ +} + +/* Custom scrollbars container - overlaid on table */ +.dt2-scrollbars { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + pointer-events: none; /* Let clicks pass through */ + z-index: 10; +} + +/* Scrollbar wrappers - clickable/draggable */ +.dt2-scrollbars-vertical-wrapper, +.dt2-scrollbars-horizontal-wrapper { + position: absolute; + background-color: var(--color-base-200); + opacity: 1; + transition: opacity 0.2s ease-in-out; + pointer-events: auto; /* Enable interaction */ +} + +/* Vertical scrollbar wrapper - right side, full table height */ +.dt2-scrollbars-vertical-wrapper { + right: 0; + top: 0; + bottom: 0; + width: 8px; +} + +/* Horizontal scrollbar wrapper - bottom, full width minus vertical scrollbar */ +.dt2-scrollbars-horizontal-wrapper { + left: 0; + right: 8px; /* Leave space for vertical scrollbar */ + bottom: 0; + height: 8px; +} + +/* Scrollbar thumbs */ +.dt2-scrollbars-vertical, +.dt2-scrollbars-horizontal { + background-color: color-mix(in oklab, var(--color-base-content) 20%, #0000); + border-radius: 3px; + position: absolute; + cursor: pointer; + transition: background-color 0.2s ease; +} + +/* Vertical scrollbar thumb */ +.dt2-scrollbars-vertical { + left: 0; + right: 0; + top: 0; + width: 100%; +} + +/* Horizontal scrollbar thumb */ +.dt2-scrollbars-horizontal { + top: 0; + bottom: 0; + left: 0; + height: 100%; +} + +/* Hover and dragging states */ +.dt2-scrollbars-vertical:hover, +.dt2-scrollbars-horizontal:hover, +.dt2-scrollbars-vertical.dt2-dragging, +.dt2-scrollbars-horizontal.dt2-dragging { + background-color: color-mix(in oklab, var(--color-base-content) 30%, #0000); +} + +/* *********************************************** */ +/* ******** DataGrid Column Drag & Drop ********** */ +/* *********************************************** */ + +/* Column being dragged - visual feedback */ +.dt2-dragging { + opacity: 0.5; +} + +/* Column animation during swap */ +.dt2-moving { + transition: transform 300ms ease; +} + +/* *********************************************** */ +/* ******** DataGrid Column Manager ********** */ +/* *********************************************** */ +.dt2-column-manager-label { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + border-radius: 0.375rem; + transition: background-color 0.15s ease; +} + +.dt2-column-manager-label:hover { + background-color: var(--color-base-300); +} diff --git a/src/myfasthtml/assets/datagrid/datagrid.js b/src/myfasthtml/assets/datagrid/datagrid.js new file mode 100644 index 0000000..bb097ec --- /dev/null +++ b/src/myfasthtml/assets/datagrid/datagrid.js @@ -0,0 +1,687 @@ +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); +} + +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'); + }); + } + }); +} + + +/** + * 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}; +} \ No newline at end of file diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index 4deb0f3..b102296 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -1,1399 +1,901 @@ -:root { - --color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000); - --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000); - --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000); - --color-selection: color-mix(in oklab, var(--color-primary) 20%, #0000); - - --datagrid-resize-zindex: 1; - --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; - --spacing: 0.25rem; - --text-xs: 0.6875rem; - --text-sm: 0.875rem; - --text-sm--line-height: calc(1.25 / 0.875); - --text-xl: 1.25rem; - --text-xl--line-height: calc(1.75 / 1.25); - --font-weight-medium: 500; - --radius-md: 0.375rem; - --default-font-family: var(--font-sans); - --default-mono-font-family: var(--font-mono); - --properties-font-size: var(--text-xs); - --mf-tooltip-zindex: 10; -} - - -.mf-icon-16 { - width: 16px; - min-width: 16px; - height: 16px; -} - -.mf-icon-20 { - width: 20px; - min-width: 20px; - height: 20px; -} - -.mf-icon-24 { - width: 24px; - min-width: 24px; - height: 24px; - -} - -.mf-icon-28 { - width: 28px; - min-width: 28px; - height: 28px; -} - -.mf-icon-32 { - width: 32px; - min-width: 32px; - height: 32px; -} - -/* - * MF Layout Component - CSS Grid Layout - * Provides fixed header/footer, collapsible drawers, and scrollable main content - * Compatible with DaisyUI 5 - */ - -.mf-button { - border-radius: 0.375rem; - transition: background-color 0.15s ease; -} - -.mf-button:hover { - background-color: var(--color-base-300); -} - - -.mf-tooltip-container { - background: var(--color-base-200); - padding: 5px 10px; - border-radius: 4px; - pointer-events: none; /* Prevent interfering with mouse events */ - font-size: 12px; - white-space: nowrap; - opacity: 0; /* Default to invisible */ - visibility: hidden; /* Prevent interaction when invisible */ - transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */ - position: fixed; /* Keep it above other content and adjust position */ - z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */ -} - -.mf-tooltip-container[data-visible="true"] { - opacity: 1; - visibility: visible; /* Show tooltip */ - transition: opacity 0.3s ease; /* No delay when becoming visible */ -} - -/* Main layout container using CSS Grid */ -.mf-layout { - display: grid; - grid-template-areas: - "header header header" - "left-drawer main right-drawer" - "footer footer footer"; - grid-template-rows: 32px 1fr 32px; - grid-template-columns: auto 1fr auto; - height: 100vh; - width: 100vw; - overflow: hidden; -} - -/* Header - fixed at top */ -.mf-layout-header { - grid-area: header; - display: flex; - align-items: center; - justify-content: space-between; /* put one item on each side */ - gap: 1rem; - padding: 0 1rem; - background-color: var(--color-base-300); - border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000); - z-index: 30; -} - -/* Footer - fixed at bottom */ -.mf-layout-footer { - grid-area: footer; - display: flex; - align-items: center; - padding: 0 1rem; - background-color: var(--color-neutral); - color: var(--color-neutral-content); - border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000); - z-index: 30; -} - -/* Main content area - scrollable */ -.mf-layout-main { - grid-area: main; - overflow-y: auto; - overflow-x: auto; - padding: 0.5rem; - background-color: var(--color-base-100); -} - -/* Drawer base styles */ -.mf-layout-drawer { - overflow-y: auto; - overflow-x: hidden; - background-color: var(--color-base-100); - transition: width 0.3s ease-in-out, margin 0.3s ease-in-out; - width: 250px; - padding: 1rem; -} - -/* Left drawer */ -.mf-layout-left-drawer { - grid-area: left-drawer; - border-right: 1px solid var(--color-border-primary); -} - -/* Right drawer */ -.mf-layout-right-drawer { - grid-area: right-drawer; - /*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/ - border-left: 1px solid var(--color-border-primary); -} - -/* Collapsed drawer states */ -.mf-layout-drawer.collapsed { - width: 0; - padding: 0; - border: none; - overflow: hidden; -} - -/* Toggle buttons positioning */ -.mf-layout-toggle-left { - margin-right: auto; -} - -.mf-layout-toggle-right { - margin-left: auto; -} - -/* Smooth scrollbar styling for webkit browsers */ -.mf-layout-main::-webkit-scrollbar, -.mf-layout-drawer::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -.mf-layout-main::-webkit-scrollbar-track, -.mf-layout-drawer::-webkit-scrollbar-track { - background: var(--color-base-200); -} - -.mf-layout-main::-webkit-scrollbar-thumb, -.mf-layout-drawer::-webkit-scrollbar-thumb { - background: color-mix(in oklab, var(--color-base-content) 20%, #0000); - border-radius: 4px; -} - -.mf-layout-main::-webkit-scrollbar-thumb:hover, -.mf-layout-drawer::-webkit-scrollbar-thumb:hover { - background: color-mix(in oklab, var(--color-base-content) 30%, #0000); -} - -/* Responsive adjustments for smaller screens */ -@media (max-width: 768px) { - .mf-layout-drawer { - width: 200px; - } - - .mf-layout-header, - .mf-layout-footer { - padding: 0 0.5rem; - } - - .mf-layout-main { - padding: 0.5rem; - } -} - -/* Handle layouts with no drawers */ -.mf-layout[data-left-drawer="false"] { - grid-template-areas: - "header header" - "main right-drawer" - "footer footer"; - grid-template-columns: 1fr auto; -} - -.mf-layout[data-right-drawer="false"] { - grid-template-areas: - "header header" - "left-drawer main" - "footer footer"; - grid-template-columns: auto 1fr; -} - -.mf-layout[data-left-drawer="false"][data-right-drawer="false"] { - grid-template-areas: - "header" - "main" - "footer"; - grid-template-columns: 1fr; -} - - -/** - * Layout Drawer Resizer Styles - * - * Styles for the resizable drawer borders with visual feedback - */ - -/* Ensure drawer has relative positioning and no overflow */ -.mf-layout-drawer { - position: relative; - overflow: hidden; -} - -/* Content wrapper handles scrolling */ -.mf-layout-drawer-content { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - overflow-y: auto; - overflow-x: hidden; - padding: 1rem; -} - -/* Base resizer styles */ -.mf-layout-resizer { - position: absolute; - top: 0; - bottom: 0; - width: 4px; - background-color: transparent; - transition: background-color 0.2s ease; - z-index: 100; -} - -/* Resizer on the right side (for left drawer) */ -.mf-layout-resizer-right { - right: 0; - cursor: col-resize; -} - -/* Resizer on the left side (for right drawer) */ -.mf-layout-resizer-left { - left: 0; - cursor: col-resize; -} - -/* Hover state */ -.mf-layout-resizer:hover { - background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */ -} - -/* Active state during resize */ -.mf-layout-drawer-resizing .mf-layout-resizer { - background-color: rgba(59, 130, 246, 0.5); -} - -/* Disable transitions during resize for smooth dragging */ -.mf-layout-drawer-resizing { - transition: none !important; -} - -/* Prevent text selection during resize */ -.mf-layout-resizing { - user-select: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; -} - -/* Cursor override for entire body during resize */ -.mf-layout-resizing * { - cursor: col-resize !important; -} - -/* Visual indicator for resizer on hover - subtle border */ -.mf-layout-resizer::before { - content: ''; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 2px; - height: 40px; - background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */ - border-radius: 2px; - opacity: 0; - transition: opacity 0.2s ease; -} - -.mf-layout-resizer-right::before { - right: 1px; -} - -.mf-layout-resizer-left::before { - left: 1px; -} - -.mf-layout-resizer:hover::before, -.mf-layout-drawer-resizing .mf-layout-resizer::before { - opacity: 1; -} - - -.mf-layout-group { - font-weight: bold; - /*font-size: var(--text-sm);*/ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-bottom: 0.2rem; -} - -/* *********************************************** */ -/* *********** Tabs Manager Component ************ */ -/* *********************************************** */ - -/* Tabs Manager Container */ -.mf-tabs-manager { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - background-color: var(--color-base-200); - color: color-mix(in oklab, var(--color-base-content) 50%, transparent); - border-radius: .5rem; -} - -/* Tabs Header using DaisyUI tabs component */ -.mf-tabs-header { - display: flex; - gap: 0; - flex-shrink: 1; - min-height: 25px; - - overflow-x: hidden; - overflow-y: hidden; - white-space: nowrap; -} - -.mf-tabs-header-wrapper { - display: flex; - justify-content: space-between; - align-items: center; - - width: 100%; - /*overflow: hidden; important */ -} - -/* Individual Tab Button using DaisyUI tab classes */ -.mf-tab-button { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0 0.5rem 0 1rem; - cursor: pointer; - transition: all 0.2s ease; -} - -.mf-tab-button:hover { - color: var(--color-base-content); /* Change text color on hover */ -} - -.mf-tab-button.mf-tab-active { - --depth: 1; - background-color: var(--color-base-100); - color: var(--color-base-content); - border-radius: .25rem; - border-bottom: 4px solid var(--color-primary); - box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000); -} - -/* Tab Label */ -.mf-tab-label { - user-select: none; - white-space: nowrap; - max-width: 150px; -} - -/* Tab Close Button */ -.mf-tab-close-btn { - display: flex; - align-items: center; - justify-content: center; - width: 1rem; - height: 1rem; - border-radius: 0.25rem; - font-size: 1.25rem; - line-height: 1; - @apply text-base-content/50; - transition: all 0.2s ease; -} - -.mf-tab-close-btn:hover { - @apply bg-base-300 text-error; -} - -/* Tab Content Area */ -.mf-tab-content { - flex: 1; - overflow: auto; - height: 100%; -} - -.mf-tab-content-wrapper { - flex: 1; - overflow: auto; - background-color: var(--color-base-100); - padding: 0.5rem; - border-top: 1px solid var(--color-border-primary); -} - -/* Empty Content State */ -.mf-empty-content { - align-items: center; - justify-content: center; - height: 100%; - @apply text-base-content/50; - font-style: italic; -} - -.mf-vis { - width: 100%; - height: 100%; -} - -.mf-search-results { - margin-top: 0.5rem; - /*max-height: 400px;*/ - overflow: auto; -} - -.mf-dropdown-wrapper { - position: relative; /* CRUCIAL for the anchor */ -} - - -.mf-dropdown { - display: none; - position: absolute; - top: 100%; - left: 0; - z-index: 50; - min-width: 200px; - padding: 0.5rem; - box-sizing: border-box; - overflow-x: auto; - - /* DaisyUI styling */ - background-color: var(--color-base-100); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - box-shadow: 0 4px 6px -1px color-mix(in oklab, var(--color-neutral) 20%, #0000), - 0 2px 4px -2px color-mix(in oklab, var(--color-neutral) 20%, #0000); -} - -.mf-dropdown.is-visible { - display: block; - opacity: 1; -} - -/* Dropdown vertical positioning */ -.mf-dropdown-below { - top: 100%; - bottom: auto; -} - -.mf-dropdown-above { - bottom: 100%; - top: auto; -} - -/* Dropdown horizontal alignment */ -.mf-dropdown-left { - left: 0; - right: auto; - transform: none; -} - -.mf-dropdown-right { - right: 0; - left: auto; - transform: none; -} - -.mf-dropdown-center { - left: 50%; - right: auto; - transform: translateX(-50%); -} - -/* *********************************************** */ -/* ************** TreeView Component ************* */ -/* *********************************************** */ - -/* TreeView Container */ -.mf-treeview { - width: 100%; - user-select: none; -} - -/* TreeNode Container */ -.mf-treenode-container { - width: 100%; -} - -/* TreeNode Element */ -.mf-treenode { - display: flex; - align-items: center; - gap: 0.25rem; - padding: 2px 0.5rem; - cursor: pointer; - transition: background-color 0.15s ease; - border-radius: 0.25rem; -} - -/* Input for Editing */ -.mf-treenode-input { - flex: 1; - padding: 2px 0.25rem; - border: 1px solid var(--color-primary); - border-radius: 0.25rem; - background-color: var(--color-base-100); - outline: none; -} - - -.mf-treenode:hover { - background-color: var(--color-base-200); -} - -.mf-treenode.selected { - background-color: var(--color-primary); - color: var(--color-primary-content); -} - -/* Toggle Icon */ -.mf-treenode-toggle { - flex-shrink: 0; - width: 20px; - text-align: center; - font-size: 0.75rem; -} - -/* Node Label */ -.mf-treenode-label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - - -.mf-treenode-input:focus { - box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent); -} - -/* Action Buttons - Hidden by default, shown on hover */ -.mf-treenode-actions { - display: none; - gap: 0.1rem; - white-space: nowrap; - margin-left: 0.5rem; -} - -.mf-treenode:hover .mf-treenode-actions { - display: flex; -} - -/* *********************************************** */ -/* ********** Generic Resizer Classes ************ */ -/* *********************************************** */ - -/* Generic resizer - used by both Layout and Panel */ -.mf-resizer { - position: absolute; - width: 4px; - cursor: col-resize; - background-color: transparent; - transition: background-color 0.2s ease; - z-index: 100; - top: 0; - bottom: 0; -} - -.mf-resizer:hover { - background-color: rgba(59, 130, 246, 0.3); -} - -/* Active state during resize */ -.mf-resizing .mf-resizer { - background-color: rgba(59, 130, 246, 0.5); -} - -/* Prevent text selection during resize */ -.mf-resizing { - user-select: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; -} - -/* Cursor override for entire body during resize */ -.mf-resizing * { - cursor: col-resize !important; -} - -/* Visual indicator for resizer on hover - subtle border */ -.mf-resizer::before { - content: ''; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 2px; - height: 40px; - background-color: rgba(156, 163, 175, 0.4); - border-radius: 2px; - opacity: 0; - transition: opacity 0.2s ease; -} - -.mf-resizer:hover::before, -.mf-resizing .mf-resizer::before { - opacity: 1; -} - -/* Resizer positioning */ -/* Left resizer is on the right side of the left panel */ -.mf-resizer-left { - right: 0; -} - -/* Right resizer is on the left side of the right panel */ -.mf-resizer-right { - left: 0; -} - -/* Position indicator for resizer */ -.mf-resizer-left::before { - right: 1px; -} - -.mf-resizer-right::before { - left: 1px; -} - -/* Disable transitions during resize for smooth dragging */ -.mf-item-resizing { - transition: none !important; -} - -/* *********************************************** */ -/* *************** Panel Component *************** */ -/* *********************************************** */ - -.mf-panel { - display: flex; - width: 100%; - height: 100%; - overflow: hidden; - position: relative; -} - -/* Common properties for side panels */ -.mf-panel-left, -.mf-panel-right { - position: relative; - flex-shrink: 0; - width: 250px; - min-width: 150px; - max-width: 500px; - height: 100%; - overflow: auto; - transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease; - padding-top: 25px; -} - -/* Left panel specific */ -.mf-panel-left { - border-right: 1px solid var(--color-border-primary); -} - -/* Right panel specific */ -.mf-panel-right { - border-left: 1px solid var(--color-border-primary); - padding-left: 0.5rem; -} - -.mf-panel-main { - flex: 1; - height: 100%; - overflow: hidden; - min-width: 0; /* Important to allow the shrinking of flexbox */ -} - -/* Hidden state - common for both panels */ -.mf-panel-left.mf-hidden, -.mf-panel-right.mf-hidden { - width: 0; - min-width: 0; - max-width: 0; - overflow: hidden; - border: none; - padding: 0; -} - -/* No transition during manual resize - common for both panels */ -.mf-panel-left.no-transition, -.mf-panel-right.no-transition { - transition: none; -} - -/* Common properties for panel toggle icons */ -.mf-panel-hide-icon, -.mf-panel-show-icon { - position: absolute; - top: 0; - right: 0; - cursor: pointer; - z-index: 10; - border-radius: 0.25rem; -} - -.mf-panel-hide-icon:hover, -.mf-panel-show-icon:hover { - background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.05)); -} - -/* Show icon positioning */ -.mf-panel-show-icon-left { - left: 0.5rem; -} - -.mf-panel-show-icon-right { - right: 0.5rem; -} - -/* Panel with title - grid layout for header + scrollable content */ -.mf-panel-body { - display: grid; - grid-template-rows: auto 1fr; - height: 100%; - overflow: hidden; -} - -.mf-panel-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.25rem 0.5rem; - background-color: var(--color-base-200); - border-bottom: 1px solid var(--color-border); -} - -/* Override absolute positioning for hide icon when inside header */ -.mf-panel-header .mf-panel-hide-icon { - position: static; -} - -.mf-panel-content { - overflow-y: auto; -} - -/* Remove padding-top when using title layout */ -.mf-panel-left.mf-panel-with-title, -.mf-panel-right.mf-panel-with-title { - padding-top: 0; -} - -/* *********************************************** */ -/* ************* Properties Component ************ */ -/* *********************************************** */ - -/*!* Properties container *!*/ -.mf-properties { - display: flex; - flex-direction: column; - gap: 1rem; -} - -/*!* Group card - using DaisyUI card styling *!*/ -.mf-properties-group-card { - background-color: var(--color-base-100); - border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent); - border-radius: var(--radius-md); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - overflow: auto; -} - -.mf-properties-group-container { - display: inline-block; /* important */ - min-width: max-content; /* important */ - width: 100%; -} - - -/*!* Group header - gradient using DaisyUI primary color *!*/ -.mf-properties-group-header { - background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%); - color: var(--color-primary-content); - padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75); - font-weight: 700; - font-size: var(--properties-font-size); -} - -/*!* Group content area *!*/ -.mf-properties-group-content { - display: flex; - flex-direction: column; - min-width: max-content; -} - -/*!* Property row *!*/ -.mf-properties-row { - display: grid; - grid-template-columns: 6rem 1fr; - - align-items: center; - padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75); - - border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent); - transition: background-color 0.15s ease; - - gap: calc(var(--properties-font-size) * 0.75); -} - -.mf-properties-row:last-child { - border-bottom: none; -} - -.mf-properties-row:hover { - background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent); -} - -/*!* Property key - normal font *!*/ -.mf-properties-key { - align-items: start; - font-weight: 600; - color: color-mix(in oklab, var(--color-base-content) 70%, transparent); - flex: 0 0 40%; - font-size: var(--properties-font-size); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/*!* Property value - monospace font *!*/ -.mf-properties-value { - font-family: var(--default-mono-font-family); - color: var(--color-base-content); - flex: 1; - text-align: left; - font-size: var(--properties-font-size); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - - -/* ********************************************* */ -/* ************* Datagrid Component ************ */ -/* ********************************************* */ - -/* Header and Footer */ -.dt2-header, -.dt2-footer { - background-color: var(--color-base-200); - border-radius: 10px 10px 0 0; - min-width: max-content; /* Content width propagates to scrollable parent */ -} - -/* Body */ -.dt2-body { - overflow: hidden; - min-width: max-content; /* Content width propagates to scrollable parent */ -} - -/* Row */ -.dt2-row { - display: flex; - width: 100%; - height: 20px; -} - -/* Cell */ -.dt2-cell { - display: flex; - align-items: center; - justify-content: flex-start; - padding: 2px 8px; - position: relative; - white-space: nowrap; - text-overflow: ellipsis; - min-width: 100px; - flex-grow: 0; - flex-shrink: 1; - box-sizing: border-box; - border-bottom: 1px solid var(--color-border); - user-select: none; -} - -/* Cell content types */ -.dt2-cell-content-text { - text-align: inherit; - width: 100%; - padding-right: 10px; -} - -.dt2-cell-content-checkbox { - display: flex; - width: 100%; - justify-content: center; - align-items: center; -} - -.dt2-cell-content-number { - text-align: right; - width: 100%; - padding-right: 10px; -} - -/* Footer cell */ -.dt2-footer-cell { - cursor: pointer; -} - -/* Resize handle */ -.dt2-resize-handle { - position: absolute; - right: 0; - top: 0; - width: 8px; - height: 100%; - cursor: col-resize; -} - -.dt2-resize-handle::after { - content: ''; - position: absolute; - z-index: var(--datagrid-resize-zindex); - display: block; - width: 3px; - height: 60%; - top: calc(50% - 60% * 0.5); - background-color: var(--color-resize); -} - -/* Hidden column */ -.dt2-col-hidden { - width: 5px; - border-bottom: 1px solid var(--color-border); -} - -/* Highlight */ -.dt2-highlight-1 { - color: var(--color-accent); -} - - -.dt2-selected-focus { - outline: 2px solid var(--color-primary); - outline-offset: -3px; /* Ensure the outline is snug to the cell */ -} - -.dt2-cell:hover, -.dt2-selected-cell { - background-color: var(--color-selection); -} - -.dt2-selected-row { - background-color: var(--color-selection); -} - -.dt2-selected-column { - background-color: var(--color-selection); -} - -.dt2-hover-row { - background-color: var(--color-selection); -} - -.dt2-hover-column { - background-color: var(--color-selection); -} - - -/* *********************************************** */ -/* ******** DataGrid Fixed Header/Footer ******** */ -/* *********************************************** */ - -/* - * DataGrid with CSS Grid + Custom Scrollbars - * - Wrapper takes 100% of parent height - * - Table uses Grid: header (auto) + body (1fr) + footer (auto) - * - Native scrollbars hidden, custom scrollbars overlaid - * - Vertical scrollbar: right side of entire table - * - Horizontal scrollbar: bottom, under footer - */ - -/* Main wrapper - takes full parent height, contains table + scrollbars */ -.dt2-table-wrapper { - height: 100%; - overflow: hidden; - position: relative; -} - -/* Table with Grid layout - horizontal scroll enabled, scrollbars hidden */ -.dt2-table { - --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000); - --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000); - height: 100%; - display: grid; - grid-template-rows: auto 1fr auto; /* header, body, footer */ - overflow-x: auto; /* Enable horizontal scroll */ - overflow-y: hidden; /* No vertical scroll on table */ - scrollbar-width: none; /* Firefox: hide scrollbar */ - -ms-overflow-style: none; /* IE/Edge: hide scrollbar */ - border: 1px solid var(--color-border); - border-radius: 10px; -} - -/* Chrome/Safari: hide scrollbar */ -.dt2-table::-webkit-scrollbar { - display: none; -} - -/* Header - no scroll, takes natural height */ -.dt2-header-container { - overflow: hidden; - min-width: max-content; /* Force table to be as wide as content */ -} - -/* Body - scrollable vertically via JS, scrollbars hidden */ -.dt2-body-container { - overflow: hidden; /* Scrollbars hidden, scroll via JS */ - min-height: 0; /* Important for Grid to allow shrinking */ - min-width: max-content; /* Force table to be as wide as content */ -} - -/* Footer - no scroll, takes natural height */ -.dt2-footer-container { - overflow: hidden; - min-width: max-content; /* Force table to be as wide as content */ -} - -/* Custom scrollbars container - overlaid on table */ -.dt2-scrollbars { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - pointer-events: none; /* Let clicks pass through */ - z-index: 10; -} - -/* Scrollbar wrappers - clickable/draggable */ -.dt2-scrollbars-vertical-wrapper, -.dt2-scrollbars-horizontal-wrapper { - position: absolute; - background-color: var(--color-base-200); - opacity: 1; - transition: opacity 0.2s ease-in-out; - pointer-events: auto; /* Enable interaction */ -} - -/* Vertical scrollbar wrapper - right side, full table height */ -.dt2-scrollbars-vertical-wrapper { - right: 0; - top: 0; - bottom: 0; - width: 8px; -} - -/* Horizontal scrollbar wrapper - bottom, full width minus vertical scrollbar */ -.dt2-scrollbars-horizontal-wrapper { - left: 0; - right: 8px; /* Leave space for vertical scrollbar */ - bottom: 0; - height: 8px; -} - -/* Scrollbar thumbs */ -.dt2-scrollbars-vertical, -.dt2-scrollbars-horizontal { - background-color: color-mix(in oklab, var(--color-base-content) 20%, #0000); - border-radius: 3px; - position: absolute; - cursor: pointer; - transition: background-color 0.2s ease; -} - -/* Vertical scrollbar thumb */ -.dt2-scrollbars-vertical { - left: 0; - right: 0; - top: 0; - width: 100%; -} - -/* Horizontal scrollbar thumb */ -.dt2-scrollbars-horizontal { - top: 0; - bottom: 0; - left: 0; - height: 100%; -} - -/* Hover and dragging states */ -.dt2-scrollbars-vertical:hover, -.dt2-scrollbars-horizontal:hover, -.dt2-scrollbars-vertical.dt2-dragging, -.dt2-scrollbars-horizontal.dt2-dragging { - background-color: color-mix(in oklab, var(--color-base-content) 30%, #0000); -} - -/* *********************************************** */ -/* ******** DataGrid Column Drag & Drop ********** */ -/* *********************************************** */ - -/* Column being dragged - visual feedback */ -.dt2-dragging { - opacity: 0.5; -} - -/* Column animation during swap */ -.dt2-moving { - transition: transform 300ms ease; -} - -/* *********************************************** */ -/* ******** DataGrid Column Manager ********** */ -/* *********************************************** */ -.dt2-column-manager-label { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - border-radius: 0.375rem; - transition: background-color 0.15s ease; -} - -.dt2-column-manager-label:hover { - background-color: var(--color-base-300); -} - -/* *********************************************** */ -/* ********** CodeMirror DaisyUI Theme *********** */ -/* *********************************************** */ - -/* Theme selector - uses DaisyUI variables for automatic theme switching */ -.cm-s-daisy.CodeMirror { - background-color: var(--color-base-100); - color: var(--color-base-content); - font-family: var(--font-mono, ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, 'Courier New', monospace); - font-size: 14px; - line-height: 1.5; - height: auto; - border-radius: 0.5rem; - border: 1px solid var(--color-border); -} - -/* Cursor */ -.cm-s-daisy .CodeMirror-cursor { - border-left-color: var(--color-primary); - border-left-width: 2px; -} - -/* Selection */ -.cm-s-daisy .CodeMirror-selected { - background-color: var(--color-selection) !important; -} - -.cm-s-daisy.CodeMirror-focused .CodeMirror-selected { - background-color: color-mix(in oklab, var(--color-primary) 30%, transparent) !important; -} - -/* Line numbers and gutters */ -.cm-s-daisy .CodeMirror-gutters { - background-color: var(--color-base-200); - border-right: 1px solid var(--color-border); -} - -.cm-s-daisy .CodeMirror-linenumber { - color: color-mix(in oklab, var(--color-base-content) 50%, transparent); - padding: 0 8px; -} - -/* Active line */ -.cm-s-daisy .CodeMirror-activeline-background { - background-color: color-mix(in oklab, var(--color-base-content) 5%, transparent); -} - -.cm-s-daisy .CodeMirror-activeline-gutter { - background-color: var(--color-base-300); -} - -/* Matching brackets */ -.cm-s-daisy .CodeMirror-matchingbracket { - color: var(--color-success) !important; - font-weight: bold; -} - -.cm-s-daisy .CodeMirror-nonmatchingbracket { - color: var(--color-error) !important; -} - -/* *********************************************** */ -/* ******** CodeMirror Syntax Highlighting ******* */ -/* *********************************************** */ - -/* Keywords (column, row, cell, if, not, and, or, in, between, case) */ -.cm-s-daisy .cm-keyword { - color: var(--color-primary); - font-weight: bold; -} - -/* Built-in functions (style, format) */ -.cm-s-daisy .cm-builtin { - color: var(--color-secondary); - font-weight: 600; -} - -/* Operators (==, <, >, contains, startswith, etc.) */ -.cm-s-daisy .cm-operator { - color: var(--color-warning); -} - -/* Strings ("error", "EUR", etc.) */ -.cm-s-daisy .cm-string { - color: var(--color-success); -} - -/* Numbers (0, 100, 3.14) */ -.cm-s-daisy .cm-number { - color: var(--color-accent); -} - -/* Booleans (True, False, true, false) */ -.cm-s-daisy .cm-atom { - color: var(--color-info); -} - -/* Special variables (value, col, row, cell) */ -.cm-s-daisy .cm-variable-2 { - color: var(--color-accent); - font-style: italic; -} - -/* Cell IDs (tcell_*) */ -.cm-s-daisy .cm-variable-3 { - color: color-mix(in oklab, var(--color-base-content) 70%, transparent); -} - -/* Comments (#...) */ -.cm-s-daisy .cm-comment { - color: color-mix(in oklab, var(--color-base-content) 50%, transparent); - font-style: italic; -} - -/* Property names (bold=, color=, etc.) */ -.cm-s-daisy .cm-property { - color: var(--color-base-content); - opacity: 0.8; -} - -/* Errors/invalid syntax */ -.cm-s-daisy .cm-error { - color: var(--color-error); - text-decoration: underline wavy; -} - -/* *********************************************** */ -/* ********** CodeMirror Autocomplete ************ */ -/* *********************************************** */ - -/* Autocomplete dropdown container */ -.CodeMirror-hints { - background-color: var(--color-base-100); - border: 1px solid var(--color-border); - border-radius: 0.5rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 13px; - max-height: 20em; - overflow-y: auto; -} - -/* Individual hint items */ -.CodeMirror-hint { - color: var(--color-base-content); - padding: 4px 8px; - cursor: pointer; -} - -/* Hovered/selected hint */ -.CodeMirror-hint-active { - background-color: var(--color-primary); - color: var(--color-primary-content); -} - -/* *********************************************** */ -/* ********** CodeMirror Lint Markers ************ */ -/* *********************************************** */ - -/* Lint gutter marker */ -.CodeMirror-lint-marker { - cursor: pointer; -} - -.CodeMirror-lint-marker-error { - color: var(--color-error); -} - -.CodeMirror-lint-marker-warning { - color: var(--color-warning); -} - -/* Lint tooltip */ -.CodeMirror-lint-tooltip { - background-color: var(--color-base-100); - border: 1px solid var(--color-border); - border-radius: 0.375rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - color: var(--color-base-content); - font-family: var(--font-sans, ui-sans-serif, system-ui); - font-size: 13px; - padding: 8px 12px; - max-width: 400px; -} - -.CodeMirror-lint-message-error { - color: var(--color-error); -} - -.CodeMirror-lint-message-warning { - color: var(--color-warning); -} - -/* *********************************************** */ -/* ********** DslEditor Wrapper Styles *********** */ -/* *********************************************** */ - -/* Wrapper container for DslEditor */ -.mf-dsl-editor-wrapper { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -/* Editor container */ -.mf-dsl-editor { - border-radius: 0.5rem; - overflow: hidden; -} +/*:root {*/ +/* --color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);*/ +/* --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);*/ +/* --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);*/ +/* --color-selection: color-mix(in oklab, var(--color-primary) 20%, #0000);*/ + +/* --datagrid-resize-zindex: 1;*/ +/* --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';*/ +/* --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;*/ +/* --spacing: 0.25rem;*/ +/* --text-xs: 0.6875rem;*/ +/* --text-sm: 0.875rem;*/ +/* --text-sm--line-height: calc(1.25 / 0.875);*/ +/* --text-xl: 1.25rem;*/ +/* --text-xl--line-height: calc(1.75 / 1.25);*/ +/* --font-weight-medium: 500;*/ +/* --radius-md: 0.375rem;*/ +/* --default-font-family: var(--font-sans);*/ +/* --default-mono-font-family: var(--font-mono);*/ +/* --properties-font-size: var(--text-xs);*/ +/* --mf-tooltip-zindex: 10;*/ +/*}*/ + + +/*.mf-icon-16 {*/ +/* width: 16px;*/ +/* min-width: 16px;*/ +/* height: 16px;*/ +/*}*/ + +/*.mf-icon-20 {*/ +/* width: 20px;*/ +/* min-width: 20px;*/ +/* height: 20px;*/ +/*}*/ + +/*.mf-icon-24 {*/ +/* width: 24px;*/ +/* min-width: 24px;*/ +/* height: 24px;*/ + +/*}*/ + +/*.mf-icon-28 {*/ +/* width: 28px;*/ +/* min-width: 28px;*/ +/* height: 28px;*/ +/*}*/ + +/*.mf-icon-32 {*/ +/* width: 32px;*/ +/* min-width: 32px;*/ +/* height: 32px;*/ +/*}*/ + +/*!**/ +/* * MF Layout Component - CSS Grid Layout*/ +/* * Provides fixed header/footer, collapsible drawers, and scrollable main content*/ +/* * Compatible with DaisyUI 5*/ +/* *!*/ + +/*.mf-button {*/ +/* border-radius: 0.375rem;*/ +/* transition: background-color 0.15s ease;*/ +/*}*/ + +/*.mf-button:hover {*/ +/* background-color: var(--color-base-300);*/ +/*}*/ + + +/*.mf-tooltip-container {*/ +/* background: var(--color-base-200);*/ +/* padding: 5px 10px;*/ +/* border-radius: 4px;*/ +/* pointer-events: none; !* Prevent interfering with mouse events *!*/ +/* font-size: 12px;*/ +/* white-space: nowrap;*/ +/* opacity: 0; !* Default to invisible *!*/ +/* visibility: hidden; !* Prevent interaction when invisible *!*/ +/* transition: opacity 0.3s ease, visibility 0s linear 0.3s; !* Delay visibility removal *!*/ +/* position: fixed; !* Keep it above other content and adjust position *!*/ +/* z-index: var(--mf-tooltip-zindex); !* Ensure it's on top *!*/ +/*}*/ + +/*.mf-tooltip-container[data-visible="true"] {*/ +/* opacity: 1;*/ +/* visibility: visible; !* Show tooltip *!*/ +/* transition: opacity 0.3s ease; !* No delay when becoming visible *!*/ +/*}*/ + +/*!* Main layout container using CSS Grid *!*/ +/*.mf-layout {*/ +/* display: grid;*/ +/* grid-template-areas:*/ +/* "header header header"*/ +/* "left-drawer main right-drawer"*/ +/* "footer footer footer";*/ +/* grid-template-rows: 32px 1fr 32px;*/ +/* grid-template-columns: auto 1fr auto;*/ +/* height: 100vh;*/ +/* width: 100vw;*/ +/* overflow: hidden;*/ +/*}*/ + +/*!* Header - fixed at top *!*/ +/*.mf-layout-header {*/ +/* grid-area: header;*/ +/* display: flex;*/ +/* align-items: center;*/ +/* justify-content: space-between; !* put one item on each side *!*/ +/* gap: 1rem;*/ +/* padding: 0 1rem;*/ +/* background-color: var(--color-base-300);*/ +/* border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/ +/* z-index: 30;*/ +/*}*/ + +/*!* Footer - fixed at bottom *!*/ +/*.mf-layout-footer {*/ +/* grid-area: footer;*/ +/* display: flex;*/ +/* align-items: center;*/ +/* padding: 0 1rem;*/ +/* background-color: var(--color-neutral);*/ +/* color: var(--color-neutral-content);*/ +/* border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/ +/* z-index: 30;*/ +/*}*/ + +/*!* Main content area - scrollable *!*/ +/*.mf-layout-main {*/ +/* grid-area: main;*/ +/* overflow-y: auto;*/ +/* overflow-x: auto;*/ +/* padding: 0.5rem;*/ +/* background-color: var(--color-base-100);*/ +/*}*/ + +/*!* Drawer base styles *!*/ +/*.mf-layout-drawer {*/ +/* overflow-y: auto;*/ +/* overflow-x: hidden;*/ +/* background-color: var(--color-base-100);*/ +/* transition: width 0.3s ease-in-out, margin 0.3s ease-in-out;*/ +/* width: 250px;*/ +/* padding: 1rem;*/ +/*}*/ + +/*!* Left drawer *!*/ +/*.mf-layout-left-drawer {*/ +/* grid-area: left-drawer;*/ +/* border-right: 1px solid var(--color-border-primary);*/ +/*}*/ + +/*!* Right drawer *!*/ +/*.mf-layout-right-drawer {*/ +/* grid-area: right-drawer;*/ +/* !*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*!*/ +/* border-left: 1px solid var(--color-border-primary);*/ +/*}*/ + +/*!* Collapsed drawer states *!*/ +/*.mf-layout-drawer.collapsed {*/ +/* width: 0;*/ +/* padding: 0;*/ +/* border: none;*/ +/* overflow: hidden;*/ +/*}*/ + +/*!* Toggle buttons positioning *!*/ +/*.mf-layout-toggle-left {*/ +/* margin-right: auto;*/ +/*}*/ + +/*.mf-layout-toggle-right {*/ +/* margin-left: auto;*/ +/*}*/ + +/*!* Smooth scrollbar styling for webkit browsers *!*/ +/*.mf-layout-main::-webkit-scrollbar,*/ +/*.mf-layout-drawer::-webkit-scrollbar {*/ +/* width: 8px;*/ +/* height: 8px;*/ +/*}*/ + +/*.mf-layout-main::-webkit-scrollbar-track,*/ +/*.mf-layout-drawer::-webkit-scrollbar-track {*/ +/* background: var(--color-base-200);*/ +/*}*/ + +/*.mf-layout-main::-webkit-scrollbar-thumb,*/ +/*.mf-layout-drawer::-webkit-scrollbar-thumb {*/ +/* background: color-mix(in oklab, var(--color-base-content) 20%, #0000);*/ +/* border-radius: 4px;*/ +/*}*/ + +/*.mf-layout-main::-webkit-scrollbar-thumb:hover,*/ +/*.mf-layout-drawer::-webkit-scrollbar-thumb:hover {*/ +/* background: color-mix(in oklab, var(--color-base-content) 30%, #0000);*/ +/*}*/ + +/*!* Responsive adjustments for smaller screens *!*/ +/*@media (max-width: 768px) {*/ +/* .mf-layout-drawer {*/ +/* width: 200px;*/ +/* }*/ + +/* .mf-layout-header,*/ +/* .mf-layout-footer {*/ +/* padding: 0 0.5rem;*/ +/* }*/ + +/* .mf-layout-main {*/ +/* padding: 0.5rem;*/ +/* }*/ +/*}*/ + +/*!* Handle layouts with no drawers *!*/ +/*.mf-layout[data-left-drawer="false"] {*/ +/* grid-template-areas:*/ +/* "header header"*/ +/* "main right-drawer"*/ +/* "footer footer";*/ +/* grid-template-columns: 1fr auto;*/ +/*}*/ + +/*.mf-layout[data-right-drawer="false"] {*/ +/* grid-template-areas:*/ +/* "header header"*/ +/* "left-drawer main"*/ +/* "footer footer";*/ +/* grid-template-columns: auto 1fr;*/ +/*}*/ + +/*.mf-layout[data-left-drawer="false"][data-right-drawer="false"] {*/ +/* grid-template-areas:*/ +/* "header"*/ +/* "main"*/ +/* "footer";*/ +/* grid-template-columns: 1fr;*/ +/*}*/ + + +/*!***/ +/* * Layout Drawer Resizer Styles*/ +/* **/ +/* * Styles for the resizable drawer borders with visual feedback*/ +/* *!*/ + +/*!* Ensure drawer has relative positioning and no overflow *!*/ +/*.mf-layout-drawer {*/ +/* position: relative;*/ +/* overflow: hidden;*/ +/*}*/ + +/*!* Content wrapper handles scrolling *!*/ +/*.mf-layout-drawer-content {*/ +/* position: absolute;*/ +/* top: 0;*/ +/* left: 0;*/ +/* right: 0;*/ +/* bottom: 0;*/ +/* overflow-y: auto;*/ +/* overflow-x: hidden;*/ +/* padding: 1rem;*/ +/*}*/ + +/*!* Base resizer styles *!*/ +/*.mf-layout-resizer {*/ +/* position: absolute;*/ +/* top: 0;*/ +/* bottom: 0;*/ +/* width: 4px;*/ +/* background-color: transparent;*/ +/* transition: background-color 0.2s ease;*/ +/* z-index: 100;*/ +/*}*/ + +/*!* Resizer on the right side (for left drawer) *!*/ +/*.mf-layout-resizer-right {*/ +/* right: 0;*/ +/* cursor: col-resize;*/ +/*}*/ + +/*!* Resizer on the left side (for right drawer) *!*/ +/*.mf-layout-resizer-left {*/ +/* left: 0;*/ +/* cursor: col-resize;*/ +/*}*/ + +/*!* Hover state *!*/ +/*.mf-layout-resizer:hover {*/ +/* background-color: rgba(59, 130, 246, 0.3); !* Blue-500 with opacity *!*/ +/*}*/ + +/*!* Active state during resize *!*/ +/*.mf-layout-drawer-resizing .mf-layout-resizer {*/ +/* background-color: rgba(59, 130, 246, 0.5);*/ +/*}*/ + +/*!* Disable transitions during resize for smooth dragging *!*/ +/*.mf-layout-drawer-resizing {*/ +/* transition: none !important;*/ +/*}*/ + +/*!* Prevent text selection during resize *!*/ +/*.mf-layout-resizing {*/ +/* user-select: none;*/ +/* -webkit-user-select: none;*/ +/* -moz-user-select: none;*/ +/* -ms-user-select: none;*/ +/*}*/ + +/*!* Cursor override for entire body during resize *!*/ +/*.mf-layout-resizing * {*/ +/* cursor: col-resize !important;*/ +/*}*/ + +/*!* Visual indicator for resizer on hover - subtle border *!*/ +/*.mf-layout-resizer::before {*/ +/* content: '';*/ +/* position: absolute;*/ +/* top: 50%;*/ +/* transform: translateY(-50%);*/ +/* width: 2px;*/ +/* height: 40px;*/ +/* background-color: rgba(156, 163, 175, 0.4); !* Gray-400 with opacity *!*/ +/* border-radius: 2px;*/ +/* opacity: 0;*/ +/* transition: opacity 0.2s ease;*/ +/*}*/ + +/*.mf-layout-resizer-right::before {*/ +/* right: 1px;*/ +/*}*/ + +/*.mf-layout-resizer-left::before {*/ +/* left: 1px;*/ +/*}*/ + +/*.mf-layout-resizer:hover::before,*/ +/*.mf-layout-drawer-resizing .mf-layout-resizer::before {*/ +/* opacity: 1;*/ +/*}*/ + + +/*.mf-layout-group {*/ +/* font-weight: bold;*/ +/* !*font-size: var(--text-sm);*!*/ +/* overflow: hidden;*/ +/* text-overflow: ellipsis;*/ +/* white-space: nowrap;*/ +/* margin-bottom: 0.2rem;*/ +/*}*/ + +/*!* *********************************************** *!*/ +/*!* *********** Tabs Manager Component ************ *!*/ +/*!* *********************************************** *!*/ + +/*!* Tabs Manager Container *!*/ +/*.mf-tabs-manager {*/ +/* display: flex;*/ +/* flex-direction: column;*/ +/* height: 100%;*/ +/* width: 100%;*/ +/* background-color: var(--color-base-200);*/ +/* color: color-mix(in oklab, var(--color-base-content) 50%, transparent);*/ +/* border-radius: .5rem;*/ +/*}*/ + +/*!* Tabs Header using DaisyUI tabs component *!*/ +/*.mf-tabs-header {*/ +/* display: flex;*/ +/* gap: 0;*/ +/* flex-shrink: 1;*/ +/* min-height: 25px;*/ + +/* overflow-x: hidden;*/ +/* overflow-y: hidden;*/ +/* white-space: nowrap;*/ +/*}*/ + +/*.mf-tabs-header-wrapper {*/ +/* display: flex;*/ +/* justify-content: space-between;*/ +/* align-items: center;*/ + +/* width: 100%;*/ +/* !*overflow: hidden; important *!*/ +/*}*/ + +/*!* Individual Tab Button using DaisyUI tab classes *!*/ +/*.mf-tab-button {*/ +/* display: flex;*/ +/* align-items: center;*/ +/* gap: 0.5rem;*/ +/* padding: 0 0.5rem 0 1rem;*/ +/* cursor: pointer;*/ +/* transition: all 0.2s ease;*/ +/*}*/ + +/*.mf-tab-button:hover {*/ +/* color: var(--color-base-content); !* Change text color on hover *!*/ +/*}*/ + +/*.mf-tab-button.mf-tab-active {*/ +/* --depth: 1;*/ +/* background-color: var(--color-base-100);*/ +/* color: var(--color-base-content);*/ +/* border-radius: .25rem;*/ +/* border-bottom: 4px solid var(--color-primary);*/ +/* box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);*/ +/*}*/ + +/*!* Tab Label *!*/ +/*.mf-tab-label {*/ +/* user-select: none;*/ +/* white-space: nowrap;*/ +/* max-width: 150px;*/ +/*}*/ + +/*!* Tab Close Button *!*/ +/*.mf-tab-close-btn {*/ +/* display: flex;*/ +/* align-items: center;*/ +/* justify-content: center;*/ +/* width: 1rem;*/ +/* height: 1rem;*/ +/* border-radius: 0.25rem;*/ +/* font-size: 1.25rem;*/ +/* line-height: 1;*/ +/* @apply text-base-content/50;*/ +/* transition: all 0.2s ease;*/ +/*}*/ + +/*.mf-tab-close-btn:hover {*/ +/* @apply bg-base-300 text-error;*/ +/*}*/ + +/*!* Tab Content Area *!*/ +/*.mf-tab-content {*/ +/* flex: 1;*/ +/* overflow: auto;*/ +/* height: 100%;*/ +/*}*/ + +/*.mf-tab-content-wrapper {*/ +/* flex: 1;*/ +/* overflow: auto;*/ +/* background-color: var(--color-base-100);*/ +/* padding: 0.5rem;*/ +/* border-top: 1px solid var(--color-border-primary);*/ +/*}*/ + +/*!* Empty Content State *!*/ +/*.mf-empty-content {*/ +/* align-items: center;*/ +/* justify-content: center;*/ +/* height: 100%;*/ +/* @apply text-base-content/50;*/ +/* font-style: italic;*/ +/*}*/ + +/*.mf-vis {*/ +/* width: 100%;*/ +/* height: 100%;*/ +/*}*/ + +/*.mf-search-results {*/ +/* margin-top: 0.5rem;*/ +/* !*max-height: 400px;*!*/ +/* overflow: auto;*/ +/*}*/ + +/*.mf-dropdown-wrapper {*/ +/* position: relative; !* CRUCIAL for the anchor *!*/ +/*}*/ + + +/*.mf-dropdown {*/ +/* display: none;*/ +/* position: absolute;*/ +/* top: 100%;*/ +/* left: 0;*/ +/* z-index: 50;*/ +/* min-width: 200px;*/ +/* padding: 0.5rem;*/ +/* box-sizing: border-box;*/ +/* overflow-x: auto;*/ + +/* !* DaisyUI styling *!*/ +/* background-color: var(--color-base-100);*/ +/* border: 1px solid var(--color-border);*/ +/* border-radius: var(--radius-md);*/ +/* box-shadow: 0 4px 6px -1px color-mix(in oklab, var(--color-neutral) 20%, #0000),*/ +/* 0 2px 4px -2px color-mix(in oklab, var(--color-neutral) 20%, #0000);*/ +/*}*/ + +/*.mf-dropdown.is-visible {*/ +/* display: block;*/ +/* opacity: 1;*/ +/*}*/ + +/*!* Dropdown vertical positioning *!*/ +/*.mf-dropdown-below {*/ +/* top: 100%;*/ +/* bottom: auto;*/ +/*}*/ + +/*.mf-dropdown-above {*/ +/* bottom: 100%;*/ +/* top: auto;*/ +/*}*/ + +/*!* Dropdown horizontal alignment *!*/ +/*.mf-dropdown-left {*/ +/* left: 0;*/ +/* right: auto;*/ +/* transform: none;*/ +/*}*/ + +/*.mf-dropdown-right {*/ +/* right: 0;*/ +/* left: auto;*/ +/* transform: none;*/ +/*}*/ + +/*.mf-dropdown-center {*/ +/* left: 50%;*/ +/* right: auto;*/ +/* transform: translateX(-50%);*/ +/*}*/ + +/*!* *********************************************** *!*/ +/*!* ************** TreeView Component ************* *!*/ +/*!* *********************************************** *!*/ + +/*!* TreeView Container *!*/ +/*.mf-treeview {*/ +/* width: 100%;*/ +/* user-select: none;*/ +/*}*/ + +/*!* TreeNode Container *!*/ +/*.mf-treenode-container {*/ +/* width: 100%;*/ +/*}*/ + +/*!* TreeNode Element *!*/ +/*.mf-treenode {*/ +/* display: flex;*/ +/* align-items: center;*/ +/* gap: 0.25rem;*/ +/* padding: 2px 0.5rem;*/ +/* cursor: pointer;*/ +/* transition: background-color 0.15s ease;*/ +/* border-radius: 0.25rem;*/ +/*}*/ + +/*!* Input for Editing *!*/ +/*.mf-treenode-input {*/ +/* flex: 1;*/ +/* padding: 2px 0.25rem;*/ +/* border: 1px solid var(--color-primary);*/ +/* border-radius: 0.25rem;*/ +/* background-color: var(--color-base-100);*/ +/* outline: none;*/ +/*}*/ + + +/*.mf-treenode:hover {*/ +/* background-color: var(--color-base-200);*/ +/*}*/ + +/*.mf-treenode.selected {*/ +/* background-color: var(--color-primary);*/ +/* color: var(--color-primary-content);*/ +/*}*/ + +/*!* Toggle Icon *!*/ +/*.mf-treenode-toggle {*/ +/* flex-shrink: 0;*/ +/* width: 20px;*/ +/* text-align: center;*/ +/* font-size: 0.75rem;*/ +/*}*/ + +/*!* Node Label *!*/ +/*.mf-treenode-label {*/ +/* flex: 1;*/ +/* overflow: hidden;*/ +/* text-overflow: ellipsis;*/ +/* white-space: nowrap;*/ +/*}*/ + + +/*.mf-treenode-input:focus {*/ +/* box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent);*/ +/*}*/ + +/*!* Action Buttons - Hidden by default, shown on hover *!*/ +/*.mf-treenode-actions {*/ +/* display: none;*/ +/* gap: 0.1rem;*/ +/* white-space: nowrap;*/ +/* margin-left: 0.5rem;*/ +/*}*/ + +/*.mf-treenode:hover .mf-treenode-actions {*/ +/* display: flex;*/ +/*}*/ + +/*!* *********************************************** *!*/ +/*!* ********** Generic Resizer Classes ************ *!*/ +/*!* *********************************************** *!*/ + +/*!* Generic resizer - used by both Layout and Panel *!*/ +/*.mf-resizer {*/ +/* position: absolute;*/ +/* width: 4px;*/ +/* cursor: col-resize;*/ +/* background-color: transparent;*/ +/* transition: background-color 0.2s ease;*/ +/* z-index: 100;*/ +/* top: 0;*/ +/* bottom: 0;*/ +/*}*/ + +/*.mf-resizer:hover {*/ +/* background-color: rgba(59, 130, 246, 0.3);*/ +/*}*/ + +/*!* Active state during resize *!*/ +/*.mf-resizing .mf-resizer {*/ +/* background-color: rgba(59, 130, 246, 0.5);*/ +/*}*/ + +/*!* Prevent text selection during resize *!*/ +/*.mf-resizing {*/ +/* user-select: none;*/ +/* -webkit-user-select: none;*/ +/* -moz-user-select: none;*/ +/* -ms-user-select: none;*/ +/*}*/ + +/*!* Cursor override for entire body during resize *!*/ +/*.mf-resizing * {*/ +/* cursor: col-resize !important;*/ +/*}*/ + +/*!* Visual indicator for resizer on hover - subtle border *!*/ +/*.mf-resizer::before {*/ +/* content: '';*/ +/* position: absolute;*/ +/* top: 50%;*/ +/* transform: translateY(-50%);*/ +/* width: 2px;*/ +/* height: 40px;*/ +/* background-color: rgba(156, 163, 175, 0.4);*/ +/* border-radius: 2px;*/ +/* opacity: 0;*/ +/* transition: opacity 0.2s ease;*/ +/*}*/ + +/*.mf-resizer:hover::before,*/ +/*.mf-resizing .mf-resizer::before {*/ +/* opacity: 1;*/ +/*}*/ + +/*!* Resizer positioning *!*/ +/*!* Left resizer is on the right side of the left panel *!*/ +/*.mf-resizer-left {*/ +/* right: 0;*/ +/*}*/ + +/*!* Right resizer is on the left side of the right panel *!*/ +/*.mf-resizer-right {*/ +/* left: 0;*/ +/*}*/ + +/*!* Position indicator for resizer *!*/ +/*.mf-resizer-left::before {*/ +/* right: 1px;*/ +/*}*/ + +/*.mf-resizer-right::before {*/ +/* left: 1px;*/ +/*}*/ + +/*!* Disable transitions during resize for smooth dragging *!*/ +/*.mf-item-resizing {*/ +/* transition: none !important;*/ +/*}*/ + +/*!* *********************************************** *!*/ +/*!* *************** Panel Component *************** *!*/ +/*!* *********************************************** *!*/ + +/*.mf-panel {*/ +/* display: flex;*/ +/* width: 100%;*/ +/* height: 100%;*/ +/* overflow: hidden;*/ +/* position: relative;*/ +/*}*/ + +/*!* Common properties for side panels *!*/ +/*.mf-panel-left,*/ +/*.mf-panel-right {*/ +/* position: relative;*/ +/* flex-shrink: 0;*/ +/* width: 250px;*/ +/* min-width: 150px;*/ +/* max-width: 500px;*/ +/* height: 100%;*/ +/* overflow: auto;*/ +/* transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease;*/ +/* padding-top: 25px;*/ +/*}*/ + +/*!* Left panel specific *!*/ +/*.mf-panel-left {*/ +/* border-right: 1px solid var(--color-border-primary);*/ +/*}*/ + +/*!* Right panel specific *!*/ +/*.mf-panel-right {*/ +/* border-left: 1px solid var(--color-border-primary);*/ +/* padding-left: 0.5rem;*/ +/*}*/ + +/*.mf-panel-main {*/ +/* flex: 1;*/ +/* height: 100%;*/ +/* overflow: hidden;*/ +/* min-width: 0; !* Important to allow the shrinking of flexbox *!*/ +/*}*/ + +/*!* Hidden state - common for both panels *!*/ +/*.mf-panel-left.mf-hidden,*/ +/*.mf-panel-right.mf-hidden {*/ +/* width: 0;*/ +/* min-width: 0;*/ +/* max-width: 0;*/ +/* overflow: hidden;*/ +/* border: none;*/ +/* padding: 0;*/ +/*}*/ + +/*!* No transition during manual resize - common for both panels *!*/ +/*.mf-panel-left.no-transition,*/ +/*.mf-panel-right.no-transition {*/ +/* transition: none;*/ +/*}*/ + +/*!* Common properties for panel toggle icons *!*/ +/*.mf-panel-hide-icon,*/ +/*.mf-panel-show-icon {*/ +/* position: absolute;*/ +/* top: 0;*/ +/* right: 0;*/ +/* cursor: pointer;*/ +/* z-index: 10;*/ +/* border-radius: 0.25rem;*/ +/*}*/ + +/*.mf-panel-hide-icon:hover,*/ +/*.mf-panel-show-icon:hover {*/ +/* background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.05));*/ +/*}*/ + +/*!* Show icon positioning *!*/ +/*.mf-panel-show-icon-left {*/ +/* left: 0.5rem;*/ +/*}*/ + +/*.mf-panel-show-icon-right {*/ +/* right: 0.5rem;*/ +/*}*/ + +/*!* Panel with title - grid layout for header + scrollable content *!*/ +/*.mf-panel-body {*/ +/* display: grid;*/ +/* grid-template-rows: auto 1fr;*/ +/* height: 100%;*/ +/* overflow: hidden;*/ +/*}*/ + +/*.mf-panel-header {*/ +/* display: flex;*/ +/* justify-content: space-between;*/ +/* align-items: center;*/ +/* padding: 0.25rem 0.5rem;*/ +/* background-color: var(--color-base-200);*/ +/* border-bottom: 1px solid var(--color-border);*/ +/*}*/ + +/*!* Override absolute positioning for hide icon when inside header *!*/ +/*.mf-panel-header .mf-panel-hide-icon {*/ +/* position: static;*/ +/*}*/ + +/*.mf-panel-content {*/ +/* overflow-y: auto;*/ +/*}*/ + +/*!* Remove padding-top when using title layout *!*/ +/*.mf-panel-left.mf-panel-with-title,*/ +/*.mf-panel-right.mf-panel-with-title {*/ +/* padding-top: 0;*/ +/*}*/ + +/*!* *********************************************** *!*/ +/*!* ************* Properties Component ************ *!*/ +/*!* *********************************************** *!*/ + +/*!*!* Properties container *!*!*/ +/*.mf-properties {*/ +/* display: flex;*/ +/* flex-direction: column;*/ +/* gap: 1rem;*/ +/*}*/ + +/*!*!* Group card - using DaisyUI card styling *!*!*/ +/*.mf-properties-group-card {*/ +/* background-color: var(--color-base-100);*/ +/* border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);*/ +/* border-radius: var(--radius-md);*/ +/* box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);*/ +/* overflow: auto;*/ +/*}*/ + +/*.mf-properties-group-container {*/ +/* display: inline-block; !* important *!*/ +/* min-width: max-content; !* important *!*/ +/* width: 100%;*/ +/*}*/ + + +/*!*!* Group header - gradient using DaisyUI primary color *!*!*/ +/*.mf-properties-group-header {*/ +/* background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);*/ +/* color: var(--color-primary-content);*/ +/* padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);*/ +/* font-weight: 700;*/ +/* font-size: var(--properties-font-size);*/ +/*}*/ + +/*!*!* Group content area *!*!*/ +/*.mf-properties-group-content {*/ +/* display: flex;*/ +/* flex-direction: column;*/ +/* min-width: max-content;*/ +/*}*/ + +/*!*!* Property row *!*!*/ +/*.mf-properties-row {*/ +/* display: grid;*/ +/* grid-template-columns: 6rem 1fr;*/ + +/* align-items: center;*/ +/* padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);*/ + +/* border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);*/ +/* transition: background-color 0.15s ease;*/ + +/* gap: calc(var(--properties-font-size) * 0.75);*/ +/*}*/ + +/*.mf-properties-row:last-child {*/ +/* border-bottom: none;*/ +/*}*/ + +/*.mf-properties-row:hover {*/ +/* background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);*/ +/*}*/ + +/*!*!* Property key - normal font *!*!*/ +/*.mf-properties-key {*/ +/* align-items: start;*/ +/* font-weight: 600;*/ +/* color: color-mix(in oklab, var(--color-base-content) 70%, transparent);*/ +/* flex: 0 0 40%;*/ +/* font-size: var(--properties-font-size);*/ +/* white-space: nowrap;*/ +/* overflow: hidden;*/ +/* text-overflow: ellipsis;*/ +/*}*/ + +/*!*!* Property value - monospace font *!*!*/ +/*.mf-properties-value {*/ +/* font-family: var(--default-mono-font-family);*/ +/* color: var(--color-base-content);*/ +/* flex: 1;*/ +/* text-align: left;*/ +/* font-size: var(--properties-font-size);*/ +/* overflow: hidden;*/ +/* text-overflow: ellipsis;*/ +/* white-space: nowrap;*/ +/*}*/ diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js index 182899f..ca8a2ad 100644 --- a/src/myfasthtml/assets/myfasthtml.js +++ b/src/myfasthtml/assets/myfasthtml.js @@ -1,2427 +1,2427 @@ -/** - * 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'); - }); - } - }); -} \ No newline at end of file +// /** +// * 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'); +// }); +// } +// }); +// } \ No newline at end of file diff --git a/src/myfasthtml/assets/vis-network.min.js b/src/myfasthtml/assets/vis/vis-network.min.js similarity index 100% rename from src/myfasthtml/assets/vis-network.min.js rename to src/myfasthtml/assets/vis/vis-network.min.js diff --git a/src/myfasthtml/assets/vis/visnetwork.css b/src/myfasthtml/assets/vis/visnetwork.css new file mode 100644 index 0000000..c740ce2 --- /dev/null +++ b/src/myfasthtml/assets/vis/visnetwork.css @@ -0,0 +1,4 @@ +.mf-vis { + width: 100%; + height: 100%; +} diff --git a/src/myfasthtml/auth/utils.py b/src/myfasthtml/auth/utils.py index 0716263..8563dc2 100644 --- a/src/myfasthtml/auth/utils.py +++ b/src/myfasthtml/auth/utils.py @@ -26,8 +26,7 @@ DEFAULT_SKIP_PATTERNS = [ r'/static/.*', r'.*\.css', r'.*\.js', - r'/myfasthtml/.*\.css', - r'/myfasthtml/.*\.js', + r'/myfasthtml/assets/.*', '/login', '/register', '/logout', diff --git a/src/myfasthtml/myfastapp.py b/src/myfasthtml/myfastapp.py index 106b148..4677458 100644 --- a/src/myfasthtml/myfastapp.py +++ b/src/myfasthtml/myfastapp.py @@ -1,11 +1,11 @@ import logging from importlib.resources import files from pathlib import Path -from typing import Optional, Any +from typing import Optional, Any, List import fasthtml.fastapp from fasthtml.components import Link, Script -from starlette.responses import Response +from starlette.staticfiles import StaticFiles from myfasthtml.auth.routes import setup_auth_routes from myfasthtml.auth.utils import create_auth_beforeware @@ -16,19 +16,63 @@ from myfasthtml.core.utils import utils_app logger = logging.getLogger("MyFastHtml") -def get_asset_path(filename): - """Get asset file path""" - return files("myfasthtml") / "assets" / filename - - # Get assets directory path -assets_path = files("myfasthtml") / "assets" -assets_dir = Path(str(assets_path)) +assets_dir = Path(str(files("myfasthtml") / "assets")) -def get_asset_content(filename): - """Get asset file content""" - return get_asset_path(filename).read_text() +def include_assets(module_name: str, order: Optional[List[str]] = None) -> list: + """Scan assets/{module_name}/ and return Link/Script headers for all CSS/JS files. + + Args: + module_name: Name of the subdirectory under assets/ to scan. + order: Optional list of file base names (without extension) that define + the loading order. For each name, matching CSS files are included + before JS files. Files not listed in order are appended + alphabetically after the ordered ones. + + Returns: + A list of Link and Script FastHTML components. + + Raises: + FileNotFoundError: If module_name directory does not exist or if a name + in order matches no file in the directory. + """ + logger.debug(f"Including assets from '{module_name}'") + module_path = assets_dir / module_name + if not module_path.is_dir(): + raise FileNotFoundError(f"Asset module not found: '{module_name}'") + + all_files = [f for f in module_path.iterdir() if f.suffix in ('.css', '.js')] + + if order: + ordered = [] + remaining = list(all_files) + for name in order: + matches = [f for f in all_files if f.name.startswith(name + ".")] + if not matches: + raise FileNotFoundError( + f"No asset files matching '{name}' in module '{module_name}'" + ) + css_files = sorted(f for f in matches if f.suffix == '.css') + js_files = sorted(f for f in matches if f.suffix == '.js') + ordered.extend(css_files + js_files) + for f in matches: + remaining.remove(f) + remaining_sorted = sorted(remaining, key=lambda f: f.name) + files_list = ordered + remaining_sorted + else: + files_list = sorted(all_files, key=lambda f: f.name) + + hdrs = [] + for f in files_list: + url = f"/myfasthtml/assets/{module_name}/{f.name}" + if f.suffix == '.css': + hdrs.append(Link(href=url, rel="stylesheet", type="text/css")) + logger.debug(f" CSS: {url}") + else: + hdrs.append(Script(src=url)) + logger.debug(f" JS: {url}") + return hdrs def create_app(daisyui: Optional[bool] = True, @@ -61,67 +105,25 @@ def create_app(daisyui: Optional[bool] = True, :return: A tuple containing the FastHtml application instance and the associated router. :rtype: Any """ - hdrs = [ - Link(href="/myfasthtml/myfasthtml.css", rel="stylesheet", type="text/css"), - Script(src="/myfasthtml/myfasthtml.js"), - ] - + hdrs = include_assets("core", order=["myfasthtml"]) + hdrs += include_assets("datagrid") + if daisyui: - hdrs += [ - Link(href="/myfasthtml/daisyui-5.css", rel="stylesheet", type="text/css"), - Link(href="/myfasthtml/daisyui-5-themes.css", rel="stylesheet", type="text/css"), - Script(src="/myfasthtml/tailwindcss-browser@4.js"), - ] - + hdrs += include_assets("daisyui") + if vis: - hdrs += [ - Script(src="/myfasthtml/vis-network.min.js"), - ] - + hdrs += include_assets("vis") + if code_mirror: - hdrs += [ - Script(src="/myfasthtml/codemirror.min.js"), - Link(href="/myfasthtml/codemirror.min.css", rel="stylesheet", type="text/css"), - - Script(src="/myfasthtml/placeholder.min.js"), - Script(src="/myfasthtml/simple.min.js"), - - Script(src="/myfasthtml/show-hint.min.js"), - Link(href="/myfasthtml/show-hint.min.css", rel="stylesheet", type="text/css"), - - Script(src="/myfasthtml/lint.min.js"), - Link(href="/myfasthtml/lint.min.css", rel="stylesheet", type="text/css"), - ] + hdrs += include_assets("codemirror", order=["codemirror"]) beforeware = create_auth_beforeware() if protect_routes else None app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs) - - # remove the global static files routes - original_routes = app.routes[:] - app.routes.clear() - - # Serve assets - @app.get("/myfasthtml/{filename:path}.{ext:static}") - def serve_assets(filename: str, ext: str): - logger.debug(f"Serving asset: {filename=}, {ext=}") - path = filename + "." + ext - try: - content = get_asset_content(path) - - if ext == '.css': - return Response(content, media_type="text/css") - elif ext == 'js': - return Response(content, media_type="application/javascript") - else: - return Response(content) - except Exception as e: - return Response(f"Asset not found: {path}", status_code=404) - - # and put it back after the myfasthtml static files routes - for r in original_routes: - app.routes.append(r) - - # route the commands and the bindings + + # Serve package assets via StaticFiles (MIME types, caching, binary support) + app.mount("/myfasthtml/assets", StaticFiles(directory=assets_dir), name="myfasthtml-assets") + + # Route the commands and the bindings app.mount("/myfasthtml", utils_app) if mount_auth_app: