Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c26ca9357 | ||
|
|
5a3b15aa95 | ||
|
|
8c273e45c8 | ||
|
|
a8116070be | ||
|
|
e562709299 | ||
|
|
9267022d97 | ||
|
|
27bf46263e | ||
|
|
0671831e3d | ||
|
|
4d852de35e | ||
|
|
fc0269ebd5 | ||
|
|
ad3035ccd9 | ||
|
|
9ebf4caf68 | ||
|
|
b3f0f3d216 | ||
|
|
885a76486b | ||
|
|
0e3aff5454 | ||
|
|
7e4034c786 | ||
|
|
38652a801a | ||
|
|
3b9ec183a5 | ||
|
|
9a70a5b6fc | ||
|
|
1ad14d140d | ||
|
|
b2ba1cc49b | ||
|
|
c224aa9ba9 | ||
|
|
69d4e9abdc | ||
|
|
4169607db2 | ||
|
|
982b558e11 | ||
|
|
11f05e286a | ||
|
|
8472491d87 | ||
|
|
211e2fdbed | ||
|
|
a7b3684e40 | ||
|
|
325e077c85 | ||
|
|
588348953a | ||
|
|
3026006090 | ||
|
|
76a700c426 | ||
|
|
81a86088f4 | ||
|
|
0532b118a5 | ||
|
|
5e63221e84 | ||
|
|
67da3cfbb8 | ||
|
|
bb8ecce1fb | ||
|
|
3b7d8c10d7 | ||
|
|
3d1cdebd70 | ||
|
|
64f6f58844 | ||
|
|
a16129a973 | ||
|
|
4476cebf57 | ||
|
|
2f364f87e6 | ||
|
|
257be0bfc7 | ||
|
|
3caa30e410 | ||
|
|
cc2cea239a | ||
|
|
e8d17999c2 | ||
|
|
be71b6391b | ||
|
|
54d48f88f5 |
@@ -116,14 +116,14 @@ 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. |
|
||||
| **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 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. |
|
||||
| **WP Toggles** | Several on/off switches in one node: each writes a true/false `$variable` for rules, can have a BOOLEAN output, and can mute or bypass the nodes wired into it. Merges existing Boolean nodes. |
|
||||
| **WP Node Group** | Gathers nodes so one WP Toggles switch mutes or bypasses them all. With nothing wired in, it stands for the canvas group it sits in (frontend only, never runs). |
|
||||
| **WP Mode Relay** | Watches nodes' mute / bypass and passes a mode on to a WP Node Group, for chains (frontend only, never runs). |
|
||||
| **WP Bookmark** | Marks a spot on the canvas to jump back to with a shortcut or from the Bookmarks dock: pick the corner, edge or centre it lands at and the zoom; the shortcut field shows clashes with ComfyUI and other extensions. Can hide itself from the canvas (frontend only, never runs). |
|
||||
| **WP Bookmark** | Marks a spot on the canvas to jump back to with a shortcut or from the Bookmarks dock: pick the corner, edge or centre it lands at and the zoom; the shortcut field shows clashes with ComfyUI and other extensions. The dock shows them as rows or as icon tiles. Can hide itself from the canvas (frontend only, never runs). |
|
||||
| **WP Image Filter** | Pauses the run so you pick which images go on to the upscaler. Their latent, conditioning, prompt text and Context are filtered with them, and you can edit a prompt or paint a mask before they go on. |
|
||||
| **WP Label & Grid** | Writes labels on images and lays them out in one labelled grid, or an XY plot sorted by each image's sweep options. Works on one image, a batch, a list or a loop's frames; grid presets follow the shape of the run. |
|
||||
| **WP Prompt Cleaner** | Tidies the final prompt: spacing, stray commas, empty `()`, nested weights, LoRA tags, duplicates, a blocklist. |
|
||||
|
||||
@@ -23,15 +23,16 @@ A bookmark has no node frame and can't be resized: it is a small pill with its i
|
||||
- **Zoom**: Set jumps to that zoom (0.1–10×; the magnifier takes the zoom you're at). Keep current leaves it alone.
|
||||
- **Shortcut**: Record, then press the keys (Esc cancels, Backspace clears). The bolt picks a free one, a new one on each press.
|
||||
- **Show on canvas**: Off hides the node when you press Done: not drawn, not clickable, not selectable. The dock's eye shows it again.
|
||||
- **Pinned**: ComfyUI's own pin: a pinned bookmark can't be moved by accident.
|
||||
- **Pinned**: ComfyUI's own pin: a pinned bookmark can't be moved by accident. New bookmarks start unpinned; a saved workflow keeps each bookmark's pin.
|
||||
- **Go**: Tries the jump.
|
||||
|
||||
## The Bookmarks dock
|
||||
|
||||
- **Row**: Click to jump. Number (a Step note's in its circle), icon, name and shortcut; a mark for one hidden on the canvas or inside a subgraph, OFF for a muted one.
|
||||
- **Grid / List**: Shows the bookmarks as icon tiles (number, icon in its colour, name, shortcut) that wrap three to a line, or as rows. The dock remembers your choice; edit mode always shows rows.
|
||||
- **Back**: Return to where you were before the last jump.
|
||||
- **↑ / ↓**: Step through the bookmarks that are on, in dock order.
|
||||
- **Pencil**: Edit mode: drag the dock by its header and it snaps to the nearest spot of a 3×3 grid on the free canvas (a dashed outline shows where); drag the grips to reorder, the eye shows or hides a bookmark on the canvas, and Bookmark this view adds one. Land clear of the dock (on by default) moves a jump out from under the dock; off, bookmarks land where they would without it, behind the see-through dock. Done goes back. The dock follows the layout (side panels, the bottom panel, the top bar, the minimap and zoom controls, a floating run bar) and stays put when the job progress panel, the error card, a toast or a menu pops up; those float over it.
|
||||
- **Pencil**: Edit mode: drag the dock by its header and it snaps to the nearest spot of a 3×3 grid on the free canvas (a dashed outline shows where); drag the grips to reorder, the eye shows or hides a bookmark on the canvas, and Bookmark this view adds one at the zoom you're at: the small 3×3 grid beside it picks where on screen the new bookmark sits, and it lands there, so jumping to it brings back that exact view. Land clear of the dock (on by default) moves a jump out from under the dock; off, bookmarks land where they would without it, behind the see-through dock. Done goes back. The dock follows the layout (side panels, the bottom panel, the top bar, the minimap and zoom controls, a floating run bar) and stays put when the job progress panel, the error card, a toast or a menu pops up; those float over it.
|
||||
<!-- fields:end -->
|
||||
|
||||
## Shortcut clashes
|
||||
|
||||
@@ -45,7 +45,7 @@ In **Detail**, the side panel edits the frame's prompts and paints a mask for th
|
||||
|
||||
- An edited prompt replaces the text output. With a CLIP wired into **clip**, the conditioning is re-encoded to match; without one it stays as it was, and the panel says so.
|
||||
- With **Same shape** a frame's picks go on together, so a prompt edit covers the whole frame. With **One per image** it covers just that image.
|
||||
- **M** or **Paint** opens the mask painter. **Brush** paints where you drag, **Lasso** fills the area you drag around, and **Fill** fills the area under a click up to the lines you painted (paint an outline, then fill its inside). **Paint** or **Erase** works with every tool (**X** swaps). **Size** is the brush (**[** and **]**), **Hardness** softens the edge of the brush, lasso and fill, and **Strength** masks part-way, which inpainting changes less; one stroke stays at its strength where it crosses itself. **Undo** and **Redo** (Ctrl+Z, Ctrl+Shift+Z), **Invert** and **Clear**. A ring under the mouse shows the brush (dashed when erasing, a dotted inner ring where a soft edge starts). The mask goes to the **masks** output, resized to the image. Images you don't paint keep the incoming mask, or an empty one when nothing is wired in. Once painted, the **Mask** tab shows a preview: **Over image** tints the masked part, **Mask only** shows it white on black as the **masks** output sends it.
|
||||
- **M** or **Paint** opens the mask painter. **Brush** paints where you drag, **Lasso** fills the area you drag around, and **Fill** fills the area under a click up to the lines you painted (paint an outline, then fill its inside). **Paint** or **Erase** works with every tool (**X** swaps). **Size** is the brush (**[** and **]**), **Hardness** softens the edge of the brush, lasso and fill, and **Strength** masks part-way, which inpainting changes less; one stroke stays at its strength where it crosses itself. **Undo** and **Redo** (Ctrl+Z, Ctrl+Shift+Z), **Invert** and **Clear**. A ring under the mouse shows the brush (dashed when erasing, a dotted inner ring where a soft edge starts). The mask goes to the **masks** output, resized to the image. Images you don't paint keep the incoming mask, or an empty one when nothing is wired in. Once painted, the **Mask** tab shows a preview: **Over image** tints the masked part, **Mask only** shows it white on black as the **masks** output sends it. Wire **masks** into a **Set Latent Noise Mask** on the filter's own **latent**, not behind an **Upscale Latent**: the sampler keeps every part you didn't paint as it gets it, and an upscaled latent left as it is decodes blocky. To refine and upscale, upscale after the masked pass.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Draws labels on images and lays them out in one labelled grid: compare runs, sho
|
||||
## Inputs and outputs
|
||||
|
||||
- **images** `IMAGE`: One image, a batch, a list, or a list of batches (a Context Loop's frames). Required.
|
||||
- **labels** `STRING`: Optional text, one entry per item and one line per image of its batch. One line covers the whole batch, and a single one-line label covers every image of every item. The Image Filter's labels output fits straight in. One typed text with a line per image works too.
|
||||
- **labels** `STRING`: Optional text, one entry per item and one line per image of its batch. One line covers the whole batch, and a single one-line label covers every image of every item. The Image Filter's labels output fits straight in. One typed text with a line per image works too, spread over the images in order. When the lines and images don't match, a warning on the node says how many images got no line or how many lines were left over.
|
||||
- **label_text** `STRING`: Optional text from another node for what each image shows; {label}, {index} and the other tokens still work. A list gives one text per item, the last one covering any item past it.
|
||||
- **title** `STRING`: Optional text from another node for the grid title. Once wired, the panel's Title box shows that it comes from the wire.
|
||||
- **row_headers, column_headers** `STRING`: Optional text from another node, one header per line (a list gives one header per entry). Wiring one sets that side to typed headers and replaces its dropdown on the panel. Like a built-in widget's, each text input's dot sits beside the field it feeds and shows when you hover that field or wire it.
|
||||
@@ -16,14 +16,15 @@ Draws labels on images and lays them out in one labelled grid: compare runs, sho
|
||||
## Label
|
||||
|
||||
- **Text**: What each image shows. {label} is the incoming label (the default), {frame} and {image} count from 1 per input item, {index} counts across all images, {count} is how many there are. \n breaks a line.
|
||||
- **Place**: Above, below, left or right adds a strip beside the image. Over image draws a see-through band over the top or bottom and keeps the size. The last button draws no label.
|
||||
- **Place**: Above, below, left or right adds a strip beside the image. Over image draws a see-through band over that edge instead and keeps the size, on any side. The last button draws no label.
|
||||
- **Text direction**: Left and right only: horizontal text, or vertical text turned along the edge (reading upwards on the left, downwards on the right), which keeps the strip thin.
|
||||
- **Font and size**: Your system fonts plus any you drop into the extension's fonts folder; type to search. An empty size is automatic: a share of the image width, so labels read the same at 512 px and at 4K.
|
||||
- **Colours**: Click a swatch for the colour square and a hex box. The strip or band also has an opacity, and None drops it.
|
||||
|
||||
## Grid
|
||||
|
||||
- **Layout**: Fit the run, one row or column per frame, fill rows (with a column count), all in one row, all in one column. Label-part headers set the layout themselves.
|
||||
- **Rows, Columns**: No headers, typed (one header per line in the box that opens), or a label part. The list shows what each part held on the last run.
|
||||
- **Rows, Columns**: No headers, typed (one header per line in the box that opens), or a label part. The list shows what each part held on the last run. An image whose label lacks the part goes to a last row or column headed (no label), and the node warns when typed headers are fewer or more than the rows or columns.
|
||||
- **Title, Spacing**: A line of text above the grid; the gap between cells, the edge around the grid, and the cell width (empty keeps image sizes, a number scales every cell to it).
|
||||
- **Colours**: The grid's background and the colour of headers and title. Reset puts spacing and these back.
|
||||
<!-- fields:end -->
|
||||
|
||||
@@ -4,7 +4,7 @@ A note that explains a workflow. It looks like a panel, not a node: no title bar
|
||||
|
||||
## Using it
|
||||
|
||||
- **Add** it from the node search (`WP Note`). A new note opens in its editor and starts pinned.
|
||||
- **Add** it from the node search (`WP Note`). A new note opens in its editor, unpinned.
|
||||
- **Edit** by double-clicking the note or with the indigo pencil in the selection toolbar while it is selected. **Done** goes back to reading.
|
||||
- **Move** it by dragging like any node (unpin it first).
|
||||
- **Resize** a Card or Sticky note from its corner: the width is yours, the height follows the text. A Label sizes itself to its title.
|
||||
@@ -18,7 +18,7 @@ A note that explains a workflow. It looks like a panel, not a node: no title bar
|
||||
- **Tone**: Step (a numbered circle), Icon (pick any icon from the list next to it) or Plain (no badge).
|
||||
- **Colour**: Presets that follow the theme (Accent, Tip, Info, Warning, Danger, Teal, Violet, Neutral), or any colour from the picker.
|
||||
- **Look**: Card, Sticky or Label (above).
|
||||
- **Pinned**: ComfyUI's own pin: a pinned note can't be moved or resized by accident. New notes start pinned.
|
||||
- **Pinned**: ComfyUI's own pin: a pinned note can't be moved or resized by accident. New notes start unpinned; a saved workflow keeps each note's pin.
|
||||
- **Title**: Shown in bold at the top, or on the pill of a Label.
|
||||
- **Text**: Its first line and length on the node; Edit text opens the text window, with the editor beside a live preview. Done (Ctrl+Enter) puts it on the note, Cancel or Escape leaves the note as it was. Markdown, with the formatting bar below over it: headings (# to ####), bold, italic, strikethrough, code, bulleted, numbered and nested lists, task lists (- [x] / - [ ]), quotes and callouts (> [!TIP], > [!WARNING]), tables with column alignment (:--, :-:, --:), rules (---) and code blocks.
|
||||
|
||||
|
||||
@@ -6,10 +6,17 @@ Write a prompt with `$variables` in it and get the finished prompt, plus a match
|
||||
## Inputs and outputs
|
||||
|
||||
- **context (in)** `context`: The variables from a WP Context (or anything that ends in one).
|
||||
- **template** `STRING`: Your prompt with $variables in it.
|
||||
- **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).
|
||||
- **template** `STRING`: Your prompt with $variables in it. Wire another node's text into it to use that text as the template instead: the box then says "From the template input", and the $variables in the wired text still fill in.
|
||||
- **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). It takes a wired text the same way the template does.
|
||||
- **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.
|
||||
- **Frame arrows (preview)**: Only with a WP Context Loop upstream: ‹ 1/4 › steps the preview through the loop's frames and moves the Loop's Edit frame pick with it. A variable only some frames set shows as a blue chip with its frames (#4), never as missing.
|
||||
<!-- fields:end -->
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -6,7 +6,7 @@ Tidy a prompt before the sampler: drop duplicate tags and stray commas, remove e
|
||||
## Inputs and outputs
|
||||
|
||||
- **prompt (in)** `STRING`: The text to clean, usually from a WP Prompt Assembler. Any text works.
|
||||
- **cleaner widget**: Pick a mode and a preset, switch single rules on or off, and edit the blocklist. After a run it shows how many fixes each rule made.
|
||||
- **cleaner widget**: Pick a mode and a preset, switch single rules on or off, and edit the blocklist. After a run it shows how many fixes each rule made, and the Last run strip opens the run's before and after with what each rule changed and why.
|
||||
- **negative (in)** `STRING`: Optional. A negative to clean too: type it or wire in the Assembler's negative. Left empty, the negative output is empty.
|
||||
- **prompt (out)** `STRING`: The cleaned prompt, for CLIP Text Encode.
|
||||
- **blocklist (in)** `STRING`: Optional text from another node, such as one shared word list feeding several cleaners. Its entries join the panel's blocklist (comma- or newline-separated in list mode, one per line in regex mode) and turn the blocklist on; the Blocklist button reads + input. Its dot sits beside that button and shows when you hover it or wire it.
|
||||
@@ -29,9 +29,9 @@ Tidy a prompt before the sampler: drop duplicate tags and stray commas, remove e
|
||||
|
||||
| Preset | Rules |
|
||||
|---|---|
|
||||
| gentle | spaces only |
|
||||
| gentle | spaces and commas only (`blurry, , lowres` becomes `blurry, lowres`) |
|
||||
| balanced | empty brackets, LoRA spacing, spaces, punctuation, duplicate tags |
|
||||
| aggressive | all of balanced, plus merge weights and near-duplicates ("bad hand" and "bad hands") |
|
||||
| aggressive | all of balanced, plus merge weights and near-duplicates (`a knight`, `knight` and `knights` keep the first; tags 90% alike letter by letter, the same tag at another weight too) |
|
||||
|
||||
**CUSTOM** shows when you've switched rules away from the preset; click the preset again to go back.
|
||||
|
||||
@@ -54,9 +54,11 @@ In tags mode the whole tag with the word goes; in text mode just the word. Never
|
||||
|
||||
## Cleaning the negative
|
||||
|
||||
With a negative wired in, the rule list gets a second column for it. It follows the prompt column, except near-duplicates and the blocklist are off. **Drop negative tags also in prompt** (off by default) removes a word from the negative when the prompt asks for it; the run stats name the overlap either way.
|
||||
With a negative wired in, the rule list gets a second column for it. It follows the prompt column, except near-duplicates and the blocklist are off. **Drop negative tags also in prompt** (off by default) removes a word from the negative when the prompt asks for it; either way the Last run strip names the overlap on an amber line.
|
||||
|
||||
## Tips
|
||||
|
||||
- Hover a rule for what it does.
|
||||
- After a run, the count next to each rule shows what it removed.
|
||||
- Each rule runs once, in list order, on what the rule before left, so a rule can show 0 because an earlier one already fixed it.
|
||||
- Duplicate removal never touches `BREAK` or `AND`, and punctuation keeps emoticon tags such as `:3` and `-_-`.
|
||||
|
||||
@@ -7,9 +7,10 @@ Several on/off switches in one node. Each toggle writes `$its_variable` as `true
|
||||
|
||||
- **upstream (in)** `context`: Optional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).
|
||||
- **Pencil (toolbar)**: Select the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.
|
||||
- **Any on / Pick one**: Any on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.
|
||||
- **Any on / Pick one**: Any on: every toggle is on or off by itself. Pick one: at most one is on; tick Always one on to keep exactly one on, like radio buttons.
|
||||
- **writes (Pick one)**: Optional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.
|
||||
- **Globe (row)**: Edit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.
|
||||
- **Always one on (Pick one)**: Like radio buttons: one toggle is always on. Clicking the picked one does nothing; pick another to switch. With none on, the first toggle is picked. Off (the default), the picked toggle can be switched off so none is on.
|
||||
- **Show in Assembler (Pick one)**: The picked variable shows in the Assembler and can print in the prompt. Off, it is internal.
|
||||
- **Style (Pick one)**: How every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.
|
||||
- **Layout**: Edit view: Rows (one row per toggle, the default) or Grid (each toggle in a tile, the tiles wrapping to the node's width), in both modes and every style. Each label has a grid button for its own group; setting it back to the node's layout makes the group follow the node again. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name, BOOLEAN outputs on the right with theirs, each side packed on its own (so Dawn | Dawn, Sunset | Night). The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), and the arrow on the merged line opens them again.
|
||||
@@ -24,7 +25,7 @@ Several on/off switches in one node. Each toggle writes `$its_variable` as `true
|
||||
- **Name**: Shown 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.
|
||||
- **Variable**: Gets 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.
|
||||
- **Looks**: An 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.
|
||||
- **Style**: Switch, 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.
|
||||
- **Style**: Switch, Check, Buttons (two words of your own, like Day / Night; the On side lights up in the control colour while picked, and a click on the lit side flips it too) 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.
|
||||
- **Value**: What 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.
|
||||
- **Negative**: Words 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.
|
||||
- **Off value (Any on)**: What 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.
|
||||
@@ -34,13 +35,13 @@ Several on/off switches in one node. Each toggle writes `$its_variable` as `true
|
||||
- **Canvas groups**: Groups on the canvas whose nodes When off also covers, by title. No wires needed.
|
||||
- **Upstream can set it**: When 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.
|
||||
- **Group**: Which label it sits under. You can also drag the toggle's grip under another label, or above the first one.
|
||||
- **BOOLEAN output**: Show an output for switch nodes. Off for a toggle only rules read.
|
||||
- **BOOLEAN output**: Show an output for switch nodes. Off for a new toggle; leave it off for a toggle only rules read.
|
||||
<!-- fields:end -->
|
||||
|
||||
## Toggles
|
||||
|
||||
- **Any on**: each toggle is on or off by itself. A toggle that **needs** another is off while that one is off, and keeps its own choice for when it is on again.
|
||||
- **Pick one**: only one toggle is on: the one the upstream picked variable names (with **Upstream can pick**), else the first one upstream set on, else the first one you switched on. Optional **writes** variable gets the picked toggle's **Value**, or its name; internal unless **Show in Assembler**. Its **Style** (Radio, Chip, Switch, Check) sets every row's look.
|
||||
- **Pick one**: only one toggle is on: the one the upstream picked variable names (with **Upstream can pick**), else the first one upstream set on, else the first one you switched on. Optional **writes** variable gets the picked toggle's **Value**, or its name; internal unless **Show in Assembler**. **Always one on** makes it work like radio buttons: the picked toggle can't be switched off, only swapped, and with none on the first one is picked. Its **Style** (Radio, Chip, Switch, Check) sets every row's look.
|
||||
- **When off**: Nothing, **Mute** or **Bypass** the nodes wired into the row's dot and any **Canvas groups** you pick, while the toggle is off (needs and Pick one count). Done on the canvas when the toggle flips.
|
||||
- **Invert**: mute / bypass while the toggle is ON instead. An icon by the name shows a control is wired: coloured while it acts, dim while not; hover for the nodes.
|
||||
- **Show** (by the variable): show the `$variable` under the name in the clean view.
|
||||
|
||||
@@ -9,7 +9,7 @@ Reads a `$variable` from the upstream Context and sends it into a dropdown input
|
||||
- **var_name** `variable`: The $variable to read. The list shows every variable the wired Context has.
|
||||
- **match** `COMBO`: How close the text must be to an option: exact, loose (the default) or closest. See below.
|
||||
- **fallback** `COMBO`: A dropdown of the options of the input it's wired into, filled when you connect it. Used when nothing matches or the variable is empty. (first option) means the first option in the list.
|
||||
- **if_no_match** `COMBO`: use fallback carries on with the fallback and warns you. stop the run stops here and lists some of the options.
|
||||
- **if_no_match** `COMBO`: use fallback carries on with the fallback and warns you. stop the run stops here and lists some of the options: the reason shows in red on the node and in a message.
|
||||
- **choice (out)** `any`: Wire it into a dropdown input: ckpt_name, sampler_name, scheduler, vae_name, lora_name and so on. The line under the picker names what it feeds and how many options that has.
|
||||
<!-- fields:end -->
|
||||
|
||||
|
||||
@@ -6,16 +6,24 @@
|
||||
- **Any extension picture in a WP Note.** The Pictures panel's **From the extension** now offers every screenshot from the docs, not just the toolbar one, found by typing part of its name (the grid shows only matches, so the panel stays tidy). After you add a picture the panel stays open, so its width slider is right there, and the icon list and picture grid use the window's height instead of a small scrolling box. The icon picker on the node now fills the editor's width.
|
||||
- **Export as plain wildcard files.** Import / Export → Export has a new **Wildcard files (.zip)** button: the picks become `.txt` wildcard files for Dynamic Prompts, Impact Pack, Prompt-PostProcessor, Easy-Use and A1111/Forge, so you can share a pack with people who don't use Wildcard Pipeline. References and `$variables` become `__folder/name__`, uneven weights become repeated lines, and a README in the zip lists what had no plain form (rules, tags, the chance of nothing, negatives).
|
||||
|
||||
- **See what the Prompt Cleaner did, and why.** Click the **Last run** strip at the bottom of WP Prompt Cleaner (it now shows after every run, not only with a negative) and a window opens with the prompt before and after, side by side: removed tags struck through in red, added ones in green, compared tag by tag, so `((silver hair))` → `(silver hair:1.21)` reads as one tag out and one in. Below it is one line per rule that changed something, in the order they ran; open one to see that rule's own before and after and why it fired: a dropped duplicate beside the tag it repeats, a near-duplicate beside the tag it is close to (and how close), a blocked tag beside the blocklist entry it matched, with the cause marked in blue in the text. A Prompt / Negative switch shows the negative's run too, including tags it shares with the prompt, and **Copy cleaned text** copies either side.
|
||||
|
||||
- **A toggle's settings open in a window.** In WP Toggles' edit view each row now has a pencil, the globe and a trash button, like a module on a Context node. The pencil opens that toggle's settings in a window instead of a panel squeezed into the node, with the row drawn on top as the node will show it; Done saves, Cancel drops the changes (it asks first when there are any). Mode, order, labels and the Pick one options stay on the node.
|
||||
- **Icons and colours for WP Toggles.** A toggle can show an icon before its name and have its own colours for the icon, the name and the control itself, so Day can be yellow and Night blue. A label picks any colour with the colour picker (not just the eight presets) and an icon too; the label's icon button is a small square like its colour one. A Chip toggle shows its variable after the name (Day · `$day`) while Show is on, a variable wears its name's colour, and icons now sit level with the name.
|
||||
- **Icons and colours for WP Toggles.** A toggle can show an icon before its name and have its own colours for the icon, the name and the control itself, so Day can be yellow and Night blue. A label picks any colour with the colour picker (not just the eight presets) and an icon too; the label's icon button is a small square like its colour one. A Chip toggle shows its variable after the name (Day · `$day`) while Show is on, a variable wears its name's colour, and icons now sit level with the name. A Buttons toggle wears the control colour too: its On side lights up while picked (in the accent colour when none is set), and clicking the lit side flips it, so any click on the pair switches it. A new toggle starts without a BOOLEAN output, so the node stays tidy; turn on **BOOLEAN output** in its window when a switch node needs it.
|
||||
- **A negative for each Pick one toggle.** Under a toggle's Value there is now a Negative: words to keep out of the image while that toggle is picked (Night: "sunlight, blue sky"). They go with the picked variable, so an Assembler that prints it puts them in its negative output.
|
||||
- **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.
|
||||
- **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. Dropping on the line itself now lands where the line shows: under a group's last toggle it goes into the group, not below 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, 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.
|
||||
- **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.
|
||||
- **Show a WP panel on its subgraph node.** Click the eye beside a WP panel in the subgraph's **Parameters** panel, the eye in the toolbar over a selected WP node inside the subgraph, or right-click that node (Context, Toggles, Label Grid, Debug, Context Loop and the rest) and pick **Show on subgraph node**: a copy of its panel appears on the subgraph node outside, under the node's title, so you can change toggles or settings without opening the subgraph. Both copies stay in step, the node inside is what runs, and the subgraph node remembers what it shows when you save. The copy's header folds it away, its sliders button (**Choose what shows**) lets you tick which parts of the panel show there (Label Grid's Font, Spacing or Title, single toggles or groups, Context modules, Cleaner rules …; new parts show until you untick them), **Move up** / **Move down** in the same menu order the WP panels on the subgraph node, and its × (or the eye again, or **Hide from subgraph node**) takes it off. The node inside always keeps the whole panel. Works in Nodes 2.0 and the classic canvas.
|
||||
|
||||
- **A grid for the Bookmarks dock.** The dock's header has a grid button: it shows your bookmarks as tiles, three to a line, each with its icon large in the bookmark's colour, the name and the shortcut, so you can find one by its icon. Click a tile to jump; the list button brings the rows back, and the dock remembers which you picked. Edit mode keeps the rows.
|
||||
- **Left and right labels in WP Label & Grid can be vertical, or over the image.** With Place set to left or right, a new switch turns the text to run along the edge (upwards on the left, downwards on the right), which keeps the strip thin, and **Over image** now works on the left and right too, drawing the band over that edge and keeping the image's size.
|
||||
|
||||
### Wildcards
|
||||
|
||||
@@ -23,6 +31,25 @@
|
||||
|
||||
### Fixes
|
||||
|
||||
- **A WP node that stops the run says why.** When WP Var → Choice stops the run (nothing matched, or the variable was empty) or another WP node fails, some ComfyUI versions only flashed "Job failed" for a moment. The reason now shows on the node: Var → Choice's last execute line turns red, reads **stopped the run** and gives the reason in place of the last run's note, and other WP nodes show a red strip at the top. A WP message at the corner of the canvas says the same. It clears when the next run gets past the node, or with the strip's ×.
|
||||
|
||||
- **WP Bookmark's editor fixes.** The icon list now fills the editor's width instead of stopping short on the right, and the shortcut's keys sit in the middle of their button instead of a little low.
|
||||
- **Bookmark this view brings back the view.** A bookmark added with the dock's **Bookmark this view** was put at the top left of the screen, under the dock, so jumping to it moved it clear of the dock and the view came back shifted. It is now put where the jump puts it, clear of the dock, so the jump brings back exactly the view you had. A small 3×3 grid beside the button picks where on screen the new bookmark sits (and lands); the dock remembers it.
|
||||
- **Prompt Cleaner's fuzzy dedupe finds plurals.** Near-duplicate removal now drops tags that are the same words up to plurals and a leading a, an or the, so `a knight, knight, knights` keeps `a knight` (and `bad hand` / `bad hands`, `dress` / `dresses`). Before, short words like these were never close enough, and the result changed depending on whether the `rapidfuzz` library was installed; it is now the same everywhere, and `red hair` / `red chair` stay apart. Last run says a dropped plural "is another form of" the tag it kept.
|
||||
- **Prompt Cleaner rule fixes.** Whitespace now also removes the space before a comma (`knight ,` → `knight,`). Punctuation keeps emoticon tags (`:3`, `;)`, `-_-`, `^_^`) and, in text mode, the full stop, `!` or `?` that ends the prompt. Tag and fuzzy dedupe never drop a repeated `BREAK` or `AND`. LoRA spacing no longer puts a space between a tag and the full stop after it, and a text-mode blocklist no longer takes the last full stop with it.
|
||||
- **Tags in both the prompt and the negative stand out.** On WP Prompt Cleaner's Last run strip they get their own amber line with a warning sign, apart from the red negative line (plain once **Drop negative tags also in prompt** removed them).
|
||||
- **Text box labels no longer sit on top of the text.** In Nodes 2.0 the "prompt" / "negative" / "template" name in a WP node's text box fades out while the box is scrolled, instead of floating over the lines, and comes back at the top.
|
||||
- **WP Label & Grid keeps the grid when the numbers don't match.** In an XY plot, an image with no label line (a batch of four with three lines) landed in a stray cell outside the grid under an empty black box. It now goes to a last row headed **(no label)** (an image missing just one part gets a (no label) row or column), and a cell with no label keeps its strip in the grid's background instead of a black box. One typed text for a list of images is spread over them in order even when the line count differs. The node now warns, under its run line, when label lines, row headers or column headers are fewer or more than what they label. Column headers sit on the cells, so a header that wraps to two lines lines up with its neighbours.
|
||||
|
||||
- **`$$` prints a single `$` in every preview.** The Prompt Assembler's preview showed "on a $$5 coin" where the prompt says "on a $5 coin"; the preview and its negative line now print `$$` as `$` and `@@` as `@`, as the run does. The same goes for the values the preview shows from wildcards, fixed values, combines and WP Toggles. In WP Toggles itself, a value now always prints `@@` as `@` (before, only a value that also held a `$` or `{` did), and a value that reads another toggle's value gets its finished text, whatever the order of the rows.
|
||||
- **A new WP Note starts unpinned.** A note you add used to pin itself, so it couldn't be moved until you unpinned it. It now starts unpinned like a WP Bookmark or any other node; pin it from its editor or ComfyUI's right-click menu. Notes in a saved workflow keep the pin they were saved with.
|
||||
- **Wiring a text into the Prompt Assembler no longer leaves a hole.** Connecting another node's text to the Assembler's template or negative template made that box vanish and left an empty gap in the node, with the wire ending in blank space (on the classic canvas and on Nodes 2.0). The box now stays and says "From the template input" (or the negative template input), with the wire landing on it, and the `$variables` in the wired text still fill in. Disconnect the wire to type in the box again.
|
||||
- **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.
|
||||
- **Loops: the Assembler knows a variable set on some frames only.** Switch a module off on base and on for frame 4 of a WP Context Loop (or the other way round) and the Assembler used to call its variable missing, with a red underline, "1 missing", "1 unresolved" and a "will render empty" warning on Run, although frame 4 printed it. Now the chip is blue with its frames (`#4`), the header says "1 on some frames", the underline is a calm dotted line, and pointing at it says which frames render it empty. The preview shows a real frame, the one picked under the Loop's Edit frame (frame 1 on base), with **‹ 1/4 ›** arrows to step through them; on a frame without the variable it is struck through and the note says it is empty there. Run and the subgraph badge only warn about a variable no frame sets. In WP Debug, a module that is off on base but on for some frames reads "off · on #4" instead of plain "off".
|
||||
- **`$iteration_value` shows in the Assembler as soon as you wire the Loop's values input.** Wiring a list into the Loop's `values` input didn't change any of its settings, so the Assembler kept its old variable list: `$iteration_value` showed as missing and Run warned it would render empty. It now appears the moment the input is wired.
|
||||
- **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.
|
||||
- **A template row carries the negatives of what it reads.** A Context Injector template row like `$cloak with a silver clasp` now takes `$cloak`'s negative ("torn fabric") along, as a combine does, so an Assembler that prints `$outfit` sends it to the negative output; before, only the row's own Negative went. A WP Toggles value that reads a `$variable` does the same. The Assembler's negative preview shows them too.
|
||||
- **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.
|
||||
@@ -44,5 +71,7 @@
|
||||
- **Scrollbars no longer cover what's under them in Firefox.** Firefox on Windows 11 (and macOS) draws scrollbars over the content, so the icon picker's last column, the end of each line in the note editor and other text boxes, the last row of tables that scroll sideways and the edge of menus sat under the bar. Those places now leave room for it, only while they actually scroll and only in browsers that draw scrollbars this way, so a box that fits (like the category icon picker) has no empty strip.
|
||||
- **WP Note's text scrolls on the first try in Firefox.** The note's text box sat in the node, which passes its clicks to the canvas so it drags like a node, and that kept its scrollbar from noticing the mouse until you clicked inside. The text box now lives in its own window (above), where it scrolls like any other.
|
||||
- **Drag a WP Note or WP Bookmark while its editor is open.** An open editor used to hold the node in place. Press on the editor's background, its rows or labels (anywhere that isn't a control) and drag to move the node; buttons, fields and switches work as before.
|
||||
- **Where an Image Filter mask goes.** The Image Filter's docs said a painted mask was for the upscaler. Behind an Upscale Latent, a Set Latent Noise Mask leaves every part you didn't paint as the upscaled latent, which comes out blocky. The docs now say to wire **masks** into a Set Latent Noise Mask on the filter's own **latent**, and to upscale after the masked pass.
|
||||
- **Image Filter keys keep working after you switch views.** Pressing Z (or G, D, M) right after clicking a button that the new view doesn't have, such as the mask's **Over image / Mask only** switch, lost the keyboard to the canvas: 0, 1 and 2 jumped to a bookmark or warned that the key was taken, and G or D did nothing. The picker now keeps the keys when its view changes.
|
||||
- **WP Note's editor fits its tools.** The editor opens 20 px wider, so the text toolbar, the bookmark's zoom box and its buttons no longer spill past its edge, and the toolbar wraps instead of overflowing on a narrower note.
|
||||
- **WP Debug looks the same with one Context.** With a single WP Context upstream, the Variables tab showed a bare list with no header, while two nodes each got their own card. A lone Context (or any lone node) now gets the same card, with its title and codename, as it does beside others.
|
||||
|
||||
+47
-27
@@ -421,6 +421,10 @@
|
||||
"title": "Something prints wrong",
|
||||
"text": "A $variable prints nothing (MISSING VAR)\nNothing before the Assembler sets it, the name is misspelt, or it's hidden (the globe on its row is on). WP Model Info and the loop's $iteration are hidden by default.\nA wildcard prints nothing\nIt picked its null option, a constraint ruled out every option, or a tag filter matched none. Give it a fallback option; WP Debug's Warnings tab says which it was.\n{a|b} prints as typed\nThe Assembler doesn't roll anything. Put the choice in a wildcard, fixed value or combine and print its variable.\n@{…} prints nothing\nReferences only work in wildcard options and derivation actions. In a combine or the Assembler they print nothing.\n$name in a wildcard option prints as typed\nOptions and fixed values can't read variables. Use a Combine below them.\nCommas or words go missing around an empty variable\nThe Assembler tidies the commas and joining words around a variable that came out empty. Write the template so it still reads well when something is empty."
|
||||
},
|
||||
{
|
||||
"title": "The run stops",
|
||||
"text": "The run stops and only \"Job failed\" flashes\nA WP node stopped it, for example WP Var → Choice set to stop the run when nothing matches. The reason now shows on that node in red (Var → Choice puts it on its last execute line, other WP nodes in a strip at the top) and in a WP message at the corner of the canvas. It goes away when the next run gets past the node, or press its ×."
|
||||
},
|
||||
{
|
||||
"title": "Picks don't change the way you expect",
|
||||
"text": "The same prompt every Generate\nThe Context's seed is set to fixed, or the rows are locked or hold their pick. Set the seed to randomize, or unlock the rows.\nEvery loop frame gives the same image\nThe sampler uses one seed for all frames. Add a WP Seed List between the loop and the sampler.\nA constraint does nothing\nCheck the order: source wildcard, then the constraint, then the target. WP Debug's Trace tab shows whether it applied; Constraint never applied means the target didn't run after it.\nAn option never comes up\nIts weight is 0, it's unticked on the node, a constraint excludes it, or a filter leaves it out. The Test Runner shows each option's share over many seeds.\nA later Context undoes my pick\nWhen two Contexts set the same variable, the later one wins. The row shows a blue dot for this."
|
||||
@@ -429,6 +433,10 @@
|
||||
"title": "Library and packs",
|
||||
"text": "A row says missing\nThe module isn't in your library, often because the workflow came from someone else. It still works; right-click → Save to library to keep it.\nA row says drift (library updated on a bundle)\nYou changed the library entry after adding it. Right-click → Refresh from library (Reset to library snapshot on a bundle) to take the change.\nA pack won't install and asks for an update\nIt uses a feature from a newer version. Update Wildcard Pipeline and try again.\nA constraint shows a missing wildcard\nOpen it and use Reattach to point it at a wildcard you have, or Download from community.\nThe manager stays on its loading screen\nThis happens when ComfyUI restarted or updated while the manager tab was open. The page now notices, waits for the server and reloads by itself. If it says the manager couldn't load, wait until ComfyUI has finished starting and press Reload.\nUndo a change to the library\nSettings › Library & data › Backups lists every backup; Restore puts one back right away, no restart, after backing up the current library as a Before restore backup; the toast's Undo puts it back. A backup from a newer version of Wildcard Pipeline, or a damaged one, is refused and nothing changes. Backups to keep keeps the newest ones, the ones you make yourself too. Pin a backup (the bookmark button) to keep it for good: pinned ones are never deleted for you and don't count toward the limit. A row marked Removed at next backup goes the next time a backup is made. Type a short note next to Back up now to tell your own backups apart."
|
||||
},
|
||||
{
|
||||
"title": "In a subgraph",
|
||||
"text": "A WP node's panel isn't on the subgraph node\nPanels with no socket (Context, Toggles, Label Grid, Debug …) stay inside. Show it with the eye beside its name in the subgraph's Parameters panel (Hidden / nested parameters), the eye in the toolbar over the selected node inside the subgraph, or right-click that node → Show on subgraph node. That puts a copy of its panel on every subgraph node made from that subgraph. Changes on either side reach the other, the node inside is still what runs, and the subgraph node remembers it when you save. The sliders button on the copy's header (Choose what shows) picks which parts show there, such as only Label Grid's Font and Title, a few toggles or some Context modules; the node inside keeps the whole panel. Its Move up / Move down items order the WP panels on the subgraph node (they sit below ComfyUI's own promoted widgets). The × (or any of the eyes, or Hide from subgraph node) takes the copy off again.\nThe Assembler's prompt box is empty on the subgraph node\nDrag its template or negative_template dot to the subgraph's input. The subgraph node then shows the same editor, with the $ list of the variables upstream of the Assembler inside."
|
||||
},
|
||||
{
|
||||
"title": "Still stuck?",
|
||||
"text": "Every warning is explained on Warning & conflict types. Problems with the AI assistant have their own page: Troubleshooting."
|
||||
@@ -1183,7 +1191,7 @@
|
||||
},
|
||||
{
|
||||
"title": "What you can write",
|
||||
"text": "Write | What it does\n$name | The variable's value. If the wildcard picked several options, all of them, joined by its separator.\n$name.0 | One of several picks, counting from 0: $colors.0 is the first. A number past the end prints nothing.\n$name.GROUP | The tag rolled from an accepts tag group: $outfit.SHOES is the shoe that goes with this run's outfit. It's rolled once, so it's the same everywhere. With several picks, $outfit.0.SHOES is the first one's. A group that isn't accepts prints nothing and gets a warning.\n$name.neg | The variable's negative words. See Negatives.\n$name.bool | A WP Toggles variable's switch, true or false, even when the toggle writes its own Value.\n@{…} | A fresh pick from another wildcard. Type @ to insert one; it shows as a chip. It can narrow the pick to some tags (warm or cool) or leave out the null option. Written out it looks like @{abcd1234#Mood:calm or intense!null}: the 8-letter id, then an optional name, filter and !null. More in Chips & references.\n{a|b|c} | Picks one part each run. {3::cat|dog} makes cat three times as likely.\n{2$$a|b|c} | Picks two different parts, joined by a comma. {2$$ and $$a|b|c} joins them with \" and \"; {1-3$$a|b|c} picks between one and three. Add ~ ({2~$$a|b|c}) to allow the same part twice.\n$$ · @@ · {{ | Print a plain $, @ or {.\nThese nest: a part of {…} can hold a reference or another choice, such as {a @{abcd1234} cat|{2$$, $$red|blue|green} sky}.\nNaming a variable: letters, digits and underscores, starting with a letter or underscore, up to 64 characters. Names starting with two underscores are kept for the engine."
|
||||
"text": "Write | What it does\n$name | The variable's value. If the wildcard picked several options, all of them, joined by its separator.\n$name.0 | One of several picks, counting from 0: $colors.0 is the first. A number past the end prints nothing.\n$name.GROUP | The tag rolled from an accepts tag group: $outfit.SHOES is the shoe that goes with this run's outfit. It's rolled once, so it's the same everywhere. With several picks, $outfit.0.SHOES is the first one's. A group that isn't accepts prints nothing and gets a warning.\n$name.neg | The variable's negative words. See Negatives.\n$name.bool | A WP Toggles variable's switch, true or false, even when the toggle writes its own Value.\n@{…} | A fresh pick from another wildcard. Type @ to insert one; it shows as a chip. It can narrow the pick to some tags (warm or cool) or leave out the null option. Written out it looks like @{abcd1234#Mood:calm or intense!null}: the 8-letter id, then an optional name, filter and !null. More in Chips & references.\n{a|b|c} | Picks one part each run. {3::cat|dog} makes cat three times as likely.\n{2$$a|b|c} | Picks two different parts, joined by a comma. {2$$ and $$a|b|c} joins them with \" and \"; {1-3$$a|b|c} picks between one and three. Add ~ ({2~$$a|b|c}) to allow the same part twice.\n$$ · @@ | Print a plain $ or @. Braces need no escape: only a closed {…} with a | (or a {2$$…} count) is a pick, so {word} and {{word}} print as written.\nThese nest: a part of {…} can hold a reference or another choice, such as {a @{abcd1234} cat|{2$$, $$red|blue|green} sky}.\nNaming a variable: letters, digits and underscores, starting with a letter or underscore, up to 64 characters. Names starting with two underscores are kept for the engine."
|
||||
},
|
||||
{
|
||||
"title": "Which syntax works where",
|
||||
@@ -1350,7 +1358,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Switch a module on or off per frame",
|
||||
"text": "The tick box on a row works per frame too. A frame uses its own setting if it has one, otherwise the base. So both of these work:\n- On in the base, off on a frame: the module runs everywhere except that frame.\n- Off in the base, on on a frame: the module only runs on that frame, so an extra element shows up just there.\nA frame with a module off still makes an image; the module just adds nothing. WP Debug shows whether a module was off for that frame or off everywhere."
|
||||
"text": "The tick box on a row works per frame too. A frame uses its own setting if it has one, otherwise the base. So both of these work:\n- On in the base, off on a frame: the module runs everywhere except that frame.\n- Off in the base, on on a frame: the module only runs on that frame, so an extra element shows up just there.\nA frame with a module off still makes an image; the module just adds nothing. WP Debug shows whether a module was off for that frame or off everywhere, and a module that is off in the base but on for some frames says where it runs (off · on #4).\nThe WP Prompt Assembler knows about this. A variable that only some frames set is not missing: its chip is blue with a dashed edge and a frame tag (#4), the variables header counts it as on some frames, its underline in the template is a blue dotted line, and pointing at it says which frames set it and which render it empty. The preview shows one frame at a time: the one picked under Edit frame (frame 1 while base is picked), or step through them with the ‹ 1/4 › arrows above it, which move the Edit frame pick too. On a frame that doesn't set the variable, the preview strikes it through and says it is empty there. Run only warns about a variable that no frame sets."
|
||||
},
|
||||
{
|
||||
"title": "Lock a pick on one frame",
|
||||
@@ -1664,7 +1672,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Join several inputs",
|
||||
"text": "Click Add template row and write a template like $input_0 by $input_1. A template row isn't tied to one input, stays when you rewire, and runs after the other rows, so it can also use the variables they made ($outfit).\nHow it works\nA row made for a wired input can only use its own input in its template. It disappears when you unplug that input. Up to 10 inputs per Injector; chain a second one for more. Text works best: images and latents turn into their text description, which is rarely useful in a prompt.\nSetting a variable here clears the negatives it had from earlier nodes; the row's own negative takes their place. A hidden row, or one that only feeds a derivation, never adds to a negative."
|
||||
"text": "Click Add template row and write a template like $input_0 by $input_1. A template row isn't tied to one input, stays when you rewire, and runs after the other rows, so it can also use the variables they made ($outfit).\nHow it works\nA row made for a wired input can only use its own input in its template. It disappears when you unplug that input. Up to 10 inputs per Injector; chain a second one for more. Text works best: images and latents turn into their text description, which is rarely useful in a prompt.\nSetting a variable here clears the negatives it had from earlier nodes; the row's own negative takes their place. A template row also carries the negatives of the variables it reads, before its own: $outfit = \"$cloak with a silver clasp\" brings $cloak's \"torn fabric\". A hidden row, or one that only feeds a derivation, never adds to a negative."
|
||||
},
|
||||
{
|
||||
"title": "Works with",
|
||||
@@ -1740,7 +1748,7 @@
|
||||
"sections": [
|
||||
{
|
||||
"title": "What it's for",
|
||||
"text": "WP Toggles replaces a stack of Boolean nodes. Each toggle writes $its_variable as true or false, so a derivation can branch on it without a Context Injector, and can show a BOOLEAN output at the right of its row for a switch node.\nFigure: Hires fix is on and Quick preview off: their BOOLEAN outputs feed ComfyUI's If/Else Switch and two Preview as Text nodes, which print what the run sent (True, False, and the switch's pick). Detailer is off, so the text node wired into its dot is muted. Screenshot after a run. A WP Toggles node in its clean view with a Render label and three toggles: Hires fix (a switch, on, $hires), Quick preview (off, $quick_preview) and Detailer (off, $detailer, with a mute icon by its name). A Detailer prompt text node is wired into the dot at the left of the Detailer row and shows Muted. Hires fix and Quick preview each have a BOOLEAN output at the right of their row. Hires fix feeds the switch input of an If/Else Switch whose on_true is 'upscaled 2x, then refined' and on_false 'base size only'; the switch's output goes to a Preview as Text that prints 'upscaled 2x, then refined'. Two more Preview as Text nodes print True (Hires fix) and False (Quick preview).\n- upstream (in)Optional Context to extend.\n- context (out)The Context with every toggle variable added.\n- toggle outputsOne BOOLEAN per toggle that has an output, at the right of its row.\n- control dots (in)A toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it."
|
||||
"text": "WP Toggles replaces a stack of Boolean nodes. Each toggle writes $its_variable as true or false, so a derivation can branch on it without a Context Injector, and can show a BOOLEAN output at the right of its row for a switch node (off for a new toggle: turn on BOOLEAN output in its window).\nFigure: Hires fix is on and Quick preview off: their BOOLEAN outputs feed ComfyUI's If/Else Switch and two Preview as Text nodes, which print what the run sent (True, False, and the switch's pick). Detailer is off, so the text node wired into its dot is muted. Screenshot after a run. A WP Toggles node in its clean view with a Render label and three toggles: Hires fix (a switch, on, $hires), Quick preview (off, $quick_preview) and Detailer (off, $detailer, with a mute icon by its name). A Detailer prompt text node is wired into the dot at the left of the Detailer row and shows Muted. Hires fix and Quick preview each have a BOOLEAN output at the right of their row. Hires fix feeds the switch input of an If/Else Switch whose on_true is 'upscaled 2x, then refined' and on_false 'base size only'; the switch's output goes to a Preview as Text that prints 'upscaled 2x, then refined'. Two more Preview as Text nodes print True (Hires fix) and False (Quick preview).\n- upstream (in)Optional Context to extend.\n- context (out)The Context with every toggle variable added.\n- toggle outputsOne BOOLEAN per toggle that has an output, at the right of its row.\n- control dots (in)A toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it."
|
||||
},
|
||||
{
|
||||
"title": "When to use it",
|
||||
@@ -1748,19 +1756,19 @@
|
||||
},
|
||||
{
|
||||
"title": "Controls",
|
||||
"text": "upstream (in)\nOptional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).\nPencil (toolbar)\nSelect the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.\nAny on / Pick one\nAny on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.\nwrites (Pick one)\nOptional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.\nGlobe (row)\nEdit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.\nShow in Assembler (Pick one)\nThe picked variable shows in the Assembler and can print in the prompt. Off, it is internal.\nStyle (Pick one)\nHow every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.\nLayout\nEdit view: Rows (one row per toggle, the default) or Grid (each toggle in a tile, the tiles wrapping to the node's width), in both modes and every style. Each label has a grid button for its own group; setting it back to the node's layout makes the group follow the node again. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name, BOOLEAN outputs on the right with theirs, each side packed on its own (so Dawn | Dawn, Sunset | Night). The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), and the arrow on the merged line opens them again.\nPencil, globe, trash (row)\nEach row in the edit view has three buttons, like a module on a Context node: the pencil opens the toggle's own settings in a window (name, variable, looks, style, needs, what off mutes or bypasses, group and its output) with the row drawn on top as the node will show it; the globe keeps its variable internal; the trash removes it.\nAdd toggle / Add label\nA new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it; its icon and colour buttons pick any icon and any colour. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.\ncontrol dots (in)\nA toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.\ncontext (out)\nThe Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler.\ntoggle outputs\nOne BOOLEAN per toggle that has an output, at the right of its row. Up to 16."
|
||||
"text": "upstream (in)\nOptional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).\nPencil (toolbar)\nSelect the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.\nAny on / Pick one\nAny on: every toggle is on or off by itself. Pick one: at most one is on; tick Always one on to keep exactly one on, like radio buttons.\nwrites (Pick one)\nOptional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.\nGlobe (row)\nEdit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.\nAlways one on (Pick one)\nLike radio buttons: one toggle is always on. Clicking the picked one does nothing; pick another to switch. With none on, the first toggle is picked. Off (the default), the picked toggle can be switched off so none is on.\nShow in Assembler (Pick one)\nThe picked variable shows in the Assembler and can print in the prompt. Off, it is internal.\nStyle (Pick one)\nHow every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.\nLayout\nEdit view: Rows (one row per toggle, the default) or Grid (each toggle in a tile, the tiles wrapping to the node's width), in both modes and every style. Each label has a grid button for its own group; setting it back to the node's layout makes the group follow the node again. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name, BOOLEAN outputs on the right with theirs, each side packed on its own (so Dawn | Dawn, Sunset | Night). The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), and the arrow on the merged line opens them again.\nPencil, globe, trash (row)\nEach row in the edit view has three buttons, like a module on a Context node: the pencil opens the toggle's own settings in a window (name, variable, looks, style, needs, what off mutes or bypasses, group and its output) with the row drawn on top as the node will show it; the globe keeps its variable internal; the trash removes it.\nAdd toggle / Add label\nA new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it; its icon and colour buttons pick any icon and any colour. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.\ncontrol dots (in)\nA toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.\ncontext (out)\nThe Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler.\ntoggle outputs\nOne BOOLEAN per toggle that has an output, at the right of its row. Up to 16."
|
||||
},
|
||||
{
|
||||
"title": "Editing a toggle",
|
||||
"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; the On side lights up in the control colour while picked, and a click on the lit side flips it too) 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 new toggle; leave it 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."
|
||||
},
|
||||
{
|
||||
"title": "Icons and colours",
|
||||
"text": "Give a toggle an icon and its own colours under Looks in its window: one for the icon, one for the name and one for the control itself, so Day can glow yellow and Night blue. A label's icon and colour buttons do the same for the group, and its toggles wear that colour on their left edge. The row at the top of the toggle's window shows each change before you press Done."
|
||||
"text": "Give a toggle an icon and its own colours under Looks in its window: one for the icon, one for the name and one for the control itself, so Day can glow yellow and Night blue. On Buttons the colour lights up the On side while it's picked, and clicking the lit side flips the toggle too. A label's icon and colour buttons do the same for the group, and its toggles wear that colour on their left edge. The row at the top of the toggle's window shows each change before you press Done."
|
||||
},
|
||||
{
|
||||
"title": "Use a toggle in the prompt",
|
||||
"text": "A toggle's variable is internal by default: rules read it, but the Assembler doesn't offer it, since true or false rarely belongs in a prompt. To print it, open the edit view and turn off the globe on its row. In Pick one, Show in Assembler does the same for the picked variable, so $time can print the picked toggle's Value.\nGive a toggle a Value and its variable holds that text while it is on and nothing while it is off: a Rain toggle with the Value \"heavy rain\" makes $rain print \"heavy rain\" or nothing. The switch itself is still there as $rain.bool, true or false, for rules and conditions. Its Negative goes with the variable while it is on, so an Assembler that prints $rain also sends \"sunny, dry\" to its negative output.\nIn Any on a toggle can also say what to write while it is off: its Off value and Off negative. A Buttons toggle Anime / Real with the Value \"anime style\" and the Off value \"photorealistic\" writes one or the other, and each side's negative keeps the other look out. Leave them empty and off prints nothing, as before."
|
||||
"text": "A toggle's variable is internal by default: rules read it, but the Assembler doesn't offer it, since true or false rarely belongs in a prompt. To print it, open the edit view and turn off the globe on its row. In Pick one, Show in Assembler does the same for the picked variable, so $time can print the picked toggle's Value.\nGive a toggle a Value and its variable holds that text while it is on and nothing while it is off: a Rain toggle with the Value \"heavy rain\" makes $rain print \"heavy rain\" or nothing. The switch itself is still there as $rain.bool, true or false, for rules and conditions. Its Negative goes with the variable while it is on, so an Assembler that prints $rain also sends \"sunny, dry\" to its negative output. A Value that reads a $variable brings that variable's negatives along too.\nIn Any on a toggle can also say what to write while it is off: its Off value and Off negative. A Buttons toggle Anime / Real with the Value \"anime style\" and the Off value \"photorealistic\" writes one or the other, and each side's negative keeps the other look out. Leave them empty and off prints nothing, as before."
|
||||
},
|
||||
{
|
||||
"title": "Mute or bypass nodes",
|
||||
@@ -1768,7 +1776,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Pick one",
|
||||
"text": "One toggle is on. Which one, first match wins: the toggle the upstream picked variable names (with Upstream can pick), then the first toggle upstream set to a yes word, then the first one you switched on. The picked variable gets the toggle's Value, or its name; it is internal unless Show in Assembler is on. A toggle's Negative, under its Value, goes with the picked variable: an Assembler that prints it sends those words to its negative output. All rows share one look here: the Style next to those boxes picks Radio (the default), Chip, Switch or Check, so a toggle's own Style and Needs are hidden."
|
||||
"text": "One toggle is on. Which one, first match wins: the toggle the upstream picked variable names (with Upstream can pick), then the first toggle upstream set to a yes word, then the first one you switched on. The picked variable gets the toggle's Value, or its name; it is internal unless Show in Assembler is on. A toggle's Negative, under its Value, goes with the picked variable: an Assembler that prints it sends those words to its negative output. All rows share one look here: the Style next to those boxes picks Radio (the default), Chip, Switch or Check, so a toggle's own Style and Needs are hidden.\nBy default you can also switch the picked toggle off, so none is on. Tick Always one on to make the group work like radio buttons: one toggle is always on, clicking it again does nothing, and you switch by picking another. With none on (or when you tick it), the first toggle is picked."
|
||||
},
|
||||
{
|
||||
"title": "Rows or a grid",
|
||||
@@ -1887,7 +1895,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Settings",
|
||||
"text": "Icon, colour, name\nThe icon opens the full icon list. The colour marks the pill and its dock row.\nLands at\nA 3×3 grid: the corner, edge or centre of the free part of the screen (what ComfyUI's toolbar, the open side panel and the bottom panel leave, following them as they open and close), 24 px in. A jump that would land under the dock, the minimap or the zoom controls moves just enough to clear them all (the dock only while its Land clear of the dock switch is on). It lands in the same place however often you press the key, selected or not. Only the layout counts: what pops up on its own (the job progress panel while a workflow runs, the error card, toasts, menus, tooltips, dialogs) never moves it.\nUse this view\nLands on the view on screen now, at its zoom, instead of a grid spot.\nZoom\nSet jumps to that zoom (0.1–10×; the magnifier takes the zoom you're at). Keep current leaves it alone.\nShortcut\nRecord, then press the keys (Esc cancels, Backspace clears). The bolt picks a free one, a new one on each press.\nShow on canvas\nOff hides the node when you press Done: not drawn, not clickable, not selectable. The dock's eye shows it again.\nPinned\nComfyUI's own pin: a pinned bookmark can't be moved by accident.\nGo\nTries the jump.\nMuting or bypassing a bookmark turns it off: its shortcut does nothing and the dock greys it."
|
||||
"text": "Icon, colour, name\nThe icon opens the full icon list. The colour marks the pill and its dock row.\nLands at\nA 3×3 grid: the corner, edge or centre of the free part of the screen (what ComfyUI's toolbar, the open side panel and the bottom panel leave, following them as they open and close), 24 px in. A jump that would land under the dock, the minimap or the zoom controls moves just enough to clear them all (the dock only while its Land clear of the dock switch is on). It lands in the same place however often you press the key, selected or not. Only the layout counts: what pops up on its own (the job progress panel while a workflow runs, the error card, toasts, menus, tooltips, dialogs) never moves it.\nUse this view\nLands on the view on screen now, at its zoom, instead of a grid spot.\nZoom\nSet jumps to that zoom (0.1–10×; the magnifier takes the zoom you're at). Keep current leaves it alone.\nShortcut\nRecord, then press the keys (Esc cancels, Backspace clears). The bolt picks a free one, a new one on each press.\nShow on canvas\nOff hides the node when you press Done: not drawn, not clickable, not selectable. The dock's eye shows it again.\nPinned\nComfyUI's own pin: a pinned bookmark can't be moved by accident. New bookmarks start unpinned; a saved workflow keeps each bookmark's pin.\nGo\nTries the jump.\nMuting or bypassing a bookmark turns it off: its shortcut does nothing and the dock greys it."
|
||||
},
|
||||
{
|
||||
"title": "Shortcut clashes",
|
||||
@@ -1895,7 +1903,7 @@
|
||||
},
|
||||
{
|
||||
"title": "The Bookmarks dock",
|
||||
"text": "The bookmark button in the top bar shows or hides the dock. It also opens by itself in a workflow with bookmarks, unless you closed it. It sits in the free part of the canvas, clear of the side panel, the bottom panel, the minimap and the zoom controls, and moves when they open or close. What pops up on its own (the job progress panel while a workflow runs, the error card, toasts, menus, tooltips) never moves it or where bookmarks land; it floats over them. It is see-through until you point at it (more so when folded).\nFigure: The dock in view mode: click a row to jump. Its pencil opens edit mode, where the eye brings back a hidden bookmark. Screenshot of the Bookmarks dock, in view mode, at the top left of the canvas, slightly see-through. Its header reads Bookmarks, 4 · 1 hidden, with Back, Previous, Next, a pencil to edit and Close. The rows show a number, a coloured icon, a name and a shortcut: 1 Prompts (Alt + 1), 2 Sampler (Alt + 2, highlighted as the bookmark just jumped to), 3 Upscale in grey italics with a small crossed-out eye because it is hidden on the canvas (Alt + 3), and 4 Save and share marked OFF because the node is muted. On the canvas, the Sampler bookmark's pill (Alt + 2) sits by the KSampler and VAE Decode, the muted Save and share pill is greyed and marked OFF, and the prompt and latent nodes are below; the hidden Upscale bookmark is not drawn.\nRow\nClick to jump. Number (a Step note's in its circle), icon, name and shortcut; a mark for one hidden on the canvas or inside a subgraph, OFF for a muted one.\nBack\nReturn to where you were before the last jump.\n↑ / ↓\nStep through the bookmarks that are on, in dock order.\nPencil\nEdit mode: drag the dock by its header and it snaps to the nearest spot of a 3×3 grid on the free canvas (a dashed outline shows where); drag the grips to reorder, the eye shows or hides a bookmark on the canvas, and Bookmark this view adds one. Land clear of the dock (on by default) moves a jump out from under the dock; off, bookmarks land where they would without it, behind the see-through dock. Done goes back. The dock follows the layout (side panels, the bottom panel, the top bar, the minimap and zoom controls, a floating run bar) and stays put when the job progress panel, the error card, a toast or a menu pops up; those float over it.\nNext, Previous, Back, Bookmark this view and the dock toggle are commands without keys: bind them in Settings › Keybinding."
|
||||
"text": "The bookmark button in the top bar shows or hides the dock. It also opens by itself in a workflow with bookmarks, unless you closed it. It sits in the free part of the canvas, clear of the side panel, the bottom panel, the minimap and the zoom controls, and moves when they open or close. What pops up on its own (the job progress panel while a workflow runs, the error card, toasts, menus, tooltips) never moves it or where bookmarks land; it floats over them. It is see-through until you point at it (more so when folded). The grid button in its header shows the bookmarks as icon tiles instead of rows, and the list button goes back; the dock remembers which you picked.\nFigure: The dock in view mode: click a row to jump. Its pencil opens edit mode, where the eye brings back a hidden bookmark. Screenshot of the Bookmarks dock, in view mode, at the top left of the canvas, slightly see-through. Its header reads Bookmarks, 4 · 1 hidden, with Back, Previous, Next, a pencil to edit and Close. The rows show a number, a coloured icon, a name and a shortcut: 1 Prompts (Alt + 1), 2 Sampler (Alt + 2, highlighted as the bookmark just jumped to), 3 Upscale in grey italics with a small crossed-out eye because it is hidden on the canvas (Alt + 3), and 4 Save and share marked OFF because the node is muted. On the canvas, the Sampler bookmark's pill (Alt + 2) sits by the KSampler and VAE Decode, the muted Save and share pill is greyed and marked OFF, and the prompt and latent nodes are below; the hidden Upscale bookmark is not drawn.\nRow\nClick to jump. Number (a Step note's in its circle), icon, name and shortcut; a mark for one hidden on the canvas or inside a subgraph, OFF for a muted one.\nGrid / List\nShows the bookmarks as icon tiles (number, icon in its colour, name, shortcut) that wrap three to a line, or as rows. The dock remembers your choice; edit mode always shows rows.\nBack\nReturn to where you were before the last jump.\n↑ / ↓\nStep through the bookmarks that are on, in dock order.\nPencil\nEdit mode: drag the dock by its header and it snaps to the nearest spot of a 3×3 grid on the free canvas (a dashed outline shows where); drag the grips to reorder, the eye shows or hides a bookmark on the canvas, and Bookmark this view adds one at the zoom you're at: the small 3×3 grid beside it picks where on screen the new bookmark sits, and it lands there, so jumping to it brings back that exact view. Land clear of the dock (on by default) moves a jump out from under the dock; off, bookmarks land where they would without it, behind the see-through dock. Done goes back. The dock follows the layout (side panels, the bottom panel, the top bar, the minimap and zoom controls, a floating run bar) and stays put when the job progress panel, the error card, a toast or a menu pops up; those float over it.\nNext, Previous, Back, Bookmark this view and the dock toggle are commands without keys: bind them in Settings › Keybinding."
|
||||
},
|
||||
{
|
||||
"title": "Works with",
|
||||
@@ -1920,15 +1928,23 @@
|
||||
},
|
||||
{
|
||||
"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."
|
||||
"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- With a WP Context Loop upstream, a variable that only some frames set (a module switched on or off per frame) is not missing: its chip is blue with a frame tag such as #4, the header counts it as on some frames, and pointing at it says which frames render it empty. The preview shows one frame, the one picked under the Loop's Edit frame; the ‹ 1/4 › arrows above it step through them (see Per-iteration overrides).\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."
|
||||
"text": "context (in)\nThe variables from a WP Context (or anything that ends in one).\ntemplate\nYour prompt with $variables in it. Wire another node's text into it to use that text as the template instead: the box then says \"From the template input\", and the $variables in the wired text still fill in.\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). It takes a wired text the same way the template does.\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."
|
||||
},
|
||||
{
|
||||
"title": "Works with",
|
||||
@@ -1949,11 +1965,11 @@
|
||||
"sections": [
|
||||
{
|
||||
"title": "What it's for",
|
||||
"text": "Wire a prompt, usually from a WP Prompt Assembler, into the Cleaner. It tidies spaces, empty brackets, stacked weights and LoRA tags, drops duplicate tags, and removes words on your blocklist. The clean text goes on to CLIP Text Encode. It needs no Context.\nGood for\n- Removing duplicates and near-duplicates after the Assembler\n- Merging weights, spacing LoRA tags, blocking words\nBetter with something else\n- Fixing a pack: if it often removes something, fix the option that wrote it\nNever blocklist or fuzzy-dedupe a LoRA's trigger words, or the LoRA stops working. The blocklist matches whole words, so list every form of a word you block (see Adult (NSFW) packs). For prose prompts use text mode on Gentle: tags mode splits on commas, so it would split sentences.\nFigure: A messy tag list and negative go into WP Prompt Cleaner and come out without the doubled tags, empty brackets and stray commas. Each rule counts its fixes, and Last run sums them up. Screenshot after a run. A Prompt text node ('masterpiece, best quality, , 1girl, red hair, Red Hair, ((smile)), (, ), solo,, < lora : style : 0.8 >') and a Negative text node ('lowres, bad hands, , lowres, red hair') wire into WP Prompt Cleaner, in tags mode, intensity balanced, with its rules (whitespace, punctuation, empty groups, merge weights, LoRA spacing, tag dedupe, fuzzy dedupe, blocklist) and their counts, prompt and negative columns, and drop negative tags also in prompt. Last run reads prompt: 1 duplicate; negative: 1 duplicate, 1 tag also in prompt 'red hair'. Two Preview as Text nodes print 'masterpiece, best quality, 1girl, red hair, ((smile)), solo, <lora:style:0.8>' and 'lowres, bad hands, red hair'.\n- prompt (out)The cleaned prompt, for CLIP Text Encode.\n- negative (out)The cleaned negative, using the negative column's rules.\n- prompt (in)The text to clean, usually from a WP Prompt Assembler.\n- negative (in)Optional.\n- cleaner widgetPick a mode and a preset, switch single rules on or off, and edit the blocklist.\n- blocklist (in)Optional text from another node, such as one shared word list feeding several cleaners."
|
||||
"text": "Wire a prompt, usually from a WP Prompt Assembler, into the Cleaner. It tidies spaces, empty brackets, stacked weights and LoRA tags, drops duplicate tags, and removes words on your blocklist. The clean text goes on to CLIP Text Encode. It needs no Context.\nGood for\n- Removing duplicates and near-duplicates after the Assembler\n- Merging weights, spacing LoRA tags, blocking words\nBetter with something else\n- Fixing a pack: if it often removes something, fix the option that wrote it\nNever blocklist or fuzzy-dedupe a LoRA's trigger words, or the LoRA stops working. The blocklist matches whole words, so list every form of a word you block (see Adult (NSFW) packs). For prose prompts use text mode on Gentle: tags mode splits on commas, so it would split sentences.\nFigure: A messy tag list and negative go into WP Prompt Cleaner and come out without the doubled tags, empty brackets and stray commas. Each rule counts its fixes, and Last run sums them up; click it for the before and after. Screenshot after a run. A Prompt text node ('masterpiece, best quality, , 1girl, red hair, Red Hair, ((smile)), (, ), solo,, < lora : style : 0.8 >') and a Negative text node ('lowres, bad hands, , lowres, red hair') wire into WP Prompt Cleaner, in tags mode, intensity balanced, with its rules (whitespace, punctuation, empty groups, merge weights, LoRA spacing, tag dedupe, fuzzy dedupe, blocklist) and their counts, prompt and negative columns, and drop negative tags also in prompt. Last run reads prompt: 1 duplicate; negative: 1 duplicate, 1 tag also in prompt 'red hair'. Two Preview as Text nodes print 'masterpiece, best quality, 1girl, red hair, ((smile)), solo, <lora:style:0.8>' and 'lowres, bad hands, red hair'.\n- prompt (out)The cleaned prompt, for CLIP Text Encode.\n- negative (out)The cleaned negative, using the negative column's rules.\n- prompt (in)The text to clean, usually from a WP Prompt Assembler.\n- negative (in)Optional.\n- cleaner widgetPick a mode and a preset, switch single rules on or off, and edit the blocklist.\n- blocklist (in)Optional text from another node, such as one shared word list feeding several cleaners."
|
||||
},
|
||||
{
|
||||
"title": "Inputs and outputs",
|
||||
"text": "prompt (in)\nThe text to clean, usually from a WP Prompt Assembler. Any text works.\ncleaner widget\nPick a mode and a preset, switch single rules on or off, and edit the blocklist. After a run it shows how many fixes each rule made.\nnegative (in)\nOptional. A negative to clean too: type it or wire in the Assembler's negative. Left empty, the negative output is empty.\nprompt (out)\nThe cleaned prompt, for CLIP Text Encode.\nblocklist (in)\nOptional text from another node, such as one shared word list feeding several cleaners. Its entries join the panel's blocklist (comma- or newline-separated in list mode, one per line in regex mode) and turn the blocklist on; the Blocklist button reads + input. Its dot sits beside that button and shows when you hover it or wire it.\nnegative (out)\nThe cleaned negative, using the negative column's rules. Wire it into your negative CLIP Text Encode."
|
||||
"text": "prompt (in)\nThe text to clean, usually from a WP Prompt Assembler. Any text works.\ncleaner widget\nPick a mode and a preset, switch single rules on or off, and edit the blocklist. After a run it shows how many fixes each rule made, and the Last run strip opens the run's before and after with what each rule changed and why.\nnegative (in)\nOptional. A negative to clean too: type it or wire in the Assembler's negative. Left empty, the negative output is empty.\nprompt (out)\nThe cleaned prompt, for CLIP Text Encode.\nblocklist (in)\nOptional text from another node, such as one shared word list feeding several cleaners. Its entries join the panel's blocklist (comma- or newline-separated in list mode, one per line in regex mode) and turn the blocklist on; the Blocklist button reads + input. Its dot sits beside that button and shows when you hover it or wire it.\nnegative (out)\nThe cleaned negative, using the negative column's rules. Wire it into your negative CLIP Text Encode."
|
||||
},
|
||||
{
|
||||
"title": "Mode",
|
||||
@@ -1961,7 +1977,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Presets",
|
||||
"text": "Gentle\nOnly tidies spaces: removes doubled spaces and spaces at the ends. The safest choice.\nBalanced\nAlso removes empty brackets, tidies LoRA tags and stray punctuation, and drops exact duplicate tags. None of these change what the prompt means. A good default.\nAggressive\nAlso merges stacked weights and drops near-duplicates (\"bad hand\" and \"bad hands\"). Now and then it removes something you meant to keep.\nCustom\nShown when you've switched single rules away from the preset."
|
||||
"text": "Gentle\nOnly tidies spaces and commas: doubled spaces, spaces at the ends and before a comma, doubled commas and empty slots between them (blurry, , lowres becomes blurry, lowres). The safest choice.\nBalanced\nAlso removes empty brackets, tidies LoRA tags and stray punctuation, and drops exact duplicate tags. None of these change what the prompt means. A good default.\nAggressive\nAlso merges stacked weights and drops near-duplicates: the same words up to plurals and a leading a, an or the (\"a knight\", \"knight\" and \"knights\" keep the first), or tags 90% alike letter by letter, which includes the same tag at another weight. Now and then it removes something you meant to keep.\nCustom\nShown when you've switched single rules away from the preset."
|
||||
},
|
||||
{
|
||||
"title": "Prompt syntax rules",
|
||||
@@ -1969,7 +1985,11 @@
|
||||
},
|
||||
{
|
||||
"title": "Cleaning the negative",
|
||||
"text": "With a negative wired in, one Cleaner cleans both. The rule list gets a second column of tick boxes for the negative. It follows the prompt column, except near-duplicate removal and the blocklist are off: near-duplicates in a negative are usually on purpose, and blocked words often belong in a negative.\nDrop negative tags also in prompt (off by default) removes a tag from the negative when the prompt asks for it too, since the model can't both draw it and avoid it. Either way, the run stats name the tags the two share."
|
||||
"text": "With a negative wired in, one Cleaner cleans both. The rule list gets a second column of tick boxes for the negative. It follows the prompt column, except near-duplicate removal and the blocklist are off: near-duplicates in a negative are usually on purpose, and blocked words often belong in a negative.\nDrop negative tags also in prompt (off by default) removes a tag from the negative when the prompt asks for it too, since the model can't both draw it and avoid it. Either way, the Last run strip names the tags the two share on an amber line of their own."
|
||||
},
|
||||
{
|
||||
"title": "Last run",
|
||||
"text": "After a run, the Last run strip at the bottom of the node says how many rules changed the prompt and what they dropped. Click it to open the last run in a window:\n- Before and After side by side, with removed tags struck through in red and added ones in green. In tags mode whole tags are compared, so ((silver hair)) → (silver hair:1.21) reads as one tag out and one in.\n- One line per rule that changed something, in the order they ran. Open a line to see that rule's own before and after, and why it fired: a dropped duplicate beside the tag it repeats, a near-duplicate beside the tag it is close to (and how close), a blocked tag beside the blocklist entry it matched. The cause is marked in blue in the text.\n- Prompt / Negative switches sides when the run cleaned a negative too; the negative side also lists tags the prompt has as well. Copy cleaned text copies the side you are on.\nEach rule runs once per run, in the order of the list (bracket and LoRA rules first, then whitespace, punctuation, tag dedupe, fuzzy dedupe, blocklist), and each works on what the one before left. So a rule can count 0 because an earlier one already fixed it: the empty slot in blurry, , lowres goes with whitespace, leaving punctuation nothing. Duplicate removal never touches BREAK or AND, and punctuation keeps emoticon tags such as :3 and -_-.\nIt's the quickest way to check a pack: if a rule keeps removing the same thing, fix the option that wrote it."
|
||||
},
|
||||
{
|
||||
"title": "Blocklist",
|
||||
@@ -2022,11 +2042,11 @@
|
||||
},
|
||||
{
|
||||
"title": "Refine before it goes on",
|
||||
"text": "In Detail, the side panel edits the frame's positive and negative prompt and paints a mask for the upscaler. Edits travel with the picks; tiles show ✎ for an edited prompt and ◐ for a painted mask.\n- An edited prompt replaces the text output. Wire a CLIP into the filter's clip input and the conditioning is re-encoded to match; without one the conditioning stays as it was, and the panel says so.\n- With Same shape a frame's picks go on together, so a prompt edit covers the whole frame. With One per image it covers just that image.\n- A painted mask goes to the masks output, resized to the image. Images you don't paint keep the incoming mask, or an empty one when nothing is wired in. Once painted, the Mask tab previews it: Over image tints the masked part, Mask only shows it white on black, as the output sends it.\nFigure: Detail on frame 3: Pick, Compare and Pick frame at the top, then the Prompt tab with each value marked in its own colour (hovering one says which variable set it) and the negative. The strip below groups the images by frame. Screenshot of the WP Image Filter picker in Detail mode on frame #3, image 1. The picture sits between ‹ and › buttons, with a strip of all 12 images under it. The side panel reads Frame #3 · image 1, 1024 × 1024, seed 41814, 5 / 12, with a Pick button, Compare and Zoom; Values lists $hair silver hair, $place desert and the seed, with Pick whole frame (2); the Prompt box marks silver hair and desert in the prompt; the Negative box reads blurry, extra fingers; Mask has a Paint button.\nFigure: How the picks carry over. Diagram titled How the picks carry over: 3 frames of 2 images from a Context Loop; frame 1 image 1 and both frame 2 images are picked, frame 3 none. Rows compare IN with OUT (same shape): images, latent and masks keep only the picked items of frames 1 and 2 (frame 2 gets a PAINTED mask); positive / negative keep 'cond red' and turn 'cond blonde' into 'cond blonde, freckles' marked RE-ENCODED; positive text becomes 'blonde hair, freckles…' marked EDITED. Notes: per-frame items follow their frame, batched ones are sliced; an edited prompt is re-encoded when a CLIP is wired; One per image emits single-image items."
|
||||
"text": "In Detail, the side panel edits the frame's positive and negative prompt and paints a mask for a refine pass. Edits travel with the picks; tiles show ✎ for an edited prompt and ◐ for a painted mask.\n- An edited prompt replaces the text output. Wire a CLIP into the filter's clip input and the conditioning is re-encoded to match; without one the conditioning stays as it was, and the panel says so.\n- With Same shape a frame's picks go on together, so a prompt edit covers the whole frame. With One per image it covers just that image.\n- A painted mask goes to the masks output, resized to the image. Images you don't paint keep the incoming mask, or an empty one when nothing is wired in. Once painted, the Mask tab previews it: Over image tints the masked part, Mask only shows it white on black, as the output sends it.\nFigure: Detail on frame 3: Pick, Compare and Pick frame at the top, then the Prompt tab with each value marked in its own colour (hovering one says which variable set it) and the negative. The strip below groups the images by frame. Screenshot of the WP Image Filter picker in Detail mode on frame #3, image 1. The picture sits between ‹ and › buttons, with a strip of all 12 images under it. The side panel reads Frame #3 · image 1, 1024 × 1024, seed 41814, 5 / 12, with a Pick button, Compare and Zoom; Values lists $hair silver hair, $place desert and the seed, with Pick whole frame (2); the Prompt box marks silver hair and desert in the prompt; the Negative box reads blurry, extra fingers; Mask has a Paint button.\nFigure: How the picks carry over. Diagram titled How the picks carry over: 3 frames of 2 images from a Context Loop; frame 1 image 1 and both frame 2 images are picked, frame 3 none. Rows compare IN with OUT (same shape): images, latent and masks keep only the picked items of frames 1 and 2 (frame 2 gets a PAINTED mask); positive / negative keep 'cond red' and turn 'cond blonde' into 'cond blonde, freckles' marked RE-ENCODED; positive text becomes 'blonde hair, freckles…' marked EDITED. Notes: per-frame items follow their frame, batched ones are sliced; an edited prompt is re-encoded when a CLIP is wired; One per image emits single-image items."
|
||||
},
|
||||
{
|
||||
"title": "Paint a mask",
|
||||
"text": "M or Paint on the Mask tab opens the painter over the image. A mask is not just on or off: each pixel can be part-way masked, and inpainting changes those parts less, so soft edges blend in without a seam.\n- Brush paints where you drag. Lasso: drag around an area and let go to fill it. Fill: click to fill the area under the pointer, up to the lines you painted (paint an outline, then fill its inside), or click a painted area to take all of it.\n- Paint adds to the mask and Erase takes it away, with any tool (X swaps).\n- Size is the brush ([ and ] change it). Hardness softens the edge of the brush, lasso and fill; at 0% the edge fades over half the brush size. Strength masks part-way: one stroke at 50% stays 50% where it crosses itself, and a second stroke adds on top.\n- A ring under the mouse is the brush at its real size (dashed when erasing, with a dotted inner ring where a soft edge starts).\n- Undo and Redo (Ctrl+Z, Ctrl+Shift+Z) step through strokes, Invert and Clear while the painter is open.\nFigure: The painter: a soft brush stroke at the top, an outline filled with Fill, and a soft-edged Lasso on the right. The side panel has the tools, Paint / Erase, Size, Hardness and Strength, then Undo, Redo, Invert and Clear. Screenshot of the WP Image Filter picker in Detail with the mask painter open on frame #1 · oil, image 1. Over the picture, painted in a red tint: a soft brush stroke across the top, a solid oval filled with the Fill tool over the lower left, and a soft-edged lasso shape on the right that fades out. The side panel reads Mask painted with a Done button, then the tool switch Brush / Lasso (selected) / Fill, the switch Paint (selected) / Erase, sliders Size 80 px, Hardness 0% and Strength 100%, buttons Undo, Redo, Invert and Clear, and a hint about hardness and strength."
|
||||
"text": "M or Paint on the Mask tab opens the painter over the image. A mask is not just on or off: each pixel can be part-way masked, and inpainting changes those parts less, so soft edges blend in without a seam.\n- Brush paints where you drag. Lasso: drag around an area and let go to fill it. Fill: click to fill the area under the pointer, up to the lines you painted (paint an outline, then fill its inside), or click a painted area to take all of it.\n- Paint adds to the mask and Erase takes it away, with any tool (X swaps).\n- Size is the brush ([ and ] change it). Hardness softens the edge of the brush, lasso and fill; at 0% the edge fades over half the brush size. Strength masks part-way: one stroke at 50% stays 50% where it crosses itself, and a second stroke adds on top.\n- A ring under the mouse is the brush at its real size (dashed when erasing, with a dotted inner ring where a soft edge starts).\n- Undo and Redo (Ctrl+Z, Ctrl+Shift+Z) step through strokes, Invert and Clear while the painter is open.\nWire the masks output into a Set Latent Noise Mask on the filter's own latent, not behind an Upscale Latent. The sampler keeps every part you didn't paint exactly as it gets it, and an upscaled latent left as it is decodes blocky. To refine and upscale, upscale after the masked pass.\nFigure: The painter: a soft brush stroke at the top, an outline filled with Fill, and a soft-edged Lasso on the right. The side panel has the tools, Paint / Erase, Size, Hardness and Strength, then Undo, Redo, Invert and Clear. Screenshot of the WP Image Filter picker in Detail with the mask painter open on frame #1 · oil, image 1. Over the picture, painted in a red tint: a soft brush stroke across the top, a solid oval filled with the Fill tool over the lower left, and a soft-edged lasso shape on the right that fades out. The side panel reads Mask painted with a Done button, then the tool switch Brush / Lasso (selected) / Fill, the switch Paint (selected) / Erase, sliders Size 80 px, Hardness 0% and Strength 100%, buttons Undo, Redo, Invert and Clear, and a hint about hardness and strength."
|
||||
},
|
||||
{
|
||||
"title": "Settings on the node",
|
||||
@@ -2064,11 +2084,11 @@
|
||||
},
|
||||
{
|
||||
"title": "Ports",
|
||||
"text": "images\nOne image, a batch, a list, or a list of batches (a Context Loop's frames). Required.\nlabels\nOptional text, one entry per item and one line per image of its batch. One line covers the whole batch, and a single one-line label covers every image of every item. The Image Filter's labels output fits straight in. One typed text with a line per image works too.\nlabel_text\nOptional text from another node for what each image shows; {label}, {index} and the other tokens still work. A list gives one text per item, the last one covering any item past it.\ntitle\nOptional text from another node for the grid title. Once wired, the panel's Title box shows that it comes from the wire.\nrow_headers, column_headers\nOptional text from another node, one header per line (a list gives one header per entry). Wiring one sets that side to typed headers and replaces its dropdown on the panel. Like a built-in widget's, each text input's dot sits beside the field it feeds and shows when you hover that field or wire it.\nimages (out)\nThe labelled images, in the shape they came in. A batch stays a batch.\ngrid (out)\nEvery image in one labelled grid. The node also shows it on the canvas, so it can end a branch."
|
||||
"text": "images\nOne image, a batch, a list, or a list of batches (a Context Loop's frames). Required.\nlabels\nOptional text, one entry per item and one line per image of its batch. One line covers the whole batch, and a single one-line label covers every image of every item. The Image Filter's labels output fits straight in. One typed text with a line per image works too, spread over the images in order. When the lines and images don't match, a warning on the node says how many images got no line or how many lines were left over.\nlabel_text\nOptional text from another node for what each image shows; {label}, {index} and the other tokens still work. A list gives one text per item, the last one covering any item past it.\ntitle\nOptional text from another node for the grid title. Once wired, the panel's Title box shows that it comes from the wire.\nrow_headers, column_headers\nOptional text from another node, one header per line (a list gives one header per entry). Wiring one sets that side to typed headers and replaces its dropdown on the panel. Like a built-in widget's, each text input's dot sits beside the field it feeds and shows when you hover that field or wire it.\nimages (out)\nThe labelled images, in the shape they came in. A batch stays a batch.\ngrid (out)\nEvery image in one labelled grid. The node also shows it on the canvas, so it can end a branch."
|
||||
},
|
||||
{
|
||||
"title": "Labels",
|
||||
"text": "Text\nWhat each image shows. {label} is the incoming label (the default), {frame} and {image} count from 1 per input item, {index} counts across all images, {count} is how many there are. \\n breaks a line.\nPlace\nAbove, below, left or right adds a strip beside the image. Over image draws a see-through band over the top or bottom and keeps the size. The last button draws no label.\nFont and size\nYour system fonts plus any you drop into the extension's fonts folder; type to search. An empty size is automatic: a share of the image width, so labels read the same at 512 px and at 4K.\nColours\nClick a swatch for the colour square and a hex box. The strip or band also has an opacity, and None drops it.\nWithout a labels input the template still works: #{index} numbers every image."
|
||||
"text": "Text\nWhat each image shows. {label} is the incoming label (the default), {frame} and {image} count from 1 per input item, {index} counts across all images, {count} is how many there are. \\n breaks a line.\nPlace\nAbove, below, left or right adds a strip beside the image. Over image draws a see-through band over that edge instead and keeps the size, on any side. The last button draws no label.\nText direction\nLeft and right only: horizontal text, or vertical text turned along the edge (reading upwards on the left, downwards on the right), which keeps the strip thin.\nFont and size\nYour system fonts plus any you drop into the extension's fonts folder; type to search. An empty size is automatic: a share of the image width, so labels read the same at 512 px and at 4K.\nColours\nClick a swatch for the colour square and a hex box. The strip or band also has an opacity, and None drops it.\nWithout a labels input the template still works: #{index} numbers every image."
|
||||
},
|
||||
{
|
||||
"title": "Grid presets",
|
||||
@@ -2076,7 +2096,7 @@
|
||||
},
|
||||
{
|
||||
"title": "The grid",
|
||||
"text": "Layout\nFit the run, one row or column per frame, fill rows (with a column count), all in one row, all in one column. Label-part headers set the layout themselves.\nRows, Columns\nNo headers, typed (one header per line in the box that opens), or a label part. The list shows what each part held on the last run.\nTitle, Spacing\nA line of text above the grid; the gap between cells, the edge around the grid, and the cell width (empty keeps image sizes, a number scales every cell to it).\nColours\nThe grid's background and the colour of headers and title. Reset puts spacing and these back.\nA label can hold parts joined by · . The Image Filter writes #3 · red · pencil: the frame, then the sweep options in the loop's order, then the image number when a frame has more than one. With Rows from label part 2 and Columns from label part 3 (what the XY plot preset picks here), every image lands under its options whatever order it came in, the options become the headers, and the cells keep just #3. Two images with the same options (another seed) repeat the row."
|
||||
"text": "Layout\nFit the run, one row or column per frame, fill rows (with a column count), all in one row, all in one column. Label-part headers set the layout themselves.\nRows, Columns\nNo headers, typed (one header per line in the box that opens), or a label part. The list shows what each part held on the last run. An image whose label lacks the part goes to a last row or column headed (no label), and the node warns when typed headers are fewer or more than the rows or columns.\nTitle, Spacing\nA line of text above the grid; the gap between cells, the edge around the grid, and the cell width (empty keeps image sizes, a number scales every cell to it).\nColours\nThe grid's background and the colour of headers and title. Reset puts spacing and these back.\nA label can hold parts joined by · . The Image Filter writes #3 · red · pencil: the frame, then the sweep options in the loop's order, then the image number when a frame has more than one. With Rows from label part 2 and Columns from label part 3 (what the XY plot preset picks here), every image lands under its options whatever order it came in, the options become the headers, and the cells keep just #3. Two images with the same options (another seed) repeat the row.\nAn image with no label line (four images, three lines), or one whose label lacks a part, goes to a last row or column headed (no label), so the grid stays a grid. A warning under the run line says when label lines, row headers or column headers are fewer or more than the images, rows or columns they are for."
|
||||
},
|
||||
{
|
||||
"title": "Good to know",
|
||||
@@ -2117,7 +2137,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.\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), each in its own card even when it is the only node, 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."
|
||||
},
|
||||
{
|
||||
"title": "Where to put it",
|
||||
@@ -2254,11 +2274,11 @@
|
||||
},
|
||||
{
|
||||
"title": "Controls",
|
||||
"text": "context (in)\nThe Context that holds the $variable.\nvar_name\nThe $variable to read. The list shows every variable the wired Context has.\nmatch\nHow close the text must be to an option: exact, loose (the default) or closest. See below.\nfallback\nA dropdown of the options of the input it's wired into, filled when you connect it. Used when nothing matches or the variable is empty. (first option) means the first option in the list.\nif_no_match\nuse fallback carries on with the fallback and warns you. stop the run stops here and lists some of the options.\nchoice (out)\nWire it into a dropdown input: ckpt_name, sampler_name, scheduler, vae_name, lora_name and so on. The line under the picker names what it feeds and how many options that has."
|
||||
"text": "context (in)\nThe Context that holds the $variable.\nvar_name\nThe $variable to read. The list shows every variable the wired Context has.\nmatch\nHow close the text must be to an option: exact, loose (the default) or closest. See below.\nfallback\nA dropdown of the options of the input it's wired into, filled when you connect it. Used when nothing matches or the variable is empty. (first option) means the first option in the list.\nif_no_match\nuse fallback carries on with the fallback and warns you. stop the run stops here and lists some of the options: the reason shows in red on the node and in a message.\nchoice (out)\nWire it into a dropdown input: ckpt_name, sampler_name, scheduler, vae_name, lora_name and so on. The line under the picker names what it feeds and how many options that has."
|
||||
},
|
||||
{
|
||||
"title": "How it matches",
|
||||
"text": "- exact: the text must be an option, letter for letter.\n- loose: capitals, folders, the file extension, spaces, _, - and . don't matter, so Euler A finds euler_ancestral. If that finds nothing, it takes the shortest option that starts with your text, then one that contains it, so dpmpp 2m finds dpmpp_2m and not dpmpp_2m_sde.\n- closest: loose, and then the most similar option, so a typo like dreamshapr still lands.\nAfter a run the picker shows what it sent and how it found it. When it used the fallback, the line turns amber and a warning pops up.\nWithout this node ComfyUI doesn't check text that comes in through a wire: a name that isn't in the list only fails inside the loader. WP Var → Choice checks it first.\nWired into a switch or another node without a list, the text passes through unchecked and the picker says so."
|
||||
"text": "- exact: the text must be an option, letter for letter.\n- loose: capitals, folders, the file extension, spaces, _, - and . don't matter, so Euler A finds euler_ancestral. If that finds nothing, it takes the shortest option that starts with your text, then one that contains it, so dpmpp 2m finds dpmpp_2m and not dpmpp_2m_sde.\n- closest: loose, and then the most similar option, so a typo like dreamshapr still lands.\nAfter a run the picker shows what it sent and how it found it. When it used the fallback, the line turns amber and a warning pops up. When stop the run stopped it, the line reads stopped the run in red with the reason under it (the variable was empty, or nothing matched, with some of the options), and a message says the same at the corner of the canvas. The next run that gets past the node clears it.\nWithout this node ComfyUI doesn't check text that comes in through a wire: a name that isn't in the list only fails inside the loader. WP Var → Choice checks it first.\nWired into a switch or another node without a list, the text passes through unchecked and the picker says so."
|
||||
},
|
||||
{
|
||||
"title": "Works with",
|
||||
@@ -2295,7 +2315,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Moving, sizing and editing",
|
||||
"text": "Unpin a note to drag it like any node. A Card or Sticky resizes from its corner: the width is yours and the height follows the text. A Label sizes itself to its title. Double-click a note, press the indigo pencil in ComfyUI's selection toolbar while it is selected, or press Edit on a label's hover card to open the editor; Done goes back to reading. The editor on the node holds the note's settings: tone, look, colour, title and the bookmark settings. Drag the editor's background (anywhere that isn't a control) to move the note while it is open.\nThe text has its own window: press Edit text (Write text on an empty note). The Markdown editor is on the left and the note as it will read on the right, following what you type: the node's own card, with its look, tone, colour, title and width. A label shows its tag and, under it, the card that opens when you point at it. Done (or Ctrl+Enter) puts the text on the note; Cancel, Escape or × leaves the note as it was, and asks first when you changed something. The node shows the text's first line and how long it is.\nTone\nStep (a numbered circle), Icon (pick any icon from the list next to it) or Plain (no badge).\nColour\nPresets that follow the theme (Accent, Tip, Info, Warning, Danger, Teal, Violet, Neutral), or any colour from the picker.\nLook\nCard, Sticky or Label (above).\nPinned\nComfyUI's own pin: a pinned note can't be moved or resized by accident. New notes start pinned.\nTitle\nShown in bold at the top, or on the pill of a Label.\nText\nIts first line and length on the node; Edit text opens the text window, with the editor beside a live preview. Done (Ctrl+Enter) puts it on the note, Cancel or Escape leaves the note as it was. Markdown, with the formatting bar below over it: headings (# to ####), bold, italic, strikethrough, code, bulleted, numbered and nested lists, task lists (- [x] / - [ ]), quotes and callouts (> [!TIP], > [!WARNING]), tables with column alignment (:--, :-:, --:), rules (---) and code blocks.\nKeys you type in the editor stay in the editor: Delete, Ctrl+Z and ComfyUI's other shortcuts don't reach the canvas."
|
||||
"text": "Unpin a note to drag it like any node. A Card or Sticky resizes from its corner: the width is yours and the height follows the text. A Label sizes itself to its title. Double-click a note, press the indigo pencil in ComfyUI's selection toolbar while it is selected, or press Edit on a label's hover card to open the editor; Done goes back to reading. The editor on the node holds the note's settings: tone, look, colour, title and the bookmark settings. Drag the editor's background (anywhere that isn't a control) to move the note while it is open.\nThe text has its own window: press Edit text (Write text on an empty note). The Markdown editor is on the left and the note as it will read on the right, following what you type: the node's own card, with its look, tone, colour, title and width. A label shows its tag and, under it, the card that opens when you point at it. Done (or Ctrl+Enter) puts the text on the note; Cancel, Escape or × leaves the note as it was, and asks first when you changed something. The node shows the text's first line and how long it is.\nTone\nStep (a numbered circle), Icon (pick any icon from the list next to it) or Plain (no badge).\nColour\nPresets that follow the theme (Accent, Tip, Info, Warning, Danger, Teal, Violet, Neutral), or any colour from the picker.\nLook\nCard, Sticky or Label (above).\nPinned\nComfyUI's own pin: a pinned note can't be moved or resized by accident. New notes start unpinned; a saved workflow keeps each note's pin.\nTitle\nShown in bold at the top, or on the pill of a Label.\nText\nIts first line and length on the node; Edit text opens the text window, with the editor beside a live preview. Done (Ctrl+Enter) puts it on the note, Cancel or Escape leaves the note as it was. Markdown, with the formatting bar below over it: headings (# to ####), bold, italic, strikethrough, code, bulleted, numbered and nested lists, task lists (- [x] / - [ ]), quotes and callouts (> [!TIP], > [!WARNING]), tables with column alignment (:--, :-:, --:), rules (---) and code blocks.\nKeys you type in the editor stay in the editor: Delete, Ctrl+Z and ComfyUI's other shortcuts don't reach the canvas."
|
||||
},
|
||||
{
|
||||
"title": "The text window and its formatting bar",
|
||||
@@ -2606,7 +2626,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."
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"title": "When you edit a module later",
|
||||
|
||||
+38
-19
@@ -80,6 +80,36 @@ def _effective_rules(
|
||||
return base
|
||||
|
||||
|
||||
def _step(rule: str, before: str, after: str) -> dict[str, Any]:
|
||||
"""One trace step: the rule and, only when it changed the text, the text
|
||||
after it (unchanged steps stay small in the UI payload)."""
|
||||
return {"rule": rule, "text": after} if after != before else {"rule": rule}
|
||||
|
||||
|
||||
def _run_rules(
|
||||
text: str, mode: str, cfg: dict[str, Any], active: set[RuleId],
|
||||
) -> tuple[str, RunReport, list[dict[str, Any]]]:
|
||||
"""Run the active rules in registry order: the text, each rule's stats,
|
||||
and one trace step per rule."""
|
||||
out = text
|
||||
report: RunReport = {}
|
||||
steps: list[dict[str, Any]] = []
|
||||
for rule_id, fn in RULE_REGISTRY:
|
||||
if rule_id not in active:
|
||||
continue
|
||||
result = fn(out, mode, cfg)
|
||||
steps.append(_step(rule_id, out, result["text"]))
|
||||
out = result["text"]
|
||||
report[rule_id] = result["stats"]
|
||||
return out, report, steps
|
||||
|
||||
|
||||
def _trace(text: str, out: str, mode: str, steps: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""What the node's Last run window replays: the text in, the text out and
|
||||
each step between (`src/components/cleaner/types.ts:CleanerTrace`)."""
|
||||
return {"input": text, "output": out, "mode": mode, "steps": steps}
|
||||
|
||||
|
||||
class PromptCleaner:
|
||||
"""Stateless orchestrator. Safe to instantiate once and reuse."""
|
||||
|
||||
@@ -100,15 +130,8 @@ class PromptCleaner:
|
||||
overrides["blocklist"] = True
|
||||
|
||||
active = _effective_rules(intensity, overrides)
|
||||
out = text
|
||||
report: RunReport = {}
|
||||
for rule_id, fn in RULE_REGISTRY:
|
||||
if rule_id not in active:
|
||||
continue
|
||||
result = fn(out, mode, cfg)
|
||||
out = result["text"]
|
||||
report[rule_id] = result["stats"]
|
||||
return {"text": out, "report": report}
|
||||
out, report, steps = _run_rules(text, mode, cfg, active)
|
||||
return {"text": out, "report": report, "trace": _trace(text, out, mode, steps)}
|
||||
|
||||
def run_negative(
|
||||
self,
|
||||
@@ -129,14 +152,8 @@ class PromptCleaner:
|
||||
intensity = cfg.get("intensity", "balanced")
|
||||
overrides: dict[str, bool] = dict(cfg.get("negative_rules_override") or {})
|
||||
active = _effective_rules(intensity, overrides, INTENSITY_TO_NEG_RULES)
|
||||
out = text
|
||||
report: dict[str, Any] = {}
|
||||
for rule_id, fn in RULE_REGISTRY:
|
||||
if rule_id not in active:
|
||||
continue
|
||||
result = fn(out, mode, cfg)
|
||||
out = result["text"]
|
||||
report[rule_id] = result["stats"]
|
||||
out, rule_report, steps = _run_rules(text, mode, cfg, active)
|
||||
report: dict[str, Any] = {**rule_report}
|
||||
|
||||
from engine.negatives import split_tags, tag_key # noqa: PLC0415
|
||||
|
||||
@@ -149,5 +166,7 @@ class PromptCleaner:
|
||||
"dropped": bool(cfg.get("drop_prompt_overlap")),
|
||||
}
|
||||
if cfg.get("drop_prompt_overlap"):
|
||||
out = ", ".join(t for t in tags if tag_key(t) not in prompt_keys)
|
||||
return {"text": out, "report": report}
|
||||
dropped = ", ".join(t for t in tags if tag_key(t) not in prompt_keys)
|
||||
steps.append(_step("prompt_overlap", out, dropped))
|
||||
out = dropped
|
||||
return {"text": out, "report": report, "trace": _trace(text, out, mode, steps)}
|
||||
|
||||
@@ -21,7 +21,9 @@ from engine.cleaner.types import RuleResult
|
||||
# doesn't see "cfg, . avoid" where "cfg, steps. avoid" used to be.
|
||||
_TEXT_ORPHAN_PUNCT = re.compile(r"\s+[.,:;!?]+(?=\s|$)")
|
||||
_TEXT_DOUBLE_SPACE = re.compile(r"\s{2,}")
|
||||
_TEXT_STRAY_EDGES = re.compile(r"^[\s.,:;!?]+|[\s.,:;!?]+$")
|
||||
# The end keeps `.` `!` `?`: removing a word must not take the full stop
|
||||
# off the last sentence.
|
||||
_TEXT_STRAY_EDGES = re.compile(r"^[\s.,:;!?]+|[\s,:;]+$")
|
||||
|
||||
|
||||
def _cleanup_text(s: str) -> str:
|
||||
@@ -46,6 +48,9 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
def _apply_list(text: str, mode: str, entries: list[str]) -> RuleResult:
|
||||
norm_entries = [e for e in entries if isinstance(e, str) and e]
|
||||
dropped: list[str] = []
|
||||
# Tags mode: `matched[i]` is the entry that dropped `dropped[i]`. Text
|
||||
# mode drops the entries themselves, so `dropped` already names them.
|
||||
matched: list[str] = []
|
||||
if mode == "tags":
|
||||
tags = [t.strip() for t in text.split(",") if t.strip()]
|
||||
kept: list[str] = []
|
||||
@@ -56,8 +61,13 @@ def _apply_list(text: str, mode: str, entries: list[str]) -> RuleResult:
|
||||
except re.error:
|
||||
continue
|
||||
for tag in tags:
|
||||
if any(p.search(tag) or tag.casefold() == src.casefold() for src, p in compiled):
|
||||
hit = next(
|
||||
(src for src, p in compiled if p.search(tag) or tag.casefold() == src.casefold()),
|
||||
None,
|
||||
)
|
||||
if hit is not None:
|
||||
dropped.append(tag)
|
||||
matched.append(hit)
|
||||
else:
|
||||
kept.append(tag)
|
||||
out = ", ".join(kept)
|
||||
@@ -73,12 +83,16 @@ def _apply_list(text: str, mode: str, entries: list[str]) -> RuleResult:
|
||||
out = new
|
||||
if any_changed:
|
||||
out = _cleanup_text(out)
|
||||
return {"text": out, "stats": {"dropped": dropped, "errors": []}}
|
||||
stats: dict = {"dropped": dropped, "errors": []}
|
||||
if matched:
|
||||
stats["matched"] = matched
|
||||
return {"text": out, "stats": stats}
|
||||
|
||||
|
||||
def _apply_regex(text: str, mode: str, entries: list[str]) -> RuleResult:
|
||||
out = text
|
||||
dropped: list[str] = []
|
||||
matched: list[str] = []
|
||||
errors: list[str] = []
|
||||
compiled: list[tuple[str, re.Pattern[str]]] = []
|
||||
for entry in entries:
|
||||
@@ -92,9 +106,10 @@ def _apply_regex(text: str, mode: str, entries: list[str]) -> RuleResult:
|
||||
tags = [t.strip() for t in out.split(",") if t.strip()]
|
||||
kept: list[str] = []
|
||||
for tag in tags:
|
||||
matched = any(p.search(tag) for _, p in compiled)
|
||||
if matched:
|
||||
hit = next((src for src, p in compiled if p.search(tag)), None)
|
||||
if hit is not None:
|
||||
dropped.append(tag)
|
||||
matched.append(hit)
|
||||
else:
|
||||
kept.append(tag)
|
||||
out = ", ".join(kept)
|
||||
@@ -108,4 +123,7 @@ def _apply_regex(text: str, mode: str, entries: list[str]) -> RuleResult:
|
||||
out = new
|
||||
if any_changed:
|
||||
out = _cleanup_text(out)
|
||||
return {"text": out, "stats": {"dropped": dropped, "errors": errors}}
|
||||
stats: dict = {"dropped": dropped, "errors": errors}
|
||||
if matched:
|
||||
stats["matched"] = matched
|
||||
return {"text": out, "stats": stats}
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
- `apply_exact` — drop later occurrences of an identical tag (tags
|
||||
mode only). Leftmost wins. Case-insensitive comparison;
|
||||
preserved case in the kept token.
|
||||
- `apply_fuzzy` — drop near-duplicates via Levenshtein ratio. Threshold
|
||||
hardcoded at 0.9. Tags mode only.
|
||||
- `apply_fuzzy` — drop near-duplicates: tags that are the same words once
|
||||
each is made singular and a leading `a` / `an` / `the`
|
||||
is set aside (`a knight` / `knight` / `knights`,
|
||||
`bad hand` / `bad hands`, `dress` / `dresses`), or
|
||||
whose Levenshtein ratio is at least 0.9 (one-letter
|
||||
slips in a longer tag, `detailed backround`). Tags
|
||||
mode only.
|
||||
|
||||
The ratio is plain Levenshtein (`1 - distance / longer length`) whether
|
||||
or not `rapidfuzz` is installed: its `fuzz.ratio` is an Indel score that
|
||||
rates `red hair` / `red chair` 0.94, so the rule used to drop different
|
||||
tags on one install and keep plurals on another.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,6 +23,11 @@ from engine.cleaner.types import RuleResult
|
||||
|
||||
_FUZZY_THRESHOLD = 0.9
|
||||
|
||||
# Prompt keywords, not tags: each `BREAK` starts a new 75-token chunk and
|
||||
# each `AND` a new prompt to blend, so repeats are the point. Upper case only,
|
||||
# the way the parsers that read them match.
|
||||
_KEYWORDS = frozenset({"BREAK", "AND"})
|
||||
|
||||
|
||||
def _split_tags(text: str) -> list[str]:
|
||||
return [t.strip() for t in text.split(",") if t.strip()]
|
||||
@@ -26,17 +41,24 @@ def apply_exact(text: str, mode: str, config: dict) -> RuleResult:
|
||||
if mode != "tags":
|
||||
return {"text": text, "stats": {"dropped": []}}
|
||||
tags = _split_tags(text)
|
||||
seen: set[str] = set()
|
||||
first: dict[str, str] = {}
|
||||
kept: list[str] = []
|
||||
dropped: list[str] = []
|
||||
# `matched[i]` is the earlier tag `dropped[i]` repeats (the Last run
|
||||
# window marks it as the reason).
|
||||
matched: list[str] = []
|
||||
for tag in tags:
|
||||
key = tag.casefold()
|
||||
if key in seen:
|
||||
dropped.append(tag)
|
||||
else:
|
||||
seen.add(key)
|
||||
if tag in _KEYWORDS:
|
||||
kept.append(tag)
|
||||
return {"text": _join_tags(kept), "stats": {"dropped": dropped}}
|
||||
continue
|
||||
if key in first:
|
||||
dropped.append(tag)
|
||||
matched.append(first[key])
|
||||
else:
|
||||
first[key] = tag
|
||||
kept.append(tag)
|
||||
return {"text": _join_tags(kept), "stats": {"dropped": dropped, "matched": matched}}
|
||||
|
||||
|
||||
def _levenshtein_ratio(a: str, b: str) -> float:
|
||||
@@ -58,23 +80,66 @@ def _levenshtein_ratio(a: str, b: str) -> float:
|
||||
|
||||
|
||||
try:
|
||||
from rapidfuzz.fuzz import ratio as _rapidfuzz_ratio # type: ignore[import-not-found]
|
||||
from rapidfuzz.distance import (
|
||||
Levenshtein as _RapidLevenshtein, # type: ignore[import-not-found]
|
||||
)
|
||||
|
||||
def _ratio(a: str, b: str) -> float:
|
||||
return _rapidfuzz_ratio(a, b) / 100.0
|
||||
return float(_RapidLevenshtein.normalized_similarity(a, b))
|
||||
except ImportError:
|
||||
_ratio = _levenshtein_ratio
|
||||
|
||||
|
||||
# Endings that make an English plural, longest first. A word ending in `ss`
|
||||
# (`dress`, `glass`) or `us` / `is` (`cactus`, `iris`) is already singular.
|
||||
_ES_AFTER = ("ches", "shes", "sses", "xes", "zes")
|
||||
|
||||
|
||||
def _singular(word: str) -> str:
|
||||
if len(word) <= 3 or not word.endswith("s"):
|
||||
return word
|
||||
if word.endswith("ies") and len(word) > 4:
|
||||
return word[:-3] + "y"
|
||||
if word.endswith(_ES_AFTER):
|
||||
return word[:-2]
|
||||
if word.endswith(("ss", "us", "is")):
|
||||
return word
|
||||
return word[:-1]
|
||||
|
||||
|
||||
_ARTICLES = frozenset({"a", "an", "the"})
|
||||
|
||||
|
||||
def _plural_key(tag: str) -> str:
|
||||
"""The tag's words without a leading article, each made singular: two
|
||||
tags with the same key only differ by `a` / `the` and plurals
|
||||
(`a knight`, `knight`, `knights`)."""
|
||||
words = tag.split()
|
||||
if len(words) > 1 and words[0] in _ARTICLES:
|
||||
words = words[1:]
|
||||
return " ".join(_singular(w) for w in words)
|
||||
|
||||
|
||||
def apply_fuzzy(text: str, mode: str, config: dict) -> RuleResult:
|
||||
if mode != "tags":
|
||||
return {"text": text, "stats": {"dropped": []}}
|
||||
tags = _split_tags(text)
|
||||
kept: list[str] = []
|
||||
dropped: list[str] = []
|
||||
# `matched[i]` is the kept tag `dropped[i]` was close to, `scores[i]`
|
||||
# how close (0-1, two decimals).
|
||||
matched: list[str] = []
|
||||
scores: list[float] = []
|
||||
# `kinds[i]` is why: "form" (same words once singular, article aside) or "close"
|
||||
# (the ratio reached the threshold; exact repeats score 1.0).
|
||||
kinds: list[str] = []
|
||||
for tag in tags:
|
||||
key = tag.casefold()
|
||||
if tag in _KEYWORDS:
|
||||
kept.append(tag)
|
||||
continue
|
||||
is_dup = False
|
||||
near, score, kind = "", 0.0, "close"
|
||||
# Versioned file references are near-identical strings by design --
|
||||
# `embedding:badhandv4` vs `...v5` scores well above the 0.9 threshold
|
||||
# while naming two different files. Exact dedupe still collapses a
|
||||
@@ -83,14 +148,25 @@ def apply_fuzzy(text: str, mode: str, config: dict) -> RuleResult:
|
||||
for kept_tag in kept:
|
||||
if tag_is_atom or contains_atom(kept_tag):
|
||||
if key == kept_tag.casefold():
|
||||
is_dup = True
|
||||
is_dup, near, score = True, kept_tag, 1.0
|
||||
break
|
||||
continue
|
||||
if _ratio(key, kept_tag.casefold()) >= _FUZZY_THRESHOLD:
|
||||
is_dup = True
|
||||
kept_key = kept_tag.casefold()
|
||||
score = _ratio(key, kept_key)
|
||||
if score >= _FUZZY_THRESHOLD:
|
||||
is_dup, near = True, kept_tag
|
||||
break
|
||||
if _plural_key(key) == _plural_key(kept_key):
|
||||
is_dup, near, kind = True, kept_tag, "form"
|
||||
break
|
||||
if is_dup:
|
||||
dropped.append(tag)
|
||||
matched.append(near)
|
||||
scores.append(round(score, 2))
|
||||
kinds.append(kind)
|
||||
else:
|
||||
kept.append(tag)
|
||||
return {"text": _join_tags(kept), "stats": {"dropped": dropped}}
|
||||
return {
|
||||
"text": _join_tags(kept),
|
||||
"stats": {"dropped": dropped, "matched": matched, "scores": scores, "kinds": kinds},
|
||||
}
|
||||
|
||||
@@ -24,9 +24,10 @@ _TAG_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# A tag needs a space from a neighbour unless that neighbour is whitespace,
|
||||
# a separator or a bracket edge.
|
||||
# a separator, a bracket edge or (after it) punctuation that ends a clause
|
||||
# (`text <lora:x:1>.` stays as written, not `<lora:x:1> .`).
|
||||
_NO_SPACE_BEFORE = set(" \t\n,([{")
|
||||
_NO_SPACE_AFTER = set(" \t\n,)]}")
|
||||
_NO_SPACE_AFTER = set(" \t\n,)]}.;:!?")
|
||||
|
||||
|
||||
def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
|
||||
@@ -5,7 +5,10 @@ Two cleanups:
|
||||
1. Tags mode — drop tags whose only content is punctuation
|
||||
(`.`, `,`, `:`, `;`, `!`, `?`, `-`, `_`, plus runs of those).
|
||||
2. Tags + text mode — strip leading + trailing punctuation runs
|
||||
from each tag (tags mode) or from the whole string (text mode).
|
||||
from each tag (tags mode) or from the whole string (text mode,
|
||||
which keeps a closing `.` `!` `?` since prose ends in one).
|
||||
|
||||
Emoticon tags (`:3`, `-_-`, `^_^`) are kept whole in tags mode.
|
||||
|
||||
Idempotent: re-running on the same input produces the same output.
|
||||
Reports `stats.stripped` = total tags + edges trimmed.
|
||||
@@ -21,6 +24,22 @@ _PUNCT_CHARS = r".,:;!?\-_"
|
||||
_PURE_PUNCT_RE = re.compile(rf"^[{_PUNCT_CHARS}\s]+$")
|
||||
_LEADING_PUNCT_RE = re.compile(rf"^[{_PUNCT_CHARS}\s]+")
|
||||
_TRAILING_PUNCT_RE = re.compile(rf"[{_PUNCT_CHARS}\s]+$")
|
||||
# Text mode keeps the end of the last sentence (`.`, `!`, `?`, `...`) and
|
||||
# strips only what can't end prose: `,` `:` `;` `-` `_`.
|
||||
_TEXT_TRAILING_RE = re.compile(r"[,:;\-_\s]+$")
|
||||
|
||||
# Emoticon tags (Danbooru `:3`, `:d`, `;)`, `-_-`, `^_^`, `>_<`, `o_o`) are
|
||||
# made of the very characters this rule strips, and they are real tags.
|
||||
_EMOTICON_RE = re.compile(
|
||||
r"^(?:[:;=][-']?[^\s,.:;!?_-]{1,2}" # :3 :d ;) :< =) :-p (not `::`)
|
||||
r"|[^\s,]_[^\s,]" # -_- ^_^ >_< o_o ;_; ._.
|
||||
r"|[()<>\[\]]{1,2}[:;=]" # ): (;
|
||||
r")$",
|
||||
)
|
||||
|
||||
|
||||
def is_emoticon(tag: str) -> bool:
|
||||
return bool(_EMOTICON_RE.match(tag))
|
||||
|
||||
|
||||
def _strip_edges(s: str) -> tuple[str, bool]:
|
||||
@@ -35,6 +54,9 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
tags = [t.strip() for t in text.split(",") if t.strip()]
|
||||
kept: list[str] = []
|
||||
for tag in tags:
|
||||
if is_emoticon(tag):
|
||||
kept.append(tag)
|
||||
continue
|
||||
if _PURE_PUNCT_RE.match(tag):
|
||||
stripped += 1
|
||||
continue
|
||||
@@ -56,10 +78,8 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
# only risk is a prompt that ENDS in an atom.
|
||||
if ends_with_atom(text):
|
||||
out = _LEADING_PUNCT_RE.sub("", text)
|
||||
if out != text:
|
||||
stripped += 1
|
||||
else:
|
||||
out, changed = _strip_edges(text)
|
||||
if changed:
|
||||
stripped += 1
|
||||
out = _TEXT_TRAILING_RE.sub("", _LEADING_PUNCT_RE.sub("", text))
|
||||
if out != text:
|
||||
stripped += 1
|
||||
return {"text": out, "stats": {"stripped": stripped}}
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
Rules applied in one pass over the input:
|
||||
1. Collapse runs of horizontal whitespace into a single space.
|
||||
2. Strip leading + trailing whitespace.
|
||||
3. Collapse runs of commas (`,,,` -> `,`).
|
||||
4. Drop leading + trailing standalone commas.
|
||||
5. Normalize `comma-space` separator in tags mode.
|
||||
3. Collapse runs of commas, empty slots included (`,,,` and `a, , b`
|
||||
-> one comma). An empty slot between two commas is spacing, not a
|
||||
tag, so this rule owns it; `punctuation` handles tags that are only
|
||||
punctuation (`a, ., b`).
|
||||
4. Drop the space before a comma (`knight ,` -> `knight,`).
|
||||
5. Drop leading + trailing standalone commas.
|
||||
6. Normalize `comma-space` separator in tags mode.
|
||||
|
||||
Counts every kind of fix into stats.fixed so the UI can show
|
||||
`ws:N` next to the rule row.
|
||||
@@ -18,6 +22,7 @@ from engine.cleaner.types import RuleResult
|
||||
|
||||
_WS_RUN = re.compile(r"[ \t]+")
|
||||
_COMMA_RUN = re.compile(r",[ \t,]*,")
|
||||
_SPACE_BEFORE_COMMA = re.compile(r"[ \t]+,")
|
||||
|
||||
|
||||
def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
@@ -39,6 +44,11 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
fixed += 1
|
||||
out = collapsed_commas
|
||||
|
||||
tightened = _SPACE_BEFORE_COMMA.sub(",", out)
|
||||
if tightened != out:
|
||||
fixed += 1
|
||||
out = tightened
|
||||
|
||||
while out.startswith(","):
|
||||
out = out[1:].lstrip()
|
||||
fixed += 1
|
||||
|
||||
+230
-85
@@ -22,15 +22,28 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageColor, ImageDraw, ImageFont
|
||||
|
||||
PART_SEP = " · "
|
||||
|
||||
POSITIONS = ("top", "bottom", "left", "right", "over_top", "over_bottom", "none")
|
||||
POSITIONS = (
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
"over_top",
|
||||
"over_bottom",
|
||||
"over_left",
|
||||
"over_right",
|
||||
"none",
|
||||
)
|
||||
ALIGNS = ("center", "left", "right")
|
||||
#: Text on a left / right label: across, or turned to run along the edge
|
||||
#: (reading upwards on the left, downwards on the right, like book spines).
|
||||
ORIENTATIONS = ("horizontal", "vertical")
|
||||
#: auto = fit the run's shape (see ``auto_arrange``), rows = one row per input
|
||||
#: item (its batch across), columns = turned around, fill = every image in
|
||||
#: turn, ``columns`` per row (0 = near square), strip = one row, stack = one
|
||||
@@ -60,32 +73,56 @@ def item_labels(labels: list[Any] | None, batch_sizes: list[int]) -> list[list[s
|
||||
|
||||
``labels[item]`` splits on newlines for the item's batch. One line covers
|
||||
every image of the item; with fewer lines than images the rest get none.
|
||||
A single entry for several items whose lines match the total image count
|
||||
is spread over all of them (typed text, one line per image), and a single
|
||||
one-line entry labels every image of every item.
|
||||
A single entry for several items is spread over their images in order
|
||||
(typed text, one line per image), and a single one-line entry labels every
|
||||
image of every item.
|
||||
"""
|
||||
return _fit_labels(labels, batch_sizes)[0]
|
||||
|
||||
|
||||
def label_fit(labels: list[Any] | None, batch_sizes: list[int]) -> tuple[int, int]:
|
||||
"""``(missing, extra)``: images the label lines left without one, and
|
||||
lines no image took. Both 0 when nothing is wired, or a line covers a
|
||||
whole item."""
|
||||
_out, missing, extra = _fit_labels(labels, batch_sizes)
|
||||
return missing, extra
|
||||
|
||||
|
||||
def _lines(raw: Any) -> list[str]:
|
||||
text = raw if isinstance(raw, str) else ("" if raw is None else str(raw))
|
||||
return [ln.strip() for ln in text.splitlines()] if text.strip() else []
|
||||
|
||||
|
||||
def _fit_labels(
|
||||
labels: list[Any] | None, batch_sizes: list[int]
|
||||
) -> tuple[list[list[str]], int, int]:
|
||||
if labels is None or not any(_lines(raw) for raw in labels):
|
||||
return [[""] * size for size in batch_sizes], 0, 0
|
||||
out: list[list[str]] = []
|
||||
if labels is not None and len(labels) == 1 and len(batch_sizes) > 1:
|
||||
# One text for several items (a typed list, one line per image): when
|
||||
# its lines match the image count, spread them over every image.
|
||||
only = labels[0] if isinstance(labels[0], str) else ""
|
||||
lines = [ln.strip() for ln in only.splitlines()]
|
||||
if len(lines) == 1 and lines[0]:
|
||||
return [[lines[0]] * size for size in batch_sizes]
|
||||
if len(lines) == sum(batch_sizes):
|
||||
for size in batch_sizes:
|
||||
out.append(lines[:size])
|
||||
lines = lines[size:]
|
||||
return out
|
||||
if len(labels) == 1 and len(batch_sizes) > 1:
|
||||
# One text for several items (a typed list, one line per image):
|
||||
# spread its lines over every image in order.
|
||||
lines = _lines(labels[0])
|
||||
if len(lines) == 1:
|
||||
return [[lines[0]] * size for size in batch_sizes], 0, 0
|
||||
total = sum(batch_sizes)
|
||||
for size in batch_sizes:
|
||||
out.append([*lines[:size], *([""] * max(0, size - len(lines)))])
|
||||
lines = lines[size:]
|
||||
taken = sum(1 for item in out for lab in item if lab)
|
||||
return out, max(0, total - len(_lines(labels[0]))), len(lines) if taken else 0
|
||||
missing = extra = 0
|
||||
for i, size in enumerate(batch_sizes):
|
||||
raw = labels[i] if labels is not None and i < len(labels) else None
|
||||
text = raw if isinstance(raw, str) else ("" if raw is None else str(raw))
|
||||
lines = [ln.strip() for ln in text.splitlines()] if text.strip() else []
|
||||
lines = _lines(labels[i]) if i < len(labels) else []
|
||||
if len(lines) == 1:
|
||||
out.append([lines[0]] * size)
|
||||
else:
|
||||
out.append([lines[j] if j < len(lines) else "" for j in range(size)])
|
||||
return out
|
||||
continue
|
||||
out.append([lines[j] if j < len(lines) else "" for j in range(size)])
|
||||
missing += max(0, size - len(lines))
|
||||
extra += max(0, len(lines) - size)
|
||||
for raw in labels[len(batch_sizes) :]:
|
||||
extra += len(_lines(raw))
|
||||
return out, missing, extra
|
||||
|
||||
|
||||
def apply_template(
|
||||
@@ -142,6 +179,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"label": "{label}",
|
||||
"position": "top",
|
||||
"align": "center",
|
||||
"orientation": "horizontal",
|
||||
"font": "default",
|
||||
"font_size": 0,
|
||||
"text_color": "#ffffff",
|
||||
@@ -187,7 +225,12 @@ def parse_config(raw: Any) -> dict[str, Any]:
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for key, allowed in (("position", POSITIONS), ("align", ALIGNS), ("arrange", ARRANGE)):
|
||||
for key, allowed in (
|
||||
("position", POSITIONS),
|
||||
("align", ALIGNS),
|
||||
("orientation", ORIENTATIONS),
|
||||
("arrange", ARRANGE),
|
||||
):
|
||||
if raw.get(key) in allowed:
|
||||
out[key] = raw[key]
|
||||
for key in ("rows_from", "columns_from"):
|
||||
@@ -442,6 +485,8 @@ def draw_lines(
|
||||
class Style:
|
||||
position: str = "top"
|
||||
align: str = "center"
|
||||
#: Left / right labels only: ``horizontal`` or ``vertical`` (turned).
|
||||
orientation: str = "horizontal"
|
||||
font: str = DEFAULT_FONT
|
||||
#: 0 = automatic (a share of the image width).
|
||||
font_size: int = 0
|
||||
@@ -454,6 +499,19 @@ class Style:
|
||||
#: 0 = keep each image's size; otherwise every cell is scaled to this width.
|
||||
cell_width: int = 0
|
||||
|
||||
@property
|
||||
def edge(self) -> str:
|
||||
"""The side the label sits on, over the image or beside it."""
|
||||
return self.position[5:] if self.position.startswith("over_") else self.position
|
||||
|
||||
@property
|
||||
def over(self) -> bool:
|
||||
return self.position.startswith("over_")
|
||||
|
||||
@property
|
||||
def vertical(self) -> bool:
|
||||
return self.orientation == "vertical" and self.edge in ("left", "right")
|
||||
|
||||
def auto_size(self, width: int) -> int:
|
||||
if self.font_size > 0:
|
||||
return self.font_size
|
||||
@@ -480,64 +538,90 @@ def _padding(size: int) -> int:
|
||||
return max(4, size // 3)
|
||||
|
||||
|
||||
def _panel(
|
||||
text: str,
|
||||
length: int,
|
||||
thickness: int,
|
||||
style: Style,
|
||||
font: Any,
|
||||
pad: int,
|
||||
fill: tuple[int, int, int, int],
|
||||
across: bool,
|
||||
) -> Image.Image:
|
||||
"""The label's text on a ``length`` × ``thickness`` RGBA panel. ``across``
|
||||
lays it out for a left / right strip (lines as wide as the strip, centred
|
||||
top to bottom); otherwise it runs along the panel like a top strip."""
|
||||
fg = parse_color(style.text_color, (255, 255, 255, 255))
|
||||
if across:
|
||||
size = (thickness, length)
|
||||
lines = wrap(text, font, thickness - 2 * pad, MAX_LABEL_LINES * 3)
|
||||
box = (pad, pad, thickness - pad, length - pad)
|
||||
else:
|
||||
size = (length, thickness)
|
||||
lines = wrap(text, font, length - 2 * pad, MAX_LABEL_LINES)
|
||||
box = (pad, 0, length - pad, thickness)
|
||||
panel = Image.new("RGBA", size, fill)
|
||||
if text:
|
||||
draw_lines(ImageDraw.Draw(panel), lines, font, box, fg, style.align)
|
||||
return panel
|
||||
|
||||
|
||||
def label_image(img: Image.Image, text: str, style: Style, strip: int | None = None) -> Image.Image:
|
||||
"""``img`` with ``text`` drawn on a strip beside it (top / bottom / left /
|
||||
right) or on a band over it. ``strip`` forces the strip's size so a batch
|
||||
keeps one shape; ``None`` fits the text. No text and no forced strip → the
|
||||
image unchanged."""
|
||||
right) or on a band over one of its edges. Left / right labels can run
|
||||
turned along the edge (``style.orientation = vertical``: upwards on the
|
||||
left, downwards on the right). ``strip`` forces the strip's size so a
|
||||
batch keeps one shape; ``None`` fits the text. No text and no forced strip
|
||||
→ the image unchanged."""
|
||||
if style.position == "none" or (not text and not strip):
|
||||
return img
|
||||
if style.over and not text:
|
||||
return img
|
||||
w, h = img.size
|
||||
size = style.auto_size(w)
|
||||
font = load_font(style.font, size)
|
||||
pad = _padding(size)
|
||||
fg = parse_color(style.text_color, (255, 255, 255, 255))
|
||||
bg = parse_color(style.label_color, (0, 0, 0, 255))
|
||||
pos = style.position
|
||||
if pos in ("left", "right"):
|
||||
side = strip or label_strip_size(text, w, h, style)
|
||||
out = Image.new("RGB", (w + side, h), _rgb(bg, style.background))
|
||||
out.paste(img, (side if pos == "left" else 0, 0))
|
||||
lines = wrap(text, font, side - 2 * pad, MAX_LABEL_LINES * 3)
|
||||
x0 = 0 if pos == "left" else w
|
||||
draw_lines(
|
||||
ImageDraw.Draw(out),
|
||||
lines,
|
||||
font,
|
||||
(x0 + pad, pad, x0 + side - pad, h - pad),
|
||||
fg,
|
||||
style.align,
|
||||
)
|
||||
return out
|
||||
lines = wrap(text, font, w - 2 * pad, MAX_LABEL_LINES)
|
||||
edge = style.edge
|
||||
side = edge in ("left", "right")
|
||||
band = strip or label_strip_size(text, w, h, style)
|
||||
if pos in ("top", "bottom"):
|
||||
out = Image.new("RGB", (w, h + band), _rgb(bg, style.background))
|
||||
out.paste(img, (0, band if pos == "top" else 0))
|
||||
y0 = 0 if pos == "top" else h
|
||||
draw_lines(ImageDraw.Draw(out), lines, font, (pad, y0, w - pad, y0 + band), fg, style.align)
|
||||
fill = bg if style.over else (*_rgb(bg, style.background), 255)
|
||||
if side and style.vertical:
|
||||
panel = _panel(text, h, band, style, font, pad, fill, across=False)
|
||||
# Turned so the text reads upwards on the left, downwards on the right.
|
||||
panel = panel.rotate(90 if edge == "left" else -90, expand=True)
|
||||
else:
|
||||
panel = _panel(text, h if side else w, band, style, font, pad, fill, across=side)
|
||||
if style.over:
|
||||
out = img.convert("RGBA")
|
||||
x = w - panel.width if edge == "right" else 0
|
||||
y = h - panel.height if edge == "bottom" else 0
|
||||
overlay = Image.new("RGBA", out.size, (0, 0, 0, 0))
|
||||
overlay.paste(panel, (x, y))
|
||||
return Image.alpha_composite(out, overlay).convert("RGB")
|
||||
if side:
|
||||
out = Image.new("RGB", (w + panel.width, h))
|
||||
out.paste(img, (panel.width if edge == "left" else 0, 0))
|
||||
out.paste(panel.convert("RGB"), (0 if edge == "left" else w, 0))
|
||||
return out
|
||||
# Over the image: a (possibly translucent) band.
|
||||
out = img.convert("RGBA")
|
||||
if not text:
|
||||
return img
|
||||
overlay = Image.new("RGBA", out.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
y0 = 0 if pos == "over_top" else h - band
|
||||
draw.rectangle((0, y0, w, y0 + band), fill=bg)
|
||||
draw_lines(draw, lines, font, (pad, y0, w - pad, y0 + band), fg, style.align)
|
||||
return Image.alpha_composite(out, overlay).convert("RGB")
|
||||
out = Image.new("RGB", (w, h + panel.height))
|
||||
out.paste(img, (0, panel.height if edge == "top" else 0))
|
||||
out.paste(panel.convert("RGB"), (0, 0 if edge == "top" else h))
|
||||
return out
|
||||
|
||||
|
||||
def label_strip_size(text: str, w: int, h: int, style: Style) -> int:
|
||||
"""The strip a label needs: its height (top / bottom / over) or width
|
||||
(left / right)."""
|
||||
"""The strip a label needs: its height (top / bottom), its width (left /
|
||||
right) or its thickness when turned."""
|
||||
if style.position == "none" or not text:
|
||||
return 0
|
||||
size = style.auto_size(w)
|
||||
font = load_font(style.font, size)
|
||||
pad = _padding(size)
|
||||
if style.position in ("left", "right"):
|
||||
if style.vertical:
|
||||
lines = wrap(text, font, h - 2 * pad, MAX_LABEL_LINES)
|
||||
return text_block_height(lines, font) + 2 * pad
|
||||
if style.edge in ("left", "right"):
|
||||
longest = max((_text_width(font, ln) for ln in text.split("\n")), default=0)
|
||||
return int(min(longest + 2 * pad + 1, max(w * 0.6, 4 * size)))
|
||||
lines = wrap(text, font, w - 2 * pad, MAX_LABEL_LINES)
|
||||
@@ -587,30 +671,47 @@ def _scale(img: Image.Image, width: int) -> Image.Image:
|
||||
Grid = list[list["Cell | None"]]
|
||||
|
||||
|
||||
#: The header of the row / column that gathers images whose label lacks the
|
||||
#: part an XY plot sorts by (fewer label lines than images, a typo).
|
||||
NO_LABEL = "(no label)"
|
||||
|
||||
|
||||
def arrange(cells: list[Cell], spec: GridSpec) -> tuple[Grid, list[str], list[str]]:
|
||||
"""Place the cells: ``rows[r][c]``, plus the column and row headers.
|
||||
|
||||
With a label part as a header, rows / columns are that part's distinct
|
||||
values in first-seen order (an XY plot); otherwise the arrange mode and the
|
||||
typed headers apply.
|
||||
typed headers apply. An image whose label lacks the part goes to a last
|
||||
row / column headed ``NO_LABEL``; one lacking both parts of an XY plot goes
|
||||
to ``NO_LABEL`` rows at the bottom, filling the columns from the left.
|
||||
"""
|
||||
|
||||
def key(cell: Cell, index: int) -> str:
|
||||
return part(cell.label, index) if index else ""
|
||||
|
||||
col_vals: list[str] = []
|
||||
row_vals: list[str] = []
|
||||
if spec.column_part or spec.row_part:
|
||||
for cell in cells:
|
||||
if spec.column_part:
|
||||
v = part(cell.label, spec.column_part)
|
||||
if v not in col_vals:
|
||||
col_vals.append(v)
|
||||
if spec.row_part:
|
||||
v = part(cell.label, spec.row_part)
|
||||
if v not in row_vals:
|
||||
row_vals.append(v)
|
||||
if spec.column_part and spec.row_part:
|
||||
for cell in cells:
|
||||
if spec.column_part:
|
||||
v = key(cell, spec.column_part)
|
||||
if v and v not in col_vals:
|
||||
col_vals.append(v)
|
||||
if spec.row_part:
|
||||
v = key(cell, spec.row_part)
|
||||
if v and v not in row_vals:
|
||||
row_vals.append(v)
|
||||
if spec.column_part and spec.row_part and (row_vals or col_vals):
|
||||
loose = [c for c in cells if not key(c, spec.row_part) and not key(c, spec.column_part)]
|
||||
gone = {id(c) for c in loose}
|
||||
placed = [c for c in cells if id(c) not in gone]
|
||||
if any(not key(c, spec.column_part) for c in placed) or not col_vals:
|
||||
col_vals.append(NO_LABEL)
|
||||
if any(not key(c, spec.row_part) for c in placed):
|
||||
row_vals.append(NO_LABEL)
|
||||
spots: dict[tuple[int, int], list[Cell]] = {}
|
||||
for cell in cells:
|
||||
r = row_vals.index(part(cell.label, spec.row_part))
|
||||
c = col_vals.index(part(cell.label, spec.column_part))
|
||||
for cell in placed:
|
||||
r = row_vals.index(key(cell, spec.row_part) or NO_LABEL)
|
||||
c = col_vals.index(key(cell, spec.column_part) or NO_LABEL)
|
||||
spots.setdefault((r, c), []).append(cell)
|
||||
# Several cells on one spot (same options, another seed): the row
|
||||
# repeats, once per extra cell, under the same header.
|
||||
@@ -626,12 +727,23 @@ def arrange(cells: list[Cell], spec: GridSpec) -> tuple[Grid, list[str], list[st
|
||||
]
|
||||
)
|
||||
heads.append(value)
|
||||
width = len(col_vals)
|
||||
for i in range(0, len(loose), width):
|
||||
row: list[Cell | None] = list(loose[i : i + width])
|
||||
grid.append([*row, *([None] * (width - len(row)))])
|
||||
heads.append(NO_LABEL)
|
||||
return grid, col_vals, heads
|
||||
if spec.column_part:
|
||||
grid = _group(cells, col_vals, lambda c: part(c.label, spec.column_part), by_column=True)
|
||||
if spec.column_part and col_vals and not spec.row_part:
|
||||
if any(not key(c, spec.column_part) for c in cells):
|
||||
col_vals.append(NO_LABEL)
|
||||
grid = _group(
|
||||
cells, col_vals, lambda c: key(c, spec.column_part) or NO_LABEL, by_column=True
|
||||
)
|
||||
return grid, col_vals, list(spec.row_headers)
|
||||
if spec.row_part:
|
||||
grid = _group(cells, row_vals, lambda c: part(c.label, spec.row_part), by_column=False)
|
||||
if spec.row_part and row_vals and not spec.column_part:
|
||||
if any(not key(c, spec.row_part) for c in cells):
|
||||
row_vals.append(NO_LABEL)
|
||||
grid = _group(cells, row_vals, lambda c: key(c, spec.row_part) or NO_LABEL, by_column=False)
|
||||
return grid, list(spec.column_headers), row_vals
|
||||
|
||||
items = sorted({c.item for c in cells})
|
||||
@@ -685,6 +797,33 @@ def _auto_columns(n: int) -> int:
|
||||
return cols
|
||||
|
||||
|
||||
def grid_fit(cells: list[Cell], spec: GridSpec) -> dict[str, Any]:
|
||||
"""How the run fits the grid's headers, for the node's warning line:
|
||||
``rows`` / ``columns`` = ``[headers typed, lanes]`` when typed headers are
|
||||
used and their count differs, ``no_label`` = images an XY plot could not
|
||||
sort by their label."""
|
||||
grid, col_heads, row_heads = arrange(cells, spec)
|
||||
out: dict[str, Any] = {}
|
||||
rows, cols = len(grid), max((len(r) for r in grid), default=0)
|
||||
if spec.row_headers and not spec.row_part and len(spec.row_headers) != rows:
|
||||
out["rows"] = [len(spec.row_headers), rows]
|
||||
if spec.column_headers and not spec.column_part and len(spec.column_headers) != cols:
|
||||
out["columns"] = [len(spec.column_headers), cols]
|
||||
if spec.row_part or spec.column_part:
|
||||
lost = 0
|
||||
for r, row in enumerate(grid):
|
||||
for c, cell in enumerate(row):
|
||||
if cell is None:
|
||||
continue
|
||||
if (spec.row_part and r < len(row_heads) and row_heads[r] == NO_LABEL) or (
|
||||
spec.column_part and c < len(col_heads) and col_heads[c] == NO_LABEL
|
||||
):
|
||||
lost += 1
|
||||
if lost:
|
||||
out["no_label"] = lost
|
||||
return out
|
||||
|
||||
|
||||
def build_grid(cells: list[Cell], style: Style, spec: GridSpec) -> Image.Image:
|
||||
"""The grid image: optional title, column headers on top, row headers on
|
||||
the left, every cell labelled per ``style`` (labels without the parts
|
||||
@@ -743,9 +882,11 @@ def build_grid(cells: list[Cell], style: Style, spec: GridSpec) -> Image.Image:
|
||||
draw,
|
||||
lines,
|
||||
hfont,
|
||||
(x + hpad, m + title_h, x + cell_w - hpad, y_body),
|
||||
(x + hpad, m + title_h, x + cell_w - hpad, y_body - hpad),
|
||||
hc,
|
||||
valign="center",
|
||||
# Sat on the cells, so a header that wraps to two lines still
|
||||
# lines up with its one-line neighbours.
|
||||
valign="bottom",
|
||||
)
|
||||
for r, lines in enumerate(row_lines):
|
||||
y = y_body + r * (cell_h + gap)
|
||||
@@ -757,7 +898,11 @@ def build_grid(cells: list[Cell], style: Style, spec: GridSpec) -> Image.Image:
|
||||
img = cell.image
|
||||
framed = Image.new("RGB", (cw, ch), fill)
|
||||
framed.paste(img, ((cw - img.width) // 2, (ch - img.height) // 2))
|
||||
tile = label_image(framed, labels[id(cell)], style, strip or None) if strip else framed
|
||||
text = labels[id(cell)]
|
||||
# A cell with no label keeps the strip (one cell shape) in the
|
||||
# grid's background, not as an empty label box.
|
||||
blank = replace(style, label_color=style.background) if not text else style
|
||||
tile = label_image(framed, text, blank, strip or None) if strip else framed
|
||||
out.paste(tile, (x_body + c * (cell_w + gap), y_body + r * (cell_h + gap)))
|
||||
longest_side = max(out.size)
|
||||
if longest_side > MAX_GRID_SIDE:
|
||||
|
||||
@@ -166,6 +166,10 @@ 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)
|
||||
],
|
||||
|
||||
+30
-1
@@ -361,6 +361,28 @@ def _frame_override(module: Any, frame: int) -> bool | None:
|
||||
return None
|
||||
|
||||
|
||||
def _frames_switched_on(module: Any) -> list[int]:
|
||||
"""0-based frames a base-off module is switched ON for (`frame_enabled`
|
||||
entries that are true), ascending. The Debug viewer names them on the
|
||||
module's "off" row ("off · on #4") so it is clear where it does run."""
|
||||
frame_enabled_map = (
|
||||
module.get("frame_enabled")
|
||||
if isinstance(module, dict)
|
||||
else getattr(module, "frame_enabled", None)
|
||||
)
|
||||
if not isinstance(frame_enabled_map, dict):
|
||||
return []
|
||||
out: list[int] = []
|
||||
for key, on in frame_enabled_map.items():
|
||||
try:
|
||||
k = int(key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if on is True and k >= 0:
|
||||
out.append(k)
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _coerce_snapshot(module: Any, module_uid: str, ctx: dict[str, Any]) -> Any:
|
||||
"""The module as a snapshot dict, with this frame's iteration override
|
||||
and sweep pin applied. Raises whatever coercion raises."""
|
||||
@@ -692,6 +714,7 @@ class PipelineEngine:
|
||||
ctx, h, module, warn_mark, explain,
|
||||
enabled=bool(h.enabled) if forced_off else False,
|
||||
status="skipped_frame" if forced_off else "skipped_disabled",
|
||||
on_frames=[] if forced_off else _frames_switched_on(module),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -783,8 +806,12 @@ class PipelineEngine:
|
||||
error: dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
seed: Any = _NO_SEED,
|
||||
on_frames: list[int] | None = None,
|
||||
) -> None:
|
||||
"""Append one module's trace row (plus explain-mode extras)."""
|
||||
"""Append one module's trace row (plus explain-mode extras).
|
||||
|
||||
`on_frames`: a base-off module's frames it is switched on for (0-based),
|
||||
written only when there are any."""
|
||||
if meta is None:
|
||||
meta = _extract_static_meta(module)
|
||||
row: dict[str, Any] = {
|
||||
@@ -798,6 +825,8 @@ class PipelineEngine:
|
||||
}
|
||||
if seed is not _NO_SEED:
|
||||
row["seed"] = seed
|
||||
if on_frames:
|
||||
row["on_frames"] = list(on_frames)
|
||||
row.update(meta)
|
||||
if explain:
|
||||
row.update(_finish_module(ctx, warn_mark, h.id, h.uid, h.name))
|
||||
|
||||
+9
-2
@@ -17,7 +17,8 @@ or a toggle::
|
||||
* ``Pick one``: one toggle is on; ``needs`` is ignored. Which one, first
|
||||
match wins: the toggle the upstream ``group_var`` names (by name or
|
||||
variable, any case; only with ``group_upstream``), then the first toggle
|
||||
upstream set to a yes word, then the first toggle whose switch is on.
|
||||
upstream set to a yes word, then the first toggle whose switch is on,
|
||||
and with ``pick_required`` (always one on) the first toggle.
|
||||
``group_var`` (optional) gets the picked toggle's ``value`` (text that may
|
||||
read ``$vars``; the node resolves it) or else its name, ``""`` when none
|
||||
is on. It is internal unless ``group_visible``.
|
||||
@@ -98,6 +99,8 @@ class Config:
|
||||
toggles: list[Toggle] = field(default_factory=list)
|
||||
group_visible: bool = False
|
||||
group_upstream: bool = False
|
||||
#: Pick one: always one on; with none on, the first toggle is picked.
|
||||
pick_required: bool = False
|
||||
#: Set by :func:`with_upstream`: the toggle the upstream ``group_var``
|
||||
#: picked, and the toggles upstream set to a yes word (row order).
|
||||
forced: str = ""
|
||||
@@ -155,6 +158,7 @@ def parse_config(raw: Any) -> Config:
|
||||
toggles=toggles,
|
||||
group_visible=data.get("group_visible") is True,
|
||||
group_upstream=data.get("group_upstream") is True,
|
||||
pick_required=data.get("pick_required") is True,
|
||||
)
|
||||
|
||||
|
||||
@@ -211,7 +215,10 @@ def picked_id(cfg: Config) -> str | None:
|
||||
first_up = next((i for i in cfg.upstream_on if i in ids), None)
|
||||
if first_up is not None:
|
||||
return first_up
|
||||
return next((t.id for t in cfg.toggles if t.on), None)
|
||||
on = next((t.id for t in cfg.toggles if t.on), None)
|
||||
if on is None and cfg.pick_required and cfg.toggles:
|
||||
return cfg.toggles[0].id
|
||||
return on
|
||||
|
||||
|
||||
def effective(cfg: Config) -> dict[str, bool]:
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
"announce": "node scripts/discord-announce.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dumiflex/wp-kit": "^0.18.1",
|
||||
"@dumiflex/wp-kit": "^0.20.1",
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^8.0.2",
|
||||
"vue": "^3.5.45",
|
||||
|
||||
Generated
+5
-5
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@dumiflex/wp-kit':
|
||||
specifier: ^0.18.1
|
||||
version: 0.18.1(primeicons@8.0.2)(vue@3.5.45(typescript@6.0.3))
|
||||
specifier: ^0.20.1
|
||||
version: 0.20.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.18.1':
|
||||
resolution: {integrity: sha512-TS6/xh2Ur4IL8iDQgEFLmhe741uYY3kpRLuI37uJorp2T6AvpkxXLB64i+rVAm7ISACqIFvvjC1DExbSCImhqQ==}
|
||||
'@dumiflex/wp-kit@0.20.1':
|
||||
resolution: {integrity: sha512-my+qeGCiXfDKLtHjlafZaf9yDP1Xwq3Or0cE4WwHalGZxi+JXzjJJoAo0lGDm3QWwmvxPUv5UTi32CGMnAo0fA==}
|
||||
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.18.1(primeicons@8.0.2)(vue@3.5.45(typescript@6.0.3))':
|
||||
'@dumiflex/wp-kit@0.20.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)
|
||||
|
||||
@@ -388,7 +388,19 @@ 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.
|
||||
const TOTAL_LIMIT = 685 * 1024; // 685 KB
|
||||
// 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.
|
||||
// RAISED 700 -> 715 KB on 2026-11-13 for the subgraph work: the promoted
|
||||
// template editor (#394), Show on subgraph node (#398) and its Choose what
|
||||
// shows frame (lazy mirror-frame chunk + part marks in every panel) put the
|
||||
// total at ~701.5 KB.
|
||||
// RAISED 715 -> 720 KB on 2026-11-14 for the Bookmarks dock grid mode
|
||||
// (DockTile), its shared SpotGrid and Bookmark this view's spot choice put
|
||||
// the total at ~715.6 KB. Run errors on WP nodes (the execution_error
|
||||
// listener, the strip every widget host can draw and the Var → Choice red
|
||||
// row, 14/11/2026) put it at ~717.5 KB.
|
||||
const TOTAL_LIMIT = 720 * 1024; // 720 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
|
||||
|
||||
@@ -59,11 +59,11 @@ export const SCENES = [
|
||||
from: "one",
|
||||
groups: ["ports", "markers"],
|
||||
build: async (S) => {
|
||||
S.only([3, 5]);
|
||||
S.w(3, 440);
|
||||
S.w(5, 440);
|
||||
await S.columns([[3], [5]]);
|
||||
return 3;
|
||||
S.only([7, 9]);
|
||||
S.w(7, 440);
|
||||
S.w(9, 440);
|
||||
await S.columns([[7], [9]]);
|
||||
return 7;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -73,12 +73,12 @@ export const SCENES = [
|
||||
run: true,
|
||||
groups: ["ports", "rowOptions"],
|
||||
build: async (S) => {
|
||||
S.only([5, 13, 14, 15, 16, 10, 3]);
|
||||
S.link(16, "context", 10, "context");
|
||||
S.fold(3, 5);
|
||||
S.w(13, 320); S.w(14, 320); S.w(15, 320); S.w(16, 460); S.w(10, 440);
|
||||
await S.columns([[3, 5, 13, 14, 15], [{ gap: 60 }, 16], [{ gap: 60 }, 10]], { gapY: 20 });
|
||||
return 16;
|
||||
S.only([7, 9, 13, 14, 15, 12, 22]);
|
||||
S.link(12, "context", 22, "context");
|
||||
S.fold(7, 9);
|
||||
S.w(13, 320); S.w(14, 320); S.w(15, 320); S.w(12, 460); S.w(22, 440);
|
||||
await S.columns([[7, 9, 13, 14, 15], [{ gap: 60 }, 12], [{ gap: 60 }, 22]], { gapY: 20 });
|
||||
return 12;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -120,12 +120,12 @@ export const SCENES = [
|
||||
height: 860,
|
||||
groups: ["ports", "tabs"],
|
||||
build: async (S) => {
|
||||
S.only([3, 5, 10]);
|
||||
S.link(5, "context", 10, "context");
|
||||
S.fold(3);
|
||||
S.w(5, 440); S.w(10, 560);
|
||||
await S.columns([[3, 5], [{ gap: 56 }, 10]], { gapY: 20 });
|
||||
return 10;
|
||||
S.only([7, 9, 22]);
|
||||
S.link(9, "context", 22, "context");
|
||||
S.fold(7);
|
||||
S.w(9, 440); S.w(22, 560);
|
||||
await S.columns([[7, 9], [{ gap: 56 }, 22]], { gapY: 20 });
|
||||
return 22;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -135,16 +135,16 @@ export const SCENES = [
|
||||
run: true,
|
||||
height: 860,
|
||||
build: async (S) => {
|
||||
S.only([3, 5, 7]);
|
||||
S.link(5, "context", 7, "context");
|
||||
S.only([7, 9, 19]);
|
||||
S.link(9, "context", 19, "context");
|
||||
const p1 = S.w(S.add("PreviewAny", { title: "Preview as Text · prompt" }), 360);
|
||||
const p2 = S.w(S.add("PreviewAny", { title: "Preview as Text · negative" }), 360);
|
||||
S.link(7, "prompt", p1, "source");
|
||||
S.link(7, "negative", p2, "source");
|
||||
S.fold(3, 5);
|
||||
S.w(7, 440);
|
||||
await S.columns([[3, 5], [7], [p1, p2]], { gapY: 20 });
|
||||
return 7;
|
||||
S.link(19, "prompt", p1, "source");
|
||||
S.link(19, "negative", p2, "source");
|
||||
S.fold(7, 9);
|
||||
S.w(19, 440);
|
||||
await S.columns([[7, 9], [19], [p1, p2]], { gapY: 20 });
|
||||
return 19;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -154,18 +154,18 @@ export const SCENES = [
|
||||
run: true,
|
||||
height: 860,
|
||||
build: async (S) => {
|
||||
S.only([8]);
|
||||
S.only([20]);
|
||||
const a = S.w(S.add("PrimitiveStringMultiline", { title: "Prompt", widgets: { value: "masterpiece, best quality, , 1girl, red hair, Red Hair, ((smile)), (, ), solo,, < lora : style : 0.8 >" } }), 340);
|
||||
const b = S.w(S.add("PrimitiveStringMultiline", { title: "Negative", widgets: { value: "lowres, bad hands, , lowres, red hair" } }), 340);
|
||||
const p1 = S.w(S.add("PreviewAny", { title: "Preview as Text · prompt" }), 340);
|
||||
const p2 = S.w(S.add("PreviewAny", { title: "Preview as Text · negative" }), 340);
|
||||
S.link(a, "STRING", 8, "prompt");
|
||||
S.link(b, "STRING", 8, "negative");
|
||||
S.link(8, "prompt", p1, "source");
|
||||
S.link(8, "negative", p2, "source");
|
||||
S.w(8, 440);
|
||||
await S.columns([[a, b], [8], [p1, p2]]);
|
||||
return 8;
|
||||
S.link(a, "STRING", 20, "prompt");
|
||||
S.link(b, "STRING", 20, "negative");
|
||||
S.link(20, "prompt", p1, "source");
|
||||
S.link(20, "negative", p2, "source");
|
||||
S.w(20, 440);
|
||||
await S.columns([[a, b], [20], [p1, p2]]);
|
||||
return 20;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -173,9 +173,9 @@ export const SCENES = [
|
||||
/** Var → Int / Float / Choice: the example's Render Context, its model family
|
||||
* given by a text into WP Model Info (no checkpoint needed), then the node. */
|
||||
for (const [image, node, id, into, port, height] of [
|
||||
["wp-var-to-int", "WP_VarToInt", 33, 25, "value", 644],
|
||||
["wp-var-to-float", "WP_VarToFloat", 35, 26, "value", 752],
|
||||
["wp-var-to-choice", "WP_VarToChoice", 36, 26, "choice", 752],
|
||||
["wp-var-to-int", "WP_VarToInt", 37, 27, "value", 644],
|
||||
["wp-var-to-float", "WP_VarToFloat", 39, 28, "value", 752],
|
||||
["wp-var-to-choice", "WP_VarToChoice", 40, 28, "choice", 752],
|
||||
]) {
|
||||
SCENES.push({
|
||||
image,
|
||||
@@ -185,14 +185,14 @@ for (const [image, node, id, into, port, height] of [
|
||||
height,
|
||||
arg: { id, into, port },
|
||||
build: async (S, a) => {
|
||||
S.only([31, 32, a.id, a.into]);
|
||||
S.only([33, 35, a.id, a.into]);
|
||||
const fam = S.w(S.add("PrimitiveString", { title: "Model family", widgets: { value: "sdxl" } }), 240);
|
||||
S.link(fam, "STRING", 31, "family");
|
||||
S.link(fam, "STRING", 33, "family");
|
||||
const p = S.w(S.add("PreviewAny", { title: "Preview as Text" }), 300);
|
||||
S.link(a.id, a.port, p, "source");
|
||||
S.fold(fam, 31, 32);
|
||||
S.fold(fam, 33, 35);
|
||||
S.w(a.id, 380); S.w(a.into, 300);
|
||||
await S.columns([[fam, 31, 32], [a.id], [a.into, p]], { gapY: 18 });
|
||||
await S.columns([[fam, 33, 35], [a.id], [a.into, p]], { gapY: 18 });
|
||||
return a.id;
|
||||
},
|
||||
});
|
||||
@@ -221,17 +221,17 @@ SCENES.push(
|
||||
run: true,
|
||||
build: async (S) => {
|
||||
// No checkpoint in the shooting ComfyUI: the family comes in as text.
|
||||
S.only([31, 32, 33]);
|
||||
S.only([33, 35, 37]);
|
||||
const fam = S.w(S.add("PrimitiveString", { title: "Model family", widgets: { value: "sdxl" } }), 240);
|
||||
S.link(fam, "STRING", 31, "family");
|
||||
S.link(fam, "STRING", 33, "family");
|
||||
const file = S.w(S.add("PrimitiveString", { title: "Checkpoint file", widgets: { value: "ponyDiffusionV6XL.safetensors" } }), 300);
|
||||
S.link(file, "STRING", 31, "name");
|
||||
S.link(file, "STRING", 33, "name");
|
||||
const p = S.w(S.add("PreviewAny", { title: "Preview as Text" }), 240);
|
||||
S.link(33, "value", p, "source");
|
||||
S.w(31, 440);
|
||||
S.fold(32, 33, p);
|
||||
await S.columns([[{ gap: 40 }, fam, file], [31], [{ gap: 20 }, 32, 33, p]], { gapY: 18 });
|
||||
return 31;
|
||||
S.link(37, "value", p, "source");
|
||||
S.w(33, 440);
|
||||
S.fold(35, 37, p);
|
||||
await S.columns([[{ gap: 40 }, fam, file], [33], [{ gap: 20 }, 35, 37, p]], { gapY: 18 });
|
||||
return 33;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -267,12 +267,12 @@ SCENES.push(
|
||||
node: "WP_ModeRelay",
|
||||
from: "one",
|
||||
build: async (S) => {
|
||||
S.only([22, 31, 32, 33, 34, 37, 38]);
|
||||
S.mode(31, 2);
|
||||
S.fold(22, 31, 32, 33, 34);
|
||||
S.w(38, 380); S.w(37, 400);
|
||||
await S.columns([[22, 31, { gap: 40 }, 32, 33, 34], [38], [37]], { gapY: 18 });
|
||||
return 38;
|
||||
S.only([3, 33, 35, 37, 38, 36, 34]);
|
||||
S.mode(33, 2);
|
||||
S.fold(3, 33, 35, 37, 38);
|
||||
S.w(34, 380); S.w(36, 400);
|
||||
await S.columns([[3, 33, { gap: 40 }, 35, 37, 38], [34], [36]], { gapY: 18 });
|
||||
return 34;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -304,16 +304,16 @@ SCENES.push(
|
||||
groups: ["ports", "settings"],
|
||||
answer: { action: "picks", picks: [[0, 0], [1, 1], [3, 0]] },
|
||||
build: async (S) => {
|
||||
S.only([3, 4, 5, 6, 7, 17]);
|
||||
S.only([3, 4, 5, 7, 8, 19]);
|
||||
const img = S.w(S.add("DocsDemoLandscape", { title: "Your images (stand-in for the sampler)", widgets: { width: 384, height: 384, count: 2 } }), 300);
|
||||
S.link(7, "prompt", img, "text");
|
||||
S.link(img, "IMAGE", 17, "images");
|
||||
S.link(8, "prompt", img, "text");
|
||||
S.link(img, "IMAGE", 19, "images");
|
||||
const prev = S.w(S.add("PreviewImage"), 380);
|
||||
S.link(17, "images", prev, "images");
|
||||
S.fold(3, 4, 5, 6, 7);
|
||||
S.w(17, 460);
|
||||
await S.columns([[3, 4, 5, 6, 7, { gap: 20 }, img], [17], [prev]], { gapY: 16 });
|
||||
return 17;
|
||||
S.link(19, "images", prev, "images");
|
||||
S.fold(3, 4, 5, 7, 8);
|
||||
S.w(19, 460);
|
||||
await S.columns([[3, 4, 5, 7, 8, { gap: 20 }, img], [19], [prev]], { gapY: 16 });
|
||||
return 19;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -343,10 +343,10 @@ SCENES.push(
|
||||
edit: true,
|
||||
scale: 0.85,
|
||||
build: async (S) => {
|
||||
S.only([26, 28]);
|
||||
S.w(26, 320);
|
||||
await S.columns([[26], [28]]);
|
||||
return 28;
|
||||
S.only([28, 30]);
|
||||
S.w(28, 320);
|
||||
await S.columns([[28], [30]]);
|
||||
return 30;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// The scenes pick nodes out of the example workflows by id. When an example
|
||||
// is rebuilt its ids move, and the shoot then fails (or pins the wrong node)
|
||||
// only on someone's PC. This checks the ids here instead.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SCENES } from "./scenes.mjs";
|
||||
|
||||
const FILES = { one: "1 - One image.json", grids: "2 - Batches and grids.json", pick: "3 - Pick and refine.json" };
|
||||
const root = join(import.meta.dirname, "..", "..", "example_workflows");
|
||||
|
||||
function types(from) {
|
||||
const wf = JSON.parse(readFileSync(join(root, FILES[from]), "utf8"));
|
||||
return new Map(wf.nodes.map((n) => [Number(n.id), n.type]));
|
||||
}
|
||||
|
||||
describe("docs-shot scenes", () => {
|
||||
for (const scene of SCENES.filter((s) => s.from)) {
|
||||
it(`${scene.image} uses ids that exist in "${scene.from}"`, () => {
|
||||
const nodes = types(scene.from);
|
||||
const src = scene.build.toString();
|
||||
const kept = (src.match(/S\.only\(\[([^\]]*)\]\)/)?.[1] ?? "")
|
||||
.split(",").map((x) => x.trim()).filter(Boolean)
|
||||
.map((x) => (x.startsWith("a.") ? scene.arg[x.slice(2)] : Number(x)));
|
||||
expect(kept.length).toBeGreaterThan(0);
|
||||
for (const id of kept) expect(nodes.has(id), `node ${id}`).toBe(true);
|
||||
const ret = src.match(/return (a\.id|\d+);\s*}\s*$/)?.[1];
|
||||
if (ret && scene.node) {
|
||||
const id = ret === "a.id" ? scene.arg.id : Number(ret);
|
||||
expect(nodes.get(id)).toBe(scene.node);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -49,8 +49,13 @@ export * as bookmarkMod from "./widgets/bookmark";
|
||||
export * as nodeToolbarMod from "./extension/node-toolbar";
|
||||
export { stripControlInputs } from "./extension/toggle-control";
|
||||
export * as imageFilterMod from "./extension/image-filter";
|
||||
export * as runErrorsMod from "./extension/run-errors";
|
||||
export * as graphEventsMod from "./extension/graph-events";
|
||||
export * as badgeSlotMod from "./extension/badge-slot";
|
||||
export * as promoteMod from "./extension/subgraph-promote";
|
||||
export * as showMod from "./extension/show-on-subgraph";
|
||||
export * as eyeMod from "./extension/subgraph-eye";
|
||||
export * as textareaLabelMod from "./extension/textarea-label";
|
||||
export * as graphMod from "./extension/graph";
|
||||
export * as assemblerVarsMod from "./extension/assembler-vars";
|
||||
export * as toastStoreMod from "./components/shared/toast-store";
|
||||
|
||||
@@ -155,6 +155,20 @@ describe("AssemblerHelper resolvedMap (#12 — boundary-disambiguation fix)", ()
|
||||
expect(varSpans[1].classes()).toContain(varColorClass("mood"));
|
||||
});
|
||||
|
||||
it("prints $$ as one $, like the engine (a literal dollar)", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
upstreamVars: ["role"],
|
||||
templateVars: ["role"],
|
||||
template: "A $role on a $$5 coin, $$role",
|
||||
resolvedMap: { role: "ranger" },
|
||||
},
|
||||
});
|
||||
const preview = wrapper.find('[data-test="asm-preview"]');
|
||||
expect(preview.text()).toBe("A ranger on a $5 coin, $role");
|
||||
expect(preview.findAll("span.res").length).toBe(1);
|
||||
});
|
||||
|
||||
it("renders unresolved vars as `$name` when not in the map", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
@@ -204,6 +218,20 @@ describe("AssemblerHelper resolvedMap (SP2a — list-valued vars + .K accessor)"
|
||||
expect(preview.text()).not.toContain(".1");
|
||||
});
|
||||
|
||||
it("reads $name.bool from the toggle's switch, not from the words it writes", () => {
|
||||
const props = {
|
||||
upstreamVars: ["lantern", "rain"],
|
||||
templateVars: ["lantern", "rain"],
|
||||
template: "$lantern [$lantern.bool] [$rain.bool] [$fog.bool]",
|
||||
resolvedMap: { lantern: "with empty hands", rain: "true" },
|
||||
};
|
||||
const off = mount(AssemblerHelper, { props: { ...props, varBools: { lantern: false } } });
|
||||
// Off with an Off value: the words are there, the switch still reads false.
|
||||
expect(off.find('[data-test="asm-preview"]').text()).toBe("with empty hands [false] [true] []");
|
||||
const on = mount(AssemblerHelper, { props: { ...props, resolvedMap: { ...props.resolvedMap, lantern: "holding a lantern" }, varBools: { lantern: true } } });
|
||||
expect(on.find('[data-test="asm-preview"]').text()).toBe("holding a lantern [true] [true] []");
|
||||
});
|
||||
|
||||
it("renders an out-of-range $mood.5 as empty, not a leaked accessor", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
@@ -338,6 +366,24 @@ 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: "" },
|
||||
@@ -566,4 +612,59 @@ describe("AssemblerHelper.vue with no Context", () => {
|
||||
});
|
||||
expect(w.find('[data-test="asm-vars-none"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe("loop frames", () => {
|
||||
const loopProps = (frame: number) => ({
|
||||
upstreamVars: frame === 3 ? ["role", "companion"] : ["role"],
|
||||
templateVars: ["role", "companion"],
|
||||
template: "$role with $companion",
|
||||
resolvedMap: (frame === 3 ? { role: "a knight", companion: "a fox" } : { role: "a knight" }) as Record<string, string>,
|
||||
loopFrames: { frame, count: 4, partial: { companion: [3] } },
|
||||
});
|
||||
|
||||
it("shows a name set on other frames as 'on some frames', not missing", () => {
|
||||
const w = mount(AssemblerHelper, { props: loopProps(0) });
|
||||
expect(w.text()).not.toContain("missing");
|
||||
expect(w.find('[data-test="asm-some-frames"]').text()).toBe("1 on some frames");
|
||||
const chip = w.find('[data-test="asm-chip-companion"]');
|
||||
expect(chip.classes()).toContain("wp-asm-var--off-frame");
|
||||
expect(chip.classes()).not.toContain("wp-asm-var--missing");
|
||||
expect(w.find('[data-test="asm-frames-companion"]').text()).toBe("#4");
|
||||
expect(w.find('[data-test="asm-preview-status"]').text()).toBe("1 empty");
|
||||
expect(w.find('[data-test="asm-preview-off"]').text()).toBe("$companion");
|
||||
});
|
||||
|
||||
it("on the frame that sets it, the chip is a normal chip with its frame tag", () => {
|
||||
const w = mount(AssemblerHelper, { props: loopProps(3) });
|
||||
const chip = w.find('[data-test="asm-chip-companion"]');
|
||||
expect(chip.classes()).not.toContain("wp-asm-var--off-frame");
|
||||
expect(w.find('[data-test="asm-frames-companion"]').text()).toBe("#4");
|
||||
expect(w.find('[data-test="asm-preview-status"]').text()).toBe("resolved");
|
||||
expect(w.find('[data-test="asm-preview"]').text()).toContain("a fox");
|
||||
});
|
||||
|
||||
it("still warns about a name no frame sets", () => {
|
||||
const w = mount(AssemblerHelper, {
|
||||
props: { ...loopProps(0), templateVars: ["role", "companion", "nope"], template: "$role $companion $nope" },
|
||||
});
|
||||
expect(w.text()).toContain("1 missing");
|
||||
expect(w.find('[data-test="asm-preview-status"]').text()).toBe("1 unresolved");
|
||||
});
|
||||
|
||||
it("steps frames through onPickFrame, within the loop", async () => {
|
||||
const picked: number[] = [];
|
||||
const w = mount(AssemblerHelper, { props: { ...loopProps(0), onPickFrame: (k: number) => picked.push(k) } });
|
||||
expect(w.find('[data-test="asm-frame-label"]').text()).toBe("1/4");
|
||||
expect(w.find('[data-test="asm-frame-prev"]').attributes("disabled")).toBeDefined();
|
||||
await w.find('[data-test="asm-frame-next"]').trigger("click");
|
||||
expect(picked).toEqual([1]);
|
||||
});
|
||||
|
||||
it("has no stepper without a loop", () => {
|
||||
const w = mount(AssemblerHelper, {
|
||||
props: { upstreamVars: ["role"], templateVars: ["role"], template: "$role", resolvedMap: { role: "x" } },
|
||||
});
|
||||
expect(w.find('[data-test="asm-frame-step"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, ref, watch } from "vue";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import { modelSyntaxHtml } from "../../widgets/richTokenize";
|
||||
import { buildNegativePreview, type NegativesTable } from "../../extension/negatives";
|
||||
import { templatePieces, templateVarReads } from "../../extension/template-pieces";
|
||||
import ContextMenu, { type ContextMenuItem } from "@/kit/ContextMenu.vue";
|
||||
import { kindIcon, type WpKind } from "../shared/kind-icons";
|
||||
import {
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
type RolledAxes,
|
||||
} from "../../widgets/richTokenize";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import { vTooltip } from "@/kit/tooltip";
|
||||
import { frameSentence, frameTag } from "./loop-frames";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Flat array of upstream variable names. Primary prop shape. */
|
||||
@@ -31,6 +34,9 @@ const props = defineProps<{
|
||||
* this, never against `resolvedMap` — the axis names a tag menu, not the
|
||||
* variable's text. */
|
||||
varAxes?: Record<string, Record<string, string>>;
|
||||
/** Each upstream toggle variable's switch: what `$name.bool` reads, since a
|
||||
* toggle with a Value writes the Value to `$name`, not true / false. */
|
||||
varBools?: Record<string, boolean>;
|
||||
/** What the preview run actually rolled, keyed by binding (the preview
|
||||
* endpoint's `axes`). Preferred over `varAxes` for any axis the chain
|
||||
* declares, because `varAxes` only knows the axis's first tag — it printed
|
||||
@@ -69,6 +75,10 @@ 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,
|
||||
@@ -82,6 +92,12 @@ const props = defineProps<{
|
||||
* the dim overlay so muted/bypassed state matches litegraph's
|
||||
* native node-frame dim. */
|
||||
nodeMode?: number;
|
||||
/** A WP Context Loop upstream: the frame the chips and preview show
|
||||
* (0-based), the Loop's frame count, and the names only some frames set
|
||||
* (name → 0-based frames). Absent without a Loop. */
|
||||
loopFrames?: { frame: number; count: number; partial: Record<string, number[]> };
|
||||
/** Move the shared frame cursor (the Loop's EDIT FRAME) to a 0-based frame. */
|
||||
onPickFrame?: (frame: number) => void;
|
||||
}>();
|
||||
|
||||
const isSkipped = computed(() => props.nodeMode === 2 || props.nodeMode === 4);
|
||||
@@ -103,27 +119,22 @@ const emit = defineEmits<{
|
||||
/** Upstream variable names. */
|
||||
const upstreamNames = computed(() => props.upstreamVars ?? []);
|
||||
|
||||
// Sentinel survives both regex passes — the Unicode replacement character;
|
||||
// doubled, it can't appear in real prompts.
|
||||
const ESCAPE_PLACEHOLDER = "��";
|
||||
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
// SP2a: capture an optional `.K` list accessor (group 2) so `$mood.0` consumes
|
||||
// the whole token — `m[0].length` then advances past `.K` (no stranded ".0"
|
||||
// literal) and group 2 drives applyVarAccessor in the substitution below.
|
||||
// Groups: 1=name · 2=index · 3=axis. The accessor has to be part of the match
|
||||
// or the preview prints the resolved value and strands ".SHOES" after it.
|
||||
// Both accessor orders the engine accepts (`$o.0.SHOES`, `$o.SHOES.0`); the
|
||||
// match is re-split by `varAccessorParts`, the one canonical grammar.
|
||||
const TEMPLATE_VAR_RE =
|
||||
/(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(?:\d+(?:\.[A-Za-z_][A-Za-z0-9_]*)?|[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?))?/g;
|
||||
|
||||
/** Variable names referenced in the template. */
|
||||
const templateVarsInternal = computed(() => {
|
||||
if (props.templateVars) return props.templateVars;
|
||||
const sanitized = props.template.replace(/\$\$/g, ESCAPE_PLACEHOLDER);
|
||||
const matches = sanitized.match(VAR_RE);
|
||||
if (!matches) return [];
|
||||
return [...new Set(matches.map((m) => m.slice(1)).filter((v) => !v.startsWith("__")))];
|
||||
const names = templateVarReads(props.template).map((r) => r.name);
|
||||
return [...new Set(names.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 r of templateVarReads(text)) used.add(r.name);
|
||||
}
|
||||
return upstreamNames.value.filter((v) => !used.has(v));
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -131,7 +142,52 @@ const templateVarsInternal = computed(() => {
|
||||
*/
|
||||
const missing = computed(() => {
|
||||
const upstream = props.upstreamVars ?? [];
|
||||
return templateVarsInternal.value.filter((v) => !upstream.includes(v));
|
||||
const partial = props.loopFrames?.partial ?? {};
|
||||
return templateVarsInternal.value.filter((v) => !upstream.includes(v) && !(v in partial));
|
||||
});
|
||||
|
||||
/** Frames that set `v` when a Loop sets it on some frames only. */
|
||||
function partialFrames(v: string): number[] | undefined {
|
||||
return props.loopFrames?.partial[v];
|
||||
}
|
||||
|
||||
/** Template names a Loop sets on some frames but not on the frame shown:
|
||||
* not missing (another frame sets them), empty in this preview. */
|
||||
const offFrame = computed(() => {
|
||||
const upstream = props.upstreamVars ?? [];
|
||||
const partial = props.loopFrames?.partial ?? {};
|
||||
return templateVarsInternal.value.filter((v) => !upstream.includes(v) && v in partial);
|
||||
});
|
||||
|
||||
/** Template names set on some frames only, shown or not. */
|
||||
const someFrames = computed(() => {
|
||||
const partial = props.loopFrames?.partial ?? {};
|
||||
return templateVarsInternal.value.filter((v) => v in partial);
|
||||
});
|
||||
|
||||
function chipFrameTag(v: string): string {
|
||||
const f = partialFrames(v);
|
||||
return f && props.loopFrames ? frameTag(f, props.loopFrames.count) : "";
|
||||
}
|
||||
|
||||
function chipFrameHint(v: string): string {
|
||||
const f = partialFrames(v);
|
||||
return f && props.loopFrames ? frameSentence(v, f, props.loopFrames.count) : "";
|
||||
}
|
||||
|
||||
/** Hover text for the preview note: each off-frame name's frames. */
|
||||
const offFrameHint = computed(() => offFrame.value.map((v) => chipFrameHint(v)).join(" "));
|
||||
|
||||
/** Hover text for the header's "on some frames" count. */
|
||||
const someFramesHint = computed(() =>
|
||||
`Set on some loop frames only: ${someFrames.value.map((v) => "$" + v).join(", ")}`,
|
||||
);
|
||||
|
||||
/** The preview's frame stepper: only with a Loop of two or more frames. */
|
||||
const stepper = computed(() => {
|
||||
const lf = props.loopFrames;
|
||||
if (!lf || lf.count < 2) return null;
|
||||
return { frame: lf.frame, count: lf.count };
|
||||
});
|
||||
|
||||
// ── Formula-driven minWidth ────────────────────────────────────────
|
||||
@@ -162,7 +218,8 @@ watch(
|
||||
// Preview tokenisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PreviewToken { kind: "literal" | "var"; text: string; varName?: string }
|
||||
/** `off`: a name a loop sets on other frames, empty on the frame shown. */
|
||||
interface PreviewToken { kind: "literal" | "var" | "off"; text: string; varName?: string }
|
||||
|
||||
/** An axis read resolves to a TAG. Falling through to the value is what
|
||||
* rendered "a white t-shirt and denim skirt.SHOES". An axis the chain does not
|
||||
@@ -180,31 +237,45 @@ function axisText(name: string, axis: string, index: number | undefined, raw: st
|
||||
return index != null && index > 0 ? "" : guess;
|
||||
}
|
||||
|
||||
/** `$name.bool`, as the engine reads it (`converters._lookup_bool`): the
|
||||
* toggle's switch when a Toggles node set it, else the value when it already
|
||||
* is true / false, else nothing. */
|
||||
function boolText(name: string, value: ResolvedValue | undefined): string {
|
||||
const on = props.varBools?.[name];
|
||||
if (on !== undefined) return on ? "true" : "false";
|
||||
const v = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
return v === "true" || v === "false" ? v : "";
|
||||
}
|
||||
|
||||
const previewTokens = computed<PreviewToken[]>(() => {
|
||||
if (!props.template) return [];
|
||||
|
||||
// Primary path: per-var resolved map. Walk template + look up each $var
|
||||
// directly. No boundary-disambiguation needed.
|
||||
if (props.resolvedMap !== undefined) {
|
||||
// `$$` / `@@` print one `$` / `@`, as the engine prints them.
|
||||
const tokens: PreviewToken[] = [];
|
||||
let last = 0;
|
||||
for (const m of props.template.matchAll(TEMPLATE_VAR_RE)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
|
||||
const raw = m[0];
|
||||
for (const piece of templatePieces(props.template)) {
|
||||
if (piece.kind !== "var") {
|
||||
tokens.push({ kind: "literal", text: piece.kind === "text" ? piece.text : piece.raw });
|
||||
continue;
|
||||
}
|
||||
const raw = piece.raw;
|
||||
const { base: name, index, axis } = varAccessorParts(raw);
|
||||
const has = Object.prototype.hasOwnProperty.call(props.resolvedMap, name);
|
||||
if (!has && partialFrames(name)) {
|
||||
tokens.push({ kind: "off", text: raw, varName: name });
|
||||
continue;
|
||||
}
|
||||
tokens.push({
|
||||
kind: "var",
|
||||
text: axis
|
||||
text: axis === "bool"
|
||||
? boolText(name, has ? props.resolvedMap[name] : undefined)
|
||||
: axis
|
||||
? axisText(name, axis, index, raw)
|
||||
: (has ? applyVarAccessor(props.resolvedMap[name], index) : raw),
|
||||
varName: name,
|
||||
});
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
if (last < props.template.length) {
|
||||
tokens.push({ kind: "literal", text: props.template.slice(last) });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
@@ -212,16 +283,10 @@ const previewTokens = computed<PreviewToken[]>(() => {
|
||||
if (props.resolved !== undefined) {
|
||||
if (!props.resolved) {
|
||||
// No resolved string yet — render template with var highlights.
|
||||
const tokens: PreviewToken[] = [];
|
||||
let last = 0;
|
||||
for (const m of props.template.matchAll(TEMPLATE_VAR_RE)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
|
||||
tokens.push({ kind: "var", text: m[0], varName: m[1] });
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
if (last < props.template.length) tokens.push({ kind: "literal", text: props.template.slice(last) });
|
||||
return tokens;
|
||||
return templatePieces(props.template).map((p): PreviewToken =>
|
||||
p.kind === "var"
|
||||
? { kind: "var", text: p.raw, varName: p.name }
|
||||
: { kind: "literal", text: p.kind === "text" ? p.text : p.raw });
|
||||
}
|
||||
|
||||
// Legacy fallback — walk template + resolved in parallel. Suffers from
|
||||
@@ -229,15 +294,11 @@ const previewTokens = computed<PreviewToken[]>(() => {
|
||||
// contains the literal that bounds the next var. Prefer `resolvedMap`.
|
||||
const tpl = props.template;
|
||||
const res = props.resolved;
|
||||
const segments: Array<{ kind: "literal" | "var"; text: string; varName?: string }> = [];
|
||||
let last = 0;
|
||||
for (const m of tpl.matchAll(TEMPLATE_VAR_RE)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) segments.push({ kind: "literal", text: tpl.slice(last, idx) });
|
||||
segments.push({ kind: "var", text: m[0], varName: m[1] });
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
if (last < tpl.length) segments.push({ kind: "literal", text: tpl.slice(last) });
|
||||
const segments: Array<{ kind: "literal" | "var"; text: string; varName?: string }> =
|
||||
templatePieces(tpl).map((p) =>
|
||||
p.kind === "var"
|
||||
? { kind: "var" as const, text: p.raw, varName: p.name }
|
||||
: { kind: "literal" as const, text: p.kind === "text" ? p.text : p.raw });
|
||||
|
||||
const tokens: PreviewToken[] = [];
|
||||
let cursor = 0;
|
||||
@@ -272,14 +333,16 @@ const previewTokens = computed<PreviewToken[]>(() => {
|
||||
* axis); a variable with no value renders nothing, as in the engine. */
|
||||
function renderVarsRaw(text: string): string {
|
||||
const map = props.resolvedMap ?? {};
|
||||
return text.replace(TEMPLATE_VAR_RE, (raw) => {
|
||||
const { base, index, axis } = varAccessorParts(raw);
|
||||
return templatePieces(text).map((p) => {
|
||||
if (p.kind === "text") return p.text;
|
||||
if (p.kind !== "var") return p.raw;
|
||||
const { base, index, axis } = varAccessorParts(p.raw);
|
||||
if (axis) {
|
||||
const out = axisText(base, axis, index, raw);
|
||||
return out === raw ? "" : out;
|
||||
const out = axisText(base, axis, index, p.raw);
|
||||
return out === p.raw ? "" : out;
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(map, base) ? applyVarAccessor(map[base], index) : "";
|
||||
});
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/** What the node's `negative` output will be, built locally from the
|
||||
@@ -307,10 +370,11 @@ const isResolved = computed(() => {
|
||||
/** The preview header's note. A template with no `$names` is plain text: it
|
||||
* passes through as typed (the node then works as a multiline text box with
|
||||
* its own negative), so it is neither "empty" nor "unresolved". */
|
||||
const previewStatus = computed<{ text: string; tone: "ok" | "warn" | "" }>(() => {
|
||||
const previewStatus = computed<{ text: string; tone: "ok" | "warn" | "info" | "" }>(() => {
|
||||
if (!props.template.trim()) return { text: "(template empty)", tone: "" };
|
||||
if (templateVarsInternal.value.length === 0) return { text: "plain text", tone: "ok" };
|
||||
if (missing.value.length > 0) return { text: `${missing.value.length} unresolved`, tone: "warn" };
|
||||
if (offFrame.value.length > 0) return { text: `${offFrame.value.length} empty`, tone: "info" };
|
||||
if (isResolved.value) return { text: "resolved", tone: "ok" };
|
||||
return { text: "(not resolved yet)", tone: "" };
|
||||
});
|
||||
@@ -496,6 +560,19 @@ 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"
|
||||
@@ -510,6 +587,13 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
</div>
|
||||
<span class="wp-asm-section-stat">
|
||||
{{ upstreamNames.length }} upstream
|
||||
<template v-if="someFrames.length">
|
||||
· <span
|
||||
v-tooltip="someFramesHint"
|
||||
class="wp-asm-section-stat--info"
|
||||
data-test="asm-some-frames"
|
||||
>{{ someFrames.length }} on some frames</span>
|
||||
</template>
|
||||
<template v-if="missing.length">
|
||||
· <span class="wp-asm-section-stat--warn">{{ missing.length }} missing</span>
|
||||
</template>
|
||||
@@ -535,6 +619,33 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="var-tok">{{ v }}</span>
|
||||
<span
|
||||
v-if="chipFrameTag(v)"
|
||||
v-tooltip="chipFrameHint(v)"
|
||||
class="wp-asm-var__frames"
|
||||
:data-test="`asm-frames-${v}`"
|
||||
>{{ chipFrameTag(v) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-for="v in offFrame"
|
||||
:key="`off-${v}`"
|
||||
v-tooltip="chipFrameHint(v)"
|
||||
:data-test="`asm-chip-${v}`"
|
||||
:class="['wp-asm-var', 'wp-asm-var--off-frame', {
|
||||
'wp-asm__chip--ripple': ripples.has(v),
|
||||
'wp-asm-var--ctx-active': ctxActiveVar === v,
|
||||
}]"
|
||||
:style="rippleStyle(v)"
|
||||
@click="(ev) => onChipClick(ev, v)"
|
||||
@contextmenu="(ev) => openChipMenu(ev, v, false)"
|
||||
>
|
||||
<i
|
||||
v-if="chipKindIcon(v)"
|
||||
:class="['wp-asm-var__kind-icon', chipKindIcon(v)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="var-tok">{{ v }}</span>
|
||||
<span class="wp-asm-var__frames" :data-test="`asm-frames-${v}`">{{ chipFrameTag(v) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-for="v in missing"
|
||||
@@ -549,7 +660,7 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
@contextmenu="(ev) => openChipMenu(ev, v, true)"
|
||||
><i class="pi pi-exclamation-triangle" aria-hidden="true" />{{ v }}</span>
|
||||
<span
|
||||
v-if="upstreamNames.length === 0 && missing.length === 0"
|
||||
v-if="upstreamNames.length === 0 && missing.length === 0 && offFrame.length === 0"
|
||||
class="wp-asm-vars__none"
|
||||
data-test="asm-vars-none"
|
||||
>No variables here: wire a Context or Injector to use $names. Plain text passes through as typed.</span>
|
||||
@@ -558,8 +669,37 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
<!-- preview section -->
|
||||
<div class="wp-asm-section">
|
||||
<span>preview</span>
|
||||
<span v-if="stepper" class="wp-asm-frame-step" data-test="asm-frame-step">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi-chevron-left"
|
||||
:disabled="stepper.frame <= 0"
|
||||
hint="Previous loop frame"
|
||||
aria-label="Previous loop frame"
|
||||
data-test="asm-frame-prev"
|
||||
@click="onPickFrame?.(stepper.frame - 1)"
|
||||
/>
|
||||
<span class="wp-asm-frame-step__label" data-test="asm-frame-label" :aria-label="`Loop frame ${stepper.frame + 1} of ${stepper.count}`">{{ stepper.frame + 1 }}/{{ stepper.count }}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi-chevron-right"
|
||||
:disabled="stepper.frame >= stepper.count - 1"
|
||||
hint="Next loop frame"
|
||||
aria-label="Next loop frame"
|
||||
data-test="asm-frame-next"
|
||||
@click="onPickFrame?.(stepper.frame + 1)"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
:class="['wp-asm-section-stat', { 'is-ok': previewStatus.tone === 'ok', 'wp-asm-section-stat--warn': previewStatus.tone === 'warn' }]"
|
||||
v-tooltip="offFrameHint"
|
||||
:class="['wp-asm-section-stat', {
|
||||
'is-ok': previewStatus.tone === 'ok',
|
||||
'wp-asm-section-stat--warn': previewStatus.tone === 'warn',
|
||||
'wp-asm-section-stat--info': previewStatus.tone === 'info',
|
||||
'wp-asm-section-stat--after-step': !!stepper,
|
||||
}]"
|
||||
data-test="asm-preview-status"
|
||||
>{{ previewStatus.text }}</span>
|
||||
</div>
|
||||
@@ -589,6 +729,12 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
class="literal"
|
||||
v-html="modelSyntaxHtml(tok.text)"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="tok.kind === 'off'"
|
||||
v-tooltip="chipFrameHint(tok.varName ?? '')"
|
||||
class="wp-asm-preview__off"
|
||||
data-test="asm-preview-off"
|
||||
>{{ tok.text }}</span>
|
||||
<span v-else :class="['res', varColorClass(tok.varName ?? '')]">{{ tok.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -651,6 +797,8 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
.wp-asm-helper--skipped { opacity: 0.45; }
|
||||
.wp-asm-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
align-items: center;
|
||||
margin: 6px 0 4px;
|
||||
font: 500 11px/1 var(--wp-font-sans);
|
||||
@@ -659,10 +807,53 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
}
|
||||
.wp-asm-section-stat {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
font: 11px/1 var(--wp-font-mono);
|
||||
color: var(--wp-text-dim);
|
||||
}
|
||||
.wp-asm-section-stat--warn { color: var(--wp-warn); }
|
||||
.wp-asm-section-stat--info { color: var(--wp-info); }
|
||||
/* The frame stepper takes the `margin-left: auto`; the status sits after it. */
|
||||
.wp-asm-section-stat--after-step { margin-left: 6px; }
|
||||
.wp-asm-frame-step {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
text-transform: none;
|
||||
}
|
||||
.wp-asm-frame-step__label {
|
||||
font: 11px/1 var(--wp-font-mono);
|
||||
color: var(--wp-text-muted);
|
||||
padding: 0 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* A loop sets this name on some frames only: a small frame tag inside the
|
||||
chip (hover names the frames). */
|
||||
.wp-asm-var__frames {
|
||||
margin-left: 4px;
|
||||
padding: 0 4px;
|
||||
border-radius: min(4px, var(--wp-radius-cap, 999px));
|
||||
font-size: 9px;
|
||||
line-height: 14px;
|
||||
color: var(--wp-info);
|
||||
background: color-mix(in oklab, var(--wp-info) 18%, transparent);
|
||||
}
|
||||
/* In the preview: renders empty on this frame. Kept in place, struck and
|
||||
faint, so the sentence still shows where it goes. */
|
||||
.wp-asm-preview__off {
|
||||
color: var(--wp-text-dim);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: color-mix(in oklab, var(--wp-info) 60%, transparent);
|
||||
}
|
||||
/* Set on other loop frames, not the one shown: not missing, so the calm
|
||||
info colour with a dashed edge instead of the warning. */
|
||||
.wp-asm-var.wp-asm-var--off-frame {
|
||||
color: var(--wp-info);
|
||||
border-style: dashed;
|
||||
border-color: color-mix(in oklab, var(--wp-info) 55%, transparent);
|
||||
background: color-mix(in oklab, var(--wp-info) 10%, transparent);
|
||||
}
|
||||
.wp-asm-section-stat.is-ok { color: var(--wp-green); }
|
||||
.wp-asm-vars__none {
|
||||
color: var(--wp-text-dim);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import NegativeTemplateBox from "./NegativeTemplateBox.vue";
|
||||
|
||||
function box(linked: boolean, collapsed = false) {
|
||||
return mount(NegativeTemplateBox, {
|
||||
props: { modelValue: "lowres, $negatives", collapsed, linked, varSuggestions: [], varProducers: new Map() },
|
||||
global: { stubs: { RichTextInput: { template: '<div data-test="neg-editor" />' } } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("NegativeTemplateBox", () => {
|
||||
it("shows the editor while nothing is wired", () => {
|
||||
const w = box(false);
|
||||
expect(w.find('[data-test="neg-template-linked"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("says where the negative comes from once its input is wired", () => {
|
||||
const w = box(true);
|
||||
const note = w.find('[data-test="neg-template-linked"]');
|
||||
expect(note.exists()).toBe(true);
|
||||
expect(note.text()).toContain("From the negative_template input");
|
||||
});
|
||||
|
||||
it("the folded line names the wire too", () => {
|
||||
const w = box(true, true);
|
||||
expect(w.find('[data-test="neg-template-summary"]').text()).toBe("driven by the connected input");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { computed, defineAsyncComponent } from "vue";
|
||||
import ListRow from "@/kit/ListRow.vue";
|
||||
import EmptyState from "@/kit/EmptyState.vue";
|
||||
import type { VarProducer } from "../../extension/graph";
|
||||
|
||||
const RichTextInput = defineAsyncComponent(() => import("../../manager/components/RichTextInput.vue"));
|
||||
@@ -21,6 +22,8 @@ const props = defineProps<{
|
||||
linked: boolean;
|
||||
varSuggestions: string[];
|
||||
varProducers: Map<string, VarProducer>;
|
||||
/** Names a loop sets on some frames only (see RichTextInput). */
|
||||
frameNotes?: Record<string, string> | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -56,22 +59,30 @@ const summary = computed(() => {
|
||||
<span class="wp-negbox__line">
|
||||
<span class="wp-negbox__label">negative</span>
|
||||
<span v-if="collapsed" class="wp-negbox__summary" data-test="neg-template-summary">{{ summary }}</span>
|
||||
<span v-else-if="linked" class="wp-negbox__summary">driven by the connected input</span>
|
||||
</span>
|
||||
</ListRow>
|
||||
<div v-if="!collapsed" class="wp-negbox__body" data-wp-socket="negative_template">
|
||||
<!-- Linked: the wired text is the negative, so say where it comes from
|
||||
instead of a greyed copy of text that no longer counts. -->
|
||||
<EmptyState
|
||||
v-if="linked"
|
||||
icon="link"
|
||||
headline="From the negative_template input"
|
||||
body="Disconnect it to type here."
|
||||
size="inline"
|
||||
data-test="neg-template-linked"
|
||||
/>
|
||||
<RichTextInput
|
||||
v-else
|
||||
:model-value="modelValue"
|
||||
surface="assembler"
|
||||
multiline
|
||||
:rows="2"
|
||||
fill
|
||||
:placeholder="linked
|
||||
? 'Driven by the connected input — disconnect it to edit here.'
|
||||
: '$negatives'"
|
||||
:disabled="linked"
|
||||
placeholder="$negatives"
|
||||
:var-suggestions="varSuggestions"
|
||||
:var-producers="varProducers"
|
||||
:frame-notes="frameNotes"
|
||||
graph-aware
|
||||
aria-label="Negative template"
|
||||
@update:model-value="(v: string) => emit('update:modelValue', v)"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { frameList, frameSentence, frameTag, otherFrames } from "./loop-frames";
|
||||
|
||||
describe("loop-frames words", () => {
|
||||
it("joins runs of frames, 1-based", () => {
|
||||
expect(frameList([0, 1, 2, 4])).toBe("1–3, 5");
|
||||
expect(frameList([3])).toBe("4");
|
||||
expect(frameList([0, 1])).toBe("1, 2");
|
||||
expect(frameList([])).toBe("");
|
||||
});
|
||||
|
||||
it("lists the other frames", () => {
|
||||
expect(otherFrames([3], 4)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("tags one or two frames by number, more by share", () => {
|
||||
expect(frameTag([3], 4)).toBe("#4");
|
||||
expect(frameTag([0, 3], 4)).toBe("#1 #4");
|
||||
expect(frameTag([0, 1, 3], 4)).toBe("3/4");
|
||||
});
|
||||
|
||||
it("says where the name is set and where it is empty", () => {
|
||||
expect(frameSentence("companion", [3], 4)).toBe(
|
||||
"$companion is set on frame 4 only. Frames 1–3 render it empty.",
|
||||
);
|
||||
expect(frameSentence("companion", [0, 1, 3], 4)).toBe(
|
||||
"$companion is set on frames 1, 2, 4. Frame 3 renders it empty.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Words for a variable a WP Context Loop sets on some frames only (the
|
||||
* Assembler's frame tag, its tooltip and the preview note). Frames arrive
|
||||
* 0-based and read 1-based, like the Loop's EDIT FRAME chips.
|
||||
*
|
||||
* Pure module: no DOM, no Vue.
|
||||
*/
|
||||
|
||||
/** `[0, 1, 2, 4]` → "1–3, 5" (1-based, runs joined). */
|
||||
export function frameList(frames: readonly number[]): string {
|
||||
const sorted = [...new Set(frames)].sort((a, b) => a - b);
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const start = sorted[i];
|
||||
let end = start;
|
||||
while (i + 1 < sorted.length && sorted[i + 1] === end + 1) end = sorted[++i];
|
||||
if (end === start) parts.push(String(start + 1));
|
||||
else if (end === start + 1) parts.push(`${start + 1}, ${end + 1}`);
|
||||
else parts.push(`${start + 1}–${end + 1}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/** The frames NOT in `frames`, below `count`. */
|
||||
export function otherFrames(frames: readonly number[], count: number): number[] {
|
||||
const on = new Set(frames);
|
||||
const out: number[] = [];
|
||||
for (let k = 0; k < count; k++) if (!on.has(k)) out.push(k);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The chip's short tag: "#4", "#1 #4", or "3/8" once it would get long. */
|
||||
export function frameTag(frames: readonly number[], count: number): string {
|
||||
if (frames.length <= 2) return frames.map((k) => `#${k + 1}`).join(" ");
|
||||
return `${frames.length}/${count}`;
|
||||
}
|
||||
|
||||
/** The chip's tooltip: which frames set the name, which render it empty. */
|
||||
export function frameSentence(name: string, frames: readonly number[], count: number): string {
|
||||
const on = frames.length === 1 ? `frame ${frameList(frames)} only` : `frames ${frameList(frames)}`;
|
||||
const off = otherFrames(frames, count);
|
||||
const offText = off.length === 1
|
||||
? `Frame ${frameList(off)} renders it empty.`
|
||||
: `Frames ${frameList(off)} render it empty.`;
|
||||
return `$${name} is set on ${on}. ${offText}`;
|
||||
}
|
||||
@@ -41,4 +41,31 @@ describe("BookmarkDock", () => {
|
||||
expect(w.find('[data-test^="bm-dock-eye-"]').exists()).toBe(false);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("shows the bookmarks as tiles in grid mode, remembers it, and edits as a list", async () => {
|
||||
localStorage.removeItem("wp.bookmarkDock");
|
||||
const w = mount(BookmarkDock);
|
||||
await w.vm.$nextTick();
|
||||
expect(w.find('[data-test="bm-dock-grid"]').exists()).toBe(false);
|
||||
|
||||
await w.get('[data-test="bm-dock-layout"]').trigger("click");
|
||||
const tiles = w.findAll('[data-test^="bm-dock-tile-"]');
|
||||
expect(tiles).toHaveLength(2);
|
||||
expect(w.findAll('[data-test^="bm-dock-row-"]')).toHaveLength(0);
|
||||
expect(tiles[0]?.text()).toContain("Prompts");
|
||||
expect(tiles[1]?.attributes("title")).toContain("hidden on the canvas");
|
||||
expect(JSON.parse(localStorage.getItem("wp.bookmarkDock") ?? "{}").layout).toBe("grid");
|
||||
|
||||
await w.get('[data-test="bm-dock-edit"]').trigger("click");
|
||||
expect(w.find('[data-test="bm-dock-grid"]').exists()).toBe(false);
|
||||
expect(w.findAll('[data-test^="bm-dock-eye-"]')).toHaveLength(2);
|
||||
await w.get('[data-test="bm-dock-done"]').trigger("click");
|
||||
expect(w.find('[data-test="bm-dock-grid"]').exists()).toBe(true);
|
||||
|
||||
await w.get('[data-test="bm-dock-tile-0"]').trigger("keydown", { key: "Enter" });
|
||||
await w.get('[data-test="bm-dock-layout"]').trigger("click");
|
||||
expect(w.find('[data-test="bm-dock-grid"]').exists()).toBe(false);
|
||||
w.unmount();
|
||||
localStorage.removeItem("wp.bookmarkDock");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* The Bookmarks dock: a small panel pinned to the canvas edge listing every
|
||||
* WP Bookmark in the workflow. A row click jumps there; the eye shows or
|
||||
* hides the bookmark on the canvas (the only way back for a hidden one);
|
||||
* the grip reorders, which is also the order Previous / Next walk.
|
||||
* the grip reorders, which is also the order Previous / Next walk. The
|
||||
* grid button shows them as icon tiles instead of rows (view mode only; edit
|
||||
* mode is always the list).
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
@@ -11,6 +13,8 @@ import SortableList from "@/kit/SortableList.vue";
|
||||
import WpIconButton from "@/kit/WpIconButton.vue";
|
||||
import WpSwitch from "@/kit/WpSwitch.vue";
|
||||
import DockRow from "./DockRow.vue";
|
||||
import DockTile from "./DockTile.vue";
|
||||
import SpotGrid from "./SpotGrid.vue";
|
||||
import {
|
||||
addBookmarkHere,
|
||||
canGoBack,
|
||||
@@ -30,7 +34,7 @@ import {
|
||||
type BookmarkEntry,
|
||||
type DockPrefs,
|
||||
} from "../../widgets/bookmark";
|
||||
import { nearestSpot, spotPosition, type ScreenBox } from "../../extension/bookmarks";
|
||||
import { BOOKMARK_ANCHORS, nearestSpot, spotPosition, type BookmarkAnchor, type DockSpot, type ScreenBox } from "../../extension/bookmarks";
|
||||
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
@@ -43,6 +47,8 @@ function savePrefs(part: Partial<DockPrefs>): void {
|
||||
/** View mode lists and jumps; edit mode adds the grips, the eyes, the side
|
||||
* switch and Bookmark this view. */
|
||||
const editing = ref(false);
|
||||
/** Icon tiles instead of rows; edit mode is always the list. */
|
||||
const grid = computed(() => prefs.value.layout === "grid" && !editing.value);
|
||||
|
||||
const entries = shallowRef<BookmarkEntry[]>([]);
|
||||
const current = shallowRef<unknown>(null);
|
||||
@@ -132,40 +138,68 @@ const style = computed(() => ({
|
||||
}));
|
||||
// Collapsing or a longer list changes its height: a bottom spot keeps its
|
||||
// bottom edge.
|
||||
watch(() => [prefs.value.collapsed, entries.value.length, editing.value], () => { void nextTick(place); });
|
||||
watch(() => [prefs.value.collapsed, prefs.value.layout, entries.value.length, editing.value], () => { void nextTick(place); });
|
||||
|
||||
// Drag by the header in edit mode (not from its buttons); a ghost shows the
|
||||
// spot it will snap to.
|
||||
let dragging = false;
|
||||
let grab = { dx: 0, dy: 0 };
|
||||
const ghost = ref<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
/** The free area and the dock's size, read once when the drag starts: the
|
||||
* scan walks the screen with `elementsFromPoint`, far too slow for every
|
||||
* pointer move, and nothing it reads moves during a drag. */
|
||||
let dragArea: { free: ScreenBox; avoid: ScreenBox[] } | null = null;
|
||||
let dragSize: [number, number] = [300, 40];
|
||||
/** The latest pointer, drawn once per frame. */
|
||||
let pending: { x: number; y: number } | null = null;
|
||||
/** Drawn solid while it moves: the see-through blur repaints the canvas
|
||||
* under it on every frame. */
|
||||
const moving = ref(false);
|
||||
let frame = 0;
|
||||
function onHeadDown(e: PointerEvent): void {
|
||||
if (!editing.value || e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("button, input, a")) return;
|
||||
dragging = true;
|
||||
dragArea = area();
|
||||
dragSize = size();
|
||||
moving.value = true;
|
||||
grab = { dx: e.clientX - box.value.x, dy: e.clientY - box.value.y };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
e.preventDefault();
|
||||
}
|
||||
function drawDrag(): void {
|
||||
frame = 0;
|
||||
if (!dragging || !pending || !dragArea) return;
|
||||
const [w, h] = dragSize;
|
||||
const x = Math.min(Math.max(0, pending.x - grab.dx), window.innerWidth - w);
|
||||
const y = Math.min(Math.max(0, pending.y - grab.dy), window.innerHeight - Math.min(h, 60));
|
||||
box.value = { x, y };
|
||||
const { free, avoid } = dragArea;
|
||||
const spot = nearestSpot([x, y], free, [w, h], GAP);
|
||||
const [gx, gy] = spotPosition(spot, free, [w, h], avoid, GAP);
|
||||
if (!ghost.value || ghost.value.x !== gx || ghost.value.y !== gy) ghost.value = { x: gx, y: gy, w, h };
|
||||
}
|
||||
function onHeadMove(e: PointerEvent): void {
|
||||
if (!dragging) return;
|
||||
const [w, h] = size();
|
||||
const x = Math.min(Math.max(0, e.clientX - grab.dx), window.innerWidth - w);
|
||||
const y = Math.min(Math.max(0, e.clientY - grab.dy), window.innerHeight - Math.min(h, 60));
|
||||
box.value = { x, y };
|
||||
const { free, avoid } = area();
|
||||
const [gx, gy] = spotPosition(nearestSpot([x, y], free, [w, h], GAP), free, [w, h], avoid, GAP);
|
||||
ghost.value = { x: gx, y: gy, w, h };
|
||||
pending = { x: e.clientX, y: e.clientY };
|
||||
if (!frame) frame = requestAnimationFrame(drawDrag);
|
||||
}
|
||||
function onHeadUp(): void {
|
||||
if (!dragging) return;
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
drawDrag();
|
||||
dragging = false;
|
||||
pending = null;
|
||||
moving.value = false;
|
||||
ghost.value = null;
|
||||
const { free } = area();
|
||||
savePrefs({ spot: nearestSpot([box.value.x, box.value.y], free, size(), GAP) });
|
||||
const free = dragArea?.free ?? area().free;
|
||||
dragArea = null;
|
||||
savePrefs({ spot: nearestSpot([box.value.x, box.value.y], free, dragSize, GAP) });
|
||||
place();
|
||||
}
|
||||
|
||||
const spotName = computed(() => BOOKMARK_ANCHORS.find((a) => a.id === prefs.value.newSpot)?.label ?? "Top left");
|
||||
const shown = computed(() => entries.value.length);
|
||||
const hiddenCount = computed(() => entries.value.filter((b) => b.config.hidden).length);
|
||||
|
||||
@@ -190,7 +224,7 @@ function close(): void {
|
||||
<section
|
||||
ref="root"
|
||||
class="wp-bmdock"
|
||||
:class="{ 'wp-bmdock--collapsed': prefs.collapsed, 'wp-bmdock--editing': editing }"
|
||||
:class="{ 'wp-bmdock--collapsed': prefs.collapsed, 'wp-bmdock--editing': editing, 'wp-bmdock--moving': moving }"
|
||||
:style="style"
|
||||
aria-label="Bookmarks"
|
||||
data-test="bm-dock"
|
||||
@@ -219,6 +253,12 @@ function close(): void {
|
||||
<WpIconButton icon="pi-undo" label="Back to where you were" :disabled="!back" data-test="bm-dock-back" @click="jumpBack" />
|
||||
<WpIconButton icon="pi-arrow-up" label="Previous bookmark" :disabled="!shown" data-test="bm-dock-prev" @click="jumpStep(-1)" />
|
||||
<WpIconButton icon="pi-arrow-down" label="Next bookmark" :disabled="!shown" data-test="bm-dock-next" @click="jumpStep(1)" />
|
||||
<WpIconButton
|
||||
:icon="grid ? 'pi-list' : 'pi-th-large'"
|
||||
:label="grid ? 'Show as a list' : 'Show as a grid'"
|
||||
data-test="bm-dock-layout"
|
||||
@click="savePrefs({ layout: grid ? 'list' : 'grid', collapsed: false })"
|
||||
/>
|
||||
<WpIconButton icon="pi-pencil" label="Edit the bookmarks" data-test="bm-dock-edit" @click="editing = true; savePrefs({ collapsed: false })" />
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -248,6 +288,11 @@ function close(): void {
|
||||
/>
|
||||
</template>
|
||||
</SortableList>
|
||||
<ul v-else-if="shown && grid" class="wp-bmdock__grid" aria-label="Bookmarks in this workflow" data-test="bm-dock-grid">
|
||||
<li v-for="(b, i) in entries" :key="keyOf(b)">
|
||||
<DockTile :entry="b" :index="i" :current="current === b.node" @jump="jumpTo(b.node)" />
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-else-if="shown" class="wp-bmdock__list" aria-label="Bookmarks in this workflow">
|
||||
<li v-for="(b, i) in entries" :key="keyOf(b)">
|
||||
<DockRow :entry="b" :index="i" :current="current === b.node" :editing="false" @jump="jumpTo(b.node)" />
|
||||
@@ -267,17 +312,29 @@ function close(): void {
|
||||
data-test="bm-dock-clear"
|
||||
@update:model-value="savePrefs({ clearDock: $event })"
|
||||
/>
|
||||
<Button
|
||||
v-if="editing || !shown"
|
||||
variant="add"
|
||||
size="sm"
|
||||
block
|
||||
icon="pi-plus"
|
||||
data-test="bm-dock-add"
|
||||
@click="addBookmarkHere()"
|
||||
>
|
||||
Bookmark this view
|
||||
</Button>
|
||||
<div v-if="editing || !shown" class="wp-bmdock__add">
|
||||
<SpotGrid
|
||||
v-if="editing"
|
||||
:model-value="prefs.newSpot"
|
||||
label="Where Bookmark this view puts the new bookmark on screen"
|
||||
test-prefix="bm-dock-spot"
|
||||
@update:model-value="(a: BookmarkAnchor) => savePrefs({ newSpot: a as DockSpot })"
|
||||
/>
|
||||
<div class="wp-bmdock__add-main">
|
||||
<span v-if="editing" class="wp-bmdock__add-note" data-test="bm-dock-spot-name">New bookmark lands at: {{ spotName }}</span>
|
||||
<Button
|
||||
variant="add"
|
||||
size="sm"
|
||||
block
|
||||
icon="pi-plus"
|
||||
:hint="`Bookmarks the view on screen. The new bookmark sits ${spotName.toLowerCase()} and lands there, so the jump brings this view back.`"
|
||||
data-test="bm-dock-add"
|
||||
@click="addBookmarkHere(undefined, prefs.newSpot)"
|
||||
>
|
||||
Bookmark this view
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
@@ -312,6 +369,7 @@ function close(): void {
|
||||
.wp-bmdock:focus-within { background: var(--wp-bg-2); }
|
||||
.wp-bmdock--collapsed { width: auto; background: color-mix(in srgb, var(--wp-bg-2) 40%, transparent); }
|
||||
.wp-bmdock--collapsed:not(:hover, :focus-within) { opacity: 0.72; }
|
||||
.wp-bmdock--moving { backdrop-filter: none; background: var(--wp-bg-2); transition: none; will-change: left, top; }
|
||||
.wp-bmdock--editing .wp-bmdock__head { cursor: move; touch-action: none; }
|
||||
.wp-bmdock__head {
|
||||
display: flex;
|
||||
@@ -333,10 +391,24 @@ function close(): void {
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding-top: 6px;
|
||||
}
|
||||
.wp-bmdock__grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
/* Three a line in the 300 px dock; tiles wrap, never widen it. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
.wp-bmdock__grid > li { display: flex; min-width: 0; }
|
||||
.wp-bmdock__grid > li > * { flex: 1; }
|
||||
.wp-bmdock__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
|
||||
/* The same air between rows in edit mode (the kit list's small size packs
|
||||
them at 2px). */
|
||||
.wp-bmdock__body :deep(.wp-sortable__list) { gap: 5px; }
|
||||
.wp-bmdock__add { display: flex; align-items: center; gap: 6px; }
|
||||
.wp-bmdock__add-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.wp-bmdock__add-note { color: var(--wp-text-dim); font-size: 11px; }
|
||||
.wp-bmdock__clear { align-self: flex-start; margin: 2px 2px 0; }
|
||||
.wp-bmdock__empty { margin: 4px 2px; color: var(--wp-text-dim); }
|
||||
.wp-bmdock-ghost {
|
||||
|
||||
@@ -9,6 +9,7 @@ import Banner from "@/kit/Banner.vue";
|
||||
import Kbd from "@/kit/Kbd.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import WpIconButton from "@/kit/WpIconButton.vue";
|
||||
import SpotGrid from "./SpotGrid.vue";
|
||||
import WpNumber from "@/kit/WpNumber.vue";
|
||||
import WpSegmented from "@/kit/WpSegmented.vue";
|
||||
import { pushToast } from "../shared/toast-store";
|
||||
@@ -141,18 +142,12 @@ function suggest(): void {
|
||||
<div class="wp-bmf wp-frameless__pass">
|
||||
<div class="wp-bmf__row wp-bmf__row--top wp-frameless__pass">
|
||||
<span class="wp-bmf__label">Lands at</span>
|
||||
<div class="wp-bmf__grid" role="radiogroup" aria-label="Where the bookmark lands on screen">
|
||||
<WpIconButton
|
||||
v-for="a in BOOKMARK_ANCHORS"
|
||||
:key="a.id"
|
||||
:icon="value.anchor === a.id ? 'pi-circle-fill' : 'pi-circle'"
|
||||
:label="a.label"
|
||||
:pressed="value.anchor === a.id"
|
||||
:tone="value.anchor === a.id ? 'accent' : 'neutral'"
|
||||
:data-test="`bm-anchor-${a.id}`"
|
||||
@click="emit('update', { anchor: a.id as BookmarkAnchor })"
|
||||
/>
|
||||
</div>
|
||||
<SpotGrid
|
||||
:model-value="value.anchor"
|
||||
label="Where the bookmark lands on screen"
|
||||
test-prefix="bm-anchor"
|
||||
@update:model-value="(a: BookmarkAnchor) => emit('update', { anchor: a })"
|
||||
/>
|
||||
<div class="wp-bmf__anchor-note">
|
||||
<span class="wp-bmf__anchor-name" data-test="bm-anchor-name">{{ anchorLabel }}</span>
|
||||
<span class="wp-bmf__hint">{{ value.anchor === "custom" ? "the view you set, at its zoom" : `of the screen, ${BOOKMARK_MARGIN} px in` }}</span>
|
||||
@@ -261,19 +256,13 @@ function suggest(): void {
|
||||
font-weight: 600;
|
||||
color: var(--wp-text-dim);
|
||||
}
|
||||
.wp-bmf__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 20px);
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: min(4px, var(--wp-radius-cap, 999px));
|
||||
}
|
||||
.wp-bmf__grid :deep(.pi) { font-size: 8px; }
|
||||
.wp-bmf__anchor-note { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; min-width: 0; }
|
||||
.wp-bmf__anchor-name { font-weight: 600; }
|
||||
.wp-bmf__zoom { width: 84px; }
|
||||
.wp-bmf__record { min-width: 86px; }
|
||||
/* The keys sit in the label line on its baseline, about 2px low; centre them
|
||||
* in the button instead. */
|
||||
.wp-bmf__record :deep(.wp-btn__label) { display: inline-flex; align-items: center; }
|
||||
.wp-bmf__hint { font-size: 10.5px; color: var(--wp-text-dim); }
|
||||
.wp-bmf__hint--block { margin: 0; padding: 0 6px; }
|
||||
.wp-bmf__conflict { display: block; }
|
||||
|
||||
@@ -217,9 +217,14 @@ function done(): void {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The kit picker's list, sized for a node: as wide as the panel, a short
|
||||
* scroll area, cells packed by width instead of a fixed 8 columns. */
|
||||
.wp-bm__icons :deep(.wp-iconpick__panel) { width: auto; max-height: 184px; }
|
||||
/* The kit picker's list, sized for a node: as wide as the card, a short
|
||||
* scroll area, cells packed by width instead of a fixed 8 columns. The kit's
|
||||
* own widths come with `[data-all]` (and the scrollbar room), so each one is
|
||||
* matched, as on the WP Note. */
|
||||
.wp-bm__icons :deep(.wp-iconpick__panel) { max-height: 184px; }
|
||||
.wp-bm__icons.wp-iconpick :deep(.wp-iconpick__panel),
|
||||
.wp-bm__icons.wp-iconpick :deep(.wp-iconpick__panel[data-all]),
|
||||
.wp-bm__icons.wp-iconpick :deep(.wp-iconpick__panel[data-all][data-wp-bar~="y"]) { width: auto; }
|
||||
.wp-bm__icons :deep(.wp-iconpick__grid) { grid-template-columns: repeat(auto-fill, minmax(28px, 1fr)); }
|
||||
.wp-bm__icons :deep(.wp-iconpick__cell) { aspect-ratio: auto; height: 28px; }
|
||||
.wp-bm {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
/** One bookmark of the dock's grid: its icon large in its colour, the name
|
||||
* under it and the shortcut. A jump target like a list row (DockRow); the
|
||||
* grid is view mode only, so it has no eye. */
|
||||
import Kbd from "@/kit/Kbd.vue";
|
||||
import type { BookmarkEntry } from "../../widgets/bookmark";
|
||||
|
||||
const props = defineProps<{
|
||||
entry: BookmarkEntry;
|
||||
index: number;
|
||||
current: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ (e: "jump"): void }>();
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
emit("jump");
|
||||
}
|
||||
}
|
||||
|
||||
/** The full name and its marks, since the tile cuts a long name short. */
|
||||
function tip(): string {
|
||||
const e = props.entry;
|
||||
return [
|
||||
e.title,
|
||||
e.nested ? "in a subgraph" : "",
|
||||
e.config.hidden ? "hidden on the canvas" : "",
|
||||
e.disabled ? "muted or bypassed" : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="wp-bmtile"
|
||||
:class="{ 'is-off': entry.disabled, 'is-hidden': entry.config.hidden, 'is-current': current }"
|
||||
:style="entry.config.color ? { '--wp-bm-color': entry.config.color } : undefined"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="tip()"
|
||||
:aria-label="`Jump to ${entry.title}`"
|
||||
:data-test="`bm-dock-tile-${index}`"
|
||||
@click="emit('jump')"
|
||||
@keydown="onKey"
|
||||
>
|
||||
<span class="wp-bmtile__num">{{ index + 1 }}</span>
|
||||
<span class="wp-bmtile__marks">
|
||||
<i v-if="entry.nested" class="pi pi-sitemap" aria-label="In a subgraph" />
|
||||
<i v-if="entry.config.hidden" class="pi pi-eye-slash" aria-label="Hidden on the canvas" />
|
||||
</span>
|
||||
<span v-if="entry.config.step" class="wp-bmtile__step" aria-hidden="true">{{ entry.config.step }}</span>
|
||||
<i v-else class="pi wp-bmtile__icon" :class="`pi-${entry.config.icon}`" aria-hidden="true" />
|
||||
<span class="wp-bmtile__name">{{ entry.title }}</span>
|
||||
<span v-if="entry.disabled" class="wp-bmtile__off">off</span>
|
||||
<Kbd v-else-if="entry.config.shortcut" :keys="entry.config.shortcut" size="sm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-bmtile {
|
||||
--wp-bm-color: var(--wp-accent);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 4px 6px;
|
||||
border-radius: min(6px, var(--wp-radius-cap, 999px));
|
||||
background: color-mix(in srgb, var(--wp-bg-1) 60%, transparent);
|
||||
box-shadow: inset 0 2px 0 var(--wp-bm-color);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.wp-bmtile:hover { background: var(--wp-bg-3, var(--wp-bg-1)); }
|
||||
.wp-bmtile:focus-visible { outline: 2px solid var(--wp-accent); outline-offset: -2px; }
|
||||
.wp-bmtile.is-current { background: color-mix(in srgb, var(--wp-bm-color) 16%, transparent); }
|
||||
.wp-bmtile.is-off { opacity: 0.55; }
|
||||
.wp-bmtile.is-hidden .wp-bmtile__name { font-style: italic; color: var(--wp-text-dim); }
|
||||
.wp-bmtile__num {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 6px;
|
||||
color: var(--wp-text-dim);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wp-bmtile__marks {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
color: var(--wp-text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
.wp-bmtile__marks .pi { font-size: 10px; }
|
||||
.wp-bmtile__icon { color: var(--wp-bm-color); font-size: 20px; line-height: 24px; }
|
||||
/* A Step note's number, in a circle like on the note. */
|
||||
.wp-bmtile__step {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--wp-radius-cap, 999px);
|
||||
background: var(--wp-bm-color);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Two lines, then an ellipsis; the tooltip has the rest. */
|
||||
.wp-bmtile__name {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.wp-bmtile__off { color: var(--wp-text-dim); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
/** The 3×3 grid of screen spots: where a bookmark lands (the editor's Lands
|
||||
* at) or where Bookmark this view puts the new one (the dock). */
|
||||
import WpIconButton from "@/kit/WpIconButton.vue";
|
||||
import { BOOKMARK_ANCHORS, type BookmarkAnchor } from "../../extension/bookmarks";
|
||||
|
||||
defineProps<{
|
||||
modelValue: BookmarkAnchor;
|
||||
/** The radiogroup's name for screen readers. */
|
||||
label: string;
|
||||
/** `data-test` prefix for each spot (`<prefix>-tl` …). */
|
||||
testPrefix: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ (e: "update:modelValue", v: BookmarkAnchor): void }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-bmspots" role="radiogroup" :aria-label="label">
|
||||
<WpIconButton
|
||||
v-for="a in BOOKMARK_ANCHORS"
|
||||
:key="a.id"
|
||||
:icon="modelValue === a.id ? 'pi-circle-fill' : 'pi-circle'"
|
||||
:label="a.label"
|
||||
:pressed="modelValue === a.id"
|
||||
:tone="modelValue === a.id ? 'accent' : 'neutral'"
|
||||
:data-test="`${testPrefix}-${a.id}`"
|
||||
@click="emit('update:modelValue', a.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-bmspots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 20px);
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: min(4px, var(--wp-radius-cap, 999px));
|
||||
}
|
||||
.wp-bmspots :deep(.pi) { font-size: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import CleanerRunModal from "./CleanerRunModal.vue";
|
||||
import type { CleanerTrace } from "./types";
|
||||
|
||||
const trace: CleanerTrace = {
|
||||
input: "blue eyes, ((silver hair)), Blue Eyes",
|
||||
output: "blue eyes, (silver hair:1.21)",
|
||||
mode: "tags",
|
||||
steps: [
|
||||
{ rule: "merge_weights", text: "blue eyes, (silver hair:1.21), Blue Eyes" },
|
||||
{ rule: "whitespace" },
|
||||
{ rule: "dedupe_exact", text: "blue eyes, (silver hair:1.21)" },
|
||||
],
|
||||
};
|
||||
const report = {
|
||||
merge_weights: { merged: 1 },
|
||||
whitespace: { fixed: 0 },
|
||||
dedupe_exact: { dropped: ["Blue Eyes"], matched: ["blue eyes"] },
|
||||
};
|
||||
|
||||
function open(extra: object = {}) {
|
||||
return mount(CleanerRunModal, {
|
||||
props: { open: true, trace, report, ...extra },
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("CleanerRunModal", () => {
|
||||
it("lists only the rules that changed the text, with why", () => {
|
||||
const w = open();
|
||||
const rules = [...document.querySelectorAll("[data-test^=cleaner-run-rule-]")].map((e) => e.getAttribute("data-test"));
|
||||
expect(rules).toEqual(["cleaner-run-rule-merge_weights", "cleaner-run-rule-dedupe_exact"]);
|
||||
expect(document.querySelector("[data-test=cleaner-run-rule-dedupe_exact]")?.textContent).toContain("Blue Eyes repeats blue eyes");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("compares whole tags, so a rewritten tag is one out and one in", () => {
|
||||
const w = open();
|
||||
const del = [...document.querySelectorAll(".wp-diff__del")].map((e) => e.textContent?.replace("removed: ", ""));
|
||||
expect(del).toContain("((silver hair)), ");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("offers the negative only when the run cleaned one", async () => {
|
||||
const w = open();
|
||||
expect(document.querySelector("[data-test=cleaner-run-side-negative]")).toBeNull();
|
||||
w.unmount();
|
||||
const n = open({
|
||||
negativeTrace: { input: "lowres, red", output: "lowres, red", steps: [] },
|
||||
negativeReport: { prompt_overlap: { tags: ["red"], dropped: false } },
|
||||
});
|
||||
const btn = document.querySelector<HTMLElement>("[data-test=cleaner-run-side-negative]");
|
||||
expect(btn).not.toBeNull();
|
||||
btn?.click();
|
||||
await n.vm.$nextTick();
|
||||
expect(document.body.textContent).toContain("Also in the prompt");
|
||||
expect(document.body.textContent).toContain("No rules were on for this text.");
|
||||
n.unmount();
|
||||
});
|
||||
|
||||
it("says to run first when there is no trace", () => {
|
||||
const w = open({ trace: null });
|
||||
expect(document.body.textContent).toContain("Run the workflow to see what the cleaner changed.");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Last run window for WP Prompt Cleaner, opened from the node's LAST RUN
|
||||
* strip: Before and After side by side (kit DiffView, split), a Prompt /
|
||||
* Negative switch when the run cleaned a negative, and one line per rule that
|
||||
* changed the text. A rule line opens to show that rule's own before/after
|
||||
* and why it fired: each dropped tag beside the tag it repeats, the tag it is
|
||||
* close to or the blocklist entry it matched, with that cause marked in the
|
||||
* diff (DiffView `marks`).
|
||||
*
|
||||
* Built only from wp-kit pieces.
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import Banner from "@/kit/Banner.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Chip from "@/kit/Chip.vue";
|
||||
import CopyButton from "@/kit/CopyButton.vue";
|
||||
import DiffView from "@/kit/DiffView.vue";
|
||||
import Disclosure from "@/kit/Disclosure.vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import WpSegmented, { type SegmentedOption } from "@/kit/WpSegmented.vue";
|
||||
import { RULE_LABELS, ruleSteps } from "./run-steps";
|
||||
import type { CleanerTrace, NegativeRunReport, RunReport, StepRule } from "./types";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean;
|
||||
trace: CleanerTrace | null;
|
||||
report: RunReport | null;
|
||||
negativeTrace?: CleanerTrace | null | undefined;
|
||||
negativeReport?: NegativeRunReport | null | undefined;
|
||||
}>(), { negativeTrace: null, negativeReport: null });
|
||||
|
||||
const emit = defineEmits<{ "update:open": [open: boolean] }>();
|
||||
|
||||
type Side = "prompt" | "negative";
|
||||
const side = ref<Side>("prompt");
|
||||
watch(() => props.negativeTrace, (t) => { if (!t) side.value = "prompt"; });
|
||||
const SIDES: SegmentedOption<Side>[] = [
|
||||
{ value: "prompt", label: "Prompt" },
|
||||
{ value: "negative", label: "Negative" },
|
||||
];
|
||||
|
||||
const current = computed(() => (side.value === "negative" ? props.negativeTrace : props.trace));
|
||||
const currentReport = computed<RunReport | null>(() =>
|
||||
side.value === "negative" ? props.negativeReport : props.report);
|
||||
const steps = computed(() => (current.value ? ruleSteps(current.value, currentReport.value) : []));
|
||||
const changed = computed(() => steps.value.filter((s) => s.changed));
|
||||
/** Tags mode compares whole tags, so a rewritten tag reads as one old tag
|
||||
* out and one new tag in; text mode compares words. */
|
||||
const by = computed<"tags" | "words">(() => (current.value?.mode === "text" ? "words" : "tags"));
|
||||
/** Spacing and edge punctuation live inside a tag, so those two steps
|
||||
* compare words; the rest add, drop or rewrite whole tags. */
|
||||
function stepBy(rule: StepRule): "tags" | "words" {
|
||||
return rule === "whitespace" || rule === "punctuation" ? "words" : by.value;
|
||||
}
|
||||
/** Tags the negative shares with the prompt and kept (a drop shows as its
|
||||
* own step instead). */
|
||||
const overlap = computed(() => {
|
||||
const o = side.value === "negative" ? props.negativeReport?.prompt_overlap : undefined;
|
||||
return o && !o.dropped && o.tags.length ? o : null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
size="lg"
|
||||
title="Last run"
|
||||
accent="var(--wp-accent)"
|
||||
icon="filter"
|
||||
@update:open="(v: boolean) => emit('update:open', v)"
|
||||
>
|
||||
<div class="wp-cleaner-run" data-test="cleaner-run">
|
||||
<WpSegmented
|
||||
v-if="negativeTrace"
|
||||
v-model="side"
|
||||
class="wp-cleaner-run__switch"
|
||||
:options="SIDES"
|
||||
size="sm"
|
||||
aria-label="Which text"
|
||||
test-prefix="cleaner-run-side"
|
||||
/>
|
||||
<p v-if="!current" class="wp-cleaner-run__dim">Run the workflow to see what the cleaner changed.</p>
|
||||
<template v-else>
|
||||
<DiffView :before="current.input" :after="current.output" :by="by" layout="split" size="sm" />
|
||||
|
||||
<Banner v-if="overlap" tone="warn" size="sm">
|
||||
Also in the prompt:
|
||||
<span class="wp-cleaner-run__chips"><Chip v-for="t in overlap.tags" :key="t" tone="warn">{{ t }}</Chip></span>
|
||||
</Banner>
|
||||
|
||||
<div class="wp-cleaner-run__rules">
|
||||
<p v-if="!changed.length" class="wp-cleaner-run__dim">
|
||||
{{ steps.length ? "No rule changed this text." : "No rules were on for this text." }}
|
||||
</p>
|
||||
<Disclosure
|
||||
v-for="s in changed"
|
||||
:key="s.rule"
|
||||
size="sm"
|
||||
flush
|
||||
:trigger-attrs="{ 'data-test': `cleaner-run-rule-${s.rule}` }"
|
||||
>
|
||||
<template #summary>
|
||||
<span class="wp-cleaner-run__rule">{{ RULE_LABELS[s.rule] }}</span>
|
||||
<span class="wp-cleaner-run__what">{{ s.reasons.length
|
||||
? s.reasons.map((r) => `${r.item} ${r.verb} ${r.cause}`).join(" · ")
|
||||
: s.items.length ? s.items.join(", ") : s.summary }}</span>
|
||||
</template>
|
||||
<div class="wp-cleaner-run__step">
|
||||
<Banner v-if="s.errors.length" tone="danger" size="sm">
|
||||
Bad regex, skipped: {{ s.errors.join(", ") }}
|
||||
</Banner>
|
||||
<ul v-if="s.reasons.length" class="wp-cleaner-run__reasons">
|
||||
<li v-for="(r, i) in s.reasons" :key="i">
|
||||
<Chip tone="danger">{{ r.item }}</Chip>
|
||||
<span class="wp-cleaner-run__verb">{{ r.verb }}</span>
|
||||
<Chip tone="info">{{ r.cause }}</Chip>
|
||||
</li>
|
||||
</ul>
|
||||
<DiffView :before="s.before" :after="s.after" :by="stepBy(s.rule)" :marks="s.marks" size="sm" />
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<CopyButton v-if="current" :text="current.output" label="Copy cleaned text" show-label variant="secondary" size="sm" />
|
||||
<span class="wp-cleaner-run__fill" />
|
||||
<Button size="sm" @click="emit('update:open', false)">Close</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-cleaner-run { display: flex; flex-direction: column; gap: var(--wp-space-3, 12px); }
|
||||
.wp-cleaner-run__switch { align-self: flex-start; }
|
||||
.wp-cleaner-run__rules { display: flex; flex-direction: column; }
|
||||
.wp-cleaner-run__rule {
|
||||
flex: 0 0 120px;
|
||||
color: var(--wp-text-dim);
|
||||
text-align: left;
|
||||
}
|
||||
.wp-cleaner-run__what {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--wp-text);
|
||||
text-align: left;
|
||||
}
|
||||
.wp-cleaner-run__step { display: flex; flex-direction: column; gap: var(--wp-space-2, 8px); padding: var(--wp-space-1, 4px) 0 var(--wp-space-3, 12px) 26px; }
|
||||
.wp-cleaner-run__reasons { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||||
.wp-cleaner-run__reasons li { display: flex; align-items: center; gap: 6px; }
|
||||
.wp-cleaner-run__verb { color: var(--wp-text-dim); font-size: var(--wp-text-sm, 12px); }
|
||||
.wp-cleaner-run__chips { display: inline-flex; flex-wrap: wrap; gap: 4px; vertical-align: middle; }
|
||||
.wp-cleaner-run__dim { margin: 0; color: var(--wp-text-dim); font-size: var(--wp-text-sm, 12px); }
|
||||
.wp-cleaner-run__fill { flex: 1; }
|
||||
</style>
|
||||
@@ -179,14 +179,48 @@ describe("CleanerWidget — neg column (send-to-negative)", () => {
|
||||
});
|
||||
expect(w.find('[data-test="cleaner-neg-count"]').text()).toBe("negative 11");
|
||||
expect(w.find('[data-test="cleaner-last-run"]').text()).toContain("prompt: 2 duplicates, 1 blocklisted");
|
||||
expect(w.find('[data-test="cleaner-last-run-neg"]').text()).toBe(
|
||||
'negative: 3 duplicates · 1 tag also in prompt: "red"',
|
||||
);
|
||||
expect(w.find('[data-test="cleaner-last-run-neg"]').text()).toBe("negative: 3 duplicates");
|
||||
// Tags in both prompt and negative get their own amber line.
|
||||
const overlap = w.find('[data-test="cleaner-last-run-overlap"]');
|
||||
expect(overlap.text()).toBe('⚠ 1 tag also in prompt: "red"');
|
||||
expect(overlap.classes()).toContain("wp-cleaner__lastrun-line--overlap");
|
||||
});
|
||||
|
||||
it("no negative report = no last-run block and no negative count", () => {
|
||||
it("an overlap the toggle dropped is a plain line, not a warning", () => {
|
||||
const w = mount(CleanerWidget, {
|
||||
props: makeProps({}, {
|
||||
lastRunReport: { dedupe_exact: { dropped: [] } },
|
||||
negativeReport: { dedupe_exact: { dropped: [] }, prompt_overlap: { tags: ["red"], dropped: true } },
|
||||
}),
|
||||
});
|
||||
const overlap = w.find('[data-test="cleaner-last-run-overlap"]');
|
||||
expect(overlap.text()).toBe('1 tag also in prompt (dropped): "red"');
|
||||
expect(overlap.classes()).toContain("wp-cleaner__lastrun-line--dropped");
|
||||
});
|
||||
|
||||
it("no run yet = no last-run strip and no negative count", () => {
|
||||
const w = mount(CleanerWidget, { props: makeProps() });
|
||||
expect(w.find('[data-test="cleaner-last-run"]').exists()).toBe(false);
|
||||
expect(w.find('[data-test="cleaner-neg-count"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("a run without a negative still shows the strip, without the negative line", () => {
|
||||
const w = mount(CleanerWidget, {
|
||||
props: makeProps({}, {
|
||||
lastRunReport: { dedupe_exact: { dropped: ["a"] } },
|
||||
lastRunTrace: {
|
||||
input: "a, a, b", output: "a, b", mode: "tags",
|
||||
steps: [{ rule: "whitespace" }, { rule: "dedupe_exact", text: "a, b" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(w.find('[data-test="cleaner-last-run"]').text()).toContain("prompt: 1 rule changed it · 1 duplicate");
|
||||
expect(w.find('[data-test="cleaner-last-run-neg"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("clicking the last-run strip asks to open the Last run window", async () => {
|
||||
const w = mount(CleanerWidget, { props: makeProps({}, { lastRunReport: {} }) });
|
||||
await w.find('[data-test="cleaner-last-run"]').trigger("click");
|
||||
expect(w.emitted("open-last-run")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "./intensity";
|
||||
import type {
|
||||
CleanerNodeConfig,
|
||||
CleanerTrace,
|
||||
Intensity,
|
||||
Mode,
|
||||
NegativeRunReport,
|
||||
@@ -49,7 +50,9 @@ const props = withDefaults(defineProps<{
|
||||
negativeWordCount?: number | null;
|
||||
/** The node's `blocklist` text input is wired: its entries join the list. */
|
||||
blocklistWired?: boolean;
|
||||
}>(), { nodeMode: 0, negativeReport: null, negativeWordCount: null, blocklistWired: false });
|
||||
/** The last run's trace: how many rules changed the prompt. */
|
||||
lastRunTrace?: CleanerTrace | null;
|
||||
}>(), { nodeMode: 0, negativeReport: null, negativeWordCount: null, blocklistWired: false, lastRunTrace: null });
|
||||
|
||||
/** The blocklist turns itself on when it has entries; a wired input counts. */
|
||||
const blocklistFilled = computed(() =>
|
||||
@@ -60,6 +63,7 @@ const isSkipped = computed(() => props.nodeMode === 2 || props.nodeMode === 4);
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [next: CleanerNodeConfig];
|
||||
"open-blocklist": [];
|
||||
"open-last-run": [];
|
||||
}>();
|
||||
|
||||
const ALL_RULES: {
|
||||
@@ -76,13 +80,13 @@ const ALL_RULES: {
|
||||
id: "whitespace",
|
||||
label: "whitespace",
|
||||
statKey: "fixed",
|
||||
tooltip: "Collapse runs of spaces, trim outer whitespace, collapse double commas, drop leading/trailing commas, normalize comma-space.",
|
||||
tooltip: "Collapse runs of spaces, trim outer whitespace, collapse double commas and empty slots ('a, , b'), drop the space before a comma, drop leading/trailing commas, normalize comma-space.",
|
||||
},
|
||||
{
|
||||
id: "punctuation",
|
||||
label: "punctuation",
|
||||
statKey: "stripped",
|
||||
tooltip: "Drop tags that are only punctuation (e.g. lone '.'). Strip leading/trailing punctuation from each tag (tags mode) or from the whole string (text mode).",
|
||||
tooltip: "Drop tags that are only punctuation (e.g. lone '.'). Strip leading/trailing punctuation from each tag (tags mode) or from the whole string (text mode, keeping a closing . ! ?). Emoticon tags like :3 and -_- stay.",
|
||||
},
|
||||
{
|
||||
id: "empty_groups",
|
||||
@@ -106,14 +110,14 @@ const ALL_RULES: {
|
||||
id: "dedupe_exact",
|
||||
label: "tag dedupe",
|
||||
statKey: "dropped",
|
||||
tooltip: "Drop later occurrences of an identical tag (case-insensitive, leftmost wins). Tags mode only.",
|
||||
tooltip: "Drop later occurrences of an identical tag (case-insensitive, leftmost wins). BREAK and AND are kept. Tags mode only.",
|
||||
tagsOnly: true,
|
||||
},
|
||||
{
|
||||
id: "fuzzy_dedupe",
|
||||
label: "fuzzy dedupe",
|
||||
statKey: "dropped",
|
||||
tooltip: "Drop near-duplicate tags via Levenshtein similarity ≥0.9 (e.g. 'pixie cut' / 'pixie cuts'). Tags mode only.",
|
||||
tooltip: "Drop near-duplicate tags: the same words up to plurals and a leading a/an/the ('a knight' / 'knight' / 'knights'), or ≥90% alike letter by letter ('detailed backround'). Weight variants count too. Tags mode only.",
|
||||
tagsOnly: true,
|
||||
},
|
||||
{
|
||||
@@ -278,12 +282,17 @@ function plural(n: number, word: string): string {
|
||||
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** "last run" lines, shown once a run cleaned a negative. */
|
||||
/** "last run" lines, shown after every run; the strip opens the Last run
|
||||
* window. */
|
||||
const promptRunLine = computed(() => {
|
||||
const parts = [plural(droppedCount(props.lastRunReport, ["dedupe_exact", "fuzzy_dedupe"]), "duplicate")];
|
||||
const blocked = droppedCount(props.lastRunReport, ["blocklist"]);
|
||||
if (blocked > 0) parts.push(`${blocked} blocklisted`);
|
||||
return `prompt: ${parts.join(", ")}`;
|
||||
const steps = props.lastRunTrace?.steps;
|
||||
const head = steps
|
||||
? `${plural(steps.filter((s) => s.text !== undefined).length, "rule")} changed it · `
|
||||
: "";
|
||||
return `prompt: ${head}${parts.join(", ")}`;
|
||||
});
|
||||
const negativeRunLine = computed(() => {
|
||||
const r = props.negativeReport;
|
||||
@@ -291,15 +300,20 @@ const negativeRunLine = computed(() => {
|
||||
const parts = [plural(droppedCount(r, ["dedupe_exact", "fuzzy_dedupe"]), "duplicate")];
|
||||
const blocked = droppedCount(r, ["blocklist"]);
|
||||
if (blocked > 0) parts[0] += `, ${blocked} blocklisted`;
|
||||
const overlap = r.prompt_overlap;
|
||||
if (overlap && overlap.tags.length > 0) {
|
||||
const quoted = overlap.tags.map((t) => `"${t}"`).join(", ");
|
||||
parts.push(
|
||||
`${plural(overlap.tags.length, "tag")} also in prompt${overlap.dropped ? " (dropped)" : ""}: ${quoted}`,
|
||||
);
|
||||
}
|
||||
return `negative: ${parts.join(" · ")}`;
|
||||
});
|
||||
/** Tags the negative shares with the prompt: shown apart from the red
|
||||
* negative line, in amber while they are still in both (the model is told
|
||||
* to draw them and not draw them), plain once the toggle dropped them. */
|
||||
const negativeOverlap = computed(() => {
|
||||
const overlap = props.negativeReport?.prompt_overlap;
|
||||
if (!overlap || overlap.tags.length === 0) return null;
|
||||
const quoted = overlap.tags.map((t) => `"${t}"`).join(", ");
|
||||
return {
|
||||
text: `${plural(overlap.tags.length, "tag")} also in prompt${overlap.dropped ? " (dropped)" : ""}: ${quoted}`,
|
||||
dropped: overlap.dropped,
|
||||
};
|
||||
});
|
||||
|
||||
function ruleStat(rid: RuleId): string {
|
||||
const stats = props.lastRunReport?.[rid];
|
||||
@@ -323,7 +337,7 @@ function isOverridden(rid: RuleId): boolean {
|
||||
|
||||
<template>
|
||||
<div :class="['wp-cleaner', { 'wp-cleaner--skipped': isSkipped }]">
|
||||
<header class="wp-cleaner__head">
|
||||
<header data-wp-part="mode" data-wp-part-label="Mode and counts" class="wp-cleaner__head">
|
||||
<WpSegmented
|
||||
:model-value="modelValue.mode"
|
||||
:options="MODE_OPTIONS"
|
||||
@@ -342,7 +356,7 @@ function isOverridden(rid: RuleId): boolean {
|
||||
>negative {{ negativeWordCount }}</span></template></span>
|
||||
</header>
|
||||
|
||||
<section class="wp-cleaner__section">
|
||||
<section data-wp-part="intensity" data-wp-part-label="Intensity" class="wp-cleaner__section">
|
||||
<div class="wp-cleaner__section-head">
|
||||
<span class="wp-cleaner__section-label">INTENSITY</span>
|
||||
<span
|
||||
@@ -362,14 +376,14 @@ function isOverridden(rid: RuleId): boolean {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="wp-cleaner__section">
|
||||
<section data-wp-part="rules" data-wp-part-label="All rules" data-wp-part-section="Rules" class="wp-cleaner__section">
|
||||
<div class="wp-cleaner__rules-head">
|
||||
<span class="wp-cleaner__section-label">RULES</span>
|
||||
<span class="wp-cleaner__col-label">prompt</span>
|
||||
<span class="wp-cleaner__col-label wp-cleaner__col-label--neg">neg</span>
|
||||
</div>
|
||||
<div class="wp-cleaner__rules">
|
||||
<div v-for="rule in ALL_RULES" :key="rule.id" class="wp-cleaner__rule-row">
|
||||
<div :data-wp-part="`rule:${rule.id}`" :data-wp-part-label="rule.label" data-wp-part-section="Rules" v-for="rule in ALL_RULES" :key="rule.id" class="wp-cleaner__rule-row">
|
||||
<div
|
||||
:class="['wp-cleaner__rule', {
|
||||
'is-on': effective.has(rule.id) && !isRuleDisabled(rule.id),
|
||||
@@ -440,13 +454,31 @@ function isOverridden(rid: RuleId): boolean {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="negativeReport" class="wp-cleaner__lastrun" data-test="cleaner-last-run">
|
||||
<span class="wp-cleaner__section-label">LAST RUN</span>
|
||||
<span class="wp-cleaner__lastrun-line">{{ promptRunLine }}</span>
|
||||
<span class="wp-cleaner__lastrun-line wp-cleaner__lastrun-line--neg" data-test="cleaner-last-run-neg">{{ negativeRunLine }}</span>
|
||||
<div v-if="lastRunReport" data-wp-part="last-run" data-wp-part-label="Last run" class="wp-cleaner__lastrun-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
block
|
||||
class="wp-cleaner__lastrun"
|
||||
data-test="cleaner-last-run"
|
||||
hint="Open the last run: before and after, what each rule changed and why"
|
||||
@click="emit('open-last-run')"
|
||||
>
|
||||
<span class="wp-cleaner__lastrun-body">
|
||||
<span class="wp-cleaner__section-label">LAST RUN</span>
|
||||
<span class="wp-cleaner__lastrun-line">{{ promptRunLine }}</span>
|
||||
<span v-if="negativeReport" class="wp-cleaner__lastrun-line wp-cleaner__lastrun-line--neg" data-test="cleaner-last-run-neg">{{ negativeRunLine }}</span>
|
||||
<span
|
||||
v-if="negativeOverlap"
|
||||
class="wp-cleaner__lastrun-line"
|
||||
:class="negativeOverlap.dropped ? 'wp-cleaner__lastrun-line--dropped' : 'wp-cleaner__lastrun-line--overlap'"
|
||||
data-test="cleaner-last-run-overlap"
|
||||
>{{ negativeOverlap.dropped ? "" : "⚠ " }}{{ negativeOverlap.text }}</span>
|
||||
</span>
|
||||
<i class="pi pi-chevron-right wp-cleaner__blocklist-chev" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="wp-cleaner__blocklist" data-wp-socket="blocklist">
|
||||
<div data-wp-part="blocklist" data-wp-part-label="Blocklist button" class="wp-cleaner__blocklist" data-wp-socket="blocklist">
|
||||
<Button
|
||||
size="sm"
|
||||
block
|
||||
@@ -564,19 +596,36 @@ function isOverridden(rid: RuleId): boolean {
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
.wp-cleaner__counter-neg { color: var(--wp-danger); }
|
||||
.wp-cleaner__lastrun {
|
||||
.wp-cleaner__lastrun-wrap { display: flex; }
|
||||
/* The strip is a kit Button that grows to its lines. */
|
||||
.wp-btn.wp-cleaner__lastrun {
|
||||
height: auto;
|
||||
min-height: var(--wp-control-h-sm, 24px);
|
||||
padding: 6px 8px;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
}
|
||||
.wp-btn.wp-cleaner__lastrun :deep(.wp-btn__label) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-cleaner__lastrun-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: min(3px, var(--wp-radius-cap, 999px));
|
||||
min-width: 0;
|
||||
font: 10px/1.5 var(--wp-font-mono, monospace);
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
white-space: normal;
|
||||
}
|
||||
.wp-cleaner__lastrun-line { word-break: break-word; }
|
||||
.wp-cleaner__lastrun-line--neg { color: var(--wp-danger); }
|
||||
.wp-cleaner__lastrun-line--overlap { color: var(--wp-amber); }
|
||||
.wp-cleaner__lastrun-line--dropped { color: var(--wp-text-muted); }
|
||||
.wp-cleaner__rule {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { entryHit, ruleSteps, tagSpans } from "./run-steps";
|
||||
import type { CleanerTrace } from "./types";
|
||||
|
||||
describe("ruleSteps", () => {
|
||||
it("walks the trace in order and carries each step's before and after", () => {
|
||||
const trace: CleanerTrace = {
|
||||
input: "a, a, b",
|
||||
output: "a, b",
|
||||
mode: "tags",
|
||||
steps: [
|
||||
{ rule: "empty_groups" },
|
||||
{ rule: "whitespace", text: "a, a, b" },
|
||||
{ rule: "dedupe_exact", text: "a, b" },
|
||||
],
|
||||
};
|
||||
const steps = ruleSteps(trace, {
|
||||
empty_groups: { removed: 0 },
|
||||
whitespace: { fixed: 1 },
|
||||
dedupe_exact: { dropped: ["a"], matched: ["a"] },
|
||||
});
|
||||
expect(steps.map((s) => [s.rule, s.changed, s.summary])).toEqual([
|
||||
["empty_groups", false, "no change"],
|
||||
["whitespace", true, "fixed 1 spacing or comma slip"],
|
||||
["dedupe_exact", true, "dropped 1 repeated tag"],
|
||||
]);
|
||||
expect(steps[2].before).toBe("a, a, b");
|
||||
expect(steps[2].after).toBe("a, b");
|
||||
expect(steps[2].reasons).toEqual([{ item: "a", verb: "repeats", cause: "a" }]);
|
||||
// The kept (first) "a" is marked, not the dropped one.
|
||||
expect(steps[2].marks).toEqual([{ start: 0, end: 1, label: 'kept: "a" repeats it' }]);
|
||||
});
|
||||
|
||||
it("says a plural fuzzy drop is another form of the kept tag", () => {
|
||||
const trace: CleanerTrace = {
|
||||
input: "knight, knights",
|
||||
output: "knight",
|
||||
steps: [{ rule: "fuzzy_dedupe", text: "knight" }],
|
||||
};
|
||||
const [fz] = ruleSteps(trace, {
|
||||
fuzzy_dedupe: { dropped: ["knights"], matched: ["knight"], scores: [0.86], kinds: ["form"] },
|
||||
});
|
||||
expect(fz.reasons).toEqual([{ item: "knights", verb: "is another form of", cause: "knight" }]);
|
||||
});
|
||||
|
||||
it("explains fuzzy drops with their closeness and blocklist drops with the entry", () => {
|
||||
const before = "pixie cut, pixie cuts, signature watermark";
|
||||
const trace: CleanerTrace = {
|
||||
input: before,
|
||||
output: "pixie cut",
|
||||
steps: [
|
||||
{ rule: "fuzzy_dedupe", text: "pixie cut, signature watermark" },
|
||||
{ rule: "blocklist", text: "pixie cut" },
|
||||
],
|
||||
};
|
||||
const [fz, bl] = ruleSteps(trace, {
|
||||
fuzzy_dedupe: { dropped: ["pixie cuts"], matched: ["pixie cut"], scores: [0.9] },
|
||||
blocklist: { dropped: ["signature watermark"], matched: ["watermark"], errors: [] },
|
||||
});
|
||||
expect(fz.reasons).toEqual([{ item: "pixie cuts", verb: "is 90% like", cause: "pixie cut" }]);
|
||||
expect(fz.marks[0]).toMatchObject({ start: 0, end: 9 });
|
||||
expect(bl.reasons).toEqual([{ item: "signature watermark", verb: "matched", cause: "watermark" }]);
|
||||
const text = "pixie cut, signature watermark";
|
||||
expect(text.slice(bl.marks[0].start, bl.marks[0].end)).toBe("watermark");
|
||||
});
|
||||
|
||||
it("names the negative's prompt-overlap drop", () => {
|
||||
const [step] = ruleSteps(
|
||||
{ input: "lowres, red", output: "lowres", steps: [{ rule: "prompt_overlap", text: "lowres" }] },
|
||||
{ prompt_overlap: { tags: ["red"], dropped: true } },
|
||||
);
|
||||
expect(step.summary).toBe("dropped 1 tag the prompt also has");
|
||||
expect(step.reasons).toEqual([{ item: "red", verb: "is also in", cause: "the prompt" }]);
|
||||
});
|
||||
|
||||
it("works without a report and without stats it doesn't know", () => {
|
||||
const steps = ruleSteps({ input: "a", output: "b", steps: [{ rule: "punctuation", text: "b" }] }, null);
|
||||
expect(steps[0]).toMatchObject({ changed: true, summary: "trimmed 0 stray punctuation marks", reasons: [], marks: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagSpans / entryHit", () => {
|
||||
it("finds trimmed tag spans", () => {
|
||||
expect(tagSpans(" a ,b,, c")).toEqual([
|
||||
{ start: 1, end: 2, tag: "a" },
|
||||
{ start: 4, end: 5, tag: "b" },
|
||||
{ start: 8, end: 9, tag: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches an entry as a regex, a word or a substring, and skips bad regexes", () => {
|
||||
const t = "bad hands, x";
|
||||
expect(entryHit(t, { start: 0, end: 9 }, "bad\\s+\\w+")).toEqual({ start: 0, end: 9 });
|
||||
expect(entryHit(t, { start: 0, end: 9 }, "HANDS")).toEqual({ start: 4, end: 9 });
|
||||
expect(entryHit(t, { start: 0, end: 9 }, "(")).toBeNull();
|
||||
expect(entryHit(t, { start: 0, end: 9 }, "zzz")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Turns a Cleaner run's trace + report into one row per rule for the Last run
|
||||
* window: the text before and after that rule, a plain line saying what it
|
||||
* did, and why: for each tag a dedupe, fuzzy dedupe or blocklist dropped, the
|
||||
* tag it repeats, the tag it is close to or the entry it matched, plus where
|
||||
* that cause sits in the text (marked in the diff).
|
||||
*/
|
||||
import type { DiffMark } from "@/kit/diff";
|
||||
import type { CleanerTrace, NegativeRunReport, RunReport, StepRule } from "./types";
|
||||
|
||||
export const RULE_LABELS: Record<StepRule, string> = {
|
||||
empty_groups: "empty groups",
|
||||
merge_weights: "merge weights",
|
||||
lora_spacing: "LoRA spacing",
|
||||
whitespace: "whitespace",
|
||||
punctuation: "punctuation",
|
||||
dedupe_exact: "tag dedupe",
|
||||
fuzzy_dedupe: "fuzzy dedupe",
|
||||
blocklist: "blocklist",
|
||||
prompt_overlap: "also in prompt",
|
||||
};
|
||||
|
||||
export interface RuleReason {
|
||||
item: string;
|
||||
/** "repeats", "is 90% like", "matched", "is also in". */
|
||||
verb: string;
|
||||
cause: string;
|
||||
}
|
||||
|
||||
export interface RuleStep {
|
||||
rule: StepRule;
|
||||
before: string;
|
||||
after: string;
|
||||
changed: boolean;
|
||||
summary: string;
|
||||
/** Tags or entries the rule dropped, when it lists them. */
|
||||
items: string[];
|
||||
/** Regex entries that didn't compile (blocklist). */
|
||||
errors: string[];
|
||||
/** Why each dropped item went. */
|
||||
reasons: RuleReason[];
|
||||
/** Where those causes sit in `before`, for the diff's marks. */
|
||||
marks: DiffMark[];
|
||||
}
|
||||
|
||||
function strings(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
||||
}
|
||||
function count(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
return Array.isArray(v) ? v.length : 0;
|
||||
}
|
||||
function n(k: number, one: string, many = `${one}s`): string {
|
||||
return `${k} ${k === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
function summarize(rule: StepRule, stats: Record<string, unknown>): string {
|
||||
switch (rule) {
|
||||
case "whitespace": return `fixed ${n(count(stats.fixed), "spacing or comma slip")}`;
|
||||
case "punctuation": return `trimmed ${n(count(stats.stripped), "stray punctuation mark")}`;
|
||||
case "empty_groups": return `removed ${n(count(stats.removed), "empty bracket group")}`;
|
||||
case "merge_weights": return `merged ${n(count(stats.merged), "nested weight")}`;
|
||||
case "lora_spacing": return `tidied ${n(count(stats.tidied), "LoRA tag")}`;
|
||||
case "dedupe_exact": return `dropped ${n(count(stats.dropped), "repeated tag")}`;
|
||||
case "fuzzy_dedupe": return `dropped ${n(count(stats.dropped), "near-duplicate tag")}`;
|
||||
case "blocklist": return `dropped ${n(count(stats.dropped), "blocked tag")}`;
|
||||
case "prompt_overlap": return `dropped ${n(count(stats.tags), "tag")} the prompt also has`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Each comma-separated tag's trimmed span in `text`. */
|
||||
export function tagSpans(text: string): { start: number; end: number; tag: string }[] {
|
||||
const out: { start: number; end: number; tag: string }[] = [];
|
||||
for (const m of text.matchAll(/[^,]+/g)) {
|
||||
const raw = m[0];
|
||||
const tag = raw.trim();
|
||||
if (!tag) continue;
|
||||
const start = (m.index ?? 0) + raw.length - raw.trimStart().length;
|
||||
out.push({ start, end: start + tag.length, tag });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeRe(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Where blocklist `entry` hits inside `span` of `text`: as a regex first
|
||||
* (regex lists), else as a plain case-insensitive word or substring. */
|
||||
export function entryHit(
|
||||
text: string,
|
||||
span: { start: number; end: number },
|
||||
entry: string,
|
||||
): { start: number; end: number } | null {
|
||||
const inside = text.slice(span.start, span.end);
|
||||
for (const src of [entry, `\\b${escapeRe(entry)}\\b`, escapeRe(entry)]) {
|
||||
let re: RegExp;
|
||||
try { re = new RegExp(src, "i"); } catch { continue; }
|
||||
const m = re.exec(inside);
|
||||
if (m && m[0]) return { start: span.start + m.index, end: span.start + m.index + m[0].length };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function explain(rule: StepRule, stats: Record<string, unknown>, before: string): Pick<RuleStep, "reasons" | "marks"> {
|
||||
const reasons: RuleReason[] = [];
|
||||
const marks: DiffMark[] = [];
|
||||
if (rule === "prompt_overlap") {
|
||||
for (const t of strings(stats.tags)) reasons.push({ item: t, verb: "is also in", cause: "the prompt" });
|
||||
return { reasons, marks };
|
||||
}
|
||||
const dropped = strings(stats.dropped);
|
||||
const matched = strings(stats.matched);
|
||||
if (!matched.length) return { reasons, marks };
|
||||
const scores = Array.isArray(stats.scores) ? stats.scores : [];
|
||||
// Fuzzy dedupe says why each drop went: "form" (same words once
|
||||
// singular, `knight` / `knights`) or "close" (the similarity score).
|
||||
const kinds = strings(stats.kinds);
|
||||
const spans = tagSpans(before);
|
||||
const usedDrops = new Set<number>();
|
||||
dropped.forEach((item, i) => {
|
||||
const cause = matched[i];
|
||||
if (cause === undefined) return;
|
||||
if (rule === "blocklist") {
|
||||
reasons.push({ item, verb: "matched", cause });
|
||||
const at = spans.findIndex((s, k) => !usedDrops.has(k) && s.tag === item);
|
||||
const span = spans[at];
|
||||
if (!span) return;
|
||||
usedDrops.add(at);
|
||||
const hit = entryHit(before, span, cause);
|
||||
if (hit) marks.push({ ...hit, label: `blocklist entry "${cause}"` });
|
||||
return;
|
||||
}
|
||||
const raw: unknown = scores[i];
|
||||
const score = typeof raw === "number" ? Math.round(raw * 100) : null;
|
||||
const verb = rule !== "fuzzy_dedupe" ? "repeats"
|
||||
: kinds[i] === "form" ? "is another form of"
|
||||
: score !== null && score < 100 ? `is ${score}% like` : "repeats";
|
||||
reasons.push({ item, verb, cause });
|
||||
const kept = spans.find((s) => s.tag === cause);
|
||||
if (kept && !marks.some((m) => m.start === kept.start)) {
|
||||
marks.push({ start: kept.start, end: kept.end, label: `kept: "${item}" ${verb} it` });
|
||||
}
|
||||
});
|
||||
return { reasons, marks };
|
||||
}
|
||||
|
||||
/** One row per trace step, in run order. */
|
||||
export function ruleSteps(trace: CleanerTrace, report: RunReport | NegativeRunReport | null): RuleStep[] {
|
||||
const out: RuleStep[] = [];
|
||||
let text = trace.input;
|
||||
for (const s of trace.steps) {
|
||||
const after = s.text ?? text;
|
||||
const stats = ((report as Record<string, unknown> | null)?.[s.rule] ?? {}) as Record<string, unknown>;
|
||||
const changed = after !== text;
|
||||
out.push({
|
||||
rule: s.rule,
|
||||
before: text,
|
||||
after,
|
||||
changed,
|
||||
summary: changed ? summarize(s.rule, stats) : "no change",
|
||||
items: strings(stats.dropped),
|
||||
errors: strings(stats.errors),
|
||||
...(changed ? explain(s.rule, stats, text) : { reasons: [], marks: [] }),
|
||||
});
|
||||
text = after;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -47,3 +47,19 @@ export function emptyCleanerConfig(): CleanerNodeConfig {
|
||||
blocklist: { kind: "list", entries: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/** A trace step's rule: a cleaner rule, or the negative's drop of tags the
|
||||
* prompt also has. */
|
||||
export type StepRule = RuleId | "prompt_overlap";
|
||||
|
||||
/** `wp_cleaner_trace` / `wp_cleaner_negative_trace` (engine/cleaner/pipeline.py
|
||||
* `_trace`): the text before the first rule, the text after the last, the
|
||||
* mode it ran in, and each rule that ran in run order with its text
|
||||
* afterwards (`text` left out when the rule changed nothing). Feeds the
|
||||
* Last run window. */
|
||||
export interface CleanerTrace {
|
||||
input: string;
|
||||
output: string;
|
||||
mode?: Mode;
|
||||
steps: { rule: StepRule; text?: string }[];
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ function toggleTotalInternal(): void {
|
||||
'wp-loop--bypass-on': modelValue.bypass,
|
||||
}"
|
||||
>
|
||||
<div class="wp-loop__section">
|
||||
<div data-wp-part="strategy" data-wp-part-label="Strategy" class="wp-loop__section">
|
||||
<div class="wp-loop__label">strategy</div>
|
||||
<WpSegmented
|
||||
:model-value="modelValue.strategy"
|
||||
@@ -232,7 +232,7 @@ function toggleTotalInternal(): void {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<Button data-wp-part="seeds-button" data-wp-part-label="Per-iteration seeds button"
|
||||
size="sm"
|
||||
block
|
||||
icon="pi-list"
|
||||
@@ -247,7 +247,7 @@ function toggleTotalInternal(): void {
|
||||
<i class="pi pi-chevron-right wp-loop__seedbtn-chev" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
<Button data-wp-part="sweep-button" data-wp-part-label="Sweep combinations button"
|
||||
size="sm"
|
||||
block
|
||||
icon="pi-th-large"
|
||||
@@ -265,7 +265,7 @@ function toggleTotalInternal(): void {
|
||||
|
||||
<!-- `base` is not a frame: it has no seed to lock and nothing to bypass,
|
||||
so it takes no modifiers. -->
|
||||
<FrameChips
|
||||
<FrameChips data-wp-part="frames" data-wp-part-label="Edit frame"
|
||||
v-model:collapsed="chipsCollapsed"
|
||||
label="edit frame"
|
||||
:count="Math.max(1, count ?? 1)"
|
||||
@@ -281,7 +281,7 @@ function toggleTotalInternal(): void {
|
||||
/>
|
||||
|
||||
|
||||
<div
|
||||
<div data-wp-part="override-seed" data-wp-part-label="Override Context seed"
|
||||
v-tooltip="OVERRIDE_SEED_TOOLTIP"
|
||||
v-toggle-row class="wp-loop__row"
|
||||
>
|
||||
@@ -294,7 +294,7 @@ function toggleTotalInternal(): void {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<div data-wp-part="iteration-var" data-wp-part-label="Iteration var"
|
||||
class="wp-loop__section"
|
||||
:data-wp-vars="`${modelValue.iteration_var_name} ${modelValue.iteration_var_name}_total`"
|
||||
>
|
||||
@@ -336,7 +336,7 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="valuesWired" class="wp-loop__values" data-wp-socket="values" data-test="loop-values-note" :data-wp-vars="`${modelValue.iteration_var_name}_value`">
|
||||
<div data-wp-part="iteration-var" data-wp-part-label="Iteration var" v-if="valuesWired" class="wp-loop__values" data-wp-socket="values" data-test="loop-values-note" :data-wp-vars="`${modelValue.iteration_var_name}_value`">
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
<span>
|
||||
One frame per line of the values input<template v-if="valuesCount != null"> ({{ valuesCount }} on the last run)</template>,
|
||||
@@ -344,7 +344,7 @@ function toggleTotalInternal(): void {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-toggle-row class="wp-loop__row">
|
||||
<div data-wp-part="bypass" data-wp-part-label="Bypass loop" v-toggle-row class="wp-loop__row">
|
||||
<span class="wp-loop__row-label">Bypass loop</span>
|
||||
<WpSwitch
|
||||
data-test="loop-bypass-toggle"
|
||||
|
||||
@@ -1966,7 +1966,7 @@ describe("ContextWidget drag-drop overhaul", () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("dragover top half adds wp-gap-before (gap metaphor)", async () => {
|
||||
it("dragover top half adds wp-rowdrag--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-gap-before");
|
||||
expect(cards[1].classes()).not.toContain("wp-gap-after");
|
||||
expect(cards[1].classes()).toContain("wp-rowdrag--before");
|
||||
expect(cards[1].classes()).not.toContain("wp-rowdrag--after");
|
||||
|
||||
await cards[0].trigger("dragend");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("dragover bottom half canonicalises to wp-gap-before on next row", async () => {
|
||||
it("dragover bottom half canonicalises to wp-rowdrag--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-gap-before");
|
||||
expect(cards[1].classes()).not.toContain("wp-gap-after");
|
||||
expect(cards[1].classes()).not.toContain("wp-gap-before");
|
||||
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");
|
||||
|
||||
await cards[0].trigger("dragend");
|
||||
wrapper.unmount();
|
||||
@@ -2335,9 +2335,13 @@ describe("ContextWidget bundle drag/drop regressions (Batch 2)", () => {
|
||||
(body.element as HTMLElement).getBoundingClientRect = rectOf(30, 0);
|
||||
}
|
||||
|
||||
// Drag std → bundle header bottom half (clientY in bottom 15px window).
|
||||
// Drag std → the middle of the closed bundle's header (its top and
|
||||
// bottom 30% drop beside it).
|
||||
await stdCard.trigger("dragstart", { dataTransfer: makeDataTransfer() });
|
||||
await header.trigger("dragover", { clientY: 22, 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("drop", { dataTransfer: makeDataTransfer() });
|
||||
await flushPromises();
|
||||
|
||||
@@ -2351,6 +2355,78 @@ 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.)
|
||||
@@ -2750,3 +2826,36 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,8 @@ 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, isHiddenInCollapsedBundle } from "./bundles/bundle-alerts";
|
||||
import { bundleAlerts, bundleDescendants, collapsedAround, isHiddenInCollapsedBundle } from "./bundles/bundle-alerts";
|
||||
import { revealTarget } from "../../widgets/flash-target";
|
||||
import ModuleRow from "./ModuleRow.vue";
|
||||
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
|
||||
import {
|
||||
@@ -26,7 +27,6 @@ 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,14 +730,28 @@ 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
|
||||
@@ -745,7 +759,7 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
the canvas, NOT editable. Cross-node UI (constraint reach pick-list,
|
||||
pair popovers) shows the same codename to say WHICH node a target
|
||||
lives in. Sits above the toolbar/hero so it shows in every state. -->
|
||||
<div class="wp-node-id">
|
||||
<div class="wp-node-id" data-wp-part="codename" data-wp-part-label="Node name">
|
||||
<span class="wp-node-id__tag">node</span>
|
||||
<span
|
||||
class="wp-node-id__chip wp-node-id__chip--fixed"
|
||||
@@ -780,6 +794,8 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
>
|
||||
<!-- Toolbar: module count + bulk-drift refresh + collapse/expand/toggle-all. -->
|
||||
<ContextToolbar
|
||||
data-wp-part="toolbar"
|
||||
data-wp-part-label="Toolbar"
|
||||
:enabled-count="enabledCount"
|
||||
:total-count="totalCount"
|
||||
:drifted-count="driftedCount"
|
||||
@@ -797,19 +813,21 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
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'"
|
||||
:data-wp-part="`bundle:${item.key}`"
|
||||
:data-wp-part-label="`${item.bundle!.name ?? 'Bundle'} (bundle)`"
|
||||
data-wp-part-section="Modules"
|
||||
:bundle="item.bundle!"
|
||||
:children="item.children!"
|
||||
:nested="false"
|
||||
/>
|
||||
<ModuleRow
|
||||
v-else
|
||||
:data-wp-part="`module:${item.module!._uid}`"
|
||||
:data-wp-part-label="item.module!.meta.name || 'Module'"
|
||||
data-wp-part-section="Modules"
|
||||
:module="item.module!"
|
||||
:idx="item.idx!"
|
||||
:data-uid="item.module!._uid"
|
||||
@@ -824,7 +842,7 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
reusable scaffolds users typically wanted that button for.
|
||||
SPA library still reachable via the right-click context
|
||||
menu on any module. -->
|
||||
<ContextAddActions class="wp-w-footer" @module="openPicker" @bundle="openBundlePicker" />
|
||||
<ContextAddActions data-wp-part="add" data-wp-part-label="Add module / bundle" class="wp-w-footer" @module="openPicker" @bundle="openBundlePicker" />
|
||||
</div>
|
||||
|
||||
<!-- Empty-state hero — shown when modules is empty and there's no
|
||||
@@ -851,7 +869,7 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
Add a wildcard, fixed values pack, combine, derivation or constraint to start
|
||||
building this context.
|
||||
</div>
|
||||
<ContextAddActions class="wp-empty-hero-actions" @module="openPicker" @bundle="openBundlePicker" />
|
||||
<ContextAddActions data-wp-part="add" data-wp-part-label="Add module / bundle" class="wp-empty-hero-actions" @module="openPicker" @bundle="openBundlePicker" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -981,5 +999,6 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import ModuleEditModal from "./ModuleEditModal.vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
// Real teleports: the confirm a window raises must land on top of that window. Every kit modal sits
|
||||
// at the same z-index, so the one later in <body> wins.
|
||||
const wildcard: ModuleEntry = {
|
||||
id: "ab12cd34",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "outfit" },
|
||||
entries: [],
|
||||
payload: { options: [{ id: "o1", value: "red", weight: 1 }, { id: "o2", value: "blue", weight: 2 }] },
|
||||
payload_hash: "deadbeefcafef00d",
|
||||
instance: { option_weights: { o1: 5 } },
|
||||
};
|
||||
|
||||
let wrapper: VueWrapper | null = null;
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("ModuleEditModal confirm stacking", () => {
|
||||
it("puts the Reset overrides confirm above the editor that opened it", async () => {
|
||||
// The Context mounts the editor hidden and shows it later, so the editor's window is teleported
|
||||
// after the editor's own (always mounted) confirm.
|
||||
wrapper = mount(ModuleEditModal, { attachTo: document.body, props: { visible: false, module: wildcard } });
|
||||
await nextTick();
|
||||
await wrapper.setProps({ visible: true });
|
||||
await nextTick();
|
||||
const reset = document.querySelector<HTMLElement>('[data-test="wcm-clear-all"]');
|
||||
expect(reset).not.toBeNull();
|
||||
reset?.click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const overlays = [...document.querySelectorAll(".wp-modal-overlay")];
|
||||
expect(overlays.length).toBeGreaterThanOrEqual(2);
|
||||
expect(overlays[overlays.length - 1]?.textContent).toContain("Reset overrides?");
|
||||
});
|
||||
});
|
||||
@@ -549,8 +549,12 @@ function cancel() {
|
||||
|
||||
<!-- Themed confirm dialog — replaces window.confirm() for any
|
||||
prompt the modal raises (reset overrides, reset to library,
|
||||
save to library). Single-instance: only one prompt at a time. -->
|
||||
save to library). Single-instance: only one prompt at a time.
|
||||
Mounted only while open: every kit modal sits at the same z-index, so
|
||||
the later one in <body> is on top, and an always-mounted confirm
|
||||
teleported BEFORE the editor window (shown later) opened under it. -->
|
||||
<ConfirmModal
|
||||
v-if="confirmDialog !== null"
|
||||
:open="confirmDialog !== null"
|
||||
:title="confirmDialog?.title ?? ''"
|
||||
:body="confirmDialog?.body ?? ''"
|
||||
@@ -565,6 +569,7 @@ function cancel() {
|
||||
confirm flows that lived in onXxxSaveToLibraryClick — see the
|
||||
handlers above for the migration commentary. -->
|
||||
<PushToLibraryModal
|
||||
v-if="pushToLibraryOpen"
|
||||
:open="pushToLibraryOpen"
|
||||
:draft="draft"
|
||||
@close="onPushToLibraryClose"
|
||||
|
||||
@@ -32,6 +32,7 @@ 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",
|
||||
|
||||
@@ -27,7 +27,7 @@ const {
|
||||
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
|
||||
severityFor, conflictTooltip, conflictBadgeText,
|
||||
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
|
||||
rowGap,
|
||||
rowGap, rowLineStyle,
|
||||
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"
|
||||
class="wp-module wp-rowdrag-row"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
:class="{
|
||||
@@ -53,17 +53,18 @@ const {
|
||||
'wp-state-modified': isModified(module),
|
||||
'wp-state-drift': isDrifted(module),
|
||||
'wp-state-missing': isMissingFromLibrary(module) && !module.example,
|
||||
'wp-gap-before': rowGap(idx) === 'before',
|
||||
'wp-gap-after': rowGap(idx) === 'after',
|
||||
'wp-rowdrag--before': rowGap(idx) === 'before',
|
||||
'wp-rowdrag--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="!!module._uid && ctx.recentDropUids.value.has(module._uid)
|
||||
? { animationDelay: ctx.pulseDelayFor(module._uid) }
|
||||
: undefined"
|
||||
:style="{
|
||||
...rowLineStyle(idx),
|
||||
...(!!module._uid && ctx.recentDropUids.value.has(module._uid) ? { animationDelay: ctx.pulseDelayFor(module._uid) } : {}),
|
||||
}"
|
||||
@dragstart="(ev) => onDragStart(ev, module, idx)"
|
||||
@dragend="onDragEnd"
|
||||
@contextmenu.stop.prevent="(ev) => openContextMenu(ev, module, idx)"
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<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>
|
||||
@@ -20,7 +20,6 @@ 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 =
|
||||
@@ -43,18 +42,21 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="wp-bundle"
|
||||
class="wp-bundle wp-rowdrag-row"
|
||||
:class="{
|
||||
'wp-bundle--collapsed': bundle.collapsed,
|
||||
'wp-bundle--disabled': !bundle.enabled,
|
||||
'wp-bundle--nested': nested,
|
||||
'wp-bundle--drop-target': ctx.isBundleDropTarget(bundle._uid),
|
||||
'wp-gap-before': ctx.bundleHeaderGap(bundle._uid) === 'before',
|
||||
'wp-gap-after': ctx.bundleHeaderGap(bundle._uid) === 'after',
|
||||
'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-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"
|
||||
@@ -84,7 +86,6 @@ 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'"
|
||||
@@ -118,6 +119,11 @@ 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;
|
||||
}
|
||||
@@ -133,9 +139,8 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 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. */
|
||||
/* Offset parent for anything absolutely placed in a bundle's body.
|
||||
* Both nested + outer frames share this rule. */
|
||||
.wp-bundle > .wp-bundle-children {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -363,12 +363,53 @@ 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--*` /
|
||||
@@ -396,7 +437,10 @@ const summary = computed(() => {
|
||||
.wp-bundle-summary {
|
||||
font: 600 11px/1.3 var(--wp-font-sans);
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
flex-shrink: 0;
|
||||
flex: 0 2 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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, isHiddenInCollapsedBundle } from "./bundle-alerts";
|
||||
import { bundleAlerts, bundleDescendants, collapsedAround, isHiddenInCollapsedBundle } from "./bundle-alerts";
|
||||
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
|
||||
|
||||
function bundle(uid: string, over: Partial<BundleInstance> = {}): BundleInstance {
|
||||
@@ -62,3 +62,25 @@ 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,9 +13,14 @@ 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 out: string[] = [];
|
||||
const origin = (module as ModuleEntry & { bundle_origin?: string | null }).bundle_origin;
|
||||
let cur: string | null | undefined = typeof origin === "string" ? origin : null;
|
||||
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;
|
||||
while (cur && parent.has(cur) && !out.includes(cur) && out.length < 8) {
|
||||
out.push(cur);
|
||||
cur = parent.get(cur);
|
||||
@@ -36,6 +41,21 @@ 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,9 +25,15 @@ 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
|
||||
* `wp-gap-before`/`wp-gap-after` margin class so a gap opens for the
|
||||
* drop-bar. */
|
||||
* kit's `wp-rowdrag--before`/`--after` class (row-drag.css): a gap
|
||||
* opens with the drop line in it. */
|
||||
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
|
||||
@@ -54,12 +60,6 @@ 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,6 +61,10 @@ 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 —
|
||||
@@ -203,6 +207,15 @@ 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);
|
||||
|
||||
@@ -71,6 +71,8 @@ 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
|
||||
@@ -88,9 +90,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 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.
|
||||
// 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.
|
||||
//
|
||||
// Slot semantics:
|
||||
// - `insertIdx === <item start_idx>` → anchor at THAT item with pos
|
||||
@@ -98,11 +100,14 @@ 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 (bar paints at container center via the
|
||||
// dropBarFor fallback).
|
||||
// Empty container → no anchor (the empty-state hero takes the drop).
|
||||
// A closed bundle → "into": the whole frame is the target.
|
||||
type AnchorInfo =
|
||||
| { kind: "row"; idx: number; pos: "before" | "after"; containerUid: string | null }
|
||||
| { kind: "bundle"; uid: string; 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 };
|
||||
|
||||
function containerItems(
|
||||
containerUid: string | null,
|
||||
@@ -145,6 +150,10 @@ 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) {
|
||||
@@ -175,6 +184,41 @@ 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".
|
||||
@@ -195,110 +239,6 @@ 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;
|
||||
@@ -468,6 +408,7 @@ 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}`);
|
||||
@@ -529,6 +470,7 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
|
||||
sameNodeDropHandled = false;
|
||||
dragOver.value = null;
|
||||
draggingModuleUid.value = null;
|
||||
draggingBundleUid.value = null;
|
||||
}
|
||||
|
||||
let sameNodeDropHandled = false;
|
||||
@@ -543,9 +485,8 @@ 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 indicator
|
||||
* paint happens via the per-container `<BundleDropBar>` reading
|
||||
* `dropBarFor()`. */
|
||||
* tier-2 cap. ContextWidget just sets `dragOver.value`; the anchor's
|
||||
* kit classes paint the gap and line. */
|
||||
function onListDragOver(ev: DragEvent) {
|
||||
const ds = dragState.value;
|
||||
if (!ds) return;
|
||||
@@ -713,7 +654,10 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
|
||||
return;
|
||||
}
|
||||
holdSuppressMove();
|
||||
commitModules(next.modules, next.bundles);
|
||||
// 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));
|
||||
await nextTick();
|
||||
playFlipSnapshot(flipSnap);
|
||||
// Pulse the bundle wrapper AND every row in its range so a
|
||||
@@ -768,7 +712,11 @@ 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, newBundle, ...freshInners]);
|
||||
commitModules(list, [
|
||||
...bundles.map((b) => (outerParentUid && b._uid === outerParentUid && b.collapsed ? { ...b, collapsed: false } : b)),
|
||||
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).
|
||||
@@ -851,11 +799,11 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
|
||||
}
|
||||
|
||||
const rowCtx = {
|
||||
rowGap, draggingModuleUid, recentDropUids, pulseDelayFor, onDragStart, onDragEnd,
|
||||
rowGap, rowLineStyle, draggingModuleUid, recentDropUids, pulseDelayFor, onDragStart, onDragEnd,
|
||||
} satisfies Partial<ModuleRowCtx>;
|
||||
const frameCtx = {
|
||||
bundleHeaderGap, isBundleDropTarget, recentDropUids, pulseDelayFor,
|
||||
onBundleDragStart, onDragEnd, dropBarFor,
|
||||
bundleHeaderGap, bundleLineStyle, isBundleDropTarget, isBundleDropInto, draggingBundleUid,
|
||||
recentDropUids, pulseDelayFor, onBundleDragStart, onDragEnd,
|
||||
} satisfies Partial<BundleFrameCtx>;
|
||||
|
||||
return {
|
||||
|
||||
@@ -59,9 +59,6 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -150,6 +147,10 @@
|
||||
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,
|
||||
@@ -478,27 +479,43 @@
|
||||
.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)); }
|
||||
|
||||
/* 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. */
|
||||
/* 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). */
|
||||
.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;
|
||||
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; }
|
||||
}
|
||||
.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 {
|
||||
|
||||
@@ -28,6 +28,9 @@ 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;
|
||||
|
||||
@@ -42,7 +42,7 @@ const props = defineProps<{
|
||||
/** The variables grouped by the node that wrote them last: each Loop,
|
||||
* Model Info, Toggles or Injector node gets a card drawn its own way, each
|
||||
* Context a block of module rows, and values from outside the trace go
|
||||
* last. One plain list when there is only one source. */
|
||||
* last. A plain list only when no node wrote anything (no trace). */
|
||||
interface Section {
|
||||
id: string;
|
||||
kind: Kind | "context" | "upstream";
|
||||
@@ -87,7 +87,10 @@ const sections = computed((): Section[] => {
|
||||
return Number(a.firstKey) - Number(b.firstKey);
|
||||
});
|
||||
});
|
||||
const grouped = computed(() => sections.value.length > 1 || sections.value.some((s) => s.kind !== "context" && s.kind !== "upstream"));
|
||||
// Every node gets its card, even when it is the only one, so a lone Context
|
||||
// looks the same as it does beside others; only a snapshot with no trace
|
||||
// (every value "From upstream") stays a plain list.
|
||||
const grouped = computed(() => sections.value.some((s) => s.kind !== "upstream"));
|
||||
|
||||
// ── Rows from other nodes ────────────────────────────────────────────────
|
||||
// Every row keeps the same metrics and everything a Context row shows
|
||||
@@ -470,7 +473,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: 17px; box-sizing: border-box;
|
||||
height: 18px; box-sizing: border-box;
|
||||
font: 600 10px/1 var(--wp-font-mono);
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--wp-text);
|
||||
@@ -481,6 +484,10 @@ 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;
|
||||
@@ -531,9 +538,14 @@ 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. */
|
||||
/* 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. */
|
||||
.wp-chip.wp-dbg-var-row__axis, .wp-chip.wp-dbg-var-row__tag {
|
||||
height: 17px; min-height: 0; box-sizing: border-box;
|
||||
height: 18px; min-height: 0; box-sizing: border-box;
|
||||
padding: 0 6px; gap: 4px;
|
||||
font: 500 10px/1 var(--wp-font-sans);
|
||||
}
|
||||
@@ -562,7 +574,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: 17px; min-height: 0; box-sizing: border-box;
|
||||
height: 18px; min-height: 0; box-sizing: border-box;
|
||||
padding: 0 6px; gap: 4px;
|
||||
font: 600 10px/1 var(--wp-font-mono);
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -405,7 +405,7 @@ function openStepMenu(ev: MouseEvent, s: TraceStep): void {
|
||||
<template>
|
||||
<div ref="root" class="wp-debug" :class="{ 'wp-debug--skipped': isSkipped }">
|
||||
<template v-if="model">
|
||||
<header class="wp-dbg-head" data-test="dbg-head">
|
||||
<header class="wp-dbg-head" data-wp-part="summary" data-wp-part-label="Summary" data-test="dbg-head">
|
||||
<CopyButton
|
||||
v-if="model.seed"
|
||||
:text="model.seed"
|
||||
@@ -461,7 +461,7 @@ function openStepMenu(ev: MouseEvent, s: TraceStep): void {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="wp-dbg-tabs">
|
||||
<nav class="wp-dbg-tabs" data-wp-part="tabs" data-wp-part-label="Tabs">
|
||||
<WpTabs
|
||||
class="wp-dbg-tabs__list"
|
||||
:model-value="activeTab"
|
||||
|
||||
@@ -77,6 +77,19 @@ describe("buildModel", () => {
|
||||
expect(m.steps[0].bindings).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("says where a base-off module runs when a loop switches it on", () => {
|
||||
const m = buildModel({
|
||||
__wp_trace__: [
|
||||
{ id: "a", type: "wildcard", status: "skipped_disabled", binding: "a", on_frames: [3] },
|
||||
{ id: "b", type: "wildcard", status: "skipped_disabled", binding: "b", on_frames: [0, 3] },
|
||||
{ id: "c", type: "wildcard", status: "skipped_disabled", binding: "c", on_frames: [0, 1, 3] },
|
||||
],
|
||||
});
|
||||
expect(m.steps.map((s) => [s.status, s.statusLabel])).toEqual([
|
||||
["frame", "off · on #4"], ["frame", "off · on #1 #4"], ["frame", "off · on 3 frames"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks a constraint that never applied, from the warning or its hit count", () => {
|
||||
const con = (id: string) => ({
|
||||
id, _uid: id, type: "constraint", status: "ok",
|
||||
|
||||
@@ -185,6 +185,8 @@ export interface RawTraceEntry {
|
||||
negative?: string;
|
||||
error?: string | { type?: string; message?: string } | null;
|
||||
detail?: RawDetail;
|
||||
/** A base-off module's loop frames it is switched ON for (0-based). */
|
||||
on_frames?: number[];
|
||||
}
|
||||
|
||||
export interface RawRef {
|
||||
@@ -508,10 +510,26 @@ function kindOf(entry: RawTraceEntry): Kind {
|
||||
}
|
||||
}
|
||||
|
||||
function statusOf(raw: string | undefined, hasError: boolean): { status: StepStatus; label: string } {
|
||||
/** "on #4", "on #1 #4", "on 3 frames": where a base-off module does run. */
|
||||
export function onFramesLabel(frames: readonly number[] | undefined): string {
|
||||
const list = (frames ?? []).filter((k) => Number.isInteger(k) && k >= 0);
|
||||
if (list.length === 0) return "";
|
||||
if (list.length <= 2) return `on ${list.map((k) => `#${k + 1}`).join(" ")}`;
|
||||
return `on ${list.length} frames`;
|
||||
}
|
||||
|
||||
function statusOf(
|
||||
raw: string | undefined,
|
||||
hasError: boolean,
|
||||
onFrames?: readonly number[],
|
||||
): { status: StepStatus; label: string } {
|
||||
if (hasError || raw === "failed" || raw === "error") return { status: "error", label: "failed" };
|
||||
if (!raw || raw === "ok") return { status: "ok", label: "ran" };
|
||||
if (raw === "skipped_disabled") return { status: "off", label: "off" };
|
||||
if (raw === "skipped_disabled") {
|
||||
// Off at base but switched on for some loop frames: say where it runs.
|
||||
const on = onFramesLabel(onFrames);
|
||||
return on ? { status: "frame", label: `off · ${on}` } : { status: "off", label: "off" };
|
||||
}
|
||||
if (raw === "skipped_frame") return { status: "frame", label: "off this frame" };
|
||||
if (raw === "skipped_unknown_type") return { status: "unknown", label: "unknown type" };
|
||||
return { status: "unknown", label: raw.replace(/_/g, " ") };
|
||||
@@ -709,7 +727,7 @@ export function buildModel(snap: Record<string, unknown>): DebugModel {
|
||||
const steps: TraceStep[] = trace.map((t, i) => {
|
||||
const kind = kindOf(t);
|
||||
const hasError = !!t.error;
|
||||
const { status, label } = statusOf(t.status, hasError);
|
||||
const { status, label } = statusOf(t.status, hasError, asList<number>(t.on_frames));
|
||||
const writes: StepWrite[] = asList<RawWrite>(t.writes).map((w) => ({
|
||||
variable: str(w.variable),
|
||||
value: formatValue(w.value),
|
||||
|
||||
@@ -127,6 +127,37 @@ describe("node rows in the view", () => {
|
||||
expect(secs[2].find('[data-test="dbg-var"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("gives a lone Context its own card, as it has beside other nodes", () => {
|
||||
const m = buildModel({
|
||||
mood: "calm",
|
||||
__wp_trace__: [{ id: "aaaa1111", _uid: "u", node_id: "9", type: "wildcard", status: "ok", writes: [{ variable: "mood", value: "calm" }] }],
|
||||
});
|
||||
const w = mount(DebugVariables, {
|
||||
props: {
|
||||
rows: m.variables, pinned: new Set<string>(), flashed: new Set<string>(), uuidToName: new Map(), uuidToKind: new Map(),
|
||||
ctxActiveKey: null, nodeInfo: () => ({ title: "", codename: "ALPHA" }),
|
||||
stepsByKey: new Map(m.steps.map((s) => [s.key, s])),
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="dbg-vars"]').classes()).toContain("is-grouped");
|
||||
const secs = w.findAll('[data-test="dbg-var-section"]');
|
||||
expect(secs).toHaveLength(1);
|
||||
expect(secs[0].find('[data-test="dbg-var-section-head"]').text()).toContain("WP Context");
|
||||
expect(secs[0].find('[data-test="dbg-var-section-head"]').text()).toContain("ALPHA");
|
||||
});
|
||||
|
||||
it("keeps a snapshot with no trace a plain list", () => {
|
||||
const m = buildModel({ mood: "calm" });
|
||||
const w = mount(DebugVariables, {
|
||||
props: {
|
||||
rows: m.variables, pinned: new Set<string>(), flashed: new Set<string>(), uuidToName: new Map(), uuidToKind: new Map(),
|
||||
ctxActiveKey: null,
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="dbg-vars"]').classes()).not.toContain("is-grouped");
|
||||
expect(w.find('[data-test="dbg-var-section-head"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("draws every pick of a Pick one node as its own switch", async () => {
|
||||
const m = buildModel({
|
||||
warm: "true", cold: "false", mood: "warm light",
|
||||
|
||||
@@ -90,10 +90,10 @@ const summary = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="wp-ifw" :class="{ 'wp-ifw--skipped': isSkipped }" data-test="image-filter-widget">
|
||||
<section class="wp-ifw__section">
|
||||
<section data-wp-part="when" data-wp-part-label="All of When it runs" data-wp-part-section="When it runs" class="wp-ifw__section">
|
||||
<div class="wp-ifw__head"><span class="wp-ifw__label">WHEN IT RUNS</span></div>
|
||||
<div class="wp-ifw__box">
|
||||
<div class="wp-ifw__row">
|
||||
<div data-wp-part="mode" data-wp-part-label="Mode" data-wp-part-section="When it runs" class="wp-ifw__row">
|
||||
<span class="wp-ifw__row-label">Mode</span>
|
||||
<WpSegmented
|
||||
:model-value="modelValue.mode"
|
||||
@@ -104,7 +104,7 @@ const summary = computed(() => {
|
||||
@update:model-value="patch({ mode: $event })"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-ifw__row">
|
||||
<div data-wp-part="send" data-wp-part-label="Send picks as" data-wp-part-section="When it runs" class="wp-ifw__row">
|
||||
<span class="wp-ifw__row-label" title="One per image: every picked image becomes its own item, with its own copy of its frame's prompt and conditioning">Send picks as</span>
|
||||
<WpSelect
|
||||
class="wp-ifw__select"
|
||||
@@ -115,7 +115,7 @@ const summary = computed(() => {
|
||||
@update:model-value="setSendAs"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-ifw__row" :class="{ 'is-off': modelValue.mode === 'pass_all' }">
|
||||
<div data-wp-part="timeout" data-wp-part-label="Timeout" data-wp-part-section="When it runs" class="wp-ifw__row" :class="{ 'is-off': modelValue.mode === 'pass_all' }">
|
||||
<span class="wp-ifw__row-label" title="Seconds to wait for your picks. 0 waits until you answer. The picker can't send an empty pick, so this is also what happens when nothing gets picked.">Timeout</span>
|
||||
<span class="wp-ifw__timeout">
|
||||
<WpNumber
|
||||
@@ -144,7 +144,7 @@ const summary = computed(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wp-ifw__section">
|
||||
<section data-wp-part="last-run" data-wp-part-label="Last run" class="wp-ifw__section">
|
||||
<div class="wp-ifw__head">
|
||||
<span class="wp-ifw__label">LAST RUN</span>
|
||||
<span class="wp-ifw__head-note" data-test="if-summary">{{ summary || "picks show here after a run" }}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { valueTypeLook } from "./value-types";
|
||||
import { computed, ref } from "vue";
|
||||
import { computed } from "vue";
|
||||
import type { InjectorRow } from "../../widgets/_shared";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import Input from "@/kit/Input.vue";
|
||||
@@ -32,13 +32,6 @@ const props = defineProps<{
|
||||
* events. When false (default), drag is a no-op so external
|
||||
* consumers (tests, isolated mounts) aren't surprised by dnd. */
|
||||
reorderable?: boolean;
|
||||
/** Parent-coordinated drop indicator. `null` = no indicator on
|
||||
* this row; `'before'` / `'after'` = paint the accent gap+bar at
|
||||
* the corresponding edge. Lifted to InjectorWidget so only ONE
|
||||
* row carries the indicator at a time — HTML5 dragleave races
|
||||
* with the next dragenter, so per-row local state can leave a
|
||||
* stale indicator on a row the pointer just left. */
|
||||
dropIndicator?: "before" | "after" | null;
|
||||
}>();
|
||||
|
||||
/** A general-template row: no slot, durable, resolved after all socket
|
||||
@@ -52,7 +45,7 @@ const slotLabel = computed(() => props.displayLabel ?? props.row.slot_name);
|
||||
const emit = defineEmits<{
|
||||
(e: "update", patch: Partial<InjectorRow>): void;
|
||||
(e: "disconnect"): void;
|
||||
(e: "row-drag-start", payload: { fromIdx: number }): void;
|
||||
(e: "row-drag-start", payload: { fromIdx: number; ev: DragEvent }): void;
|
||||
(e: "row-drag-end"): void;
|
||||
/** Right-click on the row root. Parent opens a shared ContextMenu
|
||||
* with edit / disable / collapse / move-top / move-bottom /
|
||||
@@ -106,35 +99,14 @@ function onBindingInput(ev: Event): void {
|
||||
emit("update", { binding: next });
|
||||
}
|
||||
|
||||
// Drag-to-reorder state. Only `isDragging` is local now — the visual
|
||||
// drop indicator (`dropIndicator` prop) is driven by InjectorWidget
|
||||
// based on `row-drag-over` events so a single source of truth picks
|
||||
// the active target row + edge. Pre-lift this state caused stale
|
||||
// indicators on rows the pointer just left (HTML5 dragleave races
|
||||
// with the next row's dragenter).
|
||||
const isDragging = ref(false);
|
||||
|
||||
// Drag-to-reorder: dragstart / dragend fire on the source row, so they
|
||||
// stay here; the list-level target, gap, line, slide and pulse are the
|
||||
// kit's useRowDrag in InjectorWidget (its classes fall through to this root).
|
||||
function onDragStart(ev: DragEvent): void {
|
||||
if (!props.reorderable || props.index === undefined) return;
|
||||
isDragging.value = true;
|
||||
if (ev.dataTransfer) {
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
// Set a payload so the platform considers this a valid drag —
|
||||
// some browsers/HiDPI hosts no-op dragstart when dataTransfer
|
||||
// is empty. The actual payload is read back via the parent's
|
||||
// tracked fromIdx; this is just a marker.
|
||||
ev.dataTransfer.setData("text/wp-injector-row", String(props.index));
|
||||
}
|
||||
emit("row-drag-start", { fromIdx: props.index });
|
||||
emit("row-drag-start", { fromIdx: props.index, ev });
|
||||
}
|
||||
|
||||
// dragover + drop are handled at the LIST level by InjectorWidget so
|
||||
// the drop-target stays stable when individual rows shift via
|
||||
// margin-top/bottom (the gap-opening pattern oscillates per-row
|
||||
// dragover otherwise). dragstart / dragend stay per-row because
|
||||
// HTML5 fires them on the source element.
|
||||
function onDragEnd(): void {
|
||||
isDragging.value = false;
|
||||
emit("row-drag-end");
|
||||
}
|
||||
</script>
|
||||
@@ -149,9 +121,6 @@ function onDragEnd(): void {
|
||||
:class="{
|
||||
'wp-inj-row--disconnected': props.isConnected === false,
|
||||
'wp-inj-row--disabled': !row.enabled,
|
||||
'wp-inj-row--dragging': isDragging,
|
||||
'wp-inj-row--drop-before': dropIndicator === 'before',
|
||||
'wp-inj-row--drop-after': dropIndicator === 'after',
|
||||
'wp-inj-row--has-template': hasTemplate,
|
||||
'wp-inj-row--general': isGeneral,
|
||||
'wp-conflict-info': conflictSeverity === 'info',
|
||||
@@ -298,6 +267,7 @@ function onDragEnd(): void {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="@/kit/row-drag.css"></style>
|
||||
<style scoped>
|
||||
@import "../shared/row-primitives.css";
|
||||
|
||||
@@ -328,11 +298,14 @@ function onDragEnd(): void {
|
||||
margin-bottom var(--wp-motion-fade) var(--wp-motion-curve-flip);
|
||||
}
|
||||
.wp-inj-row:last-child { margin-bottom: 0; }
|
||||
/* Drop-after on the last row still needs the gap — the `:last-child`
|
||||
* rule above would otherwise zero it out (more specific than the
|
||||
* `--drop-after` class alone). Chain both selectors to win on
|
||||
* specificity. */
|
||||
.wp-inj-row.wp-inj-row--drop-after:last-child { margin-bottom: 14px; }
|
||||
/* The kit's row-drag gap (row-drag.css loads before this block, so the
|
||||
* row's own margins above would win without the extra class), also on
|
||||
* the last row, whose `:last-child` rule zeroes its margin. */
|
||||
.wp-inj-row.wp-rowdrag--before { margin-top: var(--wp-rowdrag-gap, 14px); }
|
||||
.wp-inj-row.wp-rowdrag--after,
|
||||
.wp-inj-row.wp-rowdrag--end,
|
||||
.wp-inj-row.wp-rowdrag--after:last-child,
|
||||
.wp-inj-row.wp-rowdrag--end:last-child { margin-bottom: var(--wp-rowdrag-gap, 14px); }
|
||||
|
||||
/* Header row — original flat layout lives here. All header controls
|
||||
* stay on one line via flex-row, while the parent row can expand
|
||||
@@ -342,56 +315,11 @@ function onDragEnd(): void {
|
||||
align-items: center;
|
||||
gap: var(--wp-row-gap, 6px);
|
||||
}
|
||||
/* Drag-to-reorder. Row root carries `draggable` when reorderable;
|
||||
* the row gets a grab cursor on hover so the affordance is
|
||||
* discoverable without a separate handle glyph. Interactive
|
||||
* children (toggle, binding input, action buttons) carry
|
||||
* draggable="false" so click + text-selection still work normally.
|
||||
*
|
||||
* Dragging row: dimmed via opacity so the user sees their grab.
|
||||
* Drop target: 2px accent bar above (before-edge) or below
|
||||
* (after-edge) — mirrors the cross-platform "drop here" affordance. */
|
||||
/* Cursor: ModuleRow pattern — only `.wp-drag-handle` shows grab/grabbing
|
||||
* (from shared/row-primitives.css). Row root keeps the default cursor
|
||||
* so hovering over the binding input, buttons, or summary doesn't
|
||||
* suggest the whole row is grab-target. Drag still works from anywhere
|
||||
* on the row since `draggable=true` lives on root; the cursor is just
|
||||
* an affordance hint, not a permission gate. */
|
||||
.wp-inj-row--dragging { opacity: 0.4; }
|
||||
/* Drop-zone gap + bar — mirrors ModuleRow's wp-gap-before/after
|
||||
* pattern: target row gets extra margin so a 14px slot opens, then a
|
||||
* 3px accent bar paints inside that slot via a ::before/::after
|
||||
* pseudo-element. Bar carries a soft accent glow so the user sees
|
||||
* where the row will land at a glance. Margins animate via the
|
||||
* `transition: margin` on `.wp-inj-row` so the gap eases open as the
|
||||
* pointer crosses the row's midline. */
|
||||
.wp-inj-row {
|
||||
position: relative;
|
||||
/* Margin axis added on top of the existing background/border-color
|
||||
* transitions so the slot opens smoothly instead of snap-jumping
|
||||
* when `wp-inj-row--drop-before/after` toggles. */
|
||||
}
|
||||
.wp-inj-row--drop-before { margin-top: 14px; }
|
||||
.wp-inj-row--drop-after { margin-bottom: 14px; }
|
||||
.wp-inj-row--drop-before::before,
|
||||
.wp-inj-row--drop-after::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--wp-accent);
|
||||
border-radius: min(2px, var(--wp-radius-cap, 999px));
|
||||
box-shadow: 0 0 8px var(--wp-accent);
|
||||
pointer-events: none;
|
||||
animation: wp-inj-drop-bar-in var(--wp-motion-quick) ease-out;
|
||||
}
|
||||
.wp-inj-row--drop-before::before { top: -8px; }
|
||||
.wp-inj-row--drop-after::after { bottom: -8px; }
|
||||
@keyframes wp-inj-drop-bar-in {
|
||||
from { opacity: 0; transform: scaleX(0.6); }
|
||||
to { opacity: 1; transform: scaleX(1); }
|
||||
}
|
||||
/* Drag-to-reorder: the whole row is the drag source (interactive
|
||||
* children carry draggable="false"); only `.wp-drag-handle` shows the
|
||||
* grab cursor. The dim, gap, line and pulse are the kit's row-drag.css
|
||||
* (the `wp-rowdrag--*` classes InjectorWidget binds on this root). */
|
||||
.wp-inj-row { position: relative; }
|
||||
|
||||
/* Enter / leave / flash classes consumed by the kit's flip.ts (@/kit/flip)
|
||||
* when InjectorWidget calls withEnterAnimation /
|
||||
|
||||
@@ -668,3 +668,26 @@ 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,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { revealTarget } from "../../widgets/flash-target";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import {
|
||||
emptyInjectorRowsValue,
|
||||
@@ -11,7 +12,8 @@ import {
|
||||
} from "../../widgets/_shared";
|
||||
import { reorderInjectorRows } from "../../widgets/injector";
|
||||
import { shortConflictLabel, scanInjectorConflicts } from "../../extension/conflicts";
|
||||
import { applyFlip, captureRects, MOTION_FLIP_MS } from "@/kit/flip";
|
||||
import { applyFlip, captureRects } from "@/kit/flip";
|
||||
import { useRowDrag } from "@/kit/row-drag";
|
||||
import InjectorRowComp from "./InjectorRow.vue";
|
||||
import ContextMenu, { type ContextMenuItem } from "@/kit/ContextMenu.vue";
|
||||
import InjectorBindingModal from "./InjectorBindingModal.vue";
|
||||
@@ -260,6 +262,16 @@ 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);
|
||||
@@ -404,26 +416,13 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// ── Drag-to-reorder. Persists the new row order with sequential
|
||||
// slot_name reassignment (reorderInjectorRows): the wire at each
|
||||
// physical input_N socket then feeds whatever variable now sits at
|
||||
// that index. dragSrcIdx is captured on dragstart and cleared on
|
||||
// drop / dragend so a cancelled drag doesn't leak state.
|
||||
//
|
||||
// FLIP animation: captureRects BEFORE the row mutation snapshots
|
||||
// each row's current Y position keyed by `data-uid`. After Vue
|
||||
// re-renders the new order (nextTick), applyFlip computes the
|
||||
// inverse-transform per row and tweens back to identity — the rows
|
||||
// appear to slide to their new positions instead of jump-cutting.
|
||||
// shared/flip.ts honors the wp-a11y-no-motion body class so the
|
||||
// animation no-ops under reduce-motion settings.
|
||||
const dragSrcIdx = ref<number | null>(null);
|
||||
// Kind of the row being dragged, captured on dragstart. The list-level
|
||||
// dragover handler reads this to confine the drop target to the
|
||||
// dragged row's OWN kind-group (socket drags land only among socket
|
||||
// rows; template drags only among template rows) so the two groups
|
||||
// never interleave. Cleared on drop / dragend alongside dragSrcIdx.
|
||||
const dragSrcKind = ref<"socket" | "general" | null>(null);
|
||||
// ── Drag-to-reorder: the kit's useRowDrag (row-drag.ts + row-drag.css),
|
||||
// which every reorderable list shares. Persists the new row order with
|
||||
// sequential slot_name reassignment (reorderInjectorRows): the wire at
|
||||
// each physical input_N socket then feeds whatever variable now sits at
|
||||
// that index. A row lands only among rows of its OWN kind (socket rows
|
||||
// among sockets, template rows among templates), so the two groups never
|
||||
// interleave; the kit slides the rows into place and pulses the moved one.
|
||||
const listEl = ref<HTMLElement | null>(null);
|
||||
|
||||
/** Half-open `[start, end)` index range of the kind-group a row at
|
||||
@@ -443,170 +442,27 @@ function rowGroupBounds(idx: number): { start: number; end: number } {
|
||||
: { start: 0, end: socketEnd };
|
||||
}
|
||||
|
||||
// Parent-coordinated drop indicator. Tracks the row + edge the
|
||||
// pointer is currently over so only ONE row shows the gap+bar
|
||||
// indicator at a time. Per-row local state would race: HTML5 fires
|
||||
// the previous row's dragleave AFTER the new row's dragenter, so a
|
||||
// brief frame can have two rows showing indicators. Lifting here
|
||||
// gives a single source: each row emits row-drag-over on its own
|
||||
// dragover; we just overwrite the target.
|
||||
const dragOverUid = ref<string | null>(null);
|
||||
const dragOverEdge = ref<"before" | "after" | null>(null);
|
||||
function dropIndicatorFor(uid: string): "before" | "after" | null {
|
||||
return uid === dragOverUid.value ? dragOverEdge.value : null;
|
||||
}
|
||||
function clearDropIndicator(): void {
|
||||
dragOverUid.value = null;
|
||||
dragOverEdge.value = null;
|
||||
}
|
||||
/** UIDs of rows that just landed via drag-drop. Template binds the
|
||||
* `.wp-drop-pulse` class while a UID is present; entries clear after
|
||||
* the keyframe duration (420ms in shared/row-primitives.css). */
|
||||
const recentDropUids = ref<Set<string>>(new Set());
|
||||
const DROP_PULSE_MS = 420;
|
||||
|
||||
function onRowDragStart(fromIdx: number): void {
|
||||
dragSrcIdx.value = fromIdx;
|
||||
const row = value.value.rows[fromIdx];
|
||||
dragSrcKind.value = row?.kind === "general" ? "general" : "socket";
|
||||
}
|
||||
|
||||
// List-level dragover. Tracking at LIST level (not per-row) so the
|
||||
// drop-target stays stable when individual rows shift by margin-top
|
||||
// /bottom (the slot opens via `.wp-inj-row--drop-before/after`
|
||||
// rules). Per-row dragover ran into oscillation: row 2 dragover
|
||||
// opens its top gap → row 2 shifts down → pointer now sits in the
|
||||
// gap above row 2 → row 1's dragover fires → row 1 opens its
|
||||
// bottom gap → row 1 shifts down → flip-flop. List-level handler
|
||||
// reads pointer Y vs each row's rect once, so shifts inside the
|
||||
// list don't change the target computation. Mirrors
|
||||
// ContextWidget.onListDragOver.
|
||||
function onListDragOver(ev: DragEvent): void {
|
||||
if (dragSrcIdx.value === null) return;
|
||||
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
|
||||
const list = listEl.value;
|
||||
if (!list) return;
|
||||
// Filter candidate rows to the dragged row's OWN kind-group. Socket
|
||||
// rows never see template rows as drop targets and vice-versa, so
|
||||
// the two groups can't interleave mid-drag. Each row carries
|
||||
// data-kind="socket"|"general"; we discriminate on it. When
|
||||
// dragSrcKind is unset (extremely defensive — would only happen if
|
||||
// a drag started before kind was recorded), the empty filter falls
|
||||
// through to the "no target" branch and the drop becomes a no-op.
|
||||
const srcKind = dragSrcKind.value;
|
||||
const allRows = Array.from(list.querySelectorAll<HTMLElement>(".wp-inj-row[data-uid]"));
|
||||
const rows = srcKind
|
||||
? allRows.filter((el) => el.dataset.kind === srcKind)
|
||||
: [];
|
||||
let targetUid: string | null = null;
|
||||
let edge: "before" | "after" | null = null;
|
||||
for (const el of rows) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (ev.clientY <= r.top + r.height / 2) {
|
||||
targetUid = el.dataset.uid ?? null;
|
||||
edge = "before";
|
||||
break;
|
||||
}
|
||||
if (ev.clientY < r.bottom) {
|
||||
targetUid = el.dataset.uid ?? null;
|
||||
edge = "after";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!targetUid && rows.length > 0) {
|
||||
// Pointer past all rows in this group → drop at the END of the
|
||||
// DRAGGED ROW'S GROUP. Falling back to the whole list's last row
|
||||
// would let a socket drag drop "after" the final template row,
|
||||
// re-interleaving the two groups; clamping to the group's last
|
||||
// row keeps the invariant intact.
|
||||
const lastEl = rows[rows.length - 1];
|
||||
targetUid = lastEl.dataset.uid ?? null;
|
||||
edge = "after";
|
||||
}
|
||||
if (targetUid && edge) {
|
||||
if (dragOverUid.value !== targetUid) dragOverUid.value = targetUid;
|
||||
if (dragOverEdge.value !== edge) dragOverEdge.value = edge;
|
||||
}
|
||||
}
|
||||
|
||||
// List-level drop. Reads the last-tracked target from state instead
|
||||
// of recomputing from the drop event's clientY — by the time drop
|
||||
// fires, the gap may have already opened (margin shifted the rows),
|
||||
// so the pointer Y at drop time can pick a different target than
|
||||
// the visual indicator showed during dragover. Trust the indicator.
|
||||
function onListDrop(ev: DragEvent): void {
|
||||
ev.preventDefault();
|
||||
if (dragSrcIdx.value === null) {
|
||||
clearDropIndicator();
|
||||
return;
|
||||
}
|
||||
const targetUid = dragOverUid.value;
|
||||
const edge = dragOverEdge.value;
|
||||
clearDropIndicator();
|
||||
if (!targetUid || !edge) return;
|
||||
const overIdx = value.value.rows.findIndex((r) => r._uid === targetUid);
|
||||
if (overIdx < 0) return;
|
||||
onRowDrop(overIdx, edge);
|
||||
}
|
||||
|
||||
function onRowDrop(overIdx: number, edge: "before" | "after"): void {
|
||||
const from = dragSrcIdx.value;
|
||||
dragSrcIdx.value = null;
|
||||
dragSrcKind.value = null;
|
||||
clearDropIndicator();
|
||||
if (from === null || from === overIdx) return;
|
||||
// Convert the (overIdx, edge) pair into a single splice-style
|
||||
// target index. "before idx N" => insert at N; "after idx N" =>
|
||||
// insert at N+1. reorderInjectorRows handles the post-removal
|
||||
// shift internally.
|
||||
const toIdx = edge === "before" ? overIdx : overIdx + 1;
|
||||
const nextRows = reorderInjectorRows(value.value.rows, from, toIdx);
|
||||
if (nextRows === value.value.rows) return;
|
||||
|
||||
// Capture the moved row's uid so we can pulse it AFTER the reorder.
|
||||
// reorderInjectorRows reassigns slot_name in place (sequential
|
||||
// input_N), but _uid is stable per row identity.
|
||||
const movedUid = value.value.rows[from]?._uid;
|
||||
|
||||
// Snapshot row positions BEFORE the mutation. applyFlip below
|
||||
// diffs against these to compute the slide distance per row.
|
||||
const container = listEl.value;
|
||||
const before = container
|
||||
? captureRects(container, (el) => el.dataset.uid ?? null)
|
||||
: null;
|
||||
|
||||
value.value = { ...value.value, rows: nextRows };
|
||||
persist();
|
||||
|
||||
if (container && before) {
|
||||
// Wait one render tick so Vue commits the new row order to the
|
||||
// DOM before we re-measure rects in applyFlip.
|
||||
void nextTick(() => {
|
||||
applyFlip(container, before, (el) => el.dataset.uid ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
// Drop-pulse on the dropped row. Delay the class apply by
|
||||
// MOTION_FLIP_MS so the FLIP slide finishes first — without the
|
||||
// delay the pulse ring would paint mid-swap on a row that's still
|
||||
// animating into position (matches ContextWidget's pulseDrop
|
||||
// pattern). Skip entirely under reduce-motion.
|
||||
if (movedUid && !document.body.classList.contains("wp-a11y-no-motion")) {
|
||||
setTimeout(() => {
|
||||
recentDropUids.value = new Set([...recentDropUids.value, movedUid]);
|
||||
setTimeout(() => {
|
||||
const next = new Set(recentDropUids.value);
|
||||
next.delete(movedUid);
|
||||
recentDropUids.value = next;
|
||||
}, DROP_PULSE_MS);
|
||||
}, MOTION_FLIP_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onRowDragEnd(): void {
|
||||
dragSrcIdx.value = null;
|
||||
dragSrcKind.value = null;
|
||||
clearDropIndicator();
|
||||
const drag = useRowDrag({
|
||||
list: listEl,
|
||||
rowSelector: ".wp-inj-row[data-uid]",
|
||||
keyOf: (el) => el.dataset.uid ?? null,
|
||||
dataType: "text/wp-injector-row",
|
||||
candidate: (el, from) => el.dataset.kind === kindOfUid(from),
|
||||
onDrop: (from, t) => {
|
||||
const rows = value.value.rows;
|
||||
const fromIdx = rows.findIndex((r) => r._uid === from);
|
||||
const overIdx = rows.findIndex((r) => r._uid === t.key);
|
||||
if (fromIdx < 0 || overIdx < 0) return;
|
||||
// "before N" inserts at N, "after N" (or below the last) at N + 1;
|
||||
// reorderInjectorRows handles the post-removal shift.
|
||||
const nextRows = reorderInjectorRows(rows, fromIdx, t.edge === "before" ? overIdx : overIdx + 1);
|
||||
if (nextRows === rows) return;
|
||||
value.value = { ...value.value, rows: nextRows };
|
||||
persist();
|
||||
},
|
||||
});
|
||||
function kindOfUid(uid: string): "socket" | "general" {
|
||||
return value.value.rows.find((r) => r._uid === uid)?.kind === "general" ? "general" : "socket";
|
||||
}
|
||||
|
||||
// ── Move-to-edge (top / bottom) ────────────────────────────────────
|
||||
@@ -837,8 +693,8 @@ defineExpose({ addRow, addGeneralRow, removeRow });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-inj-widget" :class="{ 'wp-inj-widget--skipped': isSkipped }">
|
||||
<div class="wp-inj-header">
|
||||
<div class="wp-inj-widget" data-wp-reveal :class="{ 'wp-inj-widget--skipped': isSkipped }" @wp-reveal="onReveal">
|
||||
<div class="wp-inj-header" data-wp-part="header" data-wp-part-label="Header">
|
||||
<WpIconButton
|
||||
variant="bare"
|
||||
class="wp-inj-caret"
|
||||
@@ -869,13 +725,17 @@ defineExpose({ addRow, addGeneralRow, removeRow });
|
||||
<div
|
||||
ref="listEl"
|
||||
class="wp-inj-list"
|
||||
@dragover.prevent="onListDragOver"
|
||||
@drop.prevent="onListDrop"
|
||||
@dragover="drag.over"
|
||||
@drop="drag.drop"
|
||||
>
|
||||
<InjectorRowComp
|
||||
v-for="(row, idx) in value.rows"
|
||||
:key="row._uid"
|
||||
:class="{ 'wp-drop-pulse': recentDropUids.has(row._uid) }"
|
||||
:data-wp-part="`row:${row._uid}`"
|
||||
:data-wp-part-label="row.binding ? `$${row.binding}` : (row.kind === 'general' ? 'Template row' : row.slot_name)"
|
||||
data-wp-part-section="Rows"
|
||||
:class="drag.classes(row._uid)"
|
||||
:style="drag.lineStyle(row._uid)"
|
||||
:row="row"
|
||||
:index="idx"
|
||||
:reorderable="row.kind === 'general' ? generalRowCount > 1 : socketRowCount > 1"
|
||||
@@ -884,11 +744,10 @@ defineExpose({ addRow, addGeneralRow, removeRow });
|
||||
:display-label="row.kind === 'general' ? undefined : (row.slot_label ?? slotLabels[row.slot_name])"
|
||||
:conflict-severity="conflictByUid[row._uid]?.severity"
|
||||
:conflict-label="conflictByUid[row._uid]?.label"
|
||||
:drop-indicator="dropIndicatorFor(row._uid)"
|
||||
@update="(patch) => updateRow(row._uid, patch)"
|
||||
@disconnect="row.kind === 'general' ? removeRow(row._uid) : emit('disconnect-slot', row.slot_name)"
|
||||
@row-drag-start="(p) => onRowDragStart(p.fromIdx)"
|
||||
@row-drag-end="onRowDragEnd"
|
||||
@row-drag-start="(p) => drag.start(p.ev, row._uid)"
|
||||
@row-drag-end="drag.end"
|
||||
@row-contextmenu="(p) => openRowContextMenu(p.ev, p.idx)"
|
||||
/>
|
||||
<div
|
||||
@@ -899,6 +758,8 @@ defineExpose({ addRow, addGeneralRow, removeRow });
|
||||
↓ Wire an upstream node's output into this node's input pin — a variable row appears here.
|
||||
</div>
|
||||
<Button
|
||||
data-wp-part="add-row"
|
||||
data-wp-part-label="Add template row"
|
||||
size="sm"
|
||||
variant="add"
|
||||
icon="pi-plus"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defaultLabelGridConfig, type LabelGridConfig, type LabelGridRun } from
|
||||
|
||||
const SWEEP: LabelGridRun = {
|
||||
items: 9, batchSizes: Array(9).fill(1), count: 9, labelled: true, auto: "fill", grid: [1687, 2416],
|
||||
missing: 0, extra: 0, rows: null, columns: null, noLabel: 0,
|
||||
parts: [
|
||||
{ values: ["#1", "#2", "#3", "#4"], count: 9 },
|
||||
{ values: ["dawn", "noon", "night"], count: 3 },
|
||||
@@ -45,6 +46,29 @@ describe("LabelGridWidget", () => {
|
||||
expect(lastEmit(w)).toMatchObject({ position: "over_top", label_color: "#00000099" });
|
||||
});
|
||||
|
||||
it("keeps 'over image' on the left and right too", async () => {
|
||||
const over = mk({ modelValue: { ...defaultLabelGridConfig(), position: "over_top" } });
|
||||
await over.find('[data-test="lg-side-left"]').trigger("click");
|
||||
expect(lastEmit(over).position).toBe("over_left");
|
||||
const w = mk({ modelValue: { ...defaultLabelGridConfig(), position: "right" } });
|
||||
await w.find('[data-test="lg-over"]').trigger("click");
|
||||
expect(lastEmit(w).position).toBe("over_right");
|
||||
});
|
||||
|
||||
it("offers horizontal or vertical text only beside the image", async () => {
|
||||
expect(mk().find('[data-test="lg-orientation-vertical"]').exists()).toBe(false);
|
||||
const w = mk({ modelValue: { ...defaultLabelGridConfig(), position: "over_left" } });
|
||||
await w.find('[data-test="lg-orientation-vertical"]').trigger("click");
|
||||
expect(lastEmit(w).orientation).toBe("vertical");
|
||||
});
|
||||
|
||||
it("warns when the label lines or headers don't match the run", () => {
|
||||
expect(mk({ lastRun: SWEEP }).find('[data-test="lg-mismatch"]').exists()).toBe(false);
|
||||
const w = mk({ lastRun: { ...SWEEP, missing: 1, columns: [2, 3] } });
|
||||
expect(w.find('[data-test="lg-mismatch"]').text()).toContain("1 image without a label line");
|
||||
expect(w.find('[data-test="lg-mismatch"]').text()).toContain("2 column headers for 3 columns");
|
||||
});
|
||||
|
||||
it("explains each label token", () => {
|
||||
const tokens = mk().findAll('[data-test^="lg-token-"]');
|
||||
expect(tokens.map((t) => t.text())).toEqual(["{label}", "{frame}", "{image}", "{index}", "{count}"]);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* whole next config (`src/widgets/label_grid.ts` serializes it).
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import Banner from "@/kit/Banner.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Textarea from "@/kit/Textarea.vue";
|
||||
import Input from "@/kit/Input.vue";
|
||||
@@ -32,6 +33,9 @@ import {
|
||||
type LabelAlign,
|
||||
type LabelGridConfig,
|
||||
type LabelGridRun,
|
||||
type LabelOrientation,
|
||||
type LabelPosition,
|
||||
runMismatch,
|
||||
} from "../../extension/label-grid";
|
||||
import {
|
||||
PRESETS,
|
||||
@@ -80,13 +84,14 @@ const SIDES: { value: Side; label: string; icon: string }[] = [
|
||||
];
|
||||
const side = computed<Side>(() => {
|
||||
const p = cfg.value.position;
|
||||
return p === "over_top" ? "top" : p === "over_bottom" ? "bottom" : p;
|
||||
return p.startsWith("over_") ? p.slice(5) as Side : p as Side;
|
||||
});
|
||||
const over = computed(() => cfg.value.position.startsWith("over_"));
|
||||
const canOver = computed(() => side.value === "top" || side.value === "bottom");
|
||||
const canOver = computed(() => side.value !== "none");
|
||||
const sideways = computed(() => side.value === "left" || side.value === "right");
|
||||
|
||||
function setSide(next: Side): void {
|
||||
set("position", over.value && (next === "top" || next === "bottom") ? `over_${next}` : next);
|
||||
set("position", over.value && next !== "none" ? `over_${next}` as LabelPosition : next);
|
||||
}
|
||||
/** A band over the image starts see-through when the strip was the plain
|
||||
* default black, so the picture shows under it. */
|
||||
@@ -94,13 +99,18 @@ const SEE_THROUGH_BAND = `${DEFAULT_LABEL_GRID_CONFIG.label_color}99`;
|
||||
|
||||
function setOver(on: boolean): void {
|
||||
if (!canOver.value) return;
|
||||
const s = side.value as "top" | "bottom";
|
||||
const next = { ...cfg.value, position: on ? `over_${s}` as const : s };
|
||||
const s = side.value as Exclude<Side, "none">;
|
||||
const next = { ...cfg.value, position: on ? `over_${s}` as LabelPosition : s };
|
||||
if (on && cfg.value.label_color === DEFAULT_LABEL_GRID_CONFIG.label_color) next.label_color = SEE_THROUGH_BAND;
|
||||
if (!on && cfg.value.label_color === SEE_THROUGH_BAND) next.label_color = DEFAULT_LABEL_GRID_CONFIG.label_color;
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
const ORIENTATION_OPTIONS: { value: LabelOrientation; label: string; icon: string }[] = [
|
||||
{ value: "horizontal", label: "Horizontal text", icon: "pi-arrows-h" },
|
||||
{ value: "vertical", label: "Vertical text (turned along the edge)", icon: "pi-arrows-v" },
|
||||
];
|
||||
|
||||
const ALIGN_OPTIONS: { value: LabelAlign; label: string; icon: string }[] = [
|
||||
{ value: "left", label: "Align left", icon: "pi-align-left" },
|
||||
{ value: "center", label: "Centre", icon: "pi-align-center" },
|
||||
@@ -186,6 +196,8 @@ const runLine = computed(() => {
|
||||
if (run.grid) bits.push(`grid ${run.grid[0]}×${run.grid[1]}`);
|
||||
return bits.join(" · ");
|
||||
});
|
||||
/** Counts that didn't match on the last run (label lines, headers). */
|
||||
const mismatch = computed(() => (props.lastRun ? runMismatch(props.lastRun) : []));
|
||||
const suggestedLabel = computed(() => PRESETS.find((p) => p.id === suggested.value)?.label ?? "");
|
||||
|
||||
const isDefaultStyle = computed(() => {
|
||||
@@ -208,13 +220,23 @@ function text(ev: Event): string {
|
||||
|
||||
<template>
|
||||
<div class="wp-lg" :class="{ 'wp-lg--skipped': isSkipped }">
|
||||
<div v-if="runLine" class="wp-lg__run" data-test="lg-run">
|
||||
<div data-wp-part="run" data-wp-part-label="Last run" v-if="runLine" class="wp-lg__run" data-test="lg-run">
|
||||
<i class="pi pi-images" aria-hidden="true" />
|
||||
<span class="wp-lg__run-text">{{ runLine }}</span>
|
||||
</div>
|
||||
<Banner
|
||||
v-if="mismatch.length"
|
||||
data-wp-part="mismatch"
|
||||
data-wp-part-label="Counts that don't match"
|
||||
tone="warn"
|
||||
size="sm"
|
||||
data-test="lg-mismatch"
|
||||
>
|
||||
{{ mismatch.join(" · ") }}
|
||||
</Banner>
|
||||
|
||||
<!-- LABEL ───────────────────────────────────────────────────────── -->
|
||||
<section class="wp-lg__section">
|
||||
<section data-wp-part="label" data-wp-part-label="Whole label section" data-wp-part-section="Label" class="wp-lg__section">
|
||||
<div class="wp-lg__head">
|
||||
<span class="wp-lg__label">LABEL</span>
|
||||
<span class="wp-lg__head-note wp-lg__tokens">
|
||||
@@ -229,10 +251,10 @@ function text(ev: Event): string {
|
||||
>{{ t.token }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="textWired" class="wp-lg__wired" data-wp-socket="label_text" data-test="lg-template-wired">
|
||||
<span data-wp-part="label-text" data-wp-part-label="Label text" data-wp-part-section="Label" v-if="textWired" class="wp-lg__wired" data-wp-socket="label_text" data-test="lg-template-wired">
|
||||
<i class="pi pi-link" aria-hidden="true" />from the label_text input (tokens still work)
|
||||
</span>
|
||||
<Input
|
||||
<Input data-wp-part="label-text" data-wp-part-label="Label text" data-wp-part-section="Label"
|
||||
v-else
|
||||
size="sm"
|
||||
mono
|
||||
@@ -246,7 +268,7 @@ function text(ev: Event): string {
|
||||
@keydown.stop
|
||||
@change="set('label', text($event))"
|
||||
/>
|
||||
<div class="wp-lg__row">
|
||||
<div data-wp-part="place" data-wp-part-label="Place" data-wp-part-section="Label" class="wp-lg__row">
|
||||
<span class="wp-lg__key">Place</span>
|
||||
<WpSegmented
|
||||
:model-value="side"
|
||||
@@ -266,8 +288,18 @@ function text(ev: Event): string {
|
||||
data-test="lg-over"
|
||||
@update:model-value="setOver"
|
||||
/>
|
||||
<WpSegmented
|
||||
v-if="sideways"
|
||||
:model-value="cfg.orientation"
|
||||
:options="ORIENTATION_OPTIONS"
|
||||
size="sm"
|
||||
icon-only
|
||||
aria-label="Text direction"
|
||||
test-prefix="lg-orientation"
|
||||
@update:model-value="(v) => set('orientation', v)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="side !== 'none'" class="wp-lg__row">
|
||||
<div data-wp-part="font" data-wp-part-label="Font" data-wp-part-section="Label" v-if="side !== 'none'" class="wp-lg__row">
|
||||
<span class="wp-lg__key">Font</span>
|
||||
<div class="wp-lg__font" data-test="lg-font">
|
||||
<Combobox
|
||||
@@ -300,7 +332,7 @@ function text(ev: Event): string {
|
||||
@update:model-value="(v) => set('align', v)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="side !== 'none'" class="wp-lg__row">
|
||||
<div data-wp-part="label-colour" data-wp-part-label="Colour" data-wp-part-section="Label" v-if="side !== 'none'" class="wp-lg__row">
|
||||
<span class="wp-lg__key">Colour</span>
|
||||
<ColorField
|
||||
:model-value="cfg.text_color"
|
||||
@@ -321,7 +353,7 @@ function text(ev: Event): string {
|
||||
</section>
|
||||
|
||||
<!-- GRID ────────────────────────────────────────────────────────── -->
|
||||
<section class="wp-lg__section">
|
||||
<section data-wp-part="grid" data-wp-part-label="Whole grid section" data-wp-part-section="Grid" class="wp-lg__section">
|
||||
<div v-toggle-row class="wp-lg__head">
|
||||
<span class="wp-lg__label">GRID</span>
|
||||
<span class="wp-lg__head-note">{{ cfg.grid ? 'one image of every image' : 'off: only the labelled images' }}</span>
|
||||
@@ -333,7 +365,7 @@ function text(ev: Event): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="wp-lg__presets" role="group" aria-label="Grid presets">
|
||||
<div data-wp-part="presets" data-wp-part-label="Presets" data-wp-part-section="Grid" class="wp-lg__presets" role="group" aria-label="Grid presets">
|
||||
<Button
|
||||
v-for="p in PRESETS"
|
||||
:key="p.id"
|
||||
@@ -354,7 +386,7 @@ function text(ev: Event): string {
|
||||
</div>
|
||||
|
||||
<template v-if="cfg.grid">
|
||||
<div class="wp-lg__row">
|
||||
<div data-wp-part="layout" data-wp-part-label="Layout" data-wp-part-section="Grid" class="wp-lg__row">
|
||||
<span class="wp-lg__key">Layout</span>
|
||||
<WpSelect
|
||||
class="wp-lg__select"
|
||||
@@ -381,7 +413,7 @@ function text(ev: Event): string {
|
||||
<span v-else-if="headersLift" class="wp-lg__note">from the label parts</span>
|
||||
<span v-else-if="autoNote" class="wp-lg__note">{{ autoNote }}</span>
|
||||
</div>
|
||||
<div class="wp-lg__row" data-wp-socket="row_headers">
|
||||
<div data-wp-part="rows" data-wp-part-label="Rows" data-wp-part-section="Grid" class="wp-lg__row" data-wp-socket="row_headers">
|
||||
<span class="wp-lg__key">Rows</span>
|
||||
<span v-if="rowsWired" class="wp-lg__wired" data-test="lg-rows-wired">
|
||||
<i class="pi pi-link" aria-hidden="true" />from the row_headers input
|
||||
@@ -396,7 +428,7 @@ function text(ev: Event): string {
|
||||
@update:model-value="(v) => set('rows_from', v)"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
<Textarea data-wp-part="rows" data-wp-part-label="Rows" data-wp-part-section="Grid"
|
||||
v-if="cfg.rows_from === 'typed' && !rowsWired"
|
||||
mono
|
||||
class="wp-lg__area"
|
||||
@@ -409,7 +441,7 @@ function text(ev: Event): string {
|
||||
@keydown.stop
|
||||
@change="set('row_headers', text($event))"
|
||||
/>
|
||||
<div class="wp-lg__row" data-wp-socket="column_headers">
|
||||
<div data-wp-part="columns" data-wp-part-label="Columns" data-wp-part-section="Grid" class="wp-lg__row" data-wp-socket="column_headers">
|
||||
<span class="wp-lg__key">Columns</span>
|
||||
<span v-if="colsWired" class="wp-lg__wired" data-test="lg-columns-wired">
|
||||
<i class="pi pi-link" aria-hidden="true" />from the column_headers input
|
||||
@@ -424,7 +456,7 @@ function text(ev: Event): string {
|
||||
@update:model-value="(v) => set('columns_from', v)"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
<Textarea data-wp-part="columns" data-wp-part-label="Columns" data-wp-part-section="Grid"
|
||||
v-if="cfg.columns_from === 'typed' && !colsWired"
|
||||
mono
|
||||
class="wp-lg__area"
|
||||
@@ -437,7 +469,7 @@ function text(ev: Event): string {
|
||||
@keydown.stop
|
||||
@change="set('column_headers', text($event))"
|
||||
/>
|
||||
<div class="wp-lg__row" data-wp-socket="title">
|
||||
<div data-wp-part="title" data-wp-part-label="Title" data-wp-part-section="Grid" class="wp-lg__row" data-wp-socket="title">
|
||||
<span class="wp-lg__key">Title</span>
|
||||
<span v-if="titleWired" class="wp-lg__wired" data-test="lg-title-wired">
|
||||
<i class="pi pi-link" aria-hidden="true" />from the title input
|
||||
@@ -456,7 +488,7 @@ function text(ev: Event): string {
|
||||
@change="set('title', text($event))"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-lg__row wp-lg__row--wrap">
|
||||
<div data-wp-part="spacing" data-wp-part-label="Spacing" data-wp-part-section="Grid" class="wp-lg__row wp-lg__row--wrap">
|
||||
<span class="wp-lg__key">Spacing</span>
|
||||
<WpNumber
|
||||
class="wp-lg__num"
|
||||
@@ -491,7 +523,7 @@ function text(ev: Event): string {
|
||||
@update:model-value="setCellWidth"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-lg__row">
|
||||
<div data-wp-part="grid-colour" data-wp-part-label="Colour" data-wp-part-section="Grid" class="wp-lg__row">
|
||||
<span class="wp-lg__key">Colour</span>
|
||||
<ColorField
|
||||
:model-value="cfg.background"
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "./presets";
|
||||
|
||||
function run(over: Partial<LabelGridRun> = {}): LabelGridRun {
|
||||
return { items: 1, batchSizes: [1], count: 1, labelled: false, parts: [], auto: "fill", grid: null, ...over };
|
||||
return { items: 1, batchSizes: [1], count: 1, labelled: false, parts: [], auto: "fill", grid: null,
|
||||
missing: 0, extra: 0, rows: null, columns: null, noLabel: 0, ...over };
|
||||
}
|
||||
|
||||
const SWEEP = run({
|
||||
|
||||
@@ -184,13 +184,13 @@ function resetRules(): void {
|
||||
|
||||
<template>
|
||||
<div class="wp-mi" :class="{ 'wp-mi--skipped': isSkipped }">
|
||||
<section class="wp-mi__section">
|
||||
<section data-wp-part="detected" data-wp-part-label="All detected values" data-wp-part-section="Detected" class="wp-mi__section">
|
||||
<div class="wp-mi__head">
|
||||
<span class="wp-mi__label">DETECTED</span>
|
||||
<span class="wp-mi__head-note" title="What this node writes for the nodes after it">writes for later nodes</span>
|
||||
</div>
|
||||
<div class="wp-mi__vars">
|
||||
<div
|
||||
<div :data-wp-part="`var:${row.key}`" :data-wp-part-label="row.key" data-wp-part-section="Detected"
|
||||
v-for="row in rows"
|
||||
:key="row.key"
|
||||
class="wp-mi__var"
|
||||
@@ -249,7 +249,7 @@ function resetRules(): void {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wp-mi__section">
|
||||
<section data-wp-part="rules" data-wp-part-label="Variant rules" class="wp-mi__section">
|
||||
<div class="wp-mi__head">
|
||||
<span class="wp-mi__label">VARIANT RULES</span>
|
||||
<span class="wp-mi__head-note">first match in the file name wins</span>
|
||||
|
||||
@@ -181,7 +181,7 @@ function toggleOverrideStrategy(): void {
|
||||
'wp-seedlist--bypassed': isBypassed,
|
||||
}"
|
||||
>
|
||||
<div class="wp-seedlist__section">
|
||||
<div data-wp-part="strategy" data-wp-part-label="Strategy" class="wp-seedlist__section">
|
||||
<div class="wp-seedlist__label">strategy</div>
|
||||
<WpSegmented
|
||||
v-tooltip="strategyLocked ? STRATEGY_LOCKED_TOOLTIP : null"
|
||||
@@ -197,7 +197,7 @@ function toggleOverrideStrategy(): void {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<Button data-wp-part="seeds-button" data-wp-part-label="Per-iteration seeds button"
|
||||
size="sm"
|
||||
block
|
||||
icon="pi-list"
|
||||
@@ -215,7 +215,7 @@ function toggleOverrideStrategy(): void {
|
||||
<!-- Directly under the seeds button it belongs to: the chips are the
|
||||
inline view of what that modal lists, so they read as its expansion
|
||||
rather than as another unrelated section. -->
|
||||
<FrameChips
|
||||
<FrameChips data-wp-part="frames" data-wp-part-label="Seeds per frame"
|
||||
v-model:collapsed="chipsCollapsed"
|
||||
label="seeds"
|
||||
test-id="seedlist-frame"
|
||||
@@ -226,7 +226,7 @@ function toggleOverrideStrategy(): void {
|
||||
@toggle-lock="toggleFrameLock"
|
||||
/>
|
||||
|
||||
<div v-tooltip="OVERRIDE_SEED_TOOLTIP" v-toggle-row class="wp-seedlist__row">
|
||||
<div data-wp-part="override-seed" data-wp-part-label="Override base seed" data-wp-part-section="From the loop" v-tooltip="OVERRIDE_SEED_TOOLTIP" v-toggle-row class="wp-seedlist__row">
|
||||
<span class="wp-seedlist__row-label">Override base seed from loop</span>
|
||||
<WpSwitch
|
||||
data-test="seedlist-override-seed-toggle"
|
||||
@@ -236,7 +236,7 @@ function toggleOverrideStrategy(): void {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-tooltip="OVERRIDE_COUNT_TOOLTIP" v-toggle-row class="wp-seedlist__row">
|
||||
<div data-wp-part="override-count" data-wp-part-label="Override count" data-wp-part-section="From the loop" v-tooltip="OVERRIDE_COUNT_TOOLTIP" v-toggle-row class="wp-seedlist__row">
|
||||
<span class="wp-seedlist__row-label">Override count from loop</span>
|
||||
<WpSwitch
|
||||
data-test="seedlist-override-count-toggle"
|
||||
@@ -246,7 +246,7 @@ function toggleOverrideStrategy(): void {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-tooltip="OVERRIDE_STRATEGY_TOOLTIP" v-toggle-row class="wp-seedlist__row">
|
||||
<div data-wp-part="override-strategy" data-wp-part-label="Override strategy" data-wp-part-section="From the loop" v-tooltip="OVERRIDE_STRATEGY_TOOLTIP" v-toggle-row class="wp-seedlist__row">
|
||||
<span class="wp-seedlist__row-label">Override strategy from loop</span>
|
||||
<WpSwitch
|
||||
data-test="seedlist-override-strategy-toggle"
|
||||
|
||||
@@ -26,4 +26,5 @@
|
||||
}
|
||||
.wp-tb-icon.pi::before { line-height: 1; }
|
||||
.wp-tb-icon--edit.pi { font-size: 10px; }
|
||||
.wp-tb-icon--show.pi { font-size: 10px; }
|
||||
button:hover > .wp-tb-icon.pi { filter: brightness(1.12); }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ToggleRowView from "./ToggleRow.vue";
|
||||
import type { ToggleRow } from "../../extension/toggles";
|
||||
|
||||
const row = (on: boolean): ToggleRow => ({
|
||||
id: "00000001", kind: "toggle", name: "Detailer", var: "detailer", on, pin: false, out: 0, style: "buttons",
|
||||
});
|
||||
|
||||
const base = {
|
||||
pickOne: false, parentName: "", blocked: false, needsOptions: [], groupOptions: [],
|
||||
groupId: "", outputsFull: false, indent: 0, editing: false, editable: false,
|
||||
};
|
||||
|
||||
describe("ToggleRow buttons", () => {
|
||||
it.each([true, false])("a click on the lit side flips it (on: %s)", async (on) => {
|
||||
const w = mount(ToggleRowView, { props: { ...base, t: row(on), shownOn: on, effOn: on } });
|
||||
await w.find(`[data-test="tg-ctl-00000001-${on ? "on" : "off"}"]`).trigger("click");
|
||||
expect(w.emitted("set-on")).toEqual([[!on]]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("a click on the other side switches once", async () => {
|
||||
const w = mount(ToggleRowView, { props: { ...base, t: row(false), shownOn: false, effOn: false } });
|
||||
await w.find('[data-test="tg-ctl-00000001-on"]').trigger("click");
|
||||
expect(w.emitted("set-on")).toEqual([[true]]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("switches once even when the row re-renders before the click bubbles up", async () => {
|
||||
// On the canvas the row's new state is drawn while the click is still on its way
|
||||
// up, so a handler that ran after the kit's saw the side it had just lit.
|
||||
let w: ReturnType<typeof mount> | null = null;
|
||||
const relight = (): void => {
|
||||
w?.find('[data-test="tg-ctl-00000001-on"]').element.setAttribute("data-active", "true");
|
||||
w?.find('[data-test="tg-ctl-00000001-off"]').element.setAttribute("data-active", "false");
|
||||
};
|
||||
w = mount(ToggleRowView, { props: { ...base, t: row(false), shownOn: false, effOn: false, "onSet-on": relight } });
|
||||
await w.find('[data-test="tg-ctl-00000001-on"]').trigger("click");
|
||||
expect(w.emitted("set-on")).toEqual([[true]]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("does nothing while blocked", async () => {
|
||||
const w = mount(ToggleRowView, { props: { ...base, t: row(true), shownOn: true, effOn: true, blocked: true } });
|
||||
await w.find('[data-test="tg-ctl-00000001-on"]').trigger("click");
|
||||
expect(w.emitted("set-on")).toBeUndefined();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -105,10 +105,20 @@ function internalHint(): string {
|
||||
function flip(): void {
|
||||
emit("set-on", !props.shownOn);
|
||||
}
|
||||
/** Buttons: a click on the side that's already lit flips it too, so any
|
||||
* click on the pair switches it (the other side goes through the v-model).
|
||||
* Runs in the capture phase, before the kit's own handler: once the unlit
|
||||
* side's pick had re-rendered the row, a bubbling handler saw that side lit
|
||||
* and flipped it straight back, so clicking With did nothing. */
|
||||
function flipFromLit(e: MouseEvent): void {
|
||||
if (props.blocked) return;
|
||||
const opt = e.target instanceof Element ? e.target.closest(".wp-segmented__opt") : null;
|
||||
if (opt?.getAttribute("data-active") === "true") emit("set-on", !props.t.on);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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" :class="{ 'is-tile': tile }" :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 }"
|
||||
@@ -226,7 +236,10 @@ function flip(): void {
|
||||
@update:model-value="(v: boolean) => emit('set-on', v)"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="look === 'buttons'" v-tooltip="blockedHint()" class="wp-tgr__ctl" :style="tintStyle">
|
||||
<!-- Only catches clicks on their way to the kit's own option buttons
|
||||
(Space / Enter on them fire a click too), so it needs no keys. -->
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
||||
<span v-else-if="look === 'buttons'" v-tooltip="blockedHint()" class="wp-tgr__ctl" :style="tintStyle" @click.capture="flipFromLit">
|
||||
<WpSegmented
|
||||
:model-value="t.on ? 'on' : 'off'"
|
||||
:options="[{ value: 'on', label: t.on_label || 'On' }, { value: 'off', label: t.off_label || 'Off' }]"
|
||||
@@ -296,6 +309,12 @@ 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;
|
||||
@@ -358,6 +377,13 @@ function flip(): void {
|
||||
.wp-tgr__btns { display: inline-flex; align-items: center; gap: 2px; flex: none; }
|
||||
/* Mute / bypass icon: dim while it isn't acting, coloured while it is
|
||||
* (bypass in the host's bypass violet, mute in amber). */
|
||||
/* Buttons: a picked On lights up in the accent like a switch's track, so the
|
||||
toggle's control colour (tint) shows; a picked Off stays neutral. */
|
||||
.wp-tgr__ctl :deep(.wp-segmented__opt[data-active="true"]:first-child) {
|
||||
background: color-mix(in srgb, var(--wp-accent) 18%, var(--wp-bg-4));
|
||||
color: var(--wp-accent-text);
|
||||
box-shadow: var(--wp-shadow-sm), inset 0 0 0 1px var(--wp-accent);
|
||||
}
|
||||
.wp-tgr__ctlicon { flex: none; font-size: 10px; color: var(--wp-text-dim, var(--wp-text3)); opacity: 0.7; }
|
||||
.wp-tgr__ctlicon.is-active { opacity: 1; }
|
||||
.wp-tgr__ctlicon.is-active.is-bypass { color: var(--wp-violet); }
|
||||
|
||||
@@ -175,3 +175,33 @@ describe("TogglesWidget edit-view drag", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import TogglesWidget from "./TogglesWidget.vue";
|
||||
import ToggleRowView from "./ToggleRow.vue";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import {
|
||||
defaultTogglesConfig, effectiveToggles, parseTogglesConfig, pickedId,
|
||||
type ToggleRow, type TogglesConfig,
|
||||
} from "../../extension/toggles";
|
||||
|
||||
const row = (id: string, name: string, on: boolean): ToggleRow => ({
|
||||
id, kind: "toggle", name, var: name.toLowerCase(), on, pin: false, out: 0, style: "switch",
|
||||
});
|
||||
const cfg = (required: boolean, ons: [boolean, boolean, boolean]): TogglesConfig => ({
|
||||
...defaultTogglesConfig(),
|
||||
mode: "one",
|
||||
...(required ? { pick_required: true as const } : {}),
|
||||
rows: [row("00000001", "Dawn", ons[0]), row("00000002", "Noon", ons[1]), row("00000003", "Dusk", ons[2])],
|
||||
});
|
||||
function lastConfig(w: { emitted: (e: string) => unknown[][] | undefined }): TogglesConfig | undefined {
|
||||
const all = w.emitted("update:modelValue");
|
||||
return all?.[all.length - 1]?.[0] as TogglesConfig | undefined;
|
||||
}
|
||||
function rowView(w: ReturnType<typeof mount>, id: string) {
|
||||
const v = w.findAllComponents(ToggleRowView).find((c) => c.props("t").id === id);
|
||||
if (!v) throw new Error(`no row ${id}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
describe("Pick one: Always one on", () => {
|
||||
it("keeps the flag through a parse, and only as true", () => {
|
||||
expect(parseTogglesConfig(JSON.stringify(cfg(true, [false, true, false]))).pick_required).toBe(true);
|
||||
expect(parseTogglesConfig(JSON.stringify({ ...cfg(false, [false, true, false]), pick_required: false })).pick_required).toBeUndefined();
|
||||
});
|
||||
|
||||
it("picks the first toggle when none is on", () => {
|
||||
expect(pickedId(cfg(true, [false, false, false]))).toBe("00000001");
|
||||
expect(pickedId(cfg(false, [false, false, false]))).toBeUndefined();
|
||||
expect(effectiveToggles(cfg(true, [false, false, false])).get("00000001")).toBe(true);
|
||||
});
|
||||
|
||||
it("the picked toggle can't be switched off; picking another still works", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg(true, [false, true, false]) } });
|
||||
rowView(w, "00000002").vm.$emit("set-on", false);
|
||||
await w.vm.$nextTick();
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
rowView(w, "00000003").vm.$emit("set-on", true);
|
||||
await w.vm.$nextTick();
|
||||
const next = lastConfig(w);
|
||||
expect(next?.rows.map((r) => (r.kind === "toggle" ? r.on : null))).toEqual([false, false, true]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("without it, the picked toggle switches off (none on)", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg(false, [false, true, false]) } });
|
||||
rowView(w, "00000002").vm.$emit("set-on", false);
|
||||
await w.vm.$nextTick();
|
||||
expect(lastConfig(w)?.rows.every((r) => r.kind !== "toggle" || !r.on)).toBe(true);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("turning it on with none on switches the first toggle on", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg(false, [false, false, false]), editRequest: 0 } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
const box = w.findAllComponents(Checkbox).find((c) => c.attributes("data-test") === "tg-pick-required");
|
||||
if (!box) throw new Error("no Always one on box");
|
||||
box.vm.$emit("update:modelValue", true);
|
||||
await w.vm.$nextTick();
|
||||
const next = lastConfig(w);
|
||||
expect(next?.pick_required).toBe(true);
|
||||
expect(next?.rows.map((r) => (r.kind === "toggle" ? r.on : null))).toEqual([true, false, false]);
|
||||
box.vm.$emit("update:modelValue", false);
|
||||
await w.vm.$nextTick();
|
||||
expect(lastConfig(w)?.pick_required).toBeUndefined();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,9 @@ import {
|
||||
dropToggleRow,
|
||||
type ToggleDropEdge,
|
||||
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";
|
||||
@@ -219,11 +221,27 @@ function isBlocked(t: ToggleRow): boolean {
|
||||
function setOn(t: ToggleRow, on: boolean): void {
|
||||
if (isBlocked(t)) return;
|
||||
if (isOne.value) {
|
||||
// Always one on: the picked toggle only goes off by picking another.
|
||||
if (!on && cfg.value.pick_required) return;
|
||||
setRows(cfg.value.rows.map((r) => (r.kind === "toggle" ? { ...r, on: r.id === t.id ? on : false } : r)));
|
||||
} else {
|
||||
patchToggle(t.id, { on });
|
||||
}
|
||||
}
|
||||
/** Always one on: turning it on with nothing on switches the first toggle on,
|
||||
* so what the node shows and what it saves agree. */
|
||||
function setPickRequired(v: boolean): void {
|
||||
if (!v) {
|
||||
commit({ pick_required: undefined });
|
||||
return;
|
||||
}
|
||||
const toggles = toggleRows(cfg.value);
|
||||
const first = toggles[0];
|
||||
const rows = first && !toggles.some((x) => x.on)
|
||||
? cfg.value.rows.map((r) => (r.kind === "toggle" && r.id === first.id ? { ...r, on: true } : r))
|
||||
: cfg.value.rows;
|
||||
commit({ rows, pick_required: true });
|
||||
}
|
||||
/** What the control shows: Pick one shows the pick, Any on the own choice. */
|
||||
function shownOn(t: ToggleRow): boolean {
|
||||
return isOne.value ? !!eff.value.get(t.id) : t.on;
|
||||
@@ -417,6 +435,11 @@ 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;
|
||||
}
|
||||
@@ -437,7 +460,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="wp-tg" :class="{ 'wp-tg--skipped': isSkipped, 'wp-tg--edit': editMode }">
|
||||
<div ref="root" class="wp-tg" data-wp-reveal :class="{ 'wp-tg--skipped': isSkipped, 'wp-tg--edit': editMode }" @wp-reveal="onReveal">
|
||||
<div v-if="editMode" class="wp-tg__bar">
|
||||
<WpSegmented
|
||||
:model-value="cfg.mode"
|
||||
@@ -502,6 +525,16 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
/>
|
||||
<span aria-hidden="true">Upstream can pick</span>
|
||||
</span>
|
||||
<span v-tooltip="'Like radio buttons: one toggle is always on. Clicking the picked one does nothing; pick another to switch. With none on, the first toggle is picked'" v-toggle-row class="wp-tg__check">
|
||||
<Checkbox
|
||||
:model-value="!!cfg.pick_required"
|
||||
size="sm"
|
||||
aria-label="Always one on"
|
||||
data-test="tg-pick-required"
|
||||
@update:model-value="setPickRequired"
|
||||
/>
|
||||
<span aria-hidden="true">Always one on</span>
|
||||
</span>
|
||||
<span class="wp-tg__pickstyle">
|
||||
<span class="wp-tg__mode-cap">Style</span>
|
||||
<WpSegmented
|
||||
@@ -517,7 +550,12 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
|
||||
<template v-for="(g, gi) in groups" :key="groupKey(g, gi)">
|
||||
<!-- Clean view: plain rows, no grips. -->
|
||||
<div v-if="!editMode" class="wp-tg__group-box">
|
||||
<div
|
||||
v-if="!editMode"
|
||||
class="wp-tg__group-box"
|
||||
:data-wp-part="`group:${groupKey(g, gi)}`"
|
||||
:data-wp-part-label="g.label ? `${g.label.name || 'Group'} (whole group)` : 'Toggles outside a group'"
|
||||
>
|
||||
<div
|
||||
v-if="g.label"
|
||||
class="wp-tg__label"
|
||||
@@ -544,6 +582,8 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
v-for="item in g.toggles"
|
||||
:key="item.id"
|
||||
v-bind="rowProps(item)"
|
||||
:data-wp-part="`toggle:${item.id}`"
|
||||
:data-wp-part-label="item.name || 'Toggle'"
|
||||
:indent="0"
|
||||
tile
|
||||
@set-on="(v: boolean) => setOn(item, v)"
|
||||
@@ -593,6 +633,8 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
v-for="item in g.toggles"
|
||||
:key="item.id"
|
||||
v-bind="rowProps(item)"
|
||||
:data-wp-part="`toggle:${item.id}`"
|
||||
:data-wp-part-label="item.name || 'Toggle'"
|
||||
@set-on="(v: boolean) => setOn(item, v)"
|
||||
/>
|
||||
</template>
|
||||
@@ -751,19 +793,16 @@ 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: tiles wrap to the node's width, never resize it. */
|
||||
/* 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. */
|
||||
.wp-tg__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 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; }
|
||||
}
|
||||
.wp-tg__grid > .wp-tgr { flex: 1 1 auto; min-width: 96px; max-width: 100%; }
|
||||
/* 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 {
|
||||
|
||||
@@ -148,4 +148,18 @@ describe("VarPicker", () => {
|
||||
expect(w.find('[data-test="var-picker-preview"]').text()).toContain("fallback (pony.safetensors)");
|
||||
expect(w.find('[data-test="var-picker-wired"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("a stopped run reads red and its reason replaces the old note", () => {
|
||||
const w = mount(VarPicker, {
|
||||
props: {
|
||||
modelValue: "$sampler", upstreamVars: ["$sampler"], previewSource: "", previewParsed: "euler", previewDefault: "euler",
|
||||
executed: true, choiceNote: "No match for “x”. Used euler.", runError: "The variable is empty in KSampler > sampler_name",
|
||||
},
|
||||
});
|
||||
expect(w.get('[data-test="var-picker-preview"]').classes()).toContain("wp-var-picker__preview--failed");
|
||||
expect(w.get('[data-test="var-picker-preview"]').text()).toContain("stopped the run");
|
||||
expect(w.get('[data-test="var-picker-run-error"]').text()).toBe("The variable is empty in KSampler > sampler_name");
|
||||
expect(w.find('[data-test="var-picker-choice-note"]').exists()).toBe(false);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
* - `choiceNote: string` — WP_VarToChoice only: how the last run found its
|
||||
* value (`Matched “x” loosely.`, `No match for “x”. Used y.`). Replaces the
|
||||
* `parsed from` line, and the amber state reads "fallback".
|
||||
* - `runError: string` — the last run stopped here (`if_no_match` "Stop the
|
||||
* run"): the row reads "stopped the run" in red and this message replaces
|
||||
* the previous run's note. Cleared when the next run reaches the node.
|
||||
*
|
||||
* Emits:
|
||||
* - `update:modelValue(next: string)` — fires when user picks a new var.
|
||||
@@ -46,8 +49,9 @@ const props = withDefaults(
|
||||
wiredTo?: string;
|
||||
choiceNote?: string;
|
||||
executed?: boolean;
|
||||
runError?: string;
|
||||
}>(),
|
||||
{ previewSource: "", previewParsed: null, previewDefault: "", nodeMode: 0, wiredTo: "", choiceNote: "", executed: false },
|
||||
{ previewSource: "", previewParsed: null, previewDefault: "", nodeMode: 0, wiredTo: "", choiceNote: "", executed: false, runError: "" },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: string] }>();
|
||||
@@ -61,7 +65,8 @@ function onPick(next: string | string[] | null): void {
|
||||
if (typeof next === "string" && next) emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
const hasExecuted = computed<boolean>(() => props.executed || Boolean(props.previewSource || props.choiceNote));
|
||||
const failed = computed<boolean>(() => Boolean(props.runError));
|
||||
const hasExecuted = computed<boolean>(() => failed.value || props.executed || Boolean(props.previewSource || props.choiceNote));
|
||||
const usedDefault = computed<boolean>(() => props.previewParsed === null);
|
||||
const isMuted = computed<boolean>(() => props.nodeMode === 2);
|
||||
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
@@ -83,31 +88,36 @@ const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
@update:model-value="onPick"
|
||||
/>
|
||||
|
||||
<div
|
||||
<div data-wp-part="last-run" data-wp-part-label="Last run"
|
||||
class="wp-var-picker__preview"
|
||||
:class="{
|
||||
'wp-var-picker__preview--default': hasExecuted && usedDefault,
|
||||
'wp-var-picker__preview--failed': failed,
|
||||
'wp-var-picker__preview--default': !failed && hasExecuted && usedDefault,
|
||||
'wp-var-picker__preview--idle': !hasExecuted,
|
||||
}"
|
||||
data-test="var-picker-preview"
|
||||
>
|
||||
<span class="wp-var-picker__preview-label">last execute</span>
|
||||
<span class="wp-var-picker__preview-value">
|
||||
<template v-if="!hasExecuted">→ <em>run workflow to see result</em></template>
|
||||
<template v-if="failed">→ stopped the run</template>
|
||||
<template v-else-if="!hasExecuted">→ <em>run workflow to see result</em></template>
|
||||
<template v-else-if="usedDefault">→ {{ choiceNote ? "fallback" : "default" }} ({{ previewDefault }})</template>
|
||||
<template v-else>→ {{ previewParsed }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="hasExecuted && choiceNote" class="wp-var-picker__preview-src" data-test="var-picker-choice-note">
|
||||
<div data-wp-part="last-run" data-wp-part-label="Last run" v-if="failed" class="wp-var-picker__preview-src wp-var-picker__preview-src--failed" role="alert" data-test="var-picker-run-error">
|
||||
{{ runError }}
|
||||
</div>
|
||||
<div data-wp-part="last-run" data-wp-part-label="Last run" v-else-if="hasExecuted && choiceNote" class="wp-var-picker__preview-src" data-test="var-picker-choice-note">
|
||||
{{ choiceNote }}
|
||||
</div>
|
||||
<div v-else-if="hasExecuted && previewSource" class="wp-var-picker__preview-src" data-test="var-picker-source">
|
||||
<div data-wp-part="last-run" data-wp-part-label="Last run" v-else-if="hasExecuted && previewSource" class="wp-var-picker__preview-src" data-test="var-picker-source">
|
||||
parsed from "{{ previewSource }}"
|
||||
</div>
|
||||
<div v-else-if="hasExecuted && picked" class="wp-var-picker__preview-src" data-test="var-picker-unset">
|
||||
<div data-wp-part="last-run" data-wp-part-label="Last run" v-else-if="hasExecuted && picked" class="wp-var-picker__preview-src" data-test="var-picker-unset">
|
||||
{{ picked }} was not set
|
||||
</div>
|
||||
<div v-if="wiredTo" class="wp-var-picker__wired" data-test="var-picker-wired">
|
||||
<div data-wp-part="wired-to" data-wp-part-label="What it feeds" v-if="wiredTo" class="wp-var-picker__wired" data-test="var-picker-wired">
|
||||
<span class="wp-var-picker__preview-label">into</span>
|
||||
<span class="wp-var-picker__wired-value">{{ wiredTo }}</span>
|
||||
</div>
|
||||
@@ -135,6 +145,9 @@ const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
font-size: 10px;
|
||||
}
|
||||
.wp-var-picker__preview--default { border-left-color: var(--wp-status-modified); }
|
||||
.wp-var-picker__preview--failed { border-left-color: var(--wp-danger); }
|
||||
.wp-var-picker__preview--failed .wp-var-picker__preview-value { color: var(--wp-danger-text); }
|
||||
.wp-var-picker__preview-src.wp-var-picker__preview-src--failed { color: var(--wp-danger-text); white-space: normal; overflow-wrap: anywhere; }
|
||||
.wp-var-picker__preview--idle { border-left-color: var(--wp-text-dim); }
|
||||
.wp-var-picker__preview--idle .wp-var-picker__preview-value { color: var(--wp-text-dim); font-weight: 400; font-style: italic; }
|
||||
.wp-var-picker__preview-label { color: var(--wp-text-dim); flex: none; }
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ import {
|
||||
collectUpstreamProducers,
|
||||
collectUpstreamRenderableVariables,
|
||||
collectUpstreamResolved,
|
||||
readToggleBools,
|
||||
collectUpstreamVariables,
|
||||
collectUpstreamWildcardUuids,
|
||||
findWildcardHomesElsewhere,
|
||||
@@ -277,6 +278,49 @@ describe("collectUpstreamProducers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("collectUpstreamInjectorNegatives: a template row carries the negatives of what it reads", () => {
|
||||
const inj: LiteNodeLike = {
|
||||
id: 1,
|
||||
type: "WP_ContextInjector",
|
||||
outputs: [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [{
|
||||
name: "wp_rows",
|
||||
value: JSON.stringify({
|
||||
version: 1,
|
||||
rows: [
|
||||
{ kind: "general", binding: "outfit", enabled: true, template: "$cloak, $input_0 $$x", negative: "dull" },
|
||||
{ binding: "cloak", enabled: true, slot_name: "input_0", negative: "torn fabric" },
|
||||
{ kind: "general", binding: "raw", enabled: true, template: "$input_0" },
|
||||
],
|
||||
}),
|
||||
}],
|
||||
};
|
||||
const pov = ctxWriting(2, "Other", "unused", 100);
|
||||
const g = chain([inj, pov], { 100: { origin_id: 1, target_id: 2 } });
|
||||
expect(collectUpstreamInjectorNegatives(g, pov)).toEqual({
|
||||
cloak: "torn fabric",
|
||||
outfit: "torn fabric, dull",
|
||||
raw: null,
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -953,6 +997,61 @@ describe("collectUpstreamResolved axis reads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUpstreamResolved toggle values", () => {
|
||||
it("reads variables and prints $$ as $ in a toggle's value, as the node does", () => {
|
||||
const rows = [
|
||||
{ id: "a", kind: "toggle", name: "Rain", var: "rain", on: true, pin: false, out: 0, style: "switch", value: "rain on a $$5 coat" },
|
||||
{ id: "b", kind: "toggle", name: "Mail", var: "mail", on: true, pin: false, out: 0, style: "switch", value: "$zed at mail@@home" },
|
||||
{ id: "c", kind: "toggle", name: "Plain", var: "plain", on: true, pin: false, out: 0, style: "switch", value: "mail@@home" },
|
||||
{ id: "d", kind: "toggle", name: "Zed", var: "zed", on: true, pin: false, out: 0, style: "switch", value: "$rain, $$1" },
|
||||
];
|
||||
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 asm: LiteNodeLike = { id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }] };
|
||||
const g = {
|
||||
_nodes: [tg, asm],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id: number) => ({ 1: tg, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
} as LiteGraphLike;
|
||||
const out = collectUpstreamResolved(g, asm);
|
||||
expect(out.rain).toBe("rain on a $5 coat");
|
||||
// A value read by another is resolved first, whatever the row order.
|
||||
expect(out.zed).toBe("rain on a $5 coat, $1");
|
||||
expect(out.mail).toBe("rain on a $5 coat, $1 at mail@home");
|
||||
expect(out.plain).toBe("mail@home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUpstreamResolved toggle switches", () => {
|
||||
it("carries each toggle's switch for $name.bool, whatever words it writes", () => {
|
||||
const rows = [
|
||||
{ id: "a", kind: "toggle", name: "Lantern", var: "lantern", on: false, pin: false, out: 0, style: "switch", value: "holding a lantern", off_value: "with empty hands" },
|
||||
{ id: "b", kind: "toggle", name: "Note", var: "note", on: true, pin: false, out: 0, style: "switch", value: "lantern on: $lantern.bool" },
|
||||
];
|
||||
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 asm: LiteNodeLike = { id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }] };
|
||||
const g = {
|
||||
_nodes: [tg, asm],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id: number) => ({ 1: tg, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
} as LiteGraphLike;
|
||||
const out = collectUpstreamResolved(g, asm);
|
||||
expect(out.lantern).toBe("with empty hands");
|
||||
expect(readToggleBools(out)).toEqual({ lantern: false, note: true });
|
||||
// Off with an Off value is still false, not "has words, so true".
|
||||
expect(out.note).toBe("lantern on: false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUpstreamResolved derivation condition groups", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
|
||||
@@ -1307,6 +1406,30 @@ describe("collectLocalResolvedForModule", () => {
|
||||
expect(result.result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prints $$ / @@ in module values as one $ / @, as the engine does", () => {
|
||||
const { ctx, graph } = singleNode([
|
||||
{
|
||||
id: "fv000009", type: "fixed_values", enabled: true, meta: { name: "" }, entries: [],
|
||||
payload: { values: [{ id: "v1", name: "price", value: "$$5" }] },
|
||||
},
|
||||
{
|
||||
id: "wc000009", type: "wildcard", enabled: true,
|
||||
meta: { name: "" }, entries: [],
|
||||
payload: { var_binding: "$tag", options: [{ id: "o1", value: "mail@@home, $$tag" }] },
|
||||
},
|
||||
{
|
||||
id: "cb000009", type: "combine", enabled: true,
|
||||
meta: { name: "" }, entries: [],
|
||||
payload: { template: "$$$price coin, $$tag, {2$$, $$a|b}", output_var: "line" },
|
||||
},
|
||||
]);
|
||||
const result = collectLocalResolvedForModule(graph, ctx);
|
||||
expect(result.price).toBe("$5");
|
||||
expect(result.tag).toBe("mail@home, $tag");
|
||||
// `$$` then a read of `$price` (already "$5"); a pick stays as written.
|
||||
expect(result.line).toBe("$$5 coin, $tag, {2$$, $$a|b}");
|
||||
});
|
||||
|
||||
it("match_variable only matches options the node's toggles keep", () => {
|
||||
const wc = (enabled: string[]) => ({
|
||||
id: "wc000002", type: "wildcard", enabled: true, meta: { name: "" }, entries: [],
|
||||
|
||||
+387
-95
@@ -4,7 +4,8 @@ import {
|
||||
parseWidgetJson,
|
||||
type ContextWidgetValue,
|
||||
} from "../widgets/_shared";
|
||||
import { applyVarAccessor, type ResolvedValue } from "../widgets/richTokenize";
|
||||
import { applyVarAccessor, varAccessorParts, type ResolvedValue } from "../widgets/richTokenize";
|
||||
import { templatePieces, templateVarReads, unescapeTemplate } from "./template-pieces";
|
||||
import { bindFilterForRun } from "@/manager/parsing/subcatFilter";
|
||||
// probability.ts is a pure (no-Vue) module despite its path — reused here so
|
||||
// the static multi-pick representative mirrors the engine pool exactly.
|
||||
@@ -19,10 +20,11 @@ import {
|
||||
cacheVersion as previewCacheVersion,
|
||||
} from "./preview-resolver";
|
||||
import { assignCodenames, baseCodename } from "./node-codename";
|
||||
import { TOGGLES_NODE, staticToggleInternals, staticToggleValues, togglesPreviewModules } from "./toggles";
|
||||
import { TOGGLES_NODE, TOGGLE_BOOLS_KEY, nodeTogglesConfig, resolveToggles, staticToggleInternals, toggleBools, staticToggleNegatives, staticToggleValues, togglesPreviewModules, type TogglesConfig } 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";
|
||||
import { effectiveEnabled, withFrameInstance } from "../components/context/frame-overrides";
|
||||
|
||||
// ── Subgraph boundary primer ────────────────────────────────────────────
|
||||
// ComfyUI subgraphs are nested LGraph instances attached to a parent node
|
||||
@@ -760,25 +762,55 @@ export function collectUpstreamInjectorNegatives(
|
||||
continue;
|
||||
}
|
||||
if (n.type === TOGGLES_NODE && !isSkippedMode(n)) {
|
||||
for (const v of Object.keys(staticToggleValues(n))) out[v] = null;
|
||||
// A write replaces the variable's negatives with the toggle's own, after
|
||||
// those of the `$vars` its value reads (as the node files them).
|
||||
const values = staticToggleValues(n);
|
||||
const negs = staticToggleNegatives(n);
|
||||
const before = { ...out };
|
||||
for (const v of Object.keys(negs)) out[v] = inheritNegatives(before, values[v] ?? "", negs[v] ?? "");
|
||||
continue;
|
||||
}
|
||||
if (n.type !== "WP_ContextInjector" || isSkippedMode(n)) continue;
|
||||
const inj = parseCached<{
|
||||
version: 1;
|
||||
rows?: Array<{ binding?: string; enabled?: boolean; negative?: string }>;
|
||||
rows?: Array<{ binding?: string; enabled?: boolean; negative?: string; kind?: string; template?: string }>;
|
||||
}>(widgetValue(n, "wp_rows"), { version: 1, rows: [] });
|
||||
for (const row of inj.rows ?? []) {
|
||||
// Socket rows first, then template rows in order, as the node runs them.
|
||||
const rows = inj.rows ?? [];
|
||||
const ordered = [...rows.filter((r) => r.kind !== "general"), ...rows.filter((r) => r.kind === "general")];
|
||||
for (const row of ordered) {
|
||||
if (row.enabled !== true) continue;
|
||||
const binding = (row.binding ?? "").trim();
|
||||
if (!binding) continue;
|
||||
const neg = typeof row.negative === "string" ? row.negative.trim() : "";
|
||||
out[binding] = neg || null;
|
||||
// A template row carries the negatives of the variables it reads.
|
||||
out[binding] = row.kind === "general"
|
||||
? inheritNegatives(out, row.template ?? "", neg)
|
||||
: neg || null;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const READ_RE = /\$\$|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
const SOCKET_RE = /^input_\d+$/;
|
||||
|
||||
/** `own` after the negatives (in `known`) of each `$var` `text` reads, or
|
||||
* `null` when there are none. Raw sockets (`$input_N`) carry nothing. */
|
||||
function inheritNegatives(known: Record<string, string | null>, text: string, own: string): string | null {
|
||||
const parts: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of text.matchAll(READ_RE)) {
|
||||
const name = m[1];
|
||||
if (!name || SOCKET_RE.test(name) || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
const got = known[name];
|
||||
if (got) parts.push(got);
|
||||
}
|
||||
if (own) parts.push(own);
|
||||
return parts.length ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upstream from `node` and return the set of every wildcard
|
||||
* module's uuid (8-hex `id`) reachable in the chain. Used by the
|
||||
@@ -847,7 +879,7 @@ export function collectUpstreamChain(
|
||||
* `seeds`: filled with one entry per returned step, the Context's own
|
||||
* `seed` widget (null for a model / toggles step, or a seed it can't
|
||||
* read), so the preview rolls each step on the seed the run will use. */
|
||||
opts: { modelSteps?: boolean; seeds?: (number | null)[] } = {},
|
||||
opts: { modelSteps?: boolean; seeds?: (number | null)[]; frame?: number | null } = {},
|
||||
): unknown[][] {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
@@ -892,12 +924,16 @@ export function collectUpstreamChain(
|
||||
// gate, the assembler's API preview returns bindings from
|
||||
// bundle-disabled modules while the static fallback (which IS
|
||||
// gated) doesn't, and chip strip / preview disagree.
|
||||
// `frame`: the server runs a flat module list with no loop, so lay the
|
||||
// frame's switches and instance patches on before sending it.
|
||||
const frame = opts.frame;
|
||||
const modules = frame != null && frame >= 0 ? v.modules.map((m) => frameModule(m, frame)) : v.modules;
|
||||
const bundleIndex = buildBundleEnabledMap(v.bundles);
|
||||
if (bundleIndex.enabled.size === 0) {
|
||||
out.push(v.modules);
|
||||
out.push(modules);
|
||||
continue;
|
||||
}
|
||||
const gated = v.modules.map((m) => {
|
||||
const gated = modules.map((m) => {
|
||||
if (m.enabled === false) return m;
|
||||
// isModuleEffectivelyEnabled walks the parent_uid chain so a
|
||||
// disabled outer disables inner-bundle leaves too. AND with the
|
||||
@@ -935,6 +971,8 @@ export const collectUpstreamValues = collectUpstreamResolved;
|
||||
export function collectUpstreamKinds(
|
||||
rootGraph: LiteGraphLike,
|
||||
node: LiteNodeLike,
|
||||
/** A loop frame (0-based): modules as that frame runs them. */
|
||||
frame: number | null = null,
|
||||
): Record<string, string> {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
@@ -1021,7 +1059,8 @@ export function collectUpstreamKinds(
|
||||
{ version: 1, modules: [] },
|
||||
);
|
||||
const bundleEnabled = buildBundleEnabledMap(v.bundles);
|
||||
for (const m of v.modules) {
|
||||
for (const entry of v.modules) {
|
||||
const m = frame != null ? frameModule(entry, frame) : entry;
|
||||
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
|
||||
// Per-kind binding emission — mirrors writeBindings, recording
|
||||
// kind instead of value. fixed_values fans out per row; the
|
||||
@@ -1388,11 +1427,6 @@ export function collectUpstreamProducers(
|
||||
* matching the runtime walker's `max_ref_depth`.
|
||||
* -------------------------------------------------------------------- */
|
||||
|
||||
// SP2a: optional `.K` list accessor (group 2) so `$mood.0` is consumed whole
|
||||
// (no stranded ".0" literal) and the index drives applyVarAccessor below.
|
||||
const VAR_REF_RE =
|
||||
/\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?(?:\.([A-Za-z_][A-Za-z0-9_]*))?(?:\.(\d+))?/g;
|
||||
const WC_REF_RE = /@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}/g;
|
||||
const MAX_REF_DEPTH = 8;
|
||||
|
||||
interface MinimalWildcard {
|
||||
@@ -1451,7 +1485,8 @@ interface ChainMemo {
|
||||
ctx: Record<string, ResolvedValue>;
|
||||
externalRefs: Set<string>;
|
||||
}
|
||||
const chainMemo = new WeakMap<LiteNodeLike, ChainMemo>();
|
||||
/** Per head node: one entry per view ("base", or "frame/count"). */
|
||||
const chainMemo = new WeakMap<LiteNodeLike, Map<string, ChainMemo>>();
|
||||
let chainMemoEnabled = true;
|
||||
|
||||
/** @internal test hook — measure or compare against the unmemoised path. */
|
||||
@@ -1476,6 +1511,9 @@ function chainParts(chain: LiteNodeLike[]): unknown[] {
|
||||
parts.push(JSON.stringify(staticModelValues(n)), [...internalModelVars(n)].join(","));
|
||||
}
|
||||
if (n.type === TOGGLES_NODE) parts.push(widgetValue(n, "wp_toggles"));
|
||||
// Wiring the Loop's `values` input adds `$<name>_value` without
|
||||
// touching a widget, so the link has to key the memo too.
|
||||
if (n.type === "WP_ContextLoop") parts.push(loopValuesWired(n));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
@@ -1486,12 +1524,14 @@ function sameParts(a: unknown[], b: unknown[]): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveChainMemo(chain: LiteNodeLike[]): Record<string, ResolvedValue> {
|
||||
if (!chainMemoEnabled || chain.length === 0) return resolveChainStatic(chain);
|
||||
function resolveChainMemo(chain: LiteNodeLike[], view?: FrameView): Record<string, ResolvedValue> {
|
||||
if (!chainMemoEnabled || chain.length === 0) return resolveChainStatic(chain, undefined, view);
|
||||
const head = chain[0];
|
||||
const parts = chainParts(chain);
|
||||
const version = previewCacheVersion.value;
|
||||
const hit = chainMemo.get(head);
|
||||
const key = view ? `${view.frame}/${view.count}` : "base";
|
||||
let byView = chainMemo.get(head);
|
||||
const hit = byView?.get(key);
|
||||
if (hit && hit.version === version && sameParts(hit.parts, parts)) {
|
||||
// Keep the lazy fetch alive: `ensure` is a no-op for fresh entries, but it
|
||||
// retries expired failures and refetches after `markAllStale`.
|
||||
@@ -1499,14 +1539,30 @@ function resolveChainMemo(chain: LiteNodeLike[]): Record<string, ResolvedValue>
|
||||
return { ...hit.ctx };
|
||||
}
|
||||
const externalRefs = new Set<string>();
|
||||
const ctx = resolveChainStatic(chain, externalRefs);
|
||||
chainMemo.set(head, { parts, version, ctx: { ...ctx }, externalRefs });
|
||||
const ctx = resolveChainStatic(chain, externalRefs, view);
|
||||
if (!byView) {
|
||||
byView = new Map();
|
||||
chainMemo.set(head, byView);
|
||||
}
|
||||
// A frame view is keyed by its frame, so a long loop stepped through
|
||||
// frame by frame would grow the map without bound: keep the latest few.
|
||||
if (byView.size > 16) byView.clear();
|
||||
byView.set(key, { parts, version, ctx: { ...ctx }, externalRefs });
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** One loop frame to resolve the chain at: `frame` is 0-based (`-1` reads
|
||||
* every module at its base state, with no frame override), `count` the
|
||||
* Loop's frame count. */
|
||||
export interface FrameView {
|
||||
frame: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function resolveChainStatic(
|
||||
chain: LiteNodeLike[],
|
||||
externalRefs: Set<string> = new Set<string>(),
|
||||
view?: FrameView,
|
||||
): Record<string, ResolvedValue> {
|
||||
// First pass: collect every wildcard payload across the whole chain
|
||||
// into one `id → payload` catalog so `@{}` refs inside option values
|
||||
@@ -1610,7 +1666,12 @@ function resolveChainStatic(
|
||||
}
|
||||
if (n.type === TOGGLES_NODE) {
|
||||
const hidden = staticToggleInternals(n);
|
||||
for (const [k, v] of Object.entries(staticToggleValues(n))) {
|
||||
const cfg = nodeTogglesConfig(n);
|
||||
// What `$name.bool` reads: the switch, not the Value it writes
|
||||
// (engine `toggles.BOOLS_KEY`, carried on across nodes). Set first, as
|
||||
// the node does, so a toggle's own value can read another's switch.
|
||||
ctx[TOGGLE_BOOLS_KEY] = JSON.stringify({ ...readToggleBools(ctx), ...toggleBools(cfg) });
|
||||
for (const [k, v] of Object.entries(resolveToggleTexts(cfg, ctx, catalog))) {
|
||||
ctx[k] = v;
|
||||
if (hidden.has(k)) internalKeys.add(k);
|
||||
else internalKeys.delete(k);
|
||||
@@ -1633,8 +1694,8 @@ function resolveChainStatic(
|
||||
}>(typeof raw === "string" ? raw : "", {});
|
||||
const baseName = (cfg.iteration_var_name ?? "iteration").trim() || "iteration";
|
||||
const totalName = `${baseName}_total`;
|
||||
ctx[baseName] = "1";
|
||||
ctx[totalName] = "1";
|
||||
ctx[baseName] = view && view.frame >= 0 ? String(view.frame + 1) : "1";
|
||||
ctx[totalName] = view ? String(view.count) : "1";
|
||||
if (cfg.iteration_internal === true) internalKeys.add(baseName);
|
||||
else internalKeys.delete(baseName);
|
||||
if (cfg.total_internal === true) internalKeys.add(totalName);
|
||||
@@ -1652,7 +1713,10 @@ function resolveChainStatic(
|
||||
{ version: 1, modules: [] },
|
||||
);
|
||||
const bundleEnabled = buildBundleEnabledMap(v.bundles);
|
||||
for (const m of v.modules) {
|
||||
for (const entry of v.modules) {
|
||||
// A loop frame turns modules on or off and patches their instance
|
||||
// (`frame_enabled`, `iteration_overrides`), as the engine does per frame.
|
||||
const m = view ? frameModule(entry, view.frame) : entry;
|
||||
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
|
||||
// Snapshot VALUES, not just keys: an override rebinds an existing key,
|
||||
// so a key-presence check never sees it and the flag could not flip.
|
||||
@@ -1683,6 +1747,138 @@ function resolveChainStatic(
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** `m` as loop frame `frame` runs it: the frame's instance patch laid
|
||||
* over, and `enabled` the frame's own state. */
|
||||
function frameModule<M extends ContextWidgetValue["modules"][number]>(m: M, frame: number): M {
|
||||
const on = effectiveEnabled(m, frame);
|
||||
const patched = withFrameInstance(m, frame) as M;
|
||||
if ((patched.enabled !== false) === on) return patched;
|
||||
return { ...patched, enabled: on };
|
||||
}
|
||||
|
||||
/** The upstream chain of `node`, nearest first (the walk every collector
|
||||
* shares). */
|
||||
function upstreamChain(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;
|
||||
}
|
||||
|
||||
/** Most frames the coverage scan looks at. Frames past it are read as the
|
||||
* last one scanned; a loop that long is rare and the scan stays cheap. */
|
||||
export const MAX_COVERAGE_FRAMES = 1000;
|
||||
|
||||
/** The frame count of the first running WP Context Loop in `chain`, or
|
||||
* null when none drives it. */
|
||||
function chainLoopCount(chain: LiteNodeLike[]): number | null {
|
||||
for (const n of chain) {
|
||||
if (n.type !== "WP_ContextLoop" || isSkippedMode(n)) continue;
|
||||
const raw = widgetNumber(n, "count");
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.min(Math.floor(raw), MAX_COVERAGE_FRAMES) : 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 0-based frames below `count` on which some Context module in `chain`
|
||||
* differs from its base (an on/off switch or an instance patch). Every
|
||||
* other frame resolves like the base. */
|
||||
function overrideFrames(chain: LiteNodeLike[], count: number): number[] {
|
||||
const out = new Set<number>();
|
||||
const add = (k: number) => {
|
||||
if (Number.isInteger(k) && k >= 0 && k < count) out.add(k);
|
||||
};
|
||||
for (const n of chain) {
|
||||
if (n.type !== "WP_Context" || isSkippedMode(n)) continue;
|
||||
const v = parseCached<ContextWidgetValue>(widgetValue(n, "wp_modules"), { version: 1, modules: [] });
|
||||
for (const m of v.modules) {
|
||||
for (const k of Object.keys(m.frame_enabled ?? {})) add(Number(k));
|
||||
for (const k of m.disabled_frames ?? []) add(k);
|
||||
for (const k of Object.keys(m.iteration_overrides ?? {})) add(Number(k));
|
||||
}
|
||||
}
|
||||
return [...out].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** What a WP Context Loop upstream does to `node`'s variables. */
|
||||
export interface LoopFrames {
|
||||
/** Frames the Loop runs. */
|
||||
count: number;
|
||||
/** Names set on SOME frames but not all: name → the 0-based frames that
|
||||
* set it (ascending). A name set on every frame, or on none, is absent. */
|
||||
partial: Record<string, number[]>;
|
||||
/** Every name set on at least one frame. */
|
||||
anyFrame: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-frame variable coverage when a WP Context Loop drives `node`, or null
|
||||
* when none does. A module switched on (or off) for some frames only, or a
|
||||
* frame patch that changes what a module writes, makes its names exist on
|
||||
* those frames alone; the Assembler shows them as "on some frames" instead
|
||||
* of missing, and the Run check warns only about names no frame sets.
|
||||
*
|
||||
* Resolves the chain once at its base state and once per frame that carries
|
||||
* an override, so a long loop costs as much as its overridden frames.
|
||||
*/
|
||||
export function collectLoopFrames(rootGraph: LiteGraphLike, node: LiteNodeLike): LoopFrames | null {
|
||||
const chain = upstreamChain(rootGraph, node);
|
||||
const count = chainLoopCount(chain);
|
||||
if (count == null) return null;
|
||||
const keysAt = (frame: number): Set<string> =>
|
||||
new Set(Object.keys(resolveChainMemo(chain, { frame, count })).filter((k) => !k.startsWith("__")));
|
||||
const base = keysAt(-1);
|
||||
const frames = overrideFrames(chain, count);
|
||||
const perFrame = new Map(frames.map((k) => [k, keysAt(k)] as const));
|
||||
const names = new Set(base);
|
||||
for (const keys of perFrame.values()) for (const k of keys) names.add(k);
|
||||
const partial: Record<string, number[]> = {};
|
||||
for (const name of names) {
|
||||
const on: number[] = [];
|
||||
for (let k = 0; k < count; k++) {
|
||||
const keys = perFrame.get(k) ?? base;
|
||||
if (keys.has(name)) on.push(k);
|
||||
}
|
||||
if (on.length > 0 && on.length < count) partial[name] = on;
|
||||
}
|
||||
return { count, partial, anyFrame: [...names] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link collectUpstreamResolved}, read at one loop frame (0-based):
|
||||
* that frame's module switches and instance patches, its `$iteration` and
|
||||
* `$iteration_total`. Same as the plain walk when no Loop drives `node`.
|
||||
*/
|
||||
export function collectUpstreamResolvedAtFrame(
|
||||
rootGraph: LiteGraphLike,
|
||||
node: LiteNodeLike,
|
||||
frame: number,
|
||||
): Record<string, ResolvedValue> {
|
||||
const chain = upstreamChain(rootGraph, node);
|
||||
const count = chainLoopCount(chain);
|
||||
if (count == null) return resolveChainMemo(chain);
|
||||
return resolveChainMemo(chain, { frame: Math.min(Math.max(0, frame), count - 1), count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream names set on at least one frame: {@link collectUpstreamVariables}
|
||||
* for a plain chain, and every frame's names when a Loop drives `node`. What
|
||||
* the Run check and the subgraph badge test a template against, so a name a
|
||||
* frame switches on is not reported as missing.
|
||||
*/
|
||||
export function collectUpstreamVariablesAnyFrame(rootGraph: LiteGraphLike, node: LiteNodeLike): string[] {
|
||||
const loop = collectLoopFrames(rootGraph, node);
|
||||
const base = collectUpstreamVariables(rootGraph, node);
|
||||
if (!loop) return base;
|
||||
return [...new Set([...base, ...loop.anyFrame])];
|
||||
}
|
||||
|
||||
function writeBindings(
|
||||
ctx: Record<string, ResolvedValue>,
|
||||
m: ContextWidgetValue["modules"][number],
|
||||
@@ -1697,6 +1893,7 @@ function writeBindings(
|
||||
// `instance.enabled_options` filters which row ids emit bindings
|
||||
// (engine `fixed_values_handler.py:64`). Apply the same filter
|
||||
// here so disabled rows don't appear in upstream-vars / preview.
|
||||
// A value prints `$$` / `@@` as one `$` / `@`, as the engine does.
|
||||
const inst = (m.instance ?? {}) as {
|
||||
values_overrides?: Array<{ id?: string; name?: string; value?: string }>;
|
||||
enabled_options?: string[] | null;
|
||||
@@ -1712,7 +1909,7 @@ function writeBindings(
|
||||
for (const val of overrides) {
|
||||
if (!passesFilter(val.id)) continue;
|
||||
const name = (val.name ?? "").replace(/^\$/, "").trim();
|
||||
if (name) ctx[name] = String(val.value ?? "");
|
||||
if (name) ctx[name] = unescapeTemplate(String(val.value ?? ""));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1740,12 +1937,12 @@ function writeBindings(
|
||||
if (!name) continue;
|
||||
const libId = libNameToId.get(e.variable_name);
|
||||
if (!passesFilter(libId)) continue;
|
||||
ctx[name] = String(e.value ?? "");
|
||||
ctx[name] = unescapeTemplate(String(e.value ?? ""));
|
||||
}
|
||||
for (const val of libValues) {
|
||||
if (!passesFilter(val.id)) continue;
|
||||
const name = (val.name ?? "").replace(/^\$/, "").trim();
|
||||
if (name) ctx[name] = String(val.value ?? "");
|
||||
if (name) ctx[name] = unescapeTemplate(String(val.value ?? ""));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1956,78 +2153,154 @@ function expandValue(
|
||||
): string {
|
||||
if (!raw) return raw;
|
||||
if (depth > MAX_REF_DEPTH) return raw;
|
||||
// 1. Substitute `$var` (+ optional `.K` accessor) with ctx values; leave
|
||||
// unknowns (incl. their accessor) intact. ctx values are strings in this
|
||||
// static resolver, so applyVarAccessor treats `$s.0` as `$s` and `$s.K>0`
|
||||
// as "" — never leaking the literal ".K" into the preview.
|
||||
let out = raw.replace(
|
||||
VAR_REF_RE,
|
||||
(full, name, idxA, axis, idxB) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(ctx, name)) return full;
|
||||
const idx = idxA ?? idxB;
|
||||
const base = applyVarAccessor(
|
||||
ctx[name],
|
||||
idx != null ? parseInt(idx, 10) : undefined,
|
||||
);
|
||||
// An axis read resolves to a TAG, not to the variable's text. Without
|
||||
// this the preview substituted `$outfit` and left `.SHOES` stranded, so
|
||||
// both the preview and the resolved line read
|
||||
// "a white t-shirt and denim skirt.SHOES" — the accessor looked broken
|
||||
// in exactly the editor meant to teach it.
|
||||
if (axis == null) return base;
|
||||
// `$name.neg` reads the variable's negatives, which the static preview
|
||||
// doesn't track: render nothing rather than a stray accessor.
|
||||
if (axis === "neg") return "";
|
||||
// `$name.bool` reads a toggle's on/off: a toggle with a value holds the
|
||||
// value while on and nothing while off.
|
||||
if (axis === "bool") {
|
||||
const v = base.trim().toLowerCase();
|
||||
return v === "true" || v === "false" ? v : v ? "true" : "false";
|
||||
// One pass over the text as the engine reads it (`templatePieces`): `$$`
|
||||
// and `@@` print one `$` / `@`, `$var` and `@{uuid}` are substituted, and
|
||||
// a `{…}` pick stays as written (no rng here). One pass, so an escape is
|
||||
// undone exactly once and a value is never re-read as syntax.
|
||||
return templatePieces(raw).map((piece) => {
|
||||
if (piece.kind === "text") return piece.text;
|
||||
if (piece.kind === "pick") return piece.raw;
|
||||
if (piece.kind === "var") return expandVar(piece.raw, ctx, catalog);
|
||||
return expandRef(piece.raw, piece.uuid, ctx, catalog, depth);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/** Whether a toggle value needs resolving (mirror of the node's
|
||||
* `_has_syntax`): plain text is left exactly as written. */
|
||||
function hasTemplateSyntax(text: string): boolean {
|
||||
return text.includes("$") || text.includes("{") || text.includes("@");
|
||||
}
|
||||
|
||||
/**
|
||||
* A WP Toggles node's variables with their values resolved as the node
|
||||
* resolves them (`wp_nodes/toggles_node.py`): a value reads upstream
|
||||
* variables and the toggles' own, `$$` / `@@` print one `$` / `@`, and a
|
||||
* value read by another is resolved first; the Pick one value goes last.
|
||||
*/
|
||||
function resolveToggleTexts(
|
||||
cfg: TogglesConfig,
|
||||
ctx: Record<string, ResolvedValue>,
|
||||
catalog: Map<string, MinimalWildcard>,
|
||||
): Record<string, string> {
|
||||
const values = resolveToggles(cfg);
|
||||
const group = cfg.mode === "one" ? cfg.group_var : "";
|
||||
const out: Record<string, string> = { ...values };
|
||||
const done = new Set<string>();
|
||||
const busy = new Set<string>();
|
||||
const resolve = (name: string): void => {
|
||||
if (done.has(name) || busy.has(name)) return;
|
||||
const raw = values[name] ?? "";
|
||||
if (hasTemplateSyntax(raw)) {
|
||||
busy.add(name);
|
||||
for (const r of templateVarReads(raw)) {
|
||||
if (r.name !== group && Object.prototype.hasOwnProperty.call(values, r.name)) resolve(r.name);
|
||||
}
|
||||
const tag = previewAxisTag(catalog, name, axis);
|
||||
return tag ?? full;
|
||||
},
|
||||
);
|
||||
// 2. Substitute `@{8hex}` with the referenced wildcard's first option,
|
||||
// recursively expanded so chains (`@{a}` → "@{b} hat" → "blue hat")
|
||||
// flatten. Resolution ladder:
|
||||
// a. chain catalog (sibling/cousin wildcards in any chain Context)
|
||||
// b. lazy preview-resolver cache (background-fetched from DB)
|
||||
// c. preview-resolver name fallback → render `[name]` so users
|
||||
// see something meaningful even before the fetch resolves
|
||||
// a `firstOption`, or for non-wildcard kinds that have no
|
||||
// option to expand
|
||||
// d. raw `@{uuid}` placeholder (last resort — will resolve to
|
||||
// the real value at runtime via the live DB walker)
|
||||
//
|
||||
// Deep-nesting note: when (b) recurses into a cached firstOption that
|
||||
// ITSELF contains `@{}` refs not yet cached, the inner replace lands
|
||||
// back here on the next pass. Cache-miss + fire `ensure()` for that
|
||||
// nested uuid so the next 400ms poll has it ready. Chain length N
|
||||
// therefore takes ~N polling ticks to fully expand; each tick
|
||||
// surfaces one more level instead of stalling at the first miss.
|
||||
out = out.replace(WC_REF_RE, (full, uuid) => {
|
||||
const target = catalog.get(uuid);
|
||||
const opts = target?.options ?? [];
|
||||
if (opts.length) {
|
||||
return expandValue(String(opts[0].value ?? ""), ctx, catalog, depth + 1);
|
||||
busy.delete(name);
|
||||
out[name] = expandValue(raw, { ...ctx, ...out }, catalog, 0);
|
||||
}
|
||||
const lk = previewLookup(uuid);
|
||||
if (lk?.firstOption !== undefined) {
|
||||
return expandValue(lk.firstOption, ctx, catalog, depth + 1);
|
||||
}
|
||||
if (lk === undefined) {
|
||||
// First time we see this uuid in any expansion path — queue it.
|
||||
// `ensure` is idempotent + fire-and-forget; subsequent ticks read
|
||||
// from cache via the branch above.
|
||||
ensurePreviewLookup([uuid]);
|
||||
}
|
||||
if (lk?.name) return `[${lk.name}]`;
|
||||
return full;
|
||||
});
|
||||
done.add(name);
|
||||
};
|
||||
for (const name of Object.keys(values)) if (name !== group) resolve(name);
|
||||
if (group && Object.prototype.hasOwnProperty.call(values, group)) resolve(group);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The toggle switches carried in a static ctx (`TOGGLE_BOOLS_KEY`), what
|
||||
* `$name.bool` reads; {} when no Toggles node is upstream. */
|
||||
export function readToggleBools(ctx: Record<string, ResolvedValue>): Record<string, boolean> {
|
||||
const raw = ctx[TOGGLE_BOOLS_KEY];
|
||||
if (typeof raw !== "string") return {};
|
||||
try {
|
||||
const t: unknown = JSON.parse(raw);
|
||||
if (!t || typeof t !== "object" || Array.isArray(t)) return {};
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(t)) if (typeof v === "boolean") out[k] = v;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** A `$var` read (+ optional `.K` / `.AXIS` accessor) with its ctx value;
|
||||
* an unknown name (accessor included) stays as written. ctx values are
|
||||
* strings in this static resolver, so applyVarAccessor treats `$s.0` as
|
||||
* `$s` and `$s.K>0` as "" — never leaking the literal ".K" into the
|
||||
* preview. */
|
||||
function expandVar(
|
||||
full: string,
|
||||
ctx: Record<string, ResolvedValue>,
|
||||
catalog: Map<string, MinimalWildcard>,
|
||||
): string {
|
||||
const { base: name, index, axis } = varAccessorParts(full);
|
||||
if (!Object.prototype.hasOwnProperty.call(ctx, name)) return full;
|
||||
const base = applyVarAccessor(ctx[name], index);
|
||||
// An axis read resolves to a TAG, not to the variable's text. Without
|
||||
// this the preview substituted `$outfit` and left `.SHOES` stranded, so
|
||||
// both the preview and the resolved line read
|
||||
// "a white t-shirt and denim skirt.SHOES" — the accessor looked broken
|
||||
// in exactly the editor meant to teach it.
|
||||
if (axis == null) return base;
|
||||
// `$name.neg` reads the variable's negatives, which the static preview
|
||||
// doesn't track: render nothing rather than a stray accessor.
|
||||
if (axis === "neg") return "";
|
||||
// `$name.bool` reads a toggle's on/off: a toggle with a value holds the
|
||||
// value while on and nothing while off.
|
||||
if (axis === "bool") {
|
||||
const on = readToggleBools(ctx)[name];
|
||||
if (on !== undefined) return on ? "true" : "false";
|
||||
const v = base.trim().toLowerCase();
|
||||
return v === "true" || v === "false" ? v : v ? "true" : "false";
|
||||
}
|
||||
const tag = previewAxisTag(catalog, name, axis);
|
||||
return tag ?? full;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute `@{8hex}` with the referenced wildcard's first option,
|
||||
* recursively expanded so chains (`@{a}` → "@{b} hat" → "blue hat")
|
||||
* flatten. Resolution ladder:
|
||||
* a. chain catalog (sibling/cousin wildcards in any chain Context)
|
||||
* b. lazy preview-resolver cache (background-fetched from DB)
|
||||
* c. preview-resolver name fallback → render `[name]` so users
|
||||
* see something meaningful even before the fetch resolves
|
||||
* a `firstOption`, or for non-wildcard kinds that have no
|
||||
* option to expand
|
||||
* d. raw `@{uuid}` placeholder (last resort — will resolve to
|
||||
* the real value at runtime via the live DB walker)
|
||||
*
|
||||
* Deep-nesting note: when (b) recurses into a cached firstOption that
|
||||
* ITSELF contains `@{}` refs not yet cached, the inner expansion lands
|
||||
* back here on the next pass. Cache-miss + fire `ensure()` for that
|
||||
* nested uuid so the next 400ms poll has it ready. Chain length N
|
||||
* therefore takes ~N polling ticks to fully expand; each tick
|
||||
* surfaces one more level instead of stalling at the first miss.
|
||||
*/
|
||||
function expandRef(
|
||||
full: string,
|
||||
uuid: string,
|
||||
ctx: Record<string, ResolvedValue>,
|
||||
catalog: Map<string, MinimalWildcard>,
|
||||
depth: number,
|
||||
): string {
|
||||
const target = catalog.get(uuid);
|
||||
const opts = target?.options ?? [];
|
||||
if (opts.length) {
|
||||
return expandValue(String(opts[0].value ?? ""), ctx, catalog, depth + 1);
|
||||
}
|
||||
const lk = previewLookup(uuid);
|
||||
if (lk?.firstOption !== undefined) {
|
||||
return expandValue(lk.firstOption, ctx, catalog, depth + 1);
|
||||
}
|
||||
if (lk === undefined) {
|
||||
// First time we see this uuid in any expansion path — queue it.
|
||||
// `ensure` is idempotent + fire-and-forget; subsequent ticks read
|
||||
// from cache via the branch above.
|
||||
ensurePreviewLookup([uuid]);
|
||||
}
|
||||
if (lk?.name) return `[${lk.name}]`;
|
||||
return full;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator — node ids are unique only within a single LGraph; nested
|
||||
* subgraphs reuse the same id space. Pair `(graph.id, node.id)` to dedupe
|
||||
@@ -2226,6 +2499,25 @@ 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
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
headerPart,
|
||||
parseLabelGridConfig,
|
||||
parseLabelGridRun,
|
||||
runMismatch,
|
||||
} from "./label-grid";
|
||||
|
||||
describe("parseLabelGridConfig", () => {
|
||||
@@ -37,6 +38,7 @@ describe("parseLabelGridRun", () => {
|
||||
expect(r).toEqual({
|
||||
items: 2, batchSizes: [4, 4], count: 8, labelled: true,
|
||||
parts: [{ values: ["#1", "#2"], count: 2 }], auto: "rows", grid: [100, 200],
|
||||
missing: 0, extra: 0, rows: null, columns: null, noLabel: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,3 +49,26 @@ describe("parseLabelGridRun", () => {
|
||||
expect(r).toMatchObject({ items: 2, count: 5, labelled: false, parts: [], auto: "auto", grid: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMismatch", () => {
|
||||
const base = parseLabelGridRun({ batch_sizes: [4] });
|
||||
if (!base) throw new Error("no run");
|
||||
|
||||
it("says nothing when the numbers fit", () => {
|
||||
expect(runMismatch(base)).toEqual([]);
|
||||
});
|
||||
|
||||
it("names fewer and more label lines, unsorted images, and header counts", () => {
|
||||
const run = parseLabelGridRun({
|
||||
batch_sizes: [4], missing: 1, extra: 2, no_label: 1, rows: [2, 3], columns: [4, 3],
|
||||
});
|
||||
if (!run) throw new Error("no run");
|
||||
expect(runMismatch(run)).toEqual([
|
||||
"1 image without a label line",
|
||||
"2 label lines left over",
|
||||
'1 image under "(no label)"',
|
||||
"2 row headers for 3 rows",
|
||||
"1 column header left over",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,13 @@ export const LABEL_GRID_NODE = "WP_LabelGrid";
|
||||
/** The node's one widget: label + grid settings as JSON. */
|
||||
export const CONFIG_WIDGET = "wp_label_grid";
|
||||
|
||||
export type LabelPosition = "top" | "bottom" | "left" | "right" | "over_top" | "over_bottom" | "none";
|
||||
export type LabelPosition =
|
||||
| "top" | "bottom" | "left" | "right"
|
||||
| "over_top" | "over_bottom" | "over_left" | "over_right" | "none";
|
||||
export type LabelAlign = "center" | "left" | "right";
|
||||
/** Left / right labels: across, or turned along the edge (upwards on the
|
||||
* left, downwards on the right). */
|
||||
export type LabelOrientation = "horizontal" | "vertical";
|
||||
export type GridArrange = "auto" | "rows" | "columns" | "fill" | "strip" | "stack";
|
||||
/** `none` / `typed` / `part:N` (N from 1, `part:-1` = the last part). */
|
||||
export type HeaderSource = string;
|
||||
@@ -24,6 +29,7 @@ export interface LabelGridConfig {
|
||||
label: string;
|
||||
position: LabelPosition;
|
||||
align: LabelAlign;
|
||||
orientation: LabelOrientation;
|
||||
font: string;
|
||||
/** 0 = automatic (a share of the image width). */
|
||||
font_size: number;
|
||||
@@ -46,8 +52,11 @@ export interface LabelGridConfig {
|
||||
cell_width: number;
|
||||
}
|
||||
|
||||
export const POSITIONS: readonly LabelPosition[] = ["top", "bottom", "left", "right", "over_top", "over_bottom", "none"];
|
||||
export const POSITIONS: readonly LabelPosition[] = [
|
||||
"top", "bottom", "left", "right", "over_top", "over_bottom", "over_left", "over_right", "none",
|
||||
];
|
||||
export const ALIGNS: readonly LabelAlign[] = ["center", "left", "right"];
|
||||
export const ORIENTATIONS: readonly LabelOrientation[] = ["horizontal", "vertical"];
|
||||
export const ARRANGES: readonly GridArrange[] = ["auto", "rows", "columns", "fill", "strip", "stack"];
|
||||
const HEADER_FROM = /^(none|typed|part:-?[1-9]\d?)$/;
|
||||
|
||||
@@ -56,6 +65,7 @@ export const DEFAULT_LABEL_GRID_CONFIG: Readonly<LabelGridConfig> = {
|
||||
label: "{label}",
|
||||
position: "top",
|
||||
align: "center",
|
||||
orientation: "horizontal",
|
||||
font: "default",
|
||||
font_size: 0,
|
||||
text_color: "#ffffff",
|
||||
@@ -105,6 +115,7 @@ export function parseLabelGridConfig(raw: unknown): LabelGridConfig {
|
||||
const r = obj as Record<string, unknown>;
|
||||
if (oneOf(r.position, POSITIONS)) out.position = r.position;
|
||||
if (oneOf(r.align, ALIGNS)) out.align = r.align;
|
||||
if (oneOf(r.orientation, ORIENTATIONS)) out.orientation = r.orientation;
|
||||
if (oneOf(r.arrange, ARRANGES)) out.arrange = r.arrange;
|
||||
for (const key of ["rows_from", "columns_from"] as const) {
|
||||
const v = r[key];
|
||||
@@ -151,6 +162,15 @@ export interface LabelGridRun {
|
||||
auto: GridArrange;
|
||||
/** The grid's [width, height], or null when no grid was made. */
|
||||
grid: [number, number] | null;
|
||||
/** Images the label lines left without one. */
|
||||
missing: number;
|
||||
/** Label lines no image took. */
|
||||
extra: number;
|
||||
/** Typed row / column headers vs the grid's rows / columns, when they differ. */
|
||||
rows: [number, number] | null;
|
||||
columns: [number, number] | null;
|
||||
/** Images an XY plot put under "(no label)". */
|
||||
noLabel: number;
|
||||
}
|
||||
|
||||
export function parseLabelGridRun(raw: unknown): LabelGridRun | null {
|
||||
@@ -167,7 +187,9 @@ export function parseLabelGridRun(raw: unknown): LabelGridRun | null {
|
||||
return [{ values, count: num(q.count) || values.length }];
|
||||
})
|
||||
: [];
|
||||
const grid = Array.isArray(r.grid) && r.grid.length === 2 ? [num(r.grid[0]), num(r.grid[1])] as [number, number] : null;
|
||||
const pair = (v: unknown): [number, number] | null =>
|
||||
Array.isArray(v) && v.length === 2 ? [num(v[0]), num(v[1])] : null;
|
||||
const grid = pair(r.grid);
|
||||
return {
|
||||
items: num(r.items) || sizes.length,
|
||||
batchSizes: sizes,
|
||||
@@ -176,5 +198,29 @@ export function parseLabelGridRun(raw: unknown): LabelGridRun | null {
|
||||
parts,
|
||||
auto: oneOf(r.auto, ARRANGES) ? r.auto : "auto",
|
||||
grid,
|
||||
missing: num(r.missing),
|
||||
extra: num(r.extra),
|
||||
rows: pair(r.rows),
|
||||
columns: pair(r.columns),
|
||||
noLabel: num(r.no_label),
|
||||
};
|
||||
}
|
||||
|
||||
/** What doesn't match on the last run, in words ("" when it all fits):
|
||||
* label lines vs images, typed headers vs rows / columns. */
|
||||
export function runMismatch(run: LabelGridRun): string[] {
|
||||
const out: string[] = [];
|
||||
const n = (k: number, one: string, many = `${one}s`) => `${k} ${k === 1 ? one : many}`;
|
||||
if (run.missing) out.push(`${n(run.missing, "image")} without a label line`);
|
||||
if (run.extra) out.push(`${n(run.extra, "label line")} left over`);
|
||||
if (run.noLabel) out.push(`${n(run.noLabel, "image")} under "(no label)"`);
|
||||
for (const [key, word] of [["rows", "row"], ["columns", "column"]] as const) {
|
||||
const fit = run[key];
|
||||
if (!fit) continue;
|
||||
const [have, need] = fit;
|
||||
out.push(have < need
|
||||
? `${n(have, `${word} header`)} for ${n(need, word)}`
|
||||
: `${n(have - need, `${word} header`)} left over`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectLoopFrames,
|
||||
collectUpstreamChain,
|
||||
collectUpstreamKinds,
|
||||
collectUpstreamResolved,
|
||||
collectUpstreamResolvedAtFrame,
|
||||
collectUpstreamVariables,
|
||||
collectUpstreamVariablesAnyFrame,
|
||||
type LiteGraphLike,
|
||||
type LiteNodeLike,
|
||||
} from "./graph";
|
||||
import { scanTemplateConflicts } from "./conflicts";
|
||||
import { missingAssemblerVars } from "./assembler-vars";
|
||||
|
||||
/** Loop (1) → Context (2) → Assembler (3), the N05 shape. */
|
||||
function fixedModule(id: string, name: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
type: "fixed_values",
|
||||
enabled: true,
|
||||
meta: { name: id },
|
||||
entries: [{ variable_name: name, value: `${name}-text` }],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function build(modules: unknown[], opts: { count?: number; valuesLink?: number | null } = {}) {
|
||||
const loop: LiteNodeLike = {
|
||||
id: 1,
|
||||
type: "WP_ContextLoop",
|
||||
inputs: [{ name: "values", link: opts.valuesLink ?? null }],
|
||||
outputs: [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [
|
||||
{ name: "seed", value: 0 },
|
||||
{ name: "count", value: opts.count ?? 4 },
|
||||
{ name: "wp_context_loop_config", value: JSON.stringify({ iteration_var_name: "iteration" }) },
|
||||
],
|
||||
};
|
||||
const ctx: LiteNodeLike = {
|
||||
id: 2,
|
||||
type: "WP_Context",
|
||||
inputs: [{ name: "upstream", link: 100 }],
|
||||
outputs: [{ name: "context", links: [200], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [{ name: "wp_modules", value: JSON.stringify({ version: 1, modules }) }],
|
||||
};
|
||||
const asm: LiteNodeLike = {
|
||||
id: 3,
|
||||
type: "WP_PromptAssembler",
|
||||
inputs: [{ name: "context", link: 200 }],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
};
|
||||
const nodes: Record<number, LiteNodeLike> = { 1: loop, 2: ctx, 3: asm };
|
||||
const g: LiteGraphLike = {
|
||||
_nodes: [loop, ctx, asm],
|
||||
links: {
|
||||
100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 },
|
||||
200: { id: 200, origin_id: 2, origin_slot: 0, target_id: 3, target_slot: 0 },
|
||||
},
|
||||
getNodeById: (id) => nodes[id as number] ?? null,
|
||||
};
|
||||
return { g, loop, ctx, asm };
|
||||
}
|
||||
|
||||
describe("loop frame coverage", () => {
|
||||
it("a module off at base and on for frame 4 is set on frame 4 only", () => {
|
||||
const { g, asm } = build([
|
||||
fixedModule("a", "role"),
|
||||
fixedModule("b", "companion", { enabled: false, frame_enabled: { "3": true } }),
|
||||
]);
|
||||
const lf = collectLoopFrames(g, asm);
|
||||
expect(lf?.count).toBe(4);
|
||||
expect(lf?.partial).toEqual({ companion: [3] });
|
||||
expect(lf?.anyFrame).toContain("companion");
|
||||
// The plain walk reads the base and misses it; any-frame finds it.
|
||||
expect(collectUpstreamVariables(g, asm)).not.toContain("companion");
|
||||
expect(collectUpstreamVariablesAnyFrame(g, asm)).toContain("companion");
|
||||
});
|
||||
|
||||
it("a module on at base and off on frame 3 is set on the other frames", () => {
|
||||
const { g, asm } = build([
|
||||
fixedModule("b", "companion", { frame_enabled: { "2": false } }),
|
||||
]);
|
||||
expect(collectLoopFrames(g, asm)?.partial).toEqual({ companion: [0, 1, 3] });
|
||||
});
|
||||
|
||||
it("reads the legacy disabled_frames list", () => {
|
||||
const { g, asm } = build([fixedModule("b", "companion", { disabled_frames: [0] })]);
|
||||
expect(collectLoopFrames(g, asm)?.partial).toEqual({ companion: [1, 2, 3] });
|
||||
});
|
||||
|
||||
it("ignores overrides past the loop's count", () => {
|
||||
const { g, asm } = build(
|
||||
[fixedModule("b", "companion", { enabled: false, frame_enabled: { "9": true } })],
|
||||
{ count: 4 },
|
||||
);
|
||||
const lf = collectLoopFrames(g, asm);
|
||||
expect(lf?.partial).toEqual({});
|
||||
expect(lf?.anyFrame).not.toContain("companion");
|
||||
});
|
||||
|
||||
it("returns null without a loop upstream", () => {
|
||||
const { g, asm, ctx } = build([fixedModule("a", "role")]);
|
||||
ctx.inputs = [{ name: "upstream", link: null }];
|
||||
expect(collectLoopFrames(g, asm)).toBeNull();
|
||||
expect(collectUpstreamVariablesAnyFrame(g, asm)).toEqual(collectUpstreamVariables(g, asm));
|
||||
});
|
||||
|
||||
it("resolves one frame: its switches, $iteration and $iteration_total", () => {
|
||||
const { g, asm } = build([
|
||||
fixedModule("b", "companion", { enabled: false, frame_enabled: { "3": true } }),
|
||||
]);
|
||||
const f0 = collectUpstreamResolvedAtFrame(g, asm, 0);
|
||||
expect(f0.companion).toBeUndefined();
|
||||
expect(f0.iteration).toBe("1");
|
||||
expect(f0.iteration_total).toBe("4");
|
||||
const f3 = collectUpstreamResolvedAtFrame(g, asm, 3);
|
||||
expect(f3.companion).toBe("companion-text");
|
||||
expect(f3.iteration).toBe("4");
|
||||
// Past the end clamps to the last frame.
|
||||
expect(collectUpstreamResolvedAtFrame(g, asm, 99).iteration).toBe("4");
|
||||
});
|
||||
|
||||
it("frame kinds follow the frame's switches", () => {
|
||||
const { g, asm } = build([
|
||||
fixedModule("b", "companion", { enabled: false, frame_enabled: { "3": true } }),
|
||||
]);
|
||||
expect(collectUpstreamKinds(g, asm).companion).toBeUndefined();
|
||||
expect(collectUpstreamKinds(g, asm, 3).companion).toBe("fixed_values");
|
||||
});
|
||||
|
||||
it("the preview chain carries the frame's switches to the server", () => {
|
||||
const { g, asm } = build([
|
||||
fixedModule("b", "companion", { enabled: false, frame_enabled: { "3": true } }),
|
||||
]);
|
||||
const at = (frame: number | null) =>
|
||||
(collectUpstreamChain(g, asm, { frame })[0][0] as { enabled?: boolean }).enabled;
|
||||
expect(at(null)).toBe(false);
|
||||
expect(at(0)).toBe(false);
|
||||
expect(at(3)).toBe(true);
|
||||
});
|
||||
|
||||
it("the Run check and the subgraph badge stay quiet for a some-frames name", () => {
|
||||
const { g, asm } = build([
|
||||
fixedModule("b", "companion", { enabled: false, frame_enabled: { "3": true } }),
|
||||
]);
|
||||
const known = collectUpstreamVariablesAnyFrame(g, asm);
|
||||
expect(missingAssemblerVars("$companion $nowhere", "", known)).toEqual(["nowhere"]);
|
||||
expect(scanTemplateConflicts("$companion $nowhere", known).map((c) => c.variable)).toEqual(["nowhere"]);
|
||||
});
|
||||
|
||||
it("wiring the loop's values input shows $iteration_value (memo keyed on the link)", () => {
|
||||
const { g, asm, loop } = build([fixedModule("a", "role")]);
|
||||
expect(collectUpstreamResolved(g, asm).iteration_value).toBeUndefined();
|
||||
loop.inputs = [{ name: "values", link: 7 }];
|
||||
expect(collectUpstreamResolved(g, asm).iteration_value).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
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.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 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(" ");
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
import { applyVarAccessor, varAccessorParts, type ResolvedValue } from "../widgets/richTokenize";
|
||||
import { NEGATIVES_VAR } from "./assembler-vars";
|
||||
import { templateVarReads } from "./template-pieces";
|
||||
|
||||
export interface NegativeEntry {
|
||||
text: string;
|
||||
@@ -138,8 +139,8 @@ const TEMPLATE_VAR_RE =
|
||||
export function templateReads(template: string, resolved: Record<string, ResolvedValue>): VarRead[] {
|
||||
const out: VarRead[] = [];
|
||||
if (!template) return out;
|
||||
for (const m of template.matchAll(TEMPLATE_VAR_RE)) {
|
||||
const { base, index, axis } = varAccessorParts(m[0]);
|
||||
for (const r of templateVarReads(template)) {
|
||||
const { base, index, axis } = varAccessorParts(r.raw);
|
||||
if (!Object.prototype.hasOwnProperty.call(resolved, base)) continue;
|
||||
if (!axis && index != null && !applyVarAccessor(resolved[base], index)) continue;
|
||||
out.push({ name: base, index: index ?? null });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DOC_PAGES } from "../manager/docs/registry";
|
||||
import { nodeEditors } from "./_stashes";
|
||||
import {
|
||||
EDIT_COMMAND,
|
||||
SHOW_COMMAND,
|
||||
HELP_COMMAND,
|
||||
NODE_DOC_PAGES,
|
||||
docPageFor,
|
||||
@@ -69,11 +70,11 @@ describe("node toolbar", () => {
|
||||
expect(selectedItems(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers two commands with coloured icons that call through", () => {
|
||||
it("registers its commands with coloured icons that call through", () => {
|
||||
const node = { type: "WP_Bookmark", title: "Start" };
|
||||
const cmds = toolbarCommands(() => ({ selected_nodes: { 1: node } }));
|
||||
expect(cmds.map((c) => c.id)).toEqual([HELP_COMMAND, EDIT_COMMAND]);
|
||||
for (const c of cmds) expect(c.icon).toContain("wp-tb-icon");
|
||||
expect(cmds.map((c) => c.id)).toEqual([HELP_COMMAND, EDIT_COMMAND, SHOW_COMMAND]);
|
||||
for (const c of cmds) expect(typeof c.icon === "function" ? c.icon() : c.icon).toContain("wp-tb-icon");
|
||||
cmds[0]?.function();
|
||||
expect(helpTarget.value?.page).toBe("wp-bookmark");
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* - "?" opens the node's docs page in a dialog over the canvas, for every
|
||||
* WP node (`NodeHelpDialog.vue`, mounted by `main.ts` on first use);
|
||||
* - a pencil opens the node's edit view, for nodes that have one
|
||||
* (registered in `nodeEditors` by their widget glue).
|
||||
* (registered in `nodeEditors` by their widget glue);
|
||||
* - an eye shows the node's panel on its subgraph node, or hides it, for
|
||||
* a WP node inside a subgraph (`show-on-subgraph.ts`).
|
||||
*
|
||||
* Both are drawn as a filled accent-coloured dot (`node-toolbar.css`) so
|
||||
* they read as Wildcard Pipeline's and not ComfyUI's. The toolbar shows a
|
||||
@@ -13,9 +15,11 @@
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
import { nodeEditors } from "./_stashes";
|
||||
import { hidePanel, hostsOf, isShown, panelsOf, showPanel, type PanelNode } from "./show-on-subgraph";
|
||||
|
||||
export const HELP_COMMAND = "wildcardPipeline.node.help";
|
||||
export const EDIT_COMMAND = "wildcardPipeline.node.edit";
|
||||
export const SHOW_COMMAND = "wildcardPipeline.node.showOnSubgraph";
|
||||
|
||||
/** Node type → its page in the manager docs (`/wp/docs/<page>`). Kept in
|
||||
* step with `manager/docs/registry.ts` (`node-toolbar.test.ts`). */
|
||||
@@ -57,9 +61,22 @@ export function canEdit(item: unknown): boolean {
|
||||
return typeof item === "object" && item !== null && nodeEditors.has(item);
|
||||
}
|
||||
|
||||
/** `getSelectionToolboxCommands`: the pencil first (when it applies), then "?". */
|
||||
/** Whether a node is inside a subgraph and has a panel it can show there. */
|
||||
export function canShow(item: unknown): boolean {
|
||||
if (typeof item !== "object" || item === null) return false;
|
||||
return panelsOf(item).length > 0 && hostsOf(item as PanelNode).length > 0;
|
||||
}
|
||||
|
||||
/** Whether any of a node's panels is shown on its subgraph node. */
|
||||
export function anyShown(item: unknown): boolean {
|
||||
if (!canShow(item)) return false;
|
||||
return panelsOf(item as object).some((w) => isShown(item as PanelNode, w));
|
||||
}
|
||||
|
||||
/** `getSelectionToolboxCommands`: the eye, the pencil (when they apply), then "?". */
|
||||
export function toolboxCommandsFor(item: unknown): string[] {
|
||||
const out: string[] = [];
|
||||
if (canShow(item)) out.push(SHOW_COMMAND);
|
||||
if (canEdit(item)) out.push(EDIT_COMMAND);
|
||||
if (docPageFor(item)) out.push(HELP_COMMAND);
|
||||
return out;
|
||||
@@ -107,14 +124,32 @@ export function editSelection(canvas: SelectionCanvas | null | undefined): boole
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Run the eye on the selection: the first node that can show a panel. Hides
|
||||
* its panels when any is shown, else shows them all. */
|
||||
export function toggleShowSelection(canvas: SelectionCanvas | null | undefined): boolean {
|
||||
const node = selectedItems(canvas).find(canShow) as PanelNode | undefined;
|
||||
if (!node) return false;
|
||||
const on = anyShown(node);
|
||||
for (const w of panelsOf(node as object)) (on ? hidePanel : showPanel)(node, w);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Icon classes: a PrimeIcons glyph on our accent dot (`node-toolbar.css`). */
|
||||
export const HELP_ICON = "pi pi-question wp-tb-icon wp-tb-icon--help";
|
||||
export const EDIT_ICON = "pi pi-pencil wp-tb-icon wp-tb-icon--edit";
|
||||
export const SHOW_ICON = "pi pi-eye wp-tb-icon wp-tb-icon--show";
|
||||
export const HIDE_ICON = "pi pi-eye-slash wp-tb-icon wp-tb-icon--show";
|
||||
|
||||
/** The two commands, registered with the extension's `commands`. */
|
||||
export function toolbarCommands(getCanvas: () => SelectionCanvas | null | undefined) {
|
||||
return [
|
||||
{ id: HELP_COMMAND, label: "Wildcard Pipeline help for this node", icon: HELP_ICON, function: () => void helpForSelection(getCanvas()) },
|
||||
{ id: EDIT_COMMAND, label: "Edit this Wildcard Pipeline node", icon: EDIT_ICON, function: () => void editSelection(getCanvas()) },
|
||||
{
|
||||
id: SHOW_COMMAND,
|
||||
label: "Show on subgraph node",
|
||||
icon: () => (selectedItems(getCanvas()).some(anyShown) ? HIDE_ICON : SHOW_ICON),
|
||||
function: () => void toggleShowSelection(getCanvas()),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, type Ref, onScopeDispose } from "vue";
|
||||
import { ref, watch, type Ref, type WatchSource, onScopeDispose } from "vue";
|
||||
import { onGraphLoaded } from "./graph-events";
|
||||
|
||||
export { notifyGraphLoaded } from "./graph-events";
|
||||
@@ -259,6 +259,9 @@ export function reactiveFromGraph<T>(
|
||||
compute: () => T,
|
||||
equals: (a: T, b: T) => boolean = Object.is,
|
||||
pollMs = 400,
|
||||
/** Vue sources `compute` reads besides the graph (e.g. the Loop's frame
|
||||
* cursor): a change refreshes at once instead of on the next poll. */
|
||||
triggers: WatchSource[] = [],
|
||||
): Ref<T> {
|
||||
const state = ref(compute()) as Ref<T>;
|
||||
|
||||
@@ -267,6 +270,8 @@ export function reactiveFromGraph<T>(
|
||||
if (!equals(state.value, next)) state.value = next;
|
||||
}
|
||||
|
||||
const stopTriggers = triggers.length ? watch(triggers, refresh) : () => {};
|
||||
|
||||
const unwatchConnections = watchConnections(node, refresh);
|
||||
|
||||
// Mode-change intercept — instant detection of mute/bypass toggles
|
||||
@@ -320,6 +325,7 @@ export function reactiveFromGraph<T>(
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopTriggers();
|
||||
stopPoll();
|
||||
unsubscribe();
|
||||
unwatchMode();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { toasts } from "../components/shared/toast-store";
|
||||
import {
|
||||
_resetInstalled,
|
||||
clearRunError,
|
||||
installRunErrors,
|
||||
nodeForExecutionId,
|
||||
resetRunErrors,
|
||||
runErrorOf,
|
||||
setRunError,
|
||||
stripNodePrefix,
|
||||
} from "./run-errors";
|
||||
|
||||
class FakeApi {
|
||||
handlers = new Map<string, ((e: Event) => void)[]>();
|
||||
addEventListener(name: string, fn: (e: Event) => void) {
|
||||
this.handlers.set(name, [...(this.handlers.get(name) ?? []), fn]);
|
||||
}
|
||||
fire(name: string, detail?: unknown) {
|
||||
for (const fn of this.handlers.get(name) ?? []) fn(new CustomEvent(name, { detail }));
|
||||
}
|
||||
}
|
||||
|
||||
const MSG = "WP Var → Choice: The variable is empty in KSampler > sampler_name (45 options: euler, …)";
|
||||
|
||||
function setup() {
|
||||
const api = new FakeApi();
|
||||
const choice = { type: "WP_VarToChoice", title: "WP Var → Choice" };
|
||||
const ctx = { type: "WP_Context", title: "Characters" };
|
||||
const ksampler = { type: "KSampler", title: "KSampler" };
|
||||
const nodes: Record<string, object> = { "7": choice, "8": ctx, "9": ksampler };
|
||||
installRunErrors(api, (id) => (nodes[id] as never) ?? null);
|
||||
return { api, choice, ctx, ksampler };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetInstalled();
|
||||
resetRunErrors();
|
||||
toasts.value = [];
|
||||
});
|
||||
|
||||
describe("run errors", () => {
|
||||
it("stores a WP node's error without its name and pushes one error toast", () => {
|
||||
const { api, choice } = setup();
|
||||
api.fire("execution_error", { node_id: "7", node_type: "WP_VarToChoice", exception_message: MSG });
|
||||
expect(runErrorOf(choice)?.message).toBe("The variable is empty in KSampler > sampler_name (45 options: euler, …)");
|
||||
expect(toasts.value).toHaveLength(1);
|
||||
expect(toasts.value[0]?.severity).toBe("error");
|
||||
expect(toasts.value[0]?.message).toBe(MSG);
|
||||
});
|
||||
|
||||
it("puts the node's title in front of a message that lacks it", () => {
|
||||
const { api, ctx } = setup();
|
||||
api.fire("execution_error", { node_id: 8, node_type: "WP_Context", exception_message: "Invalid modules JSON: x\n" });
|
||||
expect(runErrorOf(ctx)?.message).toBe("Invalid modules JSON: x");
|
||||
expect(toasts.value[0]?.message).toBe("Characters: Invalid modules JSON: x");
|
||||
});
|
||||
|
||||
it("leaves other nodes' errors to ComfyUI", () => {
|
||||
const { api, ksampler } = setup();
|
||||
api.fire("execution_error", { node_id: "9", node_type: "KSampler", exception_message: "boom" });
|
||||
expect(runErrorOf(ksampler)).toBeNull();
|
||||
expect(toasts.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clears when the next run reaches the node (executing, both detail shapes, or cached)", () => {
|
||||
const { api, choice } = setup();
|
||||
const fail = () => api.fire("execution_error", { node_id: "7", node_type: "WP_VarToChoice", exception_message: MSG });
|
||||
fail();
|
||||
api.fire("executing", "8");
|
||||
expect(runErrorOf(choice)).not.toBeNull();
|
||||
api.fire("executing", "7");
|
||||
expect(runErrorOf(choice)).toBeNull();
|
||||
fail();
|
||||
api.fire("executing", { node: "7", display_node: "7" });
|
||||
expect(runErrorOf(choice)).toBeNull();
|
||||
fail();
|
||||
api.fire("execution_cached", { nodes: ["7"] });
|
||||
expect(runErrorOf(choice)).toBeNull();
|
||||
api.fire("executing", null);
|
||||
});
|
||||
|
||||
it("still toasts when the node can't be found", () => {
|
||||
const { api } = setup();
|
||||
api.fire("execution_error", { node_id: "99", node_type: "WP_Debug", exception_message: "" });
|
||||
expect(toasts.value[0]?.message).toBe("WP_Debug stopped the run.");
|
||||
});
|
||||
|
||||
it("set / clear by hand (the strip's close button)", () => {
|
||||
const n = {};
|
||||
setRunError(n, " ", "WP Debug");
|
||||
expect(runErrorOf(n)?.message).toBe("The node stopped the run.");
|
||||
clearRunError(n);
|
||||
clearRunError(n);
|
||||
expect(runErrorOf(n)).toBeNull();
|
||||
expect(runErrorOf(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips only a WP node-name prefix", () => {
|
||||
expect(stripNodePrefix("WP Var → Choice: x")).toBe("x");
|
||||
expect(stripNodePrefix("Error: x")).toBe("Error: x");
|
||||
});
|
||||
|
||||
it("finds nodes by execution id, inside subgraphs too", () => {
|
||||
const inner = { id: 5 };
|
||||
const sub = { getNodeById: (id: string | number) => (id === 5 ? inner : null) };
|
||||
const outer = { id: 12, subgraph: sub };
|
||||
const root = { getNodeById: (id: string | number) => (String(id) === "12" ? outer : null) };
|
||||
expect(nodeForExecutionId(root, "12")).toBe(outer);
|
||||
expect(nodeForExecutionId(root, "12:5")).toBe(inner);
|
||||
expect(nodeForExecutionId(root, "12:6")).toBeNull();
|
||||
expect(nodeForExecutionId(root, "3")).toBeNull();
|
||||
expect(nodeForExecutionId(null, "3")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Run errors of WP nodes, shown on the node itself.
|
||||
*
|
||||
* When a WP node raises during a run (Var → Choice set to "Stop the run",
|
||||
* a broken modules JSON …), ComfyUI sends `execution_error` over the
|
||||
* websocket. Some frontends only flash "Job failed" for a few seconds and
|
||||
* show the reason nowhere, so we keep the message per node here:
|
||||
*
|
||||
* - `installRunErrors(api, resolve)` listens once (from `main.ts`):
|
||||
* `execution_error` for a `WP_*` node stores its message and pushes one
|
||||
* error toast; `executing` / `execution_cached` for that node (the next
|
||||
* run reaching it) clears it again.
|
||||
* - `runErrorOf(node)` is reactive: the widget host draws a strip for it
|
||||
* (`widgets/_shared.ts`), Var → Choice paints its last-execute row red
|
||||
* (`widgets/var_picker.ts`).
|
||||
*
|
||||
* Pure apart from the toast store: no ComfyUI imports, so it is tested
|
||||
* with a fake api and the manager build can import it through `_shared`.
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
import { pushToast } from "../components/shared/toast-store";
|
||||
|
||||
export interface RunError {
|
||||
/** The exception message without the node-name prefix. */
|
||||
message: string;
|
||||
/** The message as the toast showed it (node name in front). */
|
||||
full: string;
|
||||
}
|
||||
|
||||
const errors: WeakMap<object, RunError> = new WeakMap();
|
||||
/** Nodes holding an error, so `executing` can find them by id. */
|
||||
const holders: Set<object> = new Set();
|
||||
/** Bumped on every change; `runErrorOf` reads it so callers re-render. */
|
||||
const revision = ref(0);
|
||||
|
||||
export function runErrorOf(node: object | null | undefined): RunError | null {
|
||||
void revision.value;
|
||||
if (!node) return null;
|
||||
return errors.get(node) ?? null;
|
||||
}
|
||||
|
||||
/** "WP Var → Choice: The variable is empty" → "The variable is empty". */
|
||||
export function stripNodePrefix(message: string): string {
|
||||
const m = /^WP [^:\n]{1,40}:\s*/.exec(message);
|
||||
return m ? message.slice(m[0].length) : message;
|
||||
}
|
||||
|
||||
export function setRunError(node: object, message: string, title = ""): RunError {
|
||||
const text = message.trim() || "The node stopped the run.";
|
||||
const full = /^WP [^:\n]{1,40}:/.test(text) || !title ? text : `${title}: ${text}`;
|
||||
const err: RunError = { message: stripNodePrefix(text), full };
|
||||
errors.set(node, err);
|
||||
holders.add(node);
|
||||
revision.value++;
|
||||
return err;
|
||||
}
|
||||
|
||||
export function clearRunError(node: object | null | undefined): void {
|
||||
if (!node || !errors.has(node)) return;
|
||||
errors.delete(node);
|
||||
holders.delete(node);
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
/** Forget every error (tests). */
|
||||
export function resetRunErrors(): void {
|
||||
for (const n of holders) errors.delete(n);
|
||||
holders.clear();
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
type Listener = (e: Event) => void;
|
||||
export interface RunErrorApi {
|
||||
addEventListener?: (name: string, fn: Listener) => void;
|
||||
}
|
||||
|
||||
interface NodeLike {
|
||||
type?: string;
|
||||
comfyClass?: string;
|
||||
title?: string;
|
||||
setDirtyCanvas?: (fg: boolean, bg: boolean) => void;
|
||||
}
|
||||
|
||||
/** Finds a node from its execution id ("12", or "12:5" inside a subgraph). */
|
||||
export type ResolveNode = (executionId: string) => NodeLike | null;
|
||||
|
||||
interface ErrorDetail {
|
||||
node_id?: unknown;
|
||||
node_type?: unknown;
|
||||
exception_message?: unknown;
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** Listen to ComfyUI's run events once. `resolve` maps an execution id to
|
||||
* the node on the canvas. */
|
||||
export function installRunErrors(api: RunErrorApi | undefined, resolve: ResolveNode): void {
|
||||
if (installed || !api?.addEventListener) return;
|
||||
installed = true;
|
||||
const clearId = (id: unknown): void => {
|
||||
if (holders.size === 0 || (typeof id !== "string" && typeof id !== "number")) return;
|
||||
const node = resolve(String(id));
|
||||
if (node && errors.has(node)) {
|
||||
clearRunError(node);
|
||||
node.setDirtyCanvas?.(true, false);
|
||||
}
|
||||
};
|
||||
api.addEventListener("executing", (e) => {
|
||||
const d = (e as CustomEvent).detail as unknown;
|
||||
// Older frontends send the id itself, newer ones `{ node }`.
|
||||
clearId(d && typeof d === "object" ? (d as { node?: unknown }).node : d);
|
||||
});
|
||||
api.addEventListener("execution_cached", (e) => {
|
||||
const nodes = ((e as CustomEvent).detail as { nodes?: unknown } | undefined)?.nodes;
|
||||
if (Array.isArray(nodes)) for (const id of nodes) clearId(id);
|
||||
});
|
||||
api.addEventListener("execution_error", (e) => {
|
||||
const d = (e as CustomEvent).detail as ErrorDetail | undefined;
|
||||
if (!d || (typeof d.node_id !== "string" && typeof d.node_id !== "number")) return;
|
||||
const type = typeof d.node_type === "string" ? d.node_type : "";
|
||||
if (!type.startsWith("WP_")) return;
|
||||
const node = resolve(String(d.node_id));
|
||||
const message = typeof d.exception_message === "string" ? d.exception_message : "";
|
||||
const title = node?.title || type;
|
||||
const err = node ? setRunError(node, message, title) : { full: message.trim() || `${title} stopped the run.` };
|
||||
node?.setDirtyCanvas?.(true, false);
|
||||
pushToast(err.full, { severity: "error", singletonKey: `wp-run-error-${String(d.node_id)}` });
|
||||
});
|
||||
}
|
||||
|
||||
interface GraphLike {
|
||||
getNodeById?: (id: string | number) => unknown;
|
||||
}
|
||||
|
||||
/** The node an execution id names, walking into subgraphs: "12:5" is node
|
||||
* 5 inside the subgraph of root node 12. */
|
||||
export function nodeForExecutionId(root: GraphLike | null | undefined, id: string): NodeLike | null {
|
||||
let graph: GraphLike | null | undefined = root;
|
||||
let node: unknown = null;
|
||||
for (const part of id.split(":")) {
|
||||
if (!graph?.getNodeById) return null;
|
||||
node = graph.getNodeById(part) ?? (/^\d+$/.test(part) ? graph.getNodeById(Number(part)) : null);
|
||||
if (!node || typeof node !== "object") return null;
|
||||
graph = (node as { subgraph?: GraphLike }).subgraph;
|
||||
}
|
||||
return node as NodeLike;
|
||||
}
|
||||
|
||||
/** Test hook: let `installRunErrors` run again. */
|
||||
export function _resetInstalled(): void {
|
||||
installed = false;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defineComponent, h } from "vue";
|
||||
|
||||
import { createDomWidgetHost, type DomWidget } from "../widgets/_shared";
|
||||
import { SHOW_KEY, hidePanel, hostsOf, isShown, menuItems, movePanel, offerPanel, panelsOf, restoreShown, showPanel } from "./show-on-subgraph";
|
||||
import { handleClick, markRows, targetOf } from "./subgraph-eye";
|
||||
|
||||
const Leaf = defineComponent({ setup: () => () => h("span", "leaf") });
|
||||
|
||||
interface Graph {
|
||||
_nodes: unknown[];
|
||||
rootGraph?: Graph;
|
||||
getNodeById: (id: number | string) => unknown;
|
||||
}
|
||||
|
||||
function graph(nodes: unknown[]): Graph {
|
||||
const g: Graph = { _nodes: nodes, getNodeById: (id) => g._nodes.find((n) => String((n as { id: unknown }).id) === String(id)) };
|
||||
return g;
|
||||
}
|
||||
|
||||
function domNode<T extends object>(extra: T) {
|
||||
const widgets: Array<{ name: string; widget: DomWidget }> = [];
|
||||
return Object.assign(
|
||||
{
|
||||
widgets,
|
||||
addDOMWidget(name: string, _t: string, el: HTMLElement) {
|
||||
const widget = { name, element: el } as unknown as DomWidget;
|
||||
widgets.push({ name, widget });
|
||||
return widget;
|
||||
},
|
||||
},
|
||||
extra,
|
||||
);
|
||||
}
|
||||
|
||||
/** A root graph with two subgraph nodes made from one subgraph holding a panel node. */
|
||||
function scene() {
|
||||
const root = graph([]);
|
||||
root.rootGraph = root;
|
||||
const sub = graph([]);
|
||||
sub.rootGraph = root;
|
||||
const inner = domNode({ id: 3, title: "WP Toggles", graph: sub });
|
||||
sub._nodes.push(inner);
|
||||
const makeHost = (id: number) =>
|
||||
domNode({
|
||||
id,
|
||||
graph: root,
|
||||
subgraph: sub,
|
||||
properties: {} as Record<string, unknown>,
|
||||
isSubgraphNode: () => true,
|
||||
removeWidget: vi.fn(function (this: { widgets: Array<{ widget: unknown }> }, w: unknown) {
|
||||
this.widgets.splice(this.widgets.findIndex((x) => x.widget === w), 1);
|
||||
}),
|
||||
});
|
||||
const a = makeHost(10);
|
||||
const b = makeHost(11);
|
||||
root._nodes.push(a, b, { id: 12, isSubgraphNode: () => false });
|
||||
const build = vi.fn(() => createDomWidgetHost(inner, "wp_toggles", Leaf, { initialValue: "cfg" }));
|
||||
offerPanel(inner, "wp_toggles", build);
|
||||
return { root, sub, inner, a, b, build };
|
||||
}
|
||||
|
||||
describe("show on subgraph node", () => {
|
||||
it("finds every subgraph node made from the node's subgraph", () => {
|
||||
const { inner, a, b } = scene();
|
||||
expect(hostsOf(inner)).toEqual([a, b]);
|
||||
expect(hostsOf({ id: 1, graph: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers only panels the node has built", () => {
|
||||
const { inner } = scene();
|
||||
expect(panelsOf(inner)).toEqual(["wp_toggles"]);
|
||||
offerPanel(inner, "other", () => null);
|
||||
expect(panelsOf(inner)).toEqual(["wp_toggles"]);
|
||||
});
|
||||
|
||||
it("shows on every subgraph node, remembers it, and hides again", () => {
|
||||
const { inner, a, b, build } = scene();
|
||||
expect(menuItems(inner).filter(Boolean).map((i) => i?.content)).toEqual(["Show on subgraph node"]);
|
||||
showPanel(inner, "wp_toggles");
|
||||
expect(build).toHaveBeenCalledTimes(3);
|
||||
for (const host of [a, b]) {
|
||||
expect(host.properties[SHOW_KEY]).toEqual([{ node: "3", widget: "wp_toggles" }]);
|
||||
expect(host.widgets.map((w) => w.name)).toEqual(["wp_show:3:wp_toggles"]);
|
||||
}
|
||||
// Showing twice adds nothing.
|
||||
showPanel(inner, "wp_toggles");
|
||||
expect(a.widgets).toHaveLength(1);
|
||||
expect(menuItems(inner).filter(Boolean).map((i) => i?.content)).toEqual(["Hide from subgraph node"]);
|
||||
|
||||
hidePanel(inner, "wp_toggles");
|
||||
for (const host of [a, b]) {
|
||||
expect(host.properties[SHOW_KEY]).toBeUndefined();
|
||||
expect(host.widgets).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("builds saved copies again, skipping inner nodes that are gone", () => {
|
||||
const { a } = scene();
|
||||
a.properties[SHOW_KEY] = [{ node: "3", widget: "wp_toggles" }, { node: "99", widget: "x" }, "junk"];
|
||||
restoreShown(a);
|
||||
restoreShown(a);
|
||||
expect(a.widgets.map((w) => w.name)).toEqual(["wp_show:3:wp_toggles"]);
|
||||
});
|
||||
|
||||
it("keeps the parts a copy leaves out, and drops junk", () => {
|
||||
const { a } = scene();
|
||||
a.properties[SHOW_KEY] = [{ node: "3", widget: "wp_toggles", hide: ["font", 4, "rows"] }];
|
||||
restoreShown(a);
|
||||
expect(a.widgets).toHaveLength(1);
|
||||
hidePanel({ id: 3, graph: a.subgraph as never }, "other");
|
||||
expect(a.properties[SHOW_KEY]).toEqual([{ node: "3", widget: "wp_toggles", hide: ["font", "rows"] }]);
|
||||
});
|
||||
|
||||
it("has nothing to offer outside a subgraph", () => {
|
||||
const root = graph([]);
|
||||
root.rootGraph = root;
|
||||
const plain = domNode({ id: 1, graph: root });
|
||||
offerPanel(plain, "w", () => createDomWidgetHost(plain, "w", Leaf));
|
||||
expect(menuItems(plain)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moving copies", () => {
|
||||
it("swaps the saved order and builds the copies again in it", () => {
|
||||
const { sub, inner, a } = scene();
|
||||
const second = domNode({ id: 4, title: "WP Label Grid", graph: sub });
|
||||
sub._nodes.push(second);
|
||||
offerPanel(second, "wp_label_grid", () => createDomWidgetHost(second, "wp_label_grid", Leaf, { initialValue: "lg" }));
|
||||
showPanel(inner, "wp_toggles");
|
||||
showPanel(second, "wp_label_grid");
|
||||
const order = () => (a.properties[SHOW_KEY] as Array<{ node: string }>).map((p) => p.node);
|
||||
const copies = () => a.widgets.map((w) => w.name).filter((n) => n.startsWith("wp_show:"));
|
||||
expect(order()).toEqual(["3", "4"]);
|
||||
movePanel(a, "4:wp_label_grid", -1);
|
||||
expect(order()).toEqual(["4", "3"]);
|
||||
expect(copies()).toEqual(["wp_show:4:wp_label_grid", "wp_show:3:wp_toggles"]);
|
||||
movePanel(a, "4:wp_label_grid", -1);
|
||||
expect(order()).toEqual(["4", "3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Parameters eye", () => {
|
||||
function panel(nodeId: number, widget: string): HTMLElement {
|
||||
document.body.innerHTML = `<div data-testid="subgraph-editor-hidden-section"><a>Show all</a>
|
||||
<div data-testid="subgraph-widget-item" data-nodeid="${nodeId}"><div><div>WP Toggles</div><div>${widget}</div></div>
|
||||
<button data-testid="subgraph-widget-toggle"><i class="icon-[lucide--eye-off]"></i></button></div></div>`;
|
||||
return document.body;
|
||||
}
|
||||
|
||||
it("shows and hides a WP panel from its row and marks the eye", () => {
|
||||
const { inner, a } = scene();
|
||||
const app = { canvas: { selected_nodes: { 10: a } } };
|
||||
const root = panel(3, "wp_toggles");
|
||||
const row = root.querySelector('[data-testid="subgraph-widget-item"]') as Element;
|
||||
expect(targetOf(row, app)?.widget).toBe("wp_toggles");
|
||||
const icon = root.querySelector("i") as Element;
|
||||
expect(handleClick(icon, app, root)).toBe(true);
|
||||
expect(isShown(inner, "wp_toggles")).toBe(true);
|
||||
expect(icon.classList.contains("icon-[lucide--eye]")).toBe(true);
|
||||
expect(handleClick(icon, app, root)).toBe(true);
|
||||
expect(isShown(inner, "wp_toggles")).toBe(false);
|
||||
expect(icon.classList.contains("icon-[lucide--eye-off]")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves other rows and nothing selected to ComfyUI", () => {
|
||||
const { a } = scene();
|
||||
const root = panel(3, "seed");
|
||||
const icon = root.querySelector("i") as Element;
|
||||
expect(handleClick(icon, { canvas: { selected_nodes: { 10: a } } }, root)).toBe(false);
|
||||
panel(3, "wp_toggles");
|
||||
expect(handleClick(document.querySelector("i"), { canvas: { selected_nodes: {} } }, document.body)).toBe(false);
|
||||
});
|
||||
|
||||
it("Show all shows the WP panels in the list", () => {
|
||||
const { inner, a } = scene();
|
||||
const app = { canvas: { selected_nodes: { 10: a } } };
|
||||
const root = panel(3, "wp_toggles");
|
||||
expect(handleClick(root.querySelector("a"), app, root)).toBe(false);
|
||||
expect(isShown(inner, "wp_toggles")).toBe(true);
|
||||
markRows(root, app);
|
||||
expect(root.querySelector("i")?.classList.contains("icon-[lucide--eye]")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the toolbar eye", () => {
|
||||
it("is offered for a panel node in a subgraph and toggles its panel", async () => {
|
||||
const { inner, a } = scene();
|
||||
const { SHOW_COMMAND, toolboxCommandsFor, toggleShowSelection, anyShown } = await import("./node-toolbar");
|
||||
expect(toolboxCommandsFor(inner)).toContain(SHOW_COMMAND);
|
||||
expect(toolboxCommandsFor(a)).not.toContain(SHOW_COMMAND);
|
||||
const canvas = { selected_nodes: { 3: inner } };
|
||||
expect(toggleShowSelection(canvas)).toBe(true);
|
||||
expect(anyShown(inner)).toBe(true);
|
||||
expect(toggleShowSelection(canvas)).toBe(true);
|
||||
expect(anyShown(inner)).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user