@@ -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?)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
+1
-1
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
@@ -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 `<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);
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
`<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);
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user