Add chip prompt editor documentation

This commit is contained in:
NineKey
2026-09-12 22:08:24 +08:00
parent e5356e5e3e
commit 8a939ffeca
6 changed files with 789 additions and 7 deletions
+6
View File
@@ -11,6 +11,12 @@
<img width="1465" height="849" alt="ComfyUI_Dynamic_TagLoader_01" src="https://github.com/user-attachments/assets/517a9b69-e150-4ef6-aec1-b0ffcae43ab7" />
## 方塊式 Prompt 編輯
![Dynamic Tag Loader 方塊式 Prompt 編輯範例](./example/Dynamic_Tag_composer_example.png)
在 ⚡ Dynamic Tag Loader 的 Prompt 編輯區輸入 `@`,即可搜尋並插入 tag `.txt` 檔。插入後會以方塊顯示,可與一般文字自由混排;點選方塊後可用 `Ctrl + ↑ / ↓` 依 ComfyUI 原生規則調整強度,並可用 Backspace、Delete、複製與貼上快速編輯。
## 安裝說明 (Installation)
進入 ComfyUI custom_nodes 目錄:
+7 -1
View File
@@ -12,6 +12,12 @@
<img width="1465" height="849" alt="ComfyUI_Dynamic_TagLoader_01" src="https://github.com/user-attachments/assets/517a9b69-e150-4ef6-aec1-b0ffcae43ab7" />
## Chip-based Prompt Editing
![Dynamic Tag Loader chip-based prompt editing example](./example/Dynamic_Tag_composer_example.png)
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:
![example](./example/Dynamic_Tag_example_workflow.png)
![example](./example/Workflow_Metadata_Reader_example_workflow.png)
You can drag these into ComfyUI to load and read the example workflows.
You can drag these into ComfyUI to load and read the example workflows.
+7 -1
View File
@@ -11,6 +11,12 @@
<img width="1465" height="849" alt="ComfyUI_Dynamic_TagLoader_01" src="https://github.com/user-attachments/assets/517a9b69-e150-4ef6-aec1-b0ffcae43ab7" />
## チップ式プロンプト編集
![Dynamic Tag Loader チップ式プロンプト編集例](./example/Dynamic_Tag_composer_example.png)
⚡ Dynamic Tag Loader のプロンプト編集欄で `@` を入力すると、tag `.txt` ファイルを検索して挿入できます。挿入したタグはチップとして表示され、通常のテキストと自由に混在できます。チップをクリックして `Ctrl + ↑ / ↓` を使うと、ComfyUI 標準のルールで強度を調整できます。Backspace、Delete、コピー、貼り付けによる編集にも対応しています。
## インストール
ComfyUI の `custom_nodes` ディレクトリに移動します:
@@ -96,4 +102,4 @@ ComfyUI を再起動してください。
ワークフローの例:
![example](./example/Dynamic_Tag_example_workflow.png)
![example](./example/Workflow_Metadata_Reader_example_workflow.png)
これらの画像を ComfyUI にドラッグ&ドロップすることで、サンプルワークフローを読み込むことができます。
これらの画像を ComfyUI にドラッグ&ドロップすることで、サンプルワークフローを読み込むことができます。
Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

+77 -4
View File
@@ -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)
return (final_models, final_clips, final_conditionings, final_prompts, count)
+692 -1
View File
@@ -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({
};
}
}
});
});