I can edit a cell

This commit is contained in:
2026-03-16 21:16:21 +01:00
parent 0951680466
commit ef9f269a49
8 changed files with 182 additions and 24 deletions

View File

@@ -205,7 +205,7 @@ def render(self):
return Div( return Div(
self._mk_content(), self._mk_content(),
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()), 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 id=self._id
) )
``` ```

View File

@@ -209,13 +209,14 @@ For interactive controls, compose `Keyboard` and `Mouse`:
from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.Mouse import Mouse
def render(self): def render(self):
return Div( return Div(
self._mk_content(), self._mk_content(),
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()), 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 id=self._id
) )
``` ```
--- ---

View File

@@ -14,6 +14,7 @@
pendingMatches: [], // Array of matches waiting for timeout pendingMatches: [], // Array of matches waiting for timeout
sequenceTimeout: 500, // 500ms timeout for sequences sequenceTimeout: 500, // 500ms timeout for sequences
clickHandler: null, clickHandler: null,
dblclickHandler: null, // Handler reference for dblclick
contextmenuHandler: null, contextmenuHandler: null,
mousedownState: null, // Active drag state (only after movement detected) mousedownState: null, // Active drag state (only after movement detected)
suppressNextClick: false, // Prevents click from firing after mousedown>mouseup suppressNextClick: false, // Prevents click from firing after mousedown>mouseup
@@ -35,7 +36,9 @@
// Handle aliases // Handle aliases
const aliasMap = { const aliasMap = {
'rclick': 'right_click' 'rclick': 'right_click',
'double_click': 'dblclick',
'dclick': 'dblclick'
}; };
return aliasMap[normalized] || normalized; 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 * Clean up mousedown state and safety timeout
*/ */
@@ -989,11 +1029,13 @@
if (!MouseRegistry.listenerAttached) { if (!MouseRegistry.listenerAttached) {
// Store handler references for proper removal // Store handler references for proper removal
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click'); MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
MouseRegistry.dblclickHandler = (e) => handleDblClick(e);
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click'); MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
MouseRegistry.mousedownHandler = (e) => handleMouseDown(e); MouseRegistry.mousedownHandler = (e) => handleMouseDown(e);
MouseRegistry.mouseupHandler = (e) => handleMouseUp(e); MouseRegistry.mouseupHandler = (e) => handleMouseUp(e);
document.addEventListener('click', MouseRegistry.clickHandler); document.addEventListener('click', MouseRegistry.clickHandler);
document.addEventListener('dblclick', MouseRegistry.dblclickHandler);
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler); document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
document.addEventListener('mousedown', MouseRegistry.mousedownHandler); document.addEventListener('mousedown', MouseRegistry.mousedownHandler);
document.addEventListener('mouseup', MouseRegistry.mouseupHandler); document.addEventListener('mouseup', MouseRegistry.mouseupHandler);
@@ -1007,6 +1049,7 @@
function detachGlobalListener() { function detachGlobalListener() {
if (MouseRegistry.listenerAttached) { if (MouseRegistry.listenerAttached) {
document.removeEventListener('click', MouseRegistry.clickHandler); document.removeEventListener('click', MouseRegistry.clickHandler);
document.removeEventListener('dblclick', MouseRegistry.dblclickHandler);
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler); document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
document.removeEventListener('mousedown', MouseRegistry.mousedownHandler); document.removeEventListener('mousedown', MouseRegistry.mousedownHandler);
document.removeEventListener('mouseup', MouseRegistry.mouseupHandler); document.removeEventListener('mouseup', MouseRegistry.mouseupHandler);
@@ -1014,6 +1057,7 @@
// Clean up handler references // Clean up handler references
MouseRegistry.clickHandler = null; MouseRegistry.clickHandler = null;
MouseRegistry.dblclickHandler = null;
MouseRegistry.contextmenuHandler = null; MouseRegistry.contextmenuHandler = null;
MouseRegistry.mousedownHandler = null; MouseRegistry.mousedownHandler = null;
MouseRegistry.mouseupHandler = null; MouseRegistry.mouseupHandler = null;

View File

@@ -3,7 +3,7 @@ function initDataGrid(gridId) {
initDataGridMouseOver(gridId); initDataGridMouseOver(gridId);
makeDatagridColumnsResizable(gridId); makeDatagridColumnsResizable(gridId);
makeDatagridColumnsMovable(gridId); makeDatagridColumnsMovable(gridId);
updateDatagridSelection(gridId) updateDatagridSelection(gridId);
} }
/** /**

View File

@@ -1,7 +1,6 @@
import html import html
import logging import logging
import re import re
from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Optional from typing import Optional
@@ -18,9 +17,7 @@ from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.Query import Query, QUERY_FILTER from myfasthtml.controls.Query import Query, QUERY_FILTER
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState, \ from myfasthtml.controls.datagrid_objects import *
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState, DataGridColumnUiState, \
DataGridRowSelectionColumnState
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.constants import ColumnType, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
@@ -156,7 +153,7 @@ class Commands(BaseCommands):
return Command("OnClick", return Command("OnClick",
"Click on the table", "Click on the table",
self._owner, self._owner,
self._owner.on_click self._owner.handle_on_click
).htmx(target=f"#tsm_{self._id}") ).htmx(target=f"#tsm_{self._id}")
def on_key_pressed(self): def on_key_pressed(self):
@@ -213,6 +210,21 @@ class Commands(BaseCommands):
self._owner.on_column_changed 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): class DataGrid(MultipleInstance):
def __init__(self, parent, conf=None, save_state=None, _id=None): def __init__(self, parent, conf=None, save_state=None, _id=None):
@@ -293,10 +305,12 @@ class DataGrid(MultipleInstance):
"click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, "click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
"ctrl+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()"}, "shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
"dblclick": {"command": self.commands.start_edition(), "hx_vals": "js:getCellId()"},
} }
self._key_support = { self._key_support = {
"esc": {"command": self.commands.on_key_pressed(), "require_inside": False}, "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.") 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: if self._settings.enable_edition:
self._columns.insert(0, DataGridRowSelectionColumnState()) 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: def add_new_column(self, col_def: DataGridColumnState) -> None:
"""Add a new column, delegating data mutation to DataService. """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() self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
return self.render_partial("body") 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=}") logger.debug(f"on_click table={self.get_table_name()} {combination=} {is_inside=} {cell_id=}")
if is_inside and cell_id: if is_inside and cell_id:
self._state.selection.extra_selected.clear() self._state.selection.extra_selected.clear()
if cell_id.startswith("tcell_"): pos = self._get_pos_from_element_id(cell_id)
pos = self._get_pos_from_element_id(cell_id)
self._update_current_position(pos) 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() return self.render_partial()
@@ -605,6 +645,11 @@ class DataGrid(MultipleInstance):
if combination == "esc": if combination == "esc":
self._update_current_position(None) self._update_current_position(None)
self._state.selection.extra_selected.clear() 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() return self.render_partial()
@@ -674,6 +719,30 @@ class DataGrid(MultipleInstance):
self._panel.set_title(side="right", title="Formatting") self._panel.set_title(side="right", title="Formatting")
self._panel.set_right(self._formatting_editor) 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): def handle_set_column_width(self, col_id: str, width: str):
"""Update column width after resize. Called via Command from JS.""" """Update column width after resize. Called via Command from JS."""
logger.debug(f"set_column_width: {col_id=} {width=}") 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): def get_data_service_id_from_data_grid_id(datagrid_id):
return datagrid_id.replace(DataGrid.compute_prefix(), DataService.compute_prefix(), 1) 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): def mk_headers(self):
resize_cmd = self.commands.set_column_width() resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column() move_cmd = self.commands.move_column()
@@ -867,6 +961,10 @@ class DataGrid(MultipleInstance):
if col_def.type == ColumnType.RowSelection_: if col_def.type == ColumnType.RowSelection_:
return OptimizedDiv(cls="dt2-row-selection") 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) 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 = 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) 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) :param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header)
:return: :return:
""" """
res = [] res = [self.mk_selection_manager()]
extra_attr = { extra_attr = {
"hx-on::after-settle": f"initDataGrid('{self._id}');", "hx-on::after-settle": f"initDataGrid('{self._id}');",
@@ -1133,7 +1231,21 @@ class DataGrid(MultipleInstance):
header.attrs.update(header_extra_attr) header.attrs.update(header_extra_attr)
return header 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) return tuple(res)

View File

@@ -36,7 +36,8 @@ class Mouse(MultipleInstance):
VALID_ACTIONS = { VALID_ACTIONS = {
'click', 'right_click', 'rclick', 'click', 'right_click', 'rclick',
'mousedown>mouseup', 'rmousedown>mouseup' 'mousedown>mouseup', 'rmousedown>mouseup',
'dblclick', 'double_click', 'dclick'
} }
VALID_MODIFIERS = {'ctrl', 'shift', 'alt'} VALID_MODIFIERS = {'ctrl', 'shift', 'alt'}
def __init__(self, parent, _id=None, combinations=None): def __init__(self, parent, _id=None, combinations=None):

View File

@@ -174,7 +174,7 @@ class Command:
# Set the hx-swap-oob attribute on all elements returned by the callback # Set the hx-swap-oob attribute on all elements returned by the callback
if self._htmx_extra[AUTO_SWAP_OOB]: if self._htmx_extra[AUTO_SWAP_OOB]:
for index, r in enumerate(all_ret[1:]): for index, r in enumerate(all_ret[1:]):
if hasattr(r, "__ft__"): if not hasattr(r, 'attrs') and hasattr(r, "__ft__"):
r = r.__ft__() r = r.__ft__()
all_ret[index + 1] = r all_ret[index + 1] = r
if (hasattr(r, 'attrs') if (hasattr(r, 'attrs')

View File

@@ -318,7 +318,7 @@ class TestDataGridBehaviour:
dg = datagrid dg = datagrid
dg._state.selection.selected = (1, 2) 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) assert dg._state.selection.selected == (1, 2)
@@ -332,7 +332,7 @@ class TestDataGridBehaviour:
dg = datagrid dg = datagrid
cell_id = f"tcell_{dg._id}-2-5" 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) assert dg._state.selection.selected == (2, 5)