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:
co-authored by
Claude Opus 5.5
parent
6799dc8fbb
commit
4b06ee92bb
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**A powerful, customizable magnifying glass extension for ComfyUI.**
|
||||
|
||||
[](https://github.com/comfyanonymous/ComfyUI)
|
||||
[](https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass/releases)
|
||||
[](https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass/releases)
|
||||
[](LICENSE)
|
||||
[](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
|
||||

|
||||
|
||||
* **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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+22
-80
@@ -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 { };
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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('<script>');
|
||||
expect(escaped).toContain('</script>');
|
||||
});
|
||||
|
||||
it('should escape quotes', () => {
|
||||
const input = 'Value with "quotes" and \'single quotes\'';
|
||||
const escaped = formatValue(input);
|
||||
expect(escaped).toContain('"quotes"');
|
||||
expect(escaped).toContain(''single quotes'');
|
||||
});
|
||||
});
|
||||
|
||||
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('<script>');
|
||||
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('<script>');
|
||||
});
|
||||
|
||||
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('<script>');
|
||||
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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+640
-954
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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, '"') : '[]';
|
||||
|
||||
// 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, '"')}, '${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
@@ -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
@@ -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
@@ -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"/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user