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.