diff --git a/TESTING.md b/TESTING.md index fe0f0c27..7bb0c65d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/tests/browser/pair-node.spec.mjs b/tests/browser/pair-node.spec.mjs index f35dd594..2d554a84 100644 --- a/tests/browser/pair-node.spec.mjs +++ b/tests/browser/pair-node.spec.mjs @@ -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()=>{ diff --git a/tests/comfyui/execution-text-link.spec.mjs b/tests/comfyui/execution-text-link.spec.mjs index 9560eb98..93976ff7 100644 --- a/tests/comfyui/execution-text-link.spec.mjs +++ b/tests/comfyui/execution-text-link.spec.mjs @@ -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 }]); diff --git a/tests/comfyui/fixtures/workflow-before-343-primitive.json b/tests/comfyui/fixtures/workflow-before-343-primitive.json new file mode 100644 index 00000000..4940c722 --- /dev/null +++ b/tests/comfyui/fixtures/workflow-before-343-primitive.json @@ -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 +} diff --git a/tests/comfyui/text-link.spec.mjs b/tests/comfyui/text-link.spec.mjs index 114e5637..5fc84f2b 100644 --- a/tests/comfyui/text-link.spec.mjs +++ b/tests/comfyui/text-link.spec.mjs @@ -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'); diff --git a/tests/fixtures/generation_params/vectors.json b/tests/fixtures/generation_params/vectors.json index 0f7097ef..c51da141 100644 --- a/tests/fixtures/generation_params/vectors.json +++ b/tests/fixtures/generation_params/vectors.json @@ -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, diff --git a/tests/generation_params_cases.py b/tests/generation_params_cases.py index debdc7c8..43d116b9 100644 --- a/tests/generation_params_cases.py +++ b/tests/generation_params_cases.py @@ -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, diff --git a/tests/js/text-links.test.js b/tests/js/text-links.test.js index 8a172195..800e1b8e 100644 --- a/tests/js/text-links.test.js +++ b/tests/js/text-links.test.js @@ -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); +}); diff --git a/tests/test_generation_params.py b/tests/test_generation_params.py index 507cea29..1721113e 100644 --- a/tests/test_generation_params.py +++ b/tests/test_generation_params.py @@ -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): diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index 8b9bd6b7..e7cd1211 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -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/); }); diff --git a/utils/generation_params.py b/utils/generation_params.py index 42b7c9b3..6b340d61 100644 --- a/utils/generation_params.py +++ b/utils/generation_params.py @@ -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") diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index 184a8b86..68c2bc05 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -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 diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index d3d82935..ee525253 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -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"]; diff --git a/web/js/focused-node.js b/web/js/focused-node.js index 90ff3d5a..55727284 100644 --- a/web/js/focused-node.js +++ b/web/js/focused-node.js @@ -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]); + }; } diff --git a/web/js/text-links.js b/web/js/text-links.js index 4da725ab..d2be1af9 100644 --- a/web/js/text-links.js +++ b/web/js/text-links.js @@ -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; + } +}