From 5a665b0ded6474b5d71729d2f8c6d2263ebbffdf Mon Sep 17 00:00:00 2001 From: NineKey Date: Thu, 22 Jan 2026 23:20:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=88=87=E6=8F=9B=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E7=AF=80=E9=BB=9E=E7=B8=AE=E5=9B=9E=E5=95=8F?= =?UTF-8?q?=E9=A1=8C=EF=BC=8C=E7=B5=B1=E4=B8=80=E5=82=99=E8=A8=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- loader_node.py | 84 +++++++----- web/dynamic_loader.js | 309 +++++++++++++++--------------------------- 2 files changed, 160 insertions(+), 233 deletions(-) diff --git a/loader_node.py b/loader_node.py index 0513b82..7087e4a 100644 --- a/loader_node.py +++ b/loader_node.py @@ -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 設定列表""" + """ + Regex 解析器:提取文本中的 標籤。 + + Args: + text (str): 原始 Prompt 文本 + Returns: + tuple: (清理後的文本, LoRA 配置列表 [(name, weight), ...]) + """ if not text: return "", [] lora_pattern = r":]+)(?::([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) \ No newline at end of file diff --git a/web/dynamic_loader.js b/web/dynamic_loader.js index fd283ba..bf879db 100644 --- a/web/dynamic_loader.js +++ b/web/dynamic_loader.js @@ -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); }