Initial commit

This commit is contained in:
ryanontheinside
2025-04-06 12:54:48 -04:00
commit c1602c86d6
47 changed files with 9332 additions and 0 deletions
+24
View File
@@ -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 }}
+7
View File
@@ -0,0 +1,7 @@
*.pyc
*.pyo
*.pyd
*.pyw
*.pyz
*.pywz
+21
View File
@@ -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.
+256
View File
@@ -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
View File
@@ -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

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

+307
View File
@@ -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
+357
View File
@@ -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;
}
}
+315
View File
@@ -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'
}));
}
}
+167
View File
@@ -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;
}
}
+67
View File
@@ -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();
+111
View File
@@ -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();
+589
View File
@@ -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
};
+256
View File
@@ -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
+501
View File
@@ -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
+99
View File
@@ -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;
}
+392
View File
@@ -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;
}
+159
View File
@@ -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;
}
}
}
+298
View File
@@ -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);
}
}
+253
View File
@@ -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);
}
}
}
}
+61
View File
@@ -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
View File
@@ -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);
}
}
});
+250
View File
@@ -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!");
});
}
*/
})();
+323
View File
@@ -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);
}
}
+158
View File
@@ -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');
});
}
+291
View File
@@ -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);
}
+517
View File
@@ -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 };
+653
View File
@@ -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;
}
+157
View File
@@ -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);
}
}
+239
View File
@@ -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;
}
+85
View File
@@ -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
}
});
}
+83
View File
@@ -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 };
+152
View File
@@ -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);
}
+19
View File
@@ -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 */
}
+75
View File
@@ -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);
}
}
+421
View File
@@ -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);
}
+24
View File
@@ -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');
+361
View File
@@ -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);
}
+32
View File
@@ -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);
}
+104
View File
@@ -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);
}
}
+656
View File
@@ -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 */
}
+54
View File
@@ -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;
}
+143
View File
@@ -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;
}
+15
View File
@@ -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 = "🎛️"