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:
co-authored by
Claude Opus 5.5
parent
76bb34da1d
commit
bbbbd3e53a
+157
-31
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Vendored
+6
-15
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user