diff --git a/src/myfasthtml/assets/core/mouse.js b/src/myfasthtml/assets/core/mouse.js index a44d72e..666a544 100644 --- a/src/myfasthtml/assets/core/mouse.js +++ b/src/myfasthtml/assets/core/mouse.js @@ -14,7 +14,15 @@ pendingMatches: [], // Array of matches waiting for timeout sequenceTimeout: 500, // 500ms timeout for sequences clickHandler: null, - contextmenuHandler: null + contextmenuHandler: null, + mousedownState: null, // Active drag state (only after movement detected) + suppressNextClick: false, // Prevents click from firing after mousedown>mouseup + mousedownHandler: null, // Handler reference for cleanup + mouseupHandler: null, // Handler reference for cleanup + mousedownSafetyTimeout: null, // Safety timeout if mouseup never arrives + mousedownPending: null, // Pending mousedown data (before movement detected) + mousemoveHandler: null, // Handler for detecting drag movement + dragThreshold: 5 // Minimum pixels to consider it a drag (not a click) }; /** @@ -33,6 +41,58 @@ return aliasMap[normalized] || normalized; } + /** + * Check if an action Set contains a mousedown>mouseup action + * @param {Set} actionSet - Set of normalized actions + * @returns {boolean} - True if contains mousedown>mouseup or rmousedown>mouseup + */ + function isMousedownUpAction(actionSet) { + return actionSet.has('mousedown>mouseup') || actionSet.has('rmousedown>mouseup'); + } + + /** + * Get the expected mouse button for a mousedown>mouseup action + * @param {Set} actionSet - Set of normalized actions + * @returns {number} - 0 for left button, 2 for right button + */ + function getMousedownUpButton(actionSet) { + return actionSet.has('rmousedown>mouseup') ? 2 : 0; + } + + /** + * Scan all registered element trees to find mousedown>mouseup candidates + * that are the next expected step given the current snapshot history. + * @param {Array} snapshotHistory - Current snapshot history + * @returns {Array} - Array of { elementId, node, actionSet, button } candidates + */ + function checkTreesForMousedownUp(snapshotHistory) { + const candidates = []; + + for (const [elementId, data] of MouseRegistry.elements) { + const element = document.getElementById(elementId); + if (!element) continue; + + // Traverse tree to current position in history + const currentNode = traverseTree(data.tree, snapshotHistory); + if (!currentNode) continue; + + // Check children for mousedown>mouseup actions + for (const [key, childNode] of currentNode.children) { + const actionSet = new Set(key.split('+')); + if (isMousedownUpAction(actionSet)) { + candidates.push({ + elementId: elementId, + node: childNode, + actionSet: actionSet, + button: getMousedownUpButton(actionSet) + }); + } + } + } + + return candidates; + } + /** * Create a unique string key from a Set of actions for Map indexing * @param {Set} actionSet - Set of normalized actions @@ -226,6 +286,16 @@ * @param {MouseEvent} event - The mouse event */ function handleGlobalClick(event) { + // Suppress click that fires after mousedown>mouseup drag + if (MouseRegistry.suppressNextClick) { + MouseRegistry.suppressNextClick = false; + return; + } + + // Don't process clicks during active drag mode (movement detected) + // But DO process clicks if only mousedownPending (no movement = normal click) + if (MouseRegistry.mousedownState) return; + // DEBUG: Measure click handler performance const clickStart = performance.now(); const elementCount = MouseRegistry.elements.size; @@ -362,6 +432,10 @@ * @param {MouseEvent} event - The mouse event */ function handleElementRightClick(event) { + // Don't process right-clicks during active drag with right button + // But DO process if only pending (no movement = normal right-click) + if (MouseRegistry.mousedownState && MouseRegistry.mousedownState.button === 2) return; + // Find which registered element was clicked const elementId = findRegisteredElement(event.target); @@ -489,6 +563,400 @@ } } + /** + * Clean up mousedown state and safety timeout + */ + function clearMousedownState() { + if (MouseRegistry.mousedownSafetyTimeout) { + clearTimeout(MouseRegistry.mousedownSafetyTimeout); + MouseRegistry.mousedownSafetyTimeout = null; + } + + // Remove mousemove listener if attached + if (MouseRegistry.mousemoveHandler) { + document.removeEventListener('mousemove', MouseRegistry.mousemoveHandler); + MouseRegistry.mousemoveHandler = null; + } + + MouseRegistry.mousedownState = null; + MouseRegistry.mousedownPending = null; + } + + /** + * Handle global mousedown events for mousedown>mouseup actions. + * Stores pending state and waits for movement to activate drag mode. + * @param {MouseEvent} event - The mousedown event + */ + function handleMouseDown(event) { + // Exit early if already in mousedown state or pending + if (MouseRegistry.mousedownState || MouseRegistry.mousedownPending) return; + + // Check if any registered tree has a mousedown>mouseup action as the next step + const candidates = checkTreesForMousedownUp(MouseRegistry.snapshotHistory); + if (candidates.length === 0) return; + + // Filter candidates by button match + const matchingCandidates = candidates.filter(c => c.button === event.button); + if (matchingCandidates.length === 0) return; + + // Check if mousedown target is inside any registered element + const hasFocusMap = {}; + for (const candidate of matchingCandidates) { + const element = document.getElementById(candidate.elementId); + if (element) { + hasFocusMap[candidate.elementId] = document.activeElement === element; + } + } + + // Store PENDING mousedown state (not activated yet) + MouseRegistry.mousedownPending = { + button: event.button, + ctrlKey: event.ctrlKey || event.metaKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + startX: event.clientX, + startY: event.clientY, + mousedownEvent: event, + hasFocusMap: hasFocusMap, + candidates: matchingCandidates + }; + + // Add mousemove listener to detect drag + MouseRegistry.mousemoveHandler = (moveEvent) => { + if (!MouseRegistry.mousedownPending) return; + + const deltaX = Math.abs(moveEvent.clientX - MouseRegistry.mousedownPending.startX); + const deltaY = Math.abs(moveEvent.clientY - MouseRegistry.mousedownPending.startY); + + // If moved beyond threshold, activate drag mode + if (deltaX > MouseRegistry.dragThreshold || deltaY > MouseRegistry.dragThreshold) { + activateDragMode(MouseRegistry.mousedownPending); + } + }; + document.addEventListener('mousemove', MouseRegistry.mousemoveHandler); + + // Safety timeout: clean up if mouseup never arrives (5 seconds) + MouseRegistry.mousedownSafetyTimeout = setTimeout(() => { + clearMousedownState(); + }, 5000); + } + + /** + * Activate drag mode after movement detected. + * Called by mousemove handler when threshold exceeded. + * @param {Object} pendingData - The pending mousedown data + */ + function activateDragMode(pendingData) { + if (!pendingData) return; + + const event = pendingData.mousedownEvent; + + // Suspend sequence timeout (keep pendingMatches but stop the timer) + if (MouseRegistry.pendingTimeout) { + clearTimeout(MouseRegistry.pendingTimeout); + MouseRegistry.pendingTimeout = null; + } + + // Call hx-vals-extra JS functions at mousedown time, suffix results with _mousedown + const mousedownJsValues = {}; + for (const candidate of pendingData.candidates) { + const config = candidate.node.config; + if (!config) continue; + + const extra = config['hx-vals-extra']; + if (extra && extra.js) { + try { + const func = window[extra.js]; + if (typeof func === 'function') { + 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; + } + } + } catch (e) { + console.error('Error calling dynamic hx-vals function at mousedown:', e); + } + } + } + + // Activate drag state + MouseRegistry.mousedownState = { + button: pendingData.button, + ctrlKey: pendingData.ctrlKey, + shiftKey: pendingData.shiftKey, + altKey: pendingData.altKey, + hasFocusMap: pendingData.hasFocusMap, + candidates: pendingData.candidates, + mousedownJsValues: mousedownJsValues + }; + + // Clear pending state + MouseRegistry.mousedownPending = null; + + // Remove mousemove listener (no longer needed) + if (MouseRegistry.mousemoveHandler) { + document.removeEventListener('mousemove', MouseRegistry.mousemoveHandler); + MouseRegistry.mousemoveHandler = null; + } + + // For right button: prevent context menu during drag + if (pendingData.button === 2) { + const preventContextMenu = (e) => { + e.preventDefault(); + }; + document.addEventListener('contextmenu', preventContextMenu, { capture: true, once: true }); + } + + // Prevent default if on a registered element and not in input context + const targetElementId = findRegisteredElement(event.target); + if (targetElementId && !isInInputContext()) { + event.preventDefault(); + } + } + + /** + * Handle global mouseup events for mousedown>mouseup actions. + * Resolves the mousedown>mouseup action when mouseup fires (only if drag was activated). + * @param {MouseEvent} event - The mouseup event + */ + function handleMouseUp(event) { + // Case 1: Drag mode was activated (movement detected) + if (MouseRegistry.mousedownState) { + const mdState = MouseRegistry.mousedownState; + + // Verify button matches mousedown button + if (event.button !== mdState.button) return; + + // Build action name based on button + const actionName = mdState.button === 2 ? 'rmousedown>mouseup' : 'mousedown>mouseup'; + + // Build snapshot Set with modifiers from mousedown time + const snapshot = new Set([actionName]); + if (mdState.ctrlKey) snapshot.add('ctrl'); + if (mdState.shiftKey) snapshot.add('shift'); + if (mdState.altKey) snapshot.add('alt'); + + // Add snapshot to history + MouseRegistry.snapshotHistory.push(snapshot); + + // Clear mousedown state + clearMousedownState(); + + // Suppress next click for left button (click fires after mouseup) + if (mdState.button === 0) { + MouseRegistry.suppressNextClick = true; + } + + // Resolve with tree-traversal matching + resolveAfterMouseUp(event, mdState); + return; + } + + // Case 2: Mousedown pending but no movement (it's a click, not a drag) + if (MouseRegistry.mousedownPending) { + const pending = MouseRegistry.mousedownPending; + + // Verify button matches + if (event.button !== pending.button) return; + + // Clean up pending state - let the normal click handler process it + clearMousedownState(); + // Don't suppress click - we want it to fire normally + return; + } + } + + /** + * Resolve mousedown>mouseup action using tree-traversal matching. + * Same logic as handleGlobalClick but uses triggerMousedownUpAction. + * @param {MouseEvent} mouseupEvent - The mouseup event + * @param {Object} mdState - The mousedown state captured earlier + */ + function resolveAfterMouseUp(mouseupEvent, mdState) { + const currentMatches = []; + let anyHasLongerSequence = false; + let foundAnyMatch = false; + + for (const [elementId, data] of MouseRegistry.elements) { + const element = document.getElementById(elementId); + if (!element) continue; + + // isInside is based on mouseup target position + const isInside = element.contains(mouseupEvent.target); + + const treeRoot = data.tree; + const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory); + + if (!currentNode) continue; + + foundAnyMatch = true; + + const hasMatch = currentNode.config !== null; + const hasLongerSequences = currentNode.children.size > 0; + + if (hasLongerSequences) { + anyHasLongerSequence = true; + } + + if (hasMatch) { + currentMatches.push({ + elementId: elementId, + config: currentNode.config, + combinationStr: currentNode.combinationStr, + isInside: isInside, + hasFocus: mdState.hasFocusMap[elementId] || false, + mousedownJsValues: mdState.mousedownJsValues[elementId] || {} + }); + } + } + + // Clear pending matches from before mousedown + MouseRegistry.pendingMatches = []; + + // Decision logic (same pattern as handleGlobalClick) + if (currentMatches.length > 0 && !anyHasLongerSequence) { + for (const match of currentMatches) { + triggerMousedownUpAction(match, mouseupEvent); + } + MouseRegistry.snapshotHistory = []; + + } else if (currentMatches.length > 0 && anyHasLongerSequence) { + MouseRegistry.pendingMatches = currentMatches.map(m => ({ ...m, mouseupEvent: mouseupEvent })); + + MouseRegistry.pendingTimeout = setTimeout(() => { + for (const match of MouseRegistry.pendingMatches) { + triggerMousedownUpAction(match, match.mouseupEvent); + } + MouseRegistry.snapshotHistory = []; + MouseRegistry.pendingMatches = []; + MouseRegistry.pendingTimeout = null; + }, MouseRegistry.sequenceTimeout); + + } else if (currentMatches.length === 0 && anyHasLongerSequence) { + // Wait for more input + + } else { + MouseRegistry.snapshotHistory = []; + } + + if (!foundAnyMatch) { + MouseRegistry.snapshotHistory = []; + } + + if (MouseRegistry.snapshotHistory.length > 10) { + MouseRegistry.snapshotHistory = []; + } + } + + /** + * Trigger an HTMX action for a mousedown>mouseup match. + * Similar to triggerHtmxAction but merges suffixed JS values from both phases. + * @param {Object} match - The match object with config, elementId, etc. + * @param {MouseEvent} mouseupEvent - The mouseup event + */ + function triggerMousedownUpAction(match, mouseupEvent) { + const element = document.getElementById(match.elementId); + if (!element) return; + + const config = match.config; + + // Extract HTTP method and URL + let method = 'POST'; + 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 = {}; + + if (config['hx-target']) { + htmxOptions.target = config['hx-target']; + } + + if (config['hx-swap']) { + htmxOptions.swap = config['hx-swap']; + } + + // Build values + const values = {}; + + // 1. Merge static hx-vals from command + if (config['hx-vals'] && typeof config['hx-vals'] === 'object') { + Object.assign(values, config['hx-vals']); + } + + // 2. Merge mousedown JS values (already suffixed with _mousedown) + if (match.mousedownJsValues) { + Object.assign(values, match.mousedownJsValues); + } + + // 3. Call JS function at mouseup time, suffix with _mouseup + 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 suffix with _mouseup + if (extra.js) { + try { + const func = window[extra.js]; + if (typeof func === 'function') { + const dynamicValues = func(mouseupEvent, element, match.combinationStr); + if (dynamicValues && typeof dynamicValues === 'object') { + for (const [key, value] of Object.entries(dynamicValues)) { + values[key + '_mouseup'] = value; + } + } + } + } catch (e) { + console.error('Error calling dynamic hx-vals function at mouseup:', e); + } + } + } + + // 4. Add auto params + values.combination = match.combinationStr; + values.has_focus = match.hasFocus; + values.is_inside = match.isInside; + + htmxOptions.values = values; + + // Add any other hx-* attributes + 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', 'hx-vals-extra'].includes(key)) { + const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); + htmxOptions[optionKey] = value; + } + } + + htmx.ajax(method, url, htmxOptions); + } + /** * Attach the global mouse event listeners if not already attached */ @@ -497,9 +965,13 @@ // Store handler references for proper removal MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click'); MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click'); + MouseRegistry.mousedownHandler = (e) => handleMouseDown(e); + MouseRegistry.mouseupHandler = (e) => handleMouseUp(e); document.addEventListener('click', MouseRegistry.clickHandler); document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler); + document.addEventListener('mousedown', MouseRegistry.mousedownHandler); + document.addEventListener('mouseup', MouseRegistry.mouseupHandler); MouseRegistry.listenerAttached = true; } } @@ -511,11 +983,15 @@ if (MouseRegistry.listenerAttached) { document.removeEventListener('click', MouseRegistry.clickHandler); document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler); + document.removeEventListener('mousedown', MouseRegistry.mousedownHandler); + document.removeEventListener('mouseup', MouseRegistry.mouseupHandler); MouseRegistry.listenerAttached = false; // Clean up handler references MouseRegistry.clickHandler = null; MouseRegistry.contextmenuHandler = null; + MouseRegistry.mousedownHandler = null; + MouseRegistry.mouseupHandler = null; // Clean up all state MouseRegistry.snapshotHistory = []; @@ -524,6 +1000,9 @@ MouseRegistry.pendingTimeout = null; } MouseRegistry.pendingMatches = []; + clearMousedownState(); + MouseRegistry.suppressNextClick = false; + MouseRegistry.mousedownPending = null; } } diff --git a/src/myfasthtml/assets/datagrid/datagrid.js b/src/myfasthtml/assets/datagrid/datagrid.js index bb097ec..d8f54a1 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.js +++ b/src/myfasthtml/assets/datagrid/datagrid.js @@ -666,6 +666,32 @@ function updateDatagridSelection(datagridId) { document.querySelectorAll(`[data-col="${elementId}"]`).forEach((columnElement) => { columnElement.classList.add('dt2-selected-column'); }); + } else if (selectionType === 'range') { + // Parse range tuple string: "(min_col,min_row,max_col,max_row)" + // Remove parentheses and split + const cleanedId = elementId.replace(/[()]/g, ''); + const parts = cleanedId.split(','); + if (parts.length === 4) { + const [minCol, minRow, maxCol, maxRow] = parts; + + // Convert to integers + const minColNum = parseInt(minCol); + const maxColNum = parseInt(maxCol); + const minRowNum = parseInt(minRow); + const maxRowNum = parseInt(maxRow); + + // Iterate through range and select cells by reconstructed ID + for (let col = minColNum; col <= maxColNum; col++) { + for (let row = minRowNum; row <= maxRowNum; row++) { + const cellId = `tcell_${datagridId}-${col}-${row}`; + const cell = document.getElementById(cellId); + if (cell) { + cell.classList.add('dt2-selected-cell'); + cell.style.userSelect = 'text'; + } + } + } + } } }); } diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 3ab6893..f3cf514 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -152,6 +152,13 @@ class Commands(BaseCommands): self._owner.on_click ).htmx(target=f"#tsm_{self._id}") + def on_mouse_selection(self): + return Command("OnMouseSelection", + "Range selection with mouse", + self._owner, + self._owner.on_mouse_selection + ).htmx(target=f"#tsm_{self._id}") + def toggle_columns_manager(self): return Command("ToggleColumnsManager", "Hide/Show Columns Manager", @@ -231,6 +238,7 @@ class DataGrid(MultipleInstance): # other definitions self._mouse_support = { + "mousedown>mouseup": {"command": self.commands.on_mouse_selection(), "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()"}, "shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, @@ -479,12 +487,30 @@ class DataGrid(MultipleInstance): def on_click(self, combination, is_inside, cell_id): logger.debug(f"on_click {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) return self.render_partial() + def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup): + logger.debug(f"on_mouse_selection {combination=} {is_inside=} {cell_id_mousedown=} {cell_id_mouseup=}") + if (is_inside and + cell_id_mousedown and cell_id_mouseup and + cell_id_mousedown.startswith("tcell_") and cell_id_mouseup.startswith("tcell_")): + pos_mouse_down = self._get_pos_from_element_id(cell_id_mousedown) + pos_mouse_up = self._get_pos_from_element_id(cell_id_mouseup) + + min_col, max_col = min(pos_mouse_down[0], pos_mouse_up[0]), max(pos_mouse_down[0], pos_mouse_up[0]) + min_row, max_row = min(pos_mouse_down[1], pos_mouse_up[1]), max(pos_mouse_down[1], pos_mouse_up[1]) + + self._state.selection.extra_selected.clear() + self._state.selection.extra_selected.append(("range", (min_col, min_row, max_col, max_row))) + + return self.render_partial() + def on_column_changed(self): logger.debug("on_column_changed") return self.render_partial("table") @@ -752,6 +778,9 @@ class DataGrid(MultipleInstance): # selected.append(("cell", self._get_element_id_from_pos("cell", self._state.selection.selected))) selected.append(("focus", self._get_element_id_from_pos("cell", self._state.selection.selected))) + for extra_sel in self._state.selection.extra_selected: + selected.append(extra_sel) + return Div( *[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected], id=f"tsm_{self._id}", diff --git a/src/myfasthtml/controls/Mouse.py b/src/myfasthtml/controls/Mouse.py index 4282d6f..2d0a51f 100644 --- a/src/myfasthtml/controls/Mouse.py +++ b/src/myfasthtml/controls/Mouse.py @@ -19,11 +19,62 @@ class Mouse(MultipleInstance): - Both (named params override command): mouse.add("click", command, hx_target="#other") For dynamic hx_vals, use "js:functionName()" to call a client-side function. + + Supported base actions: + - ``click`` - Left mouse click (detected globally) + - ``right_click`` (or alias ``rclick``) - Right mouse click (detected on element only) + - ``mousedown>mouseup`` - Left mouse press-and-release (captures data at both phases) + - ``rmousedown>mouseup`` - Right mouse press-and-release + + Modifiers can be combined with ``+``: ``ctrl+click``, ``shift+mousedown>mouseup``. + Sequences use space separation: ``click right_click``, ``click mousedown>mouseup``. + + For ``mousedown>mouseup`` actions with ``hx_vals="js:functionName()"``, the JS function + is called at both mousedown and mouseup. Results are suffixed: ``key_mousedown`` and + ``key_mouseup`` in the server request. """ + + VALID_ACTIONS = { + 'click', 'right_click', 'rclick', + 'mousedown>mouseup', 'rmousedown>mouseup' + } + VALID_MODIFIERS = {'ctrl', 'shift', 'alt'} def __init__(self, parent, _id=None, combinations=None): super().__init__(parent, _id=_id) self.combinations = combinations or {} + def _validate_sequence(self, sequence: str): + """ + Validate a mouse event sequence string. + + Checks that all elements in the sequence use valid action names and modifiers. + + Args: + sequence: Mouse event sequence string (e.g., "click", "ctrl+mousedown>mouseup") + + Raises: + ValueError: If any action or modifier is invalid. + """ + elements = sequence.strip().split() + for element in elements: + parts = element.split('+') + # Last part should be the action, others are modifiers + action = parts[-1].lower() + modifiers = [p.lower() for p in parts[:-1]] + + if action not in self.VALID_ACTIONS: + raise ValueError( + f"Invalid action '{action}' in sequence '{sequence}'. " + f"Valid actions: {', '.join(sorted(self.VALID_ACTIONS))}" + ) + + for mod in modifiers: + if mod not in self.VALID_MODIFIERS: + raise ValueError( + f"Invalid modifier '{mod}' in sequence '{sequence}'. " + f"Valid modifiers: {', '.join(sorted(self.VALID_MODIFIERS))}" + ) + 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, @@ -32,7 +83,11 @@ class Mouse(MultipleInstance): Add a mouse combination with optional command and HTMX parameters. Args: - sequence: Mouse event sequence (e.g., "click", "ctrl+click", "click right_click") + sequence: Mouse event sequence string. Supports: + - Simple actions: ``"click"``, ``"right_click"``, ``"mousedown>mouseup"`` + - Modifiers: ``"ctrl+click"``, ``"shift+mousedown>mouseup"`` + - Sequences: ``"click right_click"``, ``"click mousedown>mouseup"`` + - Aliases: ``"rclick"`` for ``"right_click"`` command: Optional Command object for server-side action hx_post: HTMX post URL (overrides command) hx_get: HTMX get URL (overrides command) @@ -41,11 +96,17 @@ class Mouse(MultipleInstance): hx_patch: HTMX patch URL (overrides command) hx_target: HTMX target selector (overrides command) hx_swap: HTMX swap strategy (overrides command) - hx_vals: HTMX values dict or "js:functionName()" for dynamic values + 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``. Returns: self for method chaining + + Raises: + ValueError: If the sequence contains invalid actions or modifiers. """ + self._validate_sequence(sequence) self.combinations[sequence] = { "command": command, "hx_post": hx_post, diff --git a/src/myfasthtml/controls/datagrid_objects.py b/src/myfasthtml/controls/datagrid_objects.py index 0f7457c..4de71f8 100644 --- a/src/myfasthtml/controls/datagrid_objects.py +++ b/src/myfasthtml/controls/datagrid_objects.py @@ -30,10 +30,15 @@ class DatagridEditionState: @dataclass class DatagridSelectionState: + """ + element_id: str + "tcell_grid_id_col_row" for cell + (min_col, min_row, max_col, max_row) for range + """ selected: tuple[int, int] | None = None # column first, then row last_selected: tuple[int, int] | None = None - selection_mode: str = None # valid values are "row", "column" or None for "cell" - extra_selected: list[tuple[str, str | int]] = field(default_factory=list) # list(tuple(selection_mode, element_id)) + selection_mode: str = None # valid values are "row", "column", "range" or None for "cell" + extra_selected: list[tuple[str, str | int | tuple]] = field(default_factory=list) # (selection_mode, element_id) last_extra_selected: tuple[int, int] = None diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index a69ddc0..899f4c8 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -125,8 +125,6 @@ class Command: def execute(self, client_response: dict = None): logger.debug(f"Executing command {self.name} with arguments {client_response=}") - if self._htmx_extra.get("hx-target", "").startswith("#tsm_"): - logger.warning(f" Command {self.name} needs a selection manager to work properly.") with ObservableResultCollector(self._bindings) as collector: kwargs = self._create_kwargs(self.default_kwargs, client_response, @@ -150,10 +148,6 @@ class Command: and r.get("id", None) is not None): r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true") - if self._htmx_extra.get("hx-target", "").startswith("#tsm_"): - ret_debug = [f"<{r.tag} id={r.attrs.get('id', '')}/>" if r else "None" for r in all_ret] - logger.warning(f" {ret_debug=}") - return all_ret[0] if len(all_ret) == 1 else all_ret def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None, auto_swap_oob=True): diff --git a/tests/html/test_mouse_support.html b/tests/html/test_mouse_support.html index 2992f88..3ff42ca 100644 --- a/tests/html/test_mouse_support.html +++ b/tests/html/test_mouse_support.html @@ -159,6 +159,14 @@
rclick - Right click (using rclick alias)click rclick - Click then right-click sequence (using alias)Element 3 - Mousedown>mouseup actions:
+click - Simple click (coexists with mousedown>mouseup)mousedown>mouseup - Left press and release (with JS values)ctrl+mousedown>mouseup - Ctrl + press and releasermousedown>mouseup - Right press and releaseclick mousedown>mouseup - Click then press-and-release sequenceNote: rclick is an alias for right_click and works identically.
Tip: Try different click combinations! Right-click menu will be blocked on test elements.
@@ -197,6 +205,14 @@ +