feat(sidebar): Modules — splice a saved cluster into the live canvas
Tag any saved workflow with the literal tag `module` and the Kforge Labs sidebar starts treating it as a reusable building block: a green `pi pi-plus-circle` icon replaces the file glyph, left-click **inserts** the cluster at the viewport center (with internal links re-created and the new nodes left selected), and a distinct hover tooltip surfaces the changed semantics. Designed for the `EXR-output stack` / `depth-only render` / `Wan 2.2 prompt module` pattern: small clusters the maintainer wants to drop into many workflows without rebuilding them by hand. What changed: 1. New non-destructive primitive `insertWorkflowOntoCanvas` in `web/sidebar/canvas_io.js`. Pre-flights every referenced node type against `LiteGraph.registered_node_types` and aborts cleanly with a toast naming the missing types — a partial insert with stub nodes is worse than no insert. Then deep-clones the saved graph, calls `node.configure(...)` per node with `node.id = -1` so `app.graph.add()` allocates fresh ids that can never collide with the live canvas, and finally walks the saved `links[]` to recreate internal connections via `originNode.connect(...)` (which auto-allocates fresh link ids — no manual link remap needed). Bbox placement reuses the existing CSS-pixel correction so the cluster lands at the visible viewport center on HiDPI displays. 2. Right-click on any workflow row now offers `Insert into canvas` alongside the existing `Load` — Load remains available for every row regardless of tag, so a module can still be loaded as a full session if the user wants. 3. Convention-driven module classification in `web/sidebar/constants.js` (`MODULE_TAG = module`). Picked a tag instead of a new `isModule` schema field so existing saved entries can be promoted to modules with one right-click → Tags add, no migration needed, and the existing Tags section groups them for free. Tree rendering looks up the tag at row-render time (`isModuleWorkflow` helper) and switches both the leaf icon (green `pi pi-plus-circle`) and the left-click handler (Insert instead of Load). Active rows in the Workflows section AND every row under the Tags section honor the flip; archived rows stay on Load (they're old versions, `review` not `splice`). 4. `showSaveWorkflowModal` gains a `Save as module` checkbox. The selection-save toolbar button passes `defaultModule: true` (the typical `module` candidate is a small cluster the user just selected); the whole-canvas save passes `defaultModule: false`. The tag is added in the SAME `persistMutation` as the entry write (folded into the `mutate` callback in `saveAndToast`), so a commit failure rolls back BOTH the save and the tag together — no `module`-but-untagged ghosts. Docs: new `Modules` section in `docs/maintainers/workflows-sidebar.md` covering the convention, the checkbox flow, per-surface behavior, and how to promote an existing saved workflow to a module without re-saving. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,31 @@ The format is inspired by Keep a Changelog and SemVer.
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Modules — splice a saved cluster into your live canvas instead of
|
||||
replacing it.** Tag any saved workflow with the literal tag `module`
|
||||
and the Kforge Labs sidebar starts treating it as a reusable building
|
||||
block: a green `pi pi-plus-circle` icon replaces the file glyph,
|
||||
left-click **inserts** the cluster at the viewport center (with
|
||||
internal links re-created and the new nodes left selected), and a
|
||||
distinct hover tooltip surfaces the changed semantics. The
|
||||
selection-save modal now ships a **`Save as module`** checkbox
|
||||
(pre-checked for selection saves; unchecked for whole-canvas saves),
|
||||
which adds the `module` tag inside the same `persistMutation` as the
|
||||
entry write so a commit failure rolls back both. The right-click
|
||||
menu on every workflow row gains an explicit **Insert into canvas**
|
||||
entry alongside the existing Load — Load still works on every row,
|
||||
module-tagged or not. New primitive `insertWorkflowOntoCanvas` in
|
||||
`web/sidebar/canvas_io.js` pre-flights every referenced node type
|
||||
against `LiteGraph.registered_node_types` and aborts cleanly when
|
||||
any are missing (a partial insert with stub nodes is worse than no
|
||||
insert), then deep-clones the saved graph, lets `app.graph.add()`
|
||||
allocate fresh node ids so nothing collides with the live canvas,
|
||||
and recreates internal connections via `originNode.connect(...)` so
|
||||
link ids are auto-allocated too — no manual link remap needed.
|
||||
Designed for setups like an EXR-output stack, a depth-only render
|
||||
cluster, or a Wan 2.2 prompt module the user wants to drop into many
|
||||
workflows. Full reference in
|
||||
[`docs/maintainers/workflows-sidebar.md`](docs/maintainers/workflows-sidebar.md#modules--splice-a-saved-cluster-into-your-live-canvas).
|
||||
- **Drag-link-release menu hoists curated picks to the top.** When the user
|
||||
drags a connection out of a node socket and releases on empty canvas,
|
||||
ComfyUI's compatibility-filtered suggestion list now sorts items in the
|
||||
|
||||
@@ -29,9 +29,46 @@ The archive trigger is a name collision with any existing active entry,
|
||||
regardless of which action was selected. The Action dropdown is UX
|
||||
guidance; the underlying logic flows from the resulting `(name, dir)` pair.
|
||||
|
||||
## Modules — splice a saved cluster into your live canvas
|
||||
|
||||
A saved workflow tagged with the literal tag **`module`** behaves like a
|
||||
reusable building block: clicking it **inserts** its nodes into the existing
|
||||
canvas (placed at the viewport center, freshly id-remapped, internal links
|
||||
re-created, the inserted nodes left selected) instead of replacing the
|
||||
graph the way a normal Load does.
|
||||
|
||||
Typical use cases this is designed for:
|
||||
|
||||
- **EXR output stack** — a Save EXR + path-builder + format selector cluster
|
||||
the user wants to drop into any render-time workflow.
|
||||
- **Depth-only render** — a depth-map node + preview wired up, ready to be
|
||||
spliced into a wider pipeline.
|
||||
- **Wan 2.2 prompt module** — the `EasyWan22Prompt` node + a CLIP encoder
|
||||
+ any conditioning glue, saved once, dropped into ten different
|
||||
workflows.
|
||||
|
||||
How it works:
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| **Save modal** (selection save) | Includes a **`Save as module`** checkbox, **pre-checked** for selection saves. Whole-canvas saves get the same checkbox but **unchecked** by default. The tag is added in the same atomic `persistMutation` as the save — a commit failure rolls both back. |
|
||||
| **Workflows section** | Module-tagged entries get a green **`pi pi-plus-circle`** icon and a distinct hover tooltip. **Left-click inserts** instead of loading. Right-click still offers **both** `Load` and `Insert into canvas` — `Load` stays available for every row regardless of tag. |
|
||||
| **Tags section** | Same flip — a workflow tagged `module` is treated as a module no matter which tag folder it shows up under. |
|
||||
| **Archive folder** | Archived module entries still left-click to **Load** (they're old versions, treated as "review" rather than "splice"). Right-click still has both Load and Insert. |
|
||||
|
||||
Implementation pieces:
|
||||
|
||||
- The literal tag string lives in [`web/sidebar/constants.js`](../../web/sidebar/constants.js) as `MODULE_TAG`.
|
||||
- The non-destructive insert primitive is `insertWorkflowOntoCanvas(dirPath, wfName)` in [`web/sidebar/canvas_io.js`](../../web/sidebar/canvas_io.js). It pre-flights every referenced node type against `LiteGraph.registered_node_types`, aborts cleanly with a toast when any are missing (a partial insert with stub nodes is worse than no insert), then deep-clones the saved graph, calls `node.configure(...)` per node with `node.id = -1` so `app.graph.add()` allocates fresh ids that can't collide with the live canvas, and finally walks the saved `links[]` to recreate internal connections via `originNode.connect(...)` (which auto-allocates fresh link ids — no manual link remap needed).
|
||||
- Bbox-of-cluster placement uses CSS pixels (`clientWidth`/`clientHeight`), not the HiDPI backing buffer — same correction as the existing `placeAtCanvasCenter` helper.
|
||||
|
||||
To turn an **existing** saved workflow into a module: right-click → **Tags…**
|
||||
→ add `module`. (No re-save needed; the row re-renders on the next
|
||||
`workflows-changed` event.) To un-module: remove the `module` tag the same way.
|
||||
|
||||
## Right-click menus
|
||||
|
||||
- **Workflow row**: Load / Rename / `→ <other path>` (lists every other directory in the tree) / Move to archive (or Restore from archive if already archived) / Delete.
|
||||
- **Workflow row**: Load / **Insert into canvas** / Rename / `→ <other path>` (lists every other directory in the tree) / Move to archive (or Restore from archive if already archived) / Delete.
|
||||
- **Directory row** (any depth): **Create subdirectory…** / Rename / Delete (with confirm if non-empty — the message names workflow + subdirectory counts).
|
||||
- **Archive folder** (synthetic — appears only when a directory has archived workflows): **Delete archive (N)** — removes every archived workflow in this directory in one go (active workflows in the same directory are untouched).
|
||||
|
||||
|
||||
@@ -325,6 +325,232 @@ export function insertNode(typeName) {
|
||||
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) {
|
||||
const sample = missingTypes.slice(0, 3).join(", ");
|
||||
const more = missingTypes.length > 3 ? ` (+${missingTypes.length - 3} more)` : "";
|
||||
toast(
|
||||
`Cannot insert "${wfName}" — missing node type${missingTypes.length === 1 ? "" : "s"}: ` +
|
||||
`${sample}${more}. Install the required pack(s) first.`,
|
||||
4500
|
||||
);
|
||||
console.warn(`[Koolook] insert "${wfName}" aborted; missing types:`, missingTypes);
|
||||
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;
|
||||
}
|
||||
try {
|
||||
node.configure(oldNode);
|
||||
} 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(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 = Array.isArray(clone.links) ? clone.links : [];
|
||||
let connectedLinks = 0;
|
||||
for (const link of linksRaw) {
|
||||
if (!Array.isArray(link) || link.length < 5) continue;
|
||||
const oldOrigin = link[1];
|
||||
const originSlot = link[2];
|
||||
const oldTarget = link[3];
|
||||
const targetSlot = link[4];
|
||||
const newOrigin = oldToNew.get(oldOrigin);
|
||||
const newTarget = oldToNew.get(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) || {};
|
||||
|
||||
@@ -58,6 +58,20 @@ export const WORKFLOWS_SEEDED_KEY = "koolook.workflows.seeded.v1";
|
||||
export const WORKFLOWS_CHANGED_EVENT = "koolook-workflows-changed";
|
||||
export const WORKFLOWS_DEFAULTS_URL = new URL("../workflow_defaults.json", import.meta.url).href;
|
||||
|
||||
// Convention-driven "module" classification — a saved workflow tagged with
|
||||
// this literal string is treated as a reusable building block (insert into
|
||||
// the existing canvas) instead of a full session (replace the canvas).
|
||||
//
|
||||
// Why a tag instead of a dedicated `isModule` field on the entry? Three
|
||||
// reasons: (1) zero schema migration; (2) the Tags section already groups
|
||||
// `module`-tagged entries for free; (3) re-tagging an existing saved entry
|
||||
// turns it into a module without any data shape change.
|
||||
//
|
||||
// Comparison is case-sensitive to match `addTag` / `removeTag` semantics in
|
||||
// `workflows_store.js`. The literal lives here so future renames (or a
|
||||
// "modules also includes `preset`" rule) only need to touch one constant.
|
||||
export const MODULE_TAG = "module";
|
||||
|
||||
// =============================================================================
|
||||
// Styles
|
||||
// =============================================================================
|
||||
@@ -90,6 +104,7 @@ export function ensureStyle() {
|
||||
.koolook-workflows-icon { color: #6db4ff; opacity: 0.95; flex-shrink: 0; }
|
||||
.koolook-archive-icon { opacity: 0.55; flex-shrink: 0; }
|
||||
.koolook-leaf-icon { opacity: 0.7; flex-shrink: 0; font-size: 11px; }
|
||||
.koolook-module-icon { color: #7be08a; opacity: 0.95; flex-shrink: 0; font-size: 11px; }
|
||||
.koolook-leaf-dot { width: 6px; height: 6px; margin: 0 2px; border-radius: 50%; background: rgba(255,255,255,0.45); flex-shrink: 0; }
|
||||
.koolook-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.koolook-count { background: rgba(255,255,255,0.08); padding: 1px 7px; border-radius: 8px; font-size: 11px; opacity: 0.75; }
|
||||
@@ -109,6 +124,8 @@ export function ensureStyle() {
|
||||
.koolook-modal-input, .koolook-modal-select { width: 100%; padding: 6px 8px; background: var(--comfy-input-bg, rgba(0,0,0,0.3)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; color: inherit; font-size: 13px; box-sizing: border-box; outline: none; }
|
||||
.koolook-modal-input:focus, .koolook-modal-select:focus { border-color: var(--p-primary-color, rgba(100,150,255,0.5)); }
|
||||
.koolook-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
||||
.koolook-modal-checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 12px; opacity: 0.85; cursor: pointer; user-select: none; }
|
||||
.koolook-modal-checkbox-row input[type="checkbox"] { cursor: pointer; }
|
||||
.koolook-modal-btn { padding: 6px 14px; background: var(--comfy-input-bg, rgba(0,0,0,0.3)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; color: inherit; cursor: pointer; font-size: 12px; }
|
||||
.koolook-modal-btn:hover { background: rgba(255,255,255,0.1); }
|
||||
.koolook-modal-btn-primary { background: rgba(80,140,235,0.35); border-color: rgba(80,140,235,0.55); }
|
||||
|
||||
+26
-2
@@ -149,7 +149,7 @@ export function showConfirmModal({ title, message, confirmLabel, cancelLabel, da
|
||||
({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] }));
|
||||
}
|
||||
|
||||
export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, onSave }) {
|
||||
export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, defaultModule = false, onSave }) {
|
||||
const body = document.createElement("div");
|
||||
|
||||
// ---- Directory (cascading picker) ----
|
||||
@@ -353,6 +353,26 @@ export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, on
|
||||
nameInput.placeholder = "My workflow";
|
||||
body.appendChild(nameInput);
|
||||
|
||||
// ---- "Save as module" checkbox ----
|
||||
// Tagging the saved entry with the literal `module` tag flips the
|
||||
// sidebar's left-click default from Load to Insert, and re-icons the
|
||||
// row green. The actual `addTag` call lives in the caller's `onSave`
|
||||
// handler so it can ride the same persistMutation snapshot as the
|
||||
// entry write itself — a commit failure rolls back BOTH the save and
|
||||
// the tag in one go. Visually, the checkbox sits below the name field
|
||||
// (the last thing the user touches before clicking Save) so the
|
||||
// intent decision is co-located with the destination decision.
|
||||
const moduleRow = document.createElement("label");
|
||||
moduleRow.className = "koolook-modal-checkbox-row";
|
||||
const moduleCheckbox = document.createElement("input");
|
||||
moduleCheckbox.type = "checkbox";
|
||||
moduleCheckbox.checked = !!defaultModule;
|
||||
moduleRow.appendChild(moduleCheckbox);
|
||||
const moduleText = document.createElement("span");
|
||||
moduleText.textContent = "Save as module (left-click inserts into canvas instead of replacing)";
|
||||
moduleRow.appendChild(moduleText);
|
||||
body.appendChild(moduleRow);
|
||||
|
||||
// ---- Wiring ----
|
||||
// Candidates for "Base on existing": collect every active workflow at
|
||||
// the destination dir AND walk up its ancestors. So if the user picks
|
||||
@@ -516,7 +536,11 @@ export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, on
|
||||
// match the codebase-wide convention (`dirPath` for arrays of
|
||||
// segments, `dirName` for single-segment strings, `dir` for resolved
|
||||
// DirNode objects).
|
||||
await onSave({ name, dirPath });
|
||||
// `asModule` is the "Save as module" checkbox state. Caller is
|
||||
// expected to fold an `addTag(..., MODULE_TAG)` into the same
|
||||
// persistMutation as the save when truthy, so a commit failure
|
||||
// rolls back both the entry write and the tag together.
|
||||
await onSave({ name, dirPath, asModule: moduleCheckbox.checked });
|
||||
};
|
||||
|
||||
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||||
|
||||
+80
-11
@@ -18,6 +18,7 @@ import {
|
||||
WORKFLOWS_CHANGED_EVENT,
|
||||
GROUP_MODE_KEY,
|
||||
GROUP_MODE_DEFAULT,
|
||||
MODULE_TAG,
|
||||
ensureStyle,
|
||||
toast,
|
||||
compareNames,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
serializeSelection,
|
||||
canvasIsNonEmpty,
|
||||
loadWorkflowOntoCanvas,
|
||||
insertWorkflowOntoCanvas,
|
||||
insertNode,
|
||||
getSelectedNodeTypes,
|
||||
dropPlaceholdersForPacks,
|
||||
@@ -1061,17 +1063,25 @@ function makeNodeLeafRow({ display, type, removable, onClick, packBadge, unresol
|
||||
return row;
|
||||
}
|
||||
|
||||
function makeWorkflowLeafRow({ name, dirName, onClick, onContextMenu, draggablePayload }) {
|
||||
function makeWorkflowLeafRow({ name, dirName, onClick, onContextMenu, draggablePayload, isModule = false }) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "koolook-row koolook-leaf";
|
||||
row.title = `${dirName} / ${name}`;
|
||||
// Module-tagged entries get a distinct hover tooltip so the green icon
|
||||
// isn't the only signal that a left-click does something different. The
|
||||
// `pi pi-plus-circle` class swap is what actually makes them look like
|
||||
// "drop in" affordances rather than "open" affordances.
|
||||
row.title = isModule
|
||||
? `${dirName} / ${name} — module (left-click to insert into canvas)`
|
||||
: `${dirName} / ${name}`;
|
||||
|
||||
const chevron = document.createElement("span");
|
||||
chevron.className = "koolook-chevron";
|
||||
row.appendChild(chevron);
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "pi pi-file koolook-leaf-icon";
|
||||
icon.className = isModule
|
||||
? "pi pi-plus-circle koolook-module-icon"
|
||||
: "pi pi-file koolook-leaf-icon";
|
||||
row.appendChild(icon);
|
||||
|
||||
const nameEl = document.createElement("span");
|
||||
@@ -1085,6 +1095,20 @@ function makeWorkflowLeafRow({ name, dirName, onClick, onContextMenu, draggableP
|
||||
return row;
|
||||
}
|
||||
|
||||
// Resolve module-ness from the workflow's stored tags. Computed at render
|
||||
// time (cheap — small array `.includes` per row) so we don't have to widen
|
||||
// the gather pipeline's row shape just to carry one boolean. Returns false
|
||||
// for archived entries: archived workflows are old versions; clicking them
|
||||
// in the Archive folder should still mean "load to review", not "splice an
|
||||
// outdated cluster into my live canvas". The right-click menu on archived
|
||||
// rows still exposes both Insert and Load if the user really wants to
|
||||
// insert an archived version.
|
||||
function isModuleWorkflow(dirPath, wfName) {
|
||||
const tags = getWorkflowTags(dirPath, wfName);
|
||||
if (!Array.isArray(tags)) return false;
|
||||
return tags.includes(MODULE_TAG);
|
||||
}
|
||||
|
||||
function buildFolder({ name, count, iconKind, childrenBuilder, onContextMenu, startExpanded = true, path, forceExpanded = false, draggablePayload, dropTarget }) {
|
||||
// `path` is required — every section/sub-section calling site routes
|
||||
// through `makeSectionCtx`, which always supplies a non-empty
|
||||
@@ -1310,6 +1334,14 @@ function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) {
|
||||
label: "Load",
|
||||
action: () => loadWorkflowOntoCanvas(dirPath, wfName),
|
||||
},
|
||||
{
|
||||
// Non-destructive sibling of Load — splices the saved cluster into
|
||||
// whatever's already on canvas (placed at the viewport center,
|
||||
// inserted nodes selected so the user can immediately drag).
|
||||
// Aborts cleanly if any referenced node type isn't installed.
|
||||
label: "Insert into canvas",
|
||||
action: () => insertWorkflowOntoCanvas(dirPath, wfName),
|
||||
},
|
||||
{
|
||||
label: "Rename…",
|
||||
action: () => {
|
||||
@@ -1685,12 +1717,19 @@ addSection({
|
||||
path: tag.name,
|
||||
build: (tagCtx) => {
|
||||
for (const entry of tag.entries) {
|
||||
// Module-ness is per-workflow, not per-tag-folder —
|
||||
// a workflow tagged `module` is still a module when
|
||||
// it shows up under any other tag it carries.
|
||||
const moduleEntry = isModuleWorkflow(entry.path, entry.wfName);
|
||||
tagCtx.leaf({
|
||||
row: makeWorkflowLeafRow({
|
||||
name: entry.wfName,
|
||||
dirName: entry.path.join(" / "),
|
||||
onClick: () => loadWorkflowOntoCanvas(entry.path, entry.wfName),
|
||||
onClick: () => moduleEntry
|
||||
? insertWorkflowOntoCanvas(entry.path, entry.wfName)
|
||||
: loadWorkflowOntoCanvas(entry.path, entry.wfName),
|
||||
onContextMenu: (e) => workflowRowContextMenu(e, entry.path, entry.wfName, false),
|
||||
isModule: moduleEntry,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1747,13 +1786,21 @@ function renderGatheredDir(parentCtx, dir) {
|
||||
});
|
||||
}
|
||||
for (const wfName of dir.active) {
|
||||
const moduleEntry = isModuleWorkflow(dirPath, wfName);
|
||||
dirCtx.leaf({
|
||||
row: makeWorkflowLeafRow({
|
||||
name: wfName,
|
||||
dirName: dirDisplay,
|
||||
onClick: () => loadWorkflowOntoCanvas(dirPath, wfName),
|
||||
// Module convention: left-click inserts; non-module
|
||||
// entries left-click loads (replaces canvas, with
|
||||
// confirm modal as before). Both actions are always
|
||||
// available via the right-click menu regardless.
|
||||
onClick: () => moduleEntry
|
||||
? insertWorkflowOntoCanvas(dirPath, wfName)
|
||||
: loadWorkflowOntoCanvas(dirPath, wfName),
|
||||
onContextMenu: (e) => workflowRowContextMenu(e, dirPath, wfName, false),
|
||||
draggablePayload: { type: "workflow", path: dirPath, name: wfName },
|
||||
isModule: moduleEntry,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -2085,7 +2132,13 @@ export function renderPanel(container) {
|
||||
// mutation rolls back automatically if both /userdata and the localStorage
|
||||
// fallback rejected the write — the user isn't told a save succeeded when
|
||||
// nothing was persisted, and the in-memory cache stays consistent with disk.
|
||||
const saveAndToast = async (graph, name, dirPath) => {
|
||||
//
|
||||
// `asModule` rides the same persistMutation so a commit failure rolls
|
||||
// back BOTH the entry write and the tag add. Doing the addTag in a
|
||||
// separate persistMutation would race: the save could land while the
|
||||
// tag rejected, leaving a "module" entry the user thinks is tagged
|
||||
// but isn't.
|
||||
const saveAndToast = async (graph, name, dirPath, asModule = false) => {
|
||||
// Pin the workflows section + every ancestor of the destination dir
|
||||
// so the resulting re-render leaves the whole path open. Pins are
|
||||
// consumed on the next render.
|
||||
@@ -2098,12 +2151,18 @@ export function renderPanel(container) {
|
||||
pinExpanded(pinKeys);
|
||||
const dirDisplay = dirPath.join(" / ");
|
||||
await persistMutation({
|
||||
mutate: () => saveWorkflowEntry(dirPath, name, graph),
|
||||
mutate: () => {
|
||||
const result = saveWorkflowEntry(dirPath, name, graph);
|
||||
if (!result) return false;
|
||||
if (asModule) addTag(dirPath, name, MODULE_TAG);
|
||||
return result;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
const moduleSuffix = asModule ? " as module" : "";
|
||||
if (result.archivedAs) {
|
||||
toast(`Saved "${name}" in ${dirDisplay}. Previous version moved to Archive.`);
|
||||
toast(`Saved "${name}" in ${dirDisplay}${moduleSuffix}. Previous version moved to Archive.`);
|
||||
} else {
|
||||
toast(`Saved "${name}" in ${dirDisplay}.`);
|
||||
toast(`Saved "${name}" in ${dirDisplay}${moduleSuffix}.`);
|
||||
}
|
||||
},
|
||||
persistFailedMessage: `Save failed — could not write "${name}". See console.`,
|
||||
@@ -2128,7 +2187,12 @@ export function renderPanel(container) {
|
||||
}
|
||||
showSaveWorkflowModal({
|
||||
titleSuffix: "entire canvas",
|
||||
onSave: ({ name, dirPath }) => saveAndToast(graph, name, dirPath),
|
||||
// Whole-canvas saves default to NOT being modules — the
|
||||
// typical "save my whole workflow" intent is to keep the
|
||||
// load-replaces-canvas semantics. The checkbox is still
|
||||
// visible so a power user can opt in.
|
||||
defaultModule: false,
|
||||
onSave: ({ name, dirPath, asModule }) => saveAndToast(graph, name, dirPath, asModule),
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -2154,7 +2218,12 @@ export function renderPanel(container) {
|
||||
const { graph } = result;
|
||||
showSaveWorkflowModal({
|
||||
titleSuffix: `${graph.nodes.length} selected node${graph.nodes.length === 1 ? "" : "s"}`,
|
||||
onSave: ({ name, dirPath }) => saveAndToast(graph, name, dirPath),
|
||||
// Selection saves are the canonical "module" candidate —
|
||||
// small clusters the user wants to splice into other
|
||||
// workflows. Pre-check the box; the user can untick if
|
||||
// they'd rather have replace-on-load semantics.
|
||||
defaultModule: true,
|
||||
onSave: ({ name, dirPath, asModule }) => saveAndToast(graph, name, dirPath, asModule),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user