I can select range with visual feedback
This commit is contained in:
@@ -19,6 +19,13 @@ The mouse support library provides keyboard-like binding capabilities for mouse
|
|||||||
- `ctrl+shift+click` - Multiple modifiers
|
- `ctrl+shift+click` - Multiple modifiers
|
||||||
- Any combination of 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**:
|
**Sequences**:
|
||||||
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
|
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
|
||||||
- `click click` - Double click sequence
|
- `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
|
## API Reference
|
||||||
|
|
||||||
### add_mouse_support(elementId, combinationsJson)
|
### add_mouse_support(elementId, combinationsJson)
|
||||||
@@ -237,20 +365,23 @@ mouse.add("right_click", context_menu_command)
|
|||||||
def add(self, sequence: str, command: Command = None, *,
|
def add(self, sequence: str, command: Command = None, *,
|
||||||
hx_post: str = None, hx_get: str = None, hx_put: str = None,
|
hx_post: str = None, hx_get: str = None, hx_put: str = None,
|
||||||
hx_delete: str = None, hx_patch: 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**:
|
**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
|
- `command`: Optional Command object for server-side action
|
||||||
- `hx_post`, `hx_get`, etc.: HTMX URL parameters (override command)
|
- `hx_post`, `hx_get`, etc.: HTMX URL parameters (override command)
|
||||||
- `hx_target`: HTMX target selector (overrides command)
|
- `hx_target`: HTMX target selector (overrides command)
|
||||||
- `hx_swap`: HTMX swap strategy (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**:
|
**Note**:
|
||||||
- Named parameters (except `hx_vals`) override the command's parameters.
|
- 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`.
|
- `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
|
### 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()")
|
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
|
### Sequences
|
||||||
|
|
||||||
```python
|
```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
|
## Troubleshooting
|
||||||
|
|
||||||
### Clicks not detected
|
### Clicks not detected
|
||||||
@@ -578,14 +756,29 @@ const combinations = {
|
|||||||
- Check if longer sequences exist (causes waiting)
|
- Check if longer sequences exist (causes waiting)
|
||||||
- Verify the combination string format (space-separated)
|
- 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
|
## Technical Details
|
||||||
|
|
||||||
### Architecture
|
### 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)
|
- **Tree-based matching** using prefix trees (same as keyboard support)
|
||||||
- **Single timeout** for all elements (sequence-based, not element-based)
|
- **Single timeout** for all elements (sequence-based, not element-based)
|
||||||
- **Independent from keyboard support** (separate registry and timeouts)
|
- **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
|
### Performance
|
||||||
|
|
||||||
|
|||||||
@@ -671,12 +671,8 @@
|
|||||||
const element = document.getElementById(candidate.elementId);
|
const element = document.getElementById(candidate.elementId);
|
||||||
const dynamicValues = func(event, element, candidate.node.combinationStr);
|
const dynamicValues = func(event, element, candidate.node.combinationStr);
|
||||||
if (dynamicValues && typeof dynamicValues === 'object') {
|
if (dynamicValues && typeof dynamicValues === 'object') {
|
||||||
// Suffix each key with _mousedown
|
// Store raw values - _mousedown suffix added at mouseup time
|
||||||
const suffixed = {};
|
mousedownJsValues[candidate.elementId] = dynamicValues;
|
||||||
for (const [key, value] of Object.entries(dynamicValues)) {
|
|
||||||
suffixed[key + '_mousedown'] = value;
|
|
||||||
}
|
|
||||||
mousedownJsValues[candidate.elementId] = suffixed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -699,12 +695,39 @@
|
|||||||
// Clear pending state
|
// Clear pending state
|
||||||
MouseRegistry.mousedownPending = null;
|
MouseRegistry.mousedownPending = null;
|
||||||
|
|
||||||
// Remove mousemove listener (no longer needed)
|
// Remove mousemove listener (threshold detection no longer needed)
|
||||||
if (MouseRegistry.mousemoveHandler) {
|
if (MouseRegistry.mousemoveHandler) {
|
||||||
document.removeEventListener('mousemove', MouseRegistry.mousemoveHandler);
|
document.removeEventListener('mousemove', MouseRegistry.mousemoveHandler);
|
||||||
MouseRegistry.mousemoveHandler = null;
|
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
|
// For right button: prevent context menu during drag
|
||||||
if (pendingData.button === 2) {
|
if (pendingData.button === 2) {
|
||||||
const preventContextMenu = (e) => {
|
const preventContextMenu = (e) => {
|
||||||
@@ -907,9 +930,11 @@
|
|||||||
Object.assign(values, config['hx-vals']);
|
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) {
|
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
|
// 3. Call JS function at mouseup time, suffix with _mouseup
|
||||||
|
|||||||
@@ -186,8 +186,10 @@ function bindTooltipsWithDelegation(elementId) {
|
|||||||
|
|
||||||
// Add a single mouseenter and mouseleave listener to the parent element
|
// Add a single mouseenter and mouseleave listener to the parent element
|
||||||
element.addEventListener("mouseenter", (event) => {
|
element.addEventListener("mouseenter", (event) => {
|
||||||
// Early exit - check mf-no-tooltip FIRST (before any DOM work)
|
const target = event.target;
|
||||||
if (element.hasAttribute("mf-no-tooltip")) {
|
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +198,7 @@ function bindTooltipsWithDelegation(elementId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cell = event.target.closest("[data-tooltip]");
|
const cell = target.closest("[data-tooltip]");
|
||||||
if (!cell) {
|
if (!cell) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -207,7 +209,7 @@ function bindTooltipsWithDelegation(elementId) {
|
|||||||
tooltipRafScheduled = false;
|
tooltipRafScheduled = false;
|
||||||
|
|
||||||
// Check again in case tooltip was disabled during RAF delay
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,16 @@
|
|||||||
background-color: var(--color-selection);
|
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 ******** */
|
/* ******** DataGrid Fixed Header/Footer ******** */
|
||||||
|
|||||||
@@ -633,10 +633,17 @@ function updateDatagridSelection(datagridId) {
|
|||||||
const selectionManager = document.getElementById(`tsm_${datagridId}`);
|
const selectionManager = document.getElementById(`tsm_${datagridId}`);
|
||||||
if (!selectionManager) return;
|
if (!selectionManager) return;
|
||||||
|
|
||||||
// Clear previous selections
|
// Re-enable tooltips after drag
|
||||||
document.querySelectorAll('.dt2-selected-focus, .dt2-selected-cell, .dt2-selected-row, .dt2-selected-column').forEach((element) => {
|
const wrapper = document.getElementById(`tw_${datagridId}`);
|
||||||
element.classList.remove('dt2-selected-focus', 'dt2-selected-cell', 'dt2-selected-row', 'dt2-selected-column');
|
if (wrapper) wrapper.removeAttribute('mf-no-tooltip');
|
||||||
element.style.userSelect = 'none';
|
|
||||||
|
// 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
|
// Loop through the children of the selection manager
|
||||||
@@ -654,7 +661,6 @@ function updateDatagridSelection(datagridId) {
|
|||||||
const cellElement = document.getElementById(`${elementId}`);
|
const cellElement = document.getElementById(`${elementId}`);
|
||||||
if (cellElement) {
|
if (cellElement) {
|
||||||
cellElement.classList.add('dt2-selected-cell');
|
cellElement.classList.add('dt2-selected-cell');
|
||||||
cellElement.style.userSelect = 'text';
|
|
||||||
}
|
}
|
||||||
} else if (selectionType === 'row') {
|
} else if (selectionType === 'row') {
|
||||||
const rowElement = document.getElementById(`${elementId}`);
|
const rowElement = document.getElementById(`${elementId}`);
|
||||||
@@ -687,7 +693,10 @@ function updateDatagridSelection(datagridId) {
|
|||||||
const cell = document.getElementById(cellId);
|
const cell = document.getElementById(cellId);
|
||||||
if (cell) {
|
if (cell) {
|
||||||
cell.classList.add('dt2-selected-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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -711,3 +720,71 @@ function getCellId(event) {
|
|||||||
}
|
}
|
||||||
return {cell_id: null};
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,7 +238,9 @@ class DataGrid(MultipleInstance):
|
|||||||
|
|
||||||
# other definitions
|
# other definitions
|
||||||
self._mouse_support = {
|
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()"},
|
"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()"},
|
||||||
@@ -500,6 +502,9 @@ class DataGrid(MultipleInstance):
|
|||||||
if (is_inside and
|
if (is_inside and
|
||||||
cell_id_mousedown and cell_id_mouseup and
|
cell_id_mousedown and cell_id_mouseup and
|
||||||
cell_id_mousedown.startswith("tcell_") and cell_id_mouseup.startswith("tcell_")):
|
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_down = self._get_pos_from_element_id(cell_id_mousedown)
|
||||||
pos_mouse_up = self._get_pos_from_element_id(cell_id_mouseup)
|
pos_mouse_up = self._get_pos_from_element_id(cell_id_mouseup)
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ class Mouse(MultipleInstance):
|
|||||||
def add(self, sequence: str, command: Command = None, *,
|
def add(self, sequence: str, command: Command = None, *,
|
||||||
hx_post: str = None, hx_get: str = None, hx_put: str = None,
|
hx_post: str = None, hx_get: str = None, hx_put: str = None,
|
||||||
hx_delete: str = None, hx_patch: 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.
|
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.
|
hx_vals: HTMX values dict or "js:functionName()" for dynamic values.
|
||||||
For mousedown>mouseup actions, the JS function is called at both
|
For mousedown>mouseup actions, the JS function is called at both
|
||||||
mousedown and mouseup, with results suffixed ``_mousedown`` and ``_mouseup``.
|
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:
|
Returns:
|
||||||
self for method chaining
|
self for method chaining
|
||||||
@@ -117,6 +123,7 @@ class Mouse(MultipleInstance):
|
|||||||
"hx_target": hx_target,
|
"hx_target": hx_target,
|
||||||
"hx_swap": hx_swap,
|
"hx_swap": hx_swap,
|
||||||
"hx_vals": hx_vals,
|
"hx_vals": hx_vals,
|
||||||
|
"on_move": on_move,
|
||||||
}
|
}
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -172,6 +179,15 @@ class Mouse(MultipleInstance):
|
|||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise ValueError(f"hx_vals must be a dict or 'js:functionName()', got invalid JSON: {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
|
return params
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user