@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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="",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 + "…";
|
||||
};
|
||||
Reference in New Issue
Block a user