修改saver、loader節點大小固定邏輯,右鍵選單項目新增
This commit is contained in:
+116
-120
@@ -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) {
|
||||
|
||||
+96
-98
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user