feat(sidebar): theme-classifier rework + Nodes-row polish (#80)

* revert(sidebar): drop drag-link-release menu priority patch

The patch in `web/sidebar/connection_menu.js` was reorder-only — it sorted
existing menu items so curated picks floated to the top. In practice
ComfyUI's drag-link-release menu has its own pre-filter that surfaces only
~4 stock nodes per socket type (e.g. for an IMAGE-compatible drop target:
VAEEncode, VAEEncodeForInpaint, SaveImage, PreviewImage). Most user
picks never appear in that filtered list, so the reorder had nothing to
hoist and the feature was invisible in practice.

A useful version of this would have to ADD picks to the menu — walk the
user's curated picks, type-match each against the dragged socket, inject
matches as new menu items — rather than just reorder. That's a larger
patch and out of scope for tonight; filing as a follow-up rather than
shipping dead code.

Removed: `web/sidebar/connection_menu.js`, the `patchConnectionMenu`
import + call in `web/koolook_sidebar.js`, and the corresponding
[Unreleased] CHANGELOG entry.

Kept: spotlight-on-add, modal drag-out fix, and the `pinExpanded`
buildFolder fix from #76 — all independently useful.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): theme-classifier rework + Nodes-row polish

Replaces the previous "category mode" (canonical-segment first-segment
grouping, which produced a fragmented tree of `image` / `images` /
`Image-Save` / `KJNodes/image` / `Koolook/Image` for the same semantic
theme) with a broad keyword classifier that folds anything mentioning
an image-related word into a single "Image" bucket regardless of source
pack or category casing. Picks-only; flat list inside each theme; falls
back to pack-prefix-stripped first segment when no classifier matches.

Five coordinated changes shipped together:

1. Theme classifier (new). `THEME_CLASSIFIERS` is an ordered list of
   13 broad themes (Image / Video / Mask / Audio / Model / Sampler /
   Conditioning / Text & Prompt / Math & Logic / Pipeline & Batch /
   I/O / Camera / Utility) with regex pattern arrays. `extractTheme`
   builds a search corpus from packLabel + category + display title +
   type ID and walks the classifiers in order — first match wins.
   First match for "Image Comparer (rgthree)" is "Image" (not Math)
   because Image is earlier in the list. Symbols touched in tree.js:
   `gatherNodesByTheme`, `extractTheme`, `classifyTheme`,
   `THEME_CLASSIFIERS`, `emitThemeChildren`, `collectFlatFromThemes`,
   `countNodesInThemes`. Replaces `gatherNodesByCategory`,
   `emitCategoryTreeChildren`, `collectFlatFromCategoryTree`,
   `countNodesInCategoryNode`, `ensureSyntheticBucket`.

2. Pack-name badges removed. `makeNodeLeafRow` no longer accepts a
   `packBadge` parameter; the badge rendering block is gone; all
   callers (3 sites) updated; CSS rule `.koolook-pack-badge` removed
   from constants.js. The breadcrumb prefix on search-flatten rows
   already conveys origin in a cleaner form, and theme mode by
   definition doesn't care about source pack.

3. Mode-toggle icons swapped. Repo mode: `pi-database` -> `pi-sitemap`
   (now actually tree-shaped). Theme mode: `pi-sitemap` -> `pi-bars`
   (sandwich). Better icon-to-meaning mapping.

4. Default mode flipped to "category" (theme). Newcomers more often
   think "where are my image nodes" than "which pack is this from."
   Existing users with `koolook.groupMode.v1` already in localStorage
   keep their saved choice.

5. Layout polish. `+` button uses a `pi pi-plus` icon at 12px (matches
   toolbar icon-button height) with a new dusty-green accent class
   `koolook-add-btn-green` (rgba 120/165/100). New divider between the
   search field and the Nodes action bar. `koolook-sidebar` gained
   `padding-top: 10px` so the Snapshot row no longer hugs the panel
   top edge.

CHANGELOG [Unreleased] updated with Changed entries for items 1+2.

Note: a CSS comment containing backticks (`koolook-icon-btn`) inside
a JS template literal silently broke module load on the first attempt —
backticks closed the template early, leaving "koolook-icon-btn" as a
stray identifier the browser rejected with "Unexpected identifier
'koolook'". Fixed by removing the backticks from the comment. `node
--check` did not catch this; the only reliable check is browser-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(sidebar): address fresh-eyes review nits on theme classifier

Three NITs + one IMPORTANT comment from the PR #80 review pass:

- IMPORTANT: Add a code comment on `\bget\b` / `\bset\b` in the Utility
  classifier warning future editors not to generalize to unbounded
  `/get/` / `/set/`. Current narrow forms are intentional — they're
  the last-resort net for trivial getter/setter nodes that didn't hit
  any earlier classifier.
- NIT: Fix the `classifyTheme` doc-comment example for "Image Comparer"
  to be technically accurate. The previous wording claimed Math's
  `/\bcompare\b/i` would match "Comparer" — it wouldn't, because the
  trailing word boundary fails on "Comparer". The actual reason it
  lands in Image is the `/image/i` substring hit on the "Image" word
  in the corpus, not ordering.
- NIT: Drop `.toLowerCase()` on the search corpus in `extractTheme`.
  Every classifier pattern carries the `/i` flag, so the explicit
  case fold was redundant work.
- NIT: Remove dead `(nc && ...)` defensive checks after the early
  return on `!nc`. Per CLAUDE.md "no defensive coding for impossible
  cases" — `nc` is guaranteed non-null on the lines below the early
  return. Same in `gatherNodesByTheme` where `nc` is fetched after
  `extractTheme` already filtered out unresolved picks; added a comment
  noting the invariant.

Behavior unchanged. All pure correctness/style polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-05 22:41:14 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 215616f546
commit d021e8c8da
3 changed files with 355 additions and 188 deletions
+22
View File
@@ -6,6 +6,28 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
### Changed
- **Sidebar second mode — "Theme" instead of "Category".** The sitemap-icon
toggle in the Nodes action row now groups picks by **semantic theme**
rather than by raw CATEGORY first-segment. The new algorithm strips the
source pack's name from the front of each pick's CATEGORY (via the
existing `findPackPathForType` precedence) before grouping, so
`KJNodes/image/Get`, `Koolook/Image/EasyResize`, and `image/foo` all
collapse to one top-level `image` folder. Each theme bucket renders as
a flat sorted list — no further sub-folders. Picks-only (no
REPOS-driven auto-pulled candidates) so the second mode genuinely
reflects "my image-related favorites" rather than "every image-shaped
node ComfyUI knows about." Synthetic buckets `(unresolved)` /
`(uncategorized)` survive as before.
- **Pack-name badges removed from leaf rows.** The small dim labels
(`Koolook`, `KJNodes`, `EasyUse`) that used to render at the right edge
of every leaf in theme mode and in search-flatten are gone. The
breadcrumb prefix on search-flatten rows already conveys origin in a
cleaner form, and theme mode by definition doesn't care about source
pack — the badge was redundant. `makeNodeLeafRow` no longer accepts a
`packBadge` parameter; CSS rule `.koolook-pack-badge` removed from
`constants.js`.
### Added
- **Modules — splice a saved cluster into your live canvas instead of
replacing it.** Tag any saved workflow with the literal tag `module`
+12 -4
View File
@@ -38,7 +38,10 @@ export const PICKS_CHANGED_EVENT = "koolook-picks-changed";
// path, with case-insensitive merging). Persisted in localStorage so the
// chosen mode survives reloads.
export const GROUP_MODE_KEY = "koolook.groupMode.v1";
export const GROUP_MODE_DEFAULT = "repo";
// Theme mode is the better default for newcomers — they're more likely to
// think "where are my image nodes?" than "which pack is this from?". Existing
// users with localStorage already set keep whatever mode they chose.
export const GROUP_MODE_DEFAULT = "category";
// Starter preset — a single shipped snapshot file the seeder copies into the
// user's snapshot library directory on first run (instead of seeding picks
@@ -81,7 +84,7 @@ export function ensureStyle() {
const s = document.createElement("style");
s.id = STYLE_ID;
s.textContent = `
.koolook-sidebar { display: flex; flex-direction: column; height: 100%; font-size: 13px; user-select: none; }
.koolook-sidebar { display: flex; flex-direction: column; height: 100%; font-size: 13px; user-select: none; padding-top: 10px; }
.koolook-search-row { margin: 6px; flex-shrink: 0; }
.koolook-actions-row { display: flex; align-items: center; gap: 4px; padding: 2px 6px; flex-shrink: 0; }
.koolook-actions-label { font-size: 10px; opacity: 0.55; text-transform: uppercase; letter-spacing: 0.08em; flex: 1; font-weight: 600; }
@@ -90,9 +93,15 @@ export function ensureStyle() {
.koolook-search-icon { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); opacity: 0.55; font-size: 11px; pointer-events: none; }
.koolook-search { width: 100%; padding: 5px 8px 5px 26px; box-sizing: border-box; background: var(--comfy-input-bg, rgba(0,0,0,0.25)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; color: var(--input-text, inherit); font-size: 12px; outline: none; }
.koolook-search:focus { border-color: var(--p-primary-color, rgba(100,150,255,0.5)); }
.koolook-add-btn { padding: 0 12px; background: var(--comfy-input-bg, rgba(0,0,0,0.25)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; cursor: pointer; color: var(--input-text, inherit); font-size: 16px; line-height: 1; flex-shrink: 0; }
.koolook-add-btn { padding: 0 10px; background: var(--comfy-input-bg, rgba(0,0,0,0.25)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; cursor: pointer; color: var(--input-text, inherit); font-size: 12px; line-height: 1; flex-shrink: 0; }
.koolook-add-btn:hover { background: rgba(255,255,255,0.1); }
.koolook-add-btn:active { background: rgba(255,255,255,0.15); }
/* Dusty-green accent on the "+" Add-to-favorites button — distinguishes the
primary action in the Nodes row from the neutral toolbar buttons it sits
alongside. Same dimensions as koolook-icon-btn; only the colors differ. */
.koolook-add-btn-green { background: rgba(120, 165, 100, 0.25); border-color: rgba(120, 165, 100, 0.5); }
.koolook-add-btn-green:hover { background: rgba(120, 165, 100, 0.45); }
.koolook-add-btn-green:active { background: rgba(120, 165, 100, 0.6); }
.koolook-icon-btn { padding: 0 10px; font-size: 12px; }
.koolook-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.koolook-tree { flex: 1; overflow-y: auto; padding: 0 4px 8px; }
@@ -170,7 +179,6 @@ export function ensureStyle() {
.koolook-mode-toggle-btn { padding: 0 8px; height: 22px; background: transparent; border: none; cursor: pointer; color: inherit; font-size: 11px; opacity: 0.5; line-height: 1; }
.koolook-mode-toggle-btn:hover { background: rgba(255,255,255,0.06); opacity: 0.85; }
.koolook-mode-toggle-btn.koolook-mode-active { background: rgba(80,140,235,0.25); opacity: 1; }
.koolook-pack-badge { font-size: 10px; padding: 1px 6px; border-radius: 8px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); opacity: 0.7; flex-shrink: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.koolook-leaf-unresolved { opacity: 0.55; font-style: italic; }
.koolook-leaf-crumb { opacity: 0.5; font-size: 11px; margin-right: 1px; }
.koolook-preview-card { position: fixed; z-index: 10000; background: var(--comfy-menu-bg, #2a2a2a); border: 1px solid var(--border-color, rgba(255,255,255,0.18)); border-radius: 6px; box-shadow: 0 6px 24px rgba(0,0,0,0.55); color: var(--input-text, #ddd); font-size: 12px; width: 320px; max-width: 90vw; max-height: calc(100vh - 16px); pointer-events: none; overflow: hidden auto; }
+321 -184
View File
@@ -629,22 +629,30 @@ function gatherUserPickPacks(query) {
}
// =============================================================================
// Data gathering — category-first mode
// Data gathering — theme-first mode
//
// Groups every visible pick by its node-class CATEGORY path, ignoring REPOS
// affiliation. Path segments that look the same after canonical-key
// normalization merge into one folder — `Image/Upscaling` and
// `image/upscaling` collapse to a single subtree. Display label is the
// most-common original casing seen for that key (ties → first-seen, by
// Map iteration order).
// Groups every user pick by a single semantic theme — independent of which
// pack the node ships from. Picks-only (no REPOS-driven auto-pulled nodes)
// because the user's mental model is "show me my image-related favorites in
// one place," not "show me every image-related node ComfyUI knows about."
//
// Output shape (recursive `CategoryNode`):
// {
// children: Map<canonicalKey, CategoryNode>, // sub-categories
// displayCounts: Map<originalCasing, count>, // for label resolution
// nodes: [{ type, display, packLabel, isUserPick, unresolved? }],
// }
// The root node has empty `displayCounts` (it's never rendered as a folder).
// Theme extraction (`extractTheme`):
// 1. Read the node's CATEGORY string from its registered class.
// 2. Look up the source pack via `findPackPathForType` (the same
// precedence the toolbar `+` button uses for spotlight).
// 3. If the FIRST CATEGORY segment matches the pack label (after
// canonical-key normalization — case + whitespace + underscore +
// hyphen folded), strip it. The next segment is the theme.
// 4. Otherwise the first segment is already the theme.
// 5. Canonicalize the theme key for cross-pack merging so
// `KJNodes/image/...`, `Koolook/Image/...`, and `image/...` all
// land in the same bucket regardless of casing.
//
// Output shape (flat — no nested folders within a theme):
// Map<canonicalThemeKey, {
// displayLabels: Map<originalCasing, count>, // for label resolution
// nodes: [{ type, display, isUserPick, unresolved? }],
// }>
// =============================================================================
// Canonical key for a single category-path segment. Lowercase + strip
@@ -658,10 +666,9 @@ function canonicalSegment(s) {
// Resolves a folder's display label from the count map of original
// casings seen. Picks the most common; ties → first-seen. Map iteration
// is insertion order, which mirrors `Object.entries(registry)` walk
// order in `gatherNodesByCategory` — stable across renders for a given
// registry state. Empty maps are only valid for synthetic buckets like
// `(uncategorized)`, where the caller pre-seeds the label.
// is insertion order, which mirrors the gather walk order — stable
// across renders for a given registry state. Empty maps are only valid
// for synthetic buckets where the caller pre-seeds the label.
function pickDisplayLabel(displayCounts) {
let best = null;
let bestCount = -1;
@@ -674,110 +681,241 @@ function pickDisplayLabel(displayCounts) {
return best;
}
function ensureSyntheticBucket(root, label) {
if (!root.children.has(label)) {
root.children.set(label, {
displayCounts: new Map([[label, 1]]),
children: new Map(),
nodes: [],
});
// =============================================================================
// Theme classifier — broad semantic buckets via keyword substring match.
//
// The strict canonical-segment approach (`image` ≠ `images` ≠ `Image-Save`)
// produces too many tiny buckets — "image", "images", "HQ-Image-Save",
// "rgthree/Image Comparer", "upscaling/Upscale Image By" all fragment apart
// even though the user mentally lumps them all under "Image."
//
// This classifier folds anything that mentions an image-related word
// (image / img / pixel / color / upscale / resize / etc.) into a single
// "Image" bucket regardless of which pack or subcategory the node lives
// under. First classifier whose pattern matches the search corpus wins.
//
// Corpus = pack name + full category path + node display title + node ID,
// lower-cased. Searching all four fields catches:
// • "HQ-Image-Save" (pack name has "image")
// • "rgthree/Image Comparer" (display title has "image")
// • "EasyAIPipeline" (type ID has "pipeline")
//
// Keyword choices favor breadth — adjust this list when a future pick
// lands in the wrong bucket. Patterns deeper in the list act as fallbacks
// for picks the broader earlier patterns missed.
// =============================================================================
const THEME_CLASSIFIERS = [
{
key: "image",
label: "Image",
patterns: [
/\bimg\b/i, /image/i, /pixel/i,
/color/i, /\bocio\b/i, /\bexr\b/i, /\bhdr\b/i,
/upscal/i, /resize/i, /comparer/i, /\btile\b/i,
/frame/i, /thumbnail/i,
],
},
{
key: "video",
label: "Video",
patterns: [/video/i, /\bwan\b/i, /hunyuan/i, /cogvideo/i, /\bltx\b/i, /motion/i, /sequence/i],
},
{
key: "mask",
label: "Mask",
patterns: [/\bmask/i, /\balpha\b/i, /segment/i, /matte/i, /rmbg/i],
},
{
key: "audio",
label: "Audio",
patterns: [/audio/i, /\bwav\b/i, /\bsound\b/i],
},
{
key: "model",
label: "Model",
patterns: [
/model/i, /checkpoint/i, /\blora\b/i, /\bvae\b/i, /\bclip\b/i,
/controlnet/i, /ipadapter/i, /diffusion/i, /\bunet\b/i,
],
},
{
key: "sampler",
label: "Sampler",
patterns: [/sampler/i, /scheduler/i, /\bnoise\b/i, /\bsigma\b/i],
},
{
key: "conditioning",
label: "Conditioning",
patterns: [/condition/i, /\bembed/i, /tokeniz/i],
},
{
key: "text",
label: "Text & Prompt",
patterns: [/\btext\b/i, /prompt/i, /\bstring\b/i, /caption/i],
},
{
key: "math",
label: "Math & Logic",
patterns: [
/\bmath\b/i, /\blogic\b/i, /\bint\b/i, /\bfloat\b/i, /\bbool/i,
/switch/i, /\bcompare\b/i, /numeric/i,
],
},
{
key: "pipeline",
label: "Pipeline & Batch",
patterns: [/pipeline/i, /workflow/i, /\bbatch\b/i, /\bcache\b/i],
},
{
key: "io",
label: "I/O",
patterns: [/\bload\b/i, /\bsave\b/i, /\bfile\b/i, /\bpath\b/i, /import/i, /export/i],
},
{
key: "camera",
label: "Camera",
patterns: [/camera/i, /\blens\b/i, /\bpose\b/i, /\btrack/i],
},
{
key: "utility",
label: "Utility",
// `\bget\b` / `\bset\b` are kept short on purpose — they're the
// last-resort net for trivial getter/setter nodes that didn't hit
// any earlier classifier. Do NOT generalize them to `/get/` or
// `/set/` (no boundaries) without re-walking the upstream pattern
// list — un-anchored variants would fish picks out of node names
// like "AssetGet…" that should land in their own theme.
patterns: [/\butil/i, /helper/i, /\btool\b/i, /\bget\b/i, /\bset\b/i, /reroute/i],
},
];
// Returns `{key, label}` of the first classifier whose patterns match the
// corpus, or `null` if no classifier matched. Walked in order — broader
// upstream classifiers preempt downstream ones. "Image Comparer (rgthree)"
// lands in Image because the corpus contains the word "image", which the
// Image classifier's `/image/i` pattern matches; ordering would also have
// protected it (Math is below Image), but the substring hit comes first.
function classifyTheme(corpus) {
for (const cls of THEME_CLASSIFIERS) {
for (const pat of cls.patterns) {
if (pat.test(corpus)) return { key: cls.key, label: cls.label };
}
}
return root.children.get(label);
return null;
}
// Recursive count of every leaf node under a CategoryNode (including itself
// and all descendants). Used to drive each folder's header count so a
// parent that contains only sub-folders still shows a meaningful total.
function countNodesInCategoryNode(node) {
let c = node.nodes.length;
for (const child of node.children.values()) c += countNodesInCategoryNode(child);
return c;
// Per-pick: classify into one of the broad themes above, falling back to
// the pack-prefix-stripped first CATEGORY segment when no classifier
// matched. Returns one of:
// { kind: "unresolved" } — pack not loaded, no registry entry
// { kind: "uncategorized" } — pack loaded but CATEGORY empty/missing
// { kind: "theme", themeRaw, themeCanonical }
//
// The fallback path keeps long-tail picks (a niche pack with no recognizable
// keywords) in their original-segment bucket rather than collapsing every
// unrecognized thing into one "(other)" pile.
function extractTheme(typeName, registry) {
const nc = registry[typeName];
if (!nc) return { kind: "unresolved" };
const cat = nc.category || "";
const display = nc.title || typeName;
const loc = findPackPathForType(typeName);
const packLabel = loc?.packLabel || "";
// Search corpus combines every signal we have about this node so a
// keyword in any of them — pack name, category subtree, display title,
// raw type ID — counts. No `.toLowerCase()` needed: every classifier
// pattern carries the `/i` flag.
const corpus = `${packLabel} ${cat} ${display} ${typeName}`;
const classified = classifyTheme(corpus);
if (classified) {
return {
kind: "theme",
themeRaw: classified.label,
themeCanonical: classified.key,
};
}
// Fallback: original pack-prefix-stripped first-segment behavior, so a
// niche pick from "MyPack/specialFoo/SomeNode" lands in "specialFoo"
// rather than dumping everything unmatched into "(other)".
const segs = cat.split("/").map(s => s.trim()).filter(Boolean);
if (segs.length === 0) return { kind: "uncategorized" };
let themeIdx = 0;
if (loc && loc.packLabel && segs.length > 1) {
if (canonicalSegment(loc.packLabel) === canonicalSegment(segs[0])) {
themeIdx = 1;
}
}
const themeRaw = segs[themeIdx];
if (!themeRaw) return { kind: "uncategorized" };
return {
kind: "theme",
themeRaw,
themeCanonical: canonicalSegment(themeRaw),
};
}
function gatherNodesByCategory(query) {
function gatherNodesByTheme(query) {
const q = (query || "").trim().toLowerCase();
const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {};
const userPicks = new Set(loadUserPicks());
const userPicks = loadUserPicks();
// Phase 1: collect the same union as repo-mode — REPOS-driven auto-pulled
// nodes plus user picks. `candidates` is keyed by node type so the two
// sources merge cleanly (a user pick that's also in a REPOS pack only
// appears once, but is still flagged removable).
const candidates = new Map();
for (const repo of REPOS) {
const root = repo.categoryRoot || "";
if (repo.select !== "all" || !root) continue;
for (const [type, nc] of Object.entries(registry)) {
const cat = (nc && nc.category) || "";
if (cat !== root && !cat.startsWith(root + "/")) continue;
const display = (nc && nc.title) || type;
if (repo.excludePatterns && repo.excludePatterns.some(re => re.test(display))) continue;
candidates.set(type, { display, category: cat, isUserPick: false });
}
}
for (const type of userPicks) {
if (candidates.has(type)) {
candidates.get(type).isUserPick = true;
continue;
}
const nc = registry[type];
if (!nc) {
// Pack not currently loaded. Repo mode silently drops these;
// category mode surfaces them under "(unresolved)" so the user
// can see what they picked even before installing the pack.
candidates.set(type, { display: type, category: null, isUserPick: true, unresolved: true });
continue;
}
const cat = nc.category || "";
const display = nc.title || type;
candidates.set(type, { display, category: cat, isUserPick: true });
}
// Phase 2: build the canonical-key tree from the collected candidates,
// applying the search filter at leaf insertion time.
const root = { displayCounts: new Map(), children: new Map(), nodes: [] };
for (const [type, info] of candidates) {
if (!matchesQuery(info.display, type, q)) continue;
if (info.unresolved) {
const bucket = ensureSyntheticBucket(root, "(unresolved)");
bucket.nodes.push({
type,
display: info.display,
packLabel: null,
isUserPick: true,
unresolved: true,
// Map<canonicalThemeKey, { displayLabels: Map<rawLabel, count>, nodes: [...] }>
const themes = new Map();
const ensureBucket = (key, seedLabel) => {
if (!themes.has(key)) {
themes.set(key, {
displayLabels: seedLabel ? new Map([[seedLabel, 1]]) : new Map(),
nodes: [],
});
}
return themes.get(key);
};
for (const type of userPicks) {
const result = extractTheme(type, registry);
if (result.kind === "unresolved") {
// Pack not loaded — surface the pick under (unresolved) so the
// user can see what they picked even before installing the pack.
// Display falls back to the bare type since `nc.title` is unreachable.
if (!matchesQuery(type, type, q)) continue;
const bucket = ensureBucket("(unresolved)", "(unresolved)");
bucket.nodes.push({ type, display: type, isUserPick: true, unresolved: true });
continue;
}
const cat = info.category || "";
const segs = cat.split("/").map(s => s.trim()).filter(Boolean);
if (segs.length === 0) {
const bucket = ensureSyntheticBucket(root, "(uncategorized)");
bucket.nodes.push({ type, display: info.display, packLabel: null, isUserPick: info.isUserPick });
// `nc` is guaranteed non-null here: extractTheme() above already
// bailed with `kind: "unresolved"` for any type missing from the
// registry, and we caught that branch before reaching this line.
const nc = registry[type];
const display = nc.title || type;
if (!matchesQuery(display, type, q)) continue;
if (result.kind === "uncategorized") {
const bucket = ensureBucket("(uncategorized)", "(uncategorized)");
bucket.nodes.push({ type, display, isUserPick: true });
continue;
}
// Pack label = first segment. In ComfyUI, custom-node packs typically
// namespace their categories with the pack name (e.g. `KJNodes/image`),
// so the first segment is a serviceable proxy for "which pack does
// this come from" — used for the small badge on category-mode leaves.
const packLabel = segs[0];
let cur = root;
for (const seg of segs) {
const ck = canonicalSegment(seg);
if (!cur.children.has(ck)) {
cur.children.set(ck, {
displayCounts: new Map(),
children: new Map(),
nodes: [],
});
}
const nextNode = cur.children.get(ck);
nextNode.displayCounts.set(seg, (nextNode.displayCounts.get(seg) || 0) + 1);
cur = nextNode;
}
cur.nodes.push({ type, display: info.display, packLabel, isUserPick: info.isUserPick });
const bucket = ensureBucket(result.themeCanonical);
bucket.displayLabels.set(
result.themeRaw,
(bucket.displayLabels.get(result.themeRaw) || 0) + 1,
);
bucket.nodes.push({ type, display, isUserPick: true });
}
return root;
return themes;
}
// Total-leaf count across every theme bucket. Drives the section header's
// count badge so the user sees "N picks" regardless of theme distribution.
function countNodesInThemes(themes) {
let c = 0;
for (const bucket of themes.values()) c += bucket.nodes.length;
return c;
}
// =============================================================================
@@ -993,7 +1131,7 @@ function makeFolderRow({ name, count, iconKind, onToggle, onContextMenu, draggab
return { row, chevron };
}
function makeNodeLeafRow({ display, type, removable, onClick, packBadge, unresolved, breadcrumb }) {
function makeNodeLeafRow({ display, type, removable, onClick, unresolved, breadcrumb }) {
const row = document.createElement("div");
row.className = "koolook-row koolook-leaf";
if (unresolved) row.classList.add("koolook-leaf-unresolved");
@@ -1029,19 +1167,6 @@ function makeNodeLeafRow({ display, type, removable, onClick, packBadge, unresol
}
row.appendChild(nameEl);
// Pack badge — only emitted in category mode by the caller. The first
// segment of the node's CATEGORY path doubles as a serviceable pack
// label (most custom-node packs namespace categories with the pack
// name), so users still see "where this came from" even when the tree
// is grouped by type rather than source.
if (packBadge) {
const badge = document.createElement("span");
badge.className = "koolook-pack-badge";
badge.textContent = packBadge;
badge.title = `Pack: ${packBadge}`;
row.appendChild(badge);
}
if (removable) {
const rm = document.createElement("span");
rm.className = "koolook-remove";
@@ -1509,8 +1634,8 @@ addSection({
gather(q) {
const mode = loadGroupMode();
if (mode === "category") {
const tree = gatherNodesByCategory(q);
return { mode, tree, total: countNodesInCategoryNode(tree) };
const themes = gatherNodesByTheme(q);
return { mode, themes, total: countNodesInThemes(themes) };
}
const auto = gatherNodesByRepo(q);
const user = gatherUserPickPacks(q);
@@ -1533,7 +1658,7 @@ addSection({
return;
}
if (ctx.data.mode === "category") {
emitCategoryTreeChildren(ctx, ctx.data.tree);
emitThemeChildren(ctx, ctx.data.themes);
return;
}
for (const pack of ctx.data.packs) {
@@ -1568,16 +1693,16 @@ addSection({
});
// Flat-list emitters for the Nodes section under search. Both group modes
// produce the same `{ display, type, breadcrumb, packBadge?, removable,
// unresolved? }` shape so the leaf factory call is uniform downstream.
// Sorted by display name (case-insensitive); breadcrumb is a `›`-joined
// path of the folder labels the leaf would live under in the un-filtered
// tree, so the user retains the spatial-origin signal.
// produce the same `{ display, type, breadcrumb, removable, unresolved? }`
// shape so the leaf factory call is uniform downstream. Sorted by display
// name (case-insensitive); breadcrumb is a `›`-joined path of the folder
// labels the leaf would live under in the un-filtered tree, so the user
// retains the spatial-origin signal.
function emitNodesFlatSearchResults(ctx) {
const items = [];
if (ctx.data.mode === "category") {
collectFlatFromCategoryTree(ctx.data.tree, [], items);
collectFlatFromThemes(ctx.data.themes, items);
} else {
collectFlatFromRepoMode(ctx.data.packs, items);
}
@@ -1588,7 +1713,6 @@ function emitNodesFlatSearchResults(ctx) {
display: item.display,
type: item.type,
breadcrumb: item.breadcrumb,
packBadge: item.packBadge || null,
unresolved: !!item.unresolved,
removable: !!item.removable,
onClick: () => insertNode(item.type),
@@ -1597,25 +1721,22 @@ function emitNodesFlatSearchResults(ctx) {
}
}
function collectFlatFromCategoryTree(node, ancestorLabels, out) {
for (const n of node.nodes) {
out.push({
display: n.display,
type: n.type,
packBadge: n.packLabel || null,
unresolved: !!n.unresolved,
removable: !!n.isUserPick,
breadcrumb: ancestorLabels.length ? ancestorLabels.join(" › ") : null,
});
}
for (const child of node.children.values()) {
const label = pickDisplayLabel(child.displayCounts);
// Defensive: every non-root node gets a non-empty `displayCounts`
// (real categories accumulate in `gatherNodesByCategory`; synthetic
// buckets are pre-seeded). A null label here would indicate a future
// walker forgot to seed — skip rather than emit a `null › Foo` crumb.
if (!label) continue;
collectFlatFromCategoryTree(child, [...ancestorLabels, label], out);
function collectFlatFromThemes(themes, out) {
for (const bucket of themes.values()) {
// Theme label = the most common original casing seen across the
// picks that landed in this bucket. Falls back to the canonical key
// for synthetic buckets where `displayLabels` was pre-seeded with
// the bucket name itself.
const label = pickDisplayLabel(bucket.displayLabels);
for (const n of bucket.nodes) {
out.push({
display: n.display,
type: n.type,
unresolved: !!n.unresolved,
removable: !!n.isUserPick,
breadcrumb: label || null,
});
}
}
}
@@ -1643,38 +1764,41 @@ function collectFlatFromRepoMode(packs, out) {
}
}
// Emits the children of a CategoryNode under `parentCtx`. Recursive: for each
// sub-folder we call back into ourselves with the child node. Folder paths
// use the canonical key (not the resolved display label) so the user's
// expansion state in `pathStates` survives an upstream casing change in
// the most-common label.
function emitCategoryTreeChildren(parentCtx, node) {
const childEntries = [...node.children.entries()]
.map(([ck, child]) => ({
ck,
child,
label: pickDisplayLabel(child.displayCounts) || ck,
// Emits one folder per theme bucket under `parentCtx`. Each theme folder
// holds a flat sorted list of its picks — no further nesting. Folder
// `path` uses the canonical key (not the resolved display label) so the
// user's expansion state in `pathStates` survives a future upstream
// casing change in the most-common label for that theme.
function emitThemeChildren(parentCtx, themes) {
const themeEntries = [...themes.entries()]
.map(([canonical, bucket]) => ({
canonical,
bucket,
label: pickDisplayLabel(bucket.displayLabels) || canonical,
}))
.sort((a, b) => compareNames(a.label, b.label));
for (const { ck, child, label } of childEntries) {
for (const { canonical, bucket, label } of themeEntries) {
parentCtx.folder({
name: label,
count: countNodesInCategoryNode(child),
path: ck,
build: (sub) => emitCategoryTreeChildren(sub, child),
});
}
const sortedNodes = [...node.nodes].sort((a, b) => compareNames(a.display, b.display));
for (const n of sortedNodes) {
parentCtx.leaf({
row: makeNodeLeafRow({
display: n.display,
type: n.type,
removable: !!n.isUserPick,
packBadge: n.packLabel || null,
unresolved: !!n.unresolved,
onClick: () => insertNode(n.type),
}),
count: bucket.nodes.length,
path: canonical,
build: (sub) => {
const sortedNodes = [...bucket.nodes].sort(
(a, b) => compareNames(a.display, b.display),
);
for (const n of sortedNodes) {
sub.leaf({
row: makeNodeLeafRow({
display: n.display,
type: n.type,
removable: !!n.isUserPick,
unresolved: !!n.unresolved,
onClick: () => insertNode(n.type),
}),
});
}
},
});
}
}
@@ -1990,6 +2114,13 @@ export function renderPanel(container) {
searchRow.appendChild(searchWrap);
container.appendChild(searchRow);
// Divider between the search field and the Nodes action bar — gives the
// search field its own visual zone instead of jamming it against the
// first action row beneath it.
const dividerAfterSearch = document.createElement("div");
dividerAfterSearch.className = "koolook-tree-divider";
container.appendChild(dividerAfterSearch);
// ---- Row 2: Nodes action bar ----
const nodesRow = document.createElement("div");
nodesRow.className = "koolook-actions-row";
@@ -2025,13 +2156,13 @@ export function renderPanel(container) {
const repoModeBtn = makeModeBtn(
"repo",
"pi pi-database",
"pi pi-sitemap",
"Group by pack — each repo's nodes under their original category subtree.",
);
const categoryModeBtn = makeModeBtn(
"category",
"pi pi-sitemap",
"Group by category — nodes from any pack sharing a CATEGORY path collapse together (case-insensitive).",
"pi pi-bars",
"Group by theme — picks regrouped by category theme (e.g. all image nodes together) regardless of source pack.",
);
function refreshModeToggle() {
@@ -2046,8 +2177,14 @@ export function renderPanel(container) {
nodesRow.appendChild(modeToggle);
const addBtn = document.createElement("button");
addBtn.className = "koolook-add-btn";
addBtn.textContent = "+";
// Same height as the icon-bar buttons (toolbar pi-* glyphs) — uses an
// explicit `pi pi-plus` icon at the same font-size rather than a 16px "+"
// text glyph that sat taller than the rest of the row. The dusty-green
// accent class flags it as the primary action in the Nodes row.
addBtn.className = "koolook-add-btn koolook-icon-btn koolook-add-btn-green";
const addBtnIcon = document.createElement("i");
addBtnIcon.className = "pi pi-plus";
addBtn.appendChild(addBtnIcon);
addBtn.title = "Add the selected canvas node(s) to favorites";
addBtn.addEventListener("click", () => {
const types = getSelectedNodeTypes();