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

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(