Refactored assets serving
This commit is contained in:
50
src/myfasthtml/assets/core/boundaries.js
Normal file
50
src/myfasthtml/assets/core/boundaries.js
Normal file
@@ -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});
|
||||
}
|
||||
}
|
||||
57
src/myfasthtml/assets/core/dropdown.css
Normal file
57
src/myfasthtml/assets/core/dropdown.css
Normal file
@@ -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%);
|
||||
}
|
||||
11
src/myfasthtml/assets/core/dropdown.js
Normal file
11
src/myfasthtml/assets/core/dropdown.js
Normal file
@@ -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};
|
||||
}
|
||||
209
src/myfasthtml/assets/core/dsleditor.css
Normal file
209
src/myfasthtml/assets/core/dsleditor.css
Normal file
@@ -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;
|
||||
}
|
||||
269
src/myfasthtml/assets/core/dsleditor.js
Normal file
269
src/myfasthtml/assets/core/dsleditor.js
Normal file
@@ -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"}`);
|
||||
}
|
||||
376
src/myfasthtml/assets/core/keyboard.js
Normal file
376
src/myfasthtml/assets/core/keyboard.js
Normal file
@@ -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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
270
src/myfasthtml/assets/core/layout.css
Normal file
270
src/myfasthtml/assets/core/layout.css
Normal file
@@ -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;
|
||||
}
|
||||
4
src/myfasthtml/assets/core/layout.js
Normal file
4
src/myfasthtml/assets/core/layout.js
Normal file
@@ -0,0 +1,4 @@
|
||||
function initLayout(elementId) {
|
||||
initResizer(elementId);
|
||||
bindTooltipsWithDelegation(elementId);
|
||||
}
|
||||
578
src/myfasthtml/assets/core/mouse.js
Normal file
578
src/myfasthtml/assets/core/mouse.js
Normal file
@@ -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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
164
src/myfasthtml/assets/core/myfasthtml.css
Normal file
164
src/myfasthtml/assets/core/myfasthtml.css
Normal file
@@ -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;
|
||||
}
|
||||
380
src/myfasthtml/assets/core/myfasthtml.js
Normal file
380
src/myfasthtml/assets/core/myfasthtml.js
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Generic Resizer
|
||||
*
|
||||
* Handles resizing of elements with drag functionality.
|
||||
* Communicates with server via HTMX to persist width changes.
|
||||
* Works for both Layout drawers and Panel sides.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize resizer functionality for a specific container
|
||||
*
|
||||
* @param {string} containerId - The ID of the container instance to initialize
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} options.minWidth - Minimum width in pixels (default: 150)
|
||||
* @param {number} options.maxWidth - Maximum width in pixels (default: 600)
|
||||
*/
|
||||
function initResizer(containerId, options = {}) {
|
||||
|
||||
const MIN_WIDTH = options.minWidth || 150;
|
||||
const MAX_WIDTH = options.maxWidth || 600;
|
||||
|
||||
let isResizing = false;
|
||||
let currentResizer = null;
|
||||
let currentItem = null;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
let side = null;
|
||||
|
||||
const containerElement = document.getElementById(containerId);
|
||||
|
||||
if (!containerElement) {
|
||||
console.error(`Container element with ID "${containerId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize resizer functionality for this container instance
|
||||
*/
|
||||
function initResizers() {
|
||||
const resizers = containerElement.querySelectorAll('.mf-resizer');
|
||||
|
||||
resizers.forEach(resizer => {
|
||||
// Remove existing listener if any to avoid duplicates
|
||||
resizer.removeEventListener('mousedown', handleMouseDown);
|
||||
resizer.addEventListener('mousedown', handleMouseDown);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse down event on resizer
|
||||
*/
|
||||
function handleMouseDown(e) {
|
||||
e.preventDefault();
|
||||
|
||||
currentResizer = e.target;
|
||||
side = currentResizer.dataset.side;
|
||||
currentItem = currentResizer.parentElement;
|
||||
|
||||
if (!currentItem) {
|
||||
console.error('Could not find item element');
|
||||
return;
|
||||
}
|
||||
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startWidth = currentItem.offsetWidth;
|
||||
|
||||
// Add event listeners for mouse move and up
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Add resizing class for visual feedback
|
||||
document.body.classList.add('mf-resizing');
|
||||
currentItem.classList.add('mf-item-resizing');
|
||||
// Disable transition during manual resize
|
||||
currentItem.classList.add('no-transition');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move event during resize
|
||||
*/
|
||||
function handleMouseMove(e) {
|
||||
if (!isResizing) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let newWidth;
|
||||
|
||||
if (side === 'left') {
|
||||
// Left drawer: increase width when moving right
|
||||
newWidth = startWidth + (e.clientX - startX);
|
||||
} else if (side === 'right') {
|
||||
// Right drawer: increase width when moving left
|
||||
newWidth = startWidth - (e.clientX - startX);
|
||||
}
|
||||
|
||||
// Constrain width between min and max
|
||||
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth));
|
||||
|
||||
// Update item width visually
|
||||
currentItem.style.width = `${newWidth}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse up event - end resize and save to server
|
||||
*/
|
||||
function handleMouseUp(e) {
|
||||
if (!isResizing) return;
|
||||
|
||||
isResizing = false;
|
||||
|
||||
// Remove event listeners
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Remove resizing classes
|
||||
document.body.classList.remove('mf-resizing');
|
||||
currentItem.classList.remove('mf-item-resizing');
|
||||
// Re-enable transition after manual resize
|
||||
currentItem.classList.remove('no-transition');
|
||||
|
||||
// Get final width
|
||||
const finalWidth = currentItem.offsetWidth;
|
||||
const commandId = currentResizer.dataset.commandId;
|
||||
|
||||
if (!commandId) {
|
||||
console.error('No command ID found on resizer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send width update to server
|
||||
saveWidth(commandId, finalWidth);
|
||||
|
||||
// Reset state
|
||||
currentResizer = null;
|
||||
currentItem = null;
|
||||
side = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save width to server via HTMX
|
||||
*/
|
||||
function saveWidth(commandId, width) {
|
||||
htmx.ajax('POST', '/myfasthtml/commands', {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}, swap: "outerHTML", target: `#${currentItem.id}`, values: {
|
||||
c_id: commandId, width: width
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize resizers
|
||||
initResizers();
|
||||
|
||||
// Re-initialize after HTMX swaps within this container
|
||||
containerElement.addEventListener('htmx:afterSwap', function (event) {
|
||||
initResizers();
|
||||
});
|
||||
}
|
||||
|
||||
function bindTooltipsWithDelegation(elementId) {
|
||||
// To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip
|
||||
// Then
|
||||
// the 'truncate' to show only when the text is truncated
|
||||
// the class 'mmt-tooltip' for force the display
|
||||
|
||||
console.info("bindTooltips on element " + elementId);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
const tooltipContainer = document.getElementById(`tt_${elementId}`);
|
||||
|
||||
|
||||
if (!element) {
|
||||
console.error(`Invalid element '${elementId}' container`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tooltipContainer) {
|
||||
console.error(`Invalid tooltip 'tt_${elementId}' container.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMIZATION C: Throttling flag to limit mouseenter processing
|
||||
let tooltipRafScheduled = false;
|
||||
|
||||
// Add a single mouseenter and mouseleave listener to the parent element
|
||||
element.addEventListener("mouseenter", (event) => {
|
||||
// Early exit - check mf-no-tooltip FIRST (before any DOM work)
|
||||
if (element.hasAttribute("mf-no-tooltip")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMIZATION C: Throttle mouseenter events (max 1 per frame)
|
||||
if (tooltipRafScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cell = event.target.closest("[data-tooltip]");
|
||||
if (!cell) {
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMIZATION C: Move ALL DOM reads into RAF to avoid forced synchronous layouts
|
||||
tooltipRafScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
tooltipRafScheduled = false;
|
||||
|
||||
// Check again in case tooltip was disabled during RAF delay
|
||||
if (element.hasAttribute("mf-no-tooltip")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// All DOM reads happen here (batched in RAF)
|
||||
const content = cell.querySelector(".truncate") || cell;
|
||||
const isOverflowing = content.scrollWidth > content.clientWidth;
|
||||
const forceShow = cell.classList.contains("mf-tooltip");
|
||||
|
||||
if (isOverflowing || forceShow) {
|
||||
const tooltipText = cell.getAttribute("data-tooltip");
|
||||
if (tooltipText) {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
const tooltipRect = tooltipContainer.getBoundingClientRect();
|
||||
|
||||
let top = rect.top - 30; // Above the cell
|
||||
let left = rect.left;
|
||||
|
||||
// Adjust tooltip position to prevent it from going off-screen
|
||||
if (top < 0) top = rect.bottom + 5; // Move below if no space above
|
||||
if (left + tooltipRect.width > window.innerWidth) {
|
||||
left = window.innerWidth - tooltipRect.width - 5; // Prevent overflow right
|
||||
}
|
||||
|
||||
// Apply styles (already in RAF)
|
||||
tooltipContainer.textContent = tooltipText;
|
||||
tooltipContainer.setAttribute("data-visible", "true");
|
||||
tooltipContainer.style.top = `${top}px`;
|
||||
tooltipContainer.style.left = `${left}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, true); // Capture phase required: mouseenter doesn't bubble
|
||||
|
||||
element.addEventListener("mouseleave", (event) => {
|
||||
const cell = event.target.closest("[data-tooltip]");
|
||||
if (cell) {
|
||||
tooltipContainer.setAttribute("data-visible", "false");
|
||||
}
|
||||
}, true); // Capture phase required: mouseleave doesn't bubble
|
||||
}
|
||||
|
||||
function disableTooltip() {
|
||||
const elementId = tooltipElementId
|
||||
// console.debug("disableTooltip on element " + elementId);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
console.error(`Invalid element '${elementId}' container`);
|
||||
return;
|
||||
}
|
||||
|
||||
element.setAttribute("mmt-no-tooltip", "");
|
||||
}
|
||||
|
||||
function enableTooltip() {
|
||||
const elementId = tooltipElementId
|
||||
// console.debug("enableTooltip on element " + elementId);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
console.error(`Invalid element '${elementId}' container`);
|
||||
return;
|
||||
}
|
||||
|
||||
element.removeAttribute("mmt-no-tooltip");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared utility function for triggering HTMX actions from keyboard/mouse bindings.
|
||||
* Handles dynamic hx-vals with "js:functionName()" syntax.
|
||||
*
|
||||
* @param {string} elementId - ID of the element
|
||||
* @param {Object} config - HTMX configuration object
|
||||
* @param {string} combinationStr - The matched combination string
|
||||
* @param {boolean} isInside - Whether the focus/click is inside the element
|
||||
* @param {Event} event - The event that triggered this action (KeyboardEvent or MouseEvent)
|
||||
*/
|
||||
function triggerHtmxAction(elementId, config, combinationStr, isInside, event) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const hasFocus = document.activeElement === element;
|
||||
|
||||
// Extract HTTP method and URL from hx-* attributes
|
||||
let method = 'POST'; // default
|
||||
let url = null;
|
||||
|
||||
const methodMap = {
|
||||
'hx-post': 'POST', 'hx-get': 'GET', 'hx-put': 'PUT', 'hx-delete': 'DELETE', 'hx-patch': 'PATCH'
|
||||
};
|
||||
|
||||
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||
if (config[attr]) {
|
||||
method = httpMethod;
|
||||
url = config[attr];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
console.error('No HTTP method attribute found in config:', config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build htmx.ajax options
|
||||
const htmxOptions = {};
|
||||
|
||||
// Map hx-target to target
|
||||
if (config['hx-target']) {
|
||||
htmxOptions.target = config['hx-target'];
|
||||
}
|
||||
|
||||
// Map hx-swap to swap
|
||||
if (config['hx-swap']) {
|
||||
htmxOptions.swap = config['hx-swap'];
|
||||
}
|
||||
|
||||
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||
const values = {};
|
||||
|
||||
// 1. Merge static hx-vals from command (if present)
|
||||
if (config['hx-vals'] && typeof config['hx-vals'] === 'object') {
|
||||
Object.assign(values, config['hx-vals']);
|
||||
}
|
||||
|
||||
// 2. Merge hx-vals-extra (user overrides)
|
||||
if (config['hx-vals-extra']) {
|
||||
const extra = config['hx-vals-extra'];
|
||||
|
||||
// Merge static dict values
|
||||
if (extra.dict && typeof extra.dict === 'object') {
|
||||
Object.assign(values, extra.dict);
|
||||
}
|
||||
|
||||
// Call dynamic JS function and merge result
|
||||
if (extra.js) {
|
||||
try {
|
||||
const func = window[extra.js];
|
||||
if (typeof func === 'function') {
|
||||
const dynamicValues = func(event, element, combinationStr);
|
||||
if (dynamicValues && typeof dynamicValues === 'object') {
|
||||
Object.assign(values, dynamicValues);
|
||||
}
|
||||
} else {
|
||||
console.error(`Function "${extra.js}" not found on window`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error calling dynamic hx-vals function:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
values.combination = combinationStr;
|
||||
values.has_focus = hasFocus;
|
||||
values.is_inside = isInside;
|
||||
htmxOptions.values = values;
|
||||
|
||||
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||
// Remove 'hx-' prefix and convert to camelCase
|
||||
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
htmxOptions[optionKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Make AJAX call with htmx
|
||||
htmx.ajax(method, url, htmxOptions);
|
||||
}
|
||||
|
||||
117
src/myfasthtml/assets/core/panel.css
Normal file
117
src/myfasthtml/assets/core/panel.css
Normal file
@@ -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;
|
||||
}
|
||||
88
src/myfasthtml/assets/core/properties.css
Normal file
88
src/myfasthtml/assets/core/properties.css
Normal file
@@ -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;
|
||||
}
|
||||
5
src/myfasthtml/assets/core/search.css
Normal file
5
src/myfasthtml/assets/core/search.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.mf-search-results {
|
||||
margin-top: 0.5rem;
|
||||
/*max-height: 400px;*/
|
||||
overflow: auto;
|
||||
}
|
||||
107
src/myfasthtml/assets/core/tabs.css
Normal file
107
src/myfasthtml/assets/core/tabs.css
Normal file
@@ -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;
|
||||
}
|
||||
59
src/myfasthtml/assets/core/tabs.js
Normal file
59
src/myfasthtml/assets/core/tabs.js
Normal file
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/myfasthtml/assets/core/treeview.css
Normal file
78
src/myfasthtml/assets/core/treeview.css
Normal file
@@ -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;
|
||||
}
|
||||
287
src/myfasthtml/assets/datagrid/datagrid.css
Normal file
287
src/myfasthtml/assets/datagrid/datagrid.css
Normal file
@@ -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);
|
||||
}
|
||||
687
src/myfasthtml/assets/datagrid/datagrid.js
Normal file
687
src/myfasthtml/assets/datagrid/datagrid.js
Normal file
@@ -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};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
4
src/myfasthtml/assets/vis/visnetwork.css
Normal file
4
src/myfasthtml/assets/vis/visnetwork.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.mf-vis {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user