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 -![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?) \ 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); + }; + }, +});