Fix duplicate const declaration in node_sets_menu.js that silently broke ESM loading
Remove duplicate saveSelectedAsNodeSet definition and redundant WorkflowStudio.NodeSetContextMenu registration. Add export keyword to the remaining definition. Remove debug console.log lines from top_menu_extension.js. Also includes Nodes tab UI improvements (pagination, card layout, table row selection). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c3c0a41ccf
commit
a6f45ef6a5
+62
-1
@@ -2290,7 +2290,50 @@ body {
|
||||
}
|
||||
|
||||
.wfm-node-card .wfm-card-body {
|
||||
min-height: 80px;
|
||||
min-height: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-node-card .wfm-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.wfm-node-card .wfm-fav-btn {
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wfm-node-card:hover .wfm-fav-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Node grid: wider cards for readability */
|
||||
#wfm-nodes-grid.wfm-view-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Node table row selection highlight */
|
||||
.wfm-nodes-table-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wfm-nodes-table-row:hover {
|
||||
background: var(--wfm-surface);
|
||||
}
|
||||
|
||||
.wfm-nodes-table-row.wfm-card-selected {
|
||||
background: color-mix(in srgb, var(--wfm-primary) 15%, var(--wfm-bg));
|
||||
outline: 1px solid var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-nodes-table-row .wfm-fav-btn {
|
||||
position: static;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.wfm-node-card-io {
|
||||
@@ -2299,6 +2342,24 @@ body {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.wfm-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-pagination-info {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wfm-card-tags {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
|
||||
@@ -192,8 +192,11 @@ const LANGUAGES = {
|
||||
nodesAddLink: "Add Link",
|
||||
nodesCopyToClipboard: "Copy to Clipboard",
|
||||
nodesCopied: "Node set copied to clipboard",
|
||||
nodesCopyClassName: "Copy class name",
|
||||
nodesCopiedClassName: "Class name copied!",
|
||||
nodesDeleteSet: "Delete",
|
||||
nodesDeleteSetConfirm: "Delete this node set?",
|
||||
nodesSetUpdated: "Node set updated",
|
||||
nodesRefresh: "Refresh",
|
||||
nodesCreateGroup: "Create Group",
|
||||
nodesGroupName: "Group Name",
|
||||
@@ -495,8 +498,11 @@ const LANGUAGES = {
|
||||
nodesAddLink: "接続追加",
|
||||
nodesCopyToClipboard: "クリップボードにコピー",
|
||||
nodesCopied: "ノードセットをクリップボードにコピーしました",
|
||||
nodesCopyClassName: "クラス名をコピー",
|
||||
nodesCopiedClassName: "クラス名をコピーしました!",
|
||||
nodesDeleteSet: "削除",
|
||||
nodesDeleteSetConfirm: "このノードセットを削除しますか?",
|
||||
nodesSetUpdated: "ノードセットを更新しました",
|
||||
nodesRefresh: "更新",
|
||||
nodesCreateGroup: "グループ作成",
|
||||
nodesGroupName: "グループ名",
|
||||
@@ -794,8 +800,11 @@ const LANGUAGES = {
|
||||
nodesAddLink: "添加连接",
|
||||
nodesCopyToClipboard: "复制到剪贴板",
|
||||
nodesCopied: "节点集已复制到剪贴板",
|
||||
nodesCopyClassName: "复制类名",
|
||||
nodesCopiedClassName: "类名已复制!",
|
||||
nodesDeleteSet: "删除",
|
||||
nodesDeleteSetConfirm: "删除此节点集?",
|
||||
nodesSetUpdated: "节点集已更新",
|
||||
nodesRefresh: "刷新",
|
||||
nodesCreateGroup: "创建分组",
|
||||
nodesGroupName: "分组名称",
|
||||
|
||||
+296
-21
@@ -8,6 +8,8 @@ import { t } from "./i18n.js";
|
||||
|
||||
// ── State ─────────────────────────────────────────────────
|
||||
|
||||
const NODES_PER_PAGE = 100;
|
||||
|
||||
const state = {
|
||||
allNodes: [],
|
||||
nodeMetadata: {},
|
||||
@@ -23,6 +25,7 @@ const state = {
|
||||
selectedNode: null,
|
||||
activeSubView: "browser",
|
||||
loaded: false,
|
||||
currentPage: 0,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
@@ -121,6 +124,15 @@ async function createNodeSet(data) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function updateNodeSet(id, updates) {
|
||||
const res = await fetch("/api/wfm/node-sets/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...updates }),
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function deleteNodeSet(id) {
|
||||
await fetch("/api/wfm/node-sets/delete", {
|
||||
method: "POST",
|
||||
@@ -129,11 +141,6 @@ async function deleteNodeSet(id) {
|
||||
});
|
||||
}
|
||||
|
||||
async function exportNodeSet(id) {
|
||||
const res = await fetch(`/api/wfm/node-sets/export?id=${encodeURIComponent(id)}`);
|
||||
return res.ok ? await res.json() : null;
|
||||
}
|
||||
|
||||
// ── Filtering ─────────────────────────────────────────────
|
||||
|
||||
function getAllCategories() {
|
||||
@@ -250,17 +257,26 @@ function renderNodeGrid() {
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
|
||||
removePagination();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderNodeTableView(grid, filtered);
|
||||
removePagination();
|
||||
return;
|
||||
}
|
||||
|
||||
// Card view
|
||||
// Card view with pagination
|
||||
const totalPages = Math.ceil(filtered.length / NODES_PER_PAGE);
|
||||
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
|
||||
if (state.currentPage < 0) state.currentPage = 0;
|
||||
|
||||
const start = state.currentPage * NODES_PER_PAGE;
|
||||
const pageItems = filtered.slice(start, start + NODES_PER_PAGE);
|
||||
|
||||
grid.innerHTML = "";
|
||||
filtered.forEach(node => {
|
||||
pageItems.forEach(node => {
|
||||
const meta = state.nodeMetadata[node.name] || {};
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card wfm-node-card";
|
||||
@@ -295,6 +311,56 @@ function renderNodeGrid() {
|
||||
card.addEventListener("click", () => showNodeSidePanel(node));
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
renderPagination(filtered.length, totalPages);
|
||||
}
|
||||
|
||||
function renderPagination(totalItems, totalPages) {
|
||||
removePagination();
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const grid = document.getElementById("wfm-nodes-grid");
|
||||
if (!grid) return;
|
||||
|
||||
const pager = document.createElement("div");
|
||||
pager.id = "wfm-nodes-pagination";
|
||||
pager.className = "wfm-pagination";
|
||||
|
||||
const prevBtn = document.createElement("button");
|
||||
prevBtn.className = "wfm-btn wfm-btn-sm";
|
||||
prevBtn.textContent = "\u25C0";
|
||||
prevBtn.disabled = state.currentPage === 0;
|
||||
prevBtn.addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
|
||||
const nextBtn = document.createElement("button");
|
||||
nextBtn.className = "wfm-btn wfm-btn-sm";
|
||||
nextBtn.textContent = "\u25B6";
|
||||
nextBtn.disabled = state.currentPage >= totalPages - 1;
|
||||
nextBtn.addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
|
||||
const info = document.createElement("span");
|
||||
info.className = "wfm-pagination-info";
|
||||
const start = state.currentPage * NODES_PER_PAGE + 1;
|
||||
const end = Math.min((state.currentPage + 1) * NODES_PER_PAGE, totalItems);
|
||||
info.textContent = `${start}-${end} / ${totalItems}`;
|
||||
|
||||
pager.appendChild(prevBtn);
|
||||
pager.appendChild(info);
|
||||
pager.appendChild(nextBtn);
|
||||
|
||||
grid.parentElement.insertBefore(pager, grid.nextSibling);
|
||||
}
|
||||
|
||||
function removePagination() {
|
||||
document.getElementById("wfm-nodes-pagination")?.remove();
|
||||
}
|
||||
|
||||
function scrollGridToTop() {
|
||||
document.getElementById("wfm-nodes-grid")?.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function renderNodeTableView(grid, filtered) {
|
||||
@@ -312,6 +378,8 @@ function renderNodeTableView(grid, filtered) {
|
||||
filtered.forEach(node => {
|
||||
const meta = state.nodeMetadata[node.name] || {};
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "wfm-nodes-table-row";
|
||||
tr.dataset.nodeName = node.name;
|
||||
if (state.selectedNode && state.selectedNode.name === node.name) {
|
||||
tr.classList.add("wfm-card-selected");
|
||||
}
|
||||
@@ -386,9 +454,12 @@ function renderSideDetails(node) {
|
||||
if (node.deprecated) flags.push(`<span class="wfm-badge" style="background:var(--wfm-danger)">${t("nodesDeprecated")}</span>`);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("nodesClassName")}</div>
|
||||
<div class="wfm-node-detail-value" style="font-family:monospace;font-size:12px;">${escapeHtml(node.name)}</div>
|
||||
<div class="wfm-node-detail-section" style="display:flex;align-items:center;gap:6px;">
|
||||
<div style="flex:1;">
|
||||
<div class="wfm-node-detail-label">${t("nodesClassName")}</div>
|
||||
<div class="wfm-node-detail-value" style="font-family:monospace;font-size:12px;">${escapeHtml(node.name)}</div>
|
||||
</div>
|
||||
<button id="wfm-node-copy-name-btn" class="wfm-btn wfm-btn-sm" title="${t("nodesCopyClassName")}">📋</button>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("nodesPackage")}</div>
|
||||
@@ -410,6 +481,15 @@ function renderSideDetails(node) {
|
||||
placeholder="${t("nodesEditTags")}">
|
||||
</div>`;
|
||||
|
||||
// Copy class name button
|
||||
const copyNameBtn = document.getElementById("wfm-node-copy-name-btn");
|
||||
if (copyNameBtn) {
|
||||
copyNameBtn.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(node.name);
|
||||
showToast(t("nodesCopiedClassName"), "success");
|
||||
});
|
||||
}
|
||||
|
||||
const tagInput = document.getElementById("wfm-nodes-tag-input");
|
||||
if (tagInput) {
|
||||
const saveTags = async () => {
|
||||
@@ -597,25 +677,18 @@ function renderNodeSets() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-set-actions">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-node-set-copy" title="${t("nodesCopyToClipboard")}">📋 ${t("copy")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-node-set-delete" title="${t("nodesDeleteSet")}">🗑</button>
|
||||
</div>`;
|
||||
|
||||
card.querySelector(".wfm-node-set-copy").addEventListener("click", async () => {
|
||||
const data = await exportNodeSet(set.id);
|
||||
if (data) {
|
||||
await navigator.clipboard.writeText(JSON.stringify(data.prompt, null, 2));
|
||||
showToast(t("nodesCopied"), "success");
|
||||
}
|
||||
});
|
||||
|
||||
card.querySelector(".wfm-node-set-delete").addEventListener("click", async () => {
|
||||
card.querySelector(".wfm-node-set-delete").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(t("nodesDeleteSetConfirm"))) return;
|
||||
await deleteNodeSet(set.id);
|
||||
state.nodeSets = state.nodeSets.filter(s => s.id !== set.id);
|
||||
renderNodeSets();
|
||||
});
|
||||
|
||||
card.addEventListener("click", () => showEditSetModal(set));
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
@@ -646,6 +719,11 @@ function showCreateSetModal() {
|
||||
<div id="wfm-set-nodes-list"></div>
|
||||
<button id="wfm-set-add-node" class="wfm-btn wfm-btn-sm" style="margin-top:4px;">+ ${t("nodesAddNode")}</button>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("nodesSetLinks")}</label>
|
||||
<div id="wfm-set-links-list"></div>
|
||||
<button id="wfm-set-add-link" class="wfm-btn wfm-btn-sm" style="margin-top:4px;">+ ${t("nodesAddLink")}</button>
|
||||
</div>
|
||||
<div style="margin-top:12px;text-align:right;">
|
||||
<button id="wfm-set-save" class="wfm-btn wfm-btn-primary">${t("save")}</button>
|
||||
</div>`;
|
||||
@@ -686,6 +764,33 @@ function showCreateSetModal() {
|
||||
|
||||
document.getElementById("wfm-set-add-node").addEventListener("click", addNodeRow);
|
||||
|
||||
// Link rows
|
||||
const linksList = document.getElementById("wfm-set-links-list");
|
||||
let linkRows = [];
|
||||
|
||||
function addLinkRow() {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex;gap:4px;margin-bottom:4px;align-items:center;font-size:12px;";
|
||||
row.innerHTML = `
|
||||
<span>From #</span>
|
||||
<input type="number" class="wfm-input wfm-set-link-from" style="width:50px;" min="0" value="0">
|
||||
<span>slot</span>
|
||||
<input type="number" class="wfm-input wfm-set-link-from-slot" style="width:50px;" min="0" value="0">
|
||||
<span>→ To #</span>
|
||||
<input type="number" class="wfm-input wfm-set-link-to" style="width:50px;" min="0" value="1">
|
||||
<span>slot</span>
|
||||
<input type="number" class="wfm-input wfm-set-link-to-slot" style="width:50px;" min="0" value="0">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-set-remove-link">×</button>`;
|
||||
row.querySelector(".wfm-set-remove-link").addEventListener("click", () => {
|
||||
linkRows = linkRows.filter(r => r !== row);
|
||||
row.remove();
|
||||
});
|
||||
linksList.appendChild(row);
|
||||
linkRows.push(row);
|
||||
}
|
||||
|
||||
document.getElementById("wfm-set-add-link").addEventListener("click", addLinkRow);
|
||||
|
||||
document.getElementById("wfm-set-save").addEventListener("click", async () => {
|
||||
const name = document.getElementById("wfm-set-name").value.trim();
|
||||
if (!name) return;
|
||||
@@ -704,7 +809,17 @@ function showCreateSetModal() {
|
||||
});
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const result = await createNodeSet({ name, description, tags, nodes, links: [] });
|
||||
// Collect links
|
||||
const links = [];
|
||||
linkRows.forEach(row => {
|
||||
const fromNode = parseInt(row.querySelector(".wfm-set-link-from").value) || 0;
|
||||
const fromSlot = parseInt(row.querySelector(".wfm-set-link-from-slot").value) || 0;
|
||||
const toNode = parseInt(row.querySelector(".wfm-set-link-to").value) || 0;
|
||||
const toSlot = parseInt(row.querySelector(".wfm-set-link-to-slot").value) || 0;
|
||||
links.push({ from_node: fromNode, from_slot: fromSlot, to_node: toNode, to_slot: toSlot });
|
||||
});
|
||||
|
||||
const result = await createNodeSet({ name, description, tags, nodes, links });
|
||||
if (result.nodeSet) {
|
||||
state.nodeSets.push(result.nodeSet);
|
||||
} else {
|
||||
@@ -717,6 +832,160 @@ function showCreateSetModal() {
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function showEditSetModal(set) {
|
||||
const modal = document.getElementById("wfm-modal-overlay");
|
||||
if (!modal) return;
|
||||
|
||||
const allNodeNames = state.allNodes.map(n => n.name).sort();
|
||||
|
||||
modal.querySelector(".wfm-modal-header h2").textContent = t("nodesEditSet");
|
||||
const body = modal.querySelector(".wfm-modal-body");
|
||||
body.innerHTML = `
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("nodesSetName")}</label>
|
||||
<input type="text" id="wfm-set-name" class="wfm-input" style="width:100%;">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("nodesSetDescription")}</label>
|
||||
<textarea id="wfm-set-desc" class="wfm-textarea" style="width:100%;"></textarea>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("nodesEditTags")}</label>
|
||||
<input type="text" id="wfm-set-tags" class="wfm-input" style="width:100%;" placeholder="tag1, tag2">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("nodesSetNodes")} <small style="color:var(--wfm-text-secondary);">(#0, #1, ...)</small></label>
|
||||
<div id="wfm-set-nodes-list"></div>
|
||||
<button id="wfm-set-add-node" class="wfm-btn wfm-btn-sm" style="margin-top:4px;">+ ${t("nodesAddNode")}</button>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("nodesSetLinks")}</label>
|
||||
<div id="wfm-set-links-list"></div>
|
||||
<button id="wfm-set-add-link" class="wfm-btn wfm-btn-sm" style="margin-top:4px;">+ ${t("nodesAddLink")}</button>
|
||||
</div>
|
||||
<div style="margin-top:12px;text-align:right;">
|
||||
<button id="wfm-set-save" class="wfm-btn wfm-btn-primary">${t("save")}</button>
|
||||
</div>`;
|
||||
|
||||
// Fill existing values
|
||||
document.getElementById("wfm-set-name").value = set.name || "";
|
||||
document.getElementById("wfm-set-desc").value = set.description || "";
|
||||
document.getElementById("wfm-set-tags").value = (set.tags || []).join(", ");
|
||||
|
||||
const nodesList = document.getElementById("wfm-set-nodes-list");
|
||||
let nodeRows = [];
|
||||
|
||||
// Datalist for autocomplete
|
||||
if (!document.getElementById("wfm-node-datalist")) {
|
||||
const dl = document.createElement("datalist");
|
||||
dl.id = "wfm-node-datalist";
|
||||
allNodeNames.forEach(n => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = n;
|
||||
dl.appendChild(opt);
|
||||
});
|
||||
document.body.appendChild(dl);
|
||||
}
|
||||
|
||||
function addNodeRow(classType, title, relPos) {
|
||||
const idx = nodeRows.length;
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex;gap:4px;margin-bottom:4px;align-items:center;";
|
||||
row.innerHTML = `
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);min-width:20px;">#${idx}</span>
|
||||
<input type="text" class="wfm-input wfm-set-node-class" style="flex:1;" list="wfm-node-datalist" placeholder="Node class type" value="${escapeHtml(classType || "")}">
|
||||
<input type="text" class="wfm-set-node-title wfm-input" style="width:80px;" placeholder="Title" value="${escapeHtml(title || "")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-set-remove-node">×</button>`;
|
||||
row.querySelector(".wfm-set-remove-node").addEventListener("click", () => {
|
||||
nodeRows = nodeRows.filter((_, i) => i !== idx);
|
||||
row.remove();
|
||||
});
|
||||
nodesList.appendChild(row);
|
||||
nodeRows.push(row);
|
||||
}
|
||||
|
||||
// Populate existing nodes
|
||||
(set.nodes || []).forEach((n, i) => {
|
||||
addNodeRow(n.class_type, n.title, n.rel_pos);
|
||||
});
|
||||
if ((set.nodes || []).length === 0) {
|
||||
addNodeRow("", "", null);
|
||||
}
|
||||
|
||||
document.getElementById("wfm-set-add-node").addEventListener("click", () => addNodeRow("", "", null));
|
||||
|
||||
// Link rows
|
||||
const linksList = document.getElementById("wfm-set-links-list");
|
||||
let linkRows = [];
|
||||
|
||||
function addLinkRow(fromNode, fromSlot, toNode, toSlot) {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex;gap:4px;margin-bottom:4px;align-items:center;font-size:12px;";
|
||||
row.innerHTML = `
|
||||
<span>From #</span>
|
||||
<input type="number" class="wfm-input wfm-set-link-from" style="width:50px;" min="0" value="${fromNode ?? 0}">
|
||||
<span>slot</span>
|
||||
<input type="number" class="wfm-input wfm-set-link-from-slot" style="width:50px;" min="0" value="${fromSlot ?? 0}">
|
||||
<span>→ To #</span>
|
||||
<input type="number" class="wfm-input wfm-set-link-to" style="width:50px;" min="0" value="${toNode ?? 1}">
|
||||
<span>slot</span>
|
||||
<input type="number" class="wfm-input wfm-set-link-to-slot" style="width:50px;" min="0" value="${toSlot ?? 0}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-set-remove-link">×</button>`;
|
||||
row.querySelector(".wfm-set-remove-link").addEventListener("click", () => {
|
||||
linkRows = linkRows.filter(r => r !== row);
|
||||
row.remove();
|
||||
});
|
||||
linksList.appendChild(row);
|
||||
linkRows.push(row);
|
||||
}
|
||||
|
||||
// Populate existing links
|
||||
(set.links || []).forEach(lk => {
|
||||
addLinkRow(lk.from_node, lk.from_slot, lk.to_node, lk.to_slot);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-set-add-link").addEventListener("click", () => addLinkRow(0, 0, 1, 0));
|
||||
|
||||
// Save (update)
|
||||
document.getElementById("wfm-set-save").addEventListener("click", async () => {
|
||||
const name = document.getElementById("wfm-set-name").value.trim();
|
||||
if (!name) return;
|
||||
const description = document.getElementById("wfm-set-desc").value.trim();
|
||||
const tags = document.getElementById("wfm-set-tags").value.split(",").map(s => s.trim()).filter(Boolean);
|
||||
const nodes = [];
|
||||
nodesList.querySelectorAll(".wfm-set-node-class").forEach((input, i) => {
|
||||
const classType = input.value.trim();
|
||||
if (!classType) return;
|
||||
const titleInput = nodesList.querySelectorAll(".wfm-set-node-title")[i];
|
||||
nodes.push({
|
||||
class_type: classType,
|
||||
title: titleInput?.value?.trim() || "",
|
||||
rel_pos: [i * 300, 0],
|
||||
});
|
||||
});
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const links = [];
|
||||
linkRows.forEach(row => {
|
||||
const fromNode = parseInt(row.querySelector(".wfm-set-link-from").value) || 0;
|
||||
const fromSlot = parseInt(row.querySelector(".wfm-set-link-from-slot").value) || 0;
|
||||
const toNode = parseInt(row.querySelector(".wfm-set-link-to").value) || 0;
|
||||
const toSlot = parseInt(row.querySelector(".wfm-set-link-to-slot").value) || 0;
|
||||
links.push({ from_node: fromNode, from_slot: fromSlot, to_node: toNode, to_slot: toSlot });
|
||||
});
|
||||
|
||||
await updateNodeSet(set.id, { name, description, tags, nodes, links });
|
||||
|
||||
// Refresh local state
|
||||
state.nodeSets = await fetchNodeSets();
|
||||
renderNodeSets();
|
||||
modal.style.display = "none";
|
||||
showToast(t("nodesSetUpdated"), "success");
|
||||
});
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
// ── Data Loading ──────────────────────────────────────────
|
||||
|
||||
async function loadNodesData() {
|
||||
@@ -782,6 +1051,7 @@ export function initNodesTab() {
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", () => {
|
||||
state.searchText = searchInput.value;
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
});
|
||||
}
|
||||
@@ -789,18 +1059,22 @@ export function initNodesTab() {
|
||||
// Filters
|
||||
document.getElementById("wfm-nodes-category-filter")?.addEventListener("change", e => {
|
||||
state.categoryFilter = e.target.value;
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
});
|
||||
document.getElementById("wfm-nodes-package-filter")?.addEventListener("change", e => {
|
||||
state.packageFilter = e.target.value;
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
});
|
||||
document.getElementById("wfm-nodes-tag-filter")?.addEventListener("change", e => {
|
||||
state.tagFilter = e.target.value;
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
});
|
||||
document.getElementById("wfm-nodes-group-filter")?.addEventListener("change", e => {
|
||||
state.groupFilter = e.target.value;
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
});
|
||||
|
||||
@@ -808,6 +1082,7 @@ export function initNodesTab() {
|
||||
document.getElementById("wfm-nodes-fav-btn")?.addEventListener("click", e => {
|
||||
state.showFavoritesOnly = !state.showFavoritesOnly;
|
||||
e.currentTarget.classList.toggle("active", state.showFavoritesOnly);
|
||||
state.currentPage = 0;
|
||||
renderNodeGrid();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* Node Library Sidebar Panel
|
||||
*
|
||||
* Fixed sidebar panel injected into ComfyUI DOM.
|
||||
* Three tabs: Favorites (single nodes), Node Sets (multi-node), Groups.
|
||||
* Items are draggable onto the canvas or click-to-place.
|
||||
*/
|
||||
console.log("[WFM] node_sets_menu.js loading...");
|
||||
import { app } from "../../scripts/app.js";
|
||||
console.log("[WFM] node_sets_menu.js: app imported");
|
||||
|
||||
export const NODE_SETS_TOOLTIP = "Node Library – Browse & drag nodes onto canvas";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
const state = {
|
||||
visible: false,
|
||||
activeTab: "favorites", // favorites | sets | groups
|
||||
favorites: [], // [{name, display_name}]
|
||||
nodeSets: [],
|
||||
groups: {}, // {groupName: [nodeName, ...]}
|
||||
metadata: {},
|
||||
searchText: "",
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API
|
||||
// ============================================
|
||||
|
||||
const fetchNodeSets = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/metadata");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/groups");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const [metadata, nodeSets, groups] = await Promise.all([
|
||||
fetchMetadata(), fetchNodeSets(), fetchGroups(),
|
||||
]);
|
||||
state.metadata = metadata;
|
||||
state.nodeSets = nodeSets;
|
||||
state.groups = groups;
|
||||
|
||||
// Extract favorites from metadata
|
||||
state.favorites = [];
|
||||
for (const [name, meta] of Object.entries(metadata)) {
|
||||
if (name === "_groups") continue;
|
||||
if (meta.favorite) {
|
||||
state.favorites.push({ name, display_name: name });
|
||||
}
|
||||
}
|
||||
state.loaded = true;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Canvas placement helpers
|
||||
// ============================================
|
||||
|
||||
const getCanvasDropPos = (e) => {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return [100, 100];
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0];
|
||||
const y = (e.clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1];
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
const placeSingleNode = (classType, pos) => {
|
||||
const graph = app.graph;
|
||||
if (!graph) return;
|
||||
const node = LiteGraph.createNode(classType);
|
||||
if (!node) {
|
||||
showToast(`Unknown node: ${classType}`, "error");
|
||||
return;
|
||||
}
|
||||
node.pos = pos || [100, 100];
|
||||
graph.add(node);
|
||||
app.canvas.setDirty(true, true);
|
||||
showToast(`Placed: ${node.title || classType}`, "success");
|
||||
};
|
||||
|
||||
const placeNodeSet = (nodeSet, pos) => {
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!graph || !canvas) return;
|
||||
|
||||
const nodes = nodeSet.nodes || [];
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const baseX = pos ? pos[0] : (-canvas.ds.offset[0] + canvas.canvas.width / 2 / canvas.ds.scale);
|
||||
const baseY = pos ? pos[1] : (-canvas.ds.offset[1] + canvas.canvas.height / 2 / canvas.ds.scale);
|
||||
|
||||
const positions = nodes.map(n => n.rel_pos || [0, 0]);
|
||||
const minX = Math.min(...positions.map(p => p[0]));
|
||||
const minY = Math.min(...positions.map(p => p[1]));
|
||||
|
||||
const createdNodes = [];
|
||||
for (const nodeDef of nodes) {
|
||||
if (!nodeDef.class_type) { createdNodes.push(null); continue; }
|
||||
const node = LiteGraph.createNode(nodeDef.class_type);
|
||||
if (!node) { createdNodes.push(null); continue; }
|
||||
const rp = nodeDef.rel_pos || [0, 0];
|
||||
node.pos = [baseX + rp[0] - minX, baseY + rp[1] - minY];
|
||||
if (nodeDef.title) node.title = nodeDef.title;
|
||||
if (nodeDef.widget_values && node.widgets) {
|
||||
for (const [k, v] of Object.entries(nodeDef.widget_values)) {
|
||||
const w = node.widgets.find(ww => ww.name === k);
|
||||
if (w) w.value = v;
|
||||
}
|
||||
}
|
||||
graph.add(node);
|
||||
createdNodes.push(node);
|
||||
}
|
||||
|
||||
for (const link of (nodeSet.links || [])) {
|
||||
const from = createdNodes[link.from_node];
|
||||
const to = createdNodes[link.to_node];
|
||||
if (from && to) {
|
||||
try { from.connect(link.from_slot ?? 0, to, link.to_slot ?? 0); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.setDirty(true, true);
|
||||
showToast(`Placed "${nodeSet.name}" (${createdNodes.filter(Boolean).length} nodes)`, "success");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Drag & Drop onto canvas
|
||||
// ============================================
|
||||
|
||||
let dropHandlerInstalled = false;
|
||||
|
||||
const installCanvasDropHandler = () => {
|
||||
if (dropHandlerInstalled) return;
|
||||
dropHandlerInstalled = true;
|
||||
|
||||
const canvasEl = document.getElementById("graph-canvas");
|
||||
if (!canvasEl) return;
|
||||
|
||||
canvasEl.addEventListener("dragover", (e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-wfm-node")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
});
|
||||
|
||||
canvasEl.addEventListener("drop", (e) => {
|
||||
const raw = e.dataTransfer.getData("application/x-wfm-node");
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
const pos = getCanvasDropPos(e);
|
||||
|
||||
if (data.type === "single") {
|
||||
placeSingleNode(data.classType, pos);
|
||||
} else if (data.type === "set") {
|
||||
const set = state.nodeSets.find(s => s.id === data.setId);
|
||||
if (set) placeNodeSet(set, pos);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Sidebar Panel DOM
|
||||
// ============================================
|
||||
|
||||
let panelEl = null;
|
||||
|
||||
const PANEL_ID = "wfm-node-library-panel";
|
||||
|
||||
const createPanel = () => {
|
||||
if (panelEl) return panelEl;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.id = PANEL_ID;
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-nlp-header">
|
||||
<span class="wfm-nlp-title">Node Library</span>
|
||||
<button class="wfm-nlp-refresh" title="Refresh">↻</button>
|
||||
<button class="wfm-nlp-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-tabs">
|
||||
<button class="wfm-nlp-tab active" data-tab="favorites">★ Favorites</button>
|
||||
<button class="wfm-nlp-tab" data-tab="sets">☰ Sets</button>
|
||||
<button class="wfm-nlp-tab" data-tab="groups">📁 Groups</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-search">
|
||||
<input type="text" class="wfm-nlp-search-input" placeholder="Search...">
|
||||
</div>
|
||||
<div class="wfm-nlp-content"></div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(panel);
|
||||
panelEl = panel;
|
||||
|
||||
// Tab switching
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.activeTab = btn.dataset.tab;
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
// Close
|
||||
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
|
||||
|
||||
// Search
|
||||
panel.querySelector(".wfm-nlp-search-input").addEventListener("input", (e) => {
|
||||
state.searchText = e.target.value.toLowerCase().trim();
|
||||
renderContent();
|
||||
});
|
||||
|
||||
// Refresh
|
||||
panel.querySelector(".wfm-nlp-refresh").addEventListener("click", async () => {
|
||||
await loadData();
|
||||
renderContent();
|
||||
showToast("Node Library refreshed", "success");
|
||||
});
|
||||
|
||||
injectStyles();
|
||||
return panel;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render content
|
||||
// ============================================
|
||||
|
||||
const renderContent = () => {
|
||||
const content = panelEl?.querySelector(".wfm-nlp-content");
|
||||
if (!content) return;
|
||||
|
||||
if (!state.loaded) {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state.activeTab) {
|
||||
case "favorites": renderFavorites(content); break;
|
||||
case "sets": renderSets(content); break;
|
||||
case "groups": renderGroups(content); break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFavorites = (container) => {
|
||||
let items = state.favorites;
|
||||
if (state.searchText) {
|
||||
items = items.filter(n => n.name.toLowerCase().includes(state.searchText));
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.favorites.length === 0 ? "No favorites yet.<br><small>Star nodes in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const node of items) {
|
||||
const el = createDraggableItem(node.display_name || node.name, "single", { classType: node.name });
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeSingleNode(node.name);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSets = (container) => {
|
||||
let items = state.nodeSets;
|
||||
if (state.searchText) {
|
||||
items = items.filter(s =>
|
||||
s.name.toLowerCase().includes(state.searchText) ||
|
||||
(s.tags || []).some(t => t.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.nodeSets.length === 0 ? "No node sets.<br><small>Create sets in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const set of items) {
|
||||
const count = (set.nodes || []).length;
|
||||
const el = createDraggableItem(
|
||||
`${esc(set.name)} <span class="wfm-nlp-badge">${count} nodes</span>`,
|
||||
"set",
|
||||
{ setId: set.id },
|
||||
set.description
|
||||
);
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeNodeSet(set);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroups = (container) => {
|
||||
const groupNames = Object.keys(state.groups).sort();
|
||||
|
||||
if (groupNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
No groups.<br><small>Create groups in Workflow Studio → Nodes tab</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let filtered = groupNames;
|
||||
if (state.searchText) {
|
||||
filtered = groupNames.filter(g =>
|
||||
g.toLowerCase().includes(state.searchText) ||
|
||||
(state.groups[g] || []).some(n => n.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const groupName of filtered) {
|
||||
const nodes = state.groups[groupName] || [];
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "wfm-nlp-group-section";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-nlp-group-header";
|
||||
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${nodes.length}</span>`;
|
||||
header.addEventListener("click", () => {
|
||||
const list = section.querySelector(".wfm-nlp-group-list");
|
||||
list.style.display = list.style.display === "none" ? "block" : "none";
|
||||
header.classList.toggle("collapsed");
|
||||
});
|
||||
section.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "wfm-nlp-group-list";
|
||||
for (const nodeName of nodes) {
|
||||
const el = createDraggableItem(nodeName, "single", { classType: nodeName });
|
||||
el.addEventListener("dblclick", () => placeSingleNode(nodeName));
|
||||
list.appendChild(el);
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Draggable item factory
|
||||
// ============================================
|
||||
|
||||
const createDraggableItem = (label, type, data, subtitle) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-nlp-item";
|
||||
el.draggable = true;
|
||||
el.innerHTML = `
|
||||
<div class="wfm-nlp-item-label">${label}</div>
|
||||
${subtitle ? `<div class="wfm-nlp-item-sub">${esc(subtitle)}</div>` : ""}
|
||||
`;
|
||||
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/x-wfm-node", JSON.stringify({ type, ...data }));
|
||||
el.classList.add("dragging");
|
||||
});
|
||||
|
||||
el.addEventListener("dragend", () => {
|
||||
el.classList.remove("dragging");
|
||||
});
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Toggle & exports
|
||||
// ============================================
|
||||
|
||||
export const togglePanel = async () => {
|
||||
const panel = createPanel();
|
||||
state.visible = !state.visible;
|
||||
panel.style.display = state.visible ? "flex" : "none";
|
||||
|
||||
if (state.visible && !state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
|
||||
installCanvasDropHandler();
|
||||
};
|
||||
|
||||
export const getNodeSetsIcon = () => `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<path d="M17.5 14v7M14 17.5h7"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// ============================================
|
||||
// Toast
|
||||
// ============================================
|
||||
|
||||
const showToast = (message, type = "info") => {
|
||||
const existing = document.getElementById("wfm-ns-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "wfm-ns-toast";
|
||||
const colors = {
|
||||
success: { bg: "rgba(46,213,115,0.95)" },
|
||||
error: { bg: "rgba(255,71,87,0.95)" },
|
||||
info: { bg: "rgba(74,158,255,0.95)" },
|
||||
};
|
||||
Object.assign(toast.style, {
|
||||
position: "fixed", bottom: "24px", left: "50%", transform: "translateX(-50%)",
|
||||
padding: "10px 24px", background: (colors[type] || colors.info).bg, color: "#fff",
|
||||
borderRadius: "8px", fontSize: "14px", fontWeight: "500", zIndex: "99999",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)", transition: "opacity 0.3s", whiteSpace: "nowrap",
|
||||
});
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000);
|
||||
};
|
||||
|
||||
const esc = (s) => s ? String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """) : "";
|
||||
|
||||
// ============================================
|
||||
// Save selected nodes as Node Set (context menu)
|
||||
// ============================================
|
||||
|
||||
export const saveSelectedAsNodeSet = async () => {
|
||||
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
|
||||
if (selectedNodes.length === 0) {
|
||||
showToast("Select at least one node first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt for name
|
||||
const name = prompt("Node Set name:");
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
// Build node list with relative positions
|
||||
// Use the top-left of the bounding box as origin
|
||||
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
|
||||
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
|
||||
|
||||
const nodeIdToIndex = new Map();
|
||||
const nodes = [];
|
||||
selectedNodes.forEach((node, i) => {
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
nodes.push({
|
||||
class_type: node.type || node.comfyClass || "",
|
||||
title: node.title || "",
|
||||
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
|
||||
});
|
||||
});
|
||||
|
||||
// Extract links between selected nodes
|
||||
const links = [];
|
||||
const graph = app.graph;
|
||||
|
||||
for (const node of selectedNodes) {
|
||||
if (!node.inputs) continue;
|
||||
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (input.link == null) continue;
|
||||
|
||||
// Resolve link data
|
||||
let linkData = null;
|
||||
if (graph.links instanceof Map) {
|
||||
linkData = graph.links.get(input.link);
|
||||
} else if (graph.links) {
|
||||
linkData = graph.links[input.link];
|
||||
}
|
||||
if (!linkData) continue;
|
||||
|
||||
// linkData: [id, origin_id, origin_slot, target_id, target_slot, type]
|
||||
// or object with .origin_id, .origin_slot, etc.
|
||||
const originId = linkData.origin_id ?? linkData[1];
|
||||
const originSlot = linkData.origin_slot ?? linkData[2];
|
||||
const targetSlot = linkData.target_slot ?? linkData[4];
|
||||
|
||||
// Only include if both source and target are in the selection
|
||||
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
|
||||
links.push({
|
||||
from_node: nodeIdToIndex.get(originId),
|
||||
from_slot: originSlot,
|
||||
to_node: nodeIdToIndex.get(node.id),
|
||||
to_slot: targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save via API
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
|
||||
// Refresh sidebar if open
|
||||
if (state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
} else {
|
||||
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to save: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ============================================
|
||||
// Styles
|
||||
// ============================================
|
||||
|
||||
const injectStyles = () => {
|
||||
if (document.getElementById("wfm-nlp-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wfm-nlp-styles";
|
||||
style.textContent = `
|
||||
#${PANEL_ID} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: var(--comfy-menu-bg, #1e1e1e);
|
||||
border-left: 1px solid var(--border-color, #4e4e4e);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--input-text, #ddd);
|
||||
box-shadow: -4px 0 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.wfm-nlp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.wfm-nlp-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wfm-nlp-close:hover { opacity: 1; }
|
||||
.wfm-nlp-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wfm-nlp-tab:hover { opacity: 0.9; }
|
||||
.wfm-nlp-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-search {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--comfy-input-bg, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #4e4e4e);
|
||||
border-radius: 4px;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wfm-nlp-search-input:focus {
|
||||
border-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.wfm-nlp-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--descrip-text, #999);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wfm-nlp-item {
|
||||
padding: 7px 12px;
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
transition: background 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
.wfm-nlp-item:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item-label {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wfm-nlp-item-sub {
|
||||
font-size: 10px;
|
||||
color: var(--descrip-text, #888);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wfm-nlp-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: var(--comfy-input-bg, #444);
|
||||
border-radius: 3px;
|
||||
color: var(--descrip-text, #aaa);
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.wfm-nlp-group-section {
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
}
|
||||
.wfm-nlp-group-header {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.wfm-nlp-group-header:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-group-header::before {
|
||||
content: "\\25BC";
|
||||
font-size: 8px;
|
||||
margin-right: 6px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.wfm-nlp-group-header.collapsed::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.wfm-nlp-group-list {
|
||||
padding-left: 8px;
|
||||
}
|
||||
.wfm-nlp-group-list .wfm-nlp-item {
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wfm-nlp-refresh {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wfm-nlp-refresh:hover { opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
+154
-101
@@ -1,4 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
|
||||
|
||||
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
|
||||
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
|
||||
@@ -383,15 +384,47 @@ const createSnapshotButton = async () => {
|
||||
return button;
|
||||
};
|
||||
|
||||
const createNodeSetsButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "node-sets",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
|
||||
button.element.title = NODE_SETS_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getNodeSetsIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const attachTopMenuButton = async (attempt = 0) => {
|
||||
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try settingsGroup first (legacy ComfyUI)
|
||||
const settingsGroup = app.menu?.settingsGroup;
|
||||
if (!settingsGroup?.element?.parentElement) {
|
||||
// Fallback: find the legacy topbar container used by newer ComfyUI
|
||||
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
|
||||
|
||||
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
|
||||
if (attempt >= MAX_ATTACH_ATTEMPTS) {
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI settings button group.");
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,109 +434,129 @@ const attachTopMenuButton = async (attempt = 0) => {
|
||||
|
||||
const wfmButton = await createTopMenuButton();
|
||||
const snapshotButton = await createSnapshotButton();
|
||||
const nodeSetsButton = await createNodeSetsButton();
|
||||
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
|
||||
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton);
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
|
||||
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
|
||||
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
};
|
||||
|
||||
const createExtensionObject = (useActionBar) => {
|
||||
const extensionObj = {
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
async setup() {
|
||||
if (!useActionBar) {
|
||||
console.log("Workflow Studio: using legacy button attachment (frontend version < 1.33.9)");
|
||||
await attachTopMenuButton();
|
||||
} else {
|
||||
console.log("Workflow Studio: using actionBarButtons API (frontend version >= 1.33.9)");
|
||||
}
|
||||
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
// WFM launch button
|
||||
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
|
||||
wfmButtons.forEach((button) => {
|
||||
button.classList.add("wfm-top-menu-button");
|
||||
button.innerHTML = getWfmIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
button.style.backgroundColor = "var(--primary-bg)";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
// Snapshot button
|
||||
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
|
||||
snapButtons.forEach((button) => {
|
||||
button.classList.add("wfm-snapshot-button");
|
||||
button.innerHTML = getSnapshotIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
if (wfmButtons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
};
|
||||
|
||||
if (useActionBar) {
|
||||
extensionObj.actionBarButtons = [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
];
|
||||
if (settingsGroup?.element?.parentElement) {
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
} else if (legacyContainer) {
|
||||
legacyContainer.prepend(buttonGroup.element);
|
||||
}
|
||||
|
||||
return extensionObj;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const useActionBar = await supportsActionBarButtons();
|
||||
const extensionObj = createExtensionObject(useActionBar);
|
||||
app.registerExtension(extensionObj);
|
||||
})();
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--view-grid-plus] size-4",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
onClick: () => togglePanel(),
|
||||
},
|
||||
],
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
async setup() {
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
|
||||
wfmButtons.forEach((button) => {
|
||||
button.classList.add("wfm-top-menu-button");
|
||||
button.innerHTML = getWfmIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
button.style.backgroundColor = "var(--primary-bg)";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
|
||||
snapButtons.forEach((button) => {
|
||||
button.classList.add("wfm-snapshot-button");
|
||||
button.innerHTML = getSnapshotIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const nsButtons = document.querySelectorAll(`button[aria-label="${NODE_SETS_TOOLTIP}"]`);
|
||||
nsButtons.forEach((button) => {
|
||||
button.classList.add("wfm-node-sets-btn");
|
||||
button.innerHTML = getNodeSetsIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
if (wfmButtons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user