Make the pop-out show the Inspector's rows and edit through InfoPanel

InfoPanel builds the payload, applies the Inspector's edit guards and refreshes
the viewer on a 250 ms tick while connected, also when the glass is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Æmotion Studio
2026-11-04 08:59:55 -08:00
co-authored by Claude Opus 5.5
parent 76bb34da1d
commit bbbbd3e53a
18 changed files with 929 additions and 1226 deletions
+157 -31
View File
@@ -5,7 +5,8 @@
* Main class that orchestrates all info panel components.
*/
import type { MagnifyGlassInstance } from '../types/comfyui';
import type { MagnifyGlassInstance, NodeInfo } from '../types/comfyui';
import type { PopOutInfo, WidgetRef } from '../magnify-glass/PopOutManager';
import { Logger } from '../shared/logger';
import { StateManager } from './StateManager';
import { UIManager } from './UIManager';
@@ -13,6 +14,10 @@ import { PositionManager } from './PositionManager';
import { EventManager } from './EventManager';
import { InformationGatherer } from './InformationGatherer';
import { CanvasHighlighter } from './CanvasHighlighter';
import { getImportantNodeParameters, type ParameterItem } from './NodeDataExtractor';
import { WidgetSyncManager } from './widget-editors/WidgetSyncManager';
type PopOutNav = PopOutInfo['nav'];
/**
* Professional Info Panel Manager.
@@ -31,6 +36,13 @@ export class InfoPanel {
private originalHide: () => void;
private updateFrameId: number | null = null;
private boundScheduleSelectionUpdate: () => void;
// Refreshes the pop-out while it is connected, since it gets no input of its own here
private popOutTimer: number | null = null;
// What the pop-out was last sent from, null to send the next update whatever it holds
private popOutKey: unknown[] | null = null;
// The node the pop-out shows and the graph it came from; its edits apply only there
private popOutNodeId: number | null = null;
private popOutGraph: unknown = null;
constructor(magnifyGlass: MagnifyGlassInstance) {
this.magnifyGlass = magnifyGlass;
@@ -78,20 +90,38 @@ export class InfoPanel {
this.originalHide = magnifyGlass.ui.hide;
this.hookIntoMagnifyGlass();
// Listen for popout state changes to update UI buttons
if (this.magnifyGlass.popOutManager) {
this.magnifyGlass.popOutManager.onStateChange = () => {
const popOut = this.magnifyGlass.popOutManager;
if (popOut) {
popOut.onStateChange = (isOpen: boolean) => {
this.uiManager.updateControlStates();
this.setPopOutRefresh(isOpen);
};
// A node picked in the pop-out is a jump like any other
this.magnifyGlass.popOutManager.onNodeSelect = (nodeId: number) => {
popOut.onNodeSelect = (nodeId: number) => {
this.uiManager.jumpToNode(nodeId);
};
// Listen for widget edits from popout to force refresh
this.magnifyGlass.popOutManager.onWidgetEdit = () => {
// Reflect the edit (e.g. unhidden widgets)
this.updateInfo();
// The viewer changes widgets under the Inspector's rules, and always gets the result back,
// so a rejected edit does not keep showing its value
popOut.onWidgetEdit = (data) => {
if (this.uiManager.canEdit() && this.popOutRow(data)?.isEditable) {
WidgetSyncManager.syncWidgetValue(data.nodeId, data.widgetName, data.value);
}
this.refreshPopOut();
};
popOut.onButtonClick = (data) => {
if (this.popOutRow(data)?.isActionable) {
WidgetSyncManager.clickButton(data.nodeId, data.widgetName);
}
this.refreshPopOut();
};
popOut.onRequestOptions = (data) => this.popOutRow(data)?.constraints?.options ?? [];
popOut.onPanelAction = (name, nodeId) => {
if (name === 'focus-node' && nodeId === this.popOutNodeId && app.canvas?.graph === this.popOutGraph) {
this.uiManager.jumpToNode(nodeId);
}
};
// Mounted while a viewer is connected, e.g. the Inspector turned back on
this.setPopOutRefresh(popOut.isPopOutOpen());
}
// Jumps, the lock and the mode buttons show their result at once
@@ -188,7 +218,11 @@ export class InfoPanel {
mg.popOutManager.onStateChange = null;
mg.popOutManager.onNodeSelect = null;
mg.popOutManager.onWidgetEdit = null;
mg.popOutManager.onButtonClick = null;
mg.popOutManager.onRequestOptions = null;
mg.popOutManager.onPanelAction = null;
}
this.setPopOutRefresh(false);
if (this.updateFrameId !== null) {
cancelAnimationFrame(this.updateFrameId);
@@ -220,24 +254,28 @@ export class InfoPanel {
private stickyGraph: unknown = null;
/**
* Update the info panel with current information.
* Update the info panel and the pop-out with the node to show.
*/
updateInfo(): void {
const settings = this.stateManager.state.settings;
const isActive = this.magnifyGlass.state.active;
const popOutOpen = !!this.magnifyGlass.popOutManager?.isPopOutOpen();
// Safety: If inactive, ensure hidden and return
if (!isActive) {
// Hide the panel completely when the glass is inactive
this.uiManager.hide();
// Hide the panel completely when the glass is inactive; the pop-out tick lands here too
if (this.stateManager.state.isPanelVisible) this.uiManager.hide();
if (this.uiManager.elements.controls && this.uiManager.elements.controls.style.display !== 'none') {
this.uiManager.elements.controls.style.display = 'none';
}
this.canvasHighlighter.setHighlightedNode(null);
return;
// The pop-out still shows the locked, selected or sticky node
if (!popOutOpen) return;
}
let info = this.informationGatherer.gatherInformation();
// Nothing is hovered while the glass is off
const hovered = isActive ? this.informationGatherer.getNodeUnderCursor() : null;
let node = hovered ? this.informationGatherer.getDetailedNodeInfo(hovered) : null;
let source: PopOutNav['source'] = 'hover';
const state = this.stateManager.state;
// The locked node is shown whatever is hovered, then the first selected node when following the selection.
@@ -255,37 +293,125 @@ export class InfoPanel {
state.showsSelection = !!selectedNode;
const shownNode = lockedNode ?? selectedNode;
if (shownNode) {
info = { ...info, hoveredNode: this.informationGatherer.getDetailedNodeInfo(shownNode) };
node = this.informationGatherer.getDetailedNodeInfo(shownNode);
source = lockedNode ? 'locked' : 'selection';
}
// 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;
if (node) {
this.stickyNodeId = node.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) };
node = this.informationGatherer.getDetailedNodeInfo(stickyNode);
source = 'sticky';
} else {
this.stickyNodeId = null;
}
}
state.currentNodeId = info.hoveredNode?.id ?? null;
state.currentNodeId = node?.id ?? null;
const info = { hoveredNode: node };
// Update the UI
this.uiManager.displayInfo(info);
// Update canvas highlight
this.canvasHighlighter.setHighlightedNode(info.hoveredNode ? info.hoveredNode.id : null);
// Send info to pop-out viewer if active
if (this.magnifyGlass.popOutManager && this.magnifyGlass.popOutManager.isPopOutOpen()) {
this.magnifyGlass.popOutManager.sendInfo(info as any);
if (isActive) {
this.uiManager.displayInfo(info);
this.canvasHighlighter.setHighlightedNode(node ? node.id : null);
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
}
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
if (popOutOpen) {
this.sendPopOutInfo(info, source);
}
}
/**
* Send the pop-out what the Inspector shows, when it changed since the last send.
*/
private sendPopOutInfo(info: { hoveredNode: NodeInfo | null }, source: PopOutNav['source']): void {
const nav: PopOutNav = {
source,
locked: this.stateManager.state.lockedNodeId !== null,
...this.uiManager.getHistoryNav(),
canEdit: this.uiManager.canEdit()
};
const key = [...this.uiManager.getRenderKey(info), ...Object.values(nav)];
const sent = this.popOutKey;
if (sent && key.length === sent.length && key.every((v, i) => Object.is(v, sent[i]))) return;
this.popOutKey = key;
this.popOutNodeId = info.hoveredNode?.id ?? null;
this.popOutGraph = app.canvas?.graph;
this.magnifyGlass.popOutManager!.sendInfo(this.buildPopOutInfo(info.hoveredNode, nav));
}
/**
* The pop-out payload: the node's identity and place, and its widget rows as plain data.
*/
private buildPopOutInfo(node: NodeInfo | null, nav: PopOutNav): PopOutInfo {
if (!node) return { node: null, nav, rows: [] };
return {
node: {
id: node.id,
title: node.title,
type: node.type,
mode: node.mode,
execOrder: node.executionOrder,
graphPath: node.graphPath,
group: node.group,
isSubgraph: node.isSubgraph
},
nav,
rows: getImportantNodeParameters(node).map(row => {
// Combo lists can be long, so the viewer asks for them when it opens an editor
const { options, ...constraints } = row.constraints ?? {};
const raw = row.rawValue;
return {
label: row.label,
widgetName: row.widgetName,
widgetType: row.widgetType,
value: row.value,
rawValue: raw === null || typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean' ? raw : undefined,
isEditable: !!row.isEditable,
isLinked: !!row.isLinked,
isActionable: !!row.isActionable,
constraints
};
})
};
}
/**
* The row of a widget on the node the pop-out shows, while its graph is still on screen.
*/
private popOutRow({ nodeId, widgetName }: WidgetRef): ParameterItem | undefined {
if (nodeId !== this.popOutNodeId || app.canvas?.graph !== this.popOutGraph) return undefined;
const node = this.uiManager.nodeSelector.getNodeById(nodeId);
return node ? getImportantNodeParameters(this.informationGatherer.getDetailedNodeInfo(node)).find(row => row.widgetName === widgetName) : undefined;
}
/**
* Send the pop-out its info now, even if nothing changed.
*/
private refreshPopOut(): void {
this.popOutKey = null;
this.updateInfo();
}
/**
* Refresh the pop-out a few times a second while it is connected: values change after a run
* or from another window without any input in this one.
*/
private setPopOutRefresh(connected: boolean): void {
if (this.popOutTimer !== null) {
clearInterval(this.popOutTimer);
this.popOutTimer = null;
}
if (!connected) return;
this.popOutTimer = window.setInterval(() => this.updateInfo(), 250);
this.refreshPopOut();
}
}
+1 -28
View File
@@ -5,7 +5,7 @@
* Responsible for collecting information about nodes, widgets, and canvas state.
*/
import type { ComfyApp, ComfyGraph, ComfyNode, GatheredInfo, NodeInfo } from '../types/comfyui';
import type { ComfyApp, ComfyGraph, ComfyNode, NodeInfo } from '../types/comfyui';
declare const app: ComfyApp;
declare const LiteGraph: any;
@@ -17,33 +17,6 @@ declare const LiteGraph: any;
export class InformationGatherer {
constructor() { }
gatherInformation(): GatheredInfo {
const magnifyGlass = window.comfyUIMagnifyGlass;
if (!magnifyGlass) {
return {
cursor: { canvasX: 0, canvasY: 0 },
zoom: 1,
hoveredNode: null
};
}
const info: GatheredInfo = {
cursor: {
canvasX: magnifyGlass.state.x,
canvasY: magnifyGlass.state.y
},
zoom: magnifyGlass.state.canvasScale,
hoveredNode: null
};
const nodeUnderCursor = this.getNodeUnderCursor();
if (nodeUnderCursor) {
info.hoveredNode = this.getDetailedNodeInfo(nodeUnderCursor);
}
return info;
}
getNodeUnderCursor(): ComfyNode | null {
const magnifyGlass = window.comfyUIMagnifyGlass;
const graph = app.canvas?.graph;
+18 -7
View File
@@ -561,11 +561,9 @@ export class UIManager {
lock.setAttribute('aria-label', lock.title);
find<HTMLButtonElement>('[data-action="focus-node"]').disabled = !node;
const history = this.history;
const inHistory = history.ids.length > 0 && history.graph === app.canvas?.graph;
const atEntry = history.ids[history.index] === state.currentNodeId;
find<HTMLButtonElement>('[data-action="back"]').disabled = !inHistory || history.index < (atEntry ? 1 : 0);
find<HTMLButtonElement>('[data-action="forward"]').disabled = !inHistory || !atEntry || history.index >= history.ids.length - 1;
const { canBack, canForward } = this.getHistoryNav();
find<HTMLButtonElement>('[data-action="back"]').disabled = !canBack;
find<HTMLButtonElement>('[data-action="forward"]').disabled = !canForward;
const breadcrumb = find('.panel-breadcrumb');
const path = node?.graphPath.length ? ['Root', ...node.graphPath] : [];
@@ -577,10 +575,23 @@ export class UIManager {
breadcrumb.hidden = !breadcrumb.innerHTML;
}
/**
* Whether Back and Forward have a jump to go to in the graph on screen.
*/
getHistoryNav(): { canBack: boolean; canForward: boolean } {
const history = this.history;
const inHistory = history.ids.length > 0 && history.graph === app.canvas?.graph;
const atEntry = history.ids[history.index] === this.stateManager.state.currentNodeId;
return {
canBack: inHistory && history.index >= (atEntry ? 1 : 0),
canForward: inHistory && atEntry && history.index < history.ids.length - 1
};
}
/**
* Everything the rendered content depends on, compared value by value.
*/
private getRenderKey(info: any): unknown[] {
getRenderKey(info: any): unknown[] {
const state = this.stateManager.state;
const key: unknown[] = [
app.canvas?.graph,
@@ -601,7 +612,7 @@ export class UIManager {
/**
* Widgets are editable when the shown node cannot change under the cursor on its way to the panel.
*/
private canEdit(): boolean {
canEdit(): boolean {
const state = this.stateManager.state;
return !!state.settings["🔍MagnifyGlass.InfoPanelPersist"] || state.lockedNodeId !== null || state.showsSelection;
}
@@ -215,25 +215,7 @@ export class InlineControlFactory {
e.stopPropagation();
e.preventDefault();
// Access the widget callback through the node
const app = (window as any).app;
const node = app?.canvas?.graph?.getNodeById(config.nodeId);
if (!node?.widgets) {
Logger.warn(`[InlineControl] Node ${config.nodeId} not found or has no widgets`);
return;
}
const widget = node.widgets.find((w: any) => w.name === config.widgetName);
if (widget && typeof widget.callback === 'function') {
try {
widget.callback(widget.value, app.canvas, node, [0, 0], null);
Logger.debug(`[InlineControl] Button ${config.widgetName} clicked successfully`);
} catch (error) {
Logger.warn(`[InlineControl] Button callback failed:`, error);
}
} else {
Logger.warn(`[InlineControl] Widget ${config.widgetName} not found or has no callback`);
}
WidgetSyncManager.clickButton(config.nodeId, config.widgetName);
});
container.appendChild(button);
@@ -84,6 +84,23 @@ export class WidgetSyncManager {
}
}
/**
* Run a button widget's callback on a node in the graph on screen
*/
static clickButton(nodeId: number, widgetName: string): void {
const node = app?.canvas?.graph?.getNodeById(nodeId);
const widget = node?.widgets?.find((w: any) => w.name === widgetName);
if (typeof widget?.callback !== 'function') {
Logger.warn(`[WidgetSync] Button ${widgetName} not found on node ${nodeId}`);
return;
}
try {
widget.callback(widget.value, app.canvas, node, [0, 0], null);
} catch (error) {
Logger.warn(`[WidgetSync] Button callback failed:`, error);
}
}
/**
* Let ComfyUI's change tracker pick up the edit as an undo step
*/
+66 -255
View File
@@ -7,17 +7,22 @@
import { BROADCAST_CHANNEL_NAME } from '../shared/constants';
import { Logger } from '../shared/logger';
import { WidgetSyncManager } from '../info-panel/widget-editors/WidgetSyncManager';
/**
* Widget edit command data from pop-out viewer
* A widget of a node, as the viewer names it in edits and requests
*/
interface WidgetEditData {
export interface WidgetRef {
nodeId: number;
widgetName: string;
}
export interface WidgetEditData extends WidgetRef {
value: unknown;
}
/** Inspector actions the viewer may ask for */
export type PanelAction = 'focus-node';
/**
* Node list data sent to popout for dropdown
*/
@@ -36,33 +41,44 @@ interface PopOutConfig extends PopOutStatus {
}
/**
* Inspector info data structure for pop-out viewer
* A widget row as the Inspector computes it. Combo options are sent when an editor asks for them.
*/
interface PopOutInfo {
hoveredNode?: {
export interface PopOutRow {
label: string;
widgetName?: string;
widgetType?: string;
/** Formatted for display */
value: string;
/** Primitive values only; an object value is shown but never edited */
rawValue?: string | number | boolean | null;
isEditable: boolean;
isLinked: boolean;
isActionable: boolean;
constraints: { min?: number; max?: number; step?: number; round?: number; precision?: number };
}
/**
* The shown node, how it came to be shown, and its rows, built by the Inspector.
*/
export interface PopOutInfo {
node: {
id: number;
title: string;
type: string;
id?: number;
mode?: string | number;
pos?: { x: number; y: number } | string;
size?: { w: number; h: number } | string;
executionOrder?: number;
category?: string;
pythonModule?: string;
widgets?: any[];
inputs?: any[];
outputs?: any[];
properties?: Record<string, unknown>;
};
cursor?: {
canvas?: { x: number; y: number };
};
canvas?: {
scale: number;
};
magnifier?: {
zoomFactor: number;
mode: string;
execOrder?: number;
graphPath: string[];
group?: string;
isSubgraph: boolean;
} | null;
nav: {
source: 'hover' | 'locked' | 'sticky' | 'selection';
locked: boolean;
canBack: boolean;
canForward: boolean;
canEdit: boolean;
};
rows: PopOutRow[];
}
/** Messages on the session's channel, in both directions */
@@ -73,13 +89,15 @@ type PopOutMessage =
| { type: 'visibility'; data: boolean }
| { type: 'frame'; data: string }
| { type: 'config'; data: PopOutConfig }
| { type: 'info'; data?: PopOutInfo }
| { type: 'info'; data: PopOutInfo }
| { type: 'nodes-list'; data: NodeListData }
| { type: 'request-nodes'; data: NodeListData['sortBy'] }
| { type: 'node-select'; data: number }
| { type: 'zoom-node'; data: number }
| { type: 'panel-action'; data: { name: PanelAction; nodeId?: number } }
| { type: 'widget-edit'; data: WidgetEditData }
| { type: 'button-click'; data: { nodeId: number; widgetName: string } };
| { type: 'button-click'; data: WidgetRef }
| { type: 'request-options'; data: WidgetRef }
| { type: 'options'; data: WidgetRef & { options: unknown[] } };
const SESSION_KEY = 'magnifyglass-popout-session';
@@ -92,8 +110,12 @@ export class PopOutManager {
private isOpen: boolean = false;
private popOutWindow: Window | null = null;
public onStateChange: ((isOpen: boolean) => void) | null = null;
// The Inspector owns the info and decides what the viewer may change
public onNodeSelect: ((nodeId: number) => void) | null = null;
public onWidgetEdit: (() => void) | null = null;
public onWidgetEdit: ((data: WidgetEditData) => void) | null = null;
public onButtonClick: ((data: WidgetRef) => void) | null = null;
public onPanelAction: ((name: PanelAction, nodeId?: number) => void) | null = null;
public onRequestOptions: ((data: WidgetRef) => unknown[]) | null = null;
private pingInterval: number | null = null;
// Pings since the last pong
private pendingPings: number = 0;
@@ -178,86 +200,22 @@ export class PopOutManager {
this.handleNodeListRequest(message.data);
break;
case 'node-select':
// Popout selected a node, notify main window
if (this.onNodeSelect && typeof message.data === 'number') {
this.onNodeSelect(message.data);
}
this.onNodeSelect?.(message.data);
break;
case 'zoom-node':
// Popout requested to zoom to a node
if (typeof message.data === 'number') {
const app = (window as any).app;
const node = app.canvas?.graph?.getNodeById(message.data);
if (node) {
app.canvas.centerOnNode(node);
}
}
case 'panel-action':
this.onPanelAction?.(message.data.name, message.data.nodeId);
break;
case 'widget-edit':
// Popout is sending a widget value edit
this.handleWidgetEdit(message.data);
this.onWidgetEdit?.(message.data);
break;
case 'button-click':
// Popout is requesting a button widget callback invocation
this.handleButtonClick(message.data);
this.onButtonClick?.(message.data);
break;
case 'request-options':
if (this.onRequestOptions) {
this.sendMessage({ type: 'options', data: { ...message.data, options: this.onRequestOptions(message.data) } });
}
break;
}
}
/**
* Handle widget edit command from pop-out viewer.
* Syncs the edit to the actual node widget on the canvas.
*/
private handleWidgetEdit(editData: WidgetEditData): void {
if (!editData || typeof editData.nodeId !== 'number' || !editData.widgetName) {
Logger.warn('[PopOut] Invalid widget edit data:', editData);
return;
}
const result = WidgetSyncManager.syncWidgetValue(
editData.nodeId,
editData.widgetName,
editData.value
);
if (result.success) {
Logger.debug(`[PopOut] Widget edit synced: ${editData.widgetName} = ${editData.value}`);
// Notify listeners (InfoPanel) that a widget was edited, to force an immediate refresh
if (this.onWidgetEdit) {
this.onWidgetEdit();
}
} else {
Logger.warn(`[PopOut] Widget edit failed: ${result.error}`);
}
}
/**
* Handle button click command from pop-out viewer.
* Invokes the widget's callback function.
*/
private handleButtonClick(buttonData: { nodeId: number; widgetName: string }): void {
if (!buttonData || typeof buttonData.nodeId !== 'number' || !buttonData.widgetName) {
Logger.warn('[PopOut] Invalid button click data:', buttonData);
return;
}
const app = (window as any).app;
const node = app?.canvas?.graph?.getNodeById(buttonData.nodeId);
if (!node?.widgets) {
Logger.warn(`[PopOut] Node ${buttonData.nodeId} not found or has no widgets`);
return;
}
const widget = node.widgets.find((w: any) => w.name === buttonData.widgetName);
if (widget && typeof widget.callback === 'function') {
try {
widget.callback(widget.value, app.canvas, node, [0, 0], null);
Logger.debug(`[PopOut] Button ${buttonData.widgetName} clicked successfully`);
} catch (error) {
Logger.warn(`[PopOut] Button callback failed:`, error);
}
} else {
Logger.warn(`[PopOut] Widget ${buttonData.widgetName} not found or has no callback`);
}
}
@@ -363,19 +321,11 @@ export class PopOutManager {
}
/**
* Send inspector info to the pop-out viewer.
* @param info - Inspector panel information
* Send the Inspector's info to the pop-out viewer.
*/
sendInfo(info: PopOutInfo | null): void {
if (!this.isOpen || !this.channel) return;
// Sanitize the info object to remove non-serializable data
const sanitizedInfo = this.sanitizeInfo(info);
this.sendMessage({
type: 'info',
data: sanitizedInfo || undefined
});
sendInfo(info: PopOutInfo): void {
if (!this.isOpen) return;
this.sendMessage({ type: 'info', data: info });
}
/**
@@ -431,145 +381,6 @@ export class PopOutManager {
}
}
/**
* Sanitize info object for BroadcastChannel transfer.
* Removes functions, circular references, and non-serializable data.
* Handles both GatheredInfo and PopOutInfo formats.
*/
private sanitizeInfo(info: any): PopOutInfo | null {
if (!info) return null;
try {
// Extract only the serializable parts we need
const sanitized: PopOutInfo = {};
// Handle hoveredNode
if (info.hoveredNode) {
sanitized.hoveredNode = {
title: String(info.hoveredNode.title || ''),
type: String(info.hoveredNode.type || ''),
id: info.hoveredNode.id,
mode: info.hoveredNode.mode,
executionOrder: info.hoveredNode.executionOrder,
category: info.hoveredNode.category ? String(info.hoveredNode.category) : undefined,
pythonModule: info.hoveredNode.pythonModule ? String(info.hoveredNode.pythonModule) : undefined,
// Map formatting or raw values
pos: info.hoveredNode.position && typeof info.hoveredNode.position === 'object'
? { x: info.hoveredNode.position.x, y: info.hoveredNode.position.y }
: String(info.hoveredNode.position || ''),
size: info.hoveredNode.size && typeof info.hoveredNode.size === 'object'
? { w: info.hoveredNode.size.width, h: info.hoveredNode.size.height }
: String(info.hoveredNode.size || ''),
widgets: this.extractSafeData(info.hoveredNode.widgets),
inputs: this.extractSafeData(info.hoveredNode.inputs),
outputs: this.extractSafeData(info.hoveredNode.outputs),
properties: this.sanitizeOptions(info.hoveredNode.properties) || {}
};
}
// Handle cursor - map from GatheredInfo format (canvasX/canvasY)
if (info.cursor) {
sanitized.cursor = {
canvas: {
x: Number(info.cursor.canvasX || info.cursor.canvas?.x || 0),
y: Number(info.cursor.canvasY || info.cursor.canvas?.y || 0)
}
};
}
// Handle canvas/zoom - map from GatheredInfo (zoom) to canvas.scale
if (info.zoom !== undefined || info.canvas) {
sanitized.canvas = {
scale: Number(info.zoom || info.canvas?.scale || 1)
};
}
// Handle magnifier
const magnifyGlass = (window as any).comfyUIMagnifyGlass;
if (magnifyGlass?.config) {
sanitized.magnifier = {
zoomFactor: Number(magnifyGlass.config.zoomFactor || 1)
};
}
return sanitized;
} catch (e) {
Logger.error('[PopOut] Failed to sanitize info:', e);
return null;
}
}
/**
* Extract specific safe fields from node lists (widgets, inputs, outputs).
* Removes functions like onFloatValueChange which cause cloning errors.
*/
private extractSafeData(list: any[] | undefined): any[] {
if (!list || !Array.isArray(list)) return [];
return list.map(item => {
if (!item || typeof item !== 'object') return {};
// Only extract known safe identifiers and values
const safeItem: any = {
name: item.name,
type: item.type,
label: item.label
};
// Safely copy value if it's not a function
if (item.value !== undefined && typeof item.value !== 'function') {
safeItem.value = item.value;
}
// Handle widget options - preserve arrays for combo dropdowns
if (item.options !== undefined) {
if (Array.isArray(item.options)) {
// For combo widgets, options is an array of values - keep it as is
safeItem.options = item.options.filter(
(opt: any) => typeof opt !== 'function'
);
} else if (typeof item.options === 'object' && item.options !== null) {
// Check for options.values (ComfyUI nested format for combo widgets)
if (Array.isArray(item.options.values)) {
safeItem.options = item.options.values.filter(
(opt: any) => typeof opt !== 'function'
);
} else {
// For other widgets, options is an object with config (min, max, step, etc)
safeItem.options = this.sanitizeOptions(item.options);
}
}
}
return safeItem;
});
}
/**
* Shallow copy object removing functions.
*/
private sanitizeOptions(options: any): any {
if (!options || typeof options !== 'object') return {};
const clean: any = {};
for (const key in options) {
const val = options[key];
if (typeof val !== 'function' && !key.startsWith('_')) {
// Determine if value is simple enough to copy
if (typeof val === 'object' && val !== null) {
try {
// Simple check if it's a plain object/array without cycles
JSON.stringify(val);
clean[key] = val;
} catch (e) {
// Skip complex objects
}
} else {
clean[key] = val;
}
}
}
return clean;
}
/**
* Send a message through the BroadcastChannel.
*/
+6 -15
View File
@@ -6,6 +6,7 @@
*/
import type { InfoPanel } from '../info-panel/InfoPanel';
import type { PanelAction, PopOutInfo, WidgetEditData, WidgetRef } from '../magnify-glass/PopOutManager';
// ============================================================================
// ComfyUI App
@@ -231,11 +232,14 @@ export interface MagnifyGlassInstance {
renderer: MagnifyGlassRenderer | null;
popOutManager?: {
isPopOutOpen(): boolean;
sendInfo(info: unknown): void;
sendInfo(info: PopOutInfo): void;
toggle(): void;
onStateChange?: ((isOpen: boolean) => void) | null;
onNodeSelect?: ((nodeId: number) => void) | null;
onWidgetEdit?: (() => void) | null;
onWidgetEdit?: ((data: WidgetEditData) => void) | null;
onButtonClick?: ((data: WidgetRef) => void) | null;
onPanelAction?: ((name: PanelAction, nodeId?: number) => void) | null;
onRequestOptions?: ((data: WidgetRef) => unknown[]) | null;
};
lastKnownMousePosition: { x: number; y: number };
updateMagnifiedView(): void;
@@ -293,19 +297,6 @@ export interface MagnifyGlassRenderer {
// Info Panel Types
// ============================================================================
/**
* Information gathered about the current cursor position.
* Returned by InformationGatherer.gatherInformation()
*/
export interface GatheredInfo {
cursor: {
canvasX: number;
canvasY: number;
};
zoom: number;
hoveredNode: NodeInfo | null;
}
/**
* Detailed information about a node.
* Structure matches InformationGatherer.getDetailedNodeInfo() output.
+143 -2
View File
@@ -42,12 +42,15 @@ class FakeGlass {
renderer = null;
lastKnownMousePosition = { x: 0, y: 0 };
popOutManager = {
isPopOutOpen: () => false,
isPopOutOpen: vi.fn(() => false),
sendInfo: vi.fn(),
toggle: vi.fn(),
onStateChange: null as unknown,
onNodeSelect: null as unknown,
onWidgetEdit: null as unknown
onWidgetEdit: null as unknown,
onButtonClick: null as unknown,
onPanelAction: null as unknown,
onRequestOptions: null as unknown
};
setGlassPreviewActive = vi.fn();
updateMagnifiedView() { }
@@ -149,6 +152,9 @@ describe('Info Panel lifecycle', () => {
expect(mg.popOutManager.onStateChange).toBeNull();
expect(mg.popOutManager.onNodeSelect).toBeNull();
expect(mg.popOutManager.onWidgetEdit).toBeNull();
expect(mg.popOutManager.onButtonClick).toBeNull();
expect(mg.popOutManager.onPanelAction).toBeNull();
expect(mg.popOutManager.onRequestOptions).toBeNull();
expect(mg.setGlassPreviewActive).toHaveBeenCalledWith(true);
for (const [type, handler, options] of docAdd.mock.calls) {
@@ -704,6 +710,141 @@ describe('Info Panel lifecycle', () => {
});
});
describe('pop-out', () => {
let nodes: any[];
let graph: any;
const panelManager = () => window.infoPanelManager!;
const popOut = () => mg.popOutManager as any;
const sent = () => popOut().sendInfo.mock.calls.map(([info]: any[]) => info);
const last = () => sent().at(-1);
const edit = (widgetName: string, value: unknown, nodeId = 1) => popOut().onWidgetEdit({ nodeId, widgetName, value });
const makeNode = (id: number, title: string) => ({
id, title, type: 'KSampler', order: id, mode: 0, pos: [0, 0], size: [200, 100],
widgets: [
{ name: 'steps', type: 'number', value: 20, options: { precision: 0, min: 1, max: 100, step: 10, step2: 1 } },
{ name: 'sampler_name', type: 'combo', value: 'euler', options: { values: ['euler', 'dpmpp_2m'] } },
{ name: 'model', type: 'combo', value: 'a', options: { values: ['a', 'b'] } },
{ name: 'data', type: 'custom', value: { a: 1 } },
{ name: 'run', type: 'button', value: null, callback: vi.fn() }
],
inputs: [{ name: 'model', type: 'COMBO', widget: { name: 'model' }, link: 4 }], outputs: [], properties: {}
});
beforeEach(() => {
nodes = [makeNode(1, 'Loader'), makeNode(2, 'Sampler')];
graph = { _nodes: nodes, getNodeById: (id: number) => nodes.find(n => n.id === id) ?? null };
Object.assign((window as any).app.canvas, { graph, node_over: nodes[0], selected_nodes: {}, centerOnNode: vi.fn() });
(mg as any).state = { active: true, x: 0, y: 0, canvasScale: 1 };
window.comfyUIMagnifyGlass = mg as any;
// Already connected when the Inspector mounts
popOut().isPopOutOpen.mockReturnValue(true);
mountInfoPanel(asInstance(mg));
});
afterEach(() => {
window.comfyUIMagnifyGlass = undefined;
});
it('sends the header, navigation and Inspector rows as plain data, without combo lists', () => {
const info = last();
expect(sent()).toHaveLength(1);
expect(info.node).toEqual({ id: 1, title: 'Loader', type: 'KSampler', mode: 'Always', execOrder: 1, graphPath: [], isSubgraph: false });
expect(info.nav).toEqual({ source: 'hover', locked: false, canBack: false, canForward: false, canEdit: false });
expect(info.rows.map((row: any) => [row.widgetName, row.widgetType, row.rawValue, row.isEditable, row.isLinked, row.isActionable])).toEqual([
['steps', 'INT', 20, true, false, false],
['sampler_name', 'combo', 'euler', true, false, false],
['model', 'combo', 'a', false, true, false],
['data', 'text', undefined, false, false, false],
['run', 'button', null, false, false, true]
]);
expect(info.rows[0].constraints).toEqual({ min: 1, max: 100, step: 1, precision: 0 });
expect(info.rows[1].constraints).toEqual({});
expect(info.rows[3].value).toContain('"a": 1');
expect(() => structuredClone(info)).not.toThrow();
});
it('sends only when something shown changes, and looks again a few times a second while connected', () => {
panelManager().updateInfo();
panelManager().updateInfo();
expect(sent()).toHaveLength(1);
// A run changes a value without any input in ComfyUI
nodes[0].widgets[0].value = 25;
vi.advanceTimersByTime(250);
expect(sent()).toHaveLength(2);
expect(last().rows[0].value).toBe('25');
vi.advanceTimersByTime(1000);
expect(sent()).toHaveLength(2);
// A connect sends at once, even unchanged
popOut().onStateChange(true);
expect(sent()).toHaveLength(3);
popOut().onStateChange(false);
expect(vi.getTimerCount()).toBe(0);
});
it('with the glass off it still sends the locked node, and nothing hovered', () => {
panelManager().uiManager.jumpToNode(2);
mg.state.active = false;
nodes[1].widgets[0].value = 30;
vi.advanceTimersByTime(250);
expect(last().node.id).toBe(2);
expect(last().nav.source).toBe('locked');
expect(last().rows[0].value).toBe('30');
expect(panelManager().uiManager.elements.panel!.style.display).toBe('none');
panelManager().stateManager.state.lockedNodeId = null;
vi.advanceTimersByTime(250);
expect(last().node).toBeNull();
});
it('edits apply only to editable rows of the node it shows, under the Inspector lock rule', () => {
const count = sent().length;
// Hovered only: the node could change under the cursor
edit('steps', 30);
expect(nodes[0].widgets[0].value).toBe(20);
// A rejected edit is answered too, so the viewer drops the value it shows
expect(sent()).toHaveLength(count + 1);
panelManager().uiManager.jumpToNode(1);
edit('steps', 30);
expect(nodes[0].widgets[0].value).toBe(30);
expect(last().rows[0].value).toBe('30');
edit('model', 'b');
expect(nodes[0].widgets[2].value).toBe('a');
edit('data', 'x');
expect(nodes[0].widgets[3].value).toEqual({ a: 1 });
edit('steps', 40, 2);
expect(nodes[1].widgets[0].value).toBe(20);
// Another graph on screen, where id 1 is another node
(window as any).app.canvas.graph = { ...graph };
edit('steps', 50);
expect(nodes[0].widgets[0].value).toBe(30);
});
it('buttons run their callback like the Inspector, and options come only for the node it shows', () => {
popOut().onButtonClick({ nodeId: 1, widgetName: 'run' });
popOut().onButtonClick({ nodeId: 2, widgetName: 'run' });
expect(nodes[0].widgets[4].callback).toHaveBeenCalledTimes(1);
expect(nodes[1].widgets[4].callback).not.toHaveBeenCalled();
expect(popOut().onRequestOptions({ nodeId: 1, widgetName: 'sampler_name' })).toEqual(['euler', 'dpmpp_2m']);
expect(popOut().onRequestOptions({ nodeId: 2, widgetName: 'sampler_name' })).toEqual([]);
});
it('Focus node centres on and locks the node it shows', () => {
popOut().onPanelAction('focus-node', 2);
expect((window as any).app.canvas.centerOnNode).not.toHaveBeenCalled();
popOut().onPanelAction('focus-node', 1);
expect((window as any).app.canvas.centerOnNode).toHaveBeenCalledWith(nodes[0]);
expect(last().nav).toMatchObject({ source: 'locked', locked: true, canEdit: true });
});
});
describe('modes', () => {
let nodes: any[];
const panelManager = () => window.infoPanelManager!;
-25
View File
@@ -66,31 +66,6 @@ describe('InformationGatherer', () => {
});
});
describe('gatherInformation', () => {
it('should return a GatheredInfo object with required fields', () => {
const info = gatherer.gatherInformation();
expect(Object.keys(info).sort()).toEqual(['cursor', 'hoveredNode', 'zoom']);
});
it('should return cursor position from magnifyGlass state', () => {
const info = gatherer.gatherInformation();
expect(info.cursor.canvasX).toBe(100);
expect(info.cursor.canvasY).toBe(100);
});
it('should return zoom from canvas scale', () => {
const info = gatherer.gatherInformation();
expect(info.zoom).toBe(1);
});
it('should return null for hoveredNode when no node is hovered', () => {
const info = gatherer.gatherInformation();
expect(info.hoveredNode).toBeNull();
});
});
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
+27
View File
@@ -144,4 +144,31 @@ describe('Pop-out connection', () => {
manager.sendFrame(canvas);
expect(frames()).toBe(1);
});
it('hands viewer requests to the Inspector and sends its info as it is', () => {
const calls: unknown[][] = [];
manager.onWidgetEdit = (data) => calls.push(['edit', data]);
manager.onButtonClick = (data) => calls.push(['click', data]);
manager.onPanelAction = (name, nodeId) => calls.push(['action', name, nodeId]);
manager.onRequestOptions = (data) => data.widgetName === 'size' ? [512, 768] : [];
const info = { node: null, nav: { source: 'hover', locked: false, canBack: false, canForward: false, canEdit: false }, rows: [] } as const;
manager.sendInfo(info);
expect(posted()).toEqual([]);
receive({ type: 'pong' });
receive({ type: 'widget-edit', data: { nodeId: 5, widgetName: 'seed', value: 1 } });
receive({ type: 'button-click', data: { nodeId: 5, widgetName: 'run' } });
receive({ type: 'panel-action', data: { name: 'focus-node', nodeId: 5 } });
receive({ type: 'request-options', data: { nodeId: 5, widgetName: 'size' } });
manager.sendInfo(info);
expect(calls).toEqual([
['edit', { nodeId: 5, widgetName: 'seed', value: 1 }],
['click', { nodeId: 5, widgetName: 'run' }],
['action', 'focus-node', 5]
]);
expect(posted().filter(m => m.type === 'options')).toEqual([{ type: 'options', data: { nodeId: 5, widgetName: 'size', options: [512, 768] } }]);
expect(posted().at(-1)).toEqual({ type: 'info', data: info });
});
});
+80 -30
View File
@@ -9,13 +9,21 @@ const script = html.slice(html.indexOf('<script>') + 8, html.indexOf('</script>'
describe('Pop-out viewer editing', () => {
let channel: { name: string; 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);
let rows: any[];
const posted = (type: string) => channel.postMessage.mock.calls.map(([m]) => m).filter((m: any) => m.type === type).map((m: any) => m.data);
const edits = () => posted('widget-edit');
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: [] } } } });
.find(el => rows[Number((el as HTMLElement).dataset.widget)].widgetName === name) as HTMLElement;
// A row as the Inspector builds it
const row = (widgetName: string, widgetType: string, rawValue: unknown, extra: object = {}) => ({
label: widgetName, widgetName, widgetType, value: String(rawValue), rawValue,
isEditable: true, isLinked: false, isActionable: false, constraints: {}, ...extra
});
const node = (id: unknown = 5) => ({ id, title: 'Node', type: 'Node', mode: 'Always', graphPath: [], isSubgraph: false });
const sendInfo = (list: any[], canEdit = true) => {
rows = list;
channel.onmessage!({ data: { type: 'info', data: { node: node(), nav: { source: 'locked', locked: true, canBack: false, canForward: false, canEdit }, rows } } });
};
const key = (el: Element, k: string, init: KeyboardEventInit = {}) =>
el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }));
@@ -45,7 +53,7 @@ describe('Pop-out viewer editing', () => {
});
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' }]);
sendInfo([row(`it's "quoted"`, 'multiline', 'line one\nline two')]);
valueOf(`it's "quoted"`).click();
const textarea = sidebar().querySelector('textarea')!;
@@ -58,8 +66,8 @@ describe('Pop-out viewer editing', () => {
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 } }]);
it('number inputs take min, max and step from the Inspector, and Escape cancels without closing', () => {
sendInfo([row('denoise', 'FLOAT', 0.5, { constraints: { min: 0, max: 1, step: 0.01 } }), row('seed', 'INT', 3), row('cfg', 'FLOAT', 8)]);
valueOf('denoise').click();
const input = sidebar().querySelector('input[type="number"]') as HTMLInputElement;
@@ -76,51 +84,87 @@ describe('Pop-out viewer editing', () => {
again.value = '0.7';
key(again, 'Enter');
expect(edits()).toEqual([{ nodeId: 5, widgetName: 'denoise', value: 0.7 }]);
// INT steps by whole numbers without a step of its own; FLOAT takes any value
valueOf('seed').click();
expect((sidebar().querySelector('input[type="number"]') as HTMLInputElement).step).toBe('1');
key(sidebar().querySelector('input[type="number"]')!, 'Escape');
valueOf('cfg').click();
expect((sidebar().querySelector('input[type="number"]') as HTMLInputElement).step).toBe('any');
});
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] }]);
it('a combo asks for its options, drops a reply for another widget, and a pick keeps the option type', () => {
sendInfo([row('size', 'combo', 512)]);
valueOf('size').click();
expect(posted('request-options')).toEqual([{ nodeId: 5, widgetName: 'size' }]);
let select = sidebar().querySelector('select')!;
// An info push leaves the waiting editor open
sendInfo(rows);
expect(sidebar().querySelector('select')).toBe(select);
channel.onmessage!({ data: { type: 'options', data: { nodeId: 5, widgetName: 'other', options: ['a'] } } });
expect(select.options).toHaveLength(1);
channel.onmessage!({ data: { type: 'options', data: { nodeId: 5, widgetName: 'size', options: [512, 768] } } });
expect([...select.options].map(o => o.textContent)).toEqual(['Select...', '512', '768']);
select.dispatchEvent(new Event('blur'));
expect(edits()).toEqual([]);
expect(sidebar().querySelector('select')).toBeNull();
valueOf('size').click();
select = sidebar().querySelector('select')!;
channel.onmessage!({ data: { type: 'options', data: { nodeId: 5, widgetName: 'size', options: [512, 768] } } });
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);
it('keeps an open editor while info arrives, and values without a primitive raw value are not editable', () => {
sendInfo([row('seed', 'INT', 1), row('data', 'text', undefined, { value: '{"a": 1}' })]);
expect(valueOf('data')).toBeUndefined();
valueOf('seed').click();
const input = sidebar().querySelector('input[type="number"]');
sendInfo(widgets);
sendInfo(rows);
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' }]);
it('edits only rows the Inspector allows, and shows an empty string as an editable empty value', () => {
sendInfo([row('model', 'combo', 'a', { isLinked: true, isEditable: false }), row('prompt', 'text', '')]);
expect(valueOf('model')).toBeUndefined();
expect(sidebar().textContent).toContain('linked');
const empty = valueOf('prompt');
expect(empty.querySelector('.value-empty')!.textContent).toBe('empty');
empty.click();
expect((sidebar().querySelector('input[type="text"]') as HTMLInputElement).value).toBe('');
key(sidebar().querySelector('input[type="text"]')!, 'Escape');
(sidebar().querySelector('[data-kind="toggle"]') as HTMLElement).click();
expect(edits()).toEqual([{ nodeId: 5, widgetName: `o'clock`, value: true }]);
// Without a lock, Sticky or the selection nothing is editable
sendInfo(rows, false);
expect(sidebar().querySelector('[data-kind="value"]')).toBeNull();
expect(sidebar().querySelector('.value-empty')).not.toBeNull();
});
(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"` }]);
it('buttons send their widget name, booleans flip, and a button with a boolean value is still a button', () => {
sendInfo([
row(`o'clock`, 'button', false, { isActionable: true, isEditable: false }),
row(`run "now"`, 'button', null, { isActionable: true, isEditable: false }),
row('enabled', 'boolean', false)
]);
expect(sidebar().querySelector('[data-kind="toggle"]')).toBeNull();
for (const button of sidebar().querySelectorAll<HTMLElement>('[data-kind="action"]')) button.click();
expect(posted('button-click')).toEqual([{ nodeId: 5, widgetName: `o'clock` }, { nodeId: 5, widgetName: `run "now"` }]);
valueOf('enabled').click();
expect(edits()).toEqual([{ nodeId: 5, widgetName: 'enabled', value: true }]);
});
it('a data:image value renders only the image, never its markup', () => {
const png = 'data:image/png;base64,iVBORw0KGgo=';
sendInfo([
{ name: 'evil', type: 'text', value: `<img src=x onerror="window.__pwned=1"> data:image/png;base64,AAAA` },
{ name: 'plot', type: 'text', value: `<img src="${png}" style="width:100%">` },
row('evil', 'text', `<img src=x onerror="window.__pwned=1"> data:image/png;base64,AAAA`),
row('plot', 'text', `<img src="${png}" style="width:100%">`),
]);
expect(sidebar().querySelector('[onerror]')).toBeNull();
@@ -131,17 +175,23 @@ describe('Pop-out viewer editing', () => {
it('node ids stay text, and node actions post through delegated listeners', () => {
const id = `1); window.__pwned = (1`;
channel.onmessage!({ data: { type: 'info', data: { hoveredNode: { id, title: 'Node', type: 'Node', widgets: [], inputs: [], outputs: [] } } } });
channel.onmessage!({ data: { type: 'info', data: { node: node(id), nav: { canEdit: false }, rows: [] } } });
expect(sidebar().querySelector('[onclick]')).toBeNull();
expect(sidebar().textContent).toContain(`(#${id})`);
(sidebar().querySelector('[data-action="zoom-node"]') as HTMLElement).click();
(sidebar().querySelector('[data-action="focus-node"]') as HTMLElement).click();
(sidebar().querySelector('[data-action="node-dropdown"]') as HTMLElement).click();
const posted = channel.postMessage.mock.calls.map(([m]) => m).filter((m: any) => m.type !== 'pong');
expect(posted).toEqual([{ type: 'zoom-node', data: id }, { type: 'request-nodes', data: 'title' }]);
const sent = channel.postMessage.mock.calls.map(([m]) => m).filter((m: any) => m.type !== 'pong');
expect(sent).toEqual([{ type: 'panel-action', data: { name: 'focus-node', nodeId: id } }, { type: 'request-nodes', data: 'title' }]);
expect((window as any).__pwned).toBeUndefined();
});
it('shows the empty state for no node, with no Cursor section', () => {
channel.onmessage!({ data: { type: 'info', data: { node: null, nav: { canEdit: false }, rows: [] } } });
expect(sidebar().querySelector('.empty-state')).not.toBeNull();
expect(sidebar().textContent).not.toContain('Cursor');
});
it('listens on the channel of the session in its URL, and announces itself until pinged', () => {
const pongs = () => channel.postMessage.mock.calls.filter(([m]) => m.type === 'pong').length;
expect(channel.name).toBe('comfyui-magnifyglass-popout:abc123');
@@ -161,7 +211,7 @@ describe('Pop-out viewer editing', () => {
const config = (glass: string) => channel.onmessage!({ data: { type: 'config', data: { theme: 'dark', glass, inspector: true } } });
ping();
config('off');
sendInfo([{ name: 'seed', type: 'number', value: 1 }]);
sendInfo([row('seed', 'INT', 1)]);
valueOf('seed').click();
const input = sidebar().querySelector('input[type="number"]');
expect(label()).toBe('Glass off');
@@ -185,7 +235,7 @@ describe('Pop-out viewer editing', () => {
});
it('says when the Inspector is off in ComfyUI', () => {
sendInfo([{ name: 'seed', type: 'number', value: 1 }]);
sendInfo([row('seed', 'INT', 1)]);
channel.onmessage!({ data: { type: 'config', data: { theme: 'dark', glass: 'on', inspector: false } } });
expect(sidebar().textContent).toContain('The Inspector is off in ComfyUI');
channel.onmessage!({ data: { type: 'config', data: { theme: 'dark', glass: 'on', inspector: true } } });
@@ -193,7 +243,7 @@ describe('Pop-out viewer editing', () => {
});
it('Escape closes the node list, never the window', () => {
channel.onmessage!({ data: { type: 'info', data: { hoveredNode: { id: 5, title: 'Node', type: 'Node', widgets: [], inputs: [], outputs: [] } } } });
sendInfo([]);
(sidebar().querySelector('[data-action="node-dropdown"]') as HTMLElement).click();
channel.onmessage!({ data: { type: 'nodes-list', data: { nodes: [{ id: 5, title: 'Node', type: 'Node' }], sortBy: 'title' } } });
expect(document.getElementById('node-dropdown')).not.toBeNull();
+151 -24
View File
@@ -8,6 +8,8 @@ import { PositionManager } from "./PositionManager.js";
import { EventManager } from "./EventManager.js";
import { InformationGatherer } from "./InformationGatherer.js";
import { CanvasHighlighter } from "./CanvasHighlighter.js";
import { getImportantNodeParameters } from "./NodeDataExtractor.js";
import { WidgetSyncManager } from "./widget-editors/WidgetSyncManager.js";
class InfoPanel {
constructor(magnifyGlass) {
__publicField(this, "magnifyGlass");
@@ -22,6 +24,13 @@ class InfoPanel {
__publicField(this, "originalHide");
__publicField(this, "updateFrameId", null);
__publicField(this, "boundScheduleSelectionUpdate");
// Refreshes the pop-out while it is connected, since it gets no input of its own here
__publicField(this, "popOutTimer", null);
// What the pop-out was last sent from, null to send the next update whatever it holds
__publicField(this, "popOutKey", null);
// The node the pop-out shows and the graph it came from; its edits apply only there
__publicField(this, "popOutNodeId", null);
__publicField(this, "popOutGraph", null);
// The node Sticky keeps, looked up again on each update like the locked one
__publicField(this, "stickyNodeId", null);
__publicField(this, "stickyGraph", null);
@@ -60,16 +69,40 @@ class InfoPanel {
this.originalShow = magnifyGlass.ui.show;
this.originalHide = magnifyGlass.ui.hide;
this.hookIntoMagnifyGlass();
if (this.magnifyGlass.popOutManager) {
this.magnifyGlass.popOutManager.onStateChange = () => {
const popOut = this.magnifyGlass.popOutManager;
if (popOut) {
popOut.onStateChange = (isOpen) => {
this.uiManager.updateControlStates();
this.setPopOutRefresh(isOpen);
};
this.magnifyGlass.popOutManager.onNodeSelect = (nodeId) => {
popOut.onNodeSelect = (nodeId) => {
this.uiManager.jumpToNode(nodeId);
};
this.magnifyGlass.popOutManager.onWidgetEdit = () => {
this.updateInfo();
popOut.onWidgetEdit = (data) => {
var _a;
if (this.uiManager.canEdit() && ((_a = this.popOutRow(data)) == null ? void 0 : _a.isEditable)) {
WidgetSyncManager.syncWidgetValue(data.nodeId, data.widgetName, data.value);
}
this.refreshPopOut();
};
popOut.onButtonClick = (data) => {
var _a;
if ((_a = this.popOutRow(data)) == null ? void 0 : _a.isActionable) {
WidgetSyncManager.clickButton(data.nodeId, data.widgetName);
}
this.refreshPopOut();
};
popOut.onRequestOptions = (data) => {
var _a, _b;
return ((_b = (_a = this.popOutRow(data)) == null ? void 0 : _a.constraints) == null ? void 0 : _b.options) ?? [];
};
popOut.onPanelAction = (name, nodeId) => {
var _a;
if (name === "focus-node" && nodeId === this.popOutNodeId && ((_a = app.canvas) == null ? void 0 : _a.graph) === this.popOutGraph) {
this.uiManager.jumpToNode(nodeId);
}
};
this.setPopOutRefresh(popOut.isPopOutOpen());
}
this.uiManager.onRefresh = () => this.updateInfo();
Logger.debug("Info Panel initialized successfully");
@@ -136,7 +169,11 @@ class InfoPanel {
mg.popOutManager.onStateChange = null;
mg.popOutManager.onNodeSelect = null;
mg.popOutManager.onWidgetEdit = null;
mg.popOutManager.onButtonClick = null;
mg.popOutManager.onRequestOptions = null;
mg.popOutManager.onPanelAction = null;
}
this.setPopOutRefresh(false);
if (this.updateFrameId !== null) {
cancelAnimationFrame(this.updateFrameId);
this.updateFrameId = null;
@@ -159,54 +196,144 @@ class InfoPanel {
this.uiManager.updateControlStates();
}
/**
* Update the info panel with current information.
* Update the info panel and the pop-out with the node to show.
*/
updateInfo() {
var _a, _b, _c, _d, _e;
const settings = this.stateManager.state.settings;
const isActive = this.magnifyGlass.state.active;
const popOutOpen = !!((_a = this.magnifyGlass.popOutManager) == null ? void 0 : _a.isPopOutOpen());
if (!isActive) {
this.uiManager.hide();
if (this.stateManager.state.isPanelVisible) this.uiManager.hide();
if (this.uiManager.elements.controls && this.uiManager.elements.controls.style.display !== "none") {
this.uiManager.elements.controls.style.display = "none";
}
this.canvasHighlighter.setHighlightedNode(null);
return;
if (!popOutOpen) return;
}
let info = this.informationGatherer.gatherInformation();
const hovered = isActive ? this.informationGatherer.getNodeUnderCursor() : null;
let node = hovered ? this.informationGatherer.getDetailedNodeInfo(hovered) : null;
let source = "hover";
const state = this.stateManager.state;
if (state.lockedNodeId !== null && state.lockedGraph !== ((_a = app.canvas) == null ? void 0 : _a.graph)) {
if (state.lockedNodeId !== null && state.lockedGraph !== ((_b = app.canvas) == null ? void 0 : _b.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(((_b = app.canvas) == null ? void 0 : _b.selected_nodes) ?? {})[0] : void 0;
const selectedNode = !lockedNode && settings["🔍MagnifyGlass.InfoPanelFollowSelection"] ? Object.values(((_c = app.canvas) == null ? void 0 : _c.selected_nodes) ?? {})[0] : void 0;
state.showsSelection = !!selectedNode;
const shownNode = lockedNode ?? selectedNode;
if (shownNode) {
info = { ...info, hoveredNode: this.informationGatherer.getDetailedNodeInfo(shownNode) };
node = this.informationGatherer.getDetailedNodeInfo(shownNode);
source = lockedNode ? "locked" : "selection";
}
if (info.hoveredNode) {
this.stickyNodeId = info.hoveredNode.id;
this.stickyGraph = (_c = app.canvas) == null ? void 0 : _c.graph;
if (node) {
this.stickyNodeId = node.id;
this.stickyGraph = (_d = app.canvas) == null ? void 0 : _d.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;
const stickyNode = this.stickyGraph === ((_e = app.canvas) == null ? void 0 : _e.graph) ? this.uiManager.nodeSelector.getNodeById(this.stickyNodeId) : null;
if (stickyNode) {
info = { ...info, hoveredNode: this.informationGatherer.getDetailedNodeInfo(stickyNode) };
node = this.informationGatherer.getDetailedNodeInfo(stickyNode);
source = "sticky";
} else {
this.stickyNodeId = null;
}
}
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()) {
this.magnifyGlass.popOutManager.sendInfo(info);
state.currentNodeId = (node == null ? void 0 : node.id) ?? null;
const info = { hoveredNode: node };
if (isActive) {
this.uiManager.displayInfo(info);
this.canvasHighlighter.setHighlightedNode(node ? node.id : null);
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
}
this.positionManager.positionPanel();
this.positionManager.positionFloatingControls(this.uiManager.elements.controls);
if (popOutOpen) {
this.sendPopOutInfo(info, source);
}
}
/**
* Send the pop-out what the Inspector shows, when it changed since the last send.
*/
sendPopOutInfo(info, source) {
var _a, _b;
const nav = {
source,
locked: this.stateManager.state.lockedNodeId !== null,
...this.uiManager.getHistoryNav(),
canEdit: this.uiManager.canEdit()
};
const key = [...this.uiManager.getRenderKey(info), ...Object.values(nav)];
const sent = this.popOutKey;
if (sent && key.length === sent.length && key.every((v, i) => Object.is(v, sent[i]))) return;
this.popOutKey = key;
this.popOutNodeId = ((_a = info.hoveredNode) == null ? void 0 : _a.id) ?? null;
this.popOutGraph = (_b = app.canvas) == null ? void 0 : _b.graph;
this.magnifyGlass.popOutManager.sendInfo(this.buildPopOutInfo(info.hoveredNode, nav));
}
/**
* The pop-out payload: the node's identity and place, and its widget rows as plain data.
*/
buildPopOutInfo(node, nav) {
if (!node) return { node: null, nav, rows: [] };
return {
node: {
id: node.id,
title: node.title,
type: node.type,
mode: node.mode,
execOrder: node.executionOrder,
graphPath: node.graphPath,
group: node.group,
isSubgraph: node.isSubgraph
},
nav,
rows: getImportantNodeParameters(node).map((row) => {
const { options, ...constraints } = row.constraints ?? {};
const raw = row.rawValue;
return {
label: row.label,
widgetName: row.widgetName,
widgetType: row.widgetType,
value: row.value,
rawValue: raw === null || typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean" ? raw : void 0,
isEditable: !!row.isEditable,
isLinked: !!row.isLinked,
isActionable: !!row.isActionable,
constraints
};
})
};
}
/**
* The row of a widget on the node the pop-out shows, while its graph is still on screen.
*/
popOutRow({ nodeId, widgetName }) {
var _a;
if (nodeId !== this.popOutNodeId || ((_a = app.canvas) == null ? void 0 : _a.graph) !== this.popOutGraph) return void 0;
const node = this.uiManager.nodeSelector.getNodeById(nodeId);
return node ? getImportantNodeParameters(this.informationGatherer.getDetailedNodeInfo(node)).find((row) => row.widgetName === widgetName) : void 0;
}
/**
* Send the pop-out its info now, even if nothing changed.
*/
refreshPopOut() {
this.popOutKey = null;
this.updateInfo();
}
/**
* Refresh the pop-out a few times a second while it is connected: values change after a run
* or from another window without any input in this one.
*/
setPopOutRefresh(connected) {
if (this.popOutTimer !== null) {
clearInterval(this.popOutTimer);
this.popOutTimer = null;
}
if (!connected) return;
this.popOutTimer = window.setInterval(() => this.updateInfo(), 250);
this.refreshPopOut();
}
}
function mountInfoPanel(magnifyGlass) {
-23
View File
@@ -1,29 +1,6 @@
class InformationGatherer {
constructor() {
}
gatherInformation() {
const magnifyGlass = window.comfyUIMagnifyGlass;
if (!magnifyGlass) {
return {
cursor: { canvasX: 0, canvasY: 0 },
zoom: 1,
hoveredNode: null
};
}
const info = {
cursor: {
canvasX: magnifyGlass.state.x,
canvasY: magnifyGlass.state.y
},
zoom: magnifyGlass.state.canvasScale,
hoveredNode: null
};
const nodeUnderCursor = this.getNodeUnderCursor();
if (nodeUnderCursor) {
info.hoveredNode = this.getDetailedNodeInfo(nodeUnderCursor);
}
return info;
}
getNodeUnderCursor() {
var _a, _b;
const magnifyGlass = window.comfyUIMagnifyGlass;
+16 -6
View File
@@ -453,7 +453,6 @@ class UIManager {
* The header is built once and only updated, so focus stays on its buttons.
*/
renderHeader(node) {
var _a;
const panel = this.elements.panel;
const state = this.stateManager.state;
const find = (selector) => panel.querySelector(selector);
@@ -479,11 +478,9 @@ class UIManager {
lock.title = locked ? "Unlock: show the hovered node" : "Lock to this node";
lock.setAttribute("aria-label", lock.title);
find('[data-action="focus-node"]').disabled = !node;
const history = this.history;
const inHistory = history.ids.length > 0 && history.graph === ((_a = app.canvas) == null ? void 0 : _a.graph);
const atEntry = history.ids[history.index] === state.currentNodeId;
find('[data-action="back"]').disabled = !inHistory || history.index < (atEntry ? 1 : 0);
find('[data-action="forward"]').disabled = !inHistory || !atEntry || history.index >= history.ids.length - 1;
const { canBack, canForward } = this.getHistoryNav();
find('[data-action="back"]').disabled = !canBack;
find('[data-action="forward"]').disabled = !canForward;
const breadcrumb = find(".panel-breadcrumb");
const path = (node == null ? void 0 : node.graphPath.length) ? ["Root", ...node.graphPath] : [];
breadcrumb.innerHTML = [
@@ -493,6 +490,19 @@ class UIManager {
].join("");
breadcrumb.hidden = !breadcrumb.innerHTML;
}
/**
* Whether Back and Forward have a jump to go to in the graph on screen.
*/
getHistoryNav() {
var _a;
const history = this.history;
const inHistory = history.ids.length > 0 && history.graph === ((_a = app.canvas) == null ? void 0 : _a.graph);
const atEntry = history.ids[history.index] === this.stateManager.state.currentNodeId;
return {
canBack: inHistory && history.index >= (atEntry ? 1 : 0),
canForward: inHistory && atEntry && history.index < history.ids.length - 1
};
}
/**
* Everything the rendered content depends on, compared value by value.
*/
@@ -146,26 +146,9 @@ class InlineControlFactory {
container.addEventListener("mousedown", (e) => e.stopPropagation());
container.addEventListener("mouseup", (e) => e.stopPropagation());
button.addEventListener("click", (e) => {
var _a, _b;
e.stopPropagation();
e.preventDefault();
const app = window.app;
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;
}
const widget = node.widgets.find((w) => w.name === config.widgetName);
if (widget && typeof widget.callback === "function") {
try {
widget.callback(widget.value, app.canvas, node, [0, 0], null);
Logger.debug(`[InlineControl] Button ${config.widgetName} clicked successfully`);
} catch (error) {
Logger.warn(`[InlineControl] Button callback failed:`, error);
}
} else {
Logger.warn(`[InlineControl] Widget ${config.widgetName} not found or has no callback`);
}
WidgetSyncManager.clickButton(config.nodeId, config.widgetName);
});
container.appendChild(button);
return {
@@ -41,6 +41,23 @@ class WidgetSyncManager {
return { success: false, error: message };
}
}
/**
* Run a button widget's callback on a node in the graph on screen
*/
static clickButton(nodeId, widgetName) {
var _a, _b, _c;
const node = (_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(nodeId);
const widget = (_c = node == null ? void 0 : node.widgets) == null ? void 0 : _c.find((w) => w.name === widgetName);
if (typeof (widget == null ? void 0 : widget.callback) !== "function") {
Logger.warn(`[WidgetSync] Button ${widgetName} not found on node ${nodeId}`);
return;
}
try {
widget.callback(widget.value, app.canvas, node, [0, 0], null);
} catch (error) {
Logger.warn(`[WidgetSync] Button callback failed:`, error);
}
}
/**
* Let ComfyUI's change tracker pick up the edit as an undo step
*/
+18 -183
View File
@@ -3,7 +3,6 @@ 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 { BROADCAST_CHANNEL_NAME } from "../shared/constants.js";
import { Logger } from "../shared/logger.js";
import { WidgetSyncManager } from "../info-panel/widget-editors/WidgetSyncManager.js";
const SESSION_KEY = "magnifyglass-popout-session";
class PopOutManager {
constructor(getStatus, onConnect) {
@@ -11,8 +10,12 @@ class PopOutManager {
__publicField(this, "isOpen", false);
__publicField(this, "popOutWindow", null);
__publicField(this, "onStateChange", null);
// The Inspector owns the info and decides what the viewer may change
__publicField(this, "onNodeSelect", null);
__publicField(this, "onWidgetEdit", null);
__publicField(this, "onButtonClick", null);
__publicField(this, "onPanelAction", null);
__publicField(this, "onRequestOptions", null);
__publicField(this, "pingInterval", null);
// Pings since the last pong
__publicField(this, "pendingPings", 0);
@@ -70,7 +73,7 @@ class PopOutManager {
* Handle incoming messages from the viewer tab.
*/
handleMessage(message) {
var _a, _b;
var _a, _b, _c, _d;
switch (message.type) {
case "pong":
this.pendingPings = 0;
@@ -94,76 +97,22 @@ class PopOutManager {
this.handleNodeListRequest(message.data);
break;
case "node-select":
if (this.onNodeSelect && typeof message.data === "number") {
this.onNodeSelect(message.data);
}
(_a = this.onNodeSelect) == null ? void 0 : _a.call(this, message.data);
break;
case "zoom-node":
if (typeof message.data === "number") {
const app = window.app;
const node = (_b = (_a = app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(message.data);
if (node) {
app.canvas.centerOnNode(node);
}
}
case "panel-action":
(_b = this.onPanelAction) == null ? void 0 : _b.call(this, message.data.name, message.data.nodeId);
break;
case "widget-edit":
this.handleWidgetEdit(message.data);
(_c = this.onWidgetEdit) == null ? void 0 : _c.call(this, message.data);
break;
case "button-click":
this.handleButtonClick(message.data);
(_d = this.onButtonClick) == null ? void 0 : _d.call(this, message.data);
break;
case "request-options":
if (this.onRequestOptions) {
this.sendMessage({ type: "options", data: { ...message.data, options: this.onRequestOptions(message.data) } });
}
break;
}
}
/**
* Handle widget edit command from pop-out viewer.
* Syncs the edit to the actual node widget on the canvas.
*/
handleWidgetEdit(editData) {
if (!editData || typeof editData.nodeId !== "number" || !editData.widgetName) {
Logger.warn("[PopOut] Invalid widget edit data:", editData);
return;
}
const result = WidgetSyncManager.syncWidgetValue(
editData.nodeId,
editData.widgetName,
editData.value
);
if (result.success) {
Logger.debug(`[PopOut] Widget edit synced: ${editData.widgetName} = ${editData.value}`);
if (this.onWidgetEdit) {
this.onWidgetEdit();
}
} else {
Logger.warn(`[PopOut] Widget edit failed: ${result.error}`);
}
}
/**
* Handle button click command from pop-out viewer.
* 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;
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;
}
const widget = node.widgets.find((w) => w.name === buttonData.widgetName);
if (widget && typeof widget.callback === "function") {
try {
widget.callback(widget.value, app.canvas, node, [0, 0], null);
Logger.debug(`[PopOut] Button ${buttonData.widgetName} clicked successfully`);
} catch (error) {
Logger.warn(`[PopOut] Button callback failed:`, error);
}
} else {
Logger.warn(`[PopOut] Widget ${buttonData.widgetName} not found or has no callback`);
}
}
/**
@@ -250,16 +199,11 @@ class PopOutManager {
this.sendMessage({ type: "config", data: config });
}
/**
* Send inspector info to the pop-out viewer.
* @param info - Inspector panel information
* Send the Inspector's info to the pop-out viewer.
*/
sendInfo(info) {
if (!this.isOpen || !this.channel) return;
const sanitizedInfo = this.sanitizeInfo(info);
this.sendMessage({
type: "info",
data: sanitizedInfo || void 0
});
if (!this.isOpen) return;
this.sendMessage({ type: "info", data: info });
}
/**
* Handle node list request from popout viewer.
@@ -301,115 +245,6 @@ class PopOutManager {
Logger.error("[PopOut] Failed to get node list:", e);
}
}
/**
* Sanitize info object for BroadcastChannel transfer.
* Removes functions, circular references, and non-serializable data.
* Handles both GatheredInfo and PopOutInfo formats.
*/
sanitizeInfo(info) {
var _a, _b, _c;
if (!info) return null;
try {
const sanitized = {};
if (info.hoveredNode) {
sanitized.hoveredNode = {
title: String(info.hoveredNode.title || ""),
type: String(info.hoveredNode.type || ""),
id: info.hoveredNode.id,
mode: info.hoveredNode.mode,
executionOrder: info.hoveredNode.executionOrder,
category: info.hoveredNode.category ? String(info.hoveredNode.category) : void 0,
pythonModule: info.hoveredNode.pythonModule ? String(info.hoveredNode.pythonModule) : void 0,
// Map formatting or raw values
pos: info.hoveredNode.position && typeof info.hoveredNode.position === "object" ? { x: info.hoveredNode.position.x, y: info.hoveredNode.position.y } : String(info.hoveredNode.position || ""),
size: info.hoveredNode.size && typeof info.hoveredNode.size === "object" ? { w: info.hoveredNode.size.width, h: info.hoveredNode.size.height } : String(info.hoveredNode.size || ""),
widgets: this.extractSafeData(info.hoveredNode.widgets),
inputs: this.extractSafeData(info.hoveredNode.inputs),
outputs: this.extractSafeData(info.hoveredNode.outputs),
properties: this.sanitizeOptions(info.hoveredNode.properties) || {}
};
}
if (info.cursor) {
sanitized.cursor = {
canvas: {
x: Number(info.cursor.canvasX || ((_a = info.cursor.canvas) == null ? void 0 : _a.x) || 0),
y: Number(info.cursor.canvasY || ((_b = info.cursor.canvas) == null ? void 0 : _b.y) || 0)
}
};
}
if (info.zoom !== void 0 || info.canvas) {
sanitized.canvas = {
scale: Number(info.zoom || ((_c = info.canvas) == null ? void 0 : _c.scale) || 1)
};
}
const magnifyGlass = window.comfyUIMagnifyGlass;
if (magnifyGlass == null ? void 0 : magnifyGlass.config) {
sanitized.magnifier = {
zoomFactor: Number(magnifyGlass.config.zoomFactor || 1)
};
}
return sanitized;
} catch (e) {
Logger.error("[PopOut] Failed to sanitize info:", e);
return null;
}
}
/**
* Extract specific safe fields from node lists (widgets, inputs, outputs).
* Removes functions like onFloatValueChange which cause cloning errors.
*/
extractSafeData(list) {
if (!list || !Array.isArray(list)) return [];
return list.map((item) => {
if (!item || typeof item !== "object") return {};
const safeItem = {
name: item.name,
type: item.type,
label: item.label
};
if (item.value !== void 0 && typeof item.value !== "function") {
safeItem.value = item.value;
}
if (item.options !== void 0) {
if (Array.isArray(item.options)) {
safeItem.options = item.options.filter(
(opt) => typeof opt !== "function"
);
} else if (typeof item.options === "object" && item.options !== null) {
if (Array.isArray(item.options.values)) {
safeItem.options = item.options.values.filter(
(opt) => typeof opt !== "function"
);
} else {
safeItem.options = this.sanitizeOptions(item.options);
}
}
}
return safeItem;
});
}
/**
* Shallow copy object removing functions.
*/
sanitizeOptions(options) {
if (!options || typeof options !== "object") return {};
const clean = {};
for (const key in options) {
const val = options[key];
if (typeof val !== "function" && !key.startsWith("_")) {
if (typeof val === "object" && val !== null) {
try {
JSON.stringify(val);
clean[key] = val;
} catch (e) {
}
} else {
clean[key] = val;
}
}
}
return clean;
}
/**
* Send a message through the BroadcastChannel.
*/
+210 -560
View File
@@ -660,7 +660,14 @@
border-color: var(--accent-blue);
}
.widget-list {
margin-top: 12px;
}
.widget-card {
padding: 10px 12px;
margin-bottom: 6px;
border-radius: 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
}
@@ -670,16 +677,79 @@
border-color: var(--border-default);
}
.widget-button:hover {
transform: translateY(-1px);
.widget-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.widget-button[data-kind="action"] {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
.widget-name {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.widget-button[data-kind="action"]:hover {
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25);
.widget-type {
font-size: 11px;
color: var(--text-secondary);
}
.widget-value {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.4;
color: var(--accent-blue);
background: var(--bg-tertiary);
padding: 8px 10px;
border-radius: 4px;
word-break: break-word;
white-space: pre-wrap;
}
.value-empty {
color: var(--text-secondary);
font-style: italic;
}
.widget-button {
width: 100%;
padding: 8px 16px;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--bg-primary);
background: var(--accent-blue);
border: none;
border-radius: 6px;
cursor: pointer;
}
.widget-slider {
display: flex;
align-items: center;
gap: 8px;
}
.widget-editor-slider {
flex: 1;
accent-color: var(--accent-blue);
}
.widget-slider-value {
min-width: 50px;
text-align: right;
font-family: monospace;
color: var(--accent-blue);
}
.widget-image {
background: var(--bg-tertiary);
border-radius: 4px;
padding: 8px;
text-align: center;
overflow: hidden;
}
.widget-image img {
@@ -881,6 +951,9 @@
case 'nodes-list':
handleNodesList(message.data);
break;
case 'options':
handleOptions(message.data);
break;
}
};
@@ -911,15 +984,16 @@
if (inspectorChanged) handleInfo(currentInfo);
}
// The latest info, and the node the sidebar was last rendered from
// The latest info, the info the sidebar was last rendered from, and the combo editor waiting for its options
let currentInfo = null;
let renderedNode = null;
let renderedInfo = null;
let pendingOptions = null;
function handleInfo(info) {
currentInfo = info;
// Re-rendering would drop an open editor; closing it renders the latest info
if (sidebarContent.querySelector('.widget-editor-inline')) return;
if (!info || !config.inspector) {
if (!info || !info.node || !config.inspector) {
showEmptyState();
return;
}
@@ -928,38 +1002,14 @@
renderInfo(info);
}
/**
* Send widget edit to main window via BroadcastChannel
*/
function sendWidgetEdit(nodeId, widgetName, value) {
channel.postMessage({
type: 'widget-edit',
data: {
nodeId: nodeId,
widgetName: widgetName,
value: value
}
});
function sendWidgetEdit(row, value) {
channel.postMessage({ type: 'widget-edit', data: { nodeId: renderedInfo.node.id, widgetName: row.widgetName, value } });
}
/**
* Send button click to main window via BroadcastChannel
* This invokes the widget's callback function on the main canvas
*/
function sendButtonClick(nodeId, widgetName) {
channel.postMessage({
type: 'button-click',
data: {
nodeId: nodeId,
widgetName: widgetName
}
});
}
// Widget rows carry their index into renderedNode.widgets; names never go into inline handlers
function widgetFor(element) {
// Rows carry their index into renderedInfo.rows; names never go into inline handlers
function rowFor(element) {
const index = element.dataset.widget;
return renderedNode && index !== undefined ? renderedNode.widgets[Number(index)] : null;
return renderedInfo && index !== undefined ? renderedInfo.rows[Number(index)] : null;
}
sidebarContent.addEventListener('click', (e) => {
@@ -967,21 +1017,18 @@
if (action) {
if (action.dataset.action === 'toggle-section') toggleSection(action);
else if (action.dataset.action === 'node-dropdown') requestNodeDropdown(action.dataset.sort, action);
else if (action.dataset.action === 'zoom-node' && renderedNode) requestZoomToNode(renderedNode.id);
else if (action.dataset.action === 'focus-node' && renderedInfo) {
channel.postMessage({ type: 'panel-action', data: { name: 'focus-node', nodeId: renderedInfo.node.id } });
}
return;
}
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);
const row = element && rowFor(element);
if (!row) return;
if (element.dataset.kind === 'action') {
channel.postMessage({ type: 'button-click', data: { nodeId: renderedInfo.node.id, widgetName: row.widgetName } });
} else if (element.dataset.kind === 'value') {
editWidgetValue(element, row);
}
});
@@ -992,77 +1039,77 @@
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));
const row = slider && rowFor(slider);
if (row) sendWidgetEdit(row, 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';
function handleOptions(data) {
const pending = pendingOptions;
if (!pending || pending.nodeId !== data.nodeId || pending.widgetName !== data.widgetName) return;
pendingOptions = null;
pending.fill(data.options);
}
/**
* 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, widget) {
function editWidgetValue(element, row) {
if (element.querySelector('.widget-editor-inline')) return;
const options = widget.options;
const opts = options && !Array.isArray(options) ? options : {};
const type = (widget.type || 'text').toLowerCase();
const currentValue = widget.value;
const type = row.widgetType;
const constraints = row.constraints;
if (type === 'toggle' || type === 'boolean') {
// Read the CURRENT displayed value from the element text, not the stale parameter
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)';
if (type === 'boolean') {
const newValue = !row.rawValue;
sendWidgetEdit(row, newValue);
element.textContent = String(newValue);
return;
}
let editor;
let readValue;
if (Array.isArray(options) && options.length > 0) {
if (type === 'combo') {
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 addOption = (label) => {
const option = document.createElement('option');
option.textContent = String(label);
editor.appendChild(option);
}
};
addOption('Loading…');
editor.options[0].disabled = true;
editor.options[0].hidden = true;
editor.selectedIndex = 0;
let options = [];
// 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') {
// ComfyUI sends the list only for an open editor, as it can be long
pendingOptions = {
nodeId: renderedInfo.node.id,
widgetName: row.widgetName,
editor,
fill(list) {
if (!list.length) return finish(false);
options = list;
editor.options[0].textContent = 'Select...';
editor.options[0].hidden = true;
list.forEach(addOption);
editor.selectedIndex = 0;
}
};
channel.postMessage({ type: 'request-options', data: { nodeId: pendingOptions.nodeId, widgetName: row.widgetName } });
} else if (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);
if (constraints.min !== undefined) editor.min = String(constraints.min);
if (constraints.max !== undefined) editor.max = String(constraints.max);
editor.step = String(constraints.step ?? (type === 'INT' ? 1 : 'any'));
editor.value = String(row.rawValue);
readValue = () => editor.value === '' || isNaN(parseFloat(editor.value)) ? undefined : parseFloat(editor.value);
} else {
const text = String(currentValue ?? '');
const multiline = type === 'customtext' || opts.multiline || text.includes('\n');
const text = String(row.rawValue ?? '');
const multiline = type === 'multiline' || text.includes('\n');
editor = document.createElement(multiline ? 'textarea' : 'input');
editor.className = 'widget-editor-input';
if (multiline) {
@@ -1075,22 +1122,20 @@
}
let done = false;
const finish = (commit) => {
function finish(commit) {
if (done) return;
done = true;
if (pendingOptions?.editor === editor) pendingOptions = null;
const value = readValue();
element.innerHTML = '';
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);
// Shown until ComfyUI sends the value it kept
sendWidgetEdit(row, value);
element.textContent = String(value);
} else {
element.innerHTML = '';
handleInfo(currentInfo);
}
};
}
if (editor.tagName === 'SELECT') {
editor.addEventListener('change', () => finish(true));
@@ -1118,118 +1163,45 @@
}
function renderInfo(info) {
let html = '';
renderedNode = info.hoveredNode || null;
renderedInfo = info;
const node = info.node;
const isCollapsed = window.collapsedSections?.has('node');
// Cursor section (Now First)
if (info.cursor && info.canvas) {
// Check collapsed state
const isCollapsed = window.collapsedSections?.has('cursor');
html += `
<div class="info-section ${isCollapsed ? 'collapsed' : ''}" data-section-id="cursor">
<div class="section-header" data-action="toggle-section">
<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">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="6"/>
<circle cx="12" cy="12" r="2"/>
<line x1="12" y1="2" x2="12" y2="4"/>
<line x1="12" y1="20" x2="12" y2="22"/>
<line x1="2" y1="12" x2="4" y2="12"/>
<line x1="20" y1="12" x2="22" y2="12"/>
</svg>
<span class="section-title">Cursor</span>
</div>
<div class="section-body">
<div class="info-row">
<span class="info-label">Canvas X</span>
<span class="info-value">${Math.round(info.cursor.canvas.x)}</span>
</div>
<div class="info-row">
<span class="info-label">Canvas Y</span>
<span class="info-value">${Math.round(info.cursor.canvas.y)}</span>
</div>
</div>
sidebarContent.innerHTML = `
<div class="info-section ${isCollapsed ? 'collapsed' : ''}" data-section-id="node">
<div class="section-header" data-action="toggle-section">
<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="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"/>
</svg>
<span class="section-title">Node</span>
<span class="section-badge">${escapeHtml(node.type)}</span>
</div>
`;
}
// Node section (Now Second)
if (info.hoveredNode) {
// Check collapsed state
const isCollapsed = window.collapsedSections?.has('node');
html += `
<div class="info-section ${isCollapsed ? 'collapsed' : ''}" data-section-id="node">
<div class="section-header" data-action="toggle-section">
<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="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"/>
</svg>
<span class="section-title">Node</span>
<span class="section-badge">${escapeHtml(info.hoveredNode.type || 'Unknown')}</span>
<div class="section-body">
<div class="info-row clickable-row" data-action="node-dropdown" data-sort="title">
<span class="info-label">Title</span>
<span class="info-value clickable-value">${escapeHtml(node.title)} (#${escapeHtml(node.id)})<span class="dropdown-indicator">▼</span></span>
</div>
<div class="section-body">
<div class="info-row clickable-row" data-action="node-dropdown" data-sort="title">
<span class="info-label">Title</span>
<span class="info-value clickable-value">${escapeHtml(info.hoveredNode.title || 'Untitled')} (#${escapeHtml(info.hoveredNode.id)})<span class="dropdown-indicator">▼</span></span>
</div>
${info.hoveredNode.executionOrder !== undefined ? `
<div class="info-row clickable-row" data-action="node-dropdown" data-sort="execOrder">
<span class="info-label">Exec Order</span>
<span class="info-value">${escapeHtml(info.hoveredNode.executionOrder)}<span class="dropdown-indicator">▼</span></span>
</div>` : ''}
<div class="info-row clickable-row" data-action="zoom-node">
<span class="info-label">Location</span>
<span class="info-value"><span style="display: inline-flex; align-items: center; gap: 6px; vertical-align: middle;"><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"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg> Focus Node</span></span>
</div>
${info.hoveredNode.category ? `
<div class="info-row">
<span class="info-label">Category</span>
<span class="info-value">${escapeHtml(info.hoveredNode.category)}</span>
</div>` : ''}
${info.hoveredNode.pythonModule ? `
<div class="info-row">
<span class="info-label">Path</span>
<span class="info-value">${escapeHtml(info.hoveredNode.pythonModule)}</span>
</div>` : ''}
<!-- Extended Node Properties -->
${info.hoveredNode.mode !== undefined ? `
<div class="info-row">
<span class="info-label">Mode</span>
<span class="info-value">${escapeHtml(String(info.hoveredNode.mode))}</span>
</div>` : ''}
${info.hoveredNode.size ? `
<div class="info-row">
<span class="info-label">Size</span>
<span class="info-value">${escapeHtml(formatSize(info.hoveredNode.size))}</span>
</div>` : ''}
${info.hoveredNode.pos ? `
<div class="info-row">
<span class="info-label">Pos</span>
<span class="info-value">${escapeHtml(formatPos(info.hoveredNode.pos))}</span>
</div>` : ''}
<!-- Dynamic Lists (Inputs/Outputs/Widgets) -->
${renderList('Inputs', info.hoveredNode.inputs)}
${renderList('Outputs', info.hoveredNode.outputs)}
${renderList('Widgets', info.hoveredNode.widgets, info.hoveredNode.id)}
${node.execOrder !== undefined ? `
<div class="info-row clickable-row" data-action="node-dropdown" data-sort="execOrder">
<span class="info-label">Exec Order</span>
<span class="info-value">${escapeHtml(node.execOrder)}<span class="dropdown-indicator">▼</span></span>
</div>` : ''}
<div class="info-row clickable-row" data-action="focus-node">
<span class="info-label">Location</span>
<span class="info-value">Focus Node</span>
</div>
<div class="info-row">
<span class="info-label">Mode</span>
<span class="info-value">${escapeHtml(node.mode)}</span>
</div>
${renderRows(info)}
</div>
`;
}
if (html) {
sidebarContent.innerHTML = html;
for (const holder of sidebarContent.querySelectorAll('.widget-image')) {
const img = document.createElement('img');
img.src = holder.dataset.src;
holder.appendChild(img);
}
} else {
showEmptyState();
</div>
`;
for (const holder of sidebarContent.querySelectorAll('.widget-image')) {
const img = document.createElement('img');
img.src = holder.dataset.src;
holder.appendChild(img);
}
}
@@ -1248,360 +1220,44 @@
}
}
function formatSize(size) {
if (typeof size === 'object') return `${Math.round(size.w)}×${Math.round(size.h)}`;
return String(size);
}
function formatPos(pos) {
if (typeof pos === 'object') return `${Math.round(pos.x)}, ${Math.round(pos.y)}`;
return String(pos);
}
function renderList(title, items, nodeId = null) {
if (!items || items.length === 0) return '';
// Icon mapping for different list types
const icons = {
'Inputs': `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>`,
'Outputs': `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`,
'Widgets': `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`
};
const icon = icons[title] || '';
// Gradient colors for different types
const gradients = {
'Inputs': 'linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(59, 130, 246, 0.05))',
'Outputs': 'linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(34, 197, 94, 0.05))',
'Widgets': 'linear-gradient(135deg, rgba(168, 85, 247, 0.15), rgba(168, 85, 247, 0.05))'
};
const gradient = gradients[title] || 'linear-gradient(135deg, rgba(100, 100, 100, 0.15), rgba(100, 100, 100, 0.05))';
// Badge colors for different types
const badgeColors = {
'Inputs': '#3b82f6',
'Outputs': '#22c55e',
'Widgets': '#a855f7'
};
const badgeColor = badgeColors[title] || '#888';
// Check if this is the Widgets section and we have a nodeId for editing
const isEditable = title === 'Widgets' && nodeId != null;
const listHtml = items.map((item, index) => {
const name = escapeHtml(item.name || item.label || 'Unknown');
const type = item.type ? escapeHtml(item.type) : 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)
// These are buttons that toggle on/off state
const isButtonType = type && type.toLowerCase() === 'button';
const hasBooleanValue = rawValue === 'true' || rawValue === 'false';
const isToggleButton = isButtonType && hasBooleanValue;
// True action buttons are any button that doesn't have a boolean value
// (button callbacks may set any value, so we can't rely on value being null)
const isActionButton = isButtonType && !hasBooleanValue;
// Check for slider (widgets explicitly typed as 'slider')
const isSlider = type && type.toLowerCase() === 'slider';
// The widget rows as ComfyUI's Inspector computed them; it also decides which can be edited
function renderRows(info) {
if (!info.rows.length) return '';
return `<div class="widget-list">${info.rows.map((row, index) => {
const label = escapeHtml(row.label);
// Values without a primitive raw value, like objects, have no editor that keeps them intact
const editable = row.isEditable && info.nav.canEdit && row.rawValue !== undefined;
// Plot nodes send an <img> with a data URI; only the URI itself is used
const imageSrc = rawValue && rawValue.match(IMAGE_DATA_URI)?.[0];
const value = rawValue ? escapeHtml(rawValue) : null;
// For toggle-style button widgets, render a toggle button with on/off visual indication
if (isToggleButton && nodeId != null) {
const isOn = rawValue === 'true';
const toggleColor = isOn ? 'var(--accent-green, #22c55e)' : 'var(--text-tertiary, #666)';
const toggleBg = isOn ? 'var(--accent-green, #22c55e)' : 'var(--bg-tertiary, #333)';
const toggleText = isOn ? 'ON' : 'OFF';
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">TOGGLE</span>
</div>
<button
id="${itemId}"
class="widget-button"
data-widget="${index}"
data-kind="toggle"
data-state="${isOn}"
style="
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px 16px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background: ${toggleBg};
color: ${isOn ? 'var(--bg-primary, #1a1a1a)' : 'var(--text-secondary, #aaa)'};
border: 2px solid ${isOn ? 'var(--accent-green, #22c55e)' : 'var(--border-default, #555)'};
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
"
>${isOn ? '✓ ON' : '○ OFF'}</button>
const imageSrc = row.value.match(IMAGE_DATA_URI)?.[0];
let body;
if (row.isActionable) {
body = `<button class="widget-button" data-widget="${index}" data-kind="action">${label}</button>`;
} else if (imageSrc) {
body = `<div class="widget-image" data-src="${imageSrc}"></div>`;
} else if (editable && row.widgetType === 'slider') {
const { min = 0, max = 100, step = 0.01 } = row.constraints;
body = `
<div class="widget-slider">
<input type="range" class="widget-editor-slider" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}" value="${escapeHtml(row.rawValue)}" data-widget="${index}">
<span class="widget-slider-value">${escapeHtml(row.value)}</span>
</div>`;
} else {
const shown = row.value === '' ? '<span class="value-empty">empty</span>' : escapeHtml(row.value);
const attrs = editable ? ` data-widget="${index}" data-kind="value" title="Click to edit"`
: row.isEditable && !info.nav.canEdit ? ' title="Lock the node in ComfyUI to edit"' : '';
body = `<div class="widget-value${editable ? ' widget-value-editable' : ''}"${attrs}>${shown}</div>`;
}
// For true action button widgets, render a clickable styled button
if (isActionButton && nodeId != null) {
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">ACTION</span>
</div>
<button
class="widget-button"
data-widget="${index}"
data-kind="action"
style="
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px 16px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background: var(--accent-purple, #a855f7);
color: var(--bg-primary, #1a1a1a);
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
"
>▶ Click</button>
</div>`;
}
// For slider widgets, render inline range control
if (isSlider && nodeId != null) {
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.step2 !== undefined || opts.step !== undefined ? stepFor(opts) : 0.01;
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
${type ? `<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">${type}</span>` : ''}
</div>
<div class="widget-slider-container" style="display: flex; align-items: center; gap: 8px; width: 100%;">
<input type="range"
class="widget-editor-slider"
min="${escapeHtml(min)}"
max="${escapeHtml(max)}"
step="${escapeHtml(step)}"
value="${value}"
style="flex: 1; accent-color: var(--accent-purple); cursor: pointer;"
data-widget="${index}"
/>
<span class="widget-slider-value" style="
min-width: 50px;
text-align: right;
font-family: monospace;
color: var(--accent-blue);
font-weight: 600;
">${value}</span>
</div>
</div>`;
}
if (imageSrc) {
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
${type ? `<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">${type}</span>` : ''}
</div>
<div style="
background: var(--bg-tertiary);
border-radius: 4px;
padding: 8px;
text-align: center;
overflow: hidden;
" class="widget-image" data-src="${imageSrc}"></div>
</div>`;
}
// 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 class="widget-card" style="
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 6px;
transition: all 0.15s ease;
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: ${value ? '6px' : '0'};">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
${type ? `<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.2px;
word-break: break-all;
max-width: 100%;
">${type}</span>` : ''}
${canEditValue ? `<span style="
font-size: 9px;
color: var(--accent-amber);
margin-left: auto;
opacity: 0.7;
">✏️ editable</span>` : ''}
<div class="widget-card">
<div class="widget-head">
<span class="widget-name">${label}</span>
${row.widgetType ? `<span class="widget-type">${escapeHtml(row.widgetType)}</span>` : ''}
${row.isLinked ? '<span class="widget-type" title="Set by a link">linked</span>' : ''}
</div>
${value ? `<div style="position: relative;">
<div id="${itemId}" class="${editableClass}" ${editableClick} ${editableTitle} style="
font-size: 11px;
color: var(--accent-blue);
font-weight: 500;
background: var(--bg-tertiary);
padding: 8px 10px;
border-radius: 4px;
word-break: break-word;
white-space: pre-wrap;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
line-height: 1.4;
${editableStyle}
">${value}</div>
</div>` : ''}
${body}
</div>`;
}).join('');
return `
<div style="
margin-top: 12px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-default);
background: var(--bg-primary);
">
<div style="
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: ${gradient};
border-bottom: 1px solid var(--border-subtle);
">
<span style="color: ${badgeColor}; display: flex; align-items: center;">${icon}</span>
<span style="
font-weight: 600;
font-size: 12px;
color: var(--text-primary);
">${title}</span>
<span style="
font-size: 10px;
padding: 2px 8px;
background: ${badgeColor}33;
color: ${badgeColor};
border-radius: 10px;
font-weight: 600;
margin-left: auto;
">${items.length}</span>
</div>
<div style="padding: 8px;">
${listHtml}
</div>
</div>
`;
}).join('')}</div>`;
}
const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
@@ -1637,12 +1293,6 @@
}
}
function requestZoomToNode(nodeId) {
if (channel) {
channel.postMessage({ type: 'zoom-node', data: nodeId });
}
}
function handleNodesList(data) {
if (!data || !data.nodes || !currentDropdownAnchorRect) return;
showDropdown(data.nodes, data.sortBy);