Initial commit
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.DynamicTagLoader",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "DynamicTagLoaderJS") {
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
const node = this;
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 1. 初始化設定與資料結構
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 找到後端定義的 hidden widget,將其隱藏並設為無高度
|
||||
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; // 按鈕參照
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 2. 狀態同步 (UI -> JSON)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 將當前所有動態 widget 的值打包成 JSON,寫入 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);
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 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";
|
||||
}
|
||||
} 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 (非動態生成的)
|
||||
const staticWidgets = node.widgets.filter(w =>
|
||||
w !== node.addTagButton &&
|
||||
!node.dynamicWidgets.some(g =>
|
||||
(g.type === "text" && g.textWidget === w) ||
|
||||
(g.type !== "text" && (g.folder === w || g.file === w))
|
||||
)
|
||||
);
|
||||
|
||||
// 2. 依新順序加入動態 widget
|
||||
node.widgets = [...staticWidgets];
|
||||
node.dynamicWidgets.forEach(g => {
|
||||
if (g.type === "text") {
|
||||
node.widgets.push(g.textWidget);
|
||||
} else {
|
||||
node.widgets.push(g.folder);
|
||||
node.widgets.push(g.file);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 最後加回按鈕
|
||||
if (node.addTagButton) node.widgets.push(node.addTagButton);
|
||||
|
||||
updateSettings();
|
||||
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);
|
||||
} 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();
|
||||
node.setSize([node.size[0], node.computeSize()[1]]); // 自動調整節點高度
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 5. 事件攔截與右鍵選單
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 攔截滑鼠點擊位置,判斷是否點擊在某個 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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
];
|
||||
|
||||
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
|
||||
// -----------------------------------------------------------
|
||||
|
||||
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 }
|
||||
);
|
||||
|
||||
// 建立 File 下拉選單
|
||||
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
|
||||
});
|
||||
|
||||
// 還原預設值 (如果是讀檔恢復的情況)
|
||||
if (defaultFile && fileWidget.options.values.includes(defaultFile)) {
|
||||
fileWidget.value = defaultFile;
|
||||
}
|
||||
|
||||
updateSettings();
|
||||
|
||||
// 加回 "+ Add" 按鈕
|
||||
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 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.addEventListener("input", (e) => {
|
||||
const term = e.target.value.toLowerCase();
|
||||
const entries = menu.root.querySelectorAll(".litemenu-entry");
|
||||
|
||||
entries.forEach(entry => {
|
||||
const text = entry.innerText.toLowerCase();
|
||||
if (!text) return;
|
||||
|
||||
if (text.includes(term)) {
|
||||
entry.style.display = "block";
|
||||
} else {
|
||||
entry.style.display = "none";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 阻擋事件冒泡,防止輸入時觸發 ComfyUI 快捷鍵
|
||||
searchInput.addEventListener("mouseup", (e) => e.stopPropagation());
|
||||
searchInput.addEventListener("keydown", (e) => e.stopPropagation());
|
||||
|
||||
menu.root.prepend(searchInput);
|
||||
setTimeout(() => searchInput.focus(), 10);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 8. 建立新增按鈕與啟動載入
|
||||
// -----------------------------------------------------------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 呼叫自定義搜尋選單
|
||||
createSearchableMenu(
|
||||
event,
|
||||
folderNames,
|
||||
(selectedFolder) => {
|
||||
if (selectedFolder) {
|
||||
node.addTagInputs(selectedFolder, "ALL");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// 啟動時:從後端 API 獲取資料並還原上次的設定
|
||||
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);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error restoring tags:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.DynamicTagSaver",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "DynamicTagSaver") {
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
const node = this;
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 1. 初始化與介面調整
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 隱藏後端通訊用的 Widget (lora_settings)
|
||||
const settingsWidget = node.widgets.find(w => w.name === "lora_settings");
|
||||
if (settingsWidget) {
|
||||
settingsWidget.type = "hidden";
|
||||
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], // 設定高度保留空白
|
||||
// 自定義繪製邏輯
|
||||
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"; // 還原畫布設定
|
||||
}
|
||||
};
|
||||
// 插入至 filename 下方
|
||||
node.widgets.splice(filenameIdx + 1, 0, separatorWidget);
|
||||
}
|
||||
|
||||
// 初始化資料結構
|
||||
node.loraList = []; // 儲存從後端抓取的 LoRA 清單
|
||||
node.dynamicWidgets = []; // 儲存動態生成的 LoRA 控制項
|
||||
node.addLoraButton = null;
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 2. 資料同步 (UI -> JSON)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 將目前 UI 上的 LoRA 設定序列化為 JSON,存入隱藏欄位
|
||||
const updateSettings = () => {
|
||||
const data = {};
|
||||
for (let i = 0; i < node.dynamicWidgets.length; i++) {
|
||||
const group = node.dynamicWidgets[i];
|
||||
data[i] = {
|
||||
lora_name: group.loraSelector.value,
|
||||
strength: group.strengthInput.value
|
||||
};
|
||||
}
|
||||
if (settingsWidget) {
|
||||
settingsWidget.value = JSON.stringify(data);
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 最後加回按鈕
|
||||
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.dynamicWidgets.splice(index, 1);
|
||||
|
||||
updateSettings();
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 4. 事件互動 (右鍵選單)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 攔截滑鼠點擊,判斷是否點選到 LoRA 群組
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
];
|
||||
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
title: "LoRA Options",
|
||||
event: app.canvas.last_mouse_event || window.event
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.apply(this, arguments) : null;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 5. 核心功能:動態新增 LoRA
|
||||
// -----------------------------------------------------------
|
||||
|
||||
this.addLoraInputs = function (defaultLora = null, defaultStrength = 1.0) {
|
||||
// 暫時移除 "+ Add" 按鈕
|
||||
if (node.addLoraButton) {
|
||||
const idx = node.widgets.indexOf(node.addLoraButton);
|
||||
if (idx !== -1) node.widgets.splice(idx, 1);
|
||||
}
|
||||
|
||||
const initialLora = defaultLora || (node.loraList.length > 0 ? node.loraList[0] : "None");
|
||||
|
||||
// 建立 LoRA 選擇器
|
||||
const loraSelector = node.addWidget(
|
||||
"combo",
|
||||
"LoRA Name",
|
||||
initialLora,
|
||||
() => updateSettings(),
|
||||
{ values: node.loraList }
|
||||
);
|
||||
|
||||
// 建立強度數值輸入
|
||||
const strengthInput = node.addWidget(
|
||||
"number",
|
||||
"Strength",
|
||||
defaultStrength,
|
||||
() => updateSettings(),
|
||||
{ min: -10.0, max: 10.0, step: 0.1, precision: 2 }
|
||||
);
|
||||
|
||||
strengthInput.computeSize = () => [0, 30];
|
||||
|
||||
node.dynamicWidgets.push({
|
||||
loraSelector: loraSelector,
|
||||
strengthInput: strengthInput
|
||||
});
|
||||
|
||||
updateSettings();
|
||||
|
||||
// 加回 "+ Add" 按鈕
|
||||
if (node.addLoraButton) {
|
||||
node.widgets.push(node.addLoraButton);
|
||||
}
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 6. 輔助功能:可搜尋的選單
|
||||
// -----------------------------------------------------------
|
||||
|
||||
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 LoRA...";
|
||||
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.addEventListener("input", (e) => {
|
||||
const term = e.target.value.toLowerCase();
|
||||
const entries = menu.root.querySelectorAll(".litemenu-entry");
|
||||
entries.forEach(entry => {
|
||||
const text = entry.innerText.toLowerCase();
|
||||
if (!text) return;
|
||||
if (text.includes(term)) {
|
||||
entry.style.display = "block";
|
||||
} else {
|
||||
entry.style.display = "none";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 阻止事件冒泡 (避免觸發 Canvas 縮放等)
|
||||
searchInput.addEventListener("mouseup", (e) => e.stopPropagation());
|
||||
searchInput.addEventListener("keydown", (e) => e.stopPropagation());
|
||||
|
||||
menu.root.prepend(searchInput);
|
||||
setTimeout(() => searchInput.focus(), 10);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// 7. 啟動與資料載入
|
||||
// -----------------------------------------------------------
|
||||
|
||||
// 建立 "+ Add LoRA" 按鈕
|
||||
node.addLoraButton = this.addWidget(
|
||||
"button",
|
||||
"+ Add LoRA",
|
||||
null,
|
||||
function (value, canvas, node, pos, event) {
|
||||
if (node.loraList.length === 0) {
|
||||
alert("No LoRAs found or list not loaded yet.");
|
||||
return;
|
||||
}
|
||||
// 呼叫搜尋選單
|
||||
createSearchableMenu(
|
||||
event,
|
||||
node.loraList,
|
||||
(selectedLora) => {
|
||||
if(selectedLora) {
|
||||
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);
|
||||
Object.keys(savedData)
|
||||
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||
.forEach(key => {
|
||||
const item = savedData[key];
|
||||
this.addLoraInputs(item.lora_name, item.strength);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error restoring loras:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user