Added Mouse Support
This commit is contained in:
@@ -187,6 +187,7 @@ All other `hx-*` attributes are supported and will be converted to the appropria
|
||||
The library automatically adds these parameters to every request:
|
||||
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
||||
- `has_focus` - Boolean indicating if the element had focus
|
||||
- `is_inside` - Boolean indicating if the focus is inside the element (element itself or any child)
|
||||
|
||||
Example final request:
|
||||
```javascript
|
||||
@@ -196,7 +197,8 @@ htmx.ajax('POST', '/save-url', {
|
||||
values: {
|
||||
extra: "data", // from hx-vals
|
||||
combination: "Ctrl+S", // automatic
|
||||
has_focus: true // automatic
|
||||
has_focus: true, // automatic
|
||||
is_inside: true // automatic
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -262,6 +264,55 @@ f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
|
||||
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
|
||||
```
|
||||
|
||||
### Removing Keyboard Support
|
||||
|
||||
When you no longer need keyboard support for an element:
|
||||
|
||||
```python
|
||||
# Remove keyboard support
|
||||
f"remove_keyboard_support('{element_id}')"
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- Removes the element from the keyboard registry
|
||||
- If this was the last element, automatically detaches global event listeners
|
||||
- Cleans up all associated state (timeouts, snapshots, etc.)
|
||||
- Other elements continue to work normally
|
||||
|
||||
**Example**:
|
||||
```javascript
|
||||
// Add support
|
||||
add_keyboard_support('modal', '{"esc": {"hx-post": "/close"}}');
|
||||
|
||||
// Later, remove support
|
||||
remove_keyboard_support('modal');
|
||||
// If no other elements remain, keyboard listeners are completely removed
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### add_keyboard_support(elementId, combinationsJson)
|
||||
|
||||
Adds keyboard support to an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||
|
||||
**Returns**: void
|
||||
|
||||
### remove_keyboard_support(elementId)
|
||||
|
||||
Removes keyboard support from an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
|
||||
**Returns**: void
|
||||
|
||||
**Side effects**:
|
||||
- If last element: detaches global event listeners and cleans up all state
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Trie-based Matching
|
||||
@@ -277,7 +328,7 @@ The library uses a prefix tree (trie) data structure:
|
||||
Configuration objects are mapped to htmx.ajax() calls:
|
||||
- `hx-*` attributes are converted to camelCase parameters
|
||||
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
||||
- `combination` and `has_focus` are automatically added to values
|
||||
- `combination`, `has_focus`, and `is_inside` are automatically added to values
|
||||
- All standard HTMX options are supported
|
||||
|
||||
### Key Normalization
|
||||
439
docs/Mouse Support.md
Normal file
439
docs/Mouse Support.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# Mouse Support - Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The mouse support library provides keyboard-like binding capabilities for mouse actions. It supports simple clicks, modified clicks (with Ctrl/Shift/Alt), and sequences of clicks with smart timeout logic.
|
||||
|
||||
## Features
|
||||
|
||||
### Supported Mouse Actions
|
||||
|
||||
**Basic Actions**:
|
||||
- `click` - Left click
|
||||
- `right_click` (or `rclick`) - Right click (contextmenu)
|
||||
|
||||
**Modified Actions**:
|
||||
- `ctrl+click` (or `ctrl+rclick`) - Ctrl+Click (or Cmd+Click on Mac)
|
||||
- `shift+click` (or `shift+rclick`) - Shift+Click
|
||||
- `alt+click` (or `alt+rclick`) - Alt+Click
|
||||
- `ctrl+shift+click` - Multiple modifiers
|
||||
- Any combination of modifiers
|
||||
|
||||
**Sequences**:
|
||||
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
|
||||
- `click click` - Double click sequence
|
||||
- `ctrl+click click` - Ctrl+click then normal click
|
||||
- Any sequence of actions
|
||||
|
||||
**Note**: `rclick` is an alias for `right_click` and can be used interchangeably.
|
||||
|
||||
### Smart Timeout Logic
|
||||
|
||||
Same as keyboard support:
|
||||
- If **any element** has a longer sequence possible, **all matching elements wait**
|
||||
- Timeout is 500ms between actions
|
||||
- Immediate trigger if no longer sequences exist
|
||||
|
||||
### Multiple Element Support
|
||||
|
||||
Multiple elements can listen to the same mouse action and all will trigger simultaneously.
|
||||
|
||||
## Configuration Format
|
||||
|
||||
Uses HTMX configuration objects (same as keyboard support):
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/handle-click",
|
||||
"hx-target": "#result"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/handle-ctrl-click",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"rclick": { // Alias for right_click
|
||||
"hx-post": "/context-menu"
|
||||
},
|
||||
"click rclick": { // Can use rclick in sequences too
|
||||
"hx-post": "/sequence-action",
|
||||
"hx-vals": {"type": "sequence"}
|
||||
}
|
||||
};
|
||||
|
||||
add_mouse_support('my-element', JSON.stringify(combinations));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### add_mouse_support(elementId, combinationsJson)
|
||||
|
||||
Adds mouse support to an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||
|
||||
**Returns**: void
|
||||
|
||||
**Example**:
|
||||
```javascript
|
||||
add_mouse_support('button1', JSON.stringify({
|
||||
"click": {"hx-post": "/click"},
|
||||
"ctrl+click": {"hx-post": "/ctrl-click"}
|
||||
}));
|
||||
```
|
||||
|
||||
### remove_mouse_support(elementId)
|
||||
|
||||
Removes mouse support from an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
|
||||
**Returns**: void
|
||||
|
||||
**Side effects**:
|
||||
- If last element: detaches global event listeners and cleans up all state
|
||||
|
||||
**Example**:
|
||||
```javascript
|
||||
remove_mouse_support('button1');
|
||||
```
|
||||
|
||||
## Automatic Parameters
|
||||
|
||||
The library automatically adds these parameters to every HTMX request:
|
||||
- `combination` - The mouse combination that triggered the action (e.g., "ctrl+click")
|
||||
- `has_focus` - Boolean indicating if the element had focus when clicked
|
||||
- `is_inside` - Boolean indicating if the click was inside the element
|
||||
- For `click`: `true` if clicked inside element, `false` if clicked outside
|
||||
- For `right_click`: always `true` (only triggers when clicking on element)
|
||||
- `has_focus` - Boolean indicating if the element had focus when the action triggered
|
||||
- `clicked_inside` - Boolean indicating if the click was inside the element or outside
|
||||
|
||||
### Parameter Details
|
||||
|
||||
**`has_focus`**:
|
||||
- `true` if the registered element currently has focus
|
||||
- `false` otherwise
|
||||
- Useful for knowing if the element was the active element
|
||||
|
||||
**`clicked_inside`**:
|
||||
- For `click` actions: `true` if clicked on/inside the element, `false` if clicked outside
|
||||
- For `right_click` actions: always `true` (since right-click only triggers on the element)
|
||||
- Useful for "click outside to close" logic
|
||||
|
||||
**Example values sent**:
|
||||
```javascript
|
||||
// User clicks inside a modal
|
||||
{
|
||||
combination: "click",
|
||||
has_focus: true,
|
||||
clicked_inside: true
|
||||
}
|
||||
|
||||
// User clicks outside the modal (modal still gets triggered because click is global)
|
||||
{
|
||||
combination: "click",
|
||||
has_focus: false,
|
||||
clicked_inside: false // Perfect for closing the modal!
|
||||
}
|
||||
|
||||
// User right-clicks on an item
|
||||
{
|
||||
combination: "right_click",
|
||||
has_focus: false,
|
||||
clicked_inside: true // Always true for right_click
|
||||
}
|
||||
```
|
||||
|
||||
## Python Integration
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"click": {
|
||||
"hx-post": "/item/select"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/item/select-multiple",
|
||||
"hx-vals": json.dumps({"mode": "multi"})
|
||||
},
|
||||
"right_click": {
|
||||
"hx-post": "/item/context-menu",
|
||||
"hx-target": "#context-menu",
|
||||
"hx-swap": "innerHTML"
|
||||
}
|
||||
}
|
||||
|
||||
f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')"
|
||||
```
|
||||
|
||||
### Sequences
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"click": {
|
||||
"hx-post": "/single-click"
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/double-click-sequence"
|
||||
},
|
||||
"click right_click": {
|
||||
"hx-post": "/click-then-right-click"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Elements
|
||||
|
||||
```python
|
||||
# Item 1
|
||||
item1_combinations = {
|
||||
"click": {"hx-post": f"/item/1/select"},
|
||||
"ctrl+click": {"hx-post": f"/item/1/toggle"}
|
||||
}
|
||||
f"add_mouse_support('item-1', '{json.dumps(item1_combinations)}')"
|
||||
|
||||
# Item 2
|
||||
item2_combinations = {
|
||||
"click": {"hx-post": f"/item/2/select"},
|
||||
"ctrl+click": {"hx-post": f"/item/2/toggle"}
|
||||
}
|
||||
f"add_mouse_support('item-2', '{json.dumps(item2_combinations)}')"
|
||||
```
|
||||
|
||||
## Behavior Details
|
||||
|
||||
### Click vs Right-Click Behavior
|
||||
|
||||
**IMPORTANT**: The library handles `click` and `right_click` differently:
|
||||
|
||||
**`click` (global detection)**:
|
||||
- Triggers for ALL registered elements, regardless of where you click
|
||||
- Useful for "click outside to close" functionality (modals, dropdowns, popups)
|
||||
- Example: Modal registered with `click` → clicking anywhere on the page triggers the modal's click action
|
||||
|
||||
**`right_click` (element-specific detection)**:
|
||||
- Triggers ONLY when you right-click on (or inside) the registered element
|
||||
- Right-clicking outside the element does nothing and shows browser's context menu
|
||||
- This preserves normal browser behavior while adding custom actions on your elements
|
||||
|
||||
**Example use case**:
|
||||
```javascript
|
||||
// Modal that closes when clicking anywhere
|
||||
add_mouse_support('modal', JSON.stringify({
|
||||
"click": {"hx-post": "/close-modal"} // Triggers even if you click outside modal
|
||||
}));
|
||||
|
||||
// Context menu that only appears on element
|
||||
add_mouse_support('item', JSON.stringify({
|
||||
"right_click": {"hx-post": "/item-menu"} // Only triggers when right-clicking the item
|
||||
}));
|
||||
```
|
||||
|
||||
### Modifier Keys (Cross-Platform)
|
||||
|
||||
- **Windows/Linux**: `ctrl+click` uses Ctrl key
|
||||
- **Mac**: `ctrl+click` uses Cmd (⌘) key OR Ctrl key
|
||||
- This follows standard web conventions for cross-platform compatibility
|
||||
|
||||
### Input Context Protection
|
||||
|
||||
Mouse actions are **disabled** when clicking in input fields:
|
||||
- `<input>` elements
|
||||
- `<textarea>` elements
|
||||
- Any `contenteditable` element
|
||||
|
||||
This ensures normal text selection and interaction works in forms.
|
||||
|
||||
### Right-Click Menu
|
||||
|
||||
The contextmenu (right-click menu) is prevented with `preventDefault()` when:
|
||||
- A `right_click` action is configured for the element
|
||||
- The element is NOT an input/textarea/contenteditable
|
||||
|
||||
### Event Bubbling
|
||||
|
||||
The library checks if the click target OR any parent element is registered:
|
||||
```html
|
||||
<div id="container">
|
||||
<button>Click me</button>
|
||||
</div>
|
||||
```
|
||||
If `container` is registered, clicking the button will trigger the container's actions.
|
||||
|
||||
## Examples
|
||||
|
||||
### Using Aliases
|
||||
|
||||
You can use `rclick` instead of `right_click` anywhere:
|
||||
|
||||
```javascript
|
||||
// These are equivalent
|
||||
const config1 = {
|
||||
"right_click": {"hx-post": "/menu"}
|
||||
};
|
||||
|
||||
const config2 = {
|
||||
"rclick": {"hx-post": "/menu"} // Shorter alias
|
||||
};
|
||||
|
||||
// Works in sequences too
|
||||
const config3 = {
|
||||
"click rclick": {"hx-post": "/sequence"}
|
||||
};
|
||||
|
||||
// Works with modifiers
|
||||
const config4 = {
|
||||
"ctrl+rclick": {"hx-post": "/ctrl-right-click"}
|
||||
};
|
||||
```
|
||||
|
||||
### Context Menu
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"right_click": {
|
||||
"hx-post": "/show-context-menu",
|
||||
"hx-target": "#menu",
|
||||
"hx-swap": "innerHTML"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Close Modal/Popup on Click Outside
|
||||
|
||||
Since `click` is detected globally (anywhere on the page), it's perfect for "click outside to close":
|
||||
|
||||
```javascript
|
||||
// Modal element
|
||||
const modalCombinations = {
|
||||
"click": {
|
||||
"hx-post": "/close-modal",
|
||||
"hx-target": "#modal",
|
||||
"hx-swap": "outerHTML"
|
||||
}
|
||||
};
|
||||
add_mouse_support('modal', JSON.stringify(modalCombinations));
|
||||
|
||||
// Now clicking ANYWHERE on the page will trigger the handler
|
||||
// Your backend receives:
|
||||
// - clicked_inside: true (if clicked on modal) → maybe keep it open
|
||||
// - clicked_inside: false (if clicked outside) → close it!
|
||||
```
|
||||
|
||||
**Backend example (Python/Flask)**:
|
||||
```python
|
||||
@app.route('/close-modal', methods=['POST'])
|
||||
def close_modal():
|
||||
clicked_inside = request.form.get('clicked_inside') == 'true'
|
||||
|
||||
if clicked_inside:
|
||||
# User clicked inside the modal - keep it open
|
||||
return render_template('modal_content.html')
|
||||
else:
|
||||
# User clicked outside - close the modal
|
||||
return '' # Empty response removes the modal
|
||||
```
|
||||
|
||||
**Note**: The click handler on the modal element will trigger for all clicks on the page, not just clicks on the modal itself. Use the `clicked_inside` parameter to determine the appropriate action.
|
||||
|
||||
### Multi-Select List
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/select-item",
|
||||
"hx-vals": {"mode": "single"}
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/select-item",
|
||||
"hx-vals": {"mode": "toggle"}
|
||||
},
|
||||
"shift+click": {
|
||||
"hx-post": "/select-range",
|
||||
"hx-vals": {"mode": "range"}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Interactive Canvas/Drawing
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/draw-point"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/draw-special"
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/confirm-action"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Drag-and-Drop Alternative
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/select-source"
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/set-destination"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Clicks not detected
|
||||
|
||||
- Verify the element exists and has the correct ID
|
||||
- Check browser console for errors
|
||||
- Ensure HTMX is loaded before mouse_support.js
|
||||
|
||||
### Right-click menu still appears
|
||||
|
||||
- Check if element is in input context (input/textarea)
|
||||
- Verify the combination is configured correctly
|
||||
- Check browser console for configuration errors
|
||||
|
||||
### Sequences not working
|
||||
|
||||
- Ensure clicks happen within 500ms timeout
|
||||
- Check if longer sequences exist (causes waiting)
|
||||
- Verify the combination string format (space-separated)
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Global listeners** on `document` for `click` and `contextmenu` 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)
|
||||
|
||||
### Performance
|
||||
|
||||
- Single event listener regardless of number of elements
|
||||
- O(n) matching where n is sequence length
|
||||
- Efficient memory usage with automatic cleanup
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
- Modern browsers (ES6+ required)
|
||||
- Chrome, Firefox, Safari, Edge
|
||||
- Requires HTMX library
|
||||
|
||||
## Notes
|
||||
|
||||
- Timeout value is the same as keyboard support (500ms) but in separate variable
|
||||
- Can be used independently or alongside keyboard support
|
||||
- Does not interfere with normal mouse behavior in inputs
|
||||
- Element must exist in DOM when `add_mouse_support()` is called
|
||||
- **Alias**: `rclick` can be used interchangeably with `right_click` for shorter syntax
|
||||
@@ -275,7 +275,7 @@ function updateTabs(controllerId) {
|
||||
* Global registry to store keyboard shortcuts for multiple elements
|
||||
*/
|
||||
const KeyboardRegistry = {
|
||||
elements: new Map(), // elementId -> { trie, element }
|
||||
elements: new Map(), // elementId -> { tree, element }
|
||||
listenerAttached: false,
|
||||
currentKeys: new Set(),
|
||||
snapshotHistory: [],
|
||||
@@ -339,8 +339,8 @@ function updateTabs(controllerId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new trie node
|
||||
* @returns {Object} - New trie node
|
||||
* Create a new tree node
|
||||
* @returns {Object} - New tree node
|
||||
*/
|
||||
function createTreeNode() {
|
||||
return {
|
||||
@@ -351,9 +351,9 @@ function updateTabs(controllerId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a trie from combinations
|
||||
* Build a tree from combinations
|
||||
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||
* @returns {Object} - Root trie node
|
||||
* @returns {Object} - Root tree node
|
||||
*/
|
||||
function buildTree(combinations) {
|
||||
const root = createTreeNode();
|
||||
@@ -383,12 +383,12 @@ function updateTabs(controllerId) {
|
||||
|
||||
/**
|
||||
* Traverse the tree with the current snapshot history
|
||||
* @param {Object} trieRoot - Root of the tree
|
||||
* @param {Object} treeRoot - Root of the tree
|
||||
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
|
||||
* @returns {Object|null} - Current node or null if no match
|
||||
*/
|
||||
function traverseTree(trieRoot, snapshotHistory) {
|
||||
let currentNode = trieRoot;
|
||||
function traverseTree(treeRoot, snapshotHistory) {
|
||||
let currentNode = treeRoot;
|
||||
|
||||
for (const snapshot of snapshotHistory) {
|
||||
const key = setToKey(snapshot);
|
||||
@@ -431,8 +431,9 @@ function updateTabs(controllerId) {
|
||||
* @param {string} elementId - ID of the element
|
||||
* @param {Object} config - HTMX configuration object
|
||||
* @param {string} combinationStr - The matched combination string
|
||||
* @param {boolean} isInside - Whether the focus is inside the element
|
||||
*/
|
||||
function triggerAction(elementId, config, combinationStr) {
|
||||
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
@@ -476,13 +477,14 @@ function updateTabs(controllerId) {
|
||||
htmxOptions.swap = config['hx-swap'];
|
||||
}
|
||||
|
||||
// Map hx-vals to values and add combination and has_focus
|
||||
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||
const values = {};
|
||||
if (config['hx-vals']) {
|
||||
Object.assign(values, config['hx-vals']);
|
||||
}
|
||||
values.combination = combinationStr;
|
||||
values.has_focus = hasFocus;
|
||||
values.is_inside = isInside;
|
||||
htmxOptions.values = values;
|
||||
|
||||
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||
@@ -532,6 +534,9 @@ function updateTabs(controllerId) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
// Check if focus is inside this element (element itself or any child)
|
||||
const isInside = element.contains(document.activeElement);
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
@@ -562,7 +567,8 @@ function updateTabs(controllerId) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -577,7 +583,7 @@ function updateTabs(controllerId) {
|
||||
// We have matches and NO element has longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr);
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
@@ -592,7 +598,7 @@ function updateTabs(controllerId) {
|
||||
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of KeyboardRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr);
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
@@ -643,6 +649,26 @@ function updateTabs(controllerId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the global keyboard event listener
|
||||
*/
|
||||
function detachGlobalListener() {
|
||||
if (KeyboardRegistry.listenerAttached) {
|
||||
document.removeEventListener('keydown', handleKeyboardEvent);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
KeyboardRegistry.listenerAttached = false;
|
||||
|
||||
// Clean up all state
|
||||
KeyboardRegistry.currentKeys.clear();
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
if (KeyboardRegistry.pendingTimeout) {
|
||||
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||
KeyboardRegistry.pendingTimeout = null;
|
||||
}
|
||||
KeyboardRegistry.pendingMatches = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add keyboard support to an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
@@ -671,4 +697,658 @@ function updateTabs(controllerId) {
|
||||
// Attach global listener if not already attached
|
||||
attachGlobalListener();
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove keyboard support from an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
*/
|
||||
window.remove_keyboard_support = function (elementId) {
|
||||
// Remove from registry
|
||||
if (!KeyboardRegistry.elements.has(elementId)) {
|
||||
console.warn("Element with ID", elementId, "not found in keyboard registry!");
|
||||
return;
|
||||
}
|
||||
|
||||
KeyboardRegistry.elements.delete(elementId);
|
||||
|
||||
// If no more elements, detach global listeners
|
||||
if (KeyboardRegistry.elements.size === 0) {
|
||||
detachGlobalListener();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* Create mouse bindings
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Global registry to store mouse shortcuts for multiple elements
|
||||
*/
|
||||
const MouseRegistry = {
|
||||
elements: new Map(), // elementId -> { tree, element }
|
||||
listenerAttached: false,
|
||||
snapshotHistory: [],
|
||||
pendingTimeout: null,
|
||||
pendingMatches: [], // Array of matches waiting for timeout
|
||||
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||
clickHandler: null,
|
||||
contextmenuHandler: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize mouse action names
|
||||
* @param {string} action - The action to normalize
|
||||
* @returns {string} - Normalized action name
|
||||
*/
|
||||
function normalizeAction(action) {
|
||||
const normalized = action.toLowerCase().trim();
|
||||
|
||||
// Handle aliases
|
||||
const aliasMap = {
|
||||
'rclick': 'right_click'
|
||||
};
|
||||
|
||||
return aliasMap[normalized] || normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique string key from a Set of actions for Map indexing
|
||||
* @param {Set} actionSet - Set of normalized actions
|
||||
* @returns {string} - Sorted string representation
|
||||
*/
|
||||
function setToKey(actionSet) {
|
||||
return Array.from(actionSet).sort().join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single element (can be a simple click or click with modifiers)
|
||||
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
|
||||
* @returns {Set} - Set of normalized actions
|
||||
*/
|
||||
function parseElement(element) {
|
||||
if (element.includes('+')) {
|
||||
// Click with modifiers
|
||||
return new Set(element.split('+').map(a => normalizeAction(a)));
|
||||
}
|
||||
// Simple click
|
||||
return new Set([normalizeAction(element)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a combination string into sequence elements
|
||||
* @param {string} combination - The combination string (e.g., "click right_click")
|
||||
* @returns {Array} - Array of Sets representing the sequence
|
||||
*/
|
||||
function parseCombination(combination) {
|
||||
// Check if it's a sequence (contains space)
|
||||
if (combination.includes(' ')) {
|
||||
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||
}
|
||||
|
||||
// Single element (can be a click or click with modifiers)
|
||||
return [parseElement(combination)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tree node
|
||||
* @returns {Object} - New tree node
|
||||
*/
|
||||
function createTreeNode() {
|
||||
return {
|
||||
config: null,
|
||||
combinationStr: null,
|
||||
children: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree from combinations
|
||||
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||
* @returns {Object} - Root tree node
|
||||
*/
|
||||
function buildTree(combinations) {
|
||||
const root = createTreeNode();
|
||||
|
||||
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||
const sequence = parseCombination(combinationStr);
|
||||
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
|
||||
let currentNode = root;
|
||||
|
||||
for (const actionSet of sequence) {
|
||||
const key = setToKey(actionSet);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
currentNode.children.set(key, createTreeNode());
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
// Mark as end of sequence and store config
|
||||
currentNode.config = config;
|
||||
currentNode.combinationStr = combinationStr;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse the tree with the current snapshot history
|
||||
* @param {Object} treeRoot - Root of the tree
|
||||
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
|
||||
* @returns {Object|null} - Current node or null if no match
|
||||
*/
|
||||
function traverseTree(treeRoot, snapshotHistory) {
|
||||
let currentNode = treeRoot;
|
||||
|
||||
for (const snapshot of snapshotHistory) {
|
||||
const key = setToKey(snapshot);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
return currentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're inside an input element where clicking should work normally
|
||||
* @returns {boolean} - True if inside an input-like element
|
||||
*/
|
||||
function isInInputContext() {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
|
||||
// Check for input/textarea
|
||||
if (tagName === 'input' || tagName === 'textarea') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for contenteditable
|
||||
if (activeElement.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element that was actually clicked (from registered elements)
|
||||
* @param {Element} target - The clicked element
|
||||
* @returns {string|null} - Element ID if found, null otherwise
|
||||
*/
|
||||
function findRegisteredElement(target) {
|
||||
// Check if target itself is registered
|
||||
if (target.id && MouseRegistry.elements.has(target.id)) {
|
||||
return target.id;
|
||||
}
|
||||
|
||||
// Check if any parent is registered
|
||||
let current = target.parentElement;
|
||||
while (current) {
|
||||
if (current.id && MouseRegistry.elements.has(current.id)) {
|
||||
return current.id;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot from mouse event
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||
* @returns {Set} - Set of actions representing this click
|
||||
*/
|
||||
function createSnapshot(event, baseAction) {
|
||||
const actions = new Set([baseAction]);
|
||||
|
||||
// Add modifiers if present
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
actions.add('ctrl');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
actions.add('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
actions.add('alt');
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an action for a matched combination
|
||||
* @param {string} elementId - ID of the element
|
||||
* @param {Object} config - HTMX configuration object
|
||||
* @param {string} combinationStr - The matched combination string
|
||||
* @param {boolean} isInside - Whether the click was inside the element
|
||||
*/
|
||||
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const hasFocus = document.activeElement === element;
|
||||
|
||||
// Extract HTTP method and URL from hx-* attributes
|
||||
let method = 'POST'; // default
|
||||
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 = {};
|
||||
|
||||
// Map hx-target to target
|
||||
if (config['hx-target']) {
|
||||
htmxOptions.target = config['hx-target'];
|
||||
}
|
||||
|
||||
// Map hx-swap to swap
|
||||
if (config['hx-swap']) {
|
||||
htmxOptions.swap = config['hx-swap'];
|
||||
}
|
||||
|
||||
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||
const values = {};
|
||||
if (config['hx-vals']) {
|
||||
Object.assign(values, config['hx-vals']);
|
||||
}
|
||||
values.combination = combinationStr;
|
||||
values.has_focus = hasFocus;
|
||||
values.is_inside = isInside;
|
||||
htmxOptions.values = values;
|
||||
|
||||
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||
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'].includes(key)) {
|
||||
// Remove 'hx-' prefix and convert to camelCase
|
||||
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
htmxOptions[optionKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Make AJAX call with htmx
|
||||
htmx.ajax(method, url, htmxOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse events and trigger matching combinations
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||
*/
|
||||
function handleMouseEvent(event, baseAction) {
|
||||
// Different behavior for click vs right_click
|
||||
if (baseAction === 'click') {
|
||||
// Click: trigger for ALL registered elements (useful for closing modals/popups)
|
||||
handleGlobalClick(event);
|
||||
} else if (baseAction === 'right_click') {
|
||||
// Right-click: trigger ONLY if clicked on a registered element
|
||||
handleElementRightClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle global click events (triggers for all registered elements)
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
*/
|
||||
function handleGlobalClick(event) {
|
||||
console.debug("Global click detected");
|
||||
|
||||
// Create a snapshot of current mouse action with modifiers
|
||||
const snapshot = createSnapshot(event, 'click');
|
||||
|
||||
// Add snapshot to history
|
||||
MouseRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for ALL registered elements
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
for (const [elementId, data] of MouseRegistry.elements) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
// Check if click was inside this element
|
||||
const isInside = element.contains(event.target);
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree
|
||||
continue;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has config)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches
|
||||
if (hasMatch) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Decision logic based on matches and longer sequences
|
||||
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||
// We have matches and NO longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but longer sequences are possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
MouseRegistry.pendingMatches = currentMatches;
|
||||
|
||||
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of MouseRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
MouseRegistry.pendingMatches = [];
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}, MouseRegistry.sequenceTimeout);
|
||||
|
||||
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
// No matches and no longer sequences possible
|
||||
// This is an invalid sequence - clear history
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
if (!foundAnyMatch) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle right-click events (triggers only for clicked element)
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
*/
|
||||
function handleElementRightClick(event) {
|
||||
// Find which registered element was clicked
|
||||
const elementId = findRegisteredElement(event.target);
|
||||
|
||||
if (!elementId) {
|
||||
// Right-click wasn't on a registered element - don't prevent default
|
||||
// This allows browser context menu to appear
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("Right-click on registered element", elementId);
|
||||
|
||||
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
|
||||
const clickedInside = true;
|
||||
|
||||
// Create a snapshot of current mouse action with modifiers
|
||||
const snapshot = createSnapshot(event, 'right_click');
|
||||
|
||||
// Add snapshot to history
|
||||
MouseRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for this element
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
const data = MouseRegistry.elements.get(elementId);
|
||||
if (!data) return;
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree
|
||||
console.debug("No match in tree for right-click");
|
||||
// Clear history for invalid sequences
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has config)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches
|
||||
if (hasMatch) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: true // Right-click only triggers when clicking on element
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Decision logic based on matches and longer sequences
|
||||
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||
// We have matches and NO longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but longer sequences are possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
MouseRegistry.pendingMatches = currentMatches;
|
||||
|
||||
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of MouseRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
MouseRegistry.pendingMatches = [];
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}, MouseRegistry.sequenceTimeout);
|
||||
|
||||
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
// No matches and no longer sequences possible
|
||||
// This is an invalid sequence - clear history
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
if (!foundAnyMatch) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the global mouse event listeners if not already attached
|
||||
*/
|
||||
function attachGlobalListener() {
|
||||
if (!MouseRegistry.listenerAttached) {
|
||||
// Store handler references for proper removal
|
||||
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||
|
||||
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
MouseRegistry.listenerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the global mouse event listeners
|
||||
*/
|
||||
function detachGlobalListener() {
|
||||
if (MouseRegistry.listenerAttached) {
|
||||
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
MouseRegistry.listenerAttached = false;
|
||||
|
||||
// Clean up handler references
|
||||
MouseRegistry.clickHandler = null;
|
||||
MouseRegistry.contextmenuHandler = null;
|
||||
|
||||
// Clean up all state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add mouse support to an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||
*/
|
||||
window.add_mouse_support = function (elementId, combinationsJson) {
|
||||
// Parse the combinations JSON
|
||||
const combinations = JSON.parse(combinationsJson);
|
||||
|
||||
// Build tree for this element
|
||||
const tree = buildTree(combinations);
|
||||
|
||||
// Get element reference
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
console.error("Element with ID", elementId, "not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to registry
|
||||
MouseRegistry.elements.set(elementId, {
|
||||
tree: tree,
|
||||
element: element
|
||||
});
|
||||
|
||||
// Attach global listener if not already attached
|
||||
attachGlobalListener();
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove mouse support from an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
*/
|
||||
window.remove_mouse_support = function (elementId) {
|
||||
// Remove from registry
|
||||
if (!MouseRegistry.elements.has(elementId)) {
|
||||
console.warn("Element with ID", elementId, "not found in mouse registry!");
|
||||
return;
|
||||
}
|
||||
|
||||
MouseRegistry.elements.delete(elementId);
|
||||
|
||||
// If no more elements, detach global listeners
|
||||
if (MouseRegistry.elements.size === 0) {
|
||||
detachGlobalListener();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -3,16 +3,17 @@ from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle(self):
|
||||
return Command("Toggle", "Toggle Dropdown", self._owner.toggle).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
def close(self):
|
||||
return Command("Close", "Close Dropdown", self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
def click(self):
|
||||
return Command("Click", "Click on Dropdown", self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
|
||||
class DropdownState:
|
||||
@@ -28,9 +29,6 @@ class Dropdown(MultipleInstance):
|
||||
self.commands = Commands(self)
|
||||
self._state = DropdownState()
|
||||
self._toggle_command = self.commands.toggle()
|
||||
|
||||
# attach the command to the button
|
||||
self._toggle_command.bind_ft(self.button)
|
||||
|
||||
def toggle(self):
|
||||
self._state.opened = not self._state.opened
|
||||
@@ -40,6 +38,11 @@ class Dropdown(MultipleInstance):
|
||||
self._state.opened = False
|
||||
return self._mk_content()
|
||||
|
||||
def on_click(self, combination, is_inside: bool):
|
||||
if combination == "click":
|
||||
self._state.opened = is_inside
|
||||
return self._mk_content()
|
||||
|
||||
def _mk_content(self):
|
||||
return Div(self.content,
|
||||
cls=f"mf-dropdown {'is-visible' if self._state.opened else ''}",
|
||||
@@ -52,7 +55,8 @@ class Dropdown(MultipleInstance):
|
||||
self._mk_content(),
|
||||
cls="mf-dropdown-wrapper"
|
||||
),
|
||||
Keyboard(self, "_keyboard").add("esc", self.commands.close()),
|
||||
Keyboard(self, "-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, "-mouse").add("click", self.commands.click()),
|
||||
id=self._id
|
||||
)
|
||||
|
||||
|
||||
23
src/myfasthtml/controls/Mouse.py
Normal file
23
src/myfasthtml/controls/Mouse.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import json
|
||||
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Mouse(MultipleInstance):
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
def add(self, sequence: str, command: BaseCommand):
|
||||
self.combinations[sequence] = command
|
||||
return self
|
||||
|
||||
def render(self):
|
||||
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
|
||||
return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
450
tests/html/keyboard_support.js
Normal file
450
tests/html/keyboard_support.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Create keyboard bindings
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Global registry to store keyboard shortcuts for multiple elements
|
||||
*/
|
||||
const KeyboardRegistry = {
|
||||
elements: new Map(), // elementId -> { tree, element }
|
||||
listenerAttached: false,
|
||||
currentKeys: new Set(),
|
||||
snapshotHistory: [],
|
||||
pendingTimeout: null,
|
||||
pendingMatches: [], // Array of matches waiting for timeout
|
||||
sequenceTimeout: 500 // 500ms timeout for sequences
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize key names to lowercase for case-insensitive comparison
|
||||
* @param {string} key - The key to normalize
|
||||
* @returns {string} - Normalized key name
|
||||
*/
|
||||
function normalizeKey(key) {
|
||||
const keyMap = {
|
||||
'control': 'ctrl',
|
||||
'escape': 'esc',
|
||||
'delete': 'del'
|
||||
};
|
||||
|
||||
const normalized = key.toLowerCase();
|
||||
return keyMap[normalized] || normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique string key from a Set of keys for Map indexing
|
||||
* @param {Set} keySet - Set of normalized keys
|
||||
* @returns {string} - Sorted string representation
|
||||
*/
|
||||
function setToKey(keySet) {
|
||||
return Array.from(keySet).sort().join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single element (can be a single key or a simultaneous combination)
|
||||
* @param {string} element - The element string (e.g., "a" or "Ctrl+C")
|
||||
* @returns {Set} - Set of normalized keys
|
||||
*/
|
||||
function parseElement(element) {
|
||||
if (element.includes('+')) {
|
||||
// Simultaneous combination
|
||||
return new Set(element.split('+').map(k => normalizeKey(k.trim())));
|
||||
}
|
||||
// Single key
|
||||
return new Set([normalizeKey(element.trim())]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a combination string into sequence elements
|
||||
* @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C")
|
||||
* @returns {Array} - Array of Sets representing the sequence
|
||||
*/
|
||||
function parseCombination(combination) {
|
||||
// Check if it's a sequence (contains space)
|
||||
if (combination.includes(' ')) {
|
||||
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||
}
|
||||
|
||||
// Single element (can be a key or simultaneous combination)
|
||||
return [parseElement(combination)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tree node
|
||||
* @returns {Object} - New tree node
|
||||
*/
|
||||
function createTreeNode() {
|
||||
return {
|
||||
config: null,
|
||||
combinationStr: null,
|
||||
children: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree from combinations
|
||||
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||
* @returns {Object} - Root tree node
|
||||
*/
|
||||
function buildTree(combinations) {
|
||||
const root = createTreeNode();
|
||||
|
||||
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||
const sequence = parseCombination(combinationStr);
|
||||
console.log("Parsing combination", combinationStr, "=>", sequence);
|
||||
let currentNode = root;
|
||||
|
||||
for (const keySet of sequence) {
|
||||
const key = setToKey(keySet);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
currentNode.children.set(key, createTreeNode());
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
// Mark as end of sequence and store config
|
||||
currentNode.config = config;
|
||||
currentNode.combinationStr = combinationStr;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse the tree with the current snapshot history
|
||||
* @param {Object} treeRoot - Root of the tree
|
||||
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
|
||||
* @returns {Object|null} - Current node or null if no match
|
||||
*/
|
||||
function traverseTree(treeRoot, snapshotHistory) {
|
||||
let currentNode = treeRoot;
|
||||
|
||||
for (const snapshot of snapshotHistory) {
|
||||
const key = setToKey(snapshot);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
return currentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're inside an input element where typing should work normally
|
||||
* @returns {boolean} - True if inside an input-like element
|
||||
*/
|
||||
function isInInputContext() {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
|
||||
// Check for input/textarea
|
||||
if (tagName === 'input' || tagName === 'textarea') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for contenteditable
|
||||
if (activeElement.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an action for a matched combination
|
||||
* @param {string} elementId - ID of the element
|
||||
* @param {Object} config - HTMX configuration object
|
||||
* @param {string} combinationStr - The matched combination string
|
||||
* @param {boolean} isInside - Whether the focus is inside the element
|
||||
*/
|
||||
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const hasFocus = document.activeElement === element;
|
||||
|
||||
// Extract HTTP method and URL from hx-* attributes
|
||||
let method = 'POST'; // default
|
||||
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 = {};
|
||||
|
||||
// Map hx-target to target
|
||||
if (config['hx-target']) {
|
||||
htmxOptions.target = config['hx-target'];
|
||||
}
|
||||
|
||||
// Map hx-swap to swap
|
||||
if (config['hx-swap']) {
|
||||
htmxOptions.swap = config['hx-swap'];
|
||||
}
|
||||
|
||||
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||
const values = {};
|
||||
if (config['hx-vals']) {
|
||||
Object.assign(values, config['hx-vals']);
|
||||
}
|
||||
values.combination = combinationStr;
|
||||
values.has_focus = hasFocus;
|
||||
values.is_inside = isInside;
|
||||
htmxOptions.values = values;
|
||||
|
||||
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||
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'].includes(key)) {
|
||||
// Remove 'hx-' prefix and convert to camelCase
|
||||
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
htmxOptions[optionKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Make AJAX call with htmx
|
||||
htmx.ajax(method, url, htmxOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events and trigger matching combinations
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
function handleKeyboardEvent(event) {
|
||||
const key = normalizeKey(event.key);
|
||||
|
||||
// Add key to current pressed keys
|
||||
KeyboardRegistry.currentKeys.add(key);
|
||||
console.debug("Received key", key);
|
||||
|
||||
// Create a snapshot of current keyboard state
|
||||
const snapshot = new Set(KeyboardRegistry.currentKeys);
|
||||
|
||||
// Add snapshot to history
|
||||
KeyboardRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (KeyboardRegistry.pendingTimeout) {
|
||||
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||
KeyboardRegistry.pendingTimeout = null;
|
||||
KeyboardRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for all elements
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
// Check all registered elements for matching combinations
|
||||
for (const [elementId, data] of KeyboardRegistry.elements) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
// Check if focus is inside this element (element itself or any child)
|
||||
const isInside = element.contains(document.activeElement);
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree, continue to next element
|
||||
console.debug("No match in tree for event", key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has a URL)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
// Track if ANY element has longer sequences possible
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches
|
||||
if (hasMatch) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Decision logic based on matches and longer sequences
|
||||
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||
// We have matches and NO element has longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but AT LEAST ONE element has longer sequences possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
KeyboardRegistry.pendingMatches = currentMatches;
|
||||
|
||||
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of KeyboardRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
KeyboardRegistry.pendingMatches = [];
|
||||
KeyboardRegistry.pendingTimeout = null;
|
||||
}, KeyboardRegistry.sequenceTimeout);
|
||||
|
||||
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
// No matches and no longer sequences possible
|
||||
// This is an invalid sequence - clear history
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
// This handles invalid sequences like "A C" when only "A B" exists
|
||||
if (!foundAnyMatch) {
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (KeyboardRegistry.snapshotHistory.length > 10) {
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyup event to remove keys from current pressed keys
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
function handleKeyUp(event) {
|
||||
const key = normalizeKey(event.key);
|
||||
KeyboardRegistry.currentKeys.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the global keyboard event listener if not already attached
|
||||
*/
|
||||
function attachGlobalListener() {
|
||||
if (!KeyboardRegistry.listenerAttached) {
|
||||
document.addEventListener('keydown', handleKeyboardEvent);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
KeyboardRegistry.listenerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the global keyboard event listener
|
||||
*/
|
||||
function detachGlobalListener() {
|
||||
if (KeyboardRegistry.listenerAttached) {
|
||||
document.removeEventListener('keydown', handleKeyboardEvent);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
KeyboardRegistry.listenerAttached = false;
|
||||
|
||||
// Clean up all state
|
||||
KeyboardRegistry.currentKeys.clear();
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
if (KeyboardRegistry.pendingTimeout) {
|
||||
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||
KeyboardRegistry.pendingTimeout = null;
|
||||
}
|
||||
KeyboardRegistry.pendingMatches = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add keyboard support to an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||
*/
|
||||
window.add_keyboard_support = function (elementId, combinationsJson) {
|
||||
// Parse the combinations JSON
|
||||
const combinations = JSON.parse(combinationsJson);
|
||||
|
||||
// Build tree for this element
|
||||
const tree = buildTree(combinations);
|
||||
|
||||
// Get element reference
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
console.error("Element with ID", elementId, "not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to registry
|
||||
KeyboardRegistry.elements.set(elementId, {
|
||||
tree: tree,
|
||||
element: element
|
||||
});
|
||||
|
||||
// Attach global listener if not already attached
|
||||
attachGlobalListener();
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove keyboard support from an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
*/
|
||||
window.remove_keyboard_support = function (elementId) {
|
||||
// Remove from registry
|
||||
if (!KeyboardRegistry.elements.has(elementId)) {
|
||||
console.warn("Element with ID", elementId, "not found in keyboard registry!");
|
||||
return;
|
||||
}
|
||||
|
||||
KeyboardRegistry.elements.delete(elementId);
|
||||
|
||||
// If no more elements, detach global listeners
|
||||
if (KeyboardRegistry.elements.size === 0) {
|
||||
detachGlobalListener();
|
||||
}
|
||||
};
|
||||
})();
|
||||
634
tests/html/mouse_support.js
Normal file
634
tests/html/mouse_support.js
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Create mouse bindings
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Global registry to store mouse shortcuts for multiple elements
|
||||
*/
|
||||
const MouseRegistry = {
|
||||
elements: new Map(), // elementId -> { tree, element }
|
||||
listenerAttached: false,
|
||||
snapshotHistory: [],
|
||||
pendingTimeout: null,
|
||||
pendingMatches: [], // Array of matches waiting for timeout
|
||||
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||
clickHandler: null,
|
||||
contextmenuHandler: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize mouse action names
|
||||
* @param {string} action - The action to normalize
|
||||
* @returns {string} - Normalized action name
|
||||
*/
|
||||
function normalizeAction(action) {
|
||||
const normalized = action.toLowerCase().trim();
|
||||
|
||||
// Handle aliases
|
||||
const aliasMap = {
|
||||
'rclick': 'right_click'
|
||||
};
|
||||
|
||||
return aliasMap[normalized] || normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique string key from a Set of actions for Map indexing
|
||||
* @param {Set} actionSet - Set of normalized actions
|
||||
* @returns {string} - Sorted string representation
|
||||
*/
|
||||
function setToKey(actionSet) {
|
||||
return Array.from(actionSet).sort().join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single element (can be a simple click or click with modifiers)
|
||||
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
|
||||
* @returns {Set} - Set of normalized actions
|
||||
*/
|
||||
function parseElement(element) {
|
||||
if (element.includes('+')) {
|
||||
// Click with modifiers
|
||||
return new Set(element.split('+').map(a => normalizeAction(a)));
|
||||
}
|
||||
// Simple click
|
||||
return new Set([normalizeAction(element)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a combination string into sequence elements
|
||||
* @param {string} combination - The combination string (e.g., "click right_click")
|
||||
* @returns {Array} - Array of Sets representing the sequence
|
||||
*/
|
||||
function parseCombination(combination) {
|
||||
// Check if it's a sequence (contains space)
|
||||
if (combination.includes(' ')) {
|
||||
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||
}
|
||||
|
||||
// Single element (can be a click or click with modifiers)
|
||||
return [parseElement(combination)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tree node
|
||||
* @returns {Object} - New tree node
|
||||
*/
|
||||
function createTreeNode() {
|
||||
return {
|
||||
config: null,
|
||||
combinationStr: null,
|
||||
children: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree from combinations
|
||||
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||
* @returns {Object} - Root tree node
|
||||
*/
|
||||
function buildTree(combinations) {
|
||||
const root = createTreeNode();
|
||||
|
||||
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||
const sequence = parseCombination(combinationStr);
|
||||
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
|
||||
let currentNode = root;
|
||||
|
||||
for (const actionSet of sequence) {
|
||||
const key = setToKey(actionSet);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
currentNode.children.set(key, createTreeNode());
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
// Mark as end of sequence and store config
|
||||
currentNode.config = config;
|
||||
currentNode.combinationStr = combinationStr;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse the tree with the current snapshot history
|
||||
* @param {Object} treeRoot - Root of the tree
|
||||
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
|
||||
* @returns {Object|null} - Current node or null if no match
|
||||
*/
|
||||
function traverseTree(treeRoot, snapshotHistory) {
|
||||
let currentNode = treeRoot;
|
||||
|
||||
for (const snapshot of snapshotHistory) {
|
||||
const key = setToKey(snapshot);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
return currentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're inside an input element where clicking should work normally
|
||||
* @returns {boolean} - True if inside an input-like element
|
||||
*/
|
||||
function isInInputContext() {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
|
||||
// Check for input/textarea
|
||||
if (tagName === 'input' || tagName === 'textarea') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for contenteditable
|
||||
if (activeElement.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element that was actually clicked (from registered elements)
|
||||
* @param {Element} target - The clicked element
|
||||
* @returns {string|null} - Element ID if found, null otherwise
|
||||
*/
|
||||
function findRegisteredElement(target) {
|
||||
// Check if target itself is registered
|
||||
if (target.id && MouseRegistry.elements.has(target.id)) {
|
||||
return target.id;
|
||||
}
|
||||
|
||||
// Check if any parent is registered
|
||||
let current = target.parentElement;
|
||||
while (current) {
|
||||
if (current.id && MouseRegistry.elements.has(current.id)) {
|
||||
return current.id;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot from mouse event
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||
* @returns {Set} - Set of actions representing this click
|
||||
*/
|
||||
function createSnapshot(event, baseAction) {
|
||||
const actions = new Set([baseAction]);
|
||||
|
||||
// Add modifiers if present
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
actions.add('ctrl');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
actions.add('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
actions.add('alt');
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an action for a matched combination
|
||||
* @param {string} elementId - ID of the element
|
||||
* @param {Object} config - HTMX configuration object
|
||||
* @param {string} combinationStr - The matched combination string
|
||||
* @param {boolean} isInside - Whether the click was inside the element
|
||||
*/
|
||||
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const hasFocus = document.activeElement === element;
|
||||
|
||||
// Extract HTTP method and URL from hx-* attributes
|
||||
let method = 'POST'; // default
|
||||
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 = {};
|
||||
|
||||
// Map hx-target to target
|
||||
if (config['hx-target']) {
|
||||
htmxOptions.target = config['hx-target'];
|
||||
}
|
||||
|
||||
// Map hx-swap to swap
|
||||
if (config['hx-swap']) {
|
||||
htmxOptions.swap = config['hx-swap'];
|
||||
}
|
||||
|
||||
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||
const values = {};
|
||||
if (config['hx-vals']) {
|
||||
Object.assign(values, config['hx-vals']);
|
||||
}
|
||||
values.combination = combinationStr;
|
||||
values.has_focus = hasFocus;
|
||||
values.is_inside = isInside;
|
||||
htmxOptions.values = values;
|
||||
|
||||
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||
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'].includes(key)) {
|
||||
// Remove 'hx-' prefix and convert to camelCase
|
||||
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
htmxOptions[optionKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Make AJAX call with htmx
|
||||
htmx.ajax(method, url, htmxOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse events and trigger matching combinations
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||
*/
|
||||
function handleMouseEvent(event, baseAction) {
|
||||
// Different behavior for click vs right_click
|
||||
if (baseAction === 'click') {
|
||||
// Click: trigger for ALL registered elements (useful for closing modals/popups)
|
||||
handleGlobalClick(event);
|
||||
} else if (baseAction === 'right_click') {
|
||||
// Right-click: trigger ONLY if clicked on a registered element
|
||||
handleElementRightClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle global click events (triggers for all registered elements)
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
*/
|
||||
function handleGlobalClick(event) {
|
||||
console.debug("Global click detected");
|
||||
|
||||
// Create a snapshot of current mouse action with modifiers
|
||||
const snapshot = createSnapshot(event, 'click');
|
||||
|
||||
// Add snapshot to history
|
||||
MouseRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for ALL registered elements
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
for (const [elementId, data] of MouseRegistry.elements) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
// Check if click was inside this element
|
||||
const isInside = element.contains(event.target);
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree
|
||||
continue;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has config)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches
|
||||
if (hasMatch) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Decision logic based on matches and longer sequences
|
||||
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||
// We have matches and NO longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but longer sequences are possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
MouseRegistry.pendingMatches = currentMatches;
|
||||
|
||||
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of MouseRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
MouseRegistry.pendingMatches = [];
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}, MouseRegistry.sequenceTimeout);
|
||||
|
||||
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
// No matches and no longer sequences possible
|
||||
// This is an invalid sequence - clear history
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
if (!foundAnyMatch) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle right-click events (triggers only for clicked element)
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
*/
|
||||
function handleElementRightClick(event) {
|
||||
// Find which registered element was clicked
|
||||
const elementId = findRegisteredElement(event.target);
|
||||
|
||||
if (!elementId) {
|
||||
// Right-click wasn't on a registered element - don't prevent default
|
||||
// This allows browser context menu to appear
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("Right-click on registered element", elementId);
|
||||
|
||||
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
|
||||
const clickedInside = true;
|
||||
|
||||
// Create a snapshot of current mouse action with modifiers
|
||||
const snapshot = createSnapshot(event, 'right_click');
|
||||
|
||||
// Add snapshot to history
|
||||
MouseRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for this element
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
const data = MouseRegistry.elements.get(elementId);
|
||||
if (!data) return;
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree
|
||||
console.debug("No match in tree for right-click");
|
||||
// Clear history for invalid sequences
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has config)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches
|
||||
if (hasMatch) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: true // Right-click only triggers when clicking on element
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Decision logic based on matches and longer sequences
|
||||
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||
// We have matches and NO longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but longer sequences are possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
MouseRegistry.pendingMatches = currentMatches;
|
||||
|
||||
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of MouseRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
MouseRegistry.pendingMatches = [];
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}, MouseRegistry.sequenceTimeout);
|
||||
|
||||
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
// No matches and no longer sequences possible
|
||||
// This is an invalid sequence - clear history
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
if (!foundAnyMatch) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the global mouse event listeners if not already attached
|
||||
*/
|
||||
function attachGlobalListener() {
|
||||
if (!MouseRegistry.listenerAttached) {
|
||||
// Store handler references for proper removal
|
||||
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||
|
||||
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
MouseRegistry.listenerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the global mouse event listeners
|
||||
*/
|
||||
function detachGlobalListener() {
|
||||
if (MouseRegistry.listenerAttached) {
|
||||
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
MouseRegistry.listenerAttached = false;
|
||||
|
||||
// Clean up handler references
|
||||
MouseRegistry.clickHandler = null;
|
||||
MouseRegistry.contextmenuHandler = null;
|
||||
|
||||
// Clean up all state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add mouse support to an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||
*/
|
||||
window.add_mouse_support = function (elementId, combinationsJson) {
|
||||
// Parse the combinations JSON
|
||||
const combinations = JSON.parse(combinationsJson);
|
||||
|
||||
// Build tree for this element
|
||||
const tree = buildTree(combinations);
|
||||
|
||||
// Get element reference
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
console.error("Element with ID", elementId, "not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to registry
|
||||
MouseRegistry.elements.set(elementId, {
|
||||
tree: tree,
|
||||
element: element
|
||||
});
|
||||
|
||||
// Attach global listener if not already attached
|
||||
attachGlobalListener();
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove mouse support from an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
*/
|
||||
window.remove_mouse_support = function (elementId) {
|
||||
// Remove from registry
|
||||
if (!MouseRegistry.elements.has(elementId)) {
|
||||
console.warn("Element with ID", elementId, "not found in mouse registry!");
|
||||
return;
|
||||
}
|
||||
|
||||
MouseRegistry.elements.delete(elementId);
|
||||
|
||||
// If no more elements, detach global listeners
|
||||
if (MouseRegistry.elements.size === 0) {
|
||||
detachGlobalListener();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -137,6 +137,12 @@
|
||||
<div class="test-container">
|
||||
<h2>Test Input (typing should work normally here)</h2>
|
||||
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
|
||||
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
|
||||
<strong>Parameters Explained:</strong><br>
|
||||
• <code>has_focus</code>: Whether the registered element itself has focus<br>
|
||||
• <code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
|
||||
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
@@ -151,6 +157,9 @@
|
||||
<div id="test-element-2" class="test-element" tabindex="0">
|
||||
This element also responds to ESC and Shift Shift
|
||||
</div>
|
||||
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
|
||||
Remove Element 2 Keyboard Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
@@ -172,12 +181,14 @@
|
||||
window.htmx.ajax = function(method, url, config) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const hasFocus = config.values.has_focus;
|
||||
const isInside = config.values.is_inside;
|
||||
const combination = config.values.combination;
|
||||
|
||||
// Build details string with all config options
|
||||
const details = [
|
||||
`Combination: "${combination}"`,
|
||||
`Element has focus: ${hasFocus}`
|
||||
`Element has focus: ${hasFocus}`,
|
||||
`Focus inside element: ${isInside}`
|
||||
];
|
||||
|
||||
if (config.target) {
|
||||
@@ -223,6 +234,16 @@
|
||||
function clearLog() {
|
||||
document.getElementById('log').innerHTML = '';
|
||||
}
|
||||
|
||||
function removeElement2() {
|
||||
remove_keyboard_support('test-element-2');
|
||||
logEvent('Element 2 keyboard support removed',
|
||||
'ESC and Shift Shift no longer trigger for Element 2',
|
||||
'Element 1 still active', false);
|
||||
// Disable the button
|
||||
event.target.disabled = true;
|
||||
event.target.textContent = 'Keyboard Support Removed';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Include keyboard support script -->
|
||||
356
tests/html/test_mouse_support.html
Normal file
356
tests/html/test_mouse_support.html
Normal file
@@ -0,0 +1,356 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mouse Support Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.test-container {
|
||||
border: 2px solid #333;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.test-element {
|
||||
background-color: #f0f0f0;
|
||||
border: 2px solid #999;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 10px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.test-element:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.test-element:focus {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #2196F3;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin: 5px 0;
|
||||
padding: 5px;
|
||||
border-left: 3px solid #4CAF50;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.log-entry.focus {
|
||||
border-left-color: #2196F3;
|
||||
}
|
||||
|
||||
.log-entry.no-focus {
|
||||
border-left-color: #FF9800;
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.actions-list h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.actions-list ul {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.actions-list code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background-color: #FF5722;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-color: #E64A19;
|
||||
}
|
||||
|
||||
.remove-button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.note {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196F3;
|
||||
padding: 10px 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Mouse Support Test Page</h1>
|
||||
|
||||
<div class="actions-list">
|
||||
<h3>🖱️ Configured Mouse Actions</h3>
|
||||
<p><strong>Element 1 - All Actions:</strong></p>
|
||||
<ul>
|
||||
<li><code>click</code> - Simple left click</li>
|
||||
<li><code>right_click</code> - Right click (context menu blocked)</li>
|
||||
<li><code>ctrl+click</code> - Ctrl/Cmd + Click</li>
|
||||
<li><code>shift+click</code> - Shift + Click</li>
|
||||
<li><code>ctrl+shift+click</code> - Ctrl + Shift + Click</li>
|
||||
<li><code>click right_click</code> - Click then right-click within 500ms</li>
|
||||
<li><code>click click</code> - Click twice in sequence</li>
|
||||
</ul>
|
||||
<p><strong>Element 2 - Using rclick alias:</strong></p>
|
||||
<ul>
|
||||
<li><code>click</code> - Simple click</li>
|
||||
<li><code>rclick</code> - Right click (using rclick alias)</li>
|
||||
<li><code>click rclick</code> - Click then right-click sequence (using alias)</li>
|
||||
</ul>
|
||||
<p><strong>Note:</strong> <code>rclick</code> is an alias for <code>right_click</code> and works identically.</p>
|
||||
<p><strong>Tip:</strong> Try different click combinations! Right-click menu will be blocked on test elements.</p>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Click Behavior:</strong> The <code>click</code> action is detected GLOBALLY (anywhere on the page).
|
||||
Try clicking outside the test elements - the click action will still trigger! The <code>is_inside</code>
|
||||
parameter tells you if the click was inside or outside the element (perfect for "close popup if clicked outside" logic).
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Right-Click Behavior:</strong> The <code>right_click</code> action is detected ONLY when clicking ON the element.
|
||||
Try right-clicking outside the test elements - the browser's context menu will appear normally.
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Mac Users:</strong> Use Cmd (⌘) instead of Ctrl. The library handles cross-platform compatibility automatically.
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Element 1 (All Actions)</h2>
|
||||
<div id="test-element-1" class="test-element" tabindex="0">
|
||||
Try different mouse actions here!<br>
|
||||
Click, Right-click, Ctrl+Click, Shift+Click, sequences...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Element 2 (Using rclick alias)</h2>
|
||||
<div id="test-element-2" class="test-element" tabindex="0">
|
||||
This element uses "rclick" alias for right-click<br>
|
||||
Also has a "click rclick" sequence
|
||||
</div>
|
||||
<button class="remove-button" onclick="removeElement2()">
|
||||
Remove Element 2 Mouse Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Input (normal clicking should work here)</h2>
|
||||
<input type="text" placeholder="Try clicking, right-clicking here - should work normally"
|
||||
style="width: 100%; padding: 10px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div class="test-container" style="background-color: #f9f9f9;">
|
||||
<h2>🎯 Click Outside Test Area</h2>
|
||||
<p>Click anywhere in this gray area (outside the test elements above) to see that <code>click</code> is detected globally!</p>
|
||||
<p style="margin-top: 20px; padding: 30px; background-color: white; border: 2px dashed #999; border-radius: 5px; text-align: center;">
|
||||
This is just empty space - but clicking here will still trigger the registered <code>click</code> actions!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Event Log</h2>
|
||||
<button class="clear-button" onclick="clearLog()">Clear Log</button>
|
||||
<div id="log" class="log-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Include htmx -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Mock htmx.ajax for testing -->
|
||||
<script>
|
||||
// Store original htmx.ajax if it exists
|
||||
const originalHtmxAjax = window.htmx && window.htmx.ajax;
|
||||
|
||||
// Override htmx.ajax for testing purposes
|
||||
if (window.htmx) {
|
||||
window.htmx.ajax = function(method, url, config) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const hasFocus = config.values.has_focus;
|
||||
const isInside = config.values.is_inside;
|
||||
const combination = config.values.combination;
|
||||
|
||||
// Build details string with all config options
|
||||
const details = [
|
||||
`Combination: "${combination}"`,
|
||||
`Element has focus: ${hasFocus}`,
|
||||
`Click inside element: ${isInside}`
|
||||
];
|
||||
|
||||
if (config.target) {
|
||||
details.push(`Target: ${config.target}`);
|
||||
}
|
||||
if (config.swap) {
|
||||
details.push(`Swap: ${config.swap}`);
|
||||
}
|
||||
if (config.values) {
|
||||
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus' && k !== 'is_inside');
|
||||
if (extraVals.length > 0) {
|
||||
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
|
||||
}
|
||||
}
|
||||
|
||||
logEvent(
|
||||
`[${timestamp}] ${method} ${url}`,
|
||||
...details,
|
||||
hasFocus
|
||||
);
|
||||
|
||||
// Uncomment below to use real htmx.ajax if you have a backend
|
||||
// if (originalHtmxAjax) {
|
||||
// originalHtmxAjax.call(this, method, url, config);
|
||||
// }
|
||||
};
|
||||
}
|
||||
|
||||
function logEvent(title, ...details) {
|
||||
const log = document.getElementById('log');
|
||||
const hasFocus = details[details.length - 1];
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
|
||||
entry.innerHTML = `
|
||||
<strong>${title}</strong><br>
|
||||
${details.slice(0, -1).join('<br>')}
|
||||
`;
|
||||
|
||||
log.insertBefore(entry, log.firstChild);
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById('log').innerHTML = '';
|
||||
}
|
||||
|
||||
function removeElement2() {
|
||||
remove_mouse_support('test-element-2');
|
||||
logEvent('Element 2 mouse support removed',
|
||||
'Click and right-click no longer trigger for Element 2',
|
||||
'Element 1 still active', false);
|
||||
// Disable the button
|
||||
event.target.disabled = true;
|
||||
event.target.textContent = 'Mouse Support Removed';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Include mouse support script -->
|
||||
<script src="mouse_support.js"></script>
|
||||
|
||||
<!-- Initialize mouse support -->
|
||||
<script>
|
||||
// Element 1 - Full configuration
|
||||
const combinations1 = {
|
||||
"click": {
|
||||
"hx-post": "/test/click"
|
||||
},
|
||||
"right_click": {
|
||||
"hx-post": "/test/right-click"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/test/ctrl-click",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"shift+click": {
|
||||
"hx-post": "/test/shift-click",
|
||||
"hx-target": "#result"
|
||||
},
|
||||
"ctrl+shift+click": {
|
||||
"hx-post": "/test/ctrl-shift-click",
|
||||
"hx-vals": {"modifier": "both"}
|
||||
},
|
||||
"click right_click": {
|
||||
"hx-post": "/test/click-then-right-click",
|
||||
"hx-vals": {"type": "sequence"}
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/test/double-click-sequence"
|
||||
}
|
||||
};
|
||||
|
||||
add_mouse_support('test-element-1', JSON.stringify(combinations1));
|
||||
|
||||
// Element 2 - Using rclick alias
|
||||
const combinations2 = {
|
||||
"click": {
|
||||
"hx-post": "/test/element2-click"
|
||||
},
|
||||
"rclick": { // Using rclick alias instead of right_click
|
||||
"hx-post": "/test/element2-rclick"
|
||||
},
|
||||
"click rclick": { // Sequence using rclick alias
|
||||
"hx-post": "/test/element2-click-rclick-sequence"
|
||||
}
|
||||
};
|
||||
|
||||
add_mouse_support('test-element-2', JSON.stringify(combinations2));
|
||||
|
||||
// Log initial state
|
||||
logEvent('Mouse support initialized',
|
||||
'Element 1: All mouse actions configured',
|
||||
'Element 2: Using "rclick" alias (click, rclick, and click rclick sequence)',
|
||||
'Smart timeout: 500ms for sequences', false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user