3 Commits

Author SHA1 Message Date
e3d9b106fb Added CLAUDE.md 2025-11-26 20:50:26 +01:00
d2cf51d7c3 Added Mouse Support 2025-11-25 23:13:47 +01:00
53253278b2 Working on Dropdown. Removed dynamic_get 2025-11-25 21:28:48 +01:00
14 changed files with 3175 additions and 65 deletions

425
CLAUDE.md Normal file
View File

@@ -0,0 +1,425 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
MyFastHtml is a Python utility library that simplifies FastHTML application development by providing:
- Command management system for client-server interactions
- Bidirectional data binding system
- Predefined authentication pages and routes
- Interactive control helpers
- Session-based instance management
**Tech Stack**: Python 3.12+, FastHTML, HTMX, DaisyUI 5, Tailwind CSS 4
## Development Workflow and Guidelines
### Development Process
**Code must always be testable**. Before writing any code:
1. **Explain available options first** - Present different approaches to solve the problem
2. **Wait for validation** - Ensure mutual understanding of requirements before implementation
3. **No code without approval** - Only proceed after explicit validation
### Collaboration Style
**Ask questions to clarify understanding or suggest alternative approaches:**
- Ask questions **one at a time**
- Wait for complete answer before asking the next question
- Indicate progress: "Question 1/5" if multiple questions are needed
- Never assume - always clarify ambiguities
### Communication
**Conversations**: French or English
**Code, documentation, comments**: English only
### Code Standards
**Follow PEP 8** conventions strictly:
- Variable and function names: `snake_case`
- Explicit, descriptive naming
- **No emojis in code**
**Documentation**:
- Use Google or NumPy docstring format
- Document all public functions and classes
- Include type hints where applicable
### Dependency Management
**When introducing new dependencies:**
- List all external dependencies explicitly
- Propose alternatives using Python standard library when possible
- Explain why each dependency is needed
### Unit Testing with pytest
**Test naming patterns:**
- Passing tests: `test_i_can_xxx` - Tests that should succeed
- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
**Test structure:**
- Use **functions**, not classes (unless inheritance is required)
- Before writing tests, **list all planned tests with explanations**
- Wait for validation before implementing tests
**Example:**
```python
def test_i_can_create_command_with_valid_name():
"""Test that a command can be created with a valid name."""
cmd = Command("valid_name", "description", lambda: None)
assert cmd.name == "valid_name"
def test_i_cannot_create_command_with_empty_name():
"""Test that creating a command with empty name raises ValueError."""
with pytest.raises(ValueError):
Command("", "description", lambda: None)
```
### File Management
**Always specify the full file path** when adding or modifying files:
```
✅ Modifying: src/myfasthtml/core/commands.py
✅ Creating: tests/core/test_new_feature.py
```
### Error Handling
**When errors occur:**
1. **Explain the problem clearly first**
2. **Do not propose a fix immediately**
3. **Wait for validation** that the diagnosis is correct
4. Only then propose solutions
## Development Commands
### Testing
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/core/test_bindings.py
# Run specific test
pytest tests/core/test_bindings.py::test_function_name
# Run tests with verbose output
pytest -v
```
### Cleaning
```bash
# Clean build artifacts and cache
make clean
# Clean package distribution files
make clean-package
# Clean test artifacts (.sesskey, test databases)
make clean-tests
# Clean everything including source artifacts
make clean-all
```
### Package Building
```bash
# Build distribution
python -m build
# Install in development mode
pip install -e .
```
## Architecture Overview
### Core System: Commands
Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`.
**Key classes:**
- `BaseCommand`: Base class for all commands with HTMX integration
- `Command`: Standard command that executes a Python callable
- `LambdaCommand`: Inline command for simple operations
- `CommandsManager`: Global registry for command execution
**How commands work:**
1. Create command with action: `cmd = Command("name", "description", callable)`
2. Command auto-registers with `CommandsManager`
3. `cmd.get_htmx_params()` generates HTMX attributes (`hx-post`, `hx-vals`)
4. HTMX posts to `/myfasthtml/commands` route with `c_id`
5. `CommandsManager` routes to correct command's `execute()` method
**Command customization:**
```python
# Change HTMX target and swap
cmd.htmx(target="#result", swap="innerHTML")
# Bind to observable data (disables swap by default)
cmd.bind(data_object)
```
### Core System: Bindings
Bidirectional data binding system connects UI components with Python data objects. Located in `src/myfasthtml/core/bindings.py`.
**Key concepts:**
- **Observable objects**: Use `make_observable()` from myutils to enable change detection
- **Three-phase lifecycle**: Create → Activate (bind_ft) → Deactivate
- **Detection modes**: How changes are detected (ValueChange, AttributePresence, SelectValueChange)
- **Update modes**: How UI updates (ValueChange, AttributePresence, SelectValueChange)
- **Data converters**: Transform data between UI and Python representations
**Binding flow:**
1. User changes input → HTMX posts to `/myfasthtml/bindings`
2. `Binding.update()` receives form data, updates observable object
3. Observable triggers change event → `Binding.notify()`
4. All bound UI elements update via HTMX swap-oob
**Helper usage:**
```python
from myfasthtml.controls.helpers import mk
# Bind input and label to same data
input_elt = Input(name="field")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data, "attr"))
mk.manage_binding(label_elt, Binding(data, "attr"))
```
**Important binding notes:**
- Elements MUST have a `name` attribute to trigger updates
- Multiple elements can bind to same data attribute
- First binding call uses `init_binding=True` to set initial value
- Bindings route through `/myfasthtml/bindings` endpoint
### Core System: Instances
Session-scoped instance management system. Located in `src/myfasthtml/core/instances.py`.
**Key classes:**
- `BaseInstance`: Base for all managed instances
- `SingleInstance`: One instance per parent per session
- `UniqueInstance`: One instance ever per session (singleton-like)
- `RootInstance`: Top-level singleton for application
- `InstancesManager`: Global registry with session-based isolation
**Instance creation pattern:**
```python
# __new__ checks registry before creating
# If instance exists with same (session_id, _id), returns existing
instance = MyInstance(parent, session, _id="optional")
```
**Automatic ID generation:**
- SingleInstance: `snake_case_class_name`
- UniqueInstance: `snake_case_class_name`
- Regular BaseInstance: `parent_prefix-uuid`
### Application Setup
**Main entry point**: `create_app()` in `src/myfasthtml/myfastapp.py`
```python
from myfasthtml.myfastapp import create_app
app, rt = create_app(
daisyui=True, # Include DaisyUI CSS
vis=True, # Include vis-network.js
protect_routes=True, # Enable auth beforeware
mount_auth_app=False, # Mount auth routes
base_url=None # Base URL for auth
)
```
**What create_app does:**
1. Adds MyFastHtml CSS/JS assets via custom static route
2. Optionally adds DaisyUI 5 + Tailwind CSS 4
3. Optionally adds vis-network.js
4. Mounts `/myfasthtml` app for commands and bindings routes
5. Optionally sets up auth routes and beforeware
6. Creates AuthProxy instance if auth enabled
### Authentication System
Located in `src/myfasthtml/auth/`. Integrates with FastAPI backend (myauth package).
**Key components:**
- `auth/utils.py`: JWT helpers, beforeware for route protection
- `auth/routes.py`: Login, register, logout routes
- `auth/pages/`: LoginPage, RegisterPage, WelcomePage components
**How auth works:**
1. Beforeware checks `access_token` in session before each route
2. Auto-refreshes token if < 5 minutes to expiry
3. Redirects to `/login` if token invalid/missing
4. Protected routes receive `auth` parameter with user info
**Session structure:**
```python
{
'access_token': 'jwt_token',
'refresh_token': 'refresh_token',
'user_info': {
'email': 'user@example.com',
'username': 'user',
'roles': ['admin'],
'id': 'uuid',
'created_at': 'timestamp',
'updated_at': 'timestamp'
}
}
```
## Project Structure
```
src/myfasthtml/
├── myfastapp.py # Main app factory (create_app)
├── core/
│ ├── commands.py # Command system
│ ├── bindings.py # Binding system
│ ├── instances.py # Instance management
│ ├── utils.py # Utilities, routes app
│ ├── constants.py # Routes, constants
│ ├── dbmanager.py # Database helpers
│ ├── AuthProxy.py # Auth proxy instance
│ └── network_utils.py # Network utilities
├── controls/
│ ├── helpers.py # mk class with UI helpers
│ ├── BaseCommands.py # Base command implementations
│ ├── Search.py # Search control
│ └── Keyboard.py # Keyboard shortcuts
├── auth/
│ ├── utils.py # JWT, beforeware
│ ├── routes.py # Auth routes
│ └── pages/ # Login, Register, Welcome pages
├── icons/ # Icon libraries (fluent, material, etc.)
├── assets/ # CSS/JS files
└── test/ # Test utilities
tests/
├── core/ # Core system tests
├── testclient/ # TestClient and TestableElement tests
├── auth/ # Authentication tests
├── controls/ # Control tests
└── html/ # HTML component tests
```
## Testing System
**Custom test client**: `myfasthtml.test.TestClient` extends FastHTML test client
**Key features:**
- `user.open(path)`: Navigate to route
- `user.find_element(selector)`: Find element by CSS selector
- `user.should_see(text)`: Assert text in response
- Returns `TestableElement` objects with component-specific methods
**Testable element types:**
- `TestableInput`: `.send(value)`
- `TestableCheckbox`: `.check()`, `.uncheck()`, `.toggle()`
- `TestableTextarea`: `.send(value)`, `.append(text)`, `.clear()`
- `TestableSelect`: `.select(value)`, `.select_by_text(text)`, `.deselect(value)`
- `TestableRange`: `.set(value)`, `.increase()`, `.decrease()`
- `TestableRadio`: `.select()`
- `TestableButton`: `.click()`
- `TestableDatalist`: `.send(value)`, `.select_suggestion(value)`
**Test pattern:**
```python
def test_component(user, rt):
@rt("/")
def index():
data = Data("initial")
component = Component(name="field")
label = Label()
mk.manage_binding(component, Binding(data))
mk.manage_binding(label, Binding(data))
return component, label
user.open("/")
user.should_see("initial")
elem = user.find_element("selector")
elem.method("new_value")
user.should_see("new_value")
```
## Important Patterns
### Creating interactive buttons with commands
```python
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
def action():
return "Result"
cmd = Command("action", "Description", action)
button = mk.button("Click", command=cmd)
```
### Bidirectional binding
```python
from myutils.observable import make_observable
from myfasthtml.core.bindings import Binding
from myfasthtml.controls.helpers import mk
data = make_observable(Data("value"))
input_elt = Input(name="field")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data, "value"))
mk.manage_binding(label_elt, Binding(data, "value"))
```
### Using helpers (mk class)
```python
# Button with command
mk.button("Text", command=cmd, cls="btn-primary")
# Icon with command
mk.icon(icon_svg, size=20, command=cmd)
# Label with icon
mk.label("Text", icon=icon_svg, size="sm")
# Generic wrapper
mk.mk(element, command=cmd, binding=binding)
```
## Common Gotchas
1. **Bindings require `name` attribute**: Without it, form data won't include the field
2. **Commands auto-register**: Don't manually register with CommandsManager
3. **Instances use __new__ caching**: Same (session, id) returns existing instance
4. **First binding needs init**: Use `init_binding=True` to set initial value
5. **Observable required for bindings**: Use `make_observable()` from myutils
6. **Auth routes need base_url**: Pass `base_url` to `create_app()` for proper auth API calls
## Dependencies
**Core:**
- python-fasthtml: Web framework
- myauth: Authentication backend
- mydbengine: Database abstraction
- myutils: Observable pattern, utilities
**UI:**
- DaisyUI 5: Component library
- Tailwind CSS 4: Styling
- vis-network: Network visualization
**Development:**
- pytest: Testing framework
- httpx: HTTP client for tests
- python-dotenv: Environment variables

