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:
co-authored by
Claude Opus 5.5
parent
278f8504d8
commit
7852897c1d
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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[] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+16
@@ -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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user