修正切換工作流節點縮回問題,統一備註
This commit is contained in:
+53
-31
@@ -6,15 +6,19 @@ import folder_paths
|
||||
import comfy.sd
|
||||
import comfy.utils
|
||||
|
||||
# 定義基礎路徑常數
|
||||
# -----------------------------------------------------------
|
||||
# 基礎路徑配置
|
||||
# -----------------------------------------------------------
|
||||
NODE_FILE_PATH = os.path.dirname(os.path.abspath(__file__))
|
||||
TAGS_DIR = os.path.join(NODE_FILE_PATH, "tags")
|
||||
|
||||
class DynamicTagLoaderJS:
|
||||
"""
|
||||
ComfyUI 自定義節點:動態 Tag 載入器
|
||||
功能:根據前端傳入的 JSON 設定,動態讀取資料夾內的 Prompt 檔案,
|
||||
並支援 LoRA 的自動解析與載入。
|
||||
ComfyUI 自定義節點:動態標籤加載器 (Dynamic Tag Loader)
|
||||
核心功能:
|
||||
1. 解析前端傳入的 JSON 配置,讀取對應的標籤檔案。
|
||||
2. 自動提取文本中的 LoRA 語法並應用於模型與 CLIP。
|
||||
3. 針對多個標籤群組生成「笛卡兒積 (Cartesian Product)」組合,實現批次生成。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@@ -30,21 +34,29 @@ class DynamicTagLoaderJS:
|
||||
}
|
||||
}
|
||||
|
||||
# 定義輸出類型與名稱
|
||||
# 輸出定義:模型、CLIP、Conditioning、原始 Prompt、生成的組合總數
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "CONDITIONING", "STRING", "INT")
|
||||
RETURN_NAMES = ("model", "clip", "positive", "prompt", "count")
|
||||
OUTPUT_IS_LIST = (True, True, True, True, False) # 注意: count 為單一數值,非列表
|
||||
# 標示輸出為列表形式,以便於 Batch 處理
|
||||
OUTPUT_IS_LIST = (True, True, True, True, False)
|
||||
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "Custom/TagLoader"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, **kwargs):
|
||||
# 設定為 NaN 以確保每次執行時都會重新計算 (避免快取導致不更新)
|
||||
"""強迫 ComfyUI 忽略快取機制,確保每次執行皆重新解析檔案內容"""
|
||||
return float("nan")
|
||||
|
||||
def _parse_and_strip_lora(self, text):
|
||||
"""解析並分離文本中的 <lora:...> 語法,回傳純淨文本與 LoRA 設定列表"""
|
||||
"""
|
||||
Regex 解析器:提取文本中的 <lora:name:weight> 標籤。
|
||||
|
||||
Args:
|
||||
text (str): 原始 Prompt 文本
|
||||
Returns:
|
||||
tuple: (清理後的文本, LoRA 配置列表 [(name, weight), ...])
|
||||
"""
|
||||
if not text:
|
||||
return "", []
|
||||
lora_pattern = r"<lora:([^>:]+)(?::([0-9.]+))?>"
|
||||
@@ -54,23 +66,32 @@ class DynamicTagLoaderJS:
|
||||
strength = float(match.group(2)) if match.group(2) else 1.0
|
||||
found_loras.append((lora_name, strength))
|
||||
|
||||
# 清除 LoRA 標籤並規範化換行符號
|
||||
# 移除 LoRA 語法並規範化空白與換行符號
|
||||
cleaned_text = re.sub(lora_pattern, "", text)
|
||||
cleaned_text = re.sub(r'\n\s*\n', '\n', cleaned_text)
|
||||
cleaned_text = cleaned_text.strip()
|
||||
return cleaned_text, found_loras
|
||||
|
||||
def _load_lora(self, model, clip, lora_name, strength_model, strength_clip):
|
||||
"""動態載入指定的 LoRA 模型並應用於 Model 與 CLIP"""
|
||||
"""
|
||||
動態 LoRA 加載邏輯:包含檔案路徑檢索與模糊匹配。
|
||||
|
||||
Args:
|
||||
model: 模型實例
|
||||
clip: CLIP 實例
|
||||
lora_name: LoRA 檔案名稱
|
||||
strength_model: 模型強度
|
||||
strength_clip: CLIP 強度
|
||||
"""
|
||||
if model is None or clip is None:
|
||||
return model, clip
|
||||
|
||||
# 嘗試搜尋 LoRA 檔案路徑
|
||||
# 優先執行完整路徑匹配
|
||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
||||
if lora_path is None:
|
||||
lora_path = folder_paths.get_full_path("loras", f"{lora_name}.safetensors")
|
||||
|
||||
# 若完全匹配失敗,嘗試模糊搜尋 (忽略副檔名大小寫)
|
||||
# 備選方案:執行不區分大小寫的模糊搜索 (忽略副檔名)
|
||||
if lora_path is None:
|
||||
available_loras = folder_paths.get_filename_list("loras")
|
||||
target_name = lora_name.lower()
|
||||
@@ -87,6 +108,7 @@ class DynamicTagLoaderJS:
|
||||
return model, clip
|
||||
|
||||
try:
|
||||
# 調用 ComfyUI 核心函數加載 LoRA 權重並應用至 Patch 隊列
|
||||
lora_model = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
model_lora, clip_lora = comfy.sd.load_lora_for_models(model, clip, lora_model, strength_model, strength_clip)
|
||||
return model_lora, clip_lora
|
||||
@@ -95,7 +117,7 @@ class DynamicTagLoaderJS:
|
||||
return model, clip
|
||||
|
||||
def _read_file(self, path):
|
||||
"""安全讀取檔案內容"""
|
||||
"""IO 輔助函數:執行安全讀取並過濾結尾空白"""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return f.read().strip()
|
||||
@@ -104,11 +126,11 @@ class DynamicTagLoaderJS:
|
||||
|
||||
def process(self, text_input, tag_settings, model=None, clip=None, **kwargs):
|
||||
"""
|
||||
核心處理函數
|
||||
1. 解析 tag_settings JSON 設定
|
||||
2. 讀取指定資料夾/檔案的 Prompt
|
||||
3. 組合 Prompt 並解析其中的 LoRA
|
||||
4. 生成最終的 Conditioning 與 Model 輸出
|
||||
主要處理工作流:
|
||||
1. 解析 tag_settings JSON 設定,按索引排序。
|
||||
2. 遍歷檔案系統獲取 Prompt 片段,並分離文本與 LoRA 設定。
|
||||
3. 利用 itertools.product 計算所有可能的 Prompt 組合 (笛卡兒積)。
|
||||
4. 為每組組合進行模型加權 (LoRA) 與文本編碼 (Conditioning)。
|
||||
"""
|
||||
delimiter = "\n"
|
||||
try:
|
||||
@@ -116,10 +138,11 @@ class DynamicTagLoaderJS:
|
||||
except Exception as e:
|
||||
settings = {}
|
||||
|
||||
# 預處理全域輸入 (Global Prompt)
|
||||
base_text_cleaned, base_loras = self._parse_and_strip_lora(text_input)
|
||||
prompts_groups = []
|
||||
|
||||
# 依照前端 UI 順序排序 (key 為索引值)
|
||||
# 根據前端 UI 設定的索引順序進行數據構造
|
||||
sorted_keys = sorted(settings.keys(), key=lambda x: int(x))
|
||||
|
||||
for key in sorted_keys:
|
||||
@@ -127,19 +150,19 @@ class DynamicTagLoaderJS:
|
||||
item_type = item.get("type", "file")
|
||||
|
||||
if item_type == "text":
|
||||
# 處理純文字輸入
|
||||
# 處理純文本組件
|
||||
raw_text = item.get("text", "")
|
||||
if raw_text:
|
||||
cleaned_text, loras = self._parse_and_strip_lora(raw_text)
|
||||
prompts_groups.append([(cleaned_text, loras)])
|
||||
else:
|
||||
# 處理檔案讀取
|
||||
# 處理標籤檔案組件
|
||||
folder_name = item.get("folder")
|
||||
file_name = item.get("file")
|
||||
if not folder_name or not file_name:
|
||||
continue
|
||||
|
||||
# 路徑解析:處理根目錄標識與相對路徑轉換 (相容多層級子目錄)
|
||||
# 路徑安全化處理
|
||||
if folder_name == "Root":
|
||||
folder_path = TAGS_DIR
|
||||
else:
|
||||
@@ -148,15 +171,13 @@ class DynamicTagLoaderJS:
|
||||
current_group_data = []
|
||||
files_to_read = []
|
||||
|
||||
# 檔案選取策略
|
||||
# 檔案檢索策略:若選擇 "ALL" 則枚舉目錄下所有 .txt 檔案
|
||||
if file_name == "ALL":
|
||||
if os.path.exists(folder_path):
|
||||
# 僅列出當前目錄下的 .txt 檔案 (不包含子目錄內容,符合邏輯設計)
|
||||
files_to_read = sorted([f for f in os.listdir(folder_path) if f.endswith(".txt")])
|
||||
else:
|
||||
files_to_read = [file_name]
|
||||
|
||||
# 讀取檔案內容
|
||||
for f_name in files_to_read:
|
||||
raw_content = self._read_file(os.path.join(folder_path, f_name))
|
||||
if raw_content is not None:
|
||||
@@ -166,7 +187,7 @@ class DynamicTagLoaderJS:
|
||||
if current_group_data:
|
||||
prompts_groups.append(current_group_data)
|
||||
|
||||
# 產生所有組合 (笛卡兒積)
|
||||
# 核心運算:計算所有標籤組件的笛卡兒積組合
|
||||
if not prompts_groups:
|
||||
combinations = [([],)] if base_text_cleaned or base_loras else []
|
||||
else:
|
||||
@@ -177,7 +198,7 @@ class DynamicTagLoaderJS:
|
||||
final_prompts = []
|
||||
final_conditionings = []
|
||||
|
||||
# 遍歷組合並構建輸出
|
||||
# 遍歷組合,構建最終輸出列表
|
||||
for combo in combinations:
|
||||
current_texts = []
|
||||
if base_text_cleaned:
|
||||
@@ -185,7 +206,7 @@ class DynamicTagLoaderJS:
|
||||
current_texts.extend([item[0] for item in combo if item and item[0]])
|
||||
combined_prompt = delimiter.join(current_texts)
|
||||
|
||||
# 合併全域與局部 LoRA
|
||||
# 整合全域與局部 (檔案內) 的 LoRA 配置
|
||||
all_loras = []
|
||||
if base_loras:
|
||||
all_loras.extend(base_loras)
|
||||
@@ -193,14 +214,14 @@ class DynamicTagLoaderJS:
|
||||
if item:
|
||||
all_loras.extend(item[1])
|
||||
|
||||
# 應用 LoRA 至 Model 與 CLIP
|
||||
# 執行 LoRA 疊加應用
|
||||
current_model = model
|
||||
current_clip = clip
|
||||
if current_model is not None and current_clip is not None and all_loras:
|
||||
for lora_name, strength in all_loras:
|
||||
current_model, current_clip = self._load_lora(current_model, current_clip, lora_name, strength, strength)
|
||||
|
||||
# CLIP Tokenize 與 Encode
|
||||
# 文本編碼處理:將組合成的 Prompt 轉換為 Conditioning 向量
|
||||
current_conditioning = None
|
||||
if current_clip is not None:
|
||||
try:
|
||||
@@ -216,9 +237,10 @@ class DynamicTagLoaderJS:
|
||||
final_conditionings.append(current_conditioning)
|
||||
|
||||
count = len(final_prompts)
|
||||
print(f"JS Loader: Generated {count} combinations.")
|
||||
print(f"[DynamicTagLoader] Logic: Generated {count} batch combinations.")
|
||||
|
||||
if not final_prompts:
|
||||
return ([], [], [], [], 0)
|
||||
|
||||
# 回傳封裝後的組合列表
|
||||
return (final_models, final_clips, final_conditionings, final_prompts, count)
|
||||
+107
-202
@@ -12,42 +12,60 @@ app.registerExtension({
|
||||
const node = this;
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 1. 初始化設定與資料結構
|
||||
// 1. 核心狀態與隱藏組件初始化
|
||||
// -----------------------------------------------------------
|
||||
node.expectedSize = null; // 儲存從工作流 (Workflow) 讀取的原始尺寸,防止非同步載入後塌陷
|
||||
|
||||
// 找到後端定義的 hidden widget,將其隱藏並設為無高度
|
||||
// 查找並隱藏用於儲存 JSON 狀態的 widget,使其不顯示於 UI 上
|
||||
const settingsWidget = node.widgets.find(w => w.name === "tag_settings");
|
||||
if (settingsWidget) {
|
||||
settingsWidget.type = "hidden";
|
||||
settingsWidget.computeSize = () => [0, -4];
|
||||
}
|
||||
|
||||
node.tagsData = {}; // 儲存從伺服器抓取的 tags 資料結構
|
||||
node.dynamicWidgets = []; // 儲存當前動態生成的 widget 群組
|
||||
node.addTagButton = null; // 按鈕參照
|
||||
node.tagsData = {}; // 儲存從伺服器獲取的標籤結構數據
|
||||
node.dynamicWidgets = []; // 管理動態產生的組件群組 (Folder + File)
|
||||
node.addTagButton = null; // 新增按鈕實例暫存
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 2. 狀態同步 (UI -> JSON)
|
||||
// 2. 生命週期攔截:序列化配置載入 (onConfigure)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 將當前所有動態 widget 的值打包成 JSON,寫入 settingsWidget 供後端讀取
|
||||
const onConfigure = node.onConfigure;
|
||||
node.onConfigure = function(data) {
|
||||
if (onConfigure) onConfigure.apply(this, arguments);
|
||||
if (data && data.size) {
|
||||
// 擷取工作流定義中的尺寸,用於後續內容非同步填充後的尺寸修正基準
|
||||
node.expectedSize = [...data.size];
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 3. UI 尺寸自適應校準邏輯
|
||||
// -----------------------------------------------------------
|
||||
node.fixSize = function() {
|
||||
const computeSize = node.computeSize();
|
||||
let targetHeight = computeSize[1];
|
||||
|
||||
// 比較「內容所需高度」與「工作流記錄高度」,取較大值以確保 UI 完整性
|
||||
if (node.expectedSize && node.expectedSize[1] > targetHeight) {
|
||||
targetHeight = node.expectedSize[1];
|
||||
}
|
||||
|
||||
node.size[1] = targetHeight;
|
||||
node.setDirtyCanvas(true, true); // 請求畫布重繪
|
||||
};
|
||||
|
||||
/**
|
||||
* 狀態序列化:將當前動態組件的數值同步至隱藏的 settingsWidget 中
|
||||
*/
|
||||
const updateSettings = () => {
|
||||
const data = {};
|
||||
for (let i = 0; i < node.dynamicWidgets.length; i++) {
|
||||
const group = node.dynamicWidgets[i];
|
||||
|
||||
// 目前只處理檔案類型的群組 (保留擴充性)
|
||||
if (group.type === "text") {
|
||||
data[i] = {
|
||||
type: "text",
|
||||
text: group.textWidget.value
|
||||
};
|
||||
data[i] = { type: "text", text: group.textWidget.value };
|
||||
} else {
|
||||
data[i] = {
|
||||
type: "file",
|
||||
folder: group.folder.value,
|
||||
file: group.file.value
|
||||
};
|
||||
data[i] = { type: "file", folder: group.folder.value, file: group.file.value };
|
||||
}
|
||||
}
|
||||
if (settingsWidget) {
|
||||
@@ -55,40 +73,32 @@ app.registerExtension({
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 3. UI 連動邏輯
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 當 Folder 改變時,更新 File 下拉選單的選項
|
||||
/**
|
||||
* 下拉選單連動:根據選擇的資料夾更新對應的檔案列表
|
||||
*/
|
||||
function updateFileWidget(folderName, fileWidget) {
|
||||
if (node.tagsData[folderName]) {
|
||||
fileWidget.options.values = node.tagsData[folderName];
|
||||
// 如果當前選的值不在新清單中,重置為 "ALL"
|
||||
if (!node.tagsData[folderName].includes(fileWidget.value)) {
|
||||
fileWidget.value = "ALL";
|
||||
fileWidget.value = "ALL"; // 若目前值不在新清單中,重置為預設值
|
||||
}
|
||||
} else {
|
||||
fileWidget.options.values = [];
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 4. 群組操作功能 (移動/刪除)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 上移或下移指定的 Widget 群組
|
||||
/**
|
||||
* 組件排序管理:移動指定的動態組件群組位置
|
||||
*/
|
||||
const moveGroup = (index, direction) => {
|
||||
const newIndex = index + direction;
|
||||
// 邊界檢查
|
||||
if (newIndex < 0 || newIndex >= node.dynamicWidgets.length) return;
|
||||
|
||||
// 交換陣列中的位置
|
||||
|
||||
const temp = node.dynamicWidgets[index];
|
||||
node.dynamicWidgets[index] = node.dynamicWidgets[newIndex];
|
||||
node.dynamicWidgets[newIndex] = temp;
|
||||
|
||||
// 重建 Widgets 陣列順序 (這是 ComfyUI 刷新 UI 順序的必要手段)
|
||||
// 1. 保留靜態 widget (非動態生成的)
|
||||
// 重新構建 widgets 陣列以反映 UI 排序
|
||||
const staticWidgets = node.widgets.filter(w =>
|
||||
w !== node.addTagButton &&
|
||||
!node.dynamicWidgets.some(g =>
|
||||
@@ -97,7 +107,6 @@ app.registerExtension({
|
||||
)
|
||||
);
|
||||
|
||||
// 2. 依新順序加入動態 widget
|
||||
node.widgets = [...staticWidgets];
|
||||
node.dynamicWidgets.forEach(g => {
|
||||
if (g.type === "text") {
|
||||
@@ -108,18 +117,16 @@ app.registerExtension({
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 最後加回按鈕
|
||||
if (node.addTagButton) node.widgets.push(node.addTagButton);
|
||||
|
||||
updateSettings();
|
||||
node.setDirtyCanvas(true, true); // 強制重繪
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// 刪除指定的 Widget 群組
|
||||
/**
|
||||
* 組件移除邏輯:銷毀組件實例並重新計算節點尺寸
|
||||
*/
|
||||
const removeGroup = (index) => {
|
||||
const group = node.dynamicWidgets[index];
|
||||
|
||||
// 從 UI 上移除對應的 widget
|
||||
if (group.type === "text") {
|
||||
const tIdx = node.widgets.indexOf(group.textWidget);
|
||||
if (tIdx > -1) node.widgets.splice(tIdx, 1);
|
||||
@@ -129,252 +136,150 @@ app.registerExtension({
|
||||
const lIdx = node.widgets.indexOf(group.file);
|
||||
if (lIdx > -1) node.widgets.splice(lIdx, 1);
|
||||
}
|
||||
|
||||
// 從資料結構中移除
|
||||
node.dynamicWidgets.splice(index, 1);
|
||||
|
||||
updateSettings();
|
||||
node.setSize([node.size[0], node.computeSize()[1]]); // 自動調整節點高度
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 5. 事件攔截與右鍵選單
|
||||
// 4. 交互事件攔截:精準組件定位與自定義右鍵選單
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 攔截滑鼠點擊位置,判斷是否點擊在某個 Widget 群組上
|
||||
const originalGetSlotInPosition = node.getSlotInPosition;
|
||||
node.getSlotInPosition = function(canvasX, canvasY) {
|
||||
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.apply(this, arguments) : null;
|
||||
if (slot) return slot;
|
||||
|
||||
// 遍歷所有組件,判斷滑鼠點擊位置是否落於動態組件範圍內
|
||||
let foundWidget = null;
|
||||
for (const widget of this.widgets) {
|
||||
if (widget.last_y === undefined) continue;
|
||||
const widgetHeight = widget.computeSize ? widget.computeSize(node.size[0])[1] : 20;
|
||||
|
||||
// 簡單的碰撞檢測
|
||||
if (canvasY >= this.pos[1] + widget.last_y && canvasY < this.pos[1] + widget.last_y + widgetHeight) {
|
||||
foundWidget = widget;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 若點擊到 Widget,找出它屬於哪個群組
|
||||
if (foundWidget) {
|
||||
const groupIndex = node.dynamicWidgets.findIndex(g =>
|
||||
(g.type === "text" && g.textWidget === foundWidget) ||
|
||||
(g.type !== "text" && (g.folder === foundWidget || g.file === foundWidget))
|
||||
);
|
||||
|
||||
if (groupIndex !== -1) {
|
||||
// 回傳特殊的 Slot 物件,觸發 getSlotMenuOptions
|
||||
return {
|
||||
widget: foundWidget,
|
||||
output: { type: "TAG_GROUP", groupIndex: groupIndex }
|
||||
};
|
||||
// 返回虛擬 Slot 以觸發自定義 Context Menu
|
||||
return { widget: foundWidget, output: { type: "TAG_GROUP", groupIndex: groupIndex } };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 自定義右鍵選單內容
|
||||
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
|
||||
node.getSlotMenuOptions = function(slot) {
|
||||
// 若命中動態組件,顯示自定義的操作選單(上移、下移、刪除)
|
||||
if (slot && slot.output && slot.output.type === "TAG_GROUP") {
|
||||
const index = slot.output.groupIndex;
|
||||
const canMoveUp = index > 0;
|
||||
const canMoveDown = index < node.dynamicWidgets.length - 1;
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
content: "⬆️ Move Up",
|
||||
disabled: !canMoveUp,
|
||||
callback: () => moveGroup(index, -1)
|
||||
},
|
||||
{
|
||||
content: "⬇️ Move Down",
|
||||
disabled: !canMoveDown,
|
||||
callback: () => moveGroup(index, 1)
|
||||
},
|
||||
null, // 分隔線
|
||||
{
|
||||
content: "🗑️ Remove",
|
||||
callback: () => removeGroup(index)
|
||||
}
|
||||
{ content: "⬆️ Move Up", disabled: index === 0, callback: () => moveGroup(index, -1) },
|
||||
{ content: "⬇️ Move Down", disabled: index === node.dynamicWidgets.length - 1, callback: () => moveGroup(index, 1) },
|
||||
null,
|
||||
{ content: "🗑️ Remove", callback: () => removeGroup(index) }
|
||||
];
|
||||
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
title: "Tag Group Options",
|
||||
event: app.canvas.last_mouse_event || window.event
|
||||
});
|
||||
|
||||
return null; // 阻止預設選單
|
||||
new LiteGraph.ContextMenu(menuItems, { title: "Tag Group Options", event: app.canvas.last_mouse_event || window.event });
|
||||
return null;
|
||||
}
|
||||
|
||||
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.apply(this, arguments) : null;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 6. 核心功能:動態新增 Widget
|
||||
// 5. 動態組件生成功能 (Factory Method)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
this.addTagInputs = function (defaultFolder = null, defaultFile = null) {
|
||||
// 先移除底部的 "+ Add" 按鈕 (因為新 Widget 要插在它上面)
|
||||
// 確保新增按鈕始終位於組件列表的最末端
|
||||
if (node.addTagButton) {
|
||||
const idx = node.widgets.indexOf(node.addTagButton);
|
||||
if (idx !== -1) node.widgets.splice(idx, 1);
|
||||
}
|
||||
|
||||
const folderNames = Object.keys(node.tagsData);
|
||||
|
||||
// 建立 Folder 下拉選單
|
||||
const folderWidget = node.addWidget(
|
||||
"combo",
|
||||
`Folder`,
|
||||
defaultFolder || (folderNames.length > 0 ? folderNames[0] : ""),
|
||||
(v) => {
|
||||
updateFileWidget(v, fileWidget);
|
||||
updateSettings();
|
||||
},
|
||||
{ values: folderNames }
|
||||
);
|
||||
const folderWidget = node.addWidget("combo", "Folder", defaultFolder || (folderNames.length > 0 ? folderNames[0] : ""), (v) => {
|
||||
updateFileWidget(v, fileWidget);
|
||||
updateSettings();
|
||||
}, { values: folderNames });
|
||||
|
||||
// 建立 File 下拉選單
|
||||
const fileWidget = node.addWidget(
|
||||
"combo",
|
||||
`File`,
|
||||
defaultFile || "ALL",
|
||||
() => updateSettings(),
|
||||
{ values: [] }
|
||||
);
|
||||
|
||||
fileWidget.computeSize = () => [0, 35]; // 設定高度
|
||||
|
||||
// 初始化選項
|
||||
const fileWidget = node.addWidget("combo", "File", defaultFile || "ALL", () => updateSettings(), { values: [] });
|
||||
fileWidget.computeSize = () => [0, 35];
|
||||
updateFileWidget(folderWidget.value, fileWidget);
|
||||
|
||||
// 記錄到動態陣列
|
||||
node.dynamicWidgets.push({
|
||||
type: "file",
|
||||
folder: folderWidget,
|
||||
file: fileWidget
|
||||
});
|
||||
|
||||
// 還原預設值 (如果是讀檔恢復的情況)
|
||||
node.dynamicWidgets.push({ type: "file", folder: folderWidget, file: fileWidget });
|
||||
if (defaultFile && fileWidget.options.values.includes(defaultFile)) {
|
||||
fileWidget.value = defaultFile;
|
||||
}
|
||||
|
||||
updateSettings();
|
||||
|
||||
// 加回 "+ Add" 按鈕
|
||||
if (node.addTagButton) {
|
||||
node.widgets.push(node.addTagButton);
|
||||
}
|
||||
|
||||
// 調整節點大小以適應新內容
|
||||
if (node.addTagButton) node.widgets.push(node.addTagButton);
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 7. 輔助功能:可搜尋的選單 (Searchable Menu)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 建立具備即時過濾功能的搜尋選單
|
||||
*/
|
||||
const createSearchableMenu = (event, values, callback) => {
|
||||
const menu = new LiteGraph.ContextMenu(values, {
|
||||
event: event,
|
||||
callback: callback,
|
||||
scale: 1.3
|
||||
});
|
||||
|
||||
// 建立搜尋框 DOM
|
||||
const menu = new LiteGraph.ContextMenu(values, { event: event, callback: callback, scale: 1.3 });
|
||||
const searchInput = document.createElement("input");
|
||||
searchInput.placeholder = "🔍 Search Folder...";
|
||||
searchInput.style.cssText = `
|
||||
width: 95%;
|
||||
margin: 5px auto;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
border: 1px solid #555;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
// 搜尋過濾邏輯:即時隱藏不符合的選項
|
||||
searchInput.style.cssText = `width: 95%; margin: 5px auto; display: block; background: #222; color: #fff; border: 1px solid #555; padding: 4px; border-radius: 4px;`;
|
||||
|
||||
// 實現清單過濾邏輯
|
||||
searchInput.addEventListener("input", (e) => {
|
||||
const term = e.target.value.toLowerCase();
|
||||
const entries = menu.root.querySelectorAll(".litemenu-entry");
|
||||
|
||||
entries.forEach(entry => {
|
||||
menu.root.querySelectorAll(".litemenu-entry").forEach(entry => {
|
||||
const text = entry.innerText.toLowerCase();
|
||||
if (!text) return;
|
||||
|
||||
if (text.includes(term)) {
|
||||
entry.style.display = "block";
|
||||
} else {
|
||||
entry.style.display = "none";
|
||||
}
|
||||
entry.style.display = (text && text.includes(term)) ? "block" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// 阻擋事件冒泡,防止輸入時觸發 ComfyUI 快捷鍵
|
||||
|
||||
// 阻止事件冒泡以免觸發 LiteGraph 預設行為
|
||||
searchInput.addEventListener("mouseup", (e) => e.stopPropagation());
|
||||
searchInput.addEventListener("keydown", (e) => e.stopPropagation());
|
||||
|
||||
menu.root.prepend(searchInput);
|
||||
setTimeout(() => searchInput.focus(), 10);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 8. 建立新增按鈕與啟動載入
|
||||
// 6. 初始化載入流程與非同步數據恢復
|
||||
// -----------------------------------------------------------
|
||||
|
||||
node.addTagButton = this.addWidget(
|
||||
"button",
|
||||
"+ Add Tag Group",
|
||||
null,
|
||||
function (value, canvas, node, pos, event) {
|
||||
const folderNames = Object.keys(node.tagsData).sort();
|
||||
|
||||
if (folderNames.length === 0) {
|
||||
alert("No tags folder found!");
|
||||
return;
|
||||
}
|
||||
node.addTagButton = this.addWidget("button", "+ Add Tag Group", null, function (value, canvas, node, pos, event) {
|
||||
const folderNames = Object.keys(node.tagsData).sort();
|
||||
if (folderNames.length === 0) return alert("No tags folder found!");
|
||||
createSearchableMenu(event, folderNames, (selectedFolder) => {
|
||||
if (selectedFolder) node.addTagInputs(selectedFolder, "ALL");
|
||||
});
|
||||
});
|
||||
|
||||
// 呼叫自定義搜尋選單
|
||||
createSearchableMenu(
|
||||
event,
|
||||
folderNames,
|
||||
(selectedFolder) => {
|
||||
if (selectedFolder) {
|
||||
node.addTagInputs(selectedFolder, "ALL");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// 啟動時:從後端 API 獲取資料並還原上次的設定
|
||||
// 從後端 API 獲取標籤結構並根據備份狀態恢復 UI
|
||||
fetch("/custom_nodes/tags")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
node.tagsData = data;
|
||||
|
||||
// 檢查是否有儲存的設定並還原
|
||||
if (settingsWidget && settingsWidget.value && settingsWidget.value !== "{}") {
|
||||
try {
|
||||
const savedData = JSON.parse(settingsWidget.value);
|
||||
Object.keys(savedData)
|
||||
.sort((a, b) => parseInt(a) - parseInt(b)) // 確保順序正確
|
||||
.forEach(key => {
|
||||
const item = savedData[key];
|
||||
if (item.folder) {
|
||||
this.addTagInputs(item.folder, item.file);
|
||||
}
|
||||
});
|
||||
const keys = Object.keys(savedData).sort((a, b) => parseInt(a) - parseInt(b));
|
||||
|
||||
keys.forEach(key => {
|
||||
const item = savedData[key];
|
||||
if (item.type === "file" && item.folder) {
|
||||
this.addTagInputs(item.folder, item.file);
|
||||
}
|
||||
});
|
||||
|
||||
// 所有組件渲染完成後,執行雙重延遲校準以確保尺寸計算準確
|
||||
requestAnimationFrame(() => {
|
||||
node.fixSize();
|
||||
setTimeout(() => node.fixSize(), 100);
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error restoring tags:", e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user