View File

@@ -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: The library automatically adds these parameters to every request:
- `combination` - The combination that triggered the action (e.g., "Ctrl+S") - `combination` - The combination that triggered the action (e.g., "Ctrl+S")
- `has_focus` - Boolean indicating if the element had focus - `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: Example final request:
```javascript ```javascript
@@ -196,7 +197,8 @@ htmx.ajax('POST', '/save-url', {
values: { values: {
extra: "data", // from hx-vals extra: "data", // from hx-vals
combination: "Ctrl+S", // automatic 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)}')" 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 ## Technical Details
### Trie-based Matching ### Trie-based Matching
@@ -277,7 +328,7 @@ The library uses a prefix tree (trie) data structure:
Configuration objects are mapped to htmx.ajax() calls: Configuration objects are mapped to htmx.ajax() calls:
- `hx-*` attributes are converted to camelCase parameters - `hx-*` attributes are converted to camelCase parameters
- HTTP method is extracted from `hx-post`, `hx-get`, etc. - 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 - All standard HTMX options are supported
### Key Normalization ### Key Normalization

439
docs/Mouse Support.md Normal file
View 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

View File

@@ -4,11 +4,11 @@ import yaml
from fasthtml import serve from fasthtml import serve
from myfasthtml.controls.CommandsDebugger import CommandsDebugger from myfasthtml.controls.CommandsDebugger import CommandsDebugger
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.FileUpload import FileUpload from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import Ids, mk from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.instances import UniqueInstance from myfasthtml.core.instances import UniqueInstance
@@ -38,6 +38,7 @@ def index(session):
layout.set_footer("Goodbye World") layout.set_footer("Goodbye World")
tabs_manager = TabsManager(layout, _id=f"-tabs_manager") tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
add_tab = tabs_manager.commands.add_tab
btn_show_right_drawer = mk.button("show", btn_show_right_drawer = mk.button("show",
command=layout.commands.toggle_drawer("right"), command=layout.commands.toggle_drawer("right"),
id="btn_show_right_drawer_id") id="btn_show_right_drawer_id")
@@ -45,22 +46,22 @@ def index(session):
instances_debugger = InstancesDebugger(layout) instances_debugger = InstancesDebugger(layout)
btn_show_instances_debugger = mk.label("Instances", btn_show_instances_debugger = mk.label("Instances",
icon=volume_object_storage, icon=volume_object_storage,
command=tabs_manager.commands.add_tab("Instances", instances_debugger), command=add_tab("Instances", instances_debugger),
id=instances_debugger.get_id()) id=instances_debugger.get_id())
commands_debugger = CommandsDebugger(layout) commands_debugger = CommandsDebugger(layout)
btn_show_commands_debugger = mk.label("Commands", btn_show_commands_debugger = mk.label("Commands",
icon=None, icon=None,
command=tabs_manager.commands.add_tab("Commands", commands_debugger), command=add_tab("Commands", commands_debugger),
id=commands_debugger.get_id()) id=commands_debugger.get_id())
btn_file_upload = mk.label("Upload", btn_file_upload = mk.label("Upload",
icon=folder_open20_regular, icon=folder_open20_regular,
command=tabs_manager.commands.add_tab("File Open", FileUpload(layout, _id="-file_upload")), command=add_tab("File Open", FileUpload(layout, _id="-file_upload")),
id="file_upload_id") id="file_upload_id")
btn_popup = mk.label("Popup", btn_popup = mk.label("Popup",
command=tabs_manager.commands.add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-popup"))) command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
layout.header_left.add(tabs_manager.add_tab_btn()) layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer) layout.header_right.add(btn_show_right_drawer)
@@ -70,10 +71,8 @@ def index(session):
layout.left_drawer.add(btn_popup, "Test") layout.left_drawer.add(btn_popup, "Test")
layout.set_main(tabs_manager) layout.set_main(tabs_manager)
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o", keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
tabs_manager.commands.add_tab("File Open", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
FileUpload(layout, keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
_id="-file_upload")))
keyboard.add("ctrl+n", tabs_manager.commands.add_tab("File Open", FileUpload(layout, _id="-file_upload")))
return layout, keyboard return layout, keyboard

View File

@@ -446,9 +446,6 @@
display: inline-block; display: inline-block;
} }
.mf-dropdown-wrapper:hover .mf-dropdown {
display: block;
}
.mf-dropdown { .mf-dropdown {
display: none; display: none;

View File

@@ -270,12 +270,12 @@ function updateTabs(controllerId) {
/** /**
* Create keyboard bindings * Create keyboard bindings
*/ */
(function() { (function () {
/** /**
* Global registry to store keyboard shortcuts for multiple elements * Global registry to store keyboard shortcuts for multiple elements
*/ */
const KeyboardRegistry = { const KeyboardRegistry = {
elements: new Map(), // elementId -> { trie, element } elements: new Map(), // elementId -> { tree, element }
listenerAttached: false, listenerAttached: false,
currentKeys: new Set(), currentKeys: new Set(),
snapshotHistory: [], snapshotHistory: [],
@@ -339,10 +339,10 @@ function updateTabs(controllerId) {
} }
/** /**
* Create a new trie node * Create a new tree node
* @returns {Object} - New trie node * @returns {Object} - New tree node
*/ */
function createTrieNode() { function createTreeNode() {
return { return {
config: null, config: null,
combinationStr: null, combinationStr: null,
@@ -351,22 +351,23 @@ function updateTabs(controllerId) {
} }
/** /**
* Build a trie from combinations * Build a tree from combinations
* @param {Object} combinations - Map of combination strings to HTMX config objects * @param {Object} combinations - Map of combination strings to HTMX config objects
* @returns {Object} - Root trie node * @returns {Object} - Root tree node
*/ */
function buildTrie(combinations) { function buildTree(combinations) {
const root = createTrieNode(); const root = createTreeNode();
for (const [combinationStr, config] of Object.entries(combinations)) { for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr); const sequence = parseCombination(combinationStr);
console.log("Parsing combination", combinationStr, "=>", sequence);
let currentNode = root; let currentNode = root;
for (const keySet of sequence) { for (const keySet of sequence) {
const key = setToKey(keySet); const key = setToKey(keySet);
if (!currentNode.children.has(key)) { if (!currentNode.children.has(key)) {
currentNode.children.set(key, createTrieNode()); currentNode.children.set(key, createTreeNode());
} }
currentNode = currentNode.children.get(key); currentNode = currentNode.children.get(key);
@@ -381,13 +382,13 @@ function updateTabs(controllerId) {
} }
/** /**
* Traverse the trie with the current snapshot history * Traverse the tree with the current snapshot history
* @param {Object} trieRoot - Root of the trie * @param {Object} treeRoot - Root of the tree
* @param {Array} snapshotHistory - Array of Sets representing pressed keys * @param {Array} snapshotHistory - Array of Sets representing pressed keys
* @returns {Object|null} - Current node or null if no match * @returns {Object|null} - Current node or null if no match
*/ */
function traverseTrie(trieRoot, snapshotHistory) { function traverseTree(treeRoot, snapshotHistory) {
let currentNode = trieRoot; let currentNode = treeRoot;
for (const snapshot of snapshotHistory) { for (const snapshot of snapshotHistory) {
const key = setToKey(snapshot); const key = setToKey(snapshot);
@@ -430,8 +431,9 @@ function updateTabs(controllerId) {
* @param {string} elementId - ID of the element * @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object * @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string * @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); const element = document.getElementById(elementId);
if (!element) return; if (!element) return;
@@ -475,13 +477,14 @@ function updateTabs(controllerId) {
htmxOptions.swap = config['hx-swap']; 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 = {}; const values = {};
if (config['hx-vals']) { if (config['hx-vals']) {
Object.assign(values, config['hx-vals']); Object.assign(values, config['hx-vals']);
} }
values.combination = combinationStr; values.combination = combinationStr;
values.has_focus = hasFocus; values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values; htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.) // Add any other hx-* attributes (like hx-headers, hx-select, etc.)
@@ -506,6 +509,7 @@ function updateTabs(controllerId) {
// Add key to current pressed keys // Add key to current pressed keys
KeyboardRegistry.currentKeys.add(key); KeyboardRegistry.currentKeys.add(key);
console.debug("Received key", key);
// Create a snapshot of current keyboard state // Create a snapshot of current keyboard state
const snapshot = new Set(KeyboardRegistry.currentKeys); const snapshot = new Set(KeyboardRegistry.currentKeys);
@@ -530,13 +534,17 @@ function updateTabs(controllerId) {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
if (!element) continue; if (!element) continue;
const trieRoot = data.trie; // Check if focus is inside this element (element itself or any child)
const isInside = element.contains(document.activeElement);
// Traverse the trie with current snapshot history const treeRoot = data.tree;
const currentNode = traverseTrie(trieRoot, KeyboardRegistry.snapshotHistory);
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
if (!currentNode) { if (!currentNode) {
// No match in this trie, continue to next element // No match in this tree, continue to next element
console.debug("No match in tree for event", key);
continue; continue;
} }
@@ -559,7 +567,8 @@ function updateTabs(controllerId) {
currentMatches.push({ currentMatches.push({
elementId: elementId, elementId: elementId,
config: currentNode.config, config: currentNode.config,
combinationStr: currentNode.combinationStr combinationStr: currentNode.combinationStr,
isInside: isInside
}); });
} }
} }
@@ -574,7 +583,7 @@ function updateTabs(controllerId) {
// We have matches and NO element has longer sequences possible // We have matches and NO element has longer sequences possible
// Trigger ALL matches immediately // Trigger ALL matches immediately
for (const match of currentMatches) { 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 // Clear history after triggering
@@ -589,7 +598,7 @@ function updateTabs(controllerId) {
KeyboardRegistry.pendingTimeout = setTimeout(() => { KeyboardRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches // Timeout expired, trigger ALL pending matches
for (const match of KeyboardRegistry.pendingMatches) { for (const match of KeyboardRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr); triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
} }
// Clear state // Clear state
@@ -640,28 +649,706 @@ 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 * Add keyboard support to an element
* @param {string} elementId - The ID of the element * @param {string} elementId - The ID of the element
* @param {string} combinationsJson - JSON string of combinations mapping * @param {string} combinationsJson - JSON string of combinations mapping
*/ */
window.add_keyboard_support = function(elementId, combinationsJson) { window.add_keyboard_support = function (elementId, combinationsJson) {
// Parse the combinations JSON // Parse the combinations JSON
const combinations = JSON.parse(combinationsJson); const combinations = JSON.parse(combinationsJson);
// Build trie for this element // Build tree for this element
const trie = buildTrie(combinations); const tree = buildTree(combinations);
// Get element reference // Get element reference
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
if (!element) {
console.error("Element with ID", elementId, "not found!");
return;
}
// Add to registry // Add to registry
KeyboardRegistry.elements.set(elementId, { KeyboardRegistry.elements.set(elementId, {
trie: trie, tree: tree,
element: element element: element
}); });
// Attach global listener if not already attached // Attach global listener if not already attached
attachGlobalListener(); 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();
}
};
})(); })();

View File

@@ -1,19 +1,63 @@
from fastcore.xml import FT
from fasthtml.components import Div 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 from myfasthtml.core.instances import MultipleInstance
class Commands(BaseCommands):
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:
def __init__(self):
self.opened = False
class Dropdown(MultipleInstance): class Dropdown(MultipleInstance):
def __init__(self, parent, content, button=None, _id=None): def __init__(self, parent, content=None, button=None, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.button = button self.button = Div(button) if not isinstance(button, FT) else button
self.content = content self.content = content
self.commands = Commands(self)
self._state = DropdownState()
self._toggle_command = self.commands.toggle()
def toggle(self):
self._state.opened = not self._state.opened
return self._mk_content()
def close(self):
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 ''}",
id=f"{self._id}-content"),
def render(self): def render(self):
return Div( return Div(
Div(self.button) if self.button else Div("None"), Div(
Div(self.content, cls="mf-dropdown"), Div(self.button) if self.button else Div("None"),
cls="mf-dropdown-wrapper" self._mk_content(),
cls="mf-dropdown-wrapper"
),
Keyboard(self, "-keyboard").add("esc", self.commands.close()),
Mouse(self, "-mouse").add("click", self.commands.click()),
id=self._id
) )
def __ft__(self): def __ft__(self):

View 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()

View File

@@ -102,7 +102,7 @@ class TabsManager(MultipleInstance):
tab_config = self._state.tabs[tab_id] tab_config = self._state.tabs[tab_id]
if tab_config["component_type"] is None: if tab_config["component_type"] is None:
return None return None
return InstancesManager.dynamic_get(self, tab_config["component_type"], tab_config["component_id"]) return InstancesManager.get(self._session, tab_config["component_id"])
@staticmethod @staticmethod
def _get_tab_count(): def _get_tab_count():

View File

@@ -2,10 +2,8 @@ import logging
import uuid import uuid
from typing import Optional from typing import Optional
from dbengine.utils import get_class
from myfasthtml.controls.helpers import Ids from myfasthtml.controls.helpers import Ids
from myfasthtml.core.utils import pascal_to_snake, snake_to_pascal from myfasthtml.core.utils import pascal_to_snake
logger = logging.getLogger("InstancesManager") logger = logging.getLogger("InstancesManager")
@@ -215,20 +213,6 @@ class InstancesManager:
for key, instance in InstancesManager.instances.items() for key, instance in InstancesManager.instances.items()
if key[0] != session_id if key[0] != session_id
} }
@staticmethod
def dynamic_get(parent: BaseInstance, component_type: str, instance_id: str):
logger.debug(f"Dynamic get: {component_type=} {instance_id=}")
cls = InstancesManager._get_class_name(component_type)
fully_qualified_name = f"myfasthtml.controls.{cls}.{cls}"
cls = get_class(fully_qualified_name)
return cls(parent, instance_id)
@staticmethod
def _get_class_name(component_type: str) -> str:
component_type = component_type.replace("mf-", "")
component_type = snake_to_pascal(component_type)
return component_type
RootInstance = SingleInstance(None, special_session, Ids.Root) RootInstance = SingleInstance(None, special_session, Ids.Root)

View 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
View 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();
}
};
})();

View File

@@ -137,6 +137,12 @@
<div class="test-container"> <div class="test-container">
<h2>Test Input (typing should work normally here)</h2> <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;"> <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>
<div class="test-container"> <div class="test-container">
@@ -151,6 +157,9 @@
<div id="test-element-2" class="test-element" tabindex="0"> <div id="test-element-2" class="test-element" tabindex="0">
This element also responds to ESC and Shift Shift This element also responds to ESC and Shift Shift
</div> </div>
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
Remove Element 2 Keyboard Support
</button>
</div> </div>
<div class="test-container"> <div class="test-container">
@@ -172,12 +181,14 @@
window.htmx.ajax = function(method, url, config) { window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString(); const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus; const hasFocus = config.values.has_focus;
const isInside = config.values.is_inside;
const combination = config.values.combination; const combination = config.values.combination;
// Build details string with all config options // Build details string with all config options
const details = [ const details = [
`Combination: "${combination}"`, `Combination: "${combination}"`,
`Element has focus: ${hasFocus}` `Element has focus: ${hasFocus}`,
`Focus inside element: ${isInside}`
]; ];
if (config.target) { if (config.target) {
@@ -223,6 +234,16 @@
function clearLog() { function clearLog() {
document.getElementById('log').innerHTML = ''; 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> </script>
<!-- Include keyboard support script --> <!-- Include keyboard support script -->

View 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>