Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb8ecce1fb | ||
|
|
3b7d8c10d7 |
@@ -7,9 +7,10 @@ Several on/off switches in one node. Each toggle writes `$its_variable` as `true
|
||||
|
||||
- **upstream (in)** `context`: Optional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).
|
||||
- **Pencil (toolbar)**: Select the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.
|
||||
- **Any on / Pick one**: Any on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.
|
||||
- **Any on / Pick one**: Any on: every toggle is on or off by itself. Pick one: at most one is on; tick Always one on to keep exactly one on, like radio buttons.
|
||||
- **writes (Pick one)**: Optional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.
|
||||
- **Globe (row)**: Edit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.
|
||||
- **Always one on (Pick one)**: Like radio buttons: one toggle is always on. Clicking the picked one does nothing; pick another to switch. With none on, the first toggle is picked. Off (the default), the picked toggle can be switched off so none is on.
|
||||
- **Show in Assembler (Pick one)**: The picked variable shows in the Assembler and can print in the prompt. Off, it is internal.
|
||||
- **Style (Pick one)**: How every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.
|
||||
- **Layout**: Edit view: Rows (one row per toggle, the default) or Grid (each toggle in a tile, the tiles wrapping to the node's width), in both modes and every style. Each label has a grid button for its own group; setting it back to the node's layout makes the group follow the node again. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name, BOOLEAN outputs on the right with theirs, each side packed on its own (so Dawn | Dawn, Sunset | Night). The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), and the arrow on the merged line opens them again.
|
||||
@@ -40,7 +41,7 @@ Several on/off switches in one node. Each toggle writes `$its_variable` as `true
|
||||
## Toggles
|
||||
|
||||
- **Any on**: each toggle is on or off by itself. A toggle that **needs** another is off while that one is off, and keeps its own choice for when it is on again.
|
||||
- **Pick one**: only one toggle is on: the one the upstream picked variable names (with **Upstream can pick**), else the first one upstream set on, else the first one you switched on. Optional **writes** variable gets the picked toggle's **Value**, or its name; internal unless **Show in Assembler**. Its **Style** (Radio, Chip, Switch, Check) sets every row's look.
|
||||
- **Pick one**: only one toggle is on: the one the upstream picked variable names (with **Upstream can pick**), else the first one upstream set on, else the first one you switched on. Optional **writes** variable gets the picked toggle's **Value**, or its name; internal unless **Show in Assembler**. **Always one on** makes it work like radio buttons: the picked toggle can't be switched off, only swapped, and with none on the first one is picked. Its **Style** (Radio, Chip, Switch, Check) sets every row's look.
|
||||
- **When off**: Nothing, **Mute** or **Bypass** the nodes wired into the row's dot and any **Canvas groups** you pick, while the toggle is off (needs and Pick one count). Done on the canvas when the toggle flips.
|
||||
- **Invert**: mute / bypass while the toggle is ON instead. An icon by the name shows a control is wired: coloured while it acts, dim while not; hover for the nodes.
|
||||
- **Show** (by the variable): show the `$variable` under the name in the clean view.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- **An Off value and Off negative for Any on toggles.** A toggle can also say what its variable writes while off, with its own negative: a Buttons toggle Anime / Real writes "anime style" or "photorealistic". Empty, off prints nothing as before.
|
||||
- **A Grid layout for WP Toggles.** The edit bar has a **Layout** switch, Rows or Grid, for every mode and style: Grid puts each toggle in a tile and wraps the tiles to the node's width (the node never resizes itself). Each label has a grid button for its own group, so one group can be a grid while another stays rows; a folded group keeps its toggles' dots on its title. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name and BOOLEAN outputs on the right with theirs, each side packed on its own, so one line can hold two toggles. The arrow on the first line merges a group's lines into one, like the Context Injector's merged wires, which says how many go in and out ("2 in · 3 out") with the names on hover. The edit view keeps rows.
|
||||
- **Smoother drag and drop in WP Toggles.** Rows now drag like the Context Injector's: the row you hold dims, the row it lands beside opens a gap with a glowing line, and after the drop the rows slide into place and the moved one pulses, also on a zoomed canvas. An indented line in a group's colour puts a toggle in that group; a full-width line just above a label or below the last row puts it below the group, in no group (before, a toggle could only leave a group to the top). Groups can be reordered: a label moves with its toggles and lands only between groups. In the Grid layout each tile is as wide as its name, variable and control, and the tiles in a line stretch to fill it, so a chip's name is no longer cut and On/Off buttons no longer take two tiles; a Chip tile is as tall as the tiles beside it.
|
||||
- **Always one on, for WP Toggles' Pick one.** A new box in Pick one's options makes the group work like radio buttons: one toggle is always on, clicking the picked one does nothing, and you switch by picking another. With none on, the first toggle is picked, on the node and in the run.
|
||||
- **WP Context drags like the Context Injector too.** The row it lands beside opens a gap that eases open with a glowing line in it; inside a bundle the line wears the bundle's colour, and a dragged bundle dims like a dragged row. The gap also opens beside a bundle inside another one. A closed bundle now takes drops: drop on the middle of its header to put the row (or a bundle) inside, where it opens, or on its top or bottom edge to drop it beside.
|
||||
- **More looks for WP Toggles.** Pick one has its own **Style**: Radio (as before), Chip, Switch or Check, the same for every row; a toggle's own Style and Needs are hidden there since they don't apply. A pick's **Value** is now a text box like the Assembler's that you can make taller: `$` lists every variable upstream (Context Injector ones too) and the toggles' own, tag, LoRA and embedding autocomplete work in it, and a `$name` nothing upstream sets is flagged under the box and reported in WP Debug after a run. Inline `{a|b}` picks need a seed, so in the Value and in Context Injector templates they print as written; both editors now say so. A Chip toggle now spans the whole row, in Any on and Pick one, so it's an easy target and lines up with the rows around it. In the edit view you can drag a toggle into or out of a label's group (the Group field still works), and a grouped toggle shows its label's colour on its left edge.
|
||||
- **WP Debug knows the newer nodes.** The Variables tab now groups variables into one card per node that set them, with each row's kind as a small square and a Context module's odds, pin, sweep or branch beside its name, and WP Context Loop, WP Model Info, WP Toggles and the Context Injector each get their own icon, colour (orange, lime, indigo, blue) and row details: the Loop says which frame ran, with its seed, sweep combination and value line; Model Info says where each value came from; Toggles shows each toggle's true / false, or its value text with its `.bool` state and negative (in Pick one too, beside the picked value); the Injector shows each value with its type. Group headers show the node's title beside a Context's codename. A long value folds to three lines (a long list to its first three items, a long negative to one line) until you press **Show all**, and `.axis`, `.bool` and tag values are chips. A trace step now says much more about why it came out that way: a wildcard lists every option with its weight, chance and why it couldn't come up (filtered out, toggled off, excluded by a constraint, weight 0) and the pool after each step, plus its seed, repeats and separator; a combine shows its template and what each `$variable` read; fixed values say which rows were used; a constraint lists the steps it re-weighted; and the Loop, Model Info, Toggles and Injector rows say what they read (model class, matched rule, upstream values, a parent toggle that is off, a template's inputs). The Trace tab stays the run in order: those nodes' rows are numbered steps like any module, with their icon and colour, and module rows carry their kind's icon too. **Show node** now makes the module or field the row came from blink on the canvas, for every node, opening a collapsed node, a folded bundle, a folded Toggles group or the Injector's folded row list first (a node in a subgraph opens the subgraph, which is left as it is), and a constraint warning links to its step in the trace again. Each row says where its value came from (a Loop frame and its seed and sweep combination, a Model Info detection, a pin or a file-name rule, a Toggles switch set upstream), and their warnings have readable names and sit on the node's row. The Loop's `$iteration` variables no longer show as coming from upstream, a sweep frame's wildcard says **swept**, Match variable says which variable it matched, a pool filter shows the value its `$variable` read, and with a Loop that skips frames the frame picker shows the Loop's own frame number.
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
### Fixes
|
||||
|
||||
- **The Assembler's template boxes show on a subgraph node.** Wiring the Prompt Assembler's `template` or `negative_template` to a subgraph's input left an empty space on the subgraph node, so the prompt could only be edited from inside. The subgraph node now shows the same editor, with the `$` list of the variables upstream of the Assembler inside, in Nodes 2.0 and the classic canvas; what you type there runs and is saved with the workflow.
|
||||
- **Bundle headers fit narrow nodes and the Settings preview.** On a narrow Context node, or in the Display settings' live preview, a bundle's header pushed its lock, internal and remove buttons past the frame and stretched the bundle wider than the rows around it. The frame now keeps its width: the name, the state badge and the "3 mods · 1 drifted" summary shorten first, and when the header is narrow the badge word gives way to its coloured dot (point at it for what it means).
|
||||
- **Toggle negatives show in the Assembler's negative preview.** A toggle's Negative (or Off negative), and the picked toggle's negative in Pick one, went to the negative prompt on Run but never showed in the Assembler's negative preview. The preview shows them now.
|
||||
- **WP Var nodes read `$name.bool`.** WP Var → Bool (and the other Var nodes) can now read a toggle's switch as `$name.bool`. A toggle with a Value holds that value in its variable, so the **Batches and grids** example's Quick preview stopped picking the small image size. Its Var → Bool now reads `$quick_preview.bool`.
|
||||
|
||||
@@ -1748,7 +1748,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Controls",
|
||||
"text": "upstream (in)\nOptional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).\nPencil (toolbar)\nSelect the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.\nAny on / Pick one\nAny on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.\nwrites (Pick one)\nOptional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.\nGlobe (row)\nEdit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.\nShow in Assembler (Pick one)\nThe picked variable shows in the Assembler and can print in the prompt. Off, it is internal.\nStyle (Pick one)\nHow every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.\nLayout\nEdit view: Rows (one row per toggle, the default) or Grid (each toggle in a tile, the tiles wrapping to the node's width), in both modes and every style. Each label has a grid button for its own group; setting it back to the node's layout makes the group follow the node again. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name, BOOLEAN outputs on the right with theirs, each side packed on its own (so Dawn | Dawn, Sunset | Night). The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), and the arrow on the merged line opens them again.\nPencil, globe, trash (row)\nEach row in the edit view has three buttons, like a module on a Context node: the pencil opens the toggle's own settings in a window (name, variable, looks, style, needs, what off mutes or bypasses, group and its output) with the row drawn on top as the node will show it; the globe keeps its variable internal; the trash removes it.\nAdd toggle / Add label\nA new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it; its icon and colour buttons pick any icon and any colour. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.\ncontrol dots (in)\nA toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.\ncontext (out)\nThe Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler.\ntoggle outputs\nOne BOOLEAN per toggle that has an output, at the right of its row. Up to 16."
|
||||
"text": "upstream (in)\nOptional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).\nPencil (toolbar)\nSelect the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.\nAny on / Pick one\nAny on: every toggle is on or off by itself. Pick one: at most one is on; tick Always one on to keep exactly one on, like radio buttons.\nwrites (Pick one)\nOptional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.\nGlobe (row)\nEdit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.\nAlways one on (Pick one)\nLike radio buttons: one toggle is always on. Clicking the picked one does nothing; pick another to switch. With none on, the first toggle is picked. Off (the default), the picked toggle can be switched off so none is on.\nShow in Assembler (Pick one)\nThe picked variable shows in the Assembler and can print in the prompt. Off, it is internal.\nStyle (Pick one)\nHow every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.\nLayout\nEdit view: Rows (one row per toggle, the default) or Grid (each toggle in a tile, the tiles wrapping to the node's width), in both modes and every style. Each label has a grid button for its own group; setting it back to the node's layout makes the group follow the node again. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name, BOOLEAN outputs on the right with theirs, each side packed on its own (so Dawn | Dawn, Sunset | Night). The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), and the arrow on the merged line opens them again.\nPencil, globe, trash (row)\nEach row in the edit view has three buttons, like a module on a Context node: the pencil opens the toggle's own settings in a window (name, variable, looks, style, needs, what off mutes or bypasses, group and its output) with the row drawn on top as the node will show it; the globe keeps its variable internal; the trash removes it.\nAdd toggle / Add label\nA new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it; its icon and colour buttons pick any icon and any colour. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.\ncontrol dots (in)\nA toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.\ncontext (out)\nThe Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler.\ntoggle outputs\nOne BOOLEAN per toggle that has an output, at the right of its row. Up to 16."
|
||||
},
|
||||
{
|
||||
"title": "Editing a toggle",
|
||||
@@ -1768,7 +1768,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Pick one",
|
||||
"text": "One toggle is on. Which one, first match wins: the toggle the upstream picked variable names (with Upstream can pick), then the first toggle upstream set to a yes word, then the first one you switched on. The picked variable gets the toggle's Value, or its name; it is internal unless Show in Assembler is on. A toggle's Negative, under its Value, goes with the picked variable: an Assembler that prints it sends those words to its negative output. All rows share one look here: the Style next to those boxes picks Radio (the default), Chip, Switch or Check, so a toggle's own Style and Needs are hidden."
|
||||
"text": "One toggle is on. Which one, first match wins: the toggle the upstream picked variable names (with Upstream can pick), then the first toggle upstream set to a yes word, then the first one you switched on. The picked variable gets the toggle's Value, or its name; it is internal unless Show in Assembler is on. A toggle's Negative, under its Value, goes with the picked variable: an Assembler that prints it sends those words to its negative output. All rows share one look here: the Style next to those boxes picks Radio (the default), Chip, Switch or Check, so a toggle's own Style and Needs are hidden.\nBy default you can also switch the picked toggle off, so none is on. Tick Always one on to make the group work like radio buttons: one toggle is always on, clicking it again does nothing, and you switch by picking another. With none on (or when you tick it), the first toggle is picked."
|
||||
},
|
||||
{
|
||||
"title": "Rows or a grid",
|
||||
@@ -1930,6 +1930,10 @@
|
||||
"title": "The negative output",
|
||||
"text": "Wildcard options, fixed values, combines and derivations can carry negative words. The negative output collects them, but only for variables this template printed: an Assembler for a face detailer that prints just $face never gets the hair negatives.\nFigure: Hair, Weather and Quality feed an Assembler with both outputs wired. The negative preview shows the words collected from the variables the template printed. Screenshot of a WP Context (seed 3) holding Hair, Weather and Quality, wired into a WP Prompt Assembler whose prompt and negative outputs each feed a Preview as Text node. The template is '$quality, a woman with $hair, $weather'; the preview reads 'masterpiece, best quality, a woman with black hair, clear sky'. The negative template is empty ('just the variables' negatives'), and the negative preview, marked '1 from variables', reads 'lowres, bad anatomy', which came from the Quality fixed value. The negative Preview as Text prints 'lowres, bad anatomy'.\n- A new Assembler starts with $negatives: just the collected words. Add your standing negative around it, e.g. lowres, bad anatomy, $negatives.\n- Without $negatives the negative is only what you write, and an empty box is an empty negative. Workflows saved before this get $negatives added when they load, so they print the same.\n- Repeated tags are dropped, also when written with a weight like (blurry:1.2).\n- Clear template empties the negative template too.\nMore in Negatives."
|
||||
},
|
||||
{
|
||||
"title": "In a subgraph",
|
||||
"text": "Inside a subgraph, drag the template or negative_template dot to the subgraph's input to edit it from outside. The subgraph node then shows the same editor, with the $ list of the variables upstream of the Assembler inside. What you type there is what the Assembler runs with, and it is saved with the workflow. While a wire feeds that input on the outside, the box is read-only."
|
||||
},
|
||||
{
|
||||
"title": "Inputs and outputs",
|
||||
"text": "context (in)\nThe variables from a WP Context (or anything that ends in one).\ntemplate\nYour prompt with $variables in it.\nnegative_template\nOptional, in the collapsible negative section. Your standing negative, with $negatives where the collected negative words go. New Assemblers start with $negatives; without it the negative is only your own text (empty = no negative).\nprompt (out)\nThe finished prompt. Wire it into CLIP Text Encode or a WP Prompt Cleaner.\nnegative (out)\nThe negative words of the variables this template printed, plus your negative template. Wire it into your negative CLIP Text Encode."
|
||||
|
||||
+9
-2
@@ -17,7 +17,8 @@ or a toggle::
|
||||
* ``Pick one``: one toggle is on; ``needs`` is ignored. Which one, first
|
||||
match wins: the toggle the upstream ``group_var`` names (by name or
|
||||
variable, any case; only with ``group_upstream``), then the first toggle
|
||||
upstream set to a yes word, then the first toggle whose switch is on.
|
||||
upstream set to a yes word, then the first toggle whose switch is on,
|
||||
and with ``pick_required`` (always one on) the first toggle.
|
||||
``group_var`` (optional) gets the picked toggle's ``value`` (text that may
|
||||
read ``$vars``; the node resolves it) or else its name, ``""`` when none
|
||||
is on. It is internal unless ``group_visible``.
|
||||
@@ -98,6 +99,8 @@ class Config:
|
||||
toggles: list[Toggle] = field(default_factory=list)
|
||||
group_visible: bool = False
|
||||
group_upstream: bool = False
|
||||
#: Pick one: always one on; with none on, the first toggle is picked.
|
||||
pick_required: bool = False
|
||||
#: Set by :func:`with_upstream`: the toggle the upstream ``group_var``
|
||||
#: picked, and the toggles upstream set to a yes word (row order).
|
||||
forced: str = ""
|
||||
@@ -155,6 +158,7 @@ def parse_config(raw: Any) -> Config:
|
||||
toggles=toggles,
|
||||
group_visible=data.get("group_visible") is True,
|
||||
group_upstream=data.get("group_upstream") is True,
|
||||
pick_required=data.get("pick_required") is True,
|
||||
)
|
||||
|
||||
|
||||
@@ -211,7 +215,10 @@ def picked_id(cfg: Config) -> str | None:
|
||||
first_up = next((i for i in cfg.upstream_on if i in ids), None)
|
||||
if first_up is not None:
|
||||
return first_up
|
||||
return next((t.id for t in cfg.toggles if t.on), None)
|
||||
on = next((t.id for t in cfg.toggles if t.on), None)
|
||||
if on is None and cfg.pick_required and cfg.toggles:
|
||||
return cfg.toggles[0].id
|
||||
return on
|
||||
|
||||
|
||||
def effective(cfg: Config) -> dict[str, bool]:
|
||||
|
||||
@@ -51,6 +51,7 @@ export { stripControlInputs } from "./extension/toggle-control";
|
||||
export * as imageFilterMod from "./extension/image-filter";
|
||||
export * as graphEventsMod from "./extension/graph-events";
|
||||
export * as badgeSlotMod from "./extension/badge-slot";
|
||||
export * as promoteMod from "./extension/subgraph-promote";
|
||||
export * as graphMod from "./extension/graph";
|
||||
export * as assemblerVarsMod from "./extension/assembler-vars";
|
||||
export * as toastStoreMod from "./components/shared/toast-store";
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import TogglesWidget from "./TogglesWidget.vue";
|
||||
import ToggleRowView from "./ToggleRow.vue";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import {
|
||||
defaultTogglesConfig, effectiveToggles, parseTogglesConfig, pickedId,
|
||||
type ToggleRow, type TogglesConfig,
|
||||
} from "../../extension/toggles";
|
||||
|
||||
const row = (id: string, name: string, on: boolean): ToggleRow => ({
|
||||
id, kind: "toggle", name, var: name.toLowerCase(), on, pin: false, out: 0, style: "switch",
|
||||
});
|
||||
const cfg = (required: boolean, ons: [boolean, boolean, boolean]): TogglesConfig => ({
|
||||
...defaultTogglesConfig(),
|
||||
mode: "one",
|
||||
...(required ? { pick_required: true as const } : {}),
|
||||
rows: [row("00000001", "Dawn", ons[0]), row("00000002", "Noon", ons[1]), row("00000003", "Dusk", ons[2])],
|
||||
});
|
||||
function lastConfig(w: { emitted: (e: string) => unknown[][] | undefined }): TogglesConfig | undefined {
|
||||
const all = w.emitted("update:modelValue");
|
||||
return all?.[all.length - 1]?.[0] as TogglesConfig | undefined;
|
||||
}
|
||||
function rowView(w: ReturnType<typeof mount>, id: string) {
|
||||
const v = w.findAllComponents(ToggleRowView).find((c) => c.props("t").id === id);
|
||||
if (!v) throw new Error(`no row ${id}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
describe("Pick one: Always one on", () => {
|
||||
it("keeps the flag through a parse, and only as true", () => {
|
||||
expect(parseTogglesConfig(JSON.stringify(cfg(true, [false, true, false]))).pick_required).toBe(true);
|
||||
expect(parseTogglesConfig(JSON.stringify({ ...cfg(false, [false, true, false]), pick_required: false })).pick_required).toBeUndefined();
|
||||
});
|
||||
|
||||
it("picks the first toggle when none is on", () => {
|
||||
expect(pickedId(cfg(true, [false, false, false]))).toBe("00000001");
|
||||
expect(pickedId(cfg(false, [false, false, false]))).toBeUndefined();
|
||||
expect(effectiveToggles(cfg(true, [false, false, false])).get("00000001")).toBe(true);
|
||||
});
|
||||
|
||||
it("the picked toggle can't be switched off; picking another still works", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg(true, [false, true, false]) } });
|
||||
rowView(w, "00000002").vm.$emit("set-on", false);
|
||||
await w.vm.$nextTick();
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
rowView(w, "00000003").vm.$emit("set-on", true);
|
||||
await w.vm.$nextTick();
|
||||
const next = lastConfig(w);
|
||||
expect(next?.rows.map((r) => (r.kind === "toggle" ? r.on : null))).toEqual([false, false, true]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("without it, the picked toggle switches off (none on)", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg(false, [false, true, false]) } });
|
||||
rowView(w, "00000002").vm.$emit("set-on", false);
|
||||
await w.vm.$nextTick();
|
||||
expect(lastConfig(w)?.rows.every((r) => r.kind !== "toggle" || !r.on)).toBe(true);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("turning it on with none on switches the first toggle on", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg(false, [false, false, false]), editRequest: 0 } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
const box = w.findAllComponents(Checkbox).find((c) => c.attributes("data-test") === "tg-pick-required");
|
||||
if (!box) throw new Error("no Always one on box");
|
||||
box.vm.$emit("update:modelValue", true);
|
||||
await w.vm.$nextTick();
|
||||
const next = lastConfig(w);
|
||||
expect(next?.pick_required).toBe(true);
|
||||
expect(next?.rows.map((r) => (r.kind === "toggle" ? r.on : null))).toEqual([true, false, false]);
|
||||
box.vm.$emit("update:modelValue", false);
|
||||
await w.vm.$nextTick();
|
||||
expect(lastConfig(w)?.pick_required).toBeUndefined();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -221,11 +221,27 @@ function isBlocked(t: ToggleRow): boolean {
|
||||
function setOn(t: ToggleRow, on: boolean): void {
|
||||
if (isBlocked(t)) return;
|
||||
if (isOne.value) {
|
||||
// Always one on: the picked toggle only goes off by picking another.
|
||||
if (!on && cfg.value.pick_required) return;
|
||||
setRows(cfg.value.rows.map((r) => (r.kind === "toggle" ? { ...r, on: r.id === t.id ? on : false } : r)));
|
||||
} else {
|
||||
patchToggle(t.id, { on });
|
||||
}
|
||||
}
|
||||
/** Always one on: turning it on with nothing on switches the first toggle on,
|
||||
* so what the node shows and what it saves agree. */
|
||||
function setPickRequired(v: boolean): void {
|
||||
if (!v) {
|
||||
commit({ pick_required: undefined });
|
||||
return;
|
||||
}
|
||||
const toggles = toggleRows(cfg.value);
|
||||
const first = toggles[0];
|
||||
const rows = first && !toggles.some((x) => x.on)
|
||||
? cfg.value.rows.map((r) => (r.kind === "toggle" && r.id === first.id ? { ...r, on: true } : r))
|
||||
: cfg.value.rows;
|
||||
commit({ rows, pick_required: true });
|
||||
}
|
||||
/** What the control shows: Pick one shows the pick, Any on the own choice. */
|
||||
function shownOn(t: ToggleRow): boolean {
|
||||
return isOne.value ? !!eff.value.get(t.id) : t.on;
|
||||
@@ -509,6 +525,16 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
/>
|
||||
<span aria-hidden="true">Upstream can pick</span>
|
||||
</span>
|
||||
<span v-tooltip="'Like radio buttons: one toggle is always on. Clicking the picked one does nothing; pick another to switch. With none on, the first toggle is picked'" v-toggle-row class="wp-tg__check">
|
||||
<Checkbox
|
||||
:model-value="!!cfg.pick_required"
|
||||
size="sm"
|
||||
aria-label="Always one on"
|
||||
data-test="tg-pick-required"
|
||||
@update:model-value="setPickRequired"
|
||||
/>
|
||||
<span aria-hidden="true">Always one on</span>
|
||||
</span>
|
||||
<span class="wp-tg__pickstyle">
|
||||
<span class="wp-tg__mode-cap">Style</span>
|
||||
<WpSegmented
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,244 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildPromotedHost,
|
||||
installPromotedHosts,
|
||||
needsOurHost,
|
||||
promotedSource,
|
||||
refreshAllPromotedHosts,
|
||||
refreshPromotedHosts,
|
||||
registerPromotedHost,
|
||||
type PromotedHostContext,
|
||||
type SubgraphHostNode,
|
||||
} from "./subgraph-promote";
|
||||
|
||||
interface Input {
|
||||
name: string;
|
||||
link?: number | null;
|
||||
widgetId?: string;
|
||||
_widget?: unknown;
|
||||
_subgraphSlot?: { linkIds: number[] };
|
||||
}
|
||||
|
||||
/** A source widget with its own element, as `addDOMWidget` makes. */
|
||||
function sourceWidget(name = "template") {
|
||||
return { name, type: "wp-dom", element: document.createElement("div") };
|
||||
}
|
||||
|
||||
/** A subgraph node class shaped like ComfyUI's: a per-subgraph class whose
|
||||
* prototype carries `createPromotedHostWidget`, a store-backed projection and
|
||||
* `_extraWidgets` that `addDOMWidget` fills. */
|
||||
function makeSubgraphNode(opts: {
|
||||
inner: { widgets: { name: string; element?: unknown; type?: string }[]; isSubgraphNode?: () => boolean; inputs?: Input[]; subgraph?: unknown };
|
||||
innerInput?: string;
|
||||
hostInput?: string;
|
||||
settled?: boolean;
|
||||
}) {
|
||||
const store = new Map<string, unknown>();
|
||||
const original = vi.fn(() => undefined);
|
||||
class Host {
|
||||
id = 7;
|
||||
inputs: Input[] = [];
|
||||
_extraWidgets: unknown[] = [];
|
||||
properties: Record<string, unknown> = {};
|
||||
graph: { getNodeById: (id: number | string) => unknown };
|
||||
subgraph: { getLink: (id: number | string) => unknown };
|
||||
constructor() {
|
||||
const self = this;
|
||||
this.graph = { getNodeById: (id) => (opts.settled === false ? null : id === 7 ? self : null) };
|
||||
const target = { name: opts.innerInput ?? "template" };
|
||||
this.subgraph = {
|
||||
getLink: (id) => (id === 1 ? { resolve: () => ({ inputNode: innerNode, input: target }) } : null),
|
||||
};
|
||||
}
|
||||
get widgets(): unknown[] {
|
||||
return [...this.inputs.flatMap((i) => (i._widget ? [i._widget] : [])), ...this._extraWidgets];
|
||||
}
|
||||
isSubgraphNode() {
|
||||
return true;
|
||||
}
|
||||
addDOMWidget(name: string, type: string, element: HTMLElement) {
|
||||
const w = { name, type, element, onRemove: vi.fn() };
|
||||
this._extraWidgets.push(w);
|
||||
return w;
|
||||
}
|
||||
_projectPromotedWidget(input: Input) {
|
||||
const id = input.widgetId ?? input.name;
|
||||
return {
|
||||
get value() {
|
||||
return store.get(id);
|
||||
},
|
||||
set value(v: unknown) {
|
||||
store.set(id, v);
|
||||
},
|
||||
};
|
||||
}
|
||||
rebuildInputWidgetBindings = vi.fn(() => {
|
||||
for (const input of this.inputs) {
|
||||
const source = promotedSource(this as unknown as SubgraphHostNode, input as never);
|
||||
if (source) input._widget = (this as unknown as { createPromotedHostWidget: (...a: unknown[]) => unknown }).createPromotedHostWidget(input, input.widgetId, source) ?? { projection: true };
|
||||
}
|
||||
});
|
||||
}
|
||||
(Host.prototype as unknown as { createPromotedHostWidget: unknown }).createPromotedHostWidget = original;
|
||||
const innerNode = {
|
||||
isSubgraphNode: opts.inner.isSubgraphNode ?? (() => false),
|
||||
inputs: opts.inner.inputs,
|
||||
subgraph: opts.inner.subgraph,
|
||||
getWidgetFromSlot: (slot: { name: string }) => opts.inner.widgets.find((w) => w.name === slot.name),
|
||||
};
|
||||
const node = new Host();
|
||||
node.inputs.push({ name: opts.hostInput ?? "template", widgetId: "g:7:template", _subgraphSlot: { linkIds: [1] } });
|
||||
return { node, store, original, innerNode };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("promotedSource", () => {
|
||||
it("follows the subgraph input's link to the inner widget", () => {
|
||||
const w = sourceWidget();
|
||||
const { node } = makeSubgraphNode({ inner: { widgets: [w] } });
|
||||
expect(promotedSource(node as unknown as SubgraphHostNode, node.inputs[0] as never)).toBe(w);
|
||||
});
|
||||
|
||||
it("is undefined for an input with no link", () => {
|
||||
const { node } = makeSubgraphNode({ inner: { widgets: [] } });
|
||||
expect(promotedSource(node as unknown as SubgraphHostNode, { name: "x" } as never)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows a nested subgraph node down to the real widget", () => {
|
||||
const deep = sourceWidget();
|
||||
// The inner subgraph node forwards its own `template` input to `deep`.
|
||||
const nested = makeSubgraphNode({ inner: { widgets: [deep] } }).node;
|
||||
const { node } = makeSubgraphNode({ inner: { widgets: [] } });
|
||||
const nestedAsInner = Object.assign(nested, { getWidgetFromSlot: () => ({ name: "template", type: "wp-dom" }) });
|
||||
node.subgraph = { getLink: () => ({ resolve: () => ({ inputNode: nestedAsInner, input: { name: "template" } }) }) };
|
||||
expect(promotedSource(node as unknown as SubgraphHostNode, node.inputs[0] as never)).toBe(deep);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPromotedHost", () => {
|
||||
it("leaves widgets with no factory to ComfyUI", () => {
|
||||
const w = sourceWidget();
|
||||
const { node } = makeSubgraphNode({ inner: { widgets: [w] } });
|
||||
expect(buildPromotedHost(node as unknown as SubgraphHostNode, node.inputs[0] as never, w)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds ours, bridged to ComfyUI's stored value, and takes it out of the extra widgets", () => {
|
||||
const w = sourceWidget();
|
||||
let seen: PromotedHostContext | null = null;
|
||||
registerPromotedHost(w, (ctx) => {
|
||||
seen = ctx;
|
||||
return (ctx.subgraphNode as unknown as { addDOMWidget: (n: string, t: string, e: HTMLElement) => { element: HTMLElement } }).addDOMWidget(ctx.inputName, "wp-dom", document.createElement("div"));
|
||||
});
|
||||
const { node, store } = makeSubgraphNode({ inner: { widgets: [w] }, hostInput: "my_prompt" });
|
||||
store.set("g:7:template", "a $style cat");
|
||||
const built = buildPromotedHost(node as unknown as SubgraphHostNode, node.inputs[0] as never, w);
|
||||
expect(built).not.toBeNull();
|
||||
expect(node._extraWidgets).toEqual([]);
|
||||
expect(seen).not.toBeNull();
|
||||
const ctx = seen as unknown as PromotedHostContext;
|
||||
expect(ctx.inputName).toBe("my_prompt");
|
||||
expect(ctx.value.get()).toBe("a $style cat");
|
||||
ctx.value.set("a dog");
|
||||
expect(store.get("g:7:template")).toBe("a dog");
|
||||
});
|
||||
|
||||
it("waits until the node is settled in its graph", () => {
|
||||
const w = sourceWidget();
|
||||
const factory = vi.fn(() => null);
|
||||
registerPromotedHost(w, factory);
|
||||
const { node } = makeSubgraphNode({ inner: { widgets: [w] }, settled: false });
|
||||
expect(buildPromotedHost(node as unknown as SubgraphHostNode, node.inputs[0] as never, w)).toBeNull();
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when the factory throws", () => {
|
||||
const w = sourceWidget();
|
||||
registerPromotedHost(w, () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const { node } = makeSubgraphNode({ inner: { widgets: [w] } });
|
||||
expect(buildPromotedHost(node as unknown as SubgraphHostNode, node.inputs[0] as never, w)).toBeNull();
|
||||
expect(err).toHaveBeenCalled();
|
||||
err.mockRestore();
|
||||
});
|
||||
|
||||
it("gives up (and removes its widget) when extra widgets can't be found", () => {
|
||||
const w = sourceWidget();
|
||||
const made = { element: document.createElement("div"), onRemove: vi.fn() };
|
||||
registerPromotedHost(w, () => made);
|
||||
const { node } = makeSubgraphNode({ inner: { widgets: [w] } });
|
||||
(node as unknown as { _extraWidgets?: unknown })._extraWidgets = undefined;
|
||||
expect(buildPromotedHost(node as unknown as SubgraphHostNode, node.inputs[0] as never, w)).toBeNull();
|
||||
expect(made.onRemove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooking a subgraph node class", () => {
|
||||
it("routes ours through the factory and the rest to ComfyUI's hook", () => {
|
||||
const ours = sourceWidget("template");
|
||||
const theirs = sourceWidget("other");
|
||||
registerPromotedHost(ours, (ctx) =>
|
||||
(ctx.subgraphNode as unknown as { addDOMWidget: (n: string, t: string, e: HTMLElement) => { element: HTMLElement } }).addDOMWidget(ctx.inputName, "wp-dom", document.createElement("div")),
|
||||
);
|
||||
const { node, original } = makeSubgraphNode({ inner: { widgets: [ours, theirs] } });
|
||||
refreshPromotedHosts(node as unknown as SubgraphHostNode);
|
||||
const hook = (node as unknown as { createPromotedHostWidget: (...a: unknown[]) => unknown }).createPromotedHostWidget;
|
||||
expect(hook(node.inputs[0], "id", theirs)).toBeUndefined();
|
||||
expect(original).toHaveBeenCalledTimes(1);
|
||||
expect(hook.call(node, node.inputs[0], "id", ours)).toMatchObject({ type: "wp-dom" });
|
||||
});
|
||||
|
||||
it("re-binds a node still showing ComfyUI's stand-in, and only then", () => {
|
||||
const w = sourceWidget();
|
||||
registerPromotedHost(w, (ctx) =>
|
||||
(ctx.subgraphNode as unknown as { addDOMWidget: (n: string, t: string, e: HTMLElement) => { element: HTMLElement } }).addDOMWidget(ctx.inputName, "wp-dom", document.createElement("div")),
|
||||
);
|
||||
const { node } = makeSubgraphNode({ inner: { widgets: [w] } });
|
||||
node.inputs[0]._widget = { projection: true };
|
||||
expect(needsOurHost(node as unknown as SubgraphHostNode)).toBe(true);
|
||||
refreshPromotedHosts(node as unknown as SubgraphHostNode);
|
||||
expect(node.rebuildInputWidgetBindings).toHaveBeenCalledTimes(1);
|
||||
expect(needsOurHost(node as unknown as SubgraphHostNode)).toBe(false);
|
||||
refreshPromotedHosts(node as unknown as SubgraphHostNode);
|
||||
expect(node.rebuildInputWidgetBindings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("installPromotedHosts re-binds after the current task", () => {
|
||||
vi.useFakeTimers();
|
||||
const w = sourceWidget();
|
||||
registerPromotedHost(w, (ctx) =>
|
||||
(ctx.subgraphNode as unknown as { addDOMWidget: (n: string, t: string, e: HTMLElement) => { element: HTMLElement } }).addDOMWidget(ctx.inputName, "wp-dom", document.createElement("div")),
|
||||
);
|
||||
const { node } = makeSubgraphNode({ inner: { widgets: [w] } });
|
||||
installPromotedHosts(node);
|
||||
expect(node.rebuildInputWidgetBindings).not.toHaveBeenCalled();
|
||||
vi.runAllTimers();
|
||||
expect(node.rebuildInputWidgetBindings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores ordinary nodes", () => {
|
||||
const plain = { isSubgraphNode: () => false, rebuildInputWidgetBindings: vi.fn() };
|
||||
installPromotedHosts(plain);
|
||||
refreshPromotedHosts(plain as unknown as SubgraphHostNode);
|
||||
expect(plain.rebuildInputWidgetBindings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshAllPromotedHosts walks nested subgraphs", () => {
|
||||
const w = sourceWidget();
|
||||
registerPromotedHost(w, (ctx) =>
|
||||
(ctx.subgraphNode as unknown as { addDOMWidget: (n: string, t: string, e: HTMLElement) => { element: HTMLElement } }).addDOMWidget(ctx.inputName, "wp-dom", document.createElement("div")),
|
||||
);
|
||||
const inner = makeSubgraphNode({ inner: { widgets: [w] } }).node;
|
||||
inner.inputs[0]._widget = { projection: true };
|
||||
const outer = makeSubgraphNode({ inner: { widgets: [] } }).node;
|
||||
(outer as unknown as { subgraph: { _nodes: unknown[]; getLink: () => null } }).subgraph = { _nodes: [inner], getLink: () => null };
|
||||
refreshAllPromotedHosts({ _nodes: [outer, { isSubgraphNode: () => false }] });
|
||||
expect(inner.rebuildInputWidgetBindings).toHaveBeenCalledTimes(1);
|
||||
expect(outer.rebuildInputWidgetBindings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Our widgets on a subgraph node.
|
||||
*
|
||||
* Wiring a widget's input to a subgraph's input "promotes" it: the subgraph
|
||||
* node on the outer graph shows a copy of the widget, and the value typed
|
||||
* there is what the inner node runs with. ComfyUI (frontend 1.57) keeps that
|
||||
* value in its widget value store and gives the outer node only a plain
|
||||
* stand-in object (type, value, options). It draws the stand-in by its type,
|
||||
* and for a DOM widget it looks for a real DOM widget of that name on the
|
||||
* outer node. It builds one for exactly one case, the stock multiline
|
||||
* `<textarea>` (`SubgraphNode.createPromotedHostWidget`, overridden in
|
||||
* `litegraphService.registerSubgraphNodeDef`). Every other DOM widget,
|
||||
* ours included, is promoted to an empty row: the Assembler's template box
|
||||
* vanished from the subgraph node the moment it was wired out.
|
||||
*
|
||||
* `createPromotedHostWidget` is the app-layer hook ComfyUI calls to build
|
||||
* that DOM widget ("the default falls back to the store-backed projection").
|
||||
* Each subgraph gets its own node class, so on every subgraph node we see
|
||||
* (`nodeCreated` / `loadedGraphNode`) we wrap the hook on its class once:
|
||||
* a source widget that registered a factory here gets OUR widget on the
|
||||
* outer node; anything else goes to ComfyUI's own hook unchanged. The value
|
||||
* still lives in ComfyUI's store: the factory reads and writes it through the
|
||||
* stand-in ComfyUI made for the input, so saving, running and undo keep
|
||||
* working the way they do for the stock textarea.
|
||||
*
|
||||
* Socketless panels (the Context's modules, Toggles, Label Grid, …) have no
|
||||
* input to wire to a subgraph input, so ComfyUI cannot promote them at all;
|
||||
* this module does not change that.
|
||||
*/
|
||||
|
||||
/** The value of a promoted input, as ComfyUI stores it for the outer node. */
|
||||
export interface PromotedValue {
|
||||
get(): string;
|
||||
set(v: string): void;
|
||||
}
|
||||
|
||||
export interface PromotedHostContext {
|
||||
/** The subgraph node the widget is drawn on. */
|
||||
subgraphNode: SubgraphHostNode;
|
||||
/** The outer input's name (also the promoted widget's name). */
|
||||
inputName: string;
|
||||
value: PromotedValue;
|
||||
}
|
||||
|
||||
/** What a factory hands back: the DOM widget `node.addDOMWidget` made. */
|
||||
export interface PromotedHostWidget {
|
||||
element?: unknown;
|
||||
onRemove?: (...a: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
export type PromotedHostFactory = (ctx: PromotedHostContext) => PromotedHostWidget | null;
|
||||
|
||||
interface SourceWidget {
|
||||
element?: unknown;
|
||||
type?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface StoreProjection {
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
interface LinkLike {
|
||||
resolve?: (graph: unknown) => { inputNode?: SubgraphHostNode | null; input?: InputSlot | null };
|
||||
}
|
||||
|
||||
interface InputSlot {
|
||||
name: string;
|
||||
link?: number | null;
|
||||
widgetId?: string;
|
||||
_widget?: unknown;
|
||||
_subgraphSlot?: { linkIds?: readonly (number | string)[] };
|
||||
}
|
||||
|
||||
export interface SubgraphHostNode {
|
||||
id?: number | string;
|
||||
graph?: { getNodeById?: (id: number | string) => unknown } | null;
|
||||
inputs?: InputSlot[];
|
||||
widgets?: unknown[];
|
||||
properties?: Record<string, unknown>;
|
||||
subgraph?: { getLink?: (id: number | string) => LinkLike | null | undefined };
|
||||
isSubgraphNode?: () => boolean;
|
||||
getWidgetFromSlot?: (input: InputSlot) => SourceWidget | undefined;
|
||||
rebuildInputWidgetBindings?: () => void;
|
||||
/** Private in ComfyUI; the store-backed stand-in for a promoted input. */
|
||||
_projectPromotedWidget?: (input: InputSlot) => StoreProjection | undefined;
|
||||
/** Private in ComfyUI; widgets added with `addDOMWidget` outside promotion. */
|
||||
_extraWidgets?: unknown[];
|
||||
}
|
||||
|
||||
type CreateHook = (this: SubgraphHostNode, input: InputSlot, id: string, source: SourceWidget) => unknown;
|
||||
|
||||
interface HookedProto {
|
||||
createPromotedHostWidget?: CreateHook;
|
||||
}
|
||||
|
||||
/** Factories, keyed by the source widget's DOM element. The element is the
|
||||
* one thing ComfyUI hands back unchanged: the widget object itself may be
|
||||
* re-wrapped on its way into `node.widgets`. */
|
||||
const factories = new WeakMap<object, PromotedHostFactory>();
|
||||
/** Widgets built here, so a re-check can tell ours from ComfyUI's stand-in. */
|
||||
const ourWidgets = new WeakSet<object>();
|
||||
const hookedProtos = new WeakSet<object>();
|
||||
/** Name of the placeholder an input holds while ours is built. */
|
||||
const PENDING_NAME = "\u200bwp-pending";
|
||||
|
||||
/** Offer a widget for promotion: when its input is wired to a subgraph input,
|
||||
* `factory` builds the copy shown on the subgraph node. */
|
||||
export function registerPromotedHost(widget: { element?: unknown }, factory: PromotedHostFactory): void {
|
||||
if (widget.element && typeof widget.element === "object") factories.set(widget.element, factory);
|
||||
}
|
||||
|
||||
function factoryFor(source: SourceWidget | undefined): PromotedHostFactory | undefined {
|
||||
const el = source?.element;
|
||||
return el && typeof el === "object" ? factories.get(el) : undefined;
|
||||
}
|
||||
|
||||
/** True when ComfyUI has settled the node in its graph. Clones and nodes still
|
||||
* being configured carry a passing id, and a DOM widget made for one would be
|
||||
* left behind (ComfyUI's own textarea makes the same check). */
|
||||
function settled(node: SubgraphHostNode): boolean {
|
||||
const id = node.id;
|
||||
return id != null && node.graph?.getNodeById?.(id) === node;
|
||||
}
|
||||
|
||||
/** Take a widget `addDOMWidget` filed under the node's extra widgets back out:
|
||||
* a promoted widget is listed through its input, and listing it twice would
|
||||
* draw it twice. False when this ComfyUI keeps extra widgets elsewhere. */
|
||||
function detachExtra(node: SubgraphHostNode, widget: object): boolean {
|
||||
const extras = node._extraWidgets;
|
||||
if (!Array.isArray(extras)) return false;
|
||||
const i = extras.indexOf(widget);
|
||||
if (i === -1) return !(node.widgets ?? []).includes(widget);
|
||||
extras.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function stringValue(v: unknown): string {
|
||||
return typeof v === "string" ? v : v == null ? "" : String(v);
|
||||
}
|
||||
|
||||
/** Build our copy of a promoted widget, or null to leave it to ComfyUI. */
|
||||
export function buildPromotedHost(
|
||||
node: SubgraphHostNode,
|
||||
input: InputSlot,
|
||||
source: SourceWidget,
|
||||
): PromotedHostWidget | null {
|
||||
const factory = factoryFor(source);
|
||||
if (!factory || !settled(node)) return null;
|
||||
const projection = node._projectPromotedWidget?.(input);
|
||||
if (!projection) return null;
|
||||
const value: PromotedValue = {
|
||||
get: () => stringValue(projection.value),
|
||||
set: (v) => {
|
||||
projection.value = v;
|
||||
},
|
||||
};
|
||||
// While ours is made, the input holds a placeholder. Asking for the
|
||||
// stand-in filed it as the input's widget, and `node.widgets` would make a
|
||||
// new one on every read: either way `addDOMWidget` finds the name taken and
|
||||
// renames ours "template#1", which the Nodes 2.0 row (it looks the DOM
|
||||
// widget up by name) never finds. With the name free ours is filed under
|
||||
// the input's own store id, so it reads and writes the promoted value.
|
||||
const placeholder = { name: PENDING_NAME, type: "wp-pending" };
|
||||
input._widget = placeholder;
|
||||
let widget: PromotedHostWidget | null = null;
|
||||
try {
|
||||
widget = factory({ subgraphNode: node, inputName: input.name, value });
|
||||
} catch (err) {
|
||||
console.error("[wp] promoted widget failed", err);
|
||||
return null;
|
||||
} finally {
|
||||
if (input._widget === placeholder) input._widget = undefined;
|
||||
}
|
||||
if (!widget) return null;
|
||||
if (!detachExtra(node, widget)) {
|
||||
widget.onRemove?.();
|
||||
return null;
|
||||
}
|
||||
ourWidgets.add(widget);
|
||||
return widget;
|
||||
}
|
||||
|
||||
/** The widget inside the subgraph that an outer input forwards to, following
|
||||
* nested subgraph nodes down to the real one. */
|
||||
export function promotedSource(node: SubgraphHostNode, input: InputSlot, depth = 0): SourceWidget | undefined {
|
||||
if (depth > 16) return undefined;
|
||||
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
|
||||
const link = node.subgraph?.getLink?.(linkId);
|
||||
const resolved = link?.resolve?.(node.subgraph);
|
||||
const inner = resolved?.inputNode;
|
||||
const target = resolved?.input;
|
||||
if (!inner || !target) continue;
|
||||
const widget = inner.getWidgetFromSlot?.(target);
|
||||
if (widget && !inner.isSubgraphNode?.()) return widget;
|
||||
if (inner.isSubgraphNode?.()) {
|
||||
const innerInput = inner.inputs?.find((i) => i.name === target.name);
|
||||
const deep = innerInput ? promotedSource(inner, innerInput, depth + 1) : undefined;
|
||||
if (deep) return deep;
|
||||
}
|
||||
if (widget) return widget;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether an input of this node shows ComfyUI's empty stand-in where one of
|
||||
* our widgets belongs. */
|
||||
export function needsOurHost(node: SubgraphHostNode): boolean {
|
||||
return (node.inputs ?? []).some((input) => {
|
||||
if (!input.widgetId) return false;
|
||||
if (input._widget && ourWidgets.has(input._widget as object)) return false;
|
||||
return factoryFor(promotedSource(node, input)) !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap the class's hook (once per subgraph class). */
|
||||
function hook(node: SubgraphHostNode): boolean {
|
||||
const proto = Object.getPrototypeOf(node) as HookedProto | null;
|
||||
if (!proto || typeof proto.createPromotedHostWidget !== "function") return false;
|
||||
if (hookedProtos.has(proto)) return true;
|
||||
const original = proto.createPromotedHostWidget;
|
||||
proto.createPromotedHostWidget = function (this: SubgraphHostNode, input, id, source) {
|
||||
return buildPromotedHost(this, input, source) ?? original.call(this, input, id, source);
|
||||
};
|
||||
hookedProtos.add(proto);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Re-bind the node's promoted inputs when one of ours is still showing the
|
||||
* stand-in (the node resolved its inputs before the hook was on its class,
|
||||
* or before the inner widget offered itself). ComfyUI keeps each value. */
|
||||
export function refreshPromotedHosts(node: SubgraphHostNode): void {
|
||||
if (!node.isSubgraphNode?.() || !hook(node)) return;
|
||||
if (!settled(node) || !needsOurHost(node)) return;
|
||||
try {
|
||||
node.rebuildInputWidgetBindings?.();
|
||||
} catch (err) {
|
||||
console.error("[wp] could not re-bind promoted widgets", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook a subgraph node and re-bind it once the current task (its configure,
|
||||
* and the inner nodes' widget factories) has finished. */
|
||||
export function installPromotedHosts(node: unknown): void {
|
||||
const n = node as SubgraphHostNode;
|
||||
if (!n.isSubgraphNode?.()) return;
|
||||
hook(n);
|
||||
setTimeout(() => refreshPromotedHosts(n), 0);
|
||||
}
|
||||
|
||||
/** Every subgraph node in a graph and its subgraphs. */
|
||||
export function refreshAllPromotedHosts(root: { _nodes?: unknown[] } | null | undefined, depth = 0): void {
|
||||
if (!root || depth > 16) return;
|
||||
for (const node of root._nodes ?? []) {
|
||||
const n = node as SubgraphHostNode & { subgraph?: { _nodes?: unknown[] } };
|
||||
if (!n.isSubgraphNode?.()) continue;
|
||||
refreshPromotedHosts(n);
|
||||
refreshAllPromotedHosts(n.subgraph as { _nodes?: unknown[] } | undefined, depth + 1);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,10 @@ export interface TogglesConfig {
|
||||
group_visible?: true;
|
||||
/** Pick one: an upstream `group_var` naming a toggle picks it. */
|
||||
group_upstream?: true;
|
||||
/** Pick one: always one on, like radio buttons. The picked toggle can't
|
||||
* be switched off (only another one picked), and when none is on the
|
||||
* first toggle counts as picked. Absent: none on is allowed. */
|
||||
pick_required?: true;
|
||||
/** Pick one: how every row looks (absent: radio). Row `style` is Any on's. */
|
||||
pick_style?: Exclude<PickStyle, "radio">;
|
||||
/** Clean view layout, both modes: `grid` puts each toggle in a tile and
|
||||
@@ -306,6 +310,7 @@ export function parseTogglesConfig(raw: unknown): TogglesConfig {
|
||||
};
|
||||
if (d.group_visible === true) cfg.group_visible = true;
|
||||
if (d.group_upstream === true) cfg.group_upstream = true;
|
||||
if (d.pick_required === true) cfg.pick_required = true;
|
||||
if (PICK_STYLES.includes(d.pick_style as PickStyle) && d.pick_style !== "radio") {
|
||||
cfg.pick_style = d.pick_style as Exclude<PickStyle, "radio">;
|
||||
}
|
||||
@@ -315,13 +320,14 @@ export function parseTogglesConfig(raw: unknown): TogglesConfig {
|
||||
}
|
||||
|
||||
/** Pick one: the toggle that is on. First match wins: the one the upstream
|
||||
* picked variable names, the first one upstream set on, the first one on. */
|
||||
* picked variable names, the first one upstream set on, the first one on,
|
||||
* and with `pick_required` the first toggle (mirror of `picked_id`). */
|
||||
export function pickedId(cfg: TogglesConfig): string | undefined {
|
||||
const toggles = toggleRows(cfg);
|
||||
const ids = new Set(toggles.map((t) => t.id));
|
||||
if (cfg.forced && ids.has(cfg.forced)) return cfg.forced;
|
||||
const up = (cfg.upstream_on ?? []).find((id) => ids.has(id));
|
||||
return up ?? toggles.find((t) => t.on)?.id;
|
||||
return up ?? toggles.find((t) => t.on)?.id ?? (cfg.pick_required ? toggles[0]?.id : undefined);
|
||||
}
|
||||
|
||||
/** Toggle id → on after `needs` (Any on) or the Pick one rules. */
|
||||
|
||||
@@ -44,6 +44,7 @@ const {
|
||||
loadPlayground,
|
||||
loadSubgraphBadge,
|
||||
badgeSlotMod,
|
||||
promoteMod,
|
||||
loadImageFilterPicker,
|
||||
loadBookmarkDock,
|
||||
loadNodeHelp,
|
||||
@@ -450,6 +451,8 @@ app.registerExtension({
|
||||
if (root?._nodes?.some(isSubgraphNode)) {
|
||||
withBadges((badgeMod) => badgeMod.attachAllSubgraphBadges(root));
|
||||
}
|
||||
// Our widgets wired out to a subgraph's inputs show on its node.
|
||||
promoteMod.refreshAllPromotedHosts(root);
|
||||
},
|
||||
|
||||
// Fires for every node when it's instantiated — including SubgraphNodes,
|
||||
@@ -459,6 +462,7 @@ app.registerExtension({
|
||||
// without opening it.
|
||||
nodeCreated(node: unknown) {
|
||||
if (!isSubgraphNode(node)) return;
|
||||
promoteMod.installPromotedHosts(node);
|
||||
prepareBadge(node);
|
||||
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
|
||||
if (root) withBadges((badgeMod) => badgeMod.attachSubgraphBadge(node as Parameters<BadgeModule["attachSubgraphBadge"]>[0], root));
|
||||
@@ -468,6 +472,7 @@ app.registerExtension({
|
||||
// workflow. ComfyUI splits the lifecycle so we cover both paths.
|
||||
loadedGraphNode(node: unknown) {
|
||||
if (!isSubgraphNode(node)) return;
|
||||
promoteMod.installPromotedHosts(node);
|
||||
prepareBadge(node);
|
||||
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
|
||||
if (root) withBadges((badgeMod) => badgeMod.attachSubgraphBadge(node as Parameters<BadgeModule["attachSubgraphBadge"]>[0], root));
|
||||
|
||||
@@ -250,9 +250,10 @@ export const NODE_FIELDS = {
|
||||
controls: [
|
||||
{ term: "upstream (in)", sockets: ["in:upstream"], desc: "Optional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below)." },
|
||||
{ term: "Pencil (toolbar)", pin: "page:.wp-tb-icon--edit", desc: "Select the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches." },
|
||||
{ term: "Any on / Pick one", desc: "Any on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons." },
|
||||
{ term: "Any on / Pick one", desc: "Any on: every toggle is on or off by itself. Pick one: at most one is on; tick Always one on to keep exactly one on, like radio buttons." },
|
||||
{ term: "writes (Pick one)", desc: "Optional variable that also gets the picked toggle's value (or its name), so one rule can branch on it." },
|
||||
{ term: "Globe (row)", desc: "Edit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one." },
|
||||
{ term: "Always one on (Pick one)", desc: "Like radio buttons: one toggle is always on. Clicking the picked one does nothing; pick another to switch. With none on, the first toggle is picked. Off (the default), the picked toggle can be switched off so none is on." },
|
||||
{ term: "Show in Assembler (Pick one)", desc: "The picked variable shows in the Assembler and can print in the prompt. Off, it is internal." },
|
||||
{ term: "Style (Pick one)", desc: "How every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off." },
|
||||
{ term: "Layout", desc: "Edit view: Rows (one row per toggle, the default) or Grid (each toggle in a tile, the tiles wrapping to the node's width), in both modes and every style. Each label has a grid button for its own group; setting it back to the node's layout makes the group follow the node again. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name, BOOLEAN outputs on the right with theirs, each side packed on its own (so Dawn | Dawn, Sunset | Night). The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), and the arrow on the merged line opens them again." },
|
||||
|
||||
@@ -120,6 +120,16 @@ import VarToken from "../../../components/docs/VarToken.vue";
|
||||
<p>More in <DocRef id="negatives" />.</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="In a subgraph">
|
||||
<p>
|
||||
Inside a subgraph, drag the <code>template</code> or <code>negative_template</code> dot to the
|
||||
subgraph's input to edit it from outside. The subgraph node then shows the same editor, with
|
||||
the <code>$</code> list of the variables upstream of the Assembler inside. What you type there is
|
||||
what the Assembler runs with, and it is saved with the workflow. While a wire feeds that input on
|
||||
the outside, the box is read-only.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Inputs and outputs">
|
||||
<DocFields node="WP_PromptAssembler" group="ports" />
|
||||
</DocSection>
|
||||
|
||||
@@ -128,6 +128,12 @@ import VarToken from "../../../components/docs/VarToken.vue";
|
||||
the <b>Style</b> next to those boxes picks Radio (the default), Chip, Switch or Check, so a
|
||||
toggle's own Style and Needs are hidden.
|
||||
</p>
|
||||
<p>
|
||||
By default you can also switch the picked toggle off, so none is on. Tick
|
||||
<b>Always one on</b> to make the group work like radio buttons: one toggle is always on,
|
||||
clicking it again does nothing, and you switch by picking another. With none on (or when you
|
||||
tick it), the first toggle is picked.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Rows or a grid">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NEGATIVE_COLLAPSED_PROP,
|
||||
create,
|
||||
createPromoted,
|
||||
editorVariant,
|
||||
initialNegativeCollapsed,
|
||||
} from "./templateEditor";
|
||||
@@ -34,3 +36,55 @@ describe("initialNegativeCollapsed", () => {
|
||||
expect(initialNegativeCollapsed({ [NEGATIVE_COLLAPSED_PROP]: true }, "lowres")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the template editor on a subgraph node", () => {
|
||||
interface FakeWidget {
|
||||
element: HTMLElement;
|
||||
options?: Record<string, unknown>;
|
||||
onRemove?: (...a: unknown[]) => unknown;
|
||||
}
|
||||
function fakeNode(id: number) {
|
||||
const widgets: FakeWidget[] = [];
|
||||
const removes: ReturnType<typeof vi.fn>[] = [];
|
||||
return {
|
||||
removes,
|
||||
id,
|
||||
mode: 0,
|
||||
inputs: [{ name: "template", link: null as number | null }],
|
||||
properties: {} as Record<string, unknown>,
|
||||
widgets,
|
||||
addDOMWidget(_n: string, _t: string, el: HTMLElement, opts?: Record<string, unknown>): FakeWidget {
|
||||
const remove = vi.fn();
|
||||
removes.push(remove);
|
||||
const w: FakeWidget = { element: el, options: opts, onRemove: remove };
|
||||
widgets.push(w);
|
||||
return w;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("offers itself for promotion and builds an editor bound to the stored value", () => {
|
||||
const inner = fakeNode(1);
|
||||
const host = create(inner as never, "template");
|
||||
const outer = fakeNode(2);
|
||||
let stored = "a $style cat";
|
||||
const value = { get: () => stored, set: (v: string) => { stored = v; } };
|
||||
const made = createPromoted(inner as never, "template", { subgraphNode: outer as never, inputName: "template", value });
|
||||
expect(outer.widgets).toHaveLength(1);
|
||||
expect(made).toBe(outer.widgets[0]);
|
||||
// ComfyUI setting the widget (load, undo) reaches the store.
|
||||
const setValue = outer.widgets[0]?.options?.setValue as (v: string) => void;
|
||||
const getValue = outer.widgets[0]?.options?.getValue as () => string;
|
||||
expect(getValue()).toBe("a $style cat");
|
||||
setValue("a dog");
|
||||
expect(stored).toBe("a dog");
|
||||
// Demoting the input runs ComfyUI's onRemove, then tears our editor down.
|
||||
const w = made as FakeWidget;
|
||||
const mounted = () => (w.element.firstElementChild as unknown as { __vue_app__?: unknown } | null)?.__vue_app__;
|
||||
expect(mounted()).toBeTruthy();
|
||||
w.onRemove?.();
|
||||
expect(outer.removes[0]).toHaveBeenCalledTimes(1);
|
||||
expect(mounted()).toBeFalsy();
|
||||
host.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* offers the reserved `$negatives` slot, and keeps its socket like the prompt
|
||||
* template does.
|
||||
*/
|
||||
import { computed, defineAsyncComponent, h, nextTick, ref, type Component } from "vue";
|
||||
import { computed, defineAsyncComponent, h, nextTick, ref, watch, type Component } from "vue";
|
||||
import { app } from "#comfyui/app";
|
||||
import { createDomWidgetHost, type MountTargetNode } from "./_shared";
|
||||
import { attachFieldSockets, type FieldSocketNode } from "../extension/field-sockets";
|
||||
@@ -42,6 +42,12 @@ import {
|
||||
} from "../extension/graph";
|
||||
import { reactiveFromGraph, stringArrayEqual } from "../extension/reactive";
|
||||
import { NEGATIVES_VAR } from "../extension/assembler-vars";
|
||||
import {
|
||||
registerPromotedHost,
|
||||
type PromotedHostContext,
|
||||
type PromotedHostWidget,
|
||||
type PromotedValue,
|
||||
} from "../extension/subgraph-promote";
|
||||
|
||||
type EditorNode = LiteNodeLike & MountTargetNode;
|
||||
|
||||
@@ -106,13 +112,58 @@ function reservedProducer(node: EditorNode): VarProducer {
|
||||
}
|
||||
|
||||
export function create(node: EditorNode, inputName: string) {
|
||||
const editorHost = buildEditor(node, node, inputName, null);
|
||||
// Wired to a subgraph input, the same editor shows on the subgraph node
|
||||
// (see extension/subgraph-promote.ts).
|
||||
registerPromotedHost(editorHost.widget, (ctx) => createPromoted(node, inputName, ctx));
|
||||
return editorHost;
|
||||
}
|
||||
|
||||
/** The editor on a subgraph node whose input feeds this Assembler's
|
||||
* `inputName`. It edits the subgraph node's value (what the inner node runs
|
||||
* with) and offers the inner Assembler's upstream `$vars`. */
|
||||
export function createPromoted(
|
||||
inner: EditorNode,
|
||||
inputName: string,
|
||||
ctx: PromotedHostContext,
|
||||
): PromotedHostWidget {
|
||||
const host = buildEditor(ctx.subgraphNode as unknown as EditorNode, inner, ctx.inputName, ctx.value, inputName);
|
||||
const widget = host.widget as typeof host.widget & PromotedHostWidget;
|
||||
// ComfyUI calls `onRemove` when the input stops being promoted (or is
|
||||
// re-bound); the node itself stays, so tear the editor down with it.
|
||||
const original = widget.onRemove;
|
||||
widget.onRemove = function (this: unknown, ...args: unknown[]): unknown {
|
||||
try {
|
||||
return original?.apply(this, args);
|
||||
} finally {
|
||||
host.unmount();
|
||||
}
|
||||
};
|
||||
return widget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the editor.
|
||||
*
|
||||
* `node` carries the widget; `inner` is the Assembler it edits for, whose
|
||||
* upstream the `$` suggestions come from. They are the same node except on a
|
||||
* subgraph node, where `promoted` is the value ComfyUI keeps for it and
|
||||
* `variantOf` the inner input's name (the outer one may be renamed).
|
||||
*/
|
||||
function buildEditor(
|
||||
node: EditorNode,
|
||||
inner: EditorNode,
|
||||
inputName: string,
|
||||
promoted: PromotedValue | null,
|
||||
variantOf: string = inputName,
|
||||
) {
|
||||
// Mirrors ComfyUI's stored value into something Vue tracks. `host.setValue`
|
||||
// alone is not enough: it writes the plain `state` string the widget
|
||||
// serializes from, and nothing would re-render.
|
||||
const model = ref("");
|
||||
|
||||
const editorRef = ref<{ insertTextAtCaret?: (t: string) => void } | null>(null);
|
||||
const variant = editorVariant(inputName);
|
||||
const variant = editorVariant(variantOf);
|
||||
const nodeProps = (): Record<string, unknown> => {
|
||||
const n = node as { properties?: Record<string, unknown> };
|
||||
if (!n.properties) n.properties = {};
|
||||
@@ -127,7 +178,7 @@ export function create(node: EditorNode, inputName: string) {
|
||||
// from the node's own graph — same rationale as `widgets/context.ts`.
|
||||
const rootGraph = (): LiteGraphLike | null => {
|
||||
const start =
|
||||
(node as unknown as { graph?: LiteGraphLike }).graph
|
||||
(inner as unknown as { graph?: LiteGraphLike }).graph
|
||||
?? (app.graph as unknown as LiteGraphLike);
|
||||
return findRootGraph(start);
|
||||
};
|
||||
@@ -144,7 +195,7 @@ export function create(node: EditorNode, inputName: string) {
|
||||
node as unknown as Parameters<typeof reactiveFromGraph>[0],
|
||||
() => {
|
||||
const g = rootGraph();
|
||||
return g ? collectUpstreamRenderableVariables(g, node) : [];
|
||||
return g ? collectUpstreamRenderableVariables(g, inner) : [];
|
||||
},
|
||||
stringArrayEqual,
|
||||
);
|
||||
@@ -174,9 +225,9 @@ export function create(node: EditorNode, inputName: string) {
|
||||
void varNames.value;
|
||||
const g = rootGraph();
|
||||
const map = g
|
||||
? new Map(Object.entries(collectUpstreamProducers(g, node)))
|
||||
? new Map(Object.entries(collectUpstreamProducers(g, inner)))
|
||||
: new Map<string, VarProducer>();
|
||||
for (const name of variant.reservedVars) map.set(name, reservedProducer(node));
|
||||
for (const name of variant.reservedVars) map.set(name, reservedProducer(inner));
|
||||
return map;
|
||||
});
|
||||
// Reserved names lead the list and are never "unknown".
|
||||
@@ -197,6 +248,7 @@ export function create(node: EditorNode, inputName: string) {
|
||||
"onUpdate:modelValue": (v: string) => {
|
||||
model.value = v;
|
||||
editorHost.setValue(v);
|
||||
promoted?.set(v);
|
||||
},
|
||||
onToggle: () => toggleCollapsed(),
|
||||
});
|
||||
@@ -228,6 +280,7 @@ export function create(node: EditorNode, inputName: string) {
|
||||
"onUpdate:modelValue": (v: string) => {
|
||||
model.value = v;
|
||||
editorHost.setValue(v);
|
||||
promoted?.set(v);
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -259,6 +312,13 @@ export function create(node: EditorNode, inputName: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** ComfyUI set the widget's value (load, undo). On a subgraph node the
|
||||
* value lives in ComfyUI's store, so pass it on there too. */
|
||||
function restored(v: string): void {
|
||||
model.value = v;
|
||||
if (promoted && promoted.get() !== v) promoted.set(v);
|
||||
}
|
||||
|
||||
const editorHost = variant.negative
|
||||
? createDomWidgetHost(node, inputName, wrapper, {
|
||||
// Keeps its socket like the prompt template: a STRING link can drive
|
||||
@@ -269,7 +329,8 @@ export function create(node: EditorNode, inputName: string) {
|
||||
// Content-sized (the box's height is fixed per state) and capped at
|
||||
// that size, so spare node height flows to the prompt editor.
|
||||
fitContent: true,
|
||||
onValueRestored: (v: string) => { model.value = v; },
|
||||
initialValue: promoted?.get(),
|
||||
onValueRestored: (v: string) => restored(v),
|
||||
})
|
||||
: createDomWidgetHost(node, inputName, wrapper, {
|
||||
// The one widget on the node that keeps its socket — see the file
|
||||
@@ -297,8 +358,9 @@ export function create(node: EditorNode, inputName: string) {
|
||||
* content measures zero, so without a declared minimum the node collapses.
|
||||
*/
|
||||
fillHost: true,
|
||||
initialValue: promoted?.get(),
|
||||
// Fired by ComfyUI's own value setter, i.e. workflow load and undo.
|
||||
onValueRestored: (v: string) => { model.value = v; },
|
||||
onValueRestored: (v: string) => restored(v),
|
||||
});
|
||||
|
||||
model.value = editorHost.getValue();
|
||||
@@ -307,6 +369,23 @@ export function create(node: EditorNode, inputName: string) {
|
||||
model.value,
|
||||
);
|
||||
editorHost.onCleanup(attachThemeDetector(editorHost.widget.element, app));
|
||||
if (promoted) {
|
||||
// ComfyUI changes a promoted value in its store without going through
|
||||
// the widget (undo, a reload that keeps the node): follow it.
|
||||
editorHost.scope.run(() => {
|
||||
const live = reactiveFromGraph<string>(
|
||||
node as unknown as Parameters<typeof reactiveFromGraph>[0],
|
||||
() => promoted.get(),
|
||||
Object.is,
|
||||
);
|
||||
watch(live, (v) => {
|
||||
if (v === model.value) return;
|
||||
model.value = v;
|
||||
editorHost.setValue(v);
|
||||
});
|
||||
});
|
||||
return editorHost;
|
||||
}
|
||||
// The input's dot sits level with the editor, the same as every other WP
|
||||
// input that feeds a field. The negative box marks its own parts: the box
|
||||
// when open, the folded line when not.
|
||||
|
||||
Vendored
+176
@@ -1102,6 +1102,182 @@
|
||||
"style": "anime, cartoon"
|
||||
},
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"name": "pick one with always one on picks the first toggle when none is on",
|
||||
"config": {
|
||||
"version": 1,
|
||||
"mode": "one",
|
||||
"group_var": "time",
|
||||
"rows": [
|
||||
{
|
||||
"id": "00000001",
|
||||
"kind": "toggle",
|
||||
"name": "Dawn",
|
||||
"var": "dawn",
|
||||
"on": false,
|
||||
"pin": true,
|
||||
"out": 1,
|
||||
"style": "switch",
|
||||
"needs": ""
|
||||
},
|
||||
{
|
||||
"id": "00000002",
|
||||
"kind": "toggle",
|
||||
"name": "Noon",
|
||||
"var": "noon",
|
||||
"on": false,
|
||||
"pin": true,
|
||||
"out": 2,
|
||||
"style": "switch",
|
||||
"needs": ""
|
||||
},
|
||||
{
|
||||
"id": "00000003",
|
||||
"kind": "toggle",
|
||||
"name": "Dusk",
|
||||
"var": "dusk",
|
||||
"on": false,
|
||||
"pin": false,
|
||||
"out": 0,
|
||||
"style": "switch",
|
||||
"needs": ""
|
||||
}
|
||||
],
|
||||
"pick_required": true
|
||||
},
|
||||
"values": {
|
||||
"dawn": "true",
|
||||
"noon": "false",
|
||||
"dusk": "false",
|
||||
"time": "Dawn"
|
||||
},
|
||||
"outputs": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"bools": {
|
||||
"dawn": true,
|
||||
"noon": false,
|
||||
"dusk": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pick one with always one on keeps the toggle that is on",
|
||||
"config": {
|
||||
"version": 1,
|
||||
"mode": "one",
|
||||
"group_var": "time",
|
||||
"rows": [
|
||||
{
|
||||
"id": "00000001",
|
||||
"kind": "toggle",
|
||||
"name": "Dawn",
|
||||
"var": "dawn",
|
||||
"on": false,
|
||||
"pin": true,
|
||||
"out": 1,
|
||||
"style": "switch",
|
||||
"needs": ""
|
||||
},
|
||||
{
|
||||
"id": "00000002",
|
||||
"kind": "toggle",
|
||||
"name": "Noon",
|
||||
"var": "noon",
|
||||
"on": false,
|
||||
"pin": true,
|
||||
"out": 2,
|
||||
"style": "switch",
|
||||
"needs": ""
|
||||
},
|
||||
{
|
||||
"id": "00000003",
|
||||
"kind": "toggle",
|
||||
"name": "Dusk",
|
||||
"var": "dusk",
|
||||
"on": true,
|
||||
"pin": false,
|
||||
"out": 0,
|
||||
"style": "switch",
|
||||
"needs": ""
|
||||
}
|
||||
],
|
||||
"pick_required": true
|
||||
},
|
||||
"values": {
|
||||
"dawn": "false",
|
||||
"noon": "false",
|
||||
"dusk": "true",
|
||||
"time": "Dusk"
|
||||
},
|
||||
"outputs": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"bools": {
|
||||
"dawn": false,
|
||||
"noon": false,
|
||||
"dusk": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pick one without always one on can have none on",
|
||||
"config": {
|
||||
"version": 1,
|
||||
"mode": "one",
|
||||
"group_var": "time",
|
||||
"rows": [
|
||||
{
|
||||
"id": "00000001",
|
||||
"kind": "toggle",
|
||||
"name": "Dawn",
|
||||
"var": "dawn",
|
||||
"on": false,
|
||||
"pin": true,
|
||||
"out": 1,
|
||||
"style": "switch",
|
||||
"needs": ""
|
||||
},
|
||||
{
|
||||
"id": "00000002",
|
||||
"kind": "toggle",
|
||||
"name": "Noon",
|
||||
"var": "noon",
|
||||
"on": false,
|
||||
"pin": true,
|
||||
"out": 2,
|
||||
"style": "switch",
|
||||
"needs": ""
|
||||
},
|
||||
{
|
||||
"id": "00000003",
|
||||
"kind": "toggle",
|
||||
"name": "Dusk",
|
||||
"var": "dusk",
|
||||
"on": false,
|
||||
"pin": false,
|
||||
"out": 0,
|
||||
"style": "switch",
|
||||
"needs": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"dawn": "false",
|
||||
"noon": "false",
|
||||
"dusk": "false",
|
||||
"time": ""
|
||||
},
|
||||
"outputs": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"bools": {
|
||||
"dawn": false,
|
||||
"noon": false,
|
||||
"dusk": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user