mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 08:49:36 +00:00
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:
@@ -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)" />
|
||||
|
||||
@@ -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 />' }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
39
src/components/sidebar/tabs/nodeLibrary/BlueprintsPanel.vue
Normal file
39
src/components/sidebar/tabs/nodeLibrary/BlueprintsPanel.vue
Normal 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>
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>[]) ?? []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user