Compare commits
2 Commits
0c9c8bc7fa
...
ef9f269a49
| Author | SHA1 | Date | |
|---|---|---|---|
| ef9f269a49 | |||
| 0951680466 |
@@ -205,7 +205,7 @@ def render(self):
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.handle_on_click()),
|
||||
id=self._id
|
||||
)
|
||||
```
|
||||
|
||||
@@ -209,13 +209,14 @@ For interactive controls, compose `Keyboard` and `Mouse`:
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
|
||||
id=self._id
|
||||
)
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.handle_on_click()),
|
||||
id=self._id
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
pendingMatches: [], // Array of matches waiting for timeout
|
||||
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||
clickHandler: null,
|
||||
dblclickHandler: null, // Handler reference for dblclick
|
||||
contextmenuHandler: null,
|
||||
mousedownState: null, // Active drag state (only after movement detected)
|
||||
suppressNextClick: false, // Prevents click from firing after mousedown>mouseup
|
||||
@@ -35,7 +36,9 @@
|
||||
|
||||
// Handle aliases
|
||||
const aliasMap = {
|
||||
'rclick': 'right_click'
|
||||
'rclick': 'right_click',
|
||||
'double_click': 'dblclick',
|
||||
'dclick': 'dblclick'
|
||||
};
|
||||
|
||||
return aliasMap[normalized] || normalized;
|
||||
@@ -563,6 +566,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dblclick events (triggers for all registered elements).
|
||||
* Uses a fresh single-step history so it never conflicts with click sequences.
|
||||
* @param {MouseEvent} event - The dblclick event
|
||||
*/
|
||||
function handleDblClick(event) {
|
||||
const snapshot = createSnapshot(event, 'dblclick');
|
||||
const dblclickHistory = [snapshot];
|
||||
|
||||
const currentMatches = [];
|
||||
|
||||
for (const [elementId, data] of MouseRegistry.elements) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
const isInside = element.contains(event.target);
|
||||
const currentNode = traverseTree(data.tree, dblclickHistory);
|
||||
if (!currentNode || !currentNode.config) continue;
|
||||
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
}
|
||||
|
||||
const anyMatchInside = currentMatches.some(m => m.isInside);
|
||||
if (anyMatchInside && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
for (const match of currentMatches) {
|
||||
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up mousedown state and safety timeout
|
||||
*/
|
||||
@@ -989,11 +1029,13 @@
|
||||
if (!MouseRegistry.listenerAttached) {
|
||||
// Store handler references for proper removal
|
||||
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||
MouseRegistry.dblclickHandler = (e) => handleDblClick(e);
|
||||
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||
MouseRegistry.mousedownHandler = (e) => handleMouseDown(e);
|
||||
MouseRegistry.mouseupHandler = (e) => handleMouseUp(e);
|
||||
|
||||
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||
document.addEventListener('dblclick', MouseRegistry.dblclickHandler);
|
||||
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
document.addEventListener('mousedown', MouseRegistry.mousedownHandler);
|
||||
document.addEventListener('mouseup', MouseRegistry.mouseupHandler);
|
||||
@@ -1007,6 +1049,7 @@
|
||||
function detachGlobalListener() {
|
||||
if (MouseRegistry.listenerAttached) {
|
||||
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||
document.removeEventListener('dblclick', MouseRegistry.dblclickHandler);
|
||||
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
document.removeEventListener('mousedown', MouseRegistry.mousedownHandler);
|
||||
document.removeEventListener('mouseup', MouseRegistry.mouseupHandler);
|
||||
@@ -1014,6 +1057,7 @@
|
||||
|
||||
// Clean up handler references
|
||||
MouseRegistry.clickHandler = null;
|
||||
MouseRegistry.dblclickHandler = null;
|
||||
MouseRegistry.contextmenuHandler = null;
|
||||
MouseRegistry.mousedownHandler = null;
|
||||
MouseRegistry.mouseupHandler = null;
|
||||
|
||||
@@ -3,7 +3,7 @@ function initDataGrid(gridId) {
|
||||
initDataGridMouseOver(gridId);
|
||||
makeDatagridColumnsResizable(gridId);
|
||||
makeDatagridColumnsMovable(gridId);
|
||||
updateDatagridSelection(gridId)
|
||||
updateDatagridSelection(gridId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -673,9 +673,9 @@ function updateDatagridSelection(datagridId) {
|
||||
|
||||
// Clear browser text selection to prevent stale ranges from reappearing
|
||||
// But skip if an input/textarea/contenteditable has focus (would clear text cursor)
|
||||
if (!document.activeElement?.closest('input, textarea, [contenteditable]')) {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
// if (!document.activeElement?.closest('input, textarea, [contenteditable]')) {
|
||||
// window.getSelection()?.removeAllRanges();
|
||||
// }
|
||||
|
||||
// OPTIMIZATION: scope to table instead of scanning the entire document
|
||||
const table = document.getElementById(`t_${datagridId}`);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
@@ -18,9 +17,7 @@ from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.Query import Query, QUERY_FILTER
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState, \
|
||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState, DataGridColumnUiState, \
|
||||
DataGridRowSelectionColumnState
|
||||
from myfasthtml.controls.datagrid_objects import *
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import ColumnType, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
|
||||
@@ -156,7 +153,7 @@ class Commands(BaseCommands):
|
||||
return Command("OnClick",
|
||||
"Click on the table",
|
||||
self._owner,
|
||||
self._owner.on_click
|
||||
self._owner.handle_on_click
|
||||
).htmx(target=f"#tsm_{self._id}")
|
||||
|
||||
def on_key_pressed(self):
|
||||
@@ -212,6 +209,21 @@ class Commands(BaseCommands):
|
||||
self._owner,
|
||||
self._owner.on_column_changed
|
||||
)
|
||||
|
||||
def start_edition(self):
|
||||
return Command("StartEdition",
|
||||
"Enter cell edit mode",
|
||||
self._owner,
|
||||
self._owner.handle_start_edition
|
||||
).htmx(target=f"#tsm_{self._id}")
|
||||
|
||||
def save_edition(self):
|
||||
return Command("SaveEdition",
|
||||
"Save cell edition",
|
||||
self._owner,
|
||||
self._owner.handle_save_edition
|
||||
).htmx(target=f"#tsm_{self._id}",
|
||||
trigger="blur, keydown[key=='Enter']")
|
||||
|
||||
|
||||
class DataGrid(MultipleInstance):
|
||||
@@ -293,10 +305,12 @@ class DataGrid(MultipleInstance):
|
||||
"click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"ctrl+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"dblclick": {"command": self.commands.start_edition(), "hx_vals": "js:getCellId()"},
|
||||
}
|
||||
|
||||
self._key_support = {
|
||||
"esc": {"command": self.commands.on_key_pressed(), "require_inside": False},
|
||||
"enter": {"command": self.commands.on_key_pressed(), "require_inside": True},
|
||||
}
|
||||
|
||||
logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.")
|
||||
@@ -451,6 +465,26 @@ class DataGrid(MultipleInstance):
|
||||
if self._settings.enable_edition:
|
||||
self._columns.insert(0, DataGridRowSelectionColumnState())
|
||||
|
||||
def _enter_edition(self, pos):
|
||||
col_pos, row_index = pos
|
||||
col_def = self._columns[col_pos]
|
||||
if col_def.type in (ColumnType.RowSelection_, ColumnType.RowIndex, ColumnType.Formula):
|
||||
return self.render_partial()
|
||||
self._state.edition.under_edition = pos
|
||||
self._state.save()
|
||||
return self.render_partial("cell", pos=pos)
|
||||
|
||||
def _convert_edition_value(self, value_str, col_type):
|
||||
if col_type == ColumnType.Number:
|
||||
try:
|
||||
return float(value_str) if '.' in value_str else int(value_str)
|
||||
except (ValueError, TypeError):
|
||||
return value_str
|
||||
elif col_type == ColumnType.Bool:
|
||||
return value_str.lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
return value_str
|
||||
|
||||
def add_new_column(self, col_def: DataGridColumnState) -> None:
|
||||
"""Add a new column, delegating data mutation to DataService.
|
||||
|
||||
@@ -571,14 +605,20 @@ class DataGrid(MultipleInstance):
|
||||
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
|
||||
return self.render_partial("body")
|
||||
|
||||
def on_click(self, combination, is_inside, cell_id):
|
||||
def handle_on_click(self, combination, is_inside, cell_id):
|
||||
logger.debug(f"on_click table={self.get_table_name()} {combination=} {is_inside=} {cell_id=}")
|
||||
if is_inside and cell_id:
|
||||
self._state.selection.extra_selected.clear()
|
||||
|
||||
if cell_id.startswith("tcell_"):
|
||||
pos = self._get_pos_from_element_id(cell_id)
|
||||
self._update_current_position(pos)
|
||||
pos = self._get_pos_from_element_id(cell_id)
|
||||
|
||||
if (self._settings.enable_edition and
|
||||
pos is not None and
|
||||
pos == self._state.selection.selected and
|
||||
self._state.edition.under_edition is None):
|
||||
return self._enter_edition(pos)
|
||||
|
||||
self._update_current_position(pos)
|
||||
|
||||
return self.render_partial()
|
||||
|
||||
@@ -605,6 +645,11 @@ class DataGrid(MultipleInstance):
|
||||
if combination == "esc":
|
||||
self._update_current_position(None)
|
||||
self._state.selection.extra_selected.clear()
|
||||
elif (combination == "enter" and
|
||||
self._settings.enable_edition and
|
||||
self._state.selection.selected and
|
||||
self._state.edition.under_edition is None):
|
||||
return self._enter_edition(self._state.selection.selected)
|
||||
|
||||
return self.render_partial()
|
||||
|
||||
@@ -674,6 +719,30 @@ class DataGrid(MultipleInstance):
|
||||
self._panel.set_title(side="right", title="Formatting")
|
||||
self._panel.set_right(self._formatting_editor)
|
||||
|
||||
def handle_start_edition(self, cell_id):
|
||||
logger.debug(f"handle_start_edition: {cell_id=}")
|
||||
if not self._settings.enable_edition:
|
||||
return self.render_partial()
|
||||
if self._state.edition.under_edition is not None:
|
||||
return self.render_partial()
|
||||
pos = self._get_pos_from_element_id(cell_id)
|
||||
if pos is None:
|
||||
return self.render_partial()
|
||||
self._update_current_position(pos)
|
||||
return self._enter_edition(pos)
|
||||
|
||||
def handle_save_edition(self, value):
|
||||
logger.debug(f"handle_save_edition: {value=}")
|
||||
if self._state.edition.under_edition is None:
|
||||
return self.render_partial()
|
||||
col_pos, row_index = self._state.edition.under_edition
|
||||
col_def = self._columns[col_pos]
|
||||
typed_value = self._convert_edition_value(value, col_def.type)
|
||||
self._data_service.set_data(col_def.col_id, row_index, typed_value)
|
||||
self._state.edition.under_edition = None
|
||||
self._state.save()
|
||||
return self.render_partial("cell", pos=(col_pos, row_index))
|
||||
|
||||
def handle_set_column_width(self, col_id: str, width: str):
|
||||
"""Update column width after resize. Called via Command from JS."""
|
||||
logger.debug(f"set_column_width: {col_id=} {width=}")
|
||||
@@ -724,6 +793,31 @@ class DataGrid(MultipleInstance):
|
||||
def get_data_service_id_from_data_grid_id(datagrid_id):
|
||||
return datagrid_id.replace(DataGrid.compute_prefix(), DataService.compute_prefix(), 1)
|
||||
|
||||
def _mk_edition_cell(self, col_pos, row_index, col_def: DataGridColumnState, is_last):
|
||||
col_array = self._fast_access.get(col_def.col_id)
|
||||
value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None
|
||||
value_str = str(value) if not is_null(value) else ""
|
||||
|
||||
save_cmd = self.commands.save_edition()
|
||||
input_elem = mk.mk(
|
||||
Input(value=value_str, name="value", autofocus=True, cls="dt2-cell-input"),
|
||||
command=save_cmd
|
||||
)
|
||||
|
||||
return OptimizedDiv(
|
||||
input_elem,
|
||||
id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
|
||||
cls=merge_classes("dt2-cell dt2-cell-edition", "dt2-last-cell" if is_last else None),
|
||||
style=f"width:{col_def.width}px;"
|
||||
)
|
||||
|
||||
def _mk_cell_oob(self, col_pos, row_index):
|
||||
col_def = self._columns[col_pos]
|
||||
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
|
||||
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
|
||||
is_last = col_pos == len(self._columns) - 1
|
||||
return self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower, is_last)
|
||||
|
||||
def mk_headers(self):
|
||||
resize_cmd = self.commands.set_column_width()
|
||||
move_cmd = self.commands.move_column()
|
||||
@@ -794,7 +888,7 @@ class DataGrid(MultipleInstance):
|
||||
res.append(Span(value_str[:index], cls=f"{css_class}"))
|
||||
res.append(Span(value_str[index:index + len_keyword], cls="dt2-highlight-1"))
|
||||
if index + len_keyword < len(value_str):
|
||||
res.append(Span(value_str[index + len_keyword:], cls=f"{css_class}"))
|
||||
res.append(Span(value_str[index + len_keyword:]))
|
||||
|
||||
return Span(*res, cls=f"{css_class} truncate", style=style) if len(res) > 1 else res[0]
|
||||
|
||||
@@ -867,6 +961,10 @@ class DataGrid(MultipleInstance):
|
||||
if col_def.type == ColumnType.RowSelection_:
|
||||
return OptimizedDiv(cls="dt2-row-selection")
|
||||
|
||||
if (self._settings.enable_edition and
|
||||
self._state.edition.under_edition == (col_pos, row_index)):
|
||||
return self._mk_edition_cell(col_pos, row_index, col_def, is_last)
|
||||
|
||||
col_array = self._fast_access.get(col_def.col_id)
|
||||
value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None
|
||||
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
|
||||
@@ -1105,7 +1203,7 @@ class DataGrid(MultipleInstance):
|
||||
:param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header)
|
||||
:return:
|
||||
"""
|
||||
res = []
|
||||
res = [self.mk_selection_manager()]
|
||||
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"initDataGrid('{self._id}');",
|
||||
@@ -1133,7 +1231,21 @@ class DataGrid(MultipleInstance):
|
||||
header.attrs.update(header_extra_attr)
|
||||
return header
|
||||
|
||||
res.append(self.mk_selection_manager())
|
||||
else:
|
||||
col_pos, row_index = None, None
|
||||
|
||||
if (cell_id := kwargs.get("cell_id")) is not None:
|
||||
col_pos, row_index = self._get_pos_from_element_id(cell_id)
|
||||
elif (pos := kwargs.get("pos")) is not None:
|
||||
col_pos, row_index = pos
|
||||
|
||||
if col_pos is not None and row_index is not None:
|
||||
col_def = self._columns[col_pos]
|
||||
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
|
||||
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
|
||||
is_last_col = col_pos == len(self._columns) - 1
|
||||
cell = self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower, is_last_col)
|
||||
res.append(cell)
|
||||
|
||||
return tuple(res)
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ class Mouse(MultipleInstance):
|
||||
|
||||
VALID_ACTIONS = {
|
||||
'click', 'right_click', 'rclick',
|
||||
'mousedown>mouseup', 'rmousedown>mouseup'
|
||||
'mousedown>mouseup', 'rmousedown>mouseup',
|
||||
'dblclick', 'double_click', 'dclick'
|
||||
}
|
||||
VALID_MODIFIERS = {'ctrl', 'shift', 'alt'}
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
|
||||
@@ -174,7 +174,7 @@ class Command:
|
||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||
if self._htmx_extra[AUTO_SWAP_OOB]:
|
||||
for index, r in enumerate(all_ret[1:]):
|
||||
if hasattr(r, "__ft__"):
|
||||
if not hasattr(r, 'attrs') and hasattr(r, "__ft__"):
|
||||
r = r.__ft__()
|
||||
all_ret[index + 1] = r
|
||||
if (hasattr(r, 'attrs')
|
||||
|
||||
@@ -318,7 +318,7 @@ class TestDataGridBehaviour:
|
||||
dg = datagrid
|
||||
dg._state.selection.selected = (1, 2)
|
||||
|
||||
dg.on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2")
|
||||
dg.handle_on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2")
|
||||
|
||||
assert dg._state.selection.selected == (1, 2)
|
||||
|
||||
@@ -332,7 +332,7 @@ class TestDataGridBehaviour:
|
||||
dg = datagrid
|
||||
cell_id = f"tcell_{dg._id}-2-5"
|
||||
|
||||
dg.on_click("click", is_inside=True, cell_id=cell_id)
|
||||
dg.handle_on_click("click", is_inside=True, cell_id=cell_id)
|
||||
|
||||
assert dg._state.selection.selected == (2, 5)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user