Merge pull request #9 from relhamdi/routing

Routing improvements
This commit is contained in:
relhamdi
2026-06-10 11:56:17 +02:00
committed by GitHub
15 changed files with 941 additions and 81 deletions
+38
View File
@@ -221,6 +221,25 @@ character, {0:position}, {1:background}, {2:lighting}
Connect a node (e.g. a `PromptOptionPicker`) to each slot. The placeholder
is replaced by the connected value at execution.
#### Presets
The node supports saving and recalling slot configurations as named presets, stored directly in the workflow.
```
[preset dropdown] [➕] [💾] [🗑️]
[📋 Export] [📥 Import]
```
| Button | Action |
| ---------- | ------------------------------------------------------------------ |
| `➕` | Creates a new preset from current slot values (prompts for a name) |
| `💾` | Overwrites the selected preset with current slot values |
| `🗑️` | Deletes the selected preset and recalls the next one if available |
| `📋 Export` | Copies all presets as JSON to clipboard |
| `📥 Import` | Pastes and loads presets from JSON |
Presets are sorted alphabetically. Recalling a preset pushes the saved values into connected source nodes. If a saved value is no longer available in a source node, or the source node is disconnected, an error is raised.
#### Cleanup
Same rules as `PromptPresetSelector`. Useful when a connected slot outputs an empty string, leaving residual commas or parentheses.
@@ -320,6 +339,23 @@ Changing the selection in either the parent sub-dropdown or the child router upd
Sub-routing is limited to one level deep.
#### Special node integration
When a connected source node is a `PromptOptionPicker`, `PromptLayoutFiller`, or `PromptPresetSelector`, it is marked with a `◆` indicator in the dropdown:
```
0: position ◆
1: Router B ▶
2: background
```
Selecting it reveals a third dropdown populated with the options or presets of that node. The selection is synchronized in both directions: changing the value in the Router updates the source node, and changing it in the source node updates the Router.
This also works one level deep through a sub-router:
```
[Router] → [sub-router ▶] → [OptionPicker ◆] → [value dropdown]
```
#### Editor
- The dropdown updates automatically when source nodes are connected, disconnected, or renamed.
@@ -625,9 +661,11 @@ Currently, the relative imports in the root `__init__.py` file are preventing py
`PromptPresetSelector`
- Add a field to easily concatenate text at the end of the output
- Wildcard mode for presets (randomly selected, index to -1 or -2?)
- Original `preset_names` unchanged if changed from a `PromptRouter` (even though the value is still the correct one)
`PromptRouter`
- Add support for more sub-routing levels?
- Support special node integration beyond one sub-router level?
`StyleLoader`
- LoRA toggle on/off per row
+2 -1
View File
@@ -35,6 +35,7 @@ class PromptLayoutFiller:
"tooltip": "Clean up residual artifacts from empty slots.",
},
),
"preset_data": ("STRING", {"default": "{}"}),
},
"optional": {
f"slot_{i}": (
@@ -58,7 +59,7 @@ class PromptLayoutFiller:
# # Tests done in the process() function to avoid errors
# ...
def process(self, template, cleanup, **kwargs):
def process(self, template, cleanup, preset_data, **kwargs):
# Validate template
if not template or not template.strip():
raise ValueError("PromptLayoutFiller: template cannot be empty.")
+29 -3
View File
@@ -29,6 +29,13 @@ class PromptRouter:
"tooltip": "Internal use only.",
},
),
"_value_selected": (
"STRING",
{
"default": EMPTY_VALUE,
"tooltip": "Internal use only.",
},
),
},
"optional": {
f"input_{i}": ("STRING", {"forceInput": True})
@@ -78,29 +85,48 @@ class PromptRouter:
# Build both variants, with and without indicator
label = f"{i}: {title}"
label_router = f"{i}: {title} ▶"
label_special = f"{i}: {title} ◆"
entry = (input_name, i)
mapping[label] = entry
mapping[label_router] = entry # Match both
mapping[label_router] = entry
mapping[label_special] = entry
except Exception as e:
raise ValueError(f"[PromptRouter] mapping error: {e}")
return mapping
def process(self, selected, _sub_selected, prompt=None, unique_id=None, **kwargs):
def process(
self,
selected,
_sub_selected,
_value_selected,
prompt=None,
unique_id=None,
**kwargs,
):
# Remove internal widgets from kwargs
kwargs.pop("_sub_source", None)
kwargs.pop("value_source", None)
if not selected or selected == EMPTY_VALUE:
return ("", 0)
# If special value selected, return it
mapping = self._build_mapping(prompt, unique_id, kwargs)
entry = mapping.get(selected)
index = entry[1] if entry else 0
# OptionPicker: direct value
if _value_selected and _value_selected != EMPTY_VALUE:
return (_value_selected, index)
if entry is None:
return ("", 0)
input_name, index = entry
# LayoutFiller / PresetSelector / others: value via kwargs
input_name, _ = entry
value = kwargs.get(input_name, "")
return (value, index)
+3
View File
@@ -180,11 +180,13 @@ class StyleLoader:
sampler,
scheduler,
):
print("[Style Loader] Loading checkpoint:", checkpoint)
model, clip, vae_from_ckpt, _ = _load_checkpoint(checkpoint)
if clip is None:
raise ValueError(f"Checkpoint '{checkpoint}' returned no CLIP.")
print("[Style Loader] Loading vae:", vae)
vae_out = _load_vae(vae) if vae != "none" else vae_from_ckpt
clip = clip.clone()
@@ -196,6 +198,7 @@ class StyleLoader:
loras = json.loads(loras_data) if loras_data else []
except json.JSONDecodeError:
loras = []
print("[Style Loader] Loading loras:", loras_data)
model, clip = _apply_loras(model, clip, loras)
return (
+7 -4
View File
@@ -8,7 +8,7 @@ def node():
def run(node, template, cleanup=False, **slots):
return node.process(template=template, cleanup=cleanup, **slots)
return node.process(template=template, cleanup=cleanup, preset_data="{}", **slots)
# --- Base cases ---
@@ -96,7 +96,8 @@ def test_validate_valid(node):
def test_cleanup_applied(node):
(text,) = node.process(
(text,) = run(
node,
template="({0})",
cleanup=True,
slot_0="",
@@ -105,7 +106,8 @@ def test_cleanup_applied(node):
def test_cleanup_not_applied(node):
(text,) = node.process(
(text,) = run(
node,
template="({0})",
cleanup=False,
slot_0="",
@@ -114,7 +116,8 @@ def test_cleanup_not_applied(node):
def test_cleanup_comma_residual(node):
(text,) = node.process(
(text,) = run(
node,
template="tag, {0}, other",
cleanup=True,
slot_0="",
+1
View File
@@ -34,6 +34,7 @@ def run(node, selected, prompt=None, unique_id=None, **inputs):
return node.process(
selected=selected,
_sub_selected=EMPTY_VALUE,
_value_selected=EMPTY_VALUE,
prompt=prompt,
unique_id=unique_id,
**inputs,
+249 -1
View File
@@ -8,10 +8,12 @@ import {
updateSlotVisibility,
waitForWidget,
} from "./utils.js";
import { ButtonRowWidget } from "./widgets/button_row_widget.js";
import { PresetRowWidget } from "./widgets/preset_row_widget.js";
// --- Constants ---
const NODE_NAME = "PromptLayoutFiller";
export const NODE_NAME = "PromptLayoutFiller";
const NUM_SLOTS = 10;
@@ -53,6 +55,98 @@ const getConnectedSlots = (node) => {
return connected;
};
// --- Source node helpers ---
const getSourceNode = (node, slotIndex) => {
const input = node.inputs?.find((inp) => inp.name === `slot_${slotIndex}`);
if (!input?.link) return null;
const link = app.graph.links[input.link];
if (!link) return null;
return app.graph.getNodeById(link.origin_id) ?? null;
};
const getSourceLabel = (sourceNode) => {
const widget = findWidget(sourceNode, "selected");
if (!widget) return null;
// Retrieve label from value through inverted _labelMap
if (sourceNode._labelMap) {
for (const [label, value] of sourceNode._labelMap.entries()) {
if (value === widget.value) return label;
}
}
return widget.value || null;
};
const setSourceByLabel = (sourceNode, label) => {
const widget = findWidget(sourceNode, "selected");
if (!widget) return false;
// Check value exists in options
const options = widget.options?.values ?? [];
if (!options.includes(label)) return false;
widget.value = sourceNode._labelMap?.get(label) ?? label;
widget.callback?.(label);
return true;
};
// --- Preset management ---
const loadPresets = (node) => {
const w = findWidget(node, "preset_data");
if (!w?.value) return {};
try {
return JSON.parse(w.value);
} catch {
return {};
}
};
const savePresets = (node, data) => {
const w = findWidget(node, "preset_data");
const sorted = Object.fromEntries(
Object.entries(data).sort(([a], [b]) => a.localeCompare(b)),
);
if (w) w.value = JSON.stringify(sorted);
};
const getPresetNames = (data) => Object.keys(data).sort();
const captureSlots = (node) => {
const slots = {};
for (let i = 0; i < NUM_SLOTS; i++) {
const source = getSourceNode(node, i);
if (!source) continue;
const label = getSourceLabel(source);
if (label !== null) slots[`slot_${i}`] = label;
}
return slots;
};
const recallSlots = (node, slots) => {
const errors = [];
for (const [slotName, label] of Object.entries(slots)) {
const idx = parseInt(slotName.replace("slot_", ""));
const source = getSourceNode(node, idx);
if (!source) {
errors.push(`${slotName}: source node is no longer connected.`);
continue;
}
const ok = setSourceByLabel(source, label);
if (!ok) {
errors.push(
`${slotName}: value "${label}" not found in source node options.`,
);
}
}
return errors;
};
// --- Editor ---
const attachEditor = (node) => {
@@ -75,6 +169,160 @@ const attachEditor = (node) => {
editor.innerHTML = buildHighlightedHtml(textarea.value, connected);
};
// --- Preset row widget ---
let presetRow = null;
const refreshPresetRow = () => {
if (!presetRow) return;
const data = loadPresets(node);
presetRow.setPresets(getPresetNames(data));
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
const handleNew = () => {
const name = prompt("New preset name:");
if (!name?.trim()) return;
const data = loadPresets(node);
const slots = captureSlots(node);
// No connected slots
if (!Object.keys(slots).length) {
alert(`[${NODE_NAME}] No connected slots to save.`);
return;
}
// Already existing
if (data[name.trim()]) {
alert(
`[${NODE_NAME}] Preset "${name.trim()}" already exists. Use Save to overwrite.`,
);
return;
}
data[name.trim()] = slots;
savePresets(node, data);
presetRow.setPresets(getPresetNames(data));
presetRow.value = name.trim();
presetRow.flashSave(node);
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
const handleSave = () => {
const name = presetRow.value;
if (!name || presetRow.isEmpty()) return;
const data = loadPresets(node);
const slots = captureSlots(node);
if (!Object.keys(slots).length) {
alert(`[${NODE_NAME}] No connected slots to save.`);
return;
}
data[name] = slots;
savePresets(node, data);
presetRow.flashSave(node);
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
const handleDelete = () => {
const name = presetRow.value;
if (!name || presetRow.isEmpty()) return;
if (!confirm(`Delete preset "${name}"?`)) return;
const data = loadPresets(node);
delete data[name];
savePresets(node, data);
presetRow.setPresets(getPresetNames(data));
if (!presetRow.isEmpty()) handleSelect(presetRow.value);
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
const handleSelect = (name) => {
const data = loadPresets(node);
const slots = data[name];
if (!slots) return;
const errors = recallSlots(node, slots);
if (errors.length) {
alert(`[${NODE_NAME}] Recall errors:\n${errors.join("\n")}`);
}
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
const handleExport = () => {
const data = loadPresets(node);
if (!Object.keys(data).length) {
alert(`[${NODE_NAME}] No presets to export.`);
return;
}
const json = JSON.stringify(data, null, 2);
navigator.clipboard
.writeText(json)
.then(() => {
alert(`[${NODE_NAME}] Presets copied to clipboard.`);
})
.catch(() => {
// Fallback if clipboard unavailable
prompt("Copy this JSON:", json);
});
};
const handleImport = () => {
const json = prompt("Paste preset JSON:");
if (!json?.trim()) return;
try {
const data = JSON.parse(json);
if (typeof data !== "object" || Array.isArray(data)) {
alert(`[${NODE_NAME}] Invalid format — JSON object expected.`);
return;
}
savePresets(node, data);
presetRow.setPresets(getPresetNames(data));
if (!presetRow.isEmpty()) handleSelect(presetRow.value);
if (node.graph) node.graph.setDirtyCanvas(true, true);
} catch (e) {
alert(`[${NODE_NAME}] Invalid JSON.`);
console.warn(`[${NODE_NAME}] Error during import`, e);
}
};
presetRow = new PresetRowWidget(
"preset_row",
handleNew,
handleSave,
handleDelete,
handleSelect,
);
// Initialize presets from stored data
refreshPresetRow();
// Hide preset_data widget and insert presetRow right before
const presetDataWidget = findWidget(node, "preset_data");
if (presetDataWidget) {
presetDataWidget.hidden = true;
const idx = node.widgets.indexOf(presetDataWidget);
node.widgets.splice(idx + 1, 0, presetRow);
}
const exportImportRow = new ButtonRowWidget("preset_actions", [
{ label: "📋 Export", onClick: () => handleExport() },
{ label: "📥 Import", onClick: () => handleImport() },
]);
// Insert right after presetRow
const presetRowIdx = node.widgets.indexOf(presetRow);
node.widgets.splice(presetRowIdx + 1, 0, exportImportRow);
// --- Connection change ---
const debouncedUpdate = debounce(() => {
updateSlotVisibility(node, NUM_SLOTS, "slot");
renderColored();
+7 -1
View File
@@ -10,7 +10,7 @@ import {
// --- Constants ---
const NODE_NAME = "PromptOptionPicker";
export const NODE_NAME = "PromptOptionPicker";
const EMPTY_SENTINEL = "\u200B";
@@ -191,6 +191,12 @@ const attachEditor = (node) => {
// Build label map, handle duplicate labels
const labelMap = new Map();
for (const { label, value } of options) {
if (label === EMPTY_VALUE) {
// Allow duplicates for EMPTY_VALUE
const key = `\u200B`.repeat(labelMap.size) + EMPTY_VALUE;
labelMap.set(key, value);
continue;
}
let uniqueLabel = label;
let suffix = 1;
while (labelMap.has(uniqueLabel)) {
+3 -12
View File
@@ -5,13 +5,14 @@ import {
findWidget,
hideWidget,
hookWidget,
parseComaString,
registerNode,
waitForWidget,
} from "./utils.js";
// --- Constants ---
const NODE_NAME = "PromptPresetSelector";
export const NODE_NAME = "PromptPresetSelector";
// --- Helpers ---
@@ -168,18 +169,8 @@ const attachEditor = (node) => {
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
const parseNames = () => {
const raw = namesWidget?.value;
if (!raw || typeof raw !== "string") return null;
const names = raw
.split(",")
.map((n) => n.trim())
.filter(Boolean);
return names.length >= 1 ? names : null;
};
const refreshCombo = () => {
const names = parseNames();
const names = parseComaString(namesWidget?.value);
if (!names) {
destroyCombo();
return;
+345 -18
View File
@@ -1,8 +1,12 @@
import { EMPTY_VALUE } from "./constants.js";
import { NODE_NAME as LAYOUT_FILLER_NODE_NAME } from "./prompt_layout_filler.js";
import { NODE_NAME as OPTION_PICKER_NODE_NAME } from "./prompt_option_picker.js";
import { NODE_NAME as PRESET_SELECTOR_NODE_NAME } from "./prompt_preset_selector.js";
import {
debounce,
findWidget,
hideWidget,
parseComaString,
registerNode,
updateSlotVisibility,
waitForWidgets,
@@ -33,16 +37,67 @@ const getSourceTitle = (sourceNode) => {
return sourceNode?.title?.trim() || sourceNode?.type || "unknown";
};
const getCurrentTitles = (node) => {
const titles = [];
const getCurrentSignatures = (node) => {
const sigs = [];
for (let i = 0; i < NUM_INPUTS; i++) {
const source = getSourceNode(node, i);
if (source) titles.push(`${i}:${getSourceTitle(source)}`);
if (!source) continue;
const title = getSourceTitle(source);
const sig = isSpecialNode(source)
? getSpecialNodeSignature(source)
: isRouter(source)
? getCurrentSignatures(source).join("|")
: "";
sigs.push(`${i}:${title}:${sig}`);
}
return titles;
return sigs;
};
const isRouter = (sourceNode) => sourceNode?.type === NODE_NAME;
const isOptionPicker = (node) => node?.type === OPTION_PICKER_NODE_NAME;
const isLayoutFiller = (node) => node?.type === LAYOUT_FILLER_NODE_NAME;
const isPresetSelector = (node) => node?.type === PRESET_SELECTOR_NODE_NAME;
const isSpecialNode = (node) =>
isOptionPicker(node) || isLayoutFiller(node) || isPresetSelector(node);
const getSpecialNodeOptions = (sourceNode) => {
if (isOptionPicker(sourceNode)) {
return sourceNode._labelMap ? [...sourceNode._labelMap.keys()] : [];
}
if (isLayoutFiller(sourceNode)) {
try {
const w = findWidget(sourceNode, "preset_data");
const data = JSON.parse(w?.value ?? "{}");
return Object.keys(data).sort();
} catch {
return [];
}
}
if (isPresetSelector(sourceNode)) {
const namesWidget = findWidget(sourceNode, "preset_names");
const names = parseComaString(namesWidget?.value);
if (names.length) return names;
// Fallback sur indices
const presetWidget = findWidget(sourceNode, "preset_index");
const count = sourceNode._presetCount ?? 1;
return Array.from({ length: count }, (_, i) => `preset_${i}`);
}
return [];
};
const getSpecialNodeSignature = (sourceNode) => {
if (isOptionPicker(sourceNode)) {
return [...(sourceNode._labelMap?.keys() ?? [])].join(",");
}
if (isLayoutFiller(sourceNode)) {
return findWidget(sourceNode, "preset_data")?.value ?? "";
}
if (isPresetSelector(sourceNode)) {
return findWidget(sourceNode, "preset_names")?.value ?? "";
}
return "";
};
const getRouterOptions = (routerNode) => {
const options = [];
@@ -50,16 +105,44 @@ const getRouterOptions = (routerNode) => {
const source = getSourceNode(routerNode, i);
if (!source) continue;
const title = getSourceTitle(source);
const indicator = isRouter(source) ? " ▶" : "";
options.push({
label: `${i}: ${title}${indicator}`,
index: i,
source,
});
const indicator = isRouter(source)
? " ▶"
: isSpecialNode(source)
? " ◆"
: "";
options.push({ label: `${i}: ${title}${indicator}`, index: i, source });
}
return options;
};
const setSpecialNodeValue = (sourceNode, value) => {
if (isOptionPicker(sourceNode)) {
const widget = findWidget(sourceNode, "selected");
if (!widget) return;
const options = widget.options?.values ?? [];
if (!options.includes(value)) return;
widget.value = sourceNode._labelMap?.get(value) ?? value;
widget.callback?.(value);
}
if (isLayoutFiller(sourceNode)) {
// Recall preset via presetRow
const presetRow = findWidget(sourceNode, "preset_row");
if (!presetRow) return;
presetRow.value = value;
presetRow.onSelect?.(value);
}
if (isPresetSelector(sourceNode)) {
const namesWidget = findWidget(sourceNode, "preset_names");
const names = parseComaString(namesWidget?.value);
const idx = names.indexOf(value);
const presetWidget = findWidget(sourceNode, "preset_index");
if (!presetWidget) return;
presetWidget.value =
idx >= 0 ? idx : parseInt(value.replace("preset_", "")) || 0;
presetWidget.callback?.(presetWidget.value);
}
};
// --- Sub-dropdown ---
const destroySubDropdown = (node) => {
@@ -114,6 +197,16 @@ const buildSubDropdown = (node, routerNode, selectedWidget) => {
const childSelectedWidget = findWidget(routerNode, "selected");
const childComboWidget = findWidget(routerNode, "source");
const checkSubSpecial = (value) => {
const freshSubOptions = getRouterOptions(routerNode);
const subOption = freshSubOptions.find((o) => o.label === value);
if (subOption && isSpecialNode(subOption.source)) {
buildValueDropdown(node, subOption.source, selectedWidget);
} else {
destroyValueDropdown(node);
}
};
if (childComboWidget) {
// Initialize listener set once on the child widget
if (!childComboWidget._parentListeners) {
@@ -132,6 +225,7 @@ const buildSubDropdown = (node, routerNode, selectedWidget) => {
if (node._subComboWidget?.options.values.includes(value)) {
node._subComboWidget.value = value;
}
checkSubSpecial(value);
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
childComboWidget._parentListeners.add(listener);
@@ -182,9 +276,231 @@ const buildSubDropdown = (node, routerNode, selectedWidget) => {
node._subComboWidget = subCombo;
node._subSelectedWidget = subSelectedWidget;
// Hook subCombo callback to detect special nodes
const originalSubCallback = subCombo.callback;
subCombo.callback = function (value) {
if (originalSubCallback) originalSubCallback.call(this, value);
checkSubSpecial(value);
};
// Initial check
checkSubSpecial(initialValue);
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
const destroyValueDropdown = (node) => {
if (!node._valueComboWidget) return;
// Clean source node hook
if (node._hookedValueSource) {
const { widget, listener } = node._hookedValueSource;
widget._routerListeners?.delete(listener);
node._hookedValueSource = null;
}
// Empty _value_selected
const valueSelectedWidget = findWidget(node, "_value_selected");
if (valueSelectedWidget) valueSelectedWidget.value = EMPTY_VALUE;
const idx = node.widgets.indexOf(node._valueComboWidget);
if (idx !== -1) node.widgets.splice(idx, 1);
node._valueComboWidget = null;
node._valueSourceNode = null;
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
const getCurrentSpecialValue = (sourceNode, options) => {
if (isOptionPicker(sourceNode)) {
const w = findWidget(sourceNode, "selected");
if (!w) return options[0];
const label = [...(sourceNode._labelMap?.entries() ?? [])].find(
([, v]) => v === w.value,
)?.[0];
return options.includes(label) ? label : options[0];
}
if (isPresetSelector(sourceNode)) {
const namesWidget = findWidget(sourceNode, "preset_names");
const names = parseComaString(namesWidget?.value);
const idx = findWidget(sourceNode, "preset_index")?.value ?? 0;
const label = names[idx] ?? `preset_${idx}`;
return options.includes(label) ? label : options[0];
}
if (isLayoutFiller(sourceNode)) {
const presetRow = findWidget(sourceNode, "preset_row");
const label = presetRow?.value ?? options[0];
return options.includes(label) ? label : options[0];
}
return options[0];
};
// Hook source node <-> router sync
const hookSourceNode = (
node,
sourceNode,
valueSelectedWidget,
valueCombo,
usesValueSelected,
) => {
if (isOptionPicker(sourceNode)) {
const widget = findWidget(sourceNode, "selected");
if (!widget) return;
if (!widget._routerListeners) {
widget._routerListeners = new Set();
const original = widget.callback;
widget.callback = function (value) {
if (original) original.call(this, value);
for (const listener of widget._routerListeners) {
listener(value);
}
};
}
const listener = (labelOrRaw) => {
const label = sourceNode._labelMap?.has(labelOrRaw)
? labelOrRaw
: ([...(sourceNode._labelMap?.entries() ?? [])].find(
([, v]) => v === labelOrRaw,
)?.[0] ?? labelOrRaw);
if (valueCombo.options.values.includes(label)) {
valueCombo.value = label;
}
if (usesValueSelected && valueSelectedWidget) {
const rawValue = sourceNode._labelMap?.get(label) ?? label;
valueSelectedWidget.value = rawValue;
}
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
widget._routerListeners.add(listener);
node._hookedValueSource = { widget, listener };
}
if (isPresetSelector(sourceNode)) {
const presetWidget = findWidget(sourceNode, "preset_index");
const namesWidget = findWidget(sourceNode, "preset_names");
if (!presetWidget) return;
if (!presetWidget._routerListeners) {
presetWidget._routerListeners = new Set();
const original = presetWidget.callback;
presetWidget.callback = function (value) {
if (original) original.call(this, value);
for (const listener of presetWidget._routerListeners) {
listener(value);
}
};
}
const listener = (idx) => {
const names = parseComaString(namesWidget?.value);
const label = names[idx] ?? `preset_${idx}`;
if (valueCombo.options.values.includes(label)) {
valueCombo.value = label;
}
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
presetWidget._routerListeners.add(listener);
node._hookedValueSource = { widget: presetWidget, listener };
}
if (isLayoutFiller(sourceNode)) {
const presetRow = findWidget(sourceNode, "preset_row");
if (!presetRow) return;
if (!presetRow._routerListeners) {
presetRow._routerListeners = new Set();
const originalSelect = presetRow.onSelect;
presetRow.onSelect = function (value) {
if (originalSelect) originalSelect.call(this, value);
for (const listener of presetRow._routerListeners) {
listener(value);
}
};
}
const listener = (value) => {
if (valueCombo.options.values.includes(value)) {
valueCombo.value = value;
}
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
presetRow._routerListeners.add(listener);
node._hookedValueSource = { widget: presetRow, listener };
}
};
const buildValueDropdown = (node, sourceNode, selectedWidget) => {
destroyValueDropdown(node);
const options = getSpecialNodeOptions(sourceNode);
if (!options.length) return;
const valueSelectedWidget = findWidget(node, "_value_selected");
const initialValue = getCurrentSpecialValue(sourceNode, options);
const usesValueSelected = isOptionPicker(sourceNode);
const valueCombo = node.addWidget(
"combo",
"value_source",
initialValue,
(value) => {
setSpecialNodeValue(sourceNode, value);
if (usesValueSelected && valueSelectedWidget) {
// Convert label to real value
const rawValue = sourceNode._labelMap?.get(value) ?? value;
valueSelectedWidget.value = rawValue;
} else if (valueSelectedWidget) {
valueSelectedWidget.value = EMPTY_VALUE; // Reset value
}
if (node.graph) node.graph.setDirtyCanvas(true, true);
},
{ values: options },
);
hookSourceNode(
node,
sourceNode,
valueSelectedWidget,
valueCombo,
usesValueSelected,
);
node._valueComboWidget = valueCombo;
node._valueSourceNode = sourceNode;
// Sync initial value
setSpecialNodeValue(sourceNode, initialValue);
if (usesValueSelected && valueSelectedWidget) {
const rawValue =
sourceNode._labelMap?.get(initialValue) ?? initialValue;
valueSelectedWidget.value = rawValue;
} else if (valueSelectedWidget) {
valueSelectedWidget.value = EMPTY_VALUE;
}
if (node.graph) node.graph.setDirtyCanvas(true, true);
};
const buildSecondaryDropdown = (node, sourceNode, selectedWidget) => {
if (isRouter(sourceNode)) {
destroyValueDropdown(node);
buildSubDropdown(node, sourceNode, selectedWidget);
} else if (isSpecialNode(sourceNode)) {
destroySubDropdown(node);
buildValueDropdown(node, sourceNode, selectedWidget);
} else {
destroySubDropdown(node);
destroyValueDropdown(node);
}
};
// --- Dropdown refresh ---
const refreshDropdown = (node, selectedWidget, comboWidget) => {
@@ -195,6 +511,7 @@ const refreshDropdown = (node, selectedWidget, comboWidget) => {
comboWidget.value = EMPTY_VALUE;
selectedWidget.value = EMPTY_VALUE;
destroySubDropdown(node);
destroyValueDropdown(node);
if (node.graph) node.graph.setDirtyCanvas(true, true);
return;
}
@@ -217,10 +534,11 @@ const refreshDropdown = (node, selectedWidget, comboWidget) => {
// Check if selected source is a sub-router
const selectedOption = options.find((o) => o.label === comboWidget.value);
if (selectedOption && isRouter(selectedOption.source)) {
buildSubDropdown(node, selectedOption.source, selectedWidget);
if (selectedOption) {
buildSecondaryDropdown(node, selectedOption.source, selectedWidget);
} else {
destroySubDropdown(node);
destroyValueDropdown(node);
}
if (node.graph) node.graph.setDirtyCanvas(true, true);
@@ -231,11 +549,13 @@ const refreshDropdown = (node, selectedWidget, comboWidget) => {
const attachRouter = (node) => {
const selectedWidget = findWidget(node, "selected");
const subSelectedWidget = findWidget(node, "_sub_selected");
const valueSelectedWidget = findWidget(node, "_value_selected");
if (!selectedWidget) return;
// Hide native Python widgets
hideWidget(selectedWidget);
if (subSelectedWidget) hideWidget(subSelectedWidget);
if (valueSelectedWidget) hideWidget(valueSelectedWidget);
// Create frontend combo widget
const comboWidget = node.addWidget(
@@ -249,10 +569,15 @@ const attachRouter = (node) => {
// Update sub-dropdown if selected source is a router
const options = getRouterOptions(node);
const selectedOption = options.find((o) => o.label === value);
if (selectedOption && isRouter(selectedOption.source)) {
buildSubDropdown(node, selectedOption.source, selectedWidget);
if (selectedOption) {
buildSecondaryDropdown(
node,
selectedOption.source,
selectedWidget,
);
} else {
destroySubDropdown(node);
destroyValueDropdown(node);
}
if (node.graph) node.graph.setDirtyCanvas(true, true);
@@ -265,14 +590,16 @@ const attachRouter = (node) => {
refreshDropdown(node, selectedWidget, comboWidget);
// --- Draw Foreground hook - Title change detection ---
let lastTitles = [];
let lastSignatures = [];
const originalDrawForeground = node.onDrawForeground;
node.onDrawForeground = function (ctx) {
if (originalDrawForeground) originalDrawForeground.call(this, ctx);
const currentTitles = getCurrentTitles(node);
if (JSON.stringify(currentTitles) !== JSON.stringify(lastTitles)) {
lastTitles = currentTitles;
const currentSignatures = getCurrentSignatures(node);
if (
JSON.stringify(currentSignatures) !== JSON.stringify(lastSignatures)
) {
lastSignatures = currentSignatures;
node.refreshDropdown();
}
};
+2 -12
View File
@@ -3,6 +3,7 @@ import {
findWidget,
hideWidget,
hookWidget,
parseComaString,
registerNode,
waitForWidgets,
} from "./utils.js";
@@ -11,17 +12,6 @@ import {
const NODE_NAME = "QuickCombo";
// --- Helpers ---
const parseOptions = (raw) => {
if (!raw?.trim()) return null;
const options = raw
.split(",")
.map((o) => o.trim())
.filter(Boolean);
return options.length > 0 ? options : null;
};
// --- Attach ---
const attachQuickCombo = (node) => {
@@ -44,7 +34,7 @@ const attachQuickCombo = (node) => {
);
const refreshDropdown = () => {
const options = parseOptions(optionsWidget.value);
const options = parseComaString(optionsWidget.value);
if (!options) {
comboWidget.options.values = [EMPTY_VALUE];
+34 -24
View File
@@ -290,6 +290,7 @@ const handleNew = async (node, styleFileWidget, addButtonWidget) => {
addButtonWidget,
true,
);
loadPreviewImage(data.file, node);
if (node.graph) node.graph.setDirtyCanvas(true, true);
} else {
alert(`[${NODE_NAME}] ${data.error}`);
@@ -336,6 +337,7 @@ const handleClone = async (node, styleFileWidget) => {
if (data.ok) {
patchFileDropdown(styleFileWidget, EMPTY_VALUE, file);
loadPreviewImage(file, node);
if (node.graph) node.graph.setDirtyCanvas(true, true);
} else {
alert(`[${NODE_NAME}] Clone failed: ${data.error}`);
@@ -472,34 +474,42 @@ const attachStyleLoader = (node) => {
// Action buttons
addButtons(node, styleFileWidget, addButtonWidget);
// Restore loras_data first
let hasPersistedLoras = false;
if (lorasDataWidget?.value) {
try {
const loras = JSON.parse(lorasDataWidget.value);
if (loras.length) {
rebuildLoraRows(node, loras, addButtonWidget);
hasPersistedLoras = true;
}
} catch (e) {
console.warn(`[${NODE_NAME}] Failed to restore LoRA slots:`, e);
const init = async () => {
// Ensure assets are loaded
if (!ASSETS.loras.length) {
await fetchAssets();
}
}
if (lorasDataWidget) {
lorasDataWidget.hidden = true;
}
// Restore loras_data first
let hasPersistedLoras = false;
if (lorasDataWidget?.value) {
try {
const loras = JSON.parse(lorasDataWidget.value);
if (loras.length) {
rebuildLoraRows(node, loras, addButtonWidget);
hasPersistedLoras = true;
}
} catch (e) {
console.warn(`[${NODE_NAME}] Failed to restore LoRA slots:`, e);
}
}
if (lorasDataWidget) {
lorasDataWidget.hidden = true;
}
// Load initial file + preview
loadFileIntoWidgets(
styleFileWidget.value,
node,
addButtonWidget,
!hasPersistedLoras,
);
loadPreviewImage(styleFileWidget.value, node);
};
// Attach preview
attachPreview(node);
// Load initial file + preview
loadFileIntoWidgets(
styleFileWidget.value,
node,
addButtonWidget,
!hasPersistedLoras,
);
loadPreviewImage(styleFileWidget.value, node);
init();
// Reload on file change
const originalCallback = styleFileWidget.callback;
+11 -4
View File
@@ -3,6 +3,16 @@ import { app } from "/scripts/app.js";
// --- Text ---
export const parseComaString = (raw) => {
if (!raw || !raw?.trim() || typeof raw !== "string") return null;
const parsed =
raw
.split(",")
.map((o) => o.trim())
.filter(Boolean) ?? [];
return parsed.length ? parsed : null;
};
// Escape HTML to prevent injections
export const escapeHtml = (str) =>
str.replace(
@@ -396,9 +406,7 @@ export const waitForWidgets = (node, callback) => {
// --- Buttons ---
export const flashButton = (node, index, flashLabel) => {
const buttonRowWidget = node.widgets.find(
(w) => w.name === "action_buttons",
);
const buttonRowWidget = findWidget(node, "action_buttons");
if (!buttonRowWidget) return;
const btn = buttonRowWidget.buttons[index];
@@ -407,7 +415,6 @@ export const flashButton = (node, index, flashLabel) => {
// Save original if not flashing
if (!btn._originalLabel) btn._originalLabel = btn.label;
btn.label = flashLabel;
if (node.graph) node.graph.setDirtyCanvas(true, true);
+9 -1
View File
@@ -39,6 +39,8 @@ export class LoraRowWidget {
return [0, ROW_HEIGHT];
}
// --- Draw ---
draw(ctx, node, width, posY, height) {
this.last_y = posY;
const h = height ?? ROW_HEIGHT;
@@ -135,6 +137,8 @@ export class LoraRowWidget {
ctx.restore();
}
// --- Events ---
mouse(event, pos, node) {
const x = pos[0];
const type = event.type;
@@ -224,10 +228,14 @@ export class LoraRowWidget {
return false;
}
// --- Utils ---
_inHit(x, hit) {
return x >= hit[0] && x <= hit[0] + hit[1];
}
// --- Menu ---
_showLoraMenu(event, node) {
// Destroy any previous menu
document.querySelector("._lora_picker_menu")?.remove();
@@ -358,7 +366,7 @@ export class LoraRowWidget {
}
}
// --- Utils---
// --- Helpers ---
// Fit string to width (truncate with ellipsis)
const fitString = (ctx, str, maxWidth) => {
+201
View File
@@ -0,0 +1,201 @@
import { COLORS, EMPTY_VALUE } from "../constants.js";
// --- Constants ---
const ROW_HEIGHT = 20;
const ROW_MARGIN = 10;
const INNER_MARGIN = 4;
const BTN_WIDTH = 36;
const EMPTY_PRESET = "";
// --- PresetRowWidget ---
export class PresetRowWidget {
constructor(name, onNew, onSave, onDelete, onSelect) {
this.name = name;
this.type = "custom";
this.value = EMPTY_PRESET;
this.presets = [];
this.last_y = 0;
this.onNew = onNew;
this.onSave = onSave;
this.onDelete = onDelete;
this.onSelect = onSelect;
// Hit areas [x, width]
this._hitDropdown = [0, 0];
this._hitNew = [0, 0];
this._hitSave = [0, 0];
this._hitDelete = [0, 0];
}
computeSize() {
return [0, ROW_HEIGHT];
}
// --- Public API ---
setPresets(presets) {
this.presets = presets;
if (!presets.length) {
this.value = EMPTY_PRESET;
} else if (!presets.includes(this.value)) {
this.value = presets[0];
}
}
isEmpty() {
return !this.presets.length;
}
flashSave(node, label = "✅", duration = 1500) {
if (this._flashTimer) clearTimeout(this._flashTimer);
this._saveFlash = label;
if (node.graph) node.graph.setDirtyCanvas(true, true);
this._flashTimer = setTimeout(() => {
this._saveFlash = null;
this._flashTimer = null;
if (node.graph) node.graph.setDirtyCanvas(true, true);
}, duration);
}
// --- Draw ---
draw(ctx, node, width, posY, height) {
this.last_y = posY;
const h = height ?? ROW_HEIGHT;
const midY = posY + h * 0.5;
const margin = ROW_MARGIN;
ctx.save();
ctx.font = "11px monospace";
ctx.textBaseline = "middle";
const totalWidth = width - margin * 2;
const btnsWidth = BTN_WIDTH * 3 + INNER_MARGIN * 2;
const dropdownWidth = totalWidth - btnsWidth - INNER_MARGIN;
const dropdownX = margin;
const newX = margin + dropdownWidth + INNER_MARGIN;
const saveX = newX + BTN_WIDTH + INNER_MARGIN;
const deleteX = saveX + BTN_WIDTH + INNER_MARGIN;
// Dropdown
ctx.fillStyle = "#222233";
ctx.beginPath();
ctx.roundRect(dropdownX, posY + 4, dropdownWidth, h - 8, 4);
ctx.fill();
ctx.fillStyle = this.isEmpty() ? COLORS.inactive : COLORS.text;
ctx.textAlign = "left";
ctx.fillText(
this.isEmpty()
? EMPTY_VALUE
: fitString(ctx, this.value, dropdownWidth - 16),
dropdownX + 8,
midY,
);
// Dropdown arrow
ctx.fillStyle = COLORS.inactive;
ctx.textAlign = "center";
ctx.fillText("▾", dropdownX + dropdownWidth - 10, midY);
this._hitDropdown = [dropdownX, dropdownWidth];
// New button
ctx.fillStyle = "#223322";
ctx.beginPath();
ctx.roundRect(newX, posY + 4, BTN_WIDTH, h - 8, 4);
ctx.fill();
ctx.fillStyle = COLORS.text;
ctx.textAlign = "center";
ctx.fillText("➕", newX + BTN_WIDTH / 2, midY);
this._hitNew = [newX, BTN_WIDTH];
// Save button (disabled if empty)
ctx.fillStyle = this.isEmpty() ? "#1a1a1a" : "#222233";
ctx.beginPath();
ctx.roundRect(saveX, posY + 4, BTN_WIDTH, h - 8, 4);
ctx.fill();
ctx.fillStyle = this.isEmpty() ? COLORS.inactive : COLORS.text;
ctx.fillText(this._saveFlash ?? "💾", saveX + BTN_WIDTH / 2, midY);
this._hitSave = [saveX, BTN_WIDTH];
// Delete button (disabled if empty)
ctx.fillStyle = this.isEmpty() ? "#1a1a1a" : "#4a1a1a";
ctx.beginPath();
ctx.roundRect(deleteX, posY + 4, BTN_WIDTH, h - 8, 4);
ctx.fill();
ctx.fillStyle = this.isEmpty() ? COLORS.inactive : "#ff6666";
ctx.fillText("🗑️", deleteX + BTN_WIDTH / 2, midY);
this._hitDelete = [deleteX, BTN_WIDTH];
ctx.restore();
}
// --- Mouse ---
mouse(event, pos, node) {
const x = pos[0];
const type = event.type;
if (type !== "pointerdown" && type !== "mousedown") return false;
if (this._inHit(x, this._hitNew)) {
this.onNew?.();
return true;
}
if (this._inHit(x, this._hitSave)) {
if (!this.isEmpty()) this.onSave?.();
return true;
}
if (this._inHit(x, this._hitDelete)) {
if (!this.isEmpty()) this.onDelete?.();
return true;
}
if (this._inHit(x, this._hitDropdown)) {
this._showDropdown(event, node);
return true;
}
return false;
}
// --- Utils ---
_inHit(x, hit) {
return x >= hit[0] && x <= hit[0] + hit[1];
}
// --- Dropdown ---
_showDropdown(event, node) {
if (!this.presets.length) return;
const items = this.presets.map((name) => ({
content: name,
callback: () => {
this.value = name;
this.onSelect?.(name);
node.setDirtyCanvas(true, true);
},
}));
new LiteGraph.ContextMenu(items, {
event,
title: "Select Preset",
});
}
}
// --- Helpers ---
const fitString = (ctx, str, maxWidth) => {
if (ctx.measureText(str).width <= maxWidth) return str;
while (str.length > 1 && ctx.measureText(str + "…").width > maxWidth) {
str = str.slice(0, -1);
}
return str + "…";
};