Add Inputs and Outputs links to the pop-out viewer

Each slot lists the nodes it links to as chips that jump to them, and flags
required inputs with nothing connected. Links to a subgraph's own input or
output show as plain chips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Æmotion Studio
2026-11-04 10:14:49 -08:00
co-authored by Claude Opus 5.5
parent 278f8504d8
commit 7852897c1d
10 changed files with 342 additions and 41 deletions
+13 -8
View File
@@ -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<string, string>();
@@ -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
};
}
+47 -1
View File
@@ -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 };
}
+3
View File
@@ -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[] };
}
/**
+16
View File
@@ -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<number, ComfyLink>;
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;
}
// ============================================================================
+28 -1
View File
@@ -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]);
+79
View File
@@ -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 }]);
});
});
+45 -8
View File
@@ -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: ['<b>Refine</b>'], 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: '<img src=x onerror="window.__pwned=1">', 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: '<img src=x onerror="window.__pwned = 1">', 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(['<img src=x onerror="window.__pwned = 1"> #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();
+11 -7
View File
@@ -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
};
}
/**
+32 -1
View File
@@ -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
};
+68 -15
View File
@@ -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 = `
<section class="info-section${collapsed ? ' collapsed' : ''}" data-section-id="node">
<button type="button" class="section-header" data-action="toggle-section" aria-expanded="${!collapsed}">
<span class="section-title">Node</span>
<span class="section-chevron">${ICONS.chevronDown}</span>
</button>
<div class="section-body">
${textRow('Mode', node.mode)}
${node.execOrder !== undefined ? textRow('Exec Order', String(node.execOrder)) : ''}
${renderRows(info)}
${!info.nav.canEdit && info.rows.some(row => row.isEditable) ? '<button type="button" class="edit-hint" data-action="lock-node" title="Lock to this node to edit its widgets">Lock to edit</button>' : ''}
</div>
</section>
`;
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) ? '<button type="button" class="edit-hint" data-action="lock-node" title="Lock to this node to edit its widgets">Lock to edit</button>' : ''}`),
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 `
<section class="info-section${collapsed ? ' collapsed' : ''}" data-section-id="${id}">
<button type="button" class="section-header" data-action="toggle-section" aria-expanded="${!collapsed}">
<span class="section-title">${title}</span>
<span class="section-chevron">${ICONS.chevronDown}</span>
</button>
<div class="section-body">${body}</div>
</section>`;
}
// 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 `<span class="link-chip">${escapeHtml(`${link.title} · ${link.slot}`)}</span>`;
const text = `${link.title} #${link.id} · ${link.slot}`;
return `<button type="button" class="link-chip" data-action="jump-to-link" data-node-id="${escapeHtml(link.id)}" title="Go to ${escapeHtml(text)}">${escapeHtml(text)}</button>`;
}).join('');
const value = chips ? `<div class="link-chips">${chips}</div>` : '<span class="value-empty">not connected</span>';
return `
<div class="info-row${slot.missing ? ' has-error' : ''}">
<span class="info-label">${escapeHtml(slot.name)}<span class="widget-type">${escapeHtml(slot.type)}</span></span>
${value}
${slot.missing ? '<span class="row-error">Required, but nothing is connected</span>' : ''}
</div>`;
}).join('');
}
function toggleSection(header) {
const section = header.parentElement;
const collapsed = section.classList.toggle('collapsed');