* feat(nodes): let a link drive the prompt text of every PromptManager node The Focused, Split and Pair editors hide their widgets, and ComfyUI only draws a widget's input socket while the widget is visible. `text` (and the Pair's `negative_text`) therefore had no socket, and a STRING link dropped on the editor fell through to the first free STRING input, `prepend_text`, which is how #342's workflow was wired. - Detach those inputs from their hidden widgets on create and after a workflow load, so they are ordinary sockets in Classic and Nodes 2.0. The schema, widget order and saved values are unchanged. - A STRING link dropped on the editor body fills the main prompt, then the negative, before the frontend's own choice. - Each editor says when its text comes from a link; the typed text is kept for disconnect. The Classic nodes now save the typed text under a link instead of null, and "use prompt" no longer overwrites it while linked. - Real-host tests: mouse drags in both renderers, a workflow saved before this change, and executions proving the linked text is saved, counted, recorded in history and linked to the run's image. Closes #343 * test(coverage): floor for the linked-text module * test(nodes): PromptSearchList text takes a dragged link like a core widget * fix(nodes): linked text review round for #343 - A legacy frontend Primitive linked to an editor's text or the Pair's negative now runs with its value: the graphToPrompt wrapper and the queue hook fill it in (through virtual reroutes), in new and pre-#343 workflows. - Moving a linked text and releasing it on the editor keeps it on `text`: a link being moved counts as free, as the frontend counts it. - A link dropped on a prompt box (or its link note) fills that box's input, so the Pair's Negative prompt box takes `negative_text` (Classic through getInputOnPos, Nodes 2.0 through the box's slot key). - Only exact STRING links are steered; wildcards keep the frontend choice. - A linked Classic text box gives its row back and gets it back on disconnect; the PromptManagerText note uses the theme colour. - The metadata parsers map positional widget values through the widget table when an editor's widget-bound inputs do not line up with it. - Real-host cases for the legacy Primitive (new and saved workflows, a real Run), the moved link, the Negative box, paste/clone/undo and Split. * fix(nodes): resolve a legacy Primitive's text like the host, once per run (#343) - Inputs still bound to their widget (Classic PromptManager/PromptManagerText) are left as the host resolved them; the injection no longer overwrites them. - For the editors' detached text inputs, the Primitive's value goes through its "Run widget replace on values" replacement and the hidden widget's own serializer, so {a|b} wildcards and %date% tokens resolve as on main. - Each queued output is filled once (graphToPrompt wrapper, or the queue hook for an output that did not come through it): a wildcard is drawn once. - Editors inside subgraphs are found under their <subgraph>:<inner> id. - Real-host runPrompt installs its request route before the run starts. - Shared parser case pins the widget-table fallback for a core KSampler. * fix(nodes): pin the Classic wildcard behaviour and match queued ids by type (#343) - Classic PromptManager/PromptManagerText expose _pmTextSerializerReady, settled once their own text serializer is installed; the real-host specs wait on it and assert the Classic text is queued as written, as on main. - The Primitive fill only touches an output entry whose class_type matches the graph node at that id. - TESTING.md notes the host's harmless "Invalid widget" warning per Run.
166 lines
7.5 KiB
JavaScript
166 lines
7.5 KiB
JavaScript
// #343: let a link drive the editor nodes' prompt text, the way a link drives any core widget.
|
|
//
|
|
// The Focused, Split and Pair editors hide their widgets to draw their own editor. ComfyUI's frontend draws
|
|
// and hit-tests a widget's input socket only while the widget itself is visible, so `text` (and the Pair's
|
|
// `negative_text`) had no socket, and a STRING link dropped on the editor fell through to the first free
|
|
// STRING input, `prepend_text` (#342). Detaching those inputs from their hidden widgets makes them ordinary
|
|
// sockets in both renderers; the schema, the widgets and their saved values are unchanged.
|
|
|
|
/** What an editor shows in place of a text field whose value comes from a link. */
|
|
export function linkNoteText(label) {
|
|
return `${label} comes from a link. Your typed text is kept for when you disconnect it.`;
|
|
}
|
|
|
|
export function isLinked(node, name) {
|
|
return Boolean(node?.inputs?.some(input => input.name === name && input.link != null));
|
|
}
|
|
|
|
/** Detach the named inputs from their hidden widgets so the frontend shows them as sockets. Returns the names changed. */
|
|
export function exposeTextSockets(node, names) {
|
|
const exposed = [];
|
|
for (const input of node?.inputs || []) {
|
|
if (!names.includes(input.name) || !('widget' in input || input._widget)) continue;
|
|
delete input.widget;
|
|
if ('_widget' in input) input._widget = undefined;
|
|
exposed.push(input.name);
|
|
}
|
|
return exposed;
|
|
}
|
|
|
|
function acceptsString(type) {
|
|
// Only exact STRING links: a wildcard (a legacy Primitive before it is typed, an untyped reroute) keeps the
|
|
// frontend's own choice, and is aimed at a prompt by dropping it on the prompt box or its socket.
|
|
return type != null && String(type).split(',').some(part => part.trim() === 'STRING');
|
|
}
|
|
|
|
function linkOf(graph, id) {
|
|
if (id == null || !graph) return undefined;
|
|
return graph.getLink?.(id) ?? graph.links?.get?.(id) ?? graph.links?.[id];
|
|
}
|
|
|
|
// The frontend's own free test: an input whose link is being moved (`_dragging`), or whose link the graph no
|
|
// longer has, takes a drop. Without a graph only the input's own link field is known.
|
|
function isFree(node, input) {
|
|
if (input.link == null) return true;
|
|
if (!node.graph) return false;
|
|
const link = linkOf(node.graph, input.link);
|
|
return link == null || Boolean(link._dragging);
|
|
}
|
|
|
|
/** The first free text input, in editor order, for a link of `type`; undefined leaves the choice to the frontend. */
|
|
export function preferredTextSlot(node, type, names) {
|
|
if (!acceptsString(type)) return undefined;
|
|
const inputs = node?.inputs || [];
|
|
for (const name of names) {
|
|
const index = inputs.findIndex(input => input.name === name);
|
|
if (index >= 0 && isFree(node, inputs[index])) return {index, slot:inputs[index]};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const MAX_VIRTUAL_HOPS = 32;
|
|
const REPLACE_OPTION = 'Run widget replace on values';
|
|
|
|
/** The legacy frontend Primitive (a virtual node) at the source of a link, following virtual reroutes. */
|
|
function primitiveSource(graph, id) {
|
|
let link = linkOf(graph, id);
|
|
for (let hop = 0; link && hop < MAX_VIRTUAL_HOPS; hop++) {
|
|
const origin = graph.getNodeById?.(link.origin_id);
|
|
if (!origin?.isVirtualNode) return undefined;
|
|
if (origin.type === 'PrimitiveNode') return origin;
|
|
link = linkOf(graph, origin.inputs?.[0]?.link);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** A widget's queued value for `value`: its own serializer (the host's dynamic prompts) without touching its text. */
|
|
function serializeAs(widget, value) {
|
|
if (typeof widget?.serializeValue !== 'function') return value;
|
|
const typed = widget.value;
|
|
widget.value = value;
|
|
try {
|
|
// No node argument: the host would also write the result into the workflow's widgets_values.
|
|
return widget.serializeValue();
|
|
} finally {
|
|
widget.value = typed;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The value a legacy frontend Primitive supplies to a linked editor text input, resolved the way the host resolves a
|
|
* value the Primitive writes into a widget: its text replacement when its "Run widget replace on values" option is on,
|
|
* then the input's own widget serializer (`{red|blue}` becomes one choice). Undefined when the input is unlinked, its
|
|
* source is a real node (serialized as a link), or the input is still bound to its widget, where the host already
|
|
* applied the Primitive (#343 re-review R1).
|
|
*
|
|
* The editor text inputs are no longer bound to their hidden widgets, so the Primitive cannot write into them and the
|
|
* queue path fills the value in instead (#343 review I1). Each call draws a new wildcard choice: call it once per run.
|
|
*/
|
|
export async function primitiveTextValue(node, name, {replaceText} = {}) {
|
|
const input = node?.inputs?.find(item => item.name === name);
|
|
const graph = node?.graph;
|
|
if (input?.link == null || input.widget || !graph) return undefined;
|
|
const primitive = primitiveSource(graph, input.link);
|
|
let value = primitive?.widgets?.[0]?.value;
|
|
if (value == null) return undefined;
|
|
value = String(value);
|
|
if (value && primitive.properties?.[REPLACE_OPTION] && replaceText) {
|
|
try {
|
|
value = replaceText(graph, value);
|
|
} catch (error) {
|
|
console.warn('[PromptManager] text replacement skipped for a linked Primitive', error);
|
|
}
|
|
}
|
|
return serializeAs(node.widgets?.find(widget => widget.name === name), value);
|
|
}
|
|
|
|
/** A link dropped on the editor body (not on a socket) fills the main prompt first, then the negative. */
|
|
export function preferTextInputs(prototype, names) {
|
|
const original = prototype.findInputByType;
|
|
if (typeof original !== 'function') return;
|
|
prototype.findInputByType = function (type, ...rest) {
|
|
return preferredTextSlot(this, type, names) ?? original.call(this, type, ...rest);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The value a workflow stores for a widget. A linked widget keeps what the user typed (its value), so the text
|
|
* returns when the link is disconnected; an unlinked one stores its live editor text.
|
|
*/
|
|
export function storedWidgetValue(node, widget) {
|
|
if (isLinked(node, widget.name)) return widget.value;
|
|
return widget.inputEl ? widget.inputEl.value : widget.value;
|
|
}
|
|
|
|
/** The text input whose visible drop target (a prompt box or its link note) contains a viewport point. */
|
|
export function textInputAtPoint(node, [x, y], targets) {
|
|
for (const {name, element} of targets) {
|
|
const box = element.getBoundingClientRect();
|
|
if (!box.width || !box.height || x < box.left || x > box.right || y < box.top || y > box.bottom) continue;
|
|
const input = node?.inputs?.find(item => item.name === name);
|
|
if (input) return input;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const LAYOUT_OPTIONS = ['getMinHeight','getMaxHeight'];
|
|
|
|
/**
|
|
* While its input is linked, the frontend hides a core text box but keeps its row, leaving an empty gap in the
|
|
* Classic renderer. Give the row back while linked and restore the widget's own sizing on disconnect.
|
|
*/
|
|
export function collapseLinkedWidget(widget, linked) {
|
|
const options = widget?.options;
|
|
if (!options) return;
|
|
if (linked && !widget._pmLinkedLayout) {
|
|
widget._pmLinkedLayout = Object.fromEntries(LAYOUT_OPTIONS.filter(name => name in options).map(name => [name, options[name]]));
|
|
for (const name of LAYOUT_OPTIONS) options[name] = () => 0;
|
|
} else if (!linked && widget._pmLinkedLayout) {
|
|
for (const name of LAYOUT_OPTIONS) {
|
|
if (name in widget._pmLinkedLayout) options[name] = widget._pmLinkedLayout[name];
|
|
else delete options[name];
|
|
}
|
|
delete widget._pmLinkedLayout;
|
|
}
|
|
}
|