mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-04 21:22:07 +00:00
V2 Node Search (+ hidden Node Library changes) (#8987)
## Summary Redesigned node search with categories ## Changes - **What**: Adds a v2 search component, leaving the existing implementation untouched - It also brings onboard the incomplete node library & preview changes, disabled and behind a hidden setting - **Breaking**: Changes the 'default' value of the node search setting to v2, adding v1 (legacy) as an option ## Screenshots (if applicable) https://github.com/user-attachments/assets/2ab797df-58f0-48e8-8b20-2a1809e3735f ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8987-V2-Node-Search-hidden-Node-Library-changes-30c6d73d36508160902bcb92553f147c) by [Unito](https://www.unito.io) --------- Co-authored-by: Yourz <crazilou@vip.qq.com> Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
77
src/components/sidebar/tabs/nodeLibrary/AllNodesPanel.vue
Normal file
77
src/components/sidebar/tabs/nodeLibrary/AllNodesPanel.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<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"
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Node sections -->
|
||||
<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 tracking-wide text-muted-foreground mb-0"
|
||||
>
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
<TreeExplorerV2
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="section.root"
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TabsContent } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type {
|
||||
NodeLibrarySection,
|
||||
RenderedTreeExplorerNode,
|
||||
TreeNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
|
||||
const { fillNodeInfo } = defineProps<{
|
||||
sections: NodeLibrarySection[]
|
||||
fillNodeInfo: (node: TreeNode) => RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
}>()
|
||||
|
||||
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
nodeClick: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const hasFavorites = computed(
|
||||
() => (nodeBookmarkStore.bookmarkedRoot.children?.length ?? 0) > 0
|
||||
)
|
||||
|
||||
const favoritesRoot = computed(() =>
|
||||
fillNodeInfo(nodeBookmarkStore.bookmarkedRoot)
|
||||
)
|
||||
|
||||
function handleAddToFavorites(
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
) {
|
||||
if (node.data) {
|
||||
nodeBookmarkStore.toggleBookmark(node.data)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
63
src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue
Normal file
63
src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<TabsContent value="custom" class="flex-1 flex flex-col h-full">
|
||||
<div
|
||||
v-for="(section, index) in sections"
|
||||
:key="section.title ?? index"
|
||||
class="flex-1 overflow-y-auto h-full"
|
||||
>
|
||||
<!-- Section header -->
|
||||
<h3
|
||||
v-if="section.title"
|
||||
class="px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground mb-0"
|
||||
>
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
<!-- Section tree -->
|
||||
<TreeExplorerV2
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="section.root"
|
||||
:show-context-menu="false"
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-none py-3 border-t border-border-default text-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="justify-start gap-3"
|
||||
@click="handleOpenManager"
|
||||
>
|
||||
<i class="icon-[lucide--blocks] size-5 text-muted-foreground" />
|
||||
{{ $t('g.manageExtensions') }}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TabsContent } from 'reka-ui'
|
||||
|
||||
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type {
|
||||
NodeLibrarySection,
|
||||
RenderedTreeExplorerNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
|
||||
defineProps<{
|
||||
sections: NodeLibrarySection[]
|
||||
}>()
|
||||
|
||||
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
nodeClick: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const managerState = useManagerState()
|
||||
|
||||
async function handleOpenManager() {
|
||||
await managerState.openManager()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import EssentialNodeCard from './EssentialNodeCard.vue'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn().mockReturnValue('left')
|
||||
})
|
||||
}))
|
||||
|
||||
const { mockStartDrag, mockHandleNativeDrop } = vi.hoisted(() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockHandleNativeDrop: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
|
||||
default: { template: '<div class="mock-preview" />' }
|
||||
}))
|
||||
|
||||
describe('EssentialNodeCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function createMockNode(
|
||||
overrides: Partial<ComfyNodeDefImpl> = {}
|
||||
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
|
||||
const data = {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
...overrides
|
||||
} as ComfyNodeDefImpl
|
||||
|
||||
return {
|
||||
key: 'test-key',
|
||||
label: 'Test Node',
|
||||
icon: 'icon-[comfy--node]',
|
||||
type: 'node',
|
||||
totalLeaves: 1,
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl> = createMockNode()
|
||||
) {
|
||||
return mount(EssentialNodeCard, {
|
||||
props: { node },
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should display the node display_name', () => {
|
||||
const wrapper = mountComponent(
|
||||
createMockNode({ display_name: 'Load Image' })
|
||||
)
|
||||
expect(wrapper.text()).toContain('Load Image')
|
||||
})
|
||||
|
||||
it('should set data-node-name attribute', () => {
|
||||
const wrapper = mountComponent(
|
||||
createMockNode({ display_name: 'Save Image' })
|
||||
)
|
||||
const card = wrapper.find('[data-node-name]')
|
||||
expect(card.attributes('data-node-name')).toBe('Save Image')
|
||||
})
|
||||
|
||||
it('should be draggable', () => {
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('[draggable]')
|
||||
expect(card.attributes('draggable')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon generation', () => {
|
||||
it('should use kebab-case of node name for icon', () => {
|
||||
const wrapper = mountComponent(createMockNode({ name: 'LoadImage' }))
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain('icon-[comfy--load-image]')
|
||||
})
|
||||
|
||||
it('should use kebab-case for SaveImage', () => {
|
||||
const wrapper = mountComponent(createMockNode({ name: 'SaveImage' }))
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain('icon-[comfy--save-image]')
|
||||
})
|
||||
|
||||
it('should use kebab-case for ImageCrop', () => {
|
||||
const wrapper = mountComponent(createMockNode({ name: 'ImageCrop' }))
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain('icon-[comfy--image-crop]')
|
||||
})
|
||||
|
||||
it('should use kebab-case for complex node names', () => {
|
||||
const wrapper = mountComponent(
|
||||
createMockNode({ name: 'RecraftRemoveBackgroundNode' })
|
||||
)
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain(
|
||||
'icon-[comfy--recraft-remove-background-node]'
|
||||
)
|
||||
})
|
||||
|
||||
it('should use default node icon when nodeDef has no name', () => {
|
||||
const node: RenderedTreeExplorerNode<ComfyNodeDefImpl> = {
|
||||
key: 'test-key',
|
||||
label: 'Test',
|
||||
icon: 'icon',
|
||||
type: 'node',
|
||||
totalLeaves: 1,
|
||||
data: undefined
|
||||
}
|
||||
const wrapper = mountComponent(node)
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain('icon-[comfy--node]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('should emit click event when clicked', async () => {
|
||||
const node = createMockNode()
|
||||
const wrapper = mountComponent(node)
|
||||
|
||||
await wrapper.find('div').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
expect(wrapper.emitted('click')?.[0]).toEqual([node])
|
||||
})
|
||||
|
||||
it('should not emit click when nodeDef is undefined', async () => {
|
||||
const node: RenderedTreeExplorerNode<ComfyNodeDefImpl> = {
|
||||
key: 'test-key',
|
||||
label: 'Test',
|
||||
icon: 'icon',
|
||||
type: 'node',
|
||||
totalLeaves: 1,
|
||||
data: undefined
|
||||
}
|
||||
const wrapper = mountComponent(node)
|
||||
|
||||
await wrapper.find('div').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag and drop', () => {
|
||||
it('should call startDrag on dragstart', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('div')
|
||||
|
||||
await card.trigger('dragstart')
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleNativeDrop on dragend', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('div')
|
||||
|
||||
await card.trigger('dragend')
|
||||
|
||||
expect(mockHandleNativeDrop).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hover preview', () => {
|
||||
it('should show preview on mouseenter', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('div')
|
||||
|
||||
await card.trigger('mouseenter')
|
||||
|
||||
expect(wrapper.find('teleport-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide preview after mouseleave', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('div')
|
||||
|
||||
await card.trigger('mouseenter')
|
||||
expect(wrapper.find('teleport-stub').exists()).toBe(true)
|
||||
|
||||
await card.trigger('mouseleave')
|
||||
expect(wrapper.find('teleport-stub').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content',
|
||||
'bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border',
|
||||
'aspect-square'
|
||||
)
|
||||
"
|
||||
:data-node-name="nodeDef?.display_name"
|
||||
draggable="true"
|
||||
@click="handleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<i :class="cn(nodeIcon, 'size-14 text-muted-foreground')" />
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 h-8 text-sm font-bold text-center text-foreground line-clamp-2 leading-4"
|
||||
>
|
||||
{{ nodeDef?.display_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Teleport v-if="showPreview" to="body">
|
||||
<div
|
||||
:ref="(el) => (previewRef = el as HTMLElement)"
|
||||
:style="nodePreviewStyle"
|
||||
>
|
||||
<NodePreviewCard :node-def="nodeDef!" :show-inputs-and-outputs="false" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { kebabCase } from 'es-toolkit/string'
|
||||
import { computed, inject } from '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'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const nodeDef = computed(() => node.data)
|
||||
|
||||
const panelRef = inject(SidebarContainerKey, undefined)
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
showPreview,
|
||||
nodePreviewStyle,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleDragStart,
|
||||
handleDragEnd
|
||||
} = useNodePreviewAndDrag(nodeDef, { panelRef })
|
||||
|
||||
const nodeIcon = computed(() => {
|
||||
const nodeName = nodeDef.value?.name
|
||||
const iconName = nodeName ? kebabCase(nodeName) : 'node'
|
||||
return `icon-[comfy--${iconName}]`
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (!nodeDef.value) return
|
||||
emit('click', node)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,207 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import EssentialNodesPanel from './EssentialNodesPanel.vue'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn().mockReturnValue('left')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: vi.fn(),
|
||||
handleNativeDrop: vi.fn(),
|
||||
cancelDrag: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
|
||||
describe('EssentialNodesPanel', () => {
|
||||
function createMockNode(
|
||||
name: string
|
||||
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
|
||||
return {
|
||||
key: `node-${name}`,
|
||||
label: name,
|
||||
icon: 'icon-[comfy--node]',
|
||||
type: 'node',
|
||||
totalLeaves: 1,
|
||||
data: {
|
||||
name,
|
||||
display_name: name
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
}
|
||||
|
||||
function createMockFolder(
|
||||
name: string,
|
||||
children: RenderedTreeExplorerNode<ComfyNodeDefImpl>[]
|
||||
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
|
||||
return {
|
||||
key: `folder-${name}`,
|
||||
label: name,
|
||||
icon: 'icon-[lucide--folder]',
|
||||
type: 'folder',
|
||||
totalLeaves: children.length,
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
function createMockRoot(): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
|
||||
return {
|
||||
key: 'root',
|
||||
label: 'Root',
|
||||
icon: '',
|
||||
type: 'folder',
|
||||
totalLeaves: 6,
|
||||
children: [
|
||||
createMockFolder('images', [
|
||||
createMockNode('LoadImage'),
|
||||
createMockNode('SaveImage')
|
||||
]),
|
||||
createMockFolder('video', [
|
||||
createMockNode('LoadVideo'),
|
||||
createMockNode('SaveVideo')
|
||||
]),
|
||||
createMockFolder('audio', [
|
||||
createMockNode('LoadAudio'),
|
||||
createMockNode('SaveAudio')
|
||||
])
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
root = createMockRoot(),
|
||||
expandedKeys: string[] = []
|
||||
) {
|
||||
const WrapperComponent = {
|
||||
template: `<EssentialNodesPanel :root="root" v-model:expandedKeys="keys" />`,
|
||||
components: { EssentialNodesPanel },
|
||||
setup() {
|
||||
const keys = ref(expandedKeys)
|
||||
return { root, keys }
|
||||
}
|
||||
}
|
||||
return mount(WrapperComponent, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
TabsContent: {
|
||||
template: '<div class="tabs-content"><slot /></div>'
|
||||
},
|
||||
CollapsibleRoot: {
|
||||
template:
|
||||
'<div class="collapsible-root" :data-state="open ? \'open\' : \'closed\'"><slot /></div>',
|
||||
props: ['open'],
|
||||
emits: ['update:open']
|
||||
},
|
||||
CollapsibleTrigger: {
|
||||
template:
|
||||
'<button class="collapsible-trigger" @click="$emit(\'click\')"><slot /></button>'
|
||||
},
|
||||
CollapsibleContent: {
|
||||
template: '<div class="collapsible-content"><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('folder rendering', () => {
|
||||
it('should render all top-level folders', () => {
|
||||
const wrapper = mountComponent()
|
||||
const triggers = wrapper.findAll('.collapsible-trigger')
|
||||
expect(triggers).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should display folder labels', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('images')
|
||||
expect(wrapper.text()).toContain('video')
|
||||
expect(wrapper.text()).toContain('audio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default expansion', () => {
|
||||
it('should expand all folders by default when expandedKeys is empty', async () => {
|
||||
const wrapper = mountComponent(createMockRoot(), [])
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const roots = wrapper.findAll('.collapsible-root')
|
||||
expect(roots[0].attributes('data-state')).toBe('open')
|
||||
expect(roots[1].attributes('data-state')).toBe('open')
|
||||
expect(roots[2].attributes('data-state')).toBe('open')
|
||||
})
|
||||
|
||||
it('should respect provided expandedKeys', async () => {
|
||||
const wrapper = mountComponent(createMockRoot(), ['folder-audio'])
|
||||
await nextTick()
|
||||
|
||||
const roots = wrapper.findAll('.collapsible-root')
|
||||
expect(roots[0].attributes('data-state')).toBe('closed')
|
||||
expect(roots[1].attributes('data-state')).toBe('closed')
|
||||
expect(roots[2].attributes('data-state')).toBe('open')
|
||||
})
|
||||
|
||||
it('should expand all provided keys', async () => {
|
||||
const wrapper = mountComponent(createMockRoot(), [
|
||||
'folder-images',
|
||||
'folder-video',
|
||||
'folder-audio'
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const roots = wrapper.findAll('.collapsible-root')
|
||||
expect(roots[0].attributes('data-state')).toBe('open')
|
||||
expect(roots[1].attributes('data-state')).toBe('open')
|
||||
expect(roots[2].attributes('data-state')).toBe('open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with single folder', () => {
|
||||
it('should expand only one folder when there is only one', async () => {
|
||||
const root: RenderedTreeExplorerNode<ComfyNodeDefImpl> = {
|
||||
key: 'root',
|
||||
label: 'Root',
|
||||
icon: '',
|
||||
type: 'folder',
|
||||
totalLeaves: 2,
|
||||
children: [
|
||||
createMockFolder('images', [
|
||||
createMockNode('LoadImage'),
|
||||
createMockNode('SaveImage')
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(root, [])
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const roots = wrapper.findAll('.collapsible-root')
|
||||
expect(roots).toHaveLength(1)
|
||||
expect(roots[0].attributes('data-state')).toBe('open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('node cards', () => {
|
||||
it('should render node cards for each node in expanded folders', () => {
|
||||
const wrapper = mountComponent(createMockRoot(), ['folder-images'])
|
||||
const cards = wrapper.findAllComponents({ name: 'EssentialNodeCard' })
|
||||
expect(cards.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
109
src/components/sidebar/tabs/nodeLibrary/EssentialNodesPanel.vue
Normal file
109
src/components/sidebar/tabs/nodeLibrary/EssentialNodesPanel.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<TabsContent 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)"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<EssentialNodeCard
|
||||
v-for="node in folder.children"
|
||||
:key="node.key"
|
||||
:node="node"
|
||||
@click="emit('nodeClick', $event)"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
TabsContent
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import EssentialNodeCard from './EssentialNodeCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
}>()
|
||||
|
||||
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
nodeClick: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
function flattenLeaves(
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
): RenderedTreeExplorerNode<ComfyNodeDefImpl>[] {
|
||||
if (node.type === 'node') return [node]
|
||||
return node.children?.flatMap(flattenLeaves) ?? []
|
||||
}
|
||||
|
||||
const folders = computed(() => {
|
||||
const topFolders =
|
||||
(props.root.children?.filter(
|
||||
(child) => child.type === 'folder'
|
||||
) as RenderedTreeExplorerNode<ComfyNodeDefImpl>[]) ?? []
|
||||
|
||||
return topFolders.map((folder) => ({
|
||||
...folder,
|
||||
children: flattenLeaves(folder)
|
||||
}))
|
||||
})
|
||||
|
||||
function toggleFolder(key: string, open: boolean) {
|
||||
if (open) {
|
||||
expandedKeys.value = [...expandedKeys.value, key]
|
||||
} else {
|
||||
expandedKeys.value = expandedKeys.value.filter((k) => k !== key)
|
||||
}
|
||||
}
|
||||
|
||||
const hasAutoExpanded = ref(false)
|
||||
|
||||
watch(
|
||||
folders,
|
||||
(value) => {
|
||||
if (!hasAutoExpanded.value && value.length > 0) {
|
||||
hasAutoExpanded.value = true
|
||||
if (expandedKeys.value.length === 0) {
|
||||
expandedKeys.value = value.map((folder) => folder.key)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
69
src/components/sidebar/tabs/nodeLibrary/NodeDragPreview.vue
Normal file
69
src/components/sidebar/tabs/nodeLibrary/NodeDragPreview.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDragging && draggedNode && showPreview"
|
||||
class="pointer-events-none fixed z-[10000]"
|
||||
:style="{
|
||||
left: `${previewPosition.x + 12}px`,
|
||||
top: `${previewPosition.y + 12}px`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview :node-def="draggedNode" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
} = useNodeDragToCanvas()
|
||||
|
||||
const nativeDragPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const previewPosition = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value
|
||||
}
|
||||
return cursorPosition.value
|
||||
})
|
||||
|
||||
const showPreview = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
function handleDrag(e: DragEvent) {
|
||||
if (e.clientX === 0 && e.clientY === 0) return
|
||||
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
nativeDragPosition.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupGlobalListeners()
|
||||
document.addEventListener('drag', handleDrag)
|
||||
document.addEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupGlobalListeners()
|
||||
document.removeEventListener('drag', handleDrag)
|
||||
document.removeEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user