4 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
52b4e6a8b6 Working on Dropdown 2025-11-24 22:47:49 +01:00
14 changed files with 3249 additions and 56 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:
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
- `has_focus` - Boolean indicating if the element had focus
- `is_inside` - Boolean indicating if the focus is inside the element (element itself or any child)
Example final request:
```javascript
@@ -196,7 +197,8 @@ htmx.ajax('POST', '/save-url', {
values: {
extra: "data", // from hx-vals
combination: "Ctrl+S", // automatic
has_focus: true // automatic
has_focus: true, // automatic
is_inside: true // automatic
}
})
```
@@ -262,6 +264,55 @@ f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
```
### Removing Keyboard Support
When you no longer need keyboard support for an element:
```python
# Remove keyboard support
f"remove_keyboard_support('{element_id}')"
```
**Behavior**:
- Removes the element from the keyboard registry
- If this was the last element, automatically detaches global event listeners
- Cleans up all associated state (timeouts, snapshots, etc.)
- Other elements continue to work normally
**Example**:
```javascript
// Add support
add_keyboard_support('modal', '{"esc": {"hx-post": "/close"}}');
// Later, remove support
remove_keyboard_support('modal');
// If no other elements remain, keyboard listeners are completely removed
```
## API Reference
### add_keyboard_support(elementId, combinationsJson)
Adds keyboard support to an element.
**Parameters**:
- `elementId` (string): ID of the HTML element
- `combinationsJson` (string): JSON string of combinations with HTMX configs
**Returns**: void
### remove_keyboard_support(elementId)
Removes keyboard support from an element.
**Parameters**:
- `elementId` (string): ID of the HTML element
**Returns**: void
**Side effects**:
- If last element: detaches global event listeners and cleans up all state
## Technical Details
### Trie-based Matching
@@ -277,7 +328,7 @@ The library uses a prefix tree (trie) data structure:
Configuration objects are mapped to htmx.ajax() calls:
- `hx-*` attributes are converted to camelCase parameters
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
- `combination` and `has_focus` are automatically added to values
- `combination`, `has_focus`, and `is_inside` are automatically added to values
- All standard HTMX options are supported
### Key Normalization

439
docs/Mouse Support.md Normal file
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,6 +4,7 @@ import yaml
from fasthtml import serve
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard
@@ -37,6 +38,7 @@ def index(session):
layout.set_footer("Goodbye World")
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
add_tab = tabs_manager.commands.add_tab
btn_show_right_drawer = mk.button("show",
command=layout.commands.toggle_drawer("right"),
id="btn_show_right_drawer_id")
@@ -44,31 +46,33 @@ def index(session):
instances_debugger = InstancesDebugger(layout)
btn_show_instances_debugger = mk.label("Instances",
icon=volume_object_storage,
command=tabs_manager.commands.add_tab("Instances", instances_debugger),
command=add_tab("Instances", instances_debugger),
id=instances_debugger.get_id())
commands_debugger = CommandsDebugger(layout)
btn_show_commands_debugger = mk.label("Commands",
icon=None,
command=tabs_manager.commands.add_tab("Commands", commands_debugger),
command=add_tab("Commands", commands_debugger),
id=commands_debugger.get_id())
btn_file_upload = mk.label("Upload",
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")
btn_popup = mk.label("Popup",
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_file_upload, "Test")
layout.left_drawer.add(btn_popup, "Test")
layout.set_main(tabs_manager)
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
tabs_manager.commands.add_tab("File Open",
FileUpload(layout,
_id="-file_upload")))
keyboard.add("ctrl+n", tabs_manager.commands.add_tab("File Open", FileUpload(layout, _id="-file_upload")))
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
return layout, keyboard

View File

@@ -14,7 +14,6 @@
}
.mf-icon-16 {
width: 16px;
min-width: 16px;
@@ -441,3 +440,29 @@
max-height: 200px;
overflow: auto;
}
.mf-dropdown-wrapper {
position: relative; /* CRUCIAL for the anchor */
display: inline-block;
}
.mf-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0px;
z-index: 1;
width: 200px;
border: 1px solid black;
padding: 10px;
box-sizing: border-box;
overflow-x: auto;
/*opacity: 0;*/
/*transition: opacity 0.2s ease-in-out;*/
}
.mf-dropdown.is-visible {
display: block;
opacity: 1;
}

View File

@@ -270,12 +270,12 @@ function updateTabs(controllerId) {
/**
* Create keyboard bindings
*/
(function() {
(function () {
/**
* Global registry to store keyboard shortcuts for multiple elements
*/
const KeyboardRegistry = {
elements: new Map(), // elementId -> { trie, element }
elements: new Map(), // elementId -> { tree, element }
listenerAttached: false,
currentKeys: new Set(),
snapshotHistory: [],
@@ -339,10 +339,10 @@ function updateTabs(controllerId) {
}
/**
* Create a new trie node
* @returns {Object} - New trie node
* Create a new tree node
* @returns {Object} - New tree node
*/
function createTrieNode() {
function createTreeNode() {
return {
config: 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
* @returns {Object} - Root trie node
* @returns {Object} - Root tree node
*/
function buildTrie(combinations) {
const root = createTrieNode();
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, createTrieNode());
currentNode.children.set(key, createTreeNode());
}
currentNode = currentNode.children.get(key);
@@ -381,13 +382,13 @@ function updateTabs(controllerId) {
}
/**
* Traverse the trie with the current snapshot history
* @param {Object} trieRoot - Root of the trie
* 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 traverseTrie(trieRoot, snapshotHistory) {
let currentNode = trieRoot;
function traverseTree(treeRoot, snapshotHistory) {
let currentNode = treeRoot;
for (const snapshot of snapshotHistory) {
const key = setToKey(snapshot);
@@ -430,8 +431,9 @@ function updateTabs(controllerId) {
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the focus is inside the element
*/
function triggerAction(elementId, config, combinationStr) {
function triggerAction(elementId, config, combinationStr, isInside) {
const element = document.getElementById(elementId);
if (!element) return;
@@ -475,13 +477,14 @@ function updateTabs(controllerId) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination and has_focus
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
@@ -506,6 +509,7 @@ function updateTabs(controllerId) {
// 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);
@@ -530,13 +534,17 @@ function updateTabs(controllerId) {
const element = document.getElementById(elementId);
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 currentNode = traverseTrie(trieRoot, KeyboardRegistry.snapshotHistory);
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
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;
}
@@ -559,7 +567,8 @@ function updateTabs(controllerId) {
currentMatches.push({
elementId: elementId,
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
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr);
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
@@ -589,7 +598,7 @@ function updateTabs(controllerId) {
KeyboardRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of KeyboardRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr);
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
@@ -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
* @param {string} elementId - The ID of the element
* @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
const combinations = JSON.parse(combinationsJson);
// Build trie for this element
const trie = buildTrie(combinations);
// 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, {
trie: trie,
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();
}
};
})();
/**
* 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

@@ -0,0 +1,94 @@
from fastcore.xml import FT
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance
class Commands(BaseCommands):
def 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):
def __init__(self, parent, content=None, button=None, _id=None):
super().__init__(parent, _id=_id)
self.button = Div(button) if not isinstance(button, FT) else button
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):
return Div(
Div(
Div(self.button) if self.button else Div("None"),
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):
return self.render()
# document.addEventListener('htmx:afterSwap', function(event) {
# const targetElement = event.detail.target; // L'élément qui a été mis à jour (#popup-unique-id)
#
# // Vérifie si c'est bien notre popup
# if (targetElement.classList.contains('mf-popup-container')) {
#
# // Trouver l'élément déclencheur HTMX (le bouton existant)
# // HTMX stocke l'élément déclencheur dans event.detail.elt
# const trigger = document.querySelector('#mon-bouton-existant');
#
# if (trigger) {
# // Obtenir les coordonnées de l'élément déclencheur par rapport à la fenêtre
# const rect = trigger.getBoundingClientRect();
#
# // L'élément du popup à positionner
# const popup = targetElement;
#
# // Appliquer la position au conteneur du popup
# // On utilise window.scrollY pour s'assurer que la position est absolue par rapport au document,
# // et non seulement à la fenêtre (car le popup est en position: absolute, pas fixed)
#
# // Top: Juste en dessous de l'élément déclencheur
# popup.style.top = (rect.bottom + window.scrollY) + 'px';
#
# // Left: Aligner avec le côté gauche de l'élément déclencheur
# popup.style.left = (rect.left + window.scrollX) + 'px';
# }
# }
# });

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]
if tab_config["component_type"] is 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
def _get_tab_count():

View File

@@ -2,10 +2,8 @@ import logging
import uuid
from typing import Optional
from dbengine.utils import get_class
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")
@@ -216,19 +214,5 @@ class InstancesManager:
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)

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">
<h2>Test Input (typing should work normally here)</h2>
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
<strong>Parameters Explained:</strong><br>
<code>has_focus</code>: Whether the registered element itself has focus<br>
<code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
</p>
</div>
<div class="test-container">
@@ -151,6 +157,9 @@
<div id="test-element-2" class="test-element" tabindex="0">
This element also responds to ESC and Shift Shift
</div>
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
Remove Element 2 Keyboard Support
</button>
</div>
<div class="test-container">
@@ -172,12 +181,14 @@
window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus;
const isInside = config.values.is_inside;
const combination = config.values.combination;
// Build details string with all config options
const details = [
`Combination: "${combination}"`,
`Element has focus: ${hasFocus}`
`Element has focus: ${hasFocus}`,
`Focus inside element: ${isInside}`
];
if (config.target) {
@@ -223,6 +234,16 @@
function clearLog() {
document.getElementById('log').innerHTML = '';
}
function removeElement2() {
remove_keyboard_support('test-element-2');
logEvent('Element 2 keyboard support removed',
'ESC and Shift Shift no longer trigger for Element 2',
'Element 1 still active', false);
// Disable the button
event.target.disabled = true;
event.target.textContent = 'Keyboard Support Removed';
}
</script>
<!-- Include keyboard support script -->

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>