Merge pull request #1 from relhamdi/v2

PromptPresetSelector V2
This commit is contained in:
relhamdi
2026-05-20 11:34:19 +02:00
committed by GitHub
9 changed files with 961 additions and 223 deletions
+93 -32
View File
@@ -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
![PromptPresetSelector v1](docs/images/PromptPresetSelector_v1.png)
![PromptPresetSelector v2.1](docs/images/PromptPresetSelector_v2.1.png)
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?)
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+1 -1
View File
@@ -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"
+189 -45
View File
@@ -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 = {
View File
+183
View File
@@ -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
-145
View File
@@ -1,145 +0,0 @@
import { app } from "/scripts/app.js";
// Escape HTML to prevent injections
const escapeHtml = (str) =>
str.replace(/[&<>"]/g, c => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;"
}[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 `<span style="color:${color}">${escapeHtml(part)}</span>`;
});
const sepHtml = `<span style="color:${tagColor}">${escapeHtml(separator)}</span>`;
return `<span style="color:${tagColor}">{%</span>`
+ coloredParts.join(sepHtml)
+ `<span style="color:${tagColor}">%}</span>`
;
});
};
// 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);
};
}
});
+495
View File
@@ -0,0 +1,495 @@
import { app } from "/scripts/app.js";
// --- Helpers ---
// Escape HTML to prevent injections
const escapeHtml = (str) =>
str.replace(
/[&<>"]/g,
(c) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
})[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,
`<span style="color:${COLORS.ref}">$$$1</span>`,
)
: escapeHtml(part);
return `<span style="color:${color}">${content}</span>`;
});
const sepHtml = `<span style="color:${COLORS.sep}">${escapeHtml(separator)}</span>`;
result +=
`<span style="color:${COLORS.tag}">${escapeHtml(openTag)}</span> ` +
coloredParts.join(sepHtml) +
` <span style="color:${COLORS.tag}">${escapeHtml(closeTag)}</span>`;
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);
};
},
});