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

@@ -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;

View File

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

View File

@@ -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()
@@ -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)

View File

@@ -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):

View File

@@ -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')