diff --git a/src/info-panel/InfoPanel.ts b/src/info-panel/InfoPanel.ts index a162382..ce29ce9 100644 --- a/src/info-panel/InfoPanel.ts +++ b/src/info-panel/InfoPanel.ts @@ -14,7 +14,7 @@ import { PositionManager } from './PositionManager'; import { EventManager } from './EventManager'; import { InformationGatherer } from './InformationGatherer'; import { CanvasHighlighter } from './CanvasHighlighter'; -import { getImportantNodeParameters, type ParameterItem } from './NodeDataExtractor'; +import { getImportantNodeParameters, getSlotLinks, type ParameterItem } from './NodeDataExtractor'; import { WidgetSyncManager } from './widget-editors/WidgetSyncManager'; type PopOutNav = PopOutInfo['nav']; @@ -342,11 +342,15 @@ export class InfoPanel { ...this.uiManager.getHistoryNav(), canEdit: this.uiManager.canEdit() }; - // A failed validation replaces lastNodeErrors; a run replaces the node's results and previews + // A failed validation replaces lastNodeErrors; a run replaces the node's results and previews. + // Links change in place, and so do the titles of the nodes they link to. const node = info.hoveredNode; + const liveNode = node && this.uiManager.nodeSelector.getNodeById(node.id); + const links = liveNode ? getSlotLinks(liveNode) : { inputs: [], outputs: [] }; const key = [ ...this.uiManager.getRenderKey(info), ...Object.values(nav), app.lastNodeErrors, - node && app.nodeOutputs[node.locatorId]?.images, node && app.nodePreviewImages[node.locatorId] + node && app.nodeOutputs[node.locatorId]?.images, node && app.nodePreviewImages[node.locatorId], + JSON.stringify(links) ]; const sent = this.popOutKey; if (sent && key.length === sent.length && key.every((v, i) => Object.is(v, sent[i]))) return; @@ -354,14 +358,14 @@ export class InfoPanel { this.popOutNodeId = node?.id ?? null; this.popOutGraph = app.canvas?.graph; - this.magnifyGlass.popOutManager!.sendInfo(this.buildPopOutInfo(node, nav)); + this.magnifyGlass.popOutManager!.sendInfo(this.buildPopOutInfo(node, nav, links)); } /** - * The pop-out payload: the node's identity and place, and its widget rows as plain data. + * The pop-out payload: the node's identity and place, its widget rows, outputs and links as plain data. */ - private buildPopOutInfo(node: NodeInfo | null, nav: PopOutNav): PopOutInfo { - if (!node) return { node: null, nav, rows: [], outputs: [] }; + private buildPopOutInfo(node: NodeInfo | null, nav: PopOutNav, links: PopOutInfo['links']): PopOutInfo { + if (!node) return { node: null, nav, rows: [], outputs: [], links }; // The last queued prompt's validation errors, by the input they name const errors = new Map(); @@ -400,7 +404,8 @@ export class InfoPanel { error: row.widgetName ? errors.get(row.widgetName) : undefined }; }), - outputs: this.popOutOutputs(node.locatorId) + outputs: this.popOutOutputs(node.locatorId), + links }; } diff --git a/src/info-panel/NodeDataExtractor.ts b/src/info-panel/NodeDataExtractor.ts index c113bb9..a5b30ae 100644 --- a/src/info-panel/NodeDataExtractor.ts +++ b/src/info-panel/NodeDataExtractor.ts @@ -6,7 +6,7 @@ */ import { formatWidgetValue } from './ValueFormatter'; -import type { NodeInfo } from '../types/comfyui'; +import type { ComfyNode, ComfySlot, NodeInfo } from '../types/comfyui'; import { WidgetSyncManager, type WidgetConstraints } from './widget-editors/WidgetSyncManager'; /** @@ -78,3 +78,49 @@ export function getImportantNodeParameters(nodeInfo: NodeInfo): ParameterItem[] return parameters; } + +/** + * A slot and the nodes at the other end of its links. + */ +export interface SlotLinks { + name: string; + type: string; + /** No id when the other end is the subgraph's own input or output */ + linked: { id?: number; title: string; slot: string }[]; + /** Inputs only: the node requires it and nothing is connected */ + missing?: boolean; +} + +// LiteGraph's ids for a subgraph's own input and output nodes +const SUBGRAPH_INPUT_ID = -10; +const SUBGRAPH_OUTPUT_ID = -20; + +const slotName = (slot: ComfySlot | undefined) => slot ? slot.label || slot.localized_name || slot.name : ''; + +/** + * The node's inputs and outputs with the nodes they link to. Inputs a widget sets are left out until they are linked. + */ +export function getSlotLinks(node: ComfyNode): { inputs: SlotLinks[]; outputs: SlotLinks[] } { + const graph = node.graph!; + const required = (node.constructor as any).nodeData?.input?.required ?? {}; + const inputs = (node.inputs ?? []).flatMap((input, slot) => { + if (input.widget && input.link == null) return []; + const link = input.link == null ? undefined : graph.links.get(input.link); + const origin = node.getInputNode(slot); + let linked: SlotLinks['linked'] = []; + if (link?.origin_id === SUBGRAPH_INPUT_ID) linked = [{ title: 'Subgraph input', slot: slotName(graph.inputs?.[link.origin_slot]) }]; + else if (link && origin) linked = [{ id: origin.id, title: origin.title || 'Untitled', slot: slotName(origin.outputs?.[link.origin_slot]) }]; + return [{ name: slotName(input), type: input.type, linked, missing: input.link == null && input.name in required }]; + }); + const outputs = (node.outputs ?? []).map(output => ({ + name: slotName(output), + type: output.type, + linked: (output.links ?? []).flatMap(id => { + const link = graph.links.get(id); + if (link?.target_id === SUBGRAPH_OUTPUT_ID) return [{ title: 'Subgraph output', slot: slotName(graph.outputs?.[link.target_slot]) }]; + const target = link && graph.getNodeById(link.target_id); + return link && target ? [{ id: target.id, title: target.title || 'Untitled', slot: slotName(target.inputs?.[link.target_slot]) }] : []; + }) + })); + return { inputs, outputs }; +} diff --git a/src/magnify-glass/PopOutManager.ts b/src/magnify-glass/PopOutManager.ts index 9af2871..1ab3389 100644 --- a/src/magnify-glass/PopOutManager.ts +++ b/src/magnify-glass/PopOutManager.ts @@ -8,6 +8,7 @@ import { BROADCAST_CHANNEL_NAME } from '../shared/constants'; import { Logger } from '../shared/logger'; import type { NodeListEntry } from '../info-panel/NodeSelector'; +import type { SlotLinks } from '../info-panel/NodeDataExtractor'; import type { ComfyApp, ComfyGraph, ComfyNode } from '../types/comfyui'; declare const app: ComfyApp; @@ -108,6 +109,8 @@ export interface PopOutInfo { rows: PopOutRow[]; /** The node's images as /view or blob URLs: its live previews while it runs, otherwise its last results */ outputs: string[]; + /** The node's slots and the nodes they link to, which the viewer jumps to with node-select */ + links: { inputs: SlotLinks[]; outputs: SlotLinks[] }; } /** diff --git a/src/types/comfyui.d.ts b/src/types/comfyui.d.ts index 464fcea..4218550 100644 --- a/src/types/comfyui.d.ts +++ b/src/types/comfyui.d.ts @@ -169,6 +169,10 @@ export interface ComfyGraph { rootGraph: ComfyGraph; /** Subgraphs only */ name?: string; + /** Subgraphs only: the slots of its own input and output nodes */ + inputs?: ComfySlot[]; + outputs?: ComfySlot[]; + links: Map; getNodeById(id: number): ComfyNode | null; getNodeOnPos(x: number, y: number): ComfyNode | null; getGroupOnPos(x: number, y: number): { title: string } | undefined; @@ -192,6 +196,7 @@ export interface ComfyNode { /** Subgraph nodes only */ subgraph?: ComfyGraph; isSubgraphNode?(): boolean; + getInputNode(slot: number): ComfyNode | null; } export interface ComfyWidget { @@ -206,9 +211,20 @@ export interface ComfyWidget { export interface ComfySlot { name: string; + label?: string; + localized_name?: string; type: string; link?: number | null; links?: number[]; + /** Inputs a widget sets until they are linked */ + widget?: { name: string }; +} + +export interface ComfyLink { + origin_id: number; + origin_slot: number; + target_id: number; + target_slot: number; } // ============================================================================ diff --git a/tests/unit/InfoPanelLifecycle.test.ts b/tests/unit/InfoPanelLifecycle.test.ts index 76b3503..d3617b0 100644 --- a/tests/unit/InfoPanelLifecycle.test.ts +++ b/tests/unit/InfoPanelLifecycle.test.ts @@ -734,7 +734,9 @@ describe('Info Panel lifecycle', () => { beforeEach(() => { nodes = [makeNode(1, 'Loader'), makeNode(2, 'Sampler')]; - graph = { _nodes: nodes, getNodeById: (id: number) => nodes.find(n => n.id === id) ?? null }; + graph = { _nodes: nodes, links: new Map(), getNodeById: (id: number) => nodes.find(n => n.id === id) ?? null, getGroupOnPos: () => undefined }; + graph.rootGraph = graph; + for (const node of nodes) Object.assign(node, { graph, getInputNode: () => null }); Object.assign((window as any).app.canvas, { graph, node_over: nodes[0], selected_nodes: {}, centerOnNode: vi.fn() }); Object.assign((window as any).app, { nodeOutputs: {}, nodePreviewImages: {} }); (mg as any).state = { active: true, x: 0, y: 0, canvasScale: 1 }; @@ -943,6 +945,31 @@ describe('Info Panel lifecycle', () => { expect(last().outputs).toEqual(['/api/view?filename=f.webp&subfolder=&type=output&rand=4']); }); + it('sends the links of the node it shows, and again when a link or a linked node changes in place', () => { + // Sampler's output drives Loader's model widget + graph.links.set(4, { origin_id: 2, origin_slot: 0, target_id: 1, target_slot: 0 }); + nodes[1].outputs = [{ name: 'MODEL', type: 'MODEL', links: [4] }]; + nodes[0].outputs = [{ name: 'LATENT', type: 'LATENT', links: [] }]; + nodes[0].getInputNode = () => nodes[1]; + vi.advanceTimersByTime(250); + expect(last().links).toEqual({ + inputs: [{ name: 'model', type: 'COMBO', linked: [{ id: 2, title: 'Sampler', slot: 'MODEL' }], missing: false }], + outputs: [{ name: 'LATENT', type: 'LATENT', linked: [] }] + }); + const count = sent().length; + + // LiteGraph adds links to an output's array, and renames nodes, without replacing anything + graph.links.set(5, { origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 }); + nodes[0].outputs[0].links.push(5); + vi.advanceTimersByTime(250); + expect(last().links.outputs).toEqual([{ name: 'LATENT', type: 'LATENT', linked: [{ id: 2, title: 'Sampler', slot: 'model' }] }]); + nodes[1].title = 'Refiner'; + vi.advanceTimersByTime(250); + expect(last().links.inputs[0].linked[0].title).toBe('Refiner'); + vi.advanceTimersByTime(1000); + expect(sent()).toHaveLength(count + 2); + }); + it('searches nodes like the Go to node picker', () => { expect(popOut().onSearchNodes('load')).toEqual([{ id: 1, title: 'Loader', type: 'KSampler', order: 1 }]); expect(popOut().onSearchNodes('#2').map((n: any) => n.id)).toEqual([2]); diff --git a/tests/unit/NodeDataExtractor.test.ts b/tests/unit/NodeDataExtractor.test.ts new file mode 100644 index 0000000..c0c6a75 --- /dev/null +++ b/tests/unit/NodeDataExtractor.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { getSlotLinks } from '../../src/info-panel/NodeDataExtractor'; + +describe('getSlotLinks', () => { + // Nodes and links as LiteGraph keeps them; getInputNode follows an input's link to its origin + const makeGraph = () => { + const graph: any = { _nodes: [], links: new Map() }; + graph.getNodeById = (id: number) => graph._nodes.find((n: any) => n.id === id) ?? null; + return graph; + }; + const addNode = (graph: any, id: number, title: string, inputs: any[] = [], outputs: any[] = [], required: string[] = []) => { + class Node { + static nodeData = { input: { required: Object.fromEntries(required.map(name => [name, ['IMAGE']])) } }; + id = id; + title = title; + graph = graph; + inputs = inputs; + outputs = outputs; + getInputNode(slot: number) { + const link = graph.links.get(this.inputs[slot].link); + return link ? graph.getNodeById(link.origin_id) : null; + } + } + const node = new Node(); + graph._nodes.push(node); + return node; + }; + const link = (graph: any, id: number, origin: any, originSlot: number, target: any, targetSlot: number) => { + graph.links.set(id, { origin_id: origin.id, origin_slot: originSlot, target_id: target.id, target_slot: targetSlot }); + (origin.outputs[originSlot].links ??= []).push(id); + target.inputs[targetSlot].link = id; + }; + + it('names the nodes at the other end of each link, flags required inputs with nothing connected, and leaves out unlinked widget inputs', () => { + const graph = makeGraph(); + const loader = addNode(graph, 1, 'Load Checkpoint', [], [ + { name: 'MODEL', type: 'MODEL', links: null }, + { name: 'CLIP', type: 'CLIP', localized_name: 'CLIP', links: null } + ]); + const sampler = addNode(graph, 2, 'KSampler', [ + { name: 'model', type: 'MODEL', link: null }, + { name: 'positive', type: 'CONDITIONING', label: 'Positive prompt', link: null }, + { name: 'latent_image', type: 'LATENT', link: null }, + { name: 'seed', type: 'INT', widget: { name: 'seed' }, link: null }, + { name: 'cfg', type: 'FLOAT', widget: { name: 'cfg' }, link: null } + ], [], ['model', 'positive', 'latent_image']); + const encode = addNode(graph, 3, '', [{ name: 'clip', type: 'CLIP', link: null }]); + const prim = addNode(graph, 4, 'CFG', [], [{ name: 'FLOAT', type: 'FLOAT', links: null }]); + link(graph, 10, loader, 0, sampler, 0); + link(graph, 11, loader, 1, encode, 0); + link(graph, 12, prim, 0, sampler, 4); + // A subgraph's own input and output nodes are not in graph._nodes + graph.inputs = [{ name: 'positive', label: 'Prompt', type: 'CONDITIONING' }]; + graph.outputs = [{ name: 'LATENT', type: 'LATENT' }]; + sampler.inputs[1].link = 13; + graph.links.set(13, { origin_id: -10, origin_slot: 0, target_id: 2, target_slot: 1 }); + + expect(getSlotLinks(sampler as any)).toEqual({ + inputs: [ + { name: 'model', type: 'MODEL', linked: [{ id: 1, title: 'Load Checkpoint', slot: 'MODEL' }], missing: false }, + { name: 'Positive prompt', type: 'CONDITIONING', linked: [{ title: 'Subgraph input', slot: 'Prompt' }], missing: false }, + { name: 'latent_image', type: 'LATENT', linked: [], missing: true }, + { name: 'cfg', type: 'FLOAT', linked: [{ id: 4, title: 'CFG', slot: 'FLOAT' }], missing: false } + ], + outputs: [] + }); + + link(graph, 14, loader, 0, addNode(graph, 5, 'Upscale', [{ name: 'model', label: 'Model in', type: 'MODEL', link: null }]), 0); + loader.outputs[0].links.push(15); + graph.links.set(15, { origin_id: 1, origin_slot: 0, target_id: -20, target_slot: 0 }); + expect(getSlotLinks(loader as any).outputs).toEqual([ + { name: 'MODEL', type: 'MODEL', linked: [{ id: 2, title: 'KSampler', slot: 'model' }, { id: 5, title: 'Upscale', slot: 'Model in' }, { title: 'Subgraph output', slot: 'LATENT' }] }, + { name: 'CLIP', type: 'CLIP', linked: [{ id: 3, title: 'Untitled', slot: 'clip' }] } + ]); + // Without a node definition, such as a subgraph node, nothing is required + const subgraphNode = { id: 6, title: 'Subgraph', graph, inputs: [{ name: 'image', type: 'IMAGE', link: null }], outputs: [], getInputNode: () => null }; + expect(getSlotLinks(subgraphNode as any).inputs).toEqual([{ name: 'image', type: 'IMAGE', linked: [], missing: false }]); + }); +}); diff --git a/tests/unit/PopOutViewer.test.ts b/tests/unit/PopOutViewer.test.ts index 35eeee7..ebd2815 100644 --- a/tests/unit/PopOutViewer.test.ts +++ b/tests/unit/PopOutViewer.test.ts @@ -22,9 +22,10 @@ describe('Pop-out viewer editing', () => { }); const node = (id: unknown = 5) => ({ id, title: 'Node', type: 'Node', mode: 'Always', executionId: String(id), graphPath: [], isSubgraph: false }); const nav = (canEdit = true) => ({ source: 'locked', locked: canEdit, canBack: false, canForward: false, canEdit }); + const links = { inputs: [], outputs: [] }; const sendInfo = (list: any[], canEdit = true) => { rows = list; - channel.onmessage!({ data: { type: 'info', data: { node: node(), nav: nav(canEdit), rows } } }); + channel.onmessage!({ data: { type: 'info', data: { node: node(), nav: nav(canEdit), rows, links } } }); }; const receive = (type: string, data?: unknown) => channel.onmessage!({ data: { type, data } }); const navButton = (action: string) => document.querySelector(`.node-nav [data-action="${action}"]`) as HTMLButtonElement; @@ -241,7 +242,7 @@ describe('Pop-out viewer editing', () => { it('node ids stay text, and navigation posts the shown node through delegated listeners', () => { const id = `1); window.__pwned = (1`; - channel.onmessage!({ data: { type: 'info', data: { node: node(id), nav: nav(false), rows: [] } } }); + channel.onmessage!({ data: { type: 'info', data: { node: node(id), nav: nav(false), rows: [], links } } }); expect(document.querySelector('[onclick]')).toBeNull(); expect(document.querySelector('.node-id')!.textContent).toBe(`#${id}`); @@ -259,7 +260,7 @@ describe('Pop-out viewer editing', () => { channel.onmessage!({ data: { type: 'info', data: { node: { ...node(), title: 'Sampler', type: 'KSampler', graphPath: ['Refine'], group: 'Sampling', isSubgraph: true }, - nav: { source: 'hover', locked: false, canBack: true, canForward: false, canEdit: false }, rows: [row('seed', 'INT', 1)] + nav: { source: 'hover', locked: false, canBack: true, canForward: false, canEdit: false }, rows: [row('seed', 'INT', 1)], links } } }); expect([title(), document.querySelector('.node-id')!.textContent, document.querySelector('.node-badge')!.textContent]).toEqual(['Sampler', '#5', 'KSampler']); expect([navButton('back').disabled, navButton('forward').disabled, navButton('lock-node').getAttribute('aria-pressed')]).toEqual([false, true, 'false']); @@ -421,7 +422,7 @@ describe('Pop-out viewer editing', () => { expect([badge().textContent, bar()[0]]).toEqual(['Done', 'Idle']); // Another node shown takes its own state key(input!, 'Escape'); - receive('info', { node: node(9), nav: nav(), rows: [] }); + receive('info', { node: node(9), nav: nav(), rows: [], links }); expect(badge().hidden).toBe(true); }); @@ -432,7 +433,7 @@ describe('Pop-out viewer editing', () => { const error = { nodeId: '7:5', title: '', type: 'KSampler', message: '', traceback: 'Traceback:\n line 1\n' }; expect(banner.hidden).toBe(true); - receive('info', { node: { ...node(5), executionId: '7:5' }, nav: nav(), rows: [] }); + receive('info', { node: { ...node(5), executionId: '7:5' }, nav: nav(), rows: [], links }); receive('run', { queue: 0, nodeId: null, title: '', value: 0, max: 0, cached: [], done: ['7:5'], error }); expect(banner.hidden).toBe(false); expect(banner.querySelector('.error-text')!.textContent).toBe(`KSampler #7:5 ${error.message}`); @@ -459,7 +460,7 @@ describe('Pop-out viewer editing', () => { const strip = document.querySelector('.node-outputs') as HTMLElement; const thumbs = () => [...strip.querySelectorAll('.output-thumb')] as HTMLButtonElement[]; const large = document.querySelector('.output-image') as HTMLImageElement; - const outputs = (id: number, urls: string[]) => receive('info', { node: node(id), nav: nav(), rows: [], outputs: urls }); + const outputs = (id: number, urls: string[]) => receive('info', { node: node(id), nav: nav(), rows: [], outputs: urls, links }); receive('ping'); (document.querySelector('[data-view="glass"]') as HTMLElement).click(); @@ -484,10 +485,10 @@ describe('Pop-out viewer editing', () => { expect(large.getAttribute('src')).toBe('blob:http://127.0.0.1/preview'); // New previews leave the rows as they are - receive('info', { node: node(5), nav: nav(), rows: [row('seed', 'INT', 1)], outputs: ['blob:http://127.0.0.1/a'] }); + receive('info', { node: node(5), nav: nav(), rows: [row('seed', 'INT', 1)], outputs: ['blob:http://127.0.0.1/a'], links }); const rows = sidebar().innerHTML; const seed = valueOf('seed'); - receive('info', { node: node(5), nav: nav(), rows: [row('seed', 'INT', 1)], outputs: ['blob:http://127.0.0.1/b'] }); + receive('info', { node: node(5), nav: nav(), rows: [row('seed', 'INT', 1)], outputs: ['blob:http://127.0.0.1/b'], links }); expect(valueOf('seed')).toBe(seed); expect(sidebar().innerHTML).toBe(rows); expect(large.getAttribute('src')).toBe('blob:http://127.0.0.1/b'); @@ -511,6 +512,42 @@ describe('Pop-out viewer editing', () => { expect(main.dataset.view).toBe('output'); }); + it('lists the inputs and outputs with a chip for each linked node, which jumps to it, and flags required inputs left unconnected', () => { + const section = (id: string) => sidebar().querySelector(`[data-section-id="${id}"]`); + const chips = (id: string) => [...section(id)!.querySelectorAll('.link-chip')] as HTMLButtonElement[]; + receive('info', { node: node(5), nav: nav(), rows: [], outputs: [], links: { + inputs: [ + { name: 'model', type: 'MODEL', linked: [{ id: 1, title: '', slot: 'MODEL' }], missing: false }, + { name: 'latent_image', type: 'LATENT', linked: [], missing: true }, + { name: 'mask', type: 'MASK', linked: [], missing: false } + ], + outputs: [{ name: 'LATENT', type: 'LATENT', linked: [{ id: 8, title: 'Decode', slot: 'samples' }, { id: 9, title: 'Preview', slot: 'images' }, { title: 'Subgraph output', slot: 'latent' }] }] + } }); + + expect(chips('inputs').map(chip => chip.textContent)).toEqual([' #1 · MODEL']); + expect(sidebar().querySelector('img')).toBeNull(); + expect(chips('outputs').map(chip => chip.textContent)).toEqual(['Decode #8 · samples', 'Preview #9 · images', 'Subgraph output · latent']); + expect(chips('outputs').map(chip => chip.tagName)).toEqual(['BUTTON', 'BUTTON', 'SPAN']); + const inputs = [...section('inputs')!.querySelectorAll('.info-row')]; + expect(inputs.map(row => row.classList.contains('has-error'))).toEqual([false, true, false]); + expect(inputs[1].querySelector('.row-error')!.textContent).toBe('Required, but nothing is connected'); + expect(inputs[2].querySelector('.value-empty')!.textContent).toBe('not connected'); + + // node-select locks and centres the node in ComfyUI + chips('outputs')[2].click(); + chips('outputs')[1].click(); + chips('inputs')[0].click(); + expect(posted('node-select')).toEqual([9, 1]); + expect((window as any).__pwned).toBeUndefined(); + + (section('outputs')!.querySelector('.section-header') as HTMLElement).click(); + expect(JSON.parse(localStorage.getItem('magnifyglass-viewer-layout')!).collapsed).toEqual(['outputs']); + + // A node without slots has neither section + receive('info', { node: node(6), nav: nav(), rows: [], outputs: [], links }); + expect([section('node'), section('inputs'), section('outputs')].map(Boolean)).toEqual([true, false, false]); + }); + it('shows the empty state for no node, with no Cursor section', () => { channel.onmessage!({ data: { type: 'info', data: { node: null, nav: nav(false), rows: [] } } }); expect(sidebar().querySelector('.empty-state')).not.toBeNull(); diff --git a/web/info-panel/InfoPanel.js b/web/info-panel/InfoPanel.js index 6bb6bd7..90e5a8a 100644 --- a/web/info-panel/InfoPanel.js +++ b/web/info-panel/InfoPanel.js @@ -8,7 +8,7 @@ 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 { getSlotLinks, getImportantNodeParameters } from "./NodeDataExtractor.js"; import { WidgetSyncManager } from "./widget-editors/WidgetSyncManager.js"; class InfoPanel { constructor(magnifyGlass) { @@ -270,26 +270,29 @@ class InfoPanel { canEdit: this.uiManager.canEdit() }; const node = info.hoveredNode; + const liveNode = node && this.uiManager.nodeSelector.getNodeById(node.id); + const links = liveNode ? getSlotLinks(liveNode) : { inputs: [], outputs: [] }; const key = [ ...this.uiManager.getRenderKey(info), ...Object.values(nav), app.lastNodeErrors, node && ((_a = app.nodeOutputs[node.locatorId]) == null ? void 0 : _a.images), - node && app.nodePreviewImages[node.locatorId] + node && app.nodePreviewImages[node.locatorId], + JSON.stringify(links) ]; 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 = (node == null ? void 0 : node.id) ?? null; this.popOutGraph = (_b = app.canvas) == null ? void 0 : _b.graph; - this.magnifyGlass.popOutManager.sendInfo(this.buildPopOutInfo(node, nav)); + this.magnifyGlass.popOutManager.sendInfo(this.buildPopOutInfo(node, nav, links)); } /** - * The pop-out payload: the node's identity and place, and its widget rows as plain data. + * The pop-out payload: the node's identity and place, its widget rows, outputs and links as plain data. */ - buildPopOutInfo(node, nav) { + buildPopOutInfo(node, nav, links) { var _a, _b, _c; - if (!node) return { node: null, nav, rows: [], outputs: [] }; + if (!node) return { node: null, nav, rows: [], outputs: [], links }; const errors = /* @__PURE__ */ new Map(); for (const error of ((_b = (_a = app.lastNodeErrors) == null ? void 0 : _a[node.executionId]) == null ? void 0 : _b.errors) ?? []) { const input = (_c = error.extra_info) == null ? void 0 : _c.input_name; @@ -324,7 +327,8 @@ class InfoPanel { error: row.widgetName ? errors.get(row.widgetName) : void 0 }; }), - outputs: this.popOutOutputs(node.locatorId) + outputs: this.popOutOutputs(node.locatorId), + links }; } /** diff --git a/web/info-panel/NodeDataExtractor.js b/web/info-panel/NodeDataExtractor.js index 7535a54..8c388f8 100644 --- a/web/info-panel/NodeDataExtractor.js +++ b/web/info-panel/NodeDataExtractor.js @@ -39,6 +39,37 @@ function getImportantNodeParameters(nodeInfo) { } return parameters; } +const SUBGRAPH_INPUT_ID = -10; +const SUBGRAPH_OUTPUT_ID = -20; +const slotName = (slot) => slot ? slot.label || slot.localized_name || slot.name : ""; +function getSlotLinks(node) { + var _a, _b; + const graph = node.graph; + const required = ((_b = (_a = node.constructor.nodeData) == null ? void 0 : _a.input) == null ? void 0 : _b.required) ?? {}; + const inputs = (node.inputs ?? []).flatMap((input, slot) => { + var _a2, _b2; + if (input.widget && input.link == null) return []; + const link = input.link == null ? void 0 : graph.links.get(input.link); + const origin = node.getInputNode(slot); + let linked = []; + if ((link == null ? void 0 : link.origin_id) === SUBGRAPH_INPUT_ID) linked = [{ title: "Subgraph input", slot: slotName((_a2 = graph.inputs) == null ? void 0 : _a2[link.origin_slot]) }]; + else if (link && origin) linked = [{ id: origin.id, title: origin.title || "Untitled", slot: slotName((_b2 = origin.outputs) == null ? void 0 : _b2[link.origin_slot]) }]; + return [{ name: slotName(input), type: input.type, linked, missing: input.link == null && input.name in required }]; + }); + const outputs = (node.outputs ?? []).map((output) => ({ + name: slotName(output), + type: output.type, + linked: (output.links ?? []).flatMap((id) => { + var _a2, _b2; + const link = graph.links.get(id); + if ((link == null ? void 0 : link.target_id) === SUBGRAPH_OUTPUT_ID) return [{ title: "Subgraph output", slot: slotName((_a2 = graph.outputs) == null ? void 0 : _a2[link.target_slot]) }]; + const target = link && graph.getNodeById(link.target_id); + return link && target ? [{ id: target.id, title: target.title || "Untitled", slot: slotName((_b2 = target.inputs) == null ? void 0 : _b2[link.target_slot]) }] : []; + }) + })); + return { inputs, outputs }; +} export { - getImportantNodeParameters + getImportantNodeParameters, + getSlotLinks }; diff --git a/web/popout-viewer.html b/web/popout-viewer.html index 64c340e..37d8a57 100644 --- a/web/popout-viewer.html +++ b/web/popout-viewer.html @@ -894,6 +894,32 @@ overflow-wrap: anywhere; } + .link-chips { + display: flex; + flex-wrap: wrap; + gap: 0.25em; + min-width: 0; + } + + .link-chip { + max-width: 100%; + padding: 0 0.375em; + overflow: hidden; + border: 1px solid var(--mg-stroke); + border-radius: 0.25rem; + background: transparent; + white-space: nowrap; + text-overflow: ellipsis; + } + + span.link-chip { + color: var(--mg-muted); + } + + button.link-chip:hover { + background: var(--mg-control-hover); + } + .edit-hint { margin: 0.375em 0.75em; padding: 0.125em 0.5em; @@ -1621,6 +1647,8 @@ toggleExpanded(action); } else if (name === 'toggle-section') { toggleSection(action); + } else if (name === 'jump-to-link') { + channel.postMessage({ type: 'node-select', data: Number(action.dataset.nodeId) }); } else if (name === 'show-output') { outputIndex = Number(action.dataset.output); setView('output'); @@ -1908,23 +1936,17 @@ function renderInfo(info) { const node = info.node; - const collapsed = collapsedSections.has('node'); copyTexts = []; - sidebarContent.innerHTML = ` -
- -
- ${textRow('Mode', node.mode)} - ${node.execOrder !== undefined ? textRow('Exec Order', String(node.execOrder)) : ''} - ${renderRows(info)} - ${!info.nav.canEdit && info.rows.some(row => row.isEditable) ? '' : ''} -
-
- `; + sidebarContent.innerHTML = [ + section('node', 'Node', ` + ${textRow('Mode', node.mode)} + ${node.execOrder !== undefined ? textRow('Exec Order', String(node.execOrder)) : ''} + ${renderRows(info)} + ${!info.nav.canEdit && info.rows.some(row => row.isEditable) ? '' : ''}`), + info.links.inputs.length ? section('inputs', 'Inputs', renderSlots(info.links.inputs)) : '', + info.links.outputs.length ? section('outputs', 'Outputs', renderSlots(info.links.outputs)) : '' + ].join(''); for (const holder of sidebarContent.querySelectorAll('.widget-image')) { const img = document.createElement('img'); img.src = holder.dataset.src; @@ -1932,6 +1954,37 @@ } } + function section(id, title, body) { + const collapsed = collapsedSections.has(id); + return ` +
+ +
${body}
+
`; + } + + // A slot and a chip for each node at the other end of its links; a chip jumps to its node. + // A link to the subgraph's own input or output has no node to jump to. + function renderSlots(slots) { + return slots.map(slot => { + const chips = slot.linked.map(link => { + if (link.id === undefined) return `${escapeHtml(`${link.title} · ${link.slot}`)}`; + const text = `${link.title} #${link.id} · ${link.slot}`; + return ``; + }).join(''); + const value = chips ? `` : 'not connected'; + return ` +
+ ${escapeHtml(slot.name)}${escapeHtml(slot.type)} + ${value} + ${slot.missing ? 'Required, but nothing is connected' : ''} +
`; + }).join(''); + } + function toggleSection(header) { const section = header.parentElement; const collapsed = section.classList.toggle('collapsed');