diff --git a/docs/Mouse Support.md b/docs/Mouse Support.md index 37fbbed..3f34266 100644 --- a/docs/Mouse Support.md +++ b/docs/Mouse Support.md @@ -19,6 +19,13 @@ The mouse support library provides keyboard-like binding capabilities for mouse - `ctrl+shift+click` - Multiple modifiers - Any combination of modifiers +**Drag Actions**: +- `mousedown>mouseup` - Left button drag (press, drag at least 5px, release) +- `rmousedown>mouseup` - Right button drag +- `ctrl+mousedown>mouseup` - Ctrl + left drag +- `shift+mousedown>mouseup` - Shift + left drag +- Any combination of modifiers + **Sequences**: - `click right_click` (or `click rclick`) - Click then right-click within 500ms - `click click` - Double click sequence @@ -128,6 +135,127 @@ function getCellId(event) { } ``` +## Drag Actions (mousedown>mouseup) + +### How It Works + +Drag detection uses a **5-pixel threshold**: the action only activates when the mouse has moved at least 5px after mousedown. This prevents accidental drags from normal clicks. + +**Lifecycle**: +1. `mousedown` → library waits, stores start position +2. Mouse moves > 5px → drag mode activated, `hx-vals-extra` function called with mousedown event → result stored +3. Mouse moves (during drag) → `on_move` function called on each animation frame *(if configured)* +4. `mouseup` → `hx-vals-extra` function called again with mouseup event → HTMX request fired with both values +5. The subsequent `click` event is suppressed (left button only) + +### Two-Phase Values + +For `mousedown>mouseup`, the `hx-vals-extra` function is called **twice** — once at each phase — and values are suffixed automatically: + +```javascript +// hx-vals-extra function (same function, called twice) +function getCellId(event) { + const cell = event.target.closest('.dt2-cell'); + return { cell_id: cell.id }; +} +``` + +**Values sent to server**: +```json +{ + "c_id": "command_id", + "cell_id_mousedown": "tcell_grid-0-2", + "cell_id_mouseup": "tcell_grid-3-5", + "combination": "mousedown>mouseup", + "is_inside": true, + "has_focus": false +} +``` + +**Python handler**: +```python +def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup): + # cell_id_mousedown: where the drag started + # cell_id_mouseup: where the drag ended + ... +``` + +### Real-Time Visual Feedback with `on_move` + +The `on_move` attribute specifies a JavaScript function to call on each animation frame **during the drag**, enabling real-time visual feedback without any server calls. + +**Configuration**: +```javascript +{ + "mousedown>mouseup": { + "hx-post": "/myfasthtml/commands", + "hx-vals": {"c_id": "command_id"}, + "hx-vals-extra": {"js": "getCellId"}, + "on-move": "onDragMove" // called during drag + } +} +``` + +**`on_move` function signature**: +```javascript +function onDragMove(event, combination, mousedown_result) { + // event : current mousemove event + // combination : e.g. "mousedown>mouseup" or "ctrl+mousedown>mouseup" + // mousedown_result : raw result of hx-vals-extra at mousedown (unsuffixed), or null +} +``` + +**Key properties**: +- Called only after the 5px drag threshold is exceeded (never during a simple click) +- Throttled via `requestAnimationFrame` (~60fps) — no manual throttling needed +- Return value is ignored +- Visual state cleanup is handled by the server response (which overwrites any client-side visual) + +**DataGrid range selection example**: +```javascript +function highlightDragRange(event, combination, mousedownResult) { + const startCell = mousedownResult ? mousedownResult.cell_id : null; + const endCell = event.target.closest('.dt2-cell'); + if (!startCell || !endCell) return; + + // Clear previous preview + document.querySelectorAll('.dt2-drag-preview') + .forEach(el => el.classList.remove('dt2-drag-preview')); + + // Highlight range from start to current cell + applyRangeClass(startCell, endCell.id, 'dt2-drag-preview'); +} +``` + +**Canvas selection rectangle example**: +```javascript +function drawSelectionRect(event, combination, mousedownResult) { + if (!mousedownResult) return; + const canvas = document.getElementById('my-canvas'); + const ctx = canvas.getContext('2d'); + const rect = canvas.getBoundingClientRect(); + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = 'blue'; + ctx.strokeRect( + mousedownResult.x - rect.left, + mousedownResult.y - rect.top, + event.clientX - rect.left - (mousedownResult.x - rect.left), + event.clientY - rect.top - (mousedownResult.y - rect.top) + ); +} +``` + +**Python configuration**: +```python +mouse.add( + "mousedown>mouseup", + selection_command, + hx_vals="js:getCellId()", + on_move="js:highlightDragRange()" +) +``` + ## API Reference ### add_mouse_support(elementId, combinationsJson) @@ -237,20 +365,23 @@ mouse.add("right_click", context_menu_command) 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) ``` **Parameters**: -- `sequence`: Mouse event sequence (e.g., "click", "ctrl+click", "click right_click") +- `sequence`: Mouse event sequence (e.g., "click", "ctrl+click", "mousedown>mouseup") - `command`: Optional Command object for server-side action - `hx_post`, `hx_get`, etc.: HTMX URL parameters (override command) - `hx_target`: HTMX target selector (overrides command) - `hx_swap`: HTMX swap strategy (overrides command) -- `hx_vals`: Additional HTMX values - dict or "js:functionName()" for dynamic values +- `hx_vals`: Additional HTMX values - dict or `"js:functionName()"` for dynamic values +- `on_move`: Client-side JS function called during drag — `"js:functionName()"` format. Only valid with `mousedown>mouseup` sequences. **Note**: - Named parameters (except `hx_vals`) override the command's parameters. - `hx_vals` is **merged** with command's values (stored in `hx-vals-extra`), preserving `c_id`. +- `on_move` is purely client-side — it never triggers a server call. ### Usage Patterns @@ -275,6 +406,16 @@ mouse.add("right_click", hx_post="/context-menu", hx_target="#menu", hx_swap="in mouse.add("shift+click", my_command, hx_vals="js:getClickPosition()") ``` +**With drag and real-time feedback**: +```python +mouse.add( + "mousedown>mouseup", + selection_command, + hx_vals="js:getCellId()", + on_move="js:highlightDragRange()" +) +``` + ### Sequences ```python @@ -558,6 +699,43 @@ const combinations = { }; ``` +### Range Selection with Visual Feedback + +```python +# Python: configure drag with live feedback +mouse.add( + "mousedown>mouseup", + self.commands.on_mouse_selection(), + hx_vals="js:getCellId()", + on_move="js:highlightDragRange()" +) +``` + +```javascript +// JavaScript: real-time highlight during drag +function highlightDragRange(event, combination, mousedownResult) { + const startCell = mousedownResult ? mousedownResult.cell_id : null; + const endCell = event.target.closest('.dt2-cell'); + if (!startCell || !endCell) return; + + document.querySelectorAll('.dt2-drag-preview') + .forEach(el => el.classList.remove('dt2-drag-preview')); + + applyRangeClass(startCell, endCell.id, 'dt2-drag-preview'); + // Server response will replace .dt2-drag-preview with final selection classes +} +``` + +```python +# Python: server handler receives both positions +def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup): + if is_inside and cell_id_mousedown and cell_id_mouseup: + pos_start = self._get_pos_from_element_id(cell_id_mousedown) + pos_end = self._get_pos_from_element_id(cell_id_mouseup) + self._state.selection.set_range(pos_start, pos_end) + return self.render_partial() +``` + ## Troubleshooting ### Clicks not detected @@ -578,14 +756,29 @@ const combinations = { - Check if longer sequences exist (causes waiting) - Verify the combination string format (space-separated) +### Drag not triggering + +- Ensure the mouse moved at least 5px before releasing +- Verify `mousedown>mouseup` (not `mousedown_mouseup`) in the combination string +- Check that `hx-vals-extra` function exists and is accessible via `window` + +### `on_move` not called + +- Verify `on_move` is only used with `mousedown>mouseup` sequences +- Check that the function name matches exactly (case-sensitive) +- Ensure the function is accessible via `window` (not inside a module scope) +- Remember: `on_move` only fires after the 5px threshold — it won't fire on small movements + ## Technical Details ### Architecture -- **Global listeners** on `document` for `click` and `contextmenu` events +- **Global listeners** on `document` for `click`, `contextmenu`, `mousedown`, `mouseup` events - **Tree-based matching** using prefix trees (same as keyboard support) - **Single timeout** for all elements (sequence-based, not element-based) - **Independent from keyboard support** (separate registry and timeouts) +- **Drag detection**: temporary `mousemove` listener attached on `mousedown`, removed when 5px threshold exceeded +- **`on_move` throttling**: `requestAnimationFrame` used internally — no manual throttling needed in user functions ### Performance diff --git a/src/myfasthtml/assets/core/mouse.js b/src/myfasthtml/assets/core/mouse.js index 666a544..b7ef8d5 100644 --- a/src/myfasthtml/assets/core/mouse.js +++ b/src/myfasthtml/assets/core/mouse.js @@ -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 diff --git a/src/myfasthtml/assets/core/myfasthtml.js b/src/myfasthtml/assets/core/myfasthtml.js index 5b1057a..a1da15e 100644 --- a/src/myfasthtml/assets/core/myfasthtml.js +++ b/src/myfasthtml/assets/core/myfasthtml.js @@ -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; } diff --git a/src/myfasthtml/assets/datagrid/datagrid.css b/src/myfasthtml/assets/datagrid/datagrid.css index 6699aaa..420bb5f 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.css +++ b/src/myfasthtml/assets/datagrid/datagrid.css @@ -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 ******** */ diff --git a/src/myfasthtml/assets/datagrid/datagrid.js b/src/myfasthtml/assets/datagrid/datagrid.js index d8f54a1..340a307 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.js +++ b/src/myfasthtml/assets/datagrid/datagrid.js @@ -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'); + } + } + } } \ No newline at end of file diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index f3cf514..15738e4 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -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) diff --git a/src/myfasthtml/controls/Mouse.py b/src/myfasthtml/controls/Mouse.py index 2d0a51f..17c364f 100644 --- a/src/myfasthtml/controls/Mouse.py +++ b/src/myfasthtml/controls/Mouse.py @@ -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):