diff --git a/README.md b/README.md
index f814dfe..fe91ae4 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,6 @@ A collection of custom nodes for ComfyUI.
> [!NOTE]
> This project was inspired by the [cookiecutter](https://github.com/Comfy-Org/cookiecutter-comfy-extension) template, though the full architecture is a bit different.
-
## Quickstart
1. Install [ComfyUI](https://docs.comfy.org/get_started).
@@ -13,60 +12,122 @@ A collection of custom nodes for ComfyUI.
3. Look up this extension in ComfyUI-Manager. If you are installing manually, clone this repository under `ComfyUI/custom_nodes`.
4. Restart ComfyUI.
-
## Features
### PromptPresetSelector node
-
+
-Input:
-- `separator` (str), default to |
-- `preset_index` (int) in range 1 to 50, default to 1
-- `text` (str), a multiline textarea
-- `cleanup` (bool), default to **True**
+#### Inputs
-Output:
-- `prompt` (str), the formatted prompt
-- `preset_index` (int), the selected index
+- `syntax` (str), default to `{% | %}` — defines the opening tag, separator, and closing tag as a single field, space-separated
+- `preset_index` (int), 0-based, default to `0` — loops both ways within range
+- `preset_name` (combo) — dynamic dropdown, active when `preset_names` is filled
+- `preset_names` (str), optional — comma-separated list of preset names. Ex: `neutral, happy, sad`. If fewer names than presets, remaining are auto-named (`preset_2`, `preset_3`...). If more, extra names are ignored.
+- `text` (str), multiline textarea supporting preset blocks
+- `cleanup` (bool), default to `True`
-This node helps introducing variations to a text, usually a prompt, by supporting templates. Basically, you can include a template like this `{% %}` in your `text`, that will hold various options, separated by the `separator`.
+#### Outputs
-Depending on the chosen `preset_index`, the correct preset option will be parsed and add to the text.
+- `text` (str) — the formatted text, with preset blocks resolved
+- `preset_index` (int) — the selected index
+- `preset_count` (int) — the number of presets found across blocks
+
+#### Usage
+
+Include preset blocks in your `text` using the syntax defined in the `syntax` field. Each block holds options separated by the separator. Depending on `preset_index`, the matching option is injected and the block markers are removed.
-> Example:
->
```
-This is {% an example | a text | a text %} for {% the PromptPresetSelector | the README | %}
+This is {% an example | a text | another text %} for {% the PromptPresetSelector | the README | %}
```
-- With `preset_index` == 1: This is an example for the PromptPresetSelector
-- With `preset_index` == 2: This is an text for the README
-- With `preset_index` == 3: This is an text for
+- `preset_index` = `0` -> `This is an example for the PromptPresetSelector`
+- `preset_index` = `1` -> `This is a text for the README`
+- `preset_index` = `2` -> `This is another text for`
-The node validates that the `separator` is filled, that you registered the same amount of options in all of the templates, and that you selected a `preset_index` within the length of the configured presets.
+Empty presets are allowed — the block is simply replaced by an empty string.
-> Example:
+#### Custom syntax
-If we fix `preset_index` to 3:
+The `syntax` field allows changing the opening tag, separator and closing tag. Format is always `open_tag separator close_tag`, space-separated.
-- *This is **{% a text | a message %}** for **{% the PromptPresetSelector %}*** => Error, first template with 2 presets while the second only got 1
-- *This is **{% a text | a message %}** for **{% the PromptPresetSelector | the README %}*** => Error, only 2 presets were configured
+```
+<< / >>
+```
-A `cleanup` option is also provided, to clean spaces before comas, double or empty comas and empty lines, that may result from the preset application.
+Would match blocks like `<< option_a / option_b >>`.
+#### Preset names
-### Web folder
+When `preset_names` is filled, `preset_index` is replaced by a dynamic dropdown (`preset_name`) built from the comma-separated names. The dropdown resets to the first preset when activated.
-#### PromptColorEditor
+If fewer names than presets are defined, remaining entries are auto-named (`preset_2`, `preset_3`...). If more names than presets, extra names are ignored.
-This extension adds a `contentEditable` div on top of the `text` textarea of the **PromptPresetSelector** to display the tags with some color. The colors will be displayed when the textarea loses focus, and will disappear when focused.
+#### Preset references
-Currently, tags and separators will have the same color, and the unselected presets will be grayed out. The colors are also updated live when the `selector` or `preset_index` values are changed
+Inside a preset, you can reference a previously declared preset using `$N` (0-based index).
+```
+{% base style | $0, hat | $1, jewelry %}
+```
+
+- `preset_index` = `0` → `base style`
+- `preset_index` = `1` → `base style, hat`
+- `preset_index` = `2` → `base style, hat, jewelry`
+
+References are resolved recursively — `$1` in preset 2 resolves preset 1, which may itself reference preset 0.
+
+Rules:
+- `$N` can only reference a preset with a lower index (forward references are not allowed)
+- `$N` must be within range
+- Circular references raise an error
+
+#### Validation
+
+The node runs the following checks and stops the workflow on error:
+
+- `syntax` must contain exactly 3 space-separated parts
+- `open_tag` and `close_tag` must be different
+- All preset blocks in `text` must have the same number of options
+- `preset_index` must be within range
+
+#### Cleanup
+
+When `cleanup` is enabled, the following are removed from the output:
+
+- Spaces before commas
+- Double or empty commas
+- Empty lines
+
+### Web
+
+#### PromptPresetSelector editor
+
+The `text` widget is replaced by a `contentEditable` div that provides
+syntax highlighting:
+
+- Tags and separators are highlighted in orange
+- The active preset is shown in full brightness
+- Inactive presets are grayed out
+- `$N` references are highlighted in blue on the active preset
+- Colors update in real time while editing
+- Pasting always inserts plain text, stripping any HTML formatting
+- Highlighting updates live when `syntax`, `preset_index` or `preset_name`
+ are changed
+
+When `preset_names` is filled, and `preset_name` replaces `preset_index`, the dropdown
+resets to the first preset when activated.
+
+#### Keyboard shortcuts
+
+| Shortcut | Action |
+| ----------- | ---------------------------------------------------------------------------------------------------------- |
+| `Ctrl+Up` | Increase weight of selected text or word under caret by `0.1`. Ex: `tag` → `(tag:1.1)` |
+| `Ctrl+Down` | Decrease weight of selected text or word under caret by `0.1`. Ex: `(tag:1.1)` → `tag` when reaching `1.0` |
+
+The selection is preserved after each keypress, consistent with ComfyUI native behavior.
## TODO
-- Add JS to transform the preset_index in a dynamic dropdown, based on the configured presets
-- Add field to name the different presets
-- Add field to easily concatenate text at the end of the prompt
+- Add a field to easily concatenate text at the end of the output
+- Wildcard mode for presets (randomly selected, index to -1 or -2?)
\ No newline at end of file
diff --git a/docs/images/PromptPresetSelector_v2.1.png b/docs/images/PromptPresetSelector_v2.1.png
new file mode 100644
index 0000000..20b99b7
Binary files /dev/null and b/docs/images/PromptPresetSelector_v2.1.png differ
diff --git a/docs/images/PromptPresetSelector_v2.png b/docs/images/PromptPresetSelector_v2.png
new file mode 100644
index 0000000..74ec24c
Binary files /dev/null and b/docs/images/PromptPresetSelector_v2.png differ
diff --git a/pyproject.toml b/pyproject.toml
index bd993b8..90527af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "ComfyUI-RE-CustomUtils"
-version = "0.1.0"
+version = "0.2.0"
description = "A collection of custom nodes for ComfyUI."
license = { file = "LICENSE" }
readme = "README.md"
diff --git a/src/nodes/prompt_preset_selector.py b/src/nodes/prompt_preset_selector.py
index 88fa8e0..f13c3a0 100644
--- a/src/nodes/prompt_preset_selector.py
+++ b/src/nodes/prompt_preset_selector.py
@@ -5,11 +5,15 @@ from .. import config
class PromptPresetSelector:
"""
- Selects a variant from inline prompt blocks of the form:
- {% option1 | option2 | option3 %}
+ Selects a variant from inline prompt blocks.
- The selected option is injected based on the chosen preset index.
- Preset indices start at 1.
+ Syntax: {open_tag} option_a | option_b | {close_tag}
+ Default: {% option_a | option_b %}
+
+ Rules:
+ - All blocks must have the same number of options.
+ - Empty presets are allowed: {% | something %} -> ("", "something")
+ - Index is 0-based.
"""
CATEGORY = config.NODE_CATEGORY
@@ -18,72 +22,212 @@ class PromptPresetSelector:
def INPUT_TYPES(cls):
return {
"required": {
- "separator": (
+ "syntax": (
"STRING",
{
- "default": "|",
- "tooltip": "Preset separator character",
+ "default": "{% | %}",
+ "tooltip": "Format: open_tag separator close_tag. Ex: {% | %}",
},
),
"preset_index": (
"INT",
{
- "default": 1,
- "min": 1,
- "max": 50,
+ "default": 0,
+ "min": -1,
+ "max": 99,
"step": 1,
+ "tooltip": "0-based index of the preset to select.",
+ },
+ ),
+ "preset_name": (
+ [""],
+ {
+ "default": "",
+ "tooltip": "Active when preset_names is filled.",
+ },
+ ),
+ "preset_names": (
+ "STRING",
+ {
+ "default": "",
+ "tooltip": (
+ "Optional. Name each preset separated by commas. "
+ "Ex: 'neutral, happy, sad'. "
+ "If filled, must match the number of options in all blocks."
+ ),
+ },
+ ),
+ "text": (
+ "STRING",
+ {
+ "multiline": True,
+ "tooltip": "Text with preset blocks.",
+ },
+ ),
+ "cleanup": (
+ "BOOLEAN",
+ {
+ "default": True,
+ "tooltip": "Clean up double commas and blank lines left by empty presets.",
},
),
- "cleanup": ("BOOLEAN", {"default": True}),
- "text": ("STRING", {"multiline": True}),
},
}
- RETURN_TYPES = ("STRING", "INT")
- RETURN_NAMES = ("prompt", "preset_index")
+ RETURN_TYPES = ("STRING", "INT", "INT")
+ RETURN_NAMES = ("text", "preset_index", "preset_count")
FUNCTION = "process"
- TEMPLATE_PATTERN = r"{%(.*?)%}"
-
@classmethod
- def VALIDATE_INPUTS(cls, separator, preset_index, cleanup, text):
- if not separator:
- return "Separator cannot be empty"
+ def VALIDATE_INPUTS(cls, syntax, preset_names, **kwargs):
+ parts = syntax.split()
+ if len(parts) != 3:
+ return (
+ f"Invalid syntax '{syntax}'. Expected: 'open_tag separator close_tag'"
+ )
+ open_tag, _, close_tag = parts
+ if open_tag == close_tag:
+ return "open_tag and close_tag must be different"
- blocks = re.findall(cls.TEMPLATE_PATTERN, text, re.DOTALL)
-
- expected_count = None
- for block in blocks:
- parts = [p.strip() for p in block.split(separator)]
- if expected_count is None:
- expected_count = len(parts)
- elif len(parts) != expected_count:
- return "All preset blocks must have the same number of options"
-
- if preset_index > len(parts):
- return f"Preset index {preset_index} out of range (max {len(parts)})"
+ if preset_names.strip():
+ names = [n.strip() for n in preset_names.split(",") if n.strip()]
+ if len(names) < 1:
+ return "preset_names must contain at least 1 entry separated by commas"
return True
- def process(self, separator, preset_index, cleanup, text):
- def replace_block(match: re.Match[str]) -> str:
- content = match.group(1)
- parts = [p.strip() for p in content.split(separator)]
+ def _parse_syntax(self, syntax: str) -> tuple[str, str, str]:
+ """
+ Parse the syntax string into (open_tag, separator, close_tag).
+ Expected format: 'open_tag separator close_tag', ex: '{% | %}'
+ """
+ parts = syntax.split()
+ if len(parts) != 3:
+ raise ValueError(
+ f"PromptPresetSelector: invalid syntax '{syntax}'. "
+ "Expected format: 'open_tag separator close_tag', ex: '{% | %}'"
+ )
+ return parts[0], parts[1], parts[2]
- # index is 1-based
- return parts[preset_index - 1]
+ def _build_pattern(self, open_tag: str, close_tag: str) -> str:
+ """Build the regex pattern from the configured tags."""
+ return re.escape(open_tag) + r"\s*(.*?)\s*" + re.escape(close_tag)
- def clean_prompt(text: str) -> str:
- text = re.sub(r"\s+,", ",", text) # Delete spaces before comas
- text = re.sub(r",\s*,", ",", text) # Delete double or empty comas
- text = re.sub(r"\n\s*\n", "\n", text) # Delete empty lines
- return text.strip()
+ def _parse_blocks(self, text: str, pattern: str, separator: str) -> list[list[str]]:
+ """Return all blocks as a list of option lists."""
+ raw_blocks = re.findall(pattern, text, re.DOTALL)
+ return [
+ [p.strip() for p in block.strip().split(separator)] for block in raw_blocks
+ ]
+
+ def _validate(self, blocks: list[list[str]], preset_index: int) -> str | None:
+ """
+ Validate blocks consistency.
+ Raises an error message string if an error is found, else None.
+ """
+ if not blocks:
+ return None
+
+ counts = [len(b) for b in blocks]
+ min_count = min(counts)
+ max_count = max(counts)
+
+ if min_count != max_count:
+ raise ValueError(
+ f"PromptPresetSelector: blocks have inconsistent option counts "
+ f"(found between {min_count} and {max_count})."
+ )
+
+ if preset_index >= max_count:
+ raise ValueError(
+ f"PromptPresetSelector: preset_index {preset_index} is out of range "
+ f"(max index is {max_count - 1})."
+ )
+
+ return None
+
+ def _resolve_references(self, parts: list[str], current_index: int) -> str:
+ """
+ Resolve $N references in the selected preset.
+ Only presets with index < current_index can be referenced.
+ """
+ selected = parts[current_index]
+ resolving = set()
+
+ def resolve(text: str, depth: int = 0) -> str:
+ if depth > len(parts):
+ raise ValueError("PromptPresetSelector: circular reference detected.")
+
+ def replace_ref(match: re.Match) -> str:
+ ref_index = int(match.group(1))
+
+ if ref_index >= len(parts):
+ raise ValueError(
+ f"PromptPresetSelector: reference ${{ref_index}} is out of range "
+ f"(max index is {len(parts) - 1})."
+ )
+ if ref_index >= current_index:
+ raise ValueError(
+ f"PromptPresetSelector: reference ${{ref_index}} is not yet declared "
+ f"(current preset is {current_index})."
+ )
+ if ref_index in resolving:
+ raise ValueError(
+ f"PromptPresetSelector: circular reference detected on ${ref_index}."
+ )
+
+ resolving.add(ref_index)
+ result = resolve(parts[ref_index], depth + 1)
+ resolving.discard(ref_index)
+ return result
+
+ return re.sub(r"\$(\d+)", replace_ref, text)
+
+ return resolve(selected)
+
+ def _replace_blocks(
+ self,
+ text: str,
+ pattern: str,
+ separator: str,
+ preset_index: int,
+ ) -> str:
+ """Replace each block with the selected option."""
+
+ def replace_block(match: re.Match) -> str:
+ parts = [opt.strip() for opt in match.group(1).split(separator)]
+ return self._resolve_references(parts, preset_index)
+
+ return re.sub(pattern, replace_block, text, flags=re.DOTALL)
+
+ def _clean_prompt(self, text: str) -> str:
+ text = re.sub(r" +,", ",", text) # spaces before commas
+ text = re.sub(r",\s*,", ",", text) # double or empty commas
+ text = re.sub(r"\n\s*\n", "\n", text) # empty lines
+ return text.strip()
+
+ def process(
+ self,
+ syntax: str,
+ preset_index: int,
+ text: str,
+ cleanup: bool,
+ **kwargs,
+ ) -> tuple[str, int, int]:
+ open_tag, separator, close_tag = self._parse_syntax(syntax)
+
+ pattern = self._build_pattern(open_tag, close_tag)
+ blocks = self._parse_blocks(text, pattern, separator)
+
+ self._validate(blocks, preset_index)
+
+ preset_count = max((len(b) for b in blocks), default=0)
+ result = self._replace_blocks(text, pattern, separator, preset_index)
- result = re.sub(self.TEMPLATE_PATTERN, replace_block, text, flags=re.DOTALL)
if cleanup:
- result = clean_prompt(result)
+ result = self._clean_prompt(result)
- return (result, preset_index)
+ return (result, preset_index, preset_count)
NODE_CLASS_MAPPINGS = {
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_prompt_preset_selector.py b/tests/test_prompt_preset_selector.py
new file mode 100644
index 0000000..5cb3e71
--- /dev/null
+++ b/tests/test_prompt_preset_selector.py
@@ -0,0 +1,183 @@
+import pytest
+from src.nodes.prompt_preset_selector import PromptPresetSelector
+
+
+@pytest.fixture
+def node():
+ return PromptPresetSelector()
+
+
+def run(node, text, index=0, preset_names="", cleanup=False):
+ return node.process(
+ syntax="{% | %}",
+ preset_index=index,
+ preset_names=preset_names,
+ text=text,
+ cleanup=cleanup,
+ )
+
+
+# --- Base cases ---
+
+
+def test_simple_index_0(node):
+ text, idx, count = run(node, "I am {% happy | sad %} today")
+ assert text == "I am happy today"
+ assert idx == 0
+ assert count == 2
+
+
+def test_simple_index_1(node):
+ text, _, _ = run(node, "I am {% happy | sad %} today", index=1)
+ assert text == "I am sad today"
+
+
+def test_empty_preset_first(node):
+ text, _, count = run(node, "I am {% | sad %} today", index=0)
+ assert text == "I am today"
+ assert count == 2
+
+
+def test_empty_preset_second(node):
+ text, _, _ = run(node, "I am {% happy | %} today", index=1)
+ assert text == "I am today"
+
+
+def test_multiple_blocks_same_index(node):
+ text, _, count = run(node, "{% a | b %} and {% c | d %}", index=1)
+ assert text == "b and d"
+ assert count == 2
+
+
+def test_no_blocks(node):
+ text, idx, count = run(node, "clean text")
+ assert text == "clean text"
+ assert count == 0
+
+
+def test_three_presets(node):
+ text, _, count = run(node, "{% a | b | c %}", index=2)
+ assert text == "c"
+ assert count == 3
+
+
+# --- Error raised ---
+
+
+def test_index_out_of_range(node):
+ with pytest.raises(ValueError, match="out of range"):
+ run(node, "{% a | b %}", index=5)
+
+
+def test_inconsistent_counts(node):
+ with pytest.raises(ValueError, match="inconsistent"):
+ run(node, "{% a | b %} and {% c | d | e %}")
+
+
+# --- Custom tags / separator ---
+
+
+def test_custom_tags(node):
+ result, _, _ = node.process(
+ syntax="<< / >>",
+ preset_index=1,
+ preset_names="",
+ text="I am << happy / sad >> today",
+ cleanup=False,
+ )
+ assert result == "I am sad today"
+
+
+# --- Preset names ---
+
+
+def test_preset_names_valid_count(node):
+ text, _, count = run(node, "{% a | b | c %}", preset_names="neutral, happy, sad")
+ assert count == 3
+
+
+def test_preset_names_empty_ignored(node):
+ text, _, _ = run(node, "{% a | b %}", preset_names="")
+ assert text == "a"
+
+
+def test_preset_names_mismatch_ignored(node):
+ text, _, _ = run(node, "{% a | b %}", preset_names="neutral, happy, sad")
+ assert text == "a"
+
+
+def test_preset_names_single(node):
+ text, _, _ = run(node, "{% a | b %}", preset_names="neutral")
+ assert text == "a"
+
+
+# --- References ---
+
+
+def test_reference_basic(node):
+ # Index 1, resolve reference
+ text, _, _ = run(node, "{% hello | $0 world %}", index=1)
+ assert text == "hello world"
+
+
+def test_reference_index_0(node):
+ # Index 0, nothing to resolve
+ text, _, _ = run(node, "{% hello | $0 world %}", index=0)
+ assert text == "hello"
+
+
+def test_reference_out_of_range(node):
+ with pytest.raises(ValueError, match="out of range"):
+ run(node, "{% a | b | $5 %}", index=2)
+
+
+def test_reference_not_yet_declared(node):
+ with pytest.raises(ValueError, match="not yet declared"):
+ run(node, "{% $1 | b %}", index=0)
+
+
+def test_reference_circular(node):
+ with pytest.raises(ValueError, match="circular"):
+ run(node, "{% $0 | $0 %}", index=1)
+
+
+def test_reference_chained(node):
+ text, _, _ = run(node, "{% base | $0 mid | $1 top %}", index=2)
+ assert text == "base mid top"
+
+
+def test_reference_empty_preset(node):
+ text, _, _ = run(node, "{% | $0 suffix %}", index=1)
+ assert text == " suffix"
+
+
+# --- Cleanup ---
+
+
+def test_cleanup_double_comma(node):
+ text, _, _ = run(node, "tag1, {% a | %}, tag2", index=1, cleanup=True)
+ assert text == "tag1, tag2"
+
+
+# --- VALIDATE_INPUTS ---
+
+
+def test_validate_empty_separator():
+ result = PromptPresetSelector.VALIDATE_INPUTS(syntax="{% %}", preset_names="")
+ assert result != True
+
+
+def test_validate_same_tags():
+ result = PromptPresetSelector.VALIDATE_INPUTS(syntax="% | %", preset_names="")
+ assert result != True
+
+
+def test_validate_preset_names_single_entry_valid():
+ result = PromptPresetSelector.VALIDATE_INPUTS(
+ syntax="{% | %}",
+ preset_index=0,
+ preset_names="neutral", # Only one element
+ text="",
+ cleanup=False,
+ )
+ assert result == True
diff --git a/web/prompt_color_editor.js b/web/prompt_color_editor.js
deleted file mode 100644
index 8814b87..0000000
--- a/web/prompt_color_editor.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import { app } from "/scripts/app.js";
-
-// Escape HTML to prevent injections
-const escapeHtml = (str) =>
- str.replace(/[&<>"]/g, c => ({
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """
- }[c]));
-
-// Hook into ComfyUI widget callback (reliable change detection)
-const hookWidget = (widget, onChange) => {
- if (!widget) return;
-
- const originalCallback = widget.callback;
- widget.callback = function (value) {
- if (originalCallback) {
- originalCallback.call(this, value);
- }
- onChange(value);
- };
-};
-
-// Main function to attach the custom editor to the node
-function attachEditor(node) {
- // Only apply to PromptPresetSelector custom node
- if (node.type !== "PromptPresetSelector") return;
-
- const textWidget = node.widgets?.find(w => w.name === "text");
- const separatorWidget = node.widgets?.find(w => w.name === "separator");
- const presetWidget = node.widgets?.find(w => w.name === "preset_index");
- if (!textWidget) return;
-
- const textarea = textWidget.inputEl || textWidget.element;
- if (!textarea || !textarea.parentNode) return;
-
- // Prevent double initialization
- if (textarea._presetEditorAttached) return;
- textarea._presetEditorAttached = true;
-
- // Hide the original textarea
- textarea.style.display = "none";
-
- // Create a contentEditable div for rich coloring
- const editor = document.createElement("div");
- editor.contentEditable = true;
- editor.style.cssText = `
- height: 100%;
- padding: 6px;
- border: 1px solid #444;
- background: #111;
- color: #eee;
- font-family: monospace;
- font-size: 10px;
- white-space: pre-wrap;
- overflow-y: auto;
- outline: none;
- box-sizing: border-box;
- `;
- textarea.parentNode.appendChild(editor);
- editor.innerText = textarea.value;
-
- // Used colors
- const tagColor = "#ff9800";
- const inactiveColor = "#777";
-
- let lastRawText = textarea.value;
-
- // Text recolor function
- const recolor = (force = false) => {
- const raw = editor.innerText;
- // Skip if no tags found
- if (!raw.includes("{%")) return;
-
- // Prevent caret jump while typing
- if (!force && document.activeElement === editor) return;
- if (!force && raw !== lastRawText) return;
-
- const separator = separatorWidget?.value || "|";
- const presetIndex = Math.max(1, presetWidget?.value || 1) - 1;
-
- const safe = escapeHtml(raw);
-
- editor.innerHTML = safe.replace(/\{%(.*?)%\}/gs, (_, content) => {
- const rawParts = content.split(separator);
-
- const coloredParts = rawParts.map((part, i) => {
- const color = i === presetIndex ? "inherit" : inactiveColor;
- return `${escapeHtml(part)}`;
- });
-
- const sepHtml = `${escapeHtml(separator)}`;
-
- return `{%`
- + coloredParts.join(sepHtml)
- + `%}`
- ;
- });
- };
-
- // Sync editor with textarea
- editor.addEventListener("input", () => {
- const raw = editor.innerText;
- textarea.value = raw;
- lastRawText = raw;
- textarea.dispatchEvent(new Event("input", { bubbles: true }));
- });
-
- // Raw text while editing
- editor.addEventListener("focus", () => {
- editor.innerText = textarea.value;
- });
-
- // Colored mode when leaving editor
- editor.addEventListener("blur", () => recolor(true));
-
- // React instantly to widget changes
- hookWidget(separatorWidget, () => recolor(true));
- hookWidget(presetWidget, () => recolor(true));
-
- // Initial render
- recolor(true);
-}
-
-// Register the extension
-app.registerExtension({
- name: "PromptPresetColorEditor",
- beforeRegisterNodeDef(nodeType) {
- nodeType.prototype.onNodeCreated = function () {
- const node = this;
-
- const tryAttach = () => {
- attachEditor(node);
-
- // Retry until widgets DOM is ready
- if (!node.widgets?.find(w => w.name === "text")?.inputEl?.parentNode) {
- requestAnimationFrame(tryAttach);
- }
- };
-
- requestAnimationFrame(tryAttach);
- };
- }
-});
diff --git a/web/prompt_preset_selector.js b/web/prompt_preset_selector.js
new file mode 100644
index 0000000..8a5bf39
--- /dev/null
+++ b/web/prompt_preset_selector.js
@@ -0,0 +1,495 @@
+import { app } from "/scripts/app.js";
+
+// --- Helpers ---
+
+// Escape HTML to prevent injections
+const escapeHtml = (str) =>
+ str.replace(
+ /[&<>"]/g,
+ (c) =>
+ ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ })[c],
+ );
+
+const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+// Hook into ComfyUI widget callback (reliable change detection)
+const hookWidget = (widget, onChange) => {
+ if (!widget) return;
+ const original = widget.callback;
+ widget.callback = function (value) {
+ if (original) original.call(this, value);
+ onChange(value);
+ };
+};
+
+// Parse the syntax param to { openTag, separator, closeTag }
+const parseSyntax = (syntax) => {
+ const parts = (syntax || "{% | %}").split(/\s+/);
+ if (parts.length !== 3)
+ return { openTag: "{%", separator: "|", closeTag: "%}" };
+ return { openTag: parts[0], separator: parts[1], closeTag: parts[2] };
+};
+
+// Build preset names, using placeholders if less names than presets are found
+const buildNames = (names, presetCount) => {
+ const result = [...names];
+ while (result.length < presetCount) {
+ result.push(`preset_${result.length}`);
+ }
+ return result.slice(0, presetCount);
+};
+
+// Build pattern from syntax
+const buildPattern = (syntax) => {
+ const { openTag, closeTag } = parseSyntax(syntax);
+ return new RegExp(
+ escapeRegex(openTag) + "\\s*(.*?)\\s*" + escapeRegex(closeTag),
+ "gs",
+ );
+};
+
+// Get number of presets found
+const getPresetCount = (text, syntax) => {
+ const { separator } = parseSyntax(syntax);
+ const matches = [...text.matchAll(buildPattern(syntax))];
+ if (!matches.length) return 0;
+ return Math.max(...matches.map((m) => m[1].split(separator).length));
+};
+
+// Hide ComfyUI widget component
+const hideWidget = (widgetName) => {
+ widgetName.type = "hidden";
+ widgetName.computeSize = () => [0, -4]; // -4 to cancel ComfyUI padding
+};
+
+// Save current caret position in div
+const saveCaretPosition = (el) => {
+ const sel = window.getSelection();
+ if (!sel.rangeCount) return 0;
+
+ const range = sel.getRangeAt(0);
+ const preCaretRange = range.cloneRange();
+ preCaretRange.selectNodeContents(el);
+ preCaretRange.setEnd(range.endContainer, range.endOffset);
+ return preCaretRange.toString().length;
+};
+
+// Loop over text nodes in the DOM
+const walkTextNodes = (root, callback) => {
+ const walk = (node) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ if (callback(node) === false) return false;
+ } else {
+ for (const child of node.childNodes) {
+ if (walk(child) === false) return false;
+ }
+ }
+ };
+ walk(root);
+};
+
+// Build range from offset on text node
+const buildRangeFromOffsets = (root, start, end) => {
+ const range = document.createRange();
+ let charCount = 0;
+ let startFound = false;
+ let complete = false;
+
+ walkTextNodes(root, (node) => {
+ const next = charCount + node.length;
+ if (!startFound && next > start) {
+ range.setStart(node, start - charCount);
+ startFound = true;
+ }
+ if (startFound && next >= end) {
+ range.setEnd(node, end - charCount);
+ complete = true;
+ return false;
+ }
+ charCount = next;
+ });
+
+ return complete ? range : null;
+};
+
+// Restore caret position in div
+const restoreCaretPosition = (el, offset) => {
+ const range = document.createRange();
+ const sel = window.getSelection();
+ let charCount = 0;
+ let found = false;
+
+ walkTextNodes(el, (node) => {
+ const next = charCount + node.length;
+ if (next >= offset) {
+ range.setStart(node, offset - charCount);
+ range.collapse(true);
+ found = true;
+ return false;
+ }
+ charCount = next;
+ });
+
+ if (!found) {
+ range.selectNodeContents(el);
+ range.collapse(false);
+ }
+ sel.removeAllRanges();
+ sel.addRange(range);
+};
+
+// Get selected word or at caret position
+const getSelectedOrWordAtCaret = (editor) => {
+ const sel = window.getSelection();
+ if (!sel.rangeCount) return null;
+
+ const range = sel.getRangeAt(0);
+
+ // First cae: Selected text
+ if (!range.collapsed) {
+ return {
+ text: range.toString().trim(),
+ range: range.cloneRange(),
+ };
+ }
+
+ // Second case: No selection
+ const fullText = editor.innerText;
+ const caretPos = saveCaretPosition(editor);
+
+ // Look for delimiters (commas, to avoid multi word prompts)
+ const delimiters = /[,\n]/;
+ let start = caretPos;
+ let end = caretPos;
+
+ while (start > 0 && !delimiters.test(fullText[start - 1])) start--;
+ while (end < fullText.length && !delimiters.test(fullText[end])) end++;
+
+ // Trimming spaces
+ let word = fullText.slice(start, end);
+ const leftTrim = word.length - word.trimStart().length;
+ const rightTrim = word.length - word.trimEnd().length;
+ start += leftTrim;
+ end -= rightTrim;
+ word = word.trim();
+
+ if (!word) return null;
+
+ // Rebuild the range on the word
+ const wordRange = buildRangeFromOffsets(editor, start, end);
+ if (!wordRange) return null;
+
+ return { text: word, range: wordRange };
+};
+
+// Adjust weight on selected word
+const adjustWeight = (text, delta) => {
+ const trimmed = text.trim();
+ if (!trimmed) return text;
+
+ const weighted = trimmed.match(/^\((.+):(-?\d+(?:\.\d+)?)\)$/s);
+ if (weighted) {
+ const tag = weighted[1];
+ const weight = Math.round((parseFloat(weighted[2]) + delta) * 10) / 10;
+ if (weight === 1.0) return text.replace(trimmed, tag);
+ return text.replace(trimmed, `(${tag}:${weight.toFixed(1)})`);
+ }
+
+ const weight = Math.round((1.0 + delta) * 10) / 10;
+ if (weight === 1.0) return text;
+ return text.replace(trimmed, `(${trimmed}:${weight.toFixed(1)})`);
+};
+
+// --- Colors ---
+
+const COLORS = {
+ tag: "#ff9800",
+ sep: "#ff9800",
+ active: "#e0e0e0",
+ inactive: "#555",
+ ref: "#64b5f6",
+};
+
+// --- Editor ---
+
+function buildHighlightedHtml(raw, syntax, presetIndex) {
+ const { openTag, separator, closeTag } = parseSyntax(syntax);
+ const pattern = buildPattern(syntax);
+ let result = "";
+ let lastIndex = 0;
+
+ for (const match of raw.matchAll(pattern)) {
+ // Text before the match, escaped
+ result += escapeHtml(raw.slice(lastIndex, match.index));
+
+ const parts = match[1].split(separator);
+
+ const coloredParts = parts.map((part, i) => {
+ const color = i === presetIndex ? COLORS.active : COLORS.inactive;
+ const content =
+ i === presetIndex
+ ? escapeHtml(part).replace(
+ /\$(\d+)/g,
+ `$$$1`,
+ )
+ : escapeHtml(part);
+ return `${content}`;
+ });
+
+ const sepHtml = `${escapeHtml(separator)}`;
+
+ result +=
+ `${escapeHtml(openTag)} ` +
+ coloredParts.join(sepHtml) +
+ ` ${escapeHtml(closeTag)}`;
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ // Remaining text after last match
+ result += escapeHtml(raw.slice(lastIndex));
+
+ return result;
+}
+
+// Main function to attach the custom editor to the node
+function attachEditor(node) {
+ // Only apply to PromptPresetSelector custom node
+ if (node.type !== "PromptPresetSelector") return;
+
+ const textWidget = node.widgets?.find((w) => w.name === "text");
+ const syntaxWidget = node.widgets?.find((w) => w.name === "syntax");
+ const presetWidget = node.widgets?.find((w) => w.name === "preset_index");
+ const comboWidget = node.widgets?.find((w) => w.name === "preset_name");
+ const namesWidget = node.widgets?.find((w) => w.name === "preset_names");
+
+ // Saving widget type
+ const originalPresetType = presetWidget.type;
+
+ if (!textWidget) return;
+
+ // Hide preset_name
+ hideWidget(comboWidget);
+
+ const textarea = textWidget.inputEl || textWidget.element;
+ if (!textarea?.parentNode) return;
+
+ // Prevent double initialization
+ if (textarea._editorAttached) return;
+ textarea._editorAttached = true;
+
+ // Hide native textarea
+ textarea.style.display = "none";
+
+ // Create contentEditable div
+ const editor = document.createElement("div");
+ editor.contentEditable = "true";
+ editor.spellcheck = false;
+ editor.style.cssText = `
+ width: 100%;
+ height: 100%;
+ padding: 6px;
+ border: 1px solid #444;
+ border-radius: 4px;
+ background: #111;
+ color: ${COLORS.active};
+ font-family: monospace;
+ font-size: 10px;
+ white-space: pre-wrap;
+ overflow-y: auto;
+ outline: none;
+ box-sizing: border-box;
+ cursor: text;
+ line-height: 1.5;
+ `;
+
+ textarea.parentNode.appendChild(editor);
+
+ // State helpers
+ const getPresetIndex = () => presetWidget?.value ?? 0;
+ const getSyntax = () => syntaxWidget?.value ?? "{% | %}";
+
+ // Render modes
+ const renderColored = () => {
+ const raw = textarea.value;
+ editor.innerHTML = buildHighlightedHtml(
+ raw,
+ getSyntax(),
+ getPresetIndex(),
+ );
+ };
+
+ // --- Preset names / combo ---
+
+ const destroyCombo = () => {
+ // Hide preset_name
+ hideWidget(comboWidget);
+
+ presetWidget.type = originalPresetType;
+ presetWidget.computeSize = null;
+ if (node.graph) node.graph.setDirtyCanvas(true, true);
+ };
+
+ const buildCombo = (names) => {
+ // Hide preset_index
+ hideWidget(presetWidget);
+
+ const savedIndex = presetWidget.value ?? 0;
+ const safeIndex = savedIndex < names.length ? savedIndex : 0;
+
+ comboWidget.options.values = names;
+ comboWidget.value = names[safeIndex];
+ comboWidget.type = "combo";
+ comboWidget.computeSize = null;
+
+ comboWidget.callback = (value) => {
+ presetWidget.value = names.indexOf(value);
+ if (node.graph) node.graph.setDirtyCanvas(true, true);
+ if (document.activeElement !== editor) renderColored();
+ };
+
+ presetWidget.value = safeIndex;
+ 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();
+ if (!names) {
+ destroyCombo();
+ return;
+ }
+
+ const presetCount = getPresetCount(textarea.value, getSyntax());
+ if (presetCount === 0) {
+ destroyCombo();
+ return;
+ }
+
+ buildCombo(buildNames(names, presetCount));
+ };
+
+ // --- Event listener - Input: Sync editor -> textarea ---
+ editor.addEventListener("input", () => {
+ const pos = saveCaretPosition(editor);
+ textarea.value = editor.innerText;
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ refreshCombo();
+ renderColored();
+ restoreCaretPosition(editor, pos);
+ });
+
+ // --- Event listener - Paste: Sanitize text pasting to prevent errors with HTML coloration ---
+ editor.addEventListener("paste", (e) => {
+ e.preventDefault();
+ const text = e.clipboardData.getData("text/plain");
+ document.execCommand("insertText", false, text);
+ });
+
+ // --- Event listener - Keydown: Look for keyboard shortcuts ---
+ editor.addEventListener("keydown", (e) => {
+ // Ctrl+Up/Down - Adjust weight on selected text
+ if (!e.ctrlKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown"))
+ return;
+ e.preventDefault();
+
+ const delta = e.key === "ArrowUp" ? 0.1 : -0.1;
+ const selection = getSelectedOrWordAtCaret(editor);
+ if (!selection) return;
+
+ const adjusted = adjustWeight(selection.text, delta);
+ if (adjusted === selection.text) return;
+
+ // Save selected text length
+ const adjustedLength = adjusted.length;
+
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(selection.range);
+ document.execCommand("insertText", false, adjusted);
+
+ // Reselect inserted text
+ const pos = saveCaretPosition(editor);
+ const newRange = buildRangeFromOffsets(
+ editor,
+ pos - adjustedLength,
+ pos,
+ );
+ if (newRange) {
+ sel.removeAllRanges();
+ sel.addRange(newRange);
+ }
+ });
+
+ // --- React to widget changes ---
+
+ hookWidget(presetWidget, (value) => {
+ const presetCount = getPresetCount(textarea.value, getSyntax());
+ if (presetCount === 0) return;
+
+ // Loop both ways on preset_index
+ let newValue = value % presetCount;
+ if (newValue < 0) newValue += presetCount;
+
+ if (newValue !== value) {
+ presetWidget.value = newValue;
+ if (node.graph) node.graph.setDirtyCanvas(true, true);
+ }
+
+ if (document.activeElement !== editor) renderColored();
+ });
+
+ hookWidget(syntaxWidget, () => {
+ refreshCombo();
+ if (document.activeElement !== editor) renderColored();
+ });
+
+ hookWidget(namesWidget, () => {
+ refreshCombo();
+ if (document.activeElement !== editor) renderColored();
+ });
+
+ // Initial render
+ refreshCombo();
+ renderColored();
+}
+
+// --- Registration ---
+
+app.registerExtension({
+ name: "PromptPresetSelector",
+ beforeRegisterNodeDef(nodeType, nodeData) {
+ if (nodeData.name !== "PromptPresetSelector") return;
+
+ const original = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function () {
+ if (original) original.call(this);
+
+ const node = this;
+ // Retry until widgets DOM is ready
+ const tryAttach = () => {
+ const textWidget = node.widgets?.find((w) => w.name === "text");
+ if (textWidget?.inputEl?.parentNode) {
+ attachEditor(node);
+ } else {
+ requestAnimationFrame(tryAttach);
+ }
+ };
+ requestAnimationFrame(tryAttach);
+ };
+ },
+});