diff --git a/README.md b/README.md
index f371722..c10cf95 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,12 @@
+## 方塊式 Prompt 編輯
+
+
+
+在 ⚡ Dynamic Tag Loader 的 Prompt 編輯區輸入 `@`,即可搜尋並插入 tag `.txt` 檔。插入後會以方塊顯示,可與一般文字自由混排;點選方塊後可用 `Ctrl + ↑ / ↓` 依 ComfyUI 原生規則調整強度,並可用 Backspace、Delete、複製與貼上快速編輯。
+
## 安裝說明 (Installation)
進入 ComfyUI custom_nodes 目錄:
diff --git a/README_en.md b/README_en.md
index accb71d..313414b 100644
--- a/README_en.md
+++ b/README_en.md
@@ -12,6 +12,12 @@
+## Chip-based Prompt Editing
+
+
+
+Type `@` in the ⚡ Dynamic Tag Loader prompt editor to search for and insert tag `.txt` files. Inserted tags appear as chips and can be freely mixed with normal text. Click a chip and use `Ctrl + ↑ / ↓` to adjust its strength with ComfyUI's native rules; Backspace, Delete, copy, and paste are supported for quick editing.
+
## Installation
Navigate to your ComfyUI `custom_nodes` directory:
@@ -98,4 +104,4 @@ Restart ComfyUI.
Example Workflows:


-You can drag these into ComfyUI to load and read the example workflows.
\ No newline at end of file
+You can drag these into ComfyUI to load and read the example workflows.
diff --git a/README_jp.md b/README_jp.md
index f96c9a5..510f639 100644
--- a/README_jp.md
+++ b/README_jp.md
@@ -11,6 +11,12 @@
+## チップ式プロンプト編集
+
+
+
+⚡ Dynamic Tag Loader のプロンプト編集欄で `@` を入力すると、tag `.txt` ファイルを検索して挿入できます。挿入したタグはチップとして表示され、通常のテキストと自由に混在できます。チップをクリックして `Ctrl + ↑ / ↓` を使うと、ComfyUI 標準のルールで強度を調整できます。Backspace、Delete、コピー、貼り付けによる編集にも対応しています。
+
## インストール
ComfyUI の `custom_nodes` ディレクトリに移動します:
@@ -96,4 +102,4 @@ ComfyUI を再起動してください。
ワークフローの例:


