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.
This commit is contained in:
@@ -5,7 +5,7 @@ import { api } from "../../scripts/api.js";
|
||||
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
|
||||
const { installFocusedNode, installSplitNode, installPairNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
|
||||
const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js'));
|
||||
const { isLinked, linkNoteText, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js'));
|
||||
const { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js'));
|
||||
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
|
||||
|
||||
// ============================================================================
|
||||
@@ -228,6 +228,25 @@ function syncPromptManagerWidgets() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #343: a legacy frontend Primitive linked to a PromptManager text input runs with the Primitive's value.
|
||||
* The Primitive writes into the target's widget, but the editor text inputs are no longer bound to their hidden
|
||||
* widgets, so the queued value would otherwise be the hidden typed text while the editor says "linked".
|
||||
*/
|
||||
function applyPrimitiveText(output) {
|
||||
const nodes = app.graph?._nodes || app.graph?.nodes || [];
|
||||
if (!output) return;
|
||||
for (const node of nodes) {
|
||||
const inputs = promptManagerTypes.has(node.type) ? output[node.id]?.inputs : null;
|
||||
if (!inputs) continue;
|
||||
for (const name of ['text', 'negative_text']) {
|
||||
if (Array.isArray(inputs[name])) continue;
|
||||
const value = primitiveTextValue(node, name);
|
||||
if (value !== undefined) inputs[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "PromptManager.UI",
|
||||
|
||||
@@ -243,7 +262,9 @@ app.registerExtension({
|
||||
const originalGraphToPrompt = app.graphToPrompt.bind(app);
|
||||
app.graphToPrompt = async function(...args) {
|
||||
syncPromptManagerWidgets();
|
||||
return originalGraphToPrompt(...args);
|
||||
const result = await originalGraphToPrompt(...args);
|
||||
applyPrimitiveText(result?.output);
|
||||
return result;
|
||||
};
|
||||
|
||||
// CRITICAL FIX: Intercept api.queuePrompt to fix prompt data at the LAST moment
|
||||
@@ -270,6 +291,7 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
applyPrimitiveText(output);
|
||||
return originalQueuePrompt(number, { output, workflow }, ...rest);
|
||||
};
|
||||
},
|
||||
@@ -503,7 +525,10 @@ app.registerExtension({
|
||||
return note;
|
||||
};
|
||||
nodeType.prototype.renderTextLink = function () {
|
||||
if (this._textLinkNote) this._textLinkNote.hidden = !isLinked(this, "text");
|
||||
const linked = isLinked(this, "text");
|
||||
if (this._textLinkNote) this._textLinkNote.hidden = !linked;
|
||||
collapseLinkedWidget(this.widgets?.find((w) => w.name === "text"), linked);
|
||||
this.setDirtyCanvas?.(true, true);
|
||||
};
|
||||
|
||||
// Override onRemoved to cleanup styles
|
||||
@@ -540,7 +565,7 @@ app.registerExtension({
|
||||
searchSection.style.gap = "8px";
|
||||
|
||||
const linkNote = this.createTextLinkNote();
|
||||
linkNote.style.cssText = "margin: 0; color: #f9a825; font-size: 12px;";
|
||||
linkNote.style.cssText = "margin: 0; color: var(--fg-color); font-size: 12px;";
|
||||
searchSection.appendChild(linkNote);
|
||||
|
||||
// Search buttons row
|
||||
|
||||
@@ -275,7 +275,11 @@
|
||||
|
||||
function widgetNames(n) {
|
||||
const fromInputs = (n.inputs || []).filter((i) => i && i.widget && i.widget.name).map((i) => i.widget.name);
|
||||
if (fromInputs.length) return fromInputs;
|
||||
if (fromInputs.length) {
|
||||
// Socket inputs that are not widget inputs (#343: the editors' text) would shift the positional values.
|
||||
const known = typeof n.type === "string" && Object.prototype.hasOwnProperty.call(WIDGET_POSITIONS, n.type) ? WIDGET_POSITIONS[n.type] : null;
|
||||
return known && fromInputs.some((name, index) => known[index] !== name) ? known : fromInputs;
|
||||
}
|
||||
if (WIDGET_POSITIONS[n.type]) return WIDGET_POSITIONS[n.type];
|
||||
if (/checkpointloader/i.test(n.type || "")) return ["ckpt_name"];
|
||||
if (/unet/i.test(n.type || "")) return ["unet_name"];
|
||||
|
||||
+29
-2
@@ -1,7 +1,7 @@
|
||||
import {createScope, createButton, createField, mountTheme} from './shared/toolkit/core.js';
|
||||
import {createPromptLibrary} from './prompt-library.js';
|
||||
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
|
||||
import {exposeTextSockets, linkNoteText, preferTextInputs} from './text-links.js';
|
||||
import {exposeTextSockets, linkNoteText, preferTextInputs, textInputAtPoint} from './text-links.js';
|
||||
|
||||
const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text'];
|
||||
let nextEditorId = 0;
|
||||
@@ -32,6 +32,8 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
const configure = prototype.configure;
|
||||
const serialize = prototype.serialize;
|
||||
const connections = prototype.onConnectionsChange;
|
||||
const inputOnPos = prototype.getInputOnPos;
|
||||
const added = prototype.onAdded;
|
||||
prototype.onNodeCreated = function (...args) {
|
||||
created?.apply(this, args);
|
||||
const scope = createScope(); this._focusedScope = scope;
|
||||
@@ -67,6 +69,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
const mode = data?.settings?.webui_display_mode;
|
||||
if (!scope.signal.aborted && data?.success && ['popup','newtab'].includes(mode)) webDisplayMode = mode;
|
||||
}).catch(() => {});
|
||||
const dropTargets = [];
|
||||
function textControl(name, label) {
|
||||
const widget = widgets.get(name);
|
||||
const nativeInput = widget.inputEl;
|
||||
@@ -83,6 +86,13 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
field.classList.add('pm-editor-main');
|
||||
const note = document.createElement('p'); note.className = 'pm-editor-status';
|
||||
note.textContent = linkNoteText(label);
|
||||
// The prompt box and its link note are drop targets for this input (#343): Nodes 2.0 resolves a drop
|
||||
// through the nearest `.lg-node-widget` and its `[data-slot-key]`; Classic asks getInputOnPos.
|
||||
for (const target of [field,note]) {
|
||||
const key = document.createElement('span'); key.hidden = true; key.dataset.slotKey = '';
|
||||
target.classList.add('lg-node-widget'); target.append(key);
|
||||
dropTargets.push({name,element:target,key});
|
||||
}
|
||||
return {input,field,note};
|
||||
}
|
||||
const positive = textControl('text',pair ? 'Positive prompt' : 'Main prompt');
|
||||
@@ -147,7 +157,12 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
widget.callback?.(text, app.canvas, thisNode);
|
||||
thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true);
|
||||
}
|
||||
// The node id is assigned when the node joins a graph (and changes on paste), so keys follow it.
|
||||
function syncSlotKeys() {
|
||||
for (const target of dropTargets) target.key.dataset.slotKey = `${thisNode.id}-in-${thisNode.inputs?.findIndex(input => input.name === target.name)}`;
|
||||
}
|
||||
function render() {
|
||||
syncSlotKeys();
|
||||
mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text');
|
||||
if (negative) { negative.field.hidden = Boolean(isConnected('negative_text')); negative.note.hidden = !isConnected('negative_text'); }
|
||||
if (save) { save.disabled = saving || hasConnectedPairValues(); saveConnectionNotice.hidden = !hasConnectedPairValues(); }
|
||||
@@ -218,7 +233,14 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
footer.setAttribute('role',opened ? 'status' : 'alert');
|
||||
footer.textContent = opened ? 'Web UI opened.' : 'Allow popups to open Web UI, then try again.';
|
||||
});
|
||||
this._focusedView = {element,render,restore,snapshot};
|
||||
function inputAt(position) {
|
||||
const canvas = app.canvas;
|
||||
if (canvas?.linkConnector?.state?.connectingTo !== 'input' || !canvas.ds || !canvas.canvas) return undefined;
|
||||
const [x,y] = canvas.ds.convertOffsetToCanvas(position);
|
||||
const box = canvas.canvas.getBoundingClientRect();
|
||||
return textInputAtPoint(thisNode, [box.left + x, box.top + y], dropTargets);
|
||||
}
|
||||
this._focusedView = {element,render,restore,snapshot,inputAt,syncSlotKeys};
|
||||
this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360});
|
||||
this.size = [pair ? 1100 : split ? 900 : 550,pair ? 700 : 650]; this.setSize(this.size); restore();
|
||||
};
|
||||
@@ -235,4 +257,9 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
};
|
||||
prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; };
|
||||
prototype.onRemoved = function (...args) { this._focusedScope?.dispose(); return removed?.apply(this,args); };
|
||||
prototype.onAdded = function (...args) { const result = added?.apply(this,args); this._focusedView?.syncSlotKeys(); return result; };
|
||||
// Classic: a link dropped on a prompt box (or its link note) fills that box's input.
|
||||
prototype.getInputOnPos = function (...args) {
|
||||
return inputOnPos?.apply(this,args) ?? this._focusedView?.inputAt(args[0]);
|
||||
};
|
||||
}
|
||||
|
||||
+76
-3
@@ -28,8 +28,23 @@ export function exposeTextSockets(node, names) {
|
||||
}
|
||||
|
||||
function acceptsString(type) {
|
||||
if (type == null) return false;
|
||||
return String(type).split(',').some(part => ['STRING','*',''].includes(part.trim()));
|
||||
// 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. */
|
||||
@@ -38,7 +53,33 @@ export function preferredTextSlot(node, type, names) {
|
||||
const inputs = node?.inputs || [];
|
||||
for (const name of names) {
|
||||
const index = inputs.findIndex(input => input.name === name);
|
||||
if (index >= 0 && inputs[index].link == null) return {index, slot:inputs[index]};
|
||||
if (index >= 0 && isFree(node, inputs[index])) return {index, slot:inputs[index]};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const MAX_VIRTUAL_HOPS = 32;
|
||||
|
||||
/**
|
||||
* The value a legacy frontend Primitive (a virtual node) supplies to a linked text input, following virtual
|
||||
* reroutes; undefined when the input is unlinked or its source is a real node (serialized as a link).
|
||||
*
|
||||
* A Primitive writes its value into the target's widget before the workflow is queued. The editor text inputs
|
||||
* are no longer bound to their hidden widgets, so the queue path fills the value in instead (#343 review I1).
|
||||
*/
|
||||
export function primitiveTextValue(node, name) {
|
||||
const input = node?.inputs?.find(item => item.name === name);
|
||||
const graph = node?.graph;
|
||||
if (input?.link == null || !graph) return undefined;
|
||||
let link = linkOf(graph, input.link);
|
||||
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') {
|
||||
const value = origin.widgets?.[0]?.value;
|
||||
return value == null ? undefined : String(value);
|
||||
}
|
||||
link = linkOf(graph, origin.inputs?.[0]?.link);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -60,3 +101,35 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user