diff --git a/README.md b/README.md index 720dd15..81fe3de 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/nodes/prompt_layout_filler.py b/src/nodes/prompt_layout_filler.py index b9a241c..7cc7f2d 100644 --- a/src/nodes/prompt_layout_filler.py +++ b/src/nodes/prompt_layout_filler.py @@ -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.") diff --git a/src/nodes/prompt_router.py b/src/nodes/prompt_router.py index e6372f1..eaa0645 100644 --- a/src/nodes/prompt_router.py +++ b/src/nodes/prompt_router.py @@ -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) diff --git a/src/nodes/style_loader.py b/src/nodes/style_loader.py index 34fd957..6bcc3a5 100644 --- a/src/nodes/style_loader.py +++ b/src/nodes/style_loader.py @@ -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 ( diff --git a/tests/test_prompt_layout_filler.py b/tests/test_prompt_layout_filler.py index 4ad67cc..15eb93d 100644 --- a/tests/test_prompt_layout_filler.py +++ b/tests/test_prompt_layout_filler.py @@ -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="", diff --git a/tests/test_prompt_router.py b/tests/test_prompt_router.py index 0caf356..b4e9f28 100644 --- a/tests/test_prompt_router.py +++ b/tests/test_prompt_router.py @@ -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, diff --git a/web/prompt_layout_filler.js b/web/prompt_layout_filler.js index f7f3016..e7fdc7c 100644 --- a/web/prompt_layout_filler.js +++ b/web/prompt_layout_filler.js @@ -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(); diff --git a/web/prompt_option_picker.js b/web/prompt_option_picker.js index 897df7e..762f39e 100644 --- a/web/prompt_option_picker.js +++ b/web/prompt_option_picker.js @@ -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)) { diff --git a/web/prompt_preset_selector.js b/web/prompt_preset_selector.js index 8d18a72..a1c30c9 100644 --- a/web/prompt_preset_selector.js +++ b/web/prompt_preset_selector.js @@ -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; diff --git a/web/prompt_router.js b/web/prompt_router.js index d9cc65f..0c026e4 100644 --- a/web/prompt_router.js +++ b/web/prompt_router.js @@ -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(); } }; diff --git a/web/quick_combo.js b/web/quick_combo.js index 4a4c8f7..4b8933a 100644 --- a/web/quick_combo.js +++ b/web/quick_combo.js @@ -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]; diff --git a/web/style_loader.js b/web/style_loader.js index 7f04820..36cb412 100644 --- a/web/style_loader.js +++ b/web/style_loader.js @@ -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; diff --git a/web/utils.js b/web/utils.js index 150bf5c..875d4fd 100644 --- a/web/utils.js +++ b/web/utils.js @@ -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); diff --git a/web/widgets/lora_row_widget.js b/web/widgets/lora_row_widget.js index 854ea87..15ea774 100644 --- a/web/widgets/lora_row_widget.js +++ b/web/widgets/lora_row_widget.js @@ -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) => { diff --git a/web/widgets/preset_row_widget.js b/web/widgets/preset_row_widget.js new file mode 100644 index 0000000..390c1cc --- /dev/null +++ b/web/widgets/preset_row_widget.js @@ -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 + "…"; +};