Refactored assets serving
This commit is contained in:
@@ -957,3 +957,4 @@ user.find_element("textarea[name='message']")
|
|||||||
* 0.1.0 : First release
|
* 0.1.0 : First release
|
||||||
* 0.2.0 : Updated to myauth 0.2.0
|
* 0.2.0 : Updated to myauth 0.2.0
|
||||||
* 0.3.0 : Added Bindings support
|
* 0.3.0 : Added Bindings support
|
||||||
|
* 0.4.0 : First version with Datagrid + new static file server
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "myfasthtml"
|
name = "myfasthtml"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
description = "Set of tools to quickly create HTML pages using FastHTML."
|
description = "Set of tools to quickly create HTML pages using FastHTML."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [
|
authors = [
|
||||||
@@ -74,10 +74,12 @@ dev = [
|
|||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = { "" = "src" }
|
package-dir = { "" = "src" }
|
||||||
packages = ["myfasthtml"]
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
myfasthtml = [
|
myfasthtml = [
|
||||||
"assets/*.css",
|
"assets/**/*.css",
|
||||||
"assets/*.js"
|
"assets/**/*.js"
|
||||||
]
|
]
|
||||||
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'/static/.*',
|
||||||
r'.*\.css',
|
r'.*\.css',
|
||||||
r'.*\.js',
|
r'.*\.js',
|
||||||
r'/myfasthtml/.*\.css',
|
r'/myfasthtml/assets/.*',
|
||||||
r'/myfasthtml/.*\.js',
|
|
||||||
'/login',
|
'/login',
|
||||||
'/register',
|
'/register',
|
||||||
'/logout',
|
'/logout',
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any, List
|
||||||
|
|
||||||
import fasthtml.fastapp
|
import fasthtml.fastapp
|
||||||
from fasthtml.components import Link, Script
|
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.routes import setup_auth_routes
|
||||||
from myfasthtml.auth.utils import create_auth_beforeware
|
from myfasthtml.auth.utils import create_auth_beforeware
|
||||||
@@ -16,19 +16,63 @@ from myfasthtml.core.utils import utils_app
|
|||||||
logger = logging.getLogger("MyFastHtml")
|
logger = logging.getLogger("MyFastHtml")
|
||||||
|
|
||||||
|
|
||||||
def get_asset_path(filename):
|
|
||||||
"""Get asset file path"""
|
|
||||||
return files("myfasthtml") / "assets" / filename
|
|
||||||
|
|
||||||
|
|
||||||
# Get assets directory path
|
# Get assets directory path
|
||||||
assets_path = files("myfasthtml") / "assets"
|
assets_dir = Path(str(files("myfasthtml") / "assets"))
|
||||||
assets_dir = Path(str(assets_path))
|
|
||||||
|
|
||||||
|
|
||||||
def get_asset_content(filename):
|
def include_assets(module_name: str, order: Optional[List[str]] = None) -> list:
|
||||||
"""Get asset file content"""
|
"""Scan assets/{module_name}/ and return Link/Script headers for all CSS/JS files.
|
||||||
return get_asset_path(filename).read_text()
|
|
||||||
|
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,
|
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.
|
:return: A tuple containing the FastHtml application instance and the associated router.
|
||||||
:rtype: Any
|
:rtype: Any
|
||||||
"""
|
"""
|
||||||
hdrs = [
|
hdrs = include_assets("core", order=["myfasthtml"])
|
||||||
Link(href="/myfasthtml/myfasthtml.css", rel="stylesheet", type="text/css"),
|
hdrs += include_assets("datagrid")
|
||||||
Script(src="/myfasthtml/myfasthtml.js"),
|
|
||||||
]
|
|
||||||
|
|
||||||
if daisyui:
|
if daisyui:
|
||||||
hdrs += [
|
hdrs += include_assets("daisyui")
|
||||||
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"),
|
|
||||||
]
|
|
||||||
|
|
||||||
if vis:
|
if vis:
|
||||||
hdrs += [
|
hdrs += include_assets("vis")
|
||||||
Script(src="/myfasthtml/vis-network.min.js"),
|
|
||||||
]
|
|
||||||
|
|
||||||
if code_mirror:
|
if code_mirror:
|
||||||
hdrs += [
|
hdrs += include_assets("codemirror", order=["codemirror"])
|
||||||
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"),
|
|
||||||
]
|
|
||||||
|
|
||||||
beforeware = create_auth_beforeware() if protect_routes else None
|
beforeware = create_auth_beforeware() if protect_routes else None
|
||||||
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)
|
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)
|
||||||
|
|
||||||
# remove the global static files routes
|
# Serve package assets via StaticFiles (MIME types, caching, binary support)
|
||||||
original_routes = app.routes[:]
|
app.mount("/myfasthtml/assets", StaticFiles(directory=assets_dir), name="myfasthtml-assets")
|
||||||
app.routes.clear()
|
|
||||||
|
# Route the commands and the bindings
|
||||||
# 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
|
|
||||||
app.mount("/myfasthtml", utils_app)
|
app.mount("/myfasthtml", utils_app)
|
||||||
|
|
||||||
if mount_auth_app:
|
if mount_auth_app:
|
||||||
|
|||||||
Reference in New Issue
Block a user