From ed6f3dad02a76c267dab4ab2fc11a49afbfebc89 Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Tue, 5 May 2026 20:52:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(sidebar):=20Modules=20=E2=80=94=20splice?= =?UTF-8?q?=20a=20saved=20cluster=20into=20the=20live=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 25 +++ docs/maintainers/workflows-sidebar.md | 39 ++++- web/sidebar/canvas_io.js | 226 ++++++++++++++++++++++++++ web/sidebar/constants.js | 17 ++ web/sidebar/modals.js | 28 +++- web/sidebar/tree.js | 91 +++++++++-- 6 files changed, 412 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fcc062..f371db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/maintainers/workflows-sidebar.md b/docs/maintainers/workflows-sidebar.md index bae37f3..a0fc283 100644 --- a/docs/maintainers/workflows-sidebar.md +++ b/docs/maintainers/workflows-sidebar.md @@ -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 / `→ ` (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 / `→ ` (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). diff --git a/web/sidebar/canvas_io.js b/web/sidebar/canvas_io.js index 538e582..85a4e1e 100644 --- a/web/sidebar/canvas_io.js +++ b/web/sidebar/canvas_io.js @@ -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) || {}; diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js index c628c41..139197d 100644 --- a/web/sidebar/constants.js +++ b/web/sidebar/constants.js @@ -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); } diff --git a/web/sidebar/modals.js b/web/sidebar/modals.js index d941578..e5e9387 100644 --- a/web/sidebar/modals.js +++ b/web/sidebar/modals.js @@ -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(); }); diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js index 7c7b747..87ee8a3 100644 --- a/web/sidebar/tree.js +++ b/web/sidebar/tree.js @@ -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), }); }, }));