feat: Node Library sidebar and V2 Search dialog UI/UX updates (#9085)

## Summary

Implement 11 Figma design discrepancies for the Node Library sidebar and
V2 Node Search dialog, aligning the UI with the [Toolbox Figma
design](https://www.figma.com/design/xMFxCziXJe6Denz4dpDGTq/Toolbox?node-id=2074-21394&m=dev).

## Changes

- **What**: Sidebar: reorder tabs (All/Essentials/Blueprints), rename
Custom→Blueprints, uppercase section headers, chevron-left of folder
icon, bookmark-on-hover for node rows, filter dropdown with checkbox
items, sort labels (Categorized/A-Z) with label-left/check-right layout,
hide section headers in A-Z mode. Search dialog: expand filter chips
from 3→6, add Recents and source categories to sidebar, remove "Filter
by" label. Pull foundation V2 components from merged PR #8548.
- **Dependencies**: Depends on #8987 (V2 Node Search) and #8548
(NodeLibrarySidebarTabV2)

## Review Focus

- Filter dropdown (`filterOptions`) is UI-scaffolded but not yet wired
to filtering logic (pending V2 integration)
- "Recents" category currently returns frequency-based results as
placeholder until a usage-tracking store is implemented
- Pre-existing type errors from V2 PR dependencies not in the base
commit (SearchBoxV2, usePerTabState, TextTicker, getProviderIcon,
getLinkTypeColor, SidebarContainerKey) are expected and will resolve
when rebased onto main after parent PRs land

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9085-feat-Node-Library-sidebar-and-V2-Search-dialog-Figma-design-improvements-30f6d73d36508175bf72d716f5904476)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2026-02-28 06:34:27 -08:00
committed by GitHub
parent a0e518aa98
commit 3f497081ee
38 changed files with 757 additions and 381 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -41,7 +41,9 @@ const config: KnipConfig = {
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml'
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -16,7 +16,7 @@
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,clip-text-encode,get-video-components,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@custom-variant touch (@media (hover: none));
@@ -634,6 +634,14 @@
}
}
@utility highlight {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {

View File

@@ -0,0 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 9C12.7761 9 13 9.22386 13 9.5V20C13 20.2761 13.2239 20.5 13.5 20.5H28C28.2761 20.5 28.5 20.7239 28.5 21C28.5 21.2761 28.2761 21.5 28 21.5H13.5C12.6716 21.5 12 20.8284 12 20V9.5C12 9.22386 12.2239 9 12.5 9ZM14.5 7C14.7761 7 15 7.22386 15 7.5V18C15 18.2761 15.2239 18.5 15.5 18.5H30C30.2761 18.5 30.5 18.7239 30.5 19C30.5 19.2761 30.2761 19.5 30 19.5H15.5C14.6716 19.5 14 18.8284 14 18V7.5C14 7.22386 14.2239 7 14.5 7ZM16.5 5C16.7761 5 17 5.22386 17 5.5V16C17 16.2761 17.2239 16.5 17.5 16.5H32C32.2761 16.5 32.5 16.7239 32.5 17C32.5 17.2761 32.2761 17.5 32 17.5H17.5C16.6716 17.5 16 16.8284 16 16V5.5C16 5.22386 16.2239 5 16.5 5ZM33.7061 2.5C34.4126 2.5 34.9999 3.08968 35 3.7998V14.2002C34.9999 14.9103 34.4126 15.5 33.7061 15.5H19.2939C18.5874 15.5 18.0001 14.9103 18 14.2002V3.7998C18.0001 3.08968 18.5874 2.5 19.2939 2.5H33.7061ZM19.1084 12.2676V14.2002C19.1085 14.3124 19.1814 14.3856 19.293 14.3857H33.7061C33.8179 14.3857 33.8915 14.3125 33.8916 14.2002V12.6094L30.7207 10.0615L28.1055 11.873C27.9107 12.005 27.6299 11.9923 27.4473 11.8438L23.8896 8.95312L19.1084 12.2676ZM19.2939 3.61426C19.1821 3.61426 19.1085 3.68744 19.1084 3.7998V10.9092L23.5957 7.79883C23.6707 7.74519 23.7587 7.71107 23.8496 7.7002C23.9954 7.68428 24.1465 7.72944 24.2598 7.82227L27.8164 10.7178L30.4385 8.90723C30.6334 8.7753 30.9141 8.78784 31.0967 8.93652L33.8916 11.1826V3.7998C33.8915 3.68747 33.8179 3.61426 33.7061 3.61426H19.2939ZM27.7939 5.09961C28.7054 5.09987 29.4561 5.8554 29.4561 6.77148C29.456 7.68754 28.7054 8.44213 27.7939 8.44238C26.8823 8.44238 26.1309 7.6877 26.1309 6.77148C26.1309 5.85524 26.8823 5.09961 27.7939 5.09961ZM27.7939 6.21387C27.4814 6.21387 27.2393 6.45737 27.2393 6.77148C27.2393 7.08557 27.4814 7.32812 27.7939 7.32812C28.1062 7.32788 28.3476 7.08542 28.3477 6.77148C28.3477 6.45752 28.1063 6.21411 27.7939 6.21387Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.12 4.65979C26.0073 3.75304 27.4985 3.73975 28.3787 4.65979L31.3397 7.62073H31.3387C32.2523 8.49397 32.253 10.0028 31.3182 10.8785L31.3192 10.8795L23.62 18.5797L22.5096 19.6891V7.27112L22.7 7.08069L25.12 4.65979Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M32.3396 13.8499C33.6177 13.8499 34.65 14.8804 34.6501 16.1594V20.3401C34.6501 21.6199 33.618 22.6506 32.3396 22.6506H20.3503L21.4597 21.5403L29.1501 13.8499H32.3396Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M17.7604 17.2496C17.1991 17.2496 16.7498 17.6986 16.7497 18.2594C16.7497 18.8208 17.1995 19.2701 17.7604 19.2701C18.3065 19.2699 18.7702 18.8157 18.7702 18.2594C18.7701 17.6982 18.3211 17.2499 17.7604 17.2496ZM22.1706 18.2399C22.1706 20.6987 20.2192 22.6498 17.7604 22.65C15.2992 22.65 13.3493 20.677 13.3493 18.2399V3.65979C13.3494 2.38005 14.3815 1.34933 15.6598 1.34924H19.8405C21.1222 1.34934 22.1421 2.38132 22.1706 3.64514V18.2399Z" stroke="#8A8A8A" stroke-width="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,7 +1,11 @@
<template>
<span
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
:class="textColorClass"
:class="
cn(
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs',
textColorClass
)
"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -9,12 +10,25 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
vi.mock('@/stores/nodeBookmarkStore', () => ({
useNodeBookmarkStore: () => ({
isBookmarked: vi.fn().mockReturnValue(false),
toggleBookmark: vi.fn()
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
@@ -78,6 +92,7 @@ describe('TreeExplorerV2Node', () => {
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }

View File

@@ -24,6 +24,25 @@
{{ item.value.label }}
</slot>
</span>
<button
:class="
cn(
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground hover:text-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
</div>
<!-- Folder -->
@@ -33,6 +52,15 @@
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
<i
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
/>
@@ -41,15 +69,6 @@
{{ item.value.label }}
</slot>
</span>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] mr-4 size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
</div>
</TreeItem>
@@ -73,6 +92,7 @@ import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -93,9 +113,21 @@ const emit = defineEmits<{
}>()
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeDef = computed(() => item.value.data)
const isBookmarked = computed(() => {
if (!nodeDef.value) return false
return nodeBookmarkStore.isBookmarked(nodeDef.value)
})
function toggleBookmark() {
if (nodeDef.value) {
nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
}
const {
previewRef,
showPreview,

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-(--base-background) border border-(--border-default)"
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-base-background border border-border-default"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
@@ -100,7 +100,7 @@ import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphN
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const SCALE_FACTOR = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24 // p-3 top + bottom (12px × 2)
const PREVIEW_CONTAINER_PADDING_PX = 24
const {
nodeDef,

View File

@@ -50,7 +50,8 @@ describe('NodeSearchCategorySidebar', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
essentials_category: 'basic'
essentials_category: 'basic',
python_module: 'comfy_essentials'
})
])
await nextTick()
@@ -58,9 +59,13 @@ describe('NodeSearchCategorySidebar', () => {
const wrapper = await createWrapper()
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Recents')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Custom')
expect(wrapper.text()).toContain('Blueprints')
expect(wrapper.text()).toContain('Partner')
expect(wrapper.text()).toContain('Comfy')
expect(wrapper.text()).toContain('Extensions')
})
it('should mark the selected preset category as selected', async () => {

View File

@@ -53,7 +53,6 @@ import NodeSearchCategoryTreeNode, {
CATEGORY_UNSELECTED_CLASS
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
@@ -65,11 +64,11 @@ const selectedCategory = defineModel<string>('selectedCategory', {
})
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
{ id: 'most-relevant', label: t('g.mostRelevant') },
{ id: 'recents', label: t('g.recents') },
{ id: 'favorites', label: t('g.favorites') }
])
@@ -81,10 +80,18 @@ const hasEssentialNodes = computed(() =>
const sourceCategories = computed(() => {
const categories = []
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
if (hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push({ id: 'custom', label: t('g.custom') })
categories.push(
{
id: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
},
{ id: 'partner', label: t('g.partner') },
{ id: 'comfy', label: t('g.comfy') },
{ id: 'extensions', label: t('g.extensions') }
)
return categories
})

View File

@@ -132,7 +132,7 @@ describe('NodeSearchContent', () => {
expect(wrapper.text()).toContain('No results')
})
it('should show only non-Core nodes when Custom is selected', async () => {
it('should show only CustomNodes when Extensions is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
@@ -155,7 +155,7 @@ describe('NodeSearchContent', () => {
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-custom"]').trigger('click')
await wrapper.find('[data-testid="category-extensions"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)

View File

@@ -19,9 +19,6 @@
<!-- Filter header row -->
<div class="flex items-center">
<div class="shrink-0 px-3 py-2 text-sm text-muted-foreground">
{{ $t('g.filterBy') }}
</div>
<NodeSearchFilterBar
class="flex-1"
:active-chip-key="activeFilter?.key"
@@ -128,7 +125,6 @@ const searchQuery = ref('')
const selectedCategory = ref('most-relevant')
const selectedIndex = ref(0)
// Filter selection mode
const activeFilter = ref<FilterChip | null>(null)
const filterQuery = ref('')
@@ -171,7 +167,6 @@ function cancelFilter() {
nextTick(() => searchInputRef.value?.focus())
}
// Node search
const searchResults = computed(() => {
if (!searchQuery.value && filters.length === 0) {
return nodeFrequencyStore.topNodeDefs
@@ -212,11 +207,24 @@ const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
break
case 'custom':
case 'recents':
return searchResults.value
case 'blueprints':
results = allNodes.filter(
(n) =>
n.nodeSource.type !== NodeSourceType.Core &&
n.nodeSource.type !== NodeSourceType.Essentials
(n) => n.nodeSource.type === NodeSourceType.Blueprint
)
break
case 'partner':
results = allNodes.filter((n) => n.api_node)
break
case 'comfy':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Core
)
break
case 'extensions':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.CustomNodes
)
break
default:
@@ -247,7 +255,6 @@ watch([selectedCategory, searchQuery, () => filters], () => {
selectedIndex.value = 0
})
// Keyboard navigation
function onKeyDown() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(1)

View File

@@ -39,14 +39,17 @@ describe(NodeSearchFilterBar, () => {
return wrapper
}
it('should render Input, Output, and Source filter chips', async () => {
it('should render all filter chips', async () => {
const wrapper = await createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
expect(buttons[0].text()).toBe('Input')
expect(buttons[1].text()).toBe('Output')
expect(buttons[2].text()).toBe('Source')
expect(buttons).toHaveLength(6)
expect(buttons[0].text()).toBe('Blueprints')
expect(buttons[1].text()).toBe('Partner Nodes')
expect(buttons[2].text()).toBe('Essentials')
expect(buttons[3].text()).toBe('Extensions')
expect(buttons[4].text()).toBe('Input')
expect(buttons[5].text()).toBe('Output')
})
it('should mark active chip as pressed when activeChipKey matches', async () => {

View File

@@ -52,6 +52,26 @@ const nodeDefStore = useNodeDefStore()
const chips = computed<FilterChip[]>(() => {
const searchService = nodeDefStore.nodeSearchService
return [
{
key: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints'),
filter: searchService.nodeSourceFilter
},
{
key: 'partnerNodes',
label: t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'),
filter: searchService.nodeSourceFilter
},
{
key: 'essentials',
label: t('g.essentials'),
filter: searchService.nodeSourceFilter
},
{
key: 'extensions',
label: t('g.extensions'),
filter: searchService.nodeSourceFilter
},
{
key: 'input',
label: t('g.input'),
@@ -61,11 +81,6 @@ const chips = computed<FilterChip[]>(() => {
key: 'output',
label: t('g.output'),
filter: searchService.outputTypeFilter
},
{
key: 'source',
label: t('g.source'),
filter: searchService.nodeSourceFilter
}
]
})

View File

@@ -123,13 +123,3 @@ const nodeFrequency = computed(() =>
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
</script>
<style scoped>
:deep(.highlight) {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
</style>

View File

@@ -37,15 +37,27 @@ export const testI18n = createI18n({
addNode: 'Add a node...',
filterBy: 'Filter by:',
mostRelevant: 'Most relevant',
recents: 'Recents',
favorites: 'Favorites',
essentials: 'Essentials',
custom: 'Custom',
comfy: 'Comfy',
partner: 'Partner',
extensions: 'Extensions',
noResults: 'No results',
filterByType: 'Filter by {type}...',
input: 'Input',
output: 'Output',
source: 'Source',
search: 'Search'
},
sideToolbar: {
nodeLibraryTab: {
filterOptions: {
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes'
}
}
}
}
}

View File

@@ -46,10 +46,10 @@ vi.mock('./nodeLibrary/AllNodesPanel.vue', () => ({
}
}))
vi.mock('./nodeLibrary/CustomNodesPanel.vue', () => ({
vi.mock('./nodeLibrary/BlueprintsPanel.vue', () => ({
default: {
name: 'CustomNodesPanel',
template: '<div data-testid="custom-panel"><slot /></div>',
name: 'BlueprintsPanel',
template: '<div data-testid="blueprints-panel"><slot /></div>',
props: ['sections', 'expandedKeys']
}
}))
@@ -58,7 +58,7 @@ vi.mock('./nodeLibrary/EssentialNodesPanel.vue', () => ({
default: {
name: 'EssentialNodesPanel',
template: '<div data-testid="essential-panel"><slot /></div>',
props: ['root', 'expandedKeys']
props: ['root', 'expandedKeys', 'flatNodes']
}
}))
@@ -127,6 +127,8 @@ describe('NodeLibrarySidebarTabV2', () => {
expect(wrapper.find('[data-testid="essential-panel"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="all-panel"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="custom-panel"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="blueprints-panel"]').exists()).toBe(
false
)
})
})

View File

@@ -29,17 +29,79 @@
v-for="option in sortingOptions"
:key="option.id"
:value="option.id"
class="flex cursor-pointer items-center justify-end gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{ $t(option.label) }}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
<span>{{ $t(option.label) }}</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<button
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
class="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-comfy-input hover:bg-comfy-input-hover border-none"
>
<i class="icon-[lucide--list-filter] size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="z-[9999] min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
align="end"
:side-offset="4"
>
<DropdownMenuCheckboxItem
v-model="filterOptions.blueprints"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.partnerNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.comfyNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.comfyNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.extensions"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.extensions')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<Separator decorative class="border border-dashed border-comfy-input" />
<!-- Tab list in header (fixed) -->
@@ -52,7 +114,7 @@
:value="tab.value"
:class="
cn(
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'text-sm text-foreground transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
@@ -75,6 +137,7 @@
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
<AllNodesPanel
@@ -82,12 +145,13 @@
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
<CustomNodesPanel
v-if="selectedTab === 'custom'"
<BlueprintsPanel
v-if="selectedTab === 'blueprints'"
v-model:expanded-keys="expandedKeys"
:sections="renderedCustomSections"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabsRoot>
@@ -99,6 +163,7 @@
import { cn } from '@/utils/tailwindUtil'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuPortal,
@@ -125,17 +190,23 @@ import {
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { getProviderIcon } from '@/utils/categoryUtil'
import { sortedTree } from '@/utils/treeUtil'
import { flattenTree, sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { SortingStrategyId, TabId } from '@/types/nodeOrganizationTypes'
import { buildNodeDefTree, useNodeDefStore } from '@/stores/nodeDefStore'
import type {
NodeCategoryId,
NodeSection,
SortingStrategyId,
TabId
} from '@/types/nodeOrganizationTypes'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import CustomNodesPanel from './nodeLibrary/CustomNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
@@ -161,7 +232,7 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
{
essentials: DEFAULT_SORTING_ID,
all: DEFAULT_SORTING_ID,
custom: 'alphabetical'
blueprints: 'alphabetical'
}
)
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
@@ -173,14 +244,21 @@ const sortingOptions = computed(() =>
}))
)
const filterOptions = ref<Record<NodeCategoryId, boolean>>({
blueprints: true,
partnerNodes: true,
comfyNodes: true,
extensions: true
})
const { t } = useI18n()
const searchBoxRef = ref()
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
const searchQuery = ref('')
const expandedKeysByTab = ref<Record<TabId, string[]>>({
essentials: [],
all: [],
custom: []
blueprints: []
})
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
@@ -213,8 +291,8 @@ const sections = computed(() => {
function getFolderIcon(node: TreeNode): string {
const firstLeaf = findFirstLeaf(node)
if (
firstLeaf?.key?.startsWith('root/api node') &&
firstLeaf.key.replace(`${node.key}/`, '') === firstLeaf.label
firstLeaf?.data?.api_node &&
firstLeaf.key?.replace(`${node.key}/`, '') === firstLeaf.label
) {
return getProviderIcon(node.label ?? '')
}
@@ -264,12 +342,33 @@ function applySorting(tree: TreeNode): TreeNode {
return tree
}
const renderedSections = computed(() => {
return sections.value.map((section) => ({
function renderSections(
nodeSections: NodeSection[],
filter?: (section: NodeSection) => boolean
): NodeLibrarySection<ComfyNodeDefImpl>[] {
const filtered = filter ? nodeSections.filter(filter) : nodeSections
if (sortOrder.value === 'alphabetical') {
const allNodes = filtered.flatMap((section) =>
flattenTree<ComfyNodeDefImpl>(section.tree)
)
const mergedTree = unwrapTreeRoot(buildNodeDefTree(allNodes))
return [{ root: fillNodeInfo(applySorting(mergedTree)) }]
}
return filtered.map((section) => ({
category: section.category,
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
})
}
const renderedSections = computed(() =>
renderSections(
sections.value,
(section) => !section.category || filterOptions.value[section.category]
)
)
const essentialSections = computed(() => {
if (selectedTab.value !== 'essentials') return []
@@ -286,18 +385,32 @@ const renderedEssentialRoot = computed(() => {
: fillNodeInfo({ key: 'root', label: '', children: [] })
})
const customSections = computed(() => {
if (selectedTab.value !== 'custom') return []
return nodeOrganizationService.organizeNodesByTab(activeNodes.value, 'custom')
function flattenRenderedLeaves(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
): RenderedTreeExplorerNode<ComfyNodeDefImpl>[] {
if (node.type === 'node') return [node]
return node.children?.flatMap(flattenRenderedLeaves) ?? []
}
const essentialFlatNodes = computed(() => {
if (sortOrder.value !== 'alphabetical') return []
return flattenRenderedLeaves(renderedEssentialRoot.value).sort((a, b) =>
(a.label ?? '').localeCompare(b.label ?? '')
)
})
const renderedCustomSections = computed(() => {
return customSections.value.map((section) => ({
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
const blueprintsSections = computed(() => {
if (selectedTab.value !== 'blueprints') return []
return nodeOrganizationService.organizeNodesByTab(
activeNodes.value,
'blueprints'
)
})
const renderedBlueprintsSections = computed(() =>
renderSections(blueprintsSections.value)
)
function collectFolderKeys(node: TreeNode): string[] {
if (node.leaf) return []
const keys = [node.key]
@@ -334,8 +447,8 @@ async function handleSearch() {
for (const section of essentialSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else if (selectedTab.value === 'custom') {
for (const section of customSections.value) {
} else if (selectedTab.value === 'blueprints') {
for (const section of blueprintsSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else {
@@ -347,19 +460,18 @@ async function handleSearch() {
}
const tabs = computed(() => {
const baseTabs: Array<{ value: TabId; label: string }> = [
const allTabs: Array<{ value: TabId; label: string }> = [
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
{
value: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.blueprints')
}
]
return flags.nodeLibraryEssentialsEnabled
? [
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
...baseTabs
]
: baseTabs
return flags.nodeLibraryEssentialsEnabled ? allTabs : [allTabs[0], allTabs[2]]
})
onMounted(() => {

View File

@@ -1,6 +1,5 @@
<template>
<div
ref="containerRef"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col w-full',
@@ -37,17 +36,9 @@
</div>
</template>
<script lang="ts">
import type { InjectionKey, Ref } from 'vue'
export const SidebarContainerKey: InjectionKey<Ref<HTMLElement | null>> =
Symbol('SidebarContainer')
</script>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import Toolbar from 'primevue/toolbar'
import { provide, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
@@ -58,7 +49,4 @@ const props = defineProps<{
const sidebarPt = {
start: 'min-w-0 flex-1 overflow-hidden'
}
const containerRef = ref<HTMLElement | null>(null)
provide(SidebarContainerKey, containerRef)
</script>

View File

@@ -1,28 +1,30 @@
<template>
<TabsContent value="all" class="flex-1 overflow-y-auto h-full">
<!-- Favorites section -->
<template v-if="hasFavorites">
<h3
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
</template>
<h3
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ $t('sideToolbar.nodeLibraryTab.sections.bookmarked') }}
</h3>
<TreeExplorerV2
v-if="hasFavorites"
v-model:expanded-keys="expandedKeys"
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
<div v-else class="px-6 py-2 text-xs text-muted-background">
{{ $t('sideToolbar.nodeLibraryTab.noBookmarkedNodes') }}
</div>
<!-- Node sections -->
<div v-for="(section, index) in sections" :key="section.title ?? index">
<div v-for="(section, index) in sections" :key="section.category ?? index">
<h3
v-if="section.title"
class="px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground mb-0"
v-if="section.category && sortOrder !== 'alphabetical'"
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ section.title }}
{{ $t(NODE_CATEGORY_LABELS[section.category]) }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
@@ -42,15 +44,17 @@ import { computed } from 'vue'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NODE_CATEGORY_LABELS } from '@/types/nodeOrganizationTypes'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
const { fillNodeInfo } = defineProps<{
sections: NodeLibrarySection[]
const { fillNodeInfo, sortOrder = 'original' } = defineProps<{
sections: NodeLibrarySection<ComfyNodeDefImpl>[]
fillNodeInfo: (node: TreeNode) => RenderedTreeExplorerNode<ComfyNodeDefImpl>
sortOrder?: string
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })

View File

@@ -0,0 +1,39 @@
<template>
<TabsContent value="blueprints" class="flex-1 overflow-y-auto h-full">
<div v-for="(section, index) in sections" :key="section.title ?? index">
<h3
v-if="section.title"
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ $t(section.title) }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="section.root"
:show-context-menu="false"
@node-click="(node) => emit('nodeClick', node)"
/>
</div>
</TabsContent>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
defineProps<{
sections: NodeLibrarySection<ComfyNodeDefImpl>[]
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
const emit = defineEmits<{
nodeClick: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
</script>

View File

@@ -8,7 +8,7 @@
<!-- Section header -->
<h3
v-if="section.title"
class="px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground mb-0"
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ section.title }}
</h3>
@@ -46,7 +46,7 @@ import type {
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
defineProps<{
sections: NodeLibrarySection[]
sections: NodeLibrarySection<ComfyNodeDefImpl>[]
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })

View File

@@ -35,11 +35,11 @@
<script setup lang="ts">
import { kebabCase } from 'es-toolkit/string'
import type { Ref } from 'vue'
import { computed, inject } from 'vue'
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -49,11 +49,15 @@ const { node } = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
}>()
const panelRef = inject<Ref<HTMLElement | null>>(
'essentialsPanelRef',
undefined!
)
const emit = defineEmits<{
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const panelRef = inject(SidebarContainerKey, undefined)
const nodeDef = computed(() => node.data)
const {
@@ -64,7 +68,7 @@ const {
handleMouseLeave,
handleDragStart,
handleDragEnd
} = useNodePreviewAndDrag(nodeDef, { panelRef })
} = useNodePreviewAndDrag(nodeDef, panelRef)
const nodeIcon = computed(() => {
const nodeName = node.data?.name

View File

@@ -82,14 +82,15 @@ describe('EssentialNodesPanel', () => {
function mountComponent(
root = createMockRoot(),
expandedKeys: string[] = []
expandedKeys: string[] = [],
flatNodes: RenderedTreeExplorerNode<ComfyNodeDefImpl>[] = []
) {
const WrapperComponent = {
template: `<EssentialNodesPanel :root="root" v-model:expandedKeys="keys" />`,
template: `<EssentialNodesPanel :root="root" :flat-nodes="flatNodes" v-model:expandedKeys="keys" />`,
components: { EssentialNodesPanel },
setup() {
const keys = ref(expandedKeys)
return { root, keys }
return { root, flatNodes, keys }
}
}
return mount(WrapperComponent, {
@@ -204,4 +205,20 @@ describe('EssentialNodesPanel', () => {
expect(cards.length).toBeGreaterThanOrEqual(2)
})
})
describe('flat nodes mode', () => {
it('should render flat grid without collapsible folders when flatNodes is provided', () => {
const flatNodes = [
createMockNode('LoadAudio'),
createMockNode('LoadImage'),
createMockNode('SaveImage')
]
const wrapper = mountComponent(createMockRoot(), [], flatNodes)
expect(wrapper.findAll('.collapsible-root')).toHaveLength(0)
const cards = wrapper.findAllComponents({ name: 'EssentialNodeCard' })
expect(cards).toHaveLength(3)
})
})
})

View File

@@ -1,41 +1,61 @@
<template>
<TabsContent value="essentials" class="flex-1 overflow-y-auto px-3 h-full">
<TabsContent
ref="panelEl"
value="essentials"
class="flex-1 overflow-y-auto px-3 h-full"
>
<div class="flex flex-col gap-2 pb-6">
<CollapsibleRoot
v-for="folder in folders"
:key="folder.key"
class="rounded-lg"
:open="expandedKeys.includes(folder.key)"
@update:open="toggleFolder(folder.key, $event)"
<!-- Flat sorted grid when alphabetical -->
<div
v-if="flatNodes.length > 0"
class="grid grid-cols-[repeat(auto-fill,minmax(5rem,1fr))] gap-3 pt-3"
>
<CollapsibleTrigger
class="group flex w-full cursor-pointer items-center justify-between border-0 bg-transparent py-3 px-1 text-xs font-medium tracking-wide text-muted-foreground h-8 box-content"
<EssentialNodeCard
v-for="node in flatNodes"
:key="node.key"
:node="node"
@click="emit('nodeClick', $event)"
/>
</div>
<!-- Grouped collapsible folders when original -->
<template v-else>
<CollapsibleRoot
v-for="folder in folders"
:key="folder.key"
class="rounded-lg"
:open="expandedKeys.includes(folder.key)"
@update:open="toggleFolder(folder.key, $event)"
>
<span class="uppercase">{{ folder.label }}</span>
<i
:class="
cn(
'icon-[lucide--chevron-up] size-4 transition-transform duration-200',
!expandedKeys.includes(folder.key) && '-rotate-180'
)
"
/>
</CollapsibleTrigger>
<CollapsibleContent
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(5rem,1fr))] gap-3"
<CollapsibleTrigger
class="group flex w-full cursor-pointer items-center justify-between border-0 bg-transparent py-3 px-1 text-xs font-medium tracking-wide text-muted-foreground h-8 box-content"
>
<EssentialNodeCard
v-for="node in folder.children"
:key="node.key"
:node="node"
@click="emit('nodeClick', $event)"
<span class="uppercase">{{ folder.label }}</span>
<i
:class="
cn(
'icon-[lucide--chevron-up] size-4 transition-transform duration-200',
!expandedKeys.includes(folder.key) && '-rotate-180'
)
"
/>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</CollapsibleTrigger>
<CollapsibleContent
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(5rem,1fr))] gap-3"
>
<EssentialNodeCard
v-for="node in folder.children"
:key="node.key"
:node="node"
@click="emit('nodeClick', $event)"
/>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</template>
</div>
</TabsContent>
</template>
@@ -47,16 +67,22 @@ import {
CollapsibleTrigger,
TabsContent
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const panelEl = ref<ComponentPublicInstance | null>(null)
const panelRef = computed(() => panelEl.value?.$el as HTMLElement | null)
provide('essentialsPanelRef', panelRef)
import EssentialNodeCard from './EssentialNodeCard.vue'
const props = defineProps<{
const { root, flatNodes = [] } = defineProps<{
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
flatNodes?: RenderedTreeExplorerNode<ComfyNodeDefImpl>[]
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
@@ -74,7 +100,7 @@ function flattenLeaves(
const folders = computed(() => {
const topFolders =
(props.root.children?.filter(
(root.children?.filter(
(child) => child.type === 'folder'
) as RenderedTreeExplorerNode<ComfyNodeDefImpl>[]) ?? []

View File

@@ -10,7 +10,7 @@ const PREVIEW_MARGIN = 16
export function useNodePreviewAndDrag(
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
options?: { panelRef?: Ref<HTMLElement | null> }
panelRef?: Ref<HTMLElement | null>
) {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
const settingStore = useSettingStore()
@@ -56,8 +56,7 @@ export function useNodePreviewAndDrag(
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const horizontalRect =
options?.panelRef?.value?.getBoundingClientRect() ?? rect
const horizontalRect = panelRef?.value?.getBoundingClientRect() ?? rect
const { left, viewportHeight } = calculatePreviewPosition(horizontalRect)
let top = rect.top

View File

@@ -303,7 +303,9 @@
"beta": "BETA",
"nightly": "NIGHTLY",
"profile": "Profile",
"noItems": "No items"
"noItems": "No items",
"recents": "Recents",
"partner": "Partner"
},
"manager": {
"title": "Nodes Manager",
@@ -790,8 +792,16 @@
"newBlankWorkflow": "Create a new blank workflow",
"nodeLibraryTab": {
"essentials": "Essentials",
"allNodes": "All nodes",
"allNodes": "All",
"blueprints": "Blueprints",
"custom": "Custom",
"filter": "Filter",
"filterOptions": {
"blueprints": "Blueprints",
"partnerNodes": "Partner Nodes",
"comfyNodes": "Comfy Nodes",
"extensions": "Extensions"
},
"groupBy": "Group By",
"sortMode": "Sort Mode",
"resetView": "Reset View to Default",
@@ -804,16 +814,24 @@
"sourceDesc": "Group by source type (Core, Custom, API)"
},
"sortBy": {
"original": "Original",
"original": "Categorized",
"originalDesc": "Keep original order",
"alphabetical": "Alphabetical",
"alphabetical": "A-Z",
"alphabeticalDesc": "Sort alphabetically within groups"
},
"sections": {
"favorites": "Favorites",
"favoriteNode": "Favorite Node",
"unfavoriteNode": "Unfavorite Node"
}
"unfavoriteNode": "Unfavorite Node",
"bookmarked": "Bookmarked",
"subgraphBlueprints": "Subgraph Blueprints",
"myBlueprints": "My Blueprints",
"comfyBlueprints": "Comfy Blueprints",
"partnerNodes": "Partner Nodes",
"comfyNodes": "Comfy Nodes",
"extensions": "Extensions"
},
"noBookmarkedNodes": "No favorites yet"
},
"modelLibrary": "Model Library",
"downloads": "Downloads",

View File

@@ -1,8 +1,6 @@
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import {
ESSENTIALS_CATEGORIES,
ESSENTIALS_NODES
} from '@/constants/essentialsNodes'
import { ESSENTIALS_NODES } from '@/constants/essentialsNodes'
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
@@ -14,11 +12,23 @@ import type {
} from '@/types/nodeOrganizationTypes'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { sortedTree } from '@/utils/treeUtil'
import { upperCase } from 'es-toolkit/string'
import { sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
const DEFAULT_ICON = 'pi pi-sort'
function categoryPathExtractor(nodeDef: ComfyNodeDefImpl): string[] {
const category = nodeDef.category || ''
const categoryParts = category ? category.split('/') : []
return [...categoryParts, nodeDef.name]
}
function isBlueprint(node: ComfyNodeDefImpl): boolean {
return (
node.nodeSource.type === NodeSourceType.Blueprint ||
!!node.python_module?.startsWith('blueprint')
)
}
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export const DEFAULT_TAB_ID = 'all' as const
@@ -30,11 +40,7 @@ class NodeOrganizationService {
label: 'sideToolbar.nodeLibraryTab.groupStrategies.category',
icon: 'pi pi-folder',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.categoryDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const category = nodeDef.category || ''
const categoryParts = category ? category.split('/') : []
return [...categoryParts, nodeDef.name]
}
getNodePath: categoryPathExtractor
},
{
id: 'module',
@@ -125,102 +131,197 @@ class NodeOrganizationService {
nodes: ComfyNodeDefImpl[],
tabId: TabId = DEFAULT_TAB_ID
): NodeSection[] {
const categoryPathExtractor = (nodeDef: ComfyNodeDefImpl) => {
const category = nodeDef.category || ''
const categoryParts = category ? category.split('/') : []
return [...categoryParts, nodeDef.name]
}
switch (tabId) {
case 'essentials': {
const essentialNodes = nodes.filter(
(nodeDef) => nodeDef.essentials_category !== undefined
)
const essentialsPathExtractor = (nodeDef: ComfyNodeDefImpl) => {
const folder = nodeDef.essentials_category || ''
return folder ? [folder, nodeDef.name] : [nodeDef.name]
}
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: essentialsPathExtractor
})
if (tree.children) {
const len = ESSENTIALS_CATEGORIES.length
const originalIndex = new Map(
tree.children.map((child, i) => [child, i])
)
tree.children.sort((a, b) => {
const ai = ESSENTIALS_CATEGORIES.indexOf(
a.label as EssentialsCategory
)
const bi = ESSENTIALS_CATEGORIES.indexOf(
b.label as EssentialsCategory
)
const orderA = ai === -1 ? len + originalIndex.get(a)! : ai
const orderB = bi === -1 ? len + originalIndex.get(b)! : bi
return orderA - orderB
})
for (const folder of tree.children) {
if (!folder.children) continue
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]
if (!order) continue
const nodeOrder: readonly string[] = order
const orderLen = nodeOrder.length
folder.children.sort((a, b) => {
const nameA = a.data?.name ?? a.label ?? ''
const nameB = b.data?.name ?? b.label ?? ''
const ai = nodeOrder.indexOf(nameA)
const bi = nodeOrder.indexOf(nameB)
const orderA = ai === -1 ? orderLen : ai
const orderB = bi === -1 ? orderLen : bi
return orderA - orderB
})
}
}
return [{ tree }]
}
case 'custom': {
const customNodes = nodes.filter(
(nodeDef) => nodeDef.nodeSource.type === NodeSourceType.CustomNodes
)
const groupedByMainCategory = new Map<string, ComfyNodeDefImpl[]>()
for (const node of customNodes) {
const mainCategory = node.main_category ?? 'custom_extensions'
if (!groupedByMainCategory.has(mainCategory)) {
groupedByMainCategory.set(mainCategory, [])
}
groupedByMainCategory.get(mainCategory)!.push(node)
}
return Array.from(groupedByMainCategory.entries()).map(
([mainCategory, categoryNodes]) => ({
title: upperCase(mainCategory),
tree: buildNodeDefTree(categoryNodes, {
pathExtractor: categoryPathExtractor
})
})
)
}
case 'essentials':
return this.organizeEssentials(nodes)
case 'blueprints':
return this.organizeBlueprints(nodes)
case 'all':
default: {
const groupedByMainCategory = new Map<string, ComfyNodeDefImpl[]>()
for (const node of nodes) {
const mainCategory = node.main_category ?? 'basics'
if (!groupedByMainCategory.has(mainCategory)) {
groupedByMainCategory.set(mainCategory, [])
}
groupedByMainCategory.get(mainCategory)!.push(node)
}
default:
return this.organizeAll(nodes)
}
}
return Array.from(groupedByMainCategory.entries()).map(
([mainCategory, categoryNodes]) => ({
title: upperCase(mainCategory),
tree: buildNodeDefTree(categoryNodes, {
pathExtractor: categoryPathExtractor
})
private organizeEssentials(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const essentialNodes = nodes.filter(
(nodeDef) => !!nodeDef.essentials_category
)
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: (nodeDef) => {
const folder = nodeDef.essentials_category || ''
return folder ? [folder, nodeDef.name] : [nodeDef.name]
}
})
this.sortEssentialsFolders(tree)
return [{ tree }]
}
private sortEssentialsFolders(tree: TreeNode): void {
if (!tree.children) return
for (const folder of tree.children) {
if (!folder.children) continue
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]
if (!order) continue
const orderLen = order.length
folder.children.sort((a, b) => {
const ai = order.indexOf(a.data?.name ?? a.label ?? '')
const bi = order.indexOf(b.data?.name ?? b.label ?? '')
return (ai === -1 ? orderLen : ai) - (bi === -1 ? orderLen : bi)
})
}
}
private organizeBlueprints(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const { myBlueprints, comfyBlueprints } = this.partitionBlueprints(nodes)
return [
{
title: 'sideToolbar.nodeLibraryTab.sections.myBlueprints',
tree: unwrapTreeRoot(
buildNodeDefTree(myBlueprints, {
pathExtractor: categoryPathExtractor
})
)
},
{
title: 'sideToolbar.nodeLibraryTab.sections.comfyBlueprints',
tree: unwrapTreeRoot(
buildNodeDefTree(comfyBlueprints, {
pathExtractor: categoryPathExtractor
})
)
}
]
}
private organizeAll(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const {
myBlueprints,
comfyBlueprints,
partnerNodes,
comfyNodes,
extensions
} = this.classifyNodes(nodes)
const blueprintTree = this.buildBlueprintTree(
myBlueprints,
comfyBlueprints,
categoryPathExtractor
)
const sections: NodeSection[] = []
if (blueprintTree.children?.length) {
sections.push({ category: 'blueprints', tree: blueprintTree })
}
if (partnerNodes.length > 0) {
sections.push({
category: 'partnerNodes',
tree: unwrapTreeRoot(
buildNodeDefTree(partnerNodes, {
pathExtractor: categoryPathExtractor
})
)
})
}
if (comfyNodes.length > 0) {
sections.push({
category: 'comfyNodes',
tree: buildNodeDefTree(comfyNodes, {
pathExtractor: categoryPathExtractor
})
})
}
if (extensions.length > 0) {
sections.push({
category: 'extensions',
tree: buildNodeDefTree(extensions, {
pathExtractor: categoryPathExtractor
})
})
}
return sections
}
private partitionBlueprints(nodes: ComfyNodeDefImpl[]): {
myBlueprints: ComfyNodeDefImpl[]
comfyBlueprints: ComfyNodeDefImpl[]
} {
const myBlueprints: ComfyNodeDefImpl[] = []
const comfyBlueprints: ComfyNodeDefImpl[] = []
for (const node of nodes) {
if (!isBlueprint(node)) continue
if (node.isGlobal) comfyBlueprints.push(node)
else myBlueprints.push(node)
}
return { myBlueprints, comfyBlueprints }
}
private classifyNodes(nodes: ComfyNodeDefImpl[]): {
myBlueprints: ComfyNodeDefImpl[]
comfyBlueprints: ComfyNodeDefImpl[]
partnerNodes: ComfyNodeDefImpl[]
comfyNodes: ComfyNodeDefImpl[]
extensions: ComfyNodeDefImpl[]
} {
const myBlueprints: ComfyNodeDefImpl[] = []
const comfyBlueprints: ComfyNodeDefImpl[] = []
const partnerNodes: ComfyNodeDefImpl[] = []
const comfyNodes: ComfyNodeDefImpl[] = []
const extensions: ComfyNodeDefImpl[] = []
for (const node of nodes) {
if (isBlueprint(node)) {
if (node.isGlobal) comfyBlueprints.push(node)
else myBlueprints.push(node)
} else if (node.api_node || node.category?.startsWith('api node')) {
partnerNodes.push(node)
} else if (
node.nodeSource.type === NodeSourceType.Core ||
node.nodeSource.type === NodeSourceType.Essentials
) {
comfyNodes.push(node)
} else {
extensions.push(node)
}
}
return {
myBlueprints,
comfyBlueprints,
partnerNodes,
comfyNodes,
extensions
}
}
private buildBlueprintTree(
myBlueprints: ComfyNodeDefImpl[],
comfyBlueprints: ComfyNodeDefImpl[],
pathExtractor: (nodeDef: ComfyNodeDefImpl) => string[]
): TreeNode {
const children: TreeNode[] = []
if (myBlueprints.length > 0) {
const tree = unwrapTreeRoot(
buildNodeDefTree(myBlueprints, { pathExtractor })
)
children.push({
key: 'root/my-blueprints',
label: t('sideToolbar.nodeLibraryTab.sections.myBlueprints'),
children: tree.children
})
}
if (comfyBlueprints.length > 0) {
const tree = unwrapTreeRoot(
buildNodeDefTree(comfyBlueprints, { pathExtractor })
)
children.push({
key: 'root/comfy-blueprints',
label: t('sideToolbar.nodeLibraryTab.sections.comfyBlueprints'),
children: tree.children
})
}
return { key: 'root', label: '', children }
}
organizeNodes(

View File

@@ -23,11 +23,8 @@ import type {
import { useSettingStore } from '@/platform/settings/settingStore'
import { NodeSearchService } from '@/services/nodeSearchService'
import { useSubgraphStore } from '@/stores/subgraphStore'
import {
CORE_NODE_MODULES,
getEssentialsCategory,
getNodeSource
} from '@/types/nodeSource'
import { ESSENTIALS_CATEGORY_CANONICAL } from '@/constants/essentialsNodes'
import { CORE_NODE_MODULES, getNodeSource } from '@/types/nodeSource'
import type { NodeSource } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { FuseSearchable, SearchAuxScore } from '@/utils/fuseUtil'
@@ -164,11 +161,11 @@ export class ComfyNodeDefImpl
this.output_tooltips = obj.output_tooltips
this.input_order = obj.input_order
this.price_badge = obj.price_badge
// Resolve essentials_category from API or fallback to mock data
this.essentials_category = getEssentialsCategory(
obj.name,
obj.essentials_category
)
this.essentials_category = obj.essentials_category
? (ESSENTIALS_CATEGORY_CANONICAL.get(
obj.essentials_category.toLowerCase()
) ?? obj.essentials_category)
: undefined
this.isGlobal = obj.isGlobal
this.isCoreNode = CORE_NODE_MODULES.includes(
this.python_module.split('.')[0]
@@ -181,11 +178,7 @@ export class ComfyNodeDefImpl
this.hidden = defV2.hidden
// Initialize node source
this.nodeSource = getNodeSource(
obj.python_module,
this.essentials_category,
this.name
)
this.nodeSource = getNodeSource(obj.python_module, this.essentials_category)
}
get nodePath(): string {

View File

@@ -3,7 +3,7 @@ import type { TreeNode } from '@/types/treeExplorerTypes'
export type GroupingStrategyId = 'category' | 'module' | 'source'
export type SortingStrategyId = 'original' | 'alphabetical'
export type TabId = 'essentials' | 'all' | 'custom'
export type TabId = 'essentials' | 'all' | 'blueprints'
/**
* Strategy for grouping nodes into tree structure
@@ -45,11 +45,23 @@ export interface NodeOrganizationOptions {
sortBy?: string
}
/**
* A section of nodes with an optional header title
*/
export type NodeCategoryId =
| 'blueprints'
| 'partnerNodes'
| 'comfyNodes'
| 'extensions'
export const NODE_CATEGORY_LABELS: Record<NodeCategoryId, string> = {
blueprints: 'sideToolbar.nodeLibraryTab.sections.subgraphBlueprints',
partnerNodes: 'sideToolbar.nodeLibraryTab.sections.partnerNodes',
comfyNodes: 'sideToolbar.nodeLibraryTab.sections.comfyNodes',
extensions: 'sideToolbar.nodeLibraryTab.sections.extensions'
}
export interface NodeSection {
/** Section title (i18n key), optional */
/** Filter category identifier */
category?: NodeCategoryId
/** Section title (i18n key) for tabs that don't use category-based labels */
title?: string
/** Tree of nodes in this section */
tree: TreeNode

View File

@@ -1,10 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
NodeSourceType,
getEssentialsCategory,
getNodeSource
} from '@/types/nodeSource'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
describe('getNodeSource', () => {
it('should return UNKNOWN_NODE_SOURCE when python_module is undefined', () => {
@@ -87,8 +83,7 @@ describe('getNodeSource', () => {
it('should identify essentials nodes from custom_nodes module', () => {
const result = getNodeSource(
'custom_nodes.ComfyUI-Example@1.0.0',
'Video',
'SomeNode'
'Video'
)
expect(result.type).toBe(NodeSourceType.Essentials)
expect(result.className).toBe('comfy-essentials')
@@ -96,49 +91,9 @@ describe('getNodeSource', () => {
})
it('should not identify nodes without essentials_category as essentials', () => {
// Use a node name not in the mock list
const result = getNodeSource(
'nodes.some_module',
undefined,
'UnknownNode'
)
const result = getNodeSource('nodes.some_module', undefined)
expect(result.type).toBe(NodeSourceType.Core)
})
it('should identify nodes from mock list as essentials', () => {
const result = getNodeSource('nodes.some_module', undefined, 'LoadImage')
expect(result.type).toBe(NodeSourceType.Essentials)
})
it('should normalize title-cased backend categories to canonical form', () => {
const result = getNodeSource(
'nodes.some_module',
'Image Generation',
'SomeNode'
)
expect(result.type).toBe(NodeSourceType.Essentials)
expect(getEssentialsCategory('SomeNode', 'Image Generation')).toBe(
'image generation'
)
})
})
describe('getEssentialsCategory', () => {
it('should normalize title-cased essentials_category to canonical form', () => {
expect(getEssentialsCategory('SomeNode', 'Image Generation')).toBe(
'image generation'
)
expect(getEssentialsCategory('SomeNode', '3d')).toBe('3D')
expect(getEssentialsCategory('SomeNode', '3D')).toBe('3D')
})
it('should fall back to ESSENTIALS_CATEGORY_MAP when no category provided', () => {
expect(getEssentialsCategory('LoadImage')).toBe('basics')
})
it('should return undefined for unknown node without category', () => {
expect(getEssentialsCategory('UnknownNode')).toBeUndefined()
})
})
describe('blueprint nodes', () => {

View File

@@ -1,8 +1,3 @@
import {
ESSENTIALS_CATEGORY_CANONICAL,
ESSENTIALS_CATEGORY_MAP
} from '@/constants/essentialsNodes'
export enum NodeSourceType {
Core = 'core',
CustomNodes = 'custom_nodes',
@@ -26,41 +21,21 @@ const UNKNOWN_NODE_SOURCE: NodeSource = {
badgeText: '?'
}
const shortenNodeName = (name: string) => {
function shortenNodeName(name: string) {
return name
.replace(/^(ComfyUI-|ComfyUI_|Comfy-|Comfy_)/, '')
.replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '')
}
/**
* Get the essentials category for a node, falling back to mock data if not provided.
*/
export function getEssentialsCategory(
name?: string,
essentials_category?: string
): string | undefined {
const normalizedCategory = essentials_category?.trim().toLowerCase()
const canonical = normalizedCategory
? (ESSENTIALS_CATEGORY_CANONICAL.get(normalizedCategory) ??
normalizedCategory)
: undefined
return canonical ?? (name ? ESSENTIALS_CATEGORY_MAP[name] : undefined)
}
export const getNodeSource = (
export function getNodeSource(
python_module?: string,
essentials_category?: string,
name?: string
): NodeSource => {
const resolvedEssentialsCategory = getEssentialsCategory(
name,
essentials_category
)
essentials_category?: string
): NodeSource {
if (!python_module) {
return UNKNOWN_NODE_SOURCE
}
const modules = python_module.split('.')
if (resolvedEssentialsCategory) {
if (essentials_category) {
const moduleName = modules[1] ?? modules[0] ?? 'essentials'
const displayName = shortenNodeName(moduleName.split('@')[0])
return {
@@ -85,6 +60,9 @@ export const getNodeSource = (
}
} else if (modules[0] === 'custom_nodes') {
const moduleName = modules[1]
if (!moduleName) {
return UNKNOWN_NODE_SOURCE
}
const customNodeName = moduleName.split('@')[0]
const displayName = shortenNodeName(customNodeName)
return {

View File

@@ -1,4 +1,5 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { NodeCategoryId } from '@/types/nodeOrganizationTypes'
import type { MenuItem } from 'primevue/menuitem'
import type { TreeNode as PrimeVueTreeNode } from 'primevue/treenode'
import type { InjectionKey, ModelRef, Ref } from 'vue'
@@ -8,9 +9,10 @@ export interface TreeNode extends PrimeVueTreeNode {
children?: this[]
}
export interface NodeLibrarySection {
export interface NodeLibrarySection<T = unknown> {
category?: NodeCategoryId
title?: string
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
root: RenderedTreeExplorerNode<T>
}
export interface TreeExplorerNode<T = unknown> extends TreeNode {

View File

@@ -39,6 +39,21 @@ export function buildTree<T>(items: T[], key: (item: T) => string[]): TreeNode {
return root
}
/**
* If a tree root has exactly one non-leaf child folder, promote that
* folder's children up to the root level, removing the redundant layer.
*/
export function unwrapTreeRoot(tree: TreeNode): TreeNode {
if (
tree.children?.length === 1 &&
!tree.children[0].leaf &&
(tree.children[0].children?.length ?? 0) > 0
) {
return { ...tree, children: tree.children[0].children }
}
return tree
}
export function flattenTree<T>(tree: TreeNode): T[] {
const result: T[] = []
const stack: TreeNode[] = [tree]