From bbbbd3e53a22551cb5ee1d41da02ef86f9359786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86motion=20Studio?= <163354043+AEmotionStudio@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:59:55 -0800 Subject: [PATCH] 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 --- src/info-panel/InfoPanel.ts | 188 ++++- src/info-panel/InformationGatherer.ts | 29 +- src/info-panel/UIManager.ts | 25 +- .../widget-editors/InlineControlFactory.ts | 20 +- .../widget-editors/WidgetSyncManager.ts | 17 + src/magnify-glass/PopOutManager.ts | 321 ++------ src/types/comfyui.d.ts | 21 +- tests/unit/InfoPanelLifecycle.test.ts | 145 +++- tests/unit/InformationGatherer.test.ts | 25 - tests/unit/PopOutConnection.test.ts | 27 + tests/unit/PopOutViewer.test.ts | 110 ++- web/info-panel/InfoPanel.js | 175 +++- web/info-panel/InformationGatherer.js | 23 - web/info-panel/UIManager.js | 22 +- .../widget-editors/InlineControlFactory.js | 19 +- .../widget-editors/WidgetSyncManager.js | 17 + web/magnify-glass/PopOutManager.js | 201 +---- web/popout-viewer.html | 770 +++++------------- 18 files changed, 929 insertions(+), 1226 deletions(-) diff --git a/src/info-panel/InfoPanel.ts b/src/info-panel/InfoPanel.ts index 76d93a6..172c2d9 100644 --- a/src/info-panel/InfoPanel.ts +++ b/src/info-panel/InfoPanel.ts @@ -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(); } } diff --git a/src/info-panel/InformationGatherer.ts b/src/info-panel/InformationGatherer.ts index 2a4bd15..6035084 100644 --- a/src/info-panel/InformationGatherer.ts +++ b/src/info-panel/InformationGatherer.ts @@ -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; diff --git a/src/info-panel/UIManager.ts b/src/info-panel/UIManager.ts index 778822a..ce75f4f 100644 --- a/src/info-panel/UIManager.ts +++ b/src/info-panel/UIManager.ts @@ -561,11 +561,9 @@ export class UIManager { 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 === app.canvas?.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?.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; } diff --git a/src/info-panel/widget-editors/InlineControlFactory.ts b/src/info-panel/widget-editors/InlineControlFactory.ts index b98c29e..7e7e544 100644 --- a/src/info-panel/widget-editors/InlineControlFactory.ts +++ b/src/info-panel/widget-editors/InlineControlFactory.ts @@ -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); diff --git a/src/info-panel/widget-editors/WidgetSyncManager.ts b/src/info-panel/widget-editors/WidgetSyncManager.ts index 73fca18..cb4c4ee 100644 --- a/src/info-panel/widget-editors/WidgetSyncManager.ts +++ b/src/info-panel/widget-editors/WidgetSyncManager.ts @@ -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 */ diff --git a/src/magnify-glass/PopOutManager.ts b/src/magnify-glass/PopOutManager.ts index b5e55b2..57b113a 100644 --- a/src/magnify-glass/PopOutManager.ts +++ b/src/magnify-glass/PopOutManager.ts @@ -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; - }; - 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. */ diff --git a/src/types/comfyui.d.ts b/src/types/comfyui.d.ts index d698864..d87319b 100644 --- a/src/types/comfyui.d.ts +++ b/src/types/comfyui.d.ts @@ -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. diff --git a/tests/unit/InfoPanelLifecycle.test.ts b/tests/unit/InfoPanelLifecycle.test.ts index 3ae0f5d..6117f5d 100644 --- a/tests/unit/InfoPanelLifecycle.test.ts +++ b/tests/unit/InfoPanelLifecycle.test.ts @@ -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!; diff --git a/tests/unit/InformationGatherer.test.ts b/tests/unit/InformationGatherer.test.ts index a74643b..0e01de8 100644 --- a/tests/unit/InformationGatherer.test.ts +++ b/tests/unit/InformationGatherer.test.ts @@ -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 diff --git a/tests/unit/PopOutConnection.test.ts b/tests/unit/PopOutConnection.test.ts index 98d12e2..a55fd17 100644 --- a/tests/unit/PopOutConnection.test.ts +++ b/tests/unit/PopOutConnection.test.ts @@ -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 }); + }); }); diff --git a/tests/unit/PopOutViewer.test.ts b/tests/unit/PopOutViewer.test.ts index 492f011..229c9e7 100644 --- a/tests/unit/PopOutViewer.test.ts +++ b/tests/unit/PopOutViewer.test.ts @@ -9,13 +9,21 @@ const script = html.slice(html.indexOf('' describe('Pop-out viewer editing', () => { let channel: { name: string; onmessage: ((e: { data: unknown }) => void) | null; postMessage: ReturnType }; - 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('[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: ` data:image/png;base64,AAAA` }, - { name: 'plot', type: 'text', value: `` }, + row('evil', 'text', ` data:image/png;base64,AAAA`), + row('plot', 'text', ``), ]); 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(); diff --git a/web/info-panel/InfoPanel.js b/web/info-panel/InfoPanel.js index 9607418..8aa1fd1 100644 --- a/web/info-panel/InfoPanel.js +++ b/web/info-panel/InfoPanel.js @@ -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) { diff --git a/web/info-panel/InformationGatherer.js b/web/info-panel/InformationGatherer.js index ff45883..f3a1eba 100644 --- a/web/info-panel/InformationGatherer.js +++ b/web/info-panel/InformationGatherer.js @@ -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; diff --git a/web/info-panel/UIManager.js b/web/info-panel/UIManager.js index d49f794..2e5c234 100644 --- a/web/info-panel/UIManager.js +++ b/web/info-panel/UIManager.js @@ -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. */ diff --git a/web/info-panel/widget-editors/InlineControlFactory.js b/web/info-panel/widget-editors/InlineControlFactory.js index 852b9e9..cf38e2a 100644 --- a/web/info-panel/widget-editors/InlineControlFactory.js +++ b/web/info-panel/widget-editors/InlineControlFactory.js @@ -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 { diff --git a/web/info-panel/widget-editors/WidgetSyncManager.js b/web/info-panel/widget-editors/WidgetSyncManager.js index 08561aa..284cffc 100644 --- a/web/info-panel/widget-editors/WidgetSyncManager.js +++ b/web/info-panel/widget-editors/WidgetSyncManager.js @@ -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 */ diff --git a/web/magnify-glass/PopOutManager.js b/web/magnify-glass/PopOutManager.js index 2fa0289..d305829 100644 --- a/web/magnify-glass/PopOutManager.js +++ b/web/magnify-glass/PopOutManager.js @@ -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. */ diff --git a/web/popout-viewer.html b/web/popout-viewer.html index fc1267e..28240eb 100644 --- a/web/popout-viewer.html +++ b/web/popout-viewer.html @@ -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 += ` -
-
- - - - - - - - - - Cursor -
-
-
- Canvas X - ${Math.round(info.cursor.canvas.x)} -
-
- Canvas Y - ${Math.round(info.cursor.canvas.y)} -
-
+ sidebarContent.innerHTML = ` +
+
+ + + + Node + ${escapeHtml(node.type)}
- `; - } - - // Node section (Now Second) - if (info.hoveredNode) { - // Check collapsed state - const isCollapsed = window.collapsedSections?.has('node'); - - html += ` -
-
- - - - Node - ${escapeHtml(info.hoveredNode.type || 'Unknown')} +
+
+ Title + ${escapeHtml(node.title)} (#${escapeHtml(node.id)})▼
-
-
- Title - ${escapeHtml(info.hoveredNode.title || 'Untitled')} (#${escapeHtml(info.hoveredNode.id)})▼ -
- - ${info.hoveredNode.executionOrder !== undefined ? ` -
- Exec Order - ${escapeHtml(info.hoveredNode.executionOrder)}▼ -
` : ''} - -
- Location - Focus Node -
- ${info.hoveredNode.category ? ` -
- Category - ${escapeHtml(info.hoveredNode.category)} -
` : ''} - ${info.hoveredNode.pythonModule ? ` -
- Path - ${escapeHtml(info.hoveredNode.pythonModule)} -
` : ''} - - - ${info.hoveredNode.mode !== undefined ? ` -
- Mode - ${escapeHtml(String(info.hoveredNode.mode))} -
` : ''} - ${info.hoveredNode.size ? ` -
- Size - ${escapeHtml(formatSize(info.hoveredNode.size))} -
` : ''} - ${info.hoveredNode.pos ? ` -
- Pos - ${escapeHtml(formatPos(info.hoveredNode.pos))} -
` : ''} - - - ${renderList('Inputs', info.hoveredNode.inputs)} - ${renderList('Outputs', info.hoveredNode.outputs)} - ${renderList('Widgets', info.hoveredNode.widgets, info.hoveredNode.id)} + ${node.execOrder !== undefined ? ` +
+ Exec Order + ${escapeHtml(node.execOrder)}▼ +
` : ''} +
+ Location + Focus Node
+
+ Mode + ${escapeHtml(node.mode)} +
+ ${renderRows(info)}
- `; - } - - 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(); +
+ `; + 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': ``, - 'Outputs': ``, - 'Widgets': `` - }; - 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 `
${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 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 ` -
-
- ${name} - TOGGLE -
- + const imageSrc = row.value.match(IMAGE_DATA_URI)?.[0]; + let body; + if (row.isActionable) { + body = ``; + } else if (imageSrc) { + body = `
`; + } else if (editable && row.widgetType === 'slider') { + const { min = 0, max = 100, step = 0.01 } = row.constraints; + body = ` +
+ + ${escapeHtml(row.value)}
`; + } else { + const shown = row.value === '' ? 'empty' : 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 = `
${shown}
`; } - - // For true action button widgets, render a clickable styled button - if (isActionButton && nodeId != null) { - return ` -
-
- ${name} - ACTION -
- -
`; - } - - // 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 ` -
-
- ${name} - ${type ? `${type}` : ''} -
-
- - ${value} -
-
`; - } - - if (imageSrc) { - return ` -
-
- ${name} - ${type ? `${type}` : ''} -
-
-
`; - } - - // 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 ` -
-
- ${name} - ${type ? `${type}` : ''} - ${canEditValue ? `✏️ editable` : ''} +
+
+ ${label} + ${row.widgetType ? `${escapeHtml(row.widgetType)}` : ''} + ${row.isLinked ? 'linked' : ''}
- ${value ? `
-
${value}
-
` : ''} + ${body}
`; - }).join(''); - - return ` -
-
- ${icon} - ${title} - ${items.length} -
-
- ${listHtml} -
-
- `; + }).join('')}
`; } const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; @@ -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);