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:
co-authored by
Claude Opus 4.7
parent
215616f546
commit
d021e8c8da
@@ -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`
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user