* refactor(sidebar): move Create-directory to right-click on Workflows header The 📂 (pi-folder-open) button used to live in the Workflows action bar between the section label and the Save buttons. Tree-structure operations (create / rename / delete a folder) already live on right-click of the row representing the structure being mutated — but top-level dir creation was the lone outlier in the toolbar. Move it to right-click on the "Workflows" header row so the action bar contains only save actions (Save canvas, Save selection) and every tree-structure operation lives where the others do. Implemented via a new optional `rootContextMenu` field on section descriptors plumbed through `buildFolder`; only the Workflows section opts in. Fresh-install path is unchanged — the Save modal's "+ New directory…" entry still creates the first directory when the workflow store is empty. Closes #104. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sidebar): Insert toast surfaces pack misses + subgraph misses when both present `insertWorkflowOntoCanvas`'s pre-flight error path had two failure modes behind one "missing type" classifier: pack-misses (registered_node_types has no entry → install the pack) and subgraph-misses (UUID-typed wrapper whose definition wasn't registered → native Load registers as a side effect). The "all-misses-are-subgraph" early-return only fired when EVERY missing type was a UUID; mixed cases fell through to a single "install the pack(s)" message that listed UUIDs alongside pack types but silently dropped any mention of subgraph registration. User experience: install pack, retry Insert, hit a second unexplained "still missing" failure on the unregistered subgraph. Branch the toast three ways — pure-pack / pure-subgraph / mixed — and make the mixed case name both fixes in one shot ("install pack(s) + right-click → Load once after install to register the subgraph"). The pure-subgraph message also tightened to mention the right-click → Load gesture explicitly, since the previous "Load the workflow once" was ambiguous between left-click (which Inserts on modules) and right-click. Console.warn now logs `{ packMisses, subgraphMisses }` separately for diagnostics rather than the lumped `missingTypes` array. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sidebar): de-jargon Insert toast — talk in gestures, not "subgraph definition not registered" User feedback: "I got that message. I didn't understand why I can't insert it with a left click." The previous wording — "subgraph definition not registered" — was technically accurate but unhelpful. A ComfyUI user wants to know what to *do*, not what's broken under the hood. Toasts now talk in gestures and outcomes: - Pure-subgraph: "Can't Insert "X" yet — it needs to be Loaded once first this session. Right-click → Load it, then left-click Insert will work." - Pure-pack: "Can't Insert "X" — missing node(s): A, B. Install the required pack(s) first, then retry." - Mixed: "Can't Insert "X" yet — two steps: (1) install missing pack(s) for A, B, then (2) right-click → Load it once. After that, left-click Insert will work." The "Load" verb mirrors the actual menu-item label in `workflowRowContextMenu` so the user doesn't hunt for a "paste" or "set up" item that doesn't exist. Engineer-speak (subgraph / registered / definition) stays in code comments + console.warn for devs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sidebar): tighten Insert toast wording — drop the "why", keep the gesture Per maintainer feedback ("Isn't this version a shorter version, better?"), drop the "it needs to be Loaded once first this session" explanatory clause from the pure-subgraph case and the "two steps:" preamble from the mixed case. The user wants to know what to *do*, not why it's broken. If the registration wears off on a fresh session the toast just re-fires and the same gesture solves it again — no need to pre-warn. New wording: - Pure-subgraph: "Can't Insert "X" yet — Right-click → Load it once, then left-click Insert will work." - Pure-pack: "Can't Insert "X" — missing node(s): A, B. Install the pack(s) first, then retry." - Mixed: "Can't Insert "X" yet — (1) install pack(s) for A, B, (2) Right-click → Load it once. Then left-click Insert will work." The "left-click Insert will work" outcome confirmation stays — the user just tried that gesture and got an error, so reassuring them it WILL work after the fix closes the loop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
725 lines
31 KiB
JavaScript
725 lines
31 KiB
JavaScript
// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
|
||
// SPDX-License-Identifier: GPL-3.0-only
|
||
|
||
// =============================================================================
|
||
// Canvas IO — anything that touches `app.graph` or `app.canvas` directly.
|
||
// Selection serialization prunes cross-boundary links so a saved selection
|
||
// reloads cleanly. `loadWorkflowOntoCanvas` snapshots the prior canvas and
|
||
// restores it if the load throws partway, so a corrupt workflow can't strand
|
||
// the user with a half-loaded graph.
|
||
// =============================================================================
|
||
import { app } from "../../../../scripts/app.js";
|
||
|
||
import { toast } from "./constants.js";
|
||
import { getWorkflowGraph } from "./workflows_store.js";
|
||
import { showConfirmModal } from "./modals.js";
|
||
|
||
export function serializeFullCanvas() {
|
||
try {
|
||
return app.graph.serialize();
|
||
} catch (e) {
|
||
console.warn("[Koolook] graph.serialize() failed:", e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const SUBGRAPH_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
|
||
function collectReferencedSubgraphIds(rootIds, allDefs) {
|
||
const visited = new Set();
|
||
const defsById = new Map(
|
||
allDefs
|
||
.filter(d => d && d.id)
|
||
.map(d => [d.id, d])
|
||
);
|
||
const work = [...rootIds];
|
||
while (work.length > 0) {
|
||
const id = work.pop();
|
||
if (!id || visited.has(id)) continue;
|
||
visited.add(id);
|
||
const def = defsById.get(id);
|
||
if (!def) continue;
|
||
const interiorNodes = Array.isArray(def.nodes) ? def.nodes : [];
|
||
for (const n of interiorNodes) {
|
||
if (n && typeof n.type === "string" && defsById.has(n.type)) {
|
||
work.push(n.type);
|
||
} else if (n && typeof n.type === "string" && SUBGRAPH_UUID_RE.test(n.type)) {
|
||
console.warn("[Koolook] subgraph save: unresolved transitive ref", n.type);
|
||
}
|
||
}
|
||
}
|
||
return visited;
|
||
}
|
||
|
||
function collectDefinitionsForNodes(nodes, fullGraph) {
|
||
const liveDefs = (fullGraph && fullGraph.definitions && Array.isArray(fullGraph.definitions.subgraphs))
|
||
? fullGraph.definitions.subgraphs
|
||
: [];
|
||
if (liveDefs.length === 0) return null;
|
||
const liveIds = new Set(liveDefs.map(d => d && d.id).filter(Boolean));
|
||
const rootIds = nodes
|
||
.map(n => n && n.type)
|
||
.filter(t => typeof t === "string" && liveIds.has(t));
|
||
if (rootIds.length === 0) return null;
|
||
const required = collectReferencedSubgraphIds(rootIds, liveDefs);
|
||
const subgraphs = liveDefs
|
||
.filter(d => d && required.has(d.id))
|
||
.map(d => JSON.parse(JSON.stringify(d)));
|
||
return { subgraphs };
|
||
}
|
||
|
||
function getSelectedNodeIds() {
|
||
try {
|
||
const sel = (app.canvas && app.canvas.selected_nodes) || {};
|
||
return new Set(
|
||
Object.values(sel)
|
||
.filter(n => n && n.id != null)
|
||
.map(n => n.id)
|
||
);
|
||
} catch (e) {
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
function idKey(id) {
|
||
return id == null ? null : String(id);
|
||
}
|
||
|
||
function graphLinksAsList(links) {
|
||
if (Array.isArray(links)) return links;
|
||
if (links && typeof links === "object") return Object.values(links);
|
||
return [];
|
||
}
|
||
|
||
function getLinkId(link) {
|
||
if (Array.isArray(link)) return link[0];
|
||
if (link && typeof link === "object") return link.id;
|
||
return null;
|
||
}
|
||
|
||
function getLinkOriginId(link) {
|
||
if (Array.isArray(link)) return link[1];
|
||
if (link && typeof link === "object") return link.origin_id ?? link.originId;
|
||
return null;
|
||
}
|
||
|
||
function getLinkOriginSlot(link) {
|
||
if (Array.isArray(link)) return link[2];
|
||
if (link && typeof link === "object") return link.origin_slot ?? link.originSlot;
|
||
return null;
|
||
}
|
||
|
||
function getLinkTargetId(link) {
|
||
if (Array.isArray(link)) return link[3];
|
||
if (link && typeof link === "object") return link.target_id ?? link.targetId;
|
||
return null;
|
||
}
|
||
|
||
function getLinkTargetSlot(link) {
|
||
if (Array.isArray(link)) return link[4];
|
||
if (link && typeof link === "object") return link.target_slot ?? link.targetSlot;
|
||
return null;
|
||
}
|
||
|
||
function cloneNodeWithoutLinkState(node) {
|
||
const clone = JSON.parse(JSON.stringify(node));
|
||
if (Array.isArray(clone.inputs)) {
|
||
for (const inp of clone.inputs) {
|
||
if (inp) inp.link = null;
|
||
}
|
||
}
|
||
if (Array.isArray(clone.outputs)) {
|
||
for (const out of clone.outputs) {
|
||
if (out) out.links = null;
|
||
}
|
||
}
|
||
return clone;
|
||
}
|
||
|
||
// Returns a discriminated result so callers can distinguish "user has not
|
||
// selected anything yet" from "user has a selection but every node in it is
|
||
// gone" — those produce different toasts in the UI.
|
||
//
|
||
// Shapes:
|
||
// { kind: "empty" } — `selectedIds.size === 0` OR `serializeFullCanvas`
|
||
// failed (canvas not ready / threw); caller should
|
||
// prompt the user to select something.
|
||
// { kind: "stale" } — `selectedIds.size > 0` but none of the IDs match
|
||
// a live node on the graph (selection points at
|
||
// deleted nodes — common after undo/redo). Caller
|
||
// should prompt the user to re-select live nodes.
|
||
// { kind: "ok", graph } — success.
|
||
export function serializeSelection() {
|
||
const selectedIds = getSelectedNodeIds();
|
||
if (selectedIds.size === 0) return { kind: "empty" };
|
||
const selectedKeys = new Set([...selectedIds].map(idKey));
|
||
const full = serializeFullCanvas();
|
||
if (!full) return { kind: "empty" };
|
||
|
||
const internalLinks = graphLinksAsList(full.links).filter(link =>
|
||
selectedKeys.has(idKey(getLinkOriginId(link))) &&
|
||
selectedKeys.has(idKey(getLinkTargetId(link)))
|
||
);
|
||
const internalLinkIds = new Set(internalLinks.map(l => idKey(getLinkId(l))));
|
||
|
||
const nodes = (full.nodes || [])
|
||
.filter(n => selectedKeys.has(idKey(n.id)))
|
||
.map(n => {
|
||
const clone = JSON.parse(JSON.stringify(n));
|
||
if (Array.isArray(clone.inputs)) {
|
||
for (const inp of clone.inputs) {
|
||
if (inp && inp.link != null && !internalLinkIds.has(idKey(inp.link))) {
|
||
inp.link = null;
|
||
}
|
||
}
|
||
}
|
||
if (Array.isArray(clone.outputs)) {
|
||
for (const out of clone.outputs) {
|
||
if (out && Array.isArray(out.links)) {
|
||
out.links = out.links.filter(l => internalLinkIds.has(idKey(l)));
|
||
if (out.links.length === 0) out.links = null;
|
||
}
|
||
}
|
||
}
|
||
return clone;
|
||
});
|
||
|
||
if (nodes.length === 0) return { kind: "stale" };
|
||
const definitions = collectDefinitionsForNodes(nodes, full);
|
||
|
||
return {
|
||
kind: "ok",
|
||
graph: {
|
||
last_node_id: full.last_node_id,
|
||
last_link_id: full.last_link_id,
|
||
nodes,
|
||
links: internalLinks,
|
||
groups: [],
|
||
...(definitions ? { definitions } : {}),
|
||
config: full.config || {},
|
||
extra: full.extra || {},
|
||
version: full.version || 0.4,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function canvasIsNonEmpty() {
|
||
try {
|
||
return app.graph && app.graph._nodes && app.graph._nodes.length > 0;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export async function loadWorkflowOntoCanvas(dirPath, wfName) {
|
||
const graph = getWorkflowGraph(dirPath, wfName);
|
||
if (!graph) {
|
||
toast(`Workflow not found: ${wfName}`);
|
||
return;
|
||
}
|
||
const apply = async () => {
|
||
// Snapshot the current canvas before loading. If loadGraphData throws
|
||
// partway (missing node type, malformed payload), we restore the
|
||
// user's prior canvas instead of leaving them with neither workflow.
|
||
let previousGraph = null;
|
||
try {
|
||
if (canvasIsNonEmpty()) previousGraph = app.graph.serialize();
|
||
} catch (e) {
|
||
previousGraph = null;
|
||
}
|
||
try {
|
||
// Passing `wfName` as the 4th arg makes loadGraphData create a
|
||
// temporary workflow tab titled with that name (instead of
|
||
// "Unsaved Workflow (N)"). Frontend reference: ComfyUI_frontend
|
||
// src/scripts/app.ts (loadGraphData) →
|
||
// workflowService.afterLoadNewGraph → createNewTemporary, which
|
||
// builds path `workflows/<wfName>.json` and binds it to the tab.
|
||
await app.loadGraphData(graph, true, true, wfName, {});
|
||
|
||
// Defensive fallback for frontends that didn't honor the 4th arg.
|
||
try {
|
||
const wf = app.extensionManager?.workflow?.activeWorkflow;
|
||
if (wf && wf.isTemporary && typeof wf.rename === "function" && wf.filename !== wfName) {
|
||
await wf.rename(`workflows/${wfName}.json`);
|
||
}
|
||
} catch (e) {
|
||
console.warn("[Koolook] workflow rename fallback failed:", e);
|
||
}
|
||
|
||
toast(`Loaded "${wfName}".`);
|
||
} catch (e) {
|
||
console.error("[Koolook] loadGraphData failed:", e);
|
||
if (previousGraph) {
|
||
try {
|
||
await app.loadGraphData(previousGraph);
|
||
toast(`Failed to load "${wfName}". Canvas restored. See console.`);
|
||
return;
|
||
} catch (restoreErr) {
|
||
console.error("[Koolook] canvas restore failed:", restoreErr);
|
||
toast(`Failed to load "${wfName}". Canvas may be partial. See console.`);
|
||
return;
|
||
}
|
||
}
|
||
toast(`Failed to load "${wfName}". See console.`);
|
||
}
|
||
};
|
||
if (canvasIsNonEmpty()) {
|
||
showConfirmModal({
|
||
title: "Replace current workflow?",
|
||
message: `Loading "${wfName}" will replace what's currently on the canvas. This cannot be undone with Ctrl+Z in some cases.`,
|
||
confirmLabel: "Load anyway",
|
||
danger: true,
|
||
onConfirm: apply,
|
||
});
|
||
} else {
|
||
apply();
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Placeholder workflow for "Install missing via Manager UI" handoff
|
||
// =============================================================================
|
||
|
||
// Build a fresh workflow that contains one node per missing pack, using a
|
||
// representative node ID from each pack as the node `type`. The pack isn't
|
||
// installed, so ComfyUI renders these as red "missing node type" placeholders
|
||
// — which is exactly what triggers Manager's built-in "Install Missing Custom
|
||
// Nodes" detection (Manager scans the active graph for unknown types and
|
||
// looks them up in its mapping). The user then drives Manager's own UI to
|
||
// install, which goes through Manager's UI-driven install path rather than
|
||
// our programmatic /customnode/install/git_url call — so it works at
|
||
// `security_level=normal` without us monkey-patching anyone's gate.
|
||
//
|
||
// Layout: 4-column grid, 220×100 px cells, starting at (50, 50). Each pack
|
||
// becomes one node, captioned by its representative node ID. Loaded into a
|
||
// fresh tab named "missing-nodes-for-picks" so the user can close it without
|
||
// touching their real workflow.
|
||
//
|
||
// `packsByUrl` is the same Map shape `resolvePicksToInstall().willInstall`
|
||
// returns: gitUrl → array of node IDs from that pack that are missing. We
|
||
// take the first node ID per pack as the placeholder's type. One per pack
|
||
// is enough — Manager dedupes by pack, so dropping 27 nodes from one pack
|
||
// just slows the canvas without telling Manager anything new.
|
||
//
|
||
// Confirms canvas replacement before loading (preserves the user's current
|
||
// graph as a rollback target if `loadGraphData` throws). Returns a
|
||
// discriminated result so the caller can decide how to surface failures.
|
||
export async function dropPlaceholdersForPacks(packsByUrl) {
|
||
if (!packsByUrl || packsByUrl.size === 0) {
|
||
return { ok: false, reason: "no-packs" };
|
||
}
|
||
|
||
const cellW = 220, cellH = 100, cols = 4, marginX = 50, marginY = 50;
|
||
const nodes = [];
|
||
let nextId = 1;
|
||
let idx = 0;
|
||
for (const nodeIds of packsByUrl.values()) {
|
||
if (!Array.isArray(nodeIds) || nodeIds.length === 0) continue;
|
||
const repName = nodeIds[0];
|
||
const col = idx % cols;
|
||
const row = Math.floor(idx / cols);
|
||
nodes.push({
|
||
id: nextId++,
|
||
type: repName,
|
||
pos: [marginX + col * cellW, marginY + row * cellH],
|
||
size: [cellW - 20, cellH - 20],
|
||
flags: {},
|
||
order: idx,
|
||
mode: 0,
|
||
inputs: [],
|
||
outputs: [],
|
||
properties: { "Node name for S&R": repName },
|
||
widgets_values: [],
|
||
});
|
||
idx++;
|
||
}
|
||
|
||
if (nodes.length === 0) return { ok: false, reason: "no-nodes" };
|
||
|
||
const graph = {
|
||
last_node_id: nextId - 1,
|
||
last_link_id: 0,
|
||
nodes,
|
||
links: [],
|
||
groups: [],
|
||
config: {},
|
||
extra: {},
|
||
version: 0.4,
|
||
};
|
||
|
||
const apply = async () => {
|
||
let previousGraph = null;
|
||
try {
|
||
if (canvasIsNonEmpty()) previousGraph = app.graph.serialize();
|
||
} catch (e) {
|
||
previousGraph = null;
|
||
}
|
||
try {
|
||
await app.loadGraphData(graph, true, true, "missing-nodes-for-picks", {});
|
||
toast(
|
||
`Dropped ${nodes.length} placeholder${nodes.length === 1 ? "" : "s"}. ` +
|
||
`Open Manager → "Install Missing Custom Nodes" to install.`
|
||
);
|
||
return { ok: true, count: nodes.length };
|
||
} catch (e) {
|
||
console.error("[Koolook] dropPlaceholders loadGraphData failed:", e);
|
||
if (previousGraph) {
|
||
try {
|
||
await app.loadGraphData(previousGraph);
|
||
toast("Failed to drop placeholders. Canvas restored. See console.");
|
||
return { ok: false, reason: "load-failed", error: e };
|
||
} catch (restoreErr) {
|
||
console.error("[Koolook] canvas restore failed:", restoreErr);
|
||
}
|
||
}
|
||
toast("Failed to drop placeholders. See console.");
|
||
return { ok: false, reason: "load-failed", error: e };
|
||
}
|
||
};
|
||
|
||
if (canvasIsNonEmpty()) {
|
||
return new Promise(resolve => {
|
||
showConfirmModal({
|
||
title: "Replace current workflow?",
|
||
message:
|
||
`This will replace your canvas with ${nodes.length} placeholder ` +
|
||
`node${nodes.length === 1 ? "" : "s"} (one per missing pack). ` +
|
||
`Manager's "Install Missing Custom Nodes" will then offer to install ` +
|
||
`them. Save your current work first if you have anything unsaved.`,
|
||
confirmLabel: "Replace canvas",
|
||
danger: true,
|
||
onConfirm: async () => resolve(await apply()),
|
||
});
|
||
});
|
||
}
|
||
return await apply();
|
||
}
|
||
|
||
// =============================================================================
|
||
// Node insertion
|
||
// =============================================================================
|
||
function placeAtCanvasCenter(node) {
|
||
try {
|
||
const canvas = app.canvas;
|
||
const ds = canvas.ds;
|
||
// `canvas.canvas.width` / `.height` are the BACKING-STORE pixel buffer
|
||
// dimensions, which on a HiDPI display (Retina/Mac, Windows scaling)
|
||
// are devicePixelRatio× the visible CSS pixels. LiteGraph's pan/zoom
|
||
// math is in CSS pixels, so dividing the buffer width by 2 puts the
|
||
// node at devicePixelRatio× the visible-center distance from the
|
||
// origin — i.e. way off the right side of the viewport on a 2× Mac
|
||
// display. Use clientWidth/clientHeight (CSS pixels) instead;
|
||
// fall back to the buffer dims if the canvas isn't laid out yet.
|
||
const cssWidth = canvas.canvas.clientWidth || canvas.canvas.width;
|
||
const cssHeight = canvas.canvas.clientHeight || canvas.canvas.height;
|
||
const cx = -ds.offset[0] + cssWidth / (2 * ds.scale);
|
||
const cy = -ds.offset[1] + cssHeight / (2 * ds.scale);
|
||
node.pos = [cx - node.size[0] / 2, cy - node.size[1] / 2];
|
||
} catch (e) {
|
||
// Default position is fine if the canvas isn't ready yet.
|
||
}
|
||
}
|
||
|
||
export function insertNode(typeName) {
|
||
if (typeof LiteGraph === "undefined") return;
|
||
const node = LiteGraph.createNode(typeName);
|
||
if (!node) {
|
||
console.warn(`[Koolook] could not create node: ${typeName}`);
|
||
return;
|
||
}
|
||
app.graph.add(node);
|
||
placeAtCanvasCenter(node);
|
||
app.canvas.setDirty(true, true);
|
||
}
|
||
|
||
// =============================================================================
|
||
// Workflow insertion (non-destructive — ADDS nodes from a saved workflow into
|
||
// the live canvas instead of replacing it). Sibling to `loadWorkflowOntoCanvas`
|
||
// above; the existing function rebuilds the entire graph via
|
||
// `app.loadGraphData`, which is the right call for "load this workflow as my
|
||
// session", but the wrong one for "drop this preset cluster next to my
|
||
// existing nodes". This primitive is the basis for the Modules feature: a
|
||
// saved selection (typically tagged `module`) that the user wants to splice
|
||
// into whatever they're already building.
|
||
//
|
||
// Mechanics — kept tight because the failure modes are subtle:
|
||
//
|
||
// 1. **Pre-flight missing types.** A saved cluster can reference custom
|
||
// nodes the user hasn't installed. `app.loadGraphData` masks this by
|
||
// rendering red error nodes; for an insert that would silently splice
|
||
// broken stubs into the user's working graph. We check
|
||
// `LiteGraph.registered_node_types` up-front and abort with a toast if
|
||
// anything is missing — partial inserts are worse than no insert.
|
||
//
|
||
// 2. **Let the live graph allocate ids.** The saved graph's node ids and
|
||
// link ids almost certainly collide with what's already on canvas (both
|
||
// counters reset per saved workflow). Easiest correct fix: deep-clone
|
||
// the saved graph, then for each cloned node call `node.configure(...)`
|
||
// (which restores widget values + properties from the JSON) but force
|
||
// `node.id = -1` before `app.graph.add(node)` so LiteGraph hands out a
|
||
// fresh id from `last_node_id + 1`. Build an `oldId → newId` map as we
|
||
// go so links can be reconstructed afterwards.
|
||
//
|
||
// 3. **Recreate links via `node.connect`.** Walking `clone.links` and
|
||
// calling `originNode.connect(originSlot, targetNode, targetSlot)`
|
||
// sidesteps link-id remapping entirely — `connect` allocates a fresh
|
||
// link id from `app.graph.last_link_id + 1`. Cross-boundary references
|
||
// (links pointing at nodes that weren't part of the saved selection)
|
||
// are already nulled by `serializeSelection`, so the typical case is a
|
||
// clean closed sub-graph.
|
||
//
|
||
// 4. **Place the cluster at the viewport center.** Compute the bbox of
|
||
// the cloned nodes (in their saved coords), then translate every node
|
||
// by `(viewportCenter - bboxCenter)`. Reuses the same HiDPI-correct
|
||
// math as `placeAtCanvasCenter` (CSS pixels via `clientWidth`, not the
|
||
// backing-store buffer). If the canvas isn't laid out yet, we fall
|
||
// through and place at original coords — same defensive posture as
|
||
// `placeAtCanvasCenter`.
|
||
//
|
||
// 5. **Select the inserted cluster** so the user can immediately drag it
|
||
// somewhere else, delete it, or hit Ctrl+C to duplicate. Selection API
|
||
// varies across LiteGraph forks (`selectNodes` vs. `selectNode(_, true)`
|
||
// for additive); we try both and swallow failures because selection is
|
||
// a nice-to-have, not load-critical.
|
||
//
|
||
// Returns `{ ok: true, count }` on success, `{ ok: false, reason }` on any
|
||
// abort path so callers can branch (the right-click menu just toasts; a
|
||
// future drag-onto-canvas path might want to surface a different message).
|
||
// =============================================================================
|
||
|
||
function placeBboxAtCanvasCenter(nodes) {
|
||
if (!nodes || nodes.length === 0) return;
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const n of nodes) {
|
||
const w = (n.size && n.size[0]) || 200;
|
||
const h = (n.size && n.size[1]) || 100;
|
||
const x = (n.pos && n.pos[0]) || 0;
|
||
const y = (n.pos && n.pos[1]) || 0;
|
||
if (x < minX) minX = x;
|
||
if (y < minY) minY = y;
|
||
if (x + w > maxX) maxX = x + w;
|
||
if (y + h > maxY) maxY = y + h;
|
||
}
|
||
if (!isFinite(minX) || !isFinite(minY)) return;
|
||
const bboxCx = (minX + maxX) / 2;
|
||
const bboxCy = (minY + maxY) / 2;
|
||
|
||
let dx = 0, dy = 0;
|
||
try {
|
||
const canvas = app.canvas;
|
||
const ds = canvas.ds;
|
||
const cssWidth = canvas.canvas.clientWidth || canvas.canvas.width;
|
||
const cssHeight = canvas.canvas.clientHeight || canvas.canvas.height;
|
||
const cx = -ds.offset[0] + cssWidth / (2 * ds.scale);
|
||
const cy = -ds.offset[1] + cssHeight / (2 * ds.scale);
|
||
dx = cx - bboxCx;
|
||
dy = cy - bboxCy;
|
||
} catch (e) {
|
||
// Canvas not ready — leave nodes at their saved coords. This matches
|
||
// `placeAtCanvasCenter`'s posture: don't throw on a layout race.
|
||
}
|
||
|
||
if (dx === 0 && dy === 0) return;
|
||
for (const n of nodes) {
|
||
if (!n.pos) n.pos = [0, 0];
|
||
n.pos[0] += dx;
|
||
n.pos[1] += dy;
|
||
}
|
||
}
|
||
|
||
export async function insertWorkflowOntoCanvas(dirPath, wfName) {
|
||
const sourceGraph = getWorkflowGraph(dirPath, wfName);
|
||
if (!sourceGraph) {
|
||
toast(`Workflow not found: ${wfName}`);
|
||
return { ok: false, reason: "not-found" };
|
||
}
|
||
if (typeof LiteGraph === "undefined") {
|
||
toast(`Cannot insert "${wfName}" — LiteGraph not available.`);
|
||
return { ok: false, reason: "no-litegraph" };
|
||
}
|
||
// Deep clone — `node.configure` and `placeBboxAtCanvasCenter` both
|
||
// mutate. We never want to touch the in-memory store copy.
|
||
const clone = JSON.parse(JSON.stringify(sourceGraph));
|
||
const nodesRaw = Array.isArray(clone.nodes) ? clone.nodes : [];
|
||
if (nodesRaw.length === 0) {
|
||
toast(`"${wfName}" has no nodes to insert.`);
|
||
return { ok: false, reason: "empty" };
|
||
}
|
||
|
||
// Pre-flight: every node type must be registered. Aborting here is the
|
||
// entire reason this is a separate primitive from `loadWorkflowOntoCanvas`
|
||
// — a partial insert with stub nodes silently corrupts the user's graph.
|
||
const registered = LiteGraph.registered_node_types || {};
|
||
const missingTypes = [];
|
||
const seenMissing = new Set();
|
||
for (const n of nodesRaw) {
|
||
const t = n && n.type;
|
||
if (!t) continue;
|
||
if (!registered[t] && !seenMissing.has(t)) {
|
||
seenMissing.add(t);
|
||
missingTypes.push(t);
|
||
}
|
||
}
|
||
if (missingTypes.length > 0) {
|
||
// Two distinct failure modes hide behind "missing type":
|
||
// • pack miss — registered_node_types has no entry for a custom-node
|
||
// type. Fix: install the pack(s).
|
||
// • subgraph miss — the type IS a subgraph UUID, but its definition
|
||
// hasn't been registered into registered_node_types yet (subgraphs
|
||
// are registered as a side effect of native Load, not at startup).
|
||
// Fix: right-click the source workflow → Load once in the current
|
||
// session, which routes through `app.loadGraphData` and registers
|
||
// each `definitions.subgraphs[]` entry by UUID. Subsequent Inserts
|
||
// then resolve.
|
||
// Pre-fix, the mixed case (both classes present) silently fell into the
|
||
// pack-miss branch and listed UUIDs alongside pack types under "install
|
||
// the pack(s)" — the user installed the pack, retried, and hit an
|
||
// unexplained second "still missing" failure for the unregistered
|
||
// subgraph. Surface BOTH classes when both are present so one toast
|
||
// covers both fixes in order.
|
||
// Toast wording goes plain English on purpose — "subgraph definition
|
||
// not registered" was technically accurate but confused the user, who
|
||
// just wants to know "what do I do to make Insert work?" Engineer-speak
|
||
// (subgraph / registered / definition) lives in code comments and
|
||
// console.warn for devs; user-facing copy talks in gestures and
|
||
// outcomes ("right-click → Load it once, then left-click Insert will
|
||
// work"). The "Load" verb mirrors the actual menu item label in
|
||
// workflowRowContextMenu so the user doesn't hunt for a "paste" or
|
||
// "set up" item that doesn't exist.
|
||
const subgraphMisses = missingTypes.filter(t => SUBGRAPH_UUID_RE.test(t));
|
||
const packMisses = missingTypes.filter(t => !SUBGRAPH_UUID_RE.test(t));
|
||
let message;
|
||
if (packMisses.length === 0) {
|
||
// Pure subgraph case — only definitions are missing. Skip the
|
||
// "why" (subgraphs need registration after a fresh session); jump
|
||
// straight to the gesture. If it wears off after a restart the
|
||
// toast just re-fires and the user does the same gesture again —
|
||
// no pre-emptive edge-case warning needed in the toast itself.
|
||
message =
|
||
`Can't Insert "${wfName}" yet — Right-click → Load it once, ` +
|
||
`then left-click Insert will work.`;
|
||
} else if (subgraphMisses.length === 0) {
|
||
// Pure pack case — only registered-pack types are missing.
|
||
const sample = packMisses.slice(0, 3).join(", ");
|
||
const more = packMisses.length > 3 ? ` (+${packMisses.length - 3} more)` : "";
|
||
message =
|
||
`Can't Insert "${wfName}" — missing node${packMisses.length === 1 ? "" : "s"}: ` +
|
||
`${sample}${more}. Install the pack(s) first, then retry.`;
|
||
} else {
|
||
// Mixed case — both classes missing. Numbered two-step instruction
|
||
// so the user knows the order; mentioning both upfront preempts a
|
||
// second "still missing" toast after the user installs the pack
|
||
// and retries Insert without the Load step.
|
||
const sample = packMisses.slice(0, 3).join(", ");
|
||
const more = packMisses.length > 3 ? ` (+${packMisses.length - 3} more)` : "";
|
||
message =
|
||
`Can't Insert "${wfName}" yet — ` +
|
||
`(1) install pack(s) for ${sample}${more}, ` +
|
||
`(2) Right-click → Load it once. Then left-click Insert will work.`;
|
||
}
|
||
toast(message, 6500);
|
||
console.warn(
|
||
`[Koolook] insert "${wfName}" aborted;`,
|
||
{ packMisses, subgraphMisses }
|
||
);
|
||
return { ok: false, reason: "missing-types", missingTypes };
|
||
}
|
||
|
||
// Translate the cluster to the viewport center BEFORE creating live
|
||
// nodes. `placeBboxAtCanvasCenter` writes into `n.pos` on the cloned
|
||
// entries, which `node.configure(...)` then reads.
|
||
placeBboxAtCanvasCenter(nodesRaw);
|
||
|
||
// Insert nodes; let the live graph allocate ids so nothing collides
|
||
// with `app.graph.last_node_id`.
|
||
const oldToNew = new Map();
|
||
const inserted = [];
|
||
for (const oldNode of nodesRaw) {
|
||
const node = LiteGraph.createNode(oldNode.type);
|
||
if (!node) {
|
||
// Pre-flight should have caught this; log if it happens anyway
|
||
// (a registered type whose factory returned null is a LiteGraph
|
||
// bug or a misregistered pack — we'd rather skip than crash the
|
||
// whole insert at this point).
|
||
console.warn(`[Koolook] LiteGraph.createNode("${oldNode.type}") returned null after pre-flight passed`);
|
||
continue;
|
||
}
|
||
const nodeConfig = cloneNodeWithoutLinkState(oldNode);
|
||
try {
|
||
node.configure(nodeConfig);
|
||
} catch (e) {
|
||
console.warn(`[Koolook] node.configure failed for "${oldNode.type}":`, e);
|
||
}
|
||
// configure() restored `node.id` from the saved data — that's the
|
||
// OLD id. Force re-allocation so we don't collide with the live
|
||
// graph (or with another inserted node from the same cluster).
|
||
const oldId = oldNode.id;
|
||
node.id = -1;
|
||
app.graph.add(node);
|
||
if (oldId != null) oldToNew.set(idKey(oldId), node.id);
|
||
inserted.push(node);
|
||
}
|
||
|
||
// Recreate internal links. `serializeSelection` already nulled out
|
||
// cross-boundary references, but defensively skip any link whose
|
||
// endpoint we don't have in the remap (covers hand-edited /userdata
|
||
// files and old saves from before that pruning landed).
|
||
const linksRaw = graphLinksAsList(clone.links);
|
||
let connectedLinks = 0;
|
||
for (const link of linksRaw) {
|
||
const oldOrigin = getLinkOriginId(link);
|
||
const originSlot = getLinkOriginSlot(link);
|
||
const oldTarget = getLinkTargetId(link);
|
||
const targetSlot = getLinkTargetSlot(link);
|
||
if (oldOrigin == null || oldTarget == null || originSlot == null || targetSlot == null) continue;
|
||
const newOrigin = oldToNew.get(idKey(oldOrigin));
|
||
const newTarget = oldToNew.get(idKey(oldTarget));
|
||
if (newOrigin == null || newTarget == null) continue;
|
||
const originNode = app.graph.getNodeById(newOrigin);
|
||
const targetNode = app.graph.getNodeById(newTarget);
|
||
if (!originNode || !targetNode) continue;
|
||
try {
|
||
const result = originNode.connect(originSlot, targetNode, targetSlot);
|
||
if (result) connectedLinks += 1;
|
||
} catch (e) {
|
||
console.warn(
|
||
`[Koolook] link reconnect failed: ${originNode.type}[${originSlot}] → ` +
|
||
`${targetNode.type}[${targetSlot}]:`, e
|
||
);
|
||
}
|
||
}
|
||
|
||
// Selection — best-effort, never block on it.
|
||
try {
|
||
if (typeof app.canvas.selectNodes === "function") {
|
||
app.canvas.selectNodes(inserted);
|
||
} else if (typeof app.canvas.selectNode === "function") {
|
||
// Older LiteGraph: additive single-select per node.
|
||
for (let i = 0; i < inserted.length; i += 1) {
|
||
app.canvas.selectNode(inserted[i], i > 0);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Don't surface — the nodes are on canvas, selection is just polish.
|
||
}
|
||
|
||
app.canvas.setDirty(true, true);
|
||
const noun = inserted.length === 1 ? "node" : "nodes";
|
||
const linkSuffix = connectedLinks > 0
|
||
? ` (${connectedLinks} link${connectedLinks === 1 ? "" : "s"})`
|
||
: "";
|
||
toast(`Inserted "${wfName}" — ${inserted.length} ${noun}${linkSuffix}.`);
|
||
return { ok: true, count: inserted.length, links: connectedLinks };
|
||
}
|
||
|
||
export function getSelectedNodeTypes() {
|
||
try {
|
||
const sel = (app.canvas && app.canvas.selected_nodes) || {};
|
||
return Object.values(sel)
|
||
.filter(n => n && n.type)
|
||
.map(n => n.type);
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|