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:
Vito Sansevero
2026-10-25 15:08:28 -07:00
parent d5d7f5e33d
commit 2fadd765f6
15 changed files with 924 additions and 36 deletions
+32 -8
View File
@@ -3870,25 +3870,49 @@ as it drives any core widget. The Focused, Split and Pair editors hide their wid
and the frontend only draws a widget's socket while the widget is visible, so
`web/js/text-links.js` detaches those two inputs from their hidden widgets. They become
ordinary sockets; the schema, widget order and saved `widgets_values` do not change. A
STRING link dropped on the editor body fills the main prompt first, then the negative,
before the frontend's own choice (it used to pick `prepend_text`, which caused #342).
link dropped on a prompt box (or its link note) fills that box's input: Nodes 2.0 resolves
it through the box's `.lg-node-widget` and `[data-slot-key]`, Classic through the node's
`getInputOnPos`. An exact STRING link dropped elsewhere on the editor fills the main prompt
first, then the negative, before the frontend's own choice (it used to pick `prepend_text`,
which caused #342). A link being moved off a text input counts as free, as the frontend
counts it, so dropping it back on the editor keeps it on `text`. Wildcard links (a
frontend Primitive before it is typed) are not steered.
The frontend's legacy Primitive is virtual: it writes its value into the target widget,
which the editor text no longer is. The `graphToPrompt` wrapper and the queue hook put
the Primitive's value (through virtual reroutes) into the queued `text`/`negative_text`,
so a Primitive drives the text as before, also in workflows saved before #343. A linked
Classic text box gives its canvas row back while linked (the core keeps an empty gap) and
gets its size back on disconnect. The metadata parsers map a known node's positional
widget values through its widget table when its widget-bound inputs do not line up with
it (the editors' text inputs are sockets now).
Not covered, by ruling: linking a subgraph input to an inner editor's `text` gives the
subgraph a plain STRING socket, not a promoted typeable widget as a core widget input
would. The socket was unreachable before #343, so nothing regressed.
While linked, each editor says the value comes from a link, and the typed text is kept
in the workflow for when the link is disconnected. That includes the Classic nodes,
which used to save `null` under a link.
- `tests/js/text-links.test.js`: socket exposure, drop preference, stored values.
- `tests/js/text-links.test.js`: socket exposure, drop preference (exact STRING, moved
links), box hit-test, Primitive values through reroutes, stored values, the Classic row.
- `tests/test_generation_params.py` (`workflow-only-detached-*` cases, with JS parity
vectors): editor text inputs without widget bindings keep their positional values.
- `tests/browser/{focused,pair,classic}-node.spec.mjs`: the linked note, typed text
kept through save and configure (also with a workflow's widget binding restored), the
Classic "use prompt" guard while linked.
- `tests/comfyui/text-link.spec.mjs` (both startup projects): a real mouse drag of a
STRING output onto each node's prompt box (and onto the Pair's negative socket); the
link, the note and the typed text survive renderer switches both ways, a reload,
disconnect and node removal. `fixtures/workflow-before-343.json` was serialized by
the pre-#343 code on the pinned host and must load unchanged.
disconnect and node removal. `fixtures/workflow-before-343.json` and
`workflow-before-343-primitive.json` were serialized by the pre-#343 code on the pinned
host and must load unchanged; the second one's legacy Primitives keep driving the text
through a real Run. Also: a Primitive dropped on the Main prompt box and a string on the
Negative prompt box, a moved link released on the editor body, and paste, clone and undo.
- `tests/comfyui/execution-text-link.spec.mjs` (execution host): workflows built by the
real frontend through the sockets, with text from `PrimitiveString` or through
`PreviewAny`, for PromptManager, PromptManagerText, Focused and the Pair (positive,
negative, both, neither linked). The linked text is saved, counted once, recorded as
real frontend through the sockets, with text from `PrimitiveString`, the legacy
Primitive or through `PreviewAny`, for PromptManager, PromptManagerText, Focused, Split
and the Pair (positive, negative, both, neither linked, and a Primitive negative). The linked text is saved, counted once, recorded as
the run's raw positive/negative, and after a scan the run's image links to it. Text
only known at run time (`PreviewAny`) cannot be read from the image; the case only
requires that it is never linked to another prompt (its attribution from the recorded
+17
View File
@@ -147,6 +147,23 @@ test('paired editor offers positive and negative link sockets and keeps both typ
await expect(positive).toHaveValue('Typed positive');await expect(negative).toHaveValue('Typed negative');
});
test('a link dropped on either prompt box, or its note, fills that box (#343 review M1)',async({page})=>{
const keys=await page.evaluate(()=>[...document.querySelectorAll('.pm-pair .lg-node-widget [data-slot-key]')].map(key=>key.dataset.slotKey));
// Nodes 2.0 resolves a drop through the nearest .lg-node-widget and its slot key: positive box and note, negative box and note.
expect(keys).toEqual(['1-in-3','1-in-3','1-in-4','1-in-4']);
const at=(name,connecting=true)=>page.evaluate(({name,connecting})=>{
fixtureApp.canvas={linkConnector:{state:{connectingTo:connecting?'input':undefined}},ds:{convertOffsetToCanvas:point=>point},canvas:{getBoundingClientRect:()=>({left:0,top:0})}};
const box=[...document.querySelectorAll('.pm-pair .lg-node-widget')].find(element=>element.getClientRects().length&&element.textContent.includes(name)).getBoundingClientRect();
return node.getInputOnPos([box.left+box.width/2,box.top+box.height/2])?.name??null;
},{name,connecting});
expect(await at('Negative prompt')).toBe('negative_text');
expect(await at('Positive prompt')).toBe('text');
expect(await at('Negative prompt',false)).toBeNull();
await page.evaluate(()=>{node.inputs.find(input=>input.name==='negative_text').link=4;node.onConnectionsChange();});
expect(await at('Negative prompt comes from a link')).toBe('negative_text');
expect(await page.evaluate(()=>{fixtureApp.canvas={};return node.getInputOnPos([1,1])??null;})).toBeNull();
});
test('queue hook synchronizes both editors, preserves links and forwards branch execution arguments',async({page})=>{
await draft(page);
const args=await page.evaluate(async()=>{
+25 -9
View File
@@ -13,8 +13,9 @@ import { installHostHooks } from './host-fixture.mjs';
test.beforeEach(async ({ execution }) => { expect(execution.databasePath).toBeTruthy(); });
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerPair', 'PMHistoryInputs']);
// `primitive`: a core string node the queue-time resolver follows. `preview`: PrimitiveString -> PreviewAny, the
// shape of the maintainer's TextGenerate -> PreviewAny chain, whose text is only known when the node runs.
// `primitive`: a core string node the queue-time resolver follows. `legacy`: the frontend's virtual Primitive node,
// whose value is queued as literal text. `preview`: PrimitiveString -> PreviewAny, the shape of the maintainer's
// TextGenerate -> PreviewAny chain, whose text is only known when the node runs.
function build(page, { kind, positive, negative, prefix }) {
return page.evaluate(async ({ kind, positive, negative, prefix }) => {
const { app } = await import('/scripts/app.js');
@@ -29,6 +30,13 @@ function build(page, { kind, positive, negative, prefix }) {
if (negative) set('negative_text', negative.typed);
const drive = (name, side, y) => {
if (!side.via) return;
if (side.via === 'legacy') {
// The frontend's own Primitive: a virtual node whose value is applied when the workflow is queued.
const primitive = add('PrimitiveNode', 0, y);
connect(primitive, 0, prompt, name);
primitive.widgets[0].value = side.text;
return;
}
const text = add('PrimitiveString', 0, y);
text.widgets.find(widget => widget.name === 'value').value = side.text;
let source = text;
@@ -121,14 +129,17 @@ async function expectImagesLinked(execution, evidence, viaRuntime) {
const unique = label => `${label} ${randomUUID()}`;
for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused']) {
for (const via of ['primitive', 'preview']) {
test(`${kind} saves, counts, records and links images to text linked from ${via === 'preview' ? 'PreviewAny' : 'a primitive'}`, async ({ execution, page }) => {
const sources = { primitive: 'PrimitiveString', legacy: 'a legacy Primitive', preview: 'PreviewAny' };
for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit']) {
for (const via of Object.keys(sources)) {
test(`${kind} saves, counts, records and links images to text linked from ${sources[via]}`, async ({ execution, page }) => {
test.setTimeout(90000);
const text = unique(`Linked ${kind} ${via}`);
const evidence = await run(execution, page, { kind, positive: { typed: 'Typed under the link', text, via } });
const source = evidence.output[evidence.promptNode].inputs.text;
expect(Array.isArray(source), 'the frontend queued the link, not the typed text').toBe(true);
// A real node is queued as a link; the virtual Primitive as its own value, never the hidden typed text.
if (via === 'legacy') expect(source).toBe(text);
else expect(Array.isArray(source), 'the frontend queued the link, not the typed text').toBe(true);
expect(evidence.context.positive.raw).toBe(text);
expect(evidence.context.positive.effective).toBe(text);
expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }]);
@@ -139,16 +150,21 @@ for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused'
}
}
for (const [label, linked] of [['positive', ['text']], ['negative', ['negative_text']], ['both', ['text', 'negative_text']], ['neither', []]]) {
for (const [label, linked, negativeVia] of [['positive', ['text'], 'primitive'], ['negative', ['negative_text'], 'primitive'],
['both', ['text', 'negative_text'], 'primitive'], ['neither', [], 'primitive'], ['negative from a legacy Primitive', ['negative_text'], 'legacy']]) {
test(`PromptManagerPair with ${label} linked saves, counts and records the pair and links its images`, async ({ execution, page }) => {
test.setTimeout(90000);
const sides = {
text: { typed: unique('Typed positive'), text: unique('Linked positive'), via: linked.includes('text') ? 'preview' : null },
negative_text: { typed: unique('Typed negative'), text: unique('Linked negative'), via: linked.includes('negative_text') ? 'primitive' : null },
negative_text: { typed: unique('Typed negative'), text: unique('Linked negative'), via: linked.includes('negative_text') ? negativeVia : null },
};
const evidence = await run(execution, page, { kind: 'PromptManagerPair', positive: sides.text, negative: sides.negative_text });
const expected = name => sides[name].via ? sides[name].text : sides[name].typed;
for (const name of ['text', 'negative_text']) expect(Array.isArray(evidence.output[evidence.promptNode].inputs[name]), name).toBe(linked.includes(name));
for (const name of ['text', 'negative_text']) {
const queuedValue = evidence.output[evidence.promptNode].inputs[name];
if (sides[name].via === 'legacy') expect(queuedValue, name).toBe(sides[name].text);
else expect(Array.isArray(queuedValue), name).toBe(linked.includes(name));
}
expect(evidence.context.positive.raw).toBe(expected('text'));
expect(evidence.context.negative.raw).toBe(expected('negative_text'));
expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }, { role: 'negative', run_count: 0 }, { role: 'pair', run_count: 1 }]);
@@ -0,0 +1,379 @@
{
"id": "37b6baef-94f5-49d2-9b42-1ffc70e7da92",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "PromptManagerFocused",
"pos": [
600,
0
],
"size": [
550,
650
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "prepend_text",
"name": "prepend_text",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "append_text",
"name": "append_text",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 1
},
{
"localized_name": "category",
"name": "category",
"shape": 7,
"type": "STRING",
"widget": {
"name": "category"
},
"link": null
},
{
"localized_name": "tags",
"name": "tags",
"shape": 7,
"type": "STRING",
"widget": {
"name": "tags"
},
"link": null
},
{
"localized_name": "search_text",
"name": "search_text",
"shape": 7,
"type": "STRING",
"widget": {
"name": "search_text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"title": "Focused fed by a Primitive",
"properties": {
"Node name for S&R": "PromptManagerFocused",
"focusedUI": {
"libraryOpen": false,
"libraryFilters": {
"category": "",
"tags": ""
},
"composition": false,
"metadata": false,
"preview": false
}
},
"widgets_values": [
"Primitive focused text",
"",
"",
"",
"",
"",
""
],
"widgets_values_named": {
"text": "Primitive focused text",
"category": "",
"tags": "",
"search_text": "",
"prepend_text": "",
"append_text": "",
"prompt_manager_focused_ui": ""
}
},
{
"id": 2,
"type": "PromptManagerPair",
"pos": [
600,
1200
],
"size": [
1100,
700
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "prepend_text",
"name": "prepend_text",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "append_text",
"name": "append_text",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
},
{
"localized_name": "negative_text",
"name": "negative_text",
"type": "STRING",
"widget": {
"name": "negative_text"
},
"link": 2
},
{
"localized_name": "category",
"name": "category",
"shape": 7,
"type": "STRING",
"widget": {
"name": "category"
},
"link": null
},
{
"localized_name": "tags",
"name": "tags",
"shape": 7,
"type": "STRING",
"widget": {
"name": "tags"
},
"link": null
},
{
"localized_name": "search_text",
"name": "search_text",
"shape": 7,
"type": "STRING",
"widget": {
"name": "search_text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "positive_text",
"name": "positive_text",
"type": "STRING",
"links": null
},
{
"localized_name": "negative_text",
"name": "negative_text",
"type": "STRING",
"links": null
}
],
"title": "Pair negative fed by a Primitive",
"properties": {
"Node name for S&R": "PromptManagerPair",
"pairUI": {
"libraryOpen": true,
"libraryFilters": {
"category": "",
"tags": ""
},
"composition": false,
"metadata": false,
"preview": false
}
},
"widgets_values": [
"Typed pair positive",
"Primitive pair negative",
"",
"",
"",
"",
"",
""
],
"widgets_values_named": {
"text": "Typed pair positive",
"negative_text": "Primitive pair negative",
"category": "",
"tags": "",
"search_text": "",
"prepend_text": "",
"append_text": "",
"prompt_manager_focused_ui": ""
}
},
{
"id": 3,
"type": "PrimitiveNode",
"pos": [
0,
0
],
"size": [
210,
88
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"widget": {
"name": "text"
},
"links": [
1
]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
"Primitive focused text"
],
"widgets_values_named": {
"text": "Primitive focused text"
}
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [
0,
1200
],
"size": [
210,
88
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"widget": {
"name": "negative_text"
},
"links": [
2
]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
"Primitive pair negative"
],
"widgets_values_named": {
"negative_text": "Primitive pair negative"
}
}
],
"links": [
[
1,
3,
0,
1,
3,
"STRING"
],
[
2,
4,
0,
2,
4,
"STRING"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.9,
"offset": [
416,
110
]
}
},
"version": 0.4
}
+131
View File
@@ -140,6 +140,137 @@ for (const type of ['PromptManagerFocused', 'PromptManagerSplit', 'PromptManager
});
}
async function addEditors(page) {
return page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; };
const focused = add('PromptManagerFocused', 420, 40);
focused.setSize([560, 640]);
const pair = add('PromptManagerPair', 1020, 40);
pair.setSize([1000, 640]);
for (const [node, name, value] of [[focused, 'text', 'Typed focused'], [pair, 'text', 'Typed positive'], [pair, 'negative_text', 'Typed negative']]) {
const widget = node.widgets.find(item => item.name === name);
widget.value = value;
if (widget.inputEl) widget.inputEl.value = value;
}
const sources = [0, 1].map(index => add(index ? 'PrimitiveString' : 'PrimitiveNode', 20, 60 + index * 300).id);
app.canvas.ds.scale = .7;
// Clear of the floating graph menu (top left) and the PM run bar (top right).
app.canvas.ds.offset = [110, 150];
app.canvas.setDirty(true, true);
return { focused: focused.id, pair: pair.id, primitive: sources[0], string: sources[1] };
});
}
/** The prompt a Run sends to the server, through the real app.queuePrompt path. */
async function runPrompt(page) {
const sent = new Promise(resolve => page.route('**/prompt', async route => {
if (route.request().method() !== 'POST') return route.fallback();
resolve(route.request().postDataJSON().prompt);
return route.fulfill({ json: { prompt_id: 'captured', number: 1, node_errors: {} } });
}));
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); await app.queuePrompt(0, 1); });
const prompt = await sent;
await page.unroute('**/prompt');
return prompt;
}
const box = (page, name, nth = 0) => page.getByRole('textbox', { name, exact: true }).nth(nth);
test('a legacy Primitive dropped on an editor box or socket drives the text it shows as linked (#343 review I1, M1)', async ({ page }) => {
test.setTimeout(90000);
const ids = await addEditors(page);
// A wildcard Primitive aimed at the Main prompt box, a PrimitiveString aimed at the Negative prompt box.
await drag(page, await slotPosition(page, ids.primitive, 'out', 0), center(await box(page, 'Main prompt').boundingBox()));
await expect.poll(() => linkedInputs(page, ids.focused)).toEqual(['text']);
await drag(page, await slotPosition(page, ids.string, 'out', 0), center(await box(page, 'Negative prompt').boundingBox()));
await expect.poll(() => linkedInputs(page, ids.pair)).toEqual(['negative_text']);
await expect(page.getByText(note('Main prompt'), { exact: true })).toBeVisible();
await expect(page.getByText(note('Negative prompt'), { exact: true })).toBeVisible();
await page.evaluate(async ({ primitive, string }) => {
const { app } = await import('/scripts/app.js');
app.graph.getNodeById(primitive).widgets[0].value = 'From the legacy Primitive';
app.graph.getNodeById(string).widgets.find(widget => widget.name === 'value').value = 'From PrimitiveString';
}, ids);
const prompt = await runPrompt(page);
expect(prompt[String(ids.focused)].inputs.text).toBe('From the legacy Primitive');
expect(prompt[String(ids.pair)].inputs).toMatchObject({ text: 'Typed positive', negative_text: [String(ids.string), 0] });
// The Primitive is re-read on every run; the typed text stays for disconnect.
await page.evaluate(async ({ primitive }) => { const { app } = await import('/scripts/app.js'); app.graph.getNodeById(primitive).widgets[0].value = 'Changed in the Primitive'; }, ids);
expect((await queued(page, ids.focused)).text).toBe('Changed in the Primitive');
await page.evaluate(async ({ focused }) => { const { app } = await import('/scripts/app.js'); const node = app.graph.getNodeById(focused); node.disconnectInput(node.inputs.findIndex(input => input.name === 'text')); }, ids);
expect((await queued(page, ids.focused)).text).toBe('Typed focused');
});
test('a workflow saved before #343 with legacy Primitives on editor text keeps running their values (#343 review I1)', async ({ page }) => {
const workflow = JSON.parse(await readFile(new URL('./fixtures/workflow-before-343-primitive.json', import.meta.url), 'utf8'));
const result = await page.evaluate(async workflow => {
const { app } = await import('/scripts/app.js');
await app.loadGraphData(structuredClone(workflow));
const before = (await app.graphToPrompt()).output;
app.graph.getNodeById(4).widgets[0].value = 'Edited after the load';
const after = (await app.graphToPrompt()).output;
const bound = id => app.graph.getNodeById(id).inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length;
return { before: [before['1'].inputs.text, before['2'].inputs.text, before['2'].inputs.negative_text], after: after['2'].inputs.negative_text, bound: [bound(1), bound(2)],
links: [app.graph.getNodeById(1).inputs.find(input => input.name === 'text').link != null, app.graph.getNodeById(2).inputs.find(input => input.name === 'negative_text').link != null] };
}, workflow);
expect(result).toEqual({ before: ['Primitive focused text', 'Typed pair positive', 'Primitive pair negative'], after: 'Edited after the load', bound: [0, 0], links: [true, true] });
const prompt = await runPrompt(page);
expect([prompt['1'].inputs.text, prompt['2'].inputs.negative_text]).toEqual(['Primitive focused text', 'Edited after the load']);
});
test('moving a linked text and dropping it on the editor body keeps it on text, never prepend_text (#343 review I2)', async ({ page }) => {
test.setTimeout(90000);
const ids = await addEditors(page);
await page.evaluate(async ({ focused, pair, string }) => {
const { app } = await import('/scripts/app.js');
const source = app.graph.getNodeById(string);
for (const id of [focused, pair]) { const node = app.graph.getNodeById(id); source.connect(0, node, node.inputs.findIndex(input => input.name === 'text')); }
app.canvas.setDirty(true, true);
}, ids);
for (const [id, title] of [[ids.focused, 0], [ids.pair, 1]]) {
// Grab the link at the text socket and release it over the editor's toolbar, away from any prompt box.
const toolbar = page.locator('.pm-editor-toolbar strong').nth(title);
await drag(page, await slotPosition(page, id, 'in', 'text'), center(await toolbar.boundingBox()));
await expect.poll(() => linkedInputs(page, id)).toEqual(['text']);
}
});
test('paste, clone and undo keep the editor text a linked socket (#343 review M7)', async ({ page }) => {
const ids = await addEditors(page);
const state = await page.evaluate(async ({ pair, string }) => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(pair);
app.graph.getNodeById(string).connect(0, node, node.inputs.findIndex(input => input.name === 'text'));
const describe = target => ({ bound: target.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length,
linked: target.inputs.find(input => input.name === 'text').link != null });
app.canvas.selectItems([node]);
app.canvas.copyToClipboard();
app.canvas.pasteFromClipboard({ connectInputs: true });
const pasted = app.graph._nodes.find(item => item.type === 'PromptManagerPair' && item.id !== pair);
const clone = node.clone();
app.graph.add(clone);
const output = (await app.graphToPrompt()).output;
return { pasted: describe(pasted), clone: describe(clone), queued: output[String(pasted.id)].inputs.text, pastedId: pasted.id };
}, ids);
expect(state).toMatchObject({ pasted: { bound: 0, linked: true }, clone: { bound: 0, linked: false }, queued: [String(ids.string), 0] });
// Undo restores the graph through a workflow load: the restored link is still a socket link.
const undone = await page.evaluate(async ({ pair }) => {
const { app } = await import('/scripts/app.js');
const tracker = app.extensionManager?.workflow?.activeWorkflow?.changeTracker;
if (!tracker) throw new Error('no change tracker on the active workflow');
tracker.checkState();
const node = app.graph.getNodeById(pair);
node.disconnectInput(node.inputs.findIndex(input => input.name === 'text'));
tracker.checkState();
await tracker.undo();
const restored = app.graph.getNodeById(pair);
return { bound: restored.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length,
text: (await app.graphToPrompt()).output[String(pair)].inputs.text };
}, ids);
expect(undone).toEqual({ bound: 0, text: [String(ids.string), 0] });
});
test('PromptSearchList text is a core widget socket and takes a dragged STRING link too', async ({ page }) => {
const { id, sources } = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
+34
View File
@@ -186,6 +186,40 @@
"prompt": null,
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}], \"widgets_values\": [\"a tidepool at noon\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"noisy\"]}, {\"id\": 10, \"type\": \"Reroute\", \"mode\": 0, \"inputs\": [{\"name\": \"\", \"link\": 7}]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}, {\"name\": \"latent_image\", \"link\": 2}], \"widgets_values\": [314, \"randomize\", 25, 6.5, \"dpmpp_2m\", \"karras\", 1]}, {\"id\": 11, \"type\": \"CLIPTextEncode\", \"mode\": 2, \"inputs\": [], \"widgets_values\": [\"muted node text\"]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [7, 6, 0, 10, 0, \"CONDITIONING\"], [8, 10, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"], {\"id\": 2, \"origin_id\": 99, \"origin_slot\": 0, \"target_id\": 3, \"target_slot\": 3, \"type\": \"LATENT\"}]}"
},
{
"expected": {
"ok": true,
"result": {
"cfgScale": 5,
"checkpoint": "example.safetensors",
"negativePrompt": "plain negative",
"positivePrompt": "a detached positive",
"sampler": "euler",
"seed": 7,
"steps": 12
}
},
"name": "case:workflow-only-detached-editor-text",
"prompt": null,
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerFocused\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"plain negative\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}], \"widgets_values\": [7, \"fixed\", 12, 5, \"euler\", \"normal\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"]]}"
},
{
"expected": {
"ok": true,
"result": {
"cfgScale": 5,
"checkpoint": "example.safetensors",
"negativePrompt": "a detached negative",
"positivePrompt": "a detached positive",
"sampler": "euler",
"seed": 7,
"steps": 12
}
},
"name": "case:workflow-only-detached-pair-text",
"prompt": null,
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerPair\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"negative_text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a detached negative\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}], \"widgets_values\": [7, \"fixed\", 12, 5, \"euler\", \"normal\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 6, 1, 3, 2, \"CONDITIONING\"]]}"
},
{
"expected": {
"ok": true,
+70
View File
@@ -244,6 +244,74 @@ def _workflow_only():
}
def _detached_editor_text(pair):
"""#343: editor text inputs are sockets, not widget inputs; no named values."""
names = ["text"] + (["negative_text"] if pair else [])
sockets = [
{"name": "clip", "link": 3},
{"name": "prepend_text", "link": None},
{"name": "append_text", "link": None},
]
sockets += [{"name": name, "link": None} for name in names]
sockets += [
{"name": name, "widget": {"name": name}, "link": None}
for name in ("category", "tags", "search_text")
]
editor = {
"id": 6,
"type": "PromptManagerPair" if pair else "PromptManagerFocused",
"mode": 0,
"inputs": sockets,
"widgets_values": ["a detached positive"]
+ (["a detached negative"] if pair else [])
+ ["a category", "a tag", "", "", ""],
}
negative = (
[]
if pair
else [
{
"id": 7,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [{"name": "clip", "link": 5}],
"widgets_values": ["plain negative"],
}
]
)
return {
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"mode": 0,
"inputs": [],
"widgets_values": ["example.safetensors"],
},
editor,
*negative,
{
"id": 3,
"type": "KSampler",
"mode": 0,
"inputs": [
{"name": "model", "link": 1},
{"name": "positive", "link": 8},
{"name": "negative", "link": 4},
],
"widgets_values": [7, "fixed", 12, 5, "euler", "normal", 1],
},
],
"links": [
[1, 4, 0, 3, 0, "MODEL"],
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 6, 0, 3, 1, "CONDITIONING"],
[4, 6 if pair else 7, 1 if pair else 0, 3, 2, "CONDITIONING"],
],
}
def _nested(depth):
"""A value nested ``depth`` arrays deep, as a crafted file can hold."""
value = ["leaf", 7]
@@ -389,6 +457,8 @@ CASES = {
}
),
"workflow-only": case(workflow=_workflow_only()),
"workflow-only-detached-editor-text": case(workflow=_detached_editor_text(False)),
"workflow-only-detached-pair-text": case(workflow=_detached_editor_text(True)),
"overwritten-prompt-manager-text": case(
{
"4": CHECKPOINT,
+73 -3
View File
@@ -65,14 +65,55 @@ test('a STRING link dropped on the editor prefers the first free text input over
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']), undefined, 'both linked: the frontend chooses');
});
test('only STRING-compatible links are steered to a text input', async () => {
test('only exact STRING links are steered; wildcards keep the frontend choice (#343 review M2)', async () => {
const {preferredTextSlot} = await source;
const node = pairNode();
for (const type of ['STRING','STRING,INT','*','']) assert.equal(preferredTextSlot(node, type, ['text'])?.index, 3, type);
for (const type of ['CLIP','INT','IMAGE',undefined,null]) assert.equal(preferredTextSlot(node, type, ['text']), undefined, String(type));
for (const type of ['STRING','STRING,INT',' STRING ']) assert.equal(preferredTextSlot(node, type, ['text'])?.index, 3, type);
for (const type of ['*','','CLIP','INT','IMAGE',undefined,null,0]) assert.equal(preferredTextSlot(node, type, ['text']), undefined, String(type));
assert.equal(preferredTextSlot({}, 'STRING', ['text']), undefined);
});
test('a link being moved off a text input leaves it free, as the frontend counts it (#343 review I2)', async () => {
const {preferredTextSlot} = await source;
const links = new Map([[7,{id:7,_dragging:true}],[8,{id:8}]]);
const node = Object.assign(pairNode(), {graph:{getLink:id => links.get(id)}});
node.inputs[3].link = 7;
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'text', 'the dragged link does not block text');
links.get(7)._dragging = false;
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'negative_text');
node.inputs[4].link = 8;
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']), undefined);
node.inputs[4].link = 99;
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'negative_text', 'a link the graph no longer has is free');
const legacy = Object.assign(pairNode(), {graph:{links:{5:{id:5,_dragging:true}}}});
legacy.inputs[3].link = 5;
assert.equal(preferredTextSlot(legacy, 'STRING', ['text']).slot.name, 'text', 'object-keyed link tables too');
});
test('a legacy frontend Primitive feeding a text input supplies its value (#343 review I1)', async () => {
const {primitiveTextValue} = await source;
const nodes = new Map();
const links = new Map();
const graph = {getNodeById:id => nodes.get(id), getLink:id => links.get(id)};
const add = (id, node) => { nodes.set(id, {id, graph, ...node}); return nodes.get(id); };
const target = add(1, {inputs:[{name:'text',link:10},{name:'negative_text',link:11},{name:'prepend_text',link:12},{name:'category',link:null}]});
add(2, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:'From the primitive'}]});
add(3, {type:'Reroute', isVirtualNode:true, inputs:[{link:13}]});
add(4, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:42}]});
add(5, {type:'PrimitiveStringMultiline', widgets:[{value:'a real node'}]});
links.set(10, {origin_id:2}); links.set(11, {origin_id:3}); links.set(13, {origin_id:4}); links.set(12, {origin_id:5});
assert.equal(primitiveTextValue(target, 'text'), 'From the primitive');
assert.equal(primitiveTextValue(target, 'negative_text'), '42', 'through a virtual reroute');
assert.equal(primitiveTextValue(target, 'prepend_text'), undefined, 'a real node is serialized as a link');
assert.equal(primitiveTextValue(target, 'category'), undefined, 'not linked');
assert.equal(primitiveTextValue(target, 'missing'), undefined);
nodes.get(2).widgets = [];
assert.equal(primitiveTextValue(target, 'text'), undefined, 'a primitive without its widget yet');
links.set(13, {origin_id:3});
assert.equal(primitiveTextValue(target, 'negative_text'), undefined, 'a reroute cycle ends');
assert.equal(primitiveTextValue({inputs:[{name:'text',link:1}]}, 'text'), undefined, 'no graph');
});
test('installing the preference wraps findInputByType and restores nothing it did not own', async () => {
const {preferTextInputs} = await source;
const calls = [];
@@ -89,3 +130,32 @@ test('installing the preference wraps findInputByType and restores nothing it di
preferTextInputs(bare, ['text']);
assert.equal(bare.findInputByType, undefined, 'an older frontend without findInputByType is left unchanged');
});
test('a drop point over a visible prompt box or its note picks that box input (#343 review M1)', async () => {
const {textInputAtPoint} = await source;
const box = (left, top, width, height) => ({getBoundingClientRect:() => ({left, top, width, height, right:left + width, bottom:top + height})});
const node = pairNode();
const targets = [{name:'text',element:box(0,0,100,100)},{name:'negative_text',element:box(120,0,100,100)},{name:'text',element:box(0,0,0,0)},{name:'missing',element:box(300,0,50,50)}];
assert.equal(textInputAtPoint(node, [150,50], targets).name, 'negative_text');
assert.equal(textInputAtPoint(node, [10,10], targets).name, 'text');
assert.equal(textInputAtPoint(node, [110,50], targets), undefined, 'between the boxes');
assert.equal(textInputAtPoint(node, [320,10], targets), undefined, 'a target whose input the node lacks');
assert.equal(textInputAtPoint(node, [0,0], [{name:'text',element:box(0,0,0,0)}]), undefined, 'a hidden target');
});
test('a linked Classic text box gives its row back and gets its size back on disconnect (#343 review M5)', async () => {
const {collapseLinkedWidget} = await source;
const own = () => 120;
const widget = {options:{getMinHeight:own}};
collapseLinkedWidget(widget, true);
assert.equal(widget.options.getMinHeight(), 0);
assert.equal(widget.options.getMaxHeight(), 0);
collapseLinkedWidget(widget, true);
collapseLinkedWidget(widget, false);
assert.equal(widget.options.getMinHeight, own);
assert.equal('getMaxHeight' in widget.options, false, 'an option it did not have is removed again');
collapseLinkedWidget(widget, false);
assert.equal(widget.options.getMinHeight, own);
collapseLinkedWidget(undefined, true);
collapseLinkedWidget({}, true);
});
+11
View File
@@ -82,6 +82,17 @@ class TracesPrompts(unittest.TestCase):
self.assertEqual(result["positivePrompt"], "the real positive")
self.assertEqual(result["negativePrompt"], "the real negative")
def test_editor_text_inputs_without_widget_bindings_keep_their_positions(self):
# #343: Focused/Split/Pair text inputs are plain sockets. The widget-bound
# inputs that remain (category, tags, search_text) must not shift the
# positional widget values onto the wrong names.
result = run("workflow-only-detached-editor-text")
self.assertEqual(result["positivePrompt"], "a detached positive")
self.assertEqual(result["negativePrompt"], "plain negative")
result = run("workflow-only-detached-pair-text")
self.assertEqual(result["positivePrompt"], "a detached positive")
self.assertEqual(result["negativePrompt"], "a detached negative")
class ChoosesTheSampler(unittest.TestCase):
def test_first_sampler_follows_javascript_key_order(self):
+5 -4
View File
@@ -23,21 +23,22 @@ test('execution script discovers only the new matrix and preserves all 78 origin
env: { ...process.env, PM_COMFY_ROOT: '/unused-pinned-checkout' } });
const stock = list('tools/vendor/comfyui.config.mjs');
// + local Inspect host case (inspect-sender.spec.mjs, 2 projects), + the S10 renderer case (prompt-bridge.spec.mjs, 2 projects)
// + #343 linked text: five node types, PromptSearchList and the pre-#343 workflow (text-link.spec.mjs, 2 projects)
assert.match(stock, /Total: 98 tests in 9 files/);
// + #343 linked text: five node types, PromptSearchList, the pre-#343 workflows, legacy Primitive, moved link,
// paste/clone/undo (text-link.spec.mjs, 2 projects)
assert.match(stock, /Total: 106 tests in 9 files/);
assert.doesNotMatch(stock, /execution-history/);
assert.doesNotMatch(stock, /execution-outputs/);
assert.doesNotMatch(stock, /library-location/); // it starts its own fresh host (#249 S6b)
const executions = list('tools/vendor/playwright.execution.config.mjs');
assert.match(executions, /\[classic-start\]/);
assert.match(executions, /\[nodes2-start\]/);
assert.match(executions, /Total: 68 tests in 4 files/); // + the fresh-host library location case, + #343 linked text
assert.match(executions, /Total: 82 tests in 4 files/); // + the fresh-host library location case, + #343 linked text
for (const project of ['classic-start', 'nodes2-start']) {
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1);
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 10);
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 17);
}
assert.doesNotMatch(executions, /prompt-manager.spec/);
});
+8 -2
View File
@@ -762,9 +762,15 @@ def _widget_names(node):
and _truthy(_get(item, "widget"))
and _truthy(_get(_get(item, "widget"), "name"))
]
if from_inputs:
return from_inputs
node_type = _get(node, "type")
if from_inputs:
# A known node's widget-bound inputs must line up with its widget order.
# Sockets that are not widget inputs (#343: the editors' text) would shift
# every positional value onto the wrong name; use the table then.
known = WIDGET_POSITIONS.get(node_type) if isinstance(node_type, str) else None
if known is not None and list(known[: len(from_inputs)]) != from_inputs:
return list(known)
return from_inputs
key = _js_string(node_type)
if key in _OBJECT_PROTOTYPE_KEYS:
raise GenerationParamsError(f"widget positions of {key!r} are not iterable")
+29 -4
View File
@@ -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
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}