commit c1602c86d6d52fe56b88adad0ec0c2913a06ed69
Author: ryanontheinside <7623207+ryanontheinside@users.noreply.github.com>
Date: Sun Apr 6 12:54:48 2025 -0400
Initial commit
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..4f551b4
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -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 }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c189c65
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+*.pyc
+*.pyo
+*.pyd
+*.pyw
+*.pyz
+*.pywz
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..03843ed
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d84f4a9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,256 @@
+# 🎮 Control Freak for ComfyUI
+
+
+
+
+
+
+
+
+> *__Take control of your workflows__ - Universal MIDI & Gamepad mapping for ComfyUI*
+
+Control Freak 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!
+
+
+ Adjust sliders, toggle parameters, and execute commands with physical controllers
+
+
+
+
+
+
+
+## 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)
+
+
+## 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 orange 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):**
+
+
+
+
+
+- **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 Freak will display a subtle orange background glow for easy identification.
+
+#### To UI Commands
+
+You can also map controller inputs to execute core ComfyUI actions.
+
+
+
+
+
+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:
+
+
+
+
+
+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.
+
+
\ No newline at end of file
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..aed39ef
--- /dev/null
+++ b/__init__.py
@@ -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"
+
diff --git a/assets/learning.png b/assets/learning.png
new file mode 100644
index 0000000..925897e
Binary files /dev/null and b/assets/learning.png differ
diff --git a/assets/main.png b/assets/main.png
new file mode 100644
index 0000000..991e851
Binary files /dev/null and b/assets/main.png differ
diff --git a/assets/mappings.png b/assets/mappings.png
new file mode 100644
index 0000000..698a412
Binary files /dev/null and b/assets/mappings.png differ
diff --git a/assets/uicommands.png b/assets/uicommands.png
new file mode 100644
index 0000000..a9b298b
Binary files /dev/null and b/assets/uicommands.png differ
diff --git a/js/controllers/README.md b/js/controllers/README.md
new file mode 100644
index 0000000..47a4ddf
--- /dev/null
+++ b/js/controllers/README.md
@@ -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} - 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} - 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
\ No newline at end of file
diff --git a/js/controllers/gamepad.js b/js/controllers/gamepad.js
new file mode 100644
index 0000000..8c255b6
--- /dev/null
+++ b/js/controllers/gamepad.js
@@ -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} - 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} - 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;
+ }
+}
+
diff --git a/js/controllers/midi.js b/js/controllers/midi.js
new file mode 100644
index 0000000..9921445
--- /dev/null
+++ b/js/controllers/midi.js
@@ -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} - 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} - 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'
+ }));
+ }
+}
diff --git a/js/core/commandExecutor.js b/js/core/commandExecutor.js
new file mode 100644
index 0000000..708513d
--- /dev/null
+++ b/js/core/commandExecutor.js
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/js/core/contextProvider.js b/js/core/contextProvider.js
new file mode 100644
index 0000000..0ff6a22
--- /dev/null
+++ b/js/core/contextProvider.js
@@ -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} - 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();
\ No newline at end of file
diff --git a/js/core/eventBus.js b/js/core/eventBus.js
new file mode 100644
index 0000000..b464230
--- /dev/null
+++ b/js/core/eventBus.js
@@ -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} - 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();
\ No newline at end of file
diff --git a/js/core/learningManager.js b/js/core/learningManager.js
new file mode 100644
index 0000000..808930c
--- /dev/null
+++ b/js/core/learningManager.js
@@ -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 = `${controlTypeIndicator} ${displayName}: ${rawValue.toFixed(2)}`;
+ // 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
+};
\ No newline at end of file
diff --git a/js/core/learningStateManager.js b/js/core/learningStateManager.js
new file mode 100644
index 0000000..0724317
--- /dev/null
+++ b/js/core/learningStateManager.js
@@ -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
\ No newline at end of file
diff --git a/js/core/mappingEngine.js b/js/core/mappingEngine.js
new file mode 100644
index 0000000..7568f41
--- /dev/null
+++ b/js/core/mappingEngine.js
@@ -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
\ No newline at end of file
diff --git a/js/core/mappingManager.js b/js/core/mappingManager.js
new file mode 100644
index 0000000..6416487
--- /dev/null
+++ b/js/core/mappingManager.js
@@ -0,0 +1,99 @@
+// Core API functions for controller mapping
+
+/**
+ * Fetches available controller types
+ * @returns {Promise} 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