Add chip prompt editor documentation
This commit is contained in:
@@ -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 編輯區輸入 `@`,即可搜尋並插入 tag `.txt` 檔。插入後會以方塊顯示,可與一般文字自由混排;點選方塊後可用 `Ctrl + ↑ / ↓` 依 ComfyUI 原生規則調整強度,並可用 Backspace、Delete、複製與貼上快速編輯。
|
||||
|
||||
## 安裝說明 (Installation)
|
||||
|
||||
進入 ComfyUI custom_nodes 目錄:
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
You can drag these into ComfyUI to load and read the example workflows.
|
||||
|
||||
+7
-1
@@ -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 のプロンプト編集欄で `@` を入力すると、tag `.txt` ファイルを検索して挿入できます。挿入したタグはチップとして表示され、通常のテキストと自由に混在できます。チップをクリックして `Ctrl + ↑ / ↓` を使うと、ComfyUI 標準のルールで強度を調整できます。Backspace、Delete、コピー、貼り付けによる編集にも対応しています。
|
||||
|
||||
## インストール
|
||||
|
||||
ComfyUI の `custom_nodes` ディレクトリに移動します:
|
||||
@@ -96,4 +102,4 @@ ComfyUI を再起動してください。
|
||||
ワークフローの例:
|
||||

|
||||

|
||||
これらの画像を ComfyUI にドラッグ&ドロップすることで、サンプルワークフローを読み込むことができます。
|
||||
これらの画像を ComfyUI にドラッグ&ドロップすることで、サンプルワークフローを読み込むことができます。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 505 KiB |
+77
-4
@@ -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
@@ -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({
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user