Compare commits
9 Commits
97247f824c
...
WorkingOnC
| Author | SHA1 | Date | |
|---|---|---|---|
| e3d9b106fb | |||
| d2cf51d7c3 | |||
| 53253278b2 | |||
| 52b4e6a8b6 | |||
| a6ab4b2a68 | |||
| 84c63f0c5a | |||
| bb8752233e | |||
| dd9aefa143 | |||
| b1be747101 |
425
CLAUDE.md
Normal file
425
CLAUDE.md
Normal 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
|
||||||
@@ -187,6 +187,7 @@ All other `hx-*` attributes are supported and will be converted to the appropria
|
|||||||
The library automatically adds these parameters to every request:
|
The library automatically adds these parameters to every request:
|
||||||
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
||||||
- `has_focus` - Boolean indicating if the element had focus
|
- `has_focus` - Boolean indicating if the element had focus
|
||||||
|
- `is_inside` - Boolean indicating if the focus is inside the element (element itself or any child)
|
||||||
|
|
||||||
Example final request:
|
Example final request:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -196,7 +197,8 @@ htmx.ajax('POST', '/save-url', {
|
|||||||
values: {
|
values: {
|
||||||
extra: "data", // from hx-vals
|
extra: "data", // from hx-vals
|
||||||
combination: "Ctrl+S", // automatic
|
combination: "Ctrl+S", // automatic
|
||||||
has_focus: true // automatic
|
has_focus: true, // automatic
|
||||||
|
is_inside: true // automatic
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -262,6 +264,55 @@ f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
|
|||||||
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
|
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Removing Keyboard Support
|
||||||
|
|
||||||
|
When you no longer need keyboard support for an element:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Remove keyboard support
|
||||||
|
f"remove_keyboard_support('{element_id}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
- Removes the element from the keyboard registry
|
||||||
|
- If this was the last element, automatically detaches global event listeners
|
||||||
|
- Cleans up all associated state (timeouts, snapshots, etc.)
|
||||||
|
- Other elements continue to work normally
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```javascript
|
||||||
|
// Add support
|
||||||
|
add_keyboard_support('modal', '{"esc": {"hx-post": "/close"}}');
|
||||||
|
|
||||||
|
// Later, remove support
|
||||||
|
remove_keyboard_support('modal');
|
||||||
|
// If no other elements remain, keyboard listeners are completely removed
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### add_keyboard_support(elementId, combinationsJson)
|
||||||
|
|
||||||
|
Adds keyboard support to an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
### remove_keyboard_support(elementId)
|
||||||
|
|
||||||
|
Removes keyboard support from an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
**Side effects**:
|
||||||
|
- If last element: detaches global event listeners and cleans up all state
|
||||||
|
|
||||||
## Technical Details
|
## Technical Details
|
||||||
|
|
||||||
### Trie-based Matching
|
### Trie-based Matching
|
||||||
@@ -277,7 +328,7 @@ The library uses a prefix tree (trie) data structure:
|
|||||||
Configuration objects are mapped to htmx.ajax() calls:
|
Configuration objects are mapped to htmx.ajax() calls:
|
||||||
- `hx-*` attributes are converted to camelCase parameters
|
- `hx-*` attributes are converted to camelCase parameters
|
||||||
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
||||||
- `combination` and `has_focus` are automatically added to values
|
- `combination`, `has_focus`, and `is_inside` are automatically added to values
|
||||||
- All standard HTMX options are supported
|
- All standard HTMX options are supported
|
||||||
|
|
||||||
### Key Normalization
|
### Key Normalization
|
||||||
439
docs/Mouse Support.md
Normal file
439
docs/Mouse Support.md
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
# Mouse Support - Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The mouse support library provides keyboard-like binding capabilities for mouse actions. It supports simple clicks, modified clicks (with Ctrl/Shift/Alt), and sequences of clicks with smart timeout logic.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Supported Mouse Actions
|
||||||
|
|
||||||
|
**Basic Actions**:
|
||||||
|
- `click` - Left click
|
||||||
|
- `right_click` (or `rclick`) - Right click (contextmenu)
|
||||||
|
|
||||||
|
**Modified Actions**:
|
||||||
|
- `ctrl+click` (or `ctrl+rclick`) - Ctrl+Click (or Cmd+Click on Mac)
|
||||||
|
- `shift+click` (or `shift+rclick`) - Shift+Click
|
||||||
|
- `alt+click` (or `alt+rclick`) - Alt+Click
|
||||||
|
- `ctrl+shift+click` - Multiple modifiers
|
||||||
|
- Any combination of modifiers
|
||||||
|
|
||||||
|
**Sequences**:
|
||||||
|
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
|
||||||
|
- `click click` - Double click sequence
|
||||||
|
- `ctrl+click click` - Ctrl+click then normal click
|
||||||
|
- Any sequence of actions
|
||||||
|
|
||||||
|
**Note**: `rclick` is an alias for `right_click` and can be used interchangeably.
|
||||||
|
|
||||||
|
### Smart Timeout Logic
|
||||||
|
|
||||||
|
Same as keyboard support:
|
||||||
|
- If **any element** has a longer sequence possible, **all matching elements wait**
|
||||||
|
- Timeout is 500ms between actions
|
||||||
|
- Immediate trigger if no longer sequences exist
|
||||||
|
|
||||||
|
### Multiple Element Support
|
||||||
|
|
||||||
|
Multiple elements can listen to the same mouse action and all will trigger simultaneously.
|
||||||
|
|
||||||
|
## Configuration Format
|
||||||
|
|
||||||
|
Uses HTMX configuration objects (same as keyboard support):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/handle-click",
|
||||||
|
"hx-target": "#result"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/handle-ctrl-click",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
},
|
||||||
|
"rclick": { // Alias for right_click
|
||||||
|
"hx-post": "/context-menu"
|
||||||
|
},
|
||||||
|
"click rclick": { // Can use rclick in sequences too
|
||||||
|
"hx-post": "/sequence-action",
|
||||||
|
"hx-vals": {"type": "sequence"}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_mouse_support('my-element', JSON.stringify(combinations));
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### add_mouse_support(elementId, combinationsJson)
|
||||||
|
|
||||||
|
Adds mouse support to an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```javascript
|
||||||
|
add_mouse_support('button1', JSON.stringify({
|
||||||
|
"click": {"hx-post": "/click"},
|
||||||
|
"ctrl+click": {"hx-post": "/ctrl-click"}
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### remove_mouse_support(elementId)
|
||||||
|
|
||||||
|
Removes mouse support from an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
**Side effects**:
|
||||||
|
- If last element: detaches global event listeners and cleans up all state
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```javascript
|
||||||
|
remove_mouse_support('button1');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automatic Parameters
|
||||||
|
|
||||||
|
The library automatically adds these parameters to every HTMX request:
|
||||||
|
- `combination` - The mouse combination that triggered the action (e.g., "ctrl+click")
|
||||||
|
- `has_focus` - Boolean indicating if the element had focus when clicked
|
||||||
|
- `is_inside` - Boolean indicating if the click was inside the element
|
||||||
|
- For `click`: `true` if clicked inside element, `false` if clicked outside
|
||||||
|
- For `right_click`: always `true` (only triggers when clicking on element)
|
||||||
|
- `has_focus` - Boolean indicating if the element had focus when the action triggered
|
||||||
|
- `clicked_inside` - Boolean indicating if the click was inside the element or outside
|
||||||
|
|
||||||
|
### Parameter Details
|
||||||
|
|
||||||
|
**`has_focus`**:
|
||||||
|
- `true` if the registered element currently has focus
|
||||||
|
- `false` otherwise
|
||||||
|
- Useful for knowing if the element was the active element
|
||||||
|
|
||||||
|
**`clicked_inside`**:
|
||||||
|
- For `click` actions: `true` if clicked on/inside the element, `false` if clicked outside
|
||||||
|
- For `right_click` actions: always `true` (since right-click only triggers on the element)
|
||||||
|
- Useful for "click outside to close" logic
|
||||||
|
|
||||||
|
**Example values sent**:
|
||||||
|
```javascript
|
||||||
|
// User clicks inside a modal
|
||||||
|
{
|
||||||
|
combination: "click",
|
||||||
|
has_focus: true,
|
||||||
|
clicked_inside: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// User clicks outside the modal (modal still gets triggered because click is global)
|
||||||
|
{
|
||||||
|
combination: "click",
|
||||||
|
has_focus: false,
|
||||||
|
clicked_inside: false // Perfect for closing the modal!
|
||||||
|
}
|
||||||
|
|
||||||
|
// User right-clicks on an item
|
||||||
|
{
|
||||||
|
combination: "right_click",
|
||||||
|
has_focus: false,
|
||||||
|
clicked_inside: true // Always true for right_click
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Integration
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/item/select"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/item/select-multiple",
|
||||||
|
"hx-vals": json.dumps({"mode": "multi"})
|
||||||
|
},
|
||||||
|
"right_click": {
|
||||||
|
"hx-post": "/item/context-menu",
|
||||||
|
"hx-target": "#context-menu",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequences
|
||||||
|
|
||||||
|
```python
|
||||||
|
combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/single-click"
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/double-click-sequence"
|
||||||
|
},
|
||||||
|
"click right_click": {
|
||||||
|
"hx-post": "/click-then-right-click"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Elements
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Item 1
|
||||||
|
item1_combinations = {
|
||||||
|
"click": {"hx-post": f"/item/1/select"},
|
||||||
|
"ctrl+click": {"hx-post": f"/item/1/toggle"}
|
||||||
|
}
|
||||||
|
f"add_mouse_support('item-1', '{json.dumps(item1_combinations)}')"
|
||||||
|
|
||||||
|
# Item 2
|
||||||
|
item2_combinations = {
|
||||||
|
"click": {"hx-post": f"/item/2/select"},
|
||||||
|
"ctrl+click": {"hx-post": f"/item/2/toggle"}
|
||||||
|
}
|
||||||
|
f"add_mouse_support('item-2', '{json.dumps(item2_combinations)}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior Details
|
||||||
|
|
||||||
|
### Click vs Right-Click Behavior
|
||||||
|
|
||||||
|
**IMPORTANT**: The library handles `click` and `right_click` differently:
|
||||||
|
|
||||||
|
**`click` (global detection)**:
|
||||||
|
- Triggers for ALL registered elements, regardless of where you click
|
||||||
|
- Useful for "click outside to close" functionality (modals, dropdowns, popups)
|
||||||
|
- Example: Modal registered with `click` → clicking anywhere on the page triggers the modal's click action
|
||||||
|
|
||||||
|
**`right_click` (element-specific detection)**:
|
||||||
|
- Triggers ONLY when you right-click on (or inside) the registered element
|
||||||
|
- Right-clicking outside the element does nothing and shows browser's context menu
|
||||||
|
- This preserves normal browser behavior while adding custom actions on your elements
|
||||||
|
|
||||||
|
**Example use case**:
|
||||||
|
```javascript
|
||||||
|
// Modal that closes when clicking anywhere
|
||||||
|
add_mouse_support('modal', JSON.stringify({
|
||||||
|
"click": {"hx-post": "/close-modal"} // Triggers even if you click outside modal
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Context menu that only appears on element
|
||||||
|
add_mouse_support('item', JSON.stringify({
|
||||||
|
"right_click": {"hx-post": "/item-menu"} // Only triggers when right-clicking the item
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifier Keys (Cross-Platform)
|
||||||
|
|
||||||
|
- **Windows/Linux**: `ctrl+click` uses Ctrl key
|
||||||
|
- **Mac**: `ctrl+click` uses Cmd (⌘) key OR Ctrl key
|
||||||
|
- This follows standard web conventions for cross-platform compatibility
|
||||||
|
|
||||||
|
### Input Context Protection
|
||||||
|
|
||||||
|
Mouse actions are **disabled** when clicking in input fields:
|
||||||
|
- `<input>` elements
|
||||||
|
- `<textarea>` elements
|
||||||
|
- Any `contenteditable` element
|
||||||
|
|
||||||
|
This ensures normal text selection and interaction works in forms.
|
||||||
|
|
||||||
|
### Right-Click Menu
|
||||||
|
|
||||||
|
The contextmenu (right-click menu) is prevented with `preventDefault()` when:
|
||||||
|
- A `right_click` action is configured for the element
|
||||||
|
- The element is NOT an input/textarea/contenteditable
|
||||||
|
|
||||||
|
### Event Bubbling
|
||||||
|
|
||||||
|
The library checks if the click target OR any parent element is registered:
|
||||||
|
```html
|
||||||
|
<div id="container">
|
||||||
|
<button>Click me</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
If `container` is registered, clicking the button will trigger the container's actions.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Using Aliases
|
||||||
|
|
||||||
|
You can use `rclick` instead of `right_click` anywhere:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// These are equivalent
|
||||||
|
const config1 = {
|
||||||
|
"right_click": {"hx-post": "/menu"}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config2 = {
|
||||||
|
"rclick": {"hx-post": "/menu"} // Shorter alias
|
||||||
|
};
|
||||||
|
|
||||||
|
// Works in sequences too
|
||||||
|
const config3 = {
|
||||||
|
"click rclick": {"hx-post": "/sequence"}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Works with modifiers
|
||||||
|
const config4 = {
|
||||||
|
"ctrl+rclick": {"hx-post": "/ctrl-right-click"}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Menu
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"right_click": {
|
||||||
|
"hx-post": "/show-context-menu",
|
||||||
|
"hx-target": "#menu",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Close Modal/Popup on Click Outside
|
||||||
|
|
||||||
|
Since `click` is detected globally (anywhere on the page), it's perfect for "click outside to close":
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Modal element
|
||||||
|
const modalCombinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/close-modal",
|
||||||
|
"hx-target": "#modal",
|
||||||
|
"hx-swap": "outerHTML"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
add_mouse_support('modal', JSON.stringify(modalCombinations));
|
||||||
|
|
||||||
|
// Now clicking ANYWHERE on the page will trigger the handler
|
||||||
|
// Your backend receives:
|
||||||
|
// - clicked_inside: true (if clicked on modal) → maybe keep it open
|
||||||
|
// - clicked_inside: false (if clicked outside) → close it!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend example (Python/Flask)**:
|
||||||
|
```python
|
||||||
|
@app.route('/close-modal', methods=['POST'])
|
||||||
|
def close_modal():
|
||||||
|
clicked_inside = request.form.get('clicked_inside') == 'true'
|
||||||
|
|
||||||
|
if clicked_inside:
|
||||||
|
# User clicked inside the modal - keep it open
|
||||||
|
return render_template('modal_content.html')
|
||||||
|
else:
|
||||||
|
# User clicked outside - close the modal
|
||||||
|
return '' # Empty response removes the modal
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The click handler on the modal element will trigger for all clicks on the page, not just clicks on the modal itself. Use the `clicked_inside` parameter to determine the appropriate action.
|
||||||
|
|
||||||
|
### Multi-Select List
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/select-item",
|
||||||
|
"hx-vals": {"mode": "single"}
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/select-item",
|
||||||
|
"hx-vals": {"mode": "toggle"}
|
||||||
|
},
|
||||||
|
"shift+click": {
|
||||||
|
"hx-post": "/select-range",
|
||||||
|
"hx-vals": {"mode": "range"}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Canvas/Drawing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/draw-point"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/draw-special"
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/confirm-action"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drag-and-Drop Alternative
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/select-source"
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/set-destination"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Clicks not detected
|
||||||
|
|
||||||
|
- Verify the element exists and has the correct ID
|
||||||
|
- Check browser console for errors
|
||||||
|
- Ensure HTMX is loaded before mouse_support.js
|
||||||
|
|
||||||
|
### Right-click menu still appears
|
||||||
|
|
||||||
|
- Check if element is in input context (input/textarea)
|
||||||
|
- Verify the combination is configured correctly
|
||||||
|
- Check browser console for configuration errors
|
||||||
|
|
||||||
|
### Sequences not working
|
||||||
|
|
||||||
|
- Ensure clicks happen within 500ms timeout
|
||||||
|
- Check if longer sequences exist (causes waiting)
|
||||||
|
- Verify the combination string format (space-separated)
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **Global listeners** on `document` for `click` and `contextmenu` events
|
||||||
|
- **Tree-based matching** using prefix trees (same as keyboard support)
|
||||||
|
- **Single timeout** for all elements (sequence-based, not element-based)
|
||||||
|
- **Independent from keyboard support** (separate registry and timeouts)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Single event listener regardless of number of elements
|
||||||
|
- O(n) matching where n is sequence length
|
||||||
|
- Efficient memory usage with automatic cleanup
|
||||||
|
|
||||||
|
### Browser Compatibility
|
||||||
|
|
||||||
|
- Modern browsers (ES6+ required)
|
||||||
|
- Chrome, Firefox, Safari, Edge
|
||||||
|
- Requires HTMX library
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Timeout value is the same as keyboard support (500ms) but in separate variable
|
||||||
|
- Can be used independently or alongside keyboard support
|
||||||
|
- Does not interfere with normal mouse behavior in inputs
|
||||||
|
- Element must exist in DOM when `add_mouse_support()` is called
|
||||||
|
- **Alias**: `rclick` can be used interchangeably with `right_click` for shorter syntax
|
||||||
28
src/app.py
28
src/app.py
@@ -4,13 +4,14 @@ import yaml
|
|||||||
from fasthtml import serve
|
from fasthtml import serve
|
||||||
|
|
||||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
||||||
|
from myfasthtml.controls.Dropdown import Dropdown
|
||||||
from myfasthtml.controls.FileUpload import FileUpload
|
from myfasthtml.controls.FileUpload import FileUpload
|
||||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
||||||
from myfasthtml.controls.Keyboard import Keyboard
|
from myfasthtml.controls.Keyboard import Keyboard
|
||||||
from myfasthtml.controls.Layout import Layout
|
from myfasthtml.controls.Layout import Layout
|
||||||
from myfasthtml.controls.TabsManager import TabsManager
|
from myfasthtml.controls.TabsManager import TabsManager
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
from myfasthtml.core.instances import InstancesManager, RootInstance
|
from myfasthtml.core.instances import UniqueInstance
|
||||||
from myfasthtml.icons.carbon import volume_object_storage
|
from myfasthtml.icons.carbon import volume_object_storage
|
||||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||||
from myfasthtml.myfastapp import create_app
|
from myfasthtml.myfastapp import create_app
|
||||||
@@ -32,39 +33,46 @@ app, rt = create_app(protect_routes=True,
|
|||||||
|
|
||||||
@rt("/")
|
@rt("/")
|
||||||
def index(session):
|
def index(session):
|
||||||
layout = InstancesManager.get(session, Ids.Layout, Layout, RootInstance, "Testing Layout")
|
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
||||||
|
layout = Layout(session_instance, "Testing Layout")
|
||||||
layout.set_footer("Goodbye World")
|
layout.set_footer("Goodbye World")
|
||||||
|
|
||||||
tabs_manager = TabsManager(layout, _id=f"{Ids.TabsManager}-main")
|
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
|
||||||
|
add_tab = tabs_manager.commands.add_tab
|
||||||
btn_show_right_drawer = mk.button("show",
|
btn_show_right_drawer = mk.button("show",
|
||||||
command=layout.commands.toggle_drawer("right"),
|
command=layout.commands.toggle_drawer("right"),
|
||||||
id="btn_show_right_drawer_id")
|
id="btn_show_right_drawer_id")
|
||||||
|
|
||||||
instances_debugger = InstancesManager.get(session, Ids.InstancesDebugger, InstancesDebugger, layout)
|
instances_debugger = InstancesDebugger(layout)
|
||||||
btn_show_instances_debugger = mk.label("Instances",
|
btn_show_instances_debugger = mk.label("Instances",
|
||||||
icon=volume_object_storage,
|
icon=volume_object_storage,
|
||||||
command=tabs_manager.commands.add_tab("Instances", instances_debugger),
|
command=add_tab("Instances", instances_debugger),
|
||||||
id=instances_debugger.get_id())
|
id=instances_debugger.get_id())
|
||||||
|
|
||||||
commands_debugger = InstancesManager.get(session, Ids.CommandsDebugger, CommandsDebugger, layout)
|
commands_debugger = CommandsDebugger(layout)
|
||||||
btn_show_commands_debugger = mk.label("Commands",
|
btn_show_commands_debugger = mk.label("Commands",
|
||||||
icon=None,
|
icon=None,
|
||||||
command=tabs_manager.commands.add_tab("Commands", commands_debugger),
|
command=add_tab("Commands", commands_debugger),
|
||||||
id=commands_debugger.get_id())
|
id=commands_debugger.get_id())
|
||||||
|
|
||||||
btn_file_upload = mk.label("Upload",
|
btn_file_upload = mk.label("Upload",
|
||||||
icon=folder_open20_regular,
|
icon=folder_open20_regular,
|
||||||
command=tabs_manager.commands.add_tab("File Open", FileUpload(layout)),
|
command=add_tab("File Open", FileUpload(layout, _id="-file_upload")),
|
||||||
id="file_upload_id")
|
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_left.add(tabs_manager.add_tab_btn())
|
||||||
layout.header_right.add(btn_show_right_drawer)
|
layout.header_right.add(btn_show_right_drawer)
|
||||||
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
|
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
|
||||||
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
||||||
layout.left_drawer.add(btn_file_upload, "Test")
|
layout.left_drawer.add(btn_file_upload, "Test")
|
||||||
|
layout.left_drawer.add(btn_popup, "Test")
|
||||||
layout.set_main(tabs_manager)
|
layout.set_main(tabs_manager)
|
||||||
keyboard = Keyboard(layout).add("ctrl+o", tabs_manager.commands.add_tab("File Open", FileUpload(layout)))
|
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
||||||
keyboard.add("ctrl+n", tabs_manager.commands.add_tab("File Open", FileUpload(layout)))
|
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
|
return layout, keyboard
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.mf-icon-16 {
|
.mf-icon-16 {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
min-width: 16px;
|
min-width: 16px;
|
||||||
@@ -441,3 +440,29 @@
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow: auto;
|
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;
|
||||||
|
}
|
||||||
@@ -270,12 +270,12 @@ function updateTabs(controllerId) {
|
|||||||
/**
|
/**
|
||||||
* Create keyboard bindings
|
* Create keyboard bindings
|
||||||
*/
|
*/
|
||||||
(function() {
|
(function () {
|
||||||
/**
|
/**
|
||||||
* Global registry to store keyboard shortcuts for multiple elements
|
* Global registry to store keyboard shortcuts for multiple elements
|
||||||
*/
|
*/
|
||||||
const KeyboardRegistry = {
|
const KeyboardRegistry = {
|
||||||
elements: new Map(), // elementId -> { trie, element }
|
elements: new Map(), // elementId -> { tree, element }
|
||||||
listenerAttached: false,
|
listenerAttached: false,
|
||||||
currentKeys: new Set(),
|
currentKeys: new Set(),
|
||||||
snapshotHistory: [],
|
snapshotHistory: [],
|
||||||
@@ -339,10 +339,10 @@ function updateTabs(controllerId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new trie node
|
* Create a new tree node
|
||||||
* @returns {Object} - New trie node
|
* @returns {Object} - New tree node
|
||||||
*/
|
*/
|
||||||
function createTrieNode() {
|
function createTreeNode() {
|
||||||
return {
|
return {
|
||||||
config: null,
|
config: null,
|
||||||
combinationStr: null,
|
combinationStr: null,
|
||||||
@@ -351,22 +351,23 @@ function updateTabs(controllerId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a trie from combinations
|
* Build a tree from combinations
|
||||||
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||||
* @returns {Object} - Root trie node
|
* @returns {Object} - Root tree node
|
||||||
*/
|
*/
|
||||||
function buildTrie(combinations) {
|
function buildTree(combinations) {
|
||||||
const root = createTrieNode();
|
const root = createTreeNode();
|
||||||
|
|
||||||
for (const [combinationStr, config] of Object.entries(combinations)) {
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
const sequence = parseCombination(combinationStr);
|
const sequence = parseCombination(combinationStr);
|
||||||
|
console.log("Parsing combination", combinationStr, "=>", sequence);
|
||||||
let currentNode = root;
|
let currentNode = root;
|
||||||
|
|
||||||
for (const keySet of sequence) {
|
for (const keySet of sequence) {
|
||||||
const key = setToKey(keySet);
|
const key = setToKey(keySet);
|
||||||
|
|
||||||
if (!currentNode.children.has(key)) {
|
if (!currentNode.children.has(key)) {
|
||||||
currentNode.children.set(key, createTrieNode());
|
currentNode.children.set(key, createTreeNode());
|
||||||
}
|
}
|
||||||
|
|
||||||
currentNode = currentNode.children.get(key);
|
currentNode = currentNode.children.get(key);
|
||||||
@@ -381,13 +382,13 @@ function updateTabs(controllerId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Traverse the trie with the current snapshot history
|
* Traverse the tree with the current snapshot history
|
||||||
* @param {Object} trieRoot - Root of the trie
|
* @param {Object} treeRoot - Root of the tree
|
||||||
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
|
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
|
||||||
* @returns {Object|null} - Current node or null if no match
|
* @returns {Object|null} - Current node or null if no match
|
||||||
*/
|
*/
|
||||||
function traverseTrie(trieRoot, snapshotHistory) {
|
function traverseTree(treeRoot, snapshotHistory) {
|
||||||
let currentNode = trieRoot;
|
let currentNode = treeRoot;
|
||||||
|
|
||||||
for (const snapshot of snapshotHistory) {
|
for (const snapshot of snapshotHistory) {
|
||||||
const key = setToKey(snapshot);
|
const key = setToKey(snapshot);
|
||||||
@@ -430,8 +431,9 @@ function updateTabs(controllerId) {
|
|||||||
* @param {string} elementId - ID of the element
|
* @param {string} elementId - ID of the element
|
||||||
* @param {Object} config - HTMX configuration object
|
* @param {Object} config - HTMX configuration object
|
||||||
* @param {string} combinationStr - The matched combination string
|
* @param {string} combinationStr - The matched combination string
|
||||||
|
* @param {boolean} isInside - Whether the focus is inside the element
|
||||||
*/
|
*/
|
||||||
function triggerAction(elementId, config, combinationStr) {
|
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
@@ -475,13 +477,14 @@ function updateTabs(controllerId) {
|
|||||||
htmxOptions.swap = config['hx-swap'];
|
htmxOptions.swap = config['hx-swap'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map hx-vals to values and add combination and has_focus
|
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||||
const values = {};
|
const values = {};
|
||||||
if (config['hx-vals']) {
|
if (config['hx-vals']) {
|
||||||
Object.assign(values, config['hx-vals']);
|
Object.assign(values, config['hx-vals']);
|
||||||
}
|
}
|
||||||
values.combination = combinationStr;
|
values.combination = combinationStr;
|
||||||
values.has_focus = hasFocus;
|
values.has_focus = hasFocus;
|
||||||
|
values.is_inside = isInside;
|
||||||
htmxOptions.values = values;
|
htmxOptions.values = values;
|
||||||
|
|
||||||
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||||
@@ -506,6 +509,7 @@ function updateTabs(controllerId) {
|
|||||||
|
|
||||||
// Add key to current pressed keys
|
// Add key to current pressed keys
|
||||||
KeyboardRegistry.currentKeys.add(key);
|
KeyboardRegistry.currentKeys.add(key);
|
||||||
|
console.debug("Received key", key);
|
||||||
|
|
||||||
// Create a snapshot of current keyboard state
|
// Create a snapshot of current keyboard state
|
||||||
const snapshot = new Set(KeyboardRegistry.currentKeys);
|
const snapshot = new Set(KeyboardRegistry.currentKeys);
|
||||||
@@ -530,13 +534,17 @@ function updateTabs(controllerId) {
|
|||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
if (!element) continue;
|
if (!element) continue;
|
||||||
|
|
||||||
const trieRoot = data.trie;
|
// Check if focus is inside this element (element itself or any child)
|
||||||
|
const isInside = element.contains(document.activeElement);
|
||||||
|
|
||||||
// Traverse the trie with current snapshot history
|
const treeRoot = data.tree;
|
||||||
const currentNode = traverseTrie(trieRoot, KeyboardRegistry.snapshotHistory);
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
|
||||||
|
|
||||||
if (!currentNode) {
|
if (!currentNode) {
|
||||||
// No match in this trie, continue to next element
|
// No match in this tree, continue to next element
|
||||||
|
console.debug("No match in tree for event", key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +567,8 @@ function updateTabs(controllerId) {
|
|||||||
currentMatches.push({
|
currentMatches.push({
|
||||||
elementId: elementId,
|
elementId: elementId,
|
||||||
config: currentNode.config,
|
config: currentNode.config,
|
||||||
combinationStr: currentNode.combinationStr
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: isInside
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,7 +583,7 @@ function updateTabs(controllerId) {
|
|||||||
// We have matches and NO element has longer sequences possible
|
// We have matches and NO element has longer sequences possible
|
||||||
// Trigger ALL matches immediately
|
// Trigger ALL matches immediately
|
||||||
for (const match of currentMatches) {
|
for (const match of currentMatches) {
|
||||||
triggerAction(match.elementId, match.config, match.combinationStr);
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear history after triggering
|
// Clear history after triggering
|
||||||
@@ -589,7 +598,7 @@ function updateTabs(controllerId) {
|
|||||||
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
||||||
// Timeout expired, trigger ALL pending matches
|
// Timeout expired, trigger ALL pending matches
|
||||||
for (const match of KeyboardRegistry.pendingMatches) {
|
for (const match of KeyboardRegistry.pendingMatches) {
|
||||||
triggerAction(match.elementId, match.config, match.combinationStr);
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear state
|
// Clear state
|
||||||
@@ -640,28 +649,706 @@ function updateTabs(controllerId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the global keyboard event listener
|
||||||
|
*/
|
||||||
|
function detachGlobalListener() {
|
||||||
|
if (KeyboardRegistry.listenerAttached) {
|
||||||
|
document.removeEventListener('keydown', handleKeyboardEvent);
|
||||||
|
document.removeEventListener('keyup', handleKeyUp);
|
||||||
|
KeyboardRegistry.listenerAttached = false;
|
||||||
|
|
||||||
|
// Clean up all state
|
||||||
|
KeyboardRegistry.currentKeys.clear();
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
if (KeyboardRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
}
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add keyboard support to an element
|
* Add keyboard support to an element
|
||||||
* @param {string} elementId - The ID of the element
|
* @param {string} elementId - The ID of the element
|
||||||
* @param {string} combinationsJson - JSON string of combinations mapping
|
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||||
*/
|
*/
|
||||||
window.add_keyboard_support = function(elementId, combinationsJson) {
|
window.add_keyboard_support = function (elementId, combinationsJson) {
|
||||||
// Parse the combinations JSON
|
// Parse the combinations JSON
|
||||||
const combinations = JSON.parse(combinationsJson);
|
const combinations = JSON.parse(combinationsJson);
|
||||||
|
|
||||||
// Build trie for this element
|
// Build tree for this element
|
||||||
const trie = buildTrie(combinations);
|
const tree = buildTree(combinations);
|
||||||
|
|
||||||
// Get element reference
|
// Get element reference
|
||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.error("Element with ID", elementId, "not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add to registry
|
// Add to registry
|
||||||
KeyboardRegistry.elements.set(elementId, {
|
KeyboardRegistry.elements.set(elementId, {
|
||||||
trie: trie,
|
tree: tree,
|
||||||
element: element
|
element: element
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach global listener if not already attached
|
// Attach global listener if not already attached
|
||||||
attachGlobalListener();
|
attachGlobalListener();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove keyboard support from an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
*/
|
||||||
|
window.remove_keyboard_support = function (elementId) {
|
||||||
|
// Remove from registry
|
||||||
|
if (!KeyboardRegistry.elements.has(elementId)) {
|
||||||
|
console.warn("Element with ID", elementId, "not found in keyboard registry!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyboardRegistry.elements.delete(elementId);
|
||||||
|
|
||||||
|
// If no more elements, detach global listeners
|
||||||
|
if (KeyboardRegistry.elements.size === 0) {
|
||||||
|
detachGlobalListener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create mouse bindings
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
/**
|
||||||
|
* Global registry to store mouse shortcuts for multiple elements
|
||||||
|
*/
|
||||||
|
const MouseRegistry = {
|
||||||
|
elements: new Map(), // elementId -> { tree, element }
|
||||||
|
listenerAttached: false,
|
||||||
|
snapshotHistory: [],
|
||||||
|
pendingTimeout: null,
|
||||||
|
pendingMatches: [], // Array of matches waiting for timeout
|
||||||
|
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||||
|
clickHandler: null,
|
||||||
|
contextmenuHandler: null
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize mouse action names
|
||||||
|
* @param {string} action - The action to normalize
|
||||||
|
* @returns {string} - Normalized action name
|
||||||
|
*/
|
||||||
|
function normalizeAction(action) {
|
||||||
|
const normalized = action.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Handle aliases
|
||||||
|
const aliasMap = {
|
||||||
|
'rclick': 'right_click'
|
||||||
|
};
|
||||||
|
|
||||||
|
return aliasMap[normalized] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique string key from a Set of actions for Map indexing
|
||||||
|
* @param {Set} actionSet - Set of normalized actions
|
||||||
|
* @returns {string} - Sorted string representation
|
||||||
|
*/
|
||||||
|
function setToKey(actionSet) {
|
||||||
|
return Array.from(actionSet).sort().join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single element (can be a simple click or click with modifiers)
|
||||||
|
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
|
||||||
|
* @returns {Set} - Set of normalized actions
|
||||||
|
*/
|
||||||
|
function parseElement(element) {
|
||||||
|
if (element.includes('+')) {
|
||||||
|
// Click with modifiers
|
||||||
|
return new Set(element.split('+').map(a => normalizeAction(a)));
|
||||||
|
}
|
||||||
|
// Simple click
|
||||||
|
return new Set([normalizeAction(element)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a combination string into sequence elements
|
||||||
|
* @param {string} combination - The combination string (e.g., "click right_click")
|
||||||
|
* @returns {Array} - Array of Sets representing the sequence
|
||||||
|
*/
|
||||||
|
function parseCombination(combination) {
|
||||||
|
// Check if it's a sequence (contains space)
|
||||||
|
if (combination.includes(' ')) {
|
||||||
|
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single element (can be a click or click with modifiers)
|
||||||
|
return [parseElement(combination)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tree node
|
||||||
|
* @returns {Object} - New tree node
|
||||||
|
*/
|
||||||
|
function createTreeNode() {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
combinationStr: null,
|
||||||
|
children: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a tree from combinations
|
||||||
|
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||||
|
* @returns {Object} - Root tree node
|
||||||
|
*/
|
||||||
|
function buildTree(combinations) {
|
||||||
|
const root = createTreeNode();
|
||||||
|
|
||||||
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
|
const sequence = parseCombination(combinationStr);
|
||||||
|
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
|
||||||
|
let currentNode = root;
|
||||||
|
|
||||||
|
for (const actionSet of sequence) {
|
||||||
|
const key = setToKey(actionSet);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
currentNode.children.set(key, createTreeNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as end of sequence and store config
|
||||||
|
currentNode.config = config;
|
||||||
|
currentNode.combinationStr = combinationStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverse the tree with the current snapshot history
|
||||||
|
* @param {Object} treeRoot - Root of the tree
|
||||||
|
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
|
||||||
|
* @returns {Object|null} - Current node or null if no match
|
||||||
|
*/
|
||||||
|
function traverseTree(treeRoot, snapshotHistory) {
|
||||||
|
let currentNode = treeRoot;
|
||||||
|
|
||||||
|
for (const snapshot of snapshotHistory) {
|
||||||
|
const key = setToKey(snapshot);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're inside an input element where clicking should work normally
|
||||||
|
* @returns {boolean} - True if inside an input-like element
|
||||||
|
*/
|
||||||
|
function isInInputContext() {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement) return false;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Check for input/textarea
|
||||||
|
if (tagName === 'input' || tagName === 'textarea') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contenteditable
|
||||||
|
if (activeElement.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the element that was actually clicked (from registered elements)
|
||||||
|
* @param {Element} target - The clicked element
|
||||||
|
* @returns {string|null} - Element ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
function findRegisteredElement(target) {
|
||||||
|
// Check if target itself is registered
|
||||||
|
if (target.id && MouseRegistry.elements.has(target.id)) {
|
||||||
|
return target.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any parent is registered
|
||||||
|
let current = target.parentElement;
|
||||||
|
while (current) {
|
||||||
|
if (current.id && MouseRegistry.elements.has(current.id)) {
|
||||||
|
return current.id;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a snapshot from mouse event
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||||
|
* @returns {Set} - Set of actions representing this click
|
||||||
|
*/
|
||||||
|
function createSnapshot(event, baseAction) {
|
||||||
|
const actions = new Set([baseAction]);
|
||||||
|
|
||||||
|
// Add modifiers if present
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
actions.add('ctrl');
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
actions.add('shift');
|
||||||
|
}
|
||||||
|
if (event.altKey) {
|
||||||
|
actions.add('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an action for a matched combination
|
||||||
|
* @param {string} elementId - ID of the element
|
||||||
|
* @param {Object} config - HTMX configuration object
|
||||||
|
* @param {string} combinationStr - The matched combination string
|
||||||
|
* @param {boolean} isInside - Whether the click was inside the element
|
||||||
|
*/
|
||||||
|
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasFocus = document.activeElement === element;
|
||||||
|
|
||||||
|
// Extract HTTP method and URL from hx-* attributes
|
||||||
|
let method = 'POST'; // default
|
||||||
|
let url = null;
|
||||||
|
|
||||||
|
const methodMap = {
|
||||||
|
'hx-post': 'POST',
|
||||||
|
'hx-get': 'GET',
|
||||||
|
'hx-put': 'PUT',
|
||||||
|
'hx-delete': 'DELETE',
|
||||||
|
'hx-patch': 'PATCH'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||||
|
if (config[attr]) {
|
||||||
|
method = httpMethod;
|
||||||
|
url = config[attr];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.error('No HTTP method attribute found in config:', config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build htmx.ajax options
|
||||||
|
const htmxOptions = {};
|
||||||
|
|
||||||
|
// Map hx-target to target
|
||||||
|
if (config['hx-target']) {
|
||||||
|
htmxOptions.target = config['hx-target'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-swap to swap
|
||||||
|
if (config['hx-swap']) {
|
||||||
|
htmxOptions.swap = config['hx-swap'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||||
|
const values = {};
|
||||||
|
if (config['hx-vals']) {
|
||||||
|
Object.assign(values, config['hx-vals']);
|
||||||
|
}
|
||||||
|
values.combination = combinationStr;
|
||||||
|
values.has_focus = hasFocus;
|
||||||
|
values.is_inside = isInside;
|
||||||
|
htmxOptions.values = values;
|
||||||
|
|
||||||
|
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||||
|
// Remove 'hx-' prefix and convert to camelCase
|
||||||
|
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
htmxOptions[optionKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make AJAX call with htmx
|
||||||
|
htmx.ajax(method, url, htmxOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse events and trigger matching combinations
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||||
|
*/
|
||||||
|
function handleMouseEvent(event, baseAction) {
|
||||||
|
// Different behavior for click vs right_click
|
||||||
|
if (baseAction === 'click') {
|
||||||
|
// Click: trigger for ALL registered elements (useful for closing modals/popups)
|
||||||
|
handleGlobalClick(event);
|
||||||
|
} else if (baseAction === 'right_click') {
|
||||||
|
// Right-click: trigger ONLY if clicked on a registered element
|
||||||
|
handleElementRightClick(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle global click events (triggers for all registered elements)
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
*/
|
||||||
|
function handleGlobalClick(event) {
|
||||||
|
console.debug("Global click detected");
|
||||||
|
|
||||||
|
// Create a snapshot of current mouse action with modifiers
|
||||||
|
const snapshot = createSnapshot(event, 'click');
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
MouseRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for ALL registered elements
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
for (const [elementId, data] of MouseRegistry.elements) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
// Check if click was inside this element
|
||||||
|
const isInside = element.contains(event.target);
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has config)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: isInside
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but longer sequences are possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
MouseRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of MouseRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}, MouseRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle right-click events (triggers only for clicked element)
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
*/
|
||||||
|
function handleElementRightClick(event) {
|
||||||
|
// Find which registered element was clicked
|
||||||
|
const elementId = findRegisteredElement(event.target);
|
||||||
|
|
||||||
|
if (!elementId) {
|
||||||
|
// Right-click wasn't on a registered element - don't prevent default
|
||||||
|
// This allows browser context menu to appear
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug("Right-click on registered element", elementId);
|
||||||
|
|
||||||
|
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
|
||||||
|
const clickedInside = true;
|
||||||
|
|
||||||
|
// Create a snapshot of current mouse action with modifiers
|
||||||
|
const snapshot = createSnapshot(event, 'right_click');
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
MouseRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for this element
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
const data = MouseRegistry.elements.get(elementId);
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree
|
||||||
|
console.debug("No match in tree for right-click");
|
||||||
|
// Clear history for invalid sequences
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has config)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: true // Right-click only triggers when clicking on element
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but longer sequences are possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
MouseRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of MouseRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}, MouseRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the global mouse event listeners if not already attached
|
||||||
|
*/
|
||||||
|
function attachGlobalListener() {
|
||||||
|
if (!MouseRegistry.listenerAttached) {
|
||||||
|
// Store handler references for proper removal
|
||||||
|
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||||
|
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||||
|
|
||||||
|
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||||
|
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||||
|
MouseRegistry.listenerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the global mouse event listeners
|
||||||
|
*/
|
||||||
|
function detachGlobalListener() {
|
||||||
|
if (MouseRegistry.listenerAttached) {
|
||||||
|
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||||
|
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||||
|
MouseRegistry.listenerAttached = false;
|
||||||
|
|
||||||
|
// Clean up handler references
|
||||||
|
MouseRegistry.clickHandler = null;
|
||||||
|
MouseRegistry.contextmenuHandler = null;
|
||||||
|
|
||||||
|
// Clean up all state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add mouse support to an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||||
|
*/
|
||||||
|
window.add_mouse_support = function (elementId, combinationsJson) {
|
||||||
|
// Parse the combinations JSON
|
||||||
|
const combinations = JSON.parse(combinationsJson);
|
||||||
|
|
||||||
|
// Build tree for this element
|
||||||
|
const tree = buildTree(combinations);
|
||||||
|
|
||||||
|
// Get element reference
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.error("Element with ID", elementId, "not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to registry
|
||||||
|
MouseRegistry.elements.set(elementId, {
|
||||||
|
tree: tree,
|
||||||
|
element: element
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach global listener if not already attached
|
||||||
|
attachGlobalListener();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove mouse support from an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
*/
|
||||||
|
window.remove_mouse_support = function (elementId) {
|
||||||
|
// Remove from registry
|
||||||
|
if (!MouseRegistry.elements.has(elementId)) {
|
||||||
|
console.warn("Element with ID", elementId, "not found in mouse registry!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseRegistry.elements.delete(elementId);
|
||||||
|
|
||||||
|
// If no more elements, detach global listeners
|
||||||
|
if (MouseRegistry.elements.size === 0) {
|
||||||
|
detachGlobalListener();
|
||||||
|
}
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
@@ -16,6 +16,7 @@ from ..auth.utils import (
|
|||||||
logout_user,
|
logout_user,
|
||||||
get_user_info
|
get_user_info
|
||||||
)
|
)
|
||||||
|
from ..core.instances import InstancesManager
|
||||||
|
|
||||||
|
|
||||||
def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", base_url=None):
|
def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", base_url=None):
|
||||||
@@ -181,6 +182,9 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", b
|
|||||||
if refresh_token:
|
if refresh_token:
|
||||||
logout_user(refresh_token)
|
logout_user(refresh_token)
|
||||||
|
|
||||||
|
# release memory
|
||||||
|
InstancesManager.clear_session(session)
|
||||||
|
|
||||||
# Clear session
|
# Clear session
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ class Boundaries(SingleInstance):
|
|||||||
Keep the boundaries updated
|
Keep the boundaries updated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session, owner, container_id: str = None, on_resize=None):
|
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
|
||||||
super().__init__(session, Ids.Boundaries, owner)
|
super().__init__(owner, _id=_id)
|
||||||
self._owner = owner
|
self._owner = owner
|
||||||
self._container_id = container_id or owner.get_id()
|
self._container_id = container_id or owner.get_id()
|
||||||
self._on_resize = on_resize
|
self._on_resize = on_resize
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.commands import CommandsManager
|
from myfasthtml.core.commands import CommandsManager
|
||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.network_utils import from_parent_child_list
|
from myfasthtml.core.network_utils import from_parent_child_list
|
||||||
|
|
||||||
|
|
||||||
class CommandsDebugger(SingleInstance):
|
class CommandsDebugger(SingleInstance):
|
||||||
def __init__(self, session, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(session, Ids.CommandsDebugger, parent)
|
super().__init__(parent, _id=_id)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
commands = self._get_commands()
|
commands = self._get_commands()
|
||||||
|
|||||||
94
src/myfasthtml/controls/Dropdown.py
Normal file
94
src/myfasthtml/controls/Dropdown.py
Normal 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';
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# });
|
||||||
@@ -16,7 +16,7 @@ logger = logging.getLogger("FileUpload")
|
|||||||
|
|
||||||
class FileUploadState(DbObject):
|
class FileUploadState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
# persisted in DB
|
# persisted in DB
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ class Commands(BaseCommands):
|
|||||||
class FileUpload(MultipleInstance):
|
class FileUpload(MultipleInstance):
|
||||||
|
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(Ids.FileUpload, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self._state = FileUploadState(self)
|
self._state = FileUploadState(self)
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||||
from myfasthtml.core.network_utils import from_parent_child_list
|
from myfasthtml.core.network_utils import from_parent_child_list
|
||||||
|
|
||||||
|
|
||||||
class InstancesDebugger(SingleInstance):
|
class InstancesDebugger(SingleInstance):
|
||||||
def __init__(self, session, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(session, Ids.InstancesDebugger, parent)
|
super().__init__(parent, _id=_id)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
|
s_name = InstancesManager.get_session_user_name
|
||||||
instances = self._get_instances()
|
instances = self._get_instances()
|
||||||
nodes, edges = from_parent_child_list(instances,
|
nodes, edges = from_parent_child_list(
|
||||||
id_getter=lambda x: x.get_id(),
|
instances,
|
||||||
label_getter=lambda x: x.get_prefix(),
|
id_getter=lambda x: x.get_full_id(),
|
||||||
parent_getter=lambda x: x.get_parent().get_id() if x.get_parent() else None
|
label_getter=lambda x: f"{x.get_id()}",
|
||||||
)
|
parent_getter=lambda x: x.get_full_parent_id()
|
||||||
|
)
|
||||||
|
for edge in edges:
|
||||||
|
edge["color"] = "green"
|
||||||
|
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
|
||||||
|
|
||||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
|
for node in nodes:
|
||||||
|
node["shape"] = "box"
|
||||||
|
|
||||||
|
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||||
|
# vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}})
|
||||||
return vis_network
|
return vis_network
|
||||||
|
|
||||||
def _get_instances(self):
|
def _get_instances(self):
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import json
|
|||||||
|
|
||||||
from fasthtml.xtend import Script
|
from fasthtml.xtend import Script
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.commands import BaseCommand
|
from myfasthtml.core.commands import BaseCommand
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
class Keyboard(MultipleInstance):
|
class Keyboard(MultipleInstance):
|
||||||
def __init__(self, parent, _id=None, combinations=None):
|
def __init__(self, parent, _id=None, combinations=None):
|
||||||
super().__init__(Ids.Keyboard, parent)
|
super().__init__(parent, _id=_id)
|
||||||
self.combinations = combinations or {}
|
self.combinations = combinations or {}
|
||||||
|
|
||||||
def add(self, sequence: str, command: BaseCommand):
|
def add(self, sequence: str, command: BaseCommand):
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ from fasthtml.common import *
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.Boundaries import Boundaries
|
from myfasthtml.controls.Boundaries import Boundaries
|
||||||
from myfasthtml.controls.UserProfile import UserProfile
|
from myfasthtml.controls.UserProfile import UserProfile
|
||||||
from myfasthtml.controls.helpers import mk, Ids
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.utils import get_id
|
from myfasthtml.core.utils import get_id
|
||||||
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
|
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
|
||||||
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
|
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
|
||||||
@@ -25,7 +25,7 @@ logger = logging.getLogger("LayoutControl")
|
|||||||
|
|
||||||
class LayoutState(DbObject):
|
class LayoutState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.left_drawer_open: bool = True
|
self.left_drawer_open: bool = True
|
||||||
self.right_drawer_open: bool = True
|
self.right_drawer_open: bool = True
|
||||||
@@ -100,7 +100,7 @@ class Layout(SingleInstance):
|
|||||||
def get_groups(self):
|
def get_groups(self):
|
||||||
return self._groups
|
return self._groups
|
||||||
|
|
||||||
def __init__(self, session, app_name, parent=None):
|
def __init__(self, parent, app_name, _id=None):
|
||||||
"""
|
"""
|
||||||
Initialize the Layout component.
|
Initialize the Layout component.
|
||||||
|
|
||||||
@@ -109,13 +109,13 @@ class Layout(SingleInstance):
|
|||||||
left_drawer (bool): Enable left drawer. Default is True.
|
left_drawer (bool): Enable left drawer. Default is True.
|
||||||
right_drawer (bool): Enable right drawer. Default is True.
|
right_drawer (bool): Enable right drawer. Default is True.
|
||||||
"""
|
"""
|
||||||
super().__init__(session, Ids.Layout, parent)
|
super().__init__(parent, _id=_id)
|
||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
|
|
||||||
# Content storage
|
# Content storage
|
||||||
self._main_content = None
|
self._main_content = None
|
||||||
self._state = LayoutState(self)
|
self._state = LayoutState(self)
|
||||||
self._boundaries = Boundaries(session, self)
|
self._boundaries = Boundaries(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self.left_drawer = self.Content(self)
|
self.left_drawer = self.Content(self)
|
||||||
self.right_drawer = self.Content(self)
|
self.right_drawer = self.Content(self)
|
||||||
@@ -192,8 +192,8 @@ class Layout(SingleInstance):
|
|||||||
cls="flex gap-1"
|
cls="flex gap-1"
|
||||||
),
|
),
|
||||||
Div( # right
|
Div( # right
|
||||||
*self.header_right.get_content(),
|
*self.header_right.get_content()[None],
|
||||||
InstancesManager.get(self._session, Ids.UserProfile, UserProfile),
|
UserProfile(self),
|
||||||
cls="flex gap-1"
|
cls="flex gap-1"
|
||||||
),
|
),
|
||||||
cls="mf-layout-header"
|
cls="mf-layout-header"
|
||||||
|
|||||||
23
src/myfasthtml/controls/Mouse.py
Normal file
23
src/myfasthtml/controls/Mouse.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from fasthtml.xtend import Script
|
||||||
|
|
||||||
|
from myfasthtml.core.commands import BaseCommand
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
|
class Mouse(MultipleInstance):
|
||||||
|
def __init__(self, parent, _id=None, combinations=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.combinations = combinations or {}
|
||||||
|
|
||||||
|
def add(self, sequence: str, command: BaseCommand):
|
||||||
|
self.combinations[sequence] = command
|
||||||
|
return self
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
|
||||||
|
return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
@@ -4,7 +4,7 @@ from typing import Callable, Any
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
||||||
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
|
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
|
||||||
@@ -35,14 +35,13 @@ class Search(MultipleInstance):
|
|||||||
a callable for extracting a string value from items, and a template callable for rendering
|
a callable for extracting a string value from items, and a template callable for rendering
|
||||||
the filtered items. It provides functionality to handle and organize item-based operations.
|
the filtered items. It provides functionality to handle and organize item-based operations.
|
||||||
|
|
||||||
:param session: The session object to maintain state or context across operations.
|
|
||||||
:param _id: Optional identifier for the component.
|
:param _id: Optional identifier for the component.
|
||||||
:param items: An optional list of names for the items to be filtered.
|
:param items: An optional list of names for the items to be filtered.
|
||||||
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
|
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
|
||||||
function that returns the item as is.
|
function that returns the item as is.
|
||||||
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
|
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
|
||||||
"""
|
"""
|
||||||
super().__init__(Ids.Search, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.items_names = items_names or ''
|
self.items_names = items_names or ''
|
||||||
self.items = items or []
|
self.items = items or []
|
||||||
self.filtered = self.items.copy()
|
self.filtered = self.items.copy()
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ from fasthtml.xtend import Script
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.Search import Search
|
from myfasthtml.controls.Search import Search
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
from myfasthtml.core.instances import MultipleInstance, BaseInstance, InstancesManager
|
||||||
from myfasthtml.core.instances_helper import InstancesHelper
|
|
||||||
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
||||||
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ class Boundaries:
|
|||||||
|
|
||||||
class TabsManagerState(DbObject):
|
class TabsManagerState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
# persisted in DB
|
# persisted in DB
|
||||||
self.tabs: dict[str, Any] = {}
|
self.tabs: dict[str, Any] = {}
|
||||||
@@ -78,14 +77,15 @@ class TabsManager(MultipleInstance):
|
|||||||
_tab_count = 0
|
_tab_count = 0
|
||||||
|
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(Ids.TabsManager, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self._state = TabsManagerState(self)
|
self._state = TabsManagerState(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self._boundaries = Boundaries()
|
self._boundaries = Boundaries()
|
||||||
self._search = Search(self,
|
self._search = Search(self,
|
||||||
items=self._get_tab_list(),
|
items=self._get_tab_list(),
|
||||||
get_attr=lambda x: x["label"],
|
get_attr=lambda x: x["label"],
|
||||||
template=self._mk_tab_button)
|
template=self._mk_tab_button,
|
||||||
|
_id="-search")
|
||||||
logger.debug(f"TabsManager created with id: {self._id}")
|
logger.debug(f"TabsManager created with id: {self._id}")
|
||||||
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
||||||
logger.debug(f" active tab : {self._state.active_tab}")
|
logger.debug(f" active tab : {self._state.active_tab}")
|
||||||
@@ -102,7 +102,7 @@ class TabsManager(MultipleInstance):
|
|||||||
tab_config = self._state.tabs[tab_id]
|
tab_config = self._state.tabs[tab_id]
|
||||||
if tab_config["component_type"] is None:
|
if tab_config["component_type"] is None:
|
||||||
return None
|
return None
|
||||||
return InstancesHelper.dynamic_get(self, tab_config["component_type"], tab_config["component_id"])
|
return InstancesManager.get(self._session, tab_config["component_id"])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_tab_count():
|
def _get_tab_count():
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.AuthProxy import AuthProxy
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance, RootInstance
|
||||||
from myfasthtml.core.utils import retrieve_user_info
|
from myfasthtml.core.utils import retrieve_user_info
|
||||||
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
|
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
|
||||||
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
|
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
|
||||||
@@ -15,6 +16,7 @@ class UserProfileState:
|
|||||||
self._session = owner.get_session()
|
self._session = owner.get_session()
|
||||||
|
|
||||||
self.theme = "light"
|
self.theme = "light"
|
||||||
|
self.load()
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
user_info = retrieve_user_info(self._session)
|
user_info = retrieve_user_info(self._session)
|
||||||
@@ -25,7 +27,7 @@ class UserProfileState:
|
|||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||||
auth_proxy = InstancesManager.get_auth_proxy()
|
auth_proxy = AuthProxy(RootInstance)
|
||||||
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
|
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
|
||||||
|
|
||||||
|
|
||||||
@@ -35,14 +37,15 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class UserProfile(SingleInstance):
|
class UserProfile(SingleInstance):
|
||||||
def __init__(self, session, parent=None):
|
def __init__(self, parent=None, _id=None):
|
||||||
super().__init__(session, Ids.UserProfile, parent)
|
super().__init__(parent, _id=_id)
|
||||||
self._state = UserProfileState(self)
|
self._state = UserProfileState(self)
|
||||||
self._commands = Commands(self)
|
self._commands = Commands(self)
|
||||||
|
|
||||||
def update_dark_mode(self, client_response):
|
def update_dark_mode(self, client_response):
|
||||||
self._state.theme = client_response.get("theme", "light")
|
self._state.theme = client_response.get("theme", "light")
|
||||||
self._state.save()
|
self._state.save()
|
||||||
|
retrieve_user_info(self._session).get("user_settings", {})["theme"] = self._state.theme
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
user_info = retrieve_user_info(self._session)
|
user_info = retrieve_user_info(self._session)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import logging
|
|||||||
|
|
||||||
from fasthtml.components import Script, Div
|
from fasthtml.components import Script, Div
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ logger = logging.getLogger("VisNetwork")
|
|||||||
|
|
||||||
class VisNetworkState(DbObject):
|
class VisNetworkState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
# persisted in DB
|
# persisted in DB
|
||||||
self.nodes: list = []
|
self.nodes: list = []
|
||||||
@@ -30,7 +29,7 @@ class VisNetworkState(DbObject):
|
|||||||
|
|
||||||
class VisNetwork(MultipleInstance):
|
class VisNetwork(MultipleInstance):
|
||||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
||||||
super().__init__(Ids.VisNetwork, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
logger.debug(f"VisNetwork created with id: {self._id}")
|
logger.debug(f"VisNetwork created with id: {self._id}")
|
||||||
|
|
||||||
self._state = VisNetworkState(self)
|
self._state = VisNetworkState(self)
|
||||||
@@ -51,6 +50,12 @@ class VisNetwork(MultipleInstance):
|
|||||||
|
|
||||||
self._state.update(state)
|
self._state.update(state)
|
||||||
|
|
||||||
|
def add_to_options(self, **kwargs):
|
||||||
|
logger.debug(f"add_to_options: {kwargs=}")
|
||||||
|
new_options = self._state.options.copy() | kwargs
|
||||||
|
self._update_state(None, None, new_options)
|
||||||
|
return self
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
|
|
||||||
# Serialize nodes and edges to JSON
|
# Serialize nodes and edges to JSON
|
||||||
|
|||||||
@@ -7,19 +7,8 @@ from myfasthtml.core.utils import merge_classes
|
|||||||
|
|
||||||
class Ids:
|
class Ids:
|
||||||
# Please keep the alphabetical order
|
# Please keep the alphabetical order
|
||||||
AuthProxy = "mf-auth-proxy"
|
|
||||||
Boundaries = "mf-boundaries"
|
|
||||||
CommandsDebugger = "mf-commands-debugger"
|
|
||||||
DbManager = "mf-dbmanager"
|
|
||||||
FileUpload = "mf-file-upload"
|
|
||||||
InstancesDebugger = "mf-instances-debugger"
|
|
||||||
Keyboard = "mf-keyboard"
|
|
||||||
Layout = "mf-layout"
|
|
||||||
Root = "mf-root"
|
Root = "mf-root"
|
||||||
Search = "mf-search"
|
UserSession = "mf-user_session"
|
||||||
TabsManager = "mf-tabs-manager"
|
|
||||||
UserProfile = "mf-user-profile"
|
|
||||||
VisNetwork = "mf-vis-network"
|
|
||||||
|
|
||||||
|
|
||||||
class mk:
|
class mk:
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from myfasthtml.auth.utils import login_user, save_user_info, register_user
|
from myfasthtml.auth.utils import login_user, save_user_info, register_user
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.instances import UniqueInstance, RootInstance
|
|
||||||
|
|
||||||
|
|
||||||
class AuthProxy(UniqueInstance):
|
class AuthProxy(SingleInstance):
|
||||||
def __init__(self, base_url: str = None):
|
def __init__(self, parent, base_url: str = None):
|
||||||
super().__init__(Ids.AuthProxy, RootInstance)
|
super().__init__(parent)
|
||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
|
|
||||||
def login_user(self, email: str, password: str):
|
def login_user(self, email: str, password: str):
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class BaseCommand:
|
|||||||
def execute(self, client_response: dict = None):
|
def execute(self, client_response: dict = None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def htmx(self, target="this", swap="outerHTML", trigger=None):
|
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
|
||||||
# Note that the default value is the same than in get_htmx_params()
|
# Note that the default value is the same than in get_htmx_params()
|
||||||
if target is None:
|
if target is None:
|
||||||
self._htmx_extra["hx-swap"] = "none"
|
self._htmx_extra["hx-swap"] = "none"
|
||||||
@@ -180,6 +180,15 @@ class Command(BaseCommand):
|
|||||||
return [ret] + ret_from_bindings
|
return [ret] + ret_from_bindings
|
||||||
|
|
||||||
|
|
||||||
|
class LambdaCommand(Command):
|
||||||
|
def __init__(self, delegate, name="LambdaCommand", description="Lambda Command"):
|
||||||
|
super().__init__(name, description, delegate)
|
||||||
|
self.htmx(target=None)
|
||||||
|
|
||||||
|
def execute(self, client_response: dict = None):
|
||||||
|
return self.callback(client_response)
|
||||||
|
|
||||||
|
|
||||||
class CommandsManager:
|
class CommandsManager:
|
||||||
commands = {}
|
commands = {}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from dbengine.dbengine import DbEngine
|
from dbengine.dbengine import DbEngine
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
|
||||||
from myfasthtml.core.utils import retrieve_user_info
|
from myfasthtml.core.utils import retrieve_user_info
|
||||||
|
|
||||||
|
|
||||||
class DbManager(SingleInstance):
|
class DbManager(SingleInstance):
|
||||||
def __init__(self, session, parent=None, root=".myFastHtmlDb", auto_register: bool = True):
|
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
||||||
super().__init__(session, Ids.DbManager, parent, auto_register=auto_register)
|
super().__init__(parent, auto_register=auto_register)
|
||||||
self.db = DbEngine(root=root)
|
self.db = DbEngine(root=root)
|
||||||
|
|
||||||
def save(self, entry, obj):
|
def save(self, entry, obj):
|
||||||
@@ -35,12 +34,12 @@ class DbObject:
|
|||||||
It loads from DB at startup
|
It loads from DB at startup
|
||||||
"""
|
"""
|
||||||
_initializing = False
|
_initializing = False
|
||||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_session", "_forbidden_attrs"}
|
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
|
||||||
|
|
||||||
def __init__(self, session, name=None, db_manager=None):
|
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
||||||
self._session = session
|
self._owner = owner
|
||||||
self._name = name or self.__class__.__name__
|
self._name = name or self.__class__.__name__
|
||||||
self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager)
|
self._db_manager = db_manager or DbManager(self._owner)
|
||||||
|
|
||||||
self._finalize_initialization()
|
self._finalize_initialization()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Self
|
from typing import Optional
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.controls.helpers import Ids
|
||||||
|
from myfasthtml.core.utils import pascal_to_snake
|
||||||
|
|
||||||
|
logger = logging.getLogger("InstancesManager")
|
||||||
|
|
||||||
special_session = {
|
special_session = {
|
||||||
"user_info": {"id": "** SPECIAL SESSION **"}
|
"user_info": {"id": "** SPECIAL SESSION **"}
|
||||||
@@ -18,25 +22,92 @@ class BaseInstance:
|
|||||||
Base class for all instances (manageable by InstancesManager)
|
Base class for all instances (manageable by InstancesManager)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session: dict, prefix: str, _id: str, parent: Self, auto_register: bool = True):
|
def __new__(cls, *args, **kwargs):
|
||||||
self._session = session
|
# Extract arguments from both positional and keyword arguments
|
||||||
self._id = _id
|
# Signature matches __init__: parent, session=None, _id=None, auto_register=True
|
||||||
self._prefix = prefix
|
parent = args[0] if len(args) > 0 and isinstance(args[0], BaseInstance) else kwargs.get("parent", None)
|
||||||
|
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
|
||||||
|
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
|
||||||
|
|
||||||
|
# Compute _id
|
||||||
|
_id = cls.compute_id(_id, parent)
|
||||||
|
|
||||||
|
if session is None:
|
||||||
|
if parent is not None:
|
||||||
|
session = parent.get_session()
|
||||||
|
else:
|
||||||
|
raise TypeError("Either session or parent must be provided")
|
||||||
|
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
key = (session_id, _id)
|
||||||
|
|
||||||
|
if key in InstancesManager.instances:
|
||||||
|
res = InstancesManager.instances[key]
|
||||||
|
if type(res) is not cls:
|
||||||
|
raise TypeError(f"Instance with id {_id} already exists, but is of type {type(res)}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
# Otherwise create a new instance
|
||||||
|
instance = super().__new__(cls)
|
||||||
|
instance._is_new_instance = True # mark as fresh
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional['BaseInstance'],
|
||||||
|
session: Optional[dict] = None,
|
||||||
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
if not getattr(self, "_is_new_instance", False):
|
||||||
|
# Skip __init__ if instance already existed
|
||||||
|
return
|
||||||
|
elif not isinstance(self, UniqueInstance):
|
||||||
|
# No more __init__ unless it's UniqueInstance
|
||||||
|
self._is_new_instance = False
|
||||||
|
|
||||||
self._parent = parent
|
self._parent = parent
|
||||||
|
self._session = session or (parent.get_session() if parent else None)
|
||||||
|
self._id = self.compute_id(_id, parent)
|
||||||
|
self._prefix = self._id if isinstance(self, (UniqueInstance, SingleInstance)) else self.compute_prefix()
|
||||||
|
|
||||||
if auto_register:
|
if auto_register:
|
||||||
InstancesManager.register(session, self)
|
InstancesManager.register(self._session, self)
|
||||||
|
|
||||||
def get_id(self):
|
def get_session(self) -> dict:
|
||||||
return self._id
|
|
||||||
|
|
||||||
def get_session(self):
|
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
def get_prefix(self):
|
def get_id(self) -> str:
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
def get_parent(self) -> Optional['BaseInstance']:
|
||||||
|
return self._parent
|
||||||
|
|
||||||
|
def get_prefix(self) -> str:
|
||||||
return self._prefix
|
return self._prefix
|
||||||
|
|
||||||
def get_parent(self):
|
def get_full_id(self) -> str:
|
||||||
return self._parent
|
return f"{InstancesManager.get_session_id(self._session)}-{self._id}"
|
||||||
|
|
||||||
|
def get_full_parent_id(self) -> Optional[str]:
|
||||||
|
parent = self.get_parent()
|
||||||
|
return parent.get_full_id() if parent else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def compute_prefix(cls):
|
||||||
|
return f"mf-{pascal_to_snake(cls.__name__)}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def compute_id(cls, _id: Optional[str], parent: Optional['BaseInstance']):
|
||||||
|
if _id is None:
|
||||||
|
prefix = cls.compute_prefix()
|
||||||
|
if issubclass(cls, SingleInstance):
|
||||||
|
_id = prefix
|
||||||
|
else:
|
||||||
|
_id = f"{prefix}-{str(uuid.uuid4())}"
|
||||||
|
return _id
|
||||||
|
|
||||||
|
if _id.startswith("-") and parent is not None:
|
||||||
|
return f"{parent.get_prefix()}{_id}"
|
||||||
|
|
||||||
|
return _id
|
||||||
|
|
||||||
|
|
||||||
class SingleInstance(BaseInstance):
|
class SingleInstance(BaseInstance):
|
||||||
@@ -44,19 +115,26 @@ class SingleInstance(BaseInstance):
|
|||||||
Base class for instances that can only have one instance at a time.
|
Base class for instances that can only have one instance at a time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session: dict, prefix: str, parent, auto_register: bool = True):
|
def __init__(self,
|
||||||
super().__init__(session, prefix, prefix, parent, auto_register)
|
parent: Optional[BaseInstance] = None,
|
||||||
|
session: Optional[dict] = None,
|
||||||
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
super().__init__(parent, session, _id, auto_register)
|
||||||
|
|
||||||
|
|
||||||
class UniqueInstance(BaseInstance):
|
class UniqueInstance(BaseInstance):
|
||||||
"""
|
"""
|
||||||
Base class for instances that can only have one instance at a time.
|
Base class for instances that can only have one instance at a time.
|
||||||
Does not throw exception if the instance already exists, it simply overwrites it.
|
But unlike SingleInstance, the __init__ is called every time it's instantiated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, prefix: str, parent: BaseInstance, auto_register: bool = True):
|
def __init__(self,
|
||||||
super().__init__(parent.get_session(), prefix, prefix, parent, auto_register)
|
parent: Optional[BaseInstance] = None,
|
||||||
self._prefix = prefix
|
session: Optional[dict] = None,
|
||||||
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
super().__init__(parent, session, _id, auto_register)
|
||||||
|
|
||||||
|
|
||||||
class MultipleInstance(BaseInstance):
|
class MultipleInstance(BaseInstance):
|
||||||
@@ -64,9 +142,11 @@ class MultipleInstance(BaseInstance):
|
|||||||
Base class for instances that can have multiple instances at a time.
|
Base class for instances that can have multiple instances at a time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, prefix: str, parent: BaseInstance, auto_register: bool = True, _id=None):
|
def __init__(self, parent: BaseInstance,
|
||||||
super().__init__(parent.get_session(), prefix, _id or f"{prefix}-{str(uuid.uuid4())}", parent, auto_register)
|
session: Optional[dict] = None,
|
||||||
self._prefix = prefix
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
super().__init__(parent, session, _id, auto_register)
|
||||||
|
|
||||||
|
|
||||||
class InstancesManager:
|
class InstancesManager:
|
||||||
@@ -80,7 +160,7 @@ class InstancesManager:
|
|||||||
:param instance:
|
:param instance:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
key = (InstancesManager._get_session_id(session), instance.get_id())
|
key = (InstancesManager.get_session_id(session), instance.get_id())
|
||||||
|
|
||||||
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
|
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
|
||||||
raise DuplicateInstanceError(instance)
|
raise DuplicateInstanceError(instance)
|
||||||
@@ -89,48 +169,50 @@ class InstancesManager:
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get(session: dict, instance_id: str, instance_type: type = None, parent: BaseInstance = None, *args, **kwargs):
|
def get(session: dict, instance_id: str):
|
||||||
"""
|
"""
|
||||||
Get or create an instance of the given type (from its id)
|
Get or create an instance of the given type (from its id)
|
||||||
:param session:
|
:param session:
|
||||||
:param instance_id:
|
:param instance_id:
|
||||||
:param instance_type:
|
|
||||||
:param parent:
|
|
||||||
:param args:
|
|
||||||
:param kwargs:
|
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
try:
|
key = (InstancesManager.get_session_id(session), instance_id)
|
||||||
key = (InstancesManager._get_session_id(session), instance_id)
|
return InstancesManager.instances[key]
|
||||||
|
|
||||||
return InstancesManager.instances[key]
|
|
||||||
except KeyError:
|
|
||||||
if instance_type:
|
|
||||||
if not issubclass(instance_type, SingleInstance):
|
|
||||||
assert parent is not None, "Parent instance must be provided if not SingleInstance"
|
|
||||||
|
|
||||||
if isinstance(parent, MultipleInstance):
|
|
||||||
return instance_type(parent, _id=instance_id, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
return instance_type(session, parent=parent, *args, **kwargs) # it will be automatically registered
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_session_id(session):
|
def get_session_id(session):
|
||||||
if not session:
|
if session is None:
|
||||||
return "** NOT LOGGED IN **"
|
return "** NOT LOGGED IN **"
|
||||||
if "user_info" not in session:
|
if "user_info" not in session:
|
||||||
return "** UNKNOWN USER **"
|
return "** UNKNOWN USER **"
|
||||||
return session["user_info"].get("id", "** INVALID SESSION **")
|
return session["user_info"].get("id", "** INVALID SESSION **")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_auth_proxy():
|
def get_session_user_name(session):
|
||||||
return InstancesManager.get(special_session, Ids.AuthProxy)
|
if session is None:
|
||||||
|
return "** NOT LOGGED IN **"
|
||||||
|
if "user_info" not in session:
|
||||||
|
return "** UNKNOWN USER **"
|
||||||
|
return session["user_info"].get("username", "** INVALID SESSION **")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reset():
|
def reset():
|
||||||
return InstancesManager.instances.clear()
|
InstancesManager.instances.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clear_session(session):
|
||||||
|
"""
|
||||||
|
Remove all instances belonging to the given session.
|
||||||
|
:param session:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
|
||||||
|
InstancesManager.instances = {
|
||||||
|
key: instance
|
||||||
|
for key, instance in InstancesManager.instances.items()
|
||||||
|
if key[0] != session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
RootInstance = SingleInstance(special_session, Ids.Root, None)
|
RootInstance = SingleInstance(None, special_session, Ids.Root)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
|
||||||
from myfasthtml.controls.FileUpload import FileUpload
|
|
||||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.instances import BaseInstance, InstancesManager
|
|
||||||
|
|
||||||
logger = logging.getLogger("InstancesHelper")
|
|
||||||
|
|
||||||
|
|
||||||
class InstancesHelper:
|
|
||||||
@staticmethod
|
|
||||||
def dynamic_get(parent: BaseInstance, component_type: str, instance_id: str):
|
|
||||||
logger.debug(f"Dynamic get: {component_type} {instance_id}")
|
|
||||||
if component_type == Ids.VisNetwork:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id,
|
|
||||||
VisNetwork, parent=parent, _id=instance_id)
|
|
||||||
elif component_type == Ids.InstancesDebugger:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id,
|
|
||||||
InstancesDebugger, parent.get_session(), parent, instance_id)
|
|
||||||
elif component_type == Ids.CommandsDebugger:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id,
|
|
||||||
CommandsDebugger, parent.get_session(), parent, instance_id)
|
|
||||||
elif component_type == Ids.FileUpload:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id, FileUpload, parent)
|
|
||||||
logger.warning(f"Unknown component type: {component_type}")
|
|
||||||
return None
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
ROOT_COLOR = "#ff9999"
|
||||||
|
GHOST_COLOR = "#cccccc"
|
||||||
|
|
||||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||||
"""
|
"""
|
||||||
@@ -144,50 +146,34 @@ def from_tree_with_metadata(
|
|||||||
|
|
||||||
def from_parent_child_list(
|
def from_parent_child_list(
|
||||||
items: list,
|
items: list,
|
||||||
id_getter: callable = None,
|
id_getter: Callable = None,
|
||||||
label_getter: callable = None,
|
label_getter: Callable = None,
|
||||||
parent_getter: callable = None,
|
parent_getter: Callable = None,
|
||||||
ghost_color: str = "#ff9999"
|
ghost_color: str = GHOST_COLOR,
|
||||||
|
root_color: str | None = ROOT_COLOR
|
||||||
) -> tuple[list, list]:
|
) -> tuple[list, list]:
|
||||||
"""
|
"""
|
||||||
Convert a list of items with parent references to vis.js nodes and edges format.
|
Convert a list of items with parent references to vis.js nodes and edges format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
items: List of items (dicts or objects) with parent references
|
items: List of items (dicts or objects) with parent references
|
||||||
(e.g., [{"id": "child", "parent": "root", "label": "Child"}, ...])
|
id_getter: callback to extract node ID
|
||||||
id_getter: Optional callback to extract node ID from item
|
label_getter: callback to extract node label
|
||||||
Default: lambda item: item.get("id")
|
parent_getter: callback to extract parent ID
|
||||||
label_getter: Optional callback to extract node label from item
|
ghost_color: color for ghost nodes (referenced parents)
|
||||||
Default: lambda item: item.get("label", "")
|
root_color: color for root nodes (nodes without parent)
|
||||||
parent_getter: Optional callback to extract parent ID from item
|
|
||||||
Default: lambda item: item.get("parent")
|
|
||||||
ghost_color: Color to use for ghost nodes (nodes referenced as parents but not in list)
|
|
||||||
Default: "#ff9999" (light red)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (nodes, edges) where:
|
tuple: (nodes, edges)
|
||||||
- nodes: list of dicts with IDs from items, ghost nodes have color property
|
|
||||||
- edges: list of dicts with 'from' and 'to' keys
|
|
||||||
|
|
||||||
Note:
|
|
||||||
- Nodes with parent=None or parent="" are treated as root nodes
|
|
||||||
- If a parent is referenced but doesn't exist in items, a ghost node is created
|
|
||||||
with the ghost_color applied
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> items = [
|
|
||||||
... {"id": "root", "label": "Root"},
|
|
||||||
... {"id": "child1", "parent": "root", "label": "Child 1"},
|
|
||||||
... {"id": "child2", "parent": "unknown", "label": "Child 2"}
|
|
||||||
... ]
|
|
||||||
>>> nodes, edges = from_parent_child_list(items)
|
|
||||||
>>> # "unknown" will be created as a ghost node with color="#ff9999"
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Default getters
|
# Default getters
|
||||||
if id_getter is None:
|
if id_getter is None:
|
||||||
id_getter = lambda item: item.get("id")
|
id_getter = lambda item: item.get("id")
|
||||||
|
|
||||||
if label_getter is None:
|
if label_getter is None:
|
||||||
label_getter = lambda item: item.get("label", "")
|
label_getter = lambda item: item.get("label", "")
|
||||||
|
|
||||||
if parent_getter is None:
|
if parent_getter is None:
|
||||||
parent_getter = lambda item: item.get("parent")
|
parent_getter = lambda item: item.get("parent")
|
||||||
|
|
||||||
@@ -205,34 +191,48 @@ def from_parent_child_list(
|
|||||||
existing_ids.add(node_id)
|
existing_ids.add(node_id)
|
||||||
nodes.append({
|
nodes.append({
|
||||||
"id": node_id,
|
"id": node_id,
|
||||||
"label": node_label
|
"label": node_label,
|
||||||
|
# root color assigned later
|
||||||
})
|
})
|
||||||
|
|
||||||
# Track ghost nodes to avoid duplicates
|
# Track ghost nodes
|
||||||
ghost_nodes = set()
|
ghost_nodes = set()
|
||||||
|
|
||||||
# Second pass: create edges and identify ghost nodes
|
# Track which nodes have parents
|
||||||
|
nodes_with_parent = set()
|
||||||
|
|
||||||
|
# Second pass: create edges and detect ghost nodes
|
||||||
for item in items:
|
for item in items:
|
||||||
node_id = id_getter(item)
|
node_id = id_getter(item)
|
||||||
parent_id = parent_getter(item)
|
parent_id = parent_getter(item)
|
||||||
|
|
||||||
# Skip if no parent or parent is empty string or None
|
# Skip roots
|
||||||
if parent_id is None or parent_id == "":
|
if parent_id is None or parent_id == "":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create edge from parent to child
|
# Child has a parent
|
||||||
|
nodes_with_parent.add(node_id)
|
||||||
|
|
||||||
|
# Create edge parent → child
|
||||||
edges.append({
|
edges.append({
|
||||||
"from": parent_id,
|
"from": parent_id,
|
||||||
"to": node_id
|
"to": node_id
|
||||||
})
|
})
|
||||||
|
|
||||||
# Check if parent exists, if not create ghost node
|
# Create ghost node if parent not found
|
||||||
if parent_id not in existing_ids and parent_id not in ghost_nodes:
|
if parent_id not in existing_ids and parent_id not in ghost_nodes:
|
||||||
ghost_nodes.add(parent_id)
|
ghost_nodes.add(parent_id)
|
||||||
nodes.append({
|
nodes.append({
|
||||||
"id": parent_id,
|
"id": parent_id,
|
||||||
"label": str(parent_id), # Use ID as label for ghost nodes
|
"label": str(parent_id),
|
||||||
"color": ghost_color
|
"color": ghost_color
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Final pass: assign color to root nodes
|
||||||
|
if root_color is not None:
|
||||||
|
for node in nodes:
|
||||||
|
if node["id"] not in nodes_with_parent and node["id"] not in ghost_nodes:
|
||||||
|
# Root node
|
||||||
|
node["color"] = root_color
|
||||||
|
|
||||||
return nodes, edges
|
return nodes, edges
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from bs4 import Tag
|
from bs4 import Tag
|
||||||
from fastcore.xml import FT
|
from fastcore.xml import FT
|
||||||
@@ -234,6 +235,33 @@ def get_id(obj):
|
|||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def pascal_to_snake(name: str) -> str:
|
||||||
|
"""Convert a PascalCase or CamelCase string to snake_case."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
|
# Insert underscore before capital letters (except the first one)
|
||||||
|
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||||
|
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
||||||
|
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
|
||||||
|
return s2.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def snake_to_pascal(name: str) -> str:
|
||||||
|
"""Convert a snake_case string to PascalCase."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Split on underscores and capitalize each part
|
||||||
|
parts = name.split('_')
|
||||||
|
return ''.join(word.capitalize() for word in parts if word)
|
||||||
|
|
||||||
|
|
||||||
@utils_rt(Routes.Commands)
|
@utils_rt(Routes.Commands)
|
||||||
def post(session, c_id: str, client_response: dict = None):
|
def post(session, c_id: str, client_response: dict = None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import re
|
|||||||
|
|
||||||
def pascal_to_snake(name: str) -> str:
|
def pascal_to_snake(name: str) -> str:
|
||||||
"""Convert a PascalCase or CamelCase string to snake_case."""
|
"""Convert a PascalCase or CamelCase string to snake_case."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
# Insert underscore before capital letters (except the first one)
|
# Insert underscore before capital letters (except the first one)
|
||||||
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||||
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from starlette.responses import Response
|
|||||||
from myfasthtml.auth.routes import setup_auth_routes
|
from myfasthtml.auth.routes import setup_auth_routes
|
||||||
from myfasthtml.auth.utils import create_auth_beforeware
|
from myfasthtml.auth.utils import create_auth_beforeware
|
||||||
from myfasthtml.core.AuthProxy import AuthProxy
|
from myfasthtml.core.AuthProxy import AuthProxy
|
||||||
|
from myfasthtml.core.instances import RootInstance
|
||||||
from myfasthtml.core.utils import utils_app
|
from myfasthtml.core.utils import utils_app
|
||||||
|
|
||||||
logger = logging.getLogger("MyFastHtml")
|
logger = logging.getLogger("MyFastHtml")
|
||||||
@@ -104,6 +105,6 @@ def create_app(daisyui: Optional[bool] = True,
|
|||||||
setup_auth_routes(app, rt, base_url=base_url)
|
setup_auth_routes(app, rt, base_url=base_url)
|
||||||
|
|
||||||
# create the AuthProxy instance
|
# create the AuthProxy instance
|
||||||
AuthProxy(base_url) # using the auto register mechanism to expose it
|
AuthProxy(RootInstance, base_url) # using the auto register mechanism to expose it
|
||||||
|
|
||||||
return app, rt
|
return app, rt
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ def session():
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def root_instance(session):
|
def root_instance(session):
|
||||||
return SingleInstance(session, "TestRoot", None)
|
return SingleInstance(None, session, "TestRoot")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import shutil
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
from fasthtml.xtend import Script
|
from fasthtml.xtend import Script
|
||||||
@@ -10,9 +12,11 @@ from .conftest import session
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def tabs_manager(root_instance):
|
def tabs_manager(root_instance):
|
||||||
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
yield TabsManager(root_instance)
|
yield TabsManager(root_instance)
|
||||||
|
|
||||||
InstancesManager.reset()
|
InstancesManager.reset()
|
||||||
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
class TestTabsManagerBehaviour:
|
class TestTabsManagerBehaviour:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
from fasthtml.components import Button, Div
|
from fasthtml.components import Button, Div
|
||||||
from myutils.observable import make_observable, bind
|
from myutils.observable import make_observable, bind
|
||||||
|
|
||||||
from myfasthtml.core.commands import Command, CommandsManager
|
from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand
|
||||||
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
||||||
from myfasthtml.test.matcher import matches
|
from myfasthtml.test.matcher import matches
|
||||||
|
|
||||||
@@ -183,3 +183,14 @@ class TestCommandExecute:
|
|||||||
assert "hx-swap-oob" not in res[0].attrs
|
assert "hx-swap-oob" not in res[0].attrs
|
||||||
assert "hx-swap-oob" not in res[1].attrs
|
assert "hx-swap-oob" not in res[1].attrs
|
||||||
assert "hx-swap-oob" not in res[3].attrs
|
assert "hx-swap-oob" not in res[3].attrs
|
||||||
|
|
||||||
|
|
||||||
|
class TestLambaCommand:
|
||||||
|
|
||||||
|
def test_i_can_create_a_command_from_lambda(self):
|
||||||
|
command = LambdaCommand(lambda resp: "Hello World")
|
||||||
|
assert command.execute() == "Hello World"
|
||||||
|
|
||||||
|
def test_by_default_target_is_none(self):
|
||||||
|
command = LambdaCommand(lambda resp: "Hello World")
|
||||||
|
assert command.get_htmx_params()["hx-swap"] == "none"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from myfasthtml.core.dbmanager import DbManager, DbObject
|
from myfasthtml.core.dbmanager import DbManager, DbObject
|
||||||
|
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -19,9 +20,14 @@ def session():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_manager(session):
|
def parent(session):
|
||||||
|
return SingleInstance(session=session, _id="test_parent_id")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_manager(parent):
|
||||||
shutil.rmtree("TestDb", ignore_errors=True)
|
shutil.rmtree("TestDb", ignore_errors=True)
|
||||||
db_manager_instance = DbManager(session, root="TestDb", auto_register=False)
|
db_manager_instance = DbManager(parent, root="TestDb", auto_register=False)
|
||||||
|
|
||||||
yield db_manager_instance
|
yield db_manager_instance
|
||||||
|
|
||||||
@@ -32,17 +38,17 @@ def simplify(res: dict) -> dict:
|
|||||||
return {k: v for k, v in res.items() if not k.startswith("_")}
|
return {k: v for k, v in res.items() if not k.startswith("_")}
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init(session, db_manager):
|
def test_i_can_init(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.value: str = "hello"
|
self.value: str = "hello"
|
||||||
self.number: int = 42
|
self.number: int = 42
|
||||||
self.none_value: None = None
|
self.none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
props = dummy._get_properties()
|
props = dummy._get_properties()
|
||||||
|
|
||||||
@@ -52,17 +58,17 @@ def test_i_can_init(session, db_manager):
|
|||||||
assert len(history) == 1
|
assert len(history) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_dataclass(session, db_manager):
|
def test_i_can_init_from_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
none_value: None = None
|
none_value: None = None
|
||||||
|
|
||||||
DummyObject(session)
|
DummyObject(parent)
|
||||||
|
|
||||||
in_db = db_manager.load("DummyObject")
|
in_db = db_manager.load("DummyObject")
|
||||||
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||||
@@ -70,10 +76,10 @@ def test_i_can_init_from_dataclass(session, db_manager):
|
|||||||
assert len(history) == 1
|
assert len(history) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_db_with(session, db_manager):
|
def test_i_can_init_from_db_with(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.value: str = "hello"
|
self.value: str = "hello"
|
||||||
@@ -82,17 +88,17 @@ def test_i_can_init_from_db_with(session, db_manager):
|
|||||||
# insert other values in db
|
# insert other values in db
|
||||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
assert dummy.value == "other_value"
|
assert dummy.value == "other_value"
|
||||||
assert dummy.number == 34
|
assert dummy.number == 34
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_db_with_dataclass(session, db_manager):
|
def test_i_can_init_from_db_with_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
@@ -100,16 +106,16 @@ def test_i_can_init_from_db_with_dataclass(session, db_manager):
|
|||||||
# insert other values in db
|
# insert other values in db
|
||||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
assert dummy.value == "other_value"
|
assert dummy.value == "other_value"
|
||||||
assert dummy.number == 34
|
assert dummy.number == 34
|
||||||
|
|
||||||
|
|
||||||
def test_i_do_not_save_when_prefixed_by_underscore_or_ns(session, db_manager):
|
def test_i_do_not_save_when_prefixed_by_underscore_or_ns(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.to_save: str = "value"
|
self.to_save: str = "value"
|
||||||
@@ -120,7 +126,7 @@ def test_i_do_not_save_when_prefixed_by_underscore_or_ns(session, db_manager):
|
|||||||
_not_to_save: str = "value"
|
_not_to_save: str = "value"
|
||||||
ns_not_to_save: str = "value"
|
ns_not_to_save: str = "value"
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.to_save = "other_value"
|
dummy.to_save = "other_value"
|
||||||
dummy.ns_not_to_save = "other_value"
|
dummy.ns_not_to_save = "other_value"
|
||||||
dummy._not_to_save = "other_value"
|
dummy._not_to_save = "other_value"
|
||||||
@@ -131,17 +137,17 @@ def test_i_do_not_save_when_prefixed_by_underscore_or_ns(session, db_manager):
|
|||||||
assert "ns_not_to_save" not in in_db
|
assert "ns_not_to_save" not in in_db
|
||||||
|
|
||||||
|
|
||||||
def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(session, db_manager):
|
def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
to_save: str = "value"
|
to_save: str = "value"
|
||||||
_not_to_save: str = "value"
|
_not_to_save: str = "value"
|
||||||
ns_not_to_save: str = "value"
|
ns_not_to_save: str = "value"
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.to_save = "other_value"
|
dummy.to_save = "other_value"
|
||||||
dummy.ns_not_to_save = "other_value"
|
dummy.ns_not_to_save = "other_value"
|
||||||
dummy._not_to_save = "other_value"
|
dummy._not_to_save = "other_value"
|
||||||
@@ -152,31 +158,31 @@ def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(session,
|
|||||||
assert "ns_not_to_save" not in in_db
|
assert "ns_not_to_save" not in in_db
|
||||||
|
|
||||||
|
|
||||||
def test_db_is_updated_when_attribute_is_modified(session, db_manager):
|
def test_db_is_updated_when_attribute_is_modified(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.value = "other_value"
|
dummy.value = "other_value"
|
||||||
|
|
||||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42}
|
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42}
|
||||||
|
|
||||||
|
|
||||||
def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
|
def test_i_do_not_save_in_db_when_value_is_the_same(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.value = "other_value"
|
dummy.value = "other_value"
|
||||||
in_db_1 = db_manager.load("DummyObject")
|
in_db_1 = db_manager.load("DummyObject")
|
||||||
|
|
||||||
@@ -186,16 +192,16 @@ def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
|
|||||||
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_update(session, db_manager):
|
def test_i_can_update(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
clone = dummy.copy()
|
clone = dummy.copy()
|
||||||
|
|
||||||
clone.number = 34
|
clone.number = 34
|
||||||
@@ -207,54 +213,52 @@ def test_i_can_update(session, db_manager):
|
|||||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
||||||
|
|
||||||
|
|
||||||
def test_forbidden_attributes_are_not_the_copy(session, db_manager):
|
def test_forbidden_attributes_are_not_the_copy(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.value: str = "hello"
|
self.value: str = "hello"
|
||||||
self.number: int = 42
|
self.number: int = 42
|
||||||
self.none_value: None = None
|
self.none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
clone = dummy.copy()
|
clone = dummy.copy()
|
||||||
|
|
||||||
for k in DbObject._forbidden_attrs:
|
for k in DbObject._forbidden_attrs:
|
||||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||||
|
|
||||||
|
|
||||||
def test_forbidden_attributes_are_not_the_copy_for_dataclass(session, db_manager):
|
def test_forbidden_attributes_are_not_the_copy_for_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
none_value: None = None
|
none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
clone = dummy.copy()
|
clone = dummy.copy()
|
||||||
|
|
||||||
for k in DbObject._forbidden_attrs:
|
for k in DbObject._forbidden_attrs:
|
||||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||||
|
|
||||||
|
|
||||||
def test_i_cannot_update_a_forbidden_attribute(session, db_manager):
|
def test_i_cannot_update_a_forbidden_attribute(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
none_value: None = None
|
none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
dummy.update(_session="other_value")
|
dummy.update(_owner="other_value")
|
||||||
|
|
||||||
assert dummy._session == session
|
assert dummy._owner is parent
|
||||||
|
|||||||
399
tests/core/test_instances.py
Normal file
399
tests/core/test_instances.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from myfasthtml.core.instances import (
|
||||||
|
BaseInstance,
|
||||||
|
SingleInstance,
|
||||||
|
MultipleInstance,
|
||||||
|
InstancesManager,
|
||||||
|
DuplicateInstanceError,
|
||||||
|
special_session,
|
||||||
|
Ids,
|
||||||
|
RootInstance
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_instances():
|
||||||
|
"""Reset instances before each test to ensure isolation."""
|
||||||
|
InstancesManager.instances.clear()
|
||||||
|
yield
|
||||||
|
InstancesManager.instances.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session():
|
||||||
|
"""Create a test session."""
|
||||||
|
return {"user_info": {"id": "test-user-123"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def another_session():
|
||||||
|
"""Create another test session."""
|
||||||
|
return {"user_info": {"id": "test-user-456"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def root_instance(session):
|
||||||
|
"""Create a root instance for testing."""
|
||||||
|
return SingleInstance(parent=None, session=session, _id="test-root")
|
||||||
|
|
||||||
|
|
||||||
|
# Example subclasses for testing
|
||||||
|
class SubSingleInstance(SingleInstance):
|
||||||
|
"""Example subclass of SingleInstance with simplified signature."""
|
||||||
|
|
||||||
|
def __init__(self, parent):
|
||||||
|
super().__init__(parent=parent)
|
||||||
|
|
||||||
|
|
||||||
|
class SubMultipleInstance(MultipleInstance):
|
||||||
|
"""Example subclass of MultipleInstance with custom parameter."""
|
||||||
|
|
||||||
|
def __init__(self, parent, _id=None, custom_param=None):
|
||||||
|
super().__init__(parent=parent, _id=_id)
|
||||||
|
self.custom_param = custom_param
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_a_base_instance_with_positional_args(self, session, root_instance):
|
||||||
|
"""Test that a BaseInstance can be created with positional arguments."""
|
||||||
|
instance = BaseInstance(root_instance, session, "test_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "test_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_create_a_base_instance_with_kwargs(self, session, root_instance):
|
||||||
|
"""Test that a BaseInstance can be created with keyword arguments."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session, _id="test_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "test_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_create_a_base_instance_with_mixed_args(self, session, root_instance):
|
||||||
|
"""Test that a BaseInstance can be created with mixed positional and keyword arguments."""
|
||||||
|
instance = BaseInstance(root_instance, session=session, _id="test_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "test_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_retrieve_the_same_instance_when_using_same_session_and_id(self, session, root_instance):
|
||||||
|
"""Test that creating an instance with same session and id returns the existing instance."""
|
||||||
|
instance1 = BaseInstance(root_instance, session, "same_id")
|
||||||
|
instance2 = BaseInstance(root_instance, session, "same_id")
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_can_control_instances_registration(self, session, root_instance):
|
||||||
|
"""Test that auto_register=False prevents automatic registration."""
|
||||||
|
BaseInstance(parent=root_instance, session=session, _id="test_id", auto_register=False)
|
||||||
|
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
key = (session_id, "test_id")
|
||||||
|
|
||||||
|
assert key not in InstancesManager.instances
|
||||||
|
|
||||||
|
def test_i_can_have_different_instances_for_different_sessions(self, session, another_session, root_instance):
|
||||||
|
"""Test that different sessions can have instances with the same id."""
|
||||||
|
root_instance2 = SingleInstance(parent=None, session=another_session, _id="test-root")
|
||||||
|
|
||||||
|
instance1 = BaseInstance(root_instance, session, "same_id")
|
||||||
|
instance2 = BaseInstance(root_instance2, another_session, "same_id")
|
||||||
|
|
||||||
|
assert instance1 is not instance2
|
||||||
|
assert instance1.get_session() == session
|
||||||
|
assert instance2.get_session() == another_session
|
||||||
|
|
||||||
|
def test_i_can_create_instance_with_parent_only(self, session, root_instance):
|
||||||
|
"""Test that session can be extracted from parent when not provided."""
|
||||||
|
instance = BaseInstance(parent=root_instance, _id="test_id")
|
||||||
|
|
||||||
|
assert instance.get_session() == root_instance.get_session()
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_cannot_create_instance_without_parent_or_session(self):
|
||||||
|
"""Test that creating an instance without parent or session raises TypeError."""
|
||||||
|
with pytest.raises(TypeError, match="Either session or parent must be provided"):
|
||||||
|
BaseInstance(None, _id="test_id")
|
||||||
|
|
||||||
|
def test_i_can_get_auto_generated_id(self, session, root_instance):
|
||||||
|
"""Test that if _id is not provided, an ID is auto-generated via compute_id()."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session)
|
||||||
|
|
||||||
|
assert instance.get_id() is not None
|
||||||
|
assert instance.get_id().startswith("mf-base_instance-")
|
||||||
|
|
||||||
|
def test_i_can_get_prefix_from_class_name(self, session):
|
||||||
|
"""Test that get_prefix() returns the correct snake_case prefix."""
|
||||||
|
prefix = BaseInstance(None, session).get_prefix()
|
||||||
|
|
||||||
|
assert prefix == "mf-base_instance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_a_single_instance(self, session, root_instance):
|
||||||
|
"""Test that a SingleInstance can be created."""
|
||||||
|
instance = SingleInstance(parent=root_instance, session=session)
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "mf-single_instance"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_create_single_instance_with_positional_args(self, session, root_instance):
|
||||||
|
"""Test that a SingleInstance can be created with positional arguments."""
|
||||||
|
instance = SingleInstance(root_instance, session, "custom_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "custom_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_the_same_instance_is_returned(self, session):
|
||||||
|
"""Test that single instance is cached and returned on subsequent calls."""
|
||||||
|
instance1 = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||||
|
instance2 = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_cannot_create_duplicate_single_instance(self, session):
|
||||||
|
"""Test that creating a duplicate SingleInstance raises DuplicateInstanceError."""
|
||||||
|
instance = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||||
|
|
||||||
|
with pytest.raises(DuplicateInstanceError):
|
||||||
|
InstancesManager.register(session, instance)
|
||||||
|
|
||||||
|
def test_i_can_retrieve_existing_single_instance(self, session):
|
||||||
|
"""Test that attempting to create an existing SingleInstance returns the same instance."""
|
||||||
|
instance1 = SingleInstance(parent=None, session=session, _id="same_id")
|
||||||
|
instance2 = SingleInstance(parent=None, session=session, _id="same_id", auto_register=False)
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_can_get_auto_computed_id_for_single_instance(self, session):
|
||||||
|
"""Test that the default ID equals prefix for SingleInstance."""
|
||||||
|
instance = SingleInstance(parent=None, session=session)
|
||||||
|
|
||||||
|
assert instance.get_id() == "mf-single_instance"
|
||||||
|
assert instance.get_prefix() == "mf-single_instance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleInstanceSubclass:
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_of_single_instance(self, root_instance):
|
||||||
|
"""Test that a subclass of SingleInstance works correctly."""
|
||||||
|
instance = SubSingleInstance(root_instance)
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert isinstance(instance, SingleInstance)
|
||||||
|
assert isinstance(instance, SubSingleInstance)
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
|
||||||
|
"""Test that subclass with simplified signature works correctly."""
|
||||||
|
instance = SubSingleInstance(root_instance)
|
||||||
|
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
assert instance.get_session() == root_instance.get_session()
|
||||||
|
assert instance.get_id() == "mf-sub_single_instance"
|
||||||
|
assert instance.get_prefix() == "mf-sub_single_instance"
|
||||||
|
|
||||||
|
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
|
||||||
|
"""Test that cache works for subclasses."""
|
||||||
|
instance1 = SubSingleInstance(root_instance)
|
||||||
|
instance2 = SubSingleInstance(root_instance)
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_multiple_instances_with_same_prefix(self, session, root_instance):
|
||||||
|
"""Test that multiple MultipleInstance objects can be created with the same prefix."""
|
||||||
|
instance1 = MultipleInstance(parent=root_instance, session=session)
|
||||||
|
instance2 = MultipleInstance(parent=root_instance, session=session)
|
||||||
|
|
||||||
|
assert instance1 is not instance2
|
||||||
|
assert instance1.get_id() != instance2.get_id()
|
||||||
|
assert instance1.get_id().startswith("mf-multiple_instance-")
|
||||||
|
assert instance2.get_id().startswith("mf-multiple_instance-")
|
||||||
|
|
||||||
|
def test_i_can_have_auto_generated_unique_ids(self, session, root_instance):
|
||||||
|
"""Test that each MultipleInstance receives a unique auto-generated ID."""
|
||||||
|
instances = [MultipleInstance(parent=root_instance, session=session) for _ in range(5)]
|
||||||
|
ids = [inst.get_id() for inst in instances]
|
||||||
|
|
||||||
|
# All IDs should be unique
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
|
||||||
|
# All IDs should start with the prefix
|
||||||
|
assert all(id.startswith("mf-multiple_instance-") for id in ids)
|
||||||
|
|
||||||
|
def test_i_can_provide_custom_id_to_multiple_instance(self, session, root_instance):
|
||||||
|
"""Test that a custom _id can be provided to MultipleInstance."""
|
||||||
|
custom_id = "custom-instance-id"
|
||||||
|
instance = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||||
|
|
||||||
|
assert instance.get_id() == custom_id
|
||||||
|
|
||||||
|
def test_i_can_retrieve_multiple_instance_by_custom_id(self, session, root_instance):
|
||||||
|
"""Test that a MultipleInstance with custom _id can be retrieved from cache."""
|
||||||
|
custom_id = "custom-instance-id"
|
||||||
|
instance1 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||||
|
instance2 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_key_prefixed_by_underscore_uses_the_parent_id_as_prefix(self, root_instance):
|
||||||
|
"""Test that key prefixed by underscore uses the parent id as prefix."""
|
||||||
|
instance = MultipleInstance(parent=root_instance, _id="-test_id")
|
||||||
|
|
||||||
|
assert instance.get_id() == f"{root_instance.get_id()}-test_id"
|
||||||
|
|
||||||
|
def test_no_parent_id_as_prefix_if_parent_is_none(self, session, root_instance):
|
||||||
|
"""Test that key prefixed by underscore does not use the parent id as prefix if parent is None."""
|
||||||
|
instance = MultipleInstance(parent=None, session=session, _id="-test_id")
|
||||||
|
|
||||||
|
assert instance.get_id() == "-test_id"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleInstanceSubclass:
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_of_multiple_instance(self, root_instance):
|
||||||
|
"""Test that a subclass of MultipleInstance works correctly."""
|
||||||
|
instance = SubMultipleInstance(root_instance, custom_param="test")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert isinstance(instance, MultipleInstance)
|
||||||
|
assert isinstance(instance, SubMultipleInstance)
|
||||||
|
assert instance.custom_param == "test"
|
||||||
|
|
||||||
|
def test_i_can_create_multiple_subclass_instances_with_auto_generated_ids(self, root_instance):
|
||||||
|
"""Test that multiple instances of subclass can be created with unique IDs."""
|
||||||
|
instance1 = SubMultipleInstance(root_instance, custom_param="first")
|
||||||
|
instance2 = SubMultipleInstance(root_instance, custom_param="second")
|
||||||
|
|
||||||
|
assert instance1 is not instance2
|
||||||
|
assert instance1.get_id() != instance2.get_id()
|
||||||
|
assert instance1.get_id().startswith("mf-sub_multiple_instance-")
|
||||||
|
assert instance2.get_id().startswith("mf-sub_multiple_instance-")
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
|
||||||
|
"""Test that subclass with custom parameters works correctly."""
|
||||||
|
instance = SubMultipleInstance(root_instance, custom_param="value")
|
||||||
|
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
assert instance.get_session() == root_instance.get_session()
|
||||||
|
assert instance.custom_param == "value"
|
||||||
|
|
||||||
|
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
|
||||||
|
"""Test that cache works for subclasses."""
|
||||||
|
instance1 = SubMultipleInstance(root_instance, custom_param="first")
|
||||||
|
instance2 = SubMultipleInstance(root_instance, custom_param="second", _id=instance1.get_id())
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_cannot_retrieve_subclass_instance_when_type_differs(self, root_instance):
|
||||||
|
"""Test that cache works for subclasses with custom _id."""
|
||||||
|
# Need to pass _id explicitly to enable caching
|
||||||
|
instance1 = SubMultipleInstance(root_instance)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MultipleInstance(parent=root_instance, _id=instance1.get_id())
|
||||||
|
|
||||||
|
def test_i_can_get_correct_prefix_for_multiple_subclass(self, root_instance):
|
||||||
|
"""Test that subclass has correct auto-generated prefix."""
|
||||||
|
prefix = SubMultipleInstance(root_instance).get_prefix()
|
||||||
|
|
||||||
|
assert prefix == "mf-sub_multiple_instance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstancesManager:
|
||||||
|
|
||||||
|
def test_i_can_register_an_instance_manually(self, session, root_instance):
|
||||||
|
"""Test that an instance can be manually registered."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session, _id="manual_id", auto_register=False)
|
||||||
|
|
||||||
|
InstancesManager.register(session, instance)
|
||||||
|
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
key = (session_id, "manual_id")
|
||||||
|
|
||||||
|
assert key in InstancesManager.instances
|
||||||
|
assert InstancesManager.instances[key] is instance
|
||||||
|
|
||||||
|
def test_i_can_get_existing_instance_by_id(self, session, root_instance):
|
||||||
|
"""Test that an existing instance can be retrieved by ID."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session, _id="get_id")
|
||||||
|
|
||||||
|
retrieved = InstancesManager.get(session, "get_id")
|
||||||
|
|
||||||
|
assert retrieved is instance
|
||||||
|
|
||||||
|
def test_i_cannot_get_nonexistent_instance_without_type(self, session):
|
||||||
|
"""Test that getting a non-existent instance without type raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
InstancesManager.get(session, "nonexistent_id")
|
||||||
|
|
||||||
|
def test_i_can_get_session_id_from_valid_session(self, session):
|
||||||
|
"""Test that session ID is correctly extracted from a valid session."""
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
|
||||||
|
assert session_id == "test-user-123"
|
||||||
|
|
||||||
|
def test_i_can_handle_none_session(self):
|
||||||
|
"""Test that None session returns a special identifier."""
|
||||||
|
session_id = InstancesManager.get_session_id(None)
|
||||||
|
|
||||||
|
assert session_id == "** NOT LOGGED IN **"
|
||||||
|
|
||||||
|
def test_i_can_handle_invalid_session(self):
|
||||||
|
"""Test that invalid sessions return appropriate identifiers."""
|
||||||
|
# Session is None
|
||||||
|
session_id = InstancesManager.get_session_id(None)
|
||||||
|
assert session_id == "** NOT LOGGED IN **"
|
||||||
|
|
||||||
|
# Session without user_info
|
||||||
|
session_no_user = {}
|
||||||
|
session_id = InstancesManager.get_session_id(session_no_user)
|
||||||
|
assert session_id == "** UNKNOWN USER **"
|
||||||
|
|
||||||
|
# Session with user_info but no id
|
||||||
|
session_no_id = {"user_info": {}}
|
||||||
|
session_id = InstancesManager.get_session_id(session_no_id)
|
||||||
|
assert session_id == "** INVALID SESSION **"
|
||||||
|
|
||||||
|
def test_i_can_reset_all_instances(self, session, root_instance):
|
||||||
|
"""Test that reset() clears all instances."""
|
||||||
|
BaseInstance(parent=root_instance, session=session, _id="id1")
|
||||||
|
BaseInstance(parent=root_instance, session=session, _id="id2")
|
||||||
|
|
||||||
|
assert len(InstancesManager.instances) > 0
|
||||||
|
|
||||||
|
InstancesManager.reset()
|
||||||
|
|
||||||
|
assert len(InstancesManager.instances) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRootInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_root_instance_with_positional_args(self):
|
||||||
|
"""Test that RootInstance can be created with positional arguments."""
|
||||||
|
root = SingleInstance(None, special_session, Ids.Root)
|
||||||
|
|
||||||
|
assert root is not None
|
||||||
|
assert root.get_id() == Ids.Root
|
||||||
|
assert root.get_session() == special_session
|
||||||
|
assert root.get_parent() is None
|
||||||
|
|
||||||
|
def test_i_can_access_root_instance(self):
|
||||||
|
"""Test that RootInstance is created and accessible."""
|
||||||
|
assert RootInstance is not None
|
||||||
|
assert RootInstance.get_id() == Ids.Root
|
||||||
|
assert RootInstance.get_session() == special_session
|
||||||
@@ -311,7 +311,7 @@ class TestFromParentChildList:
|
|||||||
nodes, edges = from_parent_child_list(items)
|
nodes, edges = from_parent_child_list(items)
|
||||||
|
|
||||||
assert len(nodes) == 1
|
assert len(nodes) == 1
|
||||||
assert nodes[0] == {"id": "root", "label": "Root"}
|
assert nodes[0] == {'color': '#ff9999', 'id': 'root', 'label': 'Root'}
|
||||||
assert len(edges) == 0
|
assert len(edges) == 0
|
||||||
|
|
||||||
def test_i_can_convert_simple_parent_child_relationship(self):
|
def test_i_can_convert_simple_parent_child_relationship(self):
|
||||||
@@ -323,7 +323,7 @@ class TestFromParentChildList:
|
|||||||
nodes, edges = from_parent_child_list(items)
|
nodes, edges = from_parent_child_list(items)
|
||||||
|
|
||||||
assert len(nodes) == 2
|
assert len(nodes) == 2
|
||||||
assert {"id": "root", "label": "Root"} in nodes
|
assert {'color': '#ff9999', 'id': 'root', 'label': 'Root'} in nodes
|
||||||
assert {"id": "child", "label": "Child"} in nodes
|
assert {"id": "child", "label": "Child"} in nodes
|
||||||
|
|
||||||
assert len(edges) == 1
|
assert len(edges) == 1
|
||||||
@@ -405,7 +405,7 @@ class TestFromParentChildList:
|
|||||||
|
|
||||||
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
||||||
assert "color" in ghost_node
|
assert "color" in ghost_node
|
||||||
assert ghost_node["color"] == "#ff9999"
|
assert ghost_node["color"] == "#cccccc"
|
||||||
|
|
||||||
def test_i_can_use_custom_ghost_color(self):
|
def test_i_can_use_custom_ghost_color(self):
|
||||||
"""Test that custom ghost_color parameter is applied."""
|
"""Test that custom ghost_color parameter is applied."""
|
||||||
@@ -513,3 +513,136 @@ class TestFromParentChildList:
|
|||||||
|
|
||||||
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
|
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
|
||||||
assert ghost_node["label"] == "ghost_parent"
|
assert ghost_node["label"] == "ghost_parent"
|
||||||
|
|
||||||
|
def test_i_can_apply_root_color_to_single_root(self):
|
||||||
|
"""Test that a single root node receives the root_color."""
|
||||||
|
items = [{"id": "root", "label": "Root"}]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
|
||||||
|
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["color"] == "#ff0000"
|
||||||
|
|
||||||
|
def test_i_can_apply_root_color_to_multiple_roots(self):
|
||||||
|
"""Test root_color is assigned to all nodes without parent."""
|
||||||
|
items = [
|
||||||
|
{"id": "root1", "label": "Root 1"},
|
||||||
|
{"id": "root2", "label": "Root 2"},
|
||||||
|
{"id": "child", "parent": "root1", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#aa0000")
|
||||||
|
|
||||||
|
root_nodes = [n for n in nodes if n["id"] in ("root1", "root2")]
|
||||||
|
assert all(n.get("color") == "#aa0000" for n in root_nodes)
|
||||||
|
|
||||||
|
# child must NOT have root_color
|
||||||
|
child_node = next(n for n in nodes if n["id"] == "child")
|
||||||
|
assert "color" not in child_node
|
||||||
|
|
||||||
|
def test_i_can_handle_root_with_parent_none(self):
|
||||||
|
"""Test that root_color is applied when parent=None."""
|
||||||
|
items = [
|
||||||
|
{"id": "r1", "parent": None, "label": "R1"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#112233")
|
||||||
|
|
||||||
|
assert nodes[0]["color"] == "#112233"
|
||||||
|
|
||||||
|
def test_i_can_handle_root_with_parent_empty_string(self):
|
||||||
|
"""Test that root_color is applied when parent=''."""
|
||||||
|
items = [
|
||||||
|
{"id": "r1", "parent": "", "label": "R1"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#334455")
|
||||||
|
|
||||||
|
assert nodes[0]["color"] == "#334455"
|
||||||
|
|
||||||
|
def test_i_do_not_apply_root_color_to_non_roots(self):
|
||||||
|
"""Test that only real roots receive root_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "root", "label": "Root"},
|
||||||
|
{"id": "child", "parent": "root", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
|
||||||
|
|
||||||
|
# Only one root → only this one has the color
|
||||||
|
root_node = next(n for n in nodes if n["id"] == "root")
|
||||||
|
assert root_node["color"] == "#ff0000"
|
||||||
|
|
||||||
|
child_node = next(n for n in nodes if n["id"] == "child")
|
||||||
|
assert "color" not in child_node
|
||||||
|
|
||||||
|
def test_i_do_not_override_ghost_color_with_root_color(self):
|
||||||
|
"""Ghost nodes must keep ghost_color, not root_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "child", "parent": "ghost_parent", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
items,
|
||||||
|
root_color="#ff0000",
|
||||||
|
ghost_color="#00ff00"
|
||||||
|
)
|
||||||
|
|
||||||
|
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
|
||||||
|
assert ghost_node["color"] == "#00ff00"
|
||||||
|
|
||||||
|
# child is not root → no color
|
||||||
|
child_node = next(n for n in nodes if n["id"] == "child")
|
||||||
|
assert "color" not in child_node
|
||||||
|
|
||||||
|
def test_i_can_use_custom_root_color(self):
|
||||||
|
"""Test that a custom root_color is applied instead of default."""
|
||||||
|
items = [{"id": "root", "label": "Root"}]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#123456")
|
||||||
|
|
||||||
|
assert nodes[0]["color"] == "#123456"
|
||||||
|
|
||||||
|
def test_i_can_mix_root_nodes_and_ghost_nodes(self):
|
||||||
|
"""Ensure root_color applies only to roots and ghost nodes keep ghost_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "root", "label": "Root"},
|
||||||
|
{"id": "child", "parent": "ghost_parent", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
items,
|
||||||
|
root_color="#ff0000",
|
||||||
|
ghost_color="#00ff00"
|
||||||
|
)
|
||||||
|
|
||||||
|
root_node = next(n for n in nodes if n["id"] == "root")
|
||||||
|
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
|
||||||
|
|
||||||
|
assert root_node["color"] == "#ff0000"
|
||||||
|
assert ghost_node["color"] == "#00ff00"
|
||||||
|
|
||||||
|
def test_i_do_not_mark_node_as_root_if_parent_field_exists(self):
|
||||||
|
"""Node with parent key but non-empty value should NOT get root_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "root", "label": "Root"},
|
||||||
|
{"id": "child", "parent": "root", "label": "Child"},
|
||||||
|
{"id": "other", "parent": "unknown_parent", "label": "Other"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
items,
|
||||||
|
root_color="#ff0000",
|
||||||
|
ghost_color="#00ff00"
|
||||||
|
)
|
||||||
|
|
||||||
|
# "root" is the only real root
|
||||||
|
root_node = next(n for n in nodes if n["id"] == "root")
|
||||||
|
assert root_node["color"] == "#ff0000"
|
||||||
|
|
||||||
|
# "other" is NOT root, even though its parent is missing
|
||||||
|
other_node = next(n for n in nodes if n["id"] == "other")
|
||||||
|
assert "color" not in other_node
|
||||||
|
|
||||||
|
# ghost parent must have ghost_color
|
||||||
|
ghost_node = next(n for n in nodes if n["id"] == "unknown_parent")
|
||||||
|
assert ghost_node["color"] == "#00ff00"
|
||||||
|
|
||||||
|
def test_i_do_no_add_root_color_when_its_none(self):
|
||||||
|
"""Test that a single root node receives the root_color."""
|
||||||
|
items = [{"id": "root", "label": "Root"}]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color=None)
|
||||||
|
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert "color" not in nodes[0]
|
||||||
|
|||||||
450
tests/html/keyboard_support.js
Normal file
450
tests/html/keyboard_support.js
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* Create keyboard bindings
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
/**
|
||||||
|
* Global registry to store keyboard shortcuts for multiple elements
|
||||||
|
*/
|
||||||
|
const KeyboardRegistry = {
|
||||||
|
elements: new Map(), // elementId -> { tree, element }
|
||||||
|
listenerAttached: false,
|
||||||
|
currentKeys: new Set(),
|
||||||
|
snapshotHistory: [],
|
||||||
|
pendingTimeout: null,
|
||||||
|
pendingMatches: [], // Array of matches waiting for timeout
|
||||||
|
sequenceTimeout: 500 // 500ms timeout for sequences
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize key names to lowercase for case-insensitive comparison
|
||||||
|
* @param {string} key - The key to normalize
|
||||||
|
* @returns {string} - Normalized key name
|
||||||
|
*/
|
||||||
|
function normalizeKey(key) {
|
||||||
|
const keyMap = {
|
||||||
|
'control': 'ctrl',
|
||||||
|
'escape': 'esc',
|
||||||
|
'delete': 'del'
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = key.toLowerCase();
|
||||||
|
return keyMap[normalized] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique string key from a Set of keys for Map indexing
|
||||||
|
* @param {Set} keySet - Set of normalized keys
|
||||||
|
* @returns {string} - Sorted string representation
|
||||||
|
*/
|
||||||
|
function setToKey(keySet) {
|
||||||
|
return Array.from(keySet).sort().join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single element (can be a single key or a simultaneous combination)
|
||||||
|
* @param {string} element - The element string (e.g., "a" or "Ctrl+C")
|
||||||
|
* @returns {Set} - Set of normalized keys
|
||||||
|
*/
|
||||||
|
function parseElement(element) {
|
||||||
|
if (element.includes('+')) {
|
||||||
|
// Simultaneous combination
|
||||||
|
return new Set(element.split('+').map(k => normalizeKey(k.trim())));
|
||||||
|
}
|
||||||
|
// Single key
|
||||||
|
return new Set([normalizeKey(element.trim())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a combination string into sequence elements
|
||||||
|
* @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C")
|
||||||
|
* @returns {Array} - Array of Sets representing the sequence
|
||||||
|
*/
|
||||||
|
function parseCombination(combination) {
|
||||||
|
// Check if it's a sequence (contains space)
|
||||||
|
if (combination.includes(' ')) {
|
||||||
|
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single element (can be a key or simultaneous combination)
|
||||||
|
return [parseElement(combination)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tree node
|
||||||
|
* @returns {Object} - New tree node
|
||||||
|
*/
|
||||||
|
function createTreeNode() {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
combinationStr: null,
|
||||||
|
children: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a tree from combinations
|
||||||
|
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||||
|
* @returns {Object} - Root tree node
|
||||||
|
*/
|
||||||
|
function buildTree(combinations) {
|
||||||
|
const root = createTreeNode();
|
||||||
|
|
||||||
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
|
const sequence = parseCombination(combinationStr);
|
||||||
|
console.log("Parsing combination", combinationStr, "=>", sequence);
|
||||||
|
let currentNode = root;
|
||||||
|
|
||||||
|
for (const keySet of sequence) {
|
||||||
|
const key = setToKey(keySet);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
currentNode.children.set(key, createTreeNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as end of sequence and store config
|
||||||
|
currentNode.config = config;
|
||||||
|
currentNode.combinationStr = combinationStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverse the tree with the current snapshot history
|
||||||
|
* @param {Object} treeRoot - Root of the tree
|
||||||
|
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
|
||||||
|
* @returns {Object|null} - Current node or null if no match
|
||||||
|
*/
|
||||||
|
function traverseTree(treeRoot, snapshotHistory) {
|
||||||
|
let currentNode = treeRoot;
|
||||||
|
|
||||||
|
for (const snapshot of snapshotHistory) {
|
||||||
|
const key = setToKey(snapshot);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're inside an input element where typing should work normally
|
||||||
|
* @returns {boolean} - True if inside an input-like element
|
||||||
|
*/
|
||||||
|
function isInInputContext() {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement) return false;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Check for input/textarea
|
||||||
|
if (tagName === 'input' || tagName === 'textarea') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contenteditable
|
||||||
|
if (activeElement.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an action for a matched combination
|
||||||
|
* @param {string} elementId - ID of the element
|
||||||
|
* @param {Object} config - HTMX configuration object
|
||||||
|
* @param {string} combinationStr - The matched combination string
|
||||||
|
* @param {boolean} isInside - Whether the focus is inside the element
|
||||||
|
*/
|
||||||
|
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasFocus = document.activeElement === element;
|
||||||
|
|
||||||
|
// Extract HTTP method and URL from hx-* attributes
|
||||||
|
let method = 'POST'; // default
|
||||||
|
let url = null;
|
||||||
|
|
||||||
|
const methodMap = {
|
||||||
|
'hx-post': 'POST',
|
||||||
|
'hx-get': 'GET',
|
||||||
|
'hx-put': 'PUT',
|
||||||
|
'hx-delete': 'DELETE',
|
||||||
|
'hx-patch': 'PATCH'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||||
|
if (config[attr]) {
|
||||||
|
method = httpMethod;
|
||||||
|
url = config[attr];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.error('No HTTP method attribute found in config:', config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build htmx.ajax options
|
||||||
|
const htmxOptions = {};
|
||||||
|
|
||||||
|
// Map hx-target to target
|
||||||
|
if (config['hx-target']) {
|
||||||
|
htmxOptions.target = config['hx-target'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-swap to swap
|
||||||
|
if (config['hx-swap']) {
|
||||||
|
htmxOptions.swap = config['hx-swap'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||||
|
const values = {};
|
||||||
|
if (config['hx-vals']) {
|
||||||
|
Object.assign(values, config['hx-vals']);
|
||||||
|
}
|
||||||
|
values.combination = combinationStr;
|
||||||
|
values.has_focus = hasFocus;
|
||||||
|
values.is_inside = isInside;
|
||||||
|
htmxOptions.values = values;
|
||||||
|
|
||||||
|
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||||
|
// Remove 'hx-' prefix and convert to camelCase
|
||||||
|
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
htmxOptions[optionKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make AJAX call with htmx
|
||||||
|
htmx.ajax(method, url, htmxOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard events and trigger matching combinations
|
||||||
|
* @param {KeyboardEvent} event - The keyboard event
|
||||||
|
*/
|
||||||
|
function handleKeyboardEvent(event) {
|
||||||
|
const key = normalizeKey(event.key);
|
||||||
|
|
||||||
|
// Add key to current pressed keys
|
||||||
|
KeyboardRegistry.currentKeys.add(key);
|
||||||
|
console.debug("Received key", key);
|
||||||
|
|
||||||
|
// Create a snapshot of current keyboard state
|
||||||
|
const snapshot = new Set(KeyboardRegistry.currentKeys);
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
KeyboardRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (KeyboardRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for all elements
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
// Check all registered elements for matching combinations
|
||||||
|
for (const [elementId, data] of KeyboardRegistry.elements) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
// Check if focus is inside this element (element itself or any child)
|
||||||
|
const isInside = element.contains(document.activeElement);
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree, continue to next element
|
||||||
|
console.debug("No match in tree for event", key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has a URL)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
// Track if ANY element has longer sequences possible
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: isInside
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO element has longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but AT LEAST ONE element has longer sequences possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
KeyboardRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of KeyboardRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
}, KeyboardRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
// This handles invalid sequences like "A C" when only "A B" exists
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (KeyboardRegistry.snapshotHistory.length > 10) {
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyup event to remove keys from current pressed keys
|
||||||
|
* @param {KeyboardEvent} event - The keyboard event
|
||||||
|
*/
|
||||||
|
function handleKeyUp(event) {
|
||||||
|
const key = normalizeKey(event.key);
|
||||||
|
KeyboardRegistry.currentKeys.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the global keyboard event listener if not already attached
|
||||||
|
*/
|
||||||
|
function attachGlobalListener() {
|
||||||
|
if (!KeyboardRegistry.listenerAttached) {
|
||||||
|
document.addEventListener('keydown', handleKeyboardEvent);
|
||||||
|
document.addEventListener('keyup', handleKeyUp);
|
||||||
|
KeyboardRegistry.listenerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the global keyboard event listener
|
||||||
|
*/
|
||||||
|
function detachGlobalListener() {
|
||||||
|
if (KeyboardRegistry.listenerAttached) {
|
||||||
|
document.removeEventListener('keydown', handleKeyboardEvent);
|
||||||
|
document.removeEventListener('keyup', handleKeyUp);
|
||||||
|
KeyboardRegistry.listenerAttached = false;
|
||||||
|
|
||||||
|
// Clean up all state
|
||||||
|
KeyboardRegistry.currentKeys.clear();
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
if (KeyboardRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
}
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add keyboard support to an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||||
|
*/
|
||||||
|
window.add_keyboard_support = function (elementId, combinationsJson) {
|
||||||
|
// Parse the combinations JSON
|
||||||
|
const combinations = JSON.parse(combinationsJson);
|
||||||
|
|
||||||
|
// Build tree for this element
|
||||||
|
const tree = buildTree(combinations);
|
||||||
|
|
||||||
|
// Get element reference
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.error("Element with ID", elementId, "not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to registry
|
||||||
|
KeyboardRegistry.elements.set(elementId, {
|
||||||
|
tree: tree,
|
||||||
|
element: element
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach global listener if not already attached
|
||||||
|
attachGlobalListener();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove keyboard support from an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
*/
|
||||||
|
window.remove_keyboard_support = function (elementId) {
|
||||||
|
// Remove from registry
|
||||||
|
if (!KeyboardRegistry.elements.has(elementId)) {
|
||||||
|
console.warn("Element with ID", elementId, "not found in keyboard registry!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyboardRegistry.elements.delete(elementId);
|
||||||
|
|
||||||
|
// If no more elements, detach global listeners
|
||||||
|
if (KeyboardRegistry.elements.size === 0) {
|
||||||
|
detachGlobalListener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
634
tests/html/mouse_support.js
Normal file
634
tests/html/mouse_support.js
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
/**
|
||||||
|
* Create mouse bindings
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
/**
|
||||||
|
* Global registry to store mouse shortcuts for multiple elements
|
||||||
|
*/
|
||||||
|
const MouseRegistry = {
|
||||||
|
elements: new Map(), // elementId -> { tree, element }
|
||||||
|
listenerAttached: false,
|
||||||
|
snapshotHistory: [],
|
||||||
|
pendingTimeout: null,
|
||||||
|
pendingMatches: [], // Array of matches waiting for timeout
|
||||||
|
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||||
|
clickHandler: null,
|
||||||
|
contextmenuHandler: null
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize mouse action names
|
||||||
|
* @param {string} action - The action to normalize
|
||||||
|
* @returns {string} - Normalized action name
|
||||||
|
*/
|
||||||
|
function normalizeAction(action) {
|
||||||
|
const normalized = action.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Handle aliases
|
||||||
|
const aliasMap = {
|
||||||
|
'rclick': 'right_click'
|
||||||
|
};
|
||||||
|
|
||||||
|
return aliasMap[normalized] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique string key from a Set of actions for Map indexing
|
||||||
|
* @param {Set} actionSet - Set of normalized actions
|
||||||
|
* @returns {string} - Sorted string representation
|
||||||
|
*/
|
||||||
|
function setToKey(actionSet) {
|
||||||
|
return Array.from(actionSet).sort().join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single element (can be a simple click or click with modifiers)
|
||||||
|
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
|
||||||
|
* @returns {Set} - Set of normalized actions
|
||||||
|
*/
|
||||||
|
function parseElement(element) {
|
||||||
|
if (element.includes('+')) {
|
||||||
|
// Click with modifiers
|
||||||
|
return new Set(element.split('+').map(a => normalizeAction(a)));
|
||||||
|
}
|
||||||
|
// Simple click
|
||||||
|
return new Set([normalizeAction(element)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a combination string into sequence elements
|
||||||
|
* @param {string} combination - The combination string (e.g., "click right_click")
|
||||||
|
* @returns {Array} - Array of Sets representing the sequence
|
||||||
|
*/
|
||||||
|
function parseCombination(combination) {
|
||||||
|
// Check if it's a sequence (contains space)
|
||||||
|
if (combination.includes(' ')) {
|
||||||
|
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single element (can be a click or click with modifiers)
|
||||||
|
return [parseElement(combination)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tree node
|
||||||
|
* @returns {Object} - New tree node
|
||||||
|
*/
|
||||||
|
function createTreeNode() {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
combinationStr: null,
|
||||||
|
children: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a tree from combinations
|
||||||
|
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||||
|
* @returns {Object} - Root tree node
|
||||||
|
*/
|
||||||
|
function buildTree(combinations) {
|
||||||
|
const root = createTreeNode();
|
||||||
|
|
||||||
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
|
const sequence = parseCombination(combinationStr);
|
||||||
|
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
|
||||||
|
let currentNode = root;
|
||||||
|
|
||||||
|
for (const actionSet of sequence) {
|
||||||
|
const key = setToKey(actionSet);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
currentNode.children.set(key, createTreeNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as end of sequence and store config
|
||||||
|
currentNode.config = config;
|
||||||
|
currentNode.combinationStr = combinationStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverse the tree with the current snapshot history
|
||||||
|
* @param {Object} treeRoot - Root of the tree
|
||||||
|
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
|
||||||
|
* @returns {Object|null} - Current node or null if no match
|
||||||
|
*/
|
||||||
|
function traverseTree(treeRoot, snapshotHistory) {
|
||||||
|
let currentNode = treeRoot;
|
||||||
|
|
||||||
|
for (const snapshot of snapshotHistory) {
|
||||||
|
const key = setToKey(snapshot);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're inside an input element where clicking should work normally
|
||||||
|
* @returns {boolean} - True if inside an input-like element
|
||||||
|
*/
|
||||||
|
function isInInputContext() {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement) return false;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Check for input/textarea
|
||||||
|
if (tagName === 'input' || tagName === 'textarea') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contenteditable
|
||||||
|
if (activeElement.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the element that was actually clicked (from registered elements)
|
||||||
|
* @param {Element} target - The clicked element
|
||||||
|
* @returns {string|null} - Element ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
function findRegisteredElement(target) {
|
||||||
|
// Check if target itself is registered
|
||||||
|
if (target.id && MouseRegistry.elements.has(target.id)) {
|
||||||
|
return target.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any parent is registered
|
||||||
|
let current = target.parentElement;
|
||||||
|
while (current) {
|
||||||
|
if (current.id && MouseRegistry.elements.has(current.id)) {
|
||||||
|
return current.id;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a snapshot from mouse event
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||||
|
* @returns {Set} - Set of actions representing this click
|
||||||
|
*/
|
||||||
|
function createSnapshot(event, baseAction) {
|
||||||
|
const actions = new Set([baseAction]);
|
||||||
|
|
||||||
|
// Add modifiers if present
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
actions.add('ctrl');
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
actions.add('shift');
|
||||||
|
}
|
||||||
|
if (event.altKey) {
|
||||||
|
actions.add('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an action for a matched combination
|
||||||
|
* @param {string} elementId - ID of the element
|
||||||
|
* @param {Object} config - HTMX configuration object
|
||||||
|
* @param {string} combinationStr - The matched combination string
|
||||||
|
* @param {boolean} isInside - Whether the click was inside the element
|
||||||
|
*/
|
||||||
|
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasFocus = document.activeElement === element;
|
||||||
|
|
||||||
|
// Extract HTTP method and URL from hx-* attributes
|
||||||
|
let method = 'POST'; // default
|
||||||
|
let url = null;
|
||||||
|
|
||||||
|
const methodMap = {
|
||||||
|
'hx-post': 'POST',
|
||||||
|
'hx-get': 'GET',
|
||||||
|
'hx-put': 'PUT',
|
||||||
|
'hx-delete': 'DELETE',
|
||||||
|
'hx-patch': 'PATCH'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||||
|
if (config[attr]) {
|
||||||
|
method = httpMethod;
|
||||||
|
url = config[attr];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.error('No HTTP method attribute found in config:', config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build htmx.ajax options
|
||||||
|
const htmxOptions = {};
|
||||||
|
|
||||||
|
// Map hx-target to target
|
||||||
|
if (config['hx-target']) {
|
||||||
|
htmxOptions.target = config['hx-target'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-swap to swap
|
||||||
|
if (config['hx-swap']) {
|
||||||
|
htmxOptions.swap = config['hx-swap'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||||
|
const values = {};
|
||||||
|
if (config['hx-vals']) {
|
||||||
|
Object.assign(values, config['hx-vals']);
|
||||||
|
}
|
||||||
|
values.combination = combinationStr;
|
||||||
|
values.has_focus = hasFocus;
|
||||||
|
values.is_inside = isInside;
|
||||||
|
htmxOptions.values = values;
|
||||||
|
|
||||||
|
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||||
|
// Remove 'hx-' prefix and convert to camelCase
|
||||||
|
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
htmxOptions[optionKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make AJAX call with htmx
|
||||||
|
htmx.ajax(method, url, htmxOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse events and trigger matching combinations
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||||
|
*/
|
||||||
|
function handleMouseEvent(event, baseAction) {
|
||||||
|
// Different behavior for click vs right_click
|
||||||
|
if (baseAction === 'click') {
|
||||||
|
// Click: trigger for ALL registered elements (useful for closing modals/popups)
|
||||||
|
handleGlobalClick(event);
|
||||||
|
} else if (baseAction === 'right_click') {
|
||||||
|
// Right-click: trigger ONLY if clicked on a registered element
|
||||||
|
handleElementRightClick(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle global click events (triggers for all registered elements)
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
*/
|
||||||
|
function handleGlobalClick(event) {
|
||||||
|
console.debug("Global click detected");
|
||||||
|
|
||||||
|
// Create a snapshot of current mouse action with modifiers
|
||||||
|
const snapshot = createSnapshot(event, 'click');
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
MouseRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for ALL registered elements
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
for (const [elementId, data] of MouseRegistry.elements) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
// Check if click was inside this element
|
||||||
|
const isInside = element.contains(event.target);
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has config)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: isInside
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but longer sequences are possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
MouseRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of MouseRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}, MouseRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle right-click events (triggers only for clicked element)
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
*/
|
||||||
|
function handleElementRightClick(event) {
|
||||||
|
// Find which registered element was clicked
|
||||||
|
const elementId = findRegisteredElement(event.target);
|
||||||
|
|
||||||
|
if (!elementId) {
|
||||||
|
// Right-click wasn't on a registered element - don't prevent default
|
||||||
|
// This allows browser context menu to appear
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug("Right-click on registered element", elementId);
|
||||||
|
|
||||||
|
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
|
||||||
|
const clickedInside = true;
|
||||||
|
|
||||||
|
// Create a snapshot of current mouse action with modifiers
|
||||||
|
const snapshot = createSnapshot(event, 'right_click');
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
MouseRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for this element
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
const data = MouseRegistry.elements.get(elementId);
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree
|
||||||
|
console.debug("No match in tree for right-click");
|
||||||
|
// Clear history for invalid sequences
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has config)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: true // Right-click only triggers when clicking on element
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but longer sequences are possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
MouseRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of MouseRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}, MouseRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the global mouse event listeners if not already attached
|
||||||
|
*/
|
||||||
|
function attachGlobalListener() {
|
||||||
|
if (!MouseRegistry.listenerAttached) {
|
||||||
|
// Store handler references for proper removal
|
||||||
|
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||||
|
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||||
|
|
||||||
|
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||||
|
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||||
|
MouseRegistry.listenerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the global mouse event listeners
|
||||||
|
*/
|
||||||
|
function detachGlobalListener() {
|
||||||
|
if (MouseRegistry.listenerAttached) {
|
||||||
|
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||||
|
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||||
|
MouseRegistry.listenerAttached = false;
|
||||||
|
|
||||||
|
// Clean up handler references
|
||||||
|
MouseRegistry.clickHandler = null;
|
||||||
|
MouseRegistry.contextmenuHandler = null;
|
||||||
|
|
||||||
|
// Clean up all state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add mouse support to an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||||
|
*/
|
||||||
|
window.add_mouse_support = function (elementId, combinationsJson) {
|
||||||
|
// Parse the combinations JSON
|
||||||
|
const combinations = JSON.parse(combinationsJson);
|
||||||
|
|
||||||
|
// Build tree for this element
|
||||||
|
const tree = buildTree(combinations);
|
||||||
|
|
||||||
|
// Get element reference
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.error("Element with ID", elementId, "not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to registry
|
||||||
|
MouseRegistry.elements.set(elementId, {
|
||||||
|
tree: tree,
|
||||||
|
element: element
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach global listener if not already attached
|
||||||
|
attachGlobalListener();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove mouse support from an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
*/
|
||||||
|
window.remove_mouse_support = function (elementId) {
|
||||||
|
// Remove from registry
|
||||||
|
if (!MouseRegistry.elements.has(elementId)) {
|
||||||
|
console.warn("Element with ID", elementId, "not found in mouse registry!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseRegistry.elements.delete(elementId);
|
||||||
|
|
||||||
|
// If no more elements, detach global listeners
|
||||||
|
if (MouseRegistry.elements.size === 0) {
|
||||||
|
detachGlobalListener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -137,6 +137,12 @@
|
|||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
<h2>Test Input (typing should work normally here)</h2>
|
<h2>Test Input (typing should work normally here)</h2>
|
||||||
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
|
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
|
||||||
|
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
|
||||||
|
<strong>Parameters Explained:</strong><br>
|
||||||
|
• <code>has_focus</code>: Whether the registered element itself has focus<br>
|
||||||
|
• <code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
|
||||||
|
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
@@ -151,6 +157,9 @@
|
|||||||
<div id="test-element-2" class="test-element" tabindex="0">
|
<div id="test-element-2" class="test-element" tabindex="0">
|
||||||
This element also responds to ESC and Shift Shift
|
This element also responds to ESC and Shift Shift
|
||||||
</div>
|
</div>
|
||||||
|
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
|
||||||
|
Remove Element 2 Keyboard Support
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
@@ -172,12 +181,14 @@
|
|||||||
window.htmx.ajax = function(method, url, config) {
|
window.htmx.ajax = function(method, url, config) {
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
const hasFocus = config.values.has_focus;
|
const hasFocus = config.values.has_focus;
|
||||||
|
const isInside = config.values.is_inside;
|
||||||
const combination = config.values.combination;
|
const combination = config.values.combination;
|
||||||
|
|
||||||
// Build details string with all config options
|
// Build details string with all config options
|
||||||
const details = [
|
const details = [
|
||||||
`Combination: "${combination}"`,
|
`Combination: "${combination}"`,
|
||||||
`Element has focus: ${hasFocus}`
|
`Element has focus: ${hasFocus}`,
|
||||||
|
`Focus inside element: ${isInside}`
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config.target) {
|
if (config.target) {
|
||||||
@@ -223,6 +234,16 @@
|
|||||||
function clearLog() {
|
function clearLog() {
|
||||||
document.getElementById('log').innerHTML = '';
|
document.getElementById('log').innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeElement2() {
|
||||||
|
remove_keyboard_support('test-element-2');
|
||||||
|
logEvent('Element 2 keyboard support removed',
|
||||||
|
'ESC and Shift Shift no longer trigger for Element 2',
|
||||||
|
'Element 1 still active', false);
|
||||||
|
// Disable the button
|
||||||
|
event.target.disabled = true;
|
||||||
|
event.target.textContent = 'Keyboard Support Removed';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Include keyboard support script -->
|
<!-- Include keyboard support script -->
|
||||||
356
tests/html/test_mouse_support.html
Normal file
356
tests/html/test_mouse_support.html
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mouse Support Test</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-container {
|
||||||
|
border: 2px solid #333;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 2px solid #999;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 10px 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element:focus {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-color: #2196F3;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
margin: 5px 0;
|
||||||
|
padding: 5px;
|
||||||
|
border-left: 3px solid #4CAF50;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.focus {
|
||||||
|
border-left-color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.no-focus {
|
||||||
|
border-left-color: #FF9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list ul {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list code {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button:hover {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
background-color: #FF5722;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:hover {
|
||||||
|
background-color: #E64A19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-left: 4px solid #2196F3;
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Mouse Support Test Page</h1>
|
||||||
|
|
||||||
|
<div class="actions-list">
|
||||||
|
<h3>🖱️ Configured Mouse Actions</h3>
|
||||||
|
<p><strong>Element 1 - All Actions:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>click</code> - Simple left click</li>
|
||||||
|
<li><code>right_click</code> - Right click (context menu blocked)</li>
|
||||||
|
<li><code>ctrl+click</code> - Ctrl/Cmd + Click</li>
|
||||||
|
<li><code>shift+click</code> - Shift + Click</li>
|
||||||
|
<li><code>ctrl+shift+click</code> - Ctrl + Shift + Click</li>
|
||||||
|
<li><code>click right_click</code> - Click then right-click within 500ms</li>
|
||||||
|
<li><code>click click</code> - Click twice in sequence</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Element 2 - Using rclick alias:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>click</code> - Simple click</li>
|
||||||
|
<li><code>rclick</code> - Right click (using rclick alias)</li>
|
||||||
|
<li><code>click rclick</code> - Click then right-click sequence (using alias)</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Note:</strong> <code>rclick</code> is an alias for <code>right_click</code> and works identically.</p>
|
||||||
|
<p><strong>Tip:</strong> Try different click combinations! Right-click menu will be blocked on test elements.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Click Behavior:</strong> The <code>click</code> action is detected GLOBALLY (anywhere on the page).
|
||||||
|
Try clicking outside the test elements - the click action will still trigger! The <code>is_inside</code>
|
||||||
|
parameter tells you if the click was inside or outside the element (perfect for "close popup if clicked outside" logic).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Right-Click Behavior:</strong> The <code>right_click</code> action is detected ONLY when clicking ON the element.
|
||||||
|
Try right-clicking outside the test elements - the browser's context menu will appear normally.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Mac Users:</strong> Use Cmd (⌘) instead of Ctrl. The library handles cross-platform compatibility automatically.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Element 1 (All Actions)</h2>
|
||||||
|
<div id="test-element-1" class="test-element" tabindex="0">
|
||||||
|
Try different mouse actions here!<br>
|
||||||
|
Click, Right-click, Ctrl+Click, Shift+Click, sequences...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Element 2 (Using rclick alias)</h2>
|
||||||
|
<div id="test-element-2" class="test-element" tabindex="0">
|
||||||
|
This element uses "rclick" alias for right-click<br>
|
||||||
|
Also has a "click rclick" sequence
|
||||||
|
</div>
|
||||||
|
<button class="remove-button" onclick="removeElement2()">
|
||||||
|
Remove Element 2 Mouse Support
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Input (normal clicking should work here)</h2>
|
||||||
|
<input type="text" placeholder="Try clicking, right-clicking here - should work normally"
|
||||||
|
style="width: 100%; padding: 10px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container" style="background-color: #f9f9f9;">
|
||||||
|
<h2>🎯 Click Outside Test Area</h2>
|
||||||
|
<p>Click anywhere in this gray area (outside the test elements above) to see that <code>click</code> is detected globally!</p>
|
||||||
|
<p style="margin-top: 20px; padding: 30px; background-color: white; border: 2px dashed #999; border-radius: 5px; text-align: center;">
|
||||||
|
This is just empty space - but clicking here will still trigger the registered <code>click</code> actions!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Event Log</h2>
|
||||||
|
<button class="clear-button" onclick="clearLog()">Clear Log</button>
|
||||||
|
<div id="log" class="log-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include htmx -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
|
||||||
|
<!-- Mock htmx.ajax for testing -->
|
||||||
|
<script>
|
||||||
|
// Store original htmx.ajax if it exists
|
||||||
|
const originalHtmxAjax = window.htmx && window.htmx.ajax;
|
||||||
|
|
||||||
|
// Override htmx.ajax for testing purposes
|
||||||
|
if (window.htmx) {
|
||||||
|
window.htmx.ajax = function(method, url, config) {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
const hasFocus = config.values.has_focus;
|
||||||
|
const isInside = config.values.is_inside;
|
||||||
|
const combination = config.values.combination;
|
||||||
|
|
||||||
|
// Build details string with all config options
|
||||||
|
const details = [
|
||||||
|
`Combination: "${combination}"`,
|
||||||
|
`Element has focus: ${hasFocus}`,
|
||||||
|
`Click inside element: ${isInside}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (config.target) {
|
||||||
|
details.push(`Target: ${config.target}`);
|
||||||
|
}
|
||||||
|
if (config.swap) {
|
||||||
|
details.push(`Swap: ${config.swap}`);
|
||||||
|
}
|
||||||
|
if (config.values) {
|
||||||
|
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus' && k !== 'is_inside');
|
||||||
|
if (extraVals.length > 0) {
|
||||||
|
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent(
|
||||||
|
`[${timestamp}] ${method} ${url}`,
|
||||||
|
...details,
|
||||||
|
hasFocus
|
||||||
|
);
|
||||||
|
|
||||||
|
// Uncomment below to use real htmx.ajax if you have a backend
|
||||||
|
// if (originalHtmxAjax) {
|
||||||
|
// originalHtmxAjax.call(this, method, url, config);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logEvent(title, ...details) {
|
||||||
|
const log = document.getElementById('log');
|
||||||
|
const hasFocus = details[details.length - 1];
|
||||||
|
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
|
||||||
|
entry.innerHTML = `
|
||||||
|
<strong>${title}</strong><br>
|
||||||
|
${details.slice(0, -1).join('<br>')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
log.insertBefore(entry, log.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
document.getElementById('log').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeElement2() {
|
||||||
|
remove_mouse_support('test-element-2');
|
||||||
|
logEvent('Element 2 mouse support removed',
|
||||||
|
'Click and right-click no longer trigger for Element 2',
|
||||||
|
'Element 1 still active', false);
|
||||||
|
// Disable the button
|
||||||
|
event.target.disabled = true;
|
||||||
|
event.target.textContent = 'Mouse Support Removed';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Include mouse support script -->
|
||||||
|
<script src="mouse_support.js"></script>
|
||||||
|
|
||||||
|
<!-- Initialize mouse support -->
|
||||||
|
<script>
|
||||||
|
// Element 1 - Full configuration
|
||||||
|
const combinations1 = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/test/click"
|
||||||
|
},
|
||||||
|
"right_click": {
|
||||||
|
"hx-post": "/test/right-click"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/test/ctrl-click",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
},
|
||||||
|
"shift+click": {
|
||||||
|
"hx-post": "/test/shift-click",
|
||||||
|
"hx-target": "#result"
|
||||||
|
},
|
||||||
|
"ctrl+shift+click": {
|
||||||
|
"hx-post": "/test/ctrl-shift-click",
|
||||||
|
"hx-vals": {"modifier": "both"}
|
||||||
|
},
|
||||||
|
"click right_click": {
|
||||||
|
"hx-post": "/test/click-then-right-click",
|
||||||
|
"hx-vals": {"type": "sequence"}
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/test/double-click-sequence"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_mouse_support('test-element-1', JSON.stringify(combinations1));
|
||||||
|
|
||||||
|
// Element 2 - Using rclick alias
|
||||||
|
const combinations2 = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/test/element2-click"
|
||||||
|
},
|
||||||
|
"rclick": { // Using rclick alias instead of right_click
|
||||||
|
"hx-post": "/test/element2-rclick"
|
||||||
|
},
|
||||||
|
"click rclick": { // Sequence using rclick alias
|
||||||
|
"hx-post": "/test/element2-click-rclick-sequence"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_mouse_support('test-element-2', JSON.stringify(combinations2));
|
||||||
|
|
||||||
|
// Log initial state
|
||||||
|
logEvent('Mouse support initialized',
|
||||||
|
'Element 1: All mouse actions configured',
|
||||||
|
'Element 2: Using "rclick" alias (click, rclick, and click rclick sequence)',
|
||||||
|
'Smart timeout: 500ms for sequences', false);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user