Compare commits

..
Author SHA1 Message Date
DumiFlex 720c296c7f refactor(injector): drag on the kit's shared useRowDrag 2026-11-12 04:04:42 +00:00
64 changed files with 386 additions and 2812 deletions
+1 -1
View File
@@ -116,7 +116,7 @@ doesn't exist yet, pick **Placeholder** from the `@` list and connect it later.
| Node | What it does |
|---|---|
| **WP Context** | Holds your module stack and outputs the picked variables. Chain several together. |
| **WP Prompt Assembler** | Fills `$variables` in a template and collects their negative words into a second output. One button hides the variables no Assembler prints. |
| **WP Prompt Assembler** | Fills `$variables` in a template and collects their negative words into a second output. |
| **WP Context Loop** + **WP Seed List** | Run the whole chain N times from one Generate, each with its own prompt and seed, or sweep every combination of chosen wildcard options. |
| **WP Context Injector** | Turns any ComfyUI output (text, a number) into a `$variable`. |
| **WP Model Info** | Reads the loaded checkpoint into `$model_family` / `$model_variant` (Pony, Illustrious, …) so rules can pick LoRAs and tags per model. |
-6
View File
@@ -10,12 +10,6 @@ Write a prompt with `$variables` in it and get the finished prompt, plus a match
- **negative_template** `STRING`: Optional, 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).
- **prompt (out)** `STRING`: The finished prompt. Wire it into CLIP Text Encode or a WP Prompt Cleaner.
- **negative (out)** `STRING`: The negative words of the variables this template printed, plus your negative template. Wire it into your negative CLIP Text Encode.
## On the node
- **Load / Save (toolbar)**: Load a template from your library into both boxes, or save this one (prompt and negative) to it.
- **Mark unused internal (toolbar)**: The eye button. Marks every upstream variable that neither box reads as internal, so it stops showing here; combines and rules still read it. Variables another Assembler on the same context reads are kept, and so is a module that also writes a variable in use. Undo in the message puts it back.
- **Clear (toolbar)**: Empties the template and the negative template, after asking.
<!-- fields:end -->
## How to use
+2 -3
View File
@@ -7,10 +7,9 @@ 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: at most one is on; tick Always one on to keep exactly one on, like radio buttons.
- **Any on / Pick one**: Any on: every toggle is on or off by itself. Pick one: exactly one is 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.
@@ -41,7 +40,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**. **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.
- **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.
- **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.
+1 -8
View File
@@ -13,12 +13,8 @@
- **Every toggle can have a Value and a Negative, and `$name.bool`.** Give a toggle a Value and its variable prints it while on and nothing while off: a Rain toggle with "heavy rain" makes `$rain` print "heavy rain" or nothing, and its Negative ("sunny, dry") goes to the Assembler's negative output with it. The switch is still there as `$rain.bool` (true or false) for rules and conditions; `bool` can't name a tag group any more. Packs that read `$name.bool` need this version (schema 12).
- **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.
- **Hide the variables an Assembler doesn't use.** The Prompt Assembler's variables toolbar has a new eye button beside Load, Save and Clear: it marks every upstream variable that neither the prompt nor the negative template reads as internal, so the chips and the `$` list only offer what you print. Combines and rules still read internal variables, so `$hero` used inside `$scene` keeps working. Every other Assembler on the same context is checked first, and a variable one of them prints is kept; so is a module that also writes a variable in use, since one switch covers all its variables. The message says what was marked and what was kept and why, and **Undo** puts it all back.
- **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, 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.
### Wildcards
@@ -26,9 +22,6 @@
### 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`.
- **WP Toggles variables reach the Assembler.** The Pick one variable (like `$time`) never showed in a downstream Prompt Assembler, even with its box ticked, so the Assembler said "No upstream variables yet". It shows now, and the box is called **Show in Assembler**. Each toggle also has a globe on its row in the edit view, on by default: it keeps the toggle's true/false variable internal, for rules only, and turning it off lets the Assembler print it, in Any on and Pick one alike. **Show** now hides the `$variable` under the name in the edit view too, so you can see what it does.
- **Pop-up panels in the manager have their padding back.** Since the move to Tailwind 4, the manager's pop-up panels (the Test Runner's **Add to stack** picker and others built on the same panel) lost their inner padding, so the search box, kind chips, match count and Sort sat flush against the panel's edges. They have room on every side again.
+5 -13
View File
@@ -1748,11 +1748,11 @@
},
{
"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: 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."
"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."
},
{
"title": "Editing a toggle",
"text": "Name\nShown on the row and on its output. A Chip shows Name · $variable (the variable while Show is on). A name colour also colours its variable.\nVariable\nGets true or false, or the toggle's Value while on and nothing while off. $name.bool is always true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name, or after it on a Chip.\nLooks\nAn icon before the name, and colours for the icon, the name and the control (switch, check, radio, chip or buttons), so Day can be yellow and Night blue. None keeps the theme's colour.\nStyle\nSwitch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Any on only: in Pick one the Style in the top bar sets every row's look.\nValue\nWhat the toggle's variable gets while it is on, instead of true; off, it gets its Off value (nothing when that is empty), so $rain can print \"heavy rain\" or nothing. $rain.bool still reads true or false, for rules. In Pick one the picked variable gets it too, instead of the toggle's name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller.\nNegative\nWords to keep out of the image while this toggle is on. They go with its variable (and, in Pick one, with the picked variable), so an Assembler that prints it sends them to its negative output, like a wildcard option's negative. It can read $variables; off, or without one, the variable carries none.\nOff value (Any on)\nWhat the variable gets while the toggle is off, with its own Off negative. Empty prints nothing. With both, one toggle writes either side: a Buttons toggle Anime / Real can write \"anime style\" or \"photorealistic\", each keeping the other's look out of the image. Either one makes the variable hold text, not true or false; $name.bool still reads the switch.\nNeeds\nAny on only. Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.\nWhen off\nNothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).\nInvert\nMute or bypass while the toggle is ON instead, for a node used only as a switchboard.\nCanvas groups\nGroups on the canvas whose nodes When off also covers, by title. No wires needed.\nUpstream can set it\nWhen the upstream Context holds this toggle's variable as a yes/no word (true, yes, on, 1, false, no, off, 0), that value sets the switch for the run.\nGroup\nWhich label it sits under. You can also drag the toggle's grip under another label, or above the first one.\nBOOLEAN output\nShow an output for switch nodes. Off for a toggle only rules read.\nDrag the grip to reorder: the row you drop beside opens a gap with a line. An indented line in a group's colour puts the toggle in that group (a folded one opens); a full-width line, just above a label or below the last row, puts it below the group, in no group. A label moves with its toggles and lands only between groups. Moving a row never re-points a wire: each output stays with its toggle."
"text": "Name\nShown on the row and on its output. A Chip shows Name · $variable (the variable while Show is on). A name colour also colours its variable.\nVariable\nGets true or false, or the toggle's Value while on and nothing while off. $name.bool is always true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name, or after it on a Chip.\nLooks\nAn icon before the name, and colours for the icon, the name and the control (switch, check, radio, chip or buttons), so Day can be yellow and Night blue. None keeps the theme's colour.\nStyle\nSwitch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Any on only: in Pick one the Style in the top bar sets every row's look.\nValue\nWhat the toggle's variable gets while it is on, instead of true; off, it gets its Off value (nothing when that is empty), so $rain can print \"heavy rain\" or nothing. $rain.bool still reads true or false, for rules. In Pick one the picked variable gets it too, instead of the toggle's name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller.\nNegative\nWords to keep out of the image while this toggle is on. They go with its variable (and, in Pick one, with the picked variable), so an Assembler that prints it sends them to its negative output, like a wildcard option's negative. It can read $variables; off, or without one, the variable carries none.\nOff value (Any on)\nWhat the variable gets while the toggle is off, with its own Off negative. Empty prints nothing. With both, one toggle writes either side: a Buttons toggle Anime / Real can write \"anime style\" or \"photorealistic\", each keeping the other's look out of the image. Either one makes the variable hold text, not true or false; $name.bool still reads the switch.\nNeeds\nAny on only. Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.\nWhen off\nNothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).\nInvert\nMute or bypass while the toggle is ON instead, for a node used only as a switchboard.\nCanvas groups\nGroups on the canvas whose nodes When off also covers, by title. No wires needed.\nUpstream can set it\nWhen the upstream Context holds this toggle's variable as a yes/no word (true, yes, on, 1, false, no, off, 0), that value sets the switch for the run.\nGroup\nWhich label it sits under. You can also drag the toggle's grip under another label, or above the first one.\nBOOLEAN output\nShow an output for switch nodes. Off for a toggle only rules read.\nDrag the grip to reorder. A toggle dropped under a label joins that group (a folded one opens), and one dropped above the first label leaves its group. A label moves with its toggles. Moving a row never re-points a wire: each output stays with its toggle."
},
{
"title": "Icons and colours",
@@ -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.\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."
"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."
},
{
"title": "Rows or a grid",
@@ -1922,18 +1922,10 @@
"title": "Write a template",
"text": "- Type $ and pick a variable, or click a chip in the variables strip to insert it at the cursor.\n- A variable nothing sets is underlined in amber and shows as a dashed chip, and prints nothing when you run. Click the chip to remove it from the template.\n- $$ prints a single dollar sign.\n- Save and Load in the toolbar keep templates in your library (see Templates).\nThe Assembler doesn't roll. {a|b|c} prints exactly as typed and @{…} prints nothing. Put choices in a wildcard or combine and print its variable.\nHow empty variables are tidied\nWhen a variable comes out empty, the commas around it and a joining word left with nothing after it (\"with\", \"and\") are removed. Your own words stay: with $hat, $scarf and $place empty, a girl wearing $hat with $scarf, $pose, in the $place gives \"a girl wearing, sitting\". Write templates that still read well when something is empty (put the verb inside a combine), and use WP Prompt Cleaner to remove duplicates. Emoticon tags such as :d are kept."
},
{
"title": "Hide the variables you don't use",
"text": "A big Context offers many variables, and most of them feed other variables rather than the prompt. The eye button in the variables toolbar (beside Load, Save and Clear) marks every variable that neither this template nor the negative template reads as internal, so the chip strip and the $ list only offer what you print.\n- Internal only hides a variable from Assemblers. Combines, rules, constraints and the Var nodes still read it, so $hero used inside $scene keeps working.\n- Every other Assembler on the same context is checked first: a variable one of them prints ($x.0, $x.AXIS and $x.neg count) is kept.\n- A Context module has one internal switch for all the variables it writes, so a fixed values module that also writes a variable in use is left as it is.\n- It flips the same switches you would: the globe on a module, an Injector row, a toggle, a Model Info row or a Loop variable. The message says what it marked and what it kept and why; Undo in it puts everything back.\n- An Assembler whose template comes from a wire can't tell what it uses, so nothing it can see is marked."
},
{
"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."
@@ -2125,7 +2117,7 @@
},
{
"title": "Rows from other nodes",
"text": "Besides the modules in your Context nodes, WP Debug shows what the other context nodes wrote. The Variables tab groups the variables by the node that set them, under the node's title (and a Context's codename), and each node's rows show what matters for that node. The Trace tab stays the run itself: every row is a numbered step in the order it ran, and these nodes differ only by their icon, colour and a short note on where the value came from:\n- WP Context Loop (orange): $iteration, $iteration_total and $iteration_value, under a line saying which frame this is, the frame's seed when the Loop sets seeds (click to copy), the sweep combination and the value line.\n- WP Model Info (lime): Family, Variant and Name, each saying where it came from: the model, a file-name rule, a pin, a wire or the loader's file.\n- WP Toggles (indigo): each toggle's variable as true / false, or its value text when the toggle has one (empty while off), with its .bool state and negative under it. In Pick one, each pick still has its own on/off variable (true is the picked one) and the Pick one variable holds the picked value. The source says when an upstream variable set a toggle.\n- WP Context Injector (blue): each wired value with its type (STR, INT, FLOAT, BOOL, or TPL for a template).\nShow node (in a step's details, a row's right-click menu, or the arrow on a node row) selects the node on the canvas and makes the module or field that row came from blink: a Context's module, a Model Info field, a toggle, an Injector row, or the Loop's iteration field. A collapsed node, a folded bundle, a folded Toggles group or the Injector's folded row list opens first so you can see it. A node inside a subgraph opens that subgraph; the subgraph itself is left as it is.\nA long value shows its first three lines (a long list its first three items, a long negative one line) until you press Show all under it; Show less folds it again.\nTheir warnings (an unknown $variable in a Toggles value, a Model Info rule that couldn't be read, a skipped Injector name) show in the Warnings tab and on that node's row. A wildcard pinned by a Loop sweep says swept, one set to Match variable says which variable it matched, and a pool filter that reads a $variable shows the value it read. With a Loop that skips frames, the frame picker shows the Loop's own frame number."
"text": "Besides the modules in your Context nodes, WP Debug shows what the other context nodes wrote. The Variables tab groups the variables by the node that set them, under the node's title (and a Context's codename), and each node's rows show what matters for that node. The Trace tab stays the run itself: every row is a numbered step in the order it ran, and these nodes differ only by their icon, colour and a short note on where the value came from:\n- WP Context Loop (orange): $iteration, $iteration_total and $iteration_value, under a line saying which frame this is, the frame's seed when the Loop sets seeds (click to copy), the sweep combination and the value line.\n- WP Model Info (lime): Family, Variant and Name, each saying where it came from: the model, a file-name rule, a pin, a wire or the loader's file.\n- WP Toggles (indigo): each toggle's variable as true / false, or its value text when the toggle has one (empty while off), with its .bool state and negative under it. In Pick one, each pick still has its own on/off variable (true is the picked one) and the Pick one variable holds the picked value. The source says when an upstream variable set a toggle.\n- WP Context Injector (blue): each wired value with its type (STR, INT, FLOAT, BOOL, or TPL for a template).\nShow node (in a step's details, a row's right-click menu, or the arrow on a node row) selects the node on the canvas and makes the module or field that row came from blink: a Context's module, a Model Info field, a toggle, an Injector row, or the Loop's iteration field.\nA long value shows its first three lines (a long list its first three items, a long negative one line) until you press Show all under it; Show less folds it again.\nTheir warnings (an unknown $variable in a Toggles value, a Model Info rule that couldn't be read, a skipped Injector name) show in the Warnings tab and on that node's row. A wildcard pinned by a Loop sweep says swept, one set to Match variable says which variable it matched, and a pool filter that reads a $variable shows the value it read. With a Loop that skips frames, the frame picker shows the Loop's own frame number."
},
{
"title": "Where to put it",
@@ -2614,7 +2606,7 @@
},
{
"title": "Make a bundle",
"text": "- On the canvas, select rows in a WP Context, right-click and choose Save as bundle.\n- In the manager, create one on the Bundles page and add modules to it.\n- Drop a saved bundle into a Context with Add Bundle.\n- Drag a row into a bundle on the canvas: a line in the bundle's colour shows where it lands. On a closed bundle, drop on the middle of its header to put the row inside (the bundle opens); its top and bottom edges drop the row beside it."
"text": "- On the canvas, select rows in a WP Context, right-click and choose Save as bundle.\n- In the manager, create one on the Bundles page and add modules to it.\n- Drop a saved bundle into a Context with Add Bundle."
},
{
"title": "When you edit a module later",
-4
View File
@@ -166,10 +166,6 @@ def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
"id": f"val_{i:04x}",
"name": e.get("variable_name", ""),
"value": e.get("value", ""),
# The Toggles preview step sends each variable's
# negative here (send-to-negative).
**({"negative": e["negative"]}
if isinstance(e.get("negative"), str) and e["negative"].strip() else {}),
}
for i, e in enumerate(legacy_entries)
],
+2 -9
View File
@@ -17,8 +17,7 @@ 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,
and with ``pick_required`` (always one on) the first toggle.
upstream set to a yes word, then the first toggle whose switch is on.
``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``.
@@ -99,8 +98,6 @@ 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 = ""
@@ -158,7 +155,6 @@ 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,
)
@@ -215,10 +211,7 @@ 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
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
return next((t.id for t in cfg.toggles if t.on), None)
def effective(cfg: Config) -> dict[str, bool]:
+1 -1
View File
@@ -35,7 +35,7 @@
"announce": "node scripts/discord-announce.mjs"
},
"dependencies": {
"@dumiflex/wp-kit": "^0.19.0",
"@dumiflex/wp-kit": "^0.18.1",
"pinia": "^2.2.6",
"primeicons": "^8.0.2",
"vue": "^3.5.45",
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@dumiflex/wp-kit':
specifier: ^0.19.0
version: 0.19.0(primeicons@8.0.2)(vue@3.5.45(typescript@6.0.3))
specifier: ^0.18.1
version: 0.18.1(primeicons@8.0.2)(vue@3.5.45(typescript@6.0.3))
pinia:
specifier: ^2.2.6
version: 2.3.1(typescript@6.0.3)(vue@3.5.45(typescript@6.0.3))
@@ -240,8 +240,8 @@ packages:
resolution: {integrity: sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==}
engines: {node: '>=v18'}
'@dumiflex/wp-kit@0.19.0':
resolution: {integrity: sha512-zfC44eJ/RPY5qYx5NES6B3vTq9dJUJOwC9vYoSjErzPzC/6nmUFDImTYuwrRejAanBT9WfC3y41gn9fdH1gwcA==}
'@dumiflex/wp-kit@0.18.1':
resolution: {integrity: sha512-TS6/xh2Ur4IL8iDQgEFLmhe741uYY3kpRLuI37uJorp2T6AvpkxXLB64i+rVAm7ISACqIFvvjC1DExbSCImhqQ==}
engines: {node: '>=22'}
peerDependencies:
primeicons: ^7.0.0 || ^8.0.0
@@ -2365,7 +2365,7 @@ snapshots:
'@types/conventional-commits-parser': 5.0.2
chalk: 5.6.2
'@dumiflex/wp-kit@0.19.0(primeicons@8.0.2)(vue@3.5.45(typescript@6.0.3))':
'@dumiflex/wp-kit@0.18.1(primeicons@8.0.2)(vue@3.5.45(typescript@6.0.3))':
dependencies:
primeicons: 8.0.2
vue: 3.5.45(typescript@6.0.3)
+1 -4
View File
@@ -388,10 +388,7 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// shape-check chunk (Note add-to-library press, Import tab) grew 17.9 ->
// 33.5 KB gzip, putting the total at ~684.0 KB. Moving the validators to
// `zod/mini` could win most of it back.
// RAISED 685 -> 700 KB on 2026-11-12 for kit 0.19.0: the shared row drag
// (useRowDrag + row-drag.css, now in the Toggles and Injector chunks) and the
// audit's eight kit pieces put the total at ~687.9 KB.
const TOTAL_LIMIT = 700 * 1024; // 700 KB
const TOTAL_LIMIT = 685 * 1024; // 685 KB
// The total budget is a guide, not a cap (Dumi, 2026-10-06), so the gate has
// two bands: over TOTAL_LIMIT it warns (CI stays green and shows a
// `::warning` annotation), and only past TOTAL_LIMIT + 5% does it fail. A
-1
View File
@@ -51,7 +51,6 @@ 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";
@@ -338,24 +338,6 @@ describe("AssemblerHelper var-color rendering", () => {
expect(btn.element.disabled).toBe(false);
});
it("mark-unused button fires and is off when both boxes use every variable", async () => {
const onMarkUnused = vi.fn();
const wrapper = mount(AssemblerHelper, {
props: { upstreamVars: ["mood", "hero"], template: "$mood", onMarkUnused },
});
const btn = wrapper.find<HTMLButtonElement>('[data-test="asm-mark-unused"]');
expect(btn.element.disabled).toBe(false);
await btn.trigger("click");
expect(onMarkUnused).toHaveBeenCalledTimes(1);
await wrapper.setProps({ negativeTemplate: "$negatives, $hero.neg" });
expect(btn.element.disabled).toBe(true);
});
it("mark-unused button is absent without a handler", () => {
const wrapper = mount(AssemblerHelper, { props: { upstreamVars: ["mood"], template: "" } });
expect(wrapper.find('[data-test="asm-mark-unused"]').exists()).toBe(false);
});
it("preview area shows empty-state ghost when template is empty", () => {
const wrapper = mount(AssemblerHelper, {
props: { upstreamVars: ["mood"], template: "" },
@@ -69,10 +69,6 @@ const props = defineProps<{
/** Open the load-template-from-library picker. Toolbar Load button.
* Optional. */
onLoadTemplate?: () => void;
/** Mark every upstream variable neither box reads as internal (keeping
* any another Assembler on the same context reads). Toolbar button;
* optional. */
onMarkUnused?: () => void;
/** Per-var module-kind lookup. When known, each chip renders a
* small pi-icon matching the source module's kind (wildcard,
* fixed_values, combine, derivation, constraint,
@@ -130,17 +126,6 @@ const templateVarsInternal = computed(() => {
return [...new Set(matches.map((m) => m.slice(1)).filter((v) => !v.startsWith("__")))];
});
/** Upstream variables neither the prompt nor the negative box reads — what
* the Mark unused button would look at (other Assemblers are checked when
* it is pressed). */
const unusedHere = computed(() => {
const used = new Set<string>();
for (const text of [props.template, props.negativeTemplate ?? ""]) {
for (const m of text.replace(/\$\$/g, ESCAPE_PLACEHOLDER).matchAll(VAR_RE)) used.add(m[1]);
}
return upstreamNames.value.filter((v) => !used.has(v));
});
/**
* Variables referenced in the template but NOT present in the upstream list.
*/
@@ -511,19 +496,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
aria-label="Save template"
@click="onSaveTemplate"
/>
<Button
v-if="onMarkUnused"
size="sm"
variant="outline"
icon="pi-eye-slash"
:disabled="unusedHere.length === 0"
data-test="asm-mark-unused"
:hint="unusedHere.length
? `Mark the ${unusedHere.length} unused ${unusedHere.length === 1 ? 'variable' : 'variables'} internal (keeps any that another Assembler uses)`
: 'Every variable here is in the prompt or the negative'"
aria-label="Mark unused variables internal"
@click="onMarkUnused"
/>
<Button
v-if="onClearTemplate"
size="sm"
+9 -118
View File
@@ -1966,7 +1966,7 @@ describe("ContextWidget drag-drop overhaul", () => {
}
}
it("dragover top half adds wp-rowdrag--before (gap metaphor)", async () => {
it("dragover top half adds wp-gap-before (gap metaphor)", async () => {
const wrapper = mountFour();
const cards = wrapper.findAll(".wp-module");
stubRects([
@@ -1981,14 +1981,14 @@ describe("ContextWidget drag-drop overhaul", () => {
await cards[1].trigger("dragover", { clientY: 40, dataTransfer: makeDataTransfer() });
await flushPromises();
expect(cards[1].classes()).toContain("wp-rowdrag--before");
expect(cards[1].classes()).not.toContain("wp-rowdrag--after");
expect(cards[1].classes()).toContain("wp-gap-before");
expect(cards[1].classes()).not.toContain("wp-gap-after");
await cards[0].trigger("dragend");
wrapper.unmount();
});
it("dragover bottom half canonicalises to wp-rowdrag--before on next row", async () => {
it("dragover bottom half canonicalises to wp-gap-before on next row", async () => {
// Single canonical zone per gap — bottom-half of row N projects to
// "before row N+1", so cards[2] receives the class, not cards[1].
const wrapper = mountFour();
@@ -2004,9 +2004,9 @@ describe("ContextWidget drag-drop overhaul", () => {
await cards[1].trigger("dragover", { clientY: 55, dataTransfer: makeDataTransfer() });
await flushPromises();
expect(cards[2].classes()).toContain("wp-rowdrag--before");
expect(cards[1].classes()).not.toContain("wp-rowdrag--after");
expect(cards[1].classes()).not.toContain("wp-rowdrag--before");
expect(cards[2].classes()).toContain("wp-gap-before");
expect(cards[1].classes()).not.toContain("wp-gap-after");
expect(cards[1].classes()).not.toContain("wp-gap-before");
await cards[0].trigger("dragend");
wrapper.unmount();
@@ -2335,13 +2335,9 @@ describe("ContextWidget bundle drag/drop regressions (Batch 2)", () => {
(body.element as HTMLElement).getBoundingClientRect = rectOf(30, 0);
}
// Drag std → the middle of the closed bundle's header (its top and
// bottom 30% drop beside it).
// Drag std → bundle header bottom half (clientY in bottom 15px window).
await stdCard.trigger("dragstart", { dataTransfer: makeDataTransfer() });
await header.trigger("dragover", { clientY: 15, dataTransfer: makeDataTransfer() });
// The closed frame lights up and draws the into line.
expect(wrapper.find(".wp-bundle").classes()).toContain("wp-bundle--drop-into");
expect(wrapper.find(".wp-bundle").classes()).toContain("wp-bundle--drop-target");
await header.trigger("dragover", { clientY: 22, dataTransfer: makeDataTransfer() });
await header.trigger("drop", { dataTransfer: makeDataTransfer() });
await flushPromises();
@@ -2355,78 +2351,6 @@ describe("ContextWidget bundle drag/drop regressions (Batch 2)", () => {
wrapper.unmount();
});
it("a closed bundle's top edge drops beside it: the row stays out and the bundle stays closed", async () => {
const modules = [
{
id: "ch0aaaaa", _uid: "u0", type: "wildcard", enabled: true,
meta: { name: "ch0" }, entries: [],
payload: { var_binding: "v0", options: [] }, payload_hash: "ph0",
bundle_origin: "bundleUidXX",
},
{
id: "ch1aaaaa", _uid: "u1", type: "wildcard", enabled: true,
meta: { name: "ch1" }, entries: [],
payload: { var_binding: "v1", options: [] }, payload_hash: "ph1",
bundle_origin: "bundleUidXX",
},
{
id: "stdaaaaa", _uid: "u2", type: "wildcard", enabled: true,
meta: { name: "std" }, entries: [],
payload: { var_binding: "vs", options: [] }, payload_hash: "phs",
},
];
const bundles = [{
_uid: "bundleUidXX", library_id: "lib_b", start_idx: 0, end_idx: 1,
enabled: true, collapsed: true, inserted_at_hash: "phB", name: "Group", color: null,
}];
const onChange = vi.fn();
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 9302,
initialJson: JSON.stringify({ version: 1, modules, bundles }),
upstreamVars: [],
onChange,
},
});
await flushPromises();
onChange.mockClear();
const header = wrapper.find(".wp-bundle-header");
(header.element as HTMLElement).getBoundingClientRect = rectOf(0, 30);
const stdCard = wrapper.find('[data-module-id="stdaaaaa"]');
(stdCard.element as HTMLElement).getBoundingClientRect = rectOf(80, 30);
// Collapsed bundle frame rect = just the header strip (children
// container has 0 height via grid-template-rows: auto 0fr). The
// slot-zone resolver drills via the frame rect so it needs to be
// > 0 height for drill-in to fire.
const bundle = wrapper.find(".wp-bundle");
(bundle.element as HTMLElement).getBoundingClientRect = rectOf(0, 30);
const body = wrapper.find(".wp-bundle-children");
if (body.exists()) {
(body.element as HTMLElement).getBoundingClientRect = rectOf(30, 0);
}
// Drag std → the top edge of the closed bundle's header.
await stdCard.trigger("dragstart", { dataTransfer: makeDataTransfer() });
await header.trigger("dragover", { clientY: 3, dataTransfer: makeDataTransfer() });
// A gap opens above the frame (kit line, accent colour at top level).
const frame = wrapper.find(".wp-bundle");
expect(frame.classes()).toContain("wp-rowdrag--before");
expect(frame.classes()).not.toContain("wp-bundle--drop-into");
await header.trigger("drop", { dataTransfer: makeDataTransfer() });
await flushPromises();
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
const parsed = JSON.parse(last);
expect(parsed.bundles).toHaveLength(1);
expect(parsed.bundles[0].collapsed).toBe(true);
// std moved above the bundle, outside it.
expect(parsed.modules[0].id).toBe("stdaaaaa");
expect(parsed.modules[0].bundle_origin).toBeUndefined();
wrapper.unmount();
});
// (Test "in-bundle reorder does NOT paint frame highlight" deleted —
// see slot-zone note above. The negative-case mirror of the deleted
// positive test is also obsolete since the class no longer exists.)
@@ -2826,36 +2750,3 @@ describe("ContextWidget collapsed bundles", () => {
}
});
});
describe("ContextWidget — Show node", () => {
it("unfolds the bundles around the module WP Debug points at", async () => {
const initialJson = JSON.stringify({
version: 1,
modules: [
{
id: "eeeeeeee", _uid: "m-child", type: "wildcard", enabled: true,
meta: { name: "Deep" }, entries: [],
payload: { var_binding: "v", options: [{ id: "o1", value: "x", weight: 1 }] },
payload_hash: "h", bundle_origin: "b-inner",
},
],
bundles: [
{ _uid: "b-outer", library_id: "bn_o", start_idx: 0, end_idx: 0, enabled: true, collapsed: true, inserted_at_hash: "h", name: "Outer", color: null },
{ _uid: "b-inner", parent_uid: "b-outer", library_id: "bn_i", start_idx: 0, end_idx: 0, enabled: true, collapsed: true, inserted_at_hash: "h", name: "Inner", color: null },
{ _uid: "b-other", library_id: "bn_x", start_idx: 0, end_idx: 0, enabled: true, collapsed: true, inserted_at_hash: "h", name: "Other", color: null },
],
});
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 302, initialJson, upstreamVars: [], onChange: () => {} },
});
await flushPromises();
const folded = () => [...document.querySelectorAll(".wp-bundle--collapsed")].map((el) => el.getAttribute("data-uid"));
expect(folded()).toContain("b-outer");
wrapper.find("[data-wp-reveal]").element.dispatchEvent(new CustomEvent("wp-reveal", { detail: { uid: "m-child" } }));
await flushPromises();
expect(folded()).not.toContain("b-outer");
expect(folded()).not.toContain("b-inner");
wrapper.unmount();
});
});
+6 -17
View File
@@ -15,8 +15,7 @@ import ModulePickerModal from "./ModulePickerModal.vue";
import BundlePickerModal from "./BundlePickerModal.vue";
import BundleFrame from "./bundles/BundleFrame.vue";
import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-ctx";
import { bundleAlerts, bundleDescendants, collapsedAround, isHiddenInCollapsedBundle } from "./bundles/bundle-alerts";
import { revealTarget } from "../../widgets/flash-target";
import { bundleAlerts, bundleDescendants, isHiddenInCollapsedBundle } from "./bundles/bundle-alerts";
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import {
@@ -27,6 +26,7 @@ import {
import { LockSeedKey } from "./lock-seed-ctx";
import { reconcileBundleRanges } from "./bundles/drag";
import { bundleSnapshotModified, ensureBundleFingerprints } from "./bundles/bundle-fingerprint";
import BundleDropBar from "./bundles/BundleDropBar.vue";
import { createBundleOps } from "./bundles/bundle-ops";
import { createBundleMasterToggles } from "./bundles/master-toggles";
import { useBundleEdit } from "./bundles/bundle-edit";
@@ -730,28 +730,14 @@ const bundleFrameCtx: BundleFrameCtx = {
...dragFrameCtx,
};
provide(BundleFrameCtxKey, bundleFrameCtx);
/** WP Debug "Show node": unfold the bundles that hide the module it names. */
function onReveal(ev: Event): void {
const uid = revealTarget(ev)?.uid;
if (!uid) return;
const open = new Set(collapsedAround(uid, value.value.modules, value.value.bundles));
if (!open.size) return;
value.value = {
...value.value,
bundles: (value.value.bundles ?? []).map((b) => (open.has(b._uid) ? { ...b, collapsed: false } : b)),
};
}
</script>
<template>
<div
class="wp-context"
data-wp-reveal
:class="{ 'wp-context--muted': isMuted }"
:data-mode-label="isMuted ? muteLabel : undefined"
@dragleave="onContainerLeave($event)"
@wp-reveal="onReveal"
>
<!-- Node identity strip. A fixed, read-only CODENAME naming THIS
WP_Context node — a stable `adjective-noun` derived from the
@@ -811,6 +797,10 @@ function onReveal(ev: Event): void {
class="wp-modules"
:data-suppress-move="suppressMove ? 'true' : null"
>
<!-- Floating drop indicator for top-level scope. Hidden when
the resolved zone targets a nested bundle container — that
container renders its own BundleDropBar. -->
<BundleDropBar :container-uid="null" />
<template v-for="item in topLevelItems" :key="item.key">
<BundleFrame
v-if="item.kind === 'bundle'"
@@ -991,6 +981,5 @@ function onReveal(ev: Event): void {
<style>
/* Unscoped widget styles live in context-widget.css — same cascade as the
* inline block they came from (NOT layered; see the note at its top). */
@import "@/kit/row-drag.css";
@import "./context-widget.css";
</style>
-1
View File
@@ -32,7 +32,6 @@ function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
summaryTokens: () => [],
siblingInfo: () => null,
rowGap: () => null,
rowLineStyle: () => undefined,
draggingModuleUid: ref<string | null>(null),
recentDropUids: ref(new Set<string>()),
pulseDelayFor: () => "0ms",
+7 -8
View File
@@ -27,7 +27,7 @@ const {
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
severityFor, conflictTooltip, conflictBadgeText,
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
rowGap, rowLineStyle,
rowGap,
toggleCollapsed, toggleEnabled, removeModule,
toggleLockOnCard, toggleInternalOnCard,
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
@@ -42,7 +42,7 @@ const {
:data-module-id="module.id"
:data-module-idx="idx"
:data-kind="module.type"
class="wp-module wp-rowdrag-row"
class="wp-module"
tabindex="0"
draggable="true"
:class="{
@@ -53,18 +53,17 @@ const {
'wp-state-modified': isModified(module),
'wp-state-drift': isDrifted(module),
'wp-state-missing': isMissingFromLibrary(module) && !module.example,
'wp-rowdrag--before': rowGap(idx) === 'before',
'wp-rowdrag--after': rowGap(idx) === 'after',
'wp-gap-before': rowGap(idx) === 'before',
'wp-gap-after': rowGap(idx) === 'after',
'wp-module--dragging': module._uid === ctx.draggingModuleUid.value,
'wp-drop-pulse': !!module._uid && ctx.recentDropUids.value.has(module._uid),
'wp-mod--mod': isModified(module),
'wp-mod--drift': isDrifted(module),
'wp-mod--err': isMissingFromLibrary(module) && !module.example,
}"
:style="{
...rowLineStyle(idx),
...(!!module._uid && ctx.recentDropUids.value.has(module._uid) ? { animationDelay: ctx.pulseDelayFor(module._uid) } : {}),
}"
:style="!!module._uid && ctx.recentDropUids.value.has(module._uid)
? { animationDelay: ctx.pulseDelayFor(module._uid) }
: undefined"
@dragstart="(ev) => onDragStart(ev, module, idx)"
@dragend="onDragEnd"
@contextmenu.stop.prevent="(ev) => openContextMenu(ev, module, idx)"
@@ -0,0 +1,67 @@
<script setup lang="ts">
/**
* Floating drop indicator bar. Rendered inside every drop-container
* (top-level wp-list + every wp-bundle-children). Position is computed
* from the resolved DropZone — when the zone targets this container,
* the bar appears at the correct slot; otherwise it stays hidden.
*
* The bar is absolutely positioned, sized to the container's content
* width (via left/right inset = 0). With the slot-zone redesign there
* is only one zone kind — every drop resolves to a slot anchor in
* exactly one container, so this SFC handles the full indicator
* painting on its own.
*
* The container provides a `dropBarFor(containerUid)` lookup through
* BundleFrameCtx. ContextWidget owns the dragOver ref + the rect math;
* this component is purely declarative.
*
* **No rAF refinement here.** dropBarFor uses `offsetTop` walkers
* (CSS pixels, zoom-independent) instead of getBoundingClientRect
* (viewport pixels, scaled by the canvas zoom transform). offsetTop
* is layout-stable across Vue's flush:"post" boundary, so a simple
* computed off the resolved zone is enough — no second-pass refinement
* needed. Earlier rAF-based refinement was added when the position
* used getBoundingClientRect; that machinery is no longer required
* and removing it eliminates a potential double-paint glitch when
* the bundle moves under wp-gap-before margin and the rAF callback
* reads a different value than the eager paint.
*/
import { computed, inject } from "vue";
import { BundleFrameCtxKey } from "./bundle-frame-ctx";
interface Props {
/** The container's bundle uid, or null for the top-level list. */
containerUid: string | null;
}
const props = defineProps<Props>();
const ctx = inject(BundleFrameCtxKey);
const position = computed<{ top: number } | null>(() => {
if (!ctx) return null;
return ctx.dropBarFor(props.containerUid);
});
</script>
<template>
<div
v-if="position"
class="wp-drop-bar"
:style="{ top: position.top + 'px' }"
aria-hidden="true"
/>
</template>
<style scoped>
.wp-drop-bar {
position: absolute;
left: 4px;
right: 4px;
height: 2px;
background: var(--wp-accent);
border-radius: min(1px, var(--wp-radius-cap, 999px));
pointer-events: none;
z-index: 5;
box-shadow: 0 0 6px var(--wp-accent);
}
</style>
+8 -13
View File
@@ -20,6 +20,7 @@ import { inject } from "vue";
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
import { BundleFrameCtxKey } from "./bundle-frame-ctx";
import BundleHeader from "./BundleHeader.vue";
import BundleDropBar from "./BundleDropBar.vue";
import ModuleRow from "../ModuleRow.vue";
type BundleChild =
@@ -42,21 +43,18 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
<template>
<div
class="wp-bundle wp-rowdrag-row"
class="wp-bundle"
:class="{
'wp-bundle--collapsed': bundle.collapsed,
'wp-bundle--disabled': !bundle.enabled,
'wp-bundle--nested': nested,
'wp-bundle--drop-target': ctx.isBundleDropTarget(bundle._uid),
'wp-bundle--drop-into': ctx.isBundleDropInto(bundle._uid),
'wp-rowdrag--before': ctx.bundleHeaderGap(bundle._uid) === 'before',
'wp-rowdrag--after': ctx.bundleHeaderGap(bundle._uid) === 'after',
'wp-rowdrag--dragging': ctx.draggingBundleUid.value === bundle._uid,
'wp-gap-before': ctx.bundleHeaderGap(bundle._uid) === 'before',
'wp-gap-after': ctx.bundleHeaderGap(bundle._uid) === 'after',
'wp-drop-pulse': ctx.recentDropUids.value.has(bundle._uid),
}"
:style="{
'--wp-bundle-color': bundle.color ? bundle.color : 'var(--wp-bundle-default)',
...ctx.bundleLineStyle(bundle._uid),
...(ctx.recentDropUids.value.has(bundle._uid) ? { animationDelay: ctx.pulseDelayFor(bundle._uid) } : {}),
}"
:data-bundle-uid="bundle._uid"
@@ -86,6 +84,7 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
@dragend="ctx.onDragEnd()"
/>
<div class="wp-bundle-children">
<BundleDropBar :container-uid="bundle._uid" />
<template v-for="child in children" :key="child.key">
<BundleFrame
v-if="child.kind === 'bundle'"
@@ -119,11 +118,6 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
*/
margin: 2px 0 4px 0;
}
/* The drop gap still opens beside a nested frame: its own margins above
* outrank the kit's one-class wp-rowdrag rules, so the gap and its line
* get the same weight here. */
.wp-bundle.wp-bundle--nested.wp-rowdrag--before { margin-top: var(--wp-rowdrag-gap, 14px); }
.wp-bundle.wp-bundle--nested.wp-rowdrag--after { margin-bottom: var(--wp-rowdrag-gap, 14px); }
.wp-bundle.wp-bundle--nested > .wp-bundle-children {
padding: 4px 5px 5px 5px;
}
@@ -139,8 +133,9 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
padding: 0;
overflow: hidden;
}
/* Offset parent for anything absolutely placed in a bundle's body.
* Both nested + outer frames share this rule. */
/* Drop-bar anchor — the floating indicator inside .wp-bundle-children
* is absolutely positioned; the container needs to be the offset
* parent. Both nested + outer frames share this rule. */
.wp-bundle > .wp-bundle-children {
position: relative;
}
@@ -363,53 +363,12 @@ const summary = computed(() => {
flex-shrink: 0;
}
.wp-bundle-name {
flex: 1;
font: 600 12px/1.3 var(--wp-font-sans);
color: var(--wp-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* Sized by its text and shrinking slowly next to the badges and the
* summary (flex-shrink 8 and 2 there), so the name gives way last; a few
* letters always show. */
flex: 1 1 auto;
min-width: 3em;
}
/* On a narrow header the state badges and the summary shorten instead of
* pushing the lock / internal / remove buttons past the frame. The badges
* keep one line (the row-wide cluster wraps, which would make the header
* taller than its rows) and ellipsize; the dot beside each keeps the colour. */
.wp-bundle-header {
container: wp-bundle-head / inline-size;
}
.wp-bundle-header .wp-mod-dots {
flex: 0 8 auto;
flex-wrap: nowrap;
max-width: none;
/* Room for the dot; the badge word inside shortens to nothing first. */
min-width: 11px;
/* Start-aligned: the row-wide cluster packs to the end, which clipped a
* shortened badge from the LEFT ("ARY UPDATED"). */
justify-content: flex-start;
}
.wp-bundle-header .wp-mod-dots .wp-mod-badge {
flex-shrink: 8;
min-width: 3em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Below 480 px the badge words go when a dot already says the same thing
* (indicator style "Both"): a word cut to "L…" says less than the dot and
* its tooltip. "Badges only" keeps its shortened word. */
@container wp-bundle-head (max-width: 480px) {
.wp-indicator-both .wp-bundle-header .wp-mod-dots .wp-mod-badge:not(.wp-mod-badge--example) {
display: none;
}
}
/* Narrowest headers (the preview's 300 px floor, a node squeezed thin):
* the summary goes, the name keeps what is left. */
@container wp-bundle-head (max-width: 280px) {
.wp-bundle-header .wp-bundle-summary { display: none; }
}
/* Dots for trouble inside a collapsed bundle. Same 7px circle as the row
* dots; the colour comes from the shared `.wp-conflict-dot--*` /
@@ -437,10 +396,7 @@ const summary = computed(() => {
.wp-bundle-summary {
font: 600 11px/1.3 var(--wp-font-sans);
color: var(--wp-text-muted, var(--wp-text2));
flex: 0 2 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
padding: 1px 6px;
border-radius: var(--wp-radius-cap, 999px);
background: color-mix(in oklab, var(--wp-text-muted, var(--wp-text2)) 12%, transparent);
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { bundleAlerts, bundleDescendants, collapsedAround, isHiddenInCollapsedBundle } from "./bundle-alerts";
import { bundleAlerts, bundleDescendants, isHiddenInCollapsedBundle } from "./bundle-alerts";
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
function bundle(uid: string, over: Partial<BundleInstance> = {}): BundleInstance {
@@ -62,25 +62,3 @@ describe("bundleAlerts", () => {
expect(bundleAlerts([mod("b", "x"), mod("a", "x")], checks({}, ["a"], ["b"])).state).toBe("clash");
});
});
describe("collapsedAround (Show node unfolds)", () => {
const outer = bundle("b-out", { collapsed: true });
const inner = bundle("b-in", { collapsed: true, parent_uid: "b-out" });
const open = bundle("b-open", { parent_uid: "b-out" });
const modules = [mod("m1", "b-in"), mod("m2", "b-open"), mod("m3", null)];
it("names every folded bundle around a module, innermost first", () => {
expect(collapsedAround("m1", modules, [outer, inner, open])).toEqual(["b-in", "b-out"]);
expect(collapsedAround("m2", modules, [outer, inner, open])).toEqual(["b-out"]);
});
it("leaves a bundle's own fold alone but opens the ones above it", () => {
expect(collapsedAround("b-in", modules, [outer, inner, open])).toEqual(["b-out"]);
});
it("is empty for a top-level module, an unknown uid or no folds", () => {
expect(collapsedAround("m3", modules, [outer, inner])).toEqual([]);
expect(collapsedAround("nope", modules, [outer, inner])).toEqual([]);
expect(collapsedAround("m1", modules, [bundle("b-in", { parent_uid: null })])).toEqual([]);
});
});
@@ -13,14 +13,9 @@ function parentMap(bundles: readonly BundleInstance[] | undefined): Map<string,
/** Every bundle uid a module sits in, innermost first. Depth-limited so a
* corrupt parent cycle can't loop. */
function bundleChain(module: ModuleEntry, parent: Map<string, string | null>): string[] {
const origin = (module as ModuleEntry & { bundle_origin?: string | null }).bundle_origin;
return chainFrom(typeof origin === "string" ? origin : null, parent);
}
/** `start` and every bundle above it, innermost first. */
function chainFrom(start: string | null | undefined, parent: Map<string, string | null>): string[] {
const out: string[] = [];
let cur = start;
const origin = (module as ModuleEntry & { bundle_origin?: string | null }).bundle_origin;
let cur: string | null | undefined = typeof origin === "string" ? origin : null;
while (cur && parent.has(cur) && !out.includes(cur) && out.length < 8) {
out.push(cur);
cur = parent.get(cur);
@@ -41,21 +36,6 @@ export function isHiddenInCollapsedBundle(
return bundleChain(module, parent).some((uid) => collapsed.has(uid));
}
/** The collapsed bundles that hide the module or bundle `uid` (a bundle's
* own fold is left alone: its header shows). Empty when nothing hides it. */
export function collapsedAround(
uid: string,
modules: readonly ModuleEntry[],
bundles: readonly BundleInstance[] | undefined,
): string[] {
const parent = parentMap(bundles);
const collapsed = new Set((bundles ?? []).filter((b) => b?.collapsed).map((b) => b._uid));
if (!collapsed.size) return [];
const mod = modules.find((m) => m?._uid === uid);
const chain = mod ? bundleChain(mod, parent) : parent.has(uid) ? chainFrom(parent.get(uid), parent) : [];
return chain.filter((b) => collapsed.has(b));
}
/** The modules a bundle holds, its sub-bundles' modules included. */
export function bundleDescendants(
bundle: BundleInstance,
@@ -25,15 +25,9 @@ export interface BundleFrameCtx {
bundleLockState: (b: BundleInstance) => "all" | "none" | "partial" | null;
/** Returns "before"/"after" when this bundle is the indicator anchor
* for the current slot zone, null otherwise. Drives the
* kit's `wp-rowdrag--before`/`--after` class (row-drag.css): a gap
* opens with the drop line in it. */
* `wp-gap-before`/`wp-gap-after` margin class so a gap opens for the
* drop-bar. */
bundleHeaderGap: (uid: string) => "before" | "after" | null;
/** The drop line's colour variable when this frame anchors the drop. */
bundleLineStyle: (uid: string) => Record<string, string> | undefined;
/** True when the drop goes into this CLOSED bundle (opens on drop). */
isBundleDropInto: (uid: string) => boolean;
/** `_uid` of the bundle being dragged by its header (its frame dims). */
draggingBundleUid: Ref<string | null>;
/** True when the current drag's resolved slot zone targets THIS
* bundle's body — i.e. the drop will land inside this bundle. Drives
* a frame-level highlight (ring + tinted background) so users get a
@@ -60,6 +54,12 @@ export interface BundleFrameCtx {
openBundleContextMenu: (ev: MouseEvent, uid: string) => void;
onBundleDragStart: (ev: DragEvent, uid: string) => void;
onDragEnd: () => void;
/** Returns the drop-indicator position for a given container scope.
* `containerUid = null` means the top-level list. Returns null when
* the resolved zone doesn't target this container — caller hides the
* bar. ContextWidget owns the dragOver ref + DOM rect math; this
* ctx surfaces the per-container slice. */
dropBarFor: (containerUid: string | null) => { top: number } | null;
}
export const BundleFrameCtxKey: InjectionKey<BundleFrameCtx> = Symbol("bundleFrameCtx");
@@ -61,10 +61,6 @@ function collectMovingBundleUids(
return out;
}
/** Share of a closed bundle's height, at its top and at its bottom, that
* drops beside it instead of into it. */
export const CLOSED_EDGE = 0.3;
/** Resolves a pointer position to a drop zone.
*
* jsdom + headless environments don't implement elementFromPoint —
@@ -207,15 +203,6 @@ function resolveWithin(
const b = (value.bundles ?? []).find((bb) => bb._uid === uid);
const targetIsNested = b && typeof b.parent_uid === "string" && !!b.parent_uid;
const tierCap = drag?.kind === "bundle" && (dragHasNested || targetIsNested);
// A closed bundle is just its header: the top and bottom edges
// drop beside it, the middle drops INTO it (at its end; the
// bundle opens on drop).
if (b && b.collapsed && !tierCap) {
const edge = r.height * CLOSED_EDGE;
if (y < r.top + edge) return { kind: "slot", containerUid, insertIdx: startIdxOf(k, value) };
if (y > r.bottom - edge) return { kind: "slot", containerUid, insertIdx: endIdxOf(k, value) + 1 };
return { kind: "slot", containerUid: uid, insertIdx: b.end_idx + 1 };
}
if (!tierCap) {
const body = k.querySelector<HTMLElement>(":scope > .wp-bundle-children");
if (body) return resolveWithin(body, y, uid, value, drag, moving);
+118 -66
View File
@@ -71,8 +71,6 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
// lifted-ghost class so the source visually picks up alongside the
// browser's default drag image.
const draggingModuleUid = ref<string | null>(null);
// `_uid` of the bundle being dragged by its header: its frame dims.
const draggingBundleUid = ref<string | null>(null);
// `_uid` of the most recently dropped row — pulses for 450ms after a
// drop lands, then clears.
// Phase B.4: Set + order map for multi-child pulse on cross-node bundle
@@ -90,9 +88,9 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
const suppressMove = ref(false);
let suppressMoveTimer: number | null = null;
// Drag anchor: the row or bundle whose kit class (row-drag.css) opens
// the gap with the drop line in it. Computed once per dragOver change so
// rowGap, bundleHeaderGap and the line styles share one answer.
// Drag anchor: the row or bundle whose CSS class opens the gap for the
// indicator bar. Computed once per dragOver change so rowGap +
// bundleGap + dropBarFor all consult the same source of truth.
//
// Slot semantics:
// - `insertIdx === <item start_idx>` → anchor at THAT item with pos
@@ -100,14 +98,11 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
// - No matching item → anchor at the LAST item in the container with
// pos "after" (bar below)
//
// Empty container → no anchor (the empty-state hero takes the drop).
// A closed bundle → "into": the whole frame is the target.
// Empty container → no anchor (bar paints at container center via the
// dropBarFor fallback).
type AnchorInfo =
| { kind: "row"; idx: number; pos: "before" | "after"; containerUid: string | null }
| { kind: "bundle"; uid: string; pos: "before" | "after"; containerUid: string | null }
// A closed bundle takes the drop as a whole: its frame lights up and a
// line in its colour runs under the header (no gap, its rows are hidden).
| { kind: "into"; uid: string; containerUid: string };
| { kind: "bundle"; uid: string; pos: "before" | "after"; containerUid: string | null };
function containerItems(
containerUid: string | null,
@@ -150,10 +145,6 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
const dragAnchor = computed<AnchorInfo | null>(() => {
const z = dragOver.value;
if (!z) return null;
if (z.containerUid !== null) {
const target = (value.value.bundles ?? []).find((b) => b._uid === z.containerUid);
if (target?.collapsed) return { kind: "into", uid: target._uid, containerUid: target._uid };
}
const items = containerItems(z.containerUid, value.value);
if (items.length === 0) return null;
for (const it of items) {
@@ -184,41 +175,6 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
return a.pos;
}
/** The drop line's colour: a bundle's own colour when the drop lands in
* that bundle, the accent at the top level (the kit's default). */
function containerLineColor(containerUid: string | null): string | null {
if (containerUid === null) return null;
const b = (value.value.bundles ?? []).find((bb) => bb._uid === containerUid);
if (!b) return null;
return b.color ? b.color : "var(--wp-bundle-default)";
}
/** CSS variables for the kit's drop line on its anchor (row or bundle
* frame), so a line inside a bundle wears that bundle's colour. */
function dropLineStyle(a: AnchorInfo | null): Record<string, string> | undefined {
if (!a) return undefined;
const color = containerLineColor(a.containerUid);
return color ? { "--wp-rowdrag-color": color } : undefined;
}
function rowLineStyle(idx: number): Record<string, string> | undefined {
const a = dragAnchor.value;
if (!a || a.kind !== "row" || a.idx !== idx) return undefined;
return dropLineStyle(a);
}
function bundleLineStyle(uid: string): Record<string, string> | undefined {
const a = dragAnchor.value;
if (!a || a.kind !== "bundle" || a.uid !== uid) return undefined;
return dropLineStyle(a);
}
/** True when the drop goes into this CLOSED bundle (it opens on drop). */
function isBundleDropInto(uid: string): boolean {
const a = dragAnchor.value;
return a?.kind === "into" && a.uid === uid;
}
// Bundle gap (frame margin opens above/below the bundle). Reads the
// drag anchor; returns "before" when this bundle is the anchor with
// pos "before", "after" when it's the anchor with pos "after".
@@ -239,6 +195,110 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
return z.containerUid === uid;
}
/** Looks up the indicator-bar position for a drop container.
*
* `containerUid = null` → top-level list (the `.wp-modules` container
* inside the `.wp-modules-frame` ref).
* `containerUid = bundleUid` → that bundle's `.wp-bundle-children`
* scrolling body.
*
* Returns `{top}` in pixels relative to that container's content box
* when the resolved zone targets the container; null otherwise. The
* BundleDropBar component renders an absolutely-positioned line at the
* returned offset.
*
* The bar paints in the MIDDLE of the gap opened by the `wp-gap-*`
* margin (14px) — half that offset (7px) lifts the bar off the row's
* edge so it sits in the gap visibly rather than overlapping the
* row's own top/bottom border. That gives ONE clear line per drop
* position instead of "is that the bar or the row's border?".
*/
const GAP_BAR_OFFSET = 7;
function dropBarFor(containerUid: string | null): { top: number } | null {
const a = dragAnchor.value;
if (!a) {
// Empty container case (only fires for empty top-level — bundles
// always have ≥1 child by invariant). Paint at container center.
const z = dragOver.value;
if (!z || (z.containerUid ?? null) !== (containerUid ?? null)) return null;
const containerEl = findContainerEl(containerUid);
if (!containerEl) return null;
return { top: containerEl.clientHeight / 2 };
}
if ((a.containerUid ?? null) !== (containerUid ?? null)) return null;
const containerEl = findContainerEl(containerUid);
if (!containerEl) return null;
// CRITICAL: ComfyUI applies `transform: scale(zoom)` to the dom-widget
// container. `getBoundingClientRect` returns VIEWPORT pixels (scaled).
// `offsetTop` is in CSS pixels relative to offsetParent — that's the
// right unit for `position:absolute; top` under a transformed ancestor.
const anchorEl: HTMLElement | null =
a.kind === "row" ? findRowEl(a.idx, containerEl) : findBundleFrameEl(a.uid, containerEl);
if (!anchorEl) return null;
const off = elementOffsetTopWithin(anchorEl, containerEl);
return {
top: a.pos === "before"
? off - GAP_BAR_OFFSET
: off + anchorEl.offsetHeight + GAP_BAR_OFFSET,
};
}
/** Walks `offsetParent` chain from `el` up until reaching `containerEl`,
* accumulating CSS-pixel offsets. Returns `el`'s top relative to
* `containerEl`'s padding edge (i.e. the same unit `position:absolute;
* top: <value>` resolves against when `containerEl` is positioned).
* Falls back to `el.offsetTop` if `containerEl` isn't reachable via
* the offsetParent chain (defensive — would indicate broken layout). */
function elementOffsetTopWithin(el: HTMLElement, containerEl: HTMLElement): number {
let off = 0;
let cur: HTMLElement | null = el;
let safety = 0;
while (cur && cur !== containerEl && safety < 32) {
off += cur.offsetTop;
cur = cur.offsetParent as HTMLElement | null;
safety += 1;
}
if (cur !== containerEl) return el.offsetTop;
return off;
}
/** Locate the offset container for a given scope. Top-level → the
* `.wp-modules` div inside modulesContainer.value. Nested → that
* bundle's `.wp-bundle-children`. Returns null when the container
* isn't mounted yet (e.g. early reactive read). */
function findContainerEl(containerUid: string | null): HTMLElement | null {
const frame = modulesContainer.value;
if (!frame) return null;
if (containerUid === null) {
return frame.querySelector<HTMLElement>(":scope > .wp-modules");
}
return frame.querySelector<HTMLElement>(
`.wp-bundle[data-bundle-uid="${containerUid}"] > .wp-bundle-children`,
);
}
/** Find a module row by its absolute module idx, restricted to a given
* container scope (so nested rows don't get matched at top level). */
function findRowEl(moduleIdx: number, containerEl: HTMLElement): HTMLElement | null {
return containerEl.querySelector<HTMLElement>(
`:scope > .wp-module[data-module-idx="${moduleIdx}"]`,
) ?? containerEl.querySelector<HTMLElement>(
`.wp-module[data-module-idx="${moduleIdx}"]`,
);
}
/** Find a bundle's outer wrapper element. Used for header.before/after
* anchoring; `:scope >` keeps us in the container's immediate scope
* so nested bundles don't bleed into a parent container's lookup. */
function findBundleFrameEl(uid: string, containerEl: HTMLElement): HTMLElement | null {
return containerEl.querySelector<HTMLElement>(
`:scope > .wp-bundle[data-bundle-uid="${uid}"]`,
) ?? containerEl.querySelector<HTMLElement>(
`.wp-bundle[data-bundle-uid="${uid}"]`,
);
}
function clearDragHover() {
if (dragOver.value === null) return;
dragOver.value = null;
@@ -408,7 +468,6 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
children,
innerInstances,
};
draggingBundleUid.value = uid;
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", `bundle:${uid}`);
@@ -470,7 +529,6 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
sameNodeDropHandled = false;
dragOver.value = null;
draggingModuleUid.value = null;
draggingBundleUid.value = null;
}
let sameNodeDropHandled = false;
@@ -485,8 +543,9 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
/** Dragover handler — delegates pointer-to-zone classification to the
* pure resolver. The resolver walks DOM from the pointer (innermost
* `.wp-bundle` ancestor wins as the target container) and applies the
* tier-2 cap. ContextWidget just sets `dragOver.value`; the anchor's
* kit classes paint the gap and line. */
* tier-2 cap. ContextWidget just sets `dragOver.value`; the indicator
* paint happens via the per-container `<BundleDropBar>` reading
* `dropBarFor()`. */
function onListDragOver(ev: DragEvent) {
const ds = dragState.value;
if (!ds) return;
@@ -654,10 +713,7 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
return;
}
holdSuppressMove();
// A bundle dropped into a closed bundle opens it, like a row does.
const parentUid = zone?.containerUid ?? null;
commitModules(next.modules, (next.bundles ?? []).map((b) =>
parentUid && b._uid === parentUid && b.collapsed ? { ...b, collapsed: false } : b));
commitModules(next.modules, next.bundles);
await nextTick();
playFlipSnapshot(flipSnap);
// Pulse the bundle wrapper AND every row in its range so a
@@ -712,11 +768,7 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
// reconcileBundleRanges (via commitModules) will recompute
// start_idx/end_idx for every bundle from each child's bundle_origin
// chain — the placeholder ranges on freshInners get overwritten.
commitModules(list, [
...bundles.map((b) => (outerParentUid && b._uid === outerParentUid && b.collapsed ? { ...b, collapsed: false } : b)),
newBundle,
...freshInners,
]);
commitModules(list, [...bundles, newBundle, ...freshInners]);
// Snap fingerprints on the freshly-cloned outer + inners so the
// cross-node receive registers as a clean baseline (matches the
// semantics of a library insert).
@@ -799,11 +851,11 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
}
const rowCtx = {
rowGap, rowLineStyle, draggingModuleUid, recentDropUids, pulseDelayFor, onDragStart, onDragEnd,
rowGap, draggingModuleUid, recentDropUids, pulseDelayFor, onDragStart, onDragEnd,
} satisfies Partial<ModuleRowCtx>;
const frameCtx = {
bundleHeaderGap, bundleLineStyle, isBundleDropTarget, isBundleDropInto, draggingBundleUid,
recentDropUids, pulseDelayFor, onBundleDragStart, onDragEnd,
bundleHeaderGap, isBundleDropTarget, recentDropUids, pulseDelayFor,
onBundleDragStart, onDragEnd, dropBarFor,
} satisfies Partial<BundleFrameCtx>;
return {
+17 -34
View File
@@ -59,6 +59,9 @@
display: flex;
flex-direction: column;
gap: var(--wp-row-gap);
/* Drop-bar anchor — the floating <BundleDropBar :container-uid="null">
* is absolutely positioned and needs this as its offset parent so
* its top/left coordinates resolve against the top-level scope. */
position: relative;
}
@@ -147,10 +150,6 @@
border-radius: var(--wp-radius, 4px);
background: color-mix(in srgb, var(--wp-bundle-color, var(--wp-bundle-default)) 5%, transparent);
display: grid;
/* minmax(0, 1fr): an auto column grows to the header's min-content, so
* on a narrow node (or the Settings live preview) the frame and its
* children ran past the right edge instead of the header shortening. */
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr;
transition: grid-template-rows var(--wp-motion-collapse) var(--wp-motion-curve-collapse),
border-width var(--wp-motion-quick) ease,
@@ -479,43 +478,27 @@
.wp-module.wp-mod--drift { border-color: var(--wp-warn); background: color-mix(in srgb, var(--wp-warn) 8%, var(--wp-bg3)); }
.wp-module.wp-mod--err { border-color: var(--wp-danger); background: color-mix(in srgb, var(--wp-danger) 8%, var(--wp-bg3)); }
/* Drop gap + line: the kit's row-drag.css (the Context Injector's look).
* The row or bundle frame the drop lands beside gets `wp-rowdrag--before`
* / `--after`: a 14px gap opens with a glowing line in it, in the bundle's
* colour inside a bundle. The gap eases open (margins transition here
* too, since this rule outranks the kit's). */
/* Gap metaphor: target row gets margin so a slot opens; the
* `.wp-gap-bar` element paints inside that slot. Margin transitions
* via the row's existing `transition: transform/...` rule below; we
* extend it to cover `margin`. */
.wp-module.wp-gap-before,
.wp-bundle-header.wp-gap-before,
.wp-w-footer.wp-gap-before { margin-top: 14px; }
.wp-module.wp-gap-after { margin-bottom: 14px; }
/* Margins snap (no transition) so back-to-back zone changes don't
* leave half-open gaps mid-flight. Transform/opacity/etc still animate
* via TransitionGroup's FLIP rules + the wp-list-* classes below. */
.wp-modules .wp-module {
transition: transform var(--wp-motion-fade) var(--wp-motion-curve-flip),
margin-top var(--wp-motion-fade) var(--wp-motion-curve-flip),
margin-bottom var(--wp-motion-fade) var(--wp-motion-curve-flip),
background var(--wp-motion-quick) ease, border-color var(--wp-motion-quick) ease, opacity var(--wp-motion-quick) ease;
}
.wp-bundle-header { transition: border-bottom-color var(--wp-motion-fade) ease, background var(--wp-motion-quick) ease; }
.wp-bundle {
transition: border-width var(--wp-motion-quick) ease, background var(--wp-motion-quick) ease, box-shadow var(--wp-motion-quick) ease,
margin-top var(--wp-motion-fade) var(--wp-motion-curve-flip), margin-bottom var(--wp-motion-fade) var(--wp-motion-curve-flip),
opacity var(--wp-motion-quick) ease;
}
/* A closed bundle takes a drop as a whole: the frame lights up (drop
* target, above) and a line in its colour runs under the header, the
* same line the kit draws in a gap. It opens when the drop lands. */
.wp-bundle.wp-bundle--drop-into::after {
content: "";
position: absolute;
left: 6px;
right: 6px;
bottom: 3px;
z-index: 3;
height: 3px;
background: var(--wp-bundle-color, var(--wp-bundle-default));
border-radius: min(2px, var(--wp-radius-cap, 999px));
box-shadow: 0 0 8px var(--wp-bundle-color, var(--wp-bundle-default));
pointer-events: none;
animation: wp-rowdrag-line-in var(--wp-motion-quick, 120ms) ease-out;
}
@media (prefers-reduced-motion: reduce) {
.wp-bundle.wp-bundle--drop-into::after { animation: none; }
transition: border-width var(--wp-motion-quick) ease, background var(--wp-motion-quick) ease, box-shadow var(--wp-motion-quick) ease;
}
.wp-bundle.wp-gap-before { margin-top: 14px; }
.wp-bundle.wp-gap-after { margin-bottom: 14px; }
/* Source ghost — lift the row while it's mid-drag. */
.wp-module--dragging {
-3
View File
@@ -28,9 +28,6 @@ export interface ModuleRowCtx {
summaryTokens: (m: ModuleEntry) => Array<{ kind: string; text: string; varName?: string }>;
siblingInfo: (m: ModuleEntry) => { index: number; total: number } | null;
rowGap: (idx: number) => "before" | "after" | null;
/** The kit drop line's colour variable on this row when it anchors the
* drop (a bundle's colour inside that bundle). */
rowLineStyle: (idx: number) => Record<string, string> | undefined;
draggingModuleUid: Ref<string | null>;
recentDropUids: Ref<Set<string>>;
pulseDelayFor: (uid: string | null | undefined) => string;
+4 -13
View File
@@ -470,7 +470,7 @@ const nameWidth = computed(() => {
.wp-dbg-vsec__title { font: 600 11.5px/1.4 var(--wp-font-sans); color: var(--wp-text); }
.wp-dbg-vsec__codename {
display: inline-flex; align-items: center;
height: 18px; box-sizing: border-box;
height: 17px; box-sizing: border-box;
font: 600 10px/1 var(--wp-font-mono);
letter-spacing: 0.03em;
color: var(--wp-text);
@@ -481,10 +481,6 @@ const nameWidth = computed(() => {
}
.wp-dbg-vsec__count { font: 500 9.5px/1 var(--wp-font-mono); }
.wp-dbg-vsec__goto.wp-btn { margin-left: auto; height: auto; min-height: 0; padding: 0; font-size: 10px; }
/* Only the word is underlined, not the icon (wp-kit fixes this for every
* link button from 0.20; drop this once the kit is bumped). */
.wp-dbg-vsec__goto.wp-btn:is(:hover, :focus-visible) { text-decoration: none; }
.wp-dbg-vsec__goto.wp-btn:is(:hover, :focus-visible) :deep(.wp-btn__label) { text-decoration: underline; }
.wp-dbg-var-row {
display: grid;
grid-template-columns: max(90px, var(--wp-dbg-name-w, 90px)) minmax(0, 1fr) auto;
@@ -535,14 +531,9 @@ const nameWidth = computed(() => {
}
.wp-btn.wp-dbg-var-row__more { align-self: flex-start; height: auto; min-height: 0; padding: 0; font-size: 10.5px; }
.wp-dbg-var-row__extra { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
/* Kit chips, sized like the switch chip; the accessor reads in mono.
18px, not 17: inside the 1px border that leaves an even 16px for a 10px
line, so the text and icon land on whole pixels. At 17 the half-pixel
gap rounded the icon up and the text down (Firefox, canvas zoom), and
the chip looked off-centre. Same for the switch/type chips and the
codename pill below. */
/* Kit chips, sized like the switch chip; the accessor reads in mono. */
.wp-chip.wp-dbg-var-row__axis, .wp-chip.wp-dbg-var-row__tag {
height: 18px; min-height: 0; box-sizing: border-box;
height: 17px; min-height: 0; box-sizing: border-box;
padding: 0 6px; gap: 4px;
font: 500 10px/1 var(--wp-font-sans);
}
@@ -571,7 +562,7 @@ const nameWidth = computed(() => {
.wp-dbg-var-row__radio { font-size: 9px; color: var(--wp-dbg-kind); }
/* Kit chips sized to the row's text so they don't push it around. */
.wp-chip.wp-dbg-var-row__switch, .wp-chip.wp-dbg-var-row__type {
height: 18px; min-height: 0; box-sizing: border-box;
height: 17px; min-height: 0; box-sizing: border-box;
padding: 0 6px; gap: 4px;
font: 600 10px/1 var(--wp-font-mono);
vertical-align: middle;
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { valueTypeLook } from "./value-types";
import { computed } from "vue";
import { computed, ref } from "vue";
import type { InjectorRow } from "../../widgets/_shared";
import Checkbox from "@/kit/Checkbox.vue";
import Input from "@/kit/Input.vue";
@@ -668,26 +668,3 @@ describe("InjectorWidget — collapse-connections button", () => {
expect(w.emitted("toggle-connections-collapse")).toHaveLength(1);
});
});
describe("InjectorWidget — Show node", () => {
const ROWS = JSON.stringify({
version: 1,
rows: [
{ _uid: "g1", kind: "general", slot_name: "", binding: "mood", template: "calm", enabled: true, internal: false },
],
});
it("opens the folded row list when WP Debug points at one of its rows", async () => {
const w = mount(InjectorWidget, { props: { nodeId: 7, initialJson: ROWS } });
await w.find('[data-test="inj-toolbar-collapse"]').trigger("click");
expect(w.find(".wp-inj-rows-wrap--collapsed").exists()).toBe(true);
const root = w.find("[data-wp-reveal]").element;
root.dispatchEvent(new CustomEvent("wp-reveal", { detail: { vars: ["other"] } }));
await w.vm.$nextTick();
expect(w.find(".wp-inj-rows-wrap--collapsed").exists()).toBe(true);
root.dispatchEvent(new CustomEvent("wp-reveal", { detail: { vars: ["mood"] } }));
await w.vm.$nextTick();
expect(w.find(".wp-inj-rows-wrap--collapsed").exists()).toBe(false);
w.unmount();
});
});
+1 -12
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { revealTarget } from "../../widgets/flash-target";
import { computed, nextTick, ref, watch } from "vue";
import {
emptyInjectorRowsValue,
@@ -262,16 +261,6 @@ watch(
* reload restores collapsed state. */
const collapsed = ref<boolean>(false);
/** WP Debug "Show node": open the list when it hides the row it names. */
function onReveal(ev: Event): void {
const t = revealTarget(ev);
if (!t || !collapsed.value) return;
const vars = new Set(t.vars ?? []);
if (value.value.rows.some((r) => r._uid === t.uid || vars.has(r.binding.trim().replace(/^\$/, "")))) {
collapsed.value = false;
}
}
function persist(): void {
const serialized = serializeWidgetJson(value.value);
emit("change", serialized);
@@ -693,7 +682,7 @@ defineExpose({ addRow, addGeneralRow, removeRow });
</script>
<template>
<div class="wp-inj-widget" data-wp-reveal :class="{ 'wp-inj-widget--skipped': isSkipped }" @wp-reveal="onReveal">
<div class="wp-inj-widget" :class="{ 'wp-inj-widget--skipped': isSkipped }">
<div class="wp-inj-header">
<WpIconButton
variant="bare"
+1 -7
View File
@@ -108,7 +108,7 @@ function flip(): void {
</script>
<template>
<div class="wp-tgr" :class="{ 'is-tile': tile }" :style="indent ? { marginLeft: `${indent}px` } : undefined" :data-wp-vars="t.var || undefined">
<div class="wp-tgr" :class="{ 'is-wide': tile && look === 'buttons' }" :style="indent ? { marginLeft: `${indent}px` } : undefined" :data-wp-vars="t.var || undefined">
<div
class="wp-tgr__row"
:class="{ 'is-blocked': blocked, 'is-on': effOn, 'is-chip': look === 'chip', 'is-editing': editing, 'has-icon-color': !!t.icon_color, 'has-text-color': !!t.text_color }"
@@ -296,12 +296,6 @@ function flip(): void {
.wp-tgr__chip { display: flex; flex: 1 1 0; min-width: 0; }
.wp-tgr__chip :deep(.wp-switch--chip) { width: 100%; min-width: 0; justify-content: center; }
.wp-tgr__row.is-chip .wp-tgr__sub { flex: 0 1 auto; max-width: 50%; }
/* In a grid every tile in a line is as tall as the tallest (a name + var
* tile), and a chip fills its tile, so a chip next to a switch or a checkbox
* isn't a smaller box. */
.wp-tgr.is-tile > .wp-tgr__row { flex: 1 1 auto; }
.wp-tgr.is-tile .wp-tgr__row.is-chip { align-items: stretch; }
.wp-tgr.is-tile .wp-tgr__chip :deep(.wp-switch--chip) { height: auto; min-height: var(--wp-control-h-sm); }
.wp-tgr__sub {
display: inline-flex;
align-items: center;
@@ -133,75 +133,3 @@ describe("TogglesWidget folded group", () => {
w.unmount();
});
});
describe("TogglesWidget edit-view drag", () => {
const grouped = (): TogglesConfig => ({
...defaultTogglesConfig(),
rows: [
row("00000001", "Top", false),
{ id: "0000000a", kind: "label", name: "Light", color: 2 },
row("00000002", "Rim", false),
row("00000003", "Fog", false),
{ id: "0000000b", kind: "label", name: "Sky", color: 3 },
row("00000004", "Rain", false),
],
});
it("drops a toggle below a group, into no group, with a full-width line", async () => {
const w = mount(TogglesWidget, { props: { modelValue: grouped(), editRequest: 0 } });
await w.setProps({ editRequest: 1 });
const list = w.findComponent({ name: "SortableList" });
const dropLine = list.props("dropLine") as (d: { from: number; over: number; edge: string }, noOp: boolean) => unknown;
// Shown: Top, Light, Rim, Fog, Sky, Rain. Top before Sky: below Light.
expect(dropLine({ from: 0, over: 4, edge: "before" }, false)).toBe(true);
// Top after Rim: inside Light, an inset line in its colour.
expect(dropLine({ from: 0, over: 2, edge: "after" }, false)).toMatchObject({ inset: 22, color: "var(--wp-var-2)" });
// A label can't land inside another group.
expect(dropLine({ from: 4, over: 2, edge: "after" }, false)).toBe(false);
list.vm.$emit("drop", { from: 0, over: 4, edge: "before", to: 3 });
const out = lastConfig(w);
expect(out?.rows.map((r) => r.id)).toEqual(["0000000a", "00000002", "00000003", "00000001", "0000000b", "00000004"]);
expect(out?.rows.find((r) => r.id === "00000001")).toMatchObject({ ungrouped: true });
w.unmount();
});
it("draws a toggle below a group outside it in the clean view", () => {
const c = grouped();
c.rows = [c.rows[1], c.rows[2], { ...(c.rows[0] as ToggleRow), ungrouped: true }, c.rows[4], c.rows[5]];
const w = mount(TogglesWidget, { props: { modelValue: c } });
const top = w.find('[data-test="tg-row-00000001"]');
expect(top.exists()).toBe(true);
expect(top.element.closest(".wp-tg__member.is-grouped")).toBeNull();
w.unmount();
});
});
describe("TogglesWidget Show node", () => {
it("unfolds the label group that holds the toggle WP Debug points at", async () => {
const config: TogglesConfig = {
...defaultTogglesConfig(),
mode: "any",
rows: [
{ id: "0000000a", kind: "label", name: "Weather", color: 1, collapsed: true },
row("00000001", "Rain", false),
{ id: "0000000b", kind: "label", name: "Light", color: 2, collapsed: true },
row("00000002", "Sun", true),
],
};
const w = mount(TogglesWidget, { props: { modelValue: config } });
const root = w.find("[data-wp-reveal]");
expect(root.exists()).toBe(true);
root.element.dispatchEvent(new CustomEvent("wp-reveal", { detail: { vars: ["rain"] } }));
const next = lastConfig(w);
const labels = next?.rows.filter((r) => r.kind === "label").map((r) => [r.name, r.kind === "label" && r.collapsed === true]);
expect(labels).toEqual([["Weather", false], ["Light", true]]);
w.unmount();
});
it("changes nothing when the toggle is already in sight", () => {
const w = mount(TogglesWidget, { props: { modelValue: cfg("any") } });
w.find("[data-wp-reveal]").element.dispatchEvent(new CustomEvent("wp-reveal", { detail: { vars: ["dawn"] } }));
expect(w.emitted("update:modelValue")).toBeUndefined();
w.unmount();
});
});
@@ -1,77 +0,0 @@
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();
});
});
+20 -79
View File
@@ -16,7 +16,7 @@
*
* Toggles belong to the label above them. One sortable list holds labels
* and toggles, so a toggle drags into or out of a group and a label drags
* with its toggles (`dropToggleRow`); the edit panel's Group field moves one too.
* with its toggles (`dragRow`); the edit panel's Group field moves one too.
*
* Value contract: `modelValue` is a `TogglesConfig`; every edit emits the
* whole next config (`src/widgets/toggles.ts` fits the outputs to it).
@@ -53,12 +53,9 @@ import {
type TogglesMode,
type TogglesItem,
type PickStyle,
dropToggleRow,
type ToggleDropEdge,
dragRow,
visibleRows,
unfoldForVars,
} from "../../extension/toggles";
import { revealTarget } from "../../widgets/flash-target";
import { ctlInputName, type ControlState } from "../../extension/toggle-control";
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
import { vToggleRow } from "@/kit/toggle-row";
@@ -221,27 +218,11 @@ 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;
@@ -294,7 +275,7 @@ function removeToggle(t: ToggleRow): void {
}
/** Remove a label; its toggles join the group above. */
function removeLabel(l: LabelRow): void {
setRows(rowsOf(groupsOf({ ...cfg.value, rows: cfg.value.rows.filter((r) => r.id !== l.id) })));
setRows(cfg.value.rows.filter((r) => r.id !== l.id));
}
function toggleEdit(t: ToggleRow): void {
@@ -372,10 +353,7 @@ function needsOptions(t: ToggleRow): { value: string; label: string }[] {
}
function groupOptions(): { value: string; label: string }[] {
// One "no group" choice, however many runs of loose toggles there are.
return groups.value
.filter((g, i) => g.label || i === 0)
.map((g) => ({ value: g.label?.id ?? "", label: g.label ? (g.label.name || "Group") : "No label (top)" }));
return groups.value.map((g) => ({ value: g.label?.id ?? "", label: g.label ? (g.label.name || "Group") : "No label (top)" }));
}
function groupIdOf(t: ToggleRow): string {
return groups.value.find((g) => g.toggles.some((x) => x.id === t.id))?.label?.id ?? "";
@@ -386,29 +364,6 @@ type LabelledGroup = ToggleGroup & { label: LabelRow };
const labelled = computed(() => groups.value.filter((g): g is LabelledGroup => g.label !== null));
/** The edit view's rows: labels and the toggles of unfolded groups. */
const shownRows = computed(() => visibleRows(cfg.value));
/** A group's key: its label's id; `top` for the toggles above the first
* label, and `loose-<first toggle>` for a run dropped below a group. */
function groupKey(g: ToggleGroup, i: number): string {
return g.label?.id ?? (i === 0 ? "top" : `loose-${g.toggles[0]?.id ?? i}`);
}
/** Where an edit-view drag would land, as the kit list asks it: no line
* when it can't land there; inside a group the line is indented and wears
* the group's colour, below a group it spans the row. */
function dropPlanFor(d: { from: number; over: number; edge: ToggleDropEdge }) {
const from = shownRows.value[d.from];
const over = shownRows.value[d.over];
return from && over ? dropToggleRow(cfg.value, from.id, over.id, d.edge) : null;
}
function dropLineFor(d: { from: number; over: number; edge: ToggleDropEdge }): { inset?: number; color?: string } | boolean {
const plan = dropPlanFor(d);
if (!plan) return false;
const moved = shownRows.value[d.from];
return plan.group && moved?.kind === "toggle" ? { inset: 22, color: labelColor(plan.group) } : true;
}
function onDrop(d: { from: number; over: number; edge: ToggleDropEdge }): void {
const plan = dropPlanFor(d);
if (plan) setRows(plan.rows);
}
function groupOfLabel(l: LabelRow): ToggleGroup {
return groups.value.find((g) => g.label?.id === l.id) ?? { label: l, toggles: [] };
}
@@ -435,11 +390,6 @@ function labelPickerValue(l: LabelRow): string {
function setLabelColor(l: LabelRow, v: string): void {
patchLabel(l.id, { hex: /^#[0-9a-f]{6}$/i.test(v.slice(0, 7)) ? v.slice(0, 7).toLowerCase() : undefined });
}
/** WP Debug "Show node": unfold the label groups that hide its toggle. */
function onReveal(ev: Event): void {
const rows = unfoldForVars(cfg.value, revealTarget(ev)?.vars ?? []);
if (rows) setRows(rows);
}
function onCount(g: ToggleGroup): number {
return g.toggles.filter((t) => eff.value.get(t.id)).length;
}
@@ -460,7 +410,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
</script>
<template>
<div ref="root" class="wp-tg" data-wp-reveal :class="{ 'wp-tg--skipped': isSkipped, 'wp-tg--edit': editMode }" @wp-reveal="onReveal">
<div ref="root" class="wp-tg" :class="{ 'wp-tg--skipped': isSkipped, 'wp-tg--edit': editMode }">
<div v-if="editMode" class="wp-tg__bar">
<WpSegmented
:model-value="cfg.mode"
@@ -525,16 +475,6 @@ 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
@@ -548,7 +488,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
</span>
</div>
<template v-for="(g, gi) in groups" :key="groupKey(g, gi)">
<template v-for="g in groups" :key="g.label?.id ?? '__top'">
<!-- Clean view: plain rows, no grips. -->
<div v-if="!editMode" class="wp-tg__group-box">
<div
@@ -587,7 +527,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
class="wp-tg__sockline is-on"
:data-wp-out="mergedOuts(socketLines(g))"
:data-wp-socket="mergedSockets(socketLines(g))"
:data-test="`tg-sockmerged-${groupKey(g, gi)}`"
:data-test="`tg-sockmerged-${g.label?.id ?? 'top'}`"
>
<span class="wp-tg__sockpad"><WpIconButton variant="bare" icon="pi-caret-right" label="Show each toggle's wires" data-test="tg-sock-expand" @click="toggleMerged(g)" /></span>
<span v-tooltip="sockNames(g, 'in')" class="wp-tg__sockname">{{ sockCount(g, "in") }}</span>
@@ -599,7 +539,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
class="wp-tg__sockline"
:data-wp-out="line.right ? `toggle_${line.right.out}` : undefined"
:data-wp-socket="line.left ? ctlInputName(line.left.id) : undefined"
:data-test="`tg-sockline-${groupKey(g, gi)}-${i}`"
:data-test="`tg-sockline-${g.label?.id ?? 'top'}-${i}`"
>
<!-- One caret column for every line, so the names line up. -->
<span v-if="sockPairs(g).length > 1" class="wp-tg__sockpad">
@@ -633,8 +573,8 @@ function foldedOuts(g: ToggleGroup): string | undefined {
</template>
<template v-if="editMode">
<!-- One list for toggles and labels, so a toggle drags into, out of or
below a group and a label drags with its toggles (`dropToggleRow`). -->
<!-- One list for toggles and labels, so a toggle drags into or out of
a group and a label drags with its toggles (`dragRow`). -->
<SortableList
v-if="shownRows.length"
:model-value="shownRows"
@@ -643,9 +583,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
label="Toggles and labels"
size="sm"
class="wp-tg__list"
end-zone
:drop-line="dropLineFor"
@drop="onDrop"
@move="(m: { from: number; to: number }) => setRows(dragRow(cfg, m.from, m.to))"
>
<template #item="{ item }">
<div
@@ -784,16 +722,19 @@ function foldedOuts(g: ToggleGroup): string | undefined {
.wp-tg--edit .wp-tg__label { min-height: 38px; }
.wp-tg__member.is-grouped { padding-left: 8px; border-left: 2px solid var(--wp-tg-group, var(--wp-border)); }
.wp-tg__group-box { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
/* Grid layout: each tile is as wide as what it shows (name, var, control),
* tiles wrap to the node's width, never resize it, and the tiles in a line
* grow to fill it, so nothing is cut short or stretched to two columns. */
/* Grid layout: tiles wrap to the node's width, never resize it. */
.wp-tg__grid {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 2px;
min-width: 0;
}
.wp-tg__grid > .wp-tgr { flex: 1 1 auto; min-width: 96px; max-width: 100%; }
/* A Buttons toggle (two words) takes two tiles once two fit, so its name
* isn't cut to "Tog…". */
.wp-tg__grid { container-type: inline-size; }
@container (min-width: 250px) {
.wp-tg__grid > .wp-tgr.is-wide { grid-column: span 2; }
}
/* Grid: a thin line under the tiles per wired toggle, so its control dot
* (left) and BOOLEAN output (right) sit level with its name. */
.wp-tg__sockline {
File diff suppressed because one or more lines are too long
-17
View File
@@ -277,23 +277,6 @@ describe("collectUpstreamProducers", () => {
});
});
it("collectUpstreamInjectorNegatives: a toggle's own negative, or null when it has none", () => {
const rows = [
{ id: "a", kind: "toggle", name: "Rain", var: "rain", on: true, pin: false, out: 0, style: "switch", value: "in the rain", negative: "sunny sky" },
{ id: "b", kind: "toggle", name: "Fog", var: "fog", on: false, pin: false, out: 0, style: "switch", off_negative: "clear air" },
{ id: "c", kind: "toggle", name: "Wind", var: "wind", on: true, pin: false, out: 0, style: "switch" },
];
const tg: LiteNodeLike = {
id: 1,
type: "WP_Toggles",
outputs: [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }],
widgets: [{ name: "wp_toggles", value: JSON.stringify({ version: 1, mode: "any", group_var: "", rows }) }],
};
const pov = ctxWriting(2, "Other", "unused", 100);
const g = chain([tg, pov], { 100: { origin_id: 1, target_id: 2 } });
expect(collectUpstreamInjectorNegatives(g, pov)).toEqual({ rain: "sunny sky", fog: "clear air", wind: null });
});
it("attributes the loop's iteration vars, including the _total pair", () => {
const loop: LiteNodeLike = {
id: 1,
+2 -22
View File
@@ -19,7 +19,7 @@ import {
cacheVersion as previewCacheVersion,
} from "./preview-resolver";
import { assignCodenames, baseCodename } from "./node-codename";
import { TOGGLES_NODE, staticToggleInternals, staticToggleNegatives, staticToggleValues, togglesPreviewModules } from "./toggles";
import { TOGGLES_NODE, staticToggleInternals, staticToggleValues, togglesPreviewModules } from "./toggles";
import { MODEL_INFO_NODE, MODEL_VARS, internalModelVars, modelInfoPreviewModules, staticModelValues } from "./model-info";
import { clauseActions, derivationTargets, evalConditionTree, isNegativeMode } from "./derivation-conditions";
import type { SeedStrategy } from "../components/shared/seed-derive";
@@ -760,8 +760,7 @@ export function collectUpstreamInjectorNegatives(
continue;
}
if (n.type === TOGGLES_NODE && !isSkippedMode(n)) {
// A write replaces the variable's negatives with the toggle's own.
for (const [v, neg] of Object.entries(staticToggleNegatives(n))) out[v] = neg || null;
for (const v of Object.keys(staticToggleValues(n))) out[v] = null;
continue;
}
if (n.type !== "WP_ContextInjector" || isSkippedMode(n)) continue;
@@ -2227,25 +2226,6 @@ export function findDownstreamAssemblers(rootGraph: LiteGraphLike, node: LiteNod
return out;
}
/**
* The PIPELINE_CONTEXT chain above `node`, closest first (the node itself is
* not included). Same walk the chip list and producer map use, so callers
* that act on a writer (Assembler › Mark unused internal) see the chain the
* Assembler sees. Muted / bypassed nodes are left in; callers skip them.
*/
export function upstreamChainOf(rootGraph: LiteGraphLike, node: LiteNodeLike): LiteNodeLike[] {
const parents = buildSubgraphParents(rootGraph);
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
const chain: LiteNodeLike[] = [];
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
while (cur && !seen.has(locator(cur.graph, cur.node))) {
seen.add(locator(cur.graph, cur.node));
chain.push(cur.node);
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
}
return chain;
}
/**
* Resolve a downstream link target through any subgraph boundaries:
* - target_id === -20: link enters the SubgraphOutputNode → resume from
-234
View File
@@ -1,234 +0,0 @@
import { describe, expect, it } from "vitest";
import type { LiteGraphLike, LiteLink, LiteNodeLike } from "./graph";
import { applyMarkUnused, nameList, planMarkUnused, summary, undoMarkUnused } from "./mark-unused";
type Node = LiteNodeLike & { widgets: { name: string; value: unknown }[] };
/** A graph of `nodes` wired by `[from, to]` PIPELINE_CONTEXT links (output 0
* → input 0). Each node gets `graph` set, like litegraph does. */
function build(nodes: Node[], wires: Array<[number, number]>): LiteGraphLike {
const links: Record<number, LiteLink> = {};
const byId = new Map(nodes.map((n) => [n.id, n]));
wires.forEach(([from, to], i) => {
const id = 100 + i;
links[id] = { id, origin_id: from, origin_slot: 0, target_id: to, target_slot: 0 };
const src = byId.get(from);
const dst = byId.get(to);
if (!src || !dst) throw new Error("bad wire");
const out = src.outputs?.[0];
if (out) out.links = [...(out.links ?? []), id];
const inp = dst.inputs?.[0];
if (inp) inp.link = id;
});
const graph: LiteGraphLike = {
_nodes: nodes,
links,
getNodeById: (id) => byId.get(Number(id)) ?? null,
};
for (const n of nodes) n.graph = graph;
return graph;
}
const ctxOut = () => [{ name: "context", links: [] as number[], type: "PIPELINE_CONTEXT" }];
function context(id: number, modules: unknown[]): Node {
return {
id, type: "WP_Context",
inputs: [{ name: "upstream", link: null }],
outputs: ctxOut(),
widgets: [{ name: "wp_modules", value: JSON.stringify({ version: 1, modules }) }],
};
}
function assembler(id: number, template: string, negative = "$negatives"): Node {
return {
id, type: "WP_PromptAssembler",
inputs: [{ name: "context", link: null }],
outputs: [{ name: "prompt", links: [], type: "STRING" }],
widgets: [{ name: "template", value: template }, { name: "negative_template", value: negative }],
};
}
const wildcard = (uid: string, v: string, internal = false) => ({
id: uid, _uid: uid, type: "wildcard", enabled: true, meta: { name: v }, entries: [],
payload: { var_binding: v }, instance: internal ? { internal: true } : {},
});
const combine = (uid: string, v: string) => ({
id: uid, _uid: uid, type: "combine", enabled: true, meta: { name: v }, entries: [],
payload: { output_var: v, template: "$hero in $place" }, instance: {},
});
const fixed = (uid: string, names: string[]) => ({
id: uid, _uid: uid, type: "fixed_values", enabled: true, meta: { name: "fv" }, entries: [],
payload: { values: names.map((n, i) => ({ id: `v${i}`, name: n, value: "x" })) }, instance: {},
});
const derivation = (uid: string, target: string) => ({
id: uid, _uid: uid, type: "derivation", enabled: true, meta: { name: "d" }, entries: [],
payload: { rules: [{ branches: [{ action: { target_var: target, value: "calm" } }] }] }, instance: {},
});
function modulesOf(n: Node): Array<{ _uid: string; instance?: { internal?: boolean } }> {
const raw = n.widgets.find((w) => w.name === "wp_modules")?.value;
return (JSON.parse(String(raw)) as { modules: Array<{ _uid: string; instance?: { internal?: boolean } }> }).modules;
}
const internalOf = (n: Node, uid: string) => modulesOf(n).find((m) => m._uid === uid)?.instance?.internal === true;
describe("planMarkUnused", () => {
it("marks what no Assembler reads and keeps what another Assembler or a shared module needs", () => {
const ctx = context(1, [
wildcard("h", "hero"),
wildcard("p", "place"),
combine("s", "scene"),
fixed("f", ["a", "b"]),
derivation("d", "mood"),
wildcard("o", "outfit"),
]);
const a = assembler(2, "$scene, $a");
const b = assembler(3, "$outfit.SHOES", "$negatives, $mood.neg");
const g = build([ctx, a, b], [[1, 2], [1, 3]]);
const plan = planMarkUnused(g, a);
expect(plan.wired).toBe(false);
// $hero and $place are read only by the combine, which still reads an
// internal variable: hiding them from the prompt is what internal is for.
expect(plan.marked).toEqual(["hero", "place"]);
expect(plan.kept).toEqual([
{ name: "b", reason: "shared_module", also: "a" },
{ name: "mood", reason: "other_assembler" },
{ name: "outfit", reason: "other_assembler" },
]);
const changes = applyMarkUnused(plan);
expect(changes).toHaveLength(1);
expect(internalOf(ctx, "h")).toBe(true);
expect(internalOf(ctx, "p")).toBe(true);
expect(internalOf(ctx, "s")).toBe(false);
expect(internalOf(ctx, "f")).toBe(false);
expect(undoMarkUnused(changes)).toBe(0);
expect(internalOf(ctx, "h")).toBe(false);
});
it("only counts Assemblers below the writer, through later Contexts", () => {
const up = context(1, [wildcard("x", "x"), wildcard("y", "y")]);
const mid = context(2, [wildcard("z", "z")]);
const a = assembler(3, "$z");
const b = assembler(4, "$y");
// b hangs off `up` directly; a sits below `mid`.
const g = build([up, mid, a, b], [[1, 2], [2, 3], [1, 4]]);
const plan = planMarkUnused(g, a);
expect(plan.marked).toEqual(["x"]);
expect(plan.kept).toEqual([{ name: "y", reason: "other_assembler" }]);
});
it("skips variables already internal and writers that are muted or bypassed", () => {
const ctx = context(1, [wildcard("i", "inner", true), wildcard("v", "visible")]);
const muted = context(2, [wildcard("m", "muted")]);
muted.mode = 4;
const a = assembler(3, "");
const g = build([ctx, muted, a], [[1, 2], [2, 3]]);
expect(planMarkUnused(g, a).marked).toEqual(["visible"]);
});
it("uses the closest writer of a name", () => {
const up = context(1, [wildcard("u", "hero")]);
const down = context(2, [wildcard("d", "hero")]);
const a = assembler(3, "");
const g = build([up, down, a], [[1, 2], [2, 3]]);
applyMarkUnused(planMarkUnused(g, a));
expect(internalOf(down, "d")).toBe(true);
expect(internalOf(up, "u")).toBe(false);
});
it("refuses when this Assembler's template is wired", () => {
const ctx = context(1, [wildcard("h", "hero")]);
const a = assembler(2, "");
a.inputs?.push({ name: "template", link: 999 });
const g = build([ctx, a], [[1, 2]]);
expect(planMarkUnused(g, a)).toMatchObject({ wired: true, marked: [] });
});
it("keeps everything another Assembler could read through a wired template", () => {
const ctx = context(1, [wildcard("h", "hero")]);
const a = assembler(2, "");
const b = assembler(3, "");
b.inputs?.push({ name: "negative_template", link: 998 });
const g = build([ctx, a, b], [[1, 2], [1, 3]]);
const plan = planMarkUnused(g, a);
expect(plan.marked).toEqual([]);
expect(plan.kept).toEqual([{ name: "hero", reason: "wired_template" }]);
});
it("drops a bundle master's markers so its cascade can't undo the flag", () => {
const m = { ...wildcard("h", "hero"), instance: { _ui: { master_internal: false, master_internal_by: "b1", keep: 1 } } };
const ctx = context(1, [m]);
const a = assembler(2, "");
const g = build([ctx, a], [[1, 2]]);
applyMarkUnused(planMarkUnused(g, a));
const inst = (modulesOf(ctx)[0] as unknown as { instance: { internal: boolean; _ui: Record<string, unknown> } }).instance;
expect(inst).toEqual({ internal: true, _ui: { keep: 1 } });
});
});
describe("other writers", () => {
it("flips injector rows, toggles, model info and loop vars", () => {
const inj: Node = {
id: 1, type: "WP_ContextInjector",
inputs: [{ name: "upstream", link: null }], outputs: ctxOut(),
widgets: [{ name: "wp_rows", value: JSON.stringify({ version: 1, rows: [
{ binding: "shot", enabled: true },
{ binding: "used", enabled: true },
{ binding: "off", enabled: false },
] }) }],
};
const tog: Node = {
id: 2, type: "WP_Toggles",
inputs: [{ name: "upstream", link: null }], outputs: ctxOut(),
widgets: [{ name: "wp_toggles", value: JSON.stringify({ version: 1, mode: "any", group_var: "", rows: [
{ id: "t1", kind: "toggle", name: "Rain", var: "rain", on: true, visible: true },
{ id: "t2", kind: "toggle", name: "Hidden", var: "hidden", on: true },
] }) }],
};
const model: Node = {
id: 3, type: "WP_ModelInfo",
inputs: [{ name: "upstream", link: null }], outputs: ctxOut(),
widgets: [{ name: "wp_model_info", value: JSON.stringify({ version: 1, internal: { family: false, variant: true, name: false } }) }],
};
const loop: Node = {
id: 4, type: "WP_ContextLoop",
inputs: [{ name: "upstream", link: null }], outputs: ctxOut(),
widgets: [{ name: "wp_context_loop_config", value: JSON.stringify({ iteration_var_name: "frame", iteration_internal: false, total_internal: false }) }],
};
const a = assembler(5, "$used, $model_name, $frame_total");
const g = build([inj, tog, model, loop, a], [[1, 2], [2, 3], [3, 4], [4, 5]]);
const plan = planMarkUnused(g, a);
expect(plan.marked.sort()).toEqual(["frame", "model_family", "rain", "shot"]);
const changes = applyMarkUnused(plan);
expect(changes).toHaveLength(4);
const val = (n: Node, w: string) => JSON.parse(String(n.widgets.find((x) => x.name === w)?.value)) as Record<string, unknown>;
const rows = val(inj, "wp_rows").rows as Array<{ binding: string; internal?: boolean }>;
expect(rows.map((r) => r.internal === true)).toEqual([true, false, false]);
expect((val(tog, "wp_toggles").rows as Array<{ visible?: boolean }>)[0].visible).toBeUndefined();
expect(val(model, "wp_model_info").internal).toEqual({ family: true, variant: true, name: false });
expect(val(loop, "wp_context_loop_config")).toMatchObject({ iteration_internal: true, total_internal: false });
// Undo leaves a node edited since alone.
loop.widgets[0].value = JSON.stringify({ iteration_var_name: "frame" });
expect(undoMarkUnused(changes)).toBe(1);
expect((val(inj, "wp_rows").rows as Array<{ internal?: boolean }>)[0].internal).toBeUndefined();
expect(val(loop, "wp_context_loop_config")).toEqual({ iteration_var_name: "frame" });
});
});
describe("summary", () => {
it("names what was marked and why the rest stayed", () => {
expect(nameList(["a"])).toBe("$a");
expect(nameList(["a", "b"])).toBe("$a and $b");
expect(nameList(["a", "b", "c", "d", "e", "f"])).toBe("$a, $b, $c, $d and 2 more");
expect(summary({
wired: false, units: [],
marked: ["hero"],
kept: [{ name: "mood", reason: "other_assembler" }, { name: "b", reason: "shared_module", also: "a" }],
})).toBe("Marked 1 internal: $hero. Kept $mood: another Assembler uses it. Kept $b: its module also writes $a, which is in use.");
});
});
-392
View File
@@ -1,392 +0,0 @@
/**
* Assembler › "Mark unused internal": flag every upstream variable the
* Assembler's prompt and negative don't read as internal, unless another
* Assembler on the same context still reads it.
*
* Internal is decided per WRITER, last write wins (`collectUpstreamKinds`),
* and only the Prompt Assembler drops internal variables (combine,
* derivation, constraints, Var to X and later Contexts still read them). So:
*
* - a variable read only through another one (`$scene` = "$hero in $place")
* is safe to hide: the combine still reads `$hero`;
* - a variable is in use when ANY Assembler downstream of its writer reads
* it in its template or negative template (accessor reads `$x.0`, `$x.AXIS`,
* `$x.neg`, `$x.bool` count);
* - a Context module carries ONE flag for every name it writes, so it is
* flipped only when none of those names is in use.
*
* Pure graph logic, no DOM: `planMarkUnused` decides, `applyMarkUnused`
* writes the widgets and returns what `undoMarkUnused` needs.
*/
import { findDownstreamAssemblers, upstreamChainOf, type LiteGraphLike, type LiteNodeLike } from "./graph";
import { derivationTargets } from "./derivation-conditions";
import { MODEL_INFO_NODE, MODEL_INFO_KEYS, CONFIG_WIDGET as MODEL_WIDGET } from "./model-info";
import { TOGGLES_NODE, nodeTogglesConfig, internalToggleVars, toggleRows } from "./toggles";
import { NEGATIVES_VAR } from "./assembler-vars";
import {
buildBundleEnabledMap,
isModuleEffectivelyEnabled,
parseWidgetJson,
serializeWidgetJson,
type ContextWidgetValue,
type ModuleEntry,
} from "../widgets/_shared";
const ASSEMBLER = "WP_PromptAssembler";
const SKIP_MODES = new Set<number>([2, 4]);
const VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
/** One internal flag: flipping it hides every name in `names`. */
interface Unit {
node: LiteNodeLike;
/** Which flag on the node (module `_uid`/index, binding, toggle var …). */
ref: string;
names: string[];
internal: boolean;
/** False for a name nothing can mark internal (the Loop's `_value`). */
flippable: boolean;
}
export type KeptReason =
/** Another Assembler on the same context reads it. */
| "other_assembler"
/** Its module also writes a variable that is in use (one flag for both). */
| "shared_module"
/** An Assembler that could read it has its template wired from a node. */
| "wired_template"
/** Nothing can mark it internal (the Loop's `_value`). */
| "fixed";
export interface MarkPlan {
/** This Assembler's own template is wired: nothing can be decided. */
wired: boolean;
/** Variables that will become internal, in chip order. */
marked: string[];
/** Unused here but kept, with why. */
kept: Array<{ name: string; reason: KeptReason; also?: string }>;
/** @internal what `applyMarkUnused` flips. */
units: Unit[];
}
export interface MarkChange {
node: LiteNodeLike;
widget: string;
before: string;
after: string;
}
function isSkipped(n: LiteNodeLike): boolean {
return typeof n.mode === "number" && SKIP_MODES.has(n.mode);
}
function widgetString(n: LiteNodeLike, name: string): string {
const w = n.widgets?.find((x) => x.name === name);
return typeof w?.value === "string" ? w.value : "";
}
function inputWired(n: LiteNodeLike, name: string): boolean {
return n.inputs?.find((i) => i.name === name)?.link != null;
}
function scanNames(text: string, into: Set<string>, reserved?: string): void {
for (const m of text.matchAll(VAR_RE)) {
const name = m[1];
if (name.startsWith("__") || name === reserved) continue;
into.add(name);
}
}
/** Names an Assembler's prompt + negative read, or `null` when either box is
* wired from another node (then it could read anything). */
export function assemblerReads(n: LiteNodeLike): Set<string> | null {
if (inputWired(n, "template") || inputWired(n, "negative_template")) return null;
const out = new Set<string>();
scanNames(widgetString(n, "template"), out);
scanNames(widgetString(n, "negative_template"), out, NEGATIVES_VAR);
return out;
}
const bare = (raw: string | undefined | null): string => (raw ?? "").replace(/^\$/, "").trim();
/** Every name one Context module writes (mirror of `collectUpstreamKinds`). */
export function moduleNames(m: ModuleEntry): string[] {
const out: string[] = [];
const add = (n: string) => { if (n && !out.includes(n)) out.push(n); };
if (m.type === "fixed_values") {
const inst = (m.instance ?? {}) as {
values_overrides?: Array<{ id?: string; name?: string }>;
enabled_options?: string[] | null;
};
const filter = Array.isArray(inst.enabled_options) ? new Set(inst.enabled_options) : null;
const passes = (id: string | undefined) => filter === null || (typeof id === "string" && filter.has(id));
const overrides = Array.isArray(inst.values_overrides) && inst.values_overrides.length > 0
? inst.values_overrides : null;
const rows = overrides ?? ((m.payload ?? {}) as { values?: Array<{ id?: string; name?: string }> }).values ?? [];
for (const r of rows) if (passes(r.id)) add(bare(r.name));
if (!overrides) for (const e of m.entries ?? []) add(bare(e.variable_name));
return out;
}
if (m.type === "derivation") {
for (const n of derivationTargets(m.payload)) add(n);
return out;
}
const inst = (m.instance ?? {}) as { variable_binding?: string | null };
const payload = (m.payload ?? {}) as { var_binding?: string; output_var?: string };
add(bare(inst.variable_binding ?? payload.var_binding ?? payload.output_var ?? ""));
return out;
}
function loopConfig(n: LiteNodeLike): { base: string; iter: boolean; total: boolean } {
const cfg = parseWidgetJson<{ iteration_var_name?: string; iteration_internal?: boolean; total_internal?: boolean }>(
widgetString(n, "wp_context_loop_config"), {},
);
return {
base: (cfg.iteration_var_name ?? "iteration").trim() || "iteration",
iter: cfg.iteration_internal === true,
total: cfg.total_internal === true,
};
}
/** The flags one node carries, in write order. */
function unitsOf(n: LiteNodeLike): Unit[] {
if (n.type === "WP_ContextInjector") {
const inj = parseWidgetJson<{ rows?: Array<{ binding?: string; enabled?: boolean; internal?: boolean }> }>(
widgetString(n, "wp_rows"), { rows: [] },
);
// Last enabled row of a binding decides, like the walkers.
const byName = new Map<string, boolean>();
for (const r of inj.rows ?? []) {
const b = (r.binding ?? "").trim();
if (r.enabled === true && b) byName.set(b, r.internal === true);
}
return [...byName].map(([b, internal]) => ({ node: n, ref: `row:${b}`, names: [b], internal, flippable: true }));
}
if (n.type === MODEL_INFO_NODE) {
const flags = parseWidgetJson<{ internal?: Record<string, unknown> }>(widgetString(n, MODEL_WIDGET), {}).internal ?? {};
return MODEL_INFO_KEYS.map((k) => ({
node: n, ref: `model:${k}`, names: [`model_${k}`], internal: flags[k] !== false, flippable: true,
}));
}
if (n.type === TOGGLES_NODE) {
const cfg = nodeTogglesConfig(n as Parameters<typeof nodeTogglesConfig>[0]);
const hidden = internalToggleVars(cfg);
const names = new Set<string>(toggleRows(cfg).map((t) => t.var).filter(Boolean));
if (cfg.mode === "one" && cfg.group_var) names.add(cfg.group_var);
return [...names].map((v) => ({ node: n, ref: `toggle:${v}`, names: [v], internal: hidden.has(v), flippable: true }));
}
if (n.type === "WP_ContextLoop") {
const c = loopConfig(n);
const out: Unit[] = [
{ node: n, ref: "loop:iteration", names: [c.base], internal: c.iter, flippable: true },
{ node: n, ref: "loop:total", names: [`${c.base}_total`], internal: c.total, flippable: true },
];
if (inputWired(n, "values")) out.push({ node: n, ref: "loop:value", names: [`${c.base}_value`], internal: false, flippable: false });
return out;
}
if (n.type !== "WP_Context") return [];
const v = parseWidgetJson<ContextWidgetValue>(widgetString(n, "wp_modules"), { version: 1, modules: [] });
const enabled = buildBundleEnabledMap(v.bundles);
const out: Unit[] = [];
v.modules.forEach((m, i) => {
if (!isModuleEffectivelyEnabled(m, enabled)) return;
const names = moduleNames(m);
if (!names.length) return;
out.push({ node: n, ref: `module:${m._uid ?? ""}:${i}`, names, internal: m.instance?.internal === true, flippable: true });
});
return out;
}
/**
* What the button would do for `assembler`. Nothing is written.
*/
export function planMarkUnused(rootGraph: LiteGraphLike, assembler: LiteNodeLike): MarkPlan {
const own = assemblerReads(assembler);
if (own === null) return { wired: true, marked: [], kept: [], units: [] };
// Winner per name, furthest upstream → closest (last write wins).
const chain = upstreamChainOf(rootGraph, assembler);
const winner = new Map<string, Unit>();
const order: string[] = [];
for (let i = chain.length - 1; i >= 0; i--) {
const n = chain[i];
if (isSkipped(n)) continue;
for (const u of unitsOf(n)) {
for (const name of u.names) {
if (!winner.has(name)) order.push(name);
winner.set(name, u);
}
}
}
// Reads of every Assembler below each writer node, cached per node.
const readsBelow = new Map<LiteNodeLike, { reads: Set<string>; wired: boolean }>();
const below = (n: LiteNodeLike) => {
const hit = readsBelow.get(n);
if (hit) return hit;
const reads = new Set<string>();
let wired = false;
for (const a of findDownstreamAssemblers(rootGraph, n)) {
if (a.type !== ASSEMBLER) continue;
const r = assemblerReads(a);
if (r === null) wired = true;
else for (const x of r) reads.add(x);
}
const out = { reads, wired };
readsBelow.set(n, out);
return out;
};
const marked: string[] = [];
const kept: MarkPlan["kept"] = [];
const flip = new Set<Unit>();
const decided = new Map<Unit, { ok: boolean; reason?: KeptReason; also?: string }>();
for (const name of order) {
const u = winner.get(name);
if (!u || u.internal || own.has(name)) continue;
let verdict = decided.get(u);
if (!verdict) {
const { reads, wired } = below(u.node);
const used = u.names.find((x) => reads.has(x) || own.has(x));
if (!u.flippable) verdict = { ok: false, reason: "fixed" };
else if (wired) verdict = { ok: false, reason: "wired_template" };
else if (used !== undefined) verdict = { ok: false, reason: "shared_module", also: used };
else verdict = { ok: true };
decided.set(u, verdict);
}
if (verdict.ok) {
flip.add(u);
marked.push(name);
continue;
}
// A name the other Assemblers read is kept for THAT reason, even when
// its module shares a flag with something else.
const reason: KeptReason = below(u.node).reads.has(name) ? "other_assembler" : verdict.reason ?? "other_assembler";
kept.push({ name, reason, ...(reason === "shared_module" && verdict.also ? { also: verdict.also } : {}) });
}
return { wired: false, marked, kept, units: [...flip] };
}
function writeWidget(n: LiteNodeLike, name: string, value: string): void {
const w = n.widgets?.find((x) => x.name === name);
if (!w) return;
w.value = value;
(n.graph as { setDirtyCanvas?: (fg: boolean, bg: boolean) => void } | undefined)?.setDirtyCanvas?.(true, true);
}
type Json = Record<string, unknown>;
const obj = (raw: string): Json => {
try {
const d: unknown = raw.trim() ? JSON.parse(raw) : {};
return d && typeof d === "object" && !Array.isArray(d) ? (d as Json) : {};
} catch { return {}; }
};
/** The node's widget value with `refs` flipped internal. */
function flipped(n: LiteNodeLike, refs: Set<string>): { widget: string; value: string } | null {
if (n.type === "WP_ContextInjector") {
const raw = widgetString(n, "wp_rows");
const d = obj(raw);
const rows = Array.isArray(d.rows) ? (d.rows as Json[]) : [];
d.rows = rows.map((r) => {
const b = typeof r.binding === "string" ? r.binding.trim() : "";
return r.enabled === true && refs.has(`row:${b}`) ? { ...r, internal: true } : r;
});
return { widget: "wp_rows", value: JSON.stringify(d) };
}
if (n.type === MODEL_INFO_NODE) {
const d = obj(widgetString(n, MODEL_WIDGET));
const flags = { ...(d.internal && typeof d.internal === "object" ? (d.internal as Json) : {}) };
for (const k of MODEL_INFO_KEYS) if (refs.has(`model:${k}`)) flags[k] = true;
d.internal = flags;
return { widget: MODEL_WIDGET, value: JSON.stringify(d) };
}
if (n.type === TOGGLES_NODE) {
const d = obj(widgetString(n, "wp_toggles"));
const rows = Array.isArray(d.rows) ? (d.rows as Json[]) : [];
d.rows = rows.map((r) => {
if (r.kind !== "toggle" || typeof r.var !== "string" || !refs.has(`toggle:${r.var}`)) return r;
const { visible: _v, ...rest } = r;
void _v;
return rest;
});
if (typeof d.group_var === "string" && refs.has(`toggle:${d.group_var}`)) delete d.group_visible;
return { widget: "wp_toggles", value: JSON.stringify(d) };
}
if (n.type === "WP_ContextLoop") {
const d = obj(widgetString(n, "wp_context_loop_config"));
if (refs.has("loop:iteration")) d.iteration_internal = true;
if (refs.has("loop:total")) d.total_internal = true;
return { widget: "wp_context_loop_config", value: JSON.stringify(d) };
}
if (n.type !== "WP_Context") return null;
const v = parseWidgetJson<ContextWidgetValue>(widgetString(n, "wp_modules"), { version: 1, modules: [] });
const modules = v.modules.map((m, i) => {
if (!refs.has(`module:${m._uid ?? ""}:${i}`)) return m;
// Same as the card's globe: the user now owns the flag, so a bundle
// master's markers go (its OFF cascade must not revert this row).
const inst = (m.instance ?? {}) as NonNullable<ModuleEntry["instance"]>;
const ui = { ...((inst as { _ui?: Json })._ui ?? {}) };
delete ui.master_internal;
delete ui.master_internal_by;
return { ...m, instance: { ...inst, internal: true, _ui: ui } };
});
return { widget: "wp_modules", value: serializeWidgetJson({ ...v, modules }) };
}
/** Write the plan. Returns each widget's old and new value, for Undo. */
export function applyMarkUnused(plan: MarkPlan): MarkChange[] {
const byNode = new Map<LiteNodeLike, Set<string>>();
for (const u of plan.units) {
const s = byNode.get(u.node) ?? new Set<string>();
s.add(u.ref);
byNode.set(u.node, s);
}
const changes: MarkChange[] = [];
for (const [n, refs] of byNode) {
const next = flipped(n, refs);
if (!next) continue;
const before = widgetString(n, next.widget);
if (before === next.value) continue;
writeWidget(n, next.widget, next.value);
changes.push({ node: n, widget: next.widget, before, after: next.value });
}
return changes;
}
/** Put each widget back, unless it was edited since. Returns how many were
* edited since and left alone. */
export function undoMarkUnused(changes: MarkChange[]): number {
let skipped = 0;
for (const c of changes) {
if (widgetString(c.node, c.widget) !== c.after) { skipped++; continue; }
writeWidget(c.node, c.widget, c.before);
}
return skipped;
}
/** "$a, $b and 3 more". */
export function nameList(names: string[], max = 4): string {
const shown = names.slice(0, max).map((n) => `$${n}`);
const rest = names.length - shown.length;
if (rest > 0) return `${shown.join(", ")} and ${rest} more`;
if (shown.length < 2) return shown.join("");
return `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
}
/** The toast line for a plan that was applied. */
export function summary(plan: MarkPlan): string {
const parts: string[] = [];
if (plan.marked.length) {
parts.push(`Marked ${plan.marked.length} internal: ${nameList(plan.marked)}.`);
}
const other = plan.kept.filter((k) => k.reason === "other_assembler").map((k) => k.name);
const shared = plan.kept.filter((k) => k.reason === "shared_module");
const wired = plan.kept.filter((k) => k.reason === "wired_template").map((k) => k.name);
if (other.length) parts.push(`Kept ${nameList(other)}: another Assembler uses ${other.length === 1 ? "it" : "them"}.`);
for (const k of shared.slice(0, 2)) {
parts.push(`Kept $${k.name}: its module also writes $${k.also ?? "?"}, which is in use.`);
}
if (shared.length > 2) parts.push(`Kept ${shared.length - 2} more that share a module with a used variable.`);
if (wired.length) parts.push(`Kept ${nameList(wired)}: an Assembler below gets its template from a wire.`);
return parts.join(" ");
}
-244
View File
@@ -1,244 +0,0 @@
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();
});
});
-260
View File
@@ -1,260 +0,0 @@
/**
* 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);
}
}
+19 -65
View File
@@ -10,7 +10,7 @@ import {
import {
MAX_OUTPUTS,
defaultTogglesConfig,
dropToggleRow,
dragRow,
visibleRows,
withUpstream,
groupsOf,
@@ -229,24 +229,6 @@ describe("toggle variables in the graph walkers", () => {
expect(collectUpstreamRenderableVariables(g, asm)).toEqual([]);
});
it("the preview carries each variable's negative (Any on and Pick one)", () => {
const any = cfg([
t("a", "rain", true, { value: "in the rain", negative: "sunny sky", visible: true }),
t("b", "fog", false, { value: "fog", off_negative: "clear air", visible: true }),
t("c", "wind", true, { visible: true }),
]);
const steps = togglesPreviewModules({ widgets: [{ name: "wp_toggles", value: JSON.stringify(any) }] });
expect(steps[0]?.entries).toEqual([
{ variable_name: "rain", value: "in the rain", negative: "sunny sky" },
{ variable_name: "fog", value: "", negative: "clear air" },
{ variable_name: "wind", value: "true" },
]);
const one = { ...cfg([t("a", "day", false), t("b", "night", true, { value: "at night", negative: "daylight" })], "one"), group_var: "time", group_visible: true };
const picked = togglesPreviewModules({ widgets: [{ name: "wp_toggles", value: JSON.stringify(one) }] });
const entries = picked.flatMap((s) => s.entries as { variable_name: string; negative?: string }[]);
expect(entries.find((e) => e.variable_name === "time")?.negative).toBe("daylight");
});
it("the preview splits shown and internal variables", () => {
const c = cfg([t("a", "dawn", true), t("b", "dusk", false, { visible: true })]);
const steps = togglesPreviewModules({ widgets: [{ name: "wp_toggles", value: JSON.stringify(c) }] });
@@ -269,61 +251,33 @@ describe("pick_style", () => {
});
});
describe("dropToggleRow (edit view drag across groups)", () => {
describe("dragRow (edit view drag across groups)", () => {
const L = (id: string, collapsed = false): TogglesConfig["rows"][number] => ({ id, kind: "label", name: id, color: 1, ...(collapsed ? { collapsed: true } : {}) });
const ids = (rows: TogglesConfig["rows"] | undefined) => rows?.map((r) => r.id);
const groupIds = (c: TogglesConfig) => groupsOf(c).map((g) => `${g.label?.id ?? "-"}:${g.toggles.map((x) => x.id).join("")}`);
const ids = (rows: TogglesConfig["rows"]) => rows.map((r) => r.id);
const c = cfg([t("a", "a", true), L("L1"), t("b", "b", true), t("c", "c", true), L("L2"), t("d", "d", true)]);
it("moves a toggle into another group, beside a toggle or first after its label", () => {
expect(ids(dropToggleRow(c, "a", "d", "before")?.rows)).toEqual(["L1", "b", "c", "L2", "a", "d"]);
const first = dropToggleRow(c, "d", "L1", "after");
expect(ids(first?.rows)).toEqual(["a", "L1", "d", "b", "c", "L2"]);
expect(first?.group?.id).toBe("L1");
it("moves a toggle into another group", () => {
// a L1 b c L2 d → drag a below L2
expect(ids(dragRow(c, 0, 4))).toEqual(["L1", "b", "c", "L2", "a", "d"]);
});
it("moves a toggle out of a group, above the first label", () => {
expect(ids(dropToggleRow(c, "b", "a", "after")?.rows)).toEqual(["a", "b", "L1", "c", "L2", "d"]);
expect(dropToggleRow(c, "b", "L1", "before")?.group).toBeNull();
it("moves a toggle out of a group to the top", () => {
expect(ids(dragRow(c, 2, 0))).toEqual(["b", "a", "L1", "c", "L2", "d"]);
});
it("drops a toggle below a group, in no group, and reads it back the same", () => {
const below = dropToggleRow(c, "a", "L2", "before");
expect(below?.group).toBeNull();
const back = parseTogglesConfig({ rows: below?.rows });
expect(groupIds(back)).toEqual(["-:", "L1:bc", "-:a", "L2:d"]);
expect(back.rows.find((r) => r.id === "a")).toMatchObject({ ungrouped: true });
const end = dropToggleRow(c, "b", "d", "end");
expect(groupIds({ ...c, rows: end?.rows ?? [] })).toEqual(["-:a", "L1:c", "L2:d", "-:b"]);
// Back into a group, the mark goes.
const again = dropToggleRow({ ...c, rows: end?.rows ?? [] }, "b", "d", "after");
expect(again?.rows.find((r) => r.id === "b")).not.toHaveProperty("ungrouped");
});
it("lands nowhere new when nothing would change", () => {
expect(dropToggleRow(c, "b", "b", "after")).toBeNull();
expect(dropToggleRow(c, "c", "b", "after")).toBeNull();
expect(dropToggleRow(c, "a", "L1", "before")).toBeNull();
});
it("moves a label with its toggles, only between groups", () => {
expect(ids(dropToggleRow(c, "L2", "L1", "before")?.rows)).toEqual(["a", "L2", "d", "L1", "b", "c"]);
expect(ids(dropToggleRow(c, "L1", "d", "end")?.rows)).toEqual(["a", "L2", "d", "L1", "b", "c"]);
expect(ids(dropToggleRow(c, "L2", "c", "after")?.rows)).toBeUndefined(); // already there
expect(dropToggleRow(c, "L2", "b", "after")).toBeNull(); // would split L1
expect(dropToggleRow(c, "L1", "L2", "after")).toBeNull(); // inside L2
expect(dropToggleRow(c, "L1", "b", "before")).toBeNull(); // its own toggles
});
it("puts a label among toggles in no group, splitting their run", () => {
const loose = cfg([t("a", "a", true), t("b", "b", true), L("L1"), t("c", "c", true)]);
const out = dropToggleRow(loose, "L1", "a", "after");
expect(groupIds({ ...loose, rows: out?.rows ?? [] })).toEqual(["-:a", "L1:c", "-:b"]);
it("a label carries its toggles and never splits a group", () => {
// drag L2 between b and c: it moves past c, taking d along.
expect(ids(dragRow(c, 4, 3))).toEqual(["a", "L1", "b", "c", "L2", "d"]);
// drag L2 above L1.
expect(ids(dragRow(c, 4, 1))).toEqual(["a", "L2", "d", "L1", "b", "c"]);
// drag L1 to the end.
expect(ids(dragRow(c, 1, 5))).toEqual(["a", "L2", "d", "L1", "b", "c"]);
});
it("keeps a folded group's hidden toggles and unfolds on a drop into it", () => {
const f = cfg([t("a", "a", true), L("L1", true), t("b", "b", true), L("L2"), t("d", "d", true)]);
expect(ids(visibleRows(f))).toEqual(["a", "L1", "L2", "d"]);
const out = dropToggleRow(f, "d", "L1", "after");
expect(ids(out?.rows)).toEqual(["a", "L1", "d", "b", "L2"]);
expect(out?.rows[1]).toMatchObject({ id: "L1", collapsed: false });
expect(ids(dropToggleRow(f, "L1", "d", "end")?.rows)).toEqual(["a", "L2", "d", "L1", "b"]);
// After a folded label is between groups for a label.
expect(ids(dropToggleRow(f, "L2", "L1", "after")?.rows)).toBeUndefined();
const out = dragRow(f, 3, 2); // d just below folded L1
expect(ids(out)).toEqual(["a", "L1", "b", "d", "L2"]);
expect(out[1]).toMatchObject({ id: "L1", collapsed: false });
expect(ids(dragRow(f, 1, 3))).toEqual(["a", "L2", "d", "L1", "b"]);
});
});
+46 -176
View File
@@ -49,9 +49,6 @@ export interface ToggleRow {
invert?: true;
/** Clean view: no `$variable` line under the name. */
hide_var?: true;
/** In no group though a label is above it: dropped below a group. Only on
* toggles after the first label (`rowsOf` sets it). */
ungrouped?: true;
/** What the variable gets while on, instead of "true" ("" while off); in
* Pick one also what the picked variable gets instead of the name. May
* read `$vars`. `$name.bool` still reads the switch. */
@@ -108,10 +105,6 @@ 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
@@ -292,7 +285,6 @@ export function parseTogglesConfig(raw: unknown): TogglesConfig {
if (TOGGLE_CONTROLS.includes(o.control as ToggleControl)) t.control = o.control as ToggleControl;
if (o.invert === true) t.invert = true;
if (o.hide_var === true) t.hide_var = true;
if (o.ungrouped === true) t.ungrouped = true;
if (o.visible === true) t.visible = true;
if (iconName(o.icon)) t.icon = iconName(o.icon);
if (hex(o.icon_color)) t.icon_color = hex(o.icon_color);
@@ -310,7 +302,6 @@ 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">;
}
@@ -320,14 +311,13 @@ 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,
* and with `pick_required` the first toggle (mirror of `picked_id`). */
* picked variable names, the first one upstream set on, the first one on. */
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 ?? (cfg.pick_required ? toggles[0]?.id : undefined);
return up ?? toggles.find((t) => t.on)?.id;
}
/** Toggle id → on after `needs` (Any on) or the Pick one rules. */
@@ -399,15 +389,6 @@ export function ownToggleNegatives(cfg: TogglesConfig): Record<string, string> {
return out;
}
/** Pick one: the picked toggle's negative ("" when none), what the Pick one
* variable carries (mirror of `engine/toggles.py::picked_negative`). */
export function pickedToggleNegative(cfg: TogglesConfig): string {
if (cfg.mode !== "one" || !cfg.group_var || !VAR_RE.test(cfg.group_var)) return "";
const eff = effectiveToggles(cfg);
const picked = toggleRows(cfg).find((t) => eff.get(t.id));
return picked?.negative?.trim() ?? "";
}
/** Variables the node writes that stay out of the prompt (mirror of
* `engine/toggles.py::internal_vars`): a toggle not `visible` (the
* last row writing a variable decides) and the Pick one variable unless
@@ -540,42 +521,18 @@ export interface ToggleGroup {
toggles: ToggleRow[];
}
/** Rows as label groups: toggles belong to the label above them, except an
* `ungrouped` one, which starts a run of toggles in no group below it. */
/** Rows as label groups (toggles belong to the label above them). */
export function groupsOf(cfg: TogglesConfig): ToggleGroup[] {
const groups: ToggleGroup[] = [{ label: null, toggles: [] }];
for (const r of cfg.rows) {
if (r.kind === "label") groups.push({ label: r, toggles: [] });
else {
if (r.ungrouped && groups[groups.length - 1].label) groups.push({ label: null, toggles: [] });
groups[groups.length - 1].toggles.push(r);
}
else groups[groups.length - 1].toggles.push(r);
}
return groups;
}
/** Rows from groups, marking the toggles in no group below a label
* `ungrouped` (and no others), so `groupsOf` reads them back the same. */
export function rowsOf(groups: ToggleGroup[]): TogglesItem[] {
let labelSeen = false;
const out: TogglesItem[] = [];
for (const g of groups) {
if (g.label) {
labelSeen = true;
out.push(g.label);
}
for (const t of g.toggles) {
const loose = !g.label && labelSeen;
if (loose === !!t.ungrouped) out.push(t);
else if (loose) out.push({ ...t, ungrouped: true });
else {
const rest = { ...t };
delete rest.ungrouped;
out.push(rest);
}
}
}
return out;
return groups.flatMap((g) => (g.label ? [g.label, ...g.toggles] : g.toggles));
}
/** Depth of a toggle in its needs chain (0 = needs nothing), for the indent. */
@@ -608,19 +565,6 @@ export function staticToggleValues(node: TogglesNodeLike): Record<string, string
return resolveToggles(nodeTogglesConfig(node));
}
/** Each variable the node writes → the negative it carries now ("" for none):
* its Negative while on, Off negative while off, and the pick's negative on
* the Pick one variable. */
export function staticToggleNegatives(node: TogglesNodeLike): Record<string, string> {
const cfg = nodeTogglesConfig(node);
const own = ownToggleNegatives(cfg);
const picked = pickedToggleNegative(cfg);
if (picked && cfg.group_var) own[cfg.group_var] = picked;
const out: Record<string, string> = {};
for (const v of Object.keys(resolveToggles(cfg))) out[v] = own[v] ?? "";
return out;
}
/** The node's variables that stay out of the prompt (graph walkers). */
export function staticToggleInternals(node: TogglesNodeLike): Set<string> {
return internalToggleVars(nodeTogglesConfig(node));
@@ -631,14 +575,7 @@ export function staticToggleInternals(node: TogglesNodeLike): Set<string> {
export function togglesPreviewModules(node: TogglesNodeLike): Record<string, unknown>[] {
const cfg = nodeTogglesConfig(node);
const hidden = internalToggleVars(cfg);
// Each variable's negative rides along, so the Assembler's negative
// preview shows what Run sends (the node files them the same way).
const negs = ownToggleNegatives(cfg);
const picked = pickedToggleNegative(cfg);
if (picked && cfg.group_var) negs[cfg.group_var] = picked;
const all = Object.entries(resolveToggles(cfg)).map(([variable_name, value]) => ({
variable_name, value, ...(negs[variable_name] ? { negative: negs[variable_name] } : {}),
}));
const all = Object.entries(resolveToggles(cfg)).map(([variable_name, value]) => ({ variable_name, value }));
const step = (id: string, entries: typeof all, internal: boolean): Record<string, unknown> => ({
id, type: "fixed_values", enabled: true,
meta: { name: "WP Toggles" }, entries, instance: internal ? { internal: true } : {},
@@ -652,119 +589,52 @@ export function togglesPreviewModules(node: TogglesNodeLike): Record<string, unk
}
/** Rows the edit view lists, in order: toggles under a folded label are left out. */
/** The rows with every folded label unfolded whose group holds a toggle
* writing one of `vars`, or null when none needs unfolding. */
export function unfoldForVars(cfg: TogglesConfig, vars: readonly string[]): TogglesItem[] | null {
const want = new Set(vars.filter(Boolean));
if (!want.size) return null;
const open = new Set<string>();
for (const g of groupsOf(cfg)) {
if (g.label?.collapsed && g.toggles.some((t) => t.var && want.has(t.var))) open.add(g.label.id);
}
if (!open.size) return null;
return cfg.rows.map((r) => (r.kind === "label" && open.has(r.id) ? { ...r, collapsed: false } : r));
}
export function visibleRows(cfg: TogglesConfig): TogglesItem[] {
return groupsOf(cfg).flatMap((g) => (g.label ? [g.label, ...(g.label.collapsed ? [] : g.toggles)] : g.toggles));
}
/** Where a dragged edit-view row lands: beside row `over`, or `end` below
* the last row. */
export type ToggleDropEdge = "before" | "after" | "end";
/**
* The rows after dropping the edit view's row `fromId` beside `overId`, and
* the group it lands in (null: none), or null when it can't land there or
* nothing would change.
*
* A toggle beside another toggle joins that toggle's group; after a label it
* goes first in that group (a folded one unfolds); before a label or at the
* end it lands below the group above, in no group. A label moves with all
* its toggles and only lands between groups, never inside one.
* Rows after dragging the edit view's row `from` to `to` (indices into
* {@link visibleRows}). A toggle joins the group of the label above where it
* lands (a folded one unfolds). A label takes all its toggles along and lands
* at a group edge: dropped among another group's toggles, it moves down past
* them, so it never splits a group.
*/
export function dropToggleRow(
cfg: TogglesConfig,
fromId: string,
overId: string,
edge: ToggleDropEdge,
): { rows: TogglesItem[]; group: LabelRow | null } | null {
export function dragRow(cfg: TogglesConfig, from: number, to: number): TogglesItem[] {
const shown = visibleRows(cfg);
const moved = shown[from];
if (!moved || from === to || to < 0 || to >= shown.length) return cfg.rows;
const next = [...shown];
next.splice(from, 1);
next.splice(to, 0, moved);
const groups = groupsOf(cfg);
const groupOf = (id: string): ToggleGroup | undefined =>
groups.find((g) => g.label?.id === id || g.toggles.some((t) => t.id === id));
const moved = cfg.rows.find((r) => r.id === fromId);
const over = cfg.rows.find((r) => r.id === overId);
if (!moved || !over) return null;
let next: ToggleGroup[];
let group: LabelRow | null;
if (moved.kind === "toggle") {
if (edge !== "end" && over.id === moved.id) return null;
next = groups.map((g) => ({ label: g.label, toggles: g.toggles.filter((t) => t.id !== moved.id) }));
const find = (id: string): ToggleGroup | undefined =>
next.find((g) => g.label?.id === id || g.toggles.some((t) => t.id === id));
if (edge === "end" || (over.kind === "label" && edge === "before")) {
// Below the group above: in no group.
const at = edge === "end" ? next.length : next.findIndex((g) => g.label?.id === over.id);
const above = next[at - 1];
if (above && !above.label) above.toggles.push(moved);
else next.splice(at, 0, { label: null, toggles: [moved] });
group = null;
} else if (over.kind === "label") {
const g = find(over.id);
if (!g?.label) return null;
g.toggles.unshift(moved);
if (g.label.collapsed) g.label = { ...g.label, collapsed: false };
group = g.label;
} else {
const g = find(over.id);
if (!g) return null;
const i = g.toggles.findIndex((t) => t.id === over.id);
g.toggles.splice(edge === "before" ? i : i + 1, 0, moved);
group = g.label;
}
} else {
// A label lands only between groups: before a label, among toggles in
// no group, after a group's last toggle (or a folded / empty label), or
// at the end.
const own = groupOf(moved.id);
if (!own) return null;
const og = groupOf(over.id);
if (!og) return null;
const lastOf = (g: ToggleGroup): string | undefined => (g.label?.collapsed ? g.label.id : g.toggles[g.toggles.length - 1]?.id ?? g.label?.id);
let at: { before: string } | { after: string } | "end";
if (edge === "end") at = "end";
else if (over.kind === "label" && edge === "before") at = { before: over.id };
else if (!og.label) at = edge === "before" ? { before: over.id } : { after: over.id };
else if (edge === "after" && lastOf(og) === over.id) at = { after: over.id };
else return null;
if (og === own && at !== "end") return null;
const rest = groups.filter((g) => g !== own);
next = [];
let placed = false;
const put = (): void => { next.push(own); placed = true; };
for (const g of rest) {
if (at !== "end" && "before" in at && g.label?.id === at.before) put();
if (at !== "end" && !g.label && g.toggles.some((t) => t.id === ("before" in at ? at.before : at.after))) {
const key = "before" in at ? at.before : at.after;
const i = g.toggles.findIndex((t) => t.id === key) + ("before" in at ? 0 : 1);
const head = g.toggles.slice(0, i);
const tail = g.toggles.slice(i);
if (head.length || !next.length) next.push({ label: null, toggles: head });
put();
if (tail.length) next.push({ label: null, toggles: tail });
continue;
}
next.push(g);
if (at !== "end" && "after" in at && g.label && lastOf(g) === at.after) put();
}
if (!placed) put();
group = own.label;
const ownToggles = (labelId: string): ToggleRow[] => groups.find((g) => g.label?.id === labelId)?.toggles ?? [];
let list = next;
let block: ToggleRow[] = [];
if (moved.kind === "label") {
block = ownToggles(moved.id);
const inBlock = new Set(block.map((t) => t.id));
list = next.filter((r) => !inBlock.has(r.id) && r.id !== moved.id);
let at = next.filter((r) => !inBlock.has(r.id)).indexOf(moved);
while (at < list.length && list[at]?.kind === "toggle") at++;
list.splice(at, 0, moved);
}
const rows = rowsOf(next.filter((g, i) => i === 0 || g.label || g.toggles.length));
const same = rows.length === cfg.rows.length && rows.every((r, i) => {
const o = cfg.rows[i];
return o?.id === r.id && (r.kind !== "toggle" || o.kind !== "toggle" || !!r.ungrouped === !!o.ungrouped)
&& (r.kind !== "label" || o.kind !== "label" || !!r.collapsed === !!o.collapsed);
});
return same ? null : { rows, group };
const out: TogglesItem[] = [];
let lastLabel: LabelRow | null = null;
for (const r of list) {
if (r.kind === "label") {
lastLabel = r;
out.push(r);
if (r.id === moved.id) out.push(...block);
else if (r.collapsed) out.push(...ownToggles(r.id));
continue;
}
if (r.id === moved.id && lastLabel?.collapsed) {
const i = out.indexOf(lastLabel);
out[i] = { ...lastLabel, collapsed: false };
lastLabel = out[i] as LabelRow;
}
out.push(r);
}
return out;
}
-5
View File
@@ -44,7 +44,6 @@ const {
loadPlayground,
loadSubgraphBadge,
badgeSlotMod,
promoteMod,
loadImageFilterPicker,
loadBookmarkDock,
loadNodeHelp,
@@ -451,8 +450,6 @@ 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,
@@ -462,7 +459,6 @@ 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));
@@ -472,7 +468,6 @@ 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));
+1 -7
View File
@@ -219,11 +219,6 @@ export const NODE_FIELDS = {
{ term: "prompt (out)", sockets: ["out:prompt"], desc: "The finished prompt. Wire it into CLIP Text Encode or a WP Prompt Cleaner." },
{ term: "negative (out)", sockets: ["out:negative"], desc: "The negative words of the variables this template printed, plus your negative template. Wire it into your negative CLIP Text Encode." },
],
controls: [
{ term: "Load / Save (toolbar)", desc: "Load a template from your library into both boxes, or save this one (prompt and negative) to it." },
{ term: "Mark unused internal (toolbar)", desc: "The eye button. Marks every upstream variable that neither box reads as internal, so it stops showing here; combines and rules still read it. Variables another Assembler on the same context reads are kept, and so is a module that also writes a variable in use. Undo in the message puts it back." },
{ term: "Clear (toolbar)", desc: "Empties the template and the negative template, after asking." },
],
},
WP_PromptCleaner: {
ports: [
@@ -250,10 +245,9 @@ 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: at most one is on; tick Always one on to keep exactly one on, like radio buttons." },
{ 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: "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." },
@@ -50,9 +50,6 @@ import DocRef from "../../../components/docs/DocRef.vue";
<li>On the canvas, select rows in a WP Context, right-click and choose <b>Save as bundle</b>.</li>
<li>In the manager, create one on the <b>Bundles</b> page and add modules to it.</li>
<li>Drop a saved bundle into a Context with <b>Add Bundle</b>.</li>
<li>Drag a row into a bundle on the canvas: a line in the bundle's colour shows where it lands.
On a closed bundle, drop on the middle of its header to put the row inside (the bundle opens);
its top and bottom edges drop the row beside it.</li>
</ul>
</DocSection>
+1 -3
View File
@@ -85,9 +85,7 @@ import CrossLinks from "../../../components/docs/CrossLinks.vue";
<p>
<b>Show node</b> (in a step's details, a row's right-click menu, or the arrow on a node row) selects the
node on the canvas and makes the module or field that row came from blink: a Context's module, a Model
Info field, a toggle, an Injector row, or the Loop's iteration field. A collapsed node, a folded bundle,
a folded Toggles group or the Injector's folded row list opens first so you can see it. A node inside a
subgraph opens that subgraph; the subgraph itself is left as it is.
Info field, a toggle, an Injector row, or the Loop's iteration field.
</p>
<p>
A long value shows its first three lines (a long list its first three items, a long negative one
@@ -72,29 +72,6 @@ import VarToken from "../../../components/docs/VarToken.vue";
</DocDetails>
</DocSection>
<DocSection title="Hide the variables you don't use">
<p>
A big Context offers many variables, and most of them feed other variables rather than the
prompt. The eye button in the <b>variables</b> toolbar (beside Load, Save and Clear) marks
every variable that neither this template nor the negative template reads as
<b>internal</b>, so the chip strip and the <code>$</code> list only offer what you print.
</p>
<ul>
<li>Internal only hides a variable from Assemblers. Combines, rules, constraints and the Var
nodes still read it, so <VarToken>$hero</VarToken> used inside
<VarToken>$scene</VarToken> keeps working.</li>
<li>Every other Assembler on the same context is checked first: a variable one of them
prints (<code>$x.0</code>, <code>$x.AXIS</code> and <code>$x.neg</code> count) is kept.</li>
<li>A Context module has one internal switch for all the variables it writes, so a fixed
values module that also writes a variable in use is left as it is.</li>
<li>It flips the same switches you would: the globe on a module, an Injector row, a toggle,
a Model Info row or a Loop variable. The message says what it marked and what it kept and
why; <b>Undo</b> in it puts everything back.</li>
<li>An Assembler whose template comes from a wire can't tell what it uses, so nothing it can
see is marked.</li>
</ul>
</DocSection>
<DocSection title="The negative output">
<p>
Wildcard options, fixed values, combines and derivations can carry negative words. The
@@ -120,16 +97,6 @@ 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>
+4 -11
View File
@@ -53,11 +53,10 @@ import VarToken from "../../../components/docs/VarToken.vue";
<DocSection title="Editing a toggle">
<DocFields node="WP_Toggles" group="edit" />
<Banner tone="info" note>
Drag the grip to reorder: the row you drop beside opens a gap with a line. An indented
line in a group's colour puts the toggle in that group (a folded one opens); a
full-width line, just above a label or below the last row, puts it below the group, in no
group. A label moves with its toggles and lands only between groups. Moving a row never
re-points a wire: each output stays with its toggle.
Drag the grip to reorder. A toggle dropped under a label joins that group (a folded one
opens), and one dropped above the first label leaves its group. A label moves with its
toggles. Moving a row never re-points a wire:
each output stays with its toggle.
</Banner>
</DocSection>
@@ -128,12 +127,6 @@ 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">
-34
View File
@@ -72,39 +72,6 @@ function writeNegativeTemplate(node: AssemblerNode, next: string) {
if (w) w.value = next;
}
/**
* Toolbar › Mark unused internal: flag every upstream variable neither box
* reads as internal, keeping any another Assembler on the same context reads
* (`extension/mark-unused.ts`). Toast with Undo.
*/
async function markUnused(node: AssemblerNode): Promise<void> {
const start = (node as unknown as { graph?: LiteGraphLike }).graph
?? (app.graph as unknown as LiteGraphLike);
const root = findRootGraph(start);
const mu = await import("../extension/mark-unused");
const plan = mu.planMarkUnused(root, node as unknown as LiteNodeLike);
if (plan.wired) {
pushToast("This Assembler's template comes from a wire, so it can't tell what the prompt uses.", { severity: "warning" });
return;
}
if (!plan.marked.length) {
pushToast(mu.summary(plan) || "Every variable here is in use.", { severity: "info", lifeMs: 8000 });
return;
}
const changes = mu.applyMarkUnused(plan);
pushToast(mu.summary(plan), {
severity: "success",
lifeMs: 12000,
action: {
label: "Undo",
onSelect: () => {
const skipped = mu.undoMarkUnused(changes);
if (skipped) pushToast(`${skipped} node${skipped === 1 ? " was" : "s were"} edited since and left as they are.`, { severity: "warning" });
},
},
});
}
/** `node.properties` key marking an Assembler whose negative box follows the
* opt-in rule (no `$negatives` = only the box's own text). Every new node
* gets it; a node loaded without it was saved under the old rule and gets
@@ -708,7 +675,6 @@ export function mountHelper(node: AssemblerNode) {
onLoadTemplate: () => {
loadOpen.value = true;
},
onMarkUnused: () => { void markUnused(node); },
onRequestMinWidth: (w: number) => {
if (w === dynamicMinWidth) return;
dynamicMinWidth = w;
+2 -38
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nodeWidgetHosts } from "../extension/_stashes";
import { REVEAL_EVENT, askReveal, blink, findFlashElement, flashInNode, revealTarget } from "./flash-target";
import { blink, findFlashElement, flashInNode } from "./flash-target";
function host(html: string): HTMLElement {
const h = document.createElement("div");
@@ -50,46 +50,10 @@ describe("Show node blink", () => {
el.animate = animate as unknown as HTMLElement["animate"];
flashInNode(node, { vars: ["model_family"] });
expect(animate).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
vi.advanceTimersByTime(200);
expect(animate).toHaveBeenCalledTimes(1);
});
it("asks the widgets to unfold what hides the row before it looks", () => {
vi.useFakeTimers();
const node = {};
const h = host('<div data-wp-reveal><div data-uid="m-9" hidden>child</div></div>');
nodeWidgetHosts.set(node, new Set([h]));
const wrap = h.firstElementChild as HTMLElement;
const child = wrap.firstElementChild as HTMLElement;
const seen: unknown[] = [];
wrap.addEventListener(REVEAL_EVENT, (ev) => {
seen.push(revealTarget(ev));
child.hidden = false;
});
const animate = vi.fn();
child.animate = animate as unknown as HTMLElement["animate"];
flashInNode(node, { uid: "m-9" });
vi.advanceTimersByTime(170);
expect(seen).toEqual([{ uid: "m-9" }]);
expect(animate).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(animate).toHaveBeenCalledTimes(1);
expect(seen).toHaveLength(1);
});
it("sends the reveal only to marked elements of live hosts", () => {
const gone = document.createElement("div");
gone.innerHTML = "<div data-wp-reveal></div>";
const h = host("<div data-wp-reveal></div><div></div>");
const hits: string[] = [];
for (const [name, root] of [["gone", gone], ["live", h]] as const) {
for (const el of root.querySelectorAll("div")) el.addEventListener(REVEAL_EVENT, () => hits.push(name));
}
askReveal([gone, h], { vars: ["x"] });
expect(hits).toEqual(["live"]);
expect(revealTarget(new Event("click"))).toBeNull();
});
it("does nothing without animate support", () => {
const el = document.createElement("div");
expect(() => blink(el)).not.toThrow();
+1 -36
View File
@@ -5,12 +5,6 @@
* Context modules (and bundle children) are found by `data-uid`; rows of the
* other nodes (Injector, Model Info, Toggles, Loop) carry the variables they
* write in `data-wp-vars` (space separated, no `$`).
*
* A row can be out of sight inside its node: a folded bundle, a folded
* Toggles label group. Before looking, every element marked
* `data-wp-reveal` in the node's widgets gets a `wp-reveal` event carrying
* the target, and the widget unfolds what hides it. Subgraphs are never
* unpacked: `focusNode` opens the graph the node lives in instead.
*/
import { nodeWidgetHosts } from "../extension/_stashes";
@@ -27,25 +21,6 @@ function escapeAttr(v: string): string {
: v.replace(/["\\]/g, "\\$&");
}
/** The DOM event a widget listens for to unfold what hides `target`. */
export const REVEAL_EVENT = "wp-reveal";
/** Ask every widget in `hosts` to unfold what hides `target`. */
export function askReveal(hosts: Iterable<HTMLElement>, target: FlashTarget): void {
for (const h of hosts) {
if (!h.isConnected) continue;
for (const el of h.querySelectorAll<HTMLElement>("[data-wp-reveal]")) {
el.dispatchEvent(new CustomEvent<FlashTarget>(REVEAL_EVENT, { detail: target }));
}
}
}
/** The target a `wp-reveal` event carries, or null for any other event. */
export function revealTarget(ev: Event): FlashTarget | null {
const d = (ev as CustomEvent<unknown>).detail;
return d && typeof d === "object" ? (d as FlashTarget) : null;
}
/** The element in `hosts` that best stands for `target`, or null. */
export function findFlashElement(hosts: Iterable<HTMLElement>, target: FlashTarget): HTMLElement | null {
const live = [...hosts].filter((h) => h.isConnected);
@@ -96,17 +71,8 @@ export function blink(el: HTMLElement): void {
export function flashInNode(node: object, target: FlashTarget): void {
if (!target.uid && !target.vars?.length) return;
let tries = 0;
let asked = false;
const look = (): void => {
const hosts = nodeWidgetHosts.get(node);
// Once the widgets are mounted, unfold first, then look on the next try
// (after the widget has re-rendered).
if (hosts && !asked && [...hosts].some((h) => h.isConnected)) {
asked = true;
askReveal(hosts, target);
setTimeout(look, 60);
return;
}
const el = hosts ? findFlashElement(hosts, target) : null;
if (el) {
const host = [...(hosts ?? [])].find((h) => h.contains(el));
@@ -114,8 +80,7 @@ export function flashInNode(node: object, target: FlashTarget): void {
blink(el);
return;
}
// A graph switch can take a while to mount its nodes (Nodes 2.0).
if (++tries < 20) setTimeout(look, 90);
if (++tries < 12) setTimeout(look, 90);
};
// Let the canvas finish centring (and a graph switch mount its nodes).
setTimeout(look, 160);
+1 -55
View File
@@ -1,8 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
NEGATIVE_COLLAPSED_PROP,
create,
createPromoted,
editorVariant,
initialNegativeCollapsed,
} from "./templateEditor";
@@ -36,55 +34,3 @@ 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();
});
});
+8 -87
View File
@@ -26,7 +26,7 @@
* offers the reserved `$negatives` slot, and keeps its socket like the prompt
* template does.
*/
import { computed, defineAsyncComponent, h, nextTick, ref, watch, type Component } from "vue";
import { computed, defineAsyncComponent, h, nextTick, ref, type Component } from "vue";
import { app } from "#comfyui/app";
import { createDomWidgetHost, type MountTargetNode } from "./_shared";
import { attachFieldSockets, type FieldSocketNode } from "../extension/field-sockets";
@@ -42,12 +42,6 @@ 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;
@@ -112,58 +106,13 @@ 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(variantOf);
const variant = editorVariant(inputName);
const nodeProps = (): Record<string, unknown> => {
const n = node as { properties?: Record<string, unknown> };
if (!n.properties) n.properties = {};
@@ -178,7 +127,7 @@ function buildEditor(
// from the node's own graph — same rationale as `widgets/context.ts`.
const rootGraph = (): LiteGraphLike | null => {
const start =
(inner as unknown as { graph?: LiteGraphLike }).graph
(node as unknown as { graph?: LiteGraphLike }).graph
?? (app.graph as unknown as LiteGraphLike);
return findRootGraph(start);
};
@@ -195,7 +144,7 @@ function buildEditor(
node as unknown as Parameters<typeof reactiveFromGraph>[0],
() => {
const g = rootGraph();
return g ? collectUpstreamRenderableVariables(g, inner) : [];
return g ? collectUpstreamRenderableVariables(g, node) : [];
},
stringArrayEqual,
);
@@ -225,9 +174,9 @@ function buildEditor(
void varNames.value;
const g = rootGraph();
const map = g
? new Map(Object.entries(collectUpstreamProducers(g, inner)))
? new Map(Object.entries(collectUpstreamProducers(g, node)))
: new Map<string, VarProducer>();
for (const name of variant.reservedVars) map.set(name, reservedProducer(inner));
for (const name of variant.reservedVars) map.set(name, reservedProducer(node));
return map;
});
// Reserved names lead the list and are never "unknown".
@@ -248,7 +197,6 @@ function buildEditor(
"onUpdate:modelValue": (v: string) => {
model.value = v;
editorHost.setValue(v);
promoted?.set(v);
},
onToggle: () => toggleCollapsed(),
});
@@ -280,7 +228,6 @@ function buildEditor(
"onUpdate:modelValue": (v: string) => {
model.value = v;
editorHost.setValue(v);
promoted?.set(v);
},
});
},
@@ -312,13 +259,6 @@ function buildEditor(
});
}
/** 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
@@ -329,8 +269,7 @@ function buildEditor(
// 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,
initialValue: promoted?.get(),
onValueRestored: (v: string) => restored(v),
onValueRestored: (v: string) => { model.value = v; },
})
: createDomWidgetHost(node, inputName, wrapper, {
// The one widget on the node that keeps its socket — see the file
@@ -358,9 +297,8 @@ function buildEditor(
* 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) => restored(v),
onValueRestored: (v: string) => { model.value = v; },
});
model.value = editorHost.getValue();
@@ -369,23 +307,6 @@ function buildEditor(
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.
-14
View File
@@ -191,17 +191,3 @@ def test_coerce_legacy_module_preserves_user_instance_overrides():
# Defaults still present for keys the user didn't override.
assert coerced["instance"]["variable_binding"] is None
assert coerced["instance"]["category_filter"] is None
def test_coerce_legacy_entries_keep_their_negative():
"""The Toggles preview step sends each variable's negative on its entry."""
coerced = coerce_legacy_module({
"id": "0de1f0fe",
"type": "fixed_values",
"entries": [
{"variable_name": "rain", "value": "in the rain", "negative": "sunny sky"},
{"variable_name": "fog", "value": "fog", "negative": " "},
],
})
assert coerced["payload"]["values"][0]["negative"] == "sunny sky"
assert "negative" not in coerced["payload"]["values"][1]
-176
View File
@@ -1102,182 +1102,6 @@
"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
}
}
]
}
+2 -3
View File
@@ -93,8 +93,7 @@ tutorial(
await t.beat(700);
await t.waitVoice();
await t.say("Same base seed, same strategy, same batch: a loop is repeatable.");
// The Done button sits in the kit Modal's footer, outside the seed-modal body.
await t.click(page.locator('[data-test="seed-modal-done"]').last());
await t.click(modal.locator('[data-test="seed-modal-done"]'));
await t.beat(700);
await t.waitVoice();
});
@@ -127,7 +126,7 @@ tutorial(
await t.say("Three times of day by four hair styles: twelve frames. The role isn't swept, so Hold other picks keeps one role on all of them.");
await t.waitVoice();
await t.uncallout("total");
await t.click(page.locator('[data-test="sweep-modal-done"]').last());
await t.click(modal.locator('[data-test="sweep-modal-done"]'));
await t.beat(700);
await t.say("Run.");
await t.fastForward(async () => {
+1 -90
View File
@@ -139,90 +139,9 @@ tutorial(
await t.uncallout("out");
});
await t.h.step("A value and a negative", async () => {
await t.say("A toggle can also carry words. Edit the first WP Toggles and add a toggle called Rain.");
await openEdit(tg);
await t.click(t.node(tg).locator('[data-test="tg-add"]'));
await t.beat(700);
await t.fill(winField("tg-name"), "Rain", { cps: 14 });
await t.press("Tab");
await t.beat(300);
await t.fill(winField("tg-var"), "rain", { cps: 14 });
await t.press("Tab");
await t.beat(400);
await t.waitVoice();
await t.fill(winField("tg-value"), "in the rain", { cps: 14 });
await t.beat(400);
await t.callout("value", win().locator('[data-test="tg-value"]').first(), "On: $rain prints this", 5);
await t.say("Give it a Value, and while it's on, rain holds those words instead of true. While it's off, it holds nothing.");
await t.waitVoice();
await t.uncallout("value");
await t.click(win().locator('[data-test="tg-negative-add"]'));
await t.beat(500);
await t.fill(winField("tg-negative"), "sunny sky", { cps: 14 });
await t.beat(400);
await t.callout("neg", win().locator('[data-test="tg-negative"]').first(), "Goes to the negative prompt", 5);
await t.say("A Negative goes with it, to the negative prompt, only while the toggle is on.");
await t.waitVoice();
await t.uncallout("neg");
await t.fill(winField("tg-off-value"), "under a clear sky", { cps: 14 });
await t.beat(400);
await t.callout("off", win().locator('[data-test="tg-off-value"]').first(), "Off: $rain prints this instead", 5);
await t.say("An Off value is what it holds while it's off. It can have its own negative too.");
await t.waitVoice();
await t.uncallout("off");
await winDone();
const rain = t.node(tg).locator('[data-test^="tg-row-"]').filter({ hasText: "Rain" });
await t.callout("globe", rain.locator('[data-test^="tg-internal-"]').first(), "Off: the Assembler can print it", 4);
await t.say("Turn off its globe, so the Assembler can print it.");
await t.click(rain.locator('[data-test^="tg-internal-"]').first());
await t.beat(500);
await t.waitVoice();
await t.uncallout("globe");
await t.click(t.node(tg).locator('[data-test="tg-edit-done"]'));
await t.beat(800);
await t.deselect();
await t.say("Add rain to the end of the prompt, switch Rain on, and run.");
const box = t.node(nodes.asm).locator("textarea, [contenteditable='true']").first();
await t.click(box);
await t.page.keyboard.press("ControlOrMeta+End");
await t.type(", $rain", { cps: 14 });
await t.press("Escape");
await t.beat(400);
await t.click(t.node(tg).locator('[data-test^="tg-row-"]').filter({ hasText: "Rain" }).locator('[data-test^="tg-ctl-"]').first());
await t.beat(500);
await t.waitVoice();
await runFor(/in the rain/);
await t.callout("out", t.node(nodes.text), "The value in the prompt", 5);
await t.say("Rain's words are in the prompt.");
await t.waitVoice();
await t.uncallout("out");
const neg = t.node(nodes.asm).locator('[data-test="asm-neg-preview"]');
// The step is about this word: fail rather than box a preview without it.
await neg.filter({ hasText: "sunny sky" }).waitFor({ state: "visible", timeout: 15_000 })
.catch(() => { throw new StepError("The Assembler's negative preview doesn't show sunny sky."); });
await t.callout("negp", neg, "sunny sky", 5);
await t.say("And sunny sky is in the negative.");
await t.waitVoice();
await t.uncallout("negp");
await t.say("Switch Rain off, and run again.");
await t.click(t.node(tg).locator('[data-test^="tg-row-"]').filter({ hasText: "Rain" }).locator('[data-test^="tg-ctl-"]').first());
await t.beat(500);
await runFor(/under a clear sky/);
// "hidden" also passes when the whole negative preview goes (nothing left to send).
await neg.filter({ hasText: "sunny sky" }).waitFor({ state: "hidden", timeout: 15_000 })
.catch(() => { throw new StepError("sunny sky is still in the negative preview with Rain off."); });
await t.callout("out", t.node(nodes.text), "The Off value", 5);
await t.say("Now it's the Off value, and sunny sky has left the negative.");
await t.waitVoice();
await t.uncallout("out");
await t.say("Rules still get the switch: rain dot bool is true or false, whatever the values.");
await t.waitVoice();
});
await t.h.step("Pick one", async () => {
await t.say("A toggle can also pick the words itself. Add a second WP Toggles after the first, and set it to Pick one.");
const tg2 = await t.addNode("WP Toggles", { x: 300, y: 540 });
const tg2 = await t.addNode("WP Toggles", { x: 300, y: 470 });
await t.wire(tg, "context", tg2, "upstream");
await t.wire(tg2, "context", nodes.ctx1, "upstream");
await openEdit(tg2);
@@ -258,17 +177,9 @@ tutorial(
await winDone();
}
await t.normalSpeed();
await t.say("Layout can draw the toggles as a grid of tiles instead of rows.");
await t.click(t.node(tg2).locator('[data-test="tg-layout-grid"]'));
await t.beat(500);
await t.waitVoice();
await t.click(t.node(tg2).locator('[data-test="tg-edit-done"]'));
await t.beat(800);
await t.deselect();
await t.callout("grid", t.node(tg2), "Three tiles, one picked", 4);
await t.say("Here they are, one tile each.");
await t.waitVoice();
await t.uncallout("grid");
await t.waitVoice();
await t.say("Switch off Time of day on the first Context, so the toggle decides the time, and pick Night.");
await t.click(t.moduleRow(nodes.ctx1, "Time of day").locator('[data-test="module-enabled"]'));
+3 -9
View File
@@ -84,12 +84,6 @@ tutorial(
await t.say("No warnings now: every variable is set and the rules hold.");
await t.waitVoice();
await t.uncallout("ok");
} else if (!clean) {
// Warnings left (or none Fix all can take): say so over the list, never pass over it in silence.
await t.callout("left", problems, "What's left", 5);
await t.say("The test run still lists a warning or two. Each one says what doesn't roll right, so you can fix it later.");
await t.waitVoice();
await t.uncallout("left");
}
});
@@ -103,9 +97,9 @@ tutorial(
if (await install.isEnabled().catch(() => false)) {
// Install sits low on the page: bring it up out of the caption's band first.
await t.scrollTo(install, { at: 0.45 });
// One literal per line: voice/speak.py only voices t.say("…") written out, never a ternary.
if (clean) await t.say("Install puts it in your library as one bundle, ready for a Context.");
else await t.say("A warning or two can stay: Install puts it in your library as one bundle, and you fix the rest in its editors.");
await t.say(clean
? "Install puts it in your library as one bundle, ready for a Context."
: "A warning or two can stay: Install puts it in your library as one bundle, and you fix the rest in its editors.");
await t.click(install);
await aiWait(t, { quiet: 2000 });
installed = true;
+1 -1
View File
@@ -69,7 +69,7 @@ tutorial(
if (!have) {
await t.fastForward(async () => {
const r = await t.h.api("/wp/api/tags/download", { method: "POST" });
if (r.status < 200 || r.status >= 300) throw new StepError("The tag list couldn't be downloaded on this machine.");
if (!r.ok) throw new StepError("The tag list couldn't be downloaded on this machine.");
}, 0);
}
await t.go("/wp/settings/autocomplete");
+2 -3
View File
@@ -212,7 +212,7 @@ tutorial(
await t.click(modal.locator('[data-test="mx-seed-unlockall"]'));
await t.beat(700);
await t.waitVoice();
await t.callout("cp", [modal.locator('[data-test="mx-seed-copy"]'), page.locator('[data-test="mx-seed-paste"]').last()], "Move seeds between loops", 4);
await t.callout("cp", [modal.locator('[data-test="mx-seed-copy"]'), modal.locator('[data-test="mx-seed-paste"]')], "Move seeds between loops", 4);
await t.say("Copy and Paste carry the seeds over as text, to another loop or a Seed List.");
await t.waitVoice();
await t.uncallout("cp");
@@ -223,8 +223,7 @@ tutorial(
await t.beat(700);
await t.waitVoice();
await t.uncallout("prev");
// The Done button sits in the kit Modal's footer, outside the seed-modal body.
await t.click(page.locator('[data-test="seed-modal-done"]').last());
await t.click(modal.locator('[data-test="seed-modal-done"]'));
await t.beat(600);
});
-29
View File
@@ -817,35 +817,6 @@ export class Tutor {
const hit = el.closest("[data-node-id]") ?? el;
y = hit.getBoundingClientRect().bottom + 40;
}
// A group is drawn on the canvas, so elementFromPoint can't see it, and a double click on its
// body no longer opens the search: go below the group too.
type Ds = { scale: number; offset: [number, number] };
type Grp = { _bounding?: ArrayLike<number>; bounding?: ArrayLike<number>; pos?: ArrayLike<number>; size?: ArrayLike<number> };
const w = window as unknown as { app?: { canvas?: { ds?: Ds; canvas?: HTMLCanvasElement }; graph?: { groups?: Grp[]; _groups?: Grp[] } } };
const ds = w.app?.canvas?.ds;
const rect = w.app?.canvas?.canvas?.getBoundingClientRect();
const box = (g: Grp): number[] | null => {
const b = g._bounding ?? g.bounding;
if (b && b.length >= 4) return [Number(b[0]), Number(b[1]), Number(b[2]), Number(b[3])];
if (g.pos && g.size) return [Number(g.pos[0]), Number(g.pos[1]), Number(g.size[0]), Number(g.size[1])];
return null;
};
if (ds && rect) {
const toCanvasY = (sy: number) => (sy - rect.top) / ds.scale - ds.offset[1];
const toScreenY = (cy: number) => (cy + ds.offset[1]) * ds.scale + rect.top;
const gx = (x - rect.left) / ds.scale - ds.offset[0];
const inside = (cy: number) => (w.app?.graph?.groups ?? w.app?.graph?._groups ?? [])
.map(box)
.find((b) => !!b && gx >= (b[0] ?? 0) && gx <= (b[0] ?? 0) + (b[2] ?? 0) && cy >= (b[1] ?? 0) && cy <= (b[1] ?? 0) + (b[3] ?? 0));
const hit = inside(toCanvasY(y));
if (hit) {
// Below the group when that is on screen, else above it; otherwise leave the point.
const below = toScreenY((hit[1] ?? 0) + (hit[3] ?? 0)) + 50;
const above = toScreenY(hit[1] ?? 0) - 50;
if (below < window.innerHeight - 80 && !inside(toCanvasY(below))) y = below;
else if (above > rect.top + 60 && !inside(toCanvasY(above))) y = above;
}
}
return { x, y: Math.min(y, window.innerHeight - 60) };
}, wanted);
// The search box opens on a double click on empty canvas; when the click is taken for something
+1 -7
View File
@@ -65,13 +65,7 @@ def lines_in(files: list[Path]) -> dict[str, str]:
"""Each caption, with what the voice reads for it (its own spoken text, else the rules)."""
out: dict[str, str] = {}
for f in files:
src = f.read_text(encoding="utf-8")
# A say() whose text isn't written out ("t.say(ok ? 'a' : 'b')") is never voiced: shout.
for m in re.finditer(r"\.say\(\s*(?![\s\"'`])", src):
row = src.count("\n", 0, m.start()) + 1
print(f"NOT VOICED: {f.name}:{row} says a line that isn't written out",
file=sys.stderr)
for m in SAY.finditer(src):
for m in SAY.finditer(f.read_text(encoding="utf-8")):
text = unescape(m.group("text"))
spoken = unescape(m.group("spoken")) if m.group("spoken") is not None else None
if "${" in text or (spoken and "${" in spoken):