修正切換工作流節點縮回問題,統一備註

This commit is contained in:
NineKey
2026-01-22 23:20:58 +08:00
parent 7d4fa334ba
commit 5a665b0ded
2 changed files with 160 additions and 233 deletions
+53 -31
View File
@@ -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
View File
@@ -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);
}