-これらの画像を ComfyUI にドラッグ&ドロップすることで、サンプルワークフローを読み込むことができます。
\ No newline at end of file
+これらの画像を ComfyUI にドラッグ&ドロップすることで、サンプルワークフローを読み込むことができます。
diff --git a/example/Dynamic_Tag_composer_example.png b/example/Dynamic_Tag_composer_example.png
new file mode 100644
index 0000000..37c4229
Binary files /dev/null and b/example/Dynamic_Tag_composer_example.png differ
diff --git a/loader_node.py b/loader_node.py
index 7087e4a..4cce8b5 100644
--- a/loader_node.py
+++ b/loader_node.py
@@ -27,6 +27,9 @@ class DynamicTagLoaderJS:
"required": {
"text_input": ("STRING", {"default": "", "multiline": True, "placeholder": "Global Prompt (Prepend to all)..."}),
"tag_settings": ("STRING", {"default": "{}", "multiline": False}),
+ # The visual prompt composer stores ordered text/tag segments here.
+ # text_input remains as a fallback for workflows created before the composer.
+ "inline_prompt": ("STRING", {"default": "[]", "multiline": False}),
},
"optional": {
"model": ("MODEL",),
@@ -124,7 +127,72 @@ class DynamicTagLoaderJS:
except:
return None
- def process(self, text_input, tag_settings, model=None, clip=None, **kwargs):
+ def _parse_inline_prompt(self, inline_prompt):
+ """Expand the ordered segments produced by the @ prompt composer.
+
+ A tag segment references one tag file and may carry a prompt/LoRA
+ strength. The file is deliberately resolved at execution time so a
+ workflow always uses the current contents of its tag library.
+ """
+ try:
+ segments = json.loads(inline_prompt)
+ except (TypeError, json.JSONDecodeError):
+ return None
+
+ if not isinstance(segments, list) or not segments:
+ return None
+
+ text_parts = []
+ loras = []
+ for segment in segments:
+ if not isinstance(segment, dict):
+ continue
+
+ if segment.get("type") == "text":
+ text_parts.append(str(segment.get("text", "")))
+ continue
+
+ if segment.get("type") != "tag":
+ continue
+
+ folder_name = segment.get("folder")
+ file_name = segment.get("file")
+ if not isinstance(folder_name, str) or not isinstance(file_name, str):
+ continue
+ # @ references intentionally resolve one file only; ALL remains a
+ # Tag Group feature because it produces a batch dimension.
+ if file_name == "ALL" or not file_name.endswith(".txt"):
+ continue
+
+ folder_path = TAGS_DIR if folder_name == "Root" else os.path.join(TAGS_DIR, os.path.normpath(folder_name))
+ try:
+ if os.path.commonpath([TAGS_DIR, folder_path]) != TAGS_DIR:
+ continue
+ except ValueError:
+ continue
+
+ raw_content = self._read_file(os.path.join(folder_path, file_name))
+ if raw_content is None:
+ print(f"[DynamicTagLoader] Warning: Inline tag not found: {folder_name}/{file_name}")
+ continue
+
+ clean_text, tag_loras = self._parse_and_strip_lora(raw_content)
+ try:
+ strength = float(segment.get("strength", 1.0))
+ except (TypeError, ValueError):
+ strength = 1.0
+ strength = max(0.0, min(3.0, strength))
+
+ # Prompt weighting and LoRA weighting follow the same chip value.
+ # At 1.0 the original tag text stays untouched for compatibility.
+ if clean_text and strength != 1.0:
+ clean_text = f"({clean_text}:{strength:.1f})"
+ text_parts.append(clean_text)
+ loras.extend((name, weight * strength) for name, weight in tag_loras)
+
+ return "".join(text_parts), loras
+
+ def process(self, text_input, tag_settings="{}", inline_prompt="[]", model=None, clip=None, **kwargs):
"""
主要處理工作流:
1. 解析 tag_settings JSON 設定,按索引排序。
@@ -138,8 +206,13 @@ class DynamicTagLoaderJS:
except Exception as e:
settings = {}
- # 預處理全域輸入 (Global Prompt)
- base_text_cleaned, base_loras = self._parse_and_strip_lora(text_input)
+ # Prefer the ordered @ composer data when present, falling back to the
+ # legacy Global Prompt field for older workflows.
+ parsed_inline = self._parse_inline_prompt(inline_prompt)
+ if parsed_inline is None:
+ base_text_cleaned, base_loras = self._parse_and_strip_lora(text_input)
+ else:
+ base_text_cleaned, base_loras = parsed_inline
prompts_groups = []
# 根據前端 UI 設定的索引順序進行數據構造
@@ -243,4 +316,4 @@ class DynamicTagLoaderJS:
return ([], [], [], [], 0)
# 回傳封裝後的組合列表
- return (final_models, final_clips, final_conditionings, final_prompts, count)
\ No newline at end of file
+ return (final_models, final_clips, final_conditionings, final_prompts, count)
diff --git a/web/dynamic_loader.js b/web/dynamic_loader.js
index 35d6bde..e13027a 100644
--- a/web/dynamic_loader.js
+++ b/web/dynamic_loader.js
@@ -1,6 +1,31 @@
import { app } from "../../scripts/app.js";
import { getDynamicGroupMenu, setupSizeManager } from "./dynamic_utils.js";
+function ensureComposerStyles() {
+ if (document.getElementById("dynamic-tag-composer-style")) return;
+ const style = document.createElement("style");
+ style.id = "dynamic-tag-composer-style";
+ style.textContent = `
+ .dynamic-tag-composer-host { position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 96px; }
+ .dynamic-tag-composer { position: relative; z-index: 1; box-sizing: border-box; width: 100%; height: 100%; min-height: 96px; padding: 7px; overflow: auto;
+ color: #e8e8e8; background: #171725; border: 1px solid #3b3b5b; border-radius: 5px;
+ font: 13px/1.5 sans-serif; white-space: pre-wrap; outline: none; }
+ .dynamic-tag-composer:focus { border-color: #7698ff; box-shadow: 0 0 0 1px #7698ff66; }
+ .dynamic-tag-chip { display: inline-block; margin: 1px 2px; padding: 1px 5px; border: 1px solid #666b75;
+ border-radius: 4px; color: #ededf0; background: #3d414a; cursor: pointer; user-select: all; }
+ .dynamic-tag-chip.selected { border-color: #ffd267; background: #644b16; color: #fff4d2; }
+ .dynamic-tag-completer-proxy { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%;
+ margin: 0; padding: 7px; box-sizing: border-box; opacity: 0; pointer-events: none; resize: none; }
+ .dynamic-tag-menu { position: fixed; z-index: 10000; max-height: 210px; min-width: 220px; overflow: auto;
+ border: 1px solid #4d4d71; border-radius: 5px; background: #1e1e2e; box-shadow: 0 5px 16px #0009; }
+ .dynamic-tag-menu button { display: block; width: 100%; padding: 6px 9px; border: 0; text-align: left;
+ color: #eee; background: transparent; cursor: pointer; }
+ .dynamic-tag-menu button.active, .dynamic-tag-menu button:hover { background: #354f91; }
+ .dynamic-tag-menu small { margin-left: 6px; color: #aeb9d2; }
+ `;
+ document.head.appendChild(style);
+}
+
app.registerExtension({
name: "ComfyUI.DynamicTagLoader",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
@@ -19,10 +44,662 @@ app.registerExtension({
settingsWidget.type = "hidden";
settingsWidget.computeSize = () => [0, -4];
}
+ const textInputWidget = node.widgets.find(w => w.name === "text_input");
+ const inlinePromptWidget = node.widgets.find(w => w.name === "inline_prompt");
+ for (const widget of [textInputWidget, inlinePromptWidget]) {
+ if (widget) {
+ widget.type = "hidden";
+ widget.computeSize = () => [0, -4];
+ }
+ }
node.tagsData = {};
node.dynamicWidgets = [];
node.addTagButton = null;
+ node.inlineComposerSegments = [];
+ node.inlineComposerSelectedChip = null;
+ ensureComposerStyles();
+
+ const tagKey = (folder, file) => `${folder}/${file}`;
+ const chipLabel = (folder, file) => tagKey(folder, file).replace(/\.txt$/i, "");
+ const chipWeightSuffix = (strength) => `:${Number(strength)})`;
+ const getTagEntries = () => Object.entries(node.tagsData)
+ .flatMap(([folder, files]) => files
+ .filter(file => file !== "ALL" && file.endsWith(".txt"))
+ .map(file => ({ folder, file, label: tagKey(folder, file) })));
+
+ const updateInlinePrompt = () => {
+ if (inlinePromptWidget) inlinePromptWidget.value = JSON.stringify(node.inlineComposerSegments);
+ // Once the composer has been used, avoid falling back to a
+ // stale legacy Global Prompt when every segment is deleted.
+ if (textInputWidget) textInputWidget.value = "";
+ };
+
+ const normaliseSegments = (segments) => segments.reduce((result, item) => {
+ if (item.type === "text") {
+ const text = String(item.text || "");
+ if (!text) return result;
+ const previous = result[result.length - 1];
+ if (previous?.type === "text") previous.text += text;
+ else result.push({ type: "text", text });
+ } else if (item.type === "tag" && item.folder && item.file) {
+ result.push({ type: "tag", folder: item.folder, file: item.file, strength: Number(item.strength ?? 1) });
+ }
+ return result;
+ }, []);
+
+ const makeChip = (segment) => {
+ // Keep chips directly in the editor's text flow. An
+ // editable inline wrapper confuses native vertical caret
+ // navigation when a line consists only of a chip.
+ const token = document.createDocumentFragment();
+ const chip = document.createElement("span");
+ chip.className = "dynamic-tag-chip";
+ chip.contentEditable = "false";
+ chip.dataset.folder = segment.folder;
+ chip.dataset.file = segment.file;
+ chip.dataset.strength = String(segment.strength ?? 1);
+ chip.textContent = chipLabel(segment.folder, segment.file);
+ chip.title = "Ctrl + ↑ / ↓ 調整強度;Backspace 或 Delete 刪除";
+ const selectChip = (event) => {
+ event.preventDefault();
+ node.inlineComposerSelectedChip?.classList.remove("selected");
+ node.inlineComposerSelectedChip = chip;
+ chip.classList.add("selected");
+ composer.focus();
+ // Clicking a non-editable chip can leave its label (or
+ // the previous weight edit) selected in the browser.
+ // Keep chip selection in our state and a collapsed DOM
+ // caret so the attention bridge resolves its enclosure.
+ const range = document.createRange();
+ range.setStartBefore(chip);
+ range.collapse(true);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ };
+ chip.addEventListener("click", selectChip);
+
+ if (Number(segment.strength ?? 1) === 1) token.append(chip);
+ else token.append("(", chip, chipWeightSuffix(segment.strength));
+ return token;
+ };
+
+ const composer = document.createElement("div");
+ composer.className = "dynamic-tag-composer";
+ composer.contentEditable = "true";
+ composer.spellcheck = false;
+ composer.setAttribute("aria-label", "Prompt Composer. Type @ to insert a tag.");
+ const composerHost = document.createElement("div");
+ composerHost.className = "dynamic-tag-composer-host";
+ composerHost.appendChild(composer);
+ // ExTagComplete is textarea-based. This invisible proxy lets
+ // its own dropdown/search logic work while the visible editor
+ // remains a chip-capable contenteditable surface.
+ const exTagProxy = document.createElement("textarea");
+ exTagProxy.className = "dynamic-tag-completer-proxy";
+ exTagProxy.tabIndex = -1;
+ exTagProxy.setAttribute("aria-hidden", "true");
+ composerHost.appendChild(exTagProxy);
+ const menu = document.createElement("div");
+ menu.className = "dynamic-tag-menu";
+ menu.hidden = true;
+ document.body.appendChild(menu);
+
+ const serializeComposer = () => {
+ const segments = [];
+ const appendText = (text) => segments.push({ type: "text", text });
+ const walk = (element) => {
+ for (const child of element.childNodes) {
+ if (child.nodeType === Node.TEXT_NODE) appendText(child.nodeValue);
+ else if (child.nodeType === Node.ELEMENT_NODE && child.classList.contains("dynamic-tag-chip")) {
+ segments.push({ type: "tag", folder: child.dataset.folder, file: child.dataset.file, strength: 1 });
+ } else if (child.nodeName === "BR") appendText("\n");
+ else walk(child);
+ }
+ };
+ walk(composer);
+ node.inlineComposerSegments = normaliseSegments(segments);
+ updateInlinePrompt();
+ };
+
+ const renderComposer = () => {
+ composer.replaceChildren();
+ node.inlineComposerSegments.forEach(segment => {
+ composer.append(segment.type === "tag" ? makeChip(segment) : document.createTextNode(segment.text));
+ });
+ if (!composer.childNodes.length) composer.append(document.createTextNode(""));
+ node.inlineComposerSelectedChip = null;
+ };
+
+ const getTrigger = () => {
+ const selection = window.getSelection();
+ if (!selection?.rangeCount || !composer.contains(selection.focusNode) || selection.focusNode?.nodeType !== Node.TEXT_NODE) return null;
+ const before = selection.focusNode.nodeValue.slice(0, selection.focusOffset);
+ const match = before.match(/(?:^|[\s,])@([^\s,@]*)$/);
+ return match ? { selection, textNode: selection.focusNode, start: selection.focusOffset - match[0].length + (match[0].startsWith("@") ? 0 : 1), query: match[1] } : null;
+ };
+
+ let menuEntries = [];
+ let menuIndex = 0;
+ const closeMenu = () => { menu.hidden = true; menu.replaceChildren(); };
+ const insertTagFromMenu = (entry) => {
+ const trigger = getTrigger();
+ if (!trigger) return closeMenu();
+ const range = document.createRange();
+ range.setStart(trigger.textNode, trigger.start);
+ range.setEnd(trigger.textNode, trigger.selection.focusOffset);
+ range.deleteContents();
+ const chip = makeChip({ ...entry, strength: 1 });
+ const lastInserted = chip.lastChild;
+ range.insertNode(chip);
+ range.setStartAfter(lastInserted);
+ range.collapse(true);
+ trigger.selection.removeAllRanges();
+ trigger.selection.addRange(range);
+ closeMenu();
+ serializeComposer();
+ composer.focus();
+ };
+ const showMenu = () => {
+ const trigger = getTrigger();
+ if (!trigger) {
+ closeMenu();
+ return false;
+ }
+ // A newly typed @ always starts at the first result. Keep
+ // the current index only while navigating the open menu.
+ if (menu.hidden) menuIndex = 0;
+ const query = trigger.query.toLowerCase();
+ menuEntries = getTagEntries().filter(entry => entry.label.toLowerCase().includes(query)).slice(0, 30);
+ if (!menuEntries.length) {
+ closeMenu();
+ return false;
+ }
+ menuIndex = Math.min(menuIndex, menuEntries.length - 1);
+ menu.replaceChildren(...menuEntries.map((entry, index) => {
+ const button = document.createElement("button");
+ button.className = index === menuIndex ? "active" : "";
+ button.textContent = `@${entry.label.replace(/\.txt$/i, "")}`;
+ button.addEventListener("mousedown", event => { event.preventDefault(); insertTagFromMenu(entry); });
+ return button;
+ }));
+ const rect = composer.getBoundingClientRect();
+ menu.style.left = `${rect.left}px`;
+ menu.style.top = `${Math.min(rect.bottom + 3, window.innerHeight - 220)}px`;
+ menu.hidden = false;
+ return true;
+ };
+
+ const adjacentChip = (direction) => {
+ const selection = window.getSelection();
+ if (!selection?.rangeCount || !selection.isCollapsed) return null;
+ let container = selection.focusNode;
+ let sibling = null;
+ if (container?.nodeType === Node.TEXT_NODE) {
+ if ((direction < 0 && selection.focusOffset !== 0) || (direction > 0 && selection.focusOffset !== container.nodeValue.length)) return null;
+ sibling = direction < 0 ? container.previousSibling : container.nextSibling;
+ } else if (container === composer) {
+ sibling = composer.childNodes[selection.focusOffset + (direction < 0 ? -1 : 0)];
+ }
+ if (sibling?.nodeType !== Node.ELEMENT_NODE) return null;
+ return sibling.classList.contains("dynamic-tag-chip") ? sibling : null;
+ };
+
+ const editAttentionWithNativeRules = (direction) => {
+ const selection = window.getSelection();
+ const native = window.comfyAPI?.editAttention;
+ if (!native || !selection?.rangeCount || !composer.contains(selection.anchorNode)) return false;
+ const selected = selection.getRangeAt(0);
+ const prefix = selected.cloneRange();
+ prefix.selectNodeContents(composer);
+ prefix.setEnd(selected.startContainer, selected.startOffset);
+ let start = prefix.toString().length;
+ let end = start + selected.toString().length;
+ const value = composer.textContent;
+ const chip = node.inlineComposerSelectedChip;
+ if (chip && selection.isCollapsed) {
+ prefix.selectNodeContents(composer);
+ prefix.setEndBefore(chip);
+ start = prefix.toString().length;
+ end = start + chip.textContent.length;
+
+ // Parentheses and weights are ordinary editable text.
+ // When the clicked chip is already inside a weighted
+ // enclosure, adjust that enclosure instead of nesting
+ // another pair around the chip.
+ const enclosure = native.findNearestEnclosure(value, start);
+ if (enclosure && enclosure.start <= start && enclosure.end >= end
+ && value[enclosure.start - 1] === "(" && value[enclosure.end] === ")") {
+ const weighted = value.slice(enclosure.start - 1, enclosure.end + 1);
+ if (/^\([\s\S]*:[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?\)$/.test(weighted)) {
+ start = enclosure.start - 1;
+ end = enclosure.end + 1;
+ }
+ }
+ }
+ let text = value.slice(start, end);
+ if (!text) {
+ const enclosure = native.findNearestEnclosure(value, start);
+ if (enclosure) {
+ start = enclosure.start;
+ end = enclosure.end;
+ } else {
+ const delimiters = " .,\\/!?%^*;:{}=-_`~()\r\n\t";
+ while (start > 0 && !delimiters.includes(value[start - 1])) start--;
+ while (end < value.length && !delimiters.includes(value[end])) end++;
+ }
+ text = value.slice(start, end);
+ if (!text) return false;
+ }
+ if (text.endsWith(" ")) { text = text.slice(0, -1); end--; }
+ if (value[start - 1] === "(" && value[end] === ")") {
+ start--;
+ end++;
+ text = value.slice(start, end);
+ }
+ if (!text.startsWith("(") || !text.endsWith(")")) text = `(${text})`;
+ text = native.addWeightToParentheses(text);
+ const delta = Number(app.ui.settings.getSettingValue("Comfy.EditAttention.Delta", 0.05));
+ const replacement = text.replace(/\((.*):([+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?)\)/,
+ (_, body, weight) => {
+ const next = native.incrementWeight(weight, direction * delta);
+ return Number(next) === 1 ? body : `(${body}:${next})`;
+ });
+ const range = rangeAtComposerTextOffset(start);
+ const endRange = rangeAtComposerTextOffset(end);
+ range.setEnd(endRange.startContainer, endRange.startOffset);
+ const refs = [...range.cloneContents().querySelectorAll(".dynamic-tag-chip")];
+ let portable = replacement;
+ for (const ref of refs) portable = portable.replace(ref.textContent,
+ `@{${tagKey(ref.dataset.folder, ref.dataset.file)}}`);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ pasteTextIntoComposer(portable, true);
+ return true;
+ };
+
+ let exTagCompleter = null;
+ let exTagProxyValue = "";
+ let syncingExTagProxy = false;
+ const textOffsetAt = (container, offset) => {
+ const range = document.createRange();
+ range.selectNodeContents(composer);
+ range.setEnd(container, offset);
+ return range.toString().length;
+ };
+ const composerSelectionOffsets = () => {
+ const selection = window.getSelection();
+ if (!selection?.rangeCount || !composer.contains(selection.anchorNode)) return null;
+ const range = selection.getRangeAt(0);
+ let start = textOffsetAt(range.startContainer, range.startOffset);
+ let end = textOffsetAt(range.endContainer, range.endOffset);
+ const chip = node.inlineComposerSelectedChip;
+ if (chip && selection.isCollapsed) {
+ const before = document.createRange();
+ before.selectNodeContents(composer);
+ before.setEndBefore(chip);
+ start = before.toString().length;
+ end = start + chip.textContent.length;
+ }
+ return { start, end };
+ };
+ const rangeAtComposerTextOffset = (offset) => {
+ const walker = document.createTreeWalker(composer, NodeFilter.SHOW_TEXT);
+ let textNode;
+ let remaining = Math.max(0, offset);
+ while ((textNode = walker.nextNode())) {
+ const length = textNode.nodeValue.length;
+ if (remaining <= length) {
+ const range = document.createRange();
+ const chip = textNode.parentElement?.closest('.dynamic-tag-chip');
+ if (chip && remaining === 0) range.setStartBefore(chip);
+ else if (chip && remaining === length) range.setStartAfter(chip);
+ else range.setStart(textNode, remaining);
+ range.collapse(true);
+ return range;
+ }
+ remaining -= length;
+ }
+ const range = document.createRange();
+ range.selectNodeContents(composer);
+ range.collapse(false);
+ return range;
+ };
+ const syncExTagProxy = (emitInput = true) => {
+ const offsets = composerSelectionOffsets();
+ if (!offsets) return;
+ exTagProxyValue = composer.textContent;
+ exTagProxy.value = exTagProxyValue;
+ exTagProxy.setSelectionRange(offsets.start, offsets.end);
+ if (!emitInput) return;
+ syncingExTagProxy = true;
+ exTagProxy.dispatchEvent(new Event("input", { bubbles: true }));
+ syncingExTagProxy = false;
+ };
+ exTagProxy.addEventListener("input", () => {
+ if (syncingExTagProxy || exTagProxy.value === exTagProxyValue) return;
+ const before = exTagProxyValue;
+ const after = exTagProxy.value;
+ let start = 0;
+ while (start < before.length && start < after.length && before[start] === after[start]) start++;
+ let beforeEnd = before.length;
+ let afterEnd = after.length;
+ while (beforeEnd > start && afterEnd > start && before[beforeEnd - 1] === after[afterEnd - 1]) {
+ beforeEnd--;
+ afterEnd--;
+ }
+ const range = rangeAtComposerTextOffset(start);
+ const endRange = rangeAtComposerTextOffset(beforeEnd);
+ range.setEnd(endRange.startContainer, endRange.startOffset);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ const refs = [...range.cloneContents().querySelectorAll(".dynamic-tag-chip")];
+ let inserted = after.slice(start, afterEnd);
+ for (const ref of refs) inserted = inserted.replace(ref.textContent,
+ "@{" + tagKey(ref.dataset.folder, ref.dataset.file) + "}");
+ pasteTextIntoComposer(inserted);
+ exTagProxyValue = after;
+ });
+ import("/extensions/comfy-ex-tagcomplete/tag-complete/tag_completer.js")
+ .then(({ TagCompleter }) => {
+ // The import can finish after an undo/workflow reload
+ // has already removed this node.
+ if (composerDisposed) return;
+ exTagCompleter = new TagCompleter(exTagProxy);
+ })
+ // ExTagComplete is optional; this node remains usable when
+ // the extension is not installed or uses a different URL.
+ .catch(() => {});
+
+ composer.addEventListener("input", () => {
+ serializeComposer();
+ if (!showMenu()) syncExTagProxy();
+ });
+ composer.addEventListener("click", event => {
+ if (event.target === composer) {
+ node.inlineComposerSelectedChip?.classList.remove("selected");
+ node.inlineComposerSelectedChip = null;
+ }
+ });
+ // LiteGraph assigns single-letter shortcuts (including F) to
+ // the canvas. A contenteditable element is not treated like a
+ // native textarea by every frontend build, so keep typing
+ // events inside this editor.
+ for (const type of ["keypress", "keyup"]) {
+ composer.addEventListener(type, event => {
+ if (!event.ctrlKey && !event.metaKey && !event.altKey) event.stopPropagation();
+ });
+ }
+ composer.addEventListener("keydown", event => {
+ // Plain typing must not reach LiteGraph's single-letter
+ // shortcuts. Modified commands such as Ctrl+Enter keep
+ // bubbling to ComfyUI's native command handler.
+ if (!event.ctrlKey && !event.metaKey && !event.altKey) event.stopPropagation();
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") {
+ // Preserve the browser's native Select All inside the
+ // editor without letting LiteGraph select graph nodes.
+ event.stopPropagation();
+ return;
+ }
+ if ((event.ctrlKey || event.metaKey) && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
+ if (editAttentionWithNativeRules(event.key === "ArrowUp" ? 1 : -1)) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+ return;
+ }
+ if (!menu.hidden && ["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(event.key)) {
+ event.preventDefault();
+ if (event.key === "Escape") return closeMenu();
+ if (event.key === "Enter") return insertTagFromMenu(menuEntries[menuIndex]);
+ menuIndex = (menuIndex + (event.key === "ArrowDown" ? 1 : -1) + menuEntries.length) % menuEntries.length;
+ return showMenu();
+ }
+ if (exTagCompleter?.dropdownController?.isVisible() && ["ArrowUp", "ArrowDown", "Enter", "Escape", "PageUp", "PageDown"].includes(event.key)) {
+ event.preventDefault();
+ exTagProxy.dispatchEvent(new KeyboardEvent("keydown", {
+ key: event.key,
+ code: event.code,
+ bubbles: true,
+ cancelable: true,
+ }));
+ return;
+ }
+ if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
+ node.inlineComposerSelectedChip?.classList.remove("selected");
+ node.inlineComposerSelectedChip = null;
+ }
+ if (event.key === "Backspace" || event.key === "Delete") {
+ if (node.inlineComposerSelectedChip) {
+ event.preventDefault();
+ node.inlineComposerSelectedChip.remove();
+ node.inlineComposerSelectedChip = null;
+ serializeComposer();
+ return;
+ }
+ const chip = adjacentChip(event.key === "Backspace" ? -1 : 1);
+ if (chip) {
+ event.preventDefault();
+ chip.remove();
+ node.inlineComposerSelectedChip = null;
+ serializeComposer();
+ }
+ }
+ });
+ const serializeClipboardText = () => {
+ const selection = window.getSelection();
+ if (!selection?.rangeCount || !composer.contains(selection.anchorNode)) return "";
+ const fragment = selection.getRangeAt(0).cloneContents();
+ const asElement = node => node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
+ const anchorChip = asElement(selection.anchorNode)?.closest('.dynamic-tag-chip');
+ const focusChip = asElement(selection.focusNode)?.closest('.dynamic-tag-chip');
+ // Selecting the label itself clones a text node, not its
+ // enclosing span. Preserve the reference in this case too.
+ if (anchorChip && anchorChip === focusChip) {
+ return '@{' + tagKey(anchorChip.dataset.folder, anchorChip.dataset.file) + '}';
+ }
+ fragment.querySelectorAll(".dynamic-tag-chip").forEach(chip =>
+ chip.replaceWith(document.createTextNode("@{" + tagKey(chip.dataset.folder, chip.dataset.file) + "}")));
+ return fragment.textContent;
+ };
+ const pasteTextIntoComposer = (pasted, selectInserted = false) => {
+ const selection = window.getSelection();
+ if (!selection?.rangeCount || !composer.contains(selection.anchorNode)) return false;
+ const range = selection.getRangeAt(0);
+ range.deleteContents();
+ const fragment = document.createDocumentFragment();
+ // Browser/native clipboard handlers may supply only visible
+ // labels. Recognise exact library paths as well as our
+ // portable references; leave unknown text untouched.
+ const appendPlainText = (text) => {
+ const entries = getTagEntries().map(entry => ({...entry,
+ visible: chipLabel(entry.folder, entry.file)}))
+ .sort((a, b) => b.visible.length - a.visible.length);
+ let offset = 0;
+ let plain = '';
+ while (offset < text.length) {
+ const entry = entries.find(entry => text.startsWith(entry.visible, offset)
+ && (offset === 0 || /[\s,(]/.test(text[offset - 1]))
+ && (offset + entry.visible.length === text.length
+ || /[\s,:)]/.test(text[offset + entry.visible.length])));
+ if (entry) {
+ if (plain) fragment.append(document.createTextNode(plain));
+ plain = '';
+ fragment.append(makeChip({...entry, strength: 1}));
+ offset += entry.visible.length;
+ } else plain += text[offset++];
+ }
+ if (plain) fragment.append(document.createTextNode(plain));
+ };
+ const matcher = /@\{([^}]+)\}/g;
+ let lastIndex = 0;
+ let match;
+ while ((match = matcher.exec(pasted))) {
+ appendPlainText(pasted.slice(lastIndex, match.index));
+ const parts = match[1].match(/^(.*?)(?:::([+-]?[\d.]+))?$/);
+ const reference = parts[1];
+ const slash = reference.lastIndexOf("/");
+ if (slash >= 0 && reference.endsWith(".txt")) {
+ fragment.append(makeChip({folder: reference.slice(0, slash), file: reference.slice(slash + 1),
+ strength: parts[2] === undefined ? 1 : Number(parts[2])}));
+ } else fragment.append(document.createTextNode(match[0]));
+ lastIndex = matcher.lastIndex;
+ }
+ appendPlainText(pasted.slice(lastIndex).replace(/\r\n/g, "\n"));
+ if (!fragment.childNodes.length) fragment.append(document.createTextNode(''));
+ const first = fragment.firstChild;
+ const last = fragment.lastChild;
+ range.insertNode(fragment);
+ range.setStartBefore(first);
+ range.setEndAfter(last);
+ if (!selectInserted) range.collapse(false);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ node.inlineComposerSelectedChip?.classList.remove("selected");
+ node.inlineComposerSelectedChip = null;
+ serializeComposer();
+ return true;
+ };
+ composer.__dynamicTagClipboard = { serializeClipboardText, pasteTextIntoComposer };
+ composer.addEventListener("copy", event => {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ event.clipboardData.setData("text/plain", serializeClipboardText());
+ });
+ composer.addEventListener("paste", event => {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ pasteTextIntoComposer(event.clipboardData.getData("text/plain"));
+ });
+
+ // contenteditable is not recognised as a text field by every
+ // LiteGraph frontend. Guard the global graph clipboard path so
+ // Ctrl+V never pastes a copied node beside this editor.
+ if (!document.__dynamicTagClipboardGuardInstalled) {
+ document.__dynamicTagClipboardGuardInstalled = true;
+ const activeComposer = () => {
+ const focused = document.activeElement?.closest?.('.dynamic-tag-composer');
+ if (focused) return focused;
+ const anchor = window.getSelection()?.anchorNode;
+ return (anchor?.nodeType === Node.ELEMENT_NODE ? anchor : anchor?.parentElement)
+ ?.closest?.('.dynamic-tag-composer');
+ };
+ window.addEventListener("copy", event => {
+ const active = activeComposer();
+ if (!active?.__dynamicTagClipboard || !event.clipboardData) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ event.clipboardData.setData("text/plain", active.__dynamicTagClipboard.serializeClipboardText());
+ }, true);
+ window.addEventListener("paste", event => {
+ const active = activeComposer();
+ if (!active?.__dynamicTagClipboard) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ active.__dynamicTagClipboard.pasteTextIntoComposer(event.clipboardData?.getData("text/plain") || "");
+ }, true);
+ const patchGraphPaste = () => {
+ const canvas = app.canvas;
+ const wrap = (target, key) => {
+ if (!target || typeof target[key] !== "function" || target[key].__dynamicTagClipboardPatched) return;
+ const original = target[key];
+ const guarded = function(...args) {
+ if (activeComposer()) return null;
+ return original.apply(this, args);
+ };
+ guarded.__dynamicTagClipboardPatched = true;
+ target[key] = guarded;
+ };
+ wrap(canvas, "pasteFromClipboard");
+ wrap(canvas?.constructor?.prototype, "pasteFromClipboard");
+ wrap(globalThis.LiteGraph?.LGraphCanvas?.prototype, "pasteFromClipboard");
+ };
+ patchGraphPaste();
+ queueMicrotask(patchGraphPaste);
+ setTimeout(patchGraphPaste, 0);
+ }
+
+ // DOM widgets sit above the LiteGraph canvas and therefore
+ // absorb its wheel event. Forward it so canvas zoom remains
+ // available even while the insertion caret is in this editor.
+ composer.addEventListener("wheel", event => {
+ const canvas = app.canvas?.canvas;
+ if (!canvas) return;
+ event.preventDefault();
+ event.stopPropagation();
+ canvas.dispatchEvent(new WheelEvent("wheel", {
+ deltaX: event.deltaX,
+ deltaY: event.deltaY,
+ deltaZ: event.deltaZ,
+ deltaMode: event.deltaMode,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ ctrlKey: event.ctrlKey,
+ shiftKey: event.shiftKey,
+ altKey: event.altKey,
+ metaKey: event.metaKey,
+ bubbles: true,
+ cancelable: true,
+ }));
+ }, { passive: false });
+
+ // Use ComfyUI's native DOM-widget layer instead of a manually
+ // positioned overlay. It owns canvas transforms, visibility,
+ // zoom, and the size assigned to a resized node.
+ const composerWidget = node.addDOMWidget("prompt_composer", "prompt_composer", composerHost, {
+ serialize: false,
+ hideOnZoom: false,
+ getMinHeight: () => 116,
+ getValue: () => "",
+ setValue: () => {},
+ });
+ // A fixed computeSize prevents LiteGraph from giving a DOM
+ // widget the extra height the user creates by resizing a node.
+ // Keep only a minimum; the editor then fills its allocated slot.
+ try {
+ delete composerWidget.computeSize;
+ } catch {
+ composerWidget.computeSize = undefined;
+ }
+ composerWidget.computeLayoutSize = () => ({ minHeight: 116, maxHeight: undefined, minWidth: 0 });
+ if (composerWidget.options) {
+ composerWidget.options.getMinHeight = () => 116;
+ delete composerWidget.options.getMaxHeight;
+ delete composerWidget.options.getHeight;
+ }
+ const composerInsertAt = node.widgets.indexOf(textInputWidget);
+ const composerCurrentIndex = node.widgets.indexOf(composerWidget);
+ if (composerCurrentIndex >= 0) {
+ node.widgets.splice(composerCurrentIndex, 1);
+ node.widgets.splice(composerInsertAt >= 0 ? composerInsertAt + 1 : 0, 0, composerWidget);
+ }
+
+ const originalOnRemoved = node.onRemoved;
+ const originalWidgetOnRemove = composerWidget.onRemove;
+ let composerDisposed = false;
+ const disposeComposer = () => {
+ if (composerDisposed) return;
+ composerDisposed = true;
+ closeMenu();
+ menu.remove();
+ try { exTagCompleter?.destroy?.(); } catch { /* optional integration */ }
+ exTagCompleter = null;
+ delete composer.__dynamicTagClipboard;
+ composerHost.remove();
+ if (node.inlineComposerSelectedChip?.isConnected === false) {
+ node.inlineComposerSelectedChip = null;
+ }
+ };
+ composerWidget.onRemove = function() {
+ disposeComposer();
+ return originalWidgetOnRemove?.apply(this, arguments);
+ };
+ node.onRemoved = function() {
+ disposeComposer();
+ return originalOnRemoved?.apply(this, arguments);
+ };
// -----------------------------------------------------------
// Helper: 更新 Widget 顯示列表
@@ -263,6 +940,20 @@ app.registerExtension({
.then(response => response.json())
.then(data => {
node.tagsData = data;
+ try {
+ const savedSegments = JSON.parse(inlinePromptWidget?.value || "[]");
+ if (Array.isArray(savedSegments) && savedSegments.length) {
+ node.inlineComposerSegments = normaliseSegments(savedSegments);
+ } else if (textInputWidget?.value) {
+ // Upgrade old workflows in memory without changing
+ // their legacy field; saving writes the new format.
+ node.inlineComposerSegments = [{ type: "text", text: textInputWidget.value }];
+ updateInlinePrompt();
+ }
+ } catch (error) {
+ console.warn("Unable to restore Prompt Composer", error);
+ }
+ renderComposer();
if (settingsWidget && settingsWidget.value && settingsWidget.value !== "{}") {
try {
const savedData = JSON.parse(settingsWidget.value);
@@ -289,4 +980,4 @@ app.registerExtension({
};
}
}
-});
\ No newline at end of file
+});