From 2280e33dab519db14379c7f0cb4ac43b58fbf443 Mon Sep 17 00:00:00 2001 From: NineKey Date: Wed, 28 Jan 2026 02:50:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9saver=E3=80=81loader=E7=AF=80?= =?UTF-8?q?=E9=BB=9E=E5=A4=A7=E5=B0=8F=E5=9B=BA=E5=AE=9A=E9=82=8F=E8=BC=AF?= =?UTF-8?q?=EF=BC=8C=E5=8F=B3=E9=8D=B5=E9=81=B8=E5=96=AE=E9=A0=85=E7=9B=AE?= =?UTF-8?q?=E6=96=B0=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/dynamic_loader.js | 236 +++++++++++++++++++++--------------------- web/dynamic_saver.js | 194 +++++++++++++++++----------------- web/dynamic_utils.js | 144 ++++++++++++++++++++++++++ 3 files changed, 356 insertions(+), 218 deletions(-) create mode 100644 web/dynamic_utils.js diff --git a/web/dynamic_loader.js b/web/dynamic_loader.js index bf879db..7823b77 100644 --- a/web/dynamic_loader.js +++ b/web/dynamic_loader.js @@ -1,4 +1,5 @@ import { app } from "../../scripts/app.js"; +import { getDynamicGroupMenu, setupSizeManager } from "./dynamic_utils.js"; app.registerExtension({ name: "ComfyUI.DynamicTagLoader", @@ -11,94 +12,22 @@ app.registerExtension({ const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; const node = this; - // ----------------------------------------------------------- - // 1. 核心狀態與隱藏組件初始化 - // ----------------------------------------------------------- - node.expectedSize = null; // 儲存從工作流 (Workflow) 讀取的原始尺寸,防止非同步載入後塌陷 - - // 查找並隱藏用於儲存 JSON 狀態的 widget,使其不顯示於 UI 上 + setupSizeManager(node); + const settingsWidget = node.widgets.find(w => w.name === "tag_settings"); if (settingsWidget) { settingsWidget.type = "hidden"; settingsWidget.computeSize = () => [0, -4]; } - node.tagsData = {}; // 儲存從伺服器獲取的標籤結構數據 - node.dynamicWidgets = []; // 管理動態產生的組件群組 (Folder + File) - node.addTagButton = null; // 新增按鈕實例暫存 + node.tagsData = {}; + node.dynamicWidgets = []; + node.addTagButton = null; // ----------------------------------------------------------- - // 2. 生命週期攔截:序列化配置載入 (onConfigure) + // Helper: 更新 Widget 顯示列表 // ----------------------------------------------------------- - 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 }; - } else { - data[i] = { type: "file", folder: group.folder.value, file: group.file.value }; - } - } - if (settingsWidget) { - settingsWidget.value = JSON.stringify(data); - } - }; - - /** - * 下拉選單連動:根據選擇的資料夾更新對應的檔案列表 - */ - function updateFileWidget(folderName, fileWidget) { - if (node.tagsData[folderName]) { - fileWidget.options.values = node.tagsData[folderName]; - if (!node.tagsData[folderName].includes(fileWidget.value)) { - fileWidget.value = "ALL"; // 若目前值不在新清單中,重置為預設值 - } - } else { - fileWidget.options.values = []; - } - } - - /** - * 組件排序管理:移動指定的動態組件群組位置 - */ - 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 陣列以反映 UI 排序 + const rebuildWidgetList = () => { const staticWidgets = node.widgets.filter(w => w !== node.addTagButton && !node.dynamicWidgets.some(g => @@ -118,39 +47,105 @@ app.registerExtension({ }); if (node.addTagButton) node.widgets.push(node.addTagButton); + }; + + 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 }; + } else { + data[i] = { type: "file", folder: group.folder.value, file: group.file.value }; + } + } + if (settingsWidget) { + settingsWidget.value = JSON.stringify(data); + } + }; + + function updateFileWidget(folderName, fileWidget) { + if (node.tagsData[folderName]) { + fileWidget.options.values = node.tagsData[folderName]; + if (!node.tagsData[folderName].includes(fileWidget.value)) { + fileWidget.value = "ALL"; + } + } else { + fileWidget.options.values = []; + } + } + + // ----------------------------------------------------------- + // 動作邏輯 + // ----------------------------------------------------------- + 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; + + rebuildWidgetList(); updateSettings(); node.setDirtyCanvas(true, true); }; - /** - * 組件移除邏輯:銷毀組件實例並重新計算節點尺寸 - */ - const removeGroup = (index) => { - const group = node.dynamicWidgets[index]; - if (group.type === "text") { - const tIdx = node.widgets.indexOf(group.textWidget); - if (tIdx > -1) node.widgets.splice(tIdx, 1); + const moveGroupAbsolute = (index, position) => { + if (position === "top" && index === 0) return; + if (position === "bottom" && index === node.dynamicWidgets.length - 1) return; + + const item = node.dynamicWidgets.splice(index, 1)[0]; + if (position === "top") { + node.dynamicWidgets.unshift(item); } else { - const fIdx = node.widgets.indexOf(group.folder); - if (fIdx > -1) node.widgets.splice(fIdx, 1); - const lIdx = node.widgets.indexOf(group.file); - if (lIdx > -1) node.widgets.splice(lIdx, 1); + node.dynamicWidgets.push(item); } - node.dynamicWidgets.splice(index, 1); + + rebuildWidgetList(); updateSettings(); - node.setSize([node.size[0], node.computeSize()[1]]); node.setDirtyCanvas(true, true); }; + const handleInsert = (index, position) => { + const folderNames = Object.keys(node.tagsData).sort(); + if (folderNames.length === 0) return alert("No tags folder found!"); + + createSearchableMenu(window.event, folderNames, (selectedFolder) => { + if (selectedFolder) { + const targetIndex = position === "before" ? index : index + 1; + node.performAdd(() => { + node.addTagInputs(selectedFolder, "ALL", targetIndex); + }); + } + }); + }; + + const removeGroup = (index) => { + node.performRemove(() => { + const group = node.dynamicWidgets[index]; + if (group.type === "text") { + const tIdx = node.widgets.indexOf(group.textWidget); + if (tIdx > -1) node.widgets.splice(tIdx, 1); + } else { + const fIdx = node.widgets.indexOf(group.folder); + if (fIdx > -1) node.widgets.splice(fIdx, 1); + const lIdx = node.widgets.indexOf(group.file); + if (lIdx > -1) node.widgets.splice(lIdx, 1); + } + node.dynamicWidgets.splice(index, 1); + updateSettings(); + }); + }; + // ----------------------------------------------------------- - // 4. 交互事件攔截:精準組件定位與自定義右鍵選單 + // 交互與選單 // ----------------------------------------------------------- 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; @@ -167,7 +162,6 @@ app.registerExtension({ (g.type !== "text" && (g.folder === foundWidget || g.file === foundWidget)) ); if (groupIndex !== -1) { - // 返回虛擬 Slot 以觸發自定義 Context Menu return { widget: foundWidget, output: { type: "TAG_GROUP", groupIndex: groupIndex } }; } } @@ -176,15 +170,16 @@ app.registerExtension({ const originalGetSlotMenuOptions = node.getSlotMenuOptions; node.getSlotMenuOptions = function(slot) { - // 若命中動態組件,顯示自定義的操作選單(上移、下移、刪除) if (slot && slot.output && slot.output.type === "TAG_GROUP") { const index = slot.output.groupIndex; - const menuItems = [ - { 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) } - ]; + const menuItems = getDynamicGroupMenu( + index, + node.dynamicWidgets.length, + moveGroup, + moveGroupAbsolute, + handleInsert, + removeGroup + ); new LiteGraph.ContextMenu(menuItems, { title: "Tag Group Options", event: app.canvas.last_mouse_event || window.event }); return null; } @@ -192,10 +187,9 @@ app.registerExtension({ }; // ----------------------------------------------------------- - // 5. 動態組件生成功能 (Factory Method) + // 動態組件生成 // ----------------------------------------------------------- - this.addTagInputs = function (defaultFolder = null, defaultFile = null) { - // 確保新增按鈕始終位於組件列表的最末端 + this.addTagInputs = function (defaultFolder = null, defaultFile = null, insertIndex = null) { if (node.addTagButton) { const idx = node.widgets.indexOf(node.addTagButton); if (idx !== -1) node.widgets.splice(idx, 1); @@ -211,26 +205,28 @@ app.registerExtension({ fileWidget.computeSize = () => [0, 35]; updateFileWidget(folderWidget.value, fileWidget); - node.dynamicWidgets.push({ type: "file", folder: folderWidget, file: fileWidget }); + const newGroup = { type: "file", folder: folderWidget, file: fileWidget }; + + if (insertIndex !== null && insertIndex >= 0 && insertIndex <= node.dynamicWidgets.length) { + node.dynamicWidgets.splice(insertIndex, 0, newGroup); + } else { + node.dynamicWidgets.push(newGroup); + } + if (defaultFile && fileWidget.options.values.includes(defaultFile)) { fileWidget.value = defaultFile; } + rebuildWidgetList(); updateSettings(); - if (node.addTagButton) node.widgets.push(node.addTagButton); - node.setSize([node.size[0], node.computeSize()[1]]); }; - /** - * 建立具備即時過濾功能的搜尋選單 - */ const createSearchableMenu = (event, values, callback) => { 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; background: #222; color: #fff; border: 1px solid #555; padding: 4px; border-radius: 4px;`; - // 實現清單過濾邏輯 searchInput.addEventListener("input", (e) => { const term = e.target.value.toLowerCase(); menu.root.querySelectorAll(".litemenu-entry").forEach(entry => { @@ -239,7 +235,6 @@ app.registerExtension({ }); }); - // 阻止事件冒泡以免觸發 LiteGraph 預設行為 searchInput.addEventListener("mouseup", (e) => e.stopPropagation()); searchInput.addEventListener("keydown", (e) => e.stopPropagation()); menu.root.prepend(searchInput); @@ -247,17 +242,20 @@ app.registerExtension({ }; // ----------------------------------------------------------- - // 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) return alert("No tags folder found!"); createSearchableMenu(event, folderNames, (selectedFolder) => { - if (selectedFolder) node.addTagInputs(selectedFolder, "ALL"); + if (selectedFolder) { + node.performAdd(() => { + node.addTagInputs(selectedFolder, "ALL"); + }); + } }); }); - // 從後端 API 獲取標籤結構並根據備份狀態恢復 UI fetch("/custom_nodes/tags") .then(response => response.json()) .then(data => { @@ -274,10 +272,8 @@ app.registerExtension({ } }); - // 所有組件渲染完成後,執行雙重延遲校準以確保尺寸計算準確 requestAnimationFrame(() => { - node.fixSize(); - setTimeout(() => node.fixSize(), 100); + node.triggerAutoSize(); }); } catch (e) { diff --git a/web/dynamic_saver.js b/web/dynamic_saver.js index c959e20..863040f 100644 --- a/web/dynamic_saver.js +++ b/web/dynamic_saver.js @@ -1,4 +1,5 @@ import { app } from "../../scripts/app.js"; +import { getDynamicGroupMenu, setupSizeManager } from "./dynamic_utils.js"; app.registerExtension({ name: "ComfyUI.DynamicTagSaver", @@ -11,57 +12,61 @@ app.registerExtension({ const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; const node = this; - // ----------------------------------------------------------- - // 1. 初始化與介面調整 - // ----------------------------------------------------------- + setupSizeManager(node); - // 隱藏後端通訊用的 Widget (lora_settings) const settingsWidget = node.widgets.find(w => w.name === "lora_settings"); if (settingsWidget) { settingsWidget.type = "hidden"; - settingsWidget.computeSize = () => [0, -4]; // 設定負高度以完全隱藏 + settingsWidget.computeSize = () => [0, -4]; } - // 插入視覺分隔線 (Separator Widget) - // 目的:將一般的檔名設定與下方的 LoRA 列表在視覺上分開 const filenameIdx = node.widgets.findIndex(w => w.name === "filename"); if (filenameIdx !== -1) { const separatorWidget = { name: "separator", - type: "display", // 標記為顯示專用,不參與邏輯運算 - computeSize: () => [0, 30], // 設定高度保留空白 - // 自定義繪製邏輯 + type: "display", + computeSize: () => [0, 30], draw: function(ctx, node, widget_width, y, widget_height) { - // 繪製標題文字 ctx.fillStyle = "#888"; ctx.font = "10px Arial"; ctx.textAlign = "center"; ctx.fillText("▼ LoRA Auto Merge ▼", widget_width * 0.5, y + 18); - // 繪製分隔線 ctx.strokeStyle = "#444"; ctx.beginPath(); ctx.moveTo(10, y + 24); ctx.lineTo(widget_width - 10, y + 24); ctx.stroke(); - ctx.textAlign = "left"; // 還原畫布設定 + ctx.textAlign = "left"; } }; - // 插入至 filename 下方 node.widgets.splice(filenameIdx + 1, 0, separatorWidget); } - // 初始化資料結構 - node.loraList = []; // 儲存從後端抓取的 LoRA 清單 - node.dynamicWidgets = []; // 儲存動態生成的 LoRA 控制項 + node.loraList = []; + node.dynamicWidgets = []; node.addLoraButton = null; // ----------------------------------------------------------- - // 2. 資料同步 (UI -> JSON) + // Helper: 重建 Widget 列表 // ----------------------------------------------------------- - - // 將目前 UI 上的 LoRA 設定序列化為 JSON,存入隱藏欄位 + const rebuildWidgetList = () => { + const staticWidgets = node.widgets.filter(w => + w !== node.addLoraButton && + !node.dynamicWidgets.some(g => g.loraSelector === w || g.strengthInput === w) + ); + + node.widgets = [...staticWidgets]; + + node.dynamicWidgets.forEach(g => { + node.widgets.push(g.loraSelector); + node.widgets.push(g.strengthInput); + }); + + if (node.addLoraButton) node.widgets.push(node.addLoraButton); + }; + const updateSettings = () => { const data = {}; for (let i = 0; i < node.dynamicWidgets.length; i++) { @@ -77,64 +82,68 @@ app.registerExtension({ }; // ----------------------------------------------------------- - // 3. 群組操作邏輯 (排序與刪除) + // 動作邏輯 // ----------------------------------------------------------- - - // 移動 LoRA 群組位置 (上移/下移) 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; - // 重建 UI Widget 列表 - // 注意:必須保留「靜態元件」(如檔名輸入框、我們剛做的分隔線、新增按鈕) - const staticWidgets = node.widgets.filter(w => - w !== node.addLoraButton && - !node.dynamicWidgets.some(g => g.loraSelector === w || g.strengthInput === w) - ); - - node.widgets = [...staticWidgets]; - - // 依新順序加入動態元件 - node.dynamicWidgets.forEach(g => { - node.widgets.push(g.loraSelector); - node.widgets.push(g.strengthInput); + rebuildWidgetList(); + updateSettings(); + node.setDirtyCanvas(true, true); + }; + + const moveGroupAbsolute = (index, position) => { + if (position === "top" && index === 0) return; + if (position === "bottom" && index === node.dynamicWidgets.length - 1) return; + + const item = node.dynamicWidgets.splice(index, 1)[0]; + if (position === "top") { + node.dynamicWidgets.unshift(item); + } else { + node.dynamicWidgets.push(item); + } + + rebuildWidgetList(); + updateSettings(); + node.setDirtyCanvas(true, true); + }; + + const handleInsert = (index, position) => { + if (node.loraList.length === 0) return alert("No LoRAs found!"); + + createSearchableMenu(window.event, node.loraList, (selectedLora) => { + if (selectedLora) { + const targetIndex = position === "before" ? index : index + 1; + node.performAdd(() => { + node.addLoraInputs(selectedLora, 1.0, targetIndex); + }); + } }); - - // 最後加回按鈕 - if (node.addLoraButton) node.widgets.push(node.addLoraButton); - - updateSettings(); - node.setDirtyCanvas(true, true); }; - // 刪除指定的 LoRA 群組 const removeGroup = (index) => { - const group = node.dynamicWidgets[index]; - - // 移除 UI 元件 - const lIdx = node.widgets.indexOf(group.loraSelector); - if (lIdx > -1) node.widgets.splice(lIdx, 1); - const sIdx = node.widgets.indexOf(group.strengthInput); - if (sIdx > -1) node.widgets.splice(sIdx, 1); + node.performRemove(() => { + const group = node.dynamicWidgets[index]; + + const lIdx = node.widgets.indexOf(group.loraSelector); + if (lIdx > -1) node.widgets.splice(lIdx, 1); + const sIdx = node.widgets.indexOf(group.strengthInput); + if (sIdx > -1) node.widgets.splice(sIdx, 1); - // 移除資料紀錄 - node.dynamicWidgets.splice(index, 1); + node.dynamicWidgets.splice(index, 1); - updateSettings(); - node.setSize([node.size[0], node.computeSize()[1]]); - node.setDirtyCanvas(true, true); + updateSettings(); + }); }; // ----------------------------------------------------------- - // 4. 事件互動 (右鍵選單) + // 交互與選單 // ----------------------------------------------------------- - - // 攔截滑鼠點擊,判斷是否點選到 LoRA 群組 const originalGetSlotInPosition = node.getSlotInPosition; node.getSlotInPosition = function(canvasX, canvasY) { const slot = originalGetSlotInPosition ? originalGetSlotInPosition.apply(this, arguments) : null; @@ -145,7 +154,6 @@ app.registerExtension({ 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; @@ -155,28 +163,24 @@ app.registerExtension({ if (foundWidget) { const groupIndex = node.dynamicWidgets.findIndex(g => g.loraSelector === foundWidget || g.strengthInput === foundWidget); if (groupIndex !== -1) { - // 回傳特殊類型,觸發 getSlotMenuOptions return { widget: foundWidget, output: { type: "LORA_GROUP", groupIndex: groupIndex } }; } } return null; }; - // 定義右鍵選單內容 const originalGetSlotMenuOptions = node.getSlotMenuOptions; node.getSlotMenuOptions = function(slot) { if (slot && slot.output && slot.output.type === "LORA_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) } - ]; - + const menuItems = getDynamicGroupMenu( + index, + node.dynamicWidgets.length, + moveGroup, + moveGroupAbsolute, + handleInsert, + removeGroup + ); new LiteGraph.ContextMenu(menuItems, { title: "LoRA Options", event: app.canvas.last_mouse_event || window.event @@ -187,11 +191,9 @@ app.registerExtension({ }; // ----------------------------------------------------------- - // 5. 核心功能:動態新增 LoRA + // 動態組件生成 // ----------------------------------------------------------- - - this.addLoraInputs = function (defaultLora = null, defaultStrength = 1.0) { - // 暫時移除 "+ Add" 按鈕 + this.addLoraInputs = function (defaultLora = null, defaultStrength = 1.0, insertIndex = null) { if (node.addLoraButton) { const idx = node.widgets.indexOf(node.addLoraButton); if (idx !== -1) node.widgets.splice(idx, 1); @@ -199,7 +201,6 @@ app.registerExtension({ const initialLora = defaultLora || (node.loraList.length > 0 ? node.loraList[0] : "None"); - // 建立 LoRA 選擇器 const loraSelector = node.addWidget( "combo", "LoRA Name", @@ -208,7 +209,6 @@ app.registerExtension({ { values: node.loraList } ); - // 建立強度數值輸入 const strengthInput = node.addWidget( "number", "Strength", @@ -219,24 +219,24 @@ app.registerExtension({ strengthInput.computeSize = () => [0, 30]; - node.dynamicWidgets.push({ + const newGroup = { loraSelector: loraSelector, strengthInput: strengthInput - }); + }; + + if (insertIndex !== null && insertIndex >= 0 && insertIndex <= node.dynamicWidgets.length) { + node.dynamicWidgets.splice(insertIndex, 0, newGroup); + } else { + node.dynamicWidgets.push(newGroup); + } updateSettings(); - - // 加回 "+ Add" 按鈕 - if (node.addLoraButton) { - node.widgets.push(node.addLoraButton); - } - node.setSize([node.size[0], node.computeSize()[1]]); + rebuildWidgetList(); }; // ----------------------------------------------------------- - // 6. 輔助功能:可搜尋的選單 + // 輔助功能 // ----------------------------------------------------------- - const createSearchableMenu = (event, values, callback) => { const menu = new LiteGraph.ContextMenu(values, { event: event, @@ -244,7 +244,6 @@ app.registerExtension({ scale: 1.3 }); - // 建立搜尋輸入框 const searchInput = document.createElement("input"); searchInput.placeholder = "🔍 Search LoRA..."; searchInput.style.cssText = ` @@ -259,7 +258,6 @@ app.registerExtension({ border-radius: 4px; `; - // 搜尋過濾邏輯 searchInput.addEventListener("input", (e) => { const term = e.target.value.toLowerCase(); const entries = menu.root.querySelectorAll(".litemenu-entry"); @@ -274,7 +272,6 @@ app.registerExtension({ }); }); - // 阻止事件冒泡 (避免觸發 Canvas 縮放等) searchInput.addEventListener("mouseup", (e) => e.stopPropagation()); searchInput.addEventListener("keydown", (e) => e.stopPropagation()); @@ -283,10 +280,8 @@ app.registerExtension({ }; // ----------------------------------------------------------- - // 7. 啟動與資料載入 + // 啟動與載入 // ----------------------------------------------------------- - - // 建立 "+ Add LoRA" 按鈕 node.addLoraButton = this.addWidget( "button", "+ Add LoRA", @@ -296,26 +291,25 @@ app.registerExtension({ alert("No LoRAs found or list not loaded yet."); return; } - // 呼叫搜尋選單 createSearchableMenu( event, node.loraList, (selectedLora) => { if(selectedLora) { - node.addLoraInputs(selectedLora, 1.0); + node.performAdd(() => { + node.addLoraInputs(selectedLora, 1.0); + }); } } ); } ); - // 從後端 API 獲取 LoRA 列表並還原設定 fetch("/custom_nodes/loras_list") .then(response => response.json()) .then(data => { node.loraList = ["None", ...data]; - // 檢查是否有儲存的設定並還原 if (settingsWidget && settingsWidget.value && settingsWidget.value !== "{}") { try { const savedData = JSON.parse(settingsWidget.value); @@ -325,6 +319,10 @@ app.registerExtension({ const item = savedData[key]; this.addLoraInputs(item.lora_name, item.strength); }); + + requestAnimationFrame(() => { + node.triggerAutoSize(); + }); } catch (e) { console.error("Error restoring loras:", e); } diff --git a/web/dynamic_utils.js b/web/dynamic_utils.js new file mode 100644 index 0000000..46acec3 --- /dev/null +++ b/web/dynamic_utils.js @@ -0,0 +1,144 @@ +/** + * 產生動態群組管理的通用右鍵選單 + * @param {number} index - 當前被點擊的群組索引 + * @param {number} totalLength - 群組總長度 + * @param {Function} moveCallback - 移動 (index, direction) + * @param {Function} moveAbsCallback - 絕對移動 (index, "top"|"bottom") [New] + * @param {Function} insertCallback - 插入 (index, "before"|"after") [New] + * @param {Function} removeCallback - 移除 (index) + */ +export function getDynamicGroupMenu(index, totalLength, moveCallback, moveAbsCallback, insertCallback, removeCallback) { + return [ + null, + // 1. 移動區塊 + { + content: "⏫ To Top", + disabled: index === 0, + callback: () => moveAbsCallback(index, "top") + }, + { + content: "⬆️ Move Up", + disabled: index === 0, + callback: () => moveCallback(index, -1) + }, + { + content: "⬇️ Move Down", + disabled: index === totalLength - 1, + callback: () => moveCallback(index, 1) + }, + { + content: "⏬ To Bottom", + disabled: index === totalLength - 1, + callback: () => moveAbsCallback(index, "bottom") + }, + null, // 分隔線 + + // 2. 插入區塊 + { + content: "👆 Insert Above", + callback: () => insertCallback(index, "before") + }, + { + content: "👇 Insert Below", + callback: () => insertCallback(index, "after") + }, + null, // 分隔線 + + // 3. 刪除區塊 + { + content: "🗑️ Remove", + callback: () => removeCallback(index) + } + ]; +} + +/** + * [Updated] 節點尺寸管理器 + * 包含: + * 1. 防止切換工作流時塌陷 (Restore Logic) + * 2. 防止新增項目時擠壓現有組件 (Growth Logic) + */ +export function setupSizeManager(node) { + node._userMinHeight = 0; + node._isResizing = false; + + // 1. 攔截讀取工作流配置 + const originalOnConfigure = node.onConfigure; + node.onConfigure = function(data) { + if (originalOnConfigure) originalOnConfigure.apply(this, arguments); + if (data && data.size) { + node._userMinHeight = data.size[1]; + } + }; + + // 2. 攔截使用者手動調整尺寸 + const originalOnResize = node.onResize; + node.onResize = function(size) { + if (originalOnResize) originalOnResize.apply(this, arguments); + if (!node._isResizing) { + node._userMinHeight = size[1]; + } + }; + + /** + * [Add Logic] 新增時增高節點 (保持 Text Widget 高度不變) + */ + node.performAdd = function(action) { + const startMinHeight = node.computeSize()[1]; + action(); + const endMinHeight = node.computeSize()[1]; + const growth = endMinHeight - startMinHeight; + + if (growth > 0) { + node._isResizing = true; + const newHeight = node.size[1] + growth; + node.setSize([node.size[0], newHeight]); + node._userMinHeight = newHeight; + node._isResizing = false; + } + node.setDirtyCanvas(true, true); + }; + + /** + * [Remove Logic] 移除時縮短節點 (New!) + * 計算移除前後的「內容高度差」,直接從節點總高度扣除 + */ + node.performRemove = function(action) { + // A. 記錄動作前的「內容所需最小高度」 + const startMinHeight = node.computeSize()[1]; + + // B. 執行移除動作 + action(); + + // C. 記錄動作後的「內容所需最小高度」 + const endMinHeight = node.computeSize()[1]; + + // D. 計算減少量 (Shrink Amount) + const shrink = startMinHeight - endMinHeight; + + // E. 將減少量應用到「當前節點實際高度」 + if (shrink > 0) { + node._isResizing = true; + // 讓節點變矮,這樣多出來的空間就不會被 Text Widget 吃掉 + const newHeight = Math.max(node.size[1] - shrink, endMinHeight); + node.setSize([node.size[0], newHeight]); + node._userMinHeight = newHeight; // 同步更新鎖定高度 + node._isResizing = false; + } + node.setDirtyCanvas(true, true); + }; + + /** + * [Restore/Check Logic] 用於載入時或防塌陷檢查 + */ + node.triggerAutoSize = function() { + const contentMinHeight = node.computeSize()[1]; + const targetHeight = Math.max(contentMinHeight, node._userMinHeight || 0); + + node._isResizing = true; + node.setSize([node.size[0], targetHeight]); + node._isResizing = false; + + node.setDirtyCanvas(true, true); + }; +} \ No newline at end of file