Update the Inspector panel look, navigation and editing

Restyle the panel on ComfyUI palette tokens and drop the built-in themes.
Add a header with Back/Forward, Prev/Next, Focus, Lock and a searchable
Go to node picker, a subgraph/group breadcrumb, and simplified modes:
Follow glass or Float, plus Sticky, Follow selection and Lock to node
(replaces Hold).

Fix widget edits (step2 steps, INT/FLOAT, commit on Enter/blur, undo),
subgraph lookups, orphaned dropdown menus, global arrow-key capture,
the pop-out editor, and remove Google Fonts loading and dead code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Æmotion Studio
2026-11-04 06:47:21 -08:00
co-authored by Claude Opus 5.5
parent 6799dc8fbb
commit 4b06ee92bb
72 changed files with 6060 additions and 10550 deletions
+29
View File
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.15.0] - 2026-11-04
### Changed
- **Inspector look**: The panel, its toolbar and its menus follow your ComfyUI palette (any palette, light or dark) with a flat, denser layout. Text and Accent Color override the palette only when changed from their defaults. Max Height and Opacity now apply in every mode.
- **Header and navigation**: The header shows the node's title, `#id` and type, with Lock and Minimize (double-click the header to minimize). Below it: Back/Forward, Previous/Next in execution order, Centre on node, and a searchable **Go to node** picker (title, type or `#id`) that replaces the three node lists. A breadcrumb shows the subgraph path and group, with "Enter subgraph" for subgraph nodes. Every jump centres the canvas and locks the panel to the node.
- **Simpler modes**: The panel either follows the glass or floats. Dragging the header floats it, and its position and minimized state are kept per browser. A corner handle sets Width and Max Height. **Sticky** (S) keeps the last node, **Lock** keeps one node while you hover others, and the new **Follow Selection** setting shows the selected node.
- **Editing**: Widgets are editable while Sticky, Locked or following the selection; otherwise rows offer "Lock to edit". Number steps match the canvas (Shift ×10, Alt ×0.1), text commits on Enter or blur and Escape reverts, prompts get a multiline editor (Ctrl+Enter), and a drag is one undo step. Linked widgets are read-only.
- **Copy**: Every row can be copied, the header copies `Title #id`, and copying works on plain-http LAN addresses.
- **Toolbar**: A fixed set of grouped buttons; ones that can't act are disabled. All eight Controls Positions work, and the toolbar flips sides instead of covering the panel.
- **Hotkeys**: Panel keys act only while the glass is on and never with Ctrl or Meta. `*` and ←/→ act only while the panel is hovered or focused, so the arrow keys nudge the glass otherwise. Rebinding applies at once.
- Max Height now defaults to 600px.
### Fixed
- **Subgraphs**: The panel, picker, highlight and editing use the graph on screen, so they work inside subgraphs. Title bars and collapsed nodes are hit.
- **Widget edits** run the widget's callback, redraw the canvas and record undo. CLIPLoader's `type` widget shows again.
- Dropdowns no longer get orphaned or keep writing after the panel re-renders, and they can be filtered by typing.
- The panel re-renders only when the shown node changes, so hover, focus and text selection survive.
- Saved fonts no longer reset on reload, and High Contrast Text applies on load.
- Node modes read Mute and Bypass instead of Never and On Request.
- The pop-out editor keeps newlines, handles widget names with quotes, and Escape no longer closes the window.
### Removed
- The built-in panel themes and their palette detection.
- Hold (P); use Lock.
- Loading fonts from Google Fonts. Font Family uses fonts installed locally.
- The Inspector and Media sections, the Show Inspector Tab setting, the guessed Model/Image/Text rows, and the unused Animations setting.
---
## [1.14.0] - 2026-11-03
### Fixed
+22 -18
View File
@@ -5,7 +5,7 @@
**A powerful, customizable magnifying glass extension for ComfyUI.**
[![ComfyUI](https://img.shields.io/badge/ComfyUI-Extension-green?style=for-the-badge)](https://github.com/comfyanonymous/ComfyUI)
[![Version](https://img.shields.io/badge/Version-1.14.0-orange?style=for-the-badge)](https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass/releases)
[![Version](https://img.shields.io/badge/Version-1.15.0-orange?style=for-the-badge)](https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass/releases)
[![License](https://img.shields.io/badge/License-GPLv3-red?style=for-the-badge)](LICENSE)
[![Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen?style=for-the-badge&color=blue)](package.json)
@@ -25,14 +25,14 @@
---
## 🚀 What's New in v1.14.0 (November 3, 2026)
## 🚀 What's New in v1.15.0 (November 4, 2026)
**Images and Videos in the Glass, Glass-Only Mode, New Sidebar**
**Inspector Overhaul: New Look, Navigation and Modes**
* **🖼️ Media in the Glass**: Image previews, videos and animated images now show in the glass at their real position, at any canvas zoom. Images are drawn at full resolution, videos play smoothly, and both reach the pop-out and the Invert/Grayscale filters.
* **🔍 Glass-Only Mode**: Turn off **Inspector panel** at the top of the sidebar to remove the panel, its hotkeys and its highlight entirely. Turn it back on without a reload.
* **🎨 Sidebar Redesign**: Follows your ComfyUI palette (light or dark), with an About section for links, version and the studio's other packs.
* **🧩 Nodes 2.0**: Basic support for the Vue nodes mode.
* **🎨 Follows Your Palette**: The Inspector, its toolbar and menus use your ComfyUI palette, light or dark. The built-in themes are gone.
* **🧭 Header and Go to Node**: Title, `#id` and type in the header, Back/Forward, Previous/Next in execution order, and a searchable node picker. Subgraph path and group show as a breadcrumb.
* **📌 Simpler Modes**: Follow the glass or drag the header to float. **Sticky**, **Lock** and the new **Follow Selection** decide which node is shown. Hold is replaced by Lock.
* **✏️ Reliable Editing**: Canvas-matching number steps, commit on Enter, revert on Escape, multiline prompts, undo, and subgraph support.
> 📄 **See [CHANGELOG.md](CHANGELOG.md) for the complete version history.**
@@ -61,9 +61,9 @@
### ℹ️ Inspector Panel
![Inspector Panel](https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass/releases/download/assets-v1/MagnifyGlass_InspectorPanel_v1.webp)
* **Deep Analysis**: View node parameters (Seed, CFG, Steps), text content, and image details on hover.
* **Dockable Interface**: Pin the panel to keep it stable, or let it follow the glass.
* **Themed**: Automatically matches any ComfyUI theme (Dark, Light, Solarized, Arc, Nord, GitHub).
* **Live Widgets**: Every widget of the hovered node, editable while Sticky, Locked or following the selection, with copy on every row.
* **Navigation**: Go to any node by title, type or `#id`, step through execution order, and go Back/Forward through your jumps.
* **Follows Your Palette**: Matches any ComfyUI palette. Follow the glass or drag the header to float and resize it.
</td>
</tr>
@@ -134,10 +134,11 @@ git clone https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass.git
| **`I`** | Toggle Inspector Panel Visibility |
| **`G`** | Toggle Glass Preview (with the Inspector panel on, keeps the panel and hides the glass) |
| **`Shift+P`** | Open Pop-Out Viewer in New Tab |
| **`U`** | Pin/Unpin Inspector Panel |
| **`U`** | Inspector: Follow the Glass / Float |
| **`S`** | Inspector: Toggle Sticky |
| **`O`** | Reset Offsets |
| **`*`** | **Focus Current Node** (Center canvas on inspected node) |
| **`Left/Right`** | Navigate Previous/Next node in execution order |
| **`*`** | **Centre on Node** (centres the canvas and locks the Inspector to it) |
| **`Left/Right`** | Previous/Next node in execution order (while the Inspector is hovered or focused) |
| **`D`** | Force Direct Capture (Optional after v1.11.0 improvements) |
| **Arrows** | Nudge Glass Position (When not navigating nodes) |
@@ -177,19 +178,22 @@ Access settings via the **new Sidebar Panel** or the ComfyUI Settings (⚙️) m
| Setting | Default | Description |
| :--- | :--- | :--- |
| **Inspector panel** | `On` | Off leaves only the glass: the panel, its hotkeys (`I`, `U`, `S`, `*`) and its highlight are removed. |
| **Persist Info** | `Off` | Keep last node info visible when not hovering a node. |
| **Sticky** | `Off` | Keep the last node shown when not hovering a node. |
| **Follow Selection** | `Off` | Show the selected node instead of the hovered one. |
| **Node Highlight** | `On` | Show high-contrast border around inspected node. |
| **Position** | `Bottom` | Position relative to the glass (Top/Bottom/Left/Right). |
| **Width** | `300px` | Width of the inspector panel. |
| **Max Height** | `300px` | Maximum vertical size before scrolling. |
| **Max Height** | `600px` | Maximum height before the panel scrolls. |
| **Font Size** | `14px` | Size of the text in the inspector. |
| **Opacity** | `100%` | Background transparency of the panel. |
| **Font Family** | `System` | Custom font selection for the panel. |
| **Text Color** | `#6B7280` | Custom text color for node data. |
| **Accent Color** | `#3B82F6` | Color for headers and highlights. |
| **Font Family** | `System` | Font for the panel; only fonts installed locally are used. |
| **Text Color** | `#6B7280` | Overrides the palette's text color when changed from the default. |
| **Accent Color** | `#3B82F6` | Overrides the palette's accent color when changed from the default. |
| **Hover Controls** | `On` | Show floating quick-action buttons. |
| **Controls Position** | `Left` | Anchor point for the floating controls. |
> Drag the panel's header to float it, and its corner to set Width and Max Height. **Lock** in the header keeps one node shown while you hover others.
>
> **Move Glass Position** (drag-glass mode) is one of the panel's floating controls, so it is unavailable while the Inspector panel is off. Use Follow Cursor (`H`) or the arrow keys instead.
</details>
+627 -1441
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-magnifyglass",
"version": "1.14.0",
"version": "1.15.0",
"type": "module",
"description": "A professional magnifying glass and info panel for ComfyUI",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-magnifyglass"
description = "ComfyUI-MagnifyGlass: A powerful & customizable magnifying glass for ComfyUI. Zoom into canvas details with smooth controls, configurable activation, custom styles (shape, size, border) & WebGL performance."
version = "1.14.0"
version = "1.15.0"
license = {file = "LICENSE"}
[project.urls]
+12 -41
View File
@@ -6,7 +6,6 @@
*/
import { ComfyApp, ComfyNode } from '../types/comfyui';
import { getSettingValue } from '../shared/utils';
declare const app: ComfyApp;
@@ -17,8 +16,11 @@ export class CanvasHighlighter {
private highlightEl: HTMLDivElement | null = null;
// Cache for DOM updates to minimize thrashing
private lastStyleState: string = '';
// The panel's settings cache, which the setting's onChange keeps current
private settings: { [key: string]: string | number | boolean };
constructor() {
constructor(settings: { [key: string]: string | number | boolean }) {
this.settings = settings;
this.createHighlightElement();
// Don't hook immediately, will hook lazily when needed
}
@@ -26,42 +28,10 @@ export class CanvasHighlighter {
private createHighlightElement(): void {
this.highlightEl = document.createElement("div");
this.highlightEl.id = "magnify-glass-node-highlight";
this.highlightEl.style.cssText = `
position: fixed;
pointer-events: none;
border: 2px solid #007bff;
border-radius: 10px;
z-index: 1000;
display: none;
box-sizing: border-box;
box-shadow: 0 0 10px rgba(0, 123, 255, 0.3);
`;
this.highlightEl.style.display = 'none';
document.body.appendChild(this.highlightEl);
}
/**
* Hook into the main canvas onDrawForeground method.
*/
private hookCanvas(): boolean {
const app = (window as any).app;
if (!app || !app.canvas) {
return false;
}
const canvas = app.canvas;
// Already hooked?
if (canvas.onDrawForeground === this.boundOnDrawForeground) {
return true;
}
// Save original method
this.originalOnDrawForeground = canvas.onDrawForeground;
// Override
canvas.onDrawForeground = this.boundOnDrawForeground;
return true;
}
/**
* Set the node ID to highlight.
*/
@@ -114,11 +84,10 @@ export class CanvasHighlighter {
private updateHighlightPosition(): void {
if (!this.highlightEl) return;
const app = (window as any).app;
if (!app || !app.canvas || !app.graph) return;
if (!app?.canvas?.graph) return;
// 1. Check Visibility Settings
const highlightEnabled = getSettingValue('🔍MagnifyGlass.NodeHighlightEnabled', true);
if (!highlightEnabled || this.highlightedNodeId === null) {
if (!this.settings["🔍MagnifyGlass.NodeHighlightEnabled"] || this.highlightedNodeId === null) {
if (this.highlightEl.style.display !== 'none') {
this.highlightEl.style.display = 'none';
}
@@ -126,7 +95,7 @@ export class CanvasHighlighter {
}
// 2. Get Node
const node = app.graph.getNodeById(this.highlightedNodeId);
const node = app.canvas.graph.getNodeById(this.highlightedNodeId);
if (!node) {
if (this.highlightEl.style.display !== 'none') {
this.highlightEl.style.display = 'none';
@@ -169,14 +138,16 @@ export class CanvasHighlighter {
const finalW = canvasW + (padding * 2);
const finalH = canvasH + (padding * 2);
// 5. Update DOM
const styleState = `${pageX.toFixed(1)},${pageY.toFixed(1)},${finalW.toFixed(1)},${finalH.toFixed(1)}`;
// 5. Update DOM, clipped to the canvas element (negative insets keep the glow inside it)
const clip = `inset(${canvasRect.top - pageY}px ${pageX + finalW - canvasRect.right}px ${pageY + finalH - canvasRect.bottom}px ${canvasRect.left - pageX}px)`;
const styleState = `${pageX.toFixed(1)},${pageY.toFixed(1)},${finalW.toFixed(1)},${finalH.toFixed(1)},${clip}`;
if (this.lastStyleState !== styleState || this.highlightEl.style.display === 'none') {
this.highlightEl.style.display = 'block';
this.highlightEl.style.left = `${pageX}px`;
this.highlightEl.style.top = `${pageY}px`;
this.highlightEl.style.width = `${finalW}px`;
this.highlightEl.style.height = `${finalH}px`;
this.highlightEl.style.clipPath = clip;
// Border width scales with zoom (clamped 2-6px)
const borderWidth = Math.max(2, Math.min(6, 3 * scale));
+76 -176
View File
@@ -6,15 +6,16 @@
*/
import { StateManager } from './StateManager';
import { UIManager } from './UIManager';
import { PositionManager } from './PositionManager';
import { isUserTyping } from '../shared/utils';
import { Logger } from '../shared/logger';
import { isUserTyping, getSettingValue, setSettingValue, clamp } from '../shared/utils';
import { DEFAULT_PANEL_SETTINGS, DEFAULT_GLASS_SETTINGS } from '../shared/settings/defaults';
export interface EventCallbacks {
toggleVisibility: () => void;
togglePin: () => void;
updatePosition: () => void;
toggleFloat: () => void;
toggleSticky: () => void;
/** Apply a dragged position or size: styles, toolbar states and positions */
updateLayout: () => void;
isGlassActive: () => boolean;
}
/**
@@ -24,155 +25,99 @@ export interface EventCallbacks {
export class EventManager {
stateManager: StateManager;
panelElement: HTMLElement | null;
positionManager: PositionManager;
callbacks: EventCallbacks | null;
dragCleanup: (() => void) | null;
boundHandleMouseMove: (e: MouseEvent) => void;
boundHandleKeyDown: (e: KeyboardEvent) => void;
boundHandleKeyUp: (e: KeyboardEvent) => void;
constructor(stateManager: StateManager, panelElement: HTMLElement | null, positionManager: PositionManager, callbacks?: EventCallbacks) {
constructor(stateManager: StateManager, panelElement: HTMLElement | null, callbacks?: EventCallbacks) {
this.stateManager = stateManager;
this.panelElement = panelElement;
this.positionManager = positionManager;
this.callbacks = callbacks || null;
this.dragCleanup = null;
this.boundHandleMouseMove = this.handleMouseMove.bind(this);
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.boundHandleKeyUp = this.handleKeyUp.bind(this);
this.setupEventListeners();
}
setupEventListeners(): void {
this.setupPanelEvents();
this.setupHotkeyEvents();
this.setupDragEvents();
this.setupHoverEvents();
}
setupPanelEvents(): void {
if (!this.panelElement) return;
// Vue handles most internal events now.
// We can keep specific global-ish listeners if needed here.
}
setupHotkeyEvents(): void {
document.addEventListener('keydown', this.boundHandleKeyDown);
}
/**
* Dragging the header floats the panel; the corner handle resizes it into the Width and Max Height settings.
* A press that does not move is a click, so it leaves the panel where it is.
*/
setupDragEvents(): void {
if (!this.panelElement) return;
const panel = this.panelElement;
if (!panel) return;
let isDragging = false;
let rafId: number | null = null;
let targetX = 0;
let targetY = 0;
const startDrag = (e: MouseEvent) => {
if (!this.panelElement) return;
const startDrag = (e: PointerEvent) => {
const target = e.target as HTMLElement;
// Only allow dragging from header (expand clickable area)
const header = target.closest('.panel-header');
if (!header) return;
if (target.closest('button')) return;
// Button 2 (pin icon, data-action="lock") prevents dragging
if (this.stateManager.state.isPanelLocked) return;
// Also prevent dragging if we are "Following Glass" (not pinned to screen)
// This prevents fighting with the auto-positioner and accidental drags
if (!this.stateManager.state.isPanelPinned) return;
const resizing = !!target.closest('.panel-resize');
if (e.button !== 0 || (!resizing && (!target.closest('.panel-header') || target.closest('button')))) return;
e.preventDefault();
e.stopPropagation();
isDragging = true;
const state = this.stateManager.state;
const settings = state.settings;
const startX = e.clientX;
const startY = e.clientY;
const rect = this.panelElement.getBoundingClientRect();
const startLeft = rect.left;
const startTop = rect.top;
const rect = panel.getBoundingClientRect();
const content = panel.querySelector('.panel-content') as HTMLElement;
const startContentHeight = content.offsetHeight;
// Max Height shows only while the content is at it
const capped = content.scrollHeight > startContentHeight;
const startWidth = settings["🔍MagnifyGlass.InfoPanelWidth"];
const startMaxHeight = settings["🔍MagnifyGlass.InfoPanelMaxHeight"];
let moved = false;
// Visual feedback - dragging state
this.panelElement.style.cursor = 'grabbing';
this.panelElement.style.opacity = '0.9';
this.panelElement.style.transition = 'none'; // Disable transitions during drag
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
const onPointerMove = (move: PointerEvent) => {
const dx = move.clientX - startX;
const dy = move.clientY - startY;
if (!moved && Math.abs(dx) + Math.abs(dy) < 3) return;
moved = true;
if (resizing) {
// An axis that moves less than a drag keeps its setting, so a sideways drag keeps Max Height
settings["🔍MagnifyGlass.InfoPanelWidth"] = Math.abs(dx) >= 3 ? clamp(Math.round(rect.width + dx), 200, 600) : startWidth;
settings["🔍MagnifyGlass.InfoPanelMaxHeight"] = capped && Math.abs(dy) >= 3 ? clamp(Math.round(startContentHeight + dy), 200, 1500) : startMaxHeight;
} else {
state.isFloating = true;
state.floatPosition = { x: rect.left + dx, y: rect.top + dy };
}
this.callbacks?.updateLayout();
};
const onMouseMove = (moveEvent: MouseEvent) => {
if (!isDragging) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
targetX = startLeft + dx;
targetY = startTop + dy;
// Keep within viewport bounds
const panelWidth = this.panelElement?.offsetWidth || 0;
const panelHeight = this.panelElement?.offsetHeight || 0;
targetX = Math.max(0, Math.min(targetX, window.innerWidth - panelWidth));
targetY = Math.max(0, Math.min(targetY, window.innerHeight - panelHeight));
// Use RAF for smooth updates
if (rafId === null) {
rafId = requestAnimationFrame(() => {
if (this.panelElement && isDragging) {
this.panelElement.style.left = `${targetX}px`;
this.panelElement.style.top = `${targetY}px`;
}
rafId = null;
});
const onPointerUp = () => {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
panel.classList.remove('is-dragging');
if (!moved) return;
if (resizing) {
for (const [id, start] of [["🔍MagnifyGlass.InfoPanelWidth", startWidth], ["🔍MagnifyGlass.InfoPanelMaxHeight", startMaxHeight]] as const) {
if (settings[id] !== start) setSettingValue(id, settings[id]);
}
} else {
this.stateManager.saveLayout();
}
};
const onMouseUp = () => {
isDragging = false;
// Cancel any pending RAF
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Restore visual state
if (this.panelElement) {
this.panelElement.style.cursor = '';
this.panelElement.style.opacity = '';
this.panelElement.style.transition = '';
// Final position update
this.panelElement.style.left = `${targetX}px`;
this.panelElement.style.top = `${targetY}px`;
// Save pinned position
this.stateManager.state.pinnedPosition = { x: targetX, y: targetY };
}
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
panel.classList.add('is-dragging');
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
};
this.panelElement.addEventListener('mousedown', startDrag);
this.dragCleanup = () => {
if (this.panelElement) this.panelElement.removeEventListener('mousedown', startDrag);
};
panel.addEventListener('pointerdown', startDrag);
this.dragCleanup = () => panel.removeEventListener('pointerdown', startDrag);
}
setupHoverEvents(): void {
@@ -180,88 +125,43 @@ export class EventManager {
this.panelElement.addEventListener('mouseenter', () => {
this.stateManager.state.isPanelHovered = true;
this.stateManager.clearAutoExpandTimer();
});
this.panelElement.addEventListener('mouseleave', () => {
this.stateManager.state.isPanelHovered = false;
if (!this.stateManager.state.isHoveringNode) {
this.stateManager.scheduleAutoCollapse();
}
});
}
/**
* Panel hotkeys act only while the glass is active and never with Ctrl, Meta or Shift held. Alt must be
* held when AltRequired is on and must not be otherwise. Keys are read on each press, so changes apply at once.
*/
handleKeyDown(e: KeyboardEvent): void {
if (isUserTyping()) return;
if (isUserTyping() || !this.callbacks?.isGlassActive()) return;
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
const altRequired = getSettingValue("🔍MagnifyGlass.AltRequired", DEFAULT_GLASS_SETTINGS["🔍MagnifyGlass.AltRequired"]);
if (e.altKey !== altRequired) return;
const settings = this.stateManager.state.settings;
const key = e.key.toLowerCase();
// console.log(`[InfoPanel] KeyDown: ${key}`);
const hotkey = (id: keyof typeof DEFAULT_PANEL_SETTINGS & string) => String(getSettingValue(id, DEFAULT_PANEL_SETTINGS[id])).toLowerCase();
// Toggle visibility
if (key === (settings["🔍MagnifyGlass.ToggleHotkey"] as string).toLowerCase()) {
Logger.debug('Toggle visibility hotkey matched!');
if (key === hotkey("🔍MagnifyGlass.ToggleHotkey")) {
e.preventDefault();
this.callbacks?.toggleVisibility();
this.callbacks.toggleVisibility();
}
// Toggle Pin
if (key === (settings["🔍MagnifyGlass.PinPanelHotkey"] as string).toLowerCase()) {
// Follow glass / Float
if (key === hotkey("🔍MagnifyGlass.PinPanelHotkey")) {
e.preventDefault();
this.callbacks?.togglePin();
this.callbacks.toggleFloat();
}
// Toggle Hold Info (Pause/Play)
const holdInfoHotkey = (settings["🔍MagnifyGlass.HoldInfoHotkey"] as string || "p").toLowerCase();
if (key === holdInfoHotkey) {
// Only works when Sticky Info (Persist) is enabled
if (settings["🔍MagnifyGlass.InfoPanelPersist"]) {
e.preventDefault();
const isHeld = this.stateManager.toggleHold();
Logger.debug(`Hold Info toggled: ${isHeld ? 'PAUSED' : 'PLAYING'}`);
// Update the hover controls UI
const infoPanel = (window as any).infoPanelManager;
if (infoPanel?.uiManager) {
infoPanel.uiManager.updateControlStates();
}
}
}
// Toggle Sticky Info (Persist mode)
const stickyInfoHotkey = (settings["🔍MagnifyGlass.StickyInfoHotkey"] as string || "s").toLowerCase();
if (key === stickyInfoHotkey) {
if (key === hotkey("🔍MagnifyGlass.StickyInfoHotkey")) {
e.preventDefault();
const newValue = !(settings["🔍MagnifyGlass.InfoPanelPersist"] as boolean);
// Update local state
settings["🔍MagnifyGlass.InfoPanelPersist"] = newValue;
// Persist to ComfyUI settings
try {
const app = (window as any).app;
app.ui.settings.setSettingValue("🔍MagnifyGlass.InfoPanelPersist", newValue);
} catch (err) {
Logger.debug('Failed to persist sticky info setting');
}
// If disabling sticky, also disable hold
if (!newValue) {
this.stateManager.state.isInfoHeld = false;
}
Logger.debug(`Sticky Info toggled: ${newValue ? 'ON' : 'OFF'}`);
// Update the hover controls UI
const infoPanel = (window as any).infoPanelManager;
if (infoPanel?.uiManager) {
infoPanel.uiManager.updateControlStates();
}
this.callbacks.toggleSticky();
}
}
handleMouseMove(e: MouseEvent): void {
// Placeholder
}
handleKeyUp(e: KeyboardEvent): void {
// Placeholder
}
cleanup(): void {
document.removeEventListener('keydown', this.boundHandleKeyDown);
if (this.dragCleanup) this.dragCleanup();
+73 -117
View File
@@ -30,6 +30,7 @@ export class InfoPanel {
private originalShow: () => void;
private originalHide: () => void;
private updateFrameId: number | null = null;
private boundScheduleSelectionUpdate: () => void;
constructor(magnifyGlass: MagnifyGlassInstance) {
this.magnifyGlass = magnifyGlass;
@@ -38,13 +39,13 @@ export class InfoPanel {
this.stateManager = new StateManager();
this.uiManager = new UIManager(this.stateManager);
this.informationGatherer = new InformationGatherer();
this.canvasHighlighter = new CanvasHighlighter();
this.canvasHighlighter = new CanvasHighlighter(this.stateManager.state.settings);
// Position Manager needs the UI elements
this.positionManager = new PositionManager(this.stateManager, this.uiManager.elements.panel!);
// Event Manager needs state and UI manager
this.eventManager = new EventManager(this.stateManager, this.uiManager.elements.panel!, this.positionManager, {
this.eventManager = new EventManager(this.stateManager, this.uiManager.elements.panel!, {
toggleVisibility: () => {
if (this.stateManager.state.isPanelVisible) {
this.uiManager.hide();
@@ -52,13 +53,26 @@ export class InfoPanel {
this.uiManager.show();
}
},
togglePin: () => {
this.stateManager.togglePinning();
this.uiManager.updatePinnedState();
toggleFloat: () => this.uiManager.toggleFloating(),
toggleSticky: () => this.uiManager.setPersist(!this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]),
updateLayout: () => {
this.uiManager.applyStyles();
this.uiManager.updateControlStates();
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
},
updatePosition: () => this.positionManager.positionPanel()
isGlassActive: () => this.magnifyGlass.state.active
});
// A click or key can change the selection without moving the mouse, which drives the other updates
this.boundScheduleSelectionUpdate = () => {
if (this.magnifyGlass.state.active && this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelFollowSelection"]) {
this.scheduleInfoUpdate();
}
};
document.addEventListener('pointerup', this.boundScheduleSelectionUpdate, true);
document.addEventListener('keyup', this.boundScheduleSelectionUpdate, true);
this.originalUpdateMagnifiedView = magnifyGlass.updateMagnifiedView;
this.originalShow = magnifyGlass.ui.show;
this.originalHide = magnifyGlass.ui.hide;
@@ -69,21 +83,19 @@ export class InfoPanel {
this.magnifyGlass.popOutManager.onStateChange = () => {
this.uiManager.updateControlStates();
};
// Listen for node selection from popout
// A node picked in the pop-out is a jump like any other
this.magnifyGlass.popOutManager.onNodeSelect = (nodeId: number) => {
this.onNodeSelected(nodeId);
this.uiManager.jumpToNode(nodeId);
};
// Listen for widget edits from popout to force refresh
this.magnifyGlass.popOutManager.onWidgetEdit = () => {
// Force update to reflect changes (e.g. unhidden widgets)
this.updateInfo(true);
// Reflect the edit (e.g. unhidden widgets)
this.updateInfo();
};
}
// Set up node selection callback
this.uiManager.onNodeSelected = (nodeId: number) => {
this.onNodeSelected(nodeId);
};
// Jumps, the lock and the mode buttons show their result at once
this.uiManager.onRefresh = () => this.updateInfo();
Logger.debug('Info Panel initialized successfully');
}
@@ -142,14 +154,6 @@ export class InfoPanel {
this.originalHide.call(mg.ui);
this.uiManager.hide();
// FORCE HIDE: Directly set panel display to none as a safety measure
if (this.uiManager.elements.panel) {
this.uiManager.elements.panel.style.display = 'none';
this.uiManager.elements.panel.style.pointerEvents = 'none';
// Also force opacity 0 to prevent any visual ghost
this.uiManager.elements.panel.style.opacity = '0';
}
// Explicitly hide floating controls when glass is disabled
if (this.uiManager.elements.controls) {
this.uiManager.elements.controls.style.display = 'none';
@@ -190,21 +194,35 @@ export class InfoPanel {
cancelAnimationFrame(this.updateFrameId);
this.updateFrameId = null;
}
document.removeEventListener('pointerup', this.boundScheduleSelectionUpdate, true);
document.removeEventListener('keyup', this.boundScheduleSelectionUpdate, true);
this.eventManager.cleanup();
this.uiManager.cleanup();
this.canvasHighlighter.cleanup();
this.stateManager.cleanup();
}
// State for persistence
private lastValidNodeInfo: any = null;
/**
* Follow the glass again from where it starts.
*/
resetPosition(): void {
// A hidden glass cannot be followed
if (!this.stateManager.state.isGlassPreviewVisible) return;
this.stateManager.state.isFloating = false;
this.stateManager.state.floatPosition = null;
this.stateManager.saveLayout();
this.positionManager.positionPanel();
this.uiManager.updateControlStates();
}
// The node Sticky keeps, looked up again on each update like the locked one
private stickyNodeId: number | null = null;
private stickyGraph: unknown = null;
/**
* Update the info panel with current information.
* @param forceUpdate - If true, bypass the isInfoHeld check to force an update
*/
updateInfo(forceUpdate: boolean = false): void {
updateInfo(): void {
const settings = this.stateManager.state.settings;
const isActive = this.magnifyGlass.state.active;
@@ -219,61 +237,41 @@ export class InfoPanel {
return;
}
// Logic for "Hold Info" (Freeze)
// Skip if held, UNLESS we're forcing an update (e.g., from dropdown selection)
if (this.stateManager.state.isInfoHeld && !forceUpdate) {
// If held, we skip gathering new info and skip updating the display content.
// But we MUST still update positioning to keep the UI responsive to movement.
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
return;
}
let info = this.informationGatherer.gatherInformation();
const state = this.stateManager.state;
// AUTO-CLEAR SELECTION: If user hovers a different node, clear the manual selection
// This allows the dropdown selection to be a "jump to" action, then resume normal hover
const selectedNodeId = this.stateManager.state.selectedNodeId;
if (selectedNodeId !== null && info.hoveredNode && info.hoveredNode.id !== selectedNodeId) {
// User is hovering a different node than the selected one - clear selection
this.stateManager.clearSelectedNode();
// The locked node is shown whatever is hovered, then the first selected node when following the selection.
// The lock goes when its node does, or when another graph is on screen, where its id is another node.
if (state.lockedNodeId !== null && state.lockedGraph !== app.canvas?.graph) {
state.lockedNodeId = null;
}
const lockedNode = state.lockedNodeId === null ? null : this.uiManager.nodeSelector.getNodeById(state.lockedNodeId);
if (!lockedNode) {
state.lockedNodeId = null;
}
const selectedNode = !lockedNode && settings["🔍MagnifyGlass.InfoPanelFollowSelection"]
? Object.values(app.canvas?.selected_nodes ?? {})[0]
: undefined;
state.showsSelection = !!selectedNode;
const shownNode = lockedNode ?? selectedNode;
if (shownNode) {
info = { ...info, hoveredNode: this.informationGatherer.getDetailedNodeInfo(shownNode) };
}
// NODE SELECTION OVERRIDE - check if user has selected a specific node (after auto-clear check)
const currentSelectedId = this.stateManager.state.selectedNodeId;
if (currentSelectedId !== null) {
const selectedNode = this.uiManager.nodeSelector.getNodeById(currentSelectedId);
if (selectedNode) {
// Override with selected node info
const detailedInfo = this.informationGatherer.getDetailedNodeInfo(
selectedNode,
{ x: 0, y: 0 } // No local position when manually selected
);
info = {
...info,
hoveredNode: detailedInfo,
hoveredWidget: null
};
// Over empty canvas Sticky keeps the last node, until it goes or another graph is on screen
if (info.hoveredNode) {
this.stickyNodeId = info.hoveredNode.id;
this.stickyGraph = app.canvas?.graph;
} else if (settings["🔍MagnifyGlass.InfoPanelPersist"] && this.stickyNodeId !== null) {
const stickyNode = this.stickyGraph === app.canvas?.graph ? this.uiManager.nodeSelector.getNodeById(this.stickyNodeId) : null;
if (stickyNode) {
info = { ...info, hoveredNode: this.informationGatherer.getDetailedNodeInfo(stickyNode) };
} else {
this.stickyNodeId = null;
}
}
// END NODE SELECTION OVERRIDE
// PERSISTENCE LOGIC START
if (info.hoveredNode) {
// We have a valid node, update our cache
this.lastValidNodeInfo = info;
} else if (settings["🔍MagnifyGlass.InfoPanelPersist"] && this.lastValidNodeInfo) {
// No node hovered, but persistence is ON and we have a cache.
// Merge the CACHED node info into the CURRENT info (so we keep current cursor pos/zoom but show old node)
info = {
...info, // Current timestamp, cursor, zoom
hoveredNode: this.lastValidNodeInfo.hoveredNode,
hoveredWidget: this.lastValidNodeInfo.hoveredWidget
};
}
// PERSISTENCE LOGIC END
this.stateManager.setCurrentInfo(info);
state.currentNodeId = info.hoveredNode?.id ?? null;
// Update the UI
this.uiManager.displayInfo(info);
@@ -288,48 +286,6 @@ export class InfoPanel {
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
// Handle node hover state logic if needed for internal state
if (info.hoveredNode) {
this.stateManager.state.isHoveringNode = true;
this.stateManager.expandNodeSections();
if (this.stateManager.state.lastNodeId !== info.hoveredNode.id) {
this.stateManager.state.lastNodeId = info.hoveredNode.id;
}
} else {
this.stateManager.state.isHoveringNode = false;
// Only collapse if we are NOT persisting
if (!settings["🔍MagnifyGlass.InfoPanelPersist"]) {
if (this.stateManager.state.lastNodeId !== null) {
this.stateManager.state.lastNodeId = null;
if (!this.stateManager.state.isPanelHovered) {
this.stateManager.scheduleAutoCollapse();
}
}
}
}
}
/**
* Handle node selection from dropdown.
* @param nodeId - ID of the selected node
*/
onNodeSelected(nodeId: number): void {
Logger.debug(`Node selected from dropdown: ${nodeId}`);
// Set the selected node in state manager
this.stateManager.setSelectedNode(nodeId);
// Force an immediate info update (bypasses isInfoHeld)
this.updateInfo(true);
}
/**
* Clear the selected node, returning to hover-based detection.
*/
clearSelectedNode(): void {
this.stateManager.clearSelectedNode();
this.updateInfo();
}
}
+38 -237
View File
@@ -5,7 +5,7 @@
* Responsible for collecting information about nodes, widgets, and canvas state.
*/
import type { ComfyApp, ComfyNode, ComfyWidget, GatheredInfo, NodeInfo, WidgetInfo, MediaInfo } from '../types/comfyui';
import type { ComfyApp, ComfyGraph, ComfyNode, GatheredInfo, NodeInfo } from '../types/comfyui';
declare const app: ComfyApp;
declare const LiteGraph: any;
@@ -21,85 +21,41 @@ export class InformationGatherer {
const magnifyGlass = window.comfyUIMagnifyGlass;
if (!magnifyGlass) {
return {
timestamp: Date.now(),
cursor: { screenX: 0, screenY: 0, canvasX: 0, canvasY: 0 },
cursor: { canvasX: 0, canvasY: 0 },
zoom: 1,
nodeCount: 0,
hoveredNode: null,
hoveredWidget: null,
mediaElement: null
hoveredNode: null
};
}
const info: GatheredInfo = {
timestamp: Date.now(),
cursor: {
screenX: magnifyGlass.lastKnownMousePosition.x,
screenY: magnifyGlass.lastKnownMousePosition.y,
canvasX: magnifyGlass.state.x,
canvasY: magnifyGlass.state.y
},
zoom: magnifyGlass.state.canvasScale,
nodeCount: app.graph?._nodes?.length ?? 0,
hoveredNode: null,
hoveredWidget: null,
mediaElement: null
hoveredNode: null
};
if (magnifyGlass.isOverMedia && magnifyGlass.currentMediaElement) {
info.mediaElement = this.getMediaInfo(magnifyGlass.currentMediaElement);
}
const nodeUnderCursor = this.getNodeUnderCursor();
if (nodeUnderCursor) {
info.hoveredNode = this.getDetailedNodeInfo(nodeUnderCursor.node, nodeUnderCursor.localPos);
const widget = this.getWidgetUnderCursor(nodeUnderCursor.node, nodeUnderCursor.localPos);
if (widget) {
info.hoveredWidget = this.getWidgetInfo(widget);
}
info.hoveredNode = this.getDetailedNodeInfo(nodeUnderCursor);
}
return info;
}
getNodeUnderCursor(): { node: ComfyNode, localPos: { x: number, y: number } } | null {
getNodeUnderCursor(): ComfyNode | null {
const magnifyGlass = window.comfyUIMagnifyGlass;
if (!app.graph || !app.canvas || !magnifyGlass) {
const graph = app.canvas?.graph;
if (!graph || !magnifyGlass) {
return null;
}
if (app.canvas.node_over) {
return app.canvas.node_over;
}
try {
// Try to get the node from ComfyUI's state
if (app.canvas.node_over) {
const node = app.canvas.node_over;
if (node && node.pos && node.size) {
const canvasRect = app.canvas.canvas.getBoundingClientRect();
const mouseX = magnifyGlass.lastKnownMousePosition.x - canvasRect.left;
const mouseY = magnifyGlass.lastKnownMousePosition.y - canvasRect.top;
let graphPos: [number, number];
if (app.canvas.convertOffsetToCanvasPos) {
graphPos = app.canvas.convertOffsetToCanvasPos([mouseX, mouseY]);
} else {
const ds = app.canvas.ds || { scale: 1, offset: [0, 0] };
graphPos = [
(mouseX / ds.scale) - (ds.offset[0] / ds.scale),
(mouseY / ds.scale) - (ds.offset[1] / ds.scale)
];
}
return {
node: node,
localPos: {
x: graphPos[0] - node.pos[0],
y: graphPos[1] - node.pos[1]
}
};
}
}
// Fallback to manual detection
const canvasRect = app.canvas.canvas.getBoundingClientRect();
const mouseX = magnifyGlass.lastKnownMousePosition.x - canvasRect.left;
const mouseY = magnifyGlass.lastKnownMousePosition.y - canvasRect.top;
@@ -115,24 +71,7 @@ export class InformationGatherer {
];
}
for (let i = app.graph._nodes.length - 1; i >= 0; i--) {
const node = app.graph._nodes[i];
if (!node.pos || !node.size || (node.flags && node.flags.collapsed)) continue;
if (graphPos[0] >= node.pos[0] &&
graphPos[0] <= node.pos[0] + node.size[0] &&
graphPos[1] >= node.pos[1] &&
graphPos[1] <= node.pos[1] + node.size[1]) {
return {
node: node,
localPos: {
x: graphPos[0] - node.pos[0],
y: graphPos[1] - node.pos[1]
}
};
}
}
return graph.getNodeOnPos(graphPos[0], graphPos[1]) ?? null;
} catch (err) {
console.warn("Error in node detection:", err);
}
@@ -140,9 +79,8 @@ export class InformationGatherer {
return null;
}
getDetailedNodeInfo(node: ComfyNode, localPos: { x: number, y: number }): NodeInfo {
// Try to get author and category from node constructor or registered node types
let author: string | undefined;
getDetailedNodeInfo(node: ComfyNode): NodeInfo {
// Category and module from the node constructor or the registered node types
let category: string | undefined;
let executionOrder: number | undefined;
let pythonModule: string | undefined;
@@ -152,19 +90,15 @@ export class InformationGatherer {
const nodeData = (node as any).constructor?.nodeData;
if (nodeData) {
pythonModule = nodeData.python_module;
author = nodeData.author || pythonModule?.split('.')[0];
category = nodeData.category;
}
// Try to get from LiteGraph registered node types
if (!author && typeof LiteGraph !== 'undefined') {
if (!pythonModule && typeof LiteGraph !== 'undefined') {
const nodeType = (LiteGraph as any).registered_node_types?.[node.type];
if (nodeType) {
author = nodeType.nodeData?.author || nodeType.author;
category = nodeType.category || category;
if (!pythonModule && nodeType.nodeData?.python_module) {
pythonModule = nodeType.nodeData.python_module;
}
pythonModule = nodeType.nodeData?.python_module;
}
}
@@ -181,184 +115,51 @@ export class InformationGatherer {
mode: this.getNodeModeText(node.mode),
position: {
x: Math.round(node.pos[0]),
y: Math.round(node.pos[1]),
formatted: `(${Math.round(node.pos[0])}, ${Math.round(node.pos[1])})`
y: Math.round(node.pos[1])
},
size: {
width: Math.round(node.size[0]),
height: Math.round(node.size[1]),
formatted: `${Math.round(node.size[0])}×${Math.round(node.size[1])}`
},
localPosition: {
x: Math.round(localPos.x),
y: Math.round(localPos.y),
formatted: `(${Math.round(localPos.x)}, ${Math.round(localPos.y)})`,
percentage: {
x: ((localPos.x / node.size[0]) * 100).toFixed(1),
y: ((localPos.y / node.size[1]) * 100).toFixed(1)
}
},
counts: {
widgets: node.widgets ? node.widgets.length : 0,
inputs: node.inputs ? node.inputs.length : 0,
outputs: node.outputs ? node.outputs.length : 0,
properties: node.properties ? Object.keys(node.properties).length : 0
height: Math.round(node.size[1])
},
widgets: node.widgets || [],
inputs: node.inputs || [],
outputs: node.outputs || [],
properties: node.properties || {},
hoverRegion: this.detectNodeRegion(localPos, node),
executionOrder: executionOrder,
author: author,
category: category,
pythonModule: pythonModule
pythonModule: pythonModule,
graphPath: this.getGraphPath(node.graph),
group: node.graph?.getGroupOnPos(node.pos[0] + node.size[0] / 2, node.pos[1] + node.size[1] / 2)?.title,
isSubgraph: !!node.isSubgraphNode?.()
};
}
getNodeInfo(node: ComfyNode): NodeInfo {
return {
id: node.id,
title: node.title || "Untitled",
type: node.type,
mode: node.mode,
size: node.size ? `${Math.round(node.size[0])}×${Math.round(node.size[1])}` : "Unknown",
position: node.pos ? `(${Math.round(node.pos[0])}, ${Math.round(node.pos[1])})` : "Unknown",
widgets: node.widgets || [],
inputs: node.inputs || [],
outputs: node.outputs || [],
properties: node.properties || {}
};
}
/**
* Names of the subgraphs that lead from the root graph to this one, found through the subgraph nodes.
*/
getGraphPath(graph: ComfyGraph | undefined): string[] {
if (!graph || graph.rootGraph === graph) return [];
getWidgetUnderCursor(node: ComfyNode, localPos: { x: number, y: number }): ComfyWidget | null {
if (!node.widgets || !node.widgets.length) return null;
const titleHeight = 30;
let currentY = titleHeight;
for (const widget of node.widgets) {
const widgetHeight = 25;
if (localPos.y >= currentY && localPos.y <= currentY + widgetHeight) {
return widget;
const find = (nodes: ComfyNode[], path: string[]): string[] | null => {
for (const node of nodes) {
if (!node.subgraph || !node.isSubgraphNode?.()) continue;
const subPath = [...path, node.subgraph.name || 'Subgraph'];
const found = node.subgraph === graph ? subPath : find(node.subgraph._nodes, subPath);
if (found) return found;
}
currentY += widgetHeight + 5;
}
return null;
}
getWidgetInfo(widget: ComfyWidget): WidgetInfo {
return {
name: widget.name,
type: widget.type,
value: this.formatValue(widget.value),
options: widget.options || null,
min: widget.min,
max: widget.max,
step: widget.step
return null;
};
}
getMediaInfo(mediaElement: HTMLImageElement | HTMLVideoElement): MediaInfo {
const info: MediaInfo = {
type: mediaElement instanceof HTMLImageElement ? 'image' : 'video',
tagName: mediaElement.tagName,
src: mediaElement.src ? mediaElement.src.substring(mediaElement.src.lastIndexOf('/') + 1) : 'No source',
naturalWidth: 0,
naturalHeight: 0,
displayWidth: 0,
displayHeight: 0,
aspectRatio: ''
};
if (mediaElement instanceof HTMLImageElement) {
info.naturalWidth = mediaElement.naturalWidth;
info.naturalHeight = mediaElement.naturalHeight;
info.displayWidth = Math.round(mediaElement.width);
info.displayHeight = Math.round(mediaElement.height);
if (mediaElement.naturalWidth && mediaElement.naturalHeight) {
info.aspectRatio = (mediaElement.naturalWidth / mediaElement.naturalHeight).toFixed(2);
}
} else if (mediaElement instanceof HTMLVideoElement) {
info.naturalWidth = mediaElement.videoWidth;
info.naturalHeight = mediaElement.videoHeight;
info.displayWidth = Math.round(mediaElement.width);
info.displayHeight = Math.round(mediaElement.height);
info.duration = mediaElement.duration;
info.currentTime = mediaElement.currentTime;
if (mediaElement.videoWidth && mediaElement.videoHeight) {
info.aspectRatio = (mediaElement.videoWidth / mediaElement.videoHeight).toFixed(2);
}
}
return info;
return find(graph.rootGraph._nodes, []) ?? [];
}
getNodeModeText(mode: number): string {
const modes: { [key: number]: string } = {
0: 'Always',
1: 'On Event',
2: 'Never',
2: 'Mute',
3: 'On Trigger',
4: 'On Request'
4: 'Bypass'
};
return modes[mode] || `Mode ${mode}`;
}
detectNodeRegion(localPos: { x: number, y: number }, node: ComfyNode): string {
const titleHeight = 30;
const regions: string[] = [];
if (localPos.y <= titleHeight) {
regions.push("Title Bar");
}
if (localPos.x <= 10) {
regions.push("Left Edge");
} else if (localPos.x >= node.size[0] - 10) {
regions.push("Right Edge");
}
if (localPos.y <= 10) {
regions.push("Top Edge");
} else if (localPos.y >= node.size[1] - 10) {
regions.push("Bottom Edge");
}
if (regions.length === 0) {
if (localPos.y > titleHeight) {
regions.push("Content Area");
}
}
if (localPos.x <= 20 && localPos.y > titleHeight) {
regions.push("Input Area");
} else if (localPos.x >= node.size[0] - 20 && localPos.y > titleHeight) {
regions.push("Output Area");
}
return regions.length > 0 ? regions.join(", ") : "Unknown";
}
formatValue(value: unknown): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "string") {
return value; // Show full text without truncation
}
if (typeof value === "number") {
return Number.isInteger(value) ? value.toString() : value.toFixed(3);
}
if (typeof value === "boolean") return value.toString();
if (Array.isArray(value)) {
return `Array(${value.length})`;
}
if (typeof value === "object") {
return "Object";
}
return String(value);
}
}
+15 -244
View File
@@ -7,18 +7,7 @@
import { formatWidgetValue } from './ValueFormatter';
import type { NodeInfo } from '../types/comfyui';
import { WidgetSyncManager } from './widget-editors/WidgetSyncManager';
/**
* Widget constraints for editors
*/
export interface WidgetConstraintsInfo {
min?: number;
max?: number;
step?: number;
precision?: number;
options?: unknown[];
}
import { WidgetSyncManager, type WidgetConstraints } from './widget-editors/WidgetSyncManager';
/**
* Interface for extracted parameter items.
@@ -32,206 +21,19 @@ export interface ParameterItem {
widgetType?: string; // Widget type (number, combo, text, button, etc.)
isEditable?: boolean; // Whether this can be edited (value-based)
isActionable?: boolean; // Whether this is an action button (callback-based)
isLinked?: boolean; // Driven by a link, so shown read-only
rawValue?: unknown; // Original unformatted value
constraints?: WidgetConstraintsInfo; // Widget constraints for validation
constraints?: WidgetConstraints; // Widget constraints for validation
nodeId?: number; // Node ID for sync operations
}
/**
* Get checkpoint/model info from a node.
* @param nodeInfo - Node information object
* @returns Model filename or null
*/
export function getCheckpointInfo(nodeInfo: NodeInfo): string | null {
if (nodeInfo.type && (
nodeInfo.type.includes('CheckpointLoader') ||
nodeInfo.type.includes('LoadCheckpoint') ||
nodeInfo.type.includes('ModelLoader') ||
nodeInfo.type.includes('UNETLoader') ||
nodeInfo.type.includes('VAELoader') ||
nodeInfo.type.includes('LoraLoader')
)) {
if (nodeInfo.widgets && nodeInfo.widgets.length > 0) {
for (const widget of nodeInfo.widgets) {
if (widget.name && (
widget.name.toLowerCase().includes('model') ||
widget.name.toLowerCase().includes('checkpoint') ||
widget.name.toLowerCase().includes('ckpt') ||
widget.name.toLowerCase().includes('lora') ||
widget.name.toLowerCase().includes('vae') ||
widget.name.toLowerCase().includes('file')
)) {
const value = String(widget.value);
const filename = value.split(/[\/\\]/).pop();
return filename || value;
}
}
}
}
return null;
}
/**
* Image info result type.
*/
export interface ImageInfoResult {
width: string | number;
height: string | number;
src: string;
}
/**
* Get image info from a node.
* @param nodeInfo - Node information object
* @returns Image info string, object, or null
*/
export function getImageInfo(nodeInfo: NodeInfo): string | ImageInfoResult | null {
if (nodeInfo.type && (
nodeInfo.type.includes('SaveImage') ||
nodeInfo.type.includes('PreviewImage') ||
nodeInfo.type.includes('VisionOutput') ||
nodeInfo.type.includes('ImageOutput') ||
nodeInfo.type.includes('LoadImage') ||
nodeInfo.type.includes('Display')
)) {
if (nodeInfo.widgets) {
for (const widget of nodeInfo.widgets) {
if (widget.name && (
widget.name.toLowerCase().includes('image') ||
widget.name.toLowerCase().includes('filename') ||
widget.name.toLowerCase().includes('file')
)) {
return String(widget.value);
}
}
}
if (nodeInfo.properties && (nodeInfo.properties as Record<string, unknown>).img) {
const img = (nodeInfo.properties as Record<string, unknown>).img as Record<string, unknown>;
return {
width: (img.width as string | number) || 'unknown',
height: (img.height as string | number) || 'unknown',
src: img.src ? String(img.src).split(/[\/\\]/).pop() || 'Preview available' : 'Preview available'
};
}
if (nodeInfo.outputs) {
for (const output of nodeInfo.outputs) {
if (output.links && output.links.length > 0) {
return 'Image connected to ' + output.links.length + ' node(s)';
}
}
}
return 'Image node';
}
return null;
}
/**
* Get text box content from a node.
* @param nodeInfo - Node information object
* @returns Text content or null
*/
export function getTextBoxContent(nodeInfo: NodeInfo): string | null {
if (nodeInfo.widgets && nodeInfo.widgets.length > 0) {
if (nodeInfo.type && nodeInfo.type.includes('CLIPTextEncode')) {
for (const widget of nodeInfo.widgets) {
if (widget.name === 'text' && typeof widget.value === 'string') {
return widget.value;
}
}
}
for (const widget of nodeInfo.widgets) {
if ((widget.name.toLowerCase().includes('prompt') ||
widget.name.toLowerCase().includes('conditioning')) &&
typeof widget.value === 'string' && widget.value.length > 0) {
return widget.value;
}
}
for (const widget of nodeInfo.widgets) {
if ((widget.type === 'text' || widget.type === 'textarea' ||
widget.type === 'string' || widget.name.toLowerCase().includes('text')) &&
typeof widget.value === 'string' && widget.value.length > 0) {
return widget.value;
}
}
}
return null;
}
/**
* List of important parameter names for complex nodes.
*/
const COMPLEX_NODE_PARAMS = [
'seed', 'steps', 'cfg', 'scale', 'sampler', 'scheduler',
'positive', 'negative', 'width', 'height', 'denoise', 'strength',
'noise', 'count', 'batch', 'size', 'phase', 'color', 'intensity',
'control_after_generate', 'control', 'after', 'generate',
'start_at_step', 'end_at_step', 'start', 'end',
'return_with_leftover_noise', 'leftover', 'noise_return',
'model', 'vae', 'clip', 'lora', 'checkpoint',
'latent', 'image', 'mask', 'filename', 'directory',
'prompt', 'conditioning', 'filename_prefix',
'resolution', 'num_chunks', 'seconds', 'aspect_ratio',
'style_type', 'background', 'n', 'human', 'raw', 'guidance',
'skip_preprocessing', 'movement_amplitude', 'animation',
'material_type', 'b1', 'b2', 's1', 's2', 'type', 'channel',
'sigma', 'rho', 'alpha', 'base_shift', 'shift', 'stretch', 'terminal',
'spacing', 'style', 'eta', 'norm_threshold', 'momentum',
'hypernetwork_name', 'reuse_threshold', 'verbose', 'layers',
'set_cond_area', 'audioui', 'camera_pose', 'fx', 'cx', 'fy', 'cy'
];
/**
* Parameters for save nodes.
*/
const SAVE_NODE_PARAMS = [
'filename_prefix', 'filename', 'directory', 'path',
'format', 'quality', 'extension'
];
/**
* Generic widget names to skip.
* Generic names to skip unless an input slot shows they are a real widget (CLIPLoader has a `type` widget).
*/
const SKIP_WIDGET_NAMES = ['title', 'node', 'id', 'type', 'mode'];
/**
* Check if a node type should show all widgets.
* @param nodeType - The node type string
* @returns True if all widgets should be shown
*/
function shouldShowAllWidgets(nodeType: string): boolean {
const type = nodeType.toLowerCase();
const isSaveNode = type.includes('save') &&
!type.includes('checkpoint') &&
!type.includes('model') &&
!type.includes('preview');
return (
type.includes('ksampler') ||
type.includes('sampler') ||
type.includes('k_samplers') ||
type.includes('checkpoint') ||
type.includes('model') ||
type.includes('lora') ||
type.includes('controlnet') ||
type.includes('advanced') ||
type.includes('detailer') ||
type.includes('inpaint') ||
type.includes('upscale') ||
type.includes('clip') ||
type.includes('text') ||
type.includes('encode')
) && !isSaveNode;
}
/**
* Get important node parameters based on node type.
* @param nodeInfo - Node information object
* @returns Array of parameter items with editing metadata
* The node's widgets as rows, with their editing metadata.
*/
export function getImportantNodeParameters(nodeInfo: NodeInfo): ParameterItem[] {
const parameters: ParameterItem[] = [];
@@ -240,68 +42,37 @@ export function getImportantNodeParameters(nodeInfo: NodeInfo): ParameterItem[]
return parameters;
}
const nodeType = nodeInfo.type || '';
const typeLower = nodeType.toLowerCase();
const isSaveNode = typeLower.includes('save') &&
!typeLower.includes('checkpoint') &&
!typeLower.includes('model') &&
!typeLower.includes('preview');
// Track seen values to deduplicate widgets with identical values
const seenValues = new Set<string>();
// Helper to normalize value for comparison (handles different types)
const normalizeValue = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return JSON.stringify(value);
};
// Helper to check if value is meaningful (not empty/default)
const isMeaningfulValue = (value: unknown): boolean => {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.trim().length > 0;
return true;
};
const inputs = nodeInfo.inputs || [];
const widgetSlot = (widget: any) => inputs.find((input: any) => input.widget?.name === widget.name);
// Helper to create parameter item with full metadata
const createParameterItem = (widget: any): ParameterItem => {
const widgetType = WidgetSyncManager.getWidgetType(widget);
const constraints = WidgetSyncManager.extractConstraints(widget);
const isEditable = WidgetSyncManager.isWidgetEditable(widget);
const isLinked = !!widget.computedDisabled || widgetSlot(widget)?.link != null;
const isEditable = !isLinked && WidgetSyncManager.isWidgetEditable(widget);
// Button widgets are actionable (can be clicked to trigger callbacks)
const isActionable = widgetType === 'button';
return {
label: widget.name,
value: formatWidgetValue(widget.value),
label: widget.label || widget.name,
value: formatWidgetValue(widget.value, constraints.precision),
// Editing metadata
widgetName: widget.name,
widgetType: widgetType,
isEditable: isEditable,
isActionable: isActionable,
isLinked: isLinked,
rawValue: widget.value,
constraints: constraints,
nodeId: nodeInfo.id
};
};
// Helper to add widget (no longer deduplicating - each widget is unique by name)
const addWidget = (widget: any): boolean => {
parameters.push(createParameterItem(widget));
return true;
};
// Show ALL widgets for all nodes (with skip list only)
// This matches the popout viewer's behavior
// Every named widget, as the pop-out shows them
for (const widget of nodeInfo.widgets) {
if (widget.name && widget.name !== '') {
const widgetName = widget.name.toLowerCase();
// Use exact matching for skip list to avoid false positives
if (!SKIP_WIDGET_NAMES.includes(widgetName)) {
addWidget(widget);
}
if (widget.name && (!SKIP_WIDGET_NAMES.includes(widget.name.toLowerCase()) || widgetSlot(widget))) {
parameters.push(createParameterItem(widget));
}
}
+24 -53
View File
@@ -10,18 +10,13 @@ import type { ComfyApp, ComfyNode } from '../types/comfyui';
declare const app: ComfyApp;
/**
* Node entry for dropdown display.
* Node entry for the Go to node picker and Prev/Next.
*/
export interface NodeListEntry {
id: number;
title: string;
type: string;
}
/**
* Node entry with execution order.
*/
export interface NodeExecOrderEntry extends NodeListEntry {
/** Execution order, -1 when the node has none */
order: number;
}
@@ -34,71 +29,47 @@ export class NodeSelector {
* Get all nodes from the graph.
*/
getAllNodes(): ComfyNode[] {
return app?.graph?._nodes ?? [];
return app?.canvas?.graph?._nodes ?? [];
}
/**
* Get nodes sorted alphabetically by title.
*/
getNodesSortedByTitle(): NodeListEntry[] {
return this.getAllNodes()
.map(n => ({
id: n.id,
title: n.title || 'Untitled',
type: n.type || 'Unknown'
}))
.sort((a, b) => a.title.localeCompare(b.title));
private getEntries(): NodeListEntry[] {
return this.getAllNodes().map(n => ({
id: n.id,
title: n.title || 'Untitled',
type: n.type || 'Unknown',
order: (n as any).order ?? -1
}));
}
/**
* Get nodes sorted by execution order.
* Only includes nodes that have a valid execution order.
*/
getNodesSortedByExecOrder(): NodeExecOrderEntry[] {
return this.getAllNodes()
.map(n => ({
id: n.id,
title: n.title || 'Untitled',
type: n.type || 'Unknown',
order: (n as any).order ?? -1
}))
getNodesSortedByExecOrder(): NodeListEntry[] {
return this.getEntries()
.filter(n => n.order >= 0)
.sort((a, b) => a.order - b.order);
}
/**
* Get nodes sorted by ID number (ascending).
*/
getNodesSortedById(): NodeListEntry[] {
return this.getAllNodes()
.map(n => ({
id: n.id,
title: n.title || 'Untitled',
type: n.type || 'Unknown'
}))
.sort((a, b) => a.id - b.id);
}
/**
* Get a node by its ID.
*/
getNodeById(id: number): ComfyNode | null {
return app?.graph?.getNodeById(id) ?? null;
return app?.canvas?.graph?.getNodeById(id) ?? null;
}
/**
* Search nodes by title (case-insensitive partial match).
* Nodes whose title or type contains the query (any case), or whose id it names as `#id` or plainly.
* Sorted by execution order; nodes without one come last, by title.
*/
searchByTitle(query: string): NodeListEntry[] {
const lowerQuery = query.toLowerCase();
return this.getNodesSortedByTitle()
.filter(n => n.title.toLowerCase().includes(lowerQuery));
}
/**
* Get total node count.
*/
getNodeCount(): number {
return this.getAllNodes().length;
search(query: string): NodeListEntry[] {
const q = query.trim().toLowerCase();
const idQuery = q.startsWith('#') ? q.slice(1) : null;
const rank = (n: NodeListEntry) => n.order < 0 ? Infinity : n.order;
return this.getEntries()
.filter(n => idQuery !== null
? !idQuery || String(n.id) === idQuery
: n.title.toLowerCase().includes(q) || n.type.toLowerCase().includes(q) || String(n.id) === q)
.sort((a, b) => (rank(a) - rank(b)) || a.title.localeCompare(b.title));
}
}
+32 -44
View File
@@ -20,28 +20,24 @@ export class PositionManager {
positionPanel(): void {
if (!this.panelElement) return;
// If panel is pinned, keep it at its pinned position
if (this.stateManager.state.isPanelPinned) {
this.applyPinnedPosition();
if (this.stateManager.state.isFloating) {
this.applyFloatPosition();
return;
}
// Normal positioning logic
this.calculateNormalPosition();
}
applyPinnedPosition(): void {
if (!this.panelElement) return;
let { x, y } = this.stateManager.state.pinnedPosition;
// If pinnedPosition is uninitialized (0,0), use calculateNormalPosition to get a valid position
if (x === 0 && y === 0) {
/**
* Keep a floating panel where it was put, 4px inside the window. It starts floating where it last followed the glass.
*/
applyFloatPosition(): void {
const state = this.stateManager.state;
if (!state.floatPosition) {
this.calculateNormalPosition();
// Save the calculated position as the new pinned position
if (this.panelElement) {
const rect = this.panelElement.getBoundingClientRect();
this.stateManager.state.pinnedPosition = { x: rect.left, y: rect.top };
// Not the element's rect, which is empty while the panel is hidden
if (this.cachedPanelPosition) {
state.floatPosition = { x: this.cachedPanelPosition.x, y: this.cachedPanelPosition.y };
}
return;
}
@@ -50,8 +46,10 @@ export class PositionManager {
const panelHeight = this.panelElement.offsetHeight || 400;
const margin = 4;
const boundedX = Math.max(margin, Math.min(x, window.innerWidth - panelWidth - margin));
const boundedY = Math.max(margin, Math.min(y, window.innerHeight - panelHeight - margin));
const boundedX = Math.max(margin, Math.min(state.floatPosition.x, window.innerWidth - panelWidth - margin));
const boundedY = Math.max(margin, Math.min(state.floatPosition.y, window.innerHeight - panelHeight - margin));
// Stored bounded, so a drag past the edge turns back at once
state.floatPosition = { x: boundedX, y: boundedY };
this.panelElement.style.left = `${boundedX}px`;
this.panelElement.style.top = `${boundedY}px`;
@@ -191,41 +189,31 @@ export class PositionManager {
// Enforce no transition on controls to prevent 'rubber-banding'
controlsElement.style.transition = 'none';
const controlsPosition = this.stateManager.state.settings["🔍MagnifyGlass.ControlsPosition"] || "right";
// left/right: beside the reference; top-*/bottom-*: above or below it, held by the named edge.
// A side without room flips to the other, so the toolbar never covers the panel.
const [side, edge] = String(this.stateManager.state.settings["🔍MagnifyGlass.ControlsPosition"] || "left").split('-');
const width = controlsElement.offsetWidth;
const height = controlsElement.offsetHeight;
const margin = 4;
let left: number;
let top: number;
switch (controlsPosition) {
case "left":
left = referenceRect.left - controlsElement.offsetWidth - margin;
top = referenceRect.top;
break;
case "right":
left = referenceRect.right + margin;
top = referenceRect.top;
break;
case "top":
left = referenceRect.left;
top = referenceRect.top - controlsElement.offsetHeight - margin;
break;
case "bottom":
left = referenceRect.left;
top = referenceRect.bottom + margin;
break;
case "top-right":
left = referenceRect.right - controlsElement.offsetWidth;
top = referenceRect.top - controlsElement.offsetHeight - margin;
break;
default:
left = referenceRect.right + margin;
top = referenceRect.top;
if (side === 'top' || side === 'bottom') {
const above = referenceRect.top - height - margin;
const below = referenceRect.bottom + margin;
top = side === 'top' ? (above >= margin ? above : below) : (below + height <= window.innerHeight - margin ? below : above);
left = edge === 'right' ? referenceRect.right - width : referenceRect.left;
} else {
const before = referenceRect.left - width - margin;
const after = referenceRect.right + margin;
left = side === 'left' ? (before >= margin ? before : after) : (after + width <= window.innerWidth - margin ? after : before);
top = referenceRect.top;
}
// Keep controls within viewport
left = Math.max(10, Math.min(left, window.innerWidth - controlsElement.offsetWidth - 10));
top = Math.max(10, Math.min(top, window.innerHeight - controlsElement.offsetHeight - 10));
left = Math.max(margin, Math.min(left, window.innerWidth - width - margin));
top = Math.max(margin, Math.min(top, window.innerHeight - height - margin));
controlsElement.style.left = `${left}px`;
controlsElement.style.top = `${top}px`;
+40 -388
View File
@@ -4,49 +4,37 @@
* Centralized state management for the information panel.
*/
import type { GatheredInfo } from '../types/comfyui';
import { getSettingValue, clamp } from '../shared/utils';
import { DEFAULT_PANEL_SETTINGS } from '../shared/settings';
import { PANEL_FONTS, STORAGE_KEYS } from '../shared/constants';
export interface InfoPanelStateData {
// Panel visibility and positioning
isPanelVisible: boolean;
wasPanelVisibleBeforeHide: boolean; // Persist state across glass toggles
isPanelMinimized: boolean;
isPanelPinned: boolean;
isPanelLocked: boolean; // New lock state
isAutoPinned: boolean; // Track if panel was auto-pinned by glass hide
pinnedPosition: { x: number; y: number };
lastPinnedPosition: { x: number; y: number } | null; // Remember last pinned location
// Floating: stays where it was dragged instead of following the glass
isFloating: boolean;
// Where it floats; null until it first floats
floatPosition: { x: number; y: number } | null;
// Glass visibility
isGlassPreviewVisible: boolean;
// Info freezing
isInfoHeld: boolean;
// Section expansion
expandedSections: Set<string>;
// Interaction states
isPanelHovered: boolean;
isHoveringNode: boolean;
// Auto-collapse
autoExpandTimer: ReturnType<typeof setTimeout> | null;
lastNodeId: string | number | null;
// The node the panel shows
currentNodeId: number | null;
// Current data
currentInfo: GatheredInfo | null;
// Node selection override (user-selected node from dropdown)
selectedNodeId: number | null;
// Node the panel is locked to: shown instead of the hovered node until unlocked
lockedNodeId: number | null;
// The graph that id belongs to; another graph on screen releases the lock
lockedGraph: unknown;
// The shown node is the canvas selection (Follow selection)
showsSelection: boolean;
// Settings cache - using index signature for dynamic ComfyUI settings
settings: { [key: string]: string | number | boolean };
// Auto-detected theme
currentTheme: string;
}
/**
@@ -55,11 +43,6 @@ export interface InfoPanelStateData {
*/
export class StateManager {
state: InfoPanelStateData;
private themeObserver!: MutationObserver;
private themeInterval!: ReturnType<typeof setInterval>;
private themeTimeout!: ReturnType<typeof setTimeout>;
private colorSchemeQuery!: MediaQueryList;
private readonly onColorSchemeChange = () => this.detectCurrentTheme();
constructor() {
this.state = {
@@ -67,243 +50,49 @@ export class StateManager {
isPanelVisible: false,
wasPanelVisibleBeforeHide: false,
isPanelMinimized: false,
isPanelPinned: false,
isPanelLocked: false, // New lock state
isAutoPinned: false, // Track if panel was auto-pinned by glass hide
pinnedPosition: { x: 0, y: 0 },
lastPinnedPosition: null, // Remember last pinned location
isFloating: false,
floatPosition: null,
// Glass visibility
isGlassPreviewVisible: true,
isInfoHeld: false,
// Section expansion
expandedSections: new Set(['node']),
// Interaction states
isPanelHovered: false,
isHoveringNode: false,
currentNodeId: null,
// Auto-collapse
autoExpandTimer: null,
lastNodeId: null,
// Current data
currentInfo: null,
// Node selection override
selectedNodeId: null,
lockedNodeId: null,
lockedGraph: null,
showsSelection: false,
// Settings cache
settings: {},
// Auto-detected theme
currentTheme: 'dark' // Will be auto-detected
settings: {}
};
this.initThemeDetection();
this.loadSettings();
this.loadLayout();
}
initThemeDetection(): void {
// Detect ComfyUI's current theme
this.detectCurrentTheme();
// Force an immediate check in case we missed the initial state
this.themeTimeout = setTimeout(() => {
if (this.detectCurrentTheme()) {
this.notifyThemeChange();
}
}, 1000);
// Set up mutation observer to watch for theme changes
this.setupThemeObserver();
// Listen for system theme changes as fallback
this.colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.colorSchemeQuery.addEventListener('change', this.onColorSchemeChange);
// Add click listener to theme toggle buttons for immediate detection
this.setupThemeButtonListeners();
}
detectCurrentTheme(): boolean {
let detectedTheme = 'dark'; // Default fallback
// Method 1: Try to read from ComfyUI's color palette setting
/**
* Float mode, float position and minimized state are kept per browser.
*/
private loadLayout(): void {
try {
// ComfyUI stores theme in 'Comfy.ColorPalette' setting
const colorPalette = getSettingValue('Comfy.ColorPalette', '');
if (colorPalette) {
// Extract theme name from various possible formats
const paletteStr = String(colorPalette).toLowerCase();
if (paletteStr.includes('obsidian') && paletteStr.includes('dark')) {
detectedTheme = 'obsidian-dark';
} else if (paletteStr.includes('obsidian')) {
detectedTheme = 'obsidian';
} else if (paletteStr.includes('milk') || paletteStr.includes('white')) {
detectedTheme = 'milk-white';
} else if (paletteStr.includes('solarized')) {
detectedTheme = 'solarized';
} else if (paletteStr.includes('arc')) {
detectedTheme = 'arc';
} else if (paletteStr.includes('nord')) {
detectedTheme = 'nord';
} else if (paletteStr.includes('github')) {
detectedTheme = 'github';
} else if (paletteStr.includes('light')) {
detectedTheme = 'light';
} else if (paletteStr.includes('dark')) {
detectedTheme = 'dark';
}
const layout = JSON.parse(localStorage.getItem(STORAGE_KEYS.INSPECTOR_LAYOUT) || '{}');
this.state.isFloating = layout.floating === true;
this.state.isPanelMinimized = layout.minimized === true;
if (Number.isFinite(layout.x) && Number.isFinite(layout.y)) {
this.state.floatPosition = { x: layout.x, y: layout.y };
}
} catch (e) {
// Fallback to brightness-based detection
console.warn('ComfyUI Info Panel: Could not read theme from settings, using fallback detection');
// Storage blocked or the saved layout unreadable: start from the defaults
}
// Method 2: Fallback - check body/html for theme classes or data attributes
if (detectedTheme === 'dark') {
const body = document.body;
const html = document.documentElement;
// Check for data-theme attribute
const dataTheme = body.getAttribute('data-theme') || html.getAttribute('data-theme');
if (dataTheme) {
detectedTheme = dataTheme.toLowerCase();
}
// Check for theme classes
const classes = (body.className + ' ' + html.className).toLowerCase();
if (classes.includes('theme-obsidian-dark') || classes.includes('obsidian-dark')) {
detectedTheme = 'obsidian-dark';
} else if (classes.includes('theme-obsidian') || classes.includes('obsidian')) {
detectedTheme = 'obsidian';
} else if (classes.includes('theme-milk-white') || classes.includes('milk-white') || classes.includes('milk_white')) {
detectedTheme = 'milk-white';
} else if (classes.includes('theme-solarized') || classes.includes('solarized')) {
detectedTheme = 'solarized';
} else if (classes.includes('theme-arc') || classes.includes('arc-theme')) {
detectedTheme = 'arc';
} else if (classes.includes('theme-nord') || classes.includes('nord-theme')) {
detectedTheme = 'nord';
} else if (classes.includes('theme-github') || classes.includes('github-theme')) {
detectedTheme = 'github';
} else if (classes.includes('theme-light') || classes.includes('light-theme')) {
detectedTheme = 'light';
} else if (classes.includes('theme-dracula') || classes.includes('dracula-theme')) {
detectedTheme = 'dracula';
} else if (classes.includes('theme-gruvbox') || classes.includes('gruvbox-theme')) {
detectedTheme = 'gruvbox';
} else if (classes.includes('theme-monokai') || classes.includes('monokai-theme')) {
detectedTheme = 'monokai';
}
}
// Method 3: Last resort - brightness detection for light vs dark
if (detectedTheme === 'dark') {
const bodyStyles = window.getComputedStyle(document.body);
const bgColor = bodyStyles.backgroundColor;
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
const rgbMatch = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
if (brightness > 180) {
detectedTheme = 'light';
}
}
}
}
// Update theme if it changed
if (this.state.currentTheme !== detectedTheme) {
this.state.currentTheme = detectedTheme;
return true; // Theme changed
}
return false; // No change
}
setupThemeObserver(): void {
// Watch for changes to body/html classes and attributes
const observer = this.themeObserver = new MutationObserver((mutations) => {
let shouldCheck = false;
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'class' ||
mutation.attributeName === 'data-theme' ||
mutation.attributeName === 'style')) {
shouldCheck = true;
}
});
if (shouldCheck && this.detectCurrentTheme()) {
// Theme changed, update UI immediately
this.notifyThemeChange();
}
});
// Observe changes to body, html, and vue app
observer.observe(document.body, { attributes: true, attributeOldValue: true });
observer.observe(document.documentElement, { attributes: true, attributeOldValue: true });
const vueApp = document.querySelector('#vue-app');
if (vueApp) {
observer.observe(vueApp, { attributes: true, attributeOldValue: true });
}
// Also watch for changes to any element with theme-related classes
const themeElements = document.querySelectorAll('[class*="theme"], [class*="dark"], [class*="light"], [data-theme]');
themeElements.forEach(el => {
observer.observe(el, { attributes: true, attributeOldValue: true });
});
// Periodic fallback check every 2 seconds
this.themeInterval = setInterval(() => {
if (this.detectCurrentTheme()) {
this.notifyThemeChange();
}
}, 2000);
}
setupThemeButtonListeners(): void {
document.addEventListener('click', this.onDocumentClick);
}
// Clicks on theme toggle buttons trigger an immediate re-detection
private readonly onDocumentClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const isThemeButton = target.textContent?.toLowerCase().includes('theme') ||
target.textContent?.toLowerCase().includes('light') ||
target.textContent?.toLowerCase().includes('dark') ||
target.title?.toLowerCase().includes('theme') ||
(typeof target.className === 'string' && target.className.toLowerCase().includes('theme'));
if (isThemeButton) {
setTimeout(() => {
if (this.detectCurrentTheme()) {
this.notifyThemeChange();
}
}, 100);
}
};
notifyThemeChange(): void {
// Update settings to reflect new theme
this.state.settings["🔍MagnifyGlass.InfoPanelTheme"] = this.state.currentTheme;
// Notify UI components of theme change immediately
if (window.infoPanelManager && window.infoPanelManager.uiManager) {
window.infoPanelManager.uiManager.updateTheme(this.state.currentTheme);
}
// Notify Pop-Out Manager (if it exists on the main glass instance)
const magnifyGlass = (window as any).comfyUIMagnifyGlass;
if (magnifyGlass && magnifyGlass.popOutManager) {
magnifyGlass.popOutManager.updateTheme(this.state.currentTheme);
saveLayout(): void {
const { isFloating, floatPosition, isPanelMinimized } = this.state;
try {
localStorage.setItem(STORAGE_KEYS.INSPECTOR_LAYOUT, JSON.stringify({ floating: isFloating, minimized: isPanelMinimized, ...floatPosition }));
} catch (e) {
// Storage blocked: the layout lasts until reload
}
}
@@ -311,10 +100,7 @@ export class StateManager {
const defaults = DEFAULT_PANEL_SETTINGS;
Object.keys(defaults).forEach(key => {
// Skip the theme setting since we auto-detect it
if (key !== "🔍MagnifyGlass.InfoPanelTheme") {
this.state.settings[key] = getSettingValue(key, (defaults as any)[key]);
}
this.state.settings[key] = getSettingValue(key, (defaults as any)[key]);
});
// Validate numeric settings
@@ -354,16 +140,9 @@ export class StateManager {
this.state.settings["🔍MagnifyGlass.ControlsPosition"], validControlPositions, defaults["🔍MagnifyGlass.ControlsPosition"] as string
);
const validFonts = [
'System Default', 'Inter', 'Roboto', 'JetBrains Mono', 'Fira Code',
'IBM Plex Sans', 'Space Grotesk', 'Lexend', 'Outfit', 'monospace', 'system-ui'
];
this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"] = this.validateStringOption(
this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"], validFonts, defaults["🔍MagnifyGlass.InfoPanelFontFamily"] as string
this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"], PANEL_FONTS, defaults["🔍MagnifyGlass.InfoPanelFontFamily"] as string
);
// Set theme to auto-detected value
this.state.settings["🔍MagnifyGlass.InfoPanelTheme"] = this.state.currentTheme;
}
/**
@@ -390,140 +169,13 @@ export class StateManager {
/**
* Validate string against an allowlist.
*/
private validateStringOption(value: unknown, options: string[], fallback: string): string {
private validateStringOption(value: unknown, options: readonly string[], fallback: string): string {
if (typeof value !== 'string') return fallback;
return options.includes(value) ? value : fallback;
}
togglePanelVisibility(): boolean {
this.state.isPanelVisible = !this.state.isPanelVisible;
return this.state.isPanelVisible;
}
toggleGlassPreview(): boolean {
this.state.isGlassPreviewVisible = !this.state.isGlassPreviewVisible;
return this.state.isGlassPreviewVisible;
}
togglePinning(): boolean {
const wasPinned = this.state.isPanelPinned;
this.state.isPanelPinned = !this.state.isPanelPinned;
// When unpinning, also unlock and clear auto-pin flag
if (!this.state.isPanelPinned) {
this.state.isPanelLocked = false;
this.state.isAutoPinned = false; // Clear auto-pin flag when manually unpinning
} else {
// When manually pinning, also clear auto-pin flag (this is a user-initiated pin)
this.state.isAutoPinned = false;
}
if (this.state.isPanelPinned && this.state.lastPinnedPosition) {
// Restore to last pinned position
this.state.pinnedPosition = { ...this.state.lastPinnedPosition };
} else if (!this.state.isPanelPinned && wasPinned) {
// Save current position when unpinning
this.state.lastPinnedPosition = { ...this.state.pinnedPosition };
}
return this.state.isPanelPinned;
}
toggleLocking(): boolean {
// Only allow locking when pinned
if (!this.state.isPanelPinned) {
return false;
}
this.state.isPanelLocked = !this.state.isPanelLocked;
return this.state.isPanelLocked;
}
toggleMinimized(): boolean {
this.state.isPanelMinimized = !this.state.isPanelMinimized;
return this.state.isPanelMinimized;
}
toggleHold(): boolean {
this.state.isInfoHeld = !this.state.isInfoHeld;
return this.state.isInfoHeld;
}
toggleSection(sectionId: string): boolean {
if (sectionId === 'node') return false; // Node section always expanded
if (this.state.expandedSections.has(sectionId)) {
this.state.expandedSections.delete(sectionId);
} else {
this.state.expandedSections.add(sectionId);
}
return true;
}
setPinnedPosition(x: number, y: number): void {
this.state.pinnedPosition = { x, y };
// Also save as last pinned position for memory
this.state.lastPinnedPosition = { x, y };
}
setCurrentInfo(info: GatheredInfo): void {
this.state.currentInfo = info;
}
/**
* Set the selected node ID for manual override.
* When set, the inspector will display this node instead of the hovered node.
*/
setSelectedNode(id: number | null): void {
this.state.selectedNodeId = id;
}
/**
* Clear the selected node, returning to hover-based detection.
*/
clearSelectedNode(): void {
this.state.selectedNodeId = null;
}
scheduleAutoCollapse(): void {
this.clearAutoExpandTimer();
if (this.state.settings["🔍MagnifyGlass.InfoPanelAnimations"]) {
this.state.autoExpandTimer = setTimeout(() => {
if (!this.state.isPanelHovered) {
this.collapseNodeSections();
}
}, 1500);
}
}
clearAutoExpandTimer(): void {
if (this.state.autoExpandTimer) {
clearTimeout(this.state.autoExpandTimer);
this.state.autoExpandTimer = null;
}
}
expandNodeSections(): void {
this.state.expandedSections.add('hoveredNode');
this.state.expandedSections.add('node');
this.state.expandedSections.add('cursor');
this.state.expandedSections.add('canvas');
this.state.expandedSections.add('magnifier');
this.clearAutoExpandTimer();
}
collapseNodeSections(): void {
this.state.expandedSections.delete('hoveredNode');
this.state.expandedSections.delete('node');
this.state.expandedSections.delete('widget');
}
cleanup(): void {
this.clearAutoExpandTimer();
clearTimeout(this.themeTimeout);
clearInterval(this.themeInterval);
this.themeObserver.disconnect();
this.colorSchemeQuery.removeEventListener('change', this.onColorSchemeChange);
document.removeEventListener('click', this.onDocumentClick);
}
}
+681 -1325
View File
File diff suppressed because it is too large Load Diff
+18 -82
View File
@@ -1,97 +1,33 @@
/**
* ComfyUI MagnifyGlass - Value Formatter Utilities
*
*
* Pure utility functions for formatting values in the info panel display.
* Extracted from UIManager.ts for better modularity.
*/
import { escapeHtml } from '../shared/utils';
/**
* Format a value for display in the info panel.
* @param value - The value to format
* @param label - Optional label to determine formatting rules
* @returns Formatted string representation
* Format widget value for display, with the widget's own precision for numbers.
* Arrays and objects show as JSON.
*/
export function formatValue(value: unknown, label?: string): string {
if (value === null || value === undefined) return '';
const str = String(value);
// Show full text for prompts, text content, model names, and file paths
if (label && (
label.toLowerCase().includes('text') ||
label.toLowerCase().includes('prompt') ||
label.toLowerCase().includes('model') ||
label.toLowerCase().includes('file') ||
label.toLowerCase().includes('conditioning') ||
label.toLowerCase().includes('positive') ||
label.toLowerCase().includes('negative')
)) {
return escapeHtml(str);
}
// Show full text for very long values (no truncation)
return escapeHtml(str);
}
/**
* Get CSS class names for styling a value.
* @param value - The value to get classes for
* @returns CSS class string
*/
export function getValueClass(value: unknown): string {
if (!value) return '';
const str = String(value);
const classes: string[] = [];
// Mark text that might benefit from special styling for readability
if (str.length > 100) {
classes.push('long-text');
}
return classes.join(' ');
}
/**
* Get HTML attributes for a value element.
* @param value - The value to get attributes for
* @returns HTML attribute string
*/
export function getValueAttributes(value: unknown): string {
// Since we show full text now, we don't need title attributes for long text
// Only add title for very long text that might benefit from tooltips
if (!value) return '';
const str = String(value);
if (str.length > 500) { // Only for extremely long text
return `title="${escapeHtml(str)}"`;
}
return '';
}
/**
* Format widget value for display.
* @param value - Widget value to format
* @returns Formatted string representation
*/
export function formatWidgetValue(value: unknown): string {
export function formatWidgetValue(value: unknown, precision?: number): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') {
return value; // Show full text without truncation
}
if (typeof value === 'number') {
return Number.isInteger(value) ? value.toString() : value.toFixed(3);
}
if (typeof value === 'boolean') return value.toString();
if (Array.isArray(value)) {
return `Array(${value.length})`;
return precision === undefined ? String(value) : value.toFixed(precision);
}
if (typeof value === 'object') {
return 'Object';
try {
return JSON.stringify(value, null, 2);
} catch {
// Circular values from custom widgets
return Array.isArray(value) ? `Array(${value.length})` : 'Object';
}
}
return String(value);
}
/**
* Values this long are clamped to a few lines with an expand toggle.
*/
export function isLongText(text: string): boolean {
return text.length > 200 || text.split('\n').length > 4;
}
@@ -2,7 +2,7 @@
* ComfyUI MagnifyGlass - Drag Value Controller
*
* Handles horizontal drag gestures on inspector rows for quick value changes.
* Allows users to drag left/right to adjust numbers or cycle through options.
* Allows users to drag left/right to adjust numbers.
*/
import { WidgetSyncManager, WidgetConstraints } from './WidgetSyncManager';
@@ -14,21 +14,12 @@ import { Logger } from '../../shared/logger';
export interface DragConfig {
nodeId: number;
widgetName: string;
widgetType: string;
currentValue: unknown;
constraints?: WidgetConstraints;
onChange?: (value: unknown) => void;
}
/**
* Default sensitivity settings
*/
const DRAG_SENSITIVITY = {
// Pixels to drag for one step change (applies to all numeric types)
pixelsPerStep: 10,
// Minimum pixels to trigger option cycle for combos (increased to prevent accidental cycling)
comboThreshold: 60,
};
// Pixels to drag for one step change
const PIXELS_PER_STEP = 10;
/**
* Drag Value Controller
@@ -37,11 +28,11 @@ const DRAG_SENSITIVITY = {
export class DragValueController {
private element: HTMLElement;
private config: DragConfig;
private isDragging: boolean = false;
isDragging: boolean = false;
private value: unknown = null;
private startX: number = 0;
private startValue: number = 0;
private startOptionIndex: number = 0;
private accumulatedDelta: number = 0;
private startStep: number = 0;
// Bound event handlers for cleanup
private boundPointerDown: (e: PointerEvent) => void;
@@ -70,23 +61,6 @@ export class DragValueController {
this.element.classList.add('draggable');
this.element.addEventListener('pointerdown', this.boundPointerDown);
// Add visual indicator
// this.addDragIndicator();
}
/**
* Add visual drag indicator to the row
*/
private addDragIndicator(): void {
// Check if already has indicator
if (this.element.querySelector('.drag-indicator')) return;
const indicator = document.createElement('span');
indicator.className = 'drag-indicator';
indicator.innerHTML = '⟷'; // Left-right arrow
indicator.setAttribute('aria-hidden', 'true');
this.element.appendChild(indicator);
}
/**
@@ -115,18 +89,10 @@ export class DragValueController {
this.isDragging = true;
this.startX = e.clientX;
this.accumulatedDelta = 0;
// Capture current value
const type = this.config.widgetType.toLowerCase();
if (type === 'number' || type === 'int' || type === 'float' || type === 'slider') {
this.startValue = Number(this.config.currentValue) || 0;
} else if (type === 'combo') {
const options = this.config.constraints?.options ?? [];
const currentStr = String(this.config.currentValue);
this.startOptionIndex = options.findIndex(opt => String(opt) === currentStr);
if (this.startOptionIndex === -1) this.startOptionIndex = 0;
}
// From the widget, which the editor, an undo or the canvas may have changed since the row was drawn
this.value = WidgetSyncManager.getWidgetValue(this.config.nodeId, this.config.widgetName);
this.startValue = Number(this.value) || 0;
this.startStep = WidgetSyncManager.getStep(this.config.constraints, e);
// Add dragging class
this.element.classList.add('dragging');
@@ -155,14 +121,14 @@ export class DragValueController {
e.preventDefault();
e.stopPropagation();
const deltaX = e.clientX - this.startX;
const type = this.config.widgetType.toLowerCase();
if (type === 'number' || type === 'int' || type === 'float' || type === 'slider') {
this.handleNumberDrag(deltaX);
} else if (type === 'combo') {
this.handleComboDrag(deltaX);
// Holding or releasing Shift/Alt mid-drag continues from the current value at the new step
const step = WidgetSyncManager.getStep(this.config.constraints, e);
if (step !== this.startStep) {
this.startX = e.clientX;
this.startValue = Number(this.value) || 0;
this.startStep = step;
}
this.handleNumberDrag(e.clientX - this.startX);
}
/**
@@ -170,64 +136,16 @@ export class DragValueController {
* Uses discrete stepping: every N pixels = exactly 1 step change
*/
private handleNumberDrag(deltaX: number): void {
const constraints = this.config.constraints;
const isInt = this.config.widgetType.toLowerCase() === 'int';
// Determine the step size (1 for integers, 0.1 or widget step for floats)
const step = constraints?.step ?? (isInt ? 1 : 0.1);
// Calculate discrete number of steps (truncate, don't round)
const steps = Math.trunc(deltaX / DRAG_SENSITIVITY.pixelsPerStep);
const steps = Math.trunc(deltaX / PIXELS_PER_STEP);
const target = this.startValue + steps * this.startStep;
if (target === this.value) return;
// Calculate new value
let newValue = this.startValue + (steps * step);
// Apply constraints
if (constraints?.min !== undefined) {
newValue = Math.max(constraints.min, newValue);
}
if (constraints?.max !== undefined) {
newValue = Math.min(constraints.max, newValue);
}
// Round for display precision
if (isInt) {
newValue = Math.round(newValue);
} else {
const precision = constraints?.precision ?? 2;
newValue = Number(newValue.toFixed(precision));
}
// Sync the value
WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, newValue);
this.config.currentValue = newValue;
this.config.onChange?.(newValue);
}
/**
* Handle combo value drag - cycle through options
*/
private handleComboDrag(deltaX: number): void {
const options = this.config.constraints?.options ?? [];
if (options.length === 0) return;
this.accumulatedDelta = deltaX;
// Calculate how many options to move
const optionDelta = Math.floor(Math.abs(this.accumulatedDelta) / DRAG_SENSITIVITY.comboThreshold);
if (optionDelta > 0) {
const direction = deltaX > 0 ? 1 : -1;
let newIndex = this.startOptionIndex + (optionDelta * direction);
// Wrap around
newIndex = ((newIndex % options.length) + options.length) % options.length;
const newValue = options[newIndex];
WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, newValue);
this.config.currentValue = newValue;
this.config.onChange?.(newValue);
}
// Clamping, rounding and precision happen in the sync; undo is recorded once on release
const result = WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, target, false);
if (!result.success) return;
this.value = result.newValue;
this.config.onChange?.(result.newValue);
}
/**
@@ -263,6 +181,7 @@ export class DragValueController {
private endDrag(pointerId?: number): void {
this.isDragging = false;
this.element.classList.remove('dragging');
WidgetSyncManager.recordUndo();
// Release pointer capture if we have a pointer ID
if (pointerId !== undefined) {
@@ -280,25 +199,12 @@ export class DragValueController {
this.element.removeEventListener('lostpointercapture', this.boundLostCapture);
}
/**
* Update the current value (call when value changes externally)
*/
updateValue(value: unknown): void {
this.config.currentValue = value;
}
/**
* Cleanup and remove drag handling
*/
destroy(): void {
this.element.classList.remove('draggable', 'dragging');
// Remove indicator
const indicator = this.element.querySelector('.drag-indicator');
if (indicator) {
indicator.remove();
}
// Remove the pointerdown listener from the element
this.element.removeEventListener('pointerdown', this.boundPointerDown);
@@ -314,8 +220,6 @@ export class DragValueController {
*/
static isTypeSupported(widgetType: string): boolean {
const type = widgetType.toLowerCase();
return [
'number', 'int', 'float', 'slider', 'combo'
].includes(type);
return ['int', 'float', 'slider'].includes(type);
}
}
@@ -28,6 +28,8 @@ export interface InlineControlInstance {
element: HTMLElement;
getValue(): unknown;
setValue(value: unknown): void;
/** True while the control holds an open menu that a re-render would close */
isOpen(): boolean;
destroy(): void;
}
@@ -76,6 +78,7 @@ export class InlineControlFactory {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'inline-toggle-input';
checkbox.setAttribute('aria-label', config.widgetName);
// Handle boolean coercion properly
const boolValue = config.currentValue === true || config.currentValue === 'true';
checkbox.checked = boolValue;
@@ -101,6 +104,7 @@ export class InlineControlFactory {
element: container,
getValue: () => checkbox.checked,
setValue: (v) => { checkbox.checked = Boolean(v); },
isOpen: () => false,
destroy: () => { container.remove(); }
};
}
@@ -118,23 +122,7 @@ export class InlineControlFactory {
// Create trigger element that looks like a select
const trigger = createDropdownTrigger(currentValue, 'inline-dropdown-trigger');
trigger.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
background: rgba(160, 212, 104, 0.12);
border: 1px solid transparent;
border-radius: 6px;
color: var(--info-panel-accent-color, #74b9ff);
cursor: pointer;
min-width: 100px;
max-width: 180px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
`;
trigger.setAttribute('aria-label', config.widgetName);
let activeDropdown: CustomDropdown | null = null;
@@ -150,7 +138,6 @@ export class InlineControlFactory {
if (activeDropdown) {
activeDropdown.hide();
activeDropdown = null;
return;
}
@@ -172,16 +159,6 @@ export class InlineControlFactory {
activeDropdown.show();
});
// Hover effect
trigger.addEventListener('mouseenter', () => {
trigger.style.background = 'rgba(160, 212, 104, 0.18)';
trigger.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
});
trigger.addEventListener('mouseleave', () => {
trigger.style.background = 'rgba(160, 212, 104, 0.12)';
trigger.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.1)';
});
// Keyboard support
trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -199,6 +176,7 @@ export class InlineControlFactory {
currentValue = String(v);
updateDropdownTriggerValue(trigger, currentValue);
},
isOpen: () => activeDropdown !== null,
destroy: () => {
activeDropdown?.hide();
container.remove();
@@ -227,24 +205,6 @@ export class InlineControlFactory {
button.textContent = buttonLabel;
button.type = 'button';
// Style the button to match theme
button.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background: var(--info-panel-accent-color, #a0d468);
color: var(--comfy-menu-bg, #1a1a1a);
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
`;
// Stop propagation on container to prevent parent handlers from interfering
container.addEventListener('click', (e) => e.stopPropagation());
container.addEventListener('mousedown', (e) => e.stopPropagation());
@@ -255,18 +215,9 @@ export class InlineControlFactory {
e.stopPropagation();
e.preventDefault();
// Visual feedback
button.style.transform = 'scale(0.95)';
setTimeout(() => { button.style.transform = ''; }, 100);
// Access the widget callback through the node
const app = (window as any).app;
if (!app?.graph) {
Logger.warn(`[InlineControl] No app.graph available`);
return;
}
const node = app.graph.getNodeById(config.nodeId);
const node = app?.canvas?.graph?.getNodeById(config.nodeId);
if (!node?.widgets) {
Logger.warn(`[InlineControl] Node ${config.nodeId} not found or has no widgets`);
return;
@@ -285,22 +236,13 @@ export class InlineControlFactory {
}
});
// Hover effects
button.addEventListener('mouseenter', () => {
button.style.transform = 'translateY(-1px)';
button.style.boxShadow = '0 3px 8px rgba(0, 0, 0, 0.25)';
});
button.addEventListener('mouseleave', () => {
button.style.transform = '';
button.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.2)';
});
container.appendChild(button);
return {
element: container,
getValue: () => config.currentValue,
setValue: () => { /* Buttons don't have editable values */ },
isOpen: () => false,
destroy: () => { container.remove(); }
};
}
@@ -6,8 +6,7 @@
*/
import { WidgetSyncManager, WidgetConstraints } from './WidgetSyncManager';
import { Logger } from '../../shared/logger';
import { CustomDropdown, createDropdownTrigger, updateDropdownTriggerValue } from '../../shared/CustomDropdown';
import { Icons } from '../../shared/icons';
/**
* Configuration for creating a widget editor
@@ -46,31 +45,24 @@ export class WidgetEditorFactory {
const type = config.widgetType.toLowerCase();
switch (type) {
case 'number':
case 'int':
case 'float':
return this.createNumberEditor(config);
case 'combo':
return this.createComboEditor(config);
case 'toggle':
case 'boolean':
return this.createBooleanEditor(config);
case 'slider':
return this.createSliderEditor(config);
case 'text':
case 'string':
case 'customtext':
case 'multiline':
return this.createTextEditor(config, true);
default:
return this.createTextEditor(config);
return this.createTextEditor(config, false);
}
}
/**
* Create a number input editor
* Create a number input editor. Typing commits on Enter or blur, Escape drops it.
* The arrow keys and steppers step by the widget step (Shift x10, Alt x0.1) and commit at once.
*/
private static createNumberEditor(config: WidgetEditorConfig): WidgetEditorInstance {
const container = document.createElement('div');
@@ -79,84 +71,83 @@ export class WidgetEditorFactory {
const input = document.createElement('input');
input.type = 'number';
input.className = 'widget-editor-input';
input.value = String(config.currentValue ?? 0);
input.setAttribute('aria-label', config.widgetName);
// Apply constraints
if (config.constraints) {
if (typeof config.constraints.min === 'number') {
input.min = String(config.constraints.min);
}
if (typeof config.constraints.max === 'number') {
input.max = String(config.constraints.max);
}
if (typeof config.constraints.step === 'number') {
input.step = String(config.constraints.step);
}
}
const constraints = config.constraints;
const format = (v: unknown) => typeof constraints?.precision === 'number' ? Number(v).toFixed(constraints.precision) : String(v);
input.value = format(config.currentValue ?? 0);
if (typeof constraints?.min === 'number') input.min = String(constraints.min);
if (typeof constraints?.max === 'number') input.max = String(constraints.max);
input.step = String(WidgetSyncManager.getStep(constraints));
// Create stepper buttons
const decrementBtn = document.createElement('button');
decrementBtn.className = 'widget-editor-stepper decrement';
decrementBtn.innerHTML = '−';
decrementBtn.innerHTML = Icons.minus;
decrementBtn.type = 'button';
decrementBtn.setAttribute('aria-label', 'Decrease');
const incrementBtn = document.createElement('button');
incrementBtn.className = 'widget-editor-stepper increment';
incrementBtn.innerHTML = '+';
incrementBtn.innerHTML = Icons.plus;
incrementBtn.type = 'button';
incrementBtn.setAttribute('aria-label', 'Increase');
// Event handlers
const syncValue = () => {
let committedValue = input.value;
const commit = () => {
if (input.value === committedValue) return;
const numValue = parseFloat(input.value);
if (!isNaN(numValue)) {
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue);
config.onChange?.(numValue);
}
if (isNaN(numValue)) return;
const result = WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue);
if (!result.success) return;
input.value = format(result.newValue);
committedValue = input.value;
config.onChange?.(result.newValue);
};
const nudge = (direction: number, e: MouseEvent | KeyboardEvent) => {
const typed = parseFloat(input.value);
const current = Number(config.currentValue);
const base = !isNaN(typed) ? typed : !isNaN(current) ? current : (constraints?.min ?? 0);
input.value = String(base + direction * WidgetSyncManager.getStep(constraints, e));
commit();
};
input.addEventListener('input', syncValue);
input.addEventListener('change', syncValue);
input.addEventListener('focus', () => config.onFocus?.());
input.addEventListener('blur', () => config.onBlur?.());
// On the container, so leaving from a stepper focused with Tab closes the editor too
container.addEventListener('focusout', () => {
commit();
config.onBlur?.();
});
// Keep focus in the input, so Enter and Escape still reach it after a stepper click
decrementBtn.addEventListener('mousedown', (e) => e.preventDefault());
incrementBtn.addEventListener('mousedown', (e) => e.preventDefault());
decrementBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Always use step=1 for fine-grained control via stepper buttons
const step = 1;
const currentVal = parseFloat(input.value);
// Handle NaN (empty input) - fall back to current value or min or 0
const baseValue = isNaN(currentVal) ? (config.constraints?.min ?? Number(config.currentValue) ?? 0) : currentVal;
let newValue = baseValue - step;
// Clamp to constraints
if (config.constraints?.min !== undefined) {
newValue = Math.max(config.constraints.min, newValue);
}
input.value = String(newValue);
syncValue();
nudge(-1, e);
});
incrementBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Always use step=1 for fine-grained control via stepper buttons
const step = 1;
const currentVal = parseFloat(input.value);
// Handle NaN (empty input) - fall back to current value or min or 0
const baseValue = isNaN(currentVal) ? (config.constraints?.min ?? Number(config.currentValue) ?? 0) : currentVal;
let newValue = baseValue + step;
// Clamp to constraints
if (config.constraints?.max !== undefined) {
newValue = Math.min(config.constraints.max, newValue);
}
input.value = String(newValue);
syncValue();
nudge(1, e);
});
// Handle keyboard - Enter to confirm, Escape to cancel
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape' || e.key === 'Enter') {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
nudge(e.key === 'ArrowUp' ? 1 : -1, e);
} else if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
// Drops the typing only; the steps are already committed
e.preventDefault();
input.value = committedValue;
input.blur();
}
});
@@ -170,24 +161,26 @@ export class WidgetEditorFactory {
getValue: () => parseFloat(input.value),
setValue: (v) => { input.value = String(v); },
focus: () => { input.focus(); input.select(); },
destroy: () => { container.remove(); }
destroy: () => {
commit();
container.remove();
}
};
}
/**
* Create a text input editor
* Create a text editor. Commits on blur, Enter (Ctrl/Cmd+Enter in a textarea) and when destroyed; Escape reverts.
*/
private static createTextEditor(config: WidgetEditorConfig): WidgetEditorInstance {
private static createTextEditor(config: WidgetEditorConfig, multiline: boolean): WidgetEditorInstance {
const value = String(config.currentValue ?? '');
const isLongText = value.length > 50 || value.includes('\n');
const container = document.createElement('div');
container.className = 'widget-editor widget-editor-text';
let inputElement: HTMLInputElement | HTMLTextAreaElement;
if (isLongText) {
// Use textarea for long text (prompts)
// A text input would drop newlines, so any value that has them gets a textarea too
if (multiline || value.includes('\n')) {
const textarea = document.createElement('textarea');
textarea.className = 'widget-editor-textarea';
textarea.value = value;
@@ -213,29 +206,33 @@ export class WidgetEditorFactory {
input.value = value;
inputElement = input;
}
inputElement.setAttribute('aria-label', config.widgetName);
// Event handlers
const syncValue = () => {
WidgetSyncManager.syncWidgetValueDebounced(
config.nodeId,
config.widgetName,
inputElement.value,
100
);
config.onChange?.(inputElement.value);
const startValue = inputElement.value;
let committedValue = startValue;
const commit = () => {
if (inputElement.value === committedValue) return;
committedValue = inputElement.value;
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, committedValue);
config.onChange?.(committedValue);
};
inputElement.addEventListener('input', syncValue);
inputElement.addEventListener('focus', () => config.onFocus?.());
inputElement.addEventListener('blur', () => config.onBlur?.());
inputElement.addEventListener('blur', () => {
commit();
config.onBlur?.();
});
inputElement.addEventListener('keydown', (evt) => {
const e = evt as KeyboardEvent;
if (e.key === 'Escape') {
e.preventDefault();
inputElement.value = startValue;
commit();
inputElement.blur();
}
// Allow Enter in textarea, but not in input
if (e.key === 'Enter' && inputElement instanceof HTMLInputElement) {
} else if (e.key === 'Enter' && (inputElement instanceof HTMLInputElement || e.ctrlKey || e.metaKey)) {
e.preventDefault();
inputElement.blur();
}
});
@@ -247,151 +244,13 @@ export class WidgetEditorFactory {
getValue: () => inputElement.value,
setValue: (v) => { inputElement.value = String(v); },
focus: () => { inputElement.focus(); if (inputElement instanceof HTMLInputElement) inputElement.select(); },
destroy: () => { container.remove(); }
};
}
/**
* Create a combo/dropdown editor
* Uses custom dropdown for viewport-aware positioning
*/
private static createComboEditor(config: WidgetEditorConfig): WidgetEditorInstance {
const container = document.createElement('div');
container.className = 'widget-editor widget-editor-combo';
const options = config.constraints?.options ?? [];
let currentValue = String(config.currentValue);
// Create trigger element that looks like a select
const trigger = createDropdownTrigger(currentValue, 'widget-editor-dropdown-trigger');
trigger.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 28px 4px 10px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
color: var(--info-panel-accent-color, #a0d468);
background: rgba(160, 212, 104, 0.12);
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
`;
let activeDropdown: CustomDropdown | null = null;
// Handle trigger click to show dropdown
const showDropdown = () => {
if (activeDropdown) {
activeDropdown.hide();
activeDropdown = null;
return;
}
config.onFocus?.();
activeDropdown = new CustomDropdown({
options: options.map(String),
currentValue,
anchor: trigger,
onChange: (value) => {
currentValue = value;
updateDropdownTriggerValue(trigger, value);
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, value);
config.onChange?.(value);
},
onClose: () => {
activeDropdown = null;
config.onBlur?.();
}
});
activeDropdown.show();
};
trigger.addEventListener('click', showDropdown);
// Hover effect
trigger.addEventListener('mouseenter', () => {
trigger.style.background = 'rgba(160, 212, 104, 0.18)';
trigger.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
});
trigger.addEventListener('mouseleave', () => {
trigger.style.background = 'rgba(160, 212, 104, 0.12)';
trigger.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.1)';
});
// Keyboard support
trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showDropdown();
} else if (e.key === 'Escape' && activeDropdown) {
activeDropdown.hide();
}
});
container.appendChild(trigger);
return {
element: container,
getValue: () => currentValue,
setValue: (v) => {
currentValue = String(v);
updateDropdownTriggerValue(trigger, currentValue);
},
focus: () => trigger.focus(),
destroy: () => {
activeDropdown?.hide();
commit();
container.remove();
}
};
}
/**
* Create a boolean toggle editor
*/
private static createBooleanEditor(config: WidgetEditorConfig): WidgetEditorInstance {
const container = document.createElement('div');
container.className = 'widget-editor widget-editor-boolean';
const label = document.createElement('label');
label.className = 'widget-editor-toggle';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'widget-editor-checkbox';
// Handle boolean coercion properly - string 'false' should be false, not true
const boolValue = config.currentValue === true || config.currentValue === 'true';
checkbox.checked = boolValue;
const slider = document.createElement('span');
slider.className = 'widget-editor-toggle-slider';
// Event handlers
checkbox.addEventListener('change', () => {
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, checkbox.checked);
config.onChange?.(checkbox.checked);
});
checkbox.addEventListener('focus', () => config.onFocus?.());
checkbox.addEventListener('blur', () => config.onBlur?.());
label.appendChild(checkbox);
label.appendChild(slider);
container.appendChild(label);
return {
element: container,
getValue: () => checkbox.checked,
setValue: (v) => { checkbox.checked = Boolean(v); },
focus: () => checkbox.focus(),
destroy: () => { container.remove(); }
};
}
/**
* Create a slider editor
*/
@@ -402,13 +261,14 @@ export class WidgetEditorFactory {
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'widget-editor-range';
slider.setAttribute('aria-label', config.widgetName);
slider.value = String(config.currentValue ?? 0);
// Apply constraints
if (config.constraints) {
slider.min = String(config.constraints.min ?? 0);
slider.max = String(config.constraints.max ?? 100);
slider.step = String(config.constraints.step ?? 0.01);
slider.step = String(WidgetSyncManager.getStep(config.constraints));
}
// Value display
@@ -420,11 +280,12 @@ export class WidgetEditorFactory {
const syncValue = () => {
const numValue = parseFloat(slider.value);
valueDisplay.textContent = numValue.toFixed(config.constraints?.precision ?? 2);
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue);
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue, false);
config.onChange?.(numValue);
};
slider.addEventListener('input', syncValue);
slider.addEventListener('change', () => WidgetSyncManager.recordUndo());
slider.addEventListener('focus', () => config.onFocus?.());
slider.addEventListener('blur', () => config.onBlur?.());
@@ -451,18 +312,4 @@ export class WidgetEditorFactory {
destroy: () => { container.remove(); }
};
}
/**
* Check if a widget type is supported for editing
*/
static isTypeSupported(widgetType: string): boolean {
const supported = [
'number', 'int', 'float',
'text', 'string', 'customtext',
'combo',
'toggle', 'boolean',
'slider'
];
return supported.includes(widgetType.toLowerCase());
}
}
@@ -16,6 +16,7 @@ export interface WidgetConstraints {
min?: number;
max?: number;
step?: number;
round?: number;
precision?: number;
options?: unknown[]; // For combo widgets
}
@@ -35,68 +36,46 @@ export interface SyncResult {
* Handles bidirectional sync between inspector and canvas nodes
*/
export class WidgetSyncManager {
private static debounceTimers: Map<string, number> = new Map();
private static readonly DEBOUNCE_MS = 50;
/**
* Update a widget value on a node
* Update a widget value on a node in the graph on screen
* @param nodeId - The node ID
* @param widgetName - The widget name
* @param value - The new value to set
* @param recordUndo - Record an undo step now; drags pass false and record once on release
* @returns SyncResult indicating success or failure
*/
static syncWidgetValue(nodeId: number, widgetName: string, value: unknown): SyncResult {
static syncWidgetValue(nodeId: number, widgetName: string, value: unknown, recordUndo: boolean = true): SyncResult {
try {
if (!app?.graph) {
return { success: false, error: 'ComfyUI app not available' };
}
const node = app.graph.getNodeById(nodeId);
const node = app?.canvas?.graph?.getNodeById(nodeId);
if (!node) {
return { success: false, error: `Node ${nodeId} not found` };
}
if (!node.widgets || node.widgets.length === 0) {
return { success: false, error: `Node ${nodeId} has no widgets` };
}
const widget = node.widgets.find((w: any) => w.name === widgetName);
const widget = node.widgets?.find((w: any) => w.name === widgetName);
if (!widget) {
return { success: false, error: `Widget "${widgetName}" not found on node ${nodeId}` };
}
const previousValue = widget.value;
// Apply constraints before setting value
const constrainedValue = this.applyConstraints(value, widget);
// Update widget value
widget.value = constrainedValue;
// Trigger widget callback if it exists (some widgets need this for side effects)
if (typeof widget.callback === 'function') {
try {
widget.callback(constrainedValue, app.canvas, node, [0, 0], null);
} catch (callbackError) {
Logger.warn(`Widget callback failed for ${widgetName}:`, callbackError);
// Continue anyway - the value is set
}
// setValue runs the callback, onWidgetChanged and bumps the graph version, like a canvas edit
if (typeof widget.setValue === 'function') {
widget.setValue(constrainedValue, { node, canvas: app.canvas });
} else {
widget.value = constrainedValue;
widget.callback?.(constrainedValue, app.canvas, node, app.canvas.graph_mouse);
}
// Mark node as dirty to trigger redraw
if (typeof node.setDirtyCanvas === 'function') {
node.setDirtyCanvas(true, true);
}
node.graph?.setDirtyCanvas(true, true);
if (recordUndo) this.recordUndo();
// Force canvas redraw
this.triggerCanvasRedraw();
Logger.debug(`[WidgetSync] Updated ${widgetName} on node ${nodeId}: ${previousValue} → ${constrainedValue}`);
Logger.debug(`[WidgetSync] Updated ${widgetName} on node ${nodeId}: ${previousValue} → ${widget.value}`);
return {
success: true,
previousValue,
newValue: constrainedValue
newValue: widget.value
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -106,56 +85,10 @@ export class WidgetSyncManager {
}
/**
* Debounced version of syncWidgetValue for text input
* @param nodeId - The node ID
* @param widgetName - The widget name
* @param value - The new value to set
* @param debounceMs - Debounce delay in milliseconds (default 50ms)
* Let ComfyUI's change tracker pick up the edit as an undo step
*/
static syncWidgetValueDebounced(
nodeId: number,
widgetName: string,
value: unknown,
debounceMs: number = this.DEBOUNCE_MS
): void {
const key = `${nodeId}:${widgetName}`;
// Clear existing timer
const existingTimer = this.debounceTimers.get(key);
if (existingTimer) {
window.clearTimeout(existingTimer);
}
// Set new timer
const timer = window.setTimeout(() => {
this.syncWidgetValue(nodeId, widgetName, value);
this.debounceTimers.delete(key);
}, debounceMs);
this.debounceTimers.set(key, timer);
}
/**
* Get widget constraints for validation
* @param nodeId - The node ID
* @param widgetName - The widget name
* @returns WidgetConstraints or null if widget not found
*/
static getWidgetConstraints(nodeId: number, widgetName: string): WidgetConstraints | null {
try {
if (!app?.graph) return null;
const node = app.graph.getNodeById(nodeId);
if (!node?.widgets) return null;
const widget = node.widgets.find((w: any) => w.name === widgetName);
if (!widget) return null;
return this.extractConstraints(widget);
} catch (error) {
Logger.warn(`[WidgetSync] Error getting constraints:`, error);
return null;
}
static recordUndo(): void {
app?.extensionManager?.workflow?.activeWorkflow?.changeTracker?.checkState();
}
/**
@@ -170,24 +103,39 @@ export class WidgetSyncManager {
if (typeof widget.step === 'number') constraints.step = widget.step;
// Options object (ComfyUI sometimes nests these)
if (widget.options) {
if (typeof widget.options.min === 'number') constraints.min = widget.options.min;
if (typeof widget.options.max === 'number') constraints.max = widget.options.max;
if (typeof widget.options.step === 'number') constraints.step = widget.options.step;
if (typeof widget.options.precision === 'number') constraints.precision = widget.options.precision;
if (Array.isArray(widget.options.values)) {
constraints.options = widget.options.values;
const options = widget.options;
if (options && !Array.isArray(options)) {
if (typeof options.min === 'number') constraints.min = options.min;
if (typeof options.max === 'number') constraints.max = options.max;
// ComfyUI stores step as 10x the input step and the real one as step2
if (typeof options.step2 === 'number') constraints.step = options.step2;
else if (typeof options.step === 'number') constraints.step = options.step / 10;
if (typeof options.round === 'number' && options.round > 0) constraints.round = options.round;
if (typeof options.precision === 'number') constraints.precision = options.precision;
const values = typeof options.values === 'function' ? options.values(widget, widget.node) : options.values;
if (Array.isArray(values)) {
constraints.options = values;
}
}
// Combo widget options (array of values)
if (Array.isArray(widget.options)) {
constraints.options = widget.options;
if (Array.isArray(options)) {
constraints.options = options;
}
return constraints;
}
/**
* Step for one nudge of a number widget. Shift is x10, Alt is x0.1 but never finer than the widget stores.
*/
static getStep(constraints: WidgetConstraints | undefined, e?: { shiftKey: boolean; altKey: boolean }): number {
const step = constraints?.step ?? 1;
if (e?.shiftKey) return step * 10;
if (e?.altKey) return Math.max(step / 10, constraints?.round ?? 10 ** -(constraints?.precision ?? 3));
return step;
}
/**
* Apply constraints to a value
*/
@@ -196,14 +144,20 @@ export class WidgetSyncManager {
const widgetType = this.getWidgetType(widget);
// Handle number types
if (widgetType === 'number' || widgetType === 'INT' || widgetType === 'FLOAT' || widgetType === 'slider') {
if (widgetType === 'INT' || widgetType === 'FLOAT' || widgetType === 'slider') {
let numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(numValue)) {
return widget.value; // Return current value if invalid
}
// Apply min/max
if (constraints.round) {
numValue = Math.round(numValue / constraints.round) * constraints.round;
}
if (typeof constraints.precision === 'number') {
numValue = parseFloat(numValue.toFixed(constraints.precision));
}
if (typeof constraints.min === 'number') {
numValue = Math.max(constraints.min, numValue);
}
@@ -211,14 +165,6 @@ export class WidgetSyncManager {
numValue = Math.min(constraints.max, numValue);
}
// Note: Skip step rounding to preserve user's exact input value
// The widget callback or ComfyUI pipeline will handle step constraints if needed
// Apply precision
if (typeof constraints.precision === 'number') {
numValue = parseFloat(numValue.toFixed(constraints.precision));
}
return numValue;
}
@@ -258,12 +204,13 @@ export class WidgetSyncManager {
// Button widgets - explicit type check
if (type === 'button') return 'button';
// Map ComfyUI widget types to our categories
if (type === 'number' || type === 'int' || type === 'float') return 'number';
// Map ComfyUI widget types to our categories. INT and FLOAT both use 'number'; INT has precision 0.
if (type === 'number' || type === 'knob') return widget.options?.precision === 0 ? 'INT' : 'FLOAT';
if (type === 'combo' || type === 'string' && Array.isArray(widget.options)) return 'combo';
if (type === 'toggle' || type === 'boolean') return 'boolean';
if (type === 'slider') return 'slider';
if (type === 'text' || type === 'string' || type === 'customtext') return 'text';
if (type === 'customtext' || widget.options?.multiline) return 'multiline';
if (type === 'text' || type === 'string') return 'text';
// Check for array options (combo indicator)
if (Array.isArray(widget.options) && widget.options.length > 0) {
@@ -305,80 +252,22 @@ export class WidgetSyncManager {
// Widgets marked as readonly
if (widget.readonly) return false;
// Check type
// Writing a string over an object value would corrupt it
if (widget.value !== null && typeof widget.value === 'object') return false;
const type = this.getWidgetType(widget);
// Button widgets are actionable, not editable
if (type === 'button') return false;
// DOM widgets own their element; only the multiline textarea maps to a plain value
if (widget.element && type !== 'multiline') return false;
const editableTypes = ['number', 'text', 'combo', 'boolean', 'slider', 'INT', 'FLOAT'];
return editableTypes.includes(type) || editableTypes.includes(type.toUpperCase());
}
/**
* Force canvas redraw after widget update
*/
static triggerCanvasRedraw(): void {
try {
if (app?.canvas) {
if (typeof app.canvas.setDirty === 'function') {
app.canvas.setDirty(true, true);
}
if (typeof app.canvas.draw === 'function') {
app.canvas.draw(true, true);
}
}
} catch (error) {
Logger.warn(`[WidgetSync] Canvas redraw failed:`, error);
}
return ['INT', 'FLOAT', 'text', 'multiline', 'combo', 'boolean', 'slider'].includes(type);
}
/**
* Get current value of a widget
*/
static getWidgetValue(nodeId: number, widgetName: string): unknown | null {
try {
if (!app?.graph) return null;
const node = app.graph.getNodeById(nodeId);
if (!node?.widgets) return null;
const widget = node.widgets.find((w: any) => w.name === widgetName);
return widget?.value ?? null;
} catch (error) {
return null;
}
}
/**
* Validate a value against widget constraints without applying
*/
static validateValue(nodeId: number, widgetName: string, value: unknown): { valid: boolean; error?: string } {
const constraints = this.getWidgetConstraints(nodeId, widgetName);
if (!constraints) {
return { valid: true }; // No constraints = valid
}
// Number validation
if (typeof value === 'number') {
if (typeof constraints.min === 'number' && value < constraints.min) {
return { valid: false, error: `Value must be at least ${constraints.min}` };
}
if (typeof constraints.max === 'number' && value > constraints.max) {
return { valid: false, error: `Value must be at most ${constraints.max}` };
}
}
// Combo validation - use string comparison
if (constraints.options) {
const valueStr = String(value);
const hasMatch = constraints.options.some(opt => String(opt) === valueStr);
if (!hasMatch) {
return { valid: false, error: `Invalid option selected` };
}
}
return { valid: true };
const widget = app?.canvas?.graph?.getNodeById(nodeId)?.widgets?.find((w: any) => w.name === widgetName);
return widget?.value ?? null;
}
}
+2 -2
View File
@@ -106,11 +106,11 @@ export class EventHandler {
config.offsetY += stepSize;
offsetChanged = true;
e.preventDefault();
} else if (e.key === "ArrowLeft") {
} else if (e.key === "ArrowLeft" && !e.defaultPrevented) { // Prevented when the info panel took it
config.offsetX -= stepSize;
offsetChanged = true;
e.preventDefault();
} else if (e.key === "ArrowRight") {
} else if (e.key === "ArrowRight" && !e.defaultPrevented) {
config.offsetX += stepSize;
offsetChanged = true;
e.preventDefault();
+4 -45
View File
@@ -6,7 +6,7 @@
import type { ComfyApp } from '../types/comfyui';
import { findLiteGraphCanvas } from '../shared/utils';
import { DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET, INFO_PANEL_ID } from '../shared/constants';
import { DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET } from '../shared/constants';
import { registerGlassSettings, registerAccessibilitySettings } from '../shared/settings';
import { ConfigManager } from './ConfigManager';
import { MagnifierState } from './MagnifierState';
@@ -42,12 +42,6 @@ export class MagnifyGlass {
/** Last known mouse position for better initial positioning */
lastKnownMousePosition: { x: number; y: number };
/** Whether we're currently over media (for info panel) */
isOverMedia: boolean;
/** Current media element under cursor */
currentMediaElement: HTMLImageElement | HTMLVideoElement | null;
/** Render loop ID; the loop runs while the glass is active and visible */
private animationLoopId: number | null;
@@ -75,10 +69,6 @@ export class MagnifyGlass {
// Last known mouse position for better initial positioning
this.lastKnownMousePosition = { x: 0, y: 0 };
// Media tracking
this.isOverMedia = false;
this.currentMediaElement = null;
this.animationLoopId = null;
}
@@ -159,22 +149,13 @@ export class MagnifyGlass {
/**
* Force hide all components including extensions (Info Panel, etc).
* This overcomes the "pinned" state of the info panel when the main tool is deactivated.
* This hides the info panel even while it floats, when the main tool is deactivated.
*/
private forceHideAllComponents(): void {
this.state.active = false;
this.stopAnimationLoop();
this.ui.hide();
// 1. Nuclear option: Direct DOM ID targeting
// This is necessary because in some cases object references might be stale or logic might prevent hiding
const panelEl = document.getElementById(INFO_PANEL_ID);
if (panelEl) {
panelEl.style.display = 'none';
// Also force opacity 0 just in case transition handles display
panelEl.style.opacity = '0';
}
// Iterate through all registered extensions and hide them if they have a hide method
const extensions = window.comfyUIMagnifyGlassExtensions;
if (extensions && extensions.length > 0) {
@@ -182,10 +163,6 @@ export class MagnifyGlass {
// Check basically any likely property for a UI manager or direct hide method
if (extension && extension.uiManager && typeof extension.uiManager.hide === 'function') {
extension.uiManager.hide();
// Explicitly force display none if available to be extra sure
if (extension.uiManager.elements && extension.uiManager.elements.panel) {
extension.uiManager.elements.panel.style.display = 'none';
}
}
});
}
@@ -354,26 +331,8 @@ export class MagnifyGlass {
}
}
// Reset inspector panel position if exists
const extensions = window.comfyUIMagnifyGlassExtensions;
if (extensions && extensions.length > 0) {
extensions.forEach((extension: any) => {
if (extension?.stateManager) {
extension.stateManager.state.isPanelPinned = false;
extension.stateManager.state.isPanelLocked = false;
extension.stateManager.state.isAutoPinned = false;
extension.stateManager.state.pinnedPosition = { x: 0, y: 0 };
extension.stateManager.state.lastPinnedPosition = null;
if (extension.positionManager) {
extension.positionManager.positionPanel();
}
if (extension.uiManager?.updateControlStates) {
extension.uiManager.updateControlStates();
}
}
});
}
// The inspector follows the glass again
window.comfyUIMagnifyGlassExtensions?.forEach(extension => extension.resetPosition());
this.debugger.log("Reset: Magnify glass and inspector panel positions restored to defaults");
}
+14 -52
View File
@@ -71,10 +71,6 @@ interface PopOutInfo {
magnifier?: {
zoomFactor: number;
};
media?: {
tagName: string;
naturalSize?: string;
};
}
/**
@@ -91,7 +87,8 @@ export class PopOutManager {
private lastPongTime: number = 0;
private pingInterval: number | null = null;
private viewerUrl: string;
private currentTheme: string = 'dark'; // Default theme
// The theme last sent to the viewer, null until it connects
private sentTheme: string | null = null;
/** Throttle frame sending to ~30fps */
private lastFrameTime: number = 0;
@@ -168,16 +165,19 @@ export class PopOutManager {
*/
private handleMessage(message: PopOutMessage): void {
switch (message.type) {
case 'pong':
case 'pong': {
this.lastPongTime = Date.now();
if (!this.isOpen) {
this.isOpen = true;
this.sentTheme = null;
Logger.debug('[PopOut] Viewer tab connected');
if (this.onStateChange) this.onStateChange(true);
// Send current theme immediately upon connection
this.sendConfig({ theme: this.currentTheme });
}
// The viewer follows the palette's light or dark, which the frontend keeps as body.dark-theme
const theme = document.body.classList.contains('dark-theme') ? 'dark' : 'light';
if (theme !== this.sentTheme) this.sendConfig({ theme });
break;
}
case 'close':
this.isOpen = false;
this.popOutWindow = null;
@@ -203,8 +203,8 @@ export class PopOutManager {
// Popout requested to zoom to a node
if (typeof message.data === 'number') {
const app = (window as any).app;
const node = app.graph.getNodeById(message.data);
if (node && app.canvas) {
const node = app.canvas?.graph?.getNodeById(message.data);
if (node) {
app.canvas.centerOnNode(node);
}
}
@@ -258,12 +258,7 @@ export class PopOutManager {
}
const app = (window as any).app;
if (!app?.graph) {
Logger.warn('[PopOut] No app.graph available for button click');
return;
}
const node = app.graph.getNodeById(buttonData.nodeId);
const node = app?.canvas?.graph?.getNodeById(buttonData.nodeId);
if (!node?.widgets) {
Logger.warn(`[PopOut] Node ${buttonData.nodeId} not found or has no widgets`);
return;
@@ -373,44 +368,22 @@ export class PopOutManager {
}
}
/**
* Send configuration to the pop-out viewer.
*/
/**
* Send configuration to the pop-out viewer.
*/
sendConfig(config: Partial<PopOutConfig>): void {
if (!this.channel) return;
// Merge with current theme if not provided
const finalConfig = {
theme: this.currentTheme,
...config
};
// Update local theme cache if provided in config
if (config.theme) {
this.currentTheme = config.theme;
this.sentTheme = config.theme;
}
this.sendMessage({
type: 'config',
data: finalConfig
data: config
});
}
/**
* Update the viewer theme.
* @param theme - Theme name (e.g. 'dark', 'light')
*/
updateTheme(theme: string): void {
if (this.currentTheme === theme) return;
this.currentTheme = theme;
this.sendConfig({ theme });
Logger.debug(`[PopOut] Theme updated to: ${theme}`);
}
/**
* Send inspector info to the pop-out viewer.
* @param info - Inspector panel information
@@ -437,7 +410,7 @@ export class PopOutManager {
try {
// Access nodes from ComfyUI's app object
const app = (window as any).app;
const nodes = app?.graph?._nodes || [];
const nodes = app?.canvas?.graph?._nodes || [];
let nodeList: { id: number; title: string; type: string; order?: number }[];
@@ -541,17 +514,6 @@ export class PopOutManager {
};
}
// Handle media - map from GatheredInfo mediaElement
if (info.mediaElement || info.media) {
const media = info.mediaElement || info.media;
sanitized.media = {
tagName: String(media.tagName || media.type || ''),
naturalSize: media.naturalWidth && media.naturalHeight
? `${media.naturalWidth}×${media.naturalHeight}`
: media.naturalSize
};
}
return sanitized;
} catch (e) {
Logger.error('[PopOut] Failed to sanitize info:', e);
+4
View File
@@ -23,6 +23,10 @@ app.registerExtension({
};
checkDependencies();
},
// Undo, redo and loading a workflow replace the nodes without moving the mouse, which drives the other updates
afterConfigureGraph() {
if (window.comfyUIMagnifyGlass?.state.active) window.infoPanelManager?.scheduleInfoUpdate();
}
});
+115 -157
View File
@@ -3,132 +3,151 @@
*
* A custom dropdown component that stays within viewport bounds.
* Replaces native select elements for better positioning control.
* Its filter input holds focus while it is open, so typing never reaches ComfyUI's hotkeys.
*/
import { Icons } from './icons';
export interface CustomDropdownConfig {
options: string[];
currentValue: string;
anchor: HTMLElement;
onChange: (value: string) => void;
onClose?: () => void;
theme?: string;
}
let dropdownCount = 0;
/**
* Custom dropdown that positions itself within viewport bounds.
* Use this instead of native <select> elements when positioning control is needed.
*/
export class CustomDropdown {
private dropdown: HTMLElement | null = null;
private filter: HTMLInputElement | null = null;
private visibleItems: HTMLElement[] = [];
private config: CustomDropdownConfig;
private closeHandler: ((e: MouseEvent) => void) | null = null;
private keyHandler: ((e: KeyboardEvent) => void) | null = null;
private scrollHandler: ((e: Event) => void) | null = null;
private currentIndex: number = 0; // Track active item for keyboard navigation
private listenerTimer: ReturnType<typeof setTimeout> | null = null;
private activeItem: HTMLElement | null = null; // Item that Enter picks, moved by the arrows and hover
constructor(config: CustomDropdownConfig) {
this.config = config;
}
/**
* Show the dropdown
* Show the dropdown. Does nothing while it is already open.
*/
show(): void {
this.hide(); // Close any existing dropdown first
if (this.dropdown) return;
const id = `custom-dropdown-${++dropdownCount}`;
const dropdown = document.createElement('div');
dropdown.className = `custom-dropdown-menu${this.config.theme ? ` theme-${this.config.theme}` : ''}`;
dropdown.style.cssText = `
position: fixed;
z-index: 100001;
overflow-y: auto;
background: var(--comfy-menu-bg, #1a1a1f);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
min-width: 120px;
`;
dropdown.className = 'custom-dropdown-menu';
// Appended to body, so it takes the size of the text it opens from
dropdown.style.fontSize = getComputedStyle(this.config.anchor).fontSize;
// Build dropdown items
this.config.options.forEach((optionValue) => {
const filter = document.createElement('input');
filter.type = 'text';
filter.className = 'custom-dropdown-filter';
filter.placeholder = 'Filter…';
filter.setAttribute('role', 'combobox');
filter.setAttribute('aria-label', 'Filter options');
filter.setAttribute('aria-autocomplete', 'list');
filter.setAttribute('aria-expanded', 'true');
filter.setAttribute('aria-controls', `${id}-list`);
const list = document.createElement('div');
list.className = 'custom-dropdown-list';
list.id = `${id}-list`;
list.setAttribute('role', 'listbox');
const items = this.config.options.map((optionValue, i) => {
const item = document.createElement('div');
item.className = 'custom-dropdown-item';
item.id = `${id}-${i}`;
item.dataset.value = optionValue;
const isSelected = optionValue === this.config.currentValue;
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border-color, #333);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
color: ${isSelected ? 'var(--info-panel-accent-color, #4ecdc4)' : 'inherit'};
background: ${isSelected ? 'rgba(78, 205, 196, 0.1)' : 'transparent'};
white-space: nowrap;
`;
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', String(optionValue === this.config.currentValue));
item.textContent = optionValue;
// Hover effect
item.addEventListener('mouseenter', () => {
if (!isSelected) {
item.style.background = 'var(--comfy-input-bg, #3a3a3a)';
}
});
item.addEventListener('mouseleave', () => {
item.style.background = isSelected ? 'rgba(78, 205, 196, 0.1)' : 'transparent';
});
// Selection handler
item.addEventListener('mouseenter', () => this.setActive(this.visibleItems.indexOf(item), false));
item.addEventListener('click', (e) => {
e.stopPropagation();
this.config.onChange(optionValue);
this.hide();
this.select(optionValue);
});
dropdown.appendChild(item);
list.appendChild(item);
return item;
});
// Remove border from last item
const lastItem = dropdown.lastElementChild as HTMLElement;
if (lastItem) {
lastItem.style.borderBottom = 'none';
}
filter.addEventListener('input', () => {
const query = filter.value.toLowerCase();
for (const item of items) {
item.hidden = !item.dataset.value!.toLowerCase().includes(query);
}
this.visibleItems = items.filter(item => !item.hidden);
this.setActive(0);
});
dropdown.append(filter, list);
document.body.appendChild(dropdown);
this.dropdown = dropdown;
// Initialize currentIndex based on currently selected value
this.currentIndex = this.config.options.indexOf(this.config.currentValue);
if (this.currentIndex < 0) this.currentIndex = 0;
// Make dropdown focusable for keyboard events
dropdown.setAttribute('tabindex', '-1');
this.filter = filter;
this.visibleItems = items;
// Position within viewport
this.positionWithinViewport();
// Scroll to selected item
this.scrollToSelected();
// Start on the current value
this.setActive(Math.max(0, this.config.options.indexOf(this.config.currentValue)));
// Setup close handlers
this.setupCloseHandlers();
// Focus dropdown for keyboard navigation
dropdown.focus();
this.config.anchor.setAttribute('aria-expanded', 'true');
filter.focus();
}
/**
* Hide the dropdown
* Hide the dropdown. Fires onClose once, and does nothing when it is not open.
*/
hide(): void {
if (this.dropdown && this.dropdown.parentNode) {
this.dropdown.parentNode.removeChild(this.dropdown);
this.dropdown = null;
}
if (!this.dropdown) return;
this.dropdown.remove();
this.dropdown = null;
this.filter = null;
this.visibleItems = [];
this.activeItem = null;
this.cleanupHandlers();
this.config.anchor.setAttribute('aria-expanded', 'false');
this.config.onClose?.();
}
private select(value: string): void {
this.config.onChange(value);
this.hide();
this.config.anchor.focus();
}
/**
* Move the active item, clamped to the visible items
*/
private setActive(index: number, scroll = true): void {
this.activeItem?.classList.remove('active');
this.activeItem = this.visibleItems[Math.max(0, Math.min(index, this.visibleItems.length - 1))] ?? null;
if (this.activeItem) {
this.activeItem.classList.add('active');
if (scroll) this.activeItem.scrollIntoView({ block: 'nearest' });
this.filter?.setAttribute('aria-activedescendant', this.activeItem.id);
} else {
this.filter?.removeAttribute('aria-activedescendant');
}
}
/**
* Position dropdown within viewport bounds
*/
@@ -141,9 +160,6 @@ export class CustomDropdown {
const viewportHeight = window.innerHeight;
const anchorRect = this.config.anchor.getBoundingClientRect();
// Temporarily remove constraints to measure natural size
this.dropdown.style.maxWidth = 'none';
this.dropdown.style.maxHeight = 'none';
const naturalRect = this.dropdown.getBoundingClientRect();
// Calculate available space in each direction
@@ -151,18 +167,16 @@ export class CustomDropdown {
const spaceAbove = anchorRect.top - margin - gap;
const spaceRight = viewportWidth - anchorRect.left - margin;
// Vertical positioning: prefer below, flip above if needed
let top: number;
// Vertical positioning: prefer below, flip above if needed. Above, it is anchored by its
// bottom edge so it stays against the anchor when the filter shrinks the list
let maxHeight: number;
if (spaceBelow >= naturalRect.height || spaceBelow >= spaceAbove) {
// Position below anchor
top = anchorRect.bottom + gap;
this.dropdown.style.top = `${anchorRect.bottom + gap}px`;
maxHeight = Math.max(100, spaceBelow);
} else {
// Position above anchor
this.dropdown.style.bottom = `${viewportHeight - anchorRect.top + gap}px`;
maxHeight = Math.max(100, spaceAbove);
top = anchorRect.top - gap - Math.min(naturalRect.height, maxHeight);
}
// Horizontal positioning: prefer left-aligned with anchor, shift if needed
@@ -178,33 +192,14 @@ export class CustomDropdown {
maxWidth = viewportWidth - margin * 2;
}
// Ensure minimum width matches anchor
const minWidth = Math.max(120, anchorRect.width);
// At least as wide as the anchor, and a fixed width so filtering does not resize it
const width = Math.min(Math.max(120, anchorRect.width, naturalRect.width), maxWidth);
// Apply final position and constraints
this.dropdown.style.top = `${Math.max(margin, top)}px`;
this.dropdown.style.left = `${Math.max(margin, left)}px`;
this.dropdown.style.minWidth = `${minWidth}px`;
this.dropdown.style.maxWidth = `${maxWidth}px`;
this.dropdown.style.width = `${width}px`;
this.dropdown.style.maxHeight = `${maxHeight}px`;
}
/**
* Scroll to the currently selected item
*/
private scrollToSelected(): void {
if (!this.dropdown) return;
const selectedItem = this.dropdown.querySelector(
`[data-value="${CSS.escape(this.config.currentValue)}"]`
) as HTMLElement;
if (selectedItem) {
// Scroll the item into view, centered if possible
selectedItem.scrollIntoView({ block: 'nearest' });
}
}
/**
* Setup event handlers for closing the dropdown
*/
@@ -218,65 +213,34 @@ export class CustomDropdown {
}
};
// Helper to update active item visually
const updateActiveItem = (newIndex: number) => {
if (!this.dropdown) return;
const items = this.dropdown.querySelectorAll('.custom-dropdown-item');
if (items.length === 0) return;
// Clamp index
if (newIndex < 0) newIndex = 0;
if (newIndex >= items.length) newIndex = items.length - 1;
// Remove highlight from old item
if (this.currentIndex >= 0 && this.currentIndex < items.length) {
const oldItem = items[this.currentIndex] as HTMLElement;
const isOldSelected = this.config.options[this.currentIndex] === this.config.currentValue;
oldItem.style.background = isOldSelected ? 'rgba(78, 205, 196, 0.1)' : 'transparent';
}
this.currentIndex = newIndex;
// Highlight new item
const newItem = items[this.currentIndex] as HTMLElement;
newItem.style.background = 'var(--comfy-input-bg, #3a3a3a)';
newItem.scrollIntoView({ block: 'nearest' });
};
// Key handler for navigation and closing
this.keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Tab closes it like a native select, and moves on from the trigger
if (e.key === 'Tab') {
this.hide();
this.config.anchor.focus();
return;
}
if (e.key !== 'Escape' && e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Enter') return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
updateActiveItem(this.currentIndex + 1);
const index = this.activeItem ? this.visibleItems.indexOf(this.activeItem) : -1;
if (e.key === 'Escape') {
this.hide();
this.config.anchor.focus();
} else if (e.key === 'ArrowDown') {
this.setActive(index + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
updateActiveItem(this.currentIndex - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (this.currentIndex >= 0 && this.currentIndex < this.config.options.length) {
this.config.onChange(this.config.options[this.currentIndex]);
this.hide();
}
this.setActive(index - 1);
} else if (this.activeItem) {
this.select(this.activeItem.dataset.value!);
}
};
// Close on scroll outside the dropdown (not when scrolling within it)
this.scrollHandler = (e: Event) => {
// Don't close if scrolling within the dropdown itself
if (this.dropdown && e.target instanceof Node && this.dropdown.contains(e.target)) {
return;
}
@@ -284,7 +248,8 @@ export class CustomDropdown {
};
// Delay adding listeners to avoid immediate closure from the opening click
setTimeout(() => {
this.listenerTimer = setTimeout(() => {
this.listenerTimer = null;
// Use window and capture phase for key events to intercept before ComfyUI
window.addEventListener('keydown', this.keyHandler!, true);
// Use capture phase (true) to get events before they can be stopped
@@ -298,13 +263,17 @@ export class CustomDropdown {
* Cleanup event handlers
*/
private cleanupHandlers(): void {
if (this.listenerTimer) {
clearTimeout(this.listenerTimer);
this.listenerTimer = null;
}
if (this.closeHandler) {
document.removeEventListener('mousedown', this.closeHandler, true);
document.removeEventListener('click', this.closeHandler, true);
this.closeHandler = null;
}
if (this.keyHandler) {
window.removeEventListener('keydown', this.keyHandler, true); // Match window listener
window.removeEventListener('keydown', this.keyHandler, true);
this.keyHandler = null;
}
if (this.scrollHandler) {
@@ -326,26 +295,15 @@ export function createDropdownTrigger(
trigger.setAttribute('tabindex', '0');
trigger.setAttribute('role', 'combobox');
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const valueSpan = document.createElement('span');
valueSpan.className = 'custom-dropdown-value';
valueSpan.textContent = currentValue;
valueSpan.style.cssText = `
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const arrow = document.createElement('span');
arrow.className = 'custom-dropdown-arrow';
arrow.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`;
arrow.style.cssText = `
flex-shrink: 0;
display: flex;
align-items: center;
opacity: 0.7;
`;
arrow.innerHTML = Icons.chevronDown;
trigger.appendChild(valueSpan);
trigger.appendChild(arrow);
+12 -9
View File
@@ -45,6 +45,15 @@ export const PANEL_POSITIONS = [
export type PanelPosition = typeof PANEL_POSITIONS[number];
// Info panel fonts; each is used only when installed on this computer
export const PANEL_FONTS = [
"System Default", "Inter", "Roboto", "JetBrains Mono", "Fira Code",
"IBM Plex Sans", "Space Grotesk", "Lexend", "Outfit",
"Cinzel", "Playfair Display", "Orbitron", "Dancing Script", "Amatic SC",
"Comfortaa", "Righteous", "Bangers", "Press Start 2P", "Audiowide",
"monospace"
] as const;
// Available keys for settings
export const ACTIVATION_KEYS = ["x", "z", "m", "q", "v", "c"] as const;
export const RESET_KEYS = ["r", "o", "p", "k", "l"] as const;
@@ -62,7 +71,7 @@ export const DEFAULT_OFFSET_STEP = 5;
// Panel Constants
export const DEFAULT_PANEL_WIDTH = 300;
export const DEFAULT_PANEL_MAX_HEIGHT = 300;
export const DEFAULT_PANEL_MAX_HEIGHT = 600;
export const DEFAULT_PANEL_OPACITY = 100;
export const INFO_PANEL_ID = "comfyui-magnify-info-panel-pro-v2";
@@ -74,17 +83,11 @@ export const Z_INDEX = {
CONTROLS: 99998
} as const;
// Animation durations (ms)
export const ANIMATION = {
PANEL_TRANSITION: 300,
AUTO_COLLAPSE_DELAY: 1500,
THEME_DETECTION_INTERVAL: 2000
} as const;
// LocalStorage keys
export const STORAGE_KEYS = {
OFFSET_X: 'comfyui_magnify_offset_x',
OFFSET_Y: 'comfyui_magnify_offset_y'
OFFSET_Y: 'comfyui_magnify_offset_y',
INSPECTOR_LAYOUT: 'comfyui_magnify_inspector_layout'
} as const;
// BroadcastChannel for pop-out tab communication
+40 -49
View File
@@ -12,19 +12,13 @@ export const Icons = {
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>`,
// Unlock icon - panel is floating/following cursor
unlock: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
</svg>`,
// Lock icon - panel is pinned
// Lock icon - lock the panel to a node
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>`,
// Pin icon - lock panel position
// Pin icon - the panel floats instead of following the glass
pin: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="17" x2="12" y2="22"/>
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/>
@@ -37,17 +31,6 @@ export const Icons = {
</svg>`,
// Camera/image icon - media section
camera: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>`,
// Target/crosshair icon - node section
target: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="6"/>
<circle cx="12" cy="12" r="2"/>
</svg>`,
// Location/map pin icon - empty state
mapPin: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -60,25 +43,47 @@ export const Icons = {
<polyline points="9 18 15 12 9 6"/>
</svg>`,
// Chevron left - previous node in execution order
chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>`,
// Arrow left/right - back and forward through the visited nodes
arrowLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>`,
arrowRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>`,
// Chevron down - dropdown triggers
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>`,
// Minus icon - minimize button
minus: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>`,
// Inspector/info icon
info: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
// Plus icon - expand button and stepper
plus: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>`,
// Box/node icon
box: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
// Check icon - copied to clipboard
check: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>`,
// Inspector/info icon
// Box/node icon
// Magnet icon - persist/sticky mode
magnet: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 15-4-4 6.75-6.77a7.79 7.79 0 0 1 11 11L13 22l-4-4 6.39-6.36a2.2 2.2 0 0 0-3.1-3.13l-6.29 6.49Z"/>
@@ -99,7 +104,6 @@ export const Icons = {
<polyline points="19 9 22 12 19 15"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="12" y1="2" x2="12" y2="22"/>
<line x1="12" y1="2" x2="12" y2="22"/>
</svg>`,
// Reset icon - reset position
@@ -108,23 +112,18 @@ export const Icons = {
<path d="M3 3v5h5"/>
</svg>`,
// Pointer in a frame - follow the canvas selection
selection: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"/>
<path d="M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/>
</svg>`,
// Cursor icon - cursor preview toggle
cursor: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/>
<path d="m13 13 6 6"/>
</svg>`,
// Pause icon - for Hold Info
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>`,
// Play icon - for Resume Info
play: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>`,
// Focus icon - for Focus Node button
focus: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
@@ -137,11 +136,3 @@ export const Icons = {
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>`
};
/**
* Get an icon wrapped in a span for proper sizing
*/
export function getIcon(name: keyof typeof Icons, className: string = ''): string {
const icon = Icons[name] || '';
return `<span class="icon ${className}">${icon}</span>`;
}
-163
View File
@@ -1,163 +0,0 @@
/**
* ComfyUI MagnifyGlass - Settings Configuration
*
* Centralized settings definitions and registration functions.
*
* This file re-exports from the modular settings directory for backwards compatibility.
*/
// Re-export everything from the settings module
export type { GlassSettings, PanelSettings, AccessibilitySettings, AllSettings } from './settings/types';
export { DEFAULT_GLASS_SETTINGS, DEFAULT_PANEL_SETTINGS, DEFAULT_ACCESSIBILITY_SETTINGS } from './settings/defaults';
export { registerGlassSettings } from './settings/glassSettings';
export { registerPanelSettings } from './settings/panelSettings';
export { registerAccessibilitySettings } from './settings/accessibilitySettings';
// Legacy getSettingConfig function for backwards compatibility
// @ts-ignore
import { app } from "/scripts/app.js";
import { PANEL_POSITIONS } from './constants';
import { DEFAULT_PANEL_SETTINGS } from './settings/defaults';
/**
* Get a setting configuration object for ComfyUI settings registration.
* This function generates the setting config based on the key and default value.
*
* @param key - The setting key
* @param defaultValue - The default value for the setting
* @returns The setting configuration or null if not found
*/
export function getSettingConfig(key: string, defaultValue: any): any {
const configs: Record<string, any> = {
"🔍MagnifyGlass.InfoPanelEnabled": {
id: key,
name: "📋 Magnify Glass: Info Panel",
type: "combo",
options: [
{ value: true, text: "Enabled" },
{ value: false, text: "Disabled" }
],
defaultValue,
tooltip: "Enable or disable the professional information panel."
},
"🔍MagnifyGlass.InfoPanelPosition": {
id: key,
name: "📋 Info Panel: Position",
type: "combo",
options: PANEL_POSITIONS.map(pos => ({ value: pos, text: pos })),
defaultValue,
tooltip: "Position of the info panel relative to the magnify glass."
},
"🔍MagnifyGlass.InfoPanelWidth": {
id: key,
name: "📋 Info Panel: Width (px)",
type: "slider",
defaultValue,
min: 200,
max: 600,
step: 20,
tooltip: "Width of the info panel in pixels."
},
"🔍MagnifyGlass.InfoPanelMaxHeight": {
id: key,
name: "📋 Info Panel: Max Height (px)",
type: "slider",
defaultValue,
min: 200,
max: 1500,
step: 50,
tooltip: "Maximum height of the info panel."
},
"🔍MagnifyGlass.InfoPanelOpacity": {
id: key,
name: "📋 Info Panel: Opacity (%)",
type: "slider",
defaultValue,
min: 10,
max: 100,
step: 5,
tooltip: "Opacity of the info panel background."
},
"🔍MagnifyGlass.InfoPanelAnimations": {
id: key,
name: "📋 Info Panel: Animations",
type: "combo",
options: [
{ value: true, text: "Enabled" },
{ value: false, text: "Disabled" }
],
defaultValue,
tooltip: "Enable smooth animations for the info panel."
},
"🔍MagnifyGlass.ShowInspectorTab": {
id: key,
name: "📋 Info Panel: Show Inspector",
type: "combo",
options: [
{ value: true, text: "Yes" },
{ value: false, text: "No" }
],
defaultValue,
tooltip: "Show the inspector tab with cursor and canvas information."
},
"🔍MagnifyGlass.ToggleHotkey": {
id: key,
name: "📋 Info Panel: Toggle Hotkey",
type: "combo",
options: ["i", "j", "k", "l", "n", "m"].map(k => ({ value: k, text: k.toUpperCase() })),
defaultValue,
tooltip: "Key to toggle the info panel visibility."
},
"🔍MagnifyGlass.GlassPreviewToggleHotkey": {
id: key,
name: "📋 Info Panel: Glass Toggle Hotkey",
type: "combo",
options: ["g", "f", "v", "b"].map(k => ({ value: k, text: k.toUpperCase() })),
defaultValue,
tooltip: "Key to toggle the magnify glass preview visibility."
},
"🔍MagnifyGlass.PinPanelHotkey": {
id: key,
name: "📋 Info Panel: Pin Hotkey",
type: "combo",
options: ["u", "p", "y", "t"].map(k => ({ value: k, text: k.toUpperCase() })),
defaultValue,
tooltip: "Key to pin/unlock the info panel at the current mouse location."
},
"🔍MagnifyGlass.ShowHoveringControls": {
id: key,
name: "📋 Info Panel: Floating Controls",
type: "combo",
options: [
{ value: true, text: "Show" },
{ value: false, text: "Hide" }
],
defaultValue,
tooltip: "Show floating control buttons near the info panel."
},
"🔍MagnifyGlass.ControlsPosition": {
id: key,
name: "📋 Info Panel: Controls Position",
type: "combo",
options: ["top-left", "top-right", "bottom-left", "bottom-right", "top", "bottom", "left", "right"].map(p => ({ value: p, text: p })),
defaultValue,
tooltip: "Position of the floating control buttons."
},
"🔍MagnifyGlass.InfoPanelTextColor": {
id: key,
name: "🎨 Info Panel: Text Color",
type: "color",
defaultValue,
tooltip: "Color for text in the info panel."
},
"🔍MagnifyGlass.InfoPanelAccentColor": {
id: key,
name: "🎨 Info Panel: Accent Color",
type: "color",
defaultValue,
tooltip: "Accent color for highlights in the info panel."
}
};
return configs[key] || null;
}
+2 -4
View File
@@ -49,8 +49,6 @@ export const DEFAULT_PANEL_SETTINGS: PanelSettings = {
"🔍MagnifyGlass.InfoPanelWidth": DEFAULT_PANEL_WIDTH,
"🔍MagnifyGlass.InfoPanelOpacity": DEFAULT_PANEL_OPACITY,
"🔍MagnifyGlass.InfoPanelMaxHeight": DEFAULT_PANEL_MAX_HEIGHT,
"🔍MagnifyGlass.InfoPanelAnimations": false,
"🔍MagnifyGlass.ShowInspectorTab": false,
"🔍MagnifyGlass.ToggleHotkey": "i",
"🔍MagnifyGlass.GlassPreviewToggleHotkey": "g",
"🔍MagnifyGlass.PinPanelHotkey": "u",
@@ -61,9 +59,10 @@ export const DEFAULT_PANEL_SETTINGS: PanelSettings = {
"🔍MagnifyGlass.InfoPanelFontSize": 14,
"🔍MagnifyGlass.InfoPanelFontFamily": "System Default",
"🔍MagnifyGlass.InfoPanelPersist": false,
"🔍MagnifyGlass.HoldInfoHotkey": "p",
"🔍MagnifyGlass.InfoPanelFollowSelection": false,
"🔍MagnifyGlass.StickyInfoHotkey": "s",
"🔍MagnifyGlass.NodeHighlightEnabled": true,
"🔍MagnifyGlass.HighContrastText": false,
};
/**
@@ -92,5 +91,4 @@ export const DEFAULT_SETTINGS = {
...DEFAULT_GLASS_SETTINGS,
...DEFAULT_PANEL_SETTINGS,
...DEFAULT_ACCESSIBILITY_SETTINGS,
"🔍MagnifyGlass.HighContrastText": false,
};
+19 -40
View File
@@ -7,7 +7,7 @@
// @ts-ignore
import { app } from "/scripts/app.js";
import { PANEL_POSITIONS } from '../constants';
import { PANEL_POSITIONS, PANEL_FONTS } from '../constants';
import { DEFAULT_PANEL_SETTINGS } from './defaults';
/**
@@ -42,7 +42,7 @@ export function registerPanelSettings(setEnabled: (enabled: boolean) => void): v
name: "🔍 [4] Info Panel: Sticky Mode (Persist)",
type: "boolean",
defaultValue: settings["🔍MagnifyGlass.InfoPanelPersist"],
tooltip: "Keep displaying the last node's info until you hover over another node.",
tooltip: "Keep showing the last node while the cursor is over empty canvas. Its widgets can then be edited.",
onChange: (value: any) => {
const panel = window.infoPanelManager;
if (!panel) return;
@@ -51,12 +51,26 @@ export function registerPanelSettings(setEnabled: (enabled: boolean) => void): v
}
});
app.ui.settings.addSetting({
id: "🔍MagnifyGlass.InfoPanelFollowSelection",
name: "🔍 [4] Info Panel: Follow Selection",
type: "boolean",
defaultValue: settings["🔍MagnifyGlass.InfoPanelFollowSelection"],
tooltip: "Show the selected node instead of the hovered one while a node is selected. Its widgets can then be edited.",
onChange: (value: any) => {
const panel = window.infoPanelManager;
if (!panel) return;
panel.stateManager.state.settings["🔍MagnifyGlass.InfoPanelFollowSelection"] = !!value;
panel.uiManager.updateControlStates();
}
});
app.ui.settings.addSetting({
id: "🔍MagnifyGlass.NodeHighlightEnabled",
name: "🔍 [4] Info Panel: Node Highlight Border",
type: "boolean",
defaultValue: settings["🔍MagnifyGlass.NodeHighlightEnabled"] ?? true,
tooltip: "Show a blue highlight border around the currently inspected node.",
tooltip: "Show a highlight border in the accent colour around the currently inspected node.",
onChange: (value: any) => {
const panel = window.infoPanelManager;
if (!panel) return;
@@ -68,20 +82,6 @@ export function registerPanelSettings(setEnabled: (enabled: boolean) => void): v
}
});
app.ui.settings.addSetting({
id: "🔍MagnifyGlass.ShowInspectorTab",
name: "🔍 [4] Info Panel: Show Inspector Tab",
type: "combo",
options: [{ value: true, text: "Yes" }, { value: false, text: "No" }],
defaultValue: settings["🔍MagnifyGlass.ShowInspectorTab"],
tooltip: "Show or hide the Inspector tab with cursor and canvas information.",
onChange: (value: any) => {
const panel = window.infoPanelManager;
if (!panel) return;
panel.stateManager.state.settings["🔍MagnifyGlass.ShowInspectorTab"] = !!value;
}
});
app.ui.settings.addSetting({
id: "🔍MagnifyGlass.ShowHoveringControls",
name: "🔍 [4] Info Panel: Show Hover Controls",
@@ -179,30 +179,9 @@ export function registerPanelSettings(setEnabled: (enabled: boolean) => void): v
id: "🔍MagnifyGlass.InfoPanelFontFamily",
name: "🔍 [6] Panel Style: Font Family",
type: "combo",
options: [
{ value: "System Default", text: "System Default" },
{ value: "Inter", text: "Inter" },
{ value: "Roboto", text: "Roboto" },
{ value: "JetBrains Mono", text: "JetBrains Mono" },
{ value: "Fira Code", text: "Fira Code" },
{ value: "IBM Plex Sans", text: "IBM Plex Sans" },
{ value: "Space Grotesk", text: "Space Grotesk" },
{ value: "Lexend", text: "Lexend" },
{ value: "Outfit", text: "Outfit" },
{ value: "Cinzel", text: "Cinzel (Epic)" },
{ value: "Playfair Display", text: "Playfair Display (Elegant)" },
{ value: "Orbitron", text: "Orbitron (Sci-Fi)" },
{ value: "Dancing Script", text: "Dancing Script (Cursive)" },
{ value: "Amatic SC", text: "Amatic SC (Handwritten)" },
{ value: "Comfortaa", text: "Comfortaa (Rounded)" },
{ value: "Righteous", text: "Righteous (Modern)" },
{ value: "Bangers", text: "Bangers (Comic)" },
{ value: "Press Start 2P", text: "Press Start 2P (Pixel)" },
{ value: "Audiowide", text: "Audiowide (Techno)" },
{ value: "monospace", text: "Monospace" },
],
options: PANEL_FONTS.map(f => ({ value: f, text: f === "monospace" ? "Monospace" : f })),
defaultValue: settings["🔍MagnifyGlass.InfoPanelFontFamily"],
tooltip: "Font family for the info panel text.",
tooltip: "Font family for the info panel text. A font that is not installed on this computer falls back to the default.",
onChange: (value: any) => {
const panel = window.infoPanelManager;
if (!panel) return;
+2 -3
View File
@@ -37,8 +37,6 @@ export interface PanelSettings {
"🔍MagnifyGlass.InfoPanelWidth": number;
"🔍MagnifyGlass.InfoPanelOpacity": number;
"🔍MagnifyGlass.InfoPanelMaxHeight": number;
"🔍MagnifyGlass.InfoPanelAnimations": boolean;
"🔍MagnifyGlass.ShowInspectorTab": boolean;
"🔍MagnifyGlass.ToggleHotkey": string;
"🔍MagnifyGlass.GlassPreviewToggleHotkey": string;
"🔍MagnifyGlass.PinPanelHotkey": string;
@@ -49,9 +47,10 @@ export interface PanelSettings {
"🔍MagnifyGlass.InfoPanelFontSize": number;
"🔍MagnifyGlass.InfoPanelFontFamily": string;
"🔍MagnifyGlass.InfoPanelPersist": boolean;
"🔍MagnifyGlass.HoldInfoHotkey": string;
"🔍MagnifyGlass.InfoPanelFollowSelection": boolean;
"🔍MagnifyGlass.StickyInfoHotkey": string;
"🔍MagnifyGlass.NodeHighlightEnabled": boolean;
"🔍MagnifyGlass.HighContrastText": boolean;
[key: string]: string | number | boolean; // Allow indexing
}
+15 -22
View File
@@ -11,7 +11,8 @@ import {
RESET_KEYS,
TOGGLE_FOLLOW_KEYS,
DIRECT_CAPTURE_KEYS,
PANEL_POSITIONS
PANEL_POSITIONS,
PANEL_FONTS
} from '../shared/constants';
import {
getSettingValue,
@@ -692,17 +693,16 @@ export function renderSettingsPanel(container: HTMLElement): void {
const panelSection = createSection('Info Panel');
panelSection.section.hidden = !inspectorEnabled;
panelSection.body.appendChild(createToggle('Persist Info',
panelSection.body.appendChild(createToggle('Sticky',
settingValue('🔍MagnifyGlass.InfoPanelPersist'),
(checked) => {
setSettingValue('🔍MagnifyGlass.InfoPanelPersist', checked);
// Sync with UI controls if needed
const infoPanel = (window as any).infoPanelManager;
if (infoPanel?.uiManager) {
infoPanel.uiManager.updateControlStates();
}
},
'Keep displaying the last node info when hovering empty space'
(checked) => setSettingValue('🔍MagnifyGlass.InfoPanelPersist', checked),
'Keep showing the last node over empty canvas. Its widgets can then be edited'
));
panelSection.body.appendChild(createToggle('Follow Selection',
settingValue('🔍MagnifyGlass.InfoPanelFollowSelection'),
(checked) => setSettingValue('🔍MagnifyGlass.InfoPanelFollowSelection', checked),
'Show the selected node instead of the hovered one. Its widgets can then be edited'
));
panelSection.body.appendChild(createToggle('Node Highlight Border',
@@ -788,15 +788,8 @@ export function renderSettingsPanel(container: HTMLElement): void {
}
));
const fontFamilyOptions = [
'System Default', 'Inter', 'Roboto', 'JetBrains Mono', 'Fira Code',
'IBM Plex Sans', 'Space Grotesk', 'Lexend', 'Outfit',
'Cinzel', 'Playfair Display', 'Orbitron', 'Dancing Script', 'Amatic SC',
'Comfortaa', 'Righteous', 'Bangers', 'Press Start 2P', 'Audiowide',
'monospace'
];
panelSection.body.appendChild(createSelect('Font Family',
settingValue('🔍MagnifyGlass.InfoPanelFontFamily'), fontFamilyOptions,
settingValue('🔍MagnifyGlass.InfoPanelFontFamily'), PANEL_FONTS,
(value) => setSettingValue('🔍MagnifyGlass.InfoPanelFontFamily', value),
'Font family for the info panel text',
(value) => {
@@ -878,15 +871,15 @@ export function renderSettingsPanel(container: HTMLElement): void {
panelHotkeySection.section.hidden = !inspectorEnabled;
panelHotkeySection.body.appendChild(createSelect('Toggle Key',
settingValue('🔍MagnifyGlass.ToggleHotkey'), ['i', 'j', 'k', 'l', 'n', 'm'],
settingValue('🔍MagnifyGlass.ToggleHotkey'), ['i', 'j', 'k', 'l'],
(value) => setSettingValue('🔍MagnifyGlass.ToggleHotkey', value),
'Key to toggle info panel visibility'
));
panelHotkeySection.body.appendChild(createSelect('Pin Key',
panelHotkeySection.body.appendChild(createSelect('Float Key',
settingValue('🔍MagnifyGlass.PinPanelHotkey'), ['u', 'p', 'y', 't'],
(value) => setSettingValue('🔍MagnifyGlass.PinPanelHotkey', value),
'Key to pin/unpin the info panel position'
'Key to switch the panel between following the glass and floating'
));
const panelAltToggle = createToggle('Require Alt Key',
+22 -80
View File
@@ -23,6 +23,7 @@ export interface ComfyExtension {
name: string;
setup?: () => Promise<void> | void;
init?: () => Promise<void> | void;
afterConfigureGraph?: () => Promise<void> | void;
commands?: ComfyCommand[];
keybindings?: ComfyKeybinding[];
menuCommands?: ComfyMenuCommand[];
@@ -141,7 +142,13 @@ export interface ComfyDialog {
export interface ComfyGraph {
_nodes: ComfyNode[];
/** The graph itself for the root graph; set on subgraphs */
rootGraph: ComfyGraph;
/** Subgraphs only */
name?: string;
getNodeById(id: number): ComfyNode | null;
getNodeOnPos(x: number, y: number): ComfyNode | null;
getGroupOnPos(x: number, y: number): { title: string } | undefined;
}
export interface ComfyNode {
@@ -158,6 +165,10 @@ export interface ComfyNode {
inputs?: ComfySlot[];
outputs?: ComfySlot[];
properties?: Record<string, unknown>;
graph?: ComfyGraph;
/** Subgraph nodes only */
subgraph?: ComfyGraph;
isSubgraphNode?(): boolean;
}
export interface ComfyWidget {
@@ -183,12 +194,16 @@ export interface ComfySlot {
export interface ComfyCanvas {
canvas: HTMLCanvasElement;
graph: ComfyGraph | null;
ds: {
scale: number;
offset: [number, number];
};
node_over: ComfyNode | null;
selected_nodes: Record<string, ComfyNode>;
convertOffsetToCanvasPos?(pos: [number, number]): [number, number];
centerOnNode(node: ComfyNode): void;
openSubgraph(subgraph: ComfyGraph, fromNode: ComfyNode): void;
}
// ============================================================================
@@ -198,8 +213,7 @@ export interface ComfyCanvas {
declare global {
interface Window {
comfyUIMagnifyGlass?: MagnifyGlassInstance;
magnifyGlass?: MagnifyGlassInstance; // Alias used by info panel controls
comfyUIMagnifyGlassExtensions?: unknown[];
comfyUIMagnifyGlassExtensions?: InfoPanel[];
infoPanelManager?: InfoPanel;
}
@@ -220,11 +234,8 @@ export interface MagnifyGlassInstance {
onStateChange?: ((isOpen: boolean) => void) | null;
onNodeSelect?: ((nodeId: number) => void) | null;
onWidgetEdit?: (() => void) | null;
updateTheme?(theme: string): void;
};
lastKnownMousePosition: { x: number; y: number };
isOverMedia: boolean;
currentMediaElement: HTMLImageElement | HTMLVideoElement | null;
updateMagnifiedView(): void;
applyUiChanges(): void;
toggle(): void;
@@ -285,18 +296,12 @@ export interface MagnifyGlassRenderer {
* Returned by InformationGatherer.gatherInformation()
*/
export interface GatheredInfo {
timestamp: number;
cursor: {
screenX: number;
screenY: number;
canvasX: number;
canvasY: number;
};
zoom: number;
nodeCount: number;
hoveredNode: NodeInfo | null;
hoveredWidget: WidgetInfo | null;
mediaElement: MediaInfo | null;
}
/**
@@ -307,89 +312,26 @@ export interface NodeInfo {
id: number;
title: string;
type: string;
mode: string | number;
mode: string;
position: {
x: number;
y: number;
formatted?: string;
} | string;
};
size: {
width: number;
height: number;
formatted?: string;
} | string;
localPosition?: {
x: number;
y: number;
formatted?: string;
percentage?: { x: string; y: string };
};
counts?: {
widgets: number;
inputs: number;
outputs: number;
properties: number;
};
widgets: ComfyWidget[];
inputs: ComfyInput[];
outputs: ComfyOutput[];
properties: Record<string, unknown>;
hoverRegion?: string;
executionOrder?: number;
author?: string;
category?: string;
pythonModule?: string;
}
/**
* Information about a widget.
*/
export interface WidgetInfo {
name: string;
type: string;
value: string;
options?: unknown;
min?: number;
max?: number;
step?: number;
}
/**
* Information about a media element (image or video).
*/
export interface MediaInfo {
type: 'image' | 'video';
tagName: string;
src: string;
naturalWidth: number;
naturalHeight: number;
displayWidth: number;
displayHeight: number;
aspectRatio: string;
duration?: number;
currentTime?: number;
}
/**
* A section in the info panel display.
*/
export interface SectionData {
id: string;
title: string;
icon: string;
visible: boolean;
expanded: boolean;
content: ParameterItem[];
}
/**
* A parameter item displayed in a section.
*/
export interface ParameterItem {
label: string;
value: unknown;
formattedValue?: string;
highlight?: boolean;
/** Names of the subgraphs from the root down to the node's graph */
graphPath: string[];
group?: string;
isSubgraph: boolean;
}
export { };
+214
View File
@@ -0,0 +1,214 @@
/**
* Shared dropdown: open/close lifecycle, filter, hover and keyboard navigation.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { CustomDropdown, createDropdownTrigger } from '../../src/shared/CustomDropdown';
import { InlineControlFactory } from '../../src/info-panel/widget-editors/InlineControlFactory';
import { WidgetSyncManager } from '../../src/info-panel/widget-editors/WidgetSyncManager';
const press = (key: string) => {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
(document.activeElement ?? document.body).dispatchEvent(event);
return event;
};
const menus = () => document.querySelectorAll('.custom-dropdown-menu');
const items = () => [...document.querySelectorAll<HTMLElement>('.custom-dropdown-item')];
const visible = () => items().filter(item => !item.hidden).map(item => item.textContent);
const active = () => document.querySelector('.custom-dropdown-item.active')?.textContent;
describe('CustomDropdown', () => {
let anchor: HTMLElement;
let onChange: ReturnType<typeof vi.fn>;
let onClose: ReturnType<typeof vi.fn>;
let dropdown: CustomDropdown;
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = '';
anchor = createDropdownTrigger('euler');
document.body.appendChild(anchor);
onChange = vi.fn();
onClose = vi.fn();
dropdown = new CustomDropdown({
options: ['euler', 'euler_ancestral', 'dpmpp_2m', 'ddim'],
currentValue: 'euler',
anchor,
onChange,
onClose
});
});
afterEach(() => {
dropdown.hide();
vi.useRealTimers();
});
it('show() does not fire onClose, hide() fires it once', () => {
dropdown.show();
dropdown.show();
expect(onClose).not.toHaveBeenCalled();
expect(menus()).toHaveLength(1);
dropdown.hide();
dropdown.hide();
expect(onClose).toHaveBeenCalledTimes(1);
expect(menus()).toHaveLength(0);
});
it('closing before the listeners attach leaves none behind', () => {
const add = vi.spyOn(window, 'addEventListener');
dropdown.show();
dropdown.hide();
vi.advanceTimersByTime(100);
expect(add).not.toHaveBeenCalledWith('keydown', expect.any(Function), true);
});
it('has listbox roles and tracks the active option', () => {
dropdown.show();
const filter = document.querySelector('.custom-dropdown-filter') as HTMLInputElement;
const list = document.querySelector('.custom-dropdown-list')!;
expect(document.activeElement).toBe(filter);
expect(anchor.getAttribute('aria-expanded')).toBe('true');
expect(list.getAttribute('role')).toBe('listbox');
expect(filter.getAttribute('aria-controls')).toBe(list.id);
expect(items().map(item => item.getAttribute('role'))).toEqual(['option', 'option', 'option', 'option']);
expect(items().map(item => item.getAttribute('aria-selected'))).toEqual(['true', 'false', 'false', 'false']);
expect(filter.getAttribute('aria-activedescendant')).toBe(items()[0].id);
dropdown.hide();
expect(anchor.getAttribute('aria-expanded')).toBe('false');
});
it('hover moves the item that Enter picks', () => {
dropdown.show();
vi.advanceTimersByTime(100);
items()[2].dispatchEvent(new MouseEvent('mouseenter'));
expect(active()).toBe('dpmpp_2m');
press('ArrowDown');
expect(active()).toBe('ddim');
press('ArrowUp');
press('ArrowUp');
expect(active()).toBe('euler_ancestral');
expect(press('Enter').defaultPrevented).toBe(true);
expect(onChange).toHaveBeenCalledWith('euler_ancestral');
expect(menus()).toHaveLength(0);
expect(document.activeElement).toBe(anchor);
});
it('typing filters the options and Enter picks the first match', () => {
dropdown.show();
vi.advanceTimersByTime(100);
const filter = document.activeElement as HTMLInputElement;
filter.value = 'EUL';
filter.dispatchEvent(new Event('input'));
expect(visible()).toEqual(['euler', 'euler_ancestral']);
press('ArrowDown');
press('ArrowDown');
expect(active()).toBe('euler_ancestral');
filter.value = 'dd';
filter.dispatchEvent(new Event('input'));
expect(visible()).toEqual(['ddim']);
expect(active()).toBe('ddim');
press('Enter');
expect(onChange).toHaveBeenCalledWith('ddim');
});
it('Enter with no match keeps the menu open', () => {
dropdown.show();
vi.advanceTimersByTime(100);
const filter = document.activeElement as HTMLInputElement;
filter.value = 'zzz';
filter.dispatchEvent(new Event('input'));
press('Enter');
expect(onChange).not.toHaveBeenCalled();
expect(menus()).toHaveLength(1);
expect(filter.hasAttribute('aria-activedescendant')).toBe(false);
});
it('Tab closes without a change and leaves focus on the trigger to move on from', () => {
dropdown.show();
vi.advanceTimersByTime(60);
press('ArrowDown');
const tab = press('Tab');
expect(tab.defaultPrevented).toBe(false);
expect(menus()).toHaveLength(0);
expect(onChange).not.toHaveBeenCalled();
expect(document.activeElement).toBe(anchor);
expect(press('Enter').defaultPrevented).toBe(false);
});
it('Escape closes without a change', () => {
dropdown.show();
vi.advanceTimersByTime(100);
press('Escape');
expect(onChange).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
expect(menus()).toHaveLength(0);
});
});
describe('inline combo control', () => {
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = '';
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('destroy() closes an open menu, so it cannot write after a re-render', () => {
const sync = vi.spyOn(WidgetSyncManager, 'syncWidgetValue');
const control = InlineControlFactory.createControl({
nodeId: 1, widgetName: 'sampler_name', widgetType: 'combo', currentValue: 'euler',
constraints: { options: ['euler', 'ddim'] }
})!;
document.body.appendChild(control.element);
const trigger = control.element.querySelector('.inline-dropdown-trigger') as HTMLElement;
trigger.click();
expect(menus()).toHaveLength(1);
expect(trigger.style.background).toBe('');
control.destroy();
vi.advanceTimersByTime(100);
expect(menus()).toHaveLength(0);
press('Enter');
expect(sync).not.toHaveBeenCalled();
});
it('clicking the trigger again closes the menu', () => {
const control = InlineControlFactory.createControl({
nodeId: 1, widgetName: 'sampler_name', widgetType: 'combo', currentValue: 'euler',
constraints: { options: ['euler', 'ddim'] }
})!;
document.body.appendChild(control.element);
const trigger = control.element.querySelector('.inline-dropdown-trigger') as HTMLElement;
trigger.click();
trigger.click();
expect(menus()).toHaveLength(0);
trigger.click();
expect(menus()).toHaveLength(1);
control.destroy();
});
});
File diff suppressed because it is too large Load Diff
+21 -51
View File
@@ -7,11 +7,12 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock ComfyUI app as a global (ComfyUI exposes it on window/globalThis)
const mockApp = {
graph: {
_nodes: []
},
const mockApp: any = {
canvas: {
graph: {
_nodes: [],
getNodeOnPos: () => null
},
canvas: {
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 })
},
@@ -40,9 +41,7 @@ const mockMagnifyGlass = {
offsetX: 0,
offsetY: 0
},
lastKnownMousePosition: { x: 200, y: 200 },
isOverMedia: false,
currentMediaElement: null
lastKnownMousePosition: { x: 200, y: 200 }
};
Object.defineProperty(globalThis, 'window', {
@@ -59,9 +58,6 @@ describe('InformationGatherer', () => {
beforeEach(() => {
gatherer = new InformationGatherer();
// Reset mock state
mockMagnifyGlass.isOverMedia = false;
mockMagnifyGlass.currentMediaElement = null;
});
describe('constructor', () => {
@@ -74,20 +70,12 @@ describe('InformationGatherer', () => {
it('should return a GatheredInfo object with required fields', () => {
const info = gatherer.gatherInformation();
expect(info).toHaveProperty('timestamp');
expect(info).toHaveProperty('cursor');
expect(info).toHaveProperty('zoom');
expect(info).toHaveProperty('nodeCount');
expect(info).toHaveProperty('hoveredNode');
expect(info).toHaveProperty('hoveredWidget');
expect(info).toHaveProperty('mediaElement');
expect(Object.keys(info).sort()).toEqual(['cursor', 'hoveredNode', 'zoom']);
});
it('should return cursor position from magnifyGlass state', () => {
const info = gatherer.gatherInformation();
expect(info.cursor.screenX).toBe(200);
expect(info.cursor.screenY).toBe(200);
expect(info.cursor.canvasX).toBe(100);
expect(info.cursor.canvasY).toBe(100);
});
@@ -103,38 +91,19 @@ describe('InformationGatherer', () => {
});
});
describe('formatValue', () => {
it('should format null values', () => {
expect(gatherer.formatValue(null)).toBe('null');
});
describe('getNodeUnderCursor', () => {
it('should hit-test the graph on screen, title bar and collapsed nodes included', () => {
// A collapsed node in a subgraph: only its title pill sits above pos
const node = { id: 3, pos: [190, 230], size: [140, 80], flags: { collapsed: true } };
const getNodeOnPos = vi.fn(() => node);
const canvasGraph = mockApp.canvas.graph;
mockApp.canvas.graph = { _nodes: [node], getNodeOnPos };
it('should format undefined values', () => {
expect(gatherer.formatValue(undefined)).toBe('undefined');
});
const hit = gatherer.getNodeUnderCursor();
it('should format string values as-is', () => {
expect(gatherer.formatValue('hello world')).toBe('hello world');
});
it('should format integer values without decimals', () => {
expect(gatherer.formatValue(42)).toBe('42');
});
it('should format float values with 3 decimals', () => {
expect(gatherer.formatValue(3.14159)).toBe('3.142');
});
it('should format boolean values', () => {
expect(gatherer.formatValue(true)).toBe('true');
expect(gatherer.formatValue(false)).toBe('false');
});
it('should format arrays with length', () => {
expect(gatherer.formatValue([1, 2, 3])).toBe('Array(3)');
});
it('should format objects as "Object"', () => {
expect(gatherer.formatValue({ key: 'value' })).toBe('Object');
expect(getNodeOnPos).toHaveBeenCalledWith(200, 200);
expect(hit).toBe(node);
mockApp.canvas.graph = canvasGraph;
});
});
@@ -147,8 +116,9 @@ describe('InformationGatherer', () => {
expect(gatherer.getNodeModeText(1)).toBe('On Event');
});
it('should return "Never" for mode 2', () => {
expect(gatherer.getNodeModeText(2)).toBe('Never');
it('names modes 2 and 4 as ComfyUI does', () => {
expect(gatherer.getNodeModeText(2)).toBe('Mute');
expect(gatherer.getNodeModeText(4)).toBe('Bypass');
});
it('should return fallback for unknown modes', () => {
+37 -54
View File
@@ -17,11 +17,15 @@ const mockNodes = [
{ id: 7, title: 'Untitled', type: 'Unknown' } // No order
];
const mockApp = {
graph: {
_nodes: mockNodes,
getNodeById: (id: number) => mockNodes.find(n => n.id === id) || null
}
const graphOf = (nodes: any[]) => ({
_nodes: nodes,
getNodeById: (id: number) => nodes.find(n => n.id === id) || null
});
// app.graph is always the root graph; the canvas holds the graph on screen
const mockApp: any = {
graph: graphOf([]),
canvas: { graph: graphOf(mockNodes) }
};
(globalThis as any).app = mockApp;
@@ -52,30 +56,6 @@ describe('NodeSelector', () => {
});
});
describe('getNodesSortedByTitle', () => {
it('should return nodes sorted alphabetically by title', () => {
const nodes = selector.getNodesSortedByTitle();
expect(nodes[0].title).toBe('CLIP Text Encode');
expect(nodes[1].title).toBe('Empty Latent');
expect(nodes[2].title).toBe('KSampler');
expect(nodes[3].title).toBe('Load Checkpoint');
expect(nodes[4].title).toBe('Save Image');
expect(nodes[5].title).toBe('Untitled');
expect(nodes[6].title).toBe('VAE Decode');
});
it('should include id, title, and type in each entry', () => {
const nodes = selector.getNodesSortedByTitle();
const ksamplerNode = nodes.find(n => n.id === 1);
expect(ksamplerNode).toBeDefined();
expect(ksamplerNode?.id).toBe(1);
expect(ksamplerNode?.title).toBe('KSampler');
expect(ksamplerNode?.type).toBe('KSampler');
});
});
describe('getNodesSortedByExecOrder', () => {
it('should return nodes sorted by execution order', () => {
const nodes = selector.getNodesSortedByExecOrder();
@@ -116,39 +96,42 @@ describe('NodeSelector', () => {
});
});
describe('searchByTitle', () => {
it('should find nodes matching partial title (case-insensitive)', () => {
const results = selector.searchByTitle('sample');
describe('search', () => {
it('lists every node by execution order, nodes without one last', () => {
const results = selector.search('');
expect(results).toHaveLength(1);
expect(results[0].title).toBe('KSampler');
expect(results.map(n => n.id)).toEqual([2, 3, 4, 1, 5, 6, 7]);
expect(results[0]).toEqual({ id: 2, title: 'Load Checkpoint', type: 'CheckpointLoader', order: 1 });
expect(results[6].order).toBe(-1);
});
it('should find multiple matches', () => {
const results = selector.searchByTitle('e');
// CLIP Text Encode, Empty Latent, KSampler, Load Checkpoint, Save Image, VAE Decode
expect(results.length).toBeGreaterThan(1);
it('matches title or type in any case', () => {
expect(selector.search('KSAMPLER').map(n => n.id)).toEqual([1]);
expect(selector.search('latentimage').map(n => n.id)).toEqual([4]);
expect(selector.search(' encode ').map(n => n.id)).toEqual([3]);
expect(selector.search('xyz123')).toEqual([]);
});
it('should return empty array for no matches', () => {
const results = selector.searchByTitle('xyz123');
expect(results).toEqual([]);
});
it('should be case-insensitive', () => {
const results1 = selector.searchByTitle('KSAMPLER');
const results2 = selector.searchByTitle('ksampler');
expect(results1).toHaveLength(1);
expect(results2).toHaveLength(1);
expect(results1[0].id).toBe(results2[0].id);
it('matches an id given as #id or plainly', () => {
expect(selector.search('#5').map(n => n.id)).toEqual([5]);
expect(selector.search('6').map(n => n.id)).toEqual([6]);
expect(selector.search('#').length).toBe(7);
expect(selector.search('#55')).toEqual([]);
});
});
describe('getNodeCount', () => {
it('should return the total number of nodes', () => {
expect(selector.getNodeCount()).toBe(7);
describe('inside a subgraph', () => {
it('should list and resolve nodes of the graph on screen, not the root graph', () => {
const rootCanvasGraph = mockApp.canvas.graph;
mockApp.graph = graphOf(mockNodes);
mockApp.canvas.graph = graphOf([{ id: 1, title: 'Inner Sampler', type: 'KSampler', order: 0 }]);
expect(selector.getAllNodes()).toHaveLength(1);
expect(selector.getNodeById(1)?.title).toBe('Inner Sampler');
expect(selector.getNodeById(3)).toBeNull();
mockApp.graph = graphOf([]);
mockApp.canvas.graph = rootCanvasGraph;
});
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { PopOutManager } from '../../src/magnify-glass/PopOutManager';
describe('Pop-out theme', () => {
let manager: PopOutManager;
let posted: any[];
beforeEach(() => {
vi.useFakeTimers();
posted = [];
vi.stubGlobal('BroadcastChannel', class {
onmessage: ((e: MessageEvent) => void) | null = null;
postMessage(message: unknown) { posted.push(message); }
close() { }
});
manager = new PopOutManager();
});
afterEach(() => {
manager.cleanup();
document.body.classList.remove('dark-theme');
vi.unstubAllGlobals();
vi.useRealTimers();
});
const pong = () => (manager as any).handleMessage({ type: 'pong' });
const themes = () => posted.filter(m => m.type === 'config').map(m => m.data.theme);
it('follows body.dark-theme and resends only when it changes', () => {
document.body.classList.add('dark-theme');
pong();
pong();
expect(themes()).toEqual(['dark']);
document.body.classList.remove('dark-theme');
pong();
expect(themes()).toEqual(['dark', 'light']);
});
it('sends the theme again when the viewer reconnects', () => {
pong();
(manager as any).handleMessage({ type: 'close' });
pong();
expect(themes()).toEqual(['light', 'light']);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
// The viewer is hand-maintained HTML; run its script against its own markup
const html = readFileSync(path.resolve(__dirname, '../../web/popout-viewer.html'), 'utf8');
const bodyHtml = html.slice(html.indexOf('<body>') + 6, html.indexOf('<script>'));
const script = html.slice(html.indexOf('<script>') + 8, html.indexOf('</script>'));
describe('Pop-out viewer editing', () => {
let channel: { onmessage: ((e: { data: unknown }) => void) | null; postMessage: ReturnType<typeof vi.fn> };
const edits = () => channel.postMessage.mock.calls.map(([m]) => m).filter((m: any) => m.type === 'widget-edit').map((m: any) => m.data);
const sidebar = () => document.getElementById('sidebar-content')!;
const valueOf = (name: string) => [...sidebar().querySelectorAll('[data-kind="value"]')]
.find(el => (window as any).__widgets[Number((el as HTMLElement).dataset.widget)].name === name) as HTMLElement;
const sendInfo = (widgets: any[]) => {
(window as any).__widgets = widgets;
channel.onmessage!({ data: { type: 'info', data: { hoveredNode: { id: 5, title: 'Node', type: 'Node', widgets, inputs: [], outputs: [] } } } });
};
const key = (el: Element, k: string, init: KeyboardEventInit = {}) =>
el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }));
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('localStorage', new Storage());
vi.stubGlobal('BroadcastChannel', class {
onmessage = null;
postMessage = vi.fn();
close() { }
constructor() { channel = this as any; }
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
vi.spyOn(window, 'close').mockImplementation(() => { });
document.body.innerHTML = bodyHtml;
new Function(script)();
});
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('edits names with quotes, and text keeps its newlines in a textarea', () => {
sendInfo([{ name: `it's "quoted"`, type: 'customtext', value: 'line one\nline two' }]);
valueOf(`it's "quoted"`).click();
const textarea = sidebar().querySelector('textarea')!;
expect(textarea.value).toBe('line one\nline two');
key(textarea, 'Enter'); // a newline, not a commit
expect(edits()).toEqual([]);
textarea.value = 'line one\nline three';
key(textarea, 'Enter', { ctrlKey: true });
expect(edits()).toEqual([{ nodeId: 5, widgetName: `it's "quoted"`, value: 'line one\nline three' }]);
});
it('number inputs carry min, max and the real step, and Escape cancels without closing', () => {
sendInfo([{ name: 'denoise', type: 'number', value: 0.5, options: { min: 0, max: 1, step: 0.1, step2: 0.01 } }]);
valueOf('denoise').click();
const input = sidebar().querySelector('input[type="number"]') as HTMLInputElement;
expect([input.min, input.max, input.step]).toEqual(['0', '1', '0.01']);
input.value = '0.7';
key(input, 'Escape');
expect(edits()).toEqual([]);
expect(window.close).not.toHaveBeenCalled();
expect(sidebar().querySelector('input[type="number"]')).toBeNull();
valueOf('denoise').click();
const again = sidebar().querySelector('input[type="number"]') as HTMLInputElement;
again.value = '0.7';
key(again, 'Enter');
expect(edits()).toEqual([{ nodeId: 5, widgetName: 'denoise', value: 0.7 }]);
});
it('a combo blurred without a pick cancels, and a pick keeps the option type', () => {
sendInfo([{ name: 'size', type: 'combo', value: 512, options: [512, 768] }]);
valueOf('size').click();
let select = sidebar().querySelector('select')!;
select.dispatchEvent(new Event('blur'));
expect(edits()).toEqual([]);
expect(sidebar().querySelector('select')).toBeNull();
valueOf('size').click();
select = sidebar().querySelector('select')!;
select.selectedIndex = 2;
select.dispatchEvent(new Event('change'));
expect(edits()).toEqual([{ nodeId: 5, widgetName: 'size', value: 768 }]);
});
it('keeps an open editor while info arrives, and object values are not editable', () => {
const widgets = [{ name: 'seed', type: 'number', value: 1 }, { name: 'data', type: 'custom', value: { a: 1 } }];
sendInfo(widgets);
expect(valueOf('data')).toBeUndefined();
valueOf('seed').click();
const input = sidebar().querySelector('input[type="number"]');
sendInfo(widgets);
expect(sidebar().querySelector('input[type="number"]')).toBe(input);
});
it('toggle and action buttons send their widget name', () => {
sendInfo([{ name: `o'clock`, type: 'button', value: false }, { name: `run "now"`, type: 'button' }]);
(sidebar().querySelector('[data-kind="toggle"]') as HTMLElement).click();
expect(edits()).toEqual([{ nodeId: 5, widgetName: `o'clock`, value: true }]);
(sidebar().querySelector('[data-kind="action"]') as HTMLElement).click();
const clicks = channel.postMessage.mock.calls.map(([m]) => m).filter((m: any) => m.type === 'button-click');
expect(clicks.map((m: any) => m.data)).toEqual([{ nodeId: 5, widgetName: `run "now"` }]);
});
});
+38 -86
View File
@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest';
import { Window } from 'happy-dom';
import { formatValue } from '../../src/info-panel/ValueFormatter';
import { escapeHtml, sanitizeElement } from '../../src/shared/utils';
import { UIManager } from '../../src/info-panel/UIManager';
import { StateManager } from '../../src/info-panel/StateManager';
describe('Security', () => {
describe('sanitizeElement', () => {
@@ -108,98 +109,49 @@ describe('Security', () => {
});
});
describe('formatValue', () => {
it('should escape HTML in values to prevent XSS', () => {
const maliciousInput = '<script>alert("xss")</script>';
const escaped = formatValue(maliciousInput);
// Assert that the output does NOT contain the raw script tag
expect(escaped).not.toContain('<script>');
// Assert that the output IS escaped
expect(escaped).toContain('&lt;script&gt;');
expect(escaped).toContain('&lt;/script&gt;');
});
it('should escape quotes', () => {
const input = 'Value with "quotes" and \'single quotes\'';
const escaped = formatValue(input);
expect(escaped).toContain('&quot;quotes&quot;');
expect(escaped).toContain('&#039;single quotes&#039;');
});
});
describe('UIManager Rendering Logic', () => {
// Mock logic for UIManager rendering to verify the fix for trusted HTML
it('should render widget values and their copy text as text', () => {
(globalThis as any).app = { canvas: { graph: null } };
document.head.innerHTML = '<style id="magnify-info-panel-styles-v2"></style>';
const ui = new UIManager(new StateManager());
const malicious = '<script>alert("xss")</script> \'quoted\'';
// Helper to simulate UIManager.renderSections logic
const renderItem = (item: any) => {
let value;
if (item.specialType === 'focus-node') {
// Mimic UIManager logic: inject static HTML structure but escape the dynamic value
// In actual code: `<span class="focus-node-btn">${Icons.focus} ${escapeHtml(item.value)}</span>`
// We mock Icons.focus here for simplicity
value = `<span class="focus-node-btn">ICON ${escapeHtml(item.value)}</span>`;
} else {
value = formatValue(item.value, item.label);
}
return value;
};
ui.displayInfo({
hoveredNode: {
id: 1, title: 'Node', type: 'KSampler', mode: 'Always', inputs: [], outputs: [], properties: {},
widgets: [{ name: '<img src=x onerror=alert(1)>', type: 'text', value: malicious }],
graphPath: [], group: null, isSubgraph: false
}
});
it('should escape values even if isHtml is true (legacy bypass check)', () => {
const item = {
value: '<script>alert("xss")</script>',
isHtml: true, // This flag should be IGNORED now
label: 'Malicious Button'
};
const renderedValue = renderItem(item);
// It should fall through to formatValue which escapes
expect(renderedValue).not.toContain('<script>');
expect(renderedValue).toContain('&lt;script&gt;');
const content = ui.elements.content!;
expect(content.querySelector('script, img')).toBeNull();
const row = content.querySelector('.info-row')!;
expect(row.querySelector('.info-label')!.textContent).toBe('<img src=x onerror=alert(1)>');
expect(row.querySelector('.info-value')!.textContent).toContain(malicious);
expect((row.querySelector('.copy-btn') as HTMLElement).dataset.copyValue).toBe(malicious);
ui.cleanup();
});
it('should correctly render specialType="focus-node" with safe value', () => {
const item = {
value: 'Focus Node',
specialType: 'focus-node',
label: 'Location'
};
it('should render node names in the header as text', () => {
(globalThis as any).app = { canvas: { graph: null } };
document.head.innerHTML = '<style id="magnify-info-panel-styles-v2"></style>';
const ui = new UIManager(new StateManager());
const renderedValue = renderItem(item);
ui.displayInfo({
hoveredNode: {
id: 1, title: '<img src=x onerror=alert(1)>', type: '<script>alert(1)</script>',
mode: 'Always', widgets: [], inputs: [], outputs: [], properties: {},
graphPath: ['<b onclick=alert(1)>sub</b>'], group: '<a href="javascript:alert(1)">g</a>', isSubgraph: false
}
});
expect(renderedValue).toContain('<span class="focus-node-btn">');
expect(renderedValue).toContain('Focus Node');
});
it('should escape malicious content even in specialType="focus-node"', () => {
const item = {
value: '<script>alert(1)</script>',
specialType: 'focus-node',
label: 'Location'
};
const renderedValue = renderItem(item);
// The container span is there
expect(renderedValue).toContain('<span class="focus-node-btn">');
// But the content is escaped
expect(renderedValue).not.toContain('<script>');
expect(renderedValue).toContain('&lt;script&gt;');
});
it('should escape values NOT marked as isHtml', () => {
const item = {
value: '<script>alert(1)</script>',
isHtml: false,
label: 'Malicious Input'
};
const renderedValue = renderItem(item);
expect(renderedValue).not.toContain('<script>');
expect(renderedValue).toContain('&lt;script&gt;');
const panel = ui.elements.panel!;
expect(panel.querySelector('.header-identity span *, .panel-breadcrumb b, .panel-breadcrumb a')).toBeNull();
expect(panel.querySelector('.header-title')!.textContent).toBe('<img src=x onerror=alert(1)>');
expect(panel.querySelector('.header-badge')!.textContent).toBe('<script>alert(1)</script>');
expect(panel.querySelector('.panel-breadcrumb')!.textContent).toContain('<b onclick=alert(1)>sub</b>');
ui.cleanup();
});
});
+26 -231
View File
@@ -6,68 +6,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; }
};
})();
Object.defineProperty(globalThis, 'localStorage', {
value: localStorageMock
});
// Mock window
Object.defineProperty(globalThis, 'window', {
value: {
matchMedia: () => ({
addListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
infoPanelManager: null,
getComputedStyle: () => ({
backgroundColor: 'rgb(30, 30, 30)'
})
},
writable: true
});
// Mock document
Object.defineProperty(globalThis, 'document', {
value: {
body: {
getAttribute: () => null,
className: ''
},
documentElement: {
getAttribute: () => null,
className: ''
},
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: vi.fn(),
removeEventListener: vi.fn()
},
writable: true
});
// Mock getComputedStyle
(globalThis as any).getComputedStyle = () => ({
backgroundColor: 'rgb(30, 30, 30)'
});
// Mock MutationObserver
(globalThis as any).MutationObserver = class {
observe = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn();
};
// Mock app object for settings
vi.mock('/scripts/app.js', () => ({
app: {
@@ -86,107 +24,52 @@ describe('StateManager', () => {
beforeEach(() => {
vi.useFakeTimers();
// Node's own localStorage global hides happy-dom's
vi.stubGlobal('localStorage', new Storage());
stateManager = new StateManager();
});
afterEach(() => {
vi.useRealTimers();
stateManager.cleanup();
vi.unstubAllGlobals();
});
describe('constructor', () => {
it('should initialize with default state values', () => {
expect(stateManager.state.isPanelVisible).toBe(false);
expect(stateManager.state.isPanelMinimized).toBe(false);
expect(stateManager.state.isPanelPinned).toBe(false);
expect(stateManager.state.isPanelLocked).toBe(false);
expect(stateManager.state.isFloating).toBe(false);
expect(stateManager.state.floatPosition).toBeNull();
});
it('should initialize glass preview as visible', () => {
expect(stateManager.state.isGlassPreviewVisible).toBe(true);
});
it('should initialize with node section expanded', () => {
expect(stateManager.state.expandedSections.has('node')).toBe(true);
});
it('should initialize currentInfo as null', () => {
expect(stateManager.state.currentInfo).toBeNull();
});
it('should initialize with dark theme as default', () => {
expect(stateManager.state.currentTheme).toBe('dark');
});
});
describe('togglePanelVisibility', () => {
it('should toggle panel visibility', () => {
expect(stateManager.state.isPanelVisible).toBe(false);
describe('layout', () => {
it('keeps float mode, position and minimized state across reloads', () => {
stateManager.state.isFloating = true;
stateManager.state.floatPosition = { x: 120, y: 80 };
stateManager.state.isPanelMinimized = true;
stateManager.saveLayout();
stateManager.togglePanelVisibility();
expect(stateManager.state.isPanelVisible).toBe(true);
stateManager.togglePanelVisibility();
expect(stateManager.state.isPanelVisible).toBe(false);
const reloaded = new StateManager();
expect(reloaded.state.isFloating).toBe(true);
expect(reloaded.state.floatPosition).toEqual({ x: 120, y: 80 });
expect(reloaded.state.isPanelMinimized).toBe(true);
});
it('should return new visibility state', () => {
expect(stateManager.togglePanelVisibility()).toBe(true);
expect(stateManager.togglePanelVisibility()).toBe(false);
});
});
it('starts from the defaults when storage is blocked or holds junk', () => {
localStorage.setItem('comfyui_magnify_inspector_layout', '{not json');
expect(new StateManager().state.isFloating).toBe(false);
describe('toggleGlassPreview', () => {
it('should toggle glass preview visibility', () => {
expect(stateManager.state.isGlassPreviewVisible).toBe(true);
stateManager.toggleGlassPreview();
expect(stateManager.state.isGlassPreviewVisible).toBe(false);
stateManager.toggleGlassPreview();
expect(stateManager.state.isGlassPreviewVisible).toBe(true);
});
});
describe('togglePinning', () => {
it('should toggle pinned state', () => {
expect(stateManager.state.isPanelPinned).toBe(false);
stateManager.togglePinning();
expect(stateManager.state.isPanelPinned).toBe(true);
stateManager.togglePinning();
expect(stateManager.state.isPanelPinned).toBe(false);
});
it('should clear locked state when unpinning', () => {
stateManager.state.isPanelPinned = true;
stateManager.state.isPanelLocked = true;
stateManager.togglePinning(); // Unpin
expect(stateManager.state.isPanelLocked).toBe(false);
});
});
describe('toggleLocking', () => {
it('should not allow locking when not pinned', () => {
stateManager.state.isPanelPinned = false;
const result = stateManager.toggleLocking();
expect(result).toBe(false);
expect(stateManager.state.isPanelLocked).toBe(false);
});
it('should toggle locking when pinned', () => {
stateManager.state.isPanelPinned = true;
stateManager.toggleLocking();
expect(stateManager.state.isPanelLocked).toBe(true);
stateManager.toggleLocking();
expect(stateManager.state.isPanelLocked).toBe(false);
const blocked = () => { throw new Error('blocked'); };
vi.stubGlobal('localStorage', { getItem: blocked, setItem: blocked });
const manager = new StateManager();
expect(manager.state.floatPosition).toBeNull();
expect(() => manager.saveLayout()).not.toThrow();
});
});
@@ -202,96 +85,8 @@ describe('StateManager', () => {
});
});
describe('toggleSection', () => {
it('should not toggle node section (always expanded)', () => {
const result = stateManager.toggleSection('node');
expect(result).toBe(false);
});
it('should toggle other sections', () => {
expect(stateManager.state.expandedSections.has('media')).toBe(false);
stateManager.toggleSection('media');
expect(stateManager.state.expandedSections.has('media')).toBe(true);
stateManager.toggleSection('media');
expect(stateManager.state.expandedSections.has('media')).toBe(false);
});
});
describe('setPinnedPosition', () => {
it('should set pinned position', () => {
stateManager.setPinnedPosition(100, 200);
expect(stateManager.state.pinnedPosition).toEqual({ x: 100, y: 200 });
expect(stateManager.state.lastPinnedPosition).toEqual({ x: 100, y: 200 });
});
});
describe('setCurrentInfo', () => {
it('should set current info', () => {
const mockInfo = {
timestamp: Date.now(),
cursor: { screenX: 0, screenY: 0, canvasX: 0, canvasY: 0 },
zoom: 1,
nodeCount: 5,
hoveredNode: null,
hoveredWidget: null,
mediaElement: null
};
stateManager.setCurrentInfo(mockInfo);
expect(stateManager.state.currentInfo).toEqual(mockInfo);
});
});
describe('expandNodeSections', () => {
it('should expand all node-related sections', () => {
stateManager.expandNodeSections();
expect(stateManager.state.expandedSections.has('hoveredNode')).toBe(true);
expect(stateManager.state.expandedSections.has('node')).toBe(true);
expect(stateManager.state.expandedSections.has('cursor')).toBe(true);
expect(stateManager.state.expandedSections.has('canvas')).toBe(true);
expect(stateManager.state.expandedSections.has('magnifier')).toBe(true);
});
});
describe('collapseNodeSections', () => {
it('should collapse node-related sections', () => {
stateManager.state.expandedSections.add('hoveredNode');
stateManager.state.expandedSections.add('widget');
stateManager.collapseNodeSections();
expect(stateManager.state.expandedSections.has('hoveredNode')).toBe(false);
expect(stateManager.state.expandedSections.has('widget')).toBe(false);
});
});
describe('setSelectedNode', () => {
it('should set selected node ID', () => {
expect(stateManager.state.selectedNodeId).toBeNull();
stateManager.setSelectedNode(42);
expect(stateManager.state.selectedNodeId).toBe(42);
});
it('should allow setting to null', () => {
stateManager.setSelectedNode(42);
stateManager.setSelectedNode(null);
expect(stateManager.state.selectedNodeId).toBeNull();
});
});
describe('clearSelectedNode', () => {
it('should clear selected node ID', () => {
stateManager.setSelectedNode(42);
expect(stateManager.state.selectedNodeId).toBe(42);
stateManager.clearSelectedNode();
expect(stateManager.state.selectedNodeId).toBeNull();
});
it('starts showing and locked to no node', () => {
expect(stateManager.state.currentNodeId).toBeNull();
expect(stateManager.state.lockedNodeId).toBeNull();
});
});
+10 -69
View File
@@ -1,67 +1,5 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; }
};
})();
Object.defineProperty(globalThis, 'localStorage', {
value: localStorageMock
});
// Mock window
Object.defineProperty(globalThis, 'window', {
value: {
matchMedia: () => ({
addListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
infoPanelManager: null,
getComputedStyle: () => ({
backgroundColor: 'rgb(30, 30, 30)'
})
},
writable: true
});
// Mock document
Object.defineProperty(globalThis, 'document', {
value: {
body: {
getAttribute: () => null,
className: ''
},
documentElement: {
getAttribute: () => null,
className: ''
},
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: vi.fn(),
removeEventListener: vi.fn()
},
writable: true
});
// Mock getComputedStyle
(globalThis as any).getComputedStyle = () => ({
backgroundColor: 'rgb(30, 30, 30)'
});
// Mock MutationObserver
(globalThis as any).MutationObserver = class {
observe = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn();
};
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock utils
vi.mock('../../src/shared/utils', () => ({
@@ -83,12 +21,6 @@ describe('StateManager Settings Validation', () => {
stateManager = new StateManager();
});
afterEach(() => {
if (stateManager) {
stateManager.cleanup();
}
});
it('should validate InfoPanelWidth (number validation)', () => {
// Mock invalid string input
(getSettingValue as any).mockImplementation((key: string, def: any) => {
@@ -134,4 +66,13 @@ describe('StateManager Settings Validation', () => {
stateManager.loadSettings();
expect(stateManager.state.settings['🔍MagnifyGlass.InfoPanelFontFamily']).toBe('System Default'); // Default
});
it('keeps every font the settings offer', () => {
(getSettingValue as any).mockImplementation((key: string, def: any) => {
if (key === '🔍MagnifyGlass.InfoPanelFontFamily') return 'Press Start 2P';
return def;
});
stateManager.loadSettings();
expect(stateManager.state.settings['🔍MagnifyGlass.InfoPanelFontFamily']).toBe('Press Start 2P');
});
});
+144 -163
View File
@@ -1,26 +1,49 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { UIManager } from '../../src/info-panel/UIManager';
import { StateManager } from '../../src/info-panel/StateManager';
// Mock app global
const nodes = [
{ id: 1, title: 'KSampler', type: 'KSampler', order: 2 },
{ id: 2, title: 'Load Checkpoint', type: 'CheckpointLoaderSimple', order: 0 },
{ id: 3, title: 'Positive', type: 'CLIPTextEncode', order: 1 },
{ id: 12, title: 'Note', type: 'Note' }
];
(globalThis as any).app = {
graph: { _nodes: [] },
canvas: { centerOnNode: vi.fn() }
canvas: {
graph: { _nodes: nodes, getNodeById: (id: number) => nodes.find(n => n.id === id) ?? null },
centerOnNode: vi.fn()
}
};
describe('UIManager Dropdown Accessibility', () => {
describe('Go to node picker', () => {
let uiManager: UIManager;
let stateManager: StateManager;
let anchor: HTMLButtonElement;
const picker = () => document.querySelector('.node-selector-dropdown') as HTMLElement | null;
const filter = () => picker()!.querySelector('input') as HTMLInputElement;
const items = () => Array.from(picker()!.querySelectorAll('.dropdown-item')) as HTMLElement[];
const activeItem = () => picker()!.querySelector('.dropdown-item.active') as HTMLElement | null;
const type = (text: string) => {
filter().value = text;
filter().dispatchEvent(new Event('input'));
};
const press = (key: string) => {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
filter().dispatchEvent(event);
return event;
};
beforeEach(() => {
// Keep happy-dom from fetching the panel stylesheet
document.head.innerHTML = '<style id="magnify-info-panel-styles-v2"></style>';
stateManager = new StateManager();
stateManager.state.currentTheme = 'dark';
uiManager = new UIManager(stateManager);
document.body.innerHTML = '';
uiManager.onRefresh = vi.fn();
anchor = uiManager.elements.panel!.querySelector('[data-action="go-to-node"]') as HTMLButtonElement;
Element.prototype.scrollIntoView = vi.fn();
(globalThis as any).app.canvas.centerOnNode.mockClear();
});
afterEach(() => {
@@ -29,189 +52,147 @@ describe('UIManager Dropdown Accessibility', () => {
vi.restoreAllMocks();
});
it('should create a dropdown with accessible attributes', () => {
const nodes = [
{ id: 1, title: 'Node 1', type: 'Type A' },
{ id: 2, title: 'Node 2', type: 'Type B' }
];
const anchor = document.createElement('div');
document.body.appendChild(anchor);
it('opens from the header as a combobox over a listbox, on the current node', () => {
stateManager.state.currentNodeId = 1;
anchor.click();
(uiManager as any).createDropdown(nodes, anchor, 'title');
expect(anchor.getAttribute('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(filter());
expect(filter().getAttribute('role')).toBe('combobox');
const list = picker()!.querySelector('[role="listbox"]')!;
expect(filter().getAttribute('aria-controls')).toBe(list.id);
const dropdown = document.querySelector('.node-selector-dropdown');
expect(dropdown).not.toBeNull();
expect(dropdown?.getAttribute('role')).toBe('listbox');
expect(dropdown?.getAttribute('tabindex')).toBe('-1');
// Rows in execution order: exec number, title, type and id
expect(items().map(item => item.querySelector('.dropdown-item-id')!.textContent)).toEqual(['#2', '#3', '#1', '#12']);
expect(items()[0].querySelector('.dropdown-item-order')!.textContent).toBe('0');
expect(items()[0].querySelector('.dropdown-item-type')!.textContent).toBe('CheckpointLoaderSimple');
expect(items()[3].querySelector('.dropdown-item-order')!.textContent).toBe('');
const items = dropdown?.querySelectorAll('.dropdown-item');
expect(items?.length).toBe(2);
expect(items?.[0].getAttribute('role')).toBe('option');
expect(items?.[0].getAttribute('aria-selected')).toBe('true');
expect(activeItem()).toBe(items()[2]);
expect(items()[2].getAttribute('aria-selected')).toBe('true');
expect(filter().getAttribute('aria-activedescendant')).toBe(items()[2].id);
});
it('should navigate dropdown with keyboard', async () => {
vi.useFakeTimers();
it('filters by title, type or #id', () => {
anchor.click();
const nodes = [
{ id: 1, title: 'Node 1', type: 'Type A' },
{ id: 2, title: 'Node 2', type: 'Type B' }
];
const anchor = document.createElement('div');
document.body.appendChild(anchor);
type('posit');
expect(items().map(item => item.querySelector('.dropdown-item-title')!.textContent)).toEqual(['Positive']);
type('checkpointloader');
expect(items()).toHaveLength(1);
type('#12');
expect(items().map(item => item.querySelector('.dropdown-item-title')!.textContent)).toEqual(['Note']);
expect(activeItem()).toBe(items()[0]);
(uiManager as any).createDropdown(nodes, anchor, 'title');
vi.advanceTimersByTime(100);
const dropdown = document.querySelector('.node-selector-dropdown') as HTMLElement;
const items = dropdown.querySelectorAll('.dropdown-item');
expect(document.activeElement).toBe(dropdown);
// Arrow Down
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(items[1].getAttribute('aria-selected')).toBe('true');
// Arrow Up
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
expect(items[0].getAttribute('aria-selected')).toBe('true');
vi.useRealTimers();
type('nothing like it');
expect(items()).toHaveLength(0);
expect((picker()!.querySelector('.node-picker-empty') as HTMLElement).hidden).toBe(false);
expect(filter().hasAttribute('aria-activedescendant')).toBe(false);
expect(press('Enter').defaultPrevented).toBe(true);
expect(picker()).not.toBeNull();
});
it('should cleanup event listeners when item is selected', () => {
vi.useFakeTimers();
const docRemoveSpy = vi.spyOn(document, 'removeEventListener');
const winRemoveSpy = vi.spyOn(window, 'removeEventListener');
it('escapes node titles', () => {
nodes.push({ id: 99, title: '<img src=x onerror=alert(1)>', type: 'Evil' });
try {
anchor.click();
type('#99');
const nodes = [{ id: 1, title: 'Node 1', type: 'Type A' }];
const anchor = document.createElement('div');
document.body.appendChild(anchor);
(uiManager as any).createDropdown(nodes, anchor, 'title');
vi.advanceTimersByTime(100);
const dropdown = document.querySelector('.node-selector-dropdown') as HTMLElement;
const item = dropdown.querySelector('.dropdown-item') as HTMLElement;
// Simulate click
item.click();
// Assert cleanup was called - keydown is on window, mousedown on document
expect(winRemoveSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
expect(docRemoveSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), true);
vi.useRealTimers();
expect(picker()!.querySelector('img')).toBeNull();
expect(items()[0].textContent).toContain('<img src=x onerror=alert(1)>');
} finally {
nodes.pop();
}
});
it('should focus dropdown on creation for keyboard accessibility', () => {
// Test that dropdown receives focus when created, enabling keyboard navigation
vi.useFakeTimers();
it('moves with the arrows and jumps with Enter: centre, lock, focus back', () => {
anchor.click();
expect(activeItem()).toBe(items()[0]);
const nodes = [{ id: 1, title: 'Node 1', type: 'Type A' }, { id: 2, title: 'Node 2', type: 'Type B' }];
const anchor = document.createElement('div');
document.body.appendChild(anchor);
press('ArrowDown');
press('ArrowDown');
press('ArrowDown');
press('ArrowDown'); // stays on the last
expect(activeItem()).toBe(items()[3]);
press('ArrowUp');
expect(filter().getAttribute('aria-activedescendant')).toBe(items()[2].id);
(uiManager as any).createDropdown(nodes, anchor, 'title');
// Hovering moves the item Enter picks
items()[1].dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
expect(activeItem()).toBe(items()[1]);
vi.advanceTimersByTime(100);
const dropdown = document.querySelector('.node-selector-dropdown') as HTMLElement;
// Dropdown should have focus for keyboard accessibility
expect(document.activeElement).toBe(dropdown);
// First item should be selected by default
const items = dropdown.querySelectorAll('.dropdown-item');
expect(items[0].getAttribute('aria-selected')).toBe('true');
expect(items[1].getAttribute('aria-selected')).toBe('false');
vi.useRealTimers();
const event = press('Enter');
expect(event.defaultPrevented).toBe(true);
expect(picker()).toBeNull();
expect(document.activeElement).toBe(anchor);
expect(anchor.getAttribute('aria-expanded')).toBe('false');
expect((globalThis as any).app.canvas.centerOnNode).toHaveBeenCalledWith(nodes[2]);
expect(stateManager.state.lockedNodeId).toBe(3);
expect(uiManager.onRefresh).toHaveBeenCalledTimes(1);
});
it('should cleanup previous event listeners when opening a new dropdown via anchor click', () => {
vi.useFakeTimers();
const docRemoveSpy = vi.spyOn(document, 'removeEventListener');
const winRemoveSpy = vi.spyOn(window, 'removeEventListener');
it('picks with a click', () => {
anchor.click();
items()[3].click();
const nodes = [{ id: 1, title: 'Node 1', type: 'Type A' }];
const anchor = document.createElement('div');
document.body.appendChild(anchor);
// Open first time
(uiManager as any).createDropdown(nodes, anchor, 'title');
vi.advanceTimersByTime(100);
// Reset spies to track calls for the next action
docRemoveSpy.mockClear();
winRemoveSpy.mockClear();
// Simulate re-opening (which calls hideDropdown then createDropdown)
(uiManager as any).showTitleDropdown(anchor);
// Verify that cleanup for the FIRST dropdown occurred
expect(winRemoveSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
expect(docRemoveSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), true);
vi.useRealTimers();
expect(picker()).toBeNull();
expect(stateManager.state.lockedNodeId).toBe(12);
});
it('should not intercept Escape key when focus is elsewhere', () => {
vi.useFakeTimers();
it('Escape closes it, returns focus and stays out of other listeners', () => {
anchor.click();
const later = vi.fn();
window.addEventListener('keydown', later);
const nodes = [{ id: 1, title: 'Node 1', type: 'Type A' }];
const anchor = document.createElement('div');
document.body.appendChild(anchor);
press('Escape');
(uiManager as any).createDropdown(nodes, anchor, 'title');
vi.advanceTimersByTime(100);
// Simulate focus moving elsewhere
const otherInput = document.createElement('input');
document.body.appendChild(otherInput);
otherInput.focus();
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation');
document.dispatchEvent(event);
// If the bug exists, these will be called. We expect them NOT to be called.
expect(preventDefaultSpy).not.toHaveBeenCalled();
expect(stopPropagationSpy).not.toHaveBeenCalled();
// Also dropdown should still be open (or maybe not? The user requirement is just about blocking propagation)
// If we don't intercept, it stays open. But ideally pressing Escape on another input might close that input's menu, not ours.
// Or if it's a modal, maybe it should close?
// The reviewer says: "dropdown's handler will intercept the event and block propagation".
// So simply verifying propagation is not blocked is enough.
vi.useRealTimers();
expect(picker()).toBeNull();
expect(document.activeElement).toBe(anchor);
expect(later).not.toHaveBeenCalled();
expect(stateManager.state.lockedNodeId).toBeNull();
window.removeEventListener('keydown', later);
});
it('should not add listeners if dropdown is closed before timeout', () => {
vi.useFakeTimers();
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
it('closes on a press outside, on the button again, and removes its listener', () => {
const docRemove = vi.spyOn(document, 'removeEventListener');
anchor.click();
const nodes = [{ id: 1, title: 'Node 1', type: 'Type A' }];
const anchor = document.createElement('div');
document.body.appendChild(anchor);
picker()!.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
expect(picker()).not.toBeNull();
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
expect(picker()).toBeNull();
expect(docRemove).toHaveBeenCalledWith('pointerdown', expect.any(Function), true);
(uiManager as any).createDropdown(nodes, anchor, 'title');
anchor.click();
anchor.click();
expect(picker()).toBeNull();
expect(anchor.getAttribute('aria-expanded')).toBe('false');
});
// Immediately close it (before timeout fires)
uiManager.hideDropdown();
it('closes when focus moves elsewhere', () => {
anchor.click();
const other = document.createElement('button');
document.body.appendChild(other);
// Advance time to let timeout fire
vi.advanceTimersByTime(100);
other.focus();
// Listeners should NOT have been added
expect(addEventListenerSpy).not.toHaveBeenCalledWith('keydown', expect.any(Function));
expect(addEventListenerSpy).not.toHaveBeenCalledWith('mousedown', expect.any(Function), true);
expect(picker()).toBeNull();
});
});
vi.useRealTimers();
describe('Inspector header', () => {
it('is built before window.app exists, as the panel mounts during extension setup', () => {
const saved = (globalThis as any).app;
delete (globalThis as any).app;
try {
document.head.innerHTML = '<style id="magnify-info-panel-styles-v2"></style>';
const ui = new UIManager(new StateManager());
expect(ui.elements.panel!.querySelector('.header-title')!.textContent).toBe('Inspector');
expect((ui.elements.panel!.querySelector('[data-action="back"]') as HTMLButtonElement).disabled).toBe(true);
ui.cleanup();
} finally {
(globalThis as any).app = saved;
}
});
});
+271
View File
@@ -0,0 +1,271 @@
/**
* Widget editing: constraints, the commit path and the number/text editors.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { WidgetSyncManager } from '../../src/info-panel/widget-editors/WidgetSyncManager';
import { WidgetEditorFactory } from '../../src/info-panel/widget-editors/WidgetEditorFactory';
import { DragValueController } from '../../src/info-panel/widget-editors/DragValueController';
import { getImportantNodeParameters } from '../../src/info-panel/NodeDataExtractor';
// Widget options as frontend 1.39.5 builds them: step is 10x the input step, step2 is the real one
const intWidget = (value = 20) => ({
name: 'steps', type: 'number', value,
options: { min: 1, max: 10000, step: 10, step2: 1, precision: 0 }
});
const floatWidget = (value = 1) => ({
name: 'denoise', type: 'number', value,
options: { min: 0, max: 1, step: 0.1, step2: 0.01, round: 0.01, precision: 2 }
});
let checkState: ReturnType<typeof vi.fn>;
let setDirtyCanvas: ReturnType<typeof vi.fn>;
let node: any;
function setup(widgets: any[]) {
checkState = vi.fn();
setDirtyCanvas = vi.fn();
node = { id: 5, widgets, graph: { setDirtyCanvas } };
const subgraph = { _nodes: [node], getNodeById: (id: number) => (id === 5 ? node : null) };
(globalThis as any).app = {
// app.graph is the root graph; an open subgraph only lives on the canvas
graph: { _nodes: [], getNodeById: () => null },
canvas: { graph: subgraph, graph_mouse: [0, 0] },
extensionManager: { workflow: { activeWorkflow: { changeTracker: { checkState } } } }
};
}
function key(el: HTMLElement, k: string, init: KeyboardEventInit = {}) {
el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }));
}
describe('WidgetSyncManager', () => {
it('uses step2 and keeps INT and FLOAT apart', () => {
expect(WidgetSyncManager.extractConstraints(intWidget()).step).toBe(1);
expect(WidgetSyncManager.extractConstraints(floatWidget()).step).toBe(0.01);
expect(WidgetSyncManager.extractConstraints({ type: 'number', options: { step: 5 } }).step).toBe(0.5);
expect(WidgetSyncManager.getWidgetType(intWidget())).toBe('INT');
expect(WidgetSyncManager.getWidgetType(floatWidget())).toBe('FLOAT');
expect(WidgetSyncManager.getWidgetType({ type: 'knob', options: { precision: 0 } })).toBe('INT');
});
it('scales the step with Shift and Alt, never below what the widget stores', () => {
const c = WidgetSyncManager.extractConstraints(intWidget());
expect(WidgetSyncManager.getStep(c, { shiftKey: true, altKey: false })).toBe(10);
expect(WidgetSyncManager.getStep(c, { shiftKey: false, altKey: true })).toBe(1);
const f = WidgetSyncManager.extractConstraints({ type: 'number', options: { step2: 0.1, precision: 3 } });
expect(WidgetSyncManager.getStep(f, { shiftKey: false, altKey: true })).toBeCloseTo(0.01);
});
it('reads combo values given as a function', () => {
const widget = { type: 'combo', value: 'a', options: { values: () => ['a', 'b'] } };
expect(WidgetSyncManager.extractConstraints(widget).options).toEqual(['a', 'b']);
});
it('treats multiline text as editable and object or DOM widgets as read-only', () => {
expect(WidgetSyncManager.getWidgetType({ type: 'customtext', element: {} })).toBe('multiline');
expect(WidgetSyncManager.isWidgetEditable({ type: 'customtext', value: 'x', element: {} })).toBe(true);
expect(WidgetSyncManager.isWidgetEditable({ type: 'text', value: { a: 1 } })).toBe(false);
expect(WidgetSyncManager.isWidgetEditable({ type: 'preview', value: '', element: {} })).toBe(false);
});
it('commits through widget.setValue on the graph on screen and records an undo step', () => {
const widget: any = { ...intWidget(), setValue: vi.fn(function (this: any, v: unknown) { this.value = v; }) };
setup([widget]);
const result = WidgetSyncManager.syncWidgetValue(5, 'steps', '30.4');
expect(result).toMatchObject({ success: true, previousValue: 20, newValue: 30 });
expect(widget.setValue).toHaveBeenCalledWith(30, { node, canvas: (globalThis as any).app.canvas });
expect(setDirtyCanvas).toHaveBeenCalledWith(true, true);
expect(checkState).toHaveBeenCalledTimes(1);
});
it('falls back to assigning and calling the callback, and can skip the undo step', () => {
const callback = vi.fn();
const widget: any = { ...floatWidget(), callback };
setup([widget]);
WidgetSyncManager.syncWidgetValue(5, 'denoise', 0.456, false);
expect(widget.value).toBe(0.46);
expect(callback).toHaveBeenCalledWith(0.46, expect.anything(), node, [0, 0]);
expect(checkState).not.toHaveBeenCalled();
});
});
describe('number editor', () => {
let widget: any;
let input: HTMLInputElement;
let editor: ReturnType<typeof WidgetEditorFactory.createEditor>;
beforeEach(() => {
widget = intWidget(20);
setup([widget]);
editor = WidgetEditorFactory.createEditor({
nodeId: 5, widgetName: 'steps', widgetType: 'INT', currentValue: '20',
constraints: WidgetSyncManager.extractConstraints(widget)
});
document.body.appendChild(editor.element);
input = editor.element.querySelector('input')!;
});
it('does not write while typing and commits on Enter', () => {
input.value = '102';
input.dispatchEvent(new Event('input'));
expect(widget.value).toBe(20);
input.focus();
key(input, 'Enter');
expect(widget.value).toBe(102);
expect(checkState).toHaveBeenCalledTimes(1);
});
it('drops the typing on Escape and keeps the steps', () => {
editor.element.querySelector<HTMLButtonElement>('.increment')!.click();
expect(widget.value).toBe(21);
input.value = '99';
key(input, 'Escape');
expect(widget.value).toBe(21);
expect(input.value).toBe('21');
expect(checkState).toHaveBeenCalledTimes(1);
});
it('keeps focus in the input on a stepper press and closes when focus leaves a stepper', () => {
const onBlur = vi.fn();
const other = WidgetEditorFactory.createEditor({
nodeId: 5, widgetName: 'steps', widgetType: 'INT', currentValue: '20',
constraints: WidgetSyncManager.extractConstraints(widget), onBlur
});
document.body.appendChild(other.element);
const stepper = other.element.querySelector<HTMLButtonElement>('.increment')!;
const press = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
stepper.dispatchEvent(press);
expect(press.defaultPrevented).toBe(true);
stepper.focus();
stepper.blur();
expect(onBlur).toHaveBeenCalledTimes(1);
other.destroy();
});
it('shows a float at its precision', () => {
const denoise = floatWidget(1);
setup([denoise]);
const floatEditor = WidgetEditorFactory.createEditor({
nodeId: 5, widgetName: 'denoise', widgetType: 'FLOAT', currentValue: '1',
constraints: WidgetSyncManager.extractConstraints(denoise)
});
const floatInput = floatEditor.element.querySelector('input')!;
expect(floatInput.value).toBe('1.00');
floatEditor.element.querySelector<HTMLButtonElement>('.decrement')!.click();
expect(denoise.value).toBe(0.99);
expect(floatInput.value).toBe('0.99');
});
it('steps by step2 with the buttons and arrow keys', () => {
editor.element.querySelector<HTMLButtonElement>('.decrement')!.click();
expect(widget.value).toBe(19);
key(input, 'ArrowUp', { shiftKey: true });
expect(widget.value).toBe(29);
});
});
describe('text editor', () => {
it('commits a single line on Enter and reverts on Escape', () => {
const widget: any = { name: 'filename_prefix', type: 'text', value: 'ComfyUI' };
setup([widget]);
const editor = WidgetEditorFactory.createEditor({ nodeId: 5, widgetName: 'filename_prefix', widgetType: 'text', currentValue: 'ComfyUI' });
document.body.appendChild(editor.element);
const input = editor.element.querySelector('input')!;
input.focus();
input.value = 'fennec';
input.dispatchEvent(new Event('input'));
expect(widget.value).toBe('ComfyUI');
key(input, 'Enter');
expect(widget.value).toBe('fennec');
input.focus();
input.value = 'oops';
key(input, 'Escape');
expect(widget.value).toBe('ComfyUI');
});
it('uses a textarea for multiline, commits on Ctrl+Enter and flushes on destroy', () => {
const widget: any = { name: 'text', type: 'customtext', value: 'a fox' };
setup([widget]);
const editor = WidgetEditorFactory.createEditor({ nodeId: 5, widgetName: 'text', widgetType: 'multiline', currentValue: 'a fox' });
document.body.appendChild(editor.element);
const textarea = editor.element.querySelector('textarea')!;
textarea.focus();
textarea.value = 'a fox\nwith big ears';
key(textarea, 'Enter');
expect(widget.value).toBe('a fox');
key(textarea, 'Enter', { ctrlKey: true });
expect(widget.value).toBe('a fox\nwith big ears');
textarea.focus();
textarea.value = 'a fluffy tail';
editor.destroy();
expect(widget.value).toBe('a fluffy tail');
});
});
describe('drag', () => {
it('moves an INT by step2 per 10px and records one undo step on release', () => {
const widget = intWidget(20);
setup([widget]);
const row = document.createElement('div');
document.body.appendChild(row);
row.setPointerCapture = vi.fn();
row.releasePointerCapture = vi.fn();
new DragValueController(row, {
nodeId: 5, widgetName: 'steps',
constraints: WidgetSyncManager.extractConstraints(widget)
});
row.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientX: 100, bubbles: true }));
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 135 }));
expect(widget.value).toBe(23);
expect(checkState).not.toHaveBeenCalled();
document.dispatchEvent(new PointerEvent('pointerup', { clientX: 135 }));
expect(checkState).toHaveBeenCalledTimes(1);
// A later drag starts from the widget, whatever changed it in between
widget.value = 30;
row.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientX: 100, bubbles: true }));
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 110 }));
expect(widget.value).toBe(31);
document.dispatchEvent(new PointerEvent('pointerup', { clientX: 110 }));
});
});
describe('getImportantNodeParameters', () => {
it('shows a real widget named type and marks linked widgets read-only', () => {
const items = getImportantNodeParameters({
id: 5, type: 'CLIPLoader',
widgets: [
{ name: 'clip_name', type: 'combo', value: 'clip.safetensors', options: { values: ['clip.safetensors'] } },
{ name: 'type', type: 'combo', value: 'stable_diffusion', options: { values: ['stable_diffusion'] } },
{ name: 'mode', type: 'text', value: 'not a real widget' },
{ ...intWidget(1), name: 'seed' }
],
inputs: [
{ name: 'clip_name', widget: { name: 'clip_name' } },
{ name: 'type', widget: { name: 'type' } },
{ name: 'seed', widget: { name: 'seed' }, link: 7 }
]
} as any);
expect(items.map(i => i.label)).toEqual(['clip_name', 'type', 'seed']);
const seed = items[2];
expect(seed.isLinked).toBe(true);
expect(seed.isEditable).toBe(false);
});
});
-5
View File
@@ -1,4 +1,3 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { version } from './package.json';
@@ -36,9 +35,5 @@ export default defineConfig({
'@magnify-glass': resolve(__dirname, 'src/magnify-glass'),
'@info-panel': resolve(__dirname, 'src/info-panel')
}
},
test: {
environment: 'happy-dom',
globals: true
}
});
+627 -1441
View File
File diff suppressed because it is too large Load Diff
+12 -33
View File
@@ -1,14 +1,15 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { getSettingValue } from "../shared/utils.js";
class CanvasHighlighter {
constructor() {
constructor(settings) {
__publicField(this, "originalOnDrawForeground", null);
__publicField(this, "highlightedNodeId", null);
__publicField(this, "highlightEl", null);
// Cache for DOM updates to minimize thrashing
__publicField(this, "lastStyleState", "");
// The panel's settings cache, which the setting's onChange keeps current
__publicField(this, "settings");
// Bound method to preserve 'this' and allow equality check
__publicField(this, "boundOnDrawForeground", (ctx, visible_nodes) => {
if (this.originalOnDrawForeground) {
@@ -16,39 +17,15 @@ class CanvasHighlighter {
}
this.updateHighlightPosition();
});
this.settings = settings;
this.createHighlightElement();
}
createHighlightElement() {
this.highlightEl = document.createElement("div");
this.highlightEl.id = "magnify-glass-node-highlight";
this.highlightEl.style.cssText = `
position: fixed;
pointer-events: none;
border: 2px solid #007bff;
border-radius: 10px;
z-index: 1000;
display: none;
box-sizing: border-box;
box-shadow: 0 0 10px rgba(0, 123, 255, 0.3);
`;
this.highlightEl.style.display = "none";
document.body.appendChild(this.highlightEl);
}
/**
* Hook into the main canvas onDrawForeground method.
*/
hookCanvas() {
const app = window.app;
if (!app || !app.canvas) {
return false;
}
const canvas = app.canvas;
if (canvas.onDrawForeground === this.boundOnDrawForeground) {
return true;
}
this.originalOnDrawForeground = canvas.onDrawForeground;
canvas.onDrawForeground = this.boundOnDrawForeground;
return true;
}
/**
* Set the node ID to highlight.
*/
@@ -79,17 +56,17 @@ class CanvasHighlighter {
* Update the position of the DOM highlight element.
*/
updateHighlightPosition() {
var _a;
if (!this.highlightEl) return;
const app = window.app;
if (!app || !app.canvas || !app.graph) return;
const highlightEnabled = getSettingValue("🔍MagnifyGlass.NodeHighlightEnabled", true);
if (!highlightEnabled || this.highlightedNodeId === null) {
if (!((_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph)) return;
if (!this.settings["🔍MagnifyGlass.NodeHighlightEnabled"] || this.highlightedNodeId === null) {
if (this.highlightEl.style.display !== "none") {
this.highlightEl.style.display = "none";
}
return;
}
const node = app.graph.getNodeById(this.highlightedNodeId);
const node = app.canvas.graph.getNodeById(this.highlightedNodeId);
if (!node) {
if (this.highlightEl.style.display !== "none") {
this.highlightEl.style.display = "none";
@@ -117,13 +94,15 @@ class CanvasHighlighter {
const pageY = canvasRect.top + canvasY - padding;
const finalW = canvasW + padding * 2;
const finalH = canvasH + padding * 2;
const styleState = `${pageX.toFixed(1)},${pageY.toFixed(1)},${finalW.toFixed(1)},${finalH.toFixed(1)}`;
const clip = `inset(${canvasRect.top - pageY}px ${pageX + finalW - canvasRect.right}px ${pageY + finalH - canvasRect.bottom}px ${canvasRect.left - pageX}px)`;
const styleState = `${pageX.toFixed(1)},${pageY.toFixed(1)},${finalW.toFixed(1)},${finalH.toFixed(1)},${clip}`;
if (this.lastStyleState !== styleState || this.highlightEl.style.display === "none") {
this.highlightEl.style.display = "block";
this.highlightEl.style.left = `${pageX}px`;
this.highlightEl.style.top = `${pageY}px`;
this.highlightEl.style.width = `${finalW}px`;
this.highlightEl.style.height = `${finalH}px`;
this.highlightEl.style.clipPath = clip;
const borderWidth = Math.max(2, Math.min(6, 3 * scale));
this.highlightEl.style.borderWidth = `${borderWidth}px`;
this.lastStyleState = styleState;
+69 -124
View File
@@ -1,179 +1,124 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { isUserTyping } from "../shared/utils.js";
import { Logger } from "../shared/logger.js";
import { isUserTyping, getSettingValue, clamp, setSettingValue } from "../shared/utils.js";
import { DEFAULT_GLASS_SETTINGS, DEFAULT_PANEL_SETTINGS } from "../shared/settings/defaults.js";
class EventManager {
constructor(stateManager, panelElement, positionManager, callbacks) {
constructor(stateManager, panelElement, callbacks) {
__publicField(this, "stateManager");
__publicField(this, "panelElement");
__publicField(this, "positionManager");
__publicField(this, "callbacks");
__publicField(this, "dragCleanup");
__publicField(this, "boundHandleMouseMove");
__publicField(this, "boundHandleKeyDown");
__publicField(this, "boundHandleKeyUp");
this.stateManager = stateManager;
this.panelElement = panelElement;
this.positionManager = positionManager;
this.callbacks = callbacks || null;
this.dragCleanup = null;
this.boundHandleMouseMove = this.handleMouseMove.bind(this);
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.boundHandleKeyUp = this.handleKeyUp.bind(this);
this.setupEventListeners();
}
setupEventListeners() {
this.setupPanelEvents();
this.setupHotkeyEvents();
this.setupDragEvents();
this.setupHoverEvents();
}
setupPanelEvents() {
if (!this.panelElement) return;
}
setupHotkeyEvents() {
document.addEventListener("keydown", this.boundHandleKeyDown);
}
/**
* Dragging the header floats the panel; the corner handle resizes it into the Width and Max Height settings.
* A press that does not move is a click, so it leaves the panel where it is.
*/
setupDragEvents() {
if (!this.panelElement) return;
let isDragging = false;
let rafId = null;
let targetX = 0;
let targetY = 0;
const panel = this.panelElement;
if (!panel) return;
const startDrag = (e) => {
if (!this.panelElement) return;
const target = e.target;
const header = target.closest(".panel-header");
if (!header) return;
if (target.closest("button")) return;
if (this.stateManager.state.isPanelLocked) return;
if (!this.stateManager.state.isPanelPinned) return;
const resizing = !!target.closest(".panel-resize");
if (e.button !== 0 || !resizing && (!target.closest(".panel-header") || target.closest("button"))) return;
e.preventDefault();
e.stopPropagation();
isDragging = true;
const state = this.stateManager.state;
const settings = state.settings;
const startX = e.clientX;
const startY = e.clientY;
const rect = this.panelElement.getBoundingClientRect();
const startLeft = rect.left;
const startTop = rect.top;
this.panelElement.style.cursor = "grabbing";
this.panelElement.style.opacity = "0.9";
this.panelElement.style.transition = "none";
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
const onMouseMove = (moveEvent) => {
var _a, _b;
if (!isDragging) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
targetX = startLeft + dx;
targetY = startTop + dy;
const panelWidth = ((_a = this.panelElement) == null ? void 0 : _a.offsetWidth) || 0;
const panelHeight = ((_b = this.panelElement) == null ? void 0 : _b.offsetHeight) || 0;
targetX = Math.max(0, Math.min(targetX, window.innerWidth - panelWidth));
targetY = Math.max(0, Math.min(targetY, window.innerHeight - panelHeight));
if (rafId === null) {
rafId = requestAnimationFrame(() => {
if (this.panelElement && isDragging) {
this.panelElement.style.left = `${targetX}px`;
this.panelElement.style.top = `${targetY}px`;
}
rafId = null;
});
const rect = panel.getBoundingClientRect();
const content = panel.querySelector(".panel-content");
const startContentHeight = content.offsetHeight;
const capped = content.scrollHeight > startContentHeight;
const startWidth = settings["🔍MagnifyGlass.InfoPanelWidth"];
const startMaxHeight = settings["🔍MagnifyGlass.InfoPanelMaxHeight"];
let moved = false;
const onPointerMove = (move) => {
var _a;
const dx = move.clientX - startX;
const dy = move.clientY - startY;
if (!moved && Math.abs(dx) + Math.abs(dy) < 3) return;
moved = true;
if (resizing) {
settings["🔍MagnifyGlass.InfoPanelWidth"] = Math.abs(dx) >= 3 ? clamp(Math.round(rect.width + dx), 200, 600) : startWidth;
settings["🔍MagnifyGlass.InfoPanelMaxHeight"] = capped && Math.abs(dy) >= 3 ? clamp(Math.round(startContentHeight + dy), 200, 1500) : startMaxHeight;
} else {
state.isFloating = true;
state.floatPosition = { x: rect.left + dx, y: rect.top + dy };
}
(_a = this.callbacks) == null ? void 0 : _a.updateLayout();
};
const onPointerUp = () => {
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.removeEventListener("pointercancel", onPointerUp);
panel.classList.remove("is-dragging");
if (!moved) return;
if (resizing) {
for (const [id, start] of [["🔍MagnifyGlass.InfoPanelWidth", startWidth], ["🔍MagnifyGlass.InfoPanelMaxHeight", startMaxHeight]]) {
if (settings[id] !== start) setSettingValue(id, settings[id]);
}
} else {
this.stateManager.saveLayout();
}
};
const onMouseUp = () => {
isDragging = false;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (this.panelElement) {
this.panelElement.style.cursor = "";
this.panelElement.style.opacity = "";
this.panelElement.style.transition = "";
this.panelElement.style.left = `${targetX}px`;
this.panelElement.style.top = `${targetY}px`;
this.stateManager.state.pinnedPosition = { x: targetX, y: targetY };
}
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
this.panelElement.addEventListener("mousedown", startDrag);
this.dragCleanup = () => {
if (this.panelElement) this.panelElement.removeEventListener("mousedown", startDrag);
panel.classList.add("is-dragging");
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("pointercancel", onPointerUp);
};
panel.addEventListener("pointerdown", startDrag);
this.dragCleanup = () => panel.removeEventListener("pointerdown", startDrag);
}
setupHoverEvents() {
if (!this.panelElement) return;
this.panelElement.addEventListener("mouseenter", () => {
this.stateManager.state.isPanelHovered = true;
this.stateManager.clearAutoExpandTimer();
});
this.panelElement.addEventListener("mouseleave", () => {
this.stateManager.state.isPanelHovered = false;
if (!this.stateManager.state.isHoveringNode) {
this.stateManager.scheduleAutoCollapse();
}
});
}
/**
* Panel hotkeys act only while the glass is active and never with Ctrl, Meta or Shift held. Alt must be
* held when AltRequired is on and must not be otherwise. Keys are read on each press, so changes apply at once.
*/
handleKeyDown(e) {
var _a, _b;
if (isUserTyping()) return;
const settings = this.stateManager.state.settings;
var _a;
if (isUserTyping() || !((_a = this.callbacks) == null ? void 0 : _a.isGlassActive())) return;
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
const altRequired = getSettingValue("🔍MagnifyGlass.AltRequired", DEFAULT_GLASS_SETTINGS["🔍MagnifyGlass.AltRequired"]);
if (e.altKey !== altRequired) return;
const key = e.key.toLowerCase();
if (key === settings["🔍MagnifyGlass.ToggleHotkey"].toLowerCase()) {
Logger.debug("Toggle visibility hotkey matched!");
const hotkey = (id) => String(getSettingValue(id, DEFAULT_PANEL_SETTINGS[id])).toLowerCase();
if (key === hotkey("🔍MagnifyGlass.ToggleHotkey")) {
e.preventDefault();
(_a = this.callbacks) == null ? void 0 : _a.toggleVisibility();
this.callbacks.toggleVisibility();
}
if (key === settings["🔍MagnifyGlass.PinPanelHotkey"].toLowerCase()) {
if (key === hotkey("🔍MagnifyGlass.PinPanelHotkey")) {
e.preventDefault();
(_b = this.callbacks) == null ? void 0 : _b.togglePin();
this.callbacks.toggleFloat();
}
const holdInfoHotkey = (settings["🔍MagnifyGlass.HoldInfoHotkey"] || "p").toLowerCase();
if (key === holdInfoHotkey) {
if (settings["🔍MagnifyGlass.InfoPanelPersist"]) {
e.preventDefault();
const isHeld = this.stateManager.toggleHold();
Logger.debug(`Hold Info toggled: ${isHeld ? "PAUSED" : "PLAYING"}`);
const infoPanel = window.infoPanelManager;
if (infoPanel == null ? void 0 : infoPanel.uiManager) {
infoPanel.uiManager.updateControlStates();
}
}
}
const stickyInfoHotkey = (settings["🔍MagnifyGlass.StickyInfoHotkey"] || "s").toLowerCase();
if (key === stickyInfoHotkey) {
if (key === hotkey("🔍MagnifyGlass.StickyInfoHotkey")) {
e.preventDefault();
const newValue = !settings["🔍MagnifyGlass.InfoPanelPersist"];
settings["🔍MagnifyGlass.InfoPanelPersist"] = newValue;
try {
const app = window.app;
app.ui.settings.setSettingValue("🔍MagnifyGlass.InfoPanelPersist", newValue);
} catch (err) {
Logger.debug("Failed to persist sticky info setting");
}
if (!newValue) {
this.stateManager.state.isInfoHeld = false;
}
Logger.debug(`Sticky Info toggled: ${newValue ? "ON" : "OFF"}`);
const infoPanel = window.infoPanelManager;
if (infoPanel == null ? void 0 : infoPanel.uiManager) {
infoPanel.uiManager.updateControlStates();
}
this.callbacks.toggleSticky();
}
}
handleMouseMove(e) {
}
handleKeyUp(e) {
}
cleanup() {
document.removeEventListener("keydown", this.boundHandleKeyDown);
if (this.dragCleanup) this.dragCleanup();
+61 -86
View File
@@ -21,15 +21,17 @@ class InfoPanel {
__publicField(this, "originalShow");
__publicField(this, "originalHide");
__publicField(this, "updateFrameId", null);
// State for persistence
__publicField(this, "lastValidNodeInfo", null);
__publicField(this, "boundScheduleSelectionUpdate");
// The node Sticky keeps, looked up again on each update like the locked one
__publicField(this, "stickyNodeId", null);
__publicField(this, "stickyGraph", null);
this.magnifyGlass = magnifyGlass;
this.stateManager = new StateManager();
this.uiManager = new UIManager(this.stateManager);
this.informationGatherer = new InformationGatherer();
this.canvasHighlighter = new CanvasHighlighter();
this.canvasHighlighter = new CanvasHighlighter(this.stateManager.state.settings);
this.positionManager = new PositionManager(this.stateManager, this.uiManager.elements.panel);
this.eventManager = new EventManager(this.stateManager, this.uiManager.elements.panel, this.positionManager, {
this.eventManager = new EventManager(this.stateManager, this.uiManager.elements.panel, {
toggleVisibility: () => {
if (this.stateManager.state.isPanelVisible) {
this.uiManager.hide();
@@ -37,12 +39,23 @@ class InfoPanel {
this.uiManager.show();
}
},
togglePin: () => {
this.stateManager.togglePinning();
this.uiManager.updatePinnedState();
toggleFloat: () => this.uiManager.toggleFloating(),
toggleSticky: () => this.uiManager.setPersist(!this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]),
updateLayout: () => {
this.uiManager.applyStyles();
this.uiManager.updateControlStates();
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
},
updatePosition: () => this.positionManager.positionPanel()
isGlassActive: () => this.magnifyGlass.state.active
});
this.boundScheduleSelectionUpdate = () => {
if (this.magnifyGlass.state.active && this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelFollowSelection"]) {
this.scheduleInfoUpdate();
}
};
document.addEventListener("pointerup", this.boundScheduleSelectionUpdate, true);
document.addEventListener("keyup", this.boundScheduleSelectionUpdate, true);
this.originalUpdateMagnifiedView = magnifyGlass.updateMagnifiedView;
this.originalShow = magnifyGlass.ui.show;
this.originalHide = magnifyGlass.ui.hide;
@@ -52,15 +65,13 @@ class InfoPanel {
this.uiManager.updateControlStates();
};
this.magnifyGlass.popOutManager.onNodeSelect = (nodeId) => {
this.onNodeSelected(nodeId);
this.uiManager.jumpToNode(nodeId);
};
this.magnifyGlass.popOutManager.onWidgetEdit = () => {
this.updateInfo(true);
this.updateInfo();
};
}
this.uiManager.onNodeSelected = (nodeId) => {
this.onNodeSelected(nodeId);
};
this.uiManager.onRefresh = () => this.updateInfo();
Logger.debug("Info Panel initialized successfully");
}
hookIntoMagnifyGlass() {
@@ -98,11 +109,6 @@ class InfoPanel {
this.stateManager.state.wasPanelVisibleBeforeHide = this.stateManager.state.isPanelVisible;
this.originalHide.call(mg.ui);
this.uiManager.hide();
if (this.uiManager.elements.panel) {
this.uiManager.elements.panel.style.display = "none";
this.uiManager.elements.panel.style.pointerEvents = "none";
this.uiManager.elements.panel.style.opacity = "0";
}
if (this.uiManager.elements.controls) {
this.uiManager.elements.controls.style.display = "none";
this.uiManager.elements.controls.style.pointerEvents = "none";
@@ -135,16 +141,28 @@ class InfoPanel {
cancelAnimationFrame(this.updateFrameId);
this.updateFrameId = null;
}
document.removeEventListener("pointerup", this.boundScheduleSelectionUpdate, true);
document.removeEventListener("keyup", this.boundScheduleSelectionUpdate, true);
this.eventManager.cleanup();
this.uiManager.cleanup();
this.canvasHighlighter.cleanup();
this.stateManager.cleanup();
}
/**
* Follow the glass again from where it starts.
*/
resetPosition() {
if (!this.stateManager.state.isGlassPreviewVisible) return;
this.stateManager.state.isFloating = false;
this.stateManager.state.floatPosition = null;
this.stateManager.saveLayout();
this.positionManager.positionPanel();
this.uiManager.updateControlStates();
}
/**
* Update the info panel with current information.
* @param forceUpdate - If true, bypass the isInfoHeld check to force an update
*/
updateInfo(forceUpdate = false) {
updateInfo() {
var _a, _b, _c, _d, _e;
const settings = this.stateManager.state.settings;
const isActive = this.magnifyGlass.state.active;
if (!isActive) {
@@ -155,43 +173,33 @@ class InfoPanel {
this.canvasHighlighter.setHighlightedNode(null);
return;
}
if (this.stateManager.state.isInfoHeld && !forceUpdate) {
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
return;
}
let info = this.informationGatherer.gatherInformation();
const selectedNodeId = this.stateManager.state.selectedNodeId;
if (selectedNodeId !== null && info.hoveredNode && info.hoveredNode.id !== selectedNodeId) {
this.stateManager.clearSelectedNode();
const state = this.stateManager.state;
if (state.lockedNodeId !== null && state.lockedGraph !== ((_a = app.canvas) == null ? void 0 : _a.graph)) {
state.lockedNodeId = null;
}
const currentSelectedId = this.stateManager.state.selectedNodeId;
if (currentSelectedId !== null) {
const selectedNode = this.uiManager.nodeSelector.getNodeById(currentSelectedId);
if (selectedNode) {
const detailedInfo = this.informationGatherer.getDetailedNodeInfo(
selectedNode,
{ x: 0, y: 0 }
// No local position when manually selected
);
info = {
...info,
hoveredNode: detailedInfo,
hoveredWidget: null
};
}
const lockedNode = state.lockedNodeId === null ? null : this.uiManager.nodeSelector.getNodeById(state.lockedNodeId);
if (!lockedNode) {
state.lockedNodeId = null;
}
const selectedNode = !lockedNode && settings["🔍MagnifyGlass.InfoPanelFollowSelection"] ? Object.values(((_b = app.canvas) == null ? void 0 : _b.selected_nodes) ?? {})[0] : void 0;
state.showsSelection = !!selectedNode;
const shownNode = lockedNode ?? selectedNode;
if (shownNode) {
info = { ...info, hoveredNode: this.informationGatherer.getDetailedNodeInfo(shownNode) };
}
if (info.hoveredNode) {
this.lastValidNodeInfo = info;
} else if (settings["🔍MagnifyGlass.InfoPanelPersist"] && this.lastValidNodeInfo) {
info = {
...info,
// Current timestamp, cursor, zoom
hoveredNode: this.lastValidNodeInfo.hoveredNode,
hoveredWidget: this.lastValidNodeInfo.hoveredWidget
};
this.stickyNodeId = info.hoveredNode.id;
this.stickyGraph = (_c = app.canvas) == null ? void 0 : _c.graph;
} else if (settings["🔍MagnifyGlass.InfoPanelPersist"] && this.stickyNodeId !== null) {
const stickyNode = this.stickyGraph === ((_d = app.canvas) == null ? void 0 : _d.graph) ? this.uiManager.nodeSelector.getNodeById(this.stickyNodeId) : null;
if (stickyNode) {
info = { ...info, hoveredNode: this.informationGatherer.getDetailedNodeInfo(stickyNode) };
} else {
this.stickyNodeId = null;
}
}
this.stateManager.setCurrentInfo(info);
state.currentNodeId = ((_e = info.hoveredNode) == null ? void 0 : _e.id) ?? null;
this.uiManager.displayInfo(info);
this.canvasHighlighter.setHighlightedNode(info.hoveredNode ? info.hoveredNode.id : null);
if (this.magnifyGlass.popOutManager && this.magnifyGlass.popOutManager.isPopOutOpen()) {
@@ -199,39 +207,6 @@ class InfoPanel {
}
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
if (info.hoveredNode) {
this.stateManager.state.isHoveringNode = true;
this.stateManager.expandNodeSections();
if (this.stateManager.state.lastNodeId !== info.hoveredNode.id) {
this.stateManager.state.lastNodeId = info.hoveredNode.id;
}
} else {
this.stateManager.state.isHoveringNode = false;
if (!settings["🔍MagnifyGlass.InfoPanelPersist"]) {
if (this.stateManager.state.lastNodeId !== null) {
this.stateManager.state.lastNodeId = null;
if (!this.stateManager.state.isPanelHovered) {
this.stateManager.scheduleAutoCollapse();
}
}
}
}
}
/**
* Handle node selection from dropdown.
* @param nodeId - ID of the selected node
*/
onNodeSelected(nodeId) {
Logger.debug(`Node selected from dropdown: ${nodeId}`);
this.stateManager.setSelectedNode(nodeId);
this.updateInfo(true);
}
/**
* Clear the selected node, returning to hover-based detection.
*/
clearSelectedNode() {
this.stateManager.clearSelectedNode();
this.updateInfo();
}
}
function mountInfoPanel(magnifyGlass) {
+37 -207
View File
@@ -2,77 +2,39 @@ class InformationGatherer {
constructor() {
}
gatherInformation() {
var _a, _b;
const magnifyGlass = window.comfyUIMagnifyGlass;
if (!magnifyGlass) {
return {
timestamp: Date.now(),
cursor: { screenX: 0, screenY: 0, canvasX: 0, canvasY: 0 },
cursor: { canvasX: 0, canvasY: 0 },
zoom: 1,
nodeCount: 0,
hoveredNode: null,
hoveredWidget: null,
mediaElement: null
hoveredNode: null
};
}
const info = {
timestamp: Date.now(),
cursor: {
screenX: magnifyGlass.lastKnownMousePosition.x,
screenY: magnifyGlass.lastKnownMousePosition.y,
canvasX: magnifyGlass.state.x,
canvasY: magnifyGlass.state.y
},
zoom: magnifyGlass.state.canvasScale,
nodeCount: ((_b = (_a = app.graph) == null ? void 0 : _a._nodes) == null ? void 0 : _b.length) ?? 0,
hoveredNode: null,
hoveredWidget: null,
mediaElement: null
hoveredNode: null
};
if (magnifyGlass.isOverMedia && magnifyGlass.currentMediaElement) {
info.mediaElement = this.getMediaInfo(magnifyGlass.currentMediaElement);
}
const nodeUnderCursor = this.getNodeUnderCursor();
if (nodeUnderCursor) {
info.hoveredNode = this.getDetailedNodeInfo(nodeUnderCursor.node, nodeUnderCursor.localPos);
const widget = this.getWidgetUnderCursor(nodeUnderCursor.node, nodeUnderCursor.localPos);
if (widget) {
info.hoveredWidget = this.getWidgetInfo(widget);
}
info.hoveredNode = this.getDetailedNodeInfo(nodeUnderCursor);
}
return info;
}
getNodeUnderCursor() {
var _a;
const magnifyGlass = window.comfyUIMagnifyGlass;
if (!app.graph || !app.canvas || !magnifyGlass) {
const graph = (_a = app.canvas) == null ? void 0 : _a.graph;
if (!graph || !magnifyGlass) {
return null;
}
if (app.canvas.node_over) {
return app.canvas.node_over;
}
try {
if (app.canvas.node_over) {
const node = app.canvas.node_over;
if (node && node.pos && node.size) {
const canvasRect2 = app.canvas.canvas.getBoundingClientRect();
const mouseX2 = magnifyGlass.lastKnownMousePosition.x - canvasRect2.left;
const mouseY2 = magnifyGlass.lastKnownMousePosition.y - canvasRect2.top;
let graphPos2;
if (app.canvas.convertOffsetToCanvasPos) {
graphPos2 = app.canvas.convertOffsetToCanvasPos([mouseX2, mouseY2]);
} else {
const ds = app.canvas.ds || { scale: 1, offset: [0, 0] };
graphPos2 = [
mouseX2 / ds.scale - ds.offset[0] / ds.scale,
mouseY2 / ds.scale - ds.offset[1] / ds.scale
];
}
return {
node,
localPos: {
x: graphPos2[0] - node.pos[0],
y: graphPos2[1] - node.pos[1]
}
};
}
}
const canvasRect = app.canvas.canvas.getBoundingClientRect();
const mouseX = magnifyGlass.lastKnownMousePosition.x - canvasRect.left;
const mouseY = magnifyGlass.lastKnownMousePosition.y - canvasRect.top;
@@ -86,27 +48,14 @@ class InformationGatherer {
mouseY / ds.scale - ds.offset[1] / ds.scale
];
}
for (let i = app.graph._nodes.length - 1; i >= 0; i--) {
const node = app.graph._nodes[i];
if (!node.pos || !node.size || node.flags && node.flags.collapsed) continue;
if (graphPos[0] >= node.pos[0] && graphPos[0] <= node.pos[0] + node.size[0] && graphPos[1] >= node.pos[1] && graphPos[1] <= node.pos[1] + node.size[1]) {
return {
node,
localPos: {
x: graphPos[0] - node.pos[0],
y: graphPos[1] - node.pos[1]
}
};
}
}
return graph.getNodeOnPos(graphPos[0], graphPos[1]) ?? null;
} catch (err) {
console.warn("Error in node detection:", err);
}
return null;
}
getDetailedNodeInfo(node, localPos) {
var _a, _b, _c, _d;
let author;
getDetailedNodeInfo(node) {
var _a, _b, _c, _d, _e, _f;
let category;
let executionOrder;
let pythonModule;
@@ -114,17 +63,13 @@ class InformationGatherer {
const nodeData = (_a = node.constructor) == null ? void 0 : _a.nodeData;
if (nodeData) {
pythonModule = nodeData.python_module;
author = nodeData.author || (pythonModule == null ? void 0 : pythonModule.split(".")[0]);
category = nodeData.category;
}
if (!author && typeof LiteGraph !== "undefined") {
if (!pythonModule && typeof LiteGraph !== "undefined") {
const nodeType = (_b = LiteGraph.registered_node_types) == null ? void 0 : _b[node.type];
if (nodeType) {
author = ((_c = nodeType.nodeData) == null ? void 0 : _c.author) || nodeType.author;
category = nodeType.category || category;
if (!pythonModule && ((_d = nodeType.nodeData) == null ? void 0 : _d.python_module)) {
pythonModule = nodeType.nodeData.python_module;
}
pythonModule = (_c = nodeType.nodeData) == null ? void 0 : _c.python_module;
}
}
executionOrder = node.order;
@@ -137,166 +82,51 @@ class InformationGatherer {
mode: this.getNodeModeText(node.mode),
position: {
x: Math.round(node.pos[0]),
y: Math.round(node.pos[1]),
formatted: `(${Math.round(node.pos[0])}, ${Math.round(node.pos[1])})`
y: Math.round(node.pos[1])
},
size: {
width: Math.round(node.size[0]),
height: Math.round(node.size[1]),
formatted: `${Math.round(node.size[0])}×${Math.round(node.size[1])}`
},
localPosition: {
x: Math.round(localPos.x),
y: Math.round(localPos.y),
formatted: `(${Math.round(localPos.x)}, ${Math.round(localPos.y)})`,
percentage: {
x: (localPos.x / node.size[0] * 100).toFixed(1),
y: (localPos.y / node.size[1] * 100).toFixed(1)
}
},
counts: {
widgets: node.widgets ? node.widgets.length : 0,
inputs: node.inputs ? node.inputs.length : 0,
outputs: node.outputs ? node.outputs.length : 0,
properties: node.properties ? Object.keys(node.properties).length : 0
height: Math.round(node.size[1])
},
widgets: node.widgets || [],
inputs: node.inputs || [],
outputs: node.outputs || [],
properties: node.properties || {},
hoverRegion: this.detectNodeRegion(localPos, node),
executionOrder,
author,
category,
pythonModule
pythonModule,
graphPath: this.getGraphPath(node.graph),
group: (_e = (_d = node.graph) == null ? void 0 : _d.getGroupOnPos(node.pos[0] + node.size[0] / 2, node.pos[1] + node.size[1] / 2)) == null ? void 0 : _e.title,
isSubgraph: !!((_f = node.isSubgraphNode) == null ? void 0 : _f.call(node))
};
}
getNodeInfo(node) {
return {
id: node.id,
title: node.title || "Untitled",
type: node.type,
mode: node.mode,
size: node.size ? `${Math.round(node.size[0])}×${Math.round(node.size[1])}` : "Unknown",
position: node.pos ? `(${Math.round(node.pos[0])}, ${Math.round(node.pos[1])})` : "Unknown",
widgets: node.widgets || [],
inputs: node.inputs || [],
outputs: node.outputs || [],
properties: node.properties || {}
};
}
getWidgetUnderCursor(node, localPos) {
if (!node.widgets || !node.widgets.length) return null;
const titleHeight = 30;
let currentY = titleHeight;
for (const widget of node.widgets) {
const widgetHeight = 25;
if (localPos.y >= currentY && localPos.y <= currentY + widgetHeight) {
return widget;
/**
* Names of the subgraphs that lead from the root graph to this one, found through the subgraph nodes.
*/
getGraphPath(graph) {
if (!graph || graph.rootGraph === graph) return [];
const find = (nodes, path) => {
var _a;
for (const node of nodes) {
if (!node.subgraph || !((_a = node.isSubgraphNode) == null ? void 0 : _a.call(node))) continue;
const subPath = [...path, node.subgraph.name || "Subgraph"];
const found = node.subgraph === graph ? subPath : find(node.subgraph._nodes, subPath);
if (found) return found;
}
currentY += widgetHeight + 5;
}
return null;
}
getWidgetInfo(widget) {
return {
name: widget.name,
type: widget.type,
value: this.formatValue(widget.value),
options: widget.options || null,
min: widget.min,
max: widget.max,
step: widget.step
return null;
};
}
getMediaInfo(mediaElement) {
const info = {
type: mediaElement instanceof HTMLImageElement ? "image" : "video",
tagName: mediaElement.tagName,
src: mediaElement.src ? mediaElement.src.substring(mediaElement.src.lastIndexOf("/") + 1) : "No source",
naturalWidth: 0,
naturalHeight: 0,
displayWidth: 0,
displayHeight: 0,
aspectRatio: ""
};
if (mediaElement instanceof HTMLImageElement) {
info.naturalWidth = mediaElement.naturalWidth;
info.naturalHeight = mediaElement.naturalHeight;
info.displayWidth = Math.round(mediaElement.width);
info.displayHeight = Math.round(mediaElement.height);
if (mediaElement.naturalWidth && mediaElement.naturalHeight) {
info.aspectRatio = (mediaElement.naturalWidth / mediaElement.naturalHeight).toFixed(2);
}
} else if (mediaElement instanceof HTMLVideoElement) {
info.naturalWidth = mediaElement.videoWidth;
info.naturalHeight = mediaElement.videoHeight;
info.displayWidth = Math.round(mediaElement.width);
info.displayHeight = Math.round(mediaElement.height);
info.duration = mediaElement.duration;
info.currentTime = mediaElement.currentTime;
if (mediaElement.videoWidth && mediaElement.videoHeight) {
info.aspectRatio = (mediaElement.videoWidth / mediaElement.videoHeight).toFixed(2);
}
}
return info;
return find(graph.rootGraph._nodes, []) ?? [];
}
getNodeModeText(mode) {
const modes = {
0: "Always",
1: "On Event",
2: "Never",
2: "Mute",
3: "On Trigger",
4: "On Request"
4: "Bypass"
};
return modes[mode] || `Mode ${mode}`;
}
detectNodeRegion(localPos, node) {
const titleHeight = 30;
const regions = [];
if (localPos.y <= titleHeight) {
regions.push("Title Bar");
}
if (localPos.x <= 10) {
regions.push("Left Edge");
} else if (localPos.x >= node.size[0] - 10) {
regions.push("Right Edge");
}
if (localPos.y <= 10) {
regions.push("Top Edge");
} else if (localPos.y >= node.size[1] - 10) {
regions.push("Bottom Edge");
}
if (regions.length === 0) {
if (localPos.y > titleHeight) {
regions.push("Content Area");
}
}
if (localPos.x <= 20 && localPos.y > titleHeight) {
regions.push("Input Area");
} else if (localPos.x >= node.size[0] - 20 && localPos.y > titleHeight) {
regions.push("Output Area");
}
return regions.length > 0 ? regions.join(", ") : "Unknown";
}
formatValue(value) {
if (value === null) return "null";
if (value === void 0) return "undefined";
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return Number.isInteger(value) ? value.toString() : value.toFixed(3);
}
if (typeof value === "boolean") return value.toString();
if (Array.isArray(value)) {
return `Array(${value.length})`;
}
if (typeof value === "object") {
return "Object";
}
return String(value);
}
}
export {
InformationGatherer
+14 -83
View File
@@ -1,113 +1,44 @@
import { formatWidgetValue } from "./ValueFormatter.js";
import { WidgetSyncManager } from "./widget-editors/WidgetSyncManager.js";
function getCheckpointInfo(nodeInfo) {
if (nodeInfo.type && (nodeInfo.type.includes("CheckpointLoader") || nodeInfo.type.includes("LoadCheckpoint") || nodeInfo.type.includes("ModelLoader") || nodeInfo.type.includes("UNETLoader") || nodeInfo.type.includes("VAELoader") || nodeInfo.type.includes("LoraLoader"))) {
if (nodeInfo.widgets && nodeInfo.widgets.length > 0) {
for (const widget of nodeInfo.widgets) {
if (widget.name && (widget.name.toLowerCase().includes("model") || widget.name.toLowerCase().includes("checkpoint") || widget.name.toLowerCase().includes("ckpt") || widget.name.toLowerCase().includes("lora") || widget.name.toLowerCase().includes("vae") || widget.name.toLowerCase().includes("file"))) {
const value = String(widget.value);
const filename = value.split(/[\/\\]/).pop();
return filename || value;
}
}
}
}
return null;
}
function getImageInfo(nodeInfo) {
if (nodeInfo.type && (nodeInfo.type.includes("SaveImage") || nodeInfo.type.includes("PreviewImage") || nodeInfo.type.includes("VisionOutput") || nodeInfo.type.includes("ImageOutput") || nodeInfo.type.includes("LoadImage") || nodeInfo.type.includes("Display"))) {
if (nodeInfo.widgets) {
for (const widget of nodeInfo.widgets) {
if (widget.name && (widget.name.toLowerCase().includes("image") || widget.name.toLowerCase().includes("filename") || widget.name.toLowerCase().includes("file"))) {
return String(widget.value);
}
}
}
if (nodeInfo.properties && nodeInfo.properties.img) {
const img = nodeInfo.properties.img;
return {
width: img.width || "unknown",
height: img.height || "unknown",
src: img.src ? String(img.src).split(/[\/\\]/).pop() || "Preview available" : "Preview available"
};
}
if (nodeInfo.outputs) {
for (const output of nodeInfo.outputs) {
if (output.links && output.links.length > 0) {
return "Image connected to " + output.links.length + " node(s)";
}
}
}
return "Image node";
}
return null;
}
function getTextBoxContent(nodeInfo) {
if (nodeInfo.widgets && nodeInfo.widgets.length > 0) {
if (nodeInfo.type && nodeInfo.type.includes("CLIPTextEncode")) {
for (const widget of nodeInfo.widgets) {
if (widget.name === "text" && typeof widget.value === "string") {
return widget.value;
}
}
}
for (const widget of nodeInfo.widgets) {
if ((widget.name.toLowerCase().includes("prompt") || widget.name.toLowerCase().includes("conditioning")) && typeof widget.value === "string" && widget.value.length > 0) {
return widget.value;
}
}
for (const widget of nodeInfo.widgets) {
if ((widget.type === "text" || widget.type === "textarea" || widget.type === "string" || widget.name.toLowerCase().includes("text")) && typeof widget.value === "string" && widget.value.length > 0) {
return widget.value;
}
}
}
return null;
}
const SKIP_WIDGET_NAMES = ["title", "node", "id", "type", "mode"];
function getImportantNodeParameters(nodeInfo) {
const parameters = [];
if (!nodeInfo.widgets || nodeInfo.widgets.length === 0) {
return parameters;
}
const nodeType = nodeInfo.type || "";
const typeLower = nodeType.toLowerCase();
typeLower.includes("save") && !typeLower.includes("checkpoint") && !typeLower.includes("model") && !typeLower.includes("preview");
const inputs = nodeInfo.inputs || [];
const widgetSlot = (widget) => inputs.find((input) => {
var _a;
return ((_a = input.widget) == null ? void 0 : _a.name) === widget.name;
});
const createParameterItem = (widget) => {
var _a;
const widgetType = WidgetSyncManager.getWidgetType(widget);
const constraints = WidgetSyncManager.extractConstraints(widget);
const isEditable = WidgetSyncManager.isWidgetEditable(widget);
const isLinked = !!widget.computedDisabled || ((_a = widgetSlot(widget)) == null ? void 0 : _a.link) != null;
const isEditable = !isLinked && WidgetSyncManager.isWidgetEditable(widget);
const isActionable = widgetType === "button";
return {
label: widget.name,
value: formatWidgetValue(widget.value),
label: widget.label || widget.name,
value: formatWidgetValue(widget.value, constraints.precision),
// Editing metadata
widgetName: widget.name,
widgetType,
isEditable,
isActionable,
isLinked,
rawValue: widget.value,
constraints,
nodeId: nodeInfo.id
};
};
const addWidget = (widget) => {
parameters.push(createParameterItem(widget));
return true;
};
for (const widget of nodeInfo.widgets) {
if (widget.name && widget.name !== "") {
const widgetName = widget.name.toLowerCase();
if (!SKIP_WIDGET_NAMES.includes(widgetName)) {
addWidget(widget);
}
if (widget.name && (!SKIP_WIDGET_NAMES.includes(widget.name.toLowerCase()) || widgetSlot(widget))) {
parameters.push(createParameterItem(widget));
}
}
return parameters;
}
export {
getCheckpointInfo,
getImageInfo,
getImportantNodeParameters,
getTextBoxContent
getImportantNodeParameters
};
+16 -36
View File
@@ -3,60 +3,40 @@ class NodeSelector {
* Get all nodes from the graph.
*/
getAllNodes() {
var _a;
return ((_a = app == null ? void 0 : app.graph) == null ? void 0 : _a._nodes) ?? [];
var _a, _b;
return ((_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b._nodes) ?? [];
}
/**
* Get nodes sorted alphabetically by title.
*/
getNodesSortedByTitle() {
getEntries() {
return this.getAllNodes().map((n) => ({
id: n.id,
title: n.title || "Untitled",
type: n.type || "Unknown"
})).sort((a, b) => a.title.localeCompare(b.title));
type: n.type || "Unknown",
order: n.order ?? -1
}));
}
/**
* Get nodes sorted by execution order.
* Only includes nodes that have a valid execution order.
*/
getNodesSortedByExecOrder() {
return this.getAllNodes().map((n) => ({
id: n.id,
title: n.title || "Untitled",
type: n.type || "Unknown",
order: n.order ?? -1
})).filter((n) => n.order >= 0).sort((a, b) => a.order - b.order);
}
/**
* Get nodes sorted by ID number (ascending).
*/
getNodesSortedById() {
return this.getAllNodes().map((n) => ({
id: n.id,
title: n.title || "Untitled",
type: n.type || "Unknown"
})).sort((a, b) => a.id - b.id);
return this.getEntries().filter((n) => n.order >= 0).sort((a, b) => a.order - b.order);
}
/**
* Get a node by its ID.
*/
getNodeById(id) {
var _a;
return ((_a = app == null ? void 0 : app.graph) == null ? void 0 : _a.getNodeById(id)) ?? null;
var _a, _b;
return ((_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(id)) ?? null;
}
/**
* Search nodes by title (case-insensitive partial match).
* Nodes whose title or type contains the query (any case), or whose id it names as `#id` or plainly.
* Sorted by execution order; nodes without one come last, by title.
*/
searchByTitle(query) {
const lowerQuery = query.toLowerCase();
return this.getNodesSortedByTitle().filter((n) => n.title.toLowerCase().includes(lowerQuery));
}
/**
* Get total node count.
*/
getNodeCount() {
return this.getAllNodes().length;
search(query) {
const q = query.trim().toLowerCase();
const idQuery = q.startsWith("#") ? q.slice(1) : null;
const rank = (n) => n.order < 0 ? Infinity : n.order;
return this.getEntries().filter((n) => idQuery !== null ? !idQuery || String(n.id) === idQuery : n.title.toLowerCase().includes(q) || n.type.toLowerCase().includes(q) || String(n.id) === q).sort((a, b) => rank(a) - rank(b) || a.title.localeCompare(b.title));
}
}
export {
+28 -38
View File
@@ -13,28 +13,30 @@ class PositionManager {
}
positionPanel() {
if (!this.panelElement) return;
if (this.stateManager.state.isPanelPinned) {
this.applyPinnedPosition();
if (this.stateManager.state.isFloating) {
this.applyFloatPosition();
return;
}
this.calculateNormalPosition();
}
applyPinnedPosition() {
if (!this.panelElement) return;
let { x, y } = this.stateManager.state.pinnedPosition;
if (x === 0 && y === 0) {
/**
* Keep a floating panel where it was put, 4px inside the window. It starts floating where it last followed the glass.
*/
applyFloatPosition() {
const state = this.stateManager.state;
if (!state.floatPosition) {
this.calculateNormalPosition();
if (this.panelElement) {
const rect = this.panelElement.getBoundingClientRect();
this.stateManager.state.pinnedPosition = { x: rect.left, y: rect.top };
if (this.cachedPanelPosition) {
state.floatPosition = { x: this.cachedPanelPosition.x, y: this.cachedPanelPosition.y };
}
return;
}
const panelWidth = this.panelElement.offsetWidth || 300;
const panelHeight = this.panelElement.offsetHeight || 400;
const margin = 4;
const boundedX = Math.max(margin, Math.min(x, window.innerWidth - panelWidth - margin));
const boundedY = Math.max(margin, Math.min(y, window.innerHeight - panelHeight - margin));
const boundedX = Math.max(margin, Math.min(state.floatPosition.x, window.innerWidth - panelWidth - margin));
const boundedY = Math.max(margin, Math.min(state.floatPosition.y, window.innerHeight - panelHeight - margin));
state.floatPosition = { x: boundedX, y: boundedY };
this.panelElement.style.left = `${boundedX}px`;
this.panelElement.style.top = `${boundedY}px`;
this.cachedPanelPosition = { x: boundedX, y: boundedY, width: panelWidth, height: panelHeight };
@@ -138,37 +140,25 @@ class PositionManager {
}
if (!referenceRect) return;
controlsElement.style.transition = "none";
const controlsPosition = this.stateManager.state.settings["🔍MagnifyGlass.ControlsPosition"] || "right";
const [side, edge] = String(this.stateManager.state.settings["🔍MagnifyGlass.ControlsPosition"] || "left").split("-");
const width = controlsElement.offsetWidth;
const height = controlsElement.offsetHeight;
const margin = 4;
let left;
let top;
switch (controlsPosition) {
case "left":
left = referenceRect.left - controlsElement.offsetWidth - margin;
top = referenceRect.top;
break;
case "right":
left = referenceRect.right + margin;
top = referenceRect.top;
break;
case "top":
left = referenceRect.left;
top = referenceRect.top - controlsElement.offsetHeight - margin;
break;
case "bottom":
left = referenceRect.left;
top = referenceRect.bottom + margin;
break;
case "top-right":
left = referenceRect.right - controlsElement.offsetWidth;
top = referenceRect.top - controlsElement.offsetHeight - margin;
break;
default:
left = referenceRect.right + margin;
top = referenceRect.top;
if (side === "top" || side === "bottom") {
const above = referenceRect.top - height - margin;
const below = referenceRect.bottom + margin;
top = side === "top" ? above >= margin ? above : below : below + height <= window.innerHeight - margin ? below : above;
left = edge === "right" ? referenceRect.right - width : referenceRect.left;
} else {
const before = referenceRect.left - width - margin;
const after = referenceRect.right + margin;
left = side === "left" ? before >= margin ? before : after : after + width <= window.innerWidth - margin ? after : before;
top = referenceRect.top;
}
left = Math.max(10, Math.min(left, window.innerWidth - controlsElement.offsetWidth - 10));
top = Math.max(10, Math.min(top, window.innerHeight - controlsElement.offsetHeight - 10));
left = Math.max(margin, Math.min(left, window.innerWidth - width - margin));
top = Math.max(margin, Math.min(top, window.innerHeight - height - margin));
controlsElement.style.left = `${left}px`;
controlsElement.style.top = `${top}px`;
controlsElement.style.display = "flex";
+25 -289
View File
@@ -4,204 +4,55 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
import { getSettingValue, clamp } from "../shared/utils.js";
import { DEFAULT_PANEL_SETTINGS } from "../shared/settings/defaults.js";
import "/scripts/app.js";
import { STORAGE_KEYS, PANEL_FONTS } from "../shared/constants.js";
class StateManager {
constructor() {
__publicField(this, "state");
__publicField(this, "themeObserver");
__publicField(this, "themeInterval");
__publicField(this, "themeTimeout");
__publicField(this, "colorSchemeQuery");
__publicField(this, "onColorSchemeChange", () => this.detectCurrentTheme());
// Clicks on theme toggle buttons trigger an immediate re-detection
__publicField(this, "onDocumentClick", (e) => {
var _a, _b, _c, _d;
const target = e.target;
const isThemeButton = ((_a = target.textContent) == null ? void 0 : _a.toLowerCase().includes("theme")) || ((_b = target.textContent) == null ? void 0 : _b.toLowerCase().includes("light")) || ((_c = target.textContent) == null ? void 0 : _c.toLowerCase().includes("dark")) || ((_d = target.title) == null ? void 0 : _d.toLowerCase().includes("theme")) || typeof target.className === "string" && target.className.toLowerCase().includes("theme");
if (isThemeButton) {
setTimeout(() => {
if (this.detectCurrentTheme()) {
this.notifyThemeChange();
}
}, 100);
}
});
this.state = {
// Panel visibility and positioning
isPanelVisible: false,
wasPanelVisibleBeforeHide: false,
isPanelMinimized: false,
isPanelPinned: false,
isPanelLocked: false,
// New lock state
isAutoPinned: false,
// Track if panel was auto-pinned by glass hide
pinnedPosition: { x: 0, y: 0 },
lastPinnedPosition: null,
// Remember last pinned location
isFloating: false,
floatPosition: null,
// Glass visibility
isGlassPreviewVisible: true,
isInfoHeld: false,
// Section expansion
expandedSections: /* @__PURE__ */ new Set(["node"]),
// Interaction states
isPanelHovered: false,
isHoveringNode: false,
// Auto-collapse
autoExpandTimer: null,
lastNodeId: null,
// Current data
currentInfo: null,
// Node selection override
selectedNodeId: null,
currentNodeId: null,
lockedNodeId: null,
lockedGraph: null,
showsSelection: false,
// Settings cache
settings: {},
// Auto-detected theme
currentTheme: "dark"
// Will be auto-detected
settings: {}
};
this.initThemeDetection();
this.loadSettings();
this.loadLayout();
}
initThemeDetection() {
this.detectCurrentTheme();
this.themeTimeout = setTimeout(() => {
if (this.detectCurrentTheme()) {
this.notifyThemeChange();
}
}, 1e3);
this.setupThemeObserver();
this.colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
this.colorSchemeQuery.addEventListener("change", this.onColorSchemeChange);
this.setupThemeButtonListeners();
}
detectCurrentTheme() {
let detectedTheme = "dark";
/**
* Float mode, float position and minimized state are kept per browser.
*/
loadLayout() {
try {
const colorPalette = getSettingValue("Comfy.ColorPalette", "");
if (colorPalette) {
const paletteStr = String(colorPalette).toLowerCase();
if (paletteStr.includes("obsidian") && paletteStr.includes("dark")) {
detectedTheme = "obsidian-dark";
} else if (paletteStr.includes("obsidian")) {
detectedTheme = "obsidian";
} else if (paletteStr.includes("milk") || paletteStr.includes("white")) {
detectedTheme = "milk-white";
} else if (paletteStr.includes("solarized")) {
detectedTheme = "solarized";
} else if (paletteStr.includes("arc")) {
detectedTheme = "arc";
} else if (paletteStr.includes("nord")) {
detectedTheme = "nord";
} else if (paletteStr.includes("github")) {
detectedTheme = "github";
} else if (paletteStr.includes("light")) {
detectedTheme = "light";
} else if (paletteStr.includes("dark")) {
detectedTheme = "dark";
}
const layout = JSON.parse(localStorage.getItem(STORAGE_KEYS.INSPECTOR_LAYOUT) || "{}");
this.state.isFloating = layout.floating === true;
this.state.isPanelMinimized = layout.minimized === true;
if (Number.isFinite(layout.x) && Number.isFinite(layout.y)) {
this.state.floatPosition = { x: layout.x, y: layout.y };
}
} catch (e) {
console.warn("ComfyUI Info Panel: Could not read theme from settings, using fallback detection");
}
if (detectedTheme === "dark") {
const body = document.body;
const html = document.documentElement;
const dataTheme = body.getAttribute("data-theme") || html.getAttribute("data-theme");
if (dataTheme) {
detectedTheme = dataTheme.toLowerCase();
}
const classes = (body.className + " " + html.className).toLowerCase();
if (classes.includes("theme-obsidian-dark") || classes.includes("obsidian-dark")) {
detectedTheme = "obsidian-dark";
} else if (classes.includes("theme-obsidian") || classes.includes("obsidian")) {
detectedTheme = "obsidian";
} else if (classes.includes("theme-milk-white") || classes.includes("milk-white") || classes.includes("milk_white")) {
detectedTheme = "milk-white";
} else if (classes.includes("theme-solarized") || classes.includes("solarized")) {
detectedTheme = "solarized";
} else if (classes.includes("theme-arc") || classes.includes("arc-theme")) {
detectedTheme = "arc";
} else if (classes.includes("theme-nord") || classes.includes("nord-theme")) {
detectedTheme = "nord";
} else if (classes.includes("theme-github") || classes.includes("github-theme")) {
detectedTheme = "github";
} else if (classes.includes("theme-light") || classes.includes("light-theme")) {
detectedTheme = "light";
} else if (classes.includes("theme-dracula") || classes.includes("dracula-theme")) {
detectedTheme = "dracula";
} else if (classes.includes("theme-gruvbox") || classes.includes("gruvbox-theme")) {
detectedTheme = "gruvbox";
} else if (classes.includes("theme-monokai") || classes.includes("monokai-theme")) {
detectedTheme = "monokai";
}
}
if (detectedTheme === "dark") {
const bodyStyles = window.getComputedStyle(document.body);
const bgColor = bodyStyles.backgroundColor;
if (bgColor && bgColor !== "rgba(0, 0, 0, 0)" && bgColor !== "transparent") {
const rgbMatch = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
const brightness = (r * 299 + g * 587 + b * 114) / 1e3;
if (brightness > 180) {
detectedTheme = "light";
}
}
}
}
if (this.state.currentTheme !== detectedTheme) {
this.state.currentTheme = detectedTheme;
return true;
}
return false;
}
setupThemeObserver() {
const observer = this.themeObserver = new MutationObserver((mutations) => {
let shouldCheck = false;
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && (mutation.attributeName === "class" || mutation.attributeName === "data-theme" || mutation.attributeName === "style")) {
shouldCheck = true;
}
});
if (shouldCheck && this.detectCurrentTheme()) {
this.notifyThemeChange();
}
});
observer.observe(document.body, { attributes: true, attributeOldValue: true });
observer.observe(document.documentElement, { attributes: true, attributeOldValue: true });
const vueApp = document.querySelector("#vue-app");
if (vueApp) {
observer.observe(vueApp, { attributes: true, attributeOldValue: true });
}
const themeElements = document.querySelectorAll('[class*="theme"], [class*="dark"], [class*="light"], [data-theme]');
themeElements.forEach((el) => {
observer.observe(el, { attributes: true, attributeOldValue: true });
});
this.themeInterval = setInterval(() => {
if (this.detectCurrentTheme()) {
this.notifyThemeChange();
}
}, 2e3);
}
setupThemeButtonListeners() {
document.addEventListener("click", this.onDocumentClick);
}
notifyThemeChange() {
this.state.settings["🔍MagnifyGlass.InfoPanelTheme"] = this.state.currentTheme;
if (window.infoPanelManager && window.infoPanelManager.uiManager) {
window.infoPanelManager.uiManager.updateTheme(this.state.currentTheme);
}
const magnifyGlass = window.comfyUIMagnifyGlass;
if (magnifyGlass && magnifyGlass.popOutManager) {
magnifyGlass.popOutManager.updateTheme(this.state.currentTheme);
saveLayout() {
const { isFloating, floatPosition, isPanelMinimized } = this.state;
try {
localStorage.setItem(STORAGE_KEYS.INSPECTOR_LAYOUT, JSON.stringify({ floating: isFloating, minimized: isPanelMinimized, ...floatPosition }));
} catch (e) {
}
}
loadSettings() {
const defaults = DEFAULT_PANEL_SETTINGS;
Object.keys(defaults).forEach((key) => {
if (key !== "🔍MagnifyGlass.InfoPanelTheme") {
this.state.settings[key] = getSettingValue(key, defaults[key]);
}
this.state.settings[key] = getSettingValue(key, defaults[key]);
});
this.state.settings["🔍MagnifyGlass.InfoPanelWidth"] = this.validateNumber(
this.state.settings["🔍MagnifyGlass.InfoPanelWidth"],
@@ -247,25 +98,11 @@ class StateManager {
validControlPositions,
defaults["🔍MagnifyGlass.ControlsPosition"]
);
const validFonts = [
"System Default",
"Inter",
"Roboto",
"JetBrains Mono",
"Fira Code",
"IBM Plex Sans",
"Space Grotesk",
"Lexend",
"Outfit",
"monospace",
"system-ui"
];
this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"] = this.validateStringOption(
this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"],
validFonts,
PANEL_FONTS,
defaults["🔍MagnifyGlass.InfoPanelFontFamily"]
);
this.state.settings["🔍MagnifyGlass.InfoPanelTheme"] = this.state.currentTheme;
}
/**
* Validate numeric input with bounds checking.
@@ -292,111 +129,10 @@ class StateManager {
if (typeof value !== "string") return fallback;
return options.includes(value) ? value : fallback;
}
togglePanelVisibility() {
this.state.isPanelVisible = !this.state.isPanelVisible;
return this.state.isPanelVisible;
}
toggleGlassPreview() {
this.state.isGlassPreviewVisible = !this.state.isGlassPreviewVisible;
return this.state.isGlassPreviewVisible;
}
togglePinning() {
const wasPinned = this.state.isPanelPinned;
this.state.isPanelPinned = !this.state.isPanelPinned;
if (!this.state.isPanelPinned) {
this.state.isPanelLocked = false;
this.state.isAutoPinned = false;
} else {
this.state.isAutoPinned = false;
}
if (this.state.isPanelPinned && this.state.lastPinnedPosition) {
this.state.pinnedPosition = { ...this.state.lastPinnedPosition };
} else if (!this.state.isPanelPinned && wasPinned) {
this.state.lastPinnedPosition = { ...this.state.pinnedPosition };
}
return this.state.isPanelPinned;
}
toggleLocking() {
if (!this.state.isPanelPinned) {
return false;
}
this.state.isPanelLocked = !this.state.isPanelLocked;
return this.state.isPanelLocked;
}
toggleMinimized() {
this.state.isPanelMinimized = !this.state.isPanelMinimized;
return this.state.isPanelMinimized;
}
toggleHold() {
this.state.isInfoHeld = !this.state.isInfoHeld;
return this.state.isInfoHeld;
}
toggleSection(sectionId) {
if (sectionId === "node") return false;
if (this.state.expandedSections.has(sectionId)) {
this.state.expandedSections.delete(sectionId);
} else {
this.state.expandedSections.add(sectionId);
}
return true;
}
setPinnedPosition(x, y) {
this.state.pinnedPosition = { x, y };
this.state.lastPinnedPosition = { x, y };
}
setCurrentInfo(info) {
this.state.currentInfo = info;
}
/**
* Set the selected node ID for manual override.
* When set, the inspector will display this node instead of the hovered node.
*/
setSelectedNode(id) {
this.state.selectedNodeId = id;
}
/**
* Clear the selected node, returning to hover-based detection.
*/
clearSelectedNode() {
this.state.selectedNodeId = null;
}
scheduleAutoCollapse() {
this.clearAutoExpandTimer();
if (this.state.settings["🔍MagnifyGlass.InfoPanelAnimations"]) {
this.state.autoExpandTimer = setTimeout(() => {
if (!this.state.isPanelHovered) {
this.collapseNodeSections();
}
}, 1500);
}
}
clearAutoExpandTimer() {
if (this.state.autoExpandTimer) {
clearTimeout(this.state.autoExpandTimer);
this.state.autoExpandTimer = null;
}
}
expandNodeSections() {
this.state.expandedSections.add("hoveredNode");
this.state.expandedSections.add("node");
this.state.expandedSections.add("cursor");
this.state.expandedSections.add("canvas");
this.state.expandedSections.add("magnifier");
this.clearAutoExpandTimer();
}
collapseNodeSections() {
this.state.expandedSections.delete("hoveredNode");
this.state.expandedSections.delete("node");
this.state.expandedSections.delete("widget");
}
cleanup() {
this.clearAutoExpandTimer();
clearTimeout(this.themeTimeout);
clearInterval(this.themeInterval);
this.themeObserver.disconnect();
this.colorSchemeQuery.removeEventListener("change", this.onColorSchemeChange);
document.removeEventListener("click", this.onDocumentClick);
}
}
export {
StateManager
File diff suppressed because it is too large Load Diff
+11 -39
View File
@@ -1,50 +1,22 @@
import { escapeHtml } from "../shared/utils.js";
function formatValue(value, label) {
if (value === null || value === void 0) return "";
const str = String(value);
if (label && (label.toLowerCase().includes("text") || label.toLowerCase().includes("prompt") || label.toLowerCase().includes("model") || label.toLowerCase().includes("file") || label.toLowerCase().includes("conditioning") || label.toLowerCase().includes("positive") || label.toLowerCase().includes("negative"))) {
return escapeHtml(str);
}
return escapeHtml(str);
}
function getValueClass(value) {
if (!value) return "";
const str = String(value);
const classes = [];
if (str.length > 100) {
classes.push("long-text");
}
return classes.join(" ");
}
function getValueAttributes(value) {
if (!value) return "";
const str = String(value);
if (str.length > 500) {
return `title="${escapeHtml(str)}"`;
}
return "";
}
function formatWidgetValue(value) {
function formatWidgetValue(value, precision) {
if (value === null) return "null";
if (value === void 0) return "undefined";
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return Number.isInteger(value) ? value.toString() : value.toFixed(3);
}
if (typeof value === "boolean") return value.toString();
if (Array.isArray(value)) {
return `Array(${value.length})`;
return precision === void 0 ? String(value) : value.toFixed(precision);
}
if (typeof value === "object") {
return "Object";
try {
return JSON.stringify(value, null, 2);
} catch {
return Array.isArray(value) ? `Array(${value.length})` : "Object";
}
}
return String(value);
}
function isLongText(text) {
return text.length > 200 || text.split("\n").length > 4;
}
export {
formatValue,
formatWidgetValue,
getValueAttributes,
getValueClass
isLongText
};
@@ -3,21 +3,16 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { WidgetSyncManager } from "./WidgetSyncManager.js";
import { Logger } from "../../shared/logger.js";
const DRAG_SENSITIVITY = {
// Pixels to drag for one step change (applies to all numeric types)
pixelsPerStep: 10,
// Minimum pixels to trigger option cycle for combos (increased to prevent accidental cycling)
comboThreshold: 60
};
const PIXELS_PER_STEP = 10;
class DragValueController {
constructor(element, config) {
__publicField(this, "element");
__publicField(this, "config");
__publicField(this, "isDragging", false);
__publicField(this, "value", null);
__publicField(this, "startX", 0);
__publicField(this, "startValue", 0);
__publicField(this, "startOptionIndex", 0);
__publicField(this, "accumulatedDelta", 0);
__publicField(this, "startStep", 0);
// Bound event handlers for cleanup
__publicField(this, "boundPointerDown");
__publicField(this, "boundPointerMove");
@@ -40,22 +35,10 @@ class DragValueController {
this.element.classList.add("draggable");
this.element.addEventListener("pointerdown", this.boundPointerDown);
}
/**
* Add visual drag indicator to the row
*/
addDragIndicator() {
if (this.element.querySelector(".drag-indicator")) return;
const indicator = document.createElement("span");
indicator.className = "drag-indicator";
indicator.innerHTML = "⟷";
indicator.setAttribute("aria-hidden", "true");
this.element.appendChild(indicator);
}
/**
* Handle pointer down - start drag tracking
*/
onPointerDown(e) {
var _a;
if (e.button !== 0) return;
const target = e.target;
if (target.tagName === "INPUT" || target.tagName === "SELECT" || target.tagName === "BUTTON" || target.closest(".inline-control") || target.closest(".inline-control-container") || target.closest(".info-value") || target.closest(".widget-editor") || target.closest(".widget-editor-container") || target.closest(".widget-editor-stepper") || target.closest(".widget-editor-number") || target.closest(".widget-editor-input")) {
@@ -63,16 +46,9 @@ class DragValueController {
}
this.isDragging = true;
this.startX = e.clientX;
this.accumulatedDelta = 0;
const type = this.config.widgetType.toLowerCase();
if (type === "number" || type === "int" || type === "float" || type === "slider") {
this.startValue = Number(this.config.currentValue) || 0;
} else if (type === "combo") {
const options = ((_a = this.config.constraints) == null ? void 0 : _a.options) ?? [];
const currentStr = String(this.config.currentValue);
this.startOptionIndex = options.findIndex((opt) => String(opt) === currentStr);
if (this.startOptionIndex === -1) this.startOptionIndex = 0;
}
this.value = WidgetSyncManager.getWidgetValue(this.config.nodeId, this.config.widgetName);
this.startValue = Number(this.value) || 0;
this.startStep = WidgetSyncManager.getStep(this.config.constraints, e);
this.element.classList.add("dragging");
this.element.setPointerCapture(e.pointerId);
document.addEventListener("pointermove", this.boundPointerMove, true);
@@ -90,13 +66,13 @@ class DragValueController {
if (!this.isDragging) return;
e.preventDefault();
e.stopPropagation();
const deltaX = e.clientX - this.startX;
const type = this.config.widgetType.toLowerCase();
if (type === "number" || type === "int" || type === "float" || type === "slider") {
this.handleNumberDrag(deltaX);
} else if (type === "combo") {
this.handleComboDrag(deltaX);
const step = WidgetSyncManager.getStep(this.config.constraints, e);
if (step !== this.startStep) {
this.startX = e.clientX;
this.startValue = Number(this.value) || 0;
this.startStep = step;
}
this.handleNumberDrag(e.clientX - this.startX);
}
/**
* Handle number value drag
@@ -104,45 +80,13 @@ class DragValueController {
*/
handleNumberDrag(deltaX) {
var _a, _b;
const constraints = this.config.constraints;
const isInt = this.config.widgetType.toLowerCase() === "int";
const step = (constraints == null ? void 0 : constraints.step) ?? (isInt ? 1 : 0.1);
const steps = Math.trunc(deltaX / DRAG_SENSITIVITY.pixelsPerStep);
let newValue = this.startValue + steps * step;
if ((constraints == null ? void 0 : constraints.min) !== void 0) {
newValue = Math.max(constraints.min, newValue);
}
if ((constraints == null ? void 0 : constraints.max) !== void 0) {
newValue = Math.min(constraints.max, newValue);
}
if (isInt) {
newValue = Math.round(newValue);
} else {
const precision = (constraints == null ? void 0 : constraints.precision) ?? 2;
newValue = Number(newValue.toFixed(precision));
}
WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, newValue);
this.config.currentValue = newValue;
(_b = (_a = this.config).onChange) == null ? void 0 : _b.call(_a, newValue);
}
/**
* Handle combo value drag - cycle through options
*/
handleComboDrag(deltaX) {
var _a, _b, _c;
const options = ((_a = this.config.constraints) == null ? void 0 : _a.options) ?? [];
if (options.length === 0) return;
this.accumulatedDelta = deltaX;
const optionDelta = Math.floor(Math.abs(this.accumulatedDelta) / DRAG_SENSITIVITY.comboThreshold);
if (optionDelta > 0) {
const direction = deltaX > 0 ? 1 : -1;
let newIndex = this.startOptionIndex + optionDelta * direction;
newIndex = (newIndex % options.length + options.length) % options.length;
const newValue = options[newIndex];
WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, newValue);
this.config.currentValue = newValue;
(_c = (_b = this.config).onChange) == null ? void 0 : _c.call(_b, newValue);
}
const steps = Math.trunc(deltaX / PIXELS_PER_STEP);
const target = this.startValue + steps * this.startStep;
if (target === this.value) return;
const result = WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, target, false);
if (!result.success) return;
this.value = result.newValue;
(_b = (_a = this.config).onChange) == null ? void 0 : _b.call(_a, result.newValue);
}
/**
* Handle pointer up - end drag
@@ -174,6 +118,7 @@ class DragValueController {
endDrag(pointerId) {
this.isDragging = false;
this.element.classList.remove("dragging");
WidgetSyncManager.recordUndo();
if (pointerId !== void 0) {
try {
this.element.releasePointerCapture(pointerId);
@@ -185,21 +130,11 @@ class DragValueController {
document.removeEventListener("pointercancel", this.boundPointerCancel, true);
this.element.removeEventListener("lostpointercapture", this.boundLostCapture);
}
/**
* Update the current value (call when value changes externally)
*/
updateValue(value) {
this.config.currentValue = value;
}
/**
* Cleanup and remove drag handling
*/
destroy() {
this.element.classList.remove("draggable", "dragging");
const indicator = this.element.querySelector(".drag-indicator");
if (indicator) {
indicator.remove();
}
this.element.removeEventListener("pointerdown", this.boundPointerDown);
document.removeEventListener("pointermove", this.boundPointerMove, true);
document.removeEventListener("pointerup", this.boundPointerUp, true);
@@ -211,13 +146,7 @@ class DragValueController {
*/
static isTypeSupported(widgetType) {
const type = widgetType.toLowerCase();
return [
"number",
"int",
"float",
"slider",
"combo"
].includes(type);
return ["int", "float", "slider"].includes(type);
}
}
export {
@@ -37,6 +37,7 @@ class InlineControlFactory {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "inline-toggle-input";
checkbox.setAttribute("aria-label", config.widgetName);
const boolValue = config.currentValue === true || config.currentValue === "true";
checkbox.checked = boolValue;
const slider = document.createElement("span");
@@ -57,6 +58,7 @@ class InlineControlFactory {
setValue: (v) => {
checkbox.checked = Boolean(v);
},
isOpen: () => false,
destroy: () => {
container.remove();
}
@@ -73,23 +75,7 @@ class InlineControlFactory {
const options = ((_a = config.constraints) == null ? void 0 : _a.options) ?? [];
let currentValue = String(config.currentValue);
const trigger = createDropdownTrigger(currentValue, "inline-dropdown-trigger");
trigger.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
background: rgba(160, 212, 104, 0.12);
border: 1px solid transparent;
border-radius: 6px;
color: var(--info-panel-accent-color, #74b9ff);
cursor: pointer;
min-width: 100px;
max-width: 180px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
`;
trigger.setAttribute("aria-label", config.widgetName);
let activeDropdown = null;
const stopProp = (e) => e.stopPropagation();
container.addEventListener("click", stopProp);
@@ -99,7 +85,6 @@ class InlineControlFactory {
e.stopPropagation();
if (activeDropdown) {
activeDropdown.hide();
activeDropdown = null;
return;
}
activeDropdown = new CustomDropdown({
@@ -120,14 +105,6 @@ class InlineControlFactory {
});
activeDropdown.show();
});
trigger.addEventListener("mouseenter", () => {
trigger.style.background = "rgba(160, 212, 104, 0.18)";
trigger.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.15)";
});
trigger.addEventListener("mouseleave", () => {
trigger.style.background = "rgba(160, 212, 104, 0.12)";
trigger.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.1)";
});
trigger.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -142,6 +119,7 @@ class InlineControlFactory {
currentValue = String(v);
updateDropdownTriggerValue(trigger, currentValue);
},
isOpen: () => activeDropdown !== null,
destroy: () => {
activeDropdown == null ? void 0 : activeDropdown.hide();
container.remove();
@@ -164,38 +142,15 @@ class InlineControlFactory {
}
button.textContent = buttonLabel;
button.type = "button";
button.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background: var(--info-panel-accent-color, #a0d468);
color: var(--comfy-menu-bg, #1a1a1a);
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
`;
container.addEventListener("click", (e) => e.stopPropagation());
container.addEventListener("mousedown", (e) => e.stopPropagation());
container.addEventListener("mouseup", (e) => e.stopPropagation());
button.addEventListener("click", (e) => {
var _a, _b;
e.stopPropagation();
e.preventDefault();
button.style.transform = "scale(0.95)";
setTimeout(() => {
button.style.transform = "";
}, 100);
const app = window.app;
if (!(app == null ? void 0 : app.graph)) {
Logger.warn(`[InlineControl] No app.graph available`);
return;
}
const node = app.graph.getNodeById(config.nodeId);
const node = (_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(config.nodeId);
if (!(node == null ? void 0 : node.widgets)) {
Logger.warn(`[InlineControl] Node ${config.nodeId} not found or has no widgets`);
return;
@@ -212,20 +167,13 @@ class InlineControlFactory {
Logger.warn(`[InlineControl] Widget ${config.widgetName} not found or has no callback`);
}
});
button.addEventListener("mouseenter", () => {
button.style.transform = "translateY(-1px)";
button.style.boxShadow = "0 3px 8px rgba(0, 0, 0, 0.25)";
});
button.addEventListener("mouseleave", () => {
button.style.transform = "";
button.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.2)";
});
container.appendChild(button);
return {
element: container,
getValue: () => config.currentValue,
setValue: () => {
},
isOpen: () => false,
destroy: () => {
container.remove();
}
@@ -1,5 +1,5 @@
import { WidgetSyncManager } from "./WidgetSyncManager.js";
import { createDropdownTrigger, updateDropdownTriggerValue, CustomDropdown } from "../../shared/CustomDropdown.js";
import { Icons } from "../../shared/icons.js";
class WidgetEditorFactory {
/**
* Create an editor element for the given configuration
@@ -7,26 +7,20 @@ class WidgetEditorFactory {
static createEditor(config) {
const type = config.widgetType.toLowerCase();
switch (type) {
case "number":
case "int":
case "float":
return this.createNumberEditor(config);
case "combo":
return this.createComboEditor(config);
case "toggle":
case "boolean":
return this.createBooleanEditor(config);
case "slider":
return this.createSliderEditor(config);
case "text":
case "string":
case "customtext":
case "multiline":
return this.createTextEditor(config, true);
default:
return this.createTextEditor(config);
return this.createTextEditor(config, false);
}
}
/**
* Create a number input editor
* Create a number input editor. Typing commits on Enter or blur, Escape drops it.
* The arrow keys and steppers step by the widget step (Shift x10, Alt x0.1) and commit at once.
*/
static createNumberEditor(config) {
const container = document.createElement("div");
@@ -34,75 +28,73 @@ class WidgetEditorFactory {
const input = document.createElement("input");
input.type = "number";
input.className = "widget-editor-input";
input.value = String(config.currentValue ?? 0);
if (config.constraints) {
if (typeof config.constraints.min === "number") {
input.min = String(config.constraints.min);
}
if (typeof config.constraints.max === "number") {
input.max = String(config.constraints.max);
}
if (typeof config.constraints.step === "number") {
input.step = String(config.constraints.step);
}
}
input.setAttribute("aria-label", config.widgetName);
const constraints = config.constraints;
const format = (v) => typeof (constraints == null ? void 0 : constraints.precision) === "number" ? Number(v).toFixed(constraints.precision) : String(v);
input.value = format(config.currentValue ?? 0);
if (typeof (constraints == null ? void 0 : constraints.min) === "number") input.min = String(constraints.min);
if (typeof (constraints == null ? void 0 : constraints.max) === "number") input.max = String(constraints.max);
input.step = String(WidgetSyncManager.getStep(constraints));
const decrementBtn = document.createElement("button");
decrementBtn.className = "widget-editor-stepper decrement";
decrementBtn.innerHTML = "−";
decrementBtn.innerHTML = Icons.minus;
decrementBtn.type = "button";
decrementBtn.setAttribute("aria-label", "Decrease");
const incrementBtn = document.createElement("button");
incrementBtn.className = "widget-editor-stepper increment";
incrementBtn.innerHTML = "+";
incrementBtn.innerHTML = Icons.plus;
incrementBtn.type = "button";
const syncValue = () => {
incrementBtn.setAttribute("aria-label", "Increase");
let committedValue = input.value;
const commit = () => {
var _a;
if (input.value === committedValue) return;
const numValue = parseFloat(input.value);
if (!isNaN(numValue)) {
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue);
(_a = config.onChange) == null ? void 0 : _a.call(config, numValue);
}
if (isNaN(numValue)) return;
const result = WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue);
if (!result.success) return;
input.value = format(result.newValue);
committedValue = input.value;
(_a = config.onChange) == null ? void 0 : _a.call(config, result.newValue);
};
const nudge = (direction, e) => {
const typed = parseFloat(input.value);
const current = Number(config.currentValue);
const base = !isNaN(typed) ? typed : !isNaN(current) ? current : (constraints == null ? void 0 : constraints.min) ?? 0;
input.value = String(base + direction * WidgetSyncManager.getStep(constraints, e));
commit();
};
input.addEventListener("input", syncValue);
input.addEventListener("change", syncValue);
input.addEventListener("focus", () => {
var _a;
return (_a = config.onFocus) == null ? void 0 : _a.call(config);
});
input.addEventListener("blur", () => {
container.addEventListener("focusout", () => {
var _a;
return (_a = config.onBlur) == null ? void 0 : _a.call(config);
commit();
(_a = config.onBlur) == null ? void 0 : _a.call(config);
});
decrementBtn.addEventListener("mousedown", (e) => e.preventDefault());
incrementBtn.addEventListener("mousedown", (e) => e.preventDefault());
decrementBtn.addEventListener("click", (e) => {
var _a, _b;
e.preventDefault();
e.stopPropagation();
const step = 1;
const currentVal = parseFloat(input.value);
const baseValue = isNaN(currentVal) ? ((_a = config.constraints) == null ? void 0 : _a.min) ?? Number(config.currentValue) ?? 0 : currentVal;
let newValue = baseValue - step;
if (((_b = config.constraints) == null ? void 0 : _b.min) !== void 0) {
newValue = Math.max(config.constraints.min, newValue);
}
input.value = String(newValue);
syncValue();
nudge(-1, e);
});
incrementBtn.addEventListener("click", (e) => {
var _a, _b;
e.preventDefault();
e.stopPropagation();
const step = 1;
const currentVal = parseFloat(input.value);
const baseValue = isNaN(currentVal) ? ((_a = config.constraints) == null ? void 0 : _a.min) ?? Number(config.currentValue) ?? 0 : currentVal;
let newValue = baseValue + step;
if (((_b = config.constraints) == null ? void 0 : _b.max) !== void 0) {
newValue = Math.min(config.constraints.max, newValue);
}
input.value = String(newValue);
syncValue();
nudge(1, e);
});
input.addEventListener("keydown", (e) => {
if (e.key === "Escape" || e.key === "Enter") {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
nudge(e.key === "ArrowUp" ? 1 : -1, e);
} else if (e.key === "Enter") {
e.preventDefault();
input.blur();
} else if (e.key === "Escape") {
e.preventDefault();
input.value = committedValue;
input.blur();
}
});
@@ -120,20 +112,20 @@ class WidgetEditorFactory {
input.select();
},
destroy: () => {
commit();
container.remove();
}
};
}
/**
* Create a text input editor
* Create a text editor. Commits on blur, Enter (Ctrl/Cmd+Enter in a textarea) and when destroyed; Escape reverts.
*/
static createTextEditor(config) {
static createTextEditor(config, multiline) {
const value = String(config.currentValue ?? "");
const isLongText = value.length > 50 || value.includes("\n");
const container = document.createElement("div");
container.className = "widget-editor widget-editor-text";
let inputElement;
if (isLongText) {
if (multiline || value.includes("\n")) {
const textarea = document.createElement("textarea");
textarea.className = "widget-editor-textarea";
textarea.value = value;
@@ -154,31 +146,34 @@ class WidgetEditorFactory {
input.value = value;
inputElement = input;
}
const syncValue = () => {
inputElement.setAttribute("aria-label", config.widgetName);
const startValue = inputElement.value;
let committedValue = startValue;
const commit = () => {
var _a;
WidgetSyncManager.syncWidgetValueDebounced(
config.nodeId,
config.widgetName,
inputElement.value,
100
);
(_a = config.onChange) == null ? void 0 : _a.call(config, inputElement.value);
if (inputElement.value === committedValue) return;
committedValue = inputElement.value;
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, committedValue);
(_a = config.onChange) == null ? void 0 : _a.call(config, committedValue);
};
inputElement.addEventListener("input", syncValue);
inputElement.addEventListener("focus", () => {
var _a;
return (_a = config.onFocus) == null ? void 0 : _a.call(config);
});
inputElement.addEventListener("blur", () => {
var _a;
return (_a = config.onBlur) == null ? void 0 : _a.call(config);
commit();
(_a = config.onBlur) == null ? void 0 : _a.call(config);
});
inputElement.addEventListener("keydown", (evt) => {
const e = evt;
if (e.key === "Escape") {
e.preventDefault();
inputElement.value = startValue;
commit();
inputElement.blur();
}
if (e.key === "Enter" && inputElement instanceof HTMLInputElement) {
} else if (e.key === "Enter" && (inputElement instanceof HTMLInputElement || e.ctrlKey || e.metaKey)) {
e.preventDefault();
inputElement.blur();
}
});
@@ -194,136 +189,7 @@ class WidgetEditorFactory {
if (inputElement instanceof HTMLInputElement) inputElement.select();
},
destroy: () => {
container.remove();
}
};
}
/**
* Create a combo/dropdown editor
* Uses custom dropdown for viewport-aware positioning
*/
static createComboEditor(config) {
var _a;
const container = document.createElement("div");
container.className = "widget-editor widget-editor-combo";
const options = ((_a = config.constraints) == null ? void 0 : _a.options) ?? [];
let currentValue = String(config.currentValue);
const trigger = createDropdownTrigger(currentValue, "widget-editor-dropdown-trigger");
trigger.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 28px 4px 10px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
color: var(--info-panel-accent-color, #a0d468);
background: rgba(160, 212, 104, 0.12);
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
`;
let activeDropdown = null;
const showDropdown = () => {
var _a2;
if (activeDropdown) {
activeDropdown.hide();
activeDropdown = null;
return;
}
(_a2 = config.onFocus) == null ? void 0 : _a2.call(config);
activeDropdown = new CustomDropdown({
options: options.map(String),
currentValue,
anchor: trigger,
onChange: (value) => {
var _a3;
currentValue = value;
updateDropdownTriggerValue(trigger, value);
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, value);
(_a3 = config.onChange) == null ? void 0 : _a3.call(config, value);
},
onClose: () => {
var _a3;
activeDropdown = null;
(_a3 = config.onBlur) == null ? void 0 : _a3.call(config);
}
});
activeDropdown.show();
};
trigger.addEventListener("click", showDropdown);
trigger.addEventListener("mouseenter", () => {
trigger.style.background = "rgba(160, 212, 104, 0.18)";
trigger.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.15)";
});
trigger.addEventListener("mouseleave", () => {
trigger.style.background = "rgba(160, 212, 104, 0.12)";
trigger.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.1)";
});
trigger.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
showDropdown();
} else if (e.key === "Escape" && activeDropdown) {
activeDropdown.hide();
}
});
container.appendChild(trigger);
return {
element: container,
getValue: () => currentValue,
setValue: (v) => {
currentValue = String(v);
updateDropdownTriggerValue(trigger, currentValue);
},
focus: () => trigger.focus(),
destroy: () => {
activeDropdown == null ? void 0 : activeDropdown.hide();
container.remove();
}
};
}
/**
* Create a boolean toggle editor
*/
static createBooleanEditor(config) {
const container = document.createElement("div");
container.className = "widget-editor widget-editor-boolean";
const label = document.createElement("label");
label.className = "widget-editor-toggle";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "widget-editor-checkbox";
const boolValue = config.currentValue === true || config.currentValue === "true";
checkbox.checked = boolValue;
const slider = document.createElement("span");
slider.className = "widget-editor-toggle-slider";
checkbox.addEventListener("change", () => {
var _a;
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, checkbox.checked);
(_a = config.onChange) == null ? void 0 : _a.call(config, checkbox.checked);
});
checkbox.addEventListener("focus", () => {
var _a;
return (_a = config.onFocus) == null ? void 0 : _a.call(config);
});
checkbox.addEventListener("blur", () => {
var _a;
return (_a = config.onBlur) == null ? void 0 : _a.call(config);
});
label.appendChild(checkbox);
label.appendChild(slider);
container.appendChild(label);
return {
element: container,
getValue: () => checkbox.checked,
setValue: (v) => {
checkbox.checked = Boolean(v);
},
focus: () => checkbox.focus(),
destroy: () => {
commit();
container.remove();
}
};
@@ -337,11 +203,12 @@ class WidgetEditorFactory {
const slider = document.createElement("input");
slider.type = "range";
slider.className = "widget-editor-range";
slider.setAttribute("aria-label", config.widgetName);
slider.value = String(config.currentValue ?? 0);
if (config.constraints) {
slider.min = String(config.constraints.min ?? 0);
slider.max = String(config.constraints.max ?? 100);
slider.step = String(config.constraints.step ?? 0.01);
slider.step = String(WidgetSyncManager.getStep(config.constraints));
}
const valueDisplay = document.createElement("span");
valueDisplay.className = "widget-editor-slider-value";
@@ -350,10 +217,11 @@ class WidgetEditorFactory {
var _a, _b;
const numValue = parseFloat(slider.value);
valueDisplay.textContent = numValue.toFixed(((_a = config.constraints) == null ? void 0 : _a.precision) ?? 2);
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue);
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue, false);
(_b = config.onChange) == null ? void 0 : _b.call(config, numValue);
};
slider.addEventListener("input", syncValue);
slider.addEventListener("change", () => WidgetSyncManager.recordUndo());
slider.addEventListener("focus", () => {
var _a;
return (_a = config.onFocus) == null ? void 0 : _a.call(config);
@@ -385,24 +253,6 @@ class WidgetEditorFactory {
}
};
}
/**
* Check if a widget type is supported for editing
*/
static isTypeSupported(widgetType) {
const supported = [
"number",
"int",
"float",
"text",
"string",
"customtext",
"combo",
"toggle",
"boolean",
"slider"
];
return supported.includes(widgetType.toLowerCase());
}
}
export {
WidgetEditorFactory
@@ -1,50 +1,39 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { Logger } from "../../shared/logger.js";
class WidgetSyncManager {
/**
* Update a widget value on a node
* Update a widget value on a node in the graph on screen
* @param nodeId - The node ID
* @param widgetName - The widget name
* @param value - The new value to set
* @param recordUndo - Record an undo step now; drags pass false and record once on release
* @returns SyncResult indicating success or failure
*/
static syncWidgetValue(nodeId, widgetName, value) {
static syncWidgetValue(nodeId, widgetName, value, recordUndo = true) {
var _a, _b, _c, _d, _e;
try {
if (!(app == null ? void 0 : app.graph)) {
return { success: false, error: "ComfyUI app not available" };
}
const node = app.graph.getNodeById(nodeId);
const node = (_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(nodeId);
if (!node) {
return { success: false, error: `Node ${nodeId} not found` };
}
if (!node.widgets || node.widgets.length === 0) {
return { success: false, error: `Node ${nodeId} has no widgets` };
}
const widget = node.widgets.find((w) => w.name === widgetName);
const widget = (_c = node.widgets) == null ? void 0 : _c.find((w) => w.name === widgetName);
if (!widget) {
return { success: false, error: `Widget "${widgetName}" not found on node ${nodeId}` };
}
const previousValue = widget.value;
const constrainedValue = this.applyConstraints(value, widget);
widget.value = constrainedValue;
if (typeof widget.callback === "function") {
try {
widget.callback(constrainedValue, app.canvas, node, [0, 0], null);
} catch (callbackError) {
Logger.warn(`Widget callback failed for ${widgetName}:`, callbackError);
}
if (typeof widget.setValue === "function") {
widget.setValue(constrainedValue, { node, canvas: app.canvas });
} else {
widget.value = constrainedValue;
(_d = widget.callback) == null ? void 0 : _d.call(widget, constrainedValue, app.canvas, node, app.canvas.graph_mouse);
}
if (typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
this.triggerCanvasRedraw();
Logger.debug(`[WidgetSync] Updated ${widgetName} on node ${nodeId}: ${previousValue} → ${constrainedValue}`);
(_e = node.graph) == null ? void 0 : _e.setDirtyCanvas(true, true);
if (recordUndo) this.recordUndo();
Logger.debug(`[WidgetSync] Updated ${widgetName} on node ${nodeId}: ${previousValue} → ${widget.value}`);
return {
success: true,
previousValue,
newValue: constrainedValue
newValue: widget.value
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -53,42 +42,11 @@ class WidgetSyncManager {
}
}
/**
* Debounced version of syncWidgetValue for text input
* @param nodeId - The node ID
* @param widgetName - The widget name
* @param value - The new value to set
* @param debounceMs - Debounce delay in milliseconds (default 50ms)
* Let ComfyUI's change tracker pick up the edit as an undo step
*/
static syncWidgetValueDebounced(nodeId, widgetName, value, debounceMs = this.DEBOUNCE_MS) {
const key = `${nodeId}:${widgetName}`;
const existingTimer = this.debounceTimers.get(key);
if (existingTimer) {
window.clearTimeout(existingTimer);
}
const timer = window.setTimeout(() => {
this.syncWidgetValue(nodeId, widgetName, value);
this.debounceTimers.delete(key);
}, debounceMs);
this.debounceTimers.set(key, timer);
}
/**
* Get widget constraints for validation
* @param nodeId - The node ID
* @param widgetName - The widget name
* @returns WidgetConstraints or null if widget not found
*/
static getWidgetConstraints(nodeId, widgetName) {
try {
if (!(app == null ? void 0 : app.graph)) return null;
const node = app.graph.getNodeById(nodeId);
if (!(node == null ? void 0 : node.widgets)) return null;
const widget = node.widgets.find((w) => w.name === widgetName);
if (!widget) return null;
return this.extractConstraints(widget);
} catch (error) {
Logger.warn(`[WidgetSync] Error getting constraints:`, error);
return null;
}
static recordUndo() {
var _a, _b, _c, _d;
(_d = (_c = (_b = (_a = app == null ? void 0 : app.extensionManager) == null ? void 0 : _a.workflow) == null ? void 0 : _b.activeWorkflow) == null ? void 0 : _c.changeTracker) == null ? void 0 : _d.checkState();
}
/**
* Extract constraints from a widget object
@@ -98,40 +56,56 @@ class WidgetSyncManager {
if (typeof widget.min === "number") constraints.min = widget.min;
if (typeof widget.max === "number") constraints.max = widget.max;
if (typeof widget.step === "number") constraints.step = widget.step;
if (widget.options) {
if (typeof widget.options.min === "number") constraints.min = widget.options.min;
if (typeof widget.options.max === "number") constraints.max = widget.options.max;
if (typeof widget.options.step === "number") constraints.step = widget.options.step;
if (typeof widget.options.precision === "number") constraints.precision = widget.options.precision;
if (Array.isArray(widget.options.values)) {
constraints.options = widget.options.values;
const options = widget.options;
if (options && !Array.isArray(options)) {
if (typeof options.min === "number") constraints.min = options.min;
if (typeof options.max === "number") constraints.max = options.max;
if (typeof options.step2 === "number") constraints.step = options.step2;
else if (typeof options.step === "number") constraints.step = options.step / 10;
if (typeof options.round === "number" && options.round > 0) constraints.round = options.round;
if (typeof options.precision === "number") constraints.precision = options.precision;
const values = typeof options.values === "function" ? options.values(widget, widget.node) : options.values;
if (Array.isArray(values)) {
constraints.options = values;
}
}
if (Array.isArray(widget.options)) {
constraints.options = widget.options;
if (Array.isArray(options)) {
constraints.options = options;
}
return constraints;
}
/**
* Step for one nudge of a number widget. Shift is x10, Alt is x0.1 but never finer than the widget stores.
*/
static getStep(constraints, e) {
const step = (constraints == null ? void 0 : constraints.step) ?? 1;
if (e == null ? void 0 : e.shiftKey) return step * 10;
if (e == null ? void 0 : e.altKey) return Math.max(step / 10, (constraints == null ? void 0 : constraints.round) ?? 10 ** -((constraints == null ? void 0 : constraints.precision) ?? 3));
return step;
}
/**
* Apply constraints to a value
*/
static applyConstraints(value, widget) {
const constraints = this.extractConstraints(widget);
const widgetType = this.getWidgetType(widget);
if (widgetType === "number" || widgetType === "INT" || widgetType === "FLOAT" || widgetType === "slider") {
if (widgetType === "INT" || widgetType === "FLOAT" || widgetType === "slider") {
let numValue = typeof value === "number" ? value : parseFloat(String(value));
if (isNaN(numValue)) {
return widget.value;
}
if (constraints.round) {
numValue = Math.round(numValue / constraints.round) * constraints.round;
}
if (typeof constraints.precision === "number") {
numValue = parseFloat(numValue.toFixed(constraints.precision));
}
if (typeof constraints.min === "number") {
numValue = Math.max(constraints.min, numValue);
}
if (typeof constraints.max === "number") {
numValue = Math.min(constraints.max, numValue);
}
if (typeof constraints.precision === "number") {
numValue = parseFloat(numValue.toFixed(constraints.precision));
}
return numValue;
}
if (widgetType === "combo" && constraints.options) {
@@ -155,14 +129,16 @@ class WidgetSyncManager {
* Determine widget type from widget object
*/
static getWidgetType(widget) {
var _a, _b;
if (!widget) return "unknown";
const type = (widget.type || "").toLowerCase();
if (type === "button") return "button";
if (type === "number" || type === "int" || type === "float") return "number";
if (type === "number" || type === "knob") return ((_a = widget.options) == null ? void 0 : _a.precision) === 0 ? "INT" : "FLOAT";
if (type === "combo" || type === "string" && Array.isArray(widget.options)) return "combo";
if (type === "toggle" || type === "boolean") return "boolean";
if (type === "slider") return "slider";
if (type === "text" || type === "string" || type === "customtext") return "text";
if (type === "customtext" || ((_b = widget.options) == null ? void 0 : _b.multiline)) return "multiline";
if (type === "text" || type === "string") return "text";
if (Array.isArray(widget.options) && widget.options.length > 0) {
return "combo";
}
@@ -187,70 +163,20 @@ class WidgetSyncManager {
if (!widget) return false;
if (widget.hidden) return false;
if (widget.readonly) return false;
if (widget.value !== null && typeof widget.value === "object") return false;
const type = this.getWidgetType(widget);
if (type === "button") return false;
const editableTypes = ["number", "text", "combo", "boolean", "slider", "INT", "FLOAT"];
return editableTypes.includes(type) || editableTypes.includes(type.toUpperCase());
}
/**
* Force canvas redraw after widget update
*/
static triggerCanvasRedraw() {
try {
if (app == null ? void 0 : app.canvas) {
if (typeof app.canvas.setDirty === "function") {
app.canvas.setDirty(true, true);
}
if (typeof app.canvas.draw === "function") {
app.canvas.draw(true, true);
}
}
} catch (error) {
Logger.warn(`[WidgetSync] Canvas redraw failed:`, error);
}
if (widget.element && type !== "multiline") return false;
return ["INT", "FLOAT", "text", "multiline", "combo", "boolean", "slider"].includes(type);
}
/**
* Get current value of a widget
*/
static getWidgetValue(nodeId, widgetName) {
try {
if (!(app == null ? void 0 : app.graph)) return null;
const node = app.graph.getNodeById(nodeId);
if (!(node == null ? void 0 : node.widgets)) return null;
const widget = node.widgets.find((w) => w.name === widgetName);
return (widget == null ? void 0 : widget.value) ?? null;
} catch (error) {
return null;
}
}
/**
* Validate a value against widget constraints without applying
*/
static validateValue(nodeId, widgetName, value) {
const constraints = this.getWidgetConstraints(nodeId, widgetName);
if (!constraints) {
return { valid: true };
}
if (typeof value === "number") {
if (typeof constraints.min === "number" && value < constraints.min) {
return { valid: false, error: `Value must be at least ${constraints.min}` };
}
if (typeof constraints.max === "number" && value > constraints.max) {
return { valid: false, error: `Value must be at most ${constraints.max}` };
}
}
if (constraints.options) {
const valueStr = String(value);
const hasMatch = constraints.options.some((opt) => String(opt) === valueStr);
if (!hasMatch) {
return { valid: false, error: `Invalid option selected` };
}
}
return { valid: true };
var _a, _b, _c, _d;
const widget = (_d = (_c = (_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(nodeId)) == null ? void 0 : _c.widgets) == null ? void 0 : _d.find((w) => w.name === widgetName);
return (widget == null ? void 0 : widget.value) ?? null;
}
}
__publicField(WidgetSyncManager, "debounceTimers", /* @__PURE__ */ new Map());
__publicField(WidgetSyncManager, "DEBOUNCE_MS", 50);
export {
WidgetSyncManager
};
+2 -2
View File
@@ -79,11 +79,11 @@ class EventHandler {
config.offsetY += stepSize;
offsetChanged = true;
e.preventDefault();
} else if (e.key === "ArrowLeft") {
} else if (e.key === "ArrowLeft" && !e.defaultPrevented) {
config.offsetX -= stepSize;
offsetChanged = true;
e.preventDefault();
} else if (e.key === "ArrowRight") {
} else if (e.key === "ArrowRight" && !e.defaultPrevented) {
config.offsetX += stepSize;
offsetChanged = true;
e.preventDefault();
+5 -35
View File
@@ -2,7 +2,8 @@ var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { findLiteGraphCanvas } from "../shared/utils.js";
import { INFO_PANEL_ID, DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET } from "../shared/constants.js";
import { DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET } from "../shared/constants.js";
import "../shared/settings/defaults.js";
import { registerGlassSettings } from "../shared/settings/glassSettings.js";
import "/scripts/app.js";
import { registerAccessibilitySettings } from "../shared/settings/accessibilitySettings.js";
@@ -29,10 +30,6 @@ class MagnifyGlass {
__publicField(this, "litegraphCanvas");
/** Last known mouse position for better initial positioning */
__publicField(this, "lastKnownMousePosition");
/** Whether we're currently over media (for info panel) */
__publicField(this, "isOverMedia");
/** Current media element under cursor */
__publicField(this, "currentMediaElement");
/** Render loop ID; the loop runs while the glass is active and visible */
__publicField(this, "animationLoopId");
this.config = new ConfigManager();
@@ -49,8 +46,6 @@ class MagnifyGlass {
this.eventHandler = new EventHandler(this);
this.litegraphCanvas = null;
this.lastKnownMousePosition = { x: 0, y: 0 };
this.isOverMedia = false;
this.currentMediaElement = null;
this.animationLoopId = null;
}
/**
@@ -102,25 +97,17 @@ class MagnifyGlass {
}
/**
* Force hide all components including extensions (Info Panel, etc).
* This overcomes the "pinned" state of the info panel when the main tool is deactivated.
* This hides the info panel even while it floats, when the main tool is deactivated.
*/
forceHideAllComponents() {
this.state.active = false;
this.stopAnimationLoop();
this.ui.hide();
const panelEl = document.getElementById(INFO_PANEL_ID);
if (panelEl) {
panelEl.style.display = "none";
panelEl.style.opacity = "0";
}
const extensions = window.comfyUIMagnifyGlassExtensions;
if (extensions && extensions.length > 0) {
extensions.forEach((extension) => {
if (extension && extension.uiManager && typeof extension.uiManager.hide === "function") {
extension.uiManager.hide();
if (extension.uiManager.elements && extension.uiManager.elements.panel) {
extension.uiManager.elements.panel.style.display = "none";
}
}
});
}
@@ -239,6 +226,7 @@ class MagnifyGlass {
* Reset offsets and panel positions to defaults.
*/
resetOffsets() {
var _a;
this.config.resetOffsets();
this.config.followCursor = false;
if (this.state.active) {
@@ -258,25 +246,7 @@ class MagnifyGlass {
this.ui.glassDiv.style.left = "auto";
}
}
const extensions = window.comfyUIMagnifyGlassExtensions;
if (extensions && extensions.length > 0) {
extensions.forEach((extension) => {
var _a;
if (extension == null ? void 0 : extension.stateManager) {
extension.stateManager.state.isPanelPinned = false;
extension.stateManager.state.isPanelLocked = false;
extension.stateManager.state.isAutoPinned = false;
extension.stateManager.state.pinnedPosition = { x: 0, y: 0 };
extension.stateManager.state.lastPinnedPosition = null;
if (extension.positionManager) {
extension.positionManager.positionPanel();
}
if ((_a = extension.uiManager) == null ? void 0 : _a.updateControlStates) {
extension.uiManager.updateControlStates();
}
}
});
}
(_a = window.comfyUIMagnifyGlassExtensions) == null ? void 0 : _a.forEach((extension) => extension.resetPosition());
this.debugger.log("Reset: Magnify glass and inspector panel positions restored to defaults");
}
/**
+16 -39
View File
@@ -15,8 +15,8 @@ class PopOutManager {
__publicField(this, "lastPongTime", 0);
__publicField(this, "pingInterval", null);
__publicField(this, "viewerUrl");
__publicField(this, "currentTheme", "dark");
// Default theme
// The theme last sent to the viewer, null until it connects
__publicField(this, "sentTheme", null);
/** Throttle frame sending to ~30fps */
__publicField(this, "lastFrameTime", 0);
__publicField(this, "FRAME_INTERVAL", 33);
@@ -74,16 +74,20 @@ class PopOutManager {
* Handle incoming messages from the viewer tab.
*/
handleMessage(message) {
var _a, _b;
switch (message.type) {
case "pong":
case "pong": {
this.lastPongTime = Date.now();
if (!this.isOpen) {
this.isOpen = true;
this.sentTheme = null;
Logger.debug("[PopOut] Viewer tab connected");
if (this.onStateChange) this.onStateChange(true);
this.sendConfig({ theme: this.currentTheme });
}
const theme = document.body.classList.contains("dark-theme") ? "dark" : "light";
if (theme !== this.sentTheme) this.sendConfig({ theme });
break;
}
case "close":
this.isOpen = false;
this.popOutWindow = null;
@@ -102,8 +106,8 @@ class PopOutManager {
case "zoom-node":
if (typeof message.data === "number") {
const app = window.app;
const node = app.graph.getNodeById(message.data);
if (node && app.canvas) {
const node = (_b = (_a = app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(message.data);
if (node) {
app.canvas.centerOnNode(node);
}
}
@@ -144,16 +148,13 @@ class PopOutManager {
* Invokes the widget's callback function.
*/
handleButtonClick(buttonData) {
var _a, _b;
if (!buttonData || typeof buttonData.nodeId !== "number" || !buttonData.widgetName) {
Logger.warn("[PopOut] Invalid button click data:", buttonData);
return;
}
const app = window.app;
if (!(app == null ? void 0 : app.graph)) {
Logger.warn("[PopOut] No app.graph available for button click");
return;
}
const node = app.graph.getNodeById(buttonData.nodeId);
const node = (_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(buttonData.nodeId);
if (!(node == null ? void 0 : node.widgets)) {
Logger.warn(`[PopOut] Node ${buttonData.nodeId} not found or has no widgets`);
return;
@@ -240,36 +241,19 @@ class PopOutManager {
Logger.error("[PopOut] Failed to send frame:", e);
}
}
/**
* Send configuration to the pop-out viewer.
*/
/**
* Send configuration to the pop-out viewer.
*/
sendConfig(config) {
if (!this.channel) return;
const finalConfig = {
theme: this.currentTheme,
...config
};
if (config.theme) {
this.currentTheme = config.theme;
this.sentTheme = config.theme;
}
this.sendMessage({
type: "config",
data: finalConfig
data: config
});
}
/**
* Update the viewer theme.
* @param theme - Theme name (e.g. 'dark', 'light')
*/
updateTheme(theme) {
if (this.currentTheme === theme) return;
this.currentTheme = theme;
this.sendConfig({ theme });
Logger.debug(`[PopOut] Theme updated to: ${theme}`);
}
/**
* Send inspector info to the pop-out viewer.
* @param info - Inspector panel information
@@ -287,11 +271,11 @@ class PopOutManager {
* Fetches nodes from canvas and sends them to the popout.
*/
handleNodeListRequest(sortBy) {
var _a;
var _a, _b;
if (!this.channel) return;
try {
const app = window.app;
const nodes = ((_a = app == null ? void 0 : app.graph) == null ? void 0 : _a._nodes) || [];
const nodes = ((_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b._nodes) || [];
let nodeList;
if (sortBy === "execOrder") {
nodeList = nodes.map((n) => ({
@@ -369,13 +353,6 @@ class PopOutManager {
zoomFactor: Number(magnifyGlass.config.zoomFactor || 1)
};
}
if (info.mediaElement || info.media) {
const media = info.mediaElement || info.media;
sanitized.media = {
tagName: String(media.tagName || media.type || ""),
naturalSize: media.naturalWidth && media.naturalHeight ? `${media.naturalWidth}×${media.naturalHeight}` : media.naturalSize
};
}
return sanitized;
} catch (e) {
Logger.error("[PopOut] Failed to sanitize info:", e);
+1
View File
@@ -2,6 +2,7 @@ import { MagnifyGlass } from "./magnify-glass/MagnifyGlass.js";
import { app } from "/scripts/app.js";
import { Logger } from "./shared/logger.js";
import { initSidebar } from "./sidebar/sidebar.js";
import "./shared/settings/defaults.js";
import { ABOUT_BADGES } from "./sidebar/SidebarAbout.js";
app.registerExtension({
name: "comfyui.magnify.glass",
+6
View File
@@ -1,4 +1,5 @@
import { app } from "/scripts/app.js";
import "./shared/settings/defaults.js";
import { registerPanelSettings } from "./shared/settings/panelSettings.js";
import { mountInfoPanel, unmountInfoPanel } from "./info-panel/InfoPanel.js";
app.registerExtension({
@@ -12,6 +13,11 @@ app.registerExtension({
}
};
checkDependencies();
},
// Undo, redo and loading a workflow replace the nodes without moving the mouse, which drives the other updates
afterConfigureGraph() {
var _a, _b;
if ((_a = window.comfyUIMagnifyGlass) == null ? void 0 : _a.state.active) (_b = window.infoPanelManager) == null ? void 0 : _b.scheduleInfoUpdate();
}
});
function initializeInfoPanel(magnifyGlass) {
+162 -327
View File
@@ -5,11 +5,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔍 MagnifyGlass Viewer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<style>
:root {
--bg-primary: #0f0f14;
@@ -52,159 +47,6 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
}
/* Solarized Theme */
[data-theme="solarized"] {
--bg-primary: #002b36;
--bg-secondary: #073642;
--bg-tertiary: #073642;
--bg-elevated: #002b36;
--border-subtle: rgba(88, 110, 117, 0.3);
--border-default: rgba(88, 110, 117, 0.5);
--text-primary: #839496;
--text-secondary: #586e75;
--text-tertiary: #586e75;
--accent-blue: #268bd2;
--accent-cyan: #2aa198;
--accent-green: #859900;
}
/* Arc Theme */
[data-theme="arc"] {
--bg-primary: #383c4a;
--bg-secondary: #404552;
--bg-tertiary: #2f343f;
--bg-elevated: #4b5162;
--border-subtle: rgba(0, 0, 0, 0.2);
--border-default: rgba(0, 0, 0, 0.3);
--text-primary: #d3dae3;
--text-secondary: #a0a8b9;
--text-tertiary: #7c818c;
--accent-blue: #5294e2;
}
/* Nord Theme */
[data-theme="nord"] {
--bg-primary: #2e3440;
--bg-secondary: #3b4252;
--bg-tertiary: #434c5e;
--bg-elevated: #4c566a;
--border-subtle: rgba(0, 0, 0, 0.2);
--border-default: rgba(236, 239, 244, 0.1);
--text-primary: #d8dee9;
--text-secondary: #e5e9f0;
--text-tertiary: #4c566a;
--accent-blue: #81a1c1;
--accent-cyan: #88c0d0;
--accent-green: #a3be8c;
}
/* GitHub Theme */
[data-theme="github"] {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #ffffff;
--bg-elevated: #f6f8fa;
--border-subtle: rgba(27, 31, 35, 0.05);
--border-default: rgba(27, 31, 35, 0.1);
--text-primary: #24292e;
--text-secondary: #586069;
--text-tertiary: #6a737d;
--accent-blue: #0366d6;
}
/* Gruvbox Theme */
[data-theme="gruvbox"] {
--bg-primary: #282828;
--bg-secondary: #3c3836;
--bg-tertiary: #504945;
--bg-elevated: #665c54;
--border-subtle: rgba(168, 153, 132, 0.2);
--border-default: rgba(168, 153, 132, 0.4);
--text-primary: #ebdbb2;
--text-secondary: #a89984;
--text-tertiary: #928374;
--accent-blue: #83a598;
--accent-green: #b8bb26;
}
/* Dracula Theme */
[data-theme="dracula"] {
--bg-primary: #282a36;
--bg-secondary: #44475a;
--bg-tertiary: #44475a;
--bg-elevated: #6272a4;
--border-subtle: rgba(189, 147, 249, 0.2);
--border-default: rgba(98, 114, 164, 0.5);
--text-primary: #f8f8f2;
--text-secondary: #6272a4;
--text-tertiary: #6272a4;
--accent-blue: #8be9fd;
--accent-green: #50fa7b;
}
/* Monokai Theme */
[data-theme="monokai"] {
--bg-primary: #272822;
--bg-secondary: #3e3d32;
--bg-tertiary: #3e3d32;
--bg-elevated: #49483e;
--border-subtle: rgba(255, 255, 255, 0.1);
--border-default: rgba(255, 255, 255, 0.2);
--text-primary: #f8f8f2;
--text-secondary: #75715e;
--text-tertiary: #75715e;
--accent-blue: #66d9ef;
--accent-green: #a6e22e;
}
/* Obsidian Theme - deep purple/charcoal */
[data-theme="obsidian"] {
--bg-primary: rgb(30, 30, 40);
--bg-secondary: rgb(40, 40, 55);
--bg-tertiary: rgb(50, 50, 65);
--bg-elevated: rgb(45, 45, 60);
--border-subtle: rgba(80, 80, 100, 0.3);
--border-default: rgba(80, 80, 100, 0.5);
--text-primary: #d4d4d8;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--accent-blue: #8b5cf6;
--accent-green: #a78bfa;
--accent-purple: #8b5cf6;
}
/* Obsidian Dark Theme - even darker obsidian */
[data-theme="obsidian-dark"] {
--bg-primary: rgb(15, 15, 20);
--bg-secondary: rgb(25, 25, 35);
--bg-tertiary: rgb(35, 35, 45);
--bg-elevated: rgb(30, 30, 40);
--border-subtle: rgba(60, 60, 80, 0.3);
--border-default: rgba(60, 60, 80, 0.5);
--text-primary: #c4c4cc;
--text-secondary: #9494a0;
--text-tertiary: #64647a;
--accent-blue: #a78bfa;
--accent-green: #c4b5fd;
--accent-purple: #a78bfa;
}
/* Milk White Theme - soft cream/white */
[data-theme="milk-white"] {
--bg-primary: rgb(255, 253, 250);
--bg-secondary: rgb(250, 248, 245);
--bg-tertiary: rgb(245, 243, 240);
--bg-elevated: rgb(255, 255, 255);
--border-subtle: rgba(220, 215, 210, 0.5);
--border-default: rgba(220, 215, 210, 0.8);
--text-primary: #3d3d3d;
--text-secondary: #5a5a5a;
--text-tertiary: #888888;
--accent-blue: #7c3aed;
--accent-green: #059669;
--accent-purple: #7c3aed;
}
* {
margin: 0;
padding: 0;
@@ -874,6 +716,11 @@
outline: none;
}
textarea.widget-editor-input {
resize: vertical;
line-height: 1.4;
}
.widget-editor-input:focus {
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.25);
}
@@ -1110,11 +957,14 @@
}
}
// Track current info for widget editing
// The latest info, and the node the sidebar was last rendered from
let currentInfo = null;
let renderedNode = null;
function handleInfo(info) {
currentInfo = info; // Store for widget editing
currentInfo = info;
// Re-rendering would drop an open editor; closing it renders the latest info
if (sidebarContent.querySelector('.widget-editor-inline')) return;
if (!info) {
showEmptyState();
return;
@@ -1154,97 +1004,160 @@
});
}
// Widget rows carry their index into renderedNode.widgets; names never go into inline handlers
function widgetFor(element) {
const index = element.dataset.widget;
return renderedNode && index !== undefined ? renderedNode.widgets[Number(index)] : null;
}
sidebarContent.addEventListener('click', (e) => {
const element = e.target.closest('[data-widget]');
const widget = element && widgetFor(element);
if (!widget) return;
const kind = element.dataset.kind;
if (kind === 'toggle') {
toggleButtonWidget(element, renderedNode.id, widget.name);
} else if (kind === 'action') {
sendButtonClick(renderedNode.id, widget.name);
element.style.transform = 'scale(0.95)';
setTimeout(() => element.style.transform = '', 100);
} else if (kind === 'value') {
editWidgetValue(element, renderedNode.id, widget);
}
});
sidebarContent.addEventListener('change', (e) => {
const slider = e.target.closest('.widget-editor-slider');
const widget = slider && widgetFor(slider);
if (widget) sendWidgetEdit(renderedNode.id, widget.name, parseFloat(slider.value));
});
function toggleButtonWidget(button, nodeId, widgetName) {
const newState = button.dataset.state !== 'true';
button.dataset.state = String(newState);
sendWidgetEdit(nodeId, widgetName, newState);
button.textContent = newState ? '✓ ON' : '○ OFF';
button.style.background = newState ? 'var(--accent-green, #22c55e)' : 'var(--bg-tertiary, #333)';
button.style.color = newState ? 'var(--bg-primary, #1a1a1a)' : 'var(--text-secondary, #aaa)';
button.style.borderColor = newState ? 'var(--accent-green, #22c55e)' : 'var(--border-default, #555)';
button.style.transform = 'scale(0.95)';
setTimeout(() => button.style.transform = '', 100);
}
// The real step: ComfyUI keeps a 10x legacy value in step and the actual one in step2
function stepFor(opts) {
if (opts.step2 !== undefined) return opts.step2;
return opts.step !== undefined ? opts.step / 10 : 'any';
}
/**
* Start editing a widget value inline
* Start editing a widget value inline. Enter or blur commits, Escape cancels;
* a textarea commits on Ctrl/Cmd+Enter so its newlines survive.
*/
function editWidgetValue(element, nodeId, widgetName, currentValue, widgetType, options) {
// Already editing?
function editWidgetValue(element, nodeId, widget) {
if (element.querySelector('.widget-editor-inline')) return;
const originalHtml = element.innerHTML;
// Check for combo by looking at options array, not just type
const isCombo = Array.isArray(options) && options.length > 0;
const isNumber = widgetType === 'number' || widgetType === 'int' || widgetType === 'float';
const isBoolean = widgetType === 'toggle' || widgetType === 'boolean';
const options = widget.options;
const opts = options && !Array.isArray(options) ? options : {};
const type = (widget.type || 'text').toLowerCase();
const currentValue = widget.value;
let editorHtml;
if (isCombo) {
// Add a hidden placeholder to ensure 'onchange' fires even if the user selects the same value again.
// If we pre-selected the current value, clicking it again wouldn't trigger 'change'.
const placeholder = `<option value="" disabled selected style="display:none">Select...</option>`;
const optionsHtml = options.map(opt =>
`<option value="${escapeHtml(String(opt))}">${escapeHtml(String(opt))}</option>`
).join('');
// Escape original HTML for the cancel function
const safeOriginalHtml = originalHtml.replace(/'/g, "\\\\'");
// onchange: Commit value immediately
// onblur: If we have a value (user selected something but browser weirdness), commit.
// If value is empty (user clicked away without selecting), CANCEL edit and restore original display.
editorHtml = `<select class="widget-editor-select"
onchange="if(this.value) commitWidgetEdit(this, ${nodeId}, '${widgetName}')"
onblur="if(this.value) commitWidgetEdit(this, ${nodeId}, '${widgetName}') else cancelWidgetEdit(this, '${safeOriginalHtml}')"
>${placeholder}${optionsHtml}</select>`;
} else if (isBoolean) {
if (type === 'toggle' || type === 'boolean') {
// Read the CURRENT displayed value from the element text, not the stale parameter
const displayedText = element.textContent?.trim().toLowerCase();
const currentBool = displayedText === 'true';
const newValue = !currentBool;
sendWidgetEdit(nodeId, widgetName, newValue);
// Update display - keep it simple, just update text and color
const newValue = element.textContent.trim().toLowerCase() !== 'true';
sendWidgetEdit(nodeId, widget.name, newValue);
element.textContent = newValue ? 'true' : 'false';
element.style.color = newValue ? 'var(--accent-green)' : 'var(--text-tertiary)';
return;
} else if (isNumber) {
// Use regular number input (sliders are already rendered inline from renderList)
editorHtml = `<input type="number" class="widget-editor-input" value="${escapeHtml(String(currentValue))}"
onblur="commitWidgetEdit(this, ${nodeId}, '${widgetName}')"
onkeydown="if(event.key==='Enter'){this.blur();}if(event.key==='Escape'){cancelWidgetEdit(this,'${escapeHtml(originalHtml.replace(/'/g, "\\\\'"))}');}"/>`;
}
let editor;
let readValue;
if (Array.isArray(options) && options.length > 0) {
editor = document.createElement('select');
editor.className = 'widget-editor-select';
// A hidden placeholder, so picking the current value again still fires change
for (const label of ['Select...', ...options]) {
const option = document.createElement('option');
option.textContent = String(label);
editor.appendChild(option);
}
editor.options[0].disabled = true;
editor.options[0].hidden = true;
editor.selectedIndex = 0;
// The option itself, so numeric combo values stay numbers
readValue = () => editor.selectedIndex > 0 ? options[editor.selectedIndex - 1] : undefined;
} else if (type === 'number' || type === 'knob' || type === 'int' || type === 'float') {
editor = document.createElement('input');
editor.type = 'number';
editor.className = 'widget-editor-input';
if (opts.min !== undefined) editor.min = String(opts.min);
if (opts.max !== undefined) editor.max = String(opts.max);
editor.step = String(stepFor(opts));
editor.value = String(currentValue);
readValue = () => editor.value === '' || isNaN(parseFloat(editor.value)) ? undefined : parseFloat(editor.value);
} else {
// Text input
editorHtml = `<input type="text" class="widget-editor-input" value="${escapeHtml(String(currentValue))}"
onblur="commitWidgetEdit(this, ${nodeId}, '${widgetName}')"
onkeydown="if(event.key==='Enter'){this.blur();}if(event.key==='Escape'){cancelWidgetEdit(this,'${escapeHtml(originalHtml.replace(/'/g, "\\\\'"))}');}"/>`;
const text = String(currentValue ?? '');
const multiline = type === 'customtext' || opts.multiline || text.includes('\n');
editor = document.createElement(multiline ? 'textarea' : 'input');
editor.className = 'widget-editor-input';
if (multiline) {
editor.rows = Math.min(Math.max(text.split('\n').length, 3), 12);
} else {
editor.type = 'text';
}
editor.value = text;
readValue = () => editor.value;
}
element.innerHTML = `<div class="widget-editor-inline">${editorHtml}</div>`;
let done = false;
const finish = (commit) => {
if (done) return;
done = true;
const value = readValue();
if (commit && value !== undefined) {
sendWidgetEdit(nodeId, widget.name, value);
element.innerHTML = '';
const shown = document.createElement('span');
shown.style.color = 'var(--accent-blue)';
shown.textContent = String(value);
element.appendChild(shown);
} else {
element.innerHTML = '';
handleInfo(currentInfo);
}
};
const input = element.querySelector('input, select');
if (input) {
input.focus();
if (input.select) input.select();
if (editor.tagName === 'SELECT') {
editor.addEventListener('change', () => finish(true));
// Clicking away without a pick cancels
editor.addEventListener('blur', () => finish(editor.selectedIndex > 0));
} else {
editor.addEventListener('blur', () => finish(true));
}
}
editor.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Escape on the page closes the window
e.stopPropagation();
finish(false);
} else if (e.key === 'Enter' && (editor.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
e.preventDefault();
finish(true);
}
});
/**
* Commit widget edit and send to main window
*/
function commitWidgetEdit(input, nodeId, widgetName) {
const value = input.type === 'number' ? parseFloat(input.value) : input.value;
sendWidgetEdit(nodeId, widgetName, value);
// Update display
const container = input.closest('.widget-value-editable');
if (container) {
container.innerHTML = `<span style="color: var(--accent-blue);">${escapeHtml(String(value))}</span>`;
}
}
/**
* Cancel widget edit
*/
function cancelWidgetEdit(input, originalHtml) {
const container = input.closest('.widget-value-editable');
if (container) {
container.innerHTML = originalHtml;
}
const wrapper = document.createElement('div');
wrapper.className = 'widget-editor-inline';
wrapper.appendChild(editor);
element.innerHTML = '';
element.appendChild(wrapper);
editor.focus();
if (editor.select) editor.select();
}
function renderInfo(info) {
let html = '';
renderedNode = info.hoveredNode || null;
// Cursor section (Now First)
if (info.cursor && info.canvas) {
@@ -1324,7 +1237,7 @@
${info.hoveredNode.mode !== undefined ? `
<div class="info-row">
<span class="info-label">Mode</span>
<span class="info-value">${getModeText(info.hoveredNode.mode)}</span>
<span class="info-value">${escapeHtml(String(info.hoveredNode.mode))}</span>
</div>` : ''}
${info.hoveredNode.size ? `
<div class="info-row">
@@ -1346,29 +1259,6 @@
`;
}
// Media section
if (info.media) {
html += `
<div class="info-section">
<div class="section-header">
<svg class="section-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
<span class="section-title">Media</span>
<span class="section-badge">${info.media.tagName || 'Unknown'}</span>
</div>
<div class="section-body">
${info.media.naturalSize ? `
<div class="info-row">
<span class="info-label">Size</span>
<span class="info-value">${info.media.naturalSize}</span>
</div>` : ''}
</div>
</div>
`;
}
if (html) {
sidebarContent.innerHTML = html;
} else {
@@ -1391,14 +1281,6 @@
}
}
function getModeText(mode) {
// Check if it's already text
if (typeof mode === 'string') return mode;
// Map ComfyUI mode numbers
const modes = { 0: 'Always', 1: 'On Event', 2: 'Never', 3: 'Trigger' };
return modes[mode] || String(mode);
}
function formatSize(size) {
if (typeof size === 'object') return `${Math.round(size.w)}×${Math.round(size.h)}`;
return String(size);
@@ -1442,7 +1324,8 @@
const listHtml = items.map((item, index) => {
const name = escapeHtml(item.name || item.label || 'Unknown');
const type = item.type ? escapeHtml(item.type) : null;
const rawValue = item.value !== undefined ? String(item.value) : null;
const isObjectValue = typeof item.value === 'object' && item.value !== null;
const rawValue = item.value === undefined ? null : isObjectValue ? JSON.stringify(item.value) : String(item.value);
const itemId = `${title.toLowerCase()}-${index}`;
// Detect toggle-style "button" widgets (have boolean values)
@@ -1495,18 +1378,8 @@
</div>
<button
id="${itemId}"
onclick="
const currentState = this.dataset.state === 'true';
const newState = !currentState;
this.dataset.state = String(newState);
sendWidgetEdit(${nodeId}, '${escapeHtml(item.name || '')}', newState);
this.textContent = newState ? '✓ ON' : '○ OFF';
this.style.background = newState ? 'var(--accent-green, #22c55e)' : 'var(--bg-tertiary, #333)';
this.style.color = newState ? 'var(--bg-primary, #1a1a1a)' : 'var(--text-secondary, #aaa)';
this.style.borderColor = newState ? 'var(--accent-green, #22c55e)' : 'var(--border-default, #555)';
this.style.transform='scale(0.95)';
setTimeout(() => this.style.transform='', 100);
"
data-widget="${index}"
data-kind="toggle"
data-state="${isOn}"
style="
display: flex;
@@ -1558,7 +1431,8 @@
">ACTION</span>
</div>
<button
onclick="sendButtonClick(${nodeId}, '${escapeHtml(item.name || '')}'); this.style.transform='scale(0.95)'; setTimeout(() => this.style.transform='', 100);"
data-widget="${index}"
data-kind="action"
style="
display: flex;
align-items: center;
@@ -1588,7 +1462,7 @@
const opts = item.options && typeof item.options === 'object' ? item.options : {};
const min = opts.min !== undefined ? opts.min : 0;
const max = opts.max !== undefined ? opts.max : 100;
const step = opts.step !== undefined ? opts.step : 0.01;
const step = opts.step2 !== undefined || opts.step !== undefined ? stepFor(opts) : 0.01;
return `
<div style="
@@ -1622,8 +1496,8 @@
step="${step}"
value="${rawValue}"
style="flex: 1; accent-color: var(--accent-purple); cursor: pointer;"
data-widget="${index}"
oninput="this.nextElementSibling.textContent = this.value"
onchange="commitWidgetEdit(this, ${nodeId}, '${escapeHtml(item.name || '')}')"
/>
<span class="widget-slider-value" style="
min-width: 50px;
@@ -1672,15 +1546,12 @@
</div>`;
}
// Get widget options for combo boxes
const widgetOptions = item.options ? JSON.stringify(item.options).replace(/"/g, '&quot;') : '[]';
// Build onclick handler for editable widgets
const editableClass = isEditable ? 'widget-value-editable' : '';
const editableClick = isEditable && rawValue != null ?
`onclick="editWidgetValue(this, ${nodeId}, '${escapeHtml(item.name || '')}', ${JSON.stringify(rawValue).replace(/"/g, '&quot;')}, '${type || 'text'}', ${widgetOptions})"` : '';
const editableStyle = isEditable ? 'cursor: pointer;' : '';
const editableTitle = isEditable ? 'title="Click to edit"' : '';
// Object values have no editor that keeps them intact
const canEditValue = isEditable && rawValue != null && !isObjectValue;
const editableClass = canEditValue ? 'widget-value-editable' : '';
const editableClick = canEditValue ? `data-widget="${index}" data-kind="value"` : '';
const editableStyle = canEditValue ? 'cursor: pointer;' : '';
const editableTitle = canEditValue ? 'title="Click to edit"' : '';
return `
<div style="
@@ -1710,7 +1581,7 @@
word-break: break-all;
max-width: 100%;
">${type}</span>` : ''}
${isEditable ? `<span style="
${canEditValue ? `<span style="
font-size: 9px;
color: var(--accent-amber);
margin-left: auto;
@@ -1792,18 +1663,14 @@
// Node selection dropdown functionality
let currentDropdownAnchorRect = null;
let pendingDropdownType = null;
function requestNodeDropdown(type, anchorElement) {
console.log('[Popout] requestNodeDropdown called:', type, anchorElement);
// Store anchor position immediately (before potential re-render)
if (anchorElement) {
currentDropdownAnchorRect = anchorElement.getBoundingClientRect();
console.log('[Popout] Stored anchorRect:', currentDropdownAnchorRect);
}
if (channel) {
console.log('[Popout] Sending request-nodes message');
channel.postMessage({ type: 'request-nodes', data: type });
}
}
@@ -1814,35 +1681,8 @@
}
}
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
// Show success feedback
const originalContent = button.innerHTML;
button.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
button.style.borderColor = '#22c55e';
setTimeout(() => {
button.innerHTML = originalContent;
button.style.borderColor = 'var(--border-subtle)';
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
});
}
function handleNodesList(data) {
console.log('[Popout] handleNodesList called:', data);
console.log('[Popout] currentDropdownAnchorRect:', currentDropdownAnchorRect);
if (!data || !data.nodes || !currentDropdownAnchorRect) {
console.log('[Popout] handleNodesList returning early');
return;
}
if (!data || !data.nodes || !currentDropdownAnchorRect) return;
showDropdown(data.nodes, data.sortBy);
}
@@ -1892,11 +1732,9 @@
// Position dropdown
document.body.appendChild(dropdown);
console.log('[Popout] showDropdown: Created dropdown with', nodes.length, 'nodes');
const anchorRect = savedAnchorRect;
dropdown.style.top = `${anchorRect.bottom + 4}px`;
dropdown.style.left = `${anchorRect.left}px`;
console.log('[Popout] showDropdown: Positioned at', dropdown.style.top, dropdown.style.left);
// Adjust if off-screen
const dropdownRect = dropdown.getBoundingClientRect();
@@ -1927,14 +1765,11 @@
}
document.removeEventListener('click', closeDropdownOnClickOutside);
currentDropdownAnchorRect = null;
pendingDropdownType = null;
}
function selectNode(nodeId) {
console.log('[Popout] selectNode called with nodeId:', nodeId);
// Send node selection to main window
if (channel) {
console.log('[Popout] Sending node-select message');
channel.postMessage({ type: 'node-select', data: nodeId });
}
}
+102 -124
View File
@@ -1,91 +1,114 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { Icons } from "./icons.js";
let dropdownCount = 0;
class CustomDropdown {
// Track active item for keyboard navigation
// Item that Enter picks, moved by the arrows and hover
constructor(config) {
__publicField(this, "dropdown", null);
__publicField(this, "filter", null);
__publicField(this, "visibleItems", []);
__publicField(this, "config");
__publicField(this, "closeHandler", null);
__publicField(this, "keyHandler", null);
__publicField(this, "scrollHandler", null);
__publicField(this, "currentIndex", 0);
__publicField(this, "listenerTimer", null);
__publicField(this, "activeItem", null);
this.config = config;
}
/**
* Show the dropdown
* Show the dropdown. Does nothing while it is already open.
*/
show() {
this.hide();
if (this.dropdown) return;
const id = `custom-dropdown-${++dropdownCount}`;
const dropdown = document.createElement("div");
dropdown.className = `custom-dropdown-menu${this.config.theme ? ` theme-${this.config.theme}` : ""}`;
dropdown.style.cssText = `
position: fixed;
z-index: 100001;
overflow-y: auto;
background: var(--comfy-menu-bg, #1a1a1f);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
min-width: 120px;
`;
this.config.options.forEach((optionValue) => {
dropdown.className = "custom-dropdown-menu";
dropdown.style.fontSize = getComputedStyle(this.config.anchor).fontSize;
const filter = document.createElement("input");
filter.type = "text";
filter.className = "custom-dropdown-filter";
filter.placeholder = "Filter…";
filter.setAttribute("role", "combobox");
filter.setAttribute("aria-label", "Filter options");
filter.setAttribute("aria-autocomplete", "list");
filter.setAttribute("aria-expanded", "true");
filter.setAttribute("aria-controls", `${id}-list`);
const list = document.createElement("div");
list.className = "custom-dropdown-list";
list.id = `${id}-list`;
list.setAttribute("role", "listbox");
const items = this.config.options.map((optionValue, i) => {
const item = document.createElement("div");
item.className = "custom-dropdown-item";
item.id = `${id}-${i}`;
item.dataset.value = optionValue;
const isSelected = optionValue === this.config.currentValue;
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border-color, #333);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
color: ${isSelected ? "var(--info-panel-accent-color, #4ecdc4)" : "inherit"};
background: ${isSelected ? "rgba(78, 205, 196, 0.1)" : "transparent"};
white-space: nowrap;
`;
item.setAttribute("role", "option");
item.setAttribute("aria-selected", String(optionValue === this.config.currentValue));
item.textContent = optionValue;
item.addEventListener("mouseenter", () => {
if (!isSelected) {
item.style.background = "var(--comfy-input-bg, #3a3a3a)";
}
});
item.addEventListener("mouseleave", () => {
item.style.background = isSelected ? "rgba(78, 205, 196, 0.1)" : "transparent";
});
item.addEventListener("mouseenter", () => this.setActive(this.visibleItems.indexOf(item), false));
item.addEventListener("click", (e) => {
e.stopPropagation();
this.config.onChange(optionValue);
this.hide();
this.select(optionValue);
});
dropdown.appendChild(item);
list.appendChild(item);
return item;
});
const lastItem = dropdown.lastElementChild;
if (lastItem) {
lastItem.style.borderBottom = "none";
}
filter.addEventListener("input", () => {
const query = filter.value.toLowerCase();
for (const item of items) {
item.hidden = !item.dataset.value.toLowerCase().includes(query);
}
this.visibleItems = items.filter((item) => !item.hidden);
this.setActive(0);
});
dropdown.append(filter, list);
document.body.appendChild(dropdown);
this.dropdown = dropdown;
this.currentIndex = this.config.options.indexOf(this.config.currentValue);
if (this.currentIndex < 0) this.currentIndex = 0;
dropdown.setAttribute("tabindex", "-1");
this.filter = filter;
this.visibleItems = items;
this.positionWithinViewport();
this.scrollToSelected();
this.setActive(Math.max(0, this.config.options.indexOf(this.config.currentValue)));
this.setupCloseHandlers();
dropdown.focus();
this.config.anchor.setAttribute("aria-expanded", "true");
filter.focus();
}
/**
* Hide the dropdown
* Hide the dropdown. Fires onClose once, and does nothing when it is not open.
*/
hide() {
var _a, _b;
if (this.dropdown && this.dropdown.parentNode) {
this.dropdown.parentNode.removeChild(this.dropdown);
this.dropdown = null;
}
if (!this.dropdown) return;
this.dropdown.remove();
this.dropdown = null;
this.filter = null;
this.visibleItems = [];
this.activeItem = null;
this.cleanupHandlers();
this.config.anchor.setAttribute("aria-expanded", "false");
(_b = (_a = this.config).onClose) == null ? void 0 : _b.call(_a);
}
select(value) {
this.config.onChange(value);
this.hide();
this.config.anchor.focus();
}
/**
* Move the active item, clamped to the visible items
*/
setActive(index, scroll = true) {
var _a, _b, _c;
(_a = this.activeItem) == null ? void 0 : _a.classList.remove("active");
this.activeItem = this.visibleItems[Math.max(0, Math.min(index, this.visibleItems.length - 1))] ?? null;
if (this.activeItem) {
this.activeItem.classList.add("active");
if (scroll) this.activeItem.scrollIntoView({ block: "nearest" });
(_b = this.filter) == null ? void 0 : _b.setAttribute("aria-activedescendant", this.activeItem.id);
} else {
(_c = this.filter) == null ? void 0 : _c.removeAttribute("aria-activedescendant");
}
}
/**
* Position dropdown within viewport bounds
*/
@@ -96,20 +119,17 @@ class CustomDropdown {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const anchorRect = this.config.anchor.getBoundingClientRect();
this.dropdown.style.maxWidth = "none";
this.dropdown.style.maxHeight = "none";
const naturalRect = this.dropdown.getBoundingClientRect();
const spaceBelow = viewportHeight - anchorRect.bottom - margin - gap;
const spaceAbove = anchorRect.top - margin - gap;
const spaceRight = viewportWidth - anchorRect.left - margin;
let top;
let maxHeight;
if (spaceBelow >= naturalRect.height || spaceBelow >= spaceAbove) {
top = anchorRect.bottom + gap;
this.dropdown.style.top = `${anchorRect.bottom + gap}px`;
maxHeight = Math.max(100, spaceBelow);
} else {
this.dropdown.style.bottom = `${viewportHeight - anchorRect.top + gap}px`;
maxHeight = Math.max(100, spaceAbove);
top = anchorRect.top - gap - Math.min(naturalRect.height, maxHeight);
}
let left = anchorRect.left;
let maxWidth;
@@ -119,25 +139,11 @@ class CustomDropdown {
left = Math.max(margin, viewportWidth - naturalRect.width - margin);
maxWidth = viewportWidth - margin * 2;
}
const minWidth = Math.max(120, anchorRect.width);
this.dropdown.style.top = `${Math.max(margin, top)}px`;
const width = Math.min(Math.max(120, anchorRect.width, naturalRect.width), maxWidth);
this.dropdown.style.left = `${Math.max(margin, left)}px`;
this.dropdown.style.minWidth = `${minWidth}px`;
this.dropdown.style.maxWidth = `${maxWidth}px`;
this.dropdown.style.width = `${width}px`;
this.dropdown.style.maxHeight = `${maxHeight}px`;
}
/**
* Scroll to the currently selected item
*/
scrollToSelected() {
if (!this.dropdown) return;
const selectedItem = this.dropdown.querySelector(
`[data-value="${CSS.escape(this.config.currentValue)}"]`
);
if (selectedItem) {
selectedItem.scrollIntoView({ block: "nearest" });
}
}
/**
* Setup event handlers for closing the dropdown
*/
@@ -147,48 +153,26 @@ class CustomDropdown {
this.hide();
}
};
const updateActiveItem = (newIndex) => {
if (!this.dropdown) return;
const items = this.dropdown.querySelectorAll(".custom-dropdown-item");
if (items.length === 0) return;
if (newIndex < 0) newIndex = 0;
if (newIndex >= items.length) newIndex = items.length - 1;
if (this.currentIndex >= 0 && this.currentIndex < items.length) {
const oldItem = items[this.currentIndex];
const isOldSelected = this.config.options[this.currentIndex] === this.config.currentValue;
oldItem.style.background = isOldSelected ? "rgba(78, 205, 196, 0.1)" : "transparent";
}
this.currentIndex = newIndex;
const newItem = items[this.currentIndex];
newItem.style.background = "var(--comfy-input-bg, #3a3a3a)";
newItem.scrollIntoView({ block: "nearest" });
};
this.keyHandler = (e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (e.key === "Tab") {
this.hide();
this.config.anchor.focus();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
updateActiveItem(this.currentIndex + 1);
if (e.key !== "Escape" && e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Enter") return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const index = this.activeItem ? this.visibleItems.indexOf(this.activeItem) : -1;
if (e.key === "Escape") {
this.hide();
this.config.anchor.focus();
} else if (e.key === "ArrowDown") {
this.setActive(index + 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
updateActiveItem(this.currentIndex - 1);
} else if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (this.currentIndex >= 0 && this.currentIndex < this.config.options.length) {
this.config.onChange(this.config.options[this.currentIndex]);
this.hide();
}
this.setActive(index - 1);
} else if (this.activeItem) {
this.select(this.activeItem.dataset.value);
}
};
this.scrollHandler = (e) => {
@@ -197,7 +181,8 @@ class CustomDropdown {
}
this.hide();
};
setTimeout(() => {
this.listenerTimer = setTimeout(() => {
this.listenerTimer = null;
window.addEventListener("keydown", this.keyHandler, true);
document.addEventListener("mousedown", this.closeHandler, true);
document.addEventListener("click", this.closeHandler, true);
@@ -208,6 +193,10 @@ class CustomDropdown {
* Cleanup event handlers
*/
cleanupHandlers() {
if (this.listenerTimer) {
clearTimeout(this.listenerTimer);
this.listenerTimer = null;
}
if (this.closeHandler) {
document.removeEventListener("mousedown", this.closeHandler, true);
document.removeEventListener("click", this.closeHandler, true);
@@ -229,24 +218,13 @@ function createDropdownTrigger(currentValue, className = "custom-dropdown-trigge
trigger.setAttribute("tabindex", "0");
trigger.setAttribute("role", "combobox");
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
const valueSpan = document.createElement("span");
valueSpan.className = "custom-dropdown-value";
valueSpan.textContent = currentValue;
valueSpan.style.cssText = `
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const arrow = document.createElement("span");
arrow.className = "custom-dropdown-arrow";
arrow.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`;
arrow.style.cssText = `
flex-shrink: 0;
display: flex;
align-items: center;
opacity: 0.7;
`;
arrow.innerHTML = Icons.chevronDown;
trigger.appendChild(valueSpan);
trigger.appendChild(arrow);
return trigger;
+26 -2
View File
@@ -19,6 +19,28 @@ const PANEL_POSITIONS = [
"Left",
"Right"
];
const PANEL_FONTS = [
"System Default",
"Inter",
"Roboto",
"JetBrains Mono",
"Fira Code",
"IBM Plex Sans",
"Space Grotesk",
"Lexend",
"Outfit",
"Cinzel",
"Playfair Display",
"Orbitron",
"Dancing Script",
"Amatic SC",
"Comfortaa",
"Righteous",
"Bangers",
"Press Start 2P",
"Audiowide",
"monospace"
];
const ACTIVATION_KEYS = ["x", "z", "m", "q", "v", "c"];
const RESET_KEYS = ["r", "o", "p", "k", "l"];
const TOGGLE_FOLLOW_KEYS = ["f", "g", "h", "j", "k"];
@@ -31,7 +53,7 @@ const DEFAULT_BORDER_WIDTH = 1;
const DEFAULT_BORDER_COLOR = "#6b7280";
const DEFAULT_OFFSET_STEP = 5;
const DEFAULT_PANEL_WIDTH = 300;
const DEFAULT_PANEL_MAX_HEIGHT = 300;
const DEFAULT_PANEL_MAX_HEIGHT = 600;
const DEFAULT_PANEL_OPACITY = 100;
const INFO_PANEL_ID = "comfyui-magnify-info-panel-pro-v2";
const Z_INDEX = {
@@ -39,7 +61,8 @@ const Z_INDEX = {
};
const STORAGE_KEYS = {
OFFSET_X: "comfyui_magnify_offset_x",
OFFSET_Y: "comfyui_magnify_offset_y"
OFFSET_Y: "comfyui_magnify_offset_y",
INSPECTOR_LAYOUT: "comfyui_magnify_inspector_layout"
};
const BROADCAST_CHANNEL_NAME = "comfyui-magnifyglass-popout";
export {
@@ -59,6 +82,7 @@ export {
GLASS_POSITIONS,
GLASS_SHAPES,
INFO_PANEL_ID,
PANEL_FONTS,
PANEL_POSITIONS,
RESET_KEYS,
STORAGE_KEYS,
+33 -31
View File
@@ -4,17 +4,12 @@ const Icons = {
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>`,
// Unlock icon - panel is floating/following cursor
unlock: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
</svg>`,
// Lock icon - panel is pinned
// Lock icon - lock the panel to a node
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>`,
// Pin icon - lock panel position
// Pin icon - the panel floats instead of following the glass
pin: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="17" x2="12" y2="22"/>
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/>
@@ -25,10 +20,6 @@ const Icons = {
<circle cx="12" cy="12" r="3"/>
</svg>`,
// Camera/image icon - media section
camera: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>`,
// Location/map pin icon - empty state
mapPin: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
@@ -38,22 +29,38 @@ const Icons = {
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`,
// Chevron left - previous node in execution order
chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>`,
// Arrow left/right - back and forward through the visited nodes
arrowLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>`,
arrowRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>`,
// Chevron down - dropdown triggers
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>`,
// Minus icon - minimize button
minus: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>`,
// Plus icon - expand button and stepper
plus: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>`,
// Check icon - copied to clipboard
check: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>`,
// Inspector/info icon
info: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>`,
// Box/node icon
box: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>`,
// Magnet icon - persist/sticky mode
magnet: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 15-4-4 6.75-6.77a7.79 7.79 0 0 1 11 11L13 22l-4-4 6.39-6.36a2.2 2.2 0 0 0-3.1-3.13l-6.29 6.49Z"/>
@@ -72,27 +79,22 @@ const Icons = {
<polyline points="19 9 22 12 19 15"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="12" y1="2" x2="12" y2="22"/>
<line x1="12" y1="2" x2="12" y2="22"/>
</svg>`,
// Reset icon - reset position
reset: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>`,
// Pointer in a frame - follow the canvas selection
selection: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"/>
<path d="M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/>
</svg>`,
// Cursor icon - cursor preview toggle
cursor: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/>
<path d="m13 13 6 6"/>
</svg>`,
// Pause icon - for Hold Info
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>`,
// Play icon - for Resume Info
play: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>`,
// Focus icon - for Focus Node button
focus: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
+4 -6
View File
@@ -25,8 +25,6 @@ const DEFAULT_PANEL_SETTINGS = {
"🔍MagnifyGlass.InfoPanelWidth": DEFAULT_PANEL_WIDTH,
"🔍MagnifyGlass.InfoPanelOpacity": DEFAULT_PANEL_OPACITY,
"🔍MagnifyGlass.InfoPanelMaxHeight": DEFAULT_PANEL_MAX_HEIGHT,
"🔍MagnifyGlass.InfoPanelAnimations": false,
"🔍MagnifyGlass.ShowInspectorTab": false,
"🔍MagnifyGlass.ToggleHotkey": "i",
"🔍MagnifyGlass.GlassPreviewToggleHotkey": "g",
"🔍MagnifyGlass.PinPanelHotkey": "u",
@@ -37,9 +35,10 @@ const DEFAULT_PANEL_SETTINGS = {
"🔍MagnifyGlass.InfoPanelFontSize": 14,
"🔍MagnifyGlass.InfoPanelFontFamily": "System Default",
"🔍MagnifyGlass.InfoPanelPersist": false,
"🔍MagnifyGlass.HoldInfoHotkey": "p",
"🔍MagnifyGlass.InfoPanelFollowSelection": false,
"🔍MagnifyGlass.StickyInfoHotkey": "s",
"🔍MagnifyGlass.NodeHighlightEnabled": true
"🔍MagnifyGlass.NodeHighlightEnabled": true,
"🔍MagnifyGlass.HighContrastText": false
};
const DEFAULT_ACCESSIBILITY_SETTINGS = {
"🔍MagnifyGlass.AccessibilityEnabled": false,
@@ -59,8 +58,7 @@ const DEFAULT_ACCESSIBILITY_SETTINGS = {
const DEFAULT_SETTINGS = {
...DEFAULT_GLASS_SETTINGS,
...DEFAULT_PANEL_SETTINGS,
...DEFAULT_ACCESSIBILITY_SETTINGS,
"🔍MagnifyGlass.HighContrastText": false
...DEFAULT_ACCESSIBILITY_SETTINGS
};
export {
DEFAULT_ACCESSIBILITY_SETTINGS,
+19 -40
View File
@@ -1,5 +1,5 @@
import { app } from "/scripts/app.js";
import { PANEL_POSITIONS } from "../constants.js";
import { PANEL_POSITIONS, PANEL_FONTS } from "../constants.js";
import { DEFAULT_PANEL_SETTINGS } from "./defaults.js";
function registerPanelSettings(setEnabled) {
const settings = DEFAULT_PANEL_SETTINGS;
@@ -22,7 +22,7 @@ function registerPanelSettings(setEnabled) {
name: "🔍 [4] Info Panel: Sticky Mode (Persist)",
type: "boolean",
defaultValue: settings["🔍MagnifyGlass.InfoPanelPersist"],
tooltip: "Keep displaying the last node's info until you hover over another node.",
tooltip: "Keep showing the last node while the cursor is over empty canvas. Its widgets can then be edited.",
onChange: (value) => {
const panel = window.infoPanelManager;
if (!panel) return;
@@ -30,12 +30,25 @@ function registerPanelSettings(setEnabled) {
panel.uiManager.updateControlStates();
}
});
app.ui.settings.addSetting({
id: "🔍MagnifyGlass.InfoPanelFollowSelection",
name: "🔍 [4] Info Panel: Follow Selection",
type: "boolean",
defaultValue: settings["🔍MagnifyGlass.InfoPanelFollowSelection"],
tooltip: "Show the selected node instead of the hovered one while a node is selected. Its widgets can then be edited.",
onChange: (value) => {
const panel = window.infoPanelManager;
if (!panel) return;
panel.stateManager.state.settings["🔍MagnifyGlass.InfoPanelFollowSelection"] = !!value;
panel.uiManager.updateControlStates();
}
});
app.ui.settings.addSetting({
id: "🔍MagnifyGlass.NodeHighlightEnabled",
name: "🔍 [4] Info Panel: Node Highlight Border",
type: "boolean",
defaultValue: settings["🔍MagnifyGlass.NodeHighlightEnabled"],
tooltip: "Show a blue highlight border around the currently inspected node.",
defaultValue: settings["🔍MagnifyGlass.NodeHighlightEnabled"] ?? true,
tooltip: "Show a highlight border in the accent colour around the currently inspected node.",
onChange: (value) => {
const panel = window.infoPanelManager;
if (!panel) return;
@@ -46,19 +59,6 @@ function registerPanelSettings(setEnabled) {
}
}
});
app.ui.settings.addSetting({
id: "🔍MagnifyGlass.ShowInspectorTab",
name: "🔍 [4] Info Panel: Show Inspector Tab",
type: "combo",
options: [{ value: true, text: "Yes" }, { value: false, text: "No" }],
defaultValue: settings["🔍MagnifyGlass.ShowInspectorTab"],
tooltip: "Show or hide the Inspector tab with cursor and canvas information.",
onChange: (value) => {
const panel = window.infoPanelManager;
if (!panel) return;
panel.stateManager.state.settings["🔍MagnifyGlass.ShowInspectorTab"] = !!value;
}
});
app.ui.settings.addSetting({
id: "🔍MagnifyGlass.ShowHoveringControls",
name: "🔍 [4] Info Panel: Show Hover Controls",
@@ -143,30 +143,9 @@ function registerPanelSettings(setEnabled) {
id: "🔍MagnifyGlass.InfoPanelFontFamily",
name: "🔍 [6] Panel Style: Font Family",
type: "combo",
options: [
{ value: "System Default", text: "System Default" },
{ value: "Inter", text: "Inter" },
{ value: "Roboto", text: "Roboto" },
{ value: "JetBrains Mono", text: "JetBrains Mono" },
{ value: "Fira Code", text: "Fira Code" },
{ value: "IBM Plex Sans", text: "IBM Plex Sans" },
{ value: "Space Grotesk", text: "Space Grotesk" },
{ value: "Lexend", text: "Lexend" },
{ value: "Outfit", text: "Outfit" },
{ value: "Cinzel", text: "Cinzel (Epic)" },
{ value: "Playfair Display", text: "Playfair Display (Elegant)" },
{ value: "Orbitron", text: "Orbitron (Sci-Fi)" },
{ value: "Dancing Script", text: "Dancing Script (Cursive)" },
{ value: "Amatic SC", text: "Amatic SC (Handwritten)" },
{ value: "Comfortaa", text: "Comfortaa (Rounded)" },
{ value: "Righteous", text: "Righteous (Modern)" },
{ value: "Bangers", text: "Bangers (Comic)" },
{ value: "Press Start 2P", text: "Press Start 2P (Pixel)" },
{ value: "Audiowide", text: "Audiowide (Techno)" },
{ value: "monospace", text: "Monospace" }
],
options: PANEL_FONTS.map((f) => ({ value: f, text: f === "monospace" ? "Monospace" : f })),
defaultValue: settings["🔍MagnifyGlass.InfoPanelFontFamily"],
tooltip: "Font family for the info panel text.",
tooltip: "Font family for the info panel text. A font that is not installed on this computer falls back to the default.",
onChange: (value) => {
const panel = window.infoPanelManager;
if (!panel) return;
+2 -2
View File
@@ -20,7 +20,7 @@ const PACKS = [
["Chat2Me", "Chat with a character that replies in video.", "https://github.com/aemotion-studio/ComfyUI-Chat2Me"]
];
const ABOUT_BADGES = [
{ label: `MagnifyGlass ${"1.14.0"}`, url: REPO, icon: "pi pi-github" }
{ label: `MagnifyGlass ${"1.15.0"}`, url: REPO, icon: "pi pi-github" }
];
const STUDIO = "Developed by Æmotion Studio, MagnifyGlass brings a customizable magnifying glass and node inspector to your ComfyUI workflow. Contributions are welcome!";
function link(href, text) {
@@ -44,7 +44,7 @@ function renderAbout() {
brand.className = "magnify-brand";
const heading = document.createElement("h3");
heading.className = "magnify-heading";
heading.textContent = `MagnifyGlass ${"1.14.0"}`;
heading.textContent = `MagnifyGlass ${"1.15.0"}`;
const byline = document.createElement("p");
byline.className = "magnify-hint";
byline.textContent = "by Æmotion Studio · GPL-3.0";
+14 -36
View File
@@ -1,4 +1,4 @@
import { GLASS_SHAPES, GLASS_POSITIONS, ACTIVATION_KEYS, RESET_KEYS, TOGGLE_FOLLOW_KEYS, DIRECT_CAPTURE_KEYS, PANEL_POSITIONS } from "../shared/constants.js";
import { GLASS_SHAPES, GLASS_POSITIONS, ACTIVATION_KEYS, RESET_KEYS, TOGGLE_FOLLOW_KEYS, DIRECT_CAPTURE_KEYS, PANEL_POSITIONS, PANEL_FONTS } from "../shared/constants.js";
import { setSettingValue, getSettingValue } from "../shared/utils.js";
import { DEFAULT_SETTINGS } from "../shared/settings/defaults.js";
import { resetAllSettings } from "../shared/settings/reset.js";
@@ -572,16 +572,16 @@ function renderSettingsPanel(container) {
const panelSection = createSection("Info Panel");
panelSection.section.hidden = !inspectorEnabled;
panelSection.body.appendChild(createToggle(
"Persist Info",
"Sticky",
settingValue("🔍MagnifyGlass.InfoPanelPersist"),
(checked) => {
setSettingValue("🔍MagnifyGlass.InfoPanelPersist", checked);
const infoPanel = window.infoPanelManager;
if (infoPanel == null ? void 0 : infoPanel.uiManager) {
infoPanel.uiManager.updateControlStates();
}
},
"Keep displaying the last node info when hovering empty space"
(checked) => setSettingValue("🔍MagnifyGlass.InfoPanelPersist", checked),
"Keep showing the last node over empty canvas. Its widgets can then be edited"
));
panelSection.body.appendChild(createToggle(
"Follow Selection",
settingValue("🔍MagnifyGlass.InfoPanelFollowSelection"),
(checked) => setSettingValue("🔍MagnifyGlass.InfoPanelFollowSelection", checked),
"Show the selected node instead of the hovered one. Its widgets can then be edited"
));
panelSection.body.appendChild(createToggle(
"Node Highlight Border",
@@ -682,32 +682,10 @@ function renderSettingsPanel(container) {
}
}
));
const fontFamilyOptions = [
"System Default",
"Inter",
"Roboto",
"JetBrains Mono",
"Fira Code",
"IBM Plex Sans",
"Space Grotesk",
"Lexend",
"Outfit",
"Cinzel",
"Playfair Display",
"Orbitron",
"Dancing Script",
"Amatic SC",
"Comfortaa",
"Righteous",
"Bangers",
"Press Start 2P",
"Audiowide",
"monospace"
];
panelSection.body.appendChild(createSelect(
"Font Family",
settingValue("🔍MagnifyGlass.InfoPanelFontFamily"),
fontFamilyOptions,
PANEL_FONTS,
(value) => setSettingValue("🔍MagnifyGlass.InfoPanelFontFamily", value),
"Font family for the info panel text",
(value) => {
@@ -791,16 +769,16 @@ function renderSettingsPanel(container) {
panelHotkeySection.body.appendChild(createSelect(
"Toggle Key",
settingValue("🔍MagnifyGlass.ToggleHotkey"),
["i", "j", "k", "l", "n", "m"],
["i", "j", "k", "l"],
(value) => setSettingValue("🔍MagnifyGlass.ToggleHotkey", value),
"Key to toggle info panel visibility"
));
panelHotkeySection.body.appendChild(createSelect(
"Pin Key",
"Float Key",
settingValue("🔍MagnifyGlass.PinPanelHotkey"),
["u", "p", "y", "t"],
(value) => setSettingValue("🔍MagnifyGlass.PinPanelHotkey", value),
"Key to pin/unpin the info panel position"
"Key to switch the panel between following the glass and floating"
));
const panelAltToggle = createToggle(
"Require Alt Key",