Initial commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
# if this is a forked repository. Skipping the workflow.
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,7 @@
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.pyw
|
||||
*.pyz
|
||||
*.pywz
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 ryanontheinside
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,256 @@
|
||||
# 🎮 Control <span style="color: orange;">Freak</span> for ComfyUI
|
||||
|
||||
<div align="center">
|
||||
<img src="https://img.shields.io/badge/ComfyUI-Extension-blue?style=for-the-badge" alt="ComfyUI Extension"/>
|
||||
<img src="https://img.shields.io/badge/MIDI-Compatible-brightgreen?style=for-the-badge" alt="MIDI Compatible"/>
|
||||
<img src="https://img.shields.io/badge/Gamepad-Support-orange?style=for-the-badge" alt="Gamepad Support"/>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge" alt="License: MIT"/></a>
|
||||
</div>
|
||||
|
||||
> *__Take control of your workflows__ - Universal MIDI & Gamepad mapping for ComfyUI*
|
||||
|
||||
Control <span style="color: orange;">Freak</span> gives you physical control over your ComfyUI workflows by mapping MIDI controllers, gamepads, and other input devices to any node parameter or UI element. Transform your image generation experience with tactile, responsive control. Obey the knobs!
|
||||
|
||||
<p align="center">
|
||||
<i>Adjust sliders, toggle parameters, and execute commands with physical controllers</i>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/main.png" alt="Control Freak Interface" width="800"/>
|
||||
</p>
|
||||
|
||||
<!--toc:start-->
|
||||
## Table of Contents
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Accessing the Controller Panel](#accessing-the-controller-panel)
|
||||
- [Connecting Controllers](#connecting-controllers)
|
||||
- [Mapping Controller Inputs](#mapping-controller-inputs)
|
||||
- [To Node Parameters](#to-node-parameters)
|
||||
- [To UI Commands](#to-ui-commands)
|
||||
- [Quick Mapping / Unmapping](#quick-mapping--unmapping)
|
||||
- [Managing Mapping Profiles](#managing-mapping-profiles)
|
||||
- [Supported Controllers](#supported-controllers)
|
||||
- [MIDI Controllers](#midi-controllers)
|
||||
- [Gamepads/Joysticks](#gamepadsjoysticks)
|
||||
- [Configuration](#configuration)
|
||||
- [Extending with New Controller Types](#extending-with-new-controller-types)
|
||||
- [Related Projects](#related-projects)
|
||||
- [License](#license)
|
||||
|
||||
- [Coming Soon](#coming-soon)
|
||||
<!--toc:end-->
|
||||
|
||||
## Features
|
||||
|
||||
- **Versatile Mapping:** Map any controller input (buttons, axes, knobs, sliders) to:
|
||||
- Any node parameter (widgets like sliders, toggles, number inputs, dropdowns) via right-click "Standard Map..." mode.
|
||||
- Core ComfyUI commands (e.g., Queue Prompt, Interrupt, Save/Load Workflow, Clear Workflow, Canvas Zoom/Fit) via the Controller Panel.
|
||||
- **Quick Mapping:** Instantly map a controller input to a widget using default settings via the right-click context menu (`Quick Map` option).
|
||||
- **Quick Unmapping:** Instantly remove a *single* mapping from a widget via the right-click context menu (`Quick Unmap` option, only shown if one mapping exists).
|
||||
- **Real-time Updates:** Parameters update instantly as you manipulate the controller.
|
||||
- **Controller Panel:** Dedicated UI (`Edit -> Controller Mapping`) for:
|
||||
- Viewing available controller types (MIDI, Gamepad).
|
||||
- Listing detected and connected devices.
|
||||
- Manually connecting/disconnecting controllers.
|
||||
- Viewing and managing current mappings (including multiple mappings per widget).
|
||||
- Managing mapping profiles (Load, Save, Delete).
|
||||
- **Supported Devices:** Built-in support for MIDI controllers and common gamepads/joysticks.
|
||||
- **Value Conversion:** Automatically converts controller input ranges (e.g., 0-1) to the appropriate scale, type (INT, FLOAT, BOOLEAN, COMBO), and constraints (min, max, step) of the target widget.
|
||||
- **Custom Range Overrides:** Specify custom min/max values during Standard Mapping for fine-grained control.
|
||||
- **Mapping Profiles:** Save and load different sets of mappings for various workflows or controllers.
|
||||
- **Visual Feedback:**
|
||||
- Highlights parameters during the standard mapping ("Learn") process.
|
||||
- Displays a subtle <span style="color:orange;">orange</span> background glow on widgets that currently have active mappings.
|
||||
- **Auto-Reconnection:** Remembers previously connected controllers and attempts to reconnect them automatically on startup.
|
||||
- **Extensible:** Designed to allow adding support for new controller types.
|
||||
|
||||
## Installation
|
||||
|
||||
Use the ComfyUI Manager, or.....
|
||||
|
||||
1. Navigate to your ComfyUI `custom_nodes` directory:
|
||||
```bash
|
||||
cd /path/to/ComfyUI/custom_nodes
|
||||
```
|
||||
2. Clone this repository:
|
||||
```bash
|
||||
git clone https://github.com/ryanontheinside/ComfyUI_ControlFreak.git
|
||||
```
|
||||
3. Restart ComfyUI.
|
||||
|
||||
## Usage
|
||||
|
||||
### Accessing the Controller Panel
|
||||
|
||||
After installation and restarting ComfyUI, you can access the main interface for ControlFreak:
|
||||
|
||||
- Go to the **Edit** menu in the ComfyUI menu bar.
|
||||
- Select **Controller Mapping**.
|
||||
|
||||
This will open the Controller Panel, which is the central hub for managing devices and mappings.
|
||||
|
||||
### Connecting Controllers
|
||||
|
||||
1. **Physical Connection:** Ensure your MIDI device or gamepad is physically connected to your computer via USB or other appropriate means.
|
||||
2. **Controller Panel:** Open the Controller Panel (`Edit -> Controller Mapping`).
|
||||
3. **Device List:** The panel will list detected controller types (MIDI, Gamepad) and specific devices found under each type.
|
||||
4. **Connect:** Click the "Connect" button next to the desired device. A notification should confirm the connection.
|
||||
5. **Auto-Detection:** Many devices might be detected and connected automatically upon startup or connection.
|
||||
|
||||
### Mapping Controller Inputs
|
||||
|
||||
#### To Node Parameters
|
||||
|
||||
This is the most common way to control specific aspects of your workflow. You have several ways to map parameters:
|
||||
|
||||
**1. Quick Map (Recommended for simple cases):**
|
||||
|
||||
- **Right-Click Directly on Widget:** Right-click on the interactive part of the widget you want to control (e.g., the number input field, the toggle switch).
|
||||
- **Select "Quick Map '[Widget Name]'":** Choose the first Control Freak option in the context menu.
|
||||
- **Move Controller:** Activate the desired controller input (button, axis, knob).
|
||||
- **Done:** The first input detected will be instantly mapped to the widget using default settings appropriate for the widget type (e.g., absolute mapping for numbers/sliders, toggle for booleans). A notification will confirm the mapping, and the widget will get a subtle highlight.
|
||||
|
||||
**2. Standard Map (For more configuration):**
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/learning.png" alt="Controller Learning Process" width="700"/>
|
||||
</p>
|
||||
|
||||
- **Right-Click on Node (or Widget):** Right-click anywhere on the node containing the widget, or directly on the widget.
|
||||
- **Select "Map Control..." -> Widget Name -> "Standard Map...":** Navigate the submenu to find the widget and choose "Standard Map...".
|
||||
- **Learning Dialog:** A dialog box appears.
|
||||
- **Activate Control:** Move the desired controller input.
|
||||
- **Detection & Selection:** The dialog lists detected inputs. Click the one you want to use.
|
||||
- **Configure (Optional):** Set custom min/max value overrides if needed.
|
||||
- **Select Mapping Type:** Choose Direct, Toggle, Incremental, etc.
|
||||
- **Map:** Click the **Map** button.
|
||||
|
||||
**3. Quick Unmap (If *only one* mapping exists):**
|
||||
|
||||
- **Right-Click Directly on Widget:** Right-click on the interactive part of a *mapped* widget.
|
||||
- **Select "Quick Unmap '[Widget Name]'":** If only one mapping exists for this widget, this option will appear. Click it.
|
||||
- **Done:** The single mapping is instantly removed, and the highlight disappears. A notification confirms the unmapping.
|
||||
|
||||
*(Note: If a widget has multiple mappings, you must use the Controller Panel (`Edit -> Controller Mapping` -> Mappings Tab) to manage or remove them.)*
|
||||
|
||||
**Visual Highlight:**
|
||||
|
||||
Widgets that currently have one or more active mappings assigned via Control <span style="color: orange;">Freak</span> will display a subtle <span style="color:orange;">orange</span> background glow for easy identification.
|
||||
|
||||
#### To UI Commands
|
||||
|
||||
You can also map controller inputs to execute core ComfyUI actions.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/uicommands.png" alt="UI Commands Mapping" width="700"/>
|
||||
</p>
|
||||
|
||||
1. **Controller Panel:** Open the Controller Panel (`Edit -> Controller Mapping`).
|
||||
2. **Mappings Tab:** Navigate to the "Mappings" tab.
|
||||
3. **Add Mapping:** Click the "Add Mapping" or similar button.
|
||||
4. **Select Target:** Choose "Command" as the target type.
|
||||
5. **Select Command:** Browse or search the available ComfyUI commands listed.
|
||||
6. **Learning Dialog:** Click "Learn Control" (or similar) to open the learning dialog. Activate the desired control on your device.
|
||||
7. **Detection & Selection:** Click the detected input to select it.
|
||||
8. **Configure & Map:** Set options (like mapping type, often "Trigger" for commands) and click "Map".
|
||||
|
||||
Now, activating that controller input will execute the chosen ComfyUI command.
|
||||
|
||||
### Central Mapping Management
|
||||
|
||||
The Controller Panel provides a centralized location to view and manage all your mappings:
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/mappings.png" alt="Mappings Management" width="700"/>
|
||||
</p>
|
||||
|
||||
1. **Mapping List:** Shows all current mappings organized by target type (Node Parameters, UI Commands, ComfyUI Commands).
|
||||
2. **View Details:** For each mapping, view the associated:
|
||||
* **Target:** What the controller affects (node/parameter, command)
|
||||
* **Controller:** Which controller and input is mapped
|
||||
* **Mapping Type:** Direct, Toggle, Incremental, etc.
|
||||
* **Value Range:** Custom min/max values if set
|
||||
3. **Edit Mappings:** Select any mapping to:
|
||||
* **Modify:** Change mapping type or value ranges
|
||||
* **Reassign:** Map a different controller input to the same target
|
||||
* **Remove:** Delete the mapping
|
||||
4. **Batch Operations:** Enable/disable multiple mappings at once, or delete groups of mappings.
|
||||
5. **Sorting and Filtering:** Organize mappings by controller, target type, or recently modified.
|
||||
|
||||
This central interface makes it easy to maintain complex control setups across multiple workflows.
|
||||
|
||||
### Managing Mapping Profiles
|
||||
|
||||
Profiles allow you to save and switch between different sets of mappings.
|
||||
|
||||
1. **Controller Panel:** Open the Controller Panel (`Edit -> Controller Mapping`).
|
||||
2. **Profiles Tab/Section:** Find the section for managing profiles.
|
||||
3. **Save:** Enter a name for your current set of mappings and click "Save".
|
||||
4. **Load:** Select a previously saved profile from the list and click "Load". This will replace the current mappings with the ones from the selected profile.
|
||||
5. **Delete:** Select a profile and click "Delete" to remove it.
|
||||
|
||||
## Supported Controllers
|
||||
|
||||
### MIDI Controllers
|
||||
|
||||
Connect any standard MIDI controller. The system automatically detects and uses:
|
||||
|
||||
- Buttons/Pads (Note On/Off messages)
|
||||
- Knobs and Sliders (Control Change - CC messages)
|
||||
- Pitch Wheel
|
||||
|
||||
### Gamepads/Joysticks
|
||||
|
||||
Connect common gamepads (Xbox, PlayStation, generic USB controllers, etc.). Supported inputs include:
|
||||
|
||||
- Buttons (A, B, X, Y, shoulder buttons, triggers acting as buttons, D-pad buttons, etc.)
|
||||
- Analog Sticks (X and Y axes)
|
||||
- Analog Triggers (often mapped as axes)
|
||||
|
||||
*(Note: Specific button/axis numbering may vary between controllers and operating systems.)*
|
||||
|
||||
|
||||
### Coming Soon
|
||||
|
||||
The following controller types are planned for future releases, depending on the level of adoption of this project and feedback:
|
||||
|
||||
- **Audio Interfaces:** Convert audio signals (amplitude, frequency, beat detection) to control parameters for audio-reactive generation.
|
||||
|
||||
- **MIDI Polyphonic Expression:** It will be insane
|
||||
|
||||
- **OSC:** Yeeep
|
||||
|
||||
- **Camera/image:** Motion, color, etc..
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Mappings:** All active mappings and their configurations (including ranges, types, etc.) are saved directly within the ComfyUI workflow JSON file.
|
||||
- **Profiles (Coming Soon):** Functionality to save/load sets of mappings as separate profile files is planned.
|
||||
- **Controller Settings (Future):** Controller-specific settings might be stored separately in the future.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Consolidate css
|
||||
- Await feedback
|
||||
|
||||
## Contributing / Extending with New Controller Types
|
||||
|
||||
Please feel free to open well documented pull requests for review!
|
||||
|
||||
Detailed information on adding support for new controller types (like MIDI, Gamepad, etc.) can be found in the [controllers README](./js/controllers/README.md).
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [ComfyUI_RyanOnTheInside](https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside) - Everything Reactivity
|
||||
- [ComfyUI_RealtimeNodes](https://github.com/ryanontheinside/ComfyUI_RealtimeNodes) - Real-time nodes for ComfyUI
|
||||
- [comfystream](https://github.com/yondonfu/comfystream) - Native ComfyUI plugin for streaming workflows
|
||||
- [ComfyUI_ProfilerX](https://github.com/ryanontheinside/ComfyUI_ProfilerX) - Advanced profiling for ComfyUI workflows
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
<br/>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge" alt="License: MIT"/></a>
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
CONTROL FREAK - Map MIDI controllers, gamepads, and other devices to ComfyUI parameters
|
||||
|
||||
"""
|
||||
|
||||
class ControlFreakMarker:
|
||||
"""
|
||||
This node is added to workflows with ControlFreak mappings
|
||||
to ensure ComfyUI-Manager detects the dependency.
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {}
|
||||
}
|
||||
|
||||
RETURN_TYPES = tuple()
|
||||
FUNCTION = "marker"
|
||||
CATEGORY = "controllers"
|
||||
HIDDEN = True
|
||||
|
||||
def marker(self):
|
||||
return ()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ControlFreak": ControlFreakMarker
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS"]
|
||||
|
||||
# Define the web directory for our JavaScript files
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,307 @@
|
||||
# Adding New Controllers to ComfyUI_ControlFreak
|
||||
|
||||
This guide explains how to implement a new controller type for ComfyUI_ControlFreak. Controllers allow physical input devices to interact with ComfyUI nodes and commands.
|
||||
|
||||
## Controller Implementation Pattern
|
||||
|
||||
Each controller follows a consistent pattern that includes initialization, connection management, input handling, and event emission through the event bus.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Create a New Controller File
|
||||
|
||||
Create a new JavaScript file in this directory with a descriptive name, such as `touchscreen.js` or `voice.js`.
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Your Controller Implementation Description
|
||||
*/
|
||||
|
||||
import { eventBus } from "../core/eventBus.js";
|
||||
|
||||
export class YourControllerClass {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.connectedDevices = new Map();
|
||||
// Additional controller-specific properties
|
||||
|
||||
// Set up event bus subscriptions
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
// ... rest of implementation
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Implement Required Methods
|
||||
|
||||
Your controller class should implement these key methods:
|
||||
|
||||
- **_setupEventListeners()**: Subscribe to events for connect/disconnect requests
|
||||
- **initialize()**: Set up the controller API, request permissions if needed
|
||||
- **connect(deviceId)**: Connect to a specific device
|
||||
- **disconnect(deviceId)**: Disconnect from a specific device
|
||||
- **getDevices()**: Return a list of available devices
|
||||
- **onMessage() / onUpdate()**: Register callbacks for input events
|
||||
- **onStateChange()**: Register callbacks for device connection changes
|
||||
|
||||
### 3. Event Handling
|
||||
|
||||
Controllers must emit standard events through the event bus:
|
||||
|
||||
- **controller:input**: When an input is detected (used by mapping engine)
|
||||
- **[controller-type]:message** / **[controller-type]:update**: Device-specific events
|
||||
- **[controller-type]:deviceConnected** / **[controller-type]:deviceDisconnected**: When devices connect/disconnect
|
||||
- **[controller-type]:stateChanged**: When the overall controller state changes
|
||||
- **[controller-type]:error**: When errors occur
|
||||
|
||||
### 4. Example Input Format
|
||||
|
||||
For consistency, controller input events should follow this format:
|
||||
|
||||
```javascript
|
||||
const controlInput = {
|
||||
type: 'your_controller_type', // Type identifier
|
||||
deviceId: deviceId, // Unique device identifier
|
||||
controlId: controlId, // Specific input identifier within device
|
||||
rawValue: value, // Raw input value (typically 0-1 or 0-127)
|
||||
name: `Your Controller Name`, // Human-readable name
|
||||
// Additional controller-specific properties
|
||||
};
|
||||
|
||||
// Emit to event bus
|
||||
eventBus.emit('controller:input', controlInput);
|
||||
```
|
||||
|
||||
### 5. Register Your Controller
|
||||
|
||||
Finally, register your controller in `js/index.js`:
|
||||
|
||||
```javascript
|
||||
// 1. Import your controller class
|
||||
import { YourControllerClass } from './controllers/your_controller.js';
|
||||
|
||||
// 2. Create instance in initializeControlFreak()
|
||||
const yourController = new YourControllerClass();
|
||||
|
||||
// 3. Register with context provider
|
||||
contextProvider.register('yourController', yourController);
|
||||
|
||||
// 4. Initialize your controller
|
||||
await yourController.initialize();
|
||||
|
||||
// 5. Set up event listeners in setupControllerEventListeners()
|
||||
yourController.onUpdate(update => {
|
||||
const controlInput = {
|
||||
// Format the update as controlInput
|
||||
};
|
||||
eventBus.emit('controller:input', controlInput);
|
||||
});
|
||||
```
|
||||
|
||||
## Complete Controller Template
|
||||
|
||||
Here's a starter template for a new controller implementation:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* [Your Controller] Implementation
|
||||
*/
|
||||
|
||||
import { eventBus } from "../core/eventBus.js";
|
||||
|
||||
export class YourControllerClass {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.connectedDevices = new Map(); // Track connected devices
|
||||
this.messageCallbacks = [];
|
||||
this.stateChangeCallbacks = [];
|
||||
}
|
||||
|
||||
_setupEventListeners() {
|
||||
// Listen for requests to connect/disconnect devices
|
||||
eventBus.on('yourcontroller:connect', async (deviceId) => {
|
||||
await this.connect(deviceId);
|
||||
});
|
||||
|
||||
eventBus.on('yourcontroller:disconnect', async (deviceId) => {
|
||||
await this.disconnect(deviceId);
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// Check if API is available
|
||||
if (!window.YourControllerAPI) {
|
||||
console.error("YourController: API not supported in this browser.");
|
||||
eventBus.emit('yourcontroller:error', {
|
||||
message: "API not supported in this browser"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize API and set up event listeners
|
||||
// ...
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
// Emit initialization event
|
||||
eventBus.emit('yourcontroller:initialized', {
|
||||
devices: this.getDevices()
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("YourController: Error initializing:", error);
|
||||
this.initialized = false;
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('yourcontroller:error', {
|
||||
message: "Error initializing controller",
|
||||
error
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a device
|
||||
* @param {string} deviceId - The device ID to connect to
|
||||
* @returns {Promise<string>} - The connected device ID
|
||||
*/
|
||||
async connect(deviceId) {
|
||||
try {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// Connect to device
|
||||
// ...
|
||||
|
||||
// Track the connected device
|
||||
this.connectedDevices.set(deviceId, deviceName);
|
||||
|
||||
// Emit connected event
|
||||
eventBus.emit('yourcontroller:deviceConnected', {
|
||||
deviceId,
|
||||
deviceName,
|
||||
deviceType: 'yourcontroller'
|
||||
});
|
||||
|
||||
return deviceId;
|
||||
} catch (error) {
|
||||
console.error(`YourController: Error connecting to device ${deviceId}:`, error);
|
||||
eventBus.emit('yourcontroller:error', {
|
||||
message: `Error connecting to device ${deviceId}`,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from a device
|
||||
* @param {string} deviceId - The device ID to disconnect from
|
||||
* @returns {Promise<boolean>} - Whether disconnection was successful
|
||||
*/
|
||||
async disconnect(deviceId) {
|
||||
try {
|
||||
// Disconnect from device
|
||||
// ...
|
||||
|
||||
// Remove from connected devices
|
||||
const deviceName = this.connectedDevices.get(deviceId);
|
||||
this.connectedDevices.delete(deviceId);
|
||||
|
||||
// Emit disconnected event
|
||||
eventBus.emit('yourcontroller:deviceDisconnected', {
|
||||
deviceId,
|
||||
deviceName,
|
||||
deviceType: 'yourcontroller'
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`YourController: Error disconnecting from device ${deviceId}:`, error);
|
||||
eventBus.emit('yourcontroller:error', {
|
||||
message: `Error disconnecting from device ${deviceId}`,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available devices
|
||||
* @returns {Array} - Array of device objects
|
||||
*/
|
||||
getDevices() {
|
||||
// Return array of available devices
|
||||
return Array.from(this.connectedDevices.entries()).map(([id, name]) => ({
|
||||
id,
|
||||
name,
|
||||
type: 'yourcontroller',
|
||||
connected: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for input messages
|
||||
* @param {Function} callback - Function to be called with message data
|
||||
*/
|
||||
onMessage(callback) {
|
||||
this.messageCallbacks.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for state changes
|
||||
* @param {Function} callback - Function to be called with new device list
|
||||
*/
|
||||
onStateChange(callback) {
|
||||
this.stateChangeCallbacks.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger message callbacks
|
||||
* @param {Object} message - Message data to pass to callbacks
|
||||
*/
|
||||
_triggerMessageCallbacks(message) {
|
||||
for (const callback of this.messageCallbacks) {
|
||||
try {
|
||||
callback(message);
|
||||
} catch (error) {
|
||||
console.error("YourController: Error in message callback:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger state change callbacks
|
||||
* @param {Array} devices - Updated device list to pass to callbacks
|
||||
*/
|
||||
_triggerStateChangeCallbacks(devices) {
|
||||
for (const callback of this.stateChangeCallbacks) {
|
||||
try {
|
||||
callback(devices);
|
||||
} catch (error) {
|
||||
console.error("YourController: Error in state change callback:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Error Handling**: Always wrap API calls in try/catch blocks and emit errors through the event bus
|
||||
2. **Logging**: Use descriptive console logs for debugging with consistent format
|
||||
3. **Standard Events**: Follow the established event naming conventions
|
||||
4. **Value Normalization**: Convert input values to a 0-1 range when possible
|
||||
5. **Resource Management**: Clean up event listeners and resources in disconnect/cleanup methods
|
||||
|
||||
## Existing Controller Examples
|
||||
|
||||
For reference, examine these existing controllers:
|
||||
- `midi.js`: WebMIDI API integration for MIDI controllers
|
||||
- `gamepad.js`: Gamepad API integration for game controllers
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Gamepad Controller Implementation using the Gamepad API
|
||||
*/
|
||||
|
||||
import { eventBus } from "../core/eventBus.js";
|
||||
|
||||
export class GamepadController {
|
||||
constructor() {
|
||||
this.gamepads = {};
|
||||
this.lastGamepadStates = {};
|
||||
this.initialized = false;
|
||||
this.animationFrameId = null;
|
||||
this.connectedGamepads = new Map();
|
||||
|
||||
// Set up event bus subscriptions
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
_setupEventListeners() {
|
||||
// Listen for requests to connect/disconnect devices
|
||||
eventBus.on('gamepad:connect', async (gamepadId) => {
|
||||
await this.connect(gamepadId);
|
||||
});
|
||||
|
||||
eventBus.on('gamepad:disconnect', async (gamepadId) => {
|
||||
await this.disconnect(gamepadId);
|
||||
});
|
||||
}
|
||||
|
||||
initialize() {
|
||||
try {
|
||||
if (!navigator.getGamepads) {
|
||||
console.error("GamepadController: Gamepad API not supported in this browser.");
|
||||
eventBus.emit('gamepad:error', { message: "Gamepad API not supported in this browser" });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set up event listeners for gamepad connection/disconnection
|
||||
window.addEventListener('gamepadconnected', (e) => this._handleGamepadConnected(e));
|
||||
window.addEventListener('gamepaddisconnected', (e) => this._handleGamepadDisconnected(e));
|
||||
|
||||
// Start polling for gamepad state changes
|
||||
this._startPolling();
|
||||
this.initialized = true;
|
||||
|
||||
// Emit initialization event
|
||||
eventBus.emit('gamepad:initialized', {
|
||||
gamepads: this.getGamepads()
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("GamepadController: Error initializing Gamepad API:", error);
|
||||
this.initialized = false;
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('gamepad:error', {
|
||||
message: "Error initializing Gamepad API",
|
||||
error
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
// Clear any existing animation frame
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
|
||||
// Function to poll gamepad states
|
||||
const pollGamepads = () => {
|
||||
// Get all currently connected gamepads
|
||||
const gamepads = navigator.getGamepads();
|
||||
|
||||
// Update our gamepad states
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
const gamepad = gamepads[i];
|
||||
|
||||
if (!gamepad) continue;
|
||||
|
||||
// Store the gamepad
|
||||
this.gamepads[gamepad.index] = gamepad;
|
||||
|
||||
// Skip if we haven't connected this gamepad explicitly yet
|
||||
if (!this.connectedGamepads.has(gamepad.index.toString())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for button state changes
|
||||
if (!this.lastGamepadStates[gamepad.index]) {
|
||||
this.lastGamepadStates[gamepad.index] = {
|
||||
buttons: Array(gamepad.buttons.length).fill(0),
|
||||
axes: Array(gamepad.axes.length).fill(0)
|
||||
};
|
||||
}
|
||||
|
||||
// Check buttons
|
||||
for (let b = 0; b < gamepad.buttons.length; b++) {
|
||||
const buttonValue = gamepad.buttons[b].value;
|
||||
|
||||
// If button state has changed
|
||||
if (buttonValue !== this.lastGamepadStates[gamepad.index].buttons[b]) {
|
||||
// Create update object
|
||||
const update = {
|
||||
gamepadId: gamepad.index.toString(),
|
||||
gamepadName: gamepad.id,
|
||||
type: 'button',
|
||||
index: b,
|
||||
value: buttonValue,
|
||||
pressed: gamepad.buttons[b].pressed
|
||||
};
|
||||
|
||||
// Emit the update event
|
||||
eventBus.emit('gamepad:update', update);
|
||||
|
||||
// Update the last state
|
||||
this.lastGamepadStates[gamepad.index].buttons[b] = buttonValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check axes
|
||||
for (let a = 0; a < gamepad.axes.length; a++) {
|
||||
const axisValue = gamepad.axes[a];
|
||||
|
||||
// Apply deadzone to reduce noise
|
||||
const deadzone = 0.05;
|
||||
const processedValue = Math.abs(axisValue) < deadzone ? 0 : axisValue;
|
||||
|
||||
// If axis state has changed
|
||||
if (Math.abs(processedValue - this.lastGamepadStates[gamepad.index].axes[a]) > 0.01) {
|
||||
// Create update object
|
||||
const update = {
|
||||
gamepadId: gamepad.index.toString(),
|
||||
gamepadName: gamepad.id,
|
||||
type: 'axis',
|
||||
index: a,
|
||||
value: processedValue,
|
||||
// Some additional properties that might be useful
|
||||
raw: axisValue
|
||||
};
|
||||
|
||||
// Emit the update event
|
||||
eventBus.emit('gamepad:update', update);
|
||||
|
||||
// Update the last state
|
||||
this.lastGamepadStates[gamepad.index].axes[a] = processedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
this.animationFrameId = requestAnimationFrame(pollGamepads);
|
||||
};
|
||||
|
||||
// Start the polling loop
|
||||
this.animationFrameId = requestAnimationFrame(pollGamepads);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
_handleGamepadConnected(event) {
|
||||
const gamepad = event.gamepad;
|
||||
this.gamepads[gamepad.index] = gamepad;
|
||||
|
||||
// Automatically connect new gamepads for input polling
|
||||
// This ensures inputs are processed without requiring manual connection
|
||||
this.connect(gamepad.index.toString());
|
||||
|
||||
// Emit gamepad connected event
|
||||
eventBus.emit('gamepad:deviceConnected', {
|
||||
deviceId: gamepad.index.toString(),
|
||||
deviceName: gamepad.id,
|
||||
deviceType: 'gamepad'
|
||||
});
|
||||
|
||||
// Update gamepad list
|
||||
eventBus.emit('gamepad:stateChanged', this.getGamepads());
|
||||
}
|
||||
|
||||
_handleGamepadDisconnected(event) {
|
||||
const gamepad = event.gamepad;
|
||||
|
||||
// Stop tracking this gamepad
|
||||
delete this.gamepads[gamepad.index];
|
||||
delete this.lastGamepadStates[gamepad.index];
|
||||
|
||||
// Remove from connected gamepads
|
||||
this.connectedGamepads.delete(gamepad.index.toString());
|
||||
|
||||
// Emit gamepad disconnected event
|
||||
eventBus.emit('gamepad:deviceDisconnected', {
|
||||
deviceId: gamepad.index.toString(),
|
||||
deviceName: gamepad.id,
|
||||
deviceType: 'gamepad'
|
||||
});
|
||||
|
||||
// Update gamepad list
|
||||
eventBus.emit('gamepad:stateChanged', this.getGamepads());
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a gamepad to start receiving updates
|
||||
* @param {string} gamepadId - The gamepad ID to connect to (usually its index as string)
|
||||
* @returns {Promise<string>} - The connected gamepad ID
|
||||
*/
|
||||
async connect(gamepadId) {
|
||||
try {
|
||||
if (!this.initialized) {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
// Get the numeric index from the string ID
|
||||
const index = parseInt(gamepadId);
|
||||
|
||||
// Check if the gamepad exists
|
||||
const gamepads = navigator.getGamepads();
|
||||
if (!gamepads[index]) {
|
||||
throw new Error(`Gamepad with ID ${gamepadId} not found`);
|
||||
}
|
||||
|
||||
// Mark this gamepad as connected (for polling)
|
||||
this.connectedGamepads.set(gamepadId, gamepads[index].id);
|
||||
|
||||
// Initialize the last state if needed
|
||||
if (!this.lastGamepadStates[index]) {
|
||||
this.lastGamepadStates[index] = {
|
||||
buttons: Array(gamepads[index].buttons.length).fill(0),
|
||||
axes: Array(gamepads[index].axes.length).fill(0)
|
||||
};
|
||||
}
|
||||
|
||||
// Emit connected event
|
||||
eventBus.emit('gamepad:deviceConnected', {
|
||||
deviceId: gamepadId,
|
||||
deviceName: gamepads[index].id,
|
||||
deviceType: 'gamepad'
|
||||
});
|
||||
|
||||
return gamepadId;
|
||||
} catch (error) {
|
||||
console.error(`GamepadController: Error connecting to gamepad ${gamepadId}:`, error);
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('gamepad:error', {
|
||||
message: `Error connecting to gamepad ${gamepadId}`,
|
||||
error
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from a gamepad to stop receiving updates
|
||||
* @param {string} gamepadId - The gamepad ID to disconnect from (usually its index as string)
|
||||
* @returns {Promise<boolean>} - Whether disconnection was successful
|
||||
*/
|
||||
async disconnect(gamepadId) {
|
||||
try {
|
||||
// Get the numeric index from the string ID
|
||||
const index = parseInt(gamepadId);
|
||||
|
||||
// Check if the gamepad is connected
|
||||
if (!this.connectedGamepads.has(gamepadId)) {
|
||||
throw new Error(`Gamepad with ID ${gamepadId} not found or already disconnected`);
|
||||
}
|
||||
|
||||
// Stop tracking this gamepad
|
||||
this.connectedGamepads.delete(gamepadId);
|
||||
|
||||
// Emit disconnected event
|
||||
eventBus.emit('gamepad:deviceDisconnected', {
|
||||
deviceId: gamepadId,
|
||||
deviceName: this.gamepads[index]?.id || "Unknown",
|
||||
deviceType: 'gamepad'
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`GamepadController: Error disconnecting from gamepad ${gamepadId}:`, error);
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('gamepad:error', {
|
||||
message: `Error disconnecting from gamepad ${gamepadId}`,
|
||||
error
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available gamepad devices
|
||||
* @returns {Array} Array of objects with gamepad information
|
||||
*/
|
||||
getGamepads() {
|
||||
// Get fresh gamepad data from the API
|
||||
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
const gamepadList = [];
|
||||
|
||||
// Filter connected gamepads
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
const gamepad = gamepads[i];
|
||||
if (gamepad) {
|
||||
gamepadList.push({
|
||||
id: gamepad.index.toString(),
|
||||
name: gamepad.id,
|
||||
type: 'gamepad',
|
||||
state: this.connectedGamepads.has(gamepad.index.toString()) ? 'connected' : 'detected',
|
||||
buttons: gamepad.buttons.length,
|
||||
axes: gamepad.axes.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return gamepadList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available devices (for external API)
|
||||
* @returns {Array} List of available devices
|
||||
*/
|
||||
getAvailableDevices() {
|
||||
return this.getGamepads();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method to maintain backwards compatibility
|
||||
*/
|
||||
onUpdate(callback) {
|
||||
// Subscribe the callback to the gamepad:update event
|
||||
return eventBus.on('gamepad:update', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method to maintain backwards compatibility
|
||||
*/
|
||||
onStateChange(callback) {
|
||||
// Subscribe the callback to the gamepad:stateChanged event
|
||||
const unsubscribe = eventBus.on('gamepad:stateChanged', callback);
|
||||
|
||||
// Immediately provide current state if already initialized
|
||||
if (this.initialized) {
|
||||
callback(this.getGamepads());
|
||||
}
|
||||
|
||||
return unsubscribe;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Web MIDI Controller Implementation using the Web MIDI API
|
||||
*/
|
||||
|
||||
import { eventBus } from "../core/eventBus.js";
|
||||
|
||||
export class WebMidiController {
|
||||
constructor() {
|
||||
this.midiAccess = null;
|
||||
this.inputs = new Map();
|
||||
this.outputs = new Map();
|
||||
this.initialized = false;
|
||||
|
||||
// Set up event bus subscriptions
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
_setupEventListeners() {
|
||||
// Listen for requests to connect/disconnect devices
|
||||
eventBus.on('midi:connect', async (deviceId) => {
|
||||
await this.connect(deviceId);
|
||||
});
|
||||
|
||||
eventBus.on('midi:disconnect', async (deviceId) => {
|
||||
await this.disconnect(deviceId);
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (!navigator.requestMIDIAccess) {
|
||||
console.error("WebMidiController: Web MIDI API not supported in this browser.");
|
||||
eventBus.emit('midi:error', { message: "Web MIDI API not supported in this browser" });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.midiAccess = await navigator.requestMIDIAccess();
|
||||
this.initialized = true;
|
||||
|
||||
this.midiAccess.onstatechange = (event) => this._handleStateChange(event);
|
||||
this._updateDeviceLists();
|
||||
|
||||
// Re-attach handlers to make sure they're set
|
||||
this.midiAccess.inputs.forEach((input) => {
|
||||
input.onmidimessage = (event) => {
|
||||
this._handleMidiMessage(event);
|
||||
};
|
||||
});
|
||||
|
||||
// Emit initialization event
|
||||
eventBus.emit('midi:initialized', {
|
||||
inputs: this.getInputs(),
|
||||
outputs: this.getOutputs()
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("WebMidiController: Could not access MIDI devices.", error);
|
||||
this.initialized = false;
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('midi:error', {
|
||||
message: "Could not access MIDI devices",
|
||||
error
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_updateDeviceLists() {
|
||||
// Clear existing lists
|
||||
this.inputs.clear();
|
||||
this.outputs.clear();
|
||||
|
||||
// Populate inputs
|
||||
this.midiAccess.inputs.forEach((input) => {
|
||||
this.inputs.set(input.id, input);
|
||||
|
||||
// Detach any existing handler to prevent duplicates
|
||||
if (input.onmidimessage) {
|
||||
input.onmidimessage = null;
|
||||
}
|
||||
|
||||
// Attach handler
|
||||
input.onmidimessage = (event) => {
|
||||
this._handleMidiMessage(event);
|
||||
};
|
||||
});
|
||||
|
||||
// Populate outputs
|
||||
this.midiAccess.outputs.forEach((output) => {
|
||||
this.outputs.set(output.id, output);
|
||||
});
|
||||
|
||||
// Emit state change event
|
||||
eventBus.emit('midi:stateChanged', this.getInputs(), this.getOutputs());
|
||||
}
|
||||
|
||||
_handleStateChange(event) {
|
||||
const port = event.port;
|
||||
const isConnected = port.state === 'connected';
|
||||
const deviceType = port.type === 'input' ? 'Input' : 'Output';
|
||||
|
||||
// Re-evaluate device lists
|
||||
this._updateDeviceLists();
|
||||
|
||||
// If an input device was connected/disconnected, update listeners
|
||||
if (deviceType === 'Input') {
|
||||
if (isConnected) {
|
||||
// Ensure the listener is attached to the new input
|
||||
port.onmidimessage = (event) => this._handleMidiMessage(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit device connection/disconnection event
|
||||
eventBus.emit(`midi:device${isConnected ? 'Connected' : 'Disconnected'}`, {
|
||||
deviceId: port.id,
|
||||
deviceName: port.name,
|
||||
deviceType: deviceType.toLowerCase()
|
||||
});
|
||||
}
|
||||
|
||||
_handleMidiMessage(event) {
|
||||
try {
|
||||
if (!event || !event.data) {
|
||||
console.warn("WebMidiController: Received invalid MIDI message event:", event);
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = event.currentTarget?.id || 'unknown';
|
||||
const deviceName = event.currentTarget?.name || 'Unknown Device';
|
||||
const data = event.data;
|
||||
|
||||
if (!data.length) {
|
||||
console.warn("WebMidiController: Received empty MIDI message data");
|
||||
return;
|
||||
}
|
||||
|
||||
const [command, data1, data2] = data;
|
||||
|
||||
// Basic interpretation (can be expanded)
|
||||
const message = {
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
timestamp: event.timeStamp,
|
||||
command: command, // e.g., 144 for Note On (channel 1), 128 Note Off, 176 CC
|
||||
channel: (command & 0x0F) + 1, // MIDI channel 1-16
|
||||
control: data1, // Note number or CC number
|
||||
value: data2 || 0, // Velocity or CC value (default to 0 if undefined)
|
||||
rawData: Array.from(data) // Convert to regular array for better JSON serialization
|
||||
};
|
||||
|
||||
// Emit MIDI message event
|
||||
eventBus.emit('midi:message', message);
|
||||
} catch (error) {
|
||||
console.error("WebMidiController: Error handling MIDI message:", error, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a MIDI device
|
||||
* @param {string} deviceId - The device ID to connect to
|
||||
* @returns {Promise<string>} - The connected device ID
|
||||
*/
|
||||
async connect(deviceId) {
|
||||
try {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const input = this.inputs.get(deviceId);
|
||||
if (!input) {
|
||||
throw new Error(`MIDI input device with ID ${deviceId} not found`);
|
||||
}
|
||||
|
||||
// We don't need to do anything special to "connect" since the Web MIDI API
|
||||
// handles the connection for us. We just make sure the listener is attached.
|
||||
input.onmidimessage = (event) => this._handleMidiMessage(event);
|
||||
|
||||
// Emit connected event
|
||||
eventBus.emit('midi:deviceConnected', {
|
||||
deviceId,
|
||||
deviceName: input.name,
|
||||
deviceType: 'input'
|
||||
});
|
||||
|
||||
return deviceId;
|
||||
} catch (error) {
|
||||
console.error(`WebMidiController: Error connecting to MIDI device ${deviceId}:`, error);
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('midi:error', {
|
||||
message: `Error connecting to MIDI device ${deviceId}`,
|
||||
error
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from a MIDI device
|
||||
* @param {string} deviceId - The device ID to disconnect from
|
||||
* @returns {Promise<boolean>} - Whether disconnection was successful
|
||||
*/
|
||||
async disconnect(deviceId) {
|
||||
try {
|
||||
const input = this.inputs.get(deviceId);
|
||||
if (!input) {
|
||||
throw new Error(`MIDI input device with ID ${deviceId} not found or already disconnected`);
|
||||
}
|
||||
|
||||
// The Web MIDI API doesn't have a explicit "disconnect" method for devices
|
||||
// We can just detach our message handler
|
||||
input.onmidimessage = null;
|
||||
|
||||
// Emit disconnected event
|
||||
eventBus.emit('midi:deviceDisconnected', {
|
||||
deviceId,
|
||||
deviceName: input.name,
|
||||
deviceType: 'input'
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`WebMidiController: Error disconnecting from MIDI device ${deviceId}:`, error);
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('midi:error', {
|
||||
message: `Error disconnecting from MIDI device ${deviceId}`,
|
||||
error
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Public methods for backward compatibility
|
||||
getInputs() {
|
||||
return Array.from(this.inputs.values()).map(input => ({
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
manufacturer: input.manufacturer,
|
||||
state: input.state,
|
||||
type: input.type
|
||||
}));
|
||||
}
|
||||
|
||||
getOutputs() {
|
||||
return Array.from(this.outputs.values()).map(output => ({
|
||||
id: output.id,
|
||||
name: output.name,
|
||||
manufacturer: output.manufacturer,
|
||||
state: output.state,
|
||||
type: output.type
|
||||
}));
|
||||
}
|
||||
|
||||
sendMessage(outputId, data) {
|
||||
const output = this.outputs.get(outputId);
|
||||
if (output) {
|
||||
output.send(data); // data should be Uint8Array or array of bytes
|
||||
|
||||
// Emit message sent event
|
||||
eventBus.emit('midi:messageSent', {
|
||||
deviceId: outputId,
|
||||
data
|
||||
});
|
||||
} else {
|
||||
console.error(`WebMidiController: Output device with ID ${outputId} not found.`);
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('midi:error', {
|
||||
message: `Output device with ID ${outputId} not found`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method to maintain backwards compatibility
|
||||
*/
|
||||
onMessage(callback) {
|
||||
// Subscribe the callback to the midi:message event
|
||||
return eventBus.on('midi:message', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method to maintain backwards compatibility
|
||||
*/
|
||||
onStateChange(callback) {
|
||||
// Subscribe the callback to the midi:stateChanged event
|
||||
const unsubscribe = eventBus.on('midi:stateChanged', callback);
|
||||
|
||||
// Immediately provide current state if already initialized
|
||||
if (this.initialized) {
|
||||
callback(this.getInputs(), this.getOutputs());
|
||||
}
|
||||
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available MIDI devices (inputs only)
|
||||
* @returns {Array} Array of available MIDI input devices
|
||||
*/
|
||||
getAvailableDevices() {
|
||||
return this.getInputs().map(input => ({
|
||||
id: input.id,
|
||||
name: input.name || 'Unknown MIDI Device',
|
||||
type: 'midi',
|
||||
state: input.state || 'unknown'
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Command Executor for ComfyUI_ControlFreak
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class to handle command registration and execution
|
||||
*/
|
||||
export class CommandExecutor {
|
||||
constructor(app) {
|
||||
this.commandHandlers = {}; // Object to store command handlers
|
||||
this.app = app;
|
||||
// Access the ComfyUI API if available
|
||||
this.api = window.comfyAPI?.api?.api;
|
||||
// Access ComfyUI command store if available
|
||||
this.commandStore = window.comfyAPI?.stores?.useCommandStore?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for a specific command
|
||||
* @param {String} commandId - The command ID
|
||||
* @param {Function} handler - The function to call when the command is triggered
|
||||
*/
|
||||
registerCommandHandler(commandId, handler) {
|
||||
if (typeof handler !== 'function') {
|
||||
console.error(`CommandExecutor: Handler for command ${commandId} must be a function.`);
|
||||
return;
|
||||
}
|
||||
this.commandHandlers[commandId] = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command if a handler is registered
|
||||
* @param {String} commandId - The command ID to execute
|
||||
* @returns {Boolean} Whether the command was handled
|
||||
*/
|
||||
executeCommand(commandId) {
|
||||
// 1. Try our own registered handlers first
|
||||
const handler = this.commandHandlers[commandId];
|
||||
if (handler) {
|
||||
try {
|
||||
handler();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`CommandExecutor: Error executing command ${commandId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try to use the ComfyUI command store if available (preferred method)
|
||||
if (this.commandStore && typeof this.commandStore.execute === 'function') {
|
||||
try {
|
||||
this.commandStore.execute(commandId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Fall through to our direct implementations
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to our direct implementations
|
||||
if (this.app) {
|
||||
switch (commandId) {
|
||||
// Methods available on the app object
|
||||
case "Comfy.QueuePrompt":
|
||||
if (typeof this.app.queuePrompt === 'function') {
|
||||
this.app.queuePrompt(0);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Comfy.QueuePromptFront":
|
||||
if (typeof this.app.queuePrompt === 'function') {
|
||||
this.app.queuePrompt(-1);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Comfy.OpenClipspace":
|
||||
if (typeof this.app.openClipspace === 'function') {
|
||||
this.app.openClipspace();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Comfy.RefreshNodeDefinitions":
|
||||
if (typeof this.app.refreshComboInNodes === 'function') {
|
||||
this.app.refreshComboInNodes();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Comfy.Canvas.ZoomIn":
|
||||
if (this.app.canvas && this.app.canvas.ds) {
|
||||
const ds = this.app.canvas.ds;
|
||||
ds.changeScale(
|
||||
ds.scale * 1.1,
|
||||
ds.element ? [ds.element.width / 2, ds.element.height / 2] : undefined
|
||||
);
|
||||
this.app.canvas.setDirty(true, true);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Comfy.Canvas.ZoomOut":
|
||||
if (this.app.canvas && this.app.canvas.ds) {
|
||||
const ds = this.app.canvas.ds;
|
||||
ds.changeScale(
|
||||
ds.scale / 1.1,
|
||||
ds.element ? [ds.element.width / 2, ds.element.height / 2] : undefined
|
||||
);
|
||||
this.app.canvas.setDirty(true, true);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Comfy.Canvas.FitView":
|
||||
if (this.app.canvas) {
|
||||
if (this.app.canvas.empty) {
|
||||
console.warn("Canvas is empty, cannot fit view");
|
||||
return false;
|
||||
}
|
||||
this.app.canvas.fitViewToSelectionAnimated();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Comfy.Canvas.ToggleLock":
|
||||
if (this.app.canvas) {
|
||||
this.app.canvas["read_only"] = !this.app.canvas["read_only"];
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
// Methods that use the API object
|
||||
case "Comfy.Interrupt":
|
||||
if (this.api && typeof this.api.interrupt === 'function') {
|
||||
this.api.interrupt();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
case "Comfy.OpenWorkflow":
|
||||
if (this.app.ui && typeof this.app.ui.loadFile === 'function') {
|
||||
this.app.ui.loadFile();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Comfy.ClearWorkflow":
|
||||
if (typeof this.app.clean === 'function' && this.app.graph) {
|
||||
this.app.clean();
|
||||
this.app.graph.clear();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're handling a sidebar tab command and the workspaceStore is available
|
||||
if (commandId.startsWith("Workspace.ToggleSidebarTab.") && window.comfyAPI?.stores?.useWorkspaceStore) {
|
||||
const tabName = commandId.split(".").pop();
|
||||
try {
|
||||
const workspaceStore = window.comfyAPI.stores.useWorkspaceStore();
|
||||
if (workspaceStore && workspaceStore.sidebarTab && typeof workspaceStore.sidebarTab.toggleSidebarTab === 'function') {
|
||||
workspaceStore.sidebarTab.toggleSidebarTab(tabName);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`CommandExecutor: Error executing sidebar tab command ${commandId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`CommandExecutor: No handler registered for command ${commandId}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* ContextProvider - A centralized registry for core instances
|
||||
* Replaces global variables with a singleton registry pattern
|
||||
*/
|
||||
|
||||
class ControlFreakContext {
|
||||
constructor() {
|
||||
this._instances = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an instance with the context
|
||||
* @param {string} key - The key to store the instance under
|
||||
* @param {any} instance - The instance to store
|
||||
* @returns {any} - The registered instance
|
||||
*/
|
||||
register(key, instance) {
|
||||
this._instances.set(key, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an instance from the context
|
||||
* @param {string} key - The key of the instance to retrieve
|
||||
* @returns {any} - The instance or undefined if not found
|
||||
*/
|
||||
get(key) {
|
||||
return this._instances.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an instance exists in the context
|
||||
* @param {string} key - The key to check
|
||||
* @returns {boolean} - Whether the instance exists
|
||||
*/
|
||||
has(key) {
|
||||
return this._instances.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an instance from the context
|
||||
* @param {string} key - The key of the instance to remove
|
||||
* @returns {boolean} - Whether the instance was removed
|
||||
*/
|
||||
remove(key) {
|
||||
return this._instances.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered keys
|
||||
* @returns {Array<string>} - Array of registered keys
|
||||
*/
|
||||
getKeys() {
|
||||
return Array.from(this._instances.keys());
|
||||
}
|
||||
|
||||
// Static singleton instance
|
||||
static getInstance() {
|
||||
if (!ControlFreakContext._instance) {
|
||||
ControlFreakContext._instance = new ControlFreakContext();
|
||||
}
|
||||
return ControlFreakContext._instance;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the singleton instance
|
||||
export const contextProvider = ControlFreakContext.getInstance();
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* EventBus - A centralized event system for cross-module communication
|
||||
* Replaces direct global variable access with pub/sub pattern
|
||||
*/
|
||||
|
||||
export class EventBus {
|
||||
constructor() {
|
||||
this._handlers = new Map();
|
||||
this._lastEvents = new Map(); // Stores last event data for late subscribers
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event
|
||||
* @param {string} event - The event name to listen for
|
||||
* @param {Function} handler - The callback function
|
||||
* @param {boolean} receiveLastEvent - Whether to immediately receive the last event
|
||||
* @returns {Function} - Unsubscribe function
|
||||
*/
|
||||
on(event, handler, receiveLastEvent = false) {
|
||||
if (!this._handlers.has(event)) {
|
||||
this._handlers.set(event, []);
|
||||
}
|
||||
this._handlers.get(event).push(handler);
|
||||
|
||||
// Call handler with last event data if requested and available
|
||||
if (receiveLastEvent && this._lastEvents.has(event)) {
|
||||
const lastEventData = this._lastEvents.get(event);
|
||||
handler(...lastEventData);
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => this.off(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event
|
||||
* @param {string} event - The event name
|
||||
* @param {Function} handler - The callback function to remove
|
||||
*/
|
||||
off(event, handler) {
|
||||
if (!this._handlers.has(event)) return;
|
||||
const handlers = this._handlers.get(event);
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index !== -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all subscribers
|
||||
* @param {string} event - The event name
|
||||
* @param {...any} args - Arguments to pass to handlers
|
||||
*/
|
||||
emit(event, ...args) {
|
||||
// Store last event data
|
||||
this._lastEvents.set(event, args);
|
||||
|
||||
// Call current handlers
|
||||
if (this._handlers.has(event)) {
|
||||
for (const handler of this._handlers.get(event)) {
|
||||
try {
|
||||
handler(...args);
|
||||
} catch (error) {
|
||||
console.error(`EventBus: Error in handler for event ${event}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all handlers for an event
|
||||
* @param {string} event - The event name
|
||||
*/
|
||||
clear(event) {
|
||||
if (event) {
|
||||
this._handlers.delete(event);
|
||||
this._lastEvents.delete(event);
|
||||
} else {
|
||||
this._handlers.clear();
|
||||
this._lastEvents.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all event names with active handlers
|
||||
* @returns {Array<string>} - Array of event names
|
||||
*/
|
||||
getEventNames() {
|
||||
return Array.from(this._handlers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of handlers for an event
|
||||
* @param {string} event - The event name
|
||||
* @returns {number} - Number of handlers
|
||||
*/
|
||||
getHandlerCount(event) {
|
||||
return this._handlers.has(event) ? this._handlers.get(event).length : 0;
|
||||
}
|
||||
|
||||
// Static singleton instance
|
||||
static getInstance() {
|
||||
if (!EventBus._instance) {
|
||||
EventBus._instance = new EventBus();
|
||||
}
|
||||
return EventBus._instance;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the singleton instance
|
||||
export const eventBus = EventBus.getInstance();
|
||||
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* Learning mode functionality for controller mapping
|
||||
* Manages the process of learning controller inputs and creating mappings (Client-Side)
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { contextProvider } from './contextProvider.js';
|
||||
import { showLearningDialog, hideLearningDialog } from '../ui/dialogs.js';
|
||||
import { detectMappingType, getDefaultSettingsForWidget } from './mappingTypes.js';
|
||||
import { showNotification } from '../ui/notifications.js';
|
||||
import { LearningStateManager } from './learningStateManager.js';
|
||||
import { eventBus } from './eventBus.js';
|
||||
|
||||
// Get instances from the context provider
|
||||
const getContext = () => {
|
||||
const mappingEngine = contextProvider.get('mappingEngine');
|
||||
const learningState = contextProvider.get('learningState');
|
||||
const eventBus = contextProvider.get('eventBus');
|
||||
|
||||
if (!mappingEngine || !learningState || !eventBus) {
|
||||
console.warn("ControlFreak: Core components not available, functionality may be limited");
|
||||
}
|
||||
|
||||
return { mappingEngine, learningState, eventBus };
|
||||
};
|
||||
|
||||
// --- Learning Functions ---
|
||||
|
||||
/**
|
||||
* Start the learning process for a node parameter
|
||||
* @param {String|Number} nodeId - The node ID
|
||||
* @param {String} paramName - The parameter name
|
||||
*/
|
||||
async function startMappingNode(nodeId, paramName) {
|
||||
try {
|
||||
const { learningState } = getContext();
|
||||
if (!learningState) {
|
||||
throw new Error("Learning state manager not available");
|
||||
}
|
||||
|
||||
// Find the node and parameter info
|
||||
const node = app.graph.getNodeById(+nodeId);
|
||||
if (!node) {
|
||||
throw new Error(`Node ${nodeId} not found`);
|
||||
}
|
||||
|
||||
// Find the widget
|
||||
const widget = node.widgets.find(w => w.name === paramName);
|
||||
if (!widget) {
|
||||
throw new Error(`Widget ${paramName} not found on node ${nodeId}`);
|
||||
}
|
||||
|
||||
// Create the target information
|
||||
const target = {
|
||||
type: "widget",
|
||||
nodeId: +nodeId,
|
||||
widgetName: paramName,
|
||||
nodeTitle: node.title,
|
||||
widgetConfig: null,
|
||||
comfyClass: node.comfyClass
|
||||
};
|
||||
|
||||
// Add widget configuration for min/max if available
|
||||
if (widget) {
|
||||
target.widgetConfig = {
|
||||
type: widget.type,
|
||||
config: {
|
||||
min: widget.options?.min,
|
||||
max: widget.options?.max,
|
||||
step: widget.options?.step,
|
||||
precision: widget.options?.precision
|
||||
},
|
||||
current_value: widget.value
|
||||
};
|
||||
}
|
||||
|
||||
// Start learning mode with this target
|
||||
LearningStateManager.getInstance().startLearning('widget', target);
|
||||
|
||||
// Show the learning dialog and store its reference in the state
|
||||
const { dialog } = showLearningDialog(target);
|
||||
LearningStateManager.getInstance().setDialog(dialog);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Controller: Error starting mapping:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the learning process for a UI element
|
||||
* @param {HTMLElement} element - The UI element to map
|
||||
*/
|
||||
function startMappingUI(element) {
|
||||
try {
|
||||
const { learningState } = getContext();
|
||||
if (!learningState) {
|
||||
throw new Error("Learning state manager not available");
|
||||
}
|
||||
|
||||
// Get element info
|
||||
const elementId = element.id || "unknown-element";
|
||||
const elementType = element.tagName.toLowerCase();
|
||||
|
||||
// Create target info
|
||||
const target = {
|
||||
type: "ui_element",
|
||||
elementId,
|
||||
elementType,
|
||||
elementLabel: element.textContent || element.title || elementId
|
||||
};
|
||||
|
||||
// Start learning mode with this target
|
||||
LearningStateManager.getInstance().startLearning('ui', target);
|
||||
|
||||
// Show the learning dialog and store its reference in the state
|
||||
const { dialog } = showLearningDialog(target);
|
||||
LearningStateManager.getInstance().setDialog(dialog);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Controller: Error starting mapping for UI element:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the learning process for a command
|
||||
* @param {String} commandId - The command ID
|
||||
* @param {Object} options - Additional options
|
||||
*/
|
||||
function startMappingCommand(commandId, options = {}) {
|
||||
try {
|
||||
const { learningState } = getContext();
|
||||
if (!learningState) {
|
||||
throw new Error("Learning state manager not available");
|
||||
}
|
||||
|
||||
// Create target info
|
||||
const target = {
|
||||
type: "command",
|
||||
commandId,
|
||||
label: options.label || commandId,
|
||||
description: options.description || ""
|
||||
};
|
||||
|
||||
// Start learning mode with this target
|
||||
LearningStateManager.getInstance().startLearning('command', target);
|
||||
|
||||
// Show the learning dialog and store its reference in the state
|
||||
const { dialog } = showLearningDialog(target);
|
||||
LearningStateManager.getInstance().setDialog(dialog);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Controller: Error starting mapping for command:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the learning process
|
||||
*/
|
||||
function cancelLearning() {
|
||||
try {
|
||||
const { learningState } = getContext();
|
||||
if (!learningState) {
|
||||
throw new Error("Learning state manager not available");
|
||||
}
|
||||
|
||||
// Reset learning state
|
||||
learningState.cancelLearning();
|
||||
|
||||
// Hide the learning dialog
|
||||
hideLearningDialog();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Controller: Error canceling learning:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the mapping process
|
||||
* @param {Object} selectedControl - The selected controller input
|
||||
* @param {Object} target - The target information
|
||||
* @param {Number} targetMin - Override minimum value
|
||||
* @param {Number} targetMax - Override maximum value
|
||||
* @param {String} mappingType - Type of mapping
|
||||
* @param {Number} stepSize - Step size for increment/decrement mappings
|
||||
* @param {Boolean} isInverted - Whether the output should be inverted
|
||||
*/
|
||||
async function completeMapping(selectedControl, target, targetMin, targetMax, mappingType, stepSize, isInverted) {
|
||||
try {
|
||||
|
||||
|
||||
const { mappingEngine, learningState } = getContext();
|
||||
if (!mappingEngine || !learningState) {
|
||||
throw new Error("Core components not available");
|
||||
}
|
||||
|
||||
// Check if there are existing mappings for this widget
|
||||
let existingTransform = null;
|
||||
if (target.type === 'widget') {
|
||||
const existingMappings = mappingEngine.getMappings().filter(m =>
|
||||
m.target &&
|
||||
m.target.type === 'widget' &&
|
||||
m.target.nodeId === target.nodeId &&
|
||||
m.target.widgetName === target.widgetName
|
||||
);
|
||||
|
||||
// Find the first mapping with transform data as the source of truth
|
||||
const sourceMapping = existingMappings.find(m => m.transform &&
|
||||
(typeof m.transform.targetMin === 'number' || typeof m.transform.targetMax === 'number'));
|
||||
|
||||
if (sourceMapping && sourceMapping.transform) {
|
||||
existingTransform = sourceMapping.transform;
|
||||
// Use existing min/max if provided ones are undefined
|
||||
if (targetMin === undefined && typeof existingTransform.targetMin === 'number') {
|
||||
targetMin = existingTransform.targetMin;
|
||||
}
|
||||
if (targetMax === undefined && typeof existingTransform.targetMax === 'number') {
|
||||
targetMax = existingTransform.targetMax;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the mapping object
|
||||
const mapping = {
|
||||
id: `mapping_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
||||
profile: mappingEngine.getActiveProfile(),
|
||||
control: {
|
||||
type: selectedControl.type,
|
||||
deviceId: selectedControl.deviceId,
|
||||
id: selectedControl.controlId,
|
||||
name: selectedControl.name || `${selectedControl.type} ${selectedControl.controlId}`,
|
||||
// Add default input range based on control type
|
||||
input_min: 0, // Default
|
||||
input_max: 1 // Default
|
||||
},
|
||||
target: target,
|
||||
mappingType: mappingType || detectMappingType(selectedControl.type, target),
|
||||
transform: {
|
||||
isInverted: !!isInverted,
|
||||
// Default transform info
|
||||
}
|
||||
};
|
||||
|
||||
// Set proper input range based on control type
|
||||
if (selectedControl.type === 'midi' || selectedControl.type === 'midi_cc') {
|
||||
mapping.control.input_min = 0;
|
||||
mapping.control.input_max = 127;
|
||||
} else if (selectedControl.type === 'gamepad_axis') {
|
||||
mapping.control.input_min = -1;
|
||||
mapping.control.input_max = 1;
|
||||
}
|
||||
|
||||
// Add target range and step size to transform if provided
|
||||
if (targetMin !== undefined) mapping.transform.targetMin = targetMin;
|
||||
if (targetMax !== undefined) mapping.transform.targetMax = targetMax;
|
||||
if (stepSize !== undefined && (mapping.mappingType === "incremental" || mapping.mappingType === "decremental")) {
|
||||
mapping.transform.stepSize = stepSize;
|
||||
}
|
||||
|
||||
// Add the mapping to the engine
|
||||
mappingEngine.addMapping(mapping);
|
||||
|
||||
// Save mappings
|
||||
mappingEngine.saveMappings();
|
||||
|
||||
// Reset learning state
|
||||
learningState.completeLearning();
|
||||
|
||||
// Close dialog
|
||||
hideLearningDialog();
|
||||
|
||||
// Trigger highlight of the mapped parameter
|
||||
if (target.type === 'widget') {
|
||||
const node = app.graph.getNodeById(target.nodeId);
|
||||
if (node) {
|
||||
const canvas = app.canvas;
|
||||
if (canvas) {
|
||||
// Center on the node
|
||||
canvas.centerOnNode(node);
|
||||
|
||||
// Emit event to highlight the parameter
|
||||
const { eventBus } = getContext();
|
||||
if (eventBus) {
|
||||
eventBus.emit('learning:complete', {
|
||||
mapping: mapping,
|
||||
target: target,
|
||||
node: node
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Controller: Error completing mapping:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the Quick Learning process for a specific node widget.
|
||||
* @param {object} node - The LiteGraph node object.
|
||||
* @param {object} widget - The specific widget on the node.
|
||||
*/
|
||||
export function startQuickMapping(node, widget) {
|
||||
const learningState = LearningStateManager.getInstance();
|
||||
if (!node || !widget) {
|
||||
console.error("ControlFreak: Quick Mapping requires a valid node and widget.");
|
||||
showNotification("Error starting quick map: Node or widget invalid", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Cancel any existing learning process
|
||||
learningState.cancelLearning();
|
||||
|
||||
// Get default settings based on the widget type
|
||||
const defaultSettings = getDefaultSettingsForWidget(widget);
|
||||
|
||||
// Start the quick learning state
|
||||
learningState.startQuickLearning(node, widget, defaultSettings);
|
||||
|
||||
// Show a notification to the user
|
||||
showNotification(`Quick Mapping: Move controller for '${widget.name}'...`, "info", 5000);
|
||||
|
||||
// Note: The actual mapping creation happens when the next controller input is received
|
||||
// This will be handled by the global controller input listener
|
||||
|
||||
} catch (error) {
|
||||
console.error("ControlFreak: Error starting Quick Mapping:", error);
|
||||
showNotification("Error initiating quick mapping. Check console.", "error");
|
||||
learningState.cancelLearning(); // Ensure state is reset on error
|
||||
}
|
||||
}
|
||||
|
||||
// --- Moved Function: showDetectedInput ---
|
||||
/**
|
||||
* Show detected input during learning mode
|
||||
* @param {Object} controlInput - The formatted control input data
|
||||
* @param {Object} uiContext - UI context (optional, primarily for direct calls)
|
||||
* @returns {boolean} Whether the input was handled successfully
|
||||
*/
|
||||
function showDetectedInput(controlInput, uiContext = {}) {
|
||||
const { learningState } = getContext(); // Use getContext consistently
|
||||
if (!learningState) {
|
||||
console.error("ControlFreak: Learning state not available in showDetectedInput");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to get the UI elements from the provided context first
|
||||
let inputContainer = uiContext.inputContainer;
|
||||
let mapButton = uiContext.mapButton;
|
||||
|
||||
// If not provided, get from the learningDialog stored in the state
|
||||
if ((!inputContainer || !mapButton) && learningState.learningDialog) {
|
||||
const dialog = learningState.learningDialog;
|
||||
inputContainer = dialog.querySelector(".controller-mapping-input-list");
|
||||
mapButton = dialog.querySelector(".controller-map-button");
|
||||
}
|
||||
|
||||
// Last resort: query the DOM directly if still not found (e.g., if dialog wasn't stored in state correctly)
|
||||
if (!inputContainer) {
|
||||
const dialog = document.querySelector(".controller-mapping-dialog");
|
||||
if (dialog) {
|
||||
inputContainer = dialog.querySelector(".controller-mapping-input-list");
|
||||
mapButton = dialog.querySelector(".controller-map-button");
|
||||
}
|
||||
}
|
||||
|
||||
// If UI elements are still not found, log error and exit
|
||||
if (!inputContainer) {
|
||||
console.error("ControlFreak: Input container could not be found for showDetectedInput.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const { deviceId, controlId, type, deviceName, rawValue, name } = controlInput;
|
||||
const displayName = name || deviceName || controlId;
|
||||
|
||||
const instructionEl = inputContainer.querySelector(".controller-mapping-instruction");
|
||||
if (instructionEl) {
|
||||
inputContainer.removeChild(instructionEl);
|
||||
}
|
||||
|
||||
let controlElement = inputContainer.querySelector(`[data-control-id="${controlId}"][data-device-id="${deviceId}"]`);
|
||||
|
||||
if (!controlElement) {
|
||||
controlElement = document.createElement("div");
|
||||
controlElement.dataset.controlId = controlId;
|
||||
controlElement.dataset.deviceId = deviceId;
|
||||
const isButton = type?.includes("button");
|
||||
controlElement.dataset.controlType = isButton ? "button" : "axis";
|
||||
const controlTypeIndicator = isButton ? "🔘" : "↔️";
|
||||
|
||||
controlElement.innerHTML = `<strong>${controlTypeIndicator} ${displayName}</strong>: <span class="value">${rawValue.toFixed(2)}</span>`;
|
||||
// Apply necessary styles programmatically or ensure CSS handles this
|
||||
controlElement.style.marginBottom = "5px";
|
||||
controlElement.style.padding = "8px";
|
||||
controlElement.style.borderRadius = "3px";
|
||||
controlElement.style.cursor = "pointer";
|
||||
controlElement.style.transition = "background-color 0.3s";
|
||||
controlElement.style.backgroundColor = "#2a2a3a"; // Consider using CSS variables
|
||||
controlElement.style.border = "1px solid #444"; // Consider using CSS variables
|
||||
|
||||
const controlData = {
|
||||
deviceId: deviceId,
|
||||
controlId: controlId,
|
||||
name: displayName,
|
||||
type: type
|
||||
};
|
||||
|
||||
controlElement.addEventListener("click", () => {
|
||||
inputContainer.querySelectorAll("[data-control-id]").forEach(el => {
|
||||
el.style.backgroundColor = "#2a2a3a"; // Use CSS variable
|
||||
el.style.border = "1px solid #444"; // Use CSS variable
|
||||
});
|
||||
controlElement.style.backgroundColor = "#3a546e"; // Use CSS variable for selected state
|
||||
controlElement.style.border = "1px solid #4c8eda"; // Use CSS variable
|
||||
|
||||
if (learningState) {
|
||||
learningState.selectControl(controlData);
|
||||
}
|
||||
|
||||
const rangeContainer = document.querySelector(".controller-mapping-range");
|
||||
if (rangeContainer) rangeContainer.style.display = "flex";
|
||||
|
||||
const mappingTypeSelect = document.querySelector(".controller-mapping-type-select");
|
||||
if (mappingTypeSelect) {
|
||||
if (isButton) {
|
||||
mappingTypeSelect.value = (learningState?.learningTarget?.type === "command") ? "trigger" : "toggle";
|
||||
} else {
|
||||
mappingTypeSelect.value = "direct";
|
||||
}
|
||||
mappingTypeSelect.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
if (mapButton) mapButton.disabled = false;
|
||||
});
|
||||
|
||||
inputContainer.appendChild(controlElement);
|
||||
} else {
|
||||
const valueSpan = controlElement.querySelector(".value");
|
||||
if (valueSpan) valueSpan.textContent = rawValue.toFixed(2);
|
||||
}
|
||||
|
||||
const isSelected = learningState?.selectedControl &&
|
||||
learningState.selectedControl.deviceId === deviceId &&
|
||||
learningState.selectedControl.controlId === controlId;
|
||||
const originalBackground = isSelected ? "#3a546e" : "#2a2a3a"; // Use CSS variables
|
||||
|
||||
controlElement.style.backgroundColor = "#3a3a5c"; // Highlight color - use variable
|
||||
setTimeout(() => {
|
||||
controlElement.style.backgroundColor = originalBackground;
|
||||
}, 200);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// --- Event Listener for Unhandled Controller Input ---
|
||||
|
||||
function handleUnhandledInput(controlInput) {
|
||||
const { learningState, mappingEngine, eventBus } = getContext();
|
||||
|
||||
if (!learningState || !mappingEngine || !eventBus) {
|
||||
console.warn("ControlFreak: Learning state or mapping engine not ready for unhandled input.");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Quick Learning Handling ---
|
||||
if (learningState.isQuickLearning) {
|
||||
const quickContext = learningState.getQuickLearningContext();
|
||||
if (quickContext) {
|
||||
try {
|
||||
const { node, widget, defaultSettings } = quickContext;
|
||||
|
||||
const newMapping = {
|
||||
id: `mapping_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
||||
profile: mappingEngine.getActiveProfile(),
|
||||
control: {
|
||||
type: controlInput.type,
|
||||
deviceId: controlInput.deviceId,
|
||||
id: controlInput.controlId,
|
||||
name: controlInput.name || `${controlInput.type} ${controlInput.controlId}`,
|
||||
// Keep original input range logic
|
||||
input_min: (controlInput.type === 'midi' || controlInput.type === 'midi_cc') ? 0 : (controlInput.type === 'gamepad_axis' ? -1 : 0),
|
||||
input_max: (controlInput.type === 'midi' || controlInput.type === 'midi_cc') ? 127 : 1,
|
||||
},
|
||||
target: {
|
||||
type: "widget",
|
||||
nodeId: node.id,
|
||||
widgetName: widget.name,
|
||||
// Include comfyClass for potential future use or consistency
|
||||
comfyClass: node.comfyClass
|
||||
},
|
||||
// Use the default mapping type determined earlier
|
||||
mappingType: defaultSettings.mappingType,
|
||||
// Add transform with widget's min/max, default if undefined
|
||||
transform: {
|
||||
targetMin: widget.options?.min ?? 0, // Default to 0 if min is not defined
|
||||
targetMax: widget.options?.max ?? 1, // Default to 1 if max is not defined
|
||||
isInverted: false // Default to not inverted for quick map
|
||||
}
|
||||
};
|
||||
|
||||
// Add the mapping to the engine
|
||||
mappingEngine.addMapping(newMapping);
|
||||
|
||||
// Save mappings
|
||||
mappingEngine.saveMappings();
|
||||
|
||||
// Reset learning state
|
||||
learningState.completeLearning();
|
||||
|
||||
// Close dialog
|
||||
hideLearningDialog();
|
||||
|
||||
// Trigger highlight of the mapped parameter
|
||||
if (node) {
|
||||
const canvas = app.canvas;
|
||||
if (canvas) {
|
||||
// Center on the node
|
||||
canvas.centerOnNode(node);
|
||||
|
||||
// Emit event to highlight the parameter
|
||||
if (eventBus) {
|
||||
eventBus.emit('learning:complete', {
|
||||
mapping: newMapping,
|
||||
target: {
|
||||
type: "widget",
|
||||
nodeId: node.id,
|
||||
widgetName: widget.name,
|
||||
},
|
||||
node: node
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Controller: Error completing quick learning:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Standard Learning Dialog Handling ---
|
||||
if (learningState) {
|
||||
if (learningState.isLearning && learningState.learningDialog) {
|
||||
try {
|
||||
showDetectedInput(controlInput);
|
||||
} catch (error) {
|
||||
console.error("ControlFreak: Error updating learning dialog from unhandled input:", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Register the handler with the event bus
|
||||
// Ensure eventBus is available when this module loads
|
||||
if (eventBus) {
|
||||
eventBus.on('controller:unhandledInput', handleUnhandledInput);
|
||||
} else {
|
||||
console.error("ControlFreak: EventBus not available to register unhandled input handler.");
|
||||
// Consider a mechanism to retry registration later if needed
|
||||
}
|
||||
|
||||
// --- Existing Exported Functions --- (ensure these remain)
|
||||
export {
|
||||
startMappingNode,
|
||||
startMappingUI,
|
||||
startMappingCommand,
|
||||
cancelLearning,
|
||||
completeMapping,
|
||||
// Removed showDetectedInput as it's now internal
|
||||
// Keep startQuickMapping if it exists and is exported
|
||||
// startQuickMapping
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* LearningStateManager - Manages state related to controller mapping learning
|
||||
* Replaces global variables for learning state
|
||||
*/
|
||||
|
||||
export class LearningStateManager {
|
||||
constructor(eventBus) {
|
||||
this._eventBus = eventBus;
|
||||
this._isLearning = false;
|
||||
this._learningTarget = null;
|
||||
this._selectedControl = null;
|
||||
this._learningDialog = null;
|
||||
this._isQuickLearning = false;
|
||||
this._learningType = null;
|
||||
this._quickLearningContext = null;
|
||||
this._lastDetectedInput = null;
|
||||
|
||||
// Set up event listeners
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
_setupEventListeners() {
|
||||
// Listen for controller:input events
|
||||
this._eventBus.on('controller:input', (controlInput) => {
|
||||
// If learning mode is active, handle the input
|
||||
if (this._isLearning || this._isQuickLearning) {
|
||||
// Directly forward to handleControllerInput
|
||||
this.handleControllerInput(controlInput);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether learning mode is active
|
||||
* @returns {boolean} - Whether learning mode is active
|
||||
*/
|
||||
get isLearning() {
|
||||
return this._isLearning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current learning target
|
||||
* @returns {Object|null} - The target information or null
|
||||
*/
|
||||
get learningTarget() {
|
||||
return this._learningTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected control
|
||||
* @returns {Object|null} - The control information or null
|
||||
*/
|
||||
get selectedControl() {
|
||||
return this._selectedControl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current learning dialog element
|
||||
* @returns {HTMLElement|null} - The dialog element or null
|
||||
*/
|
||||
get learningDialog() {
|
||||
return this._learningDialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether quick learning mode is active
|
||||
* @returns {boolean} - Whether quick learning mode is active
|
||||
*/
|
||||
get isQuickLearning() {
|
||||
return this._isQuickLearning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current learning type
|
||||
* @returns {string|null} - The learning type or null
|
||||
*/
|
||||
get learningType() {
|
||||
return this._learningType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current quick learning context
|
||||
* @returns {Object|null} - The quick learning context or null
|
||||
*/
|
||||
get quickLearningContext() {
|
||||
return this._quickLearningContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last detected input
|
||||
* @returns {Object|null} - The last detected input or null
|
||||
*/
|
||||
get lastDetectedInput() {
|
||||
return this._lastDetectedInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the learning process for a target
|
||||
* @param {string} type - The learning type
|
||||
* @param {Object} target - The target information
|
||||
*/
|
||||
startLearning(type, target) {
|
||||
if (this._isLearning || this._isQuickLearning) this.cancelLearning(); // Cancel any existing learning
|
||||
this._isLearning = true;
|
||||
this._isQuickLearning = false; // Ensure quick learning is off
|
||||
this._learningType = type;
|
||||
this._learningTarget = target;
|
||||
this._lastDetectedInput = null;
|
||||
this._eventBus.emit('learning:started', { type, target });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start quick learning for a widget
|
||||
* @param {Object} node - The node information
|
||||
* @param {Object} widget - The widget information
|
||||
* @param {Object} defaultSettings - The default settings for the widget
|
||||
*/
|
||||
startQuickLearning(node, widget, defaultSettings) {
|
||||
if (this._isLearning || this._isQuickLearning) this.cancelLearning(); // Cancel any existing learning
|
||||
this._isLearning = false; // Ensure standard learning is off
|
||||
this._isQuickLearning = true;
|
||||
this._learningType = 'widget'; // Quick learning is always for widgets
|
||||
this._quickLearningContext = { node, widget, defaultSettings };
|
||||
this._lastDetectedInput = null;
|
||||
this._eventBus.emit('learning:quickStarted', { node, widget });
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a control for the current learning target
|
||||
* @param {Object} control - The control information
|
||||
*/
|
||||
selectControl(control) {
|
||||
this._selectedControl = control;
|
||||
this._eventBus.emit('learning:controlSelected', control, this._learningTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the learning process
|
||||
*/
|
||||
cancelLearning() {
|
||||
const wasLearning = this._isLearning || this._isQuickLearning;
|
||||
this._isLearning = false;
|
||||
this._isQuickLearning = false;
|
||||
this._learningTarget = null;
|
||||
this._selectedControl = null;
|
||||
this._learningType = null;
|
||||
this._quickLearningContext = null;
|
||||
this._lastDetectedInput = null;
|
||||
|
||||
if (wasLearning) {
|
||||
this._eventBus.emit('learning:canceled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the learning process with a mapping
|
||||
* @param {Object} mapping - The created mapping (optional, not always available)
|
||||
*/
|
||||
completeLearning(mapping) {
|
||||
const wasLearning = this._isLearning || this._isQuickLearning; // Check if any learning was active
|
||||
|
||||
this._isLearning = false; // Reset standard learning flag
|
||||
this._isQuickLearning = false; // Reset quick learning flag
|
||||
|
||||
const target = this._learningTarget;
|
||||
const control = this._selectedControl;
|
||||
const quickContext = this._quickLearningContext; // Capture context if needed for event
|
||||
|
||||
// Clear all learning-related state properties
|
||||
this._learningTarget = null;
|
||||
this._selectedControl = null;
|
||||
this._learningType = null;
|
||||
this._quickLearningContext = null;
|
||||
this._lastDetectedInput = null; // Also clear last detected input
|
||||
|
||||
// Close dialog if it exists (relevant for standard learning)
|
||||
if (this._learningDialog) {
|
||||
try {
|
||||
if (document.body.contains(this._learningDialog)) {
|
||||
if(this._learningDialog.cleanupEvents) this._learningDialog.cleanupEvents(); // Clean up dialog listeners
|
||||
document.body.removeChild(this._learningDialog);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("ControlFreak: Error removing learning dialog on complete:", error);
|
||||
}
|
||||
this._learningDialog = null;
|
||||
}
|
||||
|
||||
// Emit event only if learning was active
|
||||
if (wasLearning) {
|
||||
// Pass relevant info based on which mode was active
|
||||
const eventData = mapping ? { mapping, target, control } : { quickContext };
|
||||
this._eventBus.emit('learning:completed', eventData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the learning dialog element
|
||||
* @param {HTMLElement|null} dialog - The dialog element
|
||||
*/
|
||||
setDialog(dialog) {
|
||||
this._learningDialog = dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle controller input during learning
|
||||
* @param {Object} controlInput - The controller input information
|
||||
* @returns {boolean} - Whether the input was handled
|
||||
*/
|
||||
handleControllerInput(controlInput) {
|
||||
if (!this._isLearning && !this._isQuickLearning) return false;
|
||||
|
||||
this._eventBus.emit('learning:controlInput', controlInput, this._learningTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last detected input
|
||||
* @param {Object} controlInput - The controller input information
|
||||
*/
|
||||
updateDetectedInput(controlInput) {
|
||||
this._lastDetectedInput = controlInput;
|
||||
this._eventBus.emit('learning:inputDetected', controlInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get learning information
|
||||
* @returns {Object|null} - The learning information or null
|
||||
*/
|
||||
getLearningInfo() {
|
||||
if (!this._isLearning && !this._isQuickLearning) return null;
|
||||
if (this._isQuickLearning) {
|
||||
return { type: this._learningType, target: this._quickLearningContext, isQuickLearning: true };
|
||||
}
|
||||
return { type: this._learningType, target: this._learningTarget, isQuickLearning: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the quick learning context
|
||||
* @returns {Object|null} - The quick learning context or null
|
||||
*/
|
||||
getQuickLearningContext() {
|
||||
return this._isQuickLearning ? this._quickLearningContext : null;
|
||||
}
|
||||
|
||||
// Static singleton instance
|
||||
static getInstance(eventBus) {
|
||||
if (!LearningStateManager._instance) {
|
||||
LearningStateManager._instance = new LearningStateManager(eventBus);
|
||||
}
|
||||
return LearningStateManager._instance;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't export a singleton instance here
|
||||
// It will be created and registered by the main module
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Client-side Mapping Engine for ComfyUI_ControlFreak
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { highlightNodeParameter } from "../handlers/controllerEventHandlers.js";
|
||||
import { shouldTriggerCommand, transformInputValue, detectMappingType } from "./mappingTypes.js";
|
||||
import { updateWidgetValue } from "./widgetUpdater.js";
|
||||
import { CommandExecutor } from "./commandExecutor.js";
|
||||
import { PersistenceManager } from "./persistenceManager.js";
|
||||
import { WorkflowIntegration } from "./workflowIntegration.js";
|
||||
import { eventBus } from "./eventBus.js";
|
||||
|
||||
export class MappingEngine {
|
||||
constructor(eventBus) {
|
||||
this.mappings = []; // Array to hold mapping objects
|
||||
this.activeProfile = 'default';
|
||||
this.eventBus = eventBus;
|
||||
|
||||
// Initialize sub-components
|
||||
this.persistenceManager = new PersistenceManager();
|
||||
this.commandExecutor = new CommandExecutor(app);
|
||||
this.workflowIntegration = new WorkflowIntegration(app, this);
|
||||
|
||||
// Set up event listeners
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
_setupEventListeners() {
|
||||
// Listen for controller input events from the event bus
|
||||
this.eventBus.on('controller:input', (controlInput) => {
|
||||
this.processControlInput(controlInput);
|
||||
});
|
||||
|
||||
// Listen for profile change requests
|
||||
this.eventBus.on('profile:change', (profileName) => {
|
||||
this.setActiveProfile(profileName);
|
||||
});
|
||||
|
||||
// Listen for mapping events
|
||||
this.eventBus.on('mapping:add', (mapping) => {
|
||||
this.addMapping(mapping);
|
||||
});
|
||||
|
||||
this.eventBus.on('mapping:update', (mappingId, updatedProperties) => {
|
||||
this.updateMapping(mappingId, updatedProperties);
|
||||
});
|
||||
|
||||
this.eventBus.on('mapping:delete', (mappingId) => {
|
||||
this.deleteMapping(mappingId);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mapping Management ---
|
||||
|
||||
addMapping(mapping) {
|
||||
// Add validation for the mapping object structure
|
||||
if (!mapping.id) {
|
||||
mapping.id = this.generateMappingId();
|
||||
}
|
||||
|
||||
// Check if a mapping with the same ID already exists
|
||||
if (this.mappings.some(m => m.id === mapping.id)) {
|
||||
console.warn(`MappingEngine: Mapping with ID ${mapping.id} already exists. Skipping add.`);
|
||||
return; // Don't add duplicates
|
||||
}
|
||||
|
||||
mapping.profile = mapping.profile || this.activeProfile;
|
||||
|
||||
// Check if this is a widget mapping with min/max overrides
|
||||
if (mapping.target && mapping.target.type === 'widget' && mapping.transform) {
|
||||
// Find any existing mappings to the same widget
|
||||
const existingMappings = this.mappings.filter(m =>
|
||||
m.profile === mapping.profile &&
|
||||
m.target &&
|
||||
m.target.type === 'widget' &&
|
||||
m.target.nodeId === mapping.target.nodeId &&
|
||||
m.target.widgetName === mapping.target.widgetName
|
||||
);
|
||||
|
||||
if (existingMappings.length > 0) {
|
||||
// Use the first existing mapping with transform data as the source of truth
|
||||
const sourceMapping = existingMappings.find(m => m.transform &&
|
||||
(typeof m.transform.targetMin === 'number' || typeof m.transform.targetMax === 'number'));
|
||||
|
||||
if (sourceMapping && sourceMapping.transform) {
|
||||
// Override the new mapping's min/max with the existing values
|
||||
if (typeof sourceMapping.transform.targetMin === 'number') {
|
||||
mapping.transform.targetMin = sourceMapping.transform.targetMin;
|
||||
}
|
||||
if (typeof sourceMapping.transform.targetMax === 'number') {
|
||||
mapping.transform.targetMax = sourceMapping.transform.targetMax;
|
||||
}
|
||||
} else if (mapping.transform.targetMin !== undefined || mapping.transform.targetMax !== undefined) {
|
||||
// This is the first mapping with overrides - apply to all existing mappings
|
||||
for (const existingMapping of existingMappings) {
|
||||
if (!existingMapping.transform) {
|
||||
existingMapping.transform = {};
|
||||
}
|
||||
|
||||
if (typeof mapping.transform.targetMin === 'number') {
|
||||
existingMapping.transform.targetMin = mapping.transform.targetMin;
|
||||
}
|
||||
if (typeof mapping.transform.targetMax === 'number') {
|
||||
existingMapping.transform.targetMax = mapping.transform.targetMax;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.mappings.push(mapping);
|
||||
// Saving should happen explicitly when needed (e.g., after import, UI changes), not always on add.
|
||||
// this.saveMappings();
|
||||
|
||||
// Emit mapping added event
|
||||
this.eventBus.emit('mapping:added', mapping);
|
||||
}
|
||||
|
||||
updateMapping(mappingId, updatedProperties) {
|
||||
const index = this.mappings.findIndex(m => m.id === mappingId);
|
||||
if (index !== -1) {
|
||||
const previousMapping = { ...this.mappings[index] };
|
||||
this.mappings[index] = { ...previousMapping, ...updatedProperties };
|
||||
|
||||
// Check if this update affects min/max overrides for a widget mapping
|
||||
const updatedMapping = this.mappings[index];
|
||||
if (updatedMapping.target &&
|
||||
updatedMapping.target.type === 'widget' &&
|
||||
updatedProperties.transform &&
|
||||
(typeof updatedProperties.transform.targetMin === 'number' ||
|
||||
typeof updatedProperties.transform.targetMax === 'number')) {
|
||||
|
||||
// Find all other mappings to the same widget
|
||||
const relatedMappings = this.mappings.filter(m =>
|
||||
m.id !== mappingId &&
|
||||
m.profile === updatedMapping.profile &&
|
||||
m.target &&
|
||||
m.target.type === 'widget' &&
|
||||
m.target.nodeId === updatedMapping.target.nodeId &&
|
||||
m.target.widgetName === updatedMapping.target.widgetName
|
||||
);
|
||||
|
||||
// Update all related mappings with the same min/max overrides
|
||||
for (const relatedMapping of relatedMappings) {
|
||||
// Initialize transform object if it doesn't exist
|
||||
if (!relatedMapping.transform) {
|
||||
relatedMapping.transform = {};
|
||||
}
|
||||
|
||||
// Synchronize the min/max values
|
||||
if (typeof updatedProperties.transform.targetMin === 'number') {
|
||||
relatedMapping.transform.targetMin = updatedProperties.transform.targetMin;
|
||||
}
|
||||
if (typeof updatedProperties.transform.targetMax === 'number') {
|
||||
relatedMapping.transform.targetMax = updatedProperties.transform.targetMax;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.saveMappings();
|
||||
|
||||
// Emit mapping updated event
|
||||
this.eventBus.emit('mapping:updated', this.mappings[index], previousMapping);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
deleteMapping(mappingId) {
|
||||
const initialLength = this.mappings.length;
|
||||
const deletedMapping = this.getMappingById(mappingId);
|
||||
|
||||
this.mappings = this.mappings.filter(m => m.id !== mappingId);
|
||||
|
||||
if (this.mappings.length < initialLength) {
|
||||
this.saveMappings();
|
||||
|
||||
// Emit mapping deleted event
|
||||
if (deletedMapping) {
|
||||
this.eventBus.emit('mapping:deleted', deletedMapping);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`MappingEngine: Mapping with ID ${mappingId} not found for deletion.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getMappingById(mappingId) {
|
||||
return this.mappings.find(m => m.id === mappingId);
|
||||
}
|
||||
|
||||
getMappings(profile = null) {
|
||||
const targetProfile = profile || this.activeProfile;
|
||||
return this.mappings.filter(m => m.profile === targetProfile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mappings for a specific widget
|
||||
* @param {String|Number} nodeId - The node ID
|
||||
* @param {String} widgetName - The widget name
|
||||
* @param {String} profile - Optional profile name (defaults to active profile)
|
||||
* @returns {Array} - Array of mappings for the widget
|
||||
*/
|
||||
getMappingsForWidget(nodeId, widgetName, profile = null) {
|
||||
const allMappings = this.getMappings(profile);
|
||||
return allMappings.filter(mapping =>
|
||||
mapping.target?.type === 'widget' &&
|
||||
mapping.target?.nodeId == nodeId && // Use == to allow string/number comparison
|
||||
mapping.target?.widgetName === widgetName
|
||||
);
|
||||
}
|
||||
|
||||
// --- Profile Management ---
|
||||
|
||||
setActiveProfile(profileName) {
|
||||
const previousProfile = this.activeProfile;
|
||||
this.activeProfile = profileName;
|
||||
|
||||
// Save mappings to workflow when profile changes
|
||||
this.saveMappings();
|
||||
|
||||
// Sync to workflow if exists
|
||||
this.workflowIntegration.syncProfileToWorkflow();
|
||||
|
||||
// Emit profile changed event
|
||||
this.eventBus.emit('profile:changed', profileName, previousProfile);
|
||||
|
||||
}
|
||||
|
||||
getActiveProfile() {
|
||||
return this.activeProfile;
|
||||
}
|
||||
|
||||
getAvailableProfiles() {
|
||||
const profiles = new Set(this.mappings.map(m => m.profile));
|
||||
// Ensure 'default' profile always exists
|
||||
profiles.add('default');
|
||||
return Array.from(profiles);
|
||||
}
|
||||
|
||||
// --- Event Processing ---
|
||||
|
||||
processControlInput(controlInput) {
|
||||
// Emit an event for this control input (useful for learning mode)
|
||||
this.eventBus.emit('controller:inputProcessed', controlInput);
|
||||
|
||||
// Ensure rawValue exists
|
||||
if (typeof controlInput.rawValue === 'undefined') {
|
||||
console.warn("MappingEngine: Received control input without rawValue:", controlInput);
|
||||
return; // Cannot process without rawValue
|
||||
}
|
||||
|
||||
const relevantMappings = this.getMappings().filter(m =>
|
||||
m.control.deviceId === controlInput.deviceId &&
|
||||
m.control.id === controlInput.controlId &&
|
||||
m.control.type === controlInput.type
|
||||
);
|
||||
|
||||
// Process existing mappings (if any)
|
||||
for (const mapping of relevantMappings) {
|
||||
// Pass the rawValue to applyMapping
|
||||
this.applyMapping(mapping, controlInput.rawValue);
|
||||
}
|
||||
|
||||
// Always emit unhandledInput *after* processing existing mappings.
|
||||
// The learningManager will decide if it needs to act on it based on its state.
|
||||
this.eventBus.emit('controller:unhandledInput', controlInput);
|
||||
}
|
||||
|
||||
applyMapping(mapping, rawInputValue) { // Renamed parameter to rawInputValue
|
||||
// Skip processing if mapping is invalid
|
||||
if (!mapping || !mapping.target || !mapping.control) {
|
||||
console.warn("MappingEngine: Invalid mapping object, skipping.", mapping);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass rawInputValue to transformInputValue
|
||||
let transformedValue = transformInputValue(mapping, rawInputValue, app);
|
||||
|
||||
// Emit event for mapping being applied (useful for UI feedback)
|
||||
// Pass rawInputValue to the event as well
|
||||
this.eventBus.emit('mapping:applying', mapping, rawInputValue, transformedValue);
|
||||
|
||||
// Apply the value to the appropriate target ONLY if a valid value was returned
|
||||
if (transformedValue !== undefined) {
|
||||
if (mapping.target.type === 'widget') {
|
||||
// Pass a flag to indicate this value is already transformed from raw to target range
|
||||
updateWidgetValue(app, mapping.target.nodeId, mapping.target.widgetName, transformedValue, mapping.mappingType, mapping, true);
|
||||
|
||||
// Emit event for widget value updated
|
||||
this.eventBus.emit('widget:valueUpdated', {
|
||||
nodeId: mapping.target.nodeId,
|
||||
widgetName: mapping.target.widgetName,
|
||||
value: transformedValue,
|
||||
mapping
|
||||
});
|
||||
} else if (mapping.target.type === 'command') {
|
||||
// Commands are handled differently, using shouldTriggerCommand
|
||||
// Pass rawInputValue to shouldTriggerCommand for consistency
|
||||
if (shouldTriggerCommand(mapping, rawInputValue)) {
|
||||
const commandId = mapping.target.commandId;
|
||||
this.commandExecutor.executeCommand(commandId);
|
||||
|
||||
// Emit event for command executed
|
||||
this.eventBus.emit('command:executed', {
|
||||
commandId,
|
||||
mapping
|
||||
});
|
||||
}
|
||||
} else if (mapping.target.type === 'ui_element') {
|
||||
// TODO: Implement UI element interaction
|
||||
|
||||
// Emit event for UI element interaction (even though not implemented yet)
|
||||
this.eventBus.emit('ui:elementInteraction', {
|
||||
elementId: mapping.target.elementId,
|
||||
value: transformedValue,
|
||||
mapping
|
||||
});
|
||||
}
|
||||
}
|
||||
// Handle command triggering separately even if transformedValue is undefined
|
||||
// This ensures button releases don't re-trigger commands meant for press
|
||||
else if (mapping.target.type === 'command') {
|
||||
// Pass rawInputValue to shouldTriggerCommand
|
||||
if (shouldTriggerCommand(mapping, rawInputValue)) {
|
||||
const commandId = mapping.target.commandId;
|
||||
this.commandExecutor.executeCommand(commandId);
|
||||
|
||||
// Emit event for command executed
|
||||
this.eventBus.emit('command:executed', {
|
||||
commandId,
|
||||
mapping
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event for mapping being applied (for post-processing)
|
||||
// Pass rawInputValue to the event
|
||||
this.eventBus.emit('mapping:applied', mapping, rawInputValue, transformedValue);
|
||||
}
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
saveMappings() {
|
||||
const data = {
|
||||
activeProfile: this.activeProfile,
|
||||
mappings: this.mappings
|
||||
};
|
||||
this.persistenceManager.saveMappings(data);
|
||||
|
||||
// Emit event for mappings saved
|
||||
this.eventBus.emit('mappings:saved', data);
|
||||
}
|
||||
|
||||
loadMappings() {
|
||||
const data = this.persistenceManager.loadMappings();
|
||||
if (data) {
|
||||
this.mappings = data.mappings || [];
|
||||
this.activeProfile = data.activeProfile || 'default';
|
||||
|
||||
// Emit event for mappings loaded
|
||||
this.eventBus.emit('mappings:loaded', {
|
||||
mappings: this.mappings,
|
||||
activeProfile: this.activeProfile
|
||||
});
|
||||
|
||||
|
||||
// Ensure all mappings are properly reconnected to controllers
|
||||
this.reconnectMappings();
|
||||
} else {
|
||||
// Initialize with empty state if nothing is saved
|
||||
this.mappings = [];
|
||||
this.activeProfile = 'default';
|
||||
|
||||
// Emit event for empty mappings initialized
|
||||
this.eventBus.emit('mappings:initialized', {
|
||||
mappings: this.mappings,
|
||||
activeProfile: this.activeProfile
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect all mappings to ensure they're active
|
||||
* Call this after loading mappings to ensure they control the nodes
|
||||
*/
|
||||
reconnectMappings() {
|
||||
|
||||
// For each mapping, check if the target exists and ensure it's properly connected
|
||||
this.getMappings().forEach(mapping => {
|
||||
if (!mapping || !mapping.target) return;
|
||||
|
||||
try {
|
||||
// Check if the node exists in the graph
|
||||
if (mapping.target.type === 'widget' && mapping.target.nodeId) {
|
||||
const node = app.graph.getNodeById(mapping.target.nodeId);
|
||||
if (node) {
|
||||
|
||||
// For widget targets, check if the widget exists
|
||||
if (mapping.target.widgetName) {
|
||||
const widget = node.widgets?.find(w => w.name === mapping.target.widgetName);
|
||||
if (!widget) {
|
||||
console.warn(`MappingEngine: Widget ${mapping.target.widgetName} not found on node ${mapping.target.nodeId}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`MappingEngine: Target node ${mapping.target.nodeId} not found for mapping ${mapping.id}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`MappingEngine: Error reconnecting mapping ${mapping.id}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Emit mappings reconnected event
|
||||
this.eventBus.emit('mappings:reconnected');
|
||||
|
||||
}
|
||||
|
||||
// --- Command Registration ---
|
||||
|
||||
registerCommandHandler(commandId, handler) {
|
||||
this.commandExecutor.registerCommandHandler(commandId, handler);
|
||||
}
|
||||
|
||||
// --- Workflow Integration ---
|
||||
|
||||
exportMappingsForWorkflow() {
|
||||
return this.getMappings(); // Only export mappings for current profile
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique mapping ID
|
||||
* @returns {string} A unique ID
|
||||
*/
|
||||
generateMappingId() {
|
||||
return `mapping_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command by ID
|
||||
* @param {string} commandId - The command ID to execute
|
||||
* @returns {boolean} Whether the command was executed successfully
|
||||
*/
|
||||
executeCommand(commandId) {
|
||||
return this.commandExecutor.executeCommand(commandId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific widget on a specific node currently has an active mapping.
|
||||
* @param {number|string} nodeId The ID of the node.
|
||||
* @param {string} widgetName The name of the widget.
|
||||
* @returns {boolean} True if the widget is mapped in the active profile, false otherwise.
|
||||
*/
|
||||
isWidgetMapped(nodeId, widgetName) {
|
||||
if (!this.mappings || this.mappings.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const activeProfile = this.getActiveProfile();
|
||||
|
||||
// Ensure nodeId is treated as a string for comparison if necessary
|
||||
const targetNodeId = String(nodeId);
|
||||
|
||||
return this.mappings.some(mapping =>
|
||||
mapping.profile === activeProfile &&
|
||||
mapping.target.type === 'widget' &&
|
||||
String(mapping.target.nodeId) === targetNodeId &&
|
||||
mapping.target.widgetName === widgetName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the mapping object for a specific widget on a specific node in the active profile.
|
||||
* @param {number|string} nodeId The ID of the node.
|
||||
* @param {string} widgetName The name of the widget.
|
||||
* @returns {object|null} The mapping object if found, otherwise null.
|
||||
*/
|
||||
findMappingForWidget(nodeId, widgetName) {
|
||||
if (!this.mappings || this.mappings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const activeProfile = this.getActiveProfile();
|
||||
const targetNodeId = String(nodeId);
|
||||
|
||||
return this.mappings.find(mapping =>
|
||||
mapping.profile === activeProfile &&
|
||||
mapping.target.type === 'widget' &&
|
||||
String(mapping.target.nodeId) === targetNodeId &&
|
||||
mapping.target.widgetName === widgetName
|
||||
) || null; // Ensure null is returned if not found
|
||||
}
|
||||
}
|
||||
|
||||
// Don't export a singleton instance here
|
||||
// It will be created and registered by the main module
|
||||
@@ -0,0 +1,99 @@
|
||||
// Core API functions for controller mapping
|
||||
|
||||
/**
|
||||
* Fetches available controller types
|
||||
* @returns {Promise<string[]>} List of controller types
|
||||
*/
|
||||
export async function fetchControllerTypes() {
|
||||
const response = await fetch('/controller/types');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const types = await response.json();
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches available devices for each controller type
|
||||
* @returns {Promise<Object>} Object mapping controller types to device lists
|
||||
*/
|
||||
export async function fetchControllerDevices() {
|
||||
const response = await fetch('/controller/devices');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const devices = await response.json();
|
||||
return devices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects a controller device
|
||||
* @param {string} type - Controller type
|
||||
* @param {string} deviceId - Device ID
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
export async function connectControllerDevice(type, deviceId) {
|
||||
|
||||
const response = await fetch('/controller/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: type,
|
||||
device_id: deviceId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`Error connecting device: ${errorText}`);
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all controller mappings
|
||||
* @returns {Promise<Array>} List of mappings
|
||||
*/
|
||||
export async function fetchMappings() {
|
||||
const response = await fetch('/controller/mappings');
|
||||
const mappings = await response.json();
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a mapping
|
||||
* @param {string} controllerId - Controller ID
|
||||
* @param {string} controlId - Control ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteMapping(controllerId, controlId) {
|
||||
await fetch(`/controller/mapping/${controllerId}:${controlId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups mappings by controller ID
|
||||
* @param {Array} mappings - List of mappings
|
||||
* @returns {Object} Mappings grouped by controller ID
|
||||
*/
|
||||
export function groupMappingsByController(mappings) {
|
||||
const groupedMappings = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!groupedMappings[mapping.controller_id]) {
|
||||
groupedMappings[mapping.controller_id] = [];
|
||||
}
|
||||
|
||||
groupedMappings[mapping.controller_id].push(mapping);
|
||||
}
|
||||
|
||||
return groupedMappings;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Mapping Type implementations for ComfyUI_ControlFreak
|
||||
*
|
||||
* This module contains utility functions for handling different mapping types
|
||||
* and transforming input values based on the mapping configuration.
|
||||
*
|
||||
* @module mappingTypes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine if a command should be triggered based on input value and mapping type
|
||||
* @param {Object} mapping - The mapping object
|
||||
* @param {Number} rawInputValue - The raw input value from the controller
|
||||
* @returns {Boolean} Whether the command should be triggered
|
||||
*/
|
||||
export function shouldTriggerCommand(mapping, rawInputValue) {
|
||||
const mappingType = (mapping.mappingType || "Toggle").toLowerCase();
|
||||
|
||||
// Define a threshold based on raw input range
|
||||
const inputMin = mapping.control?.input_min ?? 0;
|
||||
const inputMax = mapping.control?.input_max ?? 1;
|
||||
// Use a threshold relative to the input range (e.g., 50%)
|
||||
const threshold = inputMin + (inputMax - inputMin) * 0.5;
|
||||
|
||||
switch (mappingType) {
|
||||
case "momentary":
|
||||
// Trigger when raw value is above the calculated threshold
|
||||
return rawInputValue > threshold;
|
||||
|
||||
case "toggle":
|
||||
// Only trigger on rising edge (transition above threshold)
|
||||
if (!mapping._lastRawValueOverThreshold && rawInputValue > threshold) {
|
||||
mapping._lastRawValueOverThreshold = true; // Store current state
|
||||
return true;
|
||||
}
|
||||
mapping._lastRawValueOverThreshold = rawInputValue > threshold;
|
||||
return false;
|
||||
|
||||
case "trigger":
|
||||
// Like toggle, but always triggers when pressed (above threshold)
|
||||
return rawInputValue > threshold;
|
||||
|
||||
default:
|
||||
// For other types, use simple threshold
|
||||
return rawInputValue > threshold;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform input value based on mapping settings
|
||||
* @param {Object} mapping - The mapping object
|
||||
* @param {Number} rawInputValue - The raw input value from the controller
|
||||
* @param {Object} app - The ComfyUI app object
|
||||
* @returns {Number | undefined} Transformed value (scaled to target range) or undefined if no update should occur
|
||||
*/
|
||||
export function transformInputValue(mapping, rawInputValue, app) {
|
||||
// Get transform settings and mapping type
|
||||
const transform = mapping.transform || {};
|
||||
const mappingType = (mapping.mappingType || "direct").toLowerCase();
|
||||
|
||||
// Get input range from mapping control data (with defaults)
|
||||
const inputMin = mapping.control?.input_min ?? 0;
|
||||
const inputMax = mapping.control?.input_max ?? (inputMin === 0 ? 1 : 127); // Guess max based on min
|
||||
|
||||
// Get target range from mapping transform data (with defaults)
|
||||
const targetMin = transform.targetMin ?? 0;
|
||||
const targetMax = transform.targetMax ?? 1;
|
||||
|
||||
// --- Normalization ---
|
||||
let normalizedValue = 0; // Default to 0
|
||||
// Avoid division by zero
|
||||
if (inputMax !== inputMin) {
|
||||
// Check if this is a gamepad axis - for these we need to handle differently
|
||||
const isGamepadAxis = mapping.control?.type?.toLowerCase().includes('axis');
|
||||
|
||||
if (isGamepadAxis) {
|
||||
// For gamepad axes we need special handling
|
||||
// Map from [-1, 1] to [0, 1] using a different approach
|
||||
// Add 1 to shift from [-1,1] to [0,2], then divide by 2 to get [0,1]
|
||||
normalizedValue = (rawInputValue + 1) / 2;
|
||||
} else {
|
||||
// Normal scaling for other controllers from input range to [0,1]
|
||||
const scaledValue = (rawInputValue - inputMin) / (inputMax - inputMin);
|
||||
// Clamp between 0 and 1
|
||||
normalizedValue = Math.max(0, Math.min(1, scaledValue));
|
||||
}
|
||||
} else if (rawInputValue >= inputMin) {
|
||||
// If range is zero, output 1 if value is at or above the single point, else 0
|
||||
normalizedValue = 1;
|
||||
}
|
||||
|
||||
// --- Apply Inversion if specified ---
|
||||
if (transform.isInverted === true) {
|
||||
normalizedValue = 1.0 - normalizedValue;
|
||||
}
|
||||
|
||||
// --- Value Transformation based on Mapping Type ---
|
||||
let finalValue = 0; // Initialize final value
|
||||
|
||||
// Apply different transform based on mapping type, using the *normalizedValue*
|
||||
switch (mappingType) {
|
||||
case "direct":
|
||||
case "absolute":
|
||||
// Check if the target is a combo widget
|
||||
if (mapping.target?.type === 'widget') {
|
||||
try {
|
||||
const node = app.graph.getNodeById(mapping.target.nodeId);
|
||||
if (node) {
|
||||
const widget = node.widgets.find(w => w.name === mapping.target.widgetName);
|
||||
|
||||
if (widget && widget.type === "combo" && widget.options && Array.isArray(widget.options.values)) {
|
||||
// For combo widgets, map the normalized value to the proper index
|
||||
const optionsCount = widget.options.values.length;
|
||||
if (optionsCount > 0) {
|
||||
// Map 0-1 range to indices of available combo options
|
||||
const index = Math.min(Math.floor(normalizedValue * optionsCount), optionsCount - 1);
|
||||
return index; // Return the index directly
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("MappingEngine: Error processing combo widget:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct mapping for normal widgets: scale normalizedValue (0-1) to target range
|
||||
finalValue = targetMin + normalizedValue * (targetMax - targetMin);
|
||||
|
||||
// Use step size from transform if available - this is user defined
|
||||
if (transform.stepSize) {
|
||||
finalValue = Math.round((finalValue - targetMin) / transform.stepSize) * transform.stepSize + targetMin;
|
||||
}
|
||||
// We skip widget step detection since user can define their own step size in mapping config
|
||||
|
||||
return finalValue;
|
||||
|
||||
case "toggle":
|
||||
// Simple binary toggle based on normalized threshold
|
||||
finalValue = normalizedValue > 0.5 ? targetMax : targetMin; // Use target range for on/off
|
||||
// Note: Toggle state logic might be better handled in the widget updater
|
||||
return finalValue;
|
||||
|
||||
case "momentary":
|
||||
// Similar to toggle but state resets when released (normalized value drops)
|
||||
finalValue = normalizedValue > 0.5 ? targetMax : targetMin;
|
||||
return finalValue;
|
||||
|
||||
case "incremental":
|
||||
case "increment":
|
||||
// Only increment on button press (rising edge of normalized value)
|
||||
if (!mapping._lastNormalizedActive && normalizedValue > 0.5) {
|
||||
mapping._lastNormalizedActive = true; // Set active flag immediately
|
||||
|
||||
// Handle combo boxes specially
|
||||
if (mapping.target?.type === 'widget') {
|
||||
try {
|
||||
const node = app.graph.getNodeById(mapping.target.nodeId);
|
||||
if (node) {
|
||||
const widget = node.widgets.find(w => w.name === mapping.target.widgetName);
|
||||
if (widget && widget.type === "combo" && widget.options && Array.isArray(widget.options.values)) {
|
||||
// For combo widgets, cycle to the next option
|
||||
const optionsCount = widget.options.values.length;
|
||||
if (optionsCount > 0) {
|
||||
const currentIndex = typeof widget.value === 'number' ? widget.value : 0;
|
||||
const newIndex = (currentIndex + 1) % optionsCount;
|
||||
return newIndex; // Return the new index directly
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("MappingEngine: Error processing combo widget increment:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular increment for normal widgets
|
||||
// Get current widget value (needs targetMin/Max from widget itself ideally)
|
||||
let currentValue = targetMin; // Default to targetMin
|
||||
if (mapping.target && mapping.target.type === 'widget') {
|
||||
try {
|
||||
const node = app.graph.getNodeById(mapping.target.nodeId);
|
||||
if (node) {
|
||||
const widget = node.widgets.find(w => w.name === mapping.target.widgetName);
|
||||
if (widget && typeof widget.value !== 'undefined') {
|
||||
currentValue = widget.value;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("MappingEngine: Error getting widget value for increment:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the new value
|
||||
const stepSize = transform.stepSize || (targetMax - targetMin) * 0.1; // Default step 10% of range
|
||||
const newValue = currentValue + stepSize;
|
||||
|
||||
// Clamp to targetMax
|
||||
finalValue = Math.min(newValue, targetMax);
|
||||
|
||||
return finalValue; // Return the calculated value
|
||||
}
|
||||
// If not a rising edge, update the state but signal no change
|
||||
mapping._lastNormalizedActive = normalizedValue > 0.5;
|
||||
return undefined; // Indicate no update should occur
|
||||
|
||||
case "decremental":
|
||||
case "decrement":
|
||||
// Only decrement on button press (rising edge of normalized value)
|
||||
if (!mapping._lastNormalizedActive && normalizedValue > 0.5) {
|
||||
mapping._lastNormalizedActive = true; // Set active flag immediately
|
||||
|
||||
// Handle combo boxes specially
|
||||
if (mapping.target?.type === 'widget') {
|
||||
try {
|
||||
const node = app.graph.getNodeById(mapping.target.nodeId);
|
||||
if (node) {
|
||||
const widget = node.widgets.find(w => w.name === mapping.target.widgetName);
|
||||
if (widget && widget.type === "combo" && widget.options && Array.isArray(widget.options.values)) {
|
||||
// For combo widgets, cycle to the previous option
|
||||
const optionsCount = widget.options.values.length;
|
||||
if (optionsCount > 0) {
|
||||
const currentIndex = typeof widget.value === 'number' ? widget.value : 0;
|
||||
const newIndex = (currentIndex - 1 + optionsCount) % optionsCount;
|
||||
return newIndex; // Return the new index directly
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("MappingEngine: Error processing combo widget decrement:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular decrement for normal widgets
|
||||
// Get current widget value
|
||||
let currentValue = targetMax; // Default to targetMax
|
||||
if (mapping.target && mapping.target.type === 'widget') {
|
||||
try {
|
||||
const node = app.graph.getNodeById(mapping.target.nodeId);
|
||||
if (node) {
|
||||
const widget = node.widgets.find(w => w.name === mapping.target.widgetName);
|
||||
if (widget && typeof widget.value !== 'undefined') {
|
||||
currentValue = widget.value;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("MappingEngine: Error getting widget value for decrement:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the new value
|
||||
const stepSize = transform.stepSize || (targetMax - targetMin) * 0.1; // Default step 10% of range
|
||||
const newValue = currentValue - stepSize;
|
||||
|
||||
// Clamp to targetMin
|
||||
finalValue = Math.max(newValue, targetMin);
|
||||
|
||||
return finalValue; // Return the calculated value
|
||||
}
|
||||
// If not a rising edge, update the state but signal no change
|
||||
mapping._lastNormalizedActive = normalizedValue > 0.5;
|
||||
return undefined; // Indicate no update should occur
|
||||
|
||||
case "trigger":
|
||||
// Trigger actions only occur on the rising edge and don't change values
|
||||
if (!mapping._lastNormalizedActive && normalizedValue > 0.5) {
|
||||
mapping._lastNormalizedActive = true;
|
||||
// For trigger type, the command executor uses shouldTriggerCommand.
|
||||
// We return undefined here as no value needs to be set on a target widget.
|
||||
return undefined;
|
||||
}
|
||||
mapping._lastNormalizedActive = normalizedValue > 0.5;
|
||||
return undefined; // No value update
|
||||
|
||||
default:
|
||||
// Fallback: Direct mapping if type is unknown
|
||||
finalValue = targetMin + normalizedValue * (targetMax - targetMin);
|
||||
|
||||
// Use step size from transform if available - this is user defined
|
||||
if (transform.stepSize) {
|
||||
finalValue = Math.round((finalValue - targetMin) / transform.stepSize) * transform.stepSize + targetMin;
|
||||
}
|
||||
// We skip widget step detection since user can define their own step size in mapping config
|
||||
|
||||
return finalValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect an appropriate mapping type based on controller type and target widget
|
||||
* @param {String} controlType - The type of controller
|
||||
* @param {Object} targetConfig - Information about the target (optional)
|
||||
* @returns {String} The suggested mapping type
|
||||
*/
|
||||
export function detectMappingType(controlType, targetConfig = null) {
|
||||
if (!controlType) return 'direct';
|
||||
|
||||
const type = controlType.toLowerCase();
|
||||
|
||||
// Check if we're mapping to a combo widget
|
||||
const isComboWidget = targetConfig &&
|
||||
targetConfig.widgetConfig &&
|
||||
targetConfig.widgetConfig.type === "combo";
|
||||
|
||||
if (type.includes('button') || type === 'midi_note') {
|
||||
// For buttons mapping to combo widgets, incremental cycling is usually best
|
||||
if (isComboWidget) {
|
||||
return 'increment';
|
||||
}
|
||||
return 'toggle'; // Default for buttons to other widget types
|
||||
} else {
|
||||
// For continuous controllers (like knobs, sliders, etc.)
|
||||
if (isComboWidget) {
|
||||
return 'direct'; // Direct mapping for continuous controllers to combo boxes
|
||||
}
|
||||
return 'direct'; // Default for continuous controls (was 'Absolute')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines default mapping settings based on the provided widget.
|
||||
* @param {object} widget - The ComfyUI widget object.
|
||||
* @returns {object} Default mapping settings (mappingMode, rangeMin, rangeMax, sensitivity, toggleThreshold).
|
||||
*/
|
||||
export function getDefaultSettingsForWidget(widget) {
|
||||
const defaults = {
|
||||
mappingMode: 'absolute', // Default mode
|
||||
rangeMin: 0,
|
||||
rangeMax: 1,
|
||||
sensitivity: 1.0,
|
||||
toggleThreshold: 0.5, // Default for toggle mode
|
||||
isInverted: false, // Default inversion state
|
||||
};
|
||||
|
||||
if (!widget || !widget.type) {
|
||||
console.warn("ControlFreak: Cannot determine default settings for unknown widget type.");
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const widgetType = widget.type.toLowerCase();
|
||||
const config = widget.options || widget.config || {}; // Look for widget configuration
|
||||
|
||||
switch (widgetType) {
|
||||
case 'number':
|
||||
case 'float':
|
||||
case 'int':
|
||||
defaults.mappingMode = 'absolute';
|
||||
// Use widget's min/max if available, otherwise keep 0-1
|
||||
defaults.rangeMin = typeof config.min === 'number' ? config.min : 0;
|
||||
defaults.rangeMax = typeof config.max === 'number' ? config.max : 1;
|
||||
// Adjust if min/max are the same (though unlikely)
|
||||
if (defaults.rangeMin === defaults.rangeMax) defaults.rangeMax = defaults.rangeMin + 1;
|
||||
defaults.sensitivity = 1.0;
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
defaults.mappingMode = 'toggle';
|
||||
defaults.rangeMin = 0;
|
||||
defaults.rangeMax = 1;
|
||||
defaults.toggleThreshold = 0.5;
|
||||
break;
|
||||
|
||||
case 'toggle': // Sometimes used for boolean-like behavior
|
||||
defaults.mappingMode = 'toggle';
|
||||
defaults.rangeMin = 0;
|
||||
defaults.rangeMax = 1;
|
||||
defaults.toggleThreshold = 0.5;
|
||||
break;
|
||||
|
||||
case 'combo':
|
||||
case 'combobox':
|
||||
defaults.mappingMode = 'absolute'; // Map 0-1 across options
|
||||
defaults.rangeMin = 0;
|
||||
defaults.rangeMax = 1; // Simplifies quick mapping; user can refine later
|
||||
// If we could reliably get options count:
|
||||
// const optionsCount = Array.isArray(config.values) ? config.values.length : 0;
|
||||
// defaults.rangeMax = optionsCount > 0 ? optionsCount - 1 : 1;
|
||||
// defaults.mappingMode = 'step'; // Could also default to step
|
||||
break;
|
||||
|
||||
// Add cases for other specific widget types if needed
|
||||
// e.g., case 'slider': ...
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Ensure rangeMin is less than rangeMax
|
||||
if (defaults.rangeMin > defaults.rangeMax) {
|
||||
[defaults.rangeMin, defaults.rangeMax] = [defaults.rangeMax, defaults.rangeMin];
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Persistence Manager for ComfyUI_ControlFreak
|
||||
* Handles saving and loading mappings to/from the workflow
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { contextProvider } from "./contextProvider.js";
|
||||
import { eventBus } from "./eventBus.js";
|
||||
|
||||
export class PersistenceManager {
|
||||
constructor() {
|
||||
// Register event listener for graph JSON serialization
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for serialization-related events
|
||||
* @private
|
||||
*/
|
||||
_setupEventListeners() {
|
||||
// Listen for graph serialization errors
|
||||
eventBus.on('workflow:error', (error) => {
|
||||
console.error("PersistenceManager: Error during workflow operation:", error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save mappings to the workflow
|
||||
* @param {Object} data - Mapping data to save { activeProfile, mappings }
|
||||
*/
|
||||
saveMappings(data) {
|
||||
try {
|
||||
if (!app || !app.graph) {
|
||||
console.warn("PersistenceManager: App or graph not available, can't save mappings");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the graph.extra object exists
|
||||
if (!app.graph.extra) {
|
||||
app.graph.extra = {};
|
||||
}
|
||||
|
||||
// Store data in the graph.extra.controlFreak object
|
||||
app.graph.extra.controlFreak = {
|
||||
activeProfile: data.activeProfile || 'default',
|
||||
mappings: data.mappings || []
|
||||
};
|
||||
|
||||
// Trigger a graph change event so workflow saves the state
|
||||
app.graph.change();
|
||||
|
||||
// Emit event for mappings saved
|
||||
eventBus.emit('persistence:mappingsSaved', {
|
||||
profileName: data.activeProfile,
|
||||
mappingsCount: data.mappings ? data.mappings.length : 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("PersistenceManager: Error saving mappings:", error);
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('persistence:error', {
|
||||
operation: 'save',
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mappings from the workflow
|
||||
* @returns {Object|null} - The loaded mapping data or null if not found
|
||||
*/
|
||||
loadMappings() {
|
||||
try {
|
||||
// Check for any pending data from Python
|
||||
const pendingData = contextProvider.get('pendingData');
|
||||
if (pendingData) {
|
||||
// Emit event for mappings loaded
|
||||
eventBus.emit('persistence:mappingsLoaded', {
|
||||
source: 'pending',
|
||||
profileName: pendingData.activeProfile,
|
||||
mappingsCount: pendingData.mappings ? pendingData.mappings.length : 0
|
||||
});
|
||||
|
||||
// Remove the pending data reference
|
||||
contextProvider.remove('pendingData');
|
||||
|
||||
return pendingData;
|
||||
}
|
||||
|
||||
// Check for data in the workflow
|
||||
if (!app || !app.graph || !app.graph.extra || !app.graph.extra.controlFreak) {
|
||||
// Emit event for no mappings found
|
||||
eventBus.emit('persistence:noMappingsFound');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = app.graph.extra.controlFreak;
|
||||
|
||||
// Emit event for mappings loaded
|
||||
eventBus.emit('persistence:mappingsLoaded', {
|
||||
source: 'workflow',
|
||||
profileName: data.activeProfile,
|
||||
mappingsCount: data.mappings ? data.mappings.length : 0
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("PersistenceManager: Error loading mappings:", error);
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('persistence:error', {
|
||||
operation: 'load',
|
||||
error
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import mappings from workflow data
|
||||
* @param {Object} data - The workflow data to import
|
||||
* @returns {Object|null} - The processed mapping data or null if error
|
||||
*/
|
||||
importMappingsFromWorkflow(data) {
|
||||
try {
|
||||
// Simple validation
|
||||
if (!data || !data.mappings) {
|
||||
console.warn("PersistenceManager: Invalid mapping data for import");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Process the data as needed (e.g., filter, transform)
|
||||
const processedData = {
|
||||
activeProfile: data.profile || 'default',
|
||||
mappings: data.mappings.filter(m => !!m) // Remove any null/undefined entries
|
||||
};
|
||||
|
||||
// Emit event for mappings imported
|
||||
eventBus.emit('persistence:mappingsImported', {
|
||||
profileName: processedData.activeProfile,
|
||||
mappingsCount: processedData.mappings.length
|
||||
});
|
||||
|
||||
return processedData;
|
||||
} catch (error) {
|
||||
console.error("PersistenceManager: Error importing mappings:", error);
|
||||
|
||||
// Emit error event
|
||||
eventBus.emit('persistence:error', {
|
||||
operation: 'import',
|
||||
error
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Widget Update functionality for ComfyUI_ControlFreak
|
||||
*/
|
||||
import { highlightNodeParameter } from "../handlers/controllerEventHandlers.js";
|
||||
|
||||
/**
|
||||
* Updates a widget value in a ComfyUI node
|
||||
* @param {Object} app - The ComfyUI app instance
|
||||
* @param {String} nodeId - ID of the node containing the widget
|
||||
* @param {String} widgetName - Name of the widget to update
|
||||
* @param {*} value - New value to set
|
||||
* @param {String} [mappingType="Absolute"] - The type of mapping that generated the value
|
||||
* @param {Object} [mappingConfig] - The mapping configuration with transform info
|
||||
* @param {Boolean} [alreadyTransformed=false] - Flag indicating if the value has already been transformed to target range
|
||||
*/
|
||||
export function updateWidgetValue(app, nodeId, widgetName, value, mappingType = "Absolute", mappingConfig = null, alreadyTransformed = false) {
|
||||
// Requires access to the ComfyUI app object or graph
|
||||
if (app && app.graph) {
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
if (node) {
|
||||
const widget = node.widgets.find(w => w.name === widgetName);
|
||||
if (widget) {
|
||||
// Find the relevant mapping to check mapping type
|
||||
const finalValue = calculateFinalValue(widget, value, mappingType, mappingConfig, alreadyTransformed);
|
||||
|
||||
// Store the old value for comparison/callbacks
|
||||
const oldValue = widget.value;
|
||||
|
||||
// Update the widget value
|
||||
let preparedValue = finalValue;
|
||||
if (widget.type === "combo") {
|
||||
// For combo widgets, ensure we set the value in the correct format
|
||||
preparedValue = prepareComboWidgetValue(widget, finalValue);
|
||||
widget.value = preparedValue;
|
||||
} else {
|
||||
widget.value = finalValue;
|
||||
}
|
||||
|
||||
// CRITICAL: Update the serialized widget values array
|
||||
updateSerializedWidgetValue(node, widget, preparedValue);
|
||||
|
||||
// Trigger visual feedback
|
||||
highlightNodeParameter(nodeId, widgetName, app);
|
||||
|
||||
// Mark the widget as dirty
|
||||
widget.dirty = true;
|
||||
|
||||
// Apply necessary ComfyUI callbacks and state updates
|
||||
notifyComfyUI(app, node, widget, widgetName, finalValue, oldValue);
|
||||
} else {
|
||||
console.warn(`MappingEngine: Widget ${widgetName} not found on node ${nodeId}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`MappingEngine: Node ${nodeId} not found`);
|
||||
}
|
||||
} else {
|
||||
console.error("MappingEngine: Cannot access ComfyUI app or graph object.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the final widget value based on widget properties
|
||||
* @param {Object} widget - The ComfyUI widget object
|
||||
* @param {*} value - The input value
|
||||
* @param {String} mappingType - The type of mapping
|
||||
* @param {Object} mappingConfig - The mapping configuration with transform info
|
||||
* @param {Boolean} [alreadyTransformed=false] - Flag indicating if the value has already been transformed to target range
|
||||
* @returns {*} The final calculated value
|
||||
*/
|
||||
function calculateFinalValue(widget, value, mappingType, mappingConfig, alreadyTransformed = false) {
|
||||
let finalValue = value;
|
||||
|
||||
// Convert mapping type to lowercase for easier comparison
|
||||
const mappingTypeLower = mappingType ? mappingType.toLowerCase() : '';
|
||||
|
||||
// For increment and decrement, the value is already calculated with the step size in mappingTypes.js
|
||||
// We should still apply other widget properties like min/max clamping
|
||||
if (mappingTypeLower === "incremental" || mappingTypeLower === "increment" ||
|
||||
mappingTypeLower === "decremental" || mappingTypeLower === "decrement") {
|
||||
|
||||
// Get min/max values for clamping
|
||||
const min = (mappingConfig && typeof mappingConfig.transform?.targetMin === 'number')
|
||||
? mappingConfig.transform.targetMin
|
||||
: (widget.options && typeof widget.options.min === 'number' ? widget.options.min : 0);
|
||||
|
||||
const max = (mappingConfig && typeof mappingConfig.transform?.targetMax === 'number')
|
||||
? mappingConfig.transform.targetMax
|
||||
: (widget.options && typeof widget.options.max === 'number' ? widget.options.max : 1);
|
||||
|
||||
// Apply step size if it's defined in mapping config (this should already be applied in mappingTypes.js)
|
||||
// This is just a safety measure
|
||||
const userDefinedStep = mappingConfig?.transform?.stepSize;
|
||||
if (typeof userDefinedStep === 'number') {
|
||||
// Make sure value is aligned to steps relative to min
|
||||
finalValue = Math.round((finalValue - min) / userDefinedStep) * userDefinedStep + min;
|
||||
}
|
||||
|
||||
// Apply precision if defined
|
||||
if (typeof widget.options?.precision === 'number') {
|
||||
finalValue = parseFloat(finalValue.toFixed(widget.options.precision));
|
||||
}
|
||||
|
||||
// Clamp value to min/max
|
||||
finalValue = Math.max(min, Math.min(max, finalValue));
|
||||
} else {
|
||||
// Original direct mapping logic for other mapping types
|
||||
|
||||
// Get min/max values, preferring mapping overrides when available
|
||||
const min = (mappingConfig && typeof mappingConfig.transform?.targetMin === 'number')
|
||||
? mappingConfig.transform.targetMin
|
||||
: (widget.options && typeof widget.options.min === 'number' ? widget.options.min : 0);
|
||||
|
||||
const max = (mappingConfig && typeof mappingConfig.transform?.targetMax === 'number')
|
||||
? mappingConfig.transform.targetMax
|
||||
: (widget.options && typeof widget.options.max === 'number' ? widget.options.max : 1);
|
||||
|
||||
// Scale the value from 0-1 range to min-max range for direct mapping
|
||||
// This is critical for proper axis mapping
|
||||
if (typeof value === 'number') {
|
||||
if (alreadyTransformed) {
|
||||
// If already transformed in mappingTypes.js, don't re-scale
|
||||
finalValue = value;
|
||||
} else if (value >= 0 && value <= 1) {
|
||||
// Otherwise, scale from [0,1] to [min,max]
|
||||
finalValue = min + value * (max - min);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply step rounding if defined by user in mapping config
|
||||
if (mappingTypeLower === "direct" || mappingTypeLower === "absolute") {
|
||||
// First prioritize step from mapping config
|
||||
const userDefinedStep = mappingConfig?.transform?.stepSize;
|
||||
if (typeof userDefinedStep === 'number') {
|
||||
finalValue = Math.round((finalValue - min) / userDefinedStep) * userDefinedStep + min;
|
||||
}
|
||||
// Do not use widget steps for direct/absolute mapping - user must specify their desired step
|
||||
} else if (typeof widget.options?.step === 'number') {
|
||||
// For other mapping types still apply step if defined
|
||||
finalValue = Math.round(finalValue / widget.options.step) * widget.options.step;
|
||||
}
|
||||
|
||||
// Apply precision if defined
|
||||
if (typeof widget.options?.precision === 'number') {
|
||||
finalValue = parseFloat(finalValue.toFixed(widget.options.precision));
|
||||
}
|
||||
|
||||
// Clamp value to min/max (using the overridden values if available)
|
||||
finalValue = Math.max(min, Math.min(max, finalValue));
|
||||
}
|
||||
|
||||
// Handle toggle type
|
||||
if (widget.type === "toggle") {
|
||||
// Handle toggle based on threshold (e.g., > 0.5)
|
||||
finalValue = value > 0.5;
|
||||
} else if (widget.type === "combo") {
|
||||
// Handle combo boxes by mapping the normalized value to an index in the options array
|
||||
if (widget.options && Array.isArray(widget.options.values)) {
|
||||
const optionsCount = widget.options.values.length;
|
||||
if (optionsCount > 0) {
|
||||
// If the value is already transformed in mappingTypes.js, use it directly
|
||||
if (alreadyTransformed && Number.isInteger(value)) {
|
||||
// Just use the index value directly, no further transformation needed
|
||||
finalValue = value;
|
||||
}
|
||||
// For absolute/direct mapping with 0-1 normalized values
|
||||
else if (typeof value === 'number' && value >= 0 && value <= 1) {
|
||||
// Map 0-1 range to indices of available options
|
||||
// For 3 options: 0-0.33 = 0, 0.33-0.66 = 1, 0.66-1 = 2
|
||||
const index = Math.min(Math.floor(value * optionsCount), optionsCount - 1);
|
||||
finalValue = index;
|
||||
} else if (mappingTypeLower === "increment" || mappingTypeLower === "incremental") {
|
||||
// For incremental mapping, cycle through options
|
||||
const currentIndex = typeof widget.value === 'number' ? widget.value : 0;
|
||||
const newIndex = (currentIndex + 1) % optionsCount;
|
||||
finalValue = newIndex;
|
||||
} else if (mappingTypeLower === "decrement" || mappingTypeLower === "decremental") {
|
||||
// For decremental mapping, cycle through options in reverse
|
||||
const currentIndex = typeof widget.value === 'number' ? widget.value : 0;
|
||||
const newIndex = (currentIndex - 1 + optionsCount) % optionsCount;
|
||||
finalValue = newIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Add more type handling as needed
|
||||
|
||||
return finalValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a value specifically for a ComfyUI combo widget.
|
||||
* ComfyUI combo widgets can accept either an index or a string value.
|
||||
* This function ensures we're using the right format.
|
||||
*
|
||||
* @param {Object} widget - The combo widget
|
||||
* @param {Number|String} value - The value to prepare
|
||||
* @returns {Number|String} - The properly formatted value
|
||||
*/
|
||||
function prepareComboWidgetValue(widget, value) {
|
||||
// Make sure we have a widget with options
|
||||
if (!widget.options || !Array.isArray(widget.options.values) || widget.options.values.length === 0) {
|
||||
console.warn("Invalid combo widget structure:", widget);
|
||||
return value;
|
||||
}
|
||||
|
||||
const optionsCount = widget.options.values.length;
|
||||
|
||||
// If value is a number, treat it as an index and make sure it's in range
|
||||
if (typeof value === 'number') {
|
||||
// Make sure the index is within bounds
|
||||
const validIndex = Math.max(0, Math.min(Math.floor(value), optionsCount - 1));
|
||||
|
||||
// For ComfyUI, some combo widgets expect string values, others expect indices
|
||||
// Check the current value type to determine what's expected
|
||||
if (typeof widget.value === 'string') {
|
||||
// If the current value is a string, return the corresponding string option
|
||||
return widget.options.values[validIndex];
|
||||
} else {
|
||||
// If the current value is a number, return the index
|
||||
return validIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// If value is a string, find the matching option or use first option
|
||||
if (typeof value === 'string') {
|
||||
const index = widget.options.values.indexOf(value);
|
||||
if (index !== -1) {
|
||||
return typeof widget.value === 'number' ? index : value;
|
||||
}
|
||||
// If string not found in options, return first option
|
||||
return typeof widget.value === 'number' ? 0 : widget.options.values[0];
|
||||
}
|
||||
|
||||
// Fallback - just use the first option
|
||||
return typeof widget.value === 'number' ? 0 : widget.options.values[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the serialized widget value in the node
|
||||
* @param {Object} node - The ComfyUI node object
|
||||
* @param {Object} widget - The widget object
|
||||
* @param {*} finalValue - The value to set
|
||||
*/
|
||||
function updateSerializedWidgetValue(node, widget, finalValue) {
|
||||
// Find the widget index in the widgets array
|
||||
const widgetIndex = node.widgets.indexOf(widget);
|
||||
if (widgetIndex !== -1) {
|
||||
// Ensure widgets_values exists and has at least widgetIndex elements
|
||||
if (!node.widgets_values) {
|
||||
node.widgets_values = [];
|
||||
}
|
||||
// Extend array if needed
|
||||
while (node.widgets_values.length <= widgetIndex) {
|
||||
node.widgets_values.push(null);
|
||||
}
|
||||
|
||||
// Set the value in the array that will be serialized
|
||||
node.widgets_values[widgetIndex] = finalValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify ComfyUI about widget value changes
|
||||
* @param {Object} app - The ComfyUI app instance
|
||||
* @param {Object} node - The node object
|
||||
* @param {Object} widget - The widget object
|
||||
* @param {String} widgetName - The name of the widget
|
||||
* @param {*} finalValue - The new value
|
||||
* @param {*} oldValue - The previous value
|
||||
*/
|
||||
function notifyComfyUI(app, node, widget, widgetName, finalValue, oldValue) {
|
||||
// CRITICAL: Notify ComfyUI that the widget value has changed
|
||||
if (node.onWidgetChanged) {
|
||||
node.onWidgetChanged(widgetName, finalValue, oldValue, widget);
|
||||
}
|
||||
|
||||
// Call the widget's callback (this is how ComfyUI normally updates values)
|
||||
if (widget.callback) {
|
||||
try {
|
||||
widget.callback(finalValue, app.canvas, node, widgetName, null);
|
||||
} catch (e) {
|
||||
console.error(`MappingEngine: Error in widget callback: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Mark the graph as changed - this triggers serialization in ComfyUI
|
||||
if (app.graph) {
|
||||
app.graph.change();
|
||||
|
||||
// No need to manually save to localStorage/sessionStorage
|
||||
// ComfyUI will handle this automatically through its own persistence mechanisms
|
||||
// when app.graph.change() is called
|
||||
}
|
||||
|
||||
// Call widget_changed if it exists (used in ComfyUI for some widget types)
|
||||
if (app.graph && app.graph.onNodeWidgetChanged) {
|
||||
app.graph.onNodeWidgetChanged(finalValue, app.canvas, node, widgetName, widget);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Workflow Integration for ComfyUI_ControlFreak
|
||||
* Handles saving and loading mappings from the workflow JSON
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { contextProvider } from "./contextProvider.js";
|
||||
import { eventBus } from "./eventBus.js";
|
||||
|
||||
export class WorkflowIntegration {
|
||||
constructor(appInstance, mappingEngine) {
|
||||
this.app = appInstance || app;
|
||||
this.mappingEngine = mappingEngine;
|
||||
|
||||
// Set up event listeners for graph changes
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for ComfyUI workflow events
|
||||
* @private
|
||||
*/
|
||||
_setupEventListeners() {
|
||||
// Add event listener for when graph is exported to JSON
|
||||
const originalGraphToJSON = this.app.graph.toJSON;
|
||||
this.app.graph.toJSON = (data) => {
|
||||
const result = originalGraphToJSON.call(this.app.graph, data);
|
||||
|
||||
try {
|
||||
// Add our mappings data to the workflow JSON
|
||||
if (!result.extra) {
|
||||
result.extra = {};
|
||||
}
|
||||
|
||||
// Get the mapping engine to export mappings
|
||||
const engine = this.mappingEngine || contextProvider.get('mappingEngine');
|
||||
if (engine) {
|
||||
const mappings = engine.exportMappingsForWorkflow();
|
||||
|
||||
// Only add to workflow if we have mappings
|
||||
if (mappings && mappings.length > 0) {
|
||||
result.extra.controlFreak = {
|
||||
// Only include active profile data
|
||||
activeProfile: engine.getActiveProfile(),
|
||||
// Only include mappings for active profile
|
||||
mappings: mappings
|
||||
};
|
||||
|
||||
// Ensure the marker node is in the workflow
|
||||
this._ensureMarkerNodeInWorkflow(result);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("WorkflowIntegration: Error adding mapping data to workflow JSON:", error);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a hidden marker node to the workflow JSON if it doesn't exist
|
||||
* This ensures ComfyUI-Manager can detect the dependency
|
||||
* @private
|
||||
* @param {Object} workflowJSON - The workflow JSON object
|
||||
*/
|
||||
_ensureMarkerNodeInWorkflow(workflowJSON) {
|
||||
try {
|
||||
// Check if any ControlFreak node already exists
|
||||
const hasMarkerNode = workflowJSON.nodes.some(node =>
|
||||
node.type === "ControlFreak"
|
||||
);
|
||||
|
||||
// If no marker node exists, add one at a hidden position
|
||||
if (!hasMarkerNode) {
|
||||
// Add a hidden marker node to indicate ControlFreak is required
|
||||
const markerNode = {
|
||||
id: `ControlFreak_Marker_${Date.now()}`,
|
||||
type: "ControlFreak",
|
||||
pos: [-9999, -9999], // Position far off-screen
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
properties: { "hidden": true }
|
||||
};
|
||||
|
||||
workflowJSON.nodes.push(markerNode);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("WorkflowIntegration: Error adding marker node to workflow:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the profile and mappings to the workflow data
|
||||
* Call this when mappings have changed to update the workflow JSON
|
||||
*/
|
||||
syncProfileToWorkflow() {
|
||||
if (!this.app || !this.app.graph) return;
|
||||
|
||||
try {
|
||||
if (!this.app.graph.extra) {
|
||||
this.app.graph.extra = {};
|
||||
}
|
||||
|
||||
// Get the mapping engine to export mappings
|
||||
const engine = this.mappingEngine || contextProvider.get('mappingEngine');
|
||||
if (engine) {
|
||||
const mappings = engine.exportMappingsForWorkflow();
|
||||
|
||||
// Only update if we have mappings
|
||||
if (mappings && mappings.length > 0) {
|
||||
this.app.graph.extra.controlFreak = {
|
||||
activeProfile: engine.getActiveProfile(),
|
||||
mappings: mappings
|
||||
};
|
||||
|
||||
// Ensure a marker node exists in the graph
|
||||
this._addMarkerNodeToGraph();
|
||||
|
||||
// Trigger a graph change event so it gets saved
|
||||
this.app.graph.change();
|
||||
|
||||
// Emit event for profile synced to workflow
|
||||
eventBus.emit('workflow:profileSynced', {
|
||||
profile: engine.getActiveProfile(),
|
||||
mappingsCount: mappings.length
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("WorkflowIntegration: Error syncing profile to workflow:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a marker node to the current graph if none exists
|
||||
* @private
|
||||
*/
|
||||
_addMarkerNodeToGraph() {
|
||||
try {
|
||||
// Check if the marker node already exists in the graph
|
||||
const hasMarkerNode = this.app.graph.nodes.some(node =>
|
||||
node.type === "ControlFreak"
|
||||
);
|
||||
|
||||
// If no marker node exists, add one
|
||||
if (!hasMarkerNode && window.LiteGraph) {
|
||||
// Create a marker node
|
||||
let node = LiteGraph.createNode("ControlFreak");
|
||||
// Position off-screen
|
||||
node.pos = [-9999, -9999];
|
||||
// Add to graph
|
||||
this.app.graph.add(node);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("WorkflowIntegration: Error adding marker node to graph:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the profile and mappings from the workflow data
|
||||
* Call this when a workflow is loaded to get the mapping data
|
||||
*/
|
||||
syncProfileFromWorkflow() {
|
||||
if (!this.app?.graph?.extra?.controlFreak) return;
|
||||
|
||||
try {
|
||||
const workflowData = this.app.graph.extra.controlFreak;
|
||||
|
||||
// Get the mapping engine
|
||||
const engine = this.mappingEngine || contextProvider.get('mappingEngine');
|
||||
if (engine) {
|
||||
// Import mappings from workflow by adding them individually
|
||||
if (workflowData.mappings && Array.isArray(workflowData.mappings)) {
|
||||
let importedCount = 0;
|
||||
workflowData.mappings.forEach(mapping => {
|
||||
// The addMapping method now handles duplicate checks
|
||||
engine.addMapping(mapping);
|
||||
// Note: We might need to track if addMapping actually added it if we need an accurate count
|
||||
importedCount++; // Assuming addMapping handles duplicates and we count all attempts
|
||||
});
|
||||
|
||||
// Save mappings after potentially adding multiple
|
||||
engine.saveMappings();
|
||||
|
||||
// Ensure marker node exists
|
||||
this._addMarkerNodeToGraph();
|
||||
|
||||
// Emit event after import and save
|
||||
// Retrieve eventBus from context provider to ensure availability
|
||||
const busForImport = contextProvider.get('eventBus');
|
||||
if (busForImport) {
|
||||
busForImport.emit('mappings:imported', workflowData.mappings);
|
||||
} else {
|
||||
console.warn("WorkflowIntegration: eventBus not found in contextProvider when emitting mappings:imported");
|
||||
}
|
||||
}
|
||||
|
||||
// Set active profile
|
||||
if (workflowData.activeProfile) {
|
||||
engine.setActiveProfile(workflowData.activeProfile);
|
||||
}
|
||||
|
||||
// Emit event for profile synced from workflow
|
||||
// Retrieve eventBus from context provider to ensure availability
|
||||
const bus = contextProvider.get('eventBus');
|
||||
if (bus) {
|
||||
bus.emit('workflow:profileLoaded', {
|
||||
profile: workflowData.activeProfile || 'default',
|
||||
mappingsCount: workflowData.mappings ? workflowData.mappings.length : 0
|
||||
});
|
||||
} else {
|
||||
console.warn("WorkflowIntegration: eventBus not found in contextProvider during syncProfileFromWorkflow");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("WorkflowIntegration: Error loading profile from workflow:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle graph loaded event
|
||||
* Called by ComfyUI when a workflow is loaded
|
||||
* @param {Object} appInstance - The ComfyUI app instance
|
||||
*/
|
||||
static handleGraphLoaded(appInstance) {
|
||||
// Get mapping engine from context
|
||||
const mappingEngine = contextProvider.get('mappingEngine');
|
||||
|
||||
if (mappingEngine && appInstance.graph) {
|
||||
try {
|
||||
// Load mappings from workflow
|
||||
mappingEngine.loadMappings();
|
||||
|
||||
// Ensure all mappings are properly reconnected
|
||||
if (mappingEngine.reconnectMappings) {
|
||||
mappingEngine.reconnectMappings();
|
||||
}
|
||||
|
||||
// Check if we have mappings and add marker node if needed
|
||||
const workflowIntegration = new WorkflowIntegration(appInstance, mappingEngine);
|
||||
if (appInstance.graph.extra?.controlFreak?.mappings?.length > 0) {
|
||||
workflowIntegration._addMarkerNodeToGraph();
|
||||
}
|
||||
|
||||
// Emit event for graph loaded
|
||||
eventBus.emit('workflow:graphLoaded');
|
||||
} catch (error) {
|
||||
console.error("WorkflowIntegration: Error handling graph loaded:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Controller event handlers for controller mapping
|
||||
* Handles events from connected controllers and provides UI feedback (Client-Side)
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js"; // Needed for highlightNodeParameter
|
||||
import { contextProvider } from "../core/contextProvider.js";
|
||||
import { eventBus } from "../core/eventBus.js";
|
||||
|
||||
/**
|
||||
* Highlight a node parameter for visual feedback
|
||||
* @param {String} nodeId - The ID of the node
|
||||
* @param {String} paramName - The name of the parameter
|
||||
*/
|
||||
function highlightNodeParameter(nodeId, paramName) {
|
||||
// Find the node
|
||||
const node = app.graph.getNodeById(+nodeId); // Convert to number if needed
|
||||
if (!node || !app?.canvas) return;
|
||||
|
||||
// Add a visual highlight
|
||||
node.highlight = true;
|
||||
|
||||
// Remove highlight after a short delay
|
||||
setTimeout(() => {
|
||||
node.highlight = false;
|
||||
app.canvas.draw(true, true);
|
||||
}, 100);
|
||||
|
||||
// Force redraw
|
||||
app.canvas.draw(true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control update with visual feedback
|
||||
* @param {Object} data - The update data
|
||||
*/
|
||||
function handleControlUpdate(data) {
|
||||
// If data has the target structure
|
||||
if (data?.target?.type === 'widget' && data.target.nodeId) {
|
||||
highlightNodeParameter(data.target.nodeId, data.target.widgetName);
|
||||
|
||||
// Emit event for other handlers
|
||||
eventBus.emit('widget:highlighted', {
|
||||
nodeId: data.target.nodeId,
|
||||
widgetName: data.target.widgetName,
|
||||
value: data.value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners for the event bus
|
||||
eventBus.on('mapping:applying', (mapping, rawValue, transformedValue) => {
|
||||
handleControlUpdate({
|
||||
target: mapping.target,
|
||||
value: transformedValue,
|
||||
rawValue: rawValue
|
||||
});
|
||||
});
|
||||
|
||||
// Export the remaining functions
|
||||
export { highlightNodeParameter, handleControlUpdate };
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Main entry point for the Controller Mapping system
|
||||
* Exports all modules
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { WebMidiController } from './controllers/midi.js';
|
||||
import { GamepadController } from './controllers/gamepad.js';
|
||||
import { MappingEngine } from './core/mappingEngine.js';
|
||||
import { WorkflowIntegration } from './core/workflowIntegration.js';
|
||||
import { showNotification } from './ui/notifications.js';
|
||||
import { registerNodeContextMenu } from './ui/contextMenu.js';
|
||||
import { addControllerButton } from './ui/controllerButton.js';
|
||||
|
||||
import { contextProvider } from './core/contextProvider.js';
|
||||
import { eventBus } from './core/eventBus.js';
|
||||
import { LearningStateManager } from './core/learningStateManager.js';
|
||||
|
||||
// Import UI components after core initialization to avoid circular dependencies
|
||||
import { showLearningDialog, hideLearningDialog, showControllerDialog } from './ui/dialogs.js';
|
||||
import { startMappingNode, startMappingUI, cancelLearning, completeMapping as completeStandardMapping } from './core/learningManager.js';
|
||||
import { highlightNodeParameter } from './handlers/controllerEventHandlers.js';
|
||||
// import { registerExtension as registerMenuExtension } from './ui/menuIntegration.js'; // No longer needed
|
||||
import { createMappingList, deleteMapping } from './ui/mappingComponent.js';
|
||||
import { toggleMappingUI } from './ui/mappingPanel.js';
|
||||
import { initWidgetHighlighter } from './ui/widgetHighlighter.js';
|
||||
|
||||
// Variables to store controller instances for exports
|
||||
let midiController, gamepadController, mappingEngine;
|
||||
|
||||
// Initialize the ControlFreak system
|
||||
async function initializeControlFreak() {
|
||||
// Initialize core instances
|
||||
mappingEngine = new MappingEngine(eventBus);
|
||||
midiController = new WebMidiController();
|
||||
gamepadController = new GamepadController();
|
||||
const learningState = LearningStateManager.getInstance(eventBus);
|
||||
|
||||
// Register instances with context provider
|
||||
contextProvider.register('mappingEngine', mappingEngine);
|
||||
contextProvider.register('midiController', midiController);
|
||||
contextProvider.register('gamepadController', gamepadController);
|
||||
contextProvider.register('eventBus', eventBus);
|
||||
contextProvider.register('learningState', learningState);
|
||||
|
||||
// Initialize UI styles
|
||||
initializeUI();
|
||||
|
||||
// Initialize controller-UI event handlers
|
||||
initializeControllerUIHandlers();
|
||||
|
||||
// Setup workflow event handlers
|
||||
setupWorkflowEventHandlers();
|
||||
|
||||
// Initialize controllers
|
||||
await midiController.initialize();
|
||||
gamepadController.initialize();
|
||||
|
||||
// Setup controller event listeners
|
||||
setupControllerEventListeners(midiController, gamepadController);
|
||||
|
||||
// Wait until ComfyUI has loaded the graph before syncing with workflow
|
||||
waitForGraph();
|
||||
}
|
||||
|
||||
// Add UI initialization
|
||||
function initializeUI() {
|
||||
console.log("ControlFreak: Style loading initiated via CSS links.");
|
||||
}
|
||||
|
||||
// Initialize direct controller-to-UI handlers
|
||||
function initializeControllerUIHandlers() {
|
||||
// Add any other direct UI handlers here if necessary in the future
|
||||
// For now, it's empty after removing the 'controller:input' handler.
|
||||
}
|
||||
|
||||
// Add a handler for workflow events
|
||||
function setupWorkflowEventHandlers() {
|
||||
// Check if we have access to the LiteGraph graph
|
||||
if (!app || !app.graph) {
|
||||
console.warn("ControlFreak: Can't set up workflow event handlers - app or graph not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use ComfyUI's registerExtension API for graph events
|
||||
app.registerExtension({
|
||||
name: "ControlFreak.WorkflowEvents",
|
||||
async setup() {
|
||||
// Nothing to do here
|
||||
},
|
||||
async beforeClearGraph() {
|
||||
const mappingEngine = contextProvider.get('mappingEngine');
|
||||
|
||||
if (mappingEngine) {
|
||||
mappingEngine.mappings = [];
|
||||
mappingEngine.setActiveProfile('default');
|
||||
|
||||
// Clear any stored data
|
||||
if (app.graph) {
|
||||
if (!app.graph.extra) app.graph.extra = {};
|
||||
app.graph.extra.controlFreak = {
|
||||
activeProfile: 'default',
|
||||
mappings: []
|
||||
};
|
||||
}
|
||||
|
||||
// Notify about the reset
|
||||
eventBus.emit('mappings:reset');
|
||||
}
|
||||
},
|
||||
async graphLoaded() {
|
||||
// Use a small delay to ensure the graph is fully loaded
|
||||
setTimeout(() => {
|
||||
const mappingEngine = contextProvider.get('mappingEngine');
|
||||
if (mappingEngine && app.graph) {
|
||||
mappingEngine.loadMappings();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for controllers
|
||||
function setupControllerEventListeners(midiController, gamepadController) {
|
||||
// Setup event listeners for controllers to feed into the mapping engine via event bus
|
||||
midiController.onMessage(message => {
|
||||
// Format MIDI message for the mapping engine
|
||||
const controlInput = {
|
||||
type: 'midi', // Or more specific like 'midi_cc', 'midi_note'
|
||||
deviceId: message.deviceId, // Use the actual device ID from the Web MIDI API
|
||||
controlId: `${message.command}_${message.control}`, // e.g., "176_7" for CC 7
|
||||
rawValue: message.value,
|
||||
deviceName: message.deviceName,
|
||||
name: `MIDI ${message.command === 144 ? 'Note' : 'CC'} ${message.control}`
|
||||
};
|
||||
|
||||
// Publish to event bus
|
||||
eventBus.emit('controller:input', controlInput);
|
||||
});
|
||||
|
||||
gamepadController.onUpdate(update => {
|
||||
// Format Gamepad update for the mapping engine
|
||||
const controlInput = {
|
||||
type: update.type === 'button' ? 'gamepad_button' : 'gamepad_axis',
|
||||
deviceId: update.gamepadId, // Use the gamepad ID
|
||||
controlId: `${update.type}_${update.index}`, // e.g., "button_0", "axis_1"
|
||||
rawValue: update.value, // This is already correctly processed with deadzone in the gamepad controller
|
||||
name: `Gamepad ${update.type === 'button' ? 'Button' : 'Axis'} ${update.index}`,
|
||||
// Include additional data that may be helpful
|
||||
details: {
|
||||
raw: update.raw, // Original raw value before deadzone processing
|
||||
pressed: update.pressed, // For buttons
|
||||
index: update.index
|
||||
}
|
||||
};
|
||||
|
||||
// Publish to event bus
|
||||
eventBus.emit('controller:input', controlInput);
|
||||
});
|
||||
|
||||
// Listen for state changes to update UI (e.g., controller list)
|
||||
// Use a debounce mechanism to prevent infinite recursion
|
||||
let midiStateChangePending = false;
|
||||
midiController.onStateChange((inputs, outputs) => {
|
||||
if (midiStateChangePending) return;
|
||||
midiStateChangePending = true;
|
||||
|
||||
// Publish to event bus
|
||||
setTimeout(() => {
|
||||
eventBus.emit('midi:stateChanged', inputs, outputs);
|
||||
midiStateChangePending = false;
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// Use a debounce mechanism for gamepad state changes too
|
||||
let gamepadStateChangePending = false;
|
||||
gamepadController.onStateChange(gamepads => {
|
||||
if (gamepadStateChangePending) return;
|
||||
gamepadStateChangePending = true;
|
||||
|
||||
// Publish to event bus with a small delay to break potential recursion
|
||||
setTimeout(() => {
|
||||
eventBus.emit('gamepad:stateChanged', gamepads);
|
||||
gamepadStateChangePending = false;
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for graph to be available before loading mappings
|
||||
function waitForGraph() {
|
||||
if (app && app.graph && app.graph.nodes) {
|
||||
try {
|
||||
const mappingEngine = contextProvider.get('mappingEngine');
|
||||
if (mappingEngine) {
|
||||
mappingEngine.loadMappings();
|
||||
setTimeout(() => {
|
||||
if (mappingEngine.reconnectMappings) {
|
||||
mappingEngine.reconnectMappings();
|
||||
}
|
||||
}, 500);
|
||||
setTimeout(() => {
|
||||
if (mappingEngine.workflowIntegration) {
|
||||
mappingEngine.workflowIntegration.syncProfileFromWorkflow();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("ControlFreak: Error syncing with workflow:", error);
|
||||
}
|
||||
} else {
|
||||
setTimeout(waitForGraph, 1000); // Try again in 1 second
|
||||
}
|
||||
}
|
||||
|
||||
// Export necessary components for potential external use
|
||||
export { contextProvider, eventBus };
|
||||
export { toggleMappingUI };
|
||||
export { startMappingNode, startMappingUI };
|
||||
|
||||
// Export controller instances for backward compatibility
|
||||
export { midiController, gamepadController, mappingEngine };
|
||||
|
||||
// Initialize the ControlFreak system via the single extension point
|
||||
app.registerExtension({
|
||||
name: "Comfy.ControlFreak.Client",
|
||||
priority: 1000,
|
||||
async setup(appInstance) {
|
||||
try {
|
||||
// Initialize core components first
|
||||
await initializeControlFreak();
|
||||
|
||||
// Initialize all UI components here after core is ready
|
||||
registerNodeContextMenu(appInstance);
|
||||
|
||||
// Add the button using the ComfyUI-Manager approach
|
||||
await addControllerButton();
|
||||
|
||||
// *** INITIALIZE WIDGET HIGHLIGHTER ***
|
||||
initWidgetHighlighter();
|
||||
|
||||
} catch (error) {
|
||||
console.error("ControlFreak: Error in extension setup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
// UI components for command list (Client-Side)
|
||||
|
||||
import { showNotification } from './notifications.js';
|
||||
// REMOVED: import { showLearningDialog, hideLearningDialog } from './dialogs.js';
|
||||
// REMOVED: import { contextProvider } from '../core/contextProvider.js';
|
||||
import { startMappingCommand } from '../core/learningManager.js'; // Keep this import
|
||||
// REMOVED style imports
|
||||
|
||||
// Access the global instance instead
|
||||
// const getMappingEngine = () => window.controlFreakMappingEngine;
|
||||
|
||||
// Define common ComfyUI commands - these would typically be fetched from the server
|
||||
// but we can define them directly in the client for our simplified architecture
|
||||
const COMFY_UI_COMMANDS = [
|
||||
// Workflow execution
|
||||
{ id: "Comfy.QueuePrompt", label: "Queue Prompt" },
|
||||
{ id: "Comfy.QueuePromptFront", label: "Queue Prompt (Front)" },
|
||||
{ id: "Comfy.Interrupt", label: "Interrupt" },
|
||||
|
||||
// Workflow management
|
||||
{ id: "Comfy.OpenWorkflow", label: "Open Workflow" },
|
||||
{ id: "Comfy.ClearWorkflow", label: "Clear Workflow" },
|
||||
|
||||
// Canvas controls
|
||||
{ id: "Comfy.Canvas.ZoomIn", label: "Canvas Zoom In" },
|
||||
{ id: "Comfy.Canvas.ZoomOut", label: "Canvas Zoom Out" },
|
||||
{ id: "Comfy.Canvas.FitView", label: "Canvas Fit View" },
|
||||
|
||||
// UI panels
|
||||
{ id: "Workspace.ToggleSidebarTab.queue", label: "Toggle Queue Panel" },
|
||||
{ id: "Workspace.ToggleSidebarTab.workflows", label: "Toggle Workflows Panel" },
|
||||
{ id: "Workspace.ToggleSidebarTab.node-library", label: "Toggle Node Library Panel" }
|
||||
];
|
||||
|
||||
/**
|
||||
* Loads and displays available commands
|
||||
* @param {HTMLElement} panel - The panel to render commands in
|
||||
*/
|
||||
export function loadCommandsData(panel) {
|
||||
panel.innerHTML = '<div style="text-align: center; padding: 20px;">Loading commands...</div>';
|
||||
|
||||
try {
|
||||
// Use the predefined commands instead of fetching from server
|
||||
const commands = COMFY_UI_COMMANDS;
|
||||
|
||||
panel.innerHTML = '';
|
||||
|
||||
if (commands.length === 0) {
|
||||
panel.innerHTML = '<div class="no-commands">No commands available. ComfyUI commands system may not be accessible.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a container for the commands list
|
||||
const container = document.createElement('div');
|
||||
container.className = 'commands-container';
|
||||
|
||||
// Create a header with instructions
|
||||
const header = document.createElement('div');
|
||||
header.className = 'commands-panel-header';
|
||||
header.innerHTML = '<h3>Map Controllers to UI Commands</h3>' +
|
||||
'<p>Select a command to map it to a controller input.</p>';
|
||||
container.appendChild(header);
|
||||
|
||||
// Group commands by category
|
||||
const commandsByCategory = groupCommandsByCategory(commands);
|
||||
|
||||
// Create command sections for each category
|
||||
for (const [category, categoryCommands] of Object.entries(commandsByCategory)) {
|
||||
createCommandCategorySection(container, category, categoryCommands);
|
||||
}
|
||||
|
||||
panel.appendChild(container);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading commands data:', error);
|
||||
panel.innerHTML = '<div style="color: red; text-align: center; padding: 20px;">Error loading commands: ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups commands by category based on their id prefix
|
||||
* @param {Array} commands - List of all commands
|
||||
* @returns {Object} Commands grouped by category
|
||||
*/
|
||||
function groupCommandsByCategory(commands) {
|
||||
const groupedCommands = {};
|
||||
|
||||
for (const command of commands) {
|
||||
let category = 'Other';
|
||||
|
||||
// Extract category from command ID (e.g., "Comfy.Canvas.ZoomIn" -> "Comfy.Canvas")
|
||||
if (command.id && command.id.includes('.')) {
|
||||
const parts = command.id.split('.');
|
||||
if (parts.length >= 2) {
|
||||
category = `${parts[0]}.${parts[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!groupedCommands[category]) {
|
||||
groupedCommands[category] = [];
|
||||
}
|
||||
|
||||
groupedCommands[category].push(command);
|
||||
}
|
||||
|
||||
return groupedCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a section for commands in a specific category
|
||||
* @param {HTMLElement} container - Container to append to
|
||||
* @param {String} category - Category name
|
||||
* @param {Array} commands - Commands in this category
|
||||
*/
|
||||
function createCommandCategorySection(container, category, commands) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'command-category-section';
|
||||
|
||||
// Add a header for this category
|
||||
const header = document.createElement('h4');
|
||||
header.className = 'category-header';
|
||||
header.textContent = formatCategoryName(category);
|
||||
section.appendChild(header);
|
||||
|
||||
// Create the command list
|
||||
const commandList = document.createElement('div');
|
||||
commandList.className = 'command-list';
|
||||
|
||||
// Add each command
|
||||
commands.forEach(command => {
|
||||
const commandItem = createCommandItem(command);
|
||||
commandList.appendChild(commandItem);
|
||||
});
|
||||
|
||||
section.appendChild(commandList);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format category name for display
|
||||
* @param {String} category - Raw category name
|
||||
* @returns {String} Formatted category name
|
||||
*/
|
||||
function formatCategoryName(category) {
|
||||
return category.replace(/\./g, ' / ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single command item
|
||||
* @param {Object} command - The command data
|
||||
* @returns {HTMLElement} The command item element
|
||||
*/
|
||||
function createCommandItem(command) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'command-item';
|
||||
|
||||
// Command info
|
||||
const info = document.createElement('div');
|
||||
info.className = 'command-info';
|
||||
|
||||
// Command label
|
||||
const label = document.createElement('div');
|
||||
label.className = 'command-label';
|
||||
label.textContent = command.label || command.id.split('.').pop();
|
||||
info.appendChild(label);
|
||||
|
||||
// Command ID (smaller text below)
|
||||
const id = document.createElement('div');
|
||||
id.className = 'command-id';
|
||||
id.textContent = command.id;
|
||||
info.appendChild(id);
|
||||
|
||||
item.appendChild(info);
|
||||
|
||||
// Map button
|
||||
const mapBtn = document.createElement('button');
|
||||
mapBtn.textContent = 'Map';
|
||||
mapBtn.className = 'command-map-btn';
|
||||
mapBtn.onclick = () => initiateCommandMapping(command);
|
||||
|
||||
item.appendChild(mapBtn);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start mapping a command to a controller
|
||||
* @param {Object} command - The command to map
|
||||
*/
|
||||
function initiateCommandMapping(command) {
|
||||
try {
|
||||
// Call the centralized function from learningManager
|
||||
startMappingCommand(command.id, {
|
||||
label: command.label || command.id,
|
||||
description: `Execute "${command.label}" command`
|
||||
});
|
||||
|
||||
// Notification is handled by startMappingCommand/showLearningDialog now
|
||||
// showNotification(`Move a controller to map to "${command.label}"`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error starting command mapping:", error);
|
||||
showNotification(`Failed to start mapping for "${command.label}"`, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a ComfyUI command
|
||||
* @param {String} commandId - The ID of the command to execute
|
||||
*/
|
||||
// This function is replaced by the mappingEngine.executeCommand method
|
||||
/*
|
||||
function executeCommand(commandId) {
|
||||
|
||||
// Based on the command ID, perform different actions
|
||||
switch (commandId) {
|
||||
case "Comfy.QueuePrompt":
|
||||
app.queuePrompt();
|
||||
break;
|
||||
case "Comfy.QueuePromptFront":
|
||||
app.queuePrompt(true); // true for front of queue
|
||||
break;
|
||||
case "Comfy.Interrupt":
|
||||
app.interrupt();
|
||||
break;
|
||||
case "Comfy.ClearPendingTasks":
|
||||
if (typeof app.clearQueueInProgress === 'function') app.clearQueueInProgress();
|
||||
break;
|
||||
// Other command implementations can be added as needed
|
||||
// Many can be accessed via the app API or dispatched as events
|
||||
default:
|
||||
console.warn(`ControlFreak: Command ${commandId} not implemented`);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register default command handlers (if needed)
|
||||
*/
|
||||
(function registerDefaultCommandHandlers() {
|
||||
// Example of registering a handler directly using the CommandExecutor
|
||||
/*
|
||||
const commandExecutor = contextProvider.get('commandExecutor');
|
||||
if (commandExecutor) {
|
||||
commandExecutor.registerCommandHandler('MyCustomCommand.Action', () => {
|
||||
console.log("My custom command handler executed!");
|
||||
});
|
||||
}
|
||||
*/
|
||||
})();
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Context menu functionality for ComfyUI_ControlFreak
|
||||
*/
|
||||
|
||||
import { startMappingNode, startMappingCommand, startQuickMapping } from '../core/learningManager.js';
|
||||
import { contextProvider } from '../core/contextProvider.js';
|
||||
import { eventBus } from '../core/eventBus.js';
|
||||
import { app } from '../../../scripts/app.js'; // Import app
|
||||
import { showNotification } from '../ui/notifications.js'; // Make sure showNotification is imported
|
||||
|
||||
/**
|
||||
* Position a context menu at the cursor position
|
||||
* @param {HTMLElement} menuElement - The menu element to position
|
||||
* @param {MouseEvent} event - The mouse event from which to get cursor position
|
||||
*/
|
||||
function positionContextMenu(menuElement, event) {
|
||||
menuElement.style.top = event.clientY + "px";
|
||||
menuElement.style.left = event.clientX + "px";
|
||||
}
|
||||
|
||||
// Helper to check if a widget is mappable
|
||||
function isWidgetMappable(widget) {
|
||||
return widget &&
|
||||
(widget.type === "number" ||
|
||||
widget.type === "slider" ||
|
||||
widget.type === "combo" ||
|
||||
widget.type === "toggle" ||
|
||||
widget.type === "boolean" ||
|
||||
widget.type === "string") &&
|
||||
widget.name &&
|
||||
typeof widget.value !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register context menu extensions for nodes
|
||||
* @param {Object} app - The ComfyUI app instance
|
||||
*/
|
||||
export function registerNodeContextMenu(appInstance) {
|
||||
if (!window.LGraphCanvas || !window.LGraphCanvas.prototype) {
|
||||
console.warn("ControlFreak: LGraphCanvas not available, cannot register context menu");
|
||||
return;
|
||||
}
|
||||
|
||||
const originalGetNodeMenuOptions = window.LGraphCanvas.prototype.getNodeMenuOptions;
|
||||
const originalProcessContextMenu = window.LGraphCanvas.prototype.processContextMenu;
|
||||
|
||||
// Override processContextMenu to identify the clicked widget using getWidgetAtCursor
|
||||
window.LGraphCanvas.prototype.processContextMenu = function(node, event) {
|
||||
this.clickedWidget = null; // Reset
|
||||
try {
|
||||
const widget = this.getWidgetAtCursor();
|
||||
|
||||
if (widget && node && node.widgets && node.widgets.includes(widget)) {
|
||||
this.clickedWidget = widget;
|
||||
} else {
|
||||
this.clickedWidget = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ControlFreak Debug] Error calling getWidgetAtCursor():", e);
|
||||
this.clickedWidget = null;
|
||||
}
|
||||
|
||||
// Call the original method AFTER potentially setting this.clickedWidget
|
||||
originalProcessContextMenu.call(this, node, event);
|
||||
};
|
||||
|
||||
// Override getNodeMenuOptions
|
||||
window.LGraphCanvas.prototype.getNodeMenuOptions = function(node) {
|
||||
const options = originalGetNodeMenuOptions.call(this, node);
|
||||
const clickedWidget = this.clickedWidget;
|
||||
this.clickedWidget = null;
|
||||
|
||||
const mappingEngine = contextProvider.get('mappingEngine');
|
||||
if (!mappingEngine) return options; // Early exit if engine not ready
|
||||
|
||||
let controlFreakMenuItems = [];
|
||||
let controlFreakOptionsAdded = false;
|
||||
|
||||
try {
|
||||
// 1. Add specific Quick Map / Quick Unmap options if a widget was clicked
|
||||
if (clickedWidget && isWidgetMappable(clickedWidget)) {
|
||||
const existingMappings = mappingEngine.getMappingsForWidget(node.id, clickedWidget.name);
|
||||
|
||||
controlFreakMenuItems.push(null); // Separator
|
||||
|
||||
// Only show Quick Unmap if there is exactly ONE mapping
|
||||
if (existingMappings.length === 1) {
|
||||
const mappingToUnmap = existingMappings[0];
|
||||
|
||||
// Add Edit Mapping option - this will open the edit dialog directly
|
||||
controlFreakMenuItems.push({
|
||||
content: `Control<span style="color: var(--cf-brand-primary); font-style: italic;">Freak</span>: Edit Mapping`,
|
||||
callback: () => {
|
||||
try {
|
||||
// Import dynamically to avoid circular dependencies
|
||||
import('./mappingComponent.js').then(({ showMappingEditor }) => {
|
||||
showMappingEditor(mappingToUnmap.id);
|
||||
}).catch(err => {
|
||||
console.error("ControlFreak: Error importing mappingComponent:", err);
|
||||
showNotification("Error opening mapping editor", "error");
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("ControlFreak: Error opening mapping editor:", err);
|
||||
showNotification("Error opening mapping editor", "error");
|
||||
}
|
||||
},
|
||||
className: 'controlfreak-edit-mapping-option'
|
||||
});
|
||||
|
||||
// Add "Toggle Invert Mapping" option
|
||||
controlFreakMenuItems.push({
|
||||
content: `Control<span style="color: var(--cf-brand-primary); font-style: italic;">Freak</span>: ${mappingToUnmap.transform?.isInverted ? 'Disable' : 'Enable'} Invert Mapping`,
|
||||
callback: () => {
|
||||
try {
|
||||
// Get current invert state and toggle it
|
||||
const currentIsInverted = !!mappingToUnmap.transform?.isInverted;
|
||||
const newIsInverted = !currentIsInverted;
|
||||
|
||||
// Update the mapping with new transform
|
||||
const updatedData = {
|
||||
transform: {
|
||||
...(mappingToUnmap.transform || {}),
|
||||
isInverted: newIsInverted
|
||||
}
|
||||
};
|
||||
|
||||
// Update the mapping and save
|
||||
mappingEngine.updateMapping(mappingToUnmap.id, updatedData);
|
||||
mappingEngine.saveMappings();
|
||||
|
||||
// Show notification and emit event
|
||||
showNotification(
|
||||
`Invert mapping ${newIsInverted ? 'enabled' : 'disabled'} for '${clickedWidget.name}'`,
|
||||
"success"
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("ControlFreak: Error toggling invert mapping:", err);
|
||||
showNotification("Error toggling invert mapping", "error");
|
||||
}
|
||||
},
|
||||
className: 'controlfreak-toggle-invert-option'
|
||||
});
|
||||
|
||||
// Add Quick Unmap option (existing code)
|
||||
controlFreakMenuItems.push({
|
||||
content: `Control<span style="color: var(--cf-brand-primary); font-style: italic;">Freak</span>: Quick Unmap '${clickedWidget.name}'`,
|
||||
callback: () => {
|
||||
try {
|
||||
mappingEngine.deleteMapping(mappingToUnmap.id);
|
||||
mappingEngine.saveMappings();
|
||||
showNotification(`Unmapped '${clickedWidget.name}'`, "success");
|
||||
eventBus.emit('mapping:deleted', { mappingId: mappingToUnmap.id });
|
||||
} catch (err) {
|
||||
console.error("ControlFreak: Error during quick unmap:", err);
|
||||
showNotification("Error unmapping widget", "error");
|
||||
}
|
||||
},
|
||||
className: 'controlfreak-quickunmap-option'
|
||||
});
|
||||
} else if (existingMappings.length === 0) {
|
||||
// Only show Quick Map if there are NO mappings
|
||||
controlFreakMenuItems.push({
|
||||
content: `Control<span style="color: var(--cf-brand-primary); font-style: italic;">Freak</span>: Quick Map '${clickedWidget.name}'`,
|
||||
callback: () => {
|
||||
startQuickMapping(node, clickedWidget);
|
||||
},
|
||||
className: 'controlfreak-quickmap-option'
|
||||
});
|
||||
} else {
|
||||
// If > 1 mapping exists, don't show Quick Unmap (user should use panel)
|
||||
}
|
||||
controlFreakOptionsAdded = true;
|
||||
|
||||
// Add the "Standard Map..." option directly since a mappable widget was clicked
|
||||
controlFreakMenuItems.push({
|
||||
content: `Control<span style="color: var(--cf-brand-primary); font-style: italic;">Freak</span>: Standard Map '${clickedWidget.name}'...`,
|
||||
callback: () => {
|
||||
startMappingNode(node.id, clickedWidget.name);
|
||||
eventBus.emit('menu:mappingStarted', {
|
||||
type: 'widget',
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
nodeTitle: node.title || node.type,
|
||||
widgetName: clickedWidget.name,
|
||||
widgetType: clickedWidget.type
|
||||
});
|
||||
},
|
||||
className: 'controlfreak-standardmap-option' // Optional: Add class for styling
|
||||
});
|
||||
|
||||
} else if (clickedWidget) {
|
||||
// Clicked on a widget, but it's not mappable (e.g., button, custompaint)
|
||||
// Do nothing specific for ControlFreak here for now
|
||||
}
|
||||
// ELSE: Clicked on node background OR a non-mappable widget
|
||||
else if (node.widgets && node.widgets.length > 0) {
|
||||
const mappableWidgets = node.widgets.filter(isWidgetMappable);
|
||||
if (mappableWidgets.length > 0) {
|
||||
// Only show the "Map Control..." submenu if no specific widget was clicked
|
||||
const widgetMappingOptions = mappableWidgets.map(widget => ({
|
||||
content: widget.name,
|
||||
submenu: {
|
||||
options: [
|
||||
{
|
||||
content: "Standard Map...",
|
||||
callback: () => {
|
||||
startMappingNode(node.id, widget.name);
|
||||
eventBus.emit('menu:mappingStarted', {
|
||||
type: 'widget',
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
nodeTitle: node.title || node.type,
|
||||
widgetName: widget.name,
|
||||
widgetType: widget.type
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
content: "Quick Map",
|
||||
callback: () => {
|
||||
startQuickMapping(node, widget);
|
||||
eventBus.emit('menu:quickMappingStarted', {
|
||||
type: 'widget',
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
nodeTitle: node.title || node.type,
|
||||
widgetName: widget.name,
|
||||
widgetType: widget.type
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
// Check if there are actually mappable widgets before adding the submenu
|
||||
if (widgetMappingOptions.length > 0) {
|
||||
if (controlFreakMenuItems.length === 0 || (controlFreakMenuItems.length > 0 && controlFreakMenuItems[0] !== null) ) {
|
||||
controlFreakMenuItems.push(null); // Separator needed
|
||||
}
|
||||
controlFreakMenuItems.push({
|
||||
content: `Control<span style="color: var(--cf-brand-primary); font-style: italic;">Freak</span>: Map Control...`,
|
||||
submenu: {
|
||||
options: widgetMappingOptions
|
||||
}
|
||||
});
|
||||
controlFreakOptionsAdded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add all prepared ControlFreak options
|
||||
if (controlFreakOptionsAdded) {
|
||||
options.push(...controlFreakMenuItems);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("ControlFreak: Error creating controller mapping menu:", error);
|
||||
}
|
||||
return options;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register context menu for the queue button
|
||||
*/
|
||||
export function registerQueueButtonMenu() {
|
||||
try {
|
||||
// Find the queue button
|
||||
const queueButton = document.getElementById("queue-button");
|
||||
if (!queueButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the context menu handler
|
||||
queueButton.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Create a simple context menu for the queue button
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "litegraph-context-menu";
|
||||
positionContextMenu(menu, e);
|
||||
|
||||
const option = document.createElement("div");
|
||||
option.className = "menu-entry";
|
||||
option.textContent = "Map Controller to Queue Button";
|
||||
option.addEventListener("click", () => {
|
||||
// Use the imported function directly
|
||||
startMappingCommand("Comfy.QueuePrompt", {
|
||||
label: "Queue Prompt",
|
||||
description: "Execute current workflow"
|
||||
});
|
||||
|
||||
// Remove the menu
|
||||
document.body.removeChild(menu);
|
||||
|
||||
// Emit an event so we can track this in analytics if desired
|
||||
eventBus.emit('menu:mappingStarted', {
|
||||
type: 'command',
|
||||
commandId: 'Comfy.QueuePrompt',
|
||||
commandLabel: 'Queue Prompt'
|
||||
});
|
||||
});
|
||||
|
||||
menu.appendChild(option);
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Remove the menu when clicking elsewhere
|
||||
const removeMenu = () => {
|
||||
if (document.body.contains(menu)) {
|
||||
document.body.removeChild(menu);
|
||||
}
|
||||
document.body.removeEventListener("click", removeMenu);
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.addEventListener("click", removeMenu);
|
||||
}, 0);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("ControlFreak: Error registering queue button menu:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Controller button functionality for ComfyUI_ControlFreak
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { showNotification } from './notifications.js';
|
||||
import { contextProvider } from '../core/contextProvider.js';
|
||||
import { eventBus } from '../core/eventBus.js';
|
||||
import { toggleMappingUI } from './mappingPanel.js';
|
||||
|
||||
/**
|
||||
* Add the controller management button to the UI
|
||||
*/
|
||||
export async function addControllerButton() {
|
||||
try {
|
||||
// Find the ComfyUI header
|
||||
const menu = document.querySelector(".comfy-menu");
|
||||
if (!menu) {
|
||||
console.warn("ControlFreak: Could not find ComfyUI header menu");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a separator
|
||||
const separator = document.createElement("hr");
|
||||
separator.className = "cf-menu-separator";
|
||||
menu.append(separator);
|
||||
|
||||
// Create the click handler
|
||||
const handleClick = () => {
|
||||
// Get controllers from context provider
|
||||
const midiController = contextProvider.get('midiController');
|
||||
const gamepadController = contextProvider.get('gamepadController');
|
||||
const mappingEngine = contextProvider.get('mappingEngine');
|
||||
|
||||
if (!midiController || !gamepadController) {
|
||||
showNotification("Controller system not fully initialized", 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the mapping panel
|
||||
toggleMappingUI(true);
|
||||
|
||||
// Emit event for analytics
|
||||
eventBus.emit('ui:controllerButtonClicked');
|
||||
};
|
||||
|
||||
try {
|
||||
// Import the ComfyButtonGroup for proper integration
|
||||
const { ComfyButtonGroup } = await import("../../../../scripts/ui/components/buttonGroup.js");
|
||||
|
||||
// Create our branded button
|
||||
const controlFreakButton = document.createElement("button");
|
||||
controlFreakButton.className = "cf-menu-button";
|
||||
controlFreakButton.id = "controlfreak-menu-button";
|
||||
controlFreakButton.title = "Control Freak - Map your controllers to ComfyUI";
|
||||
|
||||
// Add the branded text
|
||||
const brandText = document.createElement("span");
|
||||
brandText.className = "cf-brand-text";
|
||||
brandText.innerHTML = 'Control<span class="cf-brand-highlight">Freak</span>';
|
||||
controlFreakButton.appendChild(brandText);
|
||||
|
||||
// Set click handler
|
||||
controlFreakButton.onclick = handleClick;
|
||||
|
||||
// Create a wrapper for proper menu integration
|
||||
const buttonWrapper = document.createElement("div");
|
||||
buttonWrapper.className = "comfyui-tool-group cf-button-wrapper";
|
||||
buttonWrapper.appendChild(controlFreakButton);
|
||||
|
||||
// Insert before settings group
|
||||
app.menu?.settingsGroup.element.before(buttonWrapper);
|
||||
}
|
||||
catch(exception) {
|
||||
console.error('ControlFreak: Error adding button to menu:', exception);
|
||||
}
|
||||
|
||||
// Set up event handling for controller connections
|
||||
setupControllerEvents();
|
||||
|
||||
// Set up handler to update button state when controllers connect
|
||||
updateButtonState();
|
||||
} catch (error) {
|
||||
console.error("ControlFreak: Error adding controller button:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update button state based on connected controllers
|
||||
*/
|
||||
function updateButtonState() {
|
||||
const button = document.querySelector('#controlfreak-menu-button');
|
||||
if (!button) return;
|
||||
|
||||
const midiController = contextProvider.get('midiController');
|
||||
const gamepadController = contextProvider.get('gamepadController');
|
||||
|
||||
// Count total connected controllers
|
||||
let connectedCount = 0;
|
||||
|
||||
if (midiController) {
|
||||
const midiInputs = midiController.getInputs();
|
||||
connectedCount += midiInputs.filter(d => d.state === 'connected').length;
|
||||
}
|
||||
|
||||
if (gamepadController) {
|
||||
const gamepads = gamepadController.getGamepads();
|
||||
connectedCount += gamepads.filter(d => d.state === 'connected').length;
|
||||
}
|
||||
|
||||
// Update button state
|
||||
if (connectedCount > 0) {
|
||||
button.classList.add('connected');
|
||||
button.title = `${connectedCount} controller${connectedCount > 1 ? 's' : ''} connected`;
|
||||
} else {
|
||||
button.classList.remove('connected');
|
||||
button.title = 'No controllers connected';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up handler functions for controller events
|
||||
*/
|
||||
function setupControllerEvents() {
|
||||
// Set up event handlers for controller connections
|
||||
eventBus.on('midi:deviceConnected', (device) => {
|
||||
showNotification(`MIDI Device Connected: ${device.deviceName}`, 'success');
|
||||
updateButtonState();
|
||||
});
|
||||
|
||||
eventBus.on('midi:deviceDisconnected', (device) => {
|
||||
showNotification(`MIDI Device Disconnected: ${device.deviceName}`, 'info');
|
||||
updateButtonState();
|
||||
});
|
||||
|
||||
eventBus.on('gamepad:deviceConnected', (device) => {
|
||||
showNotification(`Gamepad Connected: ${device.deviceName}`, 'success');
|
||||
updateButtonState();
|
||||
});
|
||||
|
||||
eventBus.on('gamepad:deviceDisconnected', (device) => {
|
||||
showNotification(`Gamepad Disconnected: ${device.deviceName}`, 'info');
|
||||
updateButtonState();
|
||||
});
|
||||
|
||||
// State changes
|
||||
eventBus.on('midi:stateChanged', () => updateButtonState());
|
||||
eventBus.on('gamepad:stateChanged', () => updateButtonState());
|
||||
|
||||
// Error handling
|
||||
eventBus.on('midi:error', (error) => {
|
||||
showNotification(`MIDI Error: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
eventBus.on('gamepad:error', (error) => {
|
||||
showNotification(`Gamepad Error: ${error.message}`, 'error');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// UI components for controller list (Client-Side)
|
||||
|
||||
import { contextProvider } from "../core/contextProvider.js";
|
||||
import { eventBus } from "../core/eventBus.js";
|
||||
|
||||
// Create a simple debounce function
|
||||
const debounce = (fn, delay) => {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
fn(...args);
|
||||
timeoutId = null;
|
||||
}, delay);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads and displays controller data
|
||||
* @param {HTMLElement} panel - The panel to render controllers in
|
||||
*/
|
||||
export function loadControllerData(panel) {
|
||||
// Set a flag on the panel to prevent multiple simultaneous updates
|
||||
if (panel.isUpdating) return;
|
||||
panel.isUpdating = true;
|
||||
|
||||
panel.innerHTML = '<div class="cf-loading"><span class="cf-loading-icon">🎮</span>Loading controllers...</div>';
|
||||
|
||||
try {
|
||||
// Get controllers from context
|
||||
const midiController = contextProvider.get('midiController');
|
||||
const gamepadController = contextProvider.get('gamepadController');
|
||||
|
||||
// Clear the panel
|
||||
panel.innerHTML = '';
|
||||
|
||||
// --- MIDI Controllers ---
|
||||
const midiSection = document.createElement('div');
|
||||
midiSection.className = 'controller-section midi-section';
|
||||
panel.appendChild(midiSection);
|
||||
|
||||
const midiHeader = document.createElement('h3');
|
||||
midiHeader.className = 'cf-section-header';
|
||||
midiHeader.innerHTML = '<span class="cf-section-icon">🎹</span> MIDI Controllers';
|
||||
midiSection.appendChild(midiHeader);
|
||||
|
||||
// Ensure midiController is initialized before accessing inputs
|
||||
if (midiController && midiController.initialized) {
|
||||
const midiInputs = midiController.getInputs();
|
||||
if (midiInputs.length === 0) {
|
||||
midiSection.appendChild(createNoDevicesMessage("No MIDI input devices detected."));
|
||||
} else {
|
||||
// Create a container for the cards
|
||||
const midiCardsContainer = document.createElement('div');
|
||||
midiCardsContainer.className = 'cf-cards-container';
|
||||
midiSection.appendChild(midiCardsContainer);
|
||||
|
||||
midiInputs.forEach(device => {
|
||||
createMidiDeviceCard(midiCardsContainer, device);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
midiSection.appendChild(createNoDevicesMessage("Web MIDI API not supported, not enabled, or still initializing."));
|
||||
}
|
||||
|
||||
// --- Gamepad Controllers ---
|
||||
const gamepadSection = document.createElement('div');
|
||||
gamepadSection.className = 'controller-section gamepad-section';
|
||||
panel.appendChild(gamepadSection);
|
||||
|
||||
const gamepadHeader = document.createElement('h3');
|
||||
gamepadHeader.className = 'cf-section-header';
|
||||
gamepadHeader.innerHTML = '<span class="cf-section-icon">🎮</span> Gamepad Controllers';
|
||||
gamepadSection.appendChild(gamepadHeader);
|
||||
|
||||
// Ensure gamepadController is initialized
|
||||
if (gamepadController) {
|
||||
const gamepads = gamepadController.getGamepads();
|
||||
if (gamepads.length === 0) {
|
||||
gamepadSection.appendChild(createNoDevicesMessage("No gamepads detected. Connect a gamepad and press a button."));
|
||||
} else {
|
||||
// Create a container for the cards
|
||||
const gamepadCardsContainer = document.createElement('div');
|
||||
gamepadCardsContainer.className = 'cf-cards-container';
|
||||
gamepadSection.appendChild(gamepadCardsContainer);
|
||||
|
||||
gamepads.forEach(device => {
|
||||
createGamepadDeviceCard(gamepadCardsContainer, device);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
gamepadSection.appendChild(createNoDevicesMessage("Gamepad API not available or not initialized."));
|
||||
}
|
||||
|
||||
// --- Add listeners to update UI on state change ---
|
||||
// Create a debounced update function to avoid excessive refreshes
|
||||
const debouncedUpdate = debounce(() => {
|
||||
if (panel.parentNode) { // Only update if still in DOM
|
||||
panel.isUpdating = false; // Reset the flag before calling update
|
||||
loadControllerData(panel);
|
||||
}
|
||||
}, 250); // 250ms debounce
|
||||
|
||||
// Set up event listeners
|
||||
const midiUnsubscribe = eventBus.on('midi:stateChanged', () => {
|
||||
debouncedUpdate();
|
||||
});
|
||||
|
||||
const gamepadUnsubscribe = eventBus.on('gamepad:stateChanged', () => {
|
||||
debouncedUpdate();
|
||||
});
|
||||
|
||||
// Store unsubscribe functions on the panel for cleanup
|
||||
panel.controllerListCleanup = () => {
|
||||
midiUnsubscribe();
|
||||
gamepadUnsubscribe();
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading controller data:', error);
|
||||
|
||||
// Create error panel
|
||||
panel.innerHTML = `
|
||||
<div class="cf-error-panel">
|
||||
<h3 class="cf-error-title">Error loading controllers</h3>
|
||||
<p class="cf-error-message">${error.message}</p>
|
||||
<div class="cf-error-stack">${error.stack || "No stack trace available"}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Reset the updating flag
|
||||
panel.isUpdating = false;
|
||||
}
|
||||
|
||||
/** Helper to create "No devices" message */
|
||||
function createNoDevicesMessage(text) {
|
||||
const noDevices = document.createElement('div');
|
||||
noDevices.className = 'cf-no-devices';
|
||||
noDevices.innerHTML = `
|
||||
<div class="cf-no-devices-icon">🔍</div>
|
||||
<p>${text}</p>
|
||||
`;
|
||||
return noDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MIDI device card UI component
|
||||
* @param {HTMLElement} container - The container to add the card to
|
||||
* @param {Object} device - The MIDI device data from WebMidiController
|
||||
*/
|
||||
function createMidiDeviceCard(container, device) {
|
||||
const deviceCard = document.createElement('div');
|
||||
deviceCard.className = 'controller-card midi-card';
|
||||
container.appendChild(deviceCard);
|
||||
|
||||
// MIDI devices from Web MIDI API have a state property
|
||||
const isConnected = device.state === 'connected';
|
||||
|
||||
// Add device header with status badge
|
||||
const deviceHeader = document.createElement('div');
|
||||
deviceHeader.className = 'controller-card-header';
|
||||
|
||||
const deviceName = document.createElement('h4');
|
||||
deviceName.className = 'controller-card-title';
|
||||
deviceName.textContent = device.name;
|
||||
deviceName.title = `ID: ${device.id}\nManufacturer: ${device.manufacturer || 'N/A'}`;
|
||||
deviceHeader.appendChild(deviceName);
|
||||
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = `controller-status ${isConnected ? 'connected' : 'disconnected'}`;
|
||||
statusBadge.textContent = isConnected ? 'Connected' : 'Disconnected';
|
||||
deviceHeader.appendChild(statusBadge);
|
||||
|
||||
deviceCard.appendChild(deviceHeader);
|
||||
|
||||
// Device info (MIDI devices don't typically report button/axis counts)
|
||||
const deviceInfo = document.createElement('div');
|
||||
deviceInfo.className = 'device-info';
|
||||
deviceInfo.innerHTML = `
|
||||
<div class="device-type">
|
||||
<span class="device-type-icon">🎹</span>
|
||||
<span class="device-type-label">MIDI Device</span>
|
||||
</div>
|
||||
<div class="device-state">${device.state}</div>
|
||||
`;
|
||||
deviceCard.appendChild(deviceInfo);
|
||||
|
||||
// Connect/disconnect controls
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'controller-actions';
|
||||
|
||||
if (isConnected) {
|
||||
const disconnectBtn = document.createElement('button');
|
||||
disconnectBtn.className = 'disconnect-btn';
|
||||
disconnectBtn.innerHTML = '<span>Disconnect</span>';
|
||||
disconnectBtn.onclick = () => {
|
||||
eventBus.emit('midi:disconnect', device.id);
|
||||
};
|
||||
actions.appendChild(disconnectBtn);
|
||||
} else {
|
||||
const connectBtn = document.createElement('button');
|
||||
connectBtn.className = 'connect-btn';
|
||||
connectBtn.innerHTML = '<span>Connect</span>';
|
||||
connectBtn.onclick = () => {
|
||||
eventBus.emit('midi:connect', device.id);
|
||||
};
|
||||
actions.appendChild(connectBtn);
|
||||
}
|
||||
|
||||
deviceCard.appendChild(actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Gamepad device card UI component
|
||||
* @param {HTMLElement} container - The container to add the card to
|
||||
* @param {Object} device - The Gamepad device data from GamepadController
|
||||
*/
|
||||
function createGamepadDeviceCard(container, device) {
|
||||
const deviceCard = document.createElement('div');
|
||||
deviceCard.className = 'controller-card gamepad-card';
|
||||
container.appendChild(deviceCard);
|
||||
|
||||
// Check if the gamepad is explicitly connected for input
|
||||
const isConnected = device.state === 'connected';
|
||||
|
||||
// Add device header with status badge
|
||||
const deviceHeader = document.createElement('div');
|
||||
deviceHeader.className = 'controller-card-header';
|
||||
|
||||
const deviceName = document.createElement('h4');
|
||||
deviceName.className = 'controller-card-title';
|
||||
deviceName.textContent = device.name;
|
||||
deviceName.title = `ID: ${device.id}`;
|
||||
deviceHeader.appendChild(deviceName);
|
||||
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = `controller-status ${isConnected ? 'connected' : 'detected'}`;
|
||||
statusBadge.textContent = isConnected ? 'Connected' : 'Detected';
|
||||
deviceHeader.appendChild(statusBadge);
|
||||
|
||||
deviceCard.appendChild(deviceHeader);
|
||||
|
||||
// Device info
|
||||
const deviceInfo = document.createElement('div');
|
||||
deviceInfo.className = 'device-info';
|
||||
if (device.buttons && device.axes) {
|
||||
deviceInfo.innerHTML = `
|
||||
<div class="device-type">
|
||||
<span class="device-type-icon">🎮</span>
|
||||
<span class="device-type-label">Gamepad</span>
|
||||
</div>
|
||||
<div class="device-specs">
|
||||
<span class="device-buttons">${device.buttons} buttons</span>
|
||||
<span class="device-axes">${device.axes} axes</span>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
deviceInfo.innerHTML = `
|
||||
<div class="device-type">
|
||||
<span class="device-type-icon">🎮</span>
|
||||
<span class="device-type-label">Gamepad</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
deviceCard.appendChild(deviceInfo);
|
||||
|
||||
// Connect/disconnect controls
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'controller-actions';
|
||||
|
||||
if (isConnected) {
|
||||
const disconnectBtn = document.createElement('button');
|
||||
disconnectBtn.className = 'disconnect-btn';
|
||||
disconnectBtn.innerHTML = '<span>Disconnect</span>';
|
||||
disconnectBtn.onclick = () => {
|
||||
eventBus.emit('gamepad:disconnect', device.id);
|
||||
};
|
||||
actions.appendChild(disconnectBtn);
|
||||
} else {
|
||||
const connectBtn = document.createElement('button');
|
||||
connectBtn.className = 'connect-btn';
|
||||
connectBtn.innerHTML = '<span>Connect</span>';
|
||||
connectBtn.onclick = () => {
|
||||
eventBus.emit('gamepad:connect', device.id);
|
||||
};
|
||||
actions.appendChild(connectBtn);
|
||||
}
|
||||
|
||||
deviceCard.appendChild(actions);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Dialog UI components for controller mapping
|
||||
* Handles dialog creation, display, and interaction
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { createMappingList } from "./mappingComponent.js";
|
||||
import { cancelLearning, completeMapping } from "../core/learningManager.js";
|
||||
import { contextProvider } from "../core/contextProvider.js";
|
||||
import { eventBus } from "../core/eventBus.js";
|
||||
|
||||
// Get instances from context instead of globals
|
||||
const getContext = () => {
|
||||
const mappingEngine = contextProvider.get('mappingEngine');
|
||||
const learningState = contextProvider.get('learningState');
|
||||
|
||||
if (!mappingEngine || !learningState) {
|
||||
console.warn("ControlFreak: Core components not available in dialog, functionality may be limited");
|
||||
}
|
||||
|
||||
return { mappingEngine, learningState };
|
||||
};
|
||||
|
||||
/**
|
||||
* Show the learning dialog
|
||||
* @param {Object} learningTarget - The target information from learningManager.js
|
||||
* (e.g., { type: 'widget', nodeId: ..., widgetName: ..., nodeTitle: ..., widgetConfig: ... })
|
||||
* @param {Object} callbacks - Optional callbacks for dialog actions
|
||||
* @returns {Object} Dialog elements
|
||||
*/
|
||||
function showLearningDialog(learningTarget, callbacks = {}) {
|
||||
// First, remove any existing dialog
|
||||
const existingDialog = document.getElementById("controller-learning-dialog");
|
||||
if (existingDialog) {
|
||||
existingDialog.remove();
|
||||
}
|
||||
|
||||
// Create dialog container
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "controller-dialog controller-mapping-dialog";
|
||||
dialog.id = "controller-learning-dialog"; // Add ID for easier selection
|
||||
|
||||
// Store dialog in learning state
|
||||
const { learningState } = getContext();
|
||||
if (learningState) {
|
||||
learningState.setDialog(dialog);
|
||||
} else {
|
||||
console.warn("ControlFreak Dialog: Learning state not available, dialog may not work properly");
|
||||
}
|
||||
|
||||
// Create dialog header
|
||||
const header = document.createElement("div");
|
||||
header.className = "controller-dialog-header";
|
||||
|
||||
// Add branded title with icon
|
||||
const title = document.createElement("h2");
|
||||
title.className = "cf-dialog-title";
|
||||
title.innerHTML = `
|
||||
<div class="cf-logo">
|
||||
<span class="cf-logo-icon">🎮</span>
|
||||
<span class="cf-logo-text">Control<span class="cf-logo-highlight">Freak</span></span>
|
||||
</div>
|
||||
`;
|
||||
header.appendChild(title);
|
||||
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.className = "controller-dialog-close";
|
||||
closeButton.innerHTML = "×";
|
||||
closeButton.onclick = () => {
|
||||
if (callbacks.onCancel) {
|
||||
callbacks.onCancel();
|
||||
} else {
|
||||
cancelLearning(); // Call the client-side cancel function
|
||||
}
|
||||
};
|
||||
header.appendChild(closeButton);
|
||||
|
||||
dialog.appendChild(header);
|
||||
|
||||
// Create dialog content
|
||||
const content = document.createElement("div");
|
||||
content.className = "controller-dialog-content";
|
||||
|
||||
// Add a subtitle
|
||||
const subtitle = document.createElement("div");
|
||||
subtitle.className = "cf-dialog-subtitle";
|
||||
subtitle.textContent = "Controller Mapping";
|
||||
content.appendChild(subtitle);
|
||||
|
||||
// Target info section
|
||||
const targetSection = document.createElement("div");
|
||||
targetSection.className = "controller-mapping-target";
|
||||
|
||||
const targetTitle = document.createElement("h3");
|
||||
targetTitle.textContent = "Target";
|
||||
targetSection.appendChild(targetTitle);
|
||||
|
||||
const targetInfo = document.createElement("div");
|
||||
targetInfo.className = "controller-mapping-target-info";
|
||||
|
||||
if (learningTarget.type === "widget") {
|
||||
targetInfo.innerHTML = `
|
||||
<div><strong>Type:</strong> Node Widget</div>
|
||||
<div><strong>Node:</strong> ${learningTarget.nodeTitle || learningTarget.nodeId}</div>
|
||||
<div><strong>Widget:</strong> ${learningTarget.widgetName}</div>
|
||||
`;
|
||||
} else if (learningTarget.type === "ui_element") {
|
||||
targetInfo.innerHTML = `
|
||||
<div><strong>Type:</strong> UI Element</div>
|
||||
<div><strong>Element ID:</strong> ${learningTarget.elementId}</div>
|
||||
`;
|
||||
} else if (learningTarget.type === "command") {
|
||||
targetInfo.innerHTML = `
|
||||
<div><strong>Type:</strong> Command</div>
|
||||
<div><strong>Command:</strong> ${learningTarget.label || learningTarget.commandId}</div>
|
||||
`;
|
||||
} else {
|
||||
targetInfo.textContent = "Unknown target type.";
|
||||
}
|
||||
|
||||
targetSection.appendChild(targetInfo);
|
||||
content.appendChild(targetSection);
|
||||
|
||||
// Existing mappings section
|
||||
const existingMappingsSection = document.createElement("div");
|
||||
existingMappingsSection.className = "controller-mapping-existing";
|
||||
|
||||
const existingMappingsTitle = document.createElement("h3");
|
||||
existingMappingsTitle.textContent = "Existing Mappings";
|
||||
existingMappingsSection.appendChild(existingMappingsTitle);
|
||||
|
||||
// Create a placeholder for the existing mappings list
|
||||
const mappingsPlaceholder = document.createElement("div");
|
||||
mappingsPlaceholder.className = "controller-mapping-existing-content";
|
||||
mappingsPlaceholder.textContent = "Loading existing mappings...";
|
||||
existingMappingsSection.appendChild(mappingsPlaceholder);
|
||||
|
||||
// Get existing mappings for this target
|
||||
try {
|
||||
// Get mappingEngine from context
|
||||
const { mappingEngine } = getContext();
|
||||
if (!mappingEngine) {
|
||||
mappingsPlaceholder.textContent = "MappingEngine not available";
|
||||
} else {
|
||||
// Get all mappings from current profile
|
||||
const allMappings = mappingEngine.getMappings();
|
||||
|
||||
// Filter for mappings targeting this specific widget/command/element
|
||||
const filteredMappings = allMappings.filter(mapping => {
|
||||
if (learningTarget.type === "widget" && mapping.target.type === "widget") {
|
||||
return (
|
||||
mapping.target.nodeId === learningTarget.nodeId &&
|
||||
mapping.target.widgetName === learningTarget.widgetName
|
||||
);
|
||||
} else if (learningTarget.type === "command" && mapping.target.type === "command") {
|
||||
return mapping.target.commandId === learningTarget.commandId;
|
||||
} else if (learningTarget.type === "ui_element" && mapping.target.type === "ui_element") {
|
||||
return mapping.target.elementId === learningTarget.elementId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Create the mappings list and update the placeholder
|
||||
try {
|
||||
// Clear the placeholder
|
||||
mappingsPlaceholder.innerHTML = '';
|
||||
|
||||
// Create mapping list
|
||||
const mappingsList = createMappingList(filteredMappings);
|
||||
|
||||
// Add the mappings list to the placeholder
|
||||
mappingsPlaceholder.appendChild(mappingsList);
|
||||
|
||||
// If no mappings, createMappingList will handle showing a message
|
||||
} catch (error) {
|
||||
console.error("Error creating mappings list:", error);
|
||||
mappingsPlaceholder.textContent = "Error loading mappings.";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error setting up existing mappings:", error);
|
||||
mappingsPlaceholder.textContent = "Error setting up mappings.";
|
||||
}
|
||||
|
||||
content.appendChild(existingMappingsSection);
|
||||
|
||||
// Controller input section
|
||||
const inputSection = document.createElement("div");
|
||||
inputSection.className = "controller-mapping-input";
|
||||
|
||||
const inputTitle = document.createElement("h3");
|
||||
inputTitle.textContent = "Controller Input";
|
||||
inputSection.appendChild(inputTitle);
|
||||
|
||||
// Create the input list container
|
||||
const inputContainer = document.createElement("div");
|
||||
inputContainer.className = "controller-mapping-input-list";
|
||||
inputContainer.innerHTML = "<div class='controller-mapping-instruction'>Move a control on your controller to map it</div>";
|
||||
inputSection.appendChild(inputContainer);
|
||||
|
||||
content.appendChild(inputSection);
|
||||
|
||||
// Mapping type section
|
||||
const mappingTypeSection = document.createElement("div");
|
||||
mappingTypeSection.className = "controller-mapping-type-section";
|
||||
|
||||
const mappingTypeTitle = document.createElement("h3");
|
||||
mappingTypeTitle.textContent = "Mapping Type";
|
||||
mappingTypeSection.appendChild(mappingTypeTitle);
|
||||
|
||||
// Create mapping type selector
|
||||
const mappingTypeContainer = document.createElement("div");
|
||||
mappingTypeContainer.className = "controller-mapping-type-container";
|
||||
|
||||
// Create type selector
|
||||
const typeSelect = document.createElement("select");
|
||||
typeSelect.className = "controller-mapping-type-select";
|
||||
|
||||
// Add mapping type options based on target type
|
||||
if (learningTarget.type === "widget") {
|
||||
// Different mapping types for widgets
|
||||
typeSelect.innerHTML = `
|
||||
<option value="direct">Direct Control (Default)</option>
|
||||
<option value="toggle">Toggle (On/Off)</option>
|
||||
<option value="momentary">Momentary (Active while held)</option>
|
||||
<option value="incremental">Increment Value</option>
|
||||
<option value="decremental">Decrement Value</option>
|
||||
<option value="trigger">Trigger Action</option>
|
||||
`;
|
||||
} else if (learningTarget.type === "command") {
|
||||
// Command mapping types
|
||||
typeSelect.innerHTML = `
|
||||
<option value="trigger">Trigger Command (Default)</option>
|
||||
<option value="toggle">Toggle Command</option>
|
||||
<option value="momentary">Momentary (Active while held)</option>
|
||||
`;
|
||||
} else if (learningTarget.type === "ui_element") {
|
||||
// UI element mapping types
|
||||
typeSelect.innerHTML = `
|
||||
<option value="trigger">Trigger Element (Default)</option>
|
||||
<option value="toggle">Toggle Element</option>
|
||||
<option value="momentary">Momentary (Active while held)</option>
|
||||
`;
|
||||
}
|
||||
|
||||
mappingTypeContainer.appendChild(typeSelect);
|
||||
mappingTypeSection.appendChild(mappingTypeContainer);
|
||||
|
||||
// Range options for numerical widgets
|
||||
if (learningTarget.type === "widget" && learningTarget.widgetConfig &&
|
||||
(learningTarget.widgetConfig.type === "slider" ||
|
||||
learningTarget.widgetConfig.type === "number" ||
|
||||
learningTarget.widgetConfig.type === "combo")) {
|
||||
|
||||
|
||||
|
||||
// Check for existing mappings with min/max overrides
|
||||
let existingMinMaxOverride = null;
|
||||
try {
|
||||
const { mappingEngine } = getContext();
|
||||
if (mappingEngine) {
|
||||
const existingMappings = mappingEngine.getMappings().filter(m =>
|
||||
m.target &&
|
||||
m.target.type === 'widget' &&
|
||||
m.target.nodeId === learningTarget.nodeId &&
|
||||
m.target.widgetName === learningTarget.widgetName
|
||||
);
|
||||
|
||||
// Find first mapping with min/max transform data
|
||||
const existingMapping = existingMappings.find(m =>
|
||||
m.transform &&
|
||||
(typeof m.transform.targetMin === 'number' || typeof m.transform.targetMax === 'number')
|
||||
);
|
||||
|
||||
if (existingMapping && existingMapping.transform) {
|
||||
existingMinMaxOverride = existingMapping.transform;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Error getting existing mapping transforms:", err);
|
||||
}
|
||||
|
||||
// Create range inputs for min/max values
|
||||
const rangeContainer = document.createElement("div");
|
||||
rangeContainer.className = "controller-mapping-range";
|
||||
|
||||
// Min value input
|
||||
const minContainer = document.createElement("div");
|
||||
|
||||
const minLabel = document.createElement("label");
|
||||
minLabel.textContent = "Min Value:";
|
||||
minContainer.appendChild(minLabel);
|
||||
|
||||
const minInput = document.createElement("input");
|
||||
minInput.type = "number";
|
||||
minInput.className = "controller-mapping-min";
|
||||
|
||||
// Set default value from existing mapping transform if available, otherwise use widget config
|
||||
if (existingMinMaxOverride && typeof existingMinMaxOverride.targetMin === 'number') {
|
||||
minInput.value = existingMinMaxOverride.targetMin;
|
||||
|
||||
// Add note that this value is from other mappings
|
||||
const minNote = document.createElement("small");
|
||||
minNote.className = "controller-mapping-note";
|
||||
minNote.textContent = "(from existing mapping)";
|
||||
minContainer.appendChild(minNote);
|
||||
} else if (learningTarget.widgetConfig && learningTarget.widgetConfig.config) {
|
||||
minInput.value = learningTarget.widgetConfig.config.min !== undefined ?
|
||||
learningTarget.widgetConfig.config.min : 0;
|
||||
} else {
|
||||
minInput.value = 0;
|
||||
}
|
||||
|
||||
minContainer.appendChild(minInput);
|
||||
rangeContainer.appendChild(minContainer);
|
||||
|
||||
// Max value input
|
||||
const maxContainer = document.createElement("div");
|
||||
|
||||
const maxLabel = document.createElement("label");
|
||||
maxLabel.textContent = "Max Value:";
|
||||
maxContainer.appendChild(maxLabel);
|
||||
|
||||
const maxInput = document.createElement("input");
|
||||
maxInput.type = "number";
|
||||
maxInput.className = "controller-mapping-max";
|
||||
|
||||
// Set default value from existing mapping transform if available, otherwise use widget config
|
||||
if (existingMinMaxOverride && typeof existingMinMaxOverride.targetMax === 'number') {
|
||||
maxInput.value = existingMinMaxOverride.targetMax;
|
||||
|
||||
// Add note that this value is from other mappings
|
||||
const maxNote = document.createElement("small");
|
||||
maxNote.className = "controller-mapping-note";
|
||||
maxNote.textContent = "(from existing mapping)";
|
||||
maxContainer.appendChild(maxNote);
|
||||
} else if (learningTarget.widgetConfig && learningTarget.widgetConfig.config) {
|
||||
maxInput.value = learningTarget.widgetConfig.config.max !== undefined ?
|
||||
learningTarget.widgetConfig.config.max : 1;
|
||||
} else {
|
||||
maxInput.value = 1;
|
||||
}
|
||||
|
||||
maxContainer.appendChild(maxInput);
|
||||
rangeContainer.appendChild(maxContainer);
|
||||
|
||||
// Add step size input for incremental mapping
|
||||
const stepContainer = document.createElement("div");
|
||||
stepContainer.className = "hidden"; // Hide initially, show only for incremental mapping
|
||||
|
||||
const stepLabel = document.createElement("label");
|
||||
stepLabel.textContent = "Step Size:";
|
||||
stepContainer.appendChild(stepLabel);
|
||||
|
||||
const stepInput = document.createElement("input");
|
||||
stepInput.type = "number";
|
||||
stepInput.className = "controller-mapping-step";
|
||||
|
||||
// Set default value from widget config if available
|
||||
if (learningTarget.widgetConfig && learningTarget.widgetConfig.config) {
|
||||
stepInput.value = learningTarget.widgetConfig.config.step !== undefined ?
|
||||
learningTarget.widgetConfig.config.step : 0.1;
|
||||
} else {
|
||||
stepInput.value = 0.1;
|
||||
}
|
||||
|
||||
stepContainer.appendChild(stepInput);
|
||||
rangeContainer.appendChild(stepContainer);
|
||||
|
||||
// Show/hide step size based on mapping type
|
||||
typeSelect.addEventListener("change", (e) => {
|
||||
if (e.target.value === "incremental" || e.target.value === "decremental") {
|
||||
stepContainer.classList.remove("hidden");
|
||||
stepContainer.classList.add("visible");
|
||||
stepLabel.textContent = "Step Size:";
|
||||
stepInput.placeholder = "";
|
||||
} else {
|
||||
stepContainer.classList.add("hidden");
|
||||
stepContainer.classList.remove("visible");
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger the change event to properly initialize visibility
|
||||
typeSelect.dispatchEvent(new Event("change"));
|
||||
|
||||
mappingTypeSection.appendChild(rangeContainer);
|
||||
}
|
||||
|
||||
// Invert Checkbox (always show)
|
||||
const invertContainer = document.createElement("div");
|
||||
invertContainer.className = "controller-mapping-option";
|
||||
const invertCheckbox = document.createElement("input");
|
||||
invertCheckbox.type = "checkbox";
|
||||
invertCheckbox.id = "controller-mapping-invert";
|
||||
invertCheckbox.className = "controller-mapping-invert-checkbox";
|
||||
const invertLabel = document.createElement("label");
|
||||
invertLabel.htmlFor = "controller-mapping-invert";
|
||||
invertLabel.textContent = " Invert Output (Map 0-1 to Max-Min)";
|
||||
invertContainer.appendChild(invertCheckbox);
|
||||
invertContainer.appendChild(invertLabel);
|
||||
mappingTypeSection.appendChild(invertContainer);
|
||||
|
||||
content.appendChild(mappingTypeSection);
|
||||
|
||||
// Map button and cancel button
|
||||
const buttonContainer = document.createElement("div");
|
||||
buttonContainer.className = "controller-mapping-buttons";
|
||||
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.className = "controller-cancel-button";
|
||||
cancelButton.textContent = "Cancel";
|
||||
cancelButton.onclick = () => {
|
||||
if (callbacks.onCancel) {
|
||||
callbacks.onCancel();
|
||||
} else {
|
||||
cancelLearning();
|
||||
}
|
||||
};
|
||||
buttonContainer.appendChild(cancelButton);
|
||||
|
||||
const mapButton = document.createElement("button");
|
||||
mapButton.className = "controller-map-button";
|
||||
mapButton.textContent = "Map Control";
|
||||
mapButton.disabled = true; // Disabled until a control is selected
|
||||
mapButton.onclick = () => {
|
||||
// Get the selected control and mapping options from the learningState
|
||||
const { learningState } = getContext();
|
||||
|
||||
|
||||
// Check for selected control
|
||||
if (!learningState || !learningState.selectedControl) {
|
||||
console.warn("No control selected or learning state unavailable");
|
||||
showNotification("No control input selected. Please click on a detected control.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get values from form
|
||||
const mappingType = typeSelect.value;
|
||||
const isInverted = invertCheckbox.checked;
|
||||
|
||||
// Get min/max values if applicable
|
||||
let targetMin, targetMax, stepSize;
|
||||
const minInput = document.querySelector(".controller-mapping-min");
|
||||
const maxInput = document.querySelector(".controller-mapping-max");
|
||||
const stepInput = document.querySelector(".controller-mapping-step");
|
||||
|
||||
if (minInput) targetMin = parseFloat(minInput.value);
|
||||
if (maxInput) targetMax = parseFloat(maxInput.value);
|
||||
if (stepInput && (mappingType === "incremental" || mappingType === "decremental")) stepSize = parseFloat(stepInput.value);
|
||||
|
||||
// Complete the mapping
|
||||
completeMapping(
|
||||
learningState.selectedControl,
|
||||
learningState.learningTarget,
|
||||
targetMin,
|
||||
targetMax,
|
||||
mappingType,
|
||||
stepSize,
|
||||
isInverted
|
||||
);
|
||||
};
|
||||
buttonContainer.appendChild(mapButton);
|
||||
|
||||
content.appendChild(buttonContainer);
|
||||
dialog.appendChild(content);
|
||||
|
||||
// Add the dialog to the document
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
return { dialog, inputContainer, mapButton };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the learning dialog
|
||||
*/
|
||||
function hideLearningDialog() {
|
||||
// Get the learning state manager from context
|
||||
const { learningState } = getContext();
|
||||
if (learningState) {
|
||||
const dialog = learningState.learningDialog;
|
||||
if (dialog) {
|
||||
// Clean up event listeners
|
||||
if (dialog.cleanupEvents) {
|
||||
dialog.cleanupEvents();
|
||||
}
|
||||
|
||||
// Remove the dialog from the DOM
|
||||
if (dialog.parentNode) {
|
||||
dialog.parentNode.removeChild(dialog);
|
||||
}
|
||||
|
||||
// Clear the dialog reference
|
||||
learningState.setDialog(null);
|
||||
}
|
||||
} else {
|
||||
// Fallback: try to find and remove any dialog with the mapping dialog class
|
||||
const dialogs = document.querySelectorAll('.controller-mapping-dialog');
|
||||
dialogs.forEach(dialog => {
|
||||
if (dialog.cleanupEvents) {
|
||||
dialog.cleanupEvents();
|
||||
}
|
||||
if (dialog.parentNode) {
|
||||
dialog.parentNode.removeChild(dialog);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the controller management dialog
|
||||
*/
|
||||
function showControllerDialog() {
|
||||
// Implementation will be updated in a separate PR
|
||||
}
|
||||
|
||||
// Export the dialog functions
|
||||
export { showLearningDialog, hideLearningDialog, showControllerDialog };
|
||||
@@ -0,0 +1,653 @@
|
||||
/**
|
||||
* Mapping Component for ControlFreak
|
||||
* Handles rendering and interaction with controller mappings
|
||||
*/
|
||||
|
||||
import { showNotification } from './notifications.js';
|
||||
import { contextProvider } from '../core/contextProvider.js';
|
||||
import { eventBus } from '../core/eventBus.js';
|
||||
|
||||
// Get the mapping engine from context
|
||||
const getMappingEngine = () => contextProvider.get('mappingEngine');
|
||||
|
||||
/**
|
||||
* Create a mapping UI component from a mapping object
|
||||
* @param {Object} mapping - The mapping object
|
||||
* @param {Object} options - Options for the mapping component
|
||||
* @returns {HTMLElement} - The created mapping component
|
||||
*/
|
||||
export function createMappingComponent(mapping, options = {}) {
|
||||
const component = document.createElement('div');
|
||||
component.className = 'controller-mapping-component';
|
||||
component.dataset.mappingId = mapping.id;
|
||||
|
||||
// Control source section
|
||||
const controlSource = document.createElement('div');
|
||||
controlSource.className = 'mapping-control-source';
|
||||
|
||||
const sourceIcon = document.createElement('span');
|
||||
sourceIcon.className = 'mapping-icon';
|
||||
sourceIcon.textContent = getControlTypeIcon(mapping.control.type);
|
||||
controlSource.appendChild(sourceIcon);
|
||||
|
||||
const sourceName = document.createElement('span');
|
||||
sourceName.className = 'mapping-name';
|
||||
sourceName.title = getControlDetails(mapping.control);
|
||||
sourceName.textContent = mapping.control.name || getDefaultControlName(mapping.control);
|
||||
controlSource.appendChild(sourceName);
|
||||
|
||||
component.appendChild(controlSource);
|
||||
|
||||
// Arrow between source and target
|
||||
const arrow = document.createElement('div');
|
||||
arrow.className = 'mapping-arrow';
|
||||
arrow.textContent = '→';
|
||||
component.appendChild(arrow);
|
||||
|
||||
// Target section
|
||||
const target = document.createElement('div');
|
||||
target.className = 'mapping-target';
|
||||
|
||||
const targetIcon = document.createElement('span');
|
||||
targetIcon.className = 'mapping-icon';
|
||||
targetIcon.textContent = getTargetTypeIcon(mapping.target.type);
|
||||
target.appendChild(targetIcon);
|
||||
|
||||
const targetName = document.createElement('span');
|
||||
targetName.className = 'mapping-name';
|
||||
targetName.title = getTargetDetails(mapping.target);
|
||||
targetName.textContent = getTargetName(mapping.target);
|
||||
target.appendChild(targetName);
|
||||
|
||||
component.appendChild(target);
|
||||
|
||||
// Add the mapping type badge
|
||||
const mappingTypeBadge = document.createElement('div');
|
||||
mappingTypeBadge.className = 'mapping-type-badge';
|
||||
mappingTypeBadge.textContent = getMappingTypeLabel(mapping.mappingType);
|
||||
component.appendChild(mappingTypeBadge);
|
||||
|
||||
// Add action buttons container
|
||||
const actionContainer = document.createElement('div');
|
||||
actionContainer.className = 'mapping-actions';
|
||||
|
||||
// Add edit button if needed
|
||||
if (!options.hideEdit) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.className = 'mapping-edit';
|
||||
editButton.textContent = 'Edit';
|
||||
editButton.title = 'Edit this mapping';
|
||||
editButton.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
showMappingEditor(mapping.id, component.parentNode);
|
||||
};
|
||||
actionContainer.appendChild(editButton);
|
||||
}
|
||||
|
||||
// Add delete button if allowed
|
||||
if (!options.hideDelete) {
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'mapping-delete';
|
||||
deleteButton.textContent = 'Delete';
|
||||
deleteButton.title = 'Delete this mapping';
|
||||
deleteButton.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Confirm deletion
|
||||
if (confirm(`Delete mapping from ${sourceName.textContent} to ${targetName.textContent}?`)) {
|
||||
deleteMapping(mapping.id);
|
||||
// The UI will be refreshed via event listeners, so no need to manipulate DOM directly
|
||||
}
|
||||
};
|
||||
actionContainer.appendChild(deleteButton);
|
||||
}
|
||||
|
||||
component.appendChild(actionContainer);
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list of mapping components
|
||||
* @param {Array} mappings - Array of mapping objects
|
||||
* @param {Object} options - Options for the mapping list
|
||||
* @returns {HTMLElement} - The mapping list container
|
||||
*/
|
||||
export function createMappingList(mappings, options = {}) {
|
||||
const listContainer = document.createElement('div');
|
||||
listContainer.className = 'controller-mapping-list';
|
||||
|
||||
if (!mappings || mappings.length === 0) {
|
||||
const emptyMessage = document.createElement('div');
|
||||
emptyMessage.className = 'mapping-empty-message';
|
||||
emptyMessage.textContent = options.emptyMessage || 'No mappings found';
|
||||
listContainer.appendChild(emptyMessage);
|
||||
return listContainer;
|
||||
}
|
||||
|
||||
// Create a component for each mapping
|
||||
mappings.forEach(mapping => {
|
||||
const component = createMappingComponent(mapping, options);
|
||||
listContainer.appendChild(component);
|
||||
});
|
||||
|
||||
return listContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an editor dialog for a mapping
|
||||
* @param {string} mappingId - The ID of the mapping to edit
|
||||
* @param {HTMLElement} listContainer - The parent container to potentially refresh
|
||||
*/
|
||||
export function showMappingEditor(mappingId, listContainer) {
|
||||
const mappingEngine = getMappingEngine();
|
||||
if (!mappingEngine) {
|
||||
showNotification("Mapping engine not initialized", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const mapping = mappingEngine.getMappingById(mappingId);
|
||||
if (!mapping) {
|
||||
showNotification(`Mapping with ID ${mappingId} not found.`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create editor overlay
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "mapping-editor-overlay";
|
||||
|
||||
// Create editor dialog - IMPORTANT: Add BOTH classes
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "mapping-editor controller-dialog"; // Add controller-dialog class
|
||||
|
||||
// Create editor header
|
||||
const header = document.createElement("div");
|
||||
// Use controller-dialog-header class for consistency
|
||||
header.className = "controller-dialog-header mapping-editor-header";
|
||||
|
||||
// Update to match the ControlFreak branding
|
||||
const title = document.createElement("h3");
|
||||
title.innerHTML = `<span class="cf-logo">
|
||||
<span class="cf-logo-icon">🎮</span>
|
||||
<span class="cf-logo-text">CONTROL<span class="cf-logo-highlight">FREAK</span></span>
|
||||
</span>`;
|
||||
header.appendChild(title);
|
||||
|
||||
// Close button - Use controller-dialog-close class
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "controller-dialog-close mapping-editor-close";
|
||||
closeBtn.innerHTML = "×";
|
||||
closeBtn.onclick = () => document.body.removeChild(overlay);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
editor.appendChild(header);
|
||||
|
||||
// Create editor content - Use controller-dialog-content class
|
||||
const content = document.createElement("div");
|
||||
content.className = "controller-dialog-content mapping-editor-content";
|
||||
|
||||
// Add a subtitle for "Edit Mapping"
|
||||
const subtitle = document.createElement("div");
|
||||
subtitle.className = "cf-dialog-subtitle";
|
||||
subtitle.textContent = "Edit Mapping";
|
||||
content.appendChild(subtitle);
|
||||
|
||||
// Controller info (read-only)
|
||||
const controllerSection = document.createElement("div");
|
||||
controllerSection.className = "controller-mapping-target"; // Reuse the section styling
|
||||
|
||||
const controllerHeader = document.createElement("h3");
|
||||
controllerHeader.textContent = "Control";
|
||||
controllerHeader.style.color = "var(--cf-brand-primary)";
|
||||
controllerSection.appendChild(controllerHeader);
|
||||
|
||||
const controllerInfo = document.createElement("div");
|
||||
controllerInfo.className = "controller-mapping-target-info";
|
||||
const controlName = mapping.control.name || mapping.control.id;
|
||||
const deviceName = mapping.control.deviceName || mapping.control.deviceId;
|
||||
controllerInfo.innerHTML = `${controlName} (${deviceName})`;
|
||||
controllerSection.appendChild(controllerInfo);
|
||||
|
||||
content.appendChild(controllerSection);
|
||||
|
||||
// Target info (read-only)
|
||||
const targetSection = document.createElement("div");
|
||||
targetSection.className = "controller-mapping-target";
|
||||
|
||||
const targetHeader = document.createElement("h3");
|
||||
targetHeader.textContent = "Target";
|
||||
targetHeader.style.color = "var(--cf-brand-primary)";
|
||||
targetSection.appendChild(targetHeader);
|
||||
|
||||
const targetInfo = document.createElement("div");
|
||||
targetInfo.className = "controller-mapping-target-info";
|
||||
let targetLabel = "Unknown Target";
|
||||
if (mapping.target.type === 'widget') {
|
||||
targetLabel = `Widget: ${mapping.target.widgetName} (Node ${mapping.target.nodeId})`;
|
||||
} else if (mapping.target.type === 'command') {
|
||||
targetLabel = `Command: ${mapping.target.commandId}`;
|
||||
} else if (mapping.target.type === 'ui_element') {
|
||||
targetLabel = `UI Element: ${mapping.target.elementId}`;
|
||||
}
|
||||
targetInfo.innerHTML = targetLabel;
|
||||
targetSection.appendChild(targetInfo);
|
||||
|
||||
content.appendChild(targetSection);
|
||||
|
||||
// Mapping type selection
|
||||
const mappingTypeGroup = document.createElement("div");
|
||||
mappingTypeGroup.className = "controller-mapping-type-section";
|
||||
|
||||
const mappingTypeHeader = document.createElement("h3");
|
||||
mappingTypeHeader.textContent = "Mapping Type";
|
||||
mappingTypeHeader.style.color = "var(--cf-brand-primary)";
|
||||
mappingTypeGroup.appendChild(mappingTypeHeader);
|
||||
|
||||
const mappingTypeSelect = document.createElement("select");
|
||||
mappingTypeSelect.className = "controller-mapping-type-select";
|
||||
|
||||
// Add appropriate mapping type options based on target type
|
||||
if (mapping.target.type === "widget") {
|
||||
mappingTypeSelect.innerHTML = `
|
||||
<option value="direct">Direct Control (Axes/Knobs)</option>
|
||||
<option value="toggle">Toggle (On/Off)</option>
|
||||
<option value="momentary">Momentary (Active while held)</option>
|
||||
<option value="incremental">Increment Value</option>
|
||||
<option value="decremental">Decrement Value</option>
|
||||
<option value="trigger">Trigger Action</option>
|
||||
`;
|
||||
} else if (mapping.target.type === "command" || mapping.target.type === "ui_element") {
|
||||
mappingTypeSelect.innerHTML = `
|
||||
<option value="trigger">Trigger</option>
|
||||
<option value="toggle">Toggle</option>
|
||||
<option value="momentary">Momentary (Active while held)</option>
|
||||
`;
|
||||
}
|
||||
|
||||
// Normalize mapping type for select element
|
||||
const currentMappingType = mapping.mappingType || "direct";
|
||||
|
||||
// Select the appropriate option
|
||||
for (const option of mappingTypeSelect.options) {
|
||||
if (option.value.toLowerCase() === currentMappingType.toLowerCase()) {
|
||||
option.selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
mappingTypeGroup.appendChild(mappingTypeSelect);
|
||||
content.appendChild(mappingTypeGroup);
|
||||
|
||||
// Transformation settings (e.g., min/max for widgets)
|
||||
const transformContainer = document.createElement("div");
|
||||
transformContainer.className = "controller-mapping-type-section";
|
||||
|
||||
// Declare inputs so they're available in the save handler
|
||||
let minInput, maxInput, stepInput, invertCheckbox;
|
||||
|
||||
if (mapping.target.type === "widget") {
|
||||
const transformHeader = document.createElement("h3");
|
||||
transformHeader.textContent = "Value Range";
|
||||
transformHeader.style.color = "var(--cf-brand-primary)";
|
||||
transformContainer.appendChild(transformHeader);
|
||||
|
||||
// Create a range container for min/max
|
||||
const rangeContainer = document.createElement("div");
|
||||
rangeContainer.className = "controller-mapping-range";
|
||||
|
||||
// Min Value
|
||||
const minGroup = document.createElement("div");
|
||||
const minLabel = document.createElement("label");
|
||||
minLabel.textContent = "Min Value:";
|
||||
minInput = document.createElement("input");
|
||||
minInput.className = "controller-mapping-min";
|
||||
minInput.type = "number";
|
||||
minInput.value = mapping.transform?.targetMin ?? 0;
|
||||
minGroup.appendChild(minLabel);
|
||||
minGroup.appendChild(minInput);
|
||||
rangeContainer.appendChild(minGroup);
|
||||
|
||||
// Max Value
|
||||
const maxGroup = document.createElement("div");
|
||||
const maxLabel = document.createElement("label");
|
||||
maxLabel.textContent = "Max Value:";
|
||||
maxInput = document.createElement("input");
|
||||
maxInput.className = "controller-mapping-max";
|
||||
maxInput.type = "number";
|
||||
maxInput.value = mapping.transform?.targetMax ?? 1;
|
||||
maxGroup.appendChild(maxLabel);
|
||||
maxGroup.appendChild(maxInput);
|
||||
rangeContainer.appendChild(maxGroup);
|
||||
|
||||
transformContainer.appendChild(rangeContainer);
|
||||
|
||||
// Step Size (show only for relevant mapping types)
|
||||
const stepGroup = document.createElement("div");
|
||||
stepGroup.className = "step-field hidden"; // Start hidden
|
||||
const stepLabel = document.createElement("label");
|
||||
stepLabel.textContent = "Step Size:";
|
||||
stepInput = document.createElement("input");
|
||||
stepInput.className = "controller-mapping-step";
|
||||
stepInput.type = "number";
|
||||
stepInput.value = mapping.transform?.stepSize ?? 0.1;
|
||||
stepGroup.appendChild(stepLabel);
|
||||
stepGroup.appendChild(stepInput);
|
||||
transformContainer.appendChild(stepGroup);
|
||||
|
||||
// Invert Checkbox
|
||||
const invertGroup = document.createElement("div");
|
||||
invertGroup.className = "invert-field"; // Consistent class
|
||||
invertCheckbox = document.createElement("input");
|
||||
invertCheckbox.type = "checkbox";
|
||||
invertCheckbox.id = `mapping-edit-invert-${mapping.id}`;
|
||||
invertCheckbox.checked = mapping.transform?.isInverted ?? false; // Set initial state
|
||||
const invertLabel = document.createElement("label");
|
||||
invertLabel.htmlFor = invertCheckbox.id;
|
||||
invertLabel.textContent = "Invert Output";
|
||||
invertGroup.appendChild(invertCheckbox);
|
||||
invertGroup.appendChild(invertLabel);
|
||||
transformContainer.appendChild(invertGroup);
|
||||
|
||||
// Function to show/hide step based on selected mapping type
|
||||
const updateStepVisibility = () => {
|
||||
const selectedType = mappingTypeSelect.value.toLowerCase();
|
||||
if (selectedType === "incremental" || selectedType === "decremental") {
|
||||
stepGroup.classList.remove("hidden");
|
||||
} else {
|
||||
stepGroup.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
mappingTypeSelect.addEventListener('change', updateStepVisibility);
|
||||
updateStepVisibility(); // Initial check
|
||||
|
||||
content.appendChild(transformContainer);
|
||||
}
|
||||
|
||||
editor.appendChild(content);
|
||||
|
||||
// Editor actions - Use controller-mapping-buttons structure
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "controller-mapping-buttons"; // Use class from dialog.css
|
||||
|
||||
// Create Cancel Button (using dialog class)
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "controller-cancel-button"; // Use class from dialog.css
|
||||
cancelBtn.textContent = "CANCEL";
|
||||
cancelBtn.onclick = () => document.body.removeChild(overlay);
|
||||
actions.appendChild(cancelBtn);
|
||||
|
||||
// Create Save Button (using dialog class)
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.className = "controller-map-button"; // Use class from dialog.css
|
||||
saveBtn.textContent = "SAVE CHANGES";
|
||||
saveBtn.style.backgroundColor = "var(--cf-brand-primary)";
|
||||
saveBtn.style.color = "white";
|
||||
saveBtn.onclick = () => {
|
||||
try {
|
||||
// Collect updated data
|
||||
const updatedData = {
|
||||
mappingType: mappingTypeSelect.value,
|
||||
transform: { ...mapping.transform } // Start with existing transform values
|
||||
};
|
||||
|
||||
// Update transform properties if inputs exist
|
||||
if (minInput) updatedData.transform.targetMin = parseFloat(minInput.value);
|
||||
if (maxInput) updatedData.transform.targetMax = parseFloat(maxInput.value);
|
||||
|
||||
// Only check stepGroup if it's defined (for widget targets only)
|
||||
if (stepInput) {
|
||||
// Check if step is applicable based on mapping type
|
||||
const isStepApplicable = ["incremental", "decremental"].includes(mappingTypeSelect.value.toLowerCase());
|
||||
|
||||
if (isStepApplicable) {
|
||||
updatedData.transform.stepSize = parseFloat(stepInput.value);
|
||||
} else {
|
||||
// Ensure stepSize is removed or nullified if not applicable
|
||||
delete updatedData.transform.stepSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (invertCheckbox) updatedData.transform.isInverted = invertCheckbox.checked; // <-- Get invert state
|
||||
|
||||
// Update the mapping
|
||||
const mappingEngine = getMappingEngine();
|
||||
if (!mappingEngine) {
|
||||
console.error("MappingComponent: Mapping engine not available for save");
|
||||
showNotification("Mapping engine not available", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const success = mappingEngine.updateMapping(mappingId, updatedData);
|
||||
|
||||
if (success) {
|
||||
// Save the changes to persistent storage
|
||||
mappingEngine.saveMappings();
|
||||
|
||||
showNotification("Mapping updated successfully", "success");
|
||||
// Make sure overlay exists before trying to remove it
|
||||
if (overlay && document.body.contains(overlay)) {
|
||||
document.body.removeChild(overlay);
|
||||
}
|
||||
|
||||
// Optional: Refresh the list component display if needed
|
||||
if (listContainer && typeof listContainer.refresh === 'function') {
|
||||
listContainer.refresh(); // Assuming a refresh method exists
|
||||
} else {
|
||||
// Fallback: Emit event for list to listen to
|
||||
eventBus.emit('mappings:changed');
|
||||
}
|
||||
|
||||
} else {
|
||||
showNotification("Failed to update mapping", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving mapping:", error);
|
||||
showNotification("Error: " + error.message, "error");
|
||||
}
|
||||
};
|
||||
actions.appendChild(saveBtn);
|
||||
|
||||
editor.appendChild(actions);
|
||||
|
||||
// Append editor to overlay, and overlay to body
|
||||
overlay.appendChild(editor);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Focus first input maybe?
|
||||
const firstInput = editor.querySelector('input, select');
|
||||
if(firstInput) firstInput.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an icon for a control type
|
||||
* @param {string} controlType - The control type
|
||||
* @returns {string} - Icon representing the control type
|
||||
*/
|
||||
function getControlTypeIcon(controlType) {
|
||||
if (!controlType) return '?';
|
||||
|
||||
if (controlType.includes('midi')) {
|
||||
return '🎹';
|
||||
} else if (controlType.includes('gamepad')) {
|
||||
if (controlType.includes('button')) {
|
||||
return '🔘';
|
||||
} else if (controlType.includes('axis')) {
|
||||
return '↔️';
|
||||
}
|
||||
return '🎮';
|
||||
}
|
||||
|
||||
return '🎛️';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an icon for a target type
|
||||
* @param {string} targetType - The target type
|
||||
* @returns {string} - Icon representing the target type
|
||||
*/
|
||||
function getTargetTypeIcon(targetType) {
|
||||
switch (targetType) {
|
||||
case 'widget':
|
||||
return '📊';
|
||||
case 'command':
|
||||
return '🔨';
|
||||
case 'ui_element':
|
||||
return '🖱️';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly name for a target
|
||||
* @param {Object} target - The target object
|
||||
* @returns {string} - A user-friendly name
|
||||
*/
|
||||
function getTargetName(target) {
|
||||
if (!target) return 'Unknown';
|
||||
|
||||
switch (target.type) {
|
||||
case 'widget':
|
||||
return `${target.nodeTitle || 'Node'} → ${target.widgetName || 'Widget'}`;
|
||||
case 'command':
|
||||
return target.label || target.commandId || 'Command';
|
||||
case 'ui_element':
|
||||
return target.elementLabel || target.elementId || 'UI Element';
|
||||
default:
|
||||
return 'Unknown Target';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a control
|
||||
* @param {Object} control - The control object
|
||||
* @returns {string} - Detailed information
|
||||
*/
|
||||
function getControlDetails(control) {
|
||||
if (!control) return 'Unknown Control';
|
||||
|
||||
let details = `Type: ${control.type}\n`;
|
||||
details += `Device: ${control.deviceId}\n`;
|
||||
details += `Control: ${control.id}`;
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a target
|
||||
* @param {Object} target - The target object
|
||||
* @returns {string} - Detailed information
|
||||
*/
|
||||
function getTargetDetails(target) {
|
||||
if (!target) return 'Unknown Target';
|
||||
|
||||
let details = `Type: ${target.type}\n`;
|
||||
|
||||
switch (target.type) {
|
||||
case 'widget':
|
||||
details += `Node: ${target.nodeId}\n`;
|
||||
details += `Widget: ${target.widgetName}`;
|
||||
break;
|
||||
case 'command':
|
||||
details += `Command: ${target.commandId}`;
|
||||
break;
|
||||
case 'ui_element':
|
||||
details += `Element: ${target.elementId}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default name for a control if none is provided
|
||||
* @param {Object} control - The control object
|
||||
* @returns {string} - A default name
|
||||
*/
|
||||
function getDefaultControlName(control) {
|
||||
if (!control) return 'Unknown';
|
||||
|
||||
// Try to construct a sensible name based on type and ID
|
||||
const type = control.type || '';
|
||||
const id = control.id || '';
|
||||
|
||||
if (type.includes('midi')) {
|
||||
if (id.startsWith('144_')) {
|
||||
return `MIDI Note ${id.split('_')[1]}`;
|
||||
} else if (id.startsWith('176_')) {
|
||||
return `MIDI CC ${id.split('_')[1]}`;
|
||||
}
|
||||
return `MIDI ${id}`;
|
||||
} else if (type.includes('gamepad')) {
|
||||
if (type.includes('button')) {
|
||||
return `Gamepad Button ${id.split('_')[1]}`;
|
||||
} else if (type.includes('axis')) {
|
||||
return `Gamepad Axis ${id.split('_')[1]}`;
|
||||
}
|
||||
return `Gamepad ${id}`;
|
||||
}
|
||||
|
||||
return `Control ${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly label for a mapping type
|
||||
* @param {string} mappingType - The mapping type
|
||||
* @returns {string} - A user-friendly label
|
||||
*/
|
||||
function getMappingTypeLabel(mappingType) {
|
||||
if (!mappingType) return 'Default';
|
||||
|
||||
// Normalize the mapping type to lowercase for case-insensitive comparison
|
||||
const type = mappingType.toLowerCase();
|
||||
|
||||
switch (type) {
|
||||
case 'direct':
|
||||
return 'Direct';
|
||||
case 'toggle':
|
||||
return 'Toggle';
|
||||
case 'trigger':
|
||||
return 'Trigger';
|
||||
case 'incremental':
|
||||
return 'Increment';
|
||||
case 'decremental':
|
||||
return 'Decrement';
|
||||
case 'momentary':
|
||||
return 'Momentary';
|
||||
case 'absolute':
|
||||
return 'Direct'; // Map "Absolute" (from backend) to "Direct" (for UI)
|
||||
case 'increment':
|
||||
return 'Increment'; // Map "Increment" (from backend) to "Increment" (for UI)
|
||||
case 'decrement':
|
||||
return 'Decrement'; // Map "Decrement" (from backend) to "Decrement" (for UI)
|
||||
default:
|
||||
return mappingType; // Return original if unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a mapping by ID
|
||||
* @param {string} mappingId - The ID of the mapping to delete
|
||||
* @returns {boolean} - Whether the deletion was successful
|
||||
*/
|
||||
export function deleteMapping(mappingId) {
|
||||
const mappingEngine = getMappingEngine();
|
||||
if (!mappingEngine) {
|
||||
console.error('MappingComponent: Mapping engine not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete the mapping using the mapping engine
|
||||
const success = mappingEngine.deleteMapping(mappingId);
|
||||
|
||||
if (success) {
|
||||
// Emit event for mapping deleted - we use the mappingPanel eventBus listeners to refresh the UI
|
||||
eventBus.emit('mapping:deleted', { mappingId });
|
||||
|
||||
// Show notification if needed
|
||||
showNotification(`Mapping deleted`);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// UI components for mapping list (Client-Side)
|
||||
|
||||
import { createMappingList, deleteMapping } from './mappingComponent.js';
|
||||
import { showNotification } from "./notifications.js";
|
||||
import { contextProvider } from '../core/contextProvider.js';
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
// REMOVED style imports
|
||||
// Avoid direct import to break circular dependency
|
||||
// import { mappingEngine } from "../index.js"; // Import client-side engine
|
||||
|
||||
// Get mapping engine from context provider
|
||||
const getMappingEngine = () => contextProvider.get('mappingEngine');
|
||||
|
||||
/**
|
||||
* Loads and displays mapping data
|
||||
* @param {HTMLElement} panel - The panel to render mappings in
|
||||
*/
|
||||
export function loadMappingData(panel) {
|
||||
panel.innerHTML = '<div style="text-align: center; padding: 20px;">Loading mappings...</div>';
|
||||
|
||||
try {
|
||||
const mappingEngine = getMappingEngine();
|
||||
if (!mappingEngine) {
|
||||
panel.innerHTML = '<div style="color: red; text-align: center; padding: 20px;">Error: Mapping engine not initialized</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all mappings for the currently active profile from the engine
|
||||
const mappings = mappingEngine.getMappings();
|
||||
|
||||
panel.innerHTML = '';
|
||||
|
||||
if (mappings.length === 0) {
|
||||
panel.innerHTML = `<div class="no-mappings">No mappings created yet for profile "${mappingEngine.getActiveProfile()}". Right-click on a node parameter or use the Commands tab to map controls.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group mappings by target to create mapping lists for each target
|
||||
const targets = groupMappingsByTarget(mappings);
|
||||
|
||||
// Create a container for all mapping lists
|
||||
const container = document.createElement('div');
|
||||
container.className = 'all-mappings-container';
|
||||
|
||||
// Create a header with instructions
|
||||
const header = document.createElement('div');
|
||||
header.className = 'mappings-panel-header';
|
||||
header.innerHTML = '<h3>Manage Controller Mappings</h3>' +
|
||||
`<p>Profile: <strong>${mappingEngine.getActiveProfile()}</strong>. View, edit, and delete mappings below.</p>`;
|
||||
container.appendChild(header);
|
||||
|
||||
// Create mapping sections for each target
|
||||
for (const [targetKey, target] of Object.entries(targets)) {
|
||||
createTargetMappingSection(container, target);
|
||||
}
|
||||
|
||||
panel.appendChild(container);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading mapping data:', error);
|
||||
panel.innerHTML = `<div style="color: red; text-align: center; padding: 20px;">Error loading mappings: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups mappings by target type and info
|
||||
* @param {Array} mappings - List of all mappings
|
||||
* @returns {Object} Mappings grouped by target key (e.g., widget-NodeID-WidgetName)
|
||||
*/
|
||||
function groupMappingsByTarget(mappings) {
|
||||
const targets = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
// Skip if mapping structure is invalid
|
||||
if (!mapping || !mapping.target || !mapping.control) continue;
|
||||
|
||||
let targetKey;
|
||||
let targetLabel;
|
||||
|
||||
// Use the new mapping structure
|
||||
if (mapping.target.type === 'widget') {
|
||||
const nodeId = mapping.target.nodeId;
|
||||
const paramName = mapping.target.widgetName;
|
||||
targetKey = `node-${nodeId}-${paramName}`;
|
||||
// Try to get node title for better label
|
||||
const node = app.graph?.getNodeById(nodeId);
|
||||
const nodeTitle = node?.title || `Node ${nodeId}`;
|
||||
targetLabel = `${paramName} (${nodeTitle})`;
|
||||
} else if (mapping.target.type === 'ui_element') {
|
||||
const element = mapping.target.elementId;
|
||||
targetKey = `ui-${element}`;
|
||||
targetLabel = `UI Element: ${element}`;
|
||||
} else if (mapping.target.type === 'command') {
|
||||
const commandId = mapping.target.commandId;
|
||||
targetKey = `command-${commandId}`;
|
||||
|
||||
// Format the command name for display
|
||||
// Convert "Comfy.QueuePrompt" to "Queue Prompt"
|
||||
let displayName = commandId;
|
||||
if (commandId.includes('.')) {
|
||||
displayName = commandId.split('.').pop(); // Get part after the last dot
|
||||
// Add spaces before capitals and remove camel case
|
||||
displayName = displayName.replace(/([A-Z])/g, ' $1').trim(); // Simple formatting
|
||||
}
|
||||
|
||||
targetLabel = `Command: ${displayName}`;
|
||||
} else {
|
||||
targetKey = `other-${Math.random()}`;
|
||||
targetLabel = `Unknown Target Type: ${mapping.target.type || 'undefined'}`;
|
||||
}
|
||||
|
||||
if (!targets[targetKey]) {
|
||||
targets[targetKey] = {
|
||||
type: mapping.target.type,
|
||||
label: targetLabel,
|
||||
info: mapping.target, // Store the whole target object
|
||||
mappings: []
|
||||
};
|
||||
}
|
||||
|
||||
targets[targetKey].mappings.push(mapping);
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a section for mappings to a specific target
|
||||
* @param {HTMLElement} container - Container to append to
|
||||
* @param {Object} targetGroup - Grouped target information { type, label, info, mappings }
|
||||
*/
|
||||
function createTargetMappingSection(container, targetGroup) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'target-mappings-section';
|
||||
|
||||
// Add a header for this target
|
||||
const header = document.createElement('h4');
|
||||
header.className = 'target-header';
|
||||
header.textContent = targetGroup.label;
|
||||
section.appendChild(header);
|
||||
|
||||
// Create the mapping list component, passing the specific mappings for this target
|
||||
try {
|
||||
// Pass the specific mappings for this target to createMappingList
|
||||
const mappingList = createMappingList(targetGroup.mappings);
|
||||
|
||||
section.appendChild(mappingList);
|
||||
container.appendChild(section);
|
||||
} catch (error) {
|
||||
console.error(`Error creating mapping list for ${targetGroup.label}:`, error);
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'mapping-error';
|
||||
errorEl.textContent = `Error loading mappings: ${error.message}`;
|
||||
section.appendChild(errorEl);
|
||||
container.appendChild(section);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// UI components for the mapping panel
|
||||
|
||||
import { loadControllerData } from './controllerList.js';
|
||||
import { loadMappingData } from './mappingList.js';
|
||||
import { loadCommandsData } from './commandsList.js';
|
||||
import { showNotification } from './notifications.js';
|
||||
import { contextProvider } from '../core/contextProvider.js';
|
||||
import { eventBus } from '../core/eventBus.js';
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
/**
|
||||
* Toggle the management UI
|
||||
* @param {boolean} [show] - Force show or hide
|
||||
* @param {Object} [mappingEngine] - Optional mappingEngine instance
|
||||
* @returns {HTMLElement|null} - The panel element if shown, null if hidden
|
||||
*/
|
||||
export function toggleMappingUI(show, mappingEngine) {
|
||||
const existingPanel = document.getElementById('controller-mapping-panel');
|
||||
|
||||
// Determine if we should show or hide
|
||||
const shouldShow = show !== undefined ? show : !existingPanel;
|
||||
|
||||
if (existingPanel && !shouldShow) {
|
||||
existingPanel.remove();
|
||||
eventBus.emit('ui:mappingPanelClosed');
|
||||
return null;
|
||||
} else if (shouldShow && !existingPanel) {
|
||||
const panel = createMappingPanel(mappingEngine);
|
||||
eventBus.emit('ui:mappingPanelOpened');
|
||||
return panel;
|
||||
} else if (existingPanel) {
|
||||
return existingPanel; // Already showing
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the mapping management panel
|
||||
* @param {Object} [mappingEngine] - Optional mappingEngine instance
|
||||
* @returns {HTMLElement} - The created panel
|
||||
*/
|
||||
export function createMappingPanel(mappingEngine) {
|
||||
// Get mappingEngine from parameter or context
|
||||
const engine = mappingEngine || contextProvider.get('mappingEngine');
|
||||
|
||||
// Remove mappings for deleted nodes
|
||||
removeDeletedNodeMappings(engine);
|
||||
|
||||
// Create the main panel
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'controller-mapping-panel';
|
||||
document.body.appendChild(panel);
|
||||
|
||||
// Create the header
|
||||
const header = document.createElement('div');
|
||||
header.id = 'controller-mapping-header';
|
||||
panel.appendChild(header);
|
||||
|
||||
// Create logo and title
|
||||
const title = document.createElement('h2');
|
||||
title.innerHTML = `
|
||||
<div class="cf-logo">
|
||||
<span class="cf-logo-icon">🎮</span>
|
||||
<span class="cf-logo-text">Control<span class="cf-logo-highlight">Freak</span></span>
|
||||
</div>
|
||||
<span class="cf-tagline">Take control of your workflows</span>
|
||||
`;
|
||||
header.appendChild(title);
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.id = 'controller-mapping-close';
|
||||
closeButton.textContent = '×';
|
||||
closeButton.addEventListener('click', () => {
|
||||
panel.remove();
|
||||
eventBus.emit('ui:mappingPanelClosed');
|
||||
});
|
||||
header.appendChild(closeButton);
|
||||
|
||||
// Create tabs
|
||||
const tabs = document.createElement('div');
|
||||
tabs.id = 'controller-mapping-tabs';
|
||||
panel.appendChild(tabs);
|
||||
|
||||
const controllersTab = document.createElement('div');
|
||||
controllersTab.className = 'controller-mapping-tab active';
|
||||
controllersTab.dataset.tab = 'controllers';
|
||||
controllersTab.textContent = 'Controllers';
|
||||
tabs.appendChild(controllersTab);
|
||||
|
||||
const mappingsTab = document.createElement('div');
|
||||
mappingsTab.className = 'controller-mapping-tab';
|
||||
mappingsTab.dataset.tab = 'mappings';
|
||||
mappingsTab.textContent = 'Mappings';
|
||||
tabs.appendChild(mappingsTab);
|
||||
|
||||
// Add a new tab for UI commands
|
||||
const commandsTab = document.createElement('div');
|
||||
commandsTab.className = 'controller-mapping-tab';
|
||||
commandsTab.dataset.tab = 'commands';
|
||||
commandsTab.textContent = 'UI Commands';
|
||||
tabs.appendChild(commandsTab);
|
||||
|
||||
// Add a new tab for "About" section
|
||||
const aboutTab = document.createElement('div');
|
||||
aboutTab.className = 'controller-mapping-tab';
|
||||
aboutTab.dataset.tab = 'about';
|
||||
aboutTab.textContent = 'Propaganda';
|
||||
aboutTab.style.marginLeft = 'auto'; // Push to the right
|
||||
tabs.appendChild(aboutTab);
|
||||
|
||||
// Create content area
|
||||
const content = document.createElement('div');
|
||||
content.id = 'controller-mapping-content';
|
||||
panel.appendChild(content);
|
||||
|
||||
// Create controllers panel
|
||||
const controllersPanel = document.createElement('div');
|
||||
controllersPanel.className = 'tab-panel active';
|
||||
controllersPanel.id = 'controllers-panel';
|
||||
content.appendChild(controllersPanel);
|
||||
|
||||
// Create mappings panel
|
||||
const mappingsPanel = document.createElement('div');
|
||||
mappingsPanel.className = 'tab-panel';
|
||||
mappingsPanel.id = 'mappings-panel';
|
||||
content.appendChild(mappingsPanel);
|
||||
|
||||
// Create commands panel
|
||||
const commandsPanel = document.createElement('div');
|
||||
commandsPanel.className = 'tab-panel';
|
||||
commandsPanel.id = 'commands-panel';
|
||||
content.appendChild(commandsPanel);
|
||||
|
||||
// Create about panel
|
||||
const aboutPanel = document.createElement('div');
|
||||
aboutPanel.className = 'tab-panel';
|
||||
aboutPanel.id = 'about-panel';
|
||||
|
||||
// Add about content with proper branding
|
||||
aboutPanel.innerHTML = `
|
||||
<div class="about-header">
|
||||
<a href="https://github.com/ryanontheinside/ComfyUI_ControlFreak" class="cf-logo" style="text-decoration: underline;" target="_blank">
|
||||
<span class="cf-logo-icon" style="text-decoration: none;">🎮</span>
|
||||
<span class="cf-logo-text">Control<span class="cf-logo-highlight">Freak</span></span>
|
||||
</a>
|
||||
<div class="cf-tagline">Take control of your ComfyUI workflows</div>
|
||||
<p>Universal MIDI & Gamepad Mapping for ComfyUI</p>
|
||||
</div>
|
||||
<div class="about-content" style="text-align: center; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<p>Control Freak lets you map MIDI controllers and gamepads to ComfyUI widgets and actions,
|
||||
giving you physical control over your image generation workflows.</p>
|
||||
</div>
|
||||
<div style="text-align: right; margin-top: 200px;">
|
||||
<a href="https://github.com/ryanontheinside" style="font-size: 15px; text-decoration: none;" class="cf-logo-highlight" target="_blank">RYANONTHEINSIDE</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
content.appendChild(aboutPanel);
|
||||
|
||||
// Add tab switching functionality
|
||||
tabs.querySelectorAll('.controller-mapping-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.querySelectorAll('.controller-mapping-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
content.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById(`${tab.dataset.tab}-panel`).classList.add('active');
|
||||
|
||||
// Emit tab change event
|
||||
eventBus.emit('ui:mappingPanelTabChanged', { tab: tab.dataset.tab });
|
||||
});
|
||||
});
|
||||
|
||||
// Load data
|
||||
loadControllerData(controllersPanel);
|
||||
loadMappingData(mappingsPanel, engine);
|
||||
loadCommandsData(commandsPanel, engine);
|
||||
|
||||
// Add event listeners to refresh mappings panel when mappings change
|
||||
const refreshMappings = () => {
|
||||
if (document.getElementById('mappings-panel')) {
|
||||
loadMappingData(mappingsPanel, engine);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for mapping events that should trigger a refresh
|
||||
eventBus.on('mapping:added', refreshMappings);
|
||||
eventBus.on('mapping:deleted', refreshMappings);
|
||||
eventBus.on('mapping:updated', refreshMappings);
|
||||
|
||||
// Cleanup event listeners when panel is closed
|
||||
eventBus.on('ui:mappingPanelClosed', () => {
|
||||
eventBus.off('mapping:added', refreshMappings);
|
||||
eventBus.off('mapping:deleted', refreshMappings);
|
||||
eventBus.off('mapping:updated', refreshMappings);
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove mappings for deleted nodes
|
||||
* @param {Object} mappingEngine - The mapping engine instance
|
||||
* @returns {number} - Number of removed mappings
|
||||
*/
|
||||
function removeDeletedNodeMappings(mappingEngine) {
|
||||
// Get the mapping engine from parameter or context
|
||||
const engine = mappingEngine || contextProvider.get('mappingEngine');
|
||||
if (!engine || !app?.graph?.nodes) return 0;
|
||||
|
||||
// Get mappings for current profile
|
||||
const mappings = engine.getMappings();
|
||||
let removed = 0;
|
||||
|
||||
// Check each widget mapping to see if the node still exists
|
||||
mappings.forEach(mapping => {
|
||||
if (mapping.target.type === 'widget') {
|
||||
const nodeId = mapping.target.nodeId;
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
|
||||
// If node doesn't exist, remove the mapping
|
||||
if (!node) {
|
||||
engine.deleteMapping(mapping.id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save if any mappings were removed
|
||||
if (removed > 0) {
|
||||
engine.saveMappings();
|
||||
|
||||
// Emit event for removed mappings
|
||||
eventBus.emit('mappings:cleanedUp', { removed });
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Menu integration for Controller Mapping
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { toggleMappingUI } from './mappingPanel.js';
|
||||
import { contextProvider } from '../core/contextProvider.js';
|
||||
import { eventBus } from '../core/eventBus.js';
|
||||
|
||||
/**
|
||||
* Sets up menu integration for the controller mapping UI
|
||||
*/
|
||||
export function setupMenuIntegration() {
|
||||
// Styles are loaded automatically via styles/index.js
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the extension with ComfyUI
|
||||
* @param {Object} mappingEngine - The mapping engine instance
|
||||
*/
|
||||
export function registerExtension(mappingEngine) {
|
||||
// Store a reference to the mapping engine for the toggle function
|
||||
const wrappedToggle = (state) => {
|
||||
// Get the mapping engine from the context if not provided directly
|
||||
const engine = mappingEngine || contextProvider.get('mappingEngine');
|
||||
toggleMappingUI(state, engine);
|
||||
|
||||
// Emit event for analytics
|
||||
eventBus.emit('menu:toggleMappingUI', {
|
||||
state,
|
||||
source: 'menuCommand'
|
||||
});
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.ControlFreak",
|
||||
|
||||
// Define commands that will be used by menu items
|
||||
commands: [
|
||||
{
|
||||
id: "ComfyUI.ControlFreak.OpenManager",
|
||||
icon: "pi pi-gamepad",
|
||||
label: "Control Freak",
|
||||
function: wrappedToggle
|
||||
}
|
||||
],
|
||||
|
||||
// Define where these commands appear in the menu
|
||||
menuCommands: [
|
||||
{
|
||||
path: ["Workflow"],
|
||||
commands: [
|
||||
null, // Separator
|
||||
"ComfyUI.ControlFreak.OpenManager"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
async setup() {
|
||||
// Setup menu integration and styles
|
||||
setupMenuIntegration();
|
||||
|
||||
// Check if we need to use the old menu system
|
||||
let useNewMenu = "Enabled"; // Default to new menu system
|
||||
|
||||
// Safely check if the settings store exists
|
||||
try {
|
||||
if (app.ui.settings && app.ui.settings.store && typeof app.ui.settings.store.get === 'function') {
|
||||
useNewMenu = app.ui.settings.store.get("Comfy.UseNewMenu") || "Enabled";
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[ControlFreak] Could not access settings store, defaulting to new menu system");
|
||||
}
|
||||
|
||||
if (useNewMenu === "Disabled") {
|
||||
// Old menu system
|
||||
const menu = app.ui.menu;
|
||||
const workflowMenu = menu.getMenu("Workflow");
|
||||
if (workflowMenu) {
|
||||
workflowMenu.addSeparator();
|
||||
workflowMenu.addItem("Control Freak 🎮", wrappedToggle, { icon: "pi pi-gamepad" });
|
||||
}
|
||||
}
|
||||
// New menu system is handled automatically by the menuCommands registration
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Notification system for controller mapping
|
||||
* Handles creation and display of notification messages
|
||||
*/
|
||||
|
||||
// import { addNotificationStyles } from './styles/index.js';
|
||||
|
||||
/**
|
||||
* Show a notification message
|
||||
* @param {String} message - The message to display
|
||||
* @param {String|Boolean} type - Notification type ('error', 'info', 'success') or boolean for backward compatibility
|
||||
* @param {Object} options - Additional options
|
||||
* @param {Number} options.duration - Duration in ms to show the notification (default: 3000)
|
||||
* @param {String} options.position - Position of the notification (default: 'bottom-right')
|
||||
* @returns {HTMLElement} The created notification element
|
||||
*/
|
||||
function showNotification(message, type = 'info', options = {}) {
|
||||
const defaults = {
|
||||
duration: 3000,
|
||||
position: 'bottom-right'
|
||||
};
|
||||
|
||||
const settings = { ...defaults, ...options };
|
||||
|
||||
// For backward compatibility where type was a boolean indicating error
|
||||
let notificationType = 'info';
|
||||
if (typeof type === 'boolean') {
|
||||
notificationType = type ? 'error' : 'info';
|
||||
} else if (typeof type === 'string') {
|
||||
notificationType = type;
|
||||
}
|
||||
|
||||
|
||||
// Create notification element
|
||||
const notification = document.createElement("div");
|
||||
notification.className = `controller-notification ${notificationType} ${settings.position}`;
|
||||
notification.textContent = message;
|
||||
|
||||
// Add to document
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Add animation classes
|
||||
setTimeout(() => {
|
||||
notification.classList.add('fade-in');
|
||||
}, 10);
|
||||
|
||||
// Remove after specified duration
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('fade-in');
|
||||
notification.classList.add('fade-out');
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(notification)) {
|
||||
document.body.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}, settings.duration);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject notification styles into the document
|
||||
*/
|
||||
function injectNotificationStyles() {
|
||||
if (document.querySelector("#controller-notification-styles")) {
|
||||
return; // Styles already added
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "controller-notification-styles";
|
||||
style.textContent = `
|
||||
.controller-notification {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
max-width: 300px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Export only the notification function
|
||||
export { showNotification };
|
||||
@@ -0,0 +1,152 @@
|
||||
/* styles/branding.css - EXACT from original JS */
|
||||
|
||||
/* Logo styling */
|
||||
.cf-logo {
|
||||
font-family: var(--cf-brand-font);
|
||||
font-weight: 700;
|
||||
color: var(--cf-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cf-logo-text {
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cf-logo-icon {
|
||||
color: var(--cf-brand-primary);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.cf-logo-highlight {
|
||||
color: var(--cf-brand-primary);
|
||||
}
|
||||
|
||||
/* Header with branding */
|
||||
#controller-mapping-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: var(--cf-bg-primary);
|
||||
border-bottom: 1px solid var(--cf-border-color);
|
||||
}
|
||||
|
||||
#controller-mapping-header h2 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-family: var(--cf-brand-font);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Brand tagline */
|
||||
.cf-tagline {
|
||||
font-family: var(--cf-brand-font);
|
||||
font-size: 14px;
|
||||
color: var(--cf-text-secondary);
|
||||
margin-left: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* About panel branding */
|
||||
#about-panel {
|
||||
font-family: var(--cf-brand-font);
|
||||
}
|
||||
|
||||
#about-panel .about-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
#about-panel .cf-logo {
|
||||
font-size: 32px;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#about-panel .cf-tagline {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Brand color accents in the UI */
|
||||
.controller-mapping-tab.active {
|
||||
color: var(--cf-brand-primary);
|
||||
border-bottom-color: var(--cf-brand-primary);
|
||||
}
|
||||
|
||||
/* Developer links section */
|
||||
.developer-links {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--cf-bg-overlay-light);
|
||||
border: 1px solid var(--cf-border-light);
|
||||
}
|
||||
|
||||
.developer-links h3 {
|
||||
color: var(--cf-brand-primary);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.developer-links a {
|
||||
color: var(--cf-link-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.developer-links a:hover {
|
||||
color: var(--cf-brand-primary);
|
||||
}
|
||||
|
||||
/* Project cards */
|
||||
.project-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--cf-bg-secondary);
|
||||
border: 1px solid var(--cf-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-color: var(--cf-brand-primary);
|
||||
}
|
||||
|
||||
.project-card h4 {
|
||||
margin-top: 0;
|
||||
color: var(--cf-brand-primary);
|
||||
}
|
||||
|
||||
.project-card p {
|
||||
font-size: 14px;
|
||||
color: var(--cf-text-secondary);
|
||||
}
|
||||
|
||||
.project-card a {
|
||||
color: var(--cf-link-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.project-card a:hover {
|
||||
color: var(--cf-brand-primary);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.litegraph-context-menu {
|
||||
/* These rules might conflict/override base LiteGraph context menu styles. */
|
||||
/* Consider using more specific selectors if needed, e.g., targeting Control Freak specific menus */
|
||||
position: fixed; /* Usually handled by LiteGraph? Verify if needed. */
|
||||
z-index: 10000; /* Usually handled by LiteGraph? Verify if needed. */
|
||||
background-color: var(--cf-bg-primary, #2c2c2c); /* Added fallback */
|
||||
border: 1px solid var(--cf-border-color, #555); /* Added fallback */
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px var(--cf-bg-overlay, rgba(0,0,0,0.5)); /* Added fallback */
|
||||
}
|
||||
|
||||
.litegraph-context-menu .menu-entry {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.litegraph-context-menu .menu-entry:hover {
|
||||
background-color: var(--cf-bg-hover, #4a4a4a); /* Added fallback */
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/* styles/controllerButton.css - EXACT from original JS */
|
||||
|
||||
/* Menu separator */
|
||||
.cf-menu-separator {
|
||||
margin: 20px 0;
|
||||
width: 100%;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Button wrapper */
|
||||
.cf-button-wrapper {
|
||||
display: flex;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Main menu button */
|
||||
.cf-menu-button {
|
||||
font-family: var(--cf-brand-font, Arial), sans-serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
background: #252525;
|
||||
border: 1px solid #333;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin: 0 3px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Hover effects */
|
||||
.cf-menu-button:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: var(--cf-brand-primary, #FF5722);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Active/pressed state */
|
||||
.cf-menu-button:active {
|
||||
background: #222;
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4) inset;
|
||||
}
|
||||
|
||||
/* Branding text elements */
|
||||
.cf-brand-text {
|
||||
font-family: var(--cf-brand-font, Arial), sans-serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.cf-brand-highlight {
|
||||
color: var(--cf-brand-primary, #FF5722);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Connected state */
|
||||
.cf-menu-button.connected {
|
||||
animation: cf-button-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes cf-button-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 87, 34, 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(255, 87, 34, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 87, 34, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/* Dialog styles for Control Freak UI */
|
||||
|
||||
.controller-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10000; /* Ensure it's above other elements */
|
||||
display: block; /* Original display */
|
||||
background-color: var(--cf-bg-primary);
|
||||
border: 1px solid var(--cf-border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 20px var(--cf-bg-overlay);
|
||||
padding: 0;
|
||||
min-width: 450px;
|
||||
max-width: 650px;
|
||||
max-height: 90vh; /* Allow content scrolling */
|
||||
overflow-y: auto; /* Original overflow */
|
||||
color: var(--cf-text-primary);
|
||||
font-family: var(--cf-brand-font);
|
||||
}
|
||||
|
||||
.controller-dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(90deg, var(--cf-bg-tertiary), var(--cf-brand-primary) 250%);
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--cf-border-color);
|
||||
border-radius: 6px 6px 0 0; /* Match dialog rounding */
|
||||
}
|
||||
|
||||
.controller-dialog-header h2 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--cf-brand-font);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
color: var(--cf-text-primary); /* Ensure header text color */
|
||||
}
|
||||
|
||||
.controller-dialog-header .cf-logo { /* Assuming this class is used */
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.controller-dialog-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cf-dialog-subtitle {
|
||||
color: var(--cf-text-secondary);
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.controller-dialog-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--cf-text-muted);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.controller-dialog-close:hover {
|
||||
color: var(--cf-text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Specific styles for mapping editor dialog */
|
||||
.mapping-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mapping-editor {
|
||||
/* Uses controller-dialog class, inherits most styles */
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.mapping-editor-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--cf-text-primary);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mapping-editor-info {
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--cf-border-color);
|
||||
color: var(--cf-text-secondary);
|
||||
}
|
||||
|
||||
.mapping-editor-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mapping-editor-field label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--cf-brand-primary);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mapping-editor-field select,
|
||||
.mapping-editor-field input[type="number"],
|
||||
.mapping-editor-field input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border: 1px solid var(--cf-border-color);
|
||||
color: var(--cf-text-primary);
|
||||
font-family: var(--cf-brand-font);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mapping-editor-field.invert-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mapping-editor-field.invert-field input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mapping-type-select {
|
||||
/* Inherit from controller-mapping-type-select */
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border: 1px solid var(--cf-border-color);
|
||||
color: var(--cf-text-primary);
|
||||
font-family: var(--cf-brand-font);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Fix for edit dialog button styling */
|
||||
.mapping-editor .controller-mapping-buttons {
|
||||
padding: 0 20px 20px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.mapping-editor .controller-map-button,
|
||||
.mapping-editor .controller-cancel-button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.mapping-editor .controller-map-button {
|
||||
background-color: var(--cf-brand-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mapping-editor .controller-cancel-button {
|
||||
background-color: var(--cf-bg-secondary);
|
||||
color: var(--cf-text-primary);
|
||||
border: 1px solid var(--cf-border-color);
|
||||
}
|
||||
|
||||
.controller-mapping-target,
|
||||
.controller-mapping-existing,
|
||||
.controller-mapping-input,
|
||||
.controller-mapping-type-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.controller-mapping-target h3,
|
||||
.controller-mapping-existing h3,
|
||||
.controller-mapping-input h3,
|
||||
.controller-mapping-type-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
font-family: var(--cf-brand-font);
|
||||
font-weight: 600;
|
||||
color: var(--cf-brand-primary);
|
||||
font-size: 16px;
|
||||
border-bottom: 1px solid var(--cf-border-light);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.controller-mapping-target-info,
|
||||
.controller-mapping-existing-content,
|
||||
.controller-mapping-input-list {
|
||||
padding: 12px;
|
||||
background-color: var(--cf-bg-overlay-light);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--cf-border-light);
|
||||
font-size: 14px; /* Ensure consistent font size */
|
||||
}
|
||||
|
||||
.controller-mapping-existing-content,
|
||||
.controller-mapping-input-list {
|
||||
max-height: 150px; /* Limit height */
|
||||
overflow-y: auto; /* Add scrollbar */
|
||||
}
|
||||
|
||||
.controller-mapping-instruction {
|
||||
text-align: center;
|
||||
color: var(--cf-text-muted);
|
||||
padding: 20px 0;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Visibility classes (usually better handled by adding/removing classes in JS) */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.visible {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.controller-mapping-type-container {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.controller-mapping-type-select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border: 1px solid var(--cf-border-color);
|
||||
color: var(--cf-text-primary);
|
||||
font-family: var(--cf-brand-font);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box; /* Include padding in width */
|
||||
}
|
||||
|
||||
.controller-mapping-type-select option {
|
||||
padding: 8px;
|
||||
background-color: var(--cf-bg-secondary); /* Ensure dropdown options match */
|
||||
color: var(--cf-text-primary);
|
||||
}
|
||||
|
||||
.controller-mapping-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.controller-mapping-range > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.controller-mapping-range label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--cf-text-secondary);
|
||||
}
|
||||
|
||||
.controller-mapping-min,
|
||||
.controller-mapping-max,
|
||||
.controller-mapping-step {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border: 1px solid var(--cf-border-color);
|
||||
color: var(--cf-text-primary);
|
||||
font-family: var(--cf-brand-font);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box; /* Include padding in width */
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.controller-mapping-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px; /* Add some bottom margin */
|
||||
padding: 0 20px 20px 20px; /* Add padding matching content */
|
||||
flex-shrink: 0; /* Prevent buttons shrinking */
|
||||
}
|
||||
|
||||
.controller-cancel-button,
|
||||
.controller-map-button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
font-family: var(--cf-brand-font);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px; /* Ensure consistent font size */
|
||||
}
|
||||
|
||||
.controller-cancel-button {
|
||||
background-color: var(--cf-bg-secondary);
|
||||
flex: 1; /* Equal width initially */
|
||||
border: 1px solid var(--cf-border-color);
|
||||
color: var(--cf-text-primary);
|
||||
}
|
||||
|
||||
.controller-cancel-button:hover {
|
||||
background-color: var(--cf-bg-hover);
|
||||
border-color: var(--cf-text-secondary); /* Subtle border change */
|
||||
}
|
||||
|
||||
.controller-map-button {
|
||||
background-color: var(--cf-brand-primary);
|
||||
flex: 2; /* Make map button wider */
|
||||
}
|
||||
|
||||
.controller-map-button:hover:not(:disabled) { /* Only hover effect when enabled */
|
||||
background-color: var(--cf-brand-primary-dark); /* Darker primary */
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.controller-map-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--cf-brand-primary); /* Keep base color */
|
||||
}
|
||||
|
||||
/* Specific button styling within dialog (might override general cf-button) */
|
||||
.controller-dialog button {
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border: 1px solid var(--cf-border-color);
|
||||
color: var(--cf-text-primary);
|
||||
padding: 8px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: var(--cf-brand-font);
|
||||
transition: all 0.2s;
|
||||
font-size: 14px; /* Consistent size */
|
||||
}
|
||||
|
||||
.controller-dialog button:hover:not(:disabled) {
|
||||
background-color: var(--cf-bg-hover);
|
||||
border-color: var(--cf-brand-primary); /* Highlight border on hover */
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dialog content */
|
||||
.controller-dialog-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.controller-dialog-content::-webkit-scrollbar-track {
|
||||
background: var(--cf-bg-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.controller-dialog-content::-webkit-scrollbar-thumb {
|
||||
background-color: var(--cf-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--cf-bg-primary);
|
||||
}
|
||||
.controller-dialog-content::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--cf-border-color);
|
||||
}
|
||||
.controller-dialog-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cf-bg-tertiary) var(--cf-bg-primary);
|
||||
}
|
||||
|
||||
/* Ensure input lists also get scrollbar styles */
|
||||
.controller-mapping-existing-content::-webkit-scrollbar,
|
||||
.controller-mapping-input-list::-webkit-scrollbar {
|
||||
width: 6px; /* Make slightly smaller for lists */
|
||||
}
|
||||
.controller-mapping-existing-content::-webkit-scrollbar-track,
|
||||
.controller-mapping-input-list::-webkit-scrollbar-track {
|
||||
background: var(--cf-bg-overlay-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.controller-mapping-existing-content::-webkit-scrollbar-thumb,
|
||||
.controller-mapping-input-list::-webkit-scrollbar-thumb {
|
||||
background-color: var(--cf-border-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.controller-mapping-existing-content::-webkit-scrollbar-thumb:hover,
|
||||
.controller-mapping-input-list::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--cf-text-muted);
|
||||
}
|
||||
.controller-mapping-existing-content,
|
||||
.controller-mapping-input-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cf-border-light) var(--cf-bg-overlay-light);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
function loadCssWithComfyApi(relativePath) {
|
||||
try {
|
||||
if (window.comfyAPI?.utils?.addStylesheet) {
|
||||
const cssUrlPath = `/extensions/ComfyUI_ControlFreak/ui/styles/${relativePath}`;
|
||||
window.comfyAPI.utils.addStylesheet(cssUrlPath);
|
||||
// console.log(`ControlFreak: Requested loading CSS ${cssUrlPath} via ComfyAPI`);
|
||||
} else {
|
||||
console.warn(`ControlFreak: comfyAPI.utils.addStylesheet not found. Cannot load ${relativePath} dynamically.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`ControlFreak: Error calling addStylesheet for ${relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load all CSS files
|
||||
loadCssWithComfyApi('contextMenu.css');
|
||||
loadCssWithComfyApi('notification.css');
|
||||
loadCssWithComfyApi('panel.css');
|
||||
loadCssWithComfyApi('dialog.css');
|
||||
loadCssWithComfyApi('mappingComponent.css');
|
||||
loadCssWithComfyApi('controllerButton.css');
|
||||
loadCssWithComfyApi('theme.css');
|
||||
loadCssWithComfyApi('branding.css');
|
||||
loadCssWithComfyApi('nodeStyles.css');
|
||||
@@ -0,0 +1,361 @@
|
||||
/* styles/mappingComponent.css - EXACT from original JS */
|
||||
|
||||
/* Mapping List Container */
|
||||
.mapping-list-container,
|
||||
.controller-mapping-list {
|
||||
font-family: var(--cf-brand-font); /* Use brand font */
|
||||
color: var(--cf-text-primary);
|
||||
max-width: 100%;
|
||||
/* Add some default spacing */
|
||||
/* margin-top: 10px; */
|
||||
}
|
||||
|
||||
/* Style the list shown in the Mappings tab */
|
||||
#mappings-panel .mapping-list {
|
||||
/* Add some padding/margin if needed */
|
||||
margin-top: 0; /* Remove top margin if header exists */
|
||||
gap: 8px; /* Consistent gap from commands list */
|
||||
padding-right: 0; /* Remove scroll padding if panel scrolls */
|
||||
}
|
||||
|
||||
.mapping-list-header {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Mapping List */
|
||||
.mapping-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
/* Inside dialog */
|
||||
.controller-mapping-existing-content .mapping-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Mapping Item - Original style */
|
||||
.mapping-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--cf-border-color);
|
||||
}
|
||||
|
||||
/* Mapping Component Style (main style used in panel) */
|
||||
.controller-mapping-component {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* padding: 10px; */
|
||||
padding: 10px 15px; /* Match command item padding */
|
||||
/* margin: 5px 0; */
|
||||
margin: 0; /* Remove margin, rely on parent gap */
|
||||
/* background-color: var(--cf-bg-tertiary); */
|
||||
background-color: var(--cf-bg-secondary); /* Match command item bg */
|
||||
border-radius: 4px;
|
||||
/* border: 1px solid var(--cf-border-color); */
|
||||
border: 1px solid var(--cf-border-color); /* Match command item border */
|
||||
position: relative;
|
||||
gap: 10px;
|
||||
transition: border-color 0.2s, background-color 0.2s; /* Add transition */
|
||||
}
|
||||
|
||||
/* Add hover effect like command item */
|
||||
.controller-mapping-component:hover {
|
||||
border-color: var(--cf-brand-primary);
|
||||
background-color: var(--cf-bg-tertiary);
|
||||
}
|
||||
|
||||
.mapping-control-source {
|
||||
flex: 1;
|
||||
/* margin-right: 10px; */ /* Handled by parent gap */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px; /* Increase gap slightly */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapping-target {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px; /* Increase gap slightly */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapping-icon {
|
||||
/* margin-right: 5px; */ /* Handled by parent gap */
|
||||
flex-shrink: 0;
|
||||
font-size: 16px; /* Larger icon */
|
||||
opacity: 0.9;
|
||||
color: var(--cf-text-secondary); /* Muted icon color */
|
||||
}
|
||||
|
||||
.mapping-name {
|
||||
font-weight: 500; /* Match command label weight */
|
||||
font-size: 14px; /* Match command label size */
|
||||
color: var(--cf-text-primary); /* Ensure color */
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
margin: 0 10px;
|
||||
font-size: 16px;
|
||||
color: var(--cf-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mapping-type-badge {
|
||||
padding: 3px 7px; /* Slightly larger padding */
|
||||
background-color: var(--cf-bg-tertiary); /* Slightly darker badge */
|
||||
border: 1px solid var(--cf-border-light); /* Add border */
|
||||
border-radius: 3px;
|
||||
font-size: 11px; /* Keep small */
|
||||
margin-left: 10px;
|
||||
flex-shrink: 0;
|
||||
color: var(--cf-text-secondary); /* Muted text */
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mapping-actions {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
padding-left: 15px;
|
||||
gap: 6px; /* Slightly more gap */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Make buttons look like command map button */
|
||||
.mapping-edit,
|
||||
.mapping-delete {
|
||||
/* Base style similar to .command-map-btn */
|
||||
padding: 5px 10px;
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border: 1px solid var(--cf-border-light);
|
||||
color: var(--cf-text-secondary);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
/* UPDATED HOVER: Match command map button hover */
|
||||
.mapping-edit:hover,
|
||||
.mapping-delete:hover {
|
||||
background-color: var(--cf-brand-primary); /* Orange brand color */
|
||||
border-color: var(--cf-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mapping-empty-message,
|
||||
.no-mappings-message { /* Combined empty message styles */
|
||||
color: var(--cf-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px; /* Increased padding */
|
||||
font-size: 14px;
|
||||
background-color: var(--cf-bg-secondary); /* Added background */
|
||||
border: 1px dashed var(--cf-border-color); /* Added border */
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Inside dialog, make items more compact */
|
||||
.controller-mapping-existing-content .mapping-item,
|
||||
.controller-mapping-existing-content .controller-mapping-component {
|
||||
padding: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Mapping Info */
|
||||
.mapping-info {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Inside dialog, make info more compact */
|
||||
.controller-mapping-existing-content .mapping-info {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.mapping-edit-btn,
|
||||
.mapping-delete-btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
background-color: var(--cf-accent-blue-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Inside dialog, make buttons smaller */
|
||||
.controller-mapping-existing-content .mapping-edit-btn,
|
||||
.controller-mapping-existing-content .mapping-delete-btn,
|
||||
.controller-mapping-existing-content .mapping-edit,
|
||||
.controller-mapping-existing-content .mapping-delete {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mapping-delete-btn {
|
||||
background-color: var(--cf-accent-red-dark);
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.mapping-error {
|
||||
color: var(--cf-status-error);
|
||||
padding: 8px;
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Mapping Editor Dialog */
|
||||
.mapping-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--cf-bg-overlay); /* Ensure this is applied */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10001; /* Ensure above panel */
|
||||
}
|
||||
|
||||
/* Apply base dialog styles AND editor specifics */
|
||||
.mapping-editor.controller-dialog {
|
||||
/* Base Dialog Properties (Copied/Verified from dialog.css) */
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10001; /* Ensure above panel */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--cf-bg-primary); /* Ensure solid background */
|
||||
border: 1px solid var(--cf-border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 20px var(--cf-bg-overlay);
|
||||
padding: 0; /* Padding is on inner elements */
|
||||
min-width: 450px; /* Consistent min-width */
|
||||
max-width: 600px; /* Specific max-width */
|
||||
max-height: 90vh;
|
||||
color: var(--cf-text-primary);
|
||||
font-family: var(--cf-brand-font);
|
||||
overflow: hidden; /* Prevent outer scroll */
|
||||
}
|
||||
|
||||
/* Editor Header (Inherit/Match dialog header) */
|
||||
.mapping-editor .controller-dialog-header {
|
||||
/* Styles copied from dialog.css header */
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(90deg, var(--cf-bg-tertiary), var(--cf-brand-primary) 250%);
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--cf-border-color);
|
||||
border-radius: 6px 6px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mapping-editor .controller-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--cf-brand-font);
|
||||
font-weight: 600;
|
||||
font-size: 18px; /* Editor title size */
|
||||
color: var(--cf-text-primary);
|
||||
}
|
||||
|
||||
/* Editor Close Button (Inherit/Match dialog close) */
|
||||
.mapping-editor .controller-dialog-close {
|
||||
/* Styles copied from dialog.css close */
|
||||
background: none; border: none; font-size: 24px; cursor: pointer;
|
||||
color: var(--cf-text-muted); width: 30px; height: 30px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.2s; border-radius: 4px;
|
||||
}
|
||||
.mapping-editor .controller-dialog-close:hover {
|
||||
color: var(--cf-text-primary); background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Editor Content Area (Inherit/Match dialog content) */
|
||||
.mapping-editor .controller-dialog-content {
|
||||
padding: 20px;
|
||||
overflow-y: auto; /* Allow content scroll */
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Editor Footer/Actions (Inherit/Match dialog buttons container) */
|
||||
.mapping-editor .controller-mapping-buttons {
|
||||
/* Styles copied from dialog.css buttons container */
|
||||
display: flex; justify-content: space-between; gap: 15px;
|
||||
margin-top: 15px; /* Space above */
|
||||
padding: 20px; /* Consistent padding */
|
||||
border-top: 1px solid var(--cf-border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Ensure editor buttons inherit base styles */
|
||||
.mapping-editor .controller-cancel-button,
|
||||
.mapping-editor .controller-map-button {
|
||||
/* Styles copied from dialog.css buttons */
|
||||
flex: 1; padding: 12px; border: none; border-radius: 4px;
|
||||
cursor: pointer; color: white; font-family: var(--cf-brand-font);
|
||||
font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
transition: all 0.2s; font-size: 14px;
|
||||
}
|
||||
.mapping-editor .controller-cancel-button {
|
||||
background-color: var(--cf-bg-secondary); flex: 1;
|
||||
border: 1px solid var(--cf-border-color); color: var(--cf-text-primary);
|
||||
}
|
||||
.mapping-editor .controller-cancel-button:hover {
|
||||
background-color: var(--cf-bg-hover); border-color: var(--cf-text-secondary);
|
||||
}
|
||||
.mapping-editor .controller-map-button {
|
||||
background-color: var(--cf-brand-primary); flex: 2;
|
||||
}
|
||||
.mapping-editor .controller-map-button:hover:not(:disabled) {
|
||||
background-color: var(--cf-brand-primary-dark);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.mapping-editor .controller-map-button:disabled {
|
||||
opacity: 0.5; cursor: not-allowed; background-color: var(--cf-brand-primary);
|
||||
}
|
||||
|
||||
/* Scrollbars - Applying original styles */
|
||||
.mapping-list::-webkit-scrollbar,
|
||||
.mapping-editor::-webkit-scrollbar { /* Add scrollbar to editor */
|
||||
width: 6px;
|
||||
}
|
||||
.mapping-list::-webkit-scrollbar-track,
|
||||
.mapping-editor::-webkit-scrollbar-track {
|
||||
background: var(--cf-bg-primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mapping-list::-webkit-scrollbar-thumb,
|
||||
.mapping-editor::-webkit-scrollbar-thumb {
|
||||
background-color: var(--cf-border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mapping-list::-webkit-scrollbar-thumb:hover,
|
||||
.mapping-editor::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--cf-text-muted);
|
||||
}
|
||||
.mapping-list,
|
||||
.mapping-editor {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cf-border-color) var(--cf-bg-primary);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* styles/nodeStyles.css - EXACT from original JS */
|
||||
|
||||
/* Control Freak node styling */
|
||||
.comfy-node.ControlFreak-node .title {
|
||||
background: linear-gradient(90deg, var(--cf-bg-primary), var(--cf-brand-primary) 120%) !important;
|
||||
font-family: var(--cf-brand-font);
|
||||
}
|
||||
|
||||
.comfy-node.ControlFreak-node .title-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.comfy-node.ControlFreak-node .title-text::before {
|
||||
content: "🎮";
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Style specific nodes */
|
||||
.comfy-node.ControlFreak-ControllerMapping .body {
|
||||
background-color: rgba(255, 87, 34, 0.1);
|
||||
}
|
||||
|
||||
.comfy-node.ControlFreak-ControllerInfo .body {
|
||||
background-color: rgba(139, 195, 74, 0.1);
|
||||
}
|
||||
|
||||
/* Add a subtle border */
|
||||
.comfy-node.ControlFreak-node {
|
||||
box-shadow: 0 0 0 1px var(--cf-brand-primary);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/* Notification styles for Control Freak UI */
|
||||
|
||||
.controller-notification {
|
||||
font-family: var(--cf-brand-font, sans-serif); /* Added fallback */
|
||||
font-size: 14px;
|
||||
max-width: 350px;
|
||||
position: fixed;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px var(--cf-bg-overlay, rgba(0,0,0,0.5)); /* Added fallback */
|
||||
transition: all 0.3s ease-out;
|
||||
color: var(--cf-text-primary, #eee); /* Added fallback */
|
||||
background-color: var(--cf-bg-primary, #2c2c2c); /* Moved background here */
|
||||
border-left: 4px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.controller-notification::before {
|
||||
content: "🎮";
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.controller-notification.error {
|
||||
/* background-color: var(--cf-bg-primary); */ /* Background set above */
|
||||
border-left-color: var(--cf-accent-red, #e54d4d); /* Added fallback */
|
||||
/* color: var(--cf-text-primary); */ /* Color set above */
|
||||
}
|
||||
|
||||
.controller-notification.error::before {
|
||||
content: "⚠️";
|
||||
}
|
||||
|
||||
.controller-notification.info {
|
||||
/* background-color: var(--cf-bg-primary); */
|
||||
border-left-color: var(--cf-brand-primary, #4c8eda); /* Added fallback */
|
||||
/* color: var(--cf-text-primary); */
|
||||
}
|
||||
|
||||
.controller-notification.success {
|
||||
/* background-color: var(--cf-bg-primary); */
|
||||
border-left-color: var(--cf-brand-primary, #5cb85c); /* Added fallback */
|
||||
/* color: var(--cf-text-primary); */
|
||||
}
|
||||
|
||||
.controller-notification.success::before {
|
||||
content: "✅";
|
||||
}
|
||||
|
||||
.controller-notification.bottom-right {
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.controller-notification.bottom-left {
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
.controller-notification.top-right {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.controller-notification.top-left {
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.controller-notification.fade-in {
|
||||
animation: notification-fade-in 0.3s forwards;
|
||||
}
|
||||
|
||||
.controller-notification.fade-out {
|
||||
animation: notification-fade-out 0.3s forwards;
|
||||
}
|
||||
|
||||
@keyframes notification-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
/* Panel styles for Control Freak UI */
|
||||
|
||||
#controller-mapping-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--cf-bg-primary, #2c2c2c);
|
||||
border: 1px solid var(--cf-border-color, #555);
|
||||
border-radius: 8px;
|
||||
width: 85%;
|
||||
max-width: 950px;
|
||||
max-height: 85vh;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--cf-text-primary, #eee);
|
||||
font-family: var(--cf-brand-font, sans-serif);
|
||||
box-shadow: 0 5px 25px var(--cf-bg-overlay, rgba(0,0,0,0.5));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#controller-mapping-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background: linear-gradient(90deg, var(--cf-bg-tertiary, #222), var(--cf-brand-primary, #4c8eda) 250%);
|
||||
border-bottom: 1px solid var(--cf-border-color, #555);
|
||||
}
|
||||
|
||||
#controller-mapping-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#controller-mapping-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--cf-text-primary, #eee);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
#controller-mapping-close:hover {
|
||||
background-color: var(--cf-bg-overlay-light, rgba(255,255,255,0.1));
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
#controller-mapping-tabs {
|
||||
display: flex;
|
||||
background-color: var(--cf-bg-secondary, #333);
|
||||
border-bottom: 1px solid var(--cf-border-color, #555);
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.controller-mapping-tab {
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 14px;
|
||||
color: var(--cf-text-secondary, #bbb); /* Added default color */
|
||||
}
|
||||
|
||||
.controller-mapping-tab:hover {
|
||||
background-color: var(--cf-bg-overlay-light, rgba(255,255,255,0.1));
|
||||
color: var(--cf-brand-primary, #4c8eda);
|
||||
}
|
||||
|
||||
.controller-mapping-tab.active {
|
||||
border-bottom-color: var(--cf-brand-primary, #4c8eda);
|
||||
color: var(--cf-brand-primary, #4c8eda);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#controller-mapping-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background-color: var(--cf-bg-primary, #2c2c2c); /* Ensure content bg */
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Controller sections styling */
|
||||
.controller-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.cf-section-header {
|
||||
font-size: 18px;
|
||||
margin: 0 0 15px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--cf-border-light, #666);
|
||||
color: var(--cf-brand-primary, #4c8eda);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cf-section-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.cf-loading {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--cf-text-secondary, #bbb);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cf-loading-icon {
|
||||
font-size: 32px;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0.6; transform: scale(0.95); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
100% { opacity: 0.6; transform: scale(0.95); }
|
||||
}
|
||||
|
||||
.cf-cards-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.controller-card {
|
||||
background-color: var(--cf-bg-secondary, #333);
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 8px var(--cf-bg-overlay-dark, rgba(0,0,0,0.4));
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--cf-border-color, #555);
|
||||
display: flex; /* Added for flex layout */
|
||||
flex-direction: column; /* Added for flex layout */
|
||||
}
|
||||
|
||||
.controller-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px var(--cf-bg-overlay, rgba(0,0,0,0.5));
|
||||
border-color: var(--cf-brand-primary, #4c8eda);
|
||||
}
|
||||
|
||||
.controller-card-header {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controller-card-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 70%; /* Adjust as needed */
|
||||
}
|
||||
|
||||
.controller-status {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.controller-status.connected {
|
||||
background-color: rgba(0, 128, 0, 0.15);
|
||||
color: var(--cf-status-success, #5cb85c);
|
||||
border: 1px solid rgba(0, 128, 0, 0.2);
|
||||
}
|
||||
|
||||
.controller-status.disconnected {
|
||||
background-color: rgba(128, 0, 0, 0.15);
|
||||
color: var(--cf-status-error, #e54d4d);
|
||||
border: 1px solid rgba(128, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.controller-status.detected {
|
||||
background-color: rgba(230, 180, 80, 0.15);
|
||||
color: var(--cf-status-warning, #f0ad4e);
|
||||
border: 1px solid rgba(230, 180, 80, 0.2);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding: 8px;
|
||||
background-color: var(--cf-bg-overlay-light, rgba(255,255,255,0.1));
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--cf-border-light, #666);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.device-type-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.device-specs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--cf-text-secondary, #bbb);
|
||||
}
|
||||
|
||||
.controller-controls {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid var(--cf-border-light, #666);
|
||||
max-height: 150px; /* Limit height */
|
||||
overflow-y: auto; /* Add scroll */
|
||||
}
|
||||
|
||||
.controller-controls h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--cf-text-secondary, #bbb);
|
||||
}
|
||||
|
||||
.controller-controls ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controller-controls li {
|
||||
background-color: var(--cf-bg-tertiary, #222);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--cf-border-color, #555);
|
||||
}
|
||||
|
||||
.controller-controls li .control-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.profile-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: var(--cf-bg-secondary, #333);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--cf-border-color, #555);
|
||||
}
|
||||
|
||||
.profile-selector label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-selector select {
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--cf-bg-primary, #2c2c2c);
|
||||
color: var(--cf-text-primary, #eee);
|
||||
border: 1px solid var(--cf-border-light, #666);
|
||||
flex-grow: 1; /* Allow select to grow */
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-actions button {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
margin-left: 5px;
|
||||
background-color: var(--cf-bg-tertiary, #222);
|
||||
border: 1px solid var(--cf-border-color, #555);
|
||||
color: var(--cf-text-primary, #eee);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.profile-actions button:hover {
|
||||
background-color: var(--cf-bg-hover, #4a4a4a);
|
||||
border-color: var(--cf-brand-primary, #4c8eda);
|
||||
}
|
||||
|
||||
.profile-actions button.cf-danger:hover {
|
||||
background-color: var(--cf-accent-red-dark, #b03c3c);
|
||||
border-color: var(--cf-accent-red, #e54d4d);
|
||||
}
|
||||
|
||||
.mappings-list {
|
||||
margin-top: 15px;
|
||||
/* Styles for the list itself if needed */
|
||||
}
|
||||
|
||||
/* Generic Button Styles (if not covered elsewhere) */
|
||||
.cf-button {
|
||||
padding: 8px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
font-family: var(--cf-brand-font, sans-serif);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cf-button-primary {
|
||||
background-color: var(--cf-brand-primary, #4c8eda);
|
||||
color: white;
|
||||
}
|
||||
.cf-button-primary:hover {
|
||||
background-color: var(--cf-brand-primary-dark, #3a70b2);
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.cf-button-secondary {
|
||||
background-color: var(--cf-bg-secondary, #333);
|
||||
color: var(--cf-text-primary, #eee);
|
||||
border: 1px solid var(--cf-border-color, #555);
|
||||
}
|
||||
.cf-button-secondary:hover {
|
||||
background-color: var(--cf-bg-hover, #4a4a4a);
|
||||
border-color: var(--cf-text-secondary, #bbb);
|
||||
}
|
||||
|
||||
.cf-button-danger {
|
||||
background-color: var(--cf-accent-red, #e54d4d);
|
||||
color: white;
|
||||
}
|
||||
.cf-button-danger:hover {
|
||||
background-color: var(--cf-accent-red-dark, #b03c3c);
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.cf-input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--cf-bg-input, #222);
|
||||
color: var(--cf-text-primary, #eee);
|
||||
border: 1px solid var(--cf-border-light, #666);
|
||||
font-size: 14px;
|
||||
font-family: var(--cf-brand-font, sans-serif);
|
||||
}
|
||||
.cf-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--cf-brand-primary, #4c8eda);
|
||||
box-shadow: 0 0 0 2px var(--cf-brand-primary-light, rgba(76, 142, 218, 0.3));
|
||||
}
|
||||
|
||||
/* Add scrollbar styling for webkit browsers */
|
||||
#controller-mapping-content::-webkit-scrollbar,
|
||||
.controller-controls::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
#controller-mapping-content::-webkit-scrollbar-track,
|
||||
.controller-controls::-webkit-scrollbar-track {
|
||||
background: var(--cf-bg-primary, #2c2c2c);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#controller-mapping-content::-webkit-scrollbar-thumb,
|
||||
.controller-controls::-webkit-scrollbar-thumb {
|
||||
background-color: var(--cf-bg-tertiary, #222);
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--cf-bg-primary, #2c2c2c);
|
||||
}
|
||||
|
||||
#controller-mapping-content::-webkit-scrollbar-thumb:hover,
|
||||
.controller-controls::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--cf-border-color, #555);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar styling */
|
||||
#controller-mapping-content,
|
||||
.controller-controls {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cf-bg-tertiary, #222) var(--cf-bg-primary, #2c2c2c);
|
||||
}
|
||||
|
||||
/* --- UI Commands Tab Styles (Refined) --- */
|
||||
|
||||
.commands-container {
|
||||
/* Match overall content padding */
|
||||
}
|
||||
|
||||
.commands-panel-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--cf-border-light);
|
||||
}
|
||||
|
||||
.commands-panel-header h3 {
|
||||
margin: 0 0 5px 0;
|
||||
color: var(--cf-brand-primary);
|
||||
font-weight: 600;
|
||||
font-size: 18px; /* Consistent section header */
|
||||
}
|
||||
|
||||
.commands-panel-header p {
|
||||
margin: 0;
|
||||
color: var(--cf-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.command-category-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.command-category-section .category-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--cf-text-primary);
|
||||
margin: 0 0 12px 0; /* Increased margin */
|
||||
padding-bottom: 8px; /* Increased padding */
|
||||
border-bottom: 1px solid var(--cf-border-color); /* Solid, darker border */
|
||||
}
|
||||
|
||||
.command-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 15px; /* Increased padding */
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border: 1px solid var(--cf-border-color);
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.command-item:hover {
|
||||
border-color: var(--cf-brand-primary);
|
||||
background-color: var(--cf-bg-tertiary); /* Add subtle background hover */
|
||||
}
|
||||
|
||||
.command-info {
|
||||
flex-grow: 1;
|
||||
margin-right: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.command-label {
|
||||
font-weight: 500;
|
||||
color: var(--cf-text-primary);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.command-id {
|
||||
font-size: 11px;
|
||||
color: var(--cf-text-muted);
|
||||
margin-top: 3px; /* Slightly more space */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.command-map-btn {
|
||||
padding: 5px 10px; /* Slightly smaller padding */
|
||||
background-color: var(--cf-bg-secondary); /* Match item bg */
|
||||
border: 1px solid var(--cf-border-light);
|
||||
color: var(--cf-text-secondary);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600; /* Bolder */
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.command-map-btn:hover {
|
||||
background-color: var(--cf-brand-primary); /* Orange brand color */
|
||||
border-color: var(--cf-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.no-commands {
|
||||
color: var(--cf-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background-color: var(--cf-bg-secondary); /* Add background */
|
||||
border-radius: 4px; /* Add radius */
|
||||
}
|
||||
|
||||
/* --- mappingComponent.css --- */
|
||||
|
||||
/* Mapping Component Style (main style used in panel) */
|
||||
.controller-mapping-component {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* padding: 10px; */
|
||||
padding: 10px 15px; /* Match command item padding */
|
||||
/* margin: 5px 0; */
|
||||
margin: 0; /* Remove margin, rely on parent gap */
|
||||
/* background-color: var(--cf-bg-tertiary); */
|
||||
background-color: var(--cf-bg-secondary); /* Match command item bg */
|
||||
border-radius: 4px;
|
||||
/* border: 1px solid var(--cf-border-color); */
|
||||
border: 1px solid var(--cf-border-color); /* Match command item border */
|
||||
position: relative;
|
||||
gap: 10px;
|
||||
transition: border-color 0.2s, background-color 0.2s; /* Add transition */
|
||||
}
|
||||
|
||||
/* Add hover effect like command item */
|
||||
.controller-mapping-component:hover {
|
||||
border-color: var(--cf-brand-primary);
|
||||
background-color: var(--cf-bg-tertiary);
|
||||
}
|
||||
|
||||
.mapping-control-source {
|
||||
flex: 1;
|
||||
/* margin-right: 10px; */ /* Handled by parent gap */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px; /* Increase gap slightly */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapping-target {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px; /* Increase gap slightly */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapping-icon {
|
||||
/* margin-right: 5px; */ /* Handled by parent gap */
|
||||
flex-shrink: 0;
|
||||
font-size: 16px; /* Larger icon */
|
||||
opacity: 0.9;
|
||||
color: var(--cf-text-secondary); /* Muted icon color */
|
||||
}
|
||||
|
||||
.mapping-name {
|
||||
font-weight: 500; /* Match command label weight */
|
||||
font-size: 14px; /* Match command label size */
|
||||
color: var(--cf-text-primary); /* Ensure color */
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
margin: 0 10px;
|
||||
font-size: 16px;
|
||||
color: var(--cf-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mapping-type-badge {
|
||||
padding: 3px 7px; /* Slightly larger padding */
|
||||
background-color: var(--cf-bg-tertiary); /* Slightly darker badge */
|
||||
border: 1px solid var(--cf-border-light); /* Add border */
|
||||
border-radius: 3px;
|
||||
font-size: 11px; /* Keep small */
|
||||
margin-left: 10px;
|
||||
flex-shrink: 0;
|
||||
color: var(--cf-text-secondary); /* Muted text */
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mapping-actions {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
padding-left: 15px;
|
||||
gap: 6px; /* Slightly more gap */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Make buttons look like command map button */
|
||||
.mapping-edit,
|
||||
.mapping-delete {
|
||||
/* Base style similar to .command-map-btn */
|
||||
padding: 5px 10px;
|
||||
background-color: var(--cf-bg-secondary);
|
||||
border: 1px solid var(--cf-border-light);
|
||||
color: var(--cf-text-secondary);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
/* Specific hover colors */
|
||||
.mapping-edit:hover {
|
||||
background-color: var(--cf-accent-blue);
|
||||
border-color: var(--cf-accent-blue);
|
||||
color: white;
|
||||
}
|
||||
.mapping-delete:hover {
|
||||
background-color: var(--cf-accent-red);
|
||||
border-color: var(--cf-accent-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Style the list container in the Mappings tab */
|
||||
#mappings-panel .mapping-list {
|
||||
margin-top: 0; /* Remove top margin if header exists */
|
||||
gap: 8px; /* Consistent gap from commands list */
|
||||
padding-right: 0; /* Remove scroll padding if panel scrolls */
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* styles/theme.css */
|
||||
|
||||
/* Import brand font */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
/* Base colors that extend ComfyUI variables */
|
||||
--cf-text-primary: var(--input-text, #ffffff);
|
||||
--cf-text-secondary: var(--descrip-text, #999999);
|
||||
--cf-text-muted: #aaaaaa;
|
||||
|
||||
/* Background colors */
|
||||
--cf-bg-primary: var(--comfy-menu-bg, #202020);
|
||||
--cf-bg-secondary: var(--comfy-input-bg, #303030);
|
||||
--cf-bg-tertiary: #333333;
|
||||
--cf-bg-hover: var(--comfy-menu-bg-hover, #404040);
|
||||
--cf-bg-input: var(--comfy-input-bg, #303030); /* Added explicit input */
|
||||
|
||||
/* Border colors */
|
||||
--cf-border-color: var(--border-color, #444444);
|
||||
--cf-border-light: rgba(255, 255, 255, 0.1);
|
||||
--cf-border-dark: rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Brand colors */
|
||||
--cf-brand-primary: #ff5722; /* Vibrant orange - represents control */
|
||||
--cf-brand-primary-dark: #e64a19; /* Added darker orange */
|
||||
--cf-brand-primary-light: rgba(255, 87, 34, 0.3); /* Added lighter orange */
|
||||
--cf-brand-secondary: #8bc34a; /* Fresh green - represents responsive control */
|
||||
--cf-brand-tertiary: #607d8b; /* Blue-grey - represents precision */
|
||||
|
||||
/* Accent colors */
|
||||
--cf-accent-blue: #3498db;
|
||||
--cf-accent-blue-dark: #2a5885;
|
||||
--cf-accent-green: #4a6;
|
||||
--cf-accent-red: #d9534f;
|
||||
--cf-accent-red-dark: #a46;
|
||||
|
||||
/* Status colors */
|
||||
--cf-status-success: rgb(100, 230, 100);
|
||||
--cf-status-error: rgb(230, 100, 100);
|
||||
--cf-status-warning: rgb(230, 180, 80);
|
||||
|
||||
/* Link colors */
|
||||
--cf-link-color: #64b5f6;
|
||||
--cf-link-hover: #90caf9;
|
||||
|
||||
/* Alpha backgrounds */
|
||||
--cf-bg-overlay: rgba(0, 0, 0, 0.5);
|
||||
--cf-bg-overlay-light: rgba(255, 255, 255, 0.05);
|
||||
--cf-bg-overlay-dark: rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Brand fonts */
|
||||
--cf-brand-font: 'Rajdhani', 'Roboto Condensed', sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Handles highlighting mapped widgets on the graph canvas.
|
||||
*/
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { contextProvider } from '../core/contextProvider.js';
|
||||
import { eventBus } from '../core/eventBus.js';
|
||||
|
||||
// Attempt to get LiteGraph from the global scope or app instance
|
||||
const LiteGraph = window.LiteGraph || (app ? app.LiteGraph : null);
|
||||
|
||||
let original_drawNodeWidgets = null;
|
||||
let isHighlightingActive = false;
|
||||
|
||||
/**
|
||||
* Applies the global patch to LGraphCanvas.drawNodeWidgets to enable highlighting.
|
||||
*/
|
||||
function applyWidgetHighlightPatch() {
|
||||
if (!LiteGraph || !LiteGraph.LGraphCanvas || !LiteGraph.LGraphCanvas.prototype?.drawNodeWidgets) {
|
||||
console.error("ControlFreak: Cannot apply widget highlight patch. LGraphCanvas.drawNodeWidgets not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (original_drawNodeWidgets) {
|
||||
console.warn("ControlFreak: Widget highlight patch already applied.");
|
||||
return; // Already patched
|
||||
}
|
||||
|
||||
original_drawNodeWidgets = LiteGraph.LGraphCanvas.prototype.drawNodeWidgets;
|
||||
|
||||
LiteGraph.LGraphCanvas.prototype.drawNodeWidgets = function(node) {
|
||||
// --- Draw Highlights FIRST (Behind Widgets) ---
|
||||
if (node.widgets && node.widgets.length > 0 && isHighlightingActive) {
|
||||
const mappingEngine = contextProvider.get('mappingEngine');
|
||||
if (mappingEngine) { // Check if mapping engine is available
|
||||
const ctx = this.ctx; // Get canvas context from LGraphCanvas instance
|
||||
try {
|
||||
const brandColor = getComputedStyle(document.documentElement).getPropertyValue('--cf-brand-primary').trim() || '#ff5722';
|
||||
const colorParts = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(brandColor);
|
||||
// Slightly more transparent for a background glow
|
||||
const widgetHighlightColor = colorParts
|
||||
? `rgba(${parseInt(colorParts[1], 16)}, ${parseInt(colorParts[2], 16)}, ${parseInt(colorParts[3], 16)}, 0.25)`
|
||||
: "rgba(255, 87, 34, 0.25)";
|
||||
|
||||
node.widgets.forEach(widget => {
|
||||
if (mappingEngine.isWidgetMapped(node.id, widget.name)) {
|
||||
if (typeof widget.last_y === 'number') {
|
||||
const widgetHeight = (widget.size && widget.size[1] > 0) ? widget.size[1] : (LiteGraph.NODE_WIDGET_HEIGHT || 20);
|
||||
const nodeWidth = node.size[0];
|
||||
const padding = 15;
|
||||
const widgetY = widget.last_y;
|
||||
const widgetX = padding / 2;
|
||||
const widgetWidth = nodeWidth - padding;
|
||||
|
||||
if (widgetWidth > 0 && widgetHeight > 0) {
|
||||
// Save context state
|
||||
ctx.save();
|
||||
ctx.fillStyle = widgetHighlightColor;
|
||||
ctx.beginPath();
|
||||
// Draw slightly larger rect behind the widget area
|
||||
const margin = 3; // How much larger the highlight is
|
||||
ctx.roundRect(widgetX - margin, widgetY - margin, widgetWidth + (margin * 2), widgetHeight + (margin * 2), 5); // Increased radius slightly
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`ControlFreak: Error drawing highlights for node ${node.id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Call the original function AFTER drawing highlights ---
|
||||
let result;
|
||||
try {
|
||||
result = original_drawNodeWidgets.apply(this, arguments);
|
||||
} catch (e) {
|
||||
console.error(`ControlFreak: Error in original drawNodeWidgets for node ${node.id}:`, e);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the global patch from LGraphCanvas.drawNodeWidgets.
|
||||
*/
|
||||
function removeWidgetHighlightPatch() {
|
||||
if (original_drawNodeWidgets && LiteGraph && LiteGraph.LGraphCanvas && LiteGraph.LGraphCanvas.prototype) {
|
||||
LiteGraph.LGraphCanvas.prototype.drawNodeWidgets = original_drawNodeWidgets;
|
||||
original_drawNodeWidgets = null;
|
||||
// Request redraw to remove highlights
|
||||
if(app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the widget highlighter system.
|
||||
* Applies the patch and sets up event listeners to trigger redraws.
|
||||
*/
|
||||
export function initWidgetHighlighter() {
|
||||
if (!LiteGraph) {
|
||||
console.error("ControlFreak: LiteGraph not available, cannot initialize widget highlighter.");
|
||||
return;
|
||||
}
|
||||
applyWidgetHighlightPatch();
|
||||
isHighlightingActive = true; // Activate highlighting
|
||||
|
||||
// Listen for events that change mappings and trigger redraw
|
||||
const redrawCanvas = () => {
|
||||
// Check if graph exists and has changed before forcing redraw
|
||||
if(app.graph) { // && app.graph.status !== LGraph.STATUS_STOPPED - add if needed
|
||||
app.graph.setDirtyCanvas(true, false); // Redraw only, don't recompute paths
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce redraw calls slightly to prevent excessive redraws during rapid changes
|
||||
let redrawTimeout = null;
|
||||
const debouncedRedraw = () => {
|
||||
if (redrawTimeout) clearTimeout(redrawTimeout);
|
||||
redrawTimeout = setTimeout(redrawCanvas, 50); // 50ms debounce
|
||||
};
|
||||
|
||||
eventBus.on('mapping:created', debouncedRedraw);
|
||||
eventBus.on('mapping:deleted', debouncedRedraw);
|
||||
eventBus.on('mappings:loaded', debouncedRedraw);
|
||||
eventBus.on('mappings:profileChanged', debouncedRedraw);
|
||||
// Also redraw if a mapping is updated (e.g., settings changed)
|
||||
eventBus.on('mapping:updated', debouncedRedraw);
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the widget highlighter system.
|
||||
* Removes the patch and potentially event listeners if needed.
|
||||
*/
|
||||
export function cleanupWidgetHighlighter() {
|
||||
removeWidgetHighlightPatch();
|
||||
isHighlightingActive = false;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "comfyui_controlfreak"
|
||||
description = "Map MIDI controllers, gamepads, and other devices to ComfyUI parameters"
|
||||
version = "0.0.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ryanontheinside/ComfyUI_ControlFreak"
|
||||
# Used by Comfy Registry https://comfyregistry.org
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "ryanontheinside"
|
||||
DisplayName = "ComfyUI_ControlFreak"
|
||||
Icon = "🎛️"
|
||||
Reference in New Issue
Block a user