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..885eda5
--- /dev/null
+++ b/web/prompt_preset_selector.js
@@ -0,0 +1,206 @@
+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] };
+};
+
+// --- Colors ---
+
+const COLORS = {
+ tag: "#ff9800",
+ sep: "#ff9800",
+ active: "#e0e0e0",
+ inactive: "#555",
+};
+
+// --- Editor ---
+
+function buildHighlightedHtml(raw, syntax, presetIndex) {
+ const { openTag, separator, closeTag } = parseSyntax(syntax);
+ const pattern = new RegExp(
+ escapeRegex(openTag) + "\\s*(.*?)\\s*" + escapeRegex(closeTag),
+ "gs",
+ );
+
+ 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;
+ return `${escapeHtml(part)}`;
+ });
+
+ 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");
+
+ if (!textWidget) return;
+
+ 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(),
+ );
+ };
+
+ const renderRaw = () => {
+ editor.innerText = textarea.value;
+ };
+
+ // --- Event listener - Input: Sync editor -> textarea ---
+ editor.addEventListener("input", () => {
+ textarea.value = editor.innerText;
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ // --- Event listener - Focus: Switch to raw text for editing ---
+ editor.addEventListener("focus", () => {
+ renderRaw();
+ // Place caret at end
+ const range = document.createRange();
+ range.selectNodeContents(editor);
+ range.collapse(false);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ });
+
+ // --- Event listener - Blur: Switch to colored view ---
+ editor.addEventListener("blur", () => renderColored());
+
+ // React to widget changes
+ hookWidget(syntaxWidget, () => {
+ if (document.activeElement !== editor) renderColored();
+ });
+ hookWidget(presetWidget, () => {
+ if (document.activeElement !== editor) renderColored();
+ });
+
+ // Initial render
+ 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);
+ };
+ },
+});