I can select range with visual feedback

This commit is contained in:
2026-02-10 23:00:45 +01:00
parent 79c37493af
commit 520a8914fc
7 changed files with 353 additions and 25 deletions

View File

@@ -671,12 +671,8 @@
const element = document.getElementById(candidate.elementId);
const dynamicValues = func(event, element, candidate.node.combinationStr);
if (dynamicValues && typeof dynamicValues === 'object') {
// Suffix each key with _mousedown
const suffixed = {};
for (const [key, value] of Object.entries(dynamicValues)) {
suffixed[key + '_mousedown'] = value;
}
mousedownJsValues[candidate.elementId] = suffixed;
// Store raw values - _mousedown suffix added at mouseup time
mousedownJsValues[candidate.elementId] = dynamicValues;
}
}
} catch (e) {
@@ -699,12 +695,39 @@
// Clear pending state
MouseRegistry.mousedownPending = null;
// Remove mousemove listener (no longer needed)
// Remove mousemove listener (threshold detection no longer needed)
if (MouseRegistry.mousemoveHandler) {
document.removeEventListener('mousemove', MouseRegistry.mousemoveHandler);
MouseRegistry.mousemoveHandler = null;
}
// Attach on_move handler if any candidate has 'on-move' config
const onMoveCandidates = MouseRegistry.mousedownState.candidates.filter(
c => c.node.config && c.node.config['on-move']
);
if (onMoveCandidates.length > 0) {
let rafId = null;
MouseRegistry.mousemoveHandler = (moveEvent) => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
for (const candidate of onMoveCandidates) {
const funcName = candidate.node.config['on-move'];
try {
const func = window[funcName];
if (typeof func === 'function') {
const mousedownValues = mousedownJsValues[candidate.elementId] || null;
func(moveEvent, candidate.node.combinationStr, mousedownValues);
}
} catch (e) {
console.error('Error calling on_move function:', e);
}
}
rafId = null;
});
};
document.addEventListener('mousemove', MouseRegistry.mousemoveHandler);
}
// For right button: prevent context menu during drag
if (pendingData.button === 2) {
const preventContextMenu = (e) => {
@@ -907,9 +930,11 @@
Object.assign(values, config['hx-vals']);
}
// 2. Merge mousedown JS values (already suffixed with _mousedown)
// 2. Merge mousedown JS values with _mousedown suffix
if (match.mousedownJsValues) {
Object.assign(values, match.mousedownJsValues);
for (const [key, value] of Object.entries(match.mousedownJsValues)) {
values[key + '_mousedown'] = value;
}
}
// 3. Call JS function at mouseup time, suffix with _mouseup

View File

@@ -186,8 +186,10 @@ function bindTooltipsWithDelegation(elementId) {
// 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")) {
const target = event.target;
// Early exit - check mf-no-tooltip on the registered element OR any ancestor of the target
if (element.hasAttribute("mf-no-tooltip") || target.closest("[mf-no-tooltip]")) {
return;
}
@@ -196,7 +198,7 @@ function bindTooltipsWithDelegation(elementId) {
return;
}
const cell = event.target.closest("[data-tooltip]");
const cell = target.closest("[data-tooltip]");
if (!cell) {
return;
}
@@ -207,7 +209,7 @@ function bindTooltipsWithDelegation(elementId) {
tooltipRafScheduled = false;
// Check again in case tooltip was disabled during RAF delay
if (element.hasAttribute("mf-no-tooltip")) {
if (element.hasAttribute("mf-no-tooltip") || target.closest("[mf-no-tooltip]")) {
return;
}

View File

@@ -126,6 +126,16 @@
background-color: var(--color-selection);
}
.dt2-drag-preview {
background-color: var(--color-selection);
}
/* Selection border - outlines the entire selection rectangle */
.dt2-selection-border-top { border-top: 2px solid var(--color-primary); }
.dt2-selection-border-bottom { border-bottom: 2px solid var(--color-primary); }
.dt2-selection-border-left { border-left: 2px solid var(--color-primary); }
.dt2-selection-border-right { border-right: 2px solid var(--color-primary); }
/* *********************************************** */
/* ******** DataGrid Fixed Header/Footer ******** */

View File

@@ -633,10 +633,17 @@ 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';
// Re-enable tooltips after drag
const wrapper = document.getElementById(`tw_${datagridId}`);
if (wrapper) wrapper.removeAttribute('mf-no-tooltip');
// Clear browser text selection to prevent stale ranges from reappearing
window.getSelection()?.removeAllRanges();
// Clear previous selections and drag preview
document.querySelectorAll('.dt2-selected-focus, .dt2-selected-cell, .dt2-selected-row, .dt2-selected-column, .dt2-drag-preview, .dt2-selection-border-top, .dt2-selection-border-bottom, .dt2-selection-border-left, .dt2-selection-border-right').forEach((element) => {
element.classList.remove('dt2-selected-focus', 'dt2-selected-cell', 'dt2-selected-row', 'dt2-selected-column', 'dt2-drag-preview', 'dt2-selection-border-top', 'dt2-selection-border-bottom', 'dt2-selection-border-left', 'dt2-selection-border-right');
element.style.userSelect = '';
});
// Loop through the children of the selection manager
@@ -654,7 +661,6 @@ function updateDatagridSelection(datagridId) {
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}`);
@@ -687,7 +693,10 @@ function updateDatagridSelection(datagridId) {
const cell = document.getElementById(cellId);
if (cell) {
cell.classList.add('dt2-selected-cell');
cell.style.userSelect = 'text';
if (row === minRowNum) cell.classList.add('dt2-selection-border-top');
if (row === maxRowNum) cell.classList.add('dt2-selection-border-bottom');
if (col === minColNum) cell.classList.add('dt2-selection-border-left');
if (col === maxColNum) cell.classList.add('dt2-selection-border-right');
}
}
}
@@ -710,4 +719,72 @@ function getCellId(event) {
return {cell_id: cell.id};
}
return {cell_id: null};
}
/**
* Highlight the drag selection range in real time during a mousedown>mouseup drag.
* Called by mouse.js on each animation frame while dragging.
* Applies .dt2-drag-preview to all cells in the rectangle between the start and
* current cell. The preview is cleared by updateDatagridSelection() when the server
* responds with the final selection.
*
* @param {MouseEvent} event - The current mousemove event
* @param {string} combination - The active mouse combination (e.g. "mousedown>mouseup")
* @param {Object|null} mousedownResult - Result of getCellId() at mousedown, or null
*/
function highlightDatagridDragRange(event, combination, mousedownResult) {
if (!mousedownResult || !mousedownResult.cell_id) return;
const currentCell = event.target.closest('.dt2-cell');
if (!currentCell || !currentCell.id) return;
const startCellId = mousedownResult.cell_id;
const endCellId = currentCell.id;
// Find the table from the start cell to scope the query
const startCell = document.getElementById(startCellId);
if (!startCell) return;
const table = startCell.closest('.dt2-table');
if (!table) return;
// Extract grid ID from table id: "t_{gridId}" -> "{gridId}"
const gridId = table.id.substring(2);
// Disable tooltips during drag
const wrapper = document.getElementById(`tw_${gridId}`);
if (wrapper) wrapper.setAttribute('mf-no-tooltip', '');
// Parse col/row by splitting on "-" and taking the last two numeric parts
const startParts = startCellId.split('-');
const startCol = parseInt(startParts[startParts.length - 2]);
const startRow = parseInt(startParts[startParts.length - 1]);
const endParts = endCellId.split('-');
const endCol = parseInt(endParts[endParts.length - 2]);
const endRow = parseInt(endParts[endParts.length - 1]);
if (isNaN(startCol) || isNaN(startRow) || isNaN(endCol) || isNaN(endRow)) return;
// Clear previous selection and drag preview within this table
table.querySelectorAll('.dt2-drag-preview, .dt2-selected-cell, .dt2-selected-row, .dt2-selected-column, .dt2-selected-focus, .dt2-selection-border-top, .dt2-selection-border-bottom, .dt2-selection-border-left, .dt2-selection-border-right')
.forEach(c => c.classList.remove('dt2-drag-preview', 'dt2-selected-cell', 'dt2-selected-row', 'dt2-selected-column', 'dt2-selected-focus', 'dt2-selection-border-top', 'dt2-selection-border-bottom', 'dt2-selection-border-left', 'dt2-selection-border-right'));
// Apply preview to all cells in the rectangular range
const minCol = Math.min(startCol, endCol);
const maxCol = Math.max(startCol, endCol);
const minRow = Math.min(startRow, endRow);
const maxRow = Math.max(startRow, endRow);
for (let col = minCol; col <= maxCol; col++) {
for (let row = minRow; row <= maxRow; row++) {
const cell = document.getElementById(`tcell_${gridId}-${col}-${row}`);
if (cell) {
cell.classList.add('dt2-drag-preview');
if (row === minRow) cell.classList.add('dt2-selection-border-top');
if (row === maxRow) cell.classList.add('dt2-selection-border-bottom');
if (col === minCol) cell.classList.add('dt2-selection-border-left');
if (col === maxCol) cell.classList.add('dt2-selection-border-right');
}
}
}
}

View File

@@ -238,7 +238,9 @@ class DataGrid(MultipleInstance):
# other definitions
self._mouse_support = {
"mousedown>mouseup": {"command": self.commands.on_mouse_selection(), "hx_vals": "js:getCellId()"},
"mousedown>mouseup": {"command": self.commands.on_mouse_selection(),
"hx_vals": "js:getCellId()",
"on_move": "js:highlightDatagridDragRange()"},
"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()"},
@@ -500,6 +502,9 @@ class DataGrid(MultipleInstance):
if (is_inside and
cell_id_mousedown and cell_id_mouseup and
cell_id_mousedown.startswith("tcell_") and cell_id_mouseup.startswith("tcell_")):
self._update_current_position(None)
pos_mouse_down = self._get_pos_from_element_id(cell_id_mousedown)
pos_mouse_up = self._get_pos_from_element_id(cell_id_mouseup)

View File

@@ -78,7 +78,8 @@ class Mouse(MultipleInstance):
def add(self, sequence: str, command: Command = None, *,
hx_post: str = None, hx_get: str = None, hx_put: str = None,
hx_delete: str = None, hx_patch: str = None,
hx_target: str = None, hx_swap: str = None, hx_vals=None):
hx_target: str = None, hx_swap: str = None, hx_vals=None,
on_move: str = None):
"""
Add a mouse combination with optional command and HTMX parameters.
@@ -99,6 +100,11 @@ class Mouse(MultipleInstance):
hx_vals: HTMX values dict or "js:functionName()" for dynamic values.
For mousedown>mouseup actions, the JS function is called at both
mousedown and mouseup, with results suffixed ``_mousedown`` and ``_mouseup``.
on_move: Client-side JS function called on each animation frame during a drag,
using ``"js:functionName()"`` format. Only valid with ``mousedown>mouseup``
sequences. The function receives ``(event, combination, mousedown_result)``
where ``mousedown_result`` is the raw result of ``hx_vals`` at mousedown,
or ``None`` if ``hx_vals`` is not set. Return value is ignored.
Returns:
self for method chaining
@@ -117,6 +123,7 @@ class Mouse(MultipleInstance):
"hx_target": hx_target,
"hx_swap": hx_swap,
"hx_vals": hx_vals,
"on_move": on_move,
}
return self
@@ -172,6 +179,15 @@ class Mouse(MultipleInstance):
except json.JSONDecodeError as e:
raise ValueError(f"hx_vals must be a dict or 'js:functionName()', got invalid JSON: {e}")
# Handle on_move - client-side function for real-time drag feedback
on_move = combination_data.get("on_move")
if on_move is not None:
if isinstance(on_move, str) and on_move.startswith("js:"):
func_name = on_move[3:].rstrip("()")
params["on-move"] = func_name
else:
raise ValueError(f"on_move must be 'js:functionName()', got: {on_move!r}")
return params
def render(self):