diff --git a/CHANGELOG.md b/CHANGELOG.md index 6189f7b..0ac5bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,56 @@ The format is inspired by Keep a Changelog and SemVer. ## [Unreleased] ### Added +- **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 + user's curated picks above everything else, with a `── from your picks ──` + separator between hoisted and unhoisted compatible nodes. Implementation + monkey-patches `LiteGraph.ContextMenu` and detects the connection-release + menu via the `Add Node` + `Add Reroute` operation-sentinel pair (narrow + enough that other ContextMenu uses — right-click on a node, on the + canvas — are untouched). Purely additive: no compatible nodes are + removed, only re-ordered. Wired in `web/sidebar/connection_menu.js`, + registered alongside the existing `patchCanvasMenu` in + `web/koolook_sidebar.js`. +- **Spotlight effect on add.** Clicking the toolbar `+` (or the canvas + right-click "Add to Kforge Labs") now collapses every Nodes-section + pack folder, then auto-expands just the pack + subcategory the just-saved + node lives in — a pedagogical aid that helps new users internalize + which pack each node belongs to. Multi-select adds light up every hit + pack simultaneously. Duplicate adds (already in picks) trigger the + same spotlight, since the educational reminder still applies. Helper + `findPackPathForType` mirrors the gather code's REPO-precedence-then- + user-pick-fallback to locate any node ID's sidebar path; new exported + `spotlightAddedPicks(typeNames)` from `web/sidebar/tree.js` does the + collapse + pin sequence and is called from both add paths. + +### Fixed +- **`pinExpanded` paths now expand on the immediate render** instead of + being delayed by one. `buildFolder`'s expansion-resolution chain gained + a `pinnedPaths` check between `forceExpanded` and `pathStates`. Phase 3 + of `renderTree` writes pins into `pathStates` AFTER Phase 2 has built + the DOM, so without the new check the pin had no effect on the current + render — only on the next one. Side-effect-free correctness improvement + for the workflow-save pinning that already existed; load-bearing for + the new spotlight feature above which depends on pinned paths + expanding immediately. +- **Modal drag-out-of-input no longer dismisses the dialog.** The shared + modal shell (`makeModalShell`) used by every Koolook dialog gained a + mousedown / click-intent check: a click on the overlay now only fires + the dismiss when the gesture *started* on the overlay too. Drag- + selecting text inside an input field that releases in the overlay's + dark area used to trigger `click` with `target=overlay` and dismiss + the modal mid-edit; now it stays open. Affects all dialogs including + Snapshot Save / Load / Settings, Install Missing, Save Workflow, Tags, + Confirm, Input — load-bearing especially for the Snapshot Settings + path field where the long absolute path encourages drag-selection. +- **Snapshot Settings path field shows full path on hover.** Added a + `title` attribute synced to the saved library path on every refresh + and save, so the user can read the full path without scrolling / + selecting inside the narrow input. + +### Added (continued) - **"Install missing for picks" toolbar button** in the Nodes section (`pi-cloud-download` icon, next to Add and Export). Walks the user's picks against ComfyUI-Manager's `/customnode/getmappings` mapping, diff --git a/web/koolook_sidebar.js b/web/koolook_sidebar.js index 9a844fc..6fdb512 100644 --- a/web/koolook_sidebar.js +++ b/web/koolook_sidebar.js @@ -20,6 +20,7 @@ import { } from "./sidebar/workflows_store.js"; import { renderPanel } from "./sidebar/tree.js"; import { patchCanvasMenu } from "./sidebar/canvas_menu.js"; +import { patchConnectionMenu } from "./sidebar/connection_menu.js"; app.registerExtension({ name: "koolook.curated_sidebar", @@ -48,5 +49,6 @@ app.registerExtension({ render: (el) => renderPanel(el), }); patchCanvasMenu(); + patchConnectionMenu(); }, }); diff --git a/web/sidebar/canvas_menu.js b/web/sidebar/canvas_menu.js index 6225c9c..0bba3a1 100644 --- a/web/sidebar/canvas_menu.js +++ b/web/sidebar/canvas_menu.js @@ -8,6 +8,7 @@ // ============================================================================= import { toast } from "./constants.js"; import { addToMyPicks, notifyPicksChanged } from "./picks_store.js"; +import { spotlightAddedPicks } from "./tree.js"; export function patchCanvasMenu() { const C = (typeof LGraphCanvas !== "undefined") ? LGraphCanvas : null; @@ -32,9 +33,14 @@ export function patchCanvasMenu() { const status = addToMyPicks(node.type); if (status === "added") { toast(`Added "${node.title || node.type}" to favorites.`); + spotlightAddedPicks([node.type]); notifyPicksChanged(); } else if (status === "duplicate") { toast("Already in favorites."); + // Spotlight on duplicate too — the user reached for this + // exact node; remind them where it lives. + spotlightAddedPicks([node.type]); + notifyPicksChanged(); } else { toast(`Failed to save "${node.title || node.type}". See console.`); } diff --git a/web/sidebar/connection_menu.js b/web/sidebar/connection_menu.js new file mode 100644 index 0000000..b434613 --- /dev/null +++ b/web/sidebar/connection_menu.js @@ -0,0 +1,158 @@ +// ============================================================================= +// Drag-link-release menu — curated picks priority. +// +// When the user drags a connection out of a node socket and releases on empty +// canvas, LiteGraph shows a context menu of operations + compatible nodes +// (filtered by socket type). Default order is whatever LiteGraph emits, +// which is essentially "all installed nodes that match the type, in +// registration order." On a busy ComfyUI install this menu can be 20+ items +// of nodes the user has never used. +// +// We hoist the user's curated picks (those that ALSO match the dragged +// socket's type) to the top of the suggestion list, immediately below +// LiteGraph's operation entries (Add Node / Add Reroute / Search / Reroute). +// Other compatible nodes still render below — purely additive ordering, no +// information loss. +// +// Strategy: monkey-patch `LiteGraph.ContextMenu` globally and detect the +// connection-release menu by sentinel items in its options array. Detection +// requires BOTH "Add Node" AND "Add Reroute" to be present — narrow enough +// that other ContextMenu uses (right-click on a node, on the canvas, etc.) +// are untouched, while broad enough to survive minor LiteGraph variations. +// +// LiteGraph fork variants used by ComfyUI may shape the suggestion items +// either with `content: ` or `content: ` and a parallel +// `value: `. We check both, so the picks lookup matches whichever +// shape is in flight. +// ============================================================================= +import { loadUserPicks } from "./picks_store.js"; + +const SEPARATOR_LABEL = "── from your picks ──"; + +let installed = false; + +export function patchConnectionMenu() { + const LG = (typeof LiteGraph !== "undefined") ? LiteGraph : null; + if (!LG || typeof LG.ContextMenu !== "function") { + console.warn("[Koolook] LiteGraph.ContextMenu not available; connection-menu priority skipped."); + return; + } + // Idempotency: this is a global constructor swap. A re-load (HMR / + // multiple registerExtension calls) would otherwise stack wrappers, + // running the priority logic N times per ContextMenu construction — + // performance + duplicate "── from your picks ──" separators. + if (LG.__koolookContextMenuPatched) return; + LG.__koolookContextMenuPatched = true; + + const origContextMenu = LG.ContextMenu; + + function PatchedContextMenu(options, parentMenuOptions) { + let finalOptions = options; + try { + if (looksLikeConnectionMenu(options)) { + finalOptions = reorderByPicks(options); + } + } catch (e) { + // Never let a reorder bug break the menu — fall back to the + // original options array so the user always gets SOME menu. + console.warn("[Koolook] connection-menu reorder failed, falling back:", e); + finalOptions = options; + } + return new origContextMenu(finalOptions, parentMenuOptions); + } + + // Preserve the prototype chain so `instanceof LiteGraph.ContextMenu` + // checks elsewhere in LiteGraph / ComfyUI still pass, and any static + // properties on the original constructor remain reachable through ours. + PatchedContextMenu.prototype = origContextMenu.prototype; + Object.setPrototypeOf(PatchedContextMenu, origContextMenu); + LG.ContextMenu = PatchedContextMenu; +} + +// ============================================================================= +// Detection + reorder +// ============================================================================= + +// Detects the drag-link-release menu by looking for the operation sentinels +// LiteGraph emits at the top: BOTH "Add Node" AND "Add Reroute" must be +// present. The right-click canvas menu has "Add Node" but not "Add Reroute", +// so this filter doesn't accidentally re-shape unrelated menus. +function looksLikeConnectionMenu(options) { + if (!Array.isArray(options) || options.length === 0) return false; + let hasAddNode = false, hasAddReroute = false; + for (const opt of options) { + if (!opt || typeof opt.content !== "string") continue; + if (opt.content === "Add Node") hasAddNode = true; + else if (opt.content === "Add Reroute") hasAddReroute = true; + if (hasAddNode && hasAddReroute) return true; + } + return false; +} + +// LiteGraph sets `content` to either the node type ID directly or to the +// node's display title (which can include spaces, emoji, decorations). +// Our picks list stores type IDs, so we check `value` first (when present +// it's typically the type ID) and fall back to `content`. +function pickIdOf(opt) { + if (!opt) return null; + if (typeof opt.value === "string") return opt.value; + if (typeof opt.content === "string") return opt.content; + return null; +} + +// Operation entries that always sit at the top of LiteGraph's connection +// menu — preserve their position regardless of pick membership. +const OPERATIONS = new Set(["Add Node", "Add Reroute", "Search", "Reroute"]); + +function reorderByPicks(options) { + const picks = new Set(loadUserPicks()); + if (picks.size === 0) return options; + + // Walk the options array linearly. The operation block is typically + // contiguous at the top (Add Node, Add Reroute, Search, plus the two + // Reroute insertion entries); everything after is a node suggestion. + // Stop the operation slice at the first non-operation, non-null item — + // null entries are LiteGraph separators which we want to leave wherever + // LiteGraph put them. + let operationsEnd = 0; + while (operationsEnd < options.length) { + const opt = options[operationsEnd]; + if (opt === null) { + operationsEnd += 1; + continue; + } + if (opt && typeof opt.content === "string" && OPERATIONS.has(opt.content)) { + operationsEnd += 1; + continue; + } + break; + } + + const operations = options.slice(0, operationsEnd); + const suggestions = options.slice(operationsEnd); + + // Stable partition: picks first, others second. Skipping non-pick-able + // entries (separators, weird shapes) puts them in `others` to preserve + // their relative order with the rest of the suggestion block. + const picksHits = []; + const others = []; + for (const opt of suggestions) { + const id = pickIdOf(opt); + if (id != null && picks.has(id)) picksHits.push(opt); + else others.push(opt); + } + + // No pick hits → no reordering needed; return the original array reference + // so we don't pay the array-allocation cost in the common no-match path. + if (picksHits.length === 0) return options; + + const reordered = [...operations, ...picksHits]; + if (others.length > 0) { + // Inert separator label so the user sees where their picks end and + // ComfyUI's default suggestions begin. `disabled: true` prevents + // hover/click activation; LiteGraph still renders it as a row. + reordered.push({ content: SEPARATOR_LABEL, disabled: true }); + reordered.push(...others); + } + return reordered; +} diff --git a/web/sidebar/modals.js b/web/sidebar/modals.js index f722f65..d941578 100644 --- a/web/sidebar/modals.js +++ b/web/sidebar/modals.js @@ -57,8 +57,20 @@ function makeModalShell({ title, body, actions }) { }; overlay.appendChild(modal); + // Click-to-dismiss with drag-out-of-input protection. A single click + // fires `mousedown` then `mouseup` then `click` — and `click.target` + // is the deepest common ancestor of mousedown's and mouseup's targets. + // So if you drag-select text inside an input and release in the + // overlay's dark area, click fires with target=overlay and the modal + // would otherwise close mid-edit. Track whether the gesture STARTED + // on the overlay too; only dismiss if both ends did. + let mouseDownOnOverlay = false; + overlay.addEventListener("mousedown", (e) => { + mouseDownOnOverlay = (e.target === overlay); + }); overlay.addEventListener("click", (e) => { - if (e.target === overlay) close(); + if (e.target === overlay && mouseDownOnOverlay) close(); + mouseDownOnOverlay = false; }); escHandler = (e) => { if (e.key === "Escape") close(); }; document.addEventListener("keydown", escHandler); @@ -1233,6 +1245,11 @@ export function showSnapshotSettingsDialog({ return; } input.value = settings.savedLibraryPath || ""; + // Native tooltip surfaces the full path on hover — the input is only + // ~440px wide so long paths get truncated by horizontal scroll. + // The user can still drag-select to view, but the tooltip avoids + // making them do so. + input.title = settings.savedLibraryPath || ""; input.disabled = false; let sourceLabel; if (settings.source === "settings") sourceLabel = "from this Settings panel"; @@ -1252,6 +1269,7 @@ export function showSnapshotSettingsDialog({ hint.textContent = `Currently in effect: ${result.resolvedPath}\n(source: ${sourceLabel})`; input.value = result.savedLibraryPath || ""; + input.title = result.savedLibraryPath || ""; toast(rawValue ? "Library path saved." : "Override cleared — using env var / default." diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js index 01747dd..d144767 100644 --- a/web/sidebar/tree.js +++ b/web/sidebar/tree.js @@ -185,6 +185,51 @@ function pinExpanded(paths) { for (const p of paths) pinnedPaths.add(p); } +/** + * Spotlight effect for newly-saved picks: collapse every folder under the + * Nodes section, then pin the just-added node's pack-folder + subcategory- + * folder open. Designed as a pedagogical aid — when the user adds a node + * via the toolbar `+` or the right-click "Add to Kforge Labs", the sidebar + * snaps to focus on exactly where that node lives in the pack hierarchy, + * helping newcomers internalize which pack/category each node belongs to. + * + * Caller is responsible for triggering a re-render afterwards (typically + * via `notifyPicksChanged()`); this function only mutates `pathStates` + + * `pinnedPaths`. + * + * Multi-add (caller passes several types): every pack/subcategory hit by + * the additions is pinned open. So adding 5 nodes from 3 packs lights up + * all 3 pack folders simultaneously rather than just the last. + * + * Idempotent / safe: types not in the LiteGraph registry are silently + * skipped (no-op). Empty input → no-op (no collapse, no pin). + * + * @param {string[]} typeNames + */ +export function spotlightAddedPicks(typeNames) { + if (!Array.isArray(typeNames) || typeNames.length === 0) return; + const pathsToExpand = new Set(); + for (const t of typeNames) { + const loc = findPackPathForType(t); + if (!loc) continue; + pathsToExpand.add(`${SECTION_ID_NODES}/${loc.packLabel}`); + pathsToExpand.add(`${SECTION_ID_NODES}/${loc.packLabel}/${loc.sub}`); + } + if (pathsToExpand.size === 0) return; + + // Collapse every existing Nodes-section entry (the section root + every + // pack/subcategory). The render-time fallback (`startExpanded`) keeps + // the section header itself open since `addSection` registers it with + // `startExpanded: true`; pack folders default to closed, which is what + // we want for everything except the spotlighted location. + for (const key of [...pathStates.keys()]) { + if (key === SECTION_ID_NODES || key.startsWith(SECTION_ID_NODES + "/")) { + pathStates.delete(key); + } + } + pinExpanded([...pathsToExpand]); +} + /** * Single render entry point. Idempotent — safe to call from search input, * picks-changed, workflows-changed, or storage events. @@ -443,6 +488,40 @@ function subcategoryFor(category, categoryRoot) { return parts.length > 1 ? parts.slice(1).join("/") : category; } +// Given a node ID, compute the (packLabel, sub) pair the gather code would +// place it under in the sidebar. Mirrors the precedence in +// `gatherNodesByRepo` + `gatherUserPickPacks`: registered REPOS first +// (`select="all"` matches by category prefix; `select=Array` matches by +// explicit ID), then the user-picks fallback derives the pack label from +// the first segment of the node's category. +// +// Returns `{packLabel, sub}` on success, `null` if the node isn't in the +// LiteGraph registry (caller should skip silently — the node won't render +// in the sidebar either way). Used by `spotlightAddedPicks` to know which +// folder to expand after a save. +function findPackPathForType(typeName) { + const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; + const nc = registry[typeName]; + if (!nc) return null; + const cat = (nc && nc.category) || ""; + + for (const repo of REPOS) { + if (repo.select === "all") { + const root = repo.categoryRoot || ""; + if (root && (cat === root || cat.startsWith(root + "/"))) { + return { packLabel: repo.label, sub: subcategoryFor(cat, root) }; + } + } else if (Array.isArray(repo.select)) { + if (repo.select.includes(typeName)) { + return { packLabel: repo.label, sub: subcategoryFor(cat, repo.categoryRoot || "") }; + } + } + } + + const packLabel = cat.split("/")[0] || "(uncategorized)"; + return { packLabel, sub: subcategoryFor(cat, packLabel) }; +} + function matchesQuery(display, type, q) { if (!q) return true; return display.toLowerCase().includes(q) || type.toLowerCase().includes(q); @@ -804,11 +883,19 @@ function buildFolder({ name, count, iconKind, childrenBuilder, onContextMenu, st // Resolution order: // 1. forceExpanded (e.g. search active) — overrides everything - // 2. pathStates (user has previously toggled this folder) - // 3. startExpanded (the natural default for this folder) + // 2. pinnedPaths (a save/spotlight flow has flagged this for force-open + // on the current render) — Phase 3 writes pins into pathStates AFTER + // Phase 2 builds DOM, so without a direct pinnedPaths read here the + // pin would only take effect on the *next* render. The spotlight + // effect for newly-added picks depends on pinned paths expanding + // on the immediate render. + // 3. pathStates (user has previously toggled this folder) + // 4. startExpanded (the natural default for this folder) let initiallyExpanded; if (forceExpanded) { initiallyExpanded = true; + } else if (pinnedPaths.has(path)) { + initiallyExpanded = true; } else if (pathStates.has(path)) { initiallyExpanded = pathStates.get(path); } else { @@ -1517,21 +1604,30 @@ export function renderPanel(container) { let added = 0; let duplicates = 0; let failed = 0; + const successfulTypes = []; for (const t of types) { const status = addToMyPicks(t); if (status === "added") added += 1; else if (status === "duplicate") duplicates += 1; else failed += 1; + // Spotlight even on duplicates — the user clicked + with this node + // selected; reminding them where it lives is the whole feature. + if (status !== "failed") successfulTypes.push(t); } + spotlightAddedPicks(successfulTypes); if (added > 0) { const noun = added === 1 ? "node" : "nodes"; toast(`Added ${added} ${noun} to favorites.`); notifyPicksChanged(); + } else if (duplicates > 0 && failed === 0) { + toast("Already in favorites."); + // Re-render anyway so the spotlight effect lands even on pure- + // duplicate clicks. notifyPicksChanged is the agreed-upon trigger + // for any sidebar refresh, even when picks didn't actually change. + notifyPicksChanged(); } if (failed > 0) { toast(`Failed to save ${failed} pick${failed === 1 ? "" : "s"}. See console.`); - } else if (added === 0 && duplicates > 0) { - toast("Already in favorites."); } }); nodesRow.appendChild(addBtn);