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:
pythongosssss
2026-02-20 09:10:03 +00:00
committed by GitHub
parent 8f5cdead73
commit 6902e38e6a
183 changed files with 7972 additions and 127 deletions

View File

@@ -0,0 +1,89 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import BadgePill from './BadgePill.vue'
describe('BadgePill', () => {
it('renders text content', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Test Badge' }
})
expect(wrapper.text()).toBe('Test Badge')
})
it('renders icon when provided', () => {
const wrapper = mount(BadgePill, {
props: { icon: 'icon-[comfy--credits]', text: 'Credits' }
})
expect(wrapper.find('i.icon-\\[comfy--credits\\]').exists()).toBe(true)
})
it('applies iconClass to icon', () => {
const wrapper = mount(BadgePill, {
props: {
icon: 'icon-[comfy--credits]',
iconClass: 'text-amber-400'
}
})
const icon = wrapper.find('i')
expect(icon.classes()).toContain('text-amber-400')
})
it('uses default border color when no borderStyle', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Default' }
})
expect(wrapper.attributes('style')).toContain(
'border-color: var(--border-color)'
)
})
it('applies solid border color when borderStyle is a color', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Colored', borderStyle: '#f59e0b' }
})
expect(wrapper.attributes('style')).toContain('border-color: #f59e0b')
})
it('applies gradient border when borderStyle contains linear-gradient', () => {
const gradient = 'linear-gradient(90deg, #3186FF, #FABC12)'
const wrapper = mount(BadgePill, {
props: { text: 'Gradient', borderStyle: gradient }
})
const element = wrapper.element as HTMLElement
expect(element.style.borderColor).toBe('transparent')
expect(element.style.backgroundOrigin).toBe('border-box')
expect(element.style.backgroundClip).toBe('padding-box, border-box')
})
it('applies filled style with background and text color', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
})
const style = wrapper.attributes('style')
expect(style).toContain('border-color: #f59e0b')
expect(style).toContain('background-color: #f59e0b33')
expect(style).toContain('color: #f59e0b')
})
it('has foreground text when not filled', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Not Filled', borderStyle: '#f59e0b' }
})
expect(wrapper.classes()).toContain('text-foreground')
})
it('does not have foreground text class when filled', () => {
const wrapper = mount(BadgePill, {
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
})
expect(wrapper.classes()).not.toContain('text-foreground')
})
it('renders slot content', () => {
const wrapper = mount(BadgePill, {
slots: { default: 'Slot Content' }
})
expect(wrapper.text()).toBe('Slot Content')
})
})

View File

@@ -0,0 +1,54 @@
<template>
<span
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
:class="textColorClass"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />
<slot>{{ text }}</slot>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { borderStyle, filled } = defineProps<{
text?: string
icon?: string
iconClass?: string
borderStyle?: string
filled?: boolean
}>()
const textColorClass = computed(() =>
borderStyle && filled ? '' : 'text-foreground'
)
const customStyle = computed(() => {
if (!borderStyle) {
return { borderColor: 'var(--border-color)' }
}
const isGradient = borderStyle.includes('linear-gradient')
if (isGradient) {
return {
borderColor: 'transparent',
backgroundImage: `linear-gradient(var(--base-background), var(--base-background)), ${borderStyle}`,
backgroundOrigin: 'border-box',
backgroundClip: 'padding-box, border-box'
}
}
if (filled) {
return {
borderColor: borderStyle,
backgroundColor: `${borderStyle}33`,
color: borderStyle
}
}
return { borderColor: borderStyle }
})
</script>

View File

@@ -0,0 +1,90 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SearchBoxV2 from './SearchBoxV2.vue'
vi.mock('@vueuse/core', () => ({
watchDebounced: vi.fn(() => vi.fn())
}))
describe('SearchBoxV2', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
clear: 'Clear',
searchPlaceholder: 'Search...'
}
}
}
})
function mountComponent(props = {}) {
return mount(SearchBoxV2, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: {
template: '<div><slot /></div>'
},
ComboboxAnchor: {
template: '<div><slot /></div>'
},
ComboboxInput: {
template:
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['placeholder', 'modelValue', 'autoFocus']
}
}
},
props: {
modelValue: '',
...props
}
})
}
it('uses i18n placeholder when no placeholder prop provided', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
})
it('uses custom placeholder when provided', () => {
const wrapper = mountComponent({
placeholder: 'Custom placeholder'
})
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom placeholder')
})
it('shows search icon when search term is empty', () => {
const wrapper = mountComponent({ modelValue: '' })
expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
})
it('shows clear button when search term is not empty', () => {
const wrapper = mountComponent({ modelValue: 'test' })
expect(wrapper.find('button').exists()).toBe(true)
})
it('clears search term when clear button is clicked', async () => {
const wrapper = mountComponent({ modelValue: 'test' })
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
it('applies large size classes when size is lg', () => {
const wrapper = mountComponent({ size: 'lg' })
expect(wrapper.html()).toContain('size-5')
})
it('applies medium size classes when size is md', () => {
const wrapper = mountComponent({ size: 'md' })
expect(wrapper.html()).toContain('size-4')
})
})

View File

@@ -0,0 +1,117 @@
<template>
<div class="flex flex-col gap-2 flex-auto">
<ComboboxRoot :ignore-filter="true" :open="false">
<ComboboxAnchor
:class="
cn(
'relative flex w-full cursor-text items-center',
'rounded-lg bg-comfy-input text-comfy-input-foreground',
showBorder &&
'border border-solid border-border-default box-border',
sizeClasses,
className
)
"
>
<i
v-if="!searchTerm"
:class="cn('absolute left-4 pointer-events-none', icon, iconClass)"
/>
<Button
v-else
class="absolute left-2"
variant="textonly"
size="icon"
:aria-label="$t('g.clear')"
@click="clearSearch"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<ComboboxInput
ref="inputRef"
v-model="searchTerm"
:class="
cn(
'size-full border-none bg-transparent text-sm outline-none',
inputPadding
)
"
:placeholder="placeholderText"
:auto-focus="autofocus"
/>
</ComboboxAnchor>
</ComboboxRoot>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { watchDebounced } from '@vueuse/core'
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const {
placeholder,
icon = 'icon-[lucide--search]',
debounceTime = 300,
autofocus = false,
showBorder = false,
size = 'md',
class: className
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const emit = defineEmits<{
search: [value: string]
}>()
const searchTerm = defineModel<string>({ required: true })
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
defineExpose({
focus: () => {
inputRef.value?.$el?.focus()
}
})
const isLarge = computed(() => size === 'lg')
const placeholderText = computed(
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
)
const sizeClasses = computed(() => {
if (showBorder) {
return isLarge.value ? 'h-10 p-2' : 'h-8 p-2'
}
return isLarge.value ? 'h-12 px-4 py-2' : 'h-10 px-4 py-2'
})
const iconClass = computed(() => (isLarge.value ? 'size-5' : 'size-4'))
const inputPadding = computed(() => (isLarge.value ? 'pl-8' : 'pl-6'))
function clearSearch() {
searchTerm.value = ''
}
watchDebounced(
searchTerm,
(value: string) => {
emit('search', value)
},
{ debounce: debounceTime }
)
</script>

View File

@@ -0,0 +1,122 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import TextTicker from './TextTicker.vue'
function mockScrollWidth(el: HTMLElement, scrollWidth: number) {
Object.defineProperty(el, 'scrollWidth', {
value: scrollWidth,
configurable: true
})
}
describe(TextTicker, () => {
let rafCallbacks: ((time: number) => void)[]
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
vi.useFakeTimers()
rafCallbacks = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
})
afterEach(() => {
wrapper?.unmount()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('renders slot content', () => {
wrapper = mount(TextTicker, {
slots: { default: 'Hello World' }
})
expect(wrapper.text()).toBe('Hello World')
})
it('scrolls on hover after delay', async () => {
wrapper = mount(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
const el = wrapper.element as HTMLElement
mockScrollWidth(el, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
expect(rafCallbacks.length).toBe(0)
vi.advanceTimersByTime(350)
await nextTick()
expect(rafCallbacks.length).toBeGreaterThan(0)
rafCallbacks[0](performance.now() + 500)
expect(el.scrollLeft).toBeGreaterThan(0)
})
it('cancels delayed scroll on mouse leave before delay elapses', async () => {
wrapper = mount(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
mockScrollWidth(wrapper.element as HTMLElement, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
vi.advanceTimersByTime(200)
await wrapper.trigger('mouseleave')
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()
expect(rafCallbacks.length).toBe(0)
})
it('resets scroll position on mouse leave', async () => {
wrapper = mount(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
const el = wrapper.element as HTMLElement
mockScrollWidth(el, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()
rafCallbacks[0](performance.now() + 500)
expect(el.scrollLeft).toBeGreaterThan(0)
await wrapper.trigger('mouseleave')
await nextTick()
expect(el.scrollLeft).toBe(0)
})
it('does not scroll when content fits', async () => {
wrapper = mount(TextTicker, {
slots: { default: 'Short' }
})
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()
expect(rafCallbacks.length).toBe(0)
})
})

View File

@@ -0,0 +1,69 @@
<template>
<div
ref="containerRef"
:class="
cn('overflow-hidden whitespace-nowrap', !isScrolling && 'text-ellipsis')
"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { useElementHover, useElementSize, useRafFn } from '@vueuse/core'
import { ref, watch } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { speed = 70 } = defineProps<{
/** Scroll speed in pixels per second */
speed?: number
}>()
const containerRef = ref<HTMLElement | null>(null)
const isHovered = useElementHover(containerRef, { delayEnter: 350 })
const { width: containerWidth } = useElementSize(containerRef)
const isScrolling = ref(false)
let scrollStartTime = 0
let overflowAmount = 0
const { pause, resume } = useRafFn(
({ timestamp }) => {
const el = containerRef.value
if (!el || overflowAmount <= 0) return pause()
const elapsed = timestamp - scrollStartTime
const duration = (overflowAmount / speed) * 1000
const progress = Math.min(elapsed / duration, 1)
el.scrollLeft = overflowAmount * progress
if (progress >= 1) pause()
},
{ immediate: false }
)
function startScroll() {
const el = containerRef.value
if (!el) return
overflowAmount = el.scrollWidth - containerWidth.value
if (overflowAmount <= 0) return
isScrolling.value = true
scrollStartTime = performance.now()
resume()
}
function stopScroll() {
pause()
if (containerRef.value) {
containerRef.value.scrollLeft = 0
}
isScrolling.value = false
}
watch(isHovered, (hovered) => {
if (hovered) startScroll()
else stopScroll()
})
</script>

View File

@@ -0,0 +1,113 @@
<template>
<ContextMenuRoot>
<TreeRoot
:expanded="[...expandedKeys]"
:items="root.children ?? []"
:get-key="(item) => item.key"
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 p-0 pb-6"
>
<TreeVirtualizer
v-slot="{ item }"
:estimate-size="36"
:text-content="(item) => item.value.label ?? ''"
>
<TreeExplorerV2Node
:item="
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
"
@node-click="
(node: RenderedTreeExplorerNode<ComfyNodeDefImpl>, e: MouseEvent) =>
emit('nodeClick', node, e)
"
>
<template #folder="{ node }">
<slot name="folder" :node="node" />
</template>
<template #node="{ node }">
<slot name="node" :node="node" />
</template>
</TreeExplorerV2Node>
</TreeVirtualizer>
</TreeRoot>
<ContextMenuPortal v-if="showContextMenu">
<ContextMenuContent
class="z-[9999] min-w-32 overflow-hidden rounded-md border border-border-default bg-comfy-menu-bg p-1 shadow-md"
>
<ContextMenuItem
class="flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-highlight focus:bg-highlight"
@select="handleAddToFavorites"
>
<i
:class="
isCurrentNodeBookmarked
? 'icon-[ph--star-fill]'
: 'icon-[lucide--star]'
"
class="size-4"
/>
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<script setup lang="ts">
import type { FlattenedItem } from 'reka-ui'
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
TreeRoot,
TreeVirtualizer
} from 'reka-ui'
import { computed, provide, ref } from 'vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const { showContextMenu = false } = defineProps<{
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
showContextMenu?: boolean
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', {
default: () => []
})
const emit = defineEmits<{
nodeClick: [
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
event: MouseEvent
]
addToFavorites: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
null
)
provide(InjectKeyContextMenuNode, contextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const isCurrentNodeBookmarked = computed(() => {
const node = contextMenuNode.value
if (!node?.data) return false
return nodeBookmarkStore.isBookmarked(node.data)
})
function handleAddToFavorites() {
if (contextMenuNode.value) {
emit('addToFavorites', contextMenuNode.value)
}
}
</script>

View File

@@ -0,0 +1,321 @@
import { mount } from '@vue/test-utils'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
const mockStartDrag = vi.fn()
const mockHandleNativeDrop = vi.fn()
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
startDrag: mockStartDrag,
handleNativeDrop: mockHandleNativeDrop
})
}))
describe('TreeExplorerV2Node', () => {
function createMockItem(
type: 'node' | 'folder',
overrides: Record<string, unknown> = {}
): FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>> {
const value = {
key: 'test-key',
label: 'Test Label',
type,
icon: 'pi pi-folder',
totalLeaves: 5,
...overrides
} as RenderedTreeExplorerNode<ComfyNodeDefImpl>
return {
_id: 'test-id',
index: 0,
value,
level: 1,
hasChildren: type === 'folder',
bind: { value, level: 1 }
}
}
function createTreeItemStub() {
const handleToggle = vi.fn()
const handleSelect = vi.fn()
return {
handleToggle,
handleSelect,
stub: {
template: `<div data-testid="tree-item"><slot :isExpanded="false" :isSelected="false" :handleToggle="handleToggle" :handleSelect="handleSelect" /></div>`,
setup() {
return { handleToggle, handleSelect }
}
}
}
}
function mountComponent(
props: Record<string, unknown> = {},
options: {
provide?: Record<string, unknown>
treeItemStub?: ReturnType<typeof createTreeItemStub>
} = {}
) {
const treeItemStub = options.treeItemStub ?? createTreeItemStub()
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
stubs: {
TreeItem: treeItemStub.stub,
ContextMenuTrigger: {
name: 'ContextMenuTrigger',
template: '<div data-testid="context-menu-trigger"><slot /></div>'
},
Teleport: { template: '<div />' }
},
provide: {
...options.provide
}
},
props: {
item: createMockItem('node'),
...props
}
}),
treeItemStub
}
}
describe('handleClick', () => {
it('emits nodeClick event when clicked', async () => {
const { wrapper } = mountComponent({
item: createMockItem('node')
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('click')
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(wrapper.emitted('nodeClick')?.[0]?.[0]).toMatchObject({
type: 'node',
label: 'Test Label'
})
})
it('calls handleToggle for folder items', async () => {
const treeItemStub = createTreeItemStub()
const { wrapper } = mountComponent(
{ item: createMockItem('folder') },
{ treeItemStub }
)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('click')
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(treeItemStub.handleToggle).toHaveBeenCalled()
})
it('does not call handleToggle for node items', async () => {
const treeItemStub = createTreeItemStub()
const { wrapper } = mountComponent(
{ item: createMockItem('node') },
{ treeItemStub }
)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('click')
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(treeItemStub.handleToggle).not.toHaveBeenCalled()
})
})
describe('context menu', () => {
it('renders ContextMenuTrigger when showContextMenu is true for nodes', () => {
const { wrapper } = mountComponent({
item: createMockItem('node'),
showContextMenu: true
})
expect(
wrapper.find('[data-testid="context-menu-trigger"]').exists()
).toBe(true)
})
it('does not render ContextMenuTrigger for folder items', () => {
const { wrapper } = mountComponent({
item: createMockItem('folder')
})
expect(
wrapper.find('[data-testid="context-menu-trigger"]').exists()
).toBe(false)
})
it('sets contextMenuNode when contextmenu event is triggered', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
const nodeItem = createMockItem('node')
const { wrapper } = mountComponent(
{
item: nodeItem,
showContextMenu: true
},
{
provide: {
[InjectKeyContextMenuNode as symbol]: contextMenuNode
}
}
)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('contextmenu')
expect(contextMenuNode.value).toEqual(nodeItem.value)
})
})
describe('rendering', () => {
it('renders node icon for node type', () => {
const { wrapper } = mountComponent({
item: createMockItem('node')
})
expect(wrapper.find('i.icon-\\[comfy--node\\]').exists()).toBe(true)
})
it('renders folder icon for folder type', () => {
const { wrapper } = mountComponent({
item: createMockItem('folder', { icon: 'icon-[lucide--folder]' })
})
expect(wrapper.find('i.icon-\\[lucide--folder\\]').exists()).toBe(true)
})
it('renders label text', () => {
const { wrapper } = mountComponent({
item: createMockItem('node', { label: 'My Node' })
})
expect(wrapper.text()).toContain('My Node')
})
it('renders chevron for folder with children', () => {
const { wrapper } = mountComponent({
item: {
...createMockItem('folder'),
hasChildren: true
}
})
expect(wrapper.find('i.icon-\\[lucide--chevron-down\\]').exists()).toBe(
true
)
})
})
describe('drag and drop', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('sets draggable attribute on node items', () => {
const { wrapper } = mountComponent({
item: createMockItem('node')
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
expect(nodeDiv.attributes('draggable')).toBe('true')
})
it('does not set draggable on folder items', () => {
const { wrapper } = mountComponent({
item: createMockItem('folder')
})
const folderDiv = wrapper.find('div.group\\/tree-node')
expect(folderDiv.attributes('draggable')).toBeUndefined()
})
it('calls startDrag with native mode on dragstart', async () => {
const mockData = { name: 'TestNode' }
const { wrapper } = mountComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('dragstart')
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
})
it('does not call startDrag for folder items on dragstart', async () => {
const { wrapper } = mountComponent({
item: createMockItem('folder')
})
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('dragstart')
expect(mockStartDrag).not.toHaveBeenCalled()
})
it('calls handleNativeDrop on dragend with drop coordinates', async () => {
const mockData = { name: 'TestNode' }
const { wrapper } = mountComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('dragstart')
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
Object.defineProperty(dragEndEvent, 'clientX', { value: 100 })
Object.defineProperty(dragEndEvent, 'clientY', { value: 200 })
await nodeDiv.element.dispatchEvent(dragEndEvent)
await wrapper.vm.$nextTick()
expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
})
it('calls handleNativeDrop regardless of dropEffect', async () => {
const mockData = { name: 'TestNode' }
const { wrapper } = mountComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('dragstart')
mockHandleNativeDrop.mockClear()
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
Object.defineProperty(dragEndEvent, 'clientX', { value: 300 })
Object.defineProperty(dragEndEvent, 'clientY', { value: 400 })
Object.defineProperty(dragEndEvent, 'dataTransfer', {
value: { dropEffect: 'none' }
})
await nodeDiv.element.dispatchEvent(dragEndEvent)
await wrapper.vm.$nextTick()
expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400)
})
})
})

View File

@@ -0,0 +1,141 @@
<template>
<TreeItem
v-slot="{ isExpanded, isSelected, handleToggle, handleSelect }"
:value="item.value"
:level="item.level"
as-child
>
<!-- Node with context menu -->
<ContextMenuTrigger v-if="item.value.type === 'node'" as-child>
<div
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
draggable="true"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@contextmenu="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
>
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
<slot name="node" :node="item.value">
{{ item.value.label }}
</slot>
</span>
</div>
</ContextMenuTrigger>
<!-- Folder -->
<div
v-else
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
>
<i
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
/>
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
<slot name="folder" :node="item.value">
{{ 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>
<Teleport
v-if="showPreview && item.value.type === 'node' && item.value.data"
to="body"
>
<div
:ref="(el) => (previewRef = el as HTMLElement)"
:style="nodePreviewStyle"
>
<NodePreviewCard :node-def="item.value.data as ComfyNodeDefImpl" />
</div>
</Teleport>
</template>
<script setup lang="ts">
import type { FlattenedItem } from 'reka-ui'
import { ContextMenuTrigger, TreeItem } from 'reka-ui'
import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const ROW_CLASS =
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
}>()
const emit = defineEmits<{
nodeClick: [
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
event: MouseEvent
]
}>()
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeDef = computed(() => item.value.data)
const {
previewRef,
showPreview,
nodePreviewStyle,
handleMouseEnter: baseHandleMouseEnter,
handleMouseLeave,
handleDragStart: baseHandleDragStart,
handleDragEnd
} = useNodePreviewAndDrag(nodeDef)
const rowStyle = computed(() => ({
paddingLeft: `${8 + (item.level - 1) * 24}px`
}))
function handleClick(
e: MouseEvent,
handleToggle: () => void,
handleSelect: () => void
) {
handleSelect()
if (item.value.type === 'folder') {
handleToggle()
}
emit('nodeClick', item.value, e)
}
function handleContextMenu() {
if (contextMenuNode) {
contextMenuNode.value = item.value
}
}
function handleMouseEnter(e: MouseEvent) {
if (item.value.type !== 'node') return
baseHandleMouseEnter(e)
}
function handleDragStart(e: DragEvent) {
if (item.value.type !== 'node' || !item.value.data) return
baseHandleDragStart(e)
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div
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">
<LGraphNodePreview :node-def="nodeDef" position="relative" />
</div>
</div>
<!-- Content Section -->
<div class="flex flex-col gap-2 p-3 pt-1">
<!-- Title -->
<h3 class="text-xs font-semibold text-foreground m-0">
{{ nodeDef.display_name }}
</h3>
<!-- Category Path -->
<p
v-if="showCategoryPath && nodeDef.category"
class="text-xs text-muted-foreground -mt-1"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 empty:hidden">
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge :node-def="nodeDef" />
</div>
<!-- Description -->
<p
v-if="nodeDef.description"
class="text-[11px] font-normal leading-normal text-muted-foreground m-0"
>
{{ nodeDef.description }}
</p>
<!-- Divider -->
<div
v-if="(inputs.length > 0 || outputs.length > 0) && showInputsAndOutputs"
class="border-t border-border-default"
/>
<!-- Inputs Section -->
<div
v-if="inputs.length > 0 && showInputsAndOutputs"
class="flex flex-col gap-1"
>
<h4
class="text-xxs font-semibold uppercase tracking-wide text-muted-foreground m-0"
>
{{ $t('nodeHelpPage.inputs') }}
</h4>
<div
v-for="input in inputs"
:key="input.name"
class="flex items-center justify-between gap-2 text-xxs"
>
<span class="shrink-0 text-foreground">{{ input.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{
input.type
}}</span>
</div>
</div>
<!-- Outputs Section -->
<div
v-if="outputs.length > 0 && showInputsAndOutputs"
class="flex flex-col gap-1"
>
<h4
class="text-xxs font-semibold uppercase tracking-wide text-muted-foreground m-0"
>
{{ $t('nodeHelpPage.outputs') }}
</h4>
<div
v-for="output in outputs"
:key="output.name"
class="flex items-center justify-between gap-2 text-xxs"
>
<span class="shrink-0 text-foreground">{{ output.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{
output.type
}}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { computed, ref } from 'vue'
import NodePricingBadge from '@/components/node/NodePricingBadge.vue'
import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const SCALE_FACTOR = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24 // p-3 top + bottom (12px × 2)
const {
nodeDef,
showInputsAndOutputs = true,
showCategoryPath = false
} = defineProps<{
nodeDef: ComfyNodeDefImpl
showInputsAndOutputs?: boolean
showCategoryPath?: boolean
}>()
const previewContainerRef = ref<HTMLElement>()
const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
const scaledHeight = entry.contentRect.height * SCALE_FACTOR
previewContainerRef.value.style.height = `${scaledHeight + PREVIEW_CONTAINER_PADDING_PX}px`
}
})
const inputs = computed(() => {
if (!nodeDef.inputs) return []
return Object.entries(nodeDef.inputs)
.filter(([_, input]) => !input.hidden)
.map(([name, input]) => ({
name,
type: input.type
}))
})
const outputs = computed(() => {
if (!nodeDef.outputs) return []
return nodeDef.outputs.map((output) => ({
name: output.name,
type: output.type
}))
})
</script>

View File

@@ -0,0 +1,43 @@
<template>
<BadgePill
v-if="nodeDef.api_node"
v-show="priceLabel"
:text="priceLabel"
icon="icon-[comfy--credits]"
border-style="#f59e0b"
filled
/>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import BadgePill from '@/components/common/BadgePill.vue'
import { evaluateNodeDefPricing } from '@/composables/node/useNodePricing'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const { nodeDef } = defineProps<{
nodeDef: ComfyNodeDefImpl
}>()
const priceLabel = ref('')
watch(
() => nodeDef.name,
(name) => {
if (!nodeDef.api_node) {
priceLabel.value = ''
return
}
const capturedName = name
evaluateNodeDefPricing(nodeDef)
.then((label) => {
if (nodeDef.name === capturedName) priceLabel.value = label
})
.catch((e) => {
console.error('[NodePricingBadge] pricing evaluation failed:', e)
})
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,26 @@
<template>
<BadgePill
v-if="nodeDef.api_node && providerName"
:text="providerName"
:icon="getProviderIcon(providerName)"
:border-style="getProviderBorderStyle(providerName)"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BadgePill from '@/components/common/BadgePill.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import {
getProviderBorderStyle,
getProviderIcon,
getProviderName
} from '@/utils/categoryUtil'
const { nodeDef } = defineProps<{
nodeDef: ComfyNodeDefImpl
}>()
const providerName = computed(() => getProviderName(nodeDef.category))
</script>

View File

@@ -5,7 +5,7 @@
<div
v-if="enableNodePreview && hoveredSuggestion"
class="comfy-vue-node-preview-container absolute top-[50px] left-[-375px] z-50 cursor-pointer"
@mousedown.stop="onAddNode(hoveredSuggestion!)"
@mousedown.stop="onAddNode(hoveredSuggestion!, $event)"
>
<NodePreview
:key="hoveredSuggestion?.name || ''"
@@ -148,15 +148,19 @@ const search = (query: string) => {
debouncedTrackSearch(query)
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
const emit = defineEmits<{
addFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
removeFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
addNode: [nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent]
}>()
// Track node selection and emit addNode event
const onAddNode = (nodeDef: ComfyNodeDefImpl) => {
function onAddNode(nodeDef: ComfyNodeDefImpl, event?: MouseEvent) {
telemetry?.trackNodeSearchResultSelected({
node_type: nodeDef.name,
last_query: currentQuery.value
})
emit('addNode', nodeDef)
emit('addNode', nodeDef, event)
}
let inputElement: HTMLInputElement | null = null

View File

@@ -6,10 +6,13 @@
:dismissable-mask="dismissable"
:pt="{
root: {
class: 'invisible-dialog-root',
role: 'search'
class: useSearchBoxV2
? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent mt-[10vh] max-md:w-[95%] max-md:min-w-0 overflow-visible'
: 'invisible-dialog-root'
},
mask: {
class: useSearchBoxV2 ? 'items-start' : 'node-search-box-dialog-mask'
},
mask: { class: 'node-search-box-dialog-mask' },
transition: {
enterFromClass: 'opacity-0 scale-75',
// 100ms is the duration of the transition in the dialog component
@@ -21,7 +24,24 @@
@hide="clearFilters"
>
<template #container>
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@hover-node="hoveredNodeDef = $event"
/>
<NodePreviewCard
v-if="hoveredNodeDef && enableNodePreview"
:key="hoveredNodeDef.name"
:node-def="hoveredNodeDef"
show-category-path
class="absolute top-0 left-full ml-3"
/>
</div>
<NodeSearchBox
v-else
:filters="nodeFilters"
@add-filter="addFilter"
@remove-filter="removeFilter"
@@ -33,7 +53,7 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useEventListener, useWindowSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Dialog from 'primevue/dialog'
import { computed, ref, toRaw, watch, watchEffect } from 'vue'
@@ -52,6 +72,9 @@ import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
let triggerEvent: CanvasPointerEvent | null = null
@@ -62,8 +85,19 @@ const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore)
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
storeToRefs(searchBoxStore)
const dismissable = ref(true)
const hoveredNodeDef = ref<ComfyNodeDefImpl | null>(null)
const { width: windowWidth } = useWindowSize()
// Minimum viewport width for the preview panel to fit beside the dialog
const MIN_WIDTH_FOR_PREVIEW = 1320
const enableNodePreview = computed(
() =>
useSearchBoxV2.value &&
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -74,9 +108,7 @@ function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
const isDuplicate = nodeFilters.value.some(
(f) => f.filterDef.id === filter.filterDef.id && f.value === filter.value
)
if (!isDuplicate) {
nodeFilters.value.push(filter)
}
if (!isDuplicate) nodeFilters.value.push(filter)
}
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
nodeFilters.value = nodeFilters.value.filter(
@@ -85,16 +117,20 @@ function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
}
function clearFilters() {
nodeFilters.value = []
hoveredNodeDef.value = null
}
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl) {
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const node = litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value, dragEvent }
)
if (!node) return
if (disconnectOnReset && triggerEvent) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)

View File

@@ -0,0 +1,260 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import {
createMockNodeDef,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => undefined),
set: vi.fn()
}))
}))
describe('NodeSearchCategorySidebar', () => {
beforeEach(() => {
vi.restoreAllMocks()
setupTestPinia()
})
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: 'most-relevant', ...props },
global: { plugins: [testI18n] }
})
await nextTick()
return wrapper
}
async function clickCategory(
wrapper: ReturnType<typeof mount>,
text: string,
exact = false
) {
const btn = wrapper
.findAll('button')
.find((b) => (exact ? b.text().trim() === text : b.text().includes(text)))
expect(btn, `Expected to find a button with text "${text}"`).toBeDefined()
await btn!.trigger('click')
await nextTick()
}
describe('preset categories', () => {
it('should render all preset categories', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
essentials_category: 'basic'
})
])
await nextTick()
const wrapper = await createWrapper()
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Custom')
})
it('should mark the selected preset category as selected', async () => {
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const mostRelevantBtn = wrapper.find(
'[data-testid="category-most-relevant"]'
)
expect(mostRelevantBtn.attributes('aria-current')).toBe('true')
})
it('should emit update:selectedCategory when preset is clicked', async () => {
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
await clickCategory(wrapper, 'Favorites')
expect(wrapper.emitted('update:selectedCategory')).toBeTruthy()
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'favorites'
])
})
})
describe('category tree', () => {
it('should render top-level categories from node definitions', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'loaders' }),
createMockNodeDef({ name: 'Node3', category: 'conditioning' })
])
await nextTick()
const wrapper = await createWrapper()
expect(wrapper.text()).toContain('sampling')
expect(wrapper.text()).toContain('loaders')
expect(wrapper.text()).toContain('conditioning')
})
it('should emit update:selectedCategory when category is clicked', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling')
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'sampling'
])
})
})
describe('expand/collapse functionality', () => {
it('should expand category when clicked and show subcategories', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' })
])
await nextTick()
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('advanced')
await clickCategory(wrapper, 'sampling')
expect(wrapper.text()).toContain('advanced')
expect(wrapper.text()).toContain('basic')
})
it('should collapse sibling category when another is expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'image' }),
createMockNodeDef({ name: 'Node4', category: 'image/upscale' })
])
await nextTick()
const wrapper = await createWrapper()
// Expand sampling
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
// Expand image — sampling should collapse
await clickCategory(wrapper, 'image', true)
expect(wrapper.text()).toContain('upscale')
expect(wrapper.text()).not.toContain('advanced')
})
it('should emit update:selectedCategory when subcategory is clicked', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
])
await nextTick()
const wrapper = await createWrapper()
// Expand sampling category
await clickCategory(wrapper, 'sampling', true)
// Click on advanced subcategory
await clickCategory(wrapper, 'advanced')
const emitted = wrapper.emitted('update:selectedCategory')!
expect(emitted[emitted.length - 1]).toEqual(['sampling/advanced'])
})
})
describe('category selection highlighting', () => {
it('should mark selected top-level category as selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' })
])
await nextTick()
const wrapper = await createWrapper({ selectedCategory: 'sampling' })
expect(
wrapper
.find('[data-testid="category-sampling"]')
.attributes('aria-current')
).toBe('true')
})
it('should emit selected subcategory when expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
])
await nextTick()
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
// Expand and click subcategory
await clickCategory(wrapper, 'sampling', true)
await clickCategory(wrapper, 'advanced')
const emitted = wrapper.emitted('update:selectedCategory')!
expect(emitted[emitted.length - 1]).toEqual(['sampling/advanced'])
})
})
it('should support deeply nested categories (3+ levels)', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'api' }),
createMockNodeDef({ name: 'Node2', category: 'api/image' }),
createMockNodeDef({ name: 'Node3', category: 'api/image/BFL' })
])
await nextTick()
const wrapper = await createWrapper()
// Only top-level visible initially
expect(wrapper.text()).toContain('api')
expect(wrapper.text()).not.toContain('image')
expect(wrapper.text()).not.toContain('BFL')
// Expand api
await clickCategory(wrapper, 'api', true)
expect(wrapper.text()).toContain('image')
expect(wrapper.text()).not.toContain('BFL')
// Expand image
await clickCategory(wrapper, 'image', true)
expect(wrapper.text()).toContain('BFL')
// Click BFL and verify emission
await clickCategory(wrapper, 'BFL', true)
const emitted = wrapper.emitted('update:selectedCategory')!
expect(emitted[emitted.length - 1]).toEqual(['api/image/BFL'])
})
it('should emit category without root/ prefix', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling')
expect(wrapper.emitted('update:selectedCategory')![0][0]).toBe('sampling')
})
})

View File

@@ -0,0 +1,132 @@
<template>
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
<!-- Preset categories -->
<div class="flex flex-col px-1">
<button
v-for="preset in topCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
</div>
<!-- Source categories -->
<div class="my-2 flex flex-col border-y border-border-subtle px-1 py-2">
<button
v-for="preset in sourceCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
</div>
<!-- Category tree -->
<div class="flex flex-col px-1">
<NodeSearchCategoryTreeNode
v-for="category in categoryTree"
:key="category.key"
:node="category"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
@select="selectCategory"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeSearchCategoryTreeNode, {
CATEGORY_SELECTED_CLASS,
CATEGORY_UNSELECTED_CLASS
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const selectedCategory = defineModel<string>('selectedCategory', {
required: true
})
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
{ id: 'most-relevant', label: t('g.mostRelevant') },
{ id: 'favorites', label: t('g.favorites') }
])
const hasEssentialNodes = computed(() =>
nodeDefStore.visibleNodeDefs.some(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
)
const sourceCategories = computed(() => {
const categories = []
if (hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push({ id: 'custom', label: t('g.custom') })
return categories
})
const categoryTree = computed<CategoryNode[]>(() => {
const tree = nodeOrganizationService.organizeNodes(
nodeDefStore.visibleNodeDefs,
{ groupBy: 'category' }
)
const stripRootPrefix = (key: string) => key.replace(/^root\//, '')
function mapNode(node: TreeNode): CategoryNode {
const children = node.children
?.filter((child): child is TreeNode => !child.leaf)
.map(mapNode)
return {
key: stripRootPrefix(node.key as string),
label: node.label,
...(children?.length ? { children } : {})
}
}
return (tree.children ?? [])
.filter((node): node is TreeNode => !node.leaf)
.map(mapNode)
})
function categoryBtnClass(id: string) {
return cn(
'cursor-pointer border-none bg-transparent rounded px-3 py-2.5 text-left text-sm transition-colors',
selectedCategory.value === id
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
}
const selectedCollapsed = ref(false)
function selectCategory(categoryId: string) {
if (selectedCategory.value === categoryId) {
selectedCollapsed.value = !selectedCollapsed.value
} else {
selectedCollapsed.value = false
selectedCategory.value = categoryId
}
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<button
type="button"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
:class="
cn(
'w-full cursor-pointer rounded border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
"
@click="$emit('select', node.key)"
>
{{ node.label }}
</button>
<template v-if="isExpanded && node.children?.length">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
@select="$emit('select', $event)"
/>
</template>
</template>
<script lang="ts">
export interface CategoryNode {
key: string
label: string
children?: CategoryNode[]
}
export const CATEGORY_SELECTED_CLASS =
'bg-secondary-background-hover font-semibold text-foreground'
export const CATEGORY_UNSELECTED_CLASS =
'text-muted-foreground hover:bg-secondary-background-hover hover:text-foreground'
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
node,
depth = 0,
selectedCategory,
selectedCollapsed = false
} = defineProps<{
node: CategoryNode
depth?: number
selectedCategory: string
selectedCollapsed?: boolean
}>()
defineEmits<{
select: [key: string]
}>()
const isExpanded = computed(() => {
if (selectedCategory === node.key) return !selectedCollapsed
return selectedCategory.startsWith(node.key + '/')
})
</script>

View File

@@ -0,0 +1,729 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import {
createMockNodeDef,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
describe('NodeSearchContent', () => {
beforeEach(() => {
setupTestPinia()
vi.restoreAllMocks()
})
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchContent, {
props: { filters: [], ...props },
global: {
plugins: [testI18n],
stubs: {
NodeSearchListItem: {
template: '<div class="node-item">{{ nodeDef.display_name }}</div>',
props: [
'nodeDef',
'currentQuery',
'showDescription',
'showSourceBadge',
'hideBookmarkIcon'
]
}
}
}
})
await nextTick()
return wrapper
}
async function setupFavorites(
nodes: Parameters<typeof createMockNodeDef>[0][]
) {
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
return wrapper
}
function getResultItems(wrapper: VueWrapper) {
return wrapper.findAll('[data-testid="result-item"]')
}
function getNodeItems(wrapper: VueWrapper) {
return wrapper.findAll('.node-item')
}
describe('category selection', () => {
it('should show top nodes when Most relevant is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
}),
createMockNodeDef({ name: 'RareNode', display_name: 'Rare Node' })
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
const wrapper = await createWrapper()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Frequent Node')
})
it('should show only bookmarked nodes when Favorites is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'BookmarkedNode',
display_name: 'Bookmarked Node'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Bookmarked')
})
it('should show empty state when no bookmarks exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
expect(wrapper.text()).toContain('No results')
})
it('should show only non-Core nodes when Custom is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
display_name: 'Core Node',
python_module: 'nodes'
}),
createMockNodeDef({
name: 'CustomNode',
display_name: 'Custom Node',
python_module: 'custom_nodes.my_extension'
})
])
await nextTick()
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
NodeSourceType.Core
)
expect(
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-custom"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Custom Node')
})
it('should hide Essentials category when no essential nodes exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
const wrapper = await createWrapper()
expect(wrapper.find('[data-testid="category-essentials"]').exists()).toBe(
false
)
})
it('should show only essential nodes when Essentials is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
await nextTick()
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-essentials"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Essential Node')
})
it('should include subcategory nodes when parent category is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
}),
createMockNodeDef({
name: 'LoadCheckpoint',
display_name: 'Load Checkpoint',
category: 'loaders'
}),
createMockNodeDef({
name: 'KSamplerAdvanced',
display_name: 'KSampler Advanced',
category: 'sampling/advanced'
})
])
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-sampling"]').trigger('click')
await nextTick()
const texts = getNodeItems(wrapper).map((i) => i.text())
expect(texts).toHaveLength(2)
expect(texts).toContain('KSampler')
expect(texts).toContain('KSampler Advanced')
})
})
describe('search and category interaction', () => {
it('should override category to most-relevant when search query is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
display_name: 'KSampler',
category: 'sampling'
}),
createMockNodeDef({
name: 'LoadCheckpoint',
display_name: 'Load Checkpoint',
category: 'loaders'
})
])
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-sampling"]').trigger('click')
await nextTick()
expect(getNodeItems(wrapper)).toHaveLength(1)
const input = wrapper.find('input[type="text"]')
await input.setValue('Load')
await nextTick()
const texts = getNodeItems(wrapper).map((i) => i.text())
expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(true)
})
it('should clear search query when category changes', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
])
const wrapper = await createWrapper()
const input = wrapper.find('input[type="text"]')
await input.setValue('test query')
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('test query')
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('')
})
it('should reset selected index when search query changes', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await input.setValue('Node')
await nextTick()
expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
})
it('should reset selected index when category changes', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
await wrapper
.find('[data-testid="category-most-relevant"]')
.trigger('click')
await nextTick()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
})
})
describe('keyboard and mouse interaction', () => {
it('should navigate results with ArrowDown/ArrowUp and clamp to bounds', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' },
{ name: 'Node3', display_name: 'Node Three' }
])
const input = wrapper.find('input[type="text"]')
const selectedIndex = () =>
getResultItems(wrapper).findIndex(
(r) => r.attributes('aria-selected') === 'true'
)
expect(selectedIndex()).toBe(0)
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(selectedIndex()).toBe(1)
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(selectedIndex()).toBe(2)
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(selectedIndex()).toBe(1)
// Navigate to first, then try going above — should clamp
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(selectedIndex()).toBe(0)
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(selectedIndex()).toBe(0)
})
it('should select current result with Enter key', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await wrapper
.find('input[type="text"]')
.trigger('keydown', { key: 'Enter' })
await nextTick()
expect(wrapper.emitted('addNode')).toBeTruthy()
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
})
it('should select item on hover', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const results = getResultItems(wrapper)
await results[1].trigger('mouseenter')
await nextTick()
expect(results[1].attributes('aria-selected')).toBe('true')
})
it('should add node on click', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await getResultItems(wrapper)[0].trigger('click')
await nextTick()
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
})
})
describe('hoverNode emission', () => {
it('should emit hoverNode with the currently selected node', async () => {
const wrapper = await setupFavorites([
{ name: 'HoverNode', display_name: 'Hover Node' }
])
const emitted = wrapper.emitted('hoverNode')!
expect(emitted[emitted.length - 1][0]).toMatchObject({
name: 'HoverNode'
})
})
it('should emit null hoverNode when no results', async () => {
const wrapper = await createWrapper()
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
const emitted = wrapper.emitted('hoverNode')!
expect(emitted[emitted.length - 1][0]).toBeNull()
})
})
describe('filter integration', () => {
it('should display active filters in the input area', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
display_name: 'Image Node',
input: { required: { image: ['IMAGE', {}] } }
})
])
const wrapper = await createWrapper({
filters: [
{
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
value: 'IMAGE'
}
]
})
expect(
wrapper.findAll('[data-testid="filter-chip"]').length
).toBeGreaterThan(0)
})
})
describe('chip removal', () => {
function createFilters(count: number) {
const types = ['IMAGE', 'LATENT', 'MODEL']
useNodeDefStore().updateNodeDefs(
types.slice(0, count).map((type) =>
createMockNodeDef({
name: `${type}Node`,
display_name: `${type} Node`,
input: {
required: { [type.toLowerCase()]: [type, {}] }
}
})
)
)
return types.slice(0, count).map((type) => ({
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
value: type
}))
}
it('should emit removeFilter on backspace', async () => {
const filters = createFilters(1)
const wrapper = await createWrapper({ filters })
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'Backspace' })
await nextTick()
await input.trigger('keydown', { key: 'Backspace' })
await nextTick()
expect(wrapper.emitted('removeFilter')).toHaveLength(1)
expect(wrapper.emitted('removeFilter')![0][0]).toMatchObject({
value: 'IMAGE'
})
})
it('should not interact with chips when no filters exist', async () => {
const wrapper = await createWrapper({ filters: [] })
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'Backspace' })
await nextTick()
expect(wrapper.emitted('removeFilter')).toBeUndefined()
})
it('should remove chip when clicking its delete button', async () => {
const filters = createFilters(1)
const wrapper = await createWrapper({ filters })
const deleteBtn = wrapper.find('[data-testid="chip-delete"]')
await deleteBtn.trigger('click')
await nextTick()
expect(wrapper.emitted('removeFilter')).toHaveLength(1)
expect(wrapper.emitted('removeFilter')![0][0]).toMatchObject({
value: 'IMAGE'
})
})
})
describe('filter selection mode', () => {
function setupNodesWithTypes() {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
display_name: 'Image Node',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE']
}),
createMockNodeDef({
name: 'LatentNode',
display_name: 'Latent Node',
input: { required: { latent: ['LATENT', {}] } },
output: ['LATENT']
}),
createMockNodeDef({
name: 'ModelNode',
display_name: 'Model Node',
input: { required: { model: ['MODEL', {}] } },
output: ['MODEL']
})
])
}
function findFilterBarButton(wrapper: VueWrapper, label: string) {
return wrapper
.findAll('button[aria-pressed]')
.find((b) => b.text() === label)
}
async function enterFilterMode(wrapper: VueWrapper) {
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
}
function getFilterOptions(wrapper: VueWrapper) {
return wrapper.findAll('[data-testid="filter-option"]')
}
function getFilterOptionTexts(wrapper: VueWrapper) {
return getFilterOptions(wrapper).map(
(o) =>
o
.findAll('span')[0]
?.text()
.replace(/^[•·]\s*/, '')
.trim() ?? ''
)
}
function hasSidebar(wrapper: VueWrapper) {
return wrapper.findComponent(NodeSearchCategorySidebar).exists()
}
it('should enter filter mode when a filter chip is selected', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
expect(hasSidebar(wrapper)).toBe(true)
await enterFilterMode(wrapper)
expect(hasSidebar(wrapper)).toBe(false)
expect(getFilterOptions(wrapper).length).toBeGreaterThan(0)
})
it('should show available filter options sorted alphabetically', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).toContain('LATENT')
expect(texts).toContain('MODEL')
expect(texts).toEqual([...texts].sort())
})
it('should filter options when typing in filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper.find('input[type="text"]').setValue('IMAGE')
await nextTick()
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).not.toContain('MODEL')
})
it('should show no results when filter query has no matches', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper.find('input[type="text"]').setValue('NONEXISTENT_TYPE')
await nextTick()
expect(wrapper.text()).toContain('No results')
})
it('should emit addFilter when a filter option is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const imageOption = getFilterOptions(wrapper).find((o) =>
o.text().includes('IMAGE')
)
await imageOption!.trigger('click')
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should exit filter mode after applying a filter', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await getFilterOptions(wrapper)[0].trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
it('should emit addFilter when Enter is pressed on selected option', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper
.find('input[type="text"]')
.trigger('keydown', { key: 'Enter' })
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const input = wrapper.find('input[type="text"]')
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getFilterOptions(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
})
it('should toggle filter mode off when same chip is clicked again', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
it('should reset filter query when re-entering filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const input = wrapper.find('input[type="text"]')
await input.setValue('IMAGE')
await nextTick()
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
await enterFilterMode(wrapper)
expect((input.element as HTMLInputElement).value).toBe('')
})
it('should exit filter mode when cancel button is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
expect(hasSidebar(wrapper)).toBe(false)
const cancelBtn = wrapper.find('[data-testid="cancel-filter"]')
await cancelBtn.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
})
})

View File

@@ -0,0 +1,291 @@
<template>
<div
ref="dialogRef"
class="flex max-h-[50vh] min-h-[400px] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
ref="searchInputRef"
v-model:search-query="searchQuery"
v-model:filter-query="filterQuery"
:filters="filters"
:active-filter="activeFilter"
@remove-filter="emit('removeFilter', $event)"
@cancel-filter="cancelFilter"
@navigate-down="onKeyDown"
@navigate-up="onKeyUp"
@select-current="onKeyEnter"
/>
<!-- 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"
@select-chip="onSelectFilterChip"
/>
</div>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar (hidden in filter mode) -->
<NodeSearchCategorySidebar
v-if="!activeFilter"
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
/>
<!-- Filter options list (filter selection mode) -->
<NodeSearchFilterPanel
v-if="activeFilter"
ref="filterPanelRef"
v-model:query="filterQuery"
:chip="activeFilter"
@apply="onFilterApply"
/>
<!-- Results list (normal mode) -->
<div
v-else
id="results-list"
role="listbox"
class="flex-1 overflow-y-auto py-2"
>
<div
v-for="(node, index) in displayedResults"
:id="`result-item-${index}`"
:key="node.name"
role="option"
data-testid="result-item"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex cursor-pointer items-center px-4 h-14',
index === selectedIndex && 'bg-secondary-background-hover'
)
"
@click="emit('addNode', node, $event)"
@mouseenter="selectedIndex = index"
>
<NodeSearchListItem
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="effectiveCategory !== 'essentials'"
:hide-bookmark-icon="effectiveCategory === 'favorites'"
/>
</div>
<div
v-if="displayedResults.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchFilterPanel from '@/components/searchbox/v2/NodeSearchFilterPanel.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { cn } from '@/utils/tailwindUtil'
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
}>()
const emit = defineEmits<{
addNode: [nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent]
addFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
removeFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
hoverNode: [nodeDef: ComfyNodeDefImpl | null]
}>()
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const dialogRef = ref<HTMLElement>()
const searchInputRef = ref<InstanceType<typeof NodeSearchInput>>()
const filterPanelRef = ref<InstanceType<typeof NodeSearchFilterPanel>>()
const searchQuery = ref('')
const selectedCategory = ref('most-relevant')
const selectedIndex = ref(0)
// Filter selection mode
const activeFilter = ref<FilterChip | null>(null)
const filterQuery = ref('')
function lockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = `${dialogRef.value.offsetHeight}px`
}
}
function unlockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = ''
}
}
function onSelectFilterChip(chip: FilterChip) {
if (activeFilter.value?.key === chip.key) {
cancelFilter()
return
}
lockDialogHeight()
activeFilter.value = chip
filterQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
function onFilterApply(value: string) {
if (!activeFilter.value) return
emit('addFilter', { filterDef: activeFilter.value.filter, value })
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
nextTick(() => searchInputRef.value?.focus())
}
function cancelFilter() {
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
nextTick(() => searchInputRef.value?.focus())
}
// Node search
const searchResults = computed(() => {
if (!searchQuery.value && filters.length === 0) {
return nodeFrequencyStore.topNodeDefs
}
return nodeDefStore.nodeSearchService.searchNode(searchQuery.value, filters, {
limit: 64
})
})
const effectiveCategory = computed(() =>
searchQuery.value ? 'most-relevant' : selectedCategory.value
)
const sidebarCategory = computed({
get: () => effectiveCategory.value,
set: (category: string) => {
selectedCategory.value = category
searchQuery.value = ''
}
})
function matchesFilters(node: ComfyNodeDefImpl): boolean {
return filters.every(({ filterDef, value }) => filterDef.matches(node, value))
}
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
const allNodes = nodeDefStore.visibleNodeDefs
let results: ComfyNodeDefImpl[]
switch (effectiveCategory.value) {
case 'most-relevant':
return searchResults.value
case 'favorites':
results = allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
break
case 'essentials':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
break
case 'custom':
results = allNodes.filter(
(n) =>
n.nodeSource.type !== NodeSourceType.Core &&
n.nodeSource.type !== NodeSourceType.Essentials
)
break
default:
results = allNodes.filter(
(n) =>
n.category === effectiveCategory.value ||
n.category.startsWith(effectiveCategory.value + '/')
)
break
}
return filters.length > 0 ? results.filter(matchesFilters) : results
})
const hoveredNodeDef = computed(
() => displayedResults.value[selectedIndex.value] ?? null
)
watch(
hoveredNodeDef,
(newVal) => {
emit('hoverNode', newVal)
},
{ immediate: true }
)
watch([selectedCategory, searchQuery, () => filters], () => {
selectedIndex.value = 0
})
// Keyboard navigation
function onKeyDown() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(1)
} else {
navigateResults(1)
}
}
function onKeyUp() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(-1)
} else {
navigateResults(-1)
}
}
function onKeyEnter() {
if (activeFilter.value) {
filterPanelRef.value?.selectCurrent()
} else {
selectCurrentResult()
}
}
function navigateResults(direction: number) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < displayedResults.value.length) {
selectedIndex.value = newIndex
nextTick(() => {
dialogRef.value
?.querySelector(`#result-item-${newIndex}`)
?.scrollIntoView({ block: 'nearest' })
})
}
}
function selectCurrentResult() {
const node = displayedResults.value[selectedIndex.value]
if (node) emit('addNode', node)
}
</script>

View File

@@ -0,0 +1,80 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import {
createMockNodeDef,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => undefined),
set: vi.fn()
}))
}))
describe(NodeSearchFilterBar, () => {
beforeEach(() => {
vi.restoreAllMocks()
setupTestPinia()
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE']
})
])
})
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchFilterBar, {
props,
global: { plugins: [testI18n] }
})
await nextTick()
return wrapper
}
it('should render Input, Output, and Source 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')
})
it('should mark active chip as pressed when activeChipKey matches', async () => {
const wrapper = await createWrapper({ activeChipKey: 'input' })
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
expect(inputBtn?.attributes('aria-pressed')).toBe('true')
})
it('should not mark chips as pressed when activeChipKey does not match', async () => {
const wrapper = await createWrapper({ activeChipKey: null })
wrapper.findAll('button').forEach((btn) => {
expect(btn.attributes('aria-pressed')).toBe('false')
})
})
it('should emit selectChip with chip data when clicked', async () => {
const wrapper = await createWrapper()
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
await inputBtn?.trigger('click')
const emitted = wrapper.emitted('selectChip')!
expect(emitted[0][0]).toMatchObject({
key: 'input',
label: 'Input',
filter: expect.anything()
})
})
})

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex items-center gap-2 px-2 py-1.5">
<button
v-for="chip in chips"
:key="chip.key"
type="button"
:aria-pressed="activeChipKey === chip.key"
:class="
cn(
'cursor-pointer rounded-md border px-3 py-1 text-sm transition-colors flex-auto border-secondary-background',
activeChipKey === chip.key
? 'bg-secondary-background text-foreground'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
"
@click="emit('selectChip', chip)"
>
{{ chip.label }}
</button>
</div>
</template>
<script lang="ts">
import type { FuseFilter } from '@/utils/fuseUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
export interface FilterChip {
key: string
label: string
filter: FuseFilter<ComfyNodeDefImpl, string>
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { cn } from '@/utils/tailwindUtil'
const { activeChipKey = null } = defineProps<{
activeChipKey?: string | null
}>()
const emit = defineEmits<{
selectChip: [chip: FilterChip]
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const chips = computed<FilterChip[]>(() => {
const searchService = nodeDefStore.nodeSearchService
return [
{
key: 'input',
label: t('g.input'),
filter: searchService.inputTypeFilter
},
{
key: 'output',
label: t('g.output'),
filter: searchService.outputTypeFilter
},
{
key: 'source',
label: t('g.source'),
filter: searchService.nodeSourceFilter
}
]
})
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div
id="filter-options-list"
ref="listRef"
role="listbox"
class="flex-1 overflow-y-auto py-2"
>
<div
v-for="(option, index) in options"
:id="`filter-option-${index}`"
:key="option"
role="option"
data-testid="filter-option"
:aria-selected="index === selectedIndex"
:class="
cn(
'cursor-pointer px-6 py-1.5',
index === selectedIndex && 'bg-secondary-background-hover'
)
"
@click="emit('apply', option)"
@mouseenter="selectedIndex = index"
>
<span class="text-base font-semibold text-foreground">
<span class="text-2xl mr-1" :style="{ color: getLinkTypeColor(option) }"
>&bull;</span
>
{{ option }}
</span>
</div>
<div
v-if="options.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { chip } = defineProps<{
chip: FilterChip
}>()
const query = defineModel<string>('query', { required: true })
const emit = defineEmits<{
apply: [value: string]
}>()
const listRef = ref<HTMLElement>()
const selectedIndex = ref(0)
const options = computed(() => {
const { fuseSearch } = chip.filter
if (query.value) {
return fuseSearch.search(query.value).slice(0, 64)
}
return fuseSearch.data.slice().sort()
})
watch(query, () => {
selectedIndex.value = 0
})
function navigate(direction: number) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < options.value.length) {
selectedIndex.value = newIndex
nextTick(() => {
listRef.value
?.querySelector(`#filter-option-${newIndex}`)
?.scrollIntoView({ block: 'nearest' })
})
}
}
function selectCurrent() {
const option = options.value[selectedIndex.value]
if (option) emit('apply', option)
}
defineExpose({ navigate, selectCurrent })
</script>

View File

@@ -0,0 +1,161 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import {
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
vi.mock('@/utils/litegraphUtil', () => ({
getLinkTypeColor: vi.fn((type: string) =>
type === 'IMAGE' ? '#64b5f6' : undefined
)
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(),
set: vi.fn()
}))
}))
function createFilter(
id: string,
value: string
): FuseFilterWithValue<ComfyNodeDefImpl, string> {
return {
filterDef: {
id,
matches: vi.fn(() => true)
} as unknown as FuseFilter<ComfyNodeDefImpl, string>,
value
}
}
function createActiveFilter(label: string): FilterChip {
return {
key: label.toLowerCase(),
label,
filter: {
id: label.toLowerCase(),
matches: vi.fn(() => true)
} as unknown as FuseFilter<ComfyNodeDefImpl, string>
}
}
describe('NodeSearchInput', () => {
beforeEach(() => {
setupTestPinia()
vi.restoreAllMocks()
})
function createWrapper(
props: Partial<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
searchQuery: string
filterQuery: string
}> = {}
) {
return mount(NodeSearchInput, {
props: {
filters: [],
activeFilter: null,
searchQuery: '',
filterQuery: '',
...props
},
global: { plugins: [testI18n] }
})
}
it('should route input to searchQuery when no active filter', async () => {
const wrapper = createWrapper()
await wrapper.find('input').setValue('test search')
expect(wrapper.emitted('update:searchQuery')![0]).toEqual(['test search'])
})
it('should route input to filterQuery when active filter is set', async () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('input').setValue('IMAGE')
expect(wrapper.emitted('update:filterQuery')![0]).toEqual(['IMAGE'])
expect(wrapper.emitted('update:searchQuery')).toBeUndefined()
})
it('should show filter label placeholder when active filter is set', () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
expect(
(wrapper.find('input').element as HTMLInputElement).placeholder
).toContain('input')
})
it('should show add node placeholder when no active filter', () => {
const wrapper = createWrapper()
expect(
(wrapper.find('input').element as HTMLInputElement).placeholder
).toContain('Add a node')
})
it('should hide filter chips when active filter is set', () => {
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')],
activeFilter: createActiveFilter('Input')
})
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(0)
})
it('should show filter chips when no active filter', () => {
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')]
})
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(1)
})
it('should emit cancelFilter when cancel button is clicked', async () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('[data-testid="cancel-filter"]').trigger('click')
expect(wrapper.emitted('cancelFilter')).toHaveLength(1)
})
it('should emit selectCurrent on Enter', async () => {
const wrapper = createWrapper()
await wrapper.find('input').trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('selectCurrent')).toHaveLength(1)
})
it('should emit navigateDown on ArrowDown', async () => {
const wrapper = createWrapper()
await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' })
expect(wrapper.emitted('navigateDown')).toHaveLength(1)
})
it('should emit navigateUp on ArrowUp', async () => {
const wrapper = createWrapper()
await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
expect(wrapper.emitted('navigateUp')).toHaveLength(1)
})
})

View File

@@ -0,0 +1,145 @@
<template>
<div class="p-3">
<TagsInputRoot
:model-value="tagValues"
delimiter=""
class="flex cursor-text flex-wrap items-center gap-2 rounded-lg bg-secondary-background px-4 py-3"
@remove-tag="onRemoveTag"
@click="inputRef?.focus()"
>
<!-- Active filter label (filter selection mode) -->
<span
v-if="activeFilter"
class="inline-flex shrink-0 items-center gap-1 rounded-lg bg-base-background px-2 py-1 -my-1 text-sm opacity-80 text-foreground"
>
{{ activeFilter.label }}:
<button
type="button"
data-testid="cancel-filter"
class="cursor-pointer border-none bg-transparent text-muted-foreground hover:text-base-foreground rounded-full aspect-square"
:aria-label="$t('g.remove')"
@click="emit('cancelFilter')"
>
<i class="pi pi-times text-xs" />
</button>
</span>
<!-- Applied filter chips -->
<template v-if="!activeFilter">
<TagsInputItem
v-for="filter in filters"
:key="filterKey(filter)"
:value="filterKey(filter)"
data-testid="filter-chip"
class="inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 -my-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
>
<span class="text-sm opacity-80">
{{ t(`g.${filter.filterDef.id}`) }}:
</span>
<span :style="{ color: getLinkTypeColor(filter.value) }">
&bull;
</span>
<span class="text-sm">{{ filter.value }}</span>
<TagsInputItemDelete
as="button"
type="button"
data-testid="chip-delete"
:aria-label="$t('g.remove')"
class="ml-1 cursor-pointer border-none bg-transparent text-muted-foreground hover:text-base-foreground rounded-full aspect-square"
>
<i class="pi pi-times text-xs" />
</TagsInputItemDelete>
</TagsInputItem>
</template>
<TagsInputInput as-child>
<input
ref="inputRef"
v-model="inputValue"
type="text"
role="combobox"
aria-autocomplete="list"
:aria-expanded="true"
:aria-controls="activeFilter ? 'filter-options-list' : 'results-list'"
:aria-label="inputPlaceholder"
:placeholder="inputPlaceholder"
class="h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent text-foreground text-sm outline-none placeholder:text-muted-foreground"
@keydown.enter.prevent="emit('selectCurrent')"
@keydown.down.prevent="emit('navigateDown')"
@keydown.up.prevent="emit('navigateUp')"
/>
</TagsInputInput>
</TagsInputRoot>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputRoot
} from 'reka-ui'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
const { filters, activeFilter } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
}>()
const searchQuery = defineModel<string>('searchQuery', { required: true })
const filterQuery = defineModel<string>('filterQuery', { required: true })
const emit = defineEmits<{
removeFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
cancelFilter: []
navigateDown: []
navigateUp: []
selectCurrent: []
}>()
const { t } = useI18n()
const inputRef = ref<HTMLInputElement>()
const inputValue = computed({
get: () => (activeFilter ? filterQuery.value : searchQuery.value),
set: (value: string) => {
if (activeFilter) {
filterQuery.value = value
} else {
searchQuery.value = value
}
}
})
const inputPlaceholder = computed(() =>
activeFilter
? t('g.filterByType', { type: activeFilter.label.toLowerCase() })
: t('g.addNode')
)
const tagValues = computed(() => filters.map(filterKey))
function filterKey(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
return `${filter.filterDef.id}:${filter.value}`
}
function onRemoveTag(tagValue: string) {
const filter = filters.find((f) => filterKey(f) === tagValue)
if (filter) emit('removeFilter', filter)
}
function focus() {
inputRef.value?.focus()
}
onMounted(() => {
focus()
})
defineExpose({ focus })
</script>

View File

@@ -0,0 +1,135 @@
<template>
<div
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
>
<div class="flex flex-col gap-0.5 overflow-hidden">
<div class="font-semibold text-foreground flex items-center gap-2">
<span v-if="isBookmarked && !hideBookmarkIcon">
<i class="pi pi-bookmark-fill mr-1 text-sm" />
</span>
<span v-html="highlightQuery(nodeDef.display_name, currentQuery)" />
<span v-if="showIdName">&nbsp;</span>
<span
v-if="showIdName"
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
v-html="highlightQuery(nodeDef.name, currentQuery)"
/>
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
</div>
<div
v-if="showDescription"
class="flex items-center gap-1 text-[11px] text-muted-foreground"
>
<span
v-if="
showSourceBadge &&
nodeDef.nodeSource.type !== NodeSourceType.Core &&
nodeDef.nodeSource.type !== NodeSourceType.Unknown
"
class="inline-flex shrink-0 rounded border border-border px-1.5 py-0.5 text-xs bg-base-foreground/5 text-base-foreground/70 mr-0.5"
>
{{ nodeDef.nodeSource.displayText }}
</span>
<TextTicker v-if="nodeDef.description">
{{ nodeDef.description }}
</TextTicker>
</div>
<div
v-else-if="showCategory"
class="option-category truncate text-sm font-light text-muted"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
</div>
</div>
<div v-if="!showDescription" class="flex items-center gap-1">
<span
v-if="nodeDef.deprecated"
class="rounded bg-red-500/20 px-1.5 py-0.5 text-xs text-red-400"
>
{{ $t('g.deprecated') }}
</span>
<span
v-if="nodeDef.experimental"
class="rounded bg-blue-500/20 px-1.5 py-0.5 text-xs text-blue-400"
>
{{ $t('g.experimental') }}
</span>
<span
v-if="nodeDef.dev_only"
class="rounded bg-cyan-500/20 px-1.5 py-0.5 text-xs text-cyan-400"
>
{{ $t('g.devOnly') }}
</span>
<span
v-if="showNodeFrequency && nodeFrequency > 0"
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{ formatNumberWithSuffix(nodeFrequency, { roundToInt: true }) }}
</span>
<span
v-if="nodeDef.nodeSource.type !== NodeSourceType.Unknown"
class="rounded bg-secondary-background px-2 py-0.5 text-sm text-muted-foreground"
>
{{ nodeDef.nodeSource.displayText }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import TextTicker from '@/components/common/TextTicker.vue'
import NodePricingBadge from '@/components/node/NodePricingBadge.vue'
import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
const {
nodeDef,
currentQuery,
showDescription = false,
showSourceBadge = false,
hideBookmarkIcon = false
} = defineProps<{
nodeDef: ComfyNodeDefImpl
currentQuery: string
showDescription?: boolean
showSourceBadge?: boolean
hideBookmarkIcon?: boolean
}>()
const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
)
const showIdName = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
)
const showNodeFrequency = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')
)
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeFrequency = computed(() =>
nodeFrequencyStore.getNodeFrequency(nodeDef)
)
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

@@ -0,0 +1,52 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
export function createMockNodeDef(
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef {
return {
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
python_module: 'nodes',
description: 'Test description',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
deprecated: false,
experimental: false,
...overrides
}
}
export function setupTestPinia() {
setActivePinia(createTestingPinia({ stubActions: false }))
}
export const testI18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
addNode: 'Add a node...',
filterBy: 'Filter by:',
mostRelevant: 'Most relevant',
favorites: 'Favorites',
essentials: 'Essentials',
custom: 'Custom',
noResults: 'No results',
filterByType: 'Filter by {type}...',
input: 'Input',
output: 'Output',
source: 'Source',
search: 'Search'
}
}
}
})

View File

@@ -0,0 +1,132 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
useLocalStorage: vi.fn((_key: string, defaultValue: unknown) =>
ref(defaultValue)
)
}
})
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
isDragging: { value: false },
draggedNode: { value: null },
cursorPosition: { value: { x: 0, y: 0 } },
startDrag: vi.fn(),
cancelDrag: vi.fn(),
setupGlobalListeners: vi.fn(),
cleanupGlobalListeners: vi.fn()
})
}))
vi.mock('@/services/nodeOrganizationService', () => ({
DEFAULT_TAB_ID: 'essentials',
DEFAULT_SORTING_ID: 'alphabetical',
nodeOrganizationService: {
organizeNodesByTab: vi.fn(() => []),
getSortingStrategies: vi.fn(() => [])
}
}))
vi.mock('./nodeLibrary/AllNodesPanel.vue', () => ({
default: {
name: 'AllNodesPanel',
template: '<div data-testid="all-panel"><slot /></div>',
props: ['sections', 'expandedKeys', 'fillNodeInfo']
}
}))
vi.mock('./nodeLibrary/CustomNodesPanel.vue', () => ({
default: {
name: 'CustomNodesPanel',
template: '<div data-testid="custom-panel"><slot /></div>',
props: ['sections', 'expandedKeys']
}
}))
vi.mock('./nodeLibrary/EssentialNodesPanel.vue', () => ({
default: {
name: 'EssentialNodesPanel',
template: '<div data-testid="essential-panel"><slot /></div>',
props: ['root', 'expandedKeys']
}
}))
vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
default: {
name: 'NodeDragPreview',
template: '<div />'
}
}))
vi.mock('@/components/common/SearchBoxV2.vue', () => ({
default: {
name: 'SearchBox',
template: '<input data-testid="search-box" />',
props: ['modelValue', 'placeholder'],
setup() {
return { focus: vi.fn() }
},
expose: ['focus']
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
describe('NodeLibrarySidebarTabV2', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function mountComponent() {
return mount(NodeLibrarySidebarTabV2, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
components: {
TabsRoot,
TabsList,
TabsTrigger,
TabsContent
},
stubs: {
teleport: true
}
}
})
}
it('should render with tabs', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAllComponents(TabsTrigger)
expect(triggers.length).toBe(3)
})
it('should render search box', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="search-box"]').exists()).toBe(true)
})
it('should render only the selected panel', () => {
const wrapper = mountComponent()
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)
})
})

View File

@@ -0,0 +1,348 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.nodes')">
<template #header>
<TabsRoot v-model="selectedTab" class="flex flex-col">
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
<SearchBox
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
:aria-label="$t('g.sort')"
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--arrow-up-down] 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"
>
<DropdownMenuRadioGroup v-model="sortOrder">
<DropdownMenuRadioItem
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"
>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
<span>{{ $t(option.label) }}</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<Separator decorative class="border border-dashed border-comfy-input" />
<!-- Tab list in header (fixed) -->
<TabsList
class="flex gap-4 border-b border-comfy-input bg-background p-4 justify-between"
>
<TabsTrigger
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:class="
cn(
'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'
: 'bg-transparent font-normal'
)
"
>
{{ tab.label }}
</TabsTrigger>
</TabsList>
</TabsRoot>
</template>
<template #body>
<NodeDragPreview />
<!-- Tab content (scrollable) -->
<TabsRoot v-model="selectedTab" class="h-full">
<EssentialNodesPanel
v-if="selectedTab === 'essentials'"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
@node-click="handleNodeClick"
/>
<AllNodesPanel
v-if="selectedTab === 'all'"
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
@node-click="handleNodeClick"
/>
<CustomNodesPanel
v-if="selectedTab === 'custom'"
v-model:expanded-keys="expandedKeys"
:sections="renderedCustomSections"
@node-click="handleNodeClick"
/>
</TabsRoot>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRoot,
DropdownMenuTrigger,
Separator,
TabsList,
TabsRoot,
TabsTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import {
DEFAULT_SORTING_ID,
DEFAULT_TAB_ID,
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { getProviderIcon } from '@/utils/categoryUtil'
import { sortedTree } from '@/utils/treeUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { SortingStrategyId, TabId } from '@/types/nodeOrganizationTypes'
import type {
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import CustomNodesPanel from './nodeLibrary/CustomNodesPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
const selectedTab = useLocalStorage<TabId>(
'Comfy.NodeLibrary.Tab',
DEFAULT_TAB_ID
)
const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
'Comfy.NodeLibrary.SortByTab',
{
essentials: DEFAULT_SORTING_ID,
all: DEFAULT_SORTING_ID,
custom: 'alphabetical'
}
)
const sortOrder = computed({
get: () => sortOrderByTab.value[selectedTab.value],
set: (value) => {
sortOrderByTab.value = {
...sortOrderByTab.value,
[selectedTab.value]: value
}
}
})
const sortingOptions = computed(() =>
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
id: strategy.id,
label: strategy.label
}))
)
const { t } = useI18n()
const searchBoxRef = ref()
const searchQuery = ref('')
const expandedKeysByTab = ref<Record<TabId, string[]>>({
essentials: [],
all: [],
custom: []
})
const expandedKeys = computed({
get: () => expandedKeysByTab.value[selectedTab.value],
set: (value) => {
expandedKeysByTab.value[selectedTab.value] = value
}
})
const nodeDefStore = useNodeDefStore()
const { startDrag } = useNodeDragToCanvas()
const filteredNodeDefs = computed(() => {
if (searchQuery.value.length === 0) {
return []
}
return nodeDefStore.nodeSearchService.searchNode(
searchQuery.value,
[],
{ limit: 64 },
{ matchWildcards: false }
)
})
const activeNodes = computed(() =>
filteredNodeDefs.value.length > 0
? filteredNodeDefs.value
: nodeDefStore.visibleNodeDefs
)
const sections = computed(() => {
if (selectedTab.value !== 'all') return []
return nodeOrganizationService.organizeNodesByTab(activeNodes.value, 'all')
})
function getFolderIcon(node: TreeNode): string {
const firstLeaf = findFirstLeaf(node)
if (
firstLeaf?.key?.startsWith('root/api node') &&
firstLeaf.key.replace(`${node.key}/`, '') === firstLeaf.label
) {
return getProviderIcon(node.label ?? '')
}
return 'icon-[lucide--folder]'
}
function findFirstLeaf(node: TreeNode): TreeNode | undefined {
if (node.leaf) return node
for (const child of node.children ?? []) {
const leaf = findFirstLeaf(child)
if (leaf) return leaf
}
return undefined
}
function fillNodeInfo(
node: TreeNode
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
const children = node.children?.map(fillNodeInfo)
const totalLeaves = node.leaf
? 1
: (children?.reduce((acc, child) => acc + child.totalLeaves, 0) ?? 0)
return {
key: node.key,
label: node.leaf ? node.data?.display_name : node.label,
leaf: node.leaf,
data: node.data,
icon: node.leaf ? 'icon-[comfy--node]' : getFolderIcon(node),
type: node.leaf ? 'node' : 'folder',
totalLeaves,
children
}
}
function applySorting(tree: TreeNode): TreeNode {
if (sortOrder.value === 'alphabetical') {
return sortedTree(tree, { groupLeaf: true })
}
return tree
}
const renderedSections = computed(() => {
return sections.value.map((section) => ({
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
})
const essentialSections = computed(() => {
if (selectedTab.value !== 'essentials') return []
return nodeOrganizationService.organizeNodesByTab(
activeNodes.value,
'essentials'
)
})
const renderedEssentialRoot = computed(() => {
const section = essentialSections.value[0]
return section
? fillNodeInfo(applySorting(section.tree))
: fillNodeInfo({ key: 'root', label: '', children: [] })
})
const customSections = computed(() => {
if (selectedTab.value !== 'custom') return []
return nodeOrganizationService.organizeNodesByTab(activeNodes.value, 'custom')
})
const renderedCustomSections = computed(() => {
return customSections.value.map((section) => ({
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
})
function collectFolderKeys(node: TreeNode): string[] {
if (node.leaf) return []
const keys = [node.key]
for (const child of node.children ?? []) {
keys.push(...collectFolderKeys(child))
}
return keys
}
function handleNodeClick(node: RenderedTreeExplorerNode<ComfyNodeDefImpl>) {
if (node.type === 'node' && node.data) {
startDrag(node.data)
}
if (node.type === 'folder') {
const index = expandedKeys.value.indexOf(node.key)
if (index === -1) {
expandedKeys.value = [...expandedKeys.value, node.key]
} else {
expandedKeys.value = expandedKeys.value.filter((k) => k !== node.key)
}
}
}
async function handleSearch() {
await nextTick()
if (filteredNodeDefs.value.length === 0) {
expandedKeys.value = []
return
}
const allKeys: string[] = []
if (selectedTab.value === 'essentials') {
for (const section of essentialSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else if (selectedTab.value === 'custom') {
for (const section of customSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else {
for (const section of sections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
}
expandedKeys.value = allKeys
}
const tabs = computed(() => [
{ value: 'essentials', label: t('sideToolbar.nodeLibraryTab.essentials') },
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
])
onMounted(() => {
searchBoxRef.value?.focus()
})
</script>

View File

@@ -1,5 +1,6 @@
<template>
<div
ref="containerRef"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
@@ -35,9 +36,17 @@
</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'
@@ -45,6 +54,9 @@ const props = defineProps<{
title: string
class?: string
}>()
const containerRef = ref<HTMLElement | null>(null)
provide(SidebarContainerKey, containerRef)
</script>
<style scoped>

View 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>

View 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>

View File

@@ -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)
})
})
})

View File

@@ -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>

View File

@@ -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)
})
})
})

View 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>

View 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>

View File

@@ -0,0 +1,342 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'
const { mockAddNodeOnGraph, mockConvertEventToCanvasOffset, mockCanvas } =
vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
return {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
},
convertEventToCanvasOffset: mockConvertEventToCanvasOffset
}
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({
canvas: mockCanvas
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
addNodeOnGraph: mockAddNodeOnGraph
}))
}))
describe('useNodeDragToCanvas', () => {
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node'
} as ComfyNodeDefImpl
beforeEach(async () => {
vi.resetModules()
vi.resetAllMocks()
const module = await import('./useNodeDragToCanvas')
useNodeDragToCanvas = module.useNodeDragToCanvas
})
afterEach(() => {
const { cleanupGlobalListeners } = useNodeDragToCanvas()
cleanupGlobalListeners()
vi.restoreAllMocks()
})
describe('startDrag', () => {
it('should set isDragging to true and store the node definition', () => {
const { isDragging, draggedNode, startDrag } = useNodeDragToCanvas()
expect(isDragging.value).toBe(false)
expect(draggedNode.value).toBeNull()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
expect(draggedNode.value).toBe(mockNodeDef)
})
it('should set dragMode to click by default', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dragMode.value).toBe('click')
})
it('should set dragMode to native when specified', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
})
})
describe('cancelDrag', () => {
it('should reset isDragging and draggedNode', () => {
const { isDragging, draggedNode, startDrag, cancelDrag } =
useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
cancelDrag()
expect(isDragging.value).toBe(false)
expect(draggedNode.value).toBeNull()
})
it('should reset dragMode to click', () => {
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
cancelDrag()
expect(dragMode.value).toBe('click')
})
})
describe('setupGlobalListeners', () => {
it('should add event listeners to document', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function)
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('should only setup listeners once', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const callCount = addEventListenerSpy.mock.calls.length
setupGlobalListeners()
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
})
})
describe('cursorPosition', () => {
it('should update on pointermove', () => {
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const pointerEvent = new PointerEvent('pointermove', {
clientX: 100,
clientY: 200
})
document.dispatchEvent(pointerEvent)
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
})
})
describe('endDrag behavior', () => {
it('should add node when pointer is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
document.dispatchEvent(pointerEvent)
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
pos: [150, 150]
})
})
it('should not add node when pointer is outside canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
clientX: 600,
clientY: 250,
bubbles: true
})
document.dispatchEvent(pointerEvent)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(isDragging.value).toBe(false)
})
it('should cancel drag on Escape key', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
const keyEvent = new KeyboardEvent('keydown', { key: 'Escape' })
document.dispatchEvent(keyEvent)
expect(isDragging.value).toBe(false)
})
it('should not cancel drag on other keys', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
document.dispatchEvent(keyEvent)
expect(isDragging.value).toBe(true)
})
it('should not add node on pointerup when in native drag mode', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
document.dispatchEvent(pointerEvent)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(isDragging.value).toBe(true)
})
})
describe('handleNativeDrop', () => {
it('should add node when drop position is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
pos: [200, 200]
})
})
it('should not add node when drop position is outside canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
handleNativeDrop(600, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(isDragging.value).toBe(false)
})
it('should not add node when dragMode is click', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'click')
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
})
it('should reset drag state after drop', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop, isDragging, dragMode } =
useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
handleNativeDrop(250, 250)
expect(isDragging.value).toBe(false)
expect(dragMode.value).toBe('click')
})
})
})

View File

@@ -0,0 +1,116 @@
import { ref, shallowRef } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type DragMode = 'click' | 'native'
const isDragging = ref(false)
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
const cursorPosition = ref({ x: 0, y: 0 })
const dragMode = ref<DragMode>('click')
let listenersSetup = false
function updatePosition(e: PointerEvent) {
cursorPosition.value = { x: e.clientX, y: e.clientY }
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
if (!draggedNode.value) return false
const canvasStore = useCanvasStore()
const canvas = canvasStore.canvas
if (!canvas) return false
const canvasElement = canvas.canvas as HTMLCanvasElement
const rect = canvasElement.getBoundingClientRect()
const isOverCanvas =
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
if (isOverCanvas) {
const pos = canvas.convertEventToCanvasOffset({
clientX,
clientY
} as PointerEvent)
const litegraphService = useLitegraphService()
litegraphService.addNodeOnGraph(draggedNode.value, { pos })
return true
}
return false
}
function endDrag(e: PointerEvent) {
if (!isDragging.value || !draggedNode.value) return
if (dragMode.value !== 'click') return
try {
addNodeAtPosition(e.clientX, e.clientY)
} finally {
cancelDrag()
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancelDrag()
}
function setupGlobalListeners() {
if (listenersSetup) return
listenersSetup = true
document.addEventListener('pointermove', updatePosition)
document.addEventListener('pointerup', endDrag, true)
document.addEventListener('keydown', handleKeydown)
}
function cleanupGlobalListeners() {
if (!listenersSetup) return
listenersSetup = false
document.removeEventListener('pointermove', updatePosition)
document.removeEventListener('pointerup', endDrag, true)
document.removeEventListener('keydown', handleKeydown)
if (isDragging.value && dragMode.value === 'click') {
cancelDrag()
}
}
export function useNodeDragToCanvas() {
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
isDragging.value = true
draggedNode.value = nodeDef
dragMode.value = mode
}
function handleNativeDrop(clientX: number, clientY: number) {
if (dragMode.value !== 'native') return
try {
addNodeAtPosition(clientX, clientY)
} finally {
cancelDrag()
}
}
return {
isDragging,
draggedNode,
cursorPosition,
dragMode,
startDrag,
cancelDrag,
handleNativeDrop,
setupGlobalListeners,
cleanupGlobalListeners
}
}

View File

@@ -0,0 +1,179 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodePreviewAndDrag } from './useNodePreviewAndDrag'
const mockStartDrag = vi.fn()
const mockHandleNativeDrop = vi.fn()
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
startDrag: mockStartDrag,
handleNativeDrop: mockHandleNativeDrop
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
describe('useNodePreviewAndDrag', () => {
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node'
} as ComfyNodeDefImpl
beforeEach(() => {
vi.clearAllMocks()
})
describe('initial state', () => {
it('should initialize with correct default values', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
expect(result.isHovered.value).toBe(false)
expect(result.isDragging.value).toBe(false)
expect(result.showPreview.value).toBe(false)
expect(result.previewRef.value).toBeNull()
})
it('should compute showPreview based on hover and drag state', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
result.isHovered.value = true
expect(result.showPreview.value).toBe(true)
result.isDragging.value = true
expect(result.showPreview.value).toBe(false)
})
})
describe('handleMouseEnter', () => {
it('should set isHovered to true when nodeDef exists', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
const mockElement = document.createElement('div')
vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({
top: 100,
left: 50,
right: 150,
bottom: 200,
width: 100,
height: 100,
x: 50,
y: 100,
toJSON: () => ({})
})
const mockEvent = { currentTarget: mockElement } as unknown as MouseEvent
result.handleMouseEnter(mockEvent)
expect(result.isHovered.value).toBe(true)
})
it('should not set isHovered when nodeDef is undefined', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(undefined)
const result = useNodePreviewAndDrag(nodeDef)
const mockElement = document.createElement('div')
const mockEvent = { currentTarget: mockElement } as unknown as MouseEvent
result.handleMouseEnter(mockEvent)
expect(result.isHovered.value).toBe(false)
})
})
describe('handleMouseLeave', () => {
it('should set isHovered to false', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
result.isHovered.value = true
result.handleMouseLeave()
expect(result.isHovered.value).toBe(false)
})
})
describe('handleDragStart', () => {
it('should call startDrag with native mode when nodeDef exists', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
const mockDataTransfer = {
effectAllowed: '',
setData: vi.fn(),
setDragImage: vi.fn()
}
const mockEvent = {
dataTransfer: mockDataTransfer
} as unknown as DragEvent
result.handleDragStart(mockEvent)
expect(result.isDragging.value).toBe(true)
expect(result.isHovered.value).toBe(false)
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, 'native')
expect(mockDataTransfer.effectAllowed).toBe('copy')
expect(mockDataTransfer.setData).toHaveBeenCalledWith(
'application/x-comfy-node',
'TestNode'
)
})
it('should not start drag when nodeDef is undefined', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(undefined)
const result = useNodePreviewAndDrag(nodeDef)
const mockEvent = { dataTransfer: null } as DragEvent
result.handleDragStart(mockEvent)
expect(result.isDragging.value).toBe(false)
expect(mockStartDrag).not.toHaveBeenCalled()
})
})
describe('handleDragEnd', () => {
it('should call handleNativeDrop with drop coordinates', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
result.isDragging.value = true
const mockEvent = {
clientX: 100,
clientY: 200
} as unknown as DragEvent
result.handleDragEnd(mockEvent)
expect(result.isDragging.value).toBe(false)
expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
})
it('should always call handleNativeDrop regardless of dropEffect', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
result.isDragging.value = true
const mockEvent = {
dataTransfer: { dropEffect: 'none' },
clientX: 300,
clientY: 400
} as unknown as DragEvent
result.handleDragEnd(mockEvent)
expect(result.isDragging.value).toBe(false)
expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400)
})
})
})

View File

@@ -0,0 +1,149 @@
import type { CSSProperties, Ref } from 'vue'
import { computed, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const PREVIEW_WIDTH = 200
const PREVIEW_MARGIN = 16
export function useNodePreviewAndDrag(
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
options?: { panelRef?: Ref<HTMLElement | null> }
) {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
const settingStore = useSettingStore()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const previewRef = ref<HTMLElement | null>(null)
const isHovered = ref(false)
const isDragging = ref(false)
const showPreview = computed(() => isHovered.value && !isDragging.value)
const nodePreviewStyle = ref<CSSProperties>({
position: 'fixed',
top: '0px',
left: '0px',
pointerEvents: 'none',
zIndex: 1000
})
function calculatePreviewPosition(rect: DOMRect) {
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
let left: number
if (sidebarLocation.value === 'left') {
left = rect.right + PREVIEW_MARGIN
if (left + PREVIEW_WIDTH > viewportWidth) {
left = rect.left - PREVIEW_MARGIN - PREVIEW_WIDTH
}
} else {
left = rect.left - PREVIEW_MARGIN - PREVIEW_WIDTH
if (left < 0) {
left = rect.right + PREVIEW_MARGIN
}
}
return { left, viewportHeight }
}
function handleMouseEnter(e: MouseEvent) {
if (!nodeDef.value) return
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const horizontalRect =
options?.panelRef?.value?.getBoundingClientRect() ?? rect
const { left, viewportHeight } = calculatePreviewPosition(horizontalRect)
let top = rect.top
nodePreviewStyle.value = {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
pointerEvents: 'none',
zIndex: 1000,
opacity: 0
}
isHovered.value = true
requestAnimationFrame(() => {
if (previewRef.value) {
const previewRect = previewRef.value.getBoundingClientRect()
const previewHeight = previewRect.height
const mouseY = rect.top + rect.height / 2
top = mouseY - previewHeight * 0.3
const minTop = PREVIEW_MARGIN
const maxTop = viewportHeight - previewHeight - PREVIEW_MARGIN
top = Math.max(minTop, Math.min(top, maxTop))
nodePreviewStyle.value = {
...nodePreviewStyle.value,
top: `${top}px`,
opacity: 1
}
}
})
}
function handleMouseLeave() {
isHovered.value = false
}
function createEmptyDragImage(): HTMLElement {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = '-9999px'
el.style.top = '-9999px'
el.style.width = '1px'
el.style.height = '1px'
return el
}
function handleDragStart(e: DragEvent) {
if (!nodeDef.value) return
isDragging.value = true
isHovered.value = false
startDrag(nodeDef.value, 'native')
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-comfy-node', nodeDef.value.name)
const dragImage = createEmptyDragImage()
document.body.appendChild(dragImage)
e.dataTransfer.setDragImage(dragImage, 0, 0)
requestAnimationFrame(() => {
document.body.removeChild(dragImage)
})
}
}
function handleDragEnd(e: DragEvent) {
isDragging.value = false
handleNativeDrop(e.clientX, e.clientY)
}
return {
previewRef,
isHovered,
isDragging,
showPreview,
nodePreviewStyle,
sidebarLocation,
handleMouseEnter,
handleMouseLeave,
handleDragStart,
handleDragEnd
}
}

View File

@@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import { useNodePricing } from '@/composables/node/useNodePricing'
import {
evaluateNodeDefPricing,
formatCreditsListValue,
formatCreditsRangeValue,
formatCreditsValue,
formatPricingResult,
useNodePricing
} from '@/composables/node/useNodePricing'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { PriceBadge } from '@/schemas/nodeDefSchema'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
// -----------------------------------------------------------------------------
@@ -673,18 +680,19 @@ describe('useNodePricing', () => {
expect(price).toBe('')
})
it('should return empty string for PricingResult missing type field', async () => {
it('should handle legacy format without type field', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestMissingTypeNode',
// Returns object without type field
'TestLegacyFormatNode',
// Returns object without type field (legacy format)
priceBadge('{"usd":0.05}')
)
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
const price = getNodeDisplayPrice(node)
expect(price).toBe('')
// Legacy format {usd: number} is supported
expect(price).toBe(creditsLabel(0.05))
})
it('should return empty string for non-object result', async () => {
@@ -855,3 +863,362 @@ describe('useNodePricing', () => {
})
})
})
// -----------------------------------------------------------------------------
// formatPricingResult Tests
// -----------------------------------------------------------------------------
describe('formatPricingResult', () => {
describe('type: usd', () => {
it('should format usd result', () => {
const result = formatPricingResult({ type: 'usd', usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})
it('should handle approximate prefix in valueOnly mode', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05, format: { approximate: true } },
{ valueOnly: true }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null as never })
expect(result).toBe('')
})
})
describe('type: range_usd', () => {
it('should format range result', () => {
const result = formatPricingResult({
type: 'range_usd',
min_usd: 0.05,
max_usd: 0.1
})
expect(result).toBe('10.6-21.1 credits/Run')
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.1 },
{ valueOnly: true }
)
expect(result).toBe('10.6-21.1')
})
it('should collapse range when min equals max', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})
})
describe('type: list_usd', () => {
it('should format list result', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: [0.05, 0.1, 0.15]
})
expect(result).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/)
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true }
)
expect(result).toBe('10.6/21.1')
})
})
describe('type: text', () => {
it('should return text as-is', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
})
describe('legacy format', () => {
it('should handle {usd: number} without type field', () => {
const result = formatPricingResult({ usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})
it('should return valueOnly for legacy format', () => {
const result = formatPricingResult({ usd: 0.05 }, { valueOnly: true })
expect(result).toBe('10.6')
})
})
describe('invalid inputs', () => {
it('should return empty for invalid type', () => {
const result = formatPricingResult({ type: 'invalid' })
expect(result).toBe('')
})
it('should return empty for null', () => {
const result = formatPricingResult(null)
expect(result).toBe('')
})
it('should return empty for undefined', () => {
const result = formatPricingResult(undefined)
expect(result).toBe('')
})
})
})
// -----------------------------------------------------------------------------
// formatCreditsValue / Range / List Tests
// -----------------------------------------------------------------------------
describe('formatCreditsValue', () => {
it('should format USD to credits', () => {
expect(formatCreditsValue(0.05)).toBe('10.6')
expect(formatCreditsValue(1.0)).toBe('211')
})
})
describe('formatCreditsRangeValue', () => {
it('should format min-max range', () => {
expect(formatCreditsRangeValue(0.05, 0.1)).toBe('10.6-21.1')
})
it('should collapse when min equals max', () => {
expect(formatCreditsRangeValue(0.05, 0.05)).toBe('10.6')
})
})
describe('formatCreditsListValue', () => {
it('should join values with separator', () => {
expect(formatCreditsListValue([0.05, 0.1])).toBe('10.6/21.1')
})
it('should use custom separator', () => {
expect(formatCreditsListValue([0.05, 0.1], ' | ')).toBe('10.6 | 21.1')
})
})
// -----------------------------------------------------------------------------
// evaluateNodeDefPricing Tests
// -----------------------------------------------------------------------------
describe('evaluateNodeDefPricing', () => {
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef =>
({
name: 'TestNode',
display_name: 'Test Node',
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
python_module: 'test',
...overrides
}) as ComfyNodeDef
it('should return empty for node without price_badge', async () => {
const nodeDef = createMockNodeDef()
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})
it('should evaluate static expression', async () => {
const nodeDef = createMockNodeDef({
name: 'StaticPriceNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should use default value from input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'DefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
count: ['INT', { default: 10 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
price_badge: {
engine: 'jsonata',
expr: '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: [['standard', 'pro'], {}]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option is "standard", not "pro", so should be 0.05 USD
expect(result).toBe('10.6')
})
it('should use "original" as fallback for dynamic COMBO without input', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicComboNode',
price_badge: {
engine: 'jsonata',
expr: `(
$prices := {"original": 0.05, "720p": 0.03};
{"type":"usd","usd": $lookup($prices, widgets.resolution)}
)`,
depends_on: {
widgets: [{ name: 'resolution', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
// resolution widget is NOT in inputs (dynamically created)
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// Fallback to "original" = 0.05 USD
expect(result).toBe('10.6')
})
it('should handle dynamic combo with options array', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.model = "model_a" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'model', type: 'COMFY_DYNAMICCOMBO_V3' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
model: [
'COMFY_DYNAMICCOMBO_V3',
{ options: [{ key: 'model_a' }, { key: 'model_b' }] }
]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option key is "model_a" = 0.05 USD
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',
price_badge: {
engine: 'jsonata',
expr: 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [],
inputs: ['image'],
input_groups: []
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// In preview, inputs are assumed disconnected
expect(result).toBe('10.6')
})
it('should assume inputGroups have 0 count in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputGroupNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": 0.05 + inputGroups.videos * 0.02}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// 0.05 + 0 * 0.02 = 0.05 USD
expect(result).toBe('10.6')
})
it('should return empty on JSONata error', async () => {
const nodeDef = createMockNodeDef({
name: 'ErrorNode',
price_badge: {
engine: 'jsonata',
expr: '$lookup(undefined, "key")',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})
it('should handle range_usd result', async () => {
const nodeDef = createMockNodeDef({
name: 'RangeNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"range_usd","min_usd":0.05,"max_usd":0.10}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6-21.1')
})
it('should handle approximate format in valueOnly mode', async () => {
const nodeDef = createMockNodeDef({
name: 'ApproximateNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05,"format":{"approximate":true}}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('~10.6')
})
})

View File

@@ -10,6 +10,7 @@
// - async evaluation + cache,
// - reactive tick to update UI when async evaluation completes.
import { memoize } from 'es-toolkit'
import { readonly, ref } from 'vue'
import type { Ref } from 'vue'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
@@ -47,7 +48,7 @@ type CreditFormatOptions = {
separator?: string
}
const formatCreditsValue = (usd: number): string => {
export const formatCreditsValue = (usd: number): string => {
// Use raw credits value (before rounding) to determine decimal display
const rawCredits = usd * CREDITS_PER_USD
return formatCredits({
@@ -68,23 +69,37 @@ const formatCreditsLabel = (
): string =>
`${makePrefix(approximate)}${formatCreditsValue(usd)} credits${makeSuffix(suffix)}${appendNote(note)}`
export const formatCreditsRangeValue = (
minUsd: number,
maxUsd: number
): string => {
const min = formatCreditsValue(minUsd)
const max = formatCreditsValue(maxUsd)
return min === max ? min : `${min}-${max}`
}
const formatCreditsRangeLabel = (
minUsd: number,
maxUsd: number,
{ suffix, note, approximate }: CreditFormatOptions = {}
): string => {
const min = formatCreditsValue(minUsd)
const max = formatCreditsValue(maxUsd)
const rangeValue = min === max ? min : `${min}-${max}`
const rangeValue = formatCreditsRangeValue(minUsd, maxUsd)
return `${makePrefix(approximate)}${rangeValue} credits${makeSuffix(suffix)}${appendNote(note)}`
}
export const formatCreditsListValue = (
usdValues: number[],
separator = '/'
): string => {
const parts = usdValues.map((value) => formatCreditsValue(value))
return parts.join(separator)
}
const formatCreditsListLabel = (
usdValues: number[],
{ suffix, note, approximate, separator }: CreditFormatOptions = {}
): string => {
const parts = usdValues.map((value) => formatCreditsValue(value))
const value = parts.join(separator ?? '/')
const value = formatCreditsListValue(usdValues, separator)
return `${makePrefix(approximate)}${value} credits${makeSuffix(suffix)}${appendNote(note)}`
}
@@ -130,7 +145,6 @@ type JsonataPricingRule = {
input_groups: string[]
}
expr: string
result_defaults?: CreditFormatOptions
}
type CompiledJsonataPricingRule = JsonataPricingRule & {
@@ -283,10 +297,39 @@ const buildSignature = (
// -----------------------------
// Result formatting
// -----------------------------
const formatPricingResult = (
type FormatPricingResultOptions = {
/** If true, return only the value without "credits/Run" suffix */
valueOnly?: boolean
defaults?: CreditFormatOptions
}
/**
* Format a PricingResult into a display string.
* @param result - The pricing result from JSONata evaluation
* @param options - Formatting options
* @returns Formatted string, e.g. "10 credits/Run" or "10" if valueOnly
*/
export const formatPricingResult = (
result: unknown,
defaults: CreditFormatOptions = {}
options: FormatPricingResultOptions = {}
): string => {
const { valueOnly = false, defaults = {} } = options
// Handle legacy format: { usd: number } without type field
if (
result &&
typeof result === 'object' &&
!('type' in result) &&
'usd' in result
) {
const r = result as { usd: unknown }
const usd = asFiniteNumber(r.usd)
if (usd === null) return ''
if (valueOnly) return formatCreditsValue(usd)
return formatCreditsLabel(usd, defaults)
}
if (!isPricingResult(result)) {
if (result !== undefined && result !== null) {
console.warn('[pricing/jsonata] invalid result format:', result)
@@ -302,6 +345,10 @@ const formatPricingResult = (
const usd = asFiniteNumber(result.usd)
if (usd === null) return ''
const fmt = { ...defaults, ...(result.format ?? {}) }
if (valueOnly) {
const prefix = fmt.approximate ? '~' : ''
return `${prefix}${formatCreditsValue(usd)}`
}
return formatCreditsLabel(usd, fmt)
}
@@ -310,6 +357,10 @@ const formatPricingResult = (
const maxUsd = asFiniteNumber(result.max_usd)
if (minUsd === null || maxUsd === null) return ''
const fmt = { ...defaults, ...(result.format ?? {}) }
if (valueOnly) {
const prefix = fmt.approximate ? '~' : ''
return `${prefix}${formatCreditsRangeValue(minUsd, maxUsd)}`
}
return formatCreditsRangeLabel(minUsd, maxUsd, fmt)
}
@@ -324,6 +375,10 @@ const formatPricingResult = (
if (usdValues.length === 0) return ''
const fmt = { ...defaults, ...(result.format ?? {}) }
if (valueOnly) {
const prefix = fmt.approximate ? '~' : ''
return `${prefix}${formatCreditsListValue(usdValues)}`
}
return formatCreditsListLabel(usdValues, fmt)
}
@@ -418,8 +473,6 @@ const cache = new WeakMap<LGraphNode, CacheEntry>()
const desiredSig = new WeakMap<LGraphNode, string>()
const inflight = new WeakMap<LGraphNode, InflightEntry>()
const DEBUG_JSONATA_PRICING = false
const scheduleEvaluation = (
node: LGraphNode,
rule: CompiledJsonataPricingRule,
@@ -433,31 +486,17 @@ const scheduleEvaluation = (
if (!rule._compiled) return
const nodeName = getNodeConstructorData(node)?.name ?? ''
const promise = Promise.resolve(rule._compiled.evaluate(ctx))
.then((res) => {
const label = formatPricingResult(res, rule.result_defaults ?? {})
const label = formatPricingResult(res)
// Ignore stale results: if the node changed while we were evaluating,
// desiredSig will no longer match.
if (desiredSig.get(node) !== sig) return
cache.set(node, { sig, label })
if (DEBUG_JSONATA_PRICING) {
console.warn('[pricing/jsonata] resolved', nodeName, {
sig,
res,
label
})
}
})
.catch((err) => {
if (process.env.NODE_ENV === 'development') {
console.warn('[pricing/jsonata] evaluation failed', nodeName, err)
}
.catch(() => {
// Cache empty to avoid retry-spam for same signature
if (desiredSig.get(node) === sig) {
cache.set(node, { sig, label: '' })
@@ -497,6 +536,14 @@ const getRuleForNode = (
return compiled ?? undefined
}
// -----------------------------
// Helper to get price badge from node type
// -----------------------------
const getNodePriceBadge = (nodeType: string): PriceBadge | undefined => {
const nodeDefStore = useNodeDefStore()
return nodeDefStore.nodeDefsByName[nodeType]?.price_badge
}
// -----------------------------
// Public composable API
// -----------------------------
@@ -550,11 +597,7 @@ export const useNodePricing = () => {
* returns union of widget dependencies + input dependencies for a node type.
*/
const getRelevantWidgetNames = (nodeType: string): string[] => {
const nodeDefStore = useNodeDefStore()
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return []
const priceBadge = nodeDef.price_badge
const priceBadge = getNodePriceBadge(nodeType)
if (!priceBadge) return []
const dependsOn = priceBadge.depends_on ?? {
@@ -563,10 +606,9 @@ export const useNodePricing = () => {
input_groups: []
}
// Extract widget names
const widgetNames = (dependsOn.widgets ?? []).map((w) => w.name)
// Keep stable output (dedupe while preserving order)
// Dedupe while preserving order
const out: string[] = []
for (const n of [
...widgetNames,
@@ -582,11 +624,7 @@ export const useNodePricing = () => {
* Check if a node type has dynamic pricing (depends on widgets, inputs, or input_groups).
*/
const hasDynamicPricing = (nodeType: string): boolean => {
const nodeDefStore = useNodeDefStore()
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return false
const priceBadge = nodeDef.price_badge
const priceBadge = getNodePriceBadge(nodeType)
if (!priceBadge) return false
const dependsOn = priceBadge.depends_on
@@ -603,28 +641,16 @@ export const useNodePricing = () => {
* Get input_groups prefixes for a node type (for watching connection changes).
*/
const getInputGroupPrefixes = (nodeType: string): string[] => {
const nodeDefStore = useNodeDefStore()
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return []
const priceBadge = nodeDef.price_badge
if (!priceBadge) return []
return priceBadge.depends_on?.input_groups ?? []
const priceBadge = getNodePriceBadge(nodeType)
return priceBadge?.depends_on?.input_groups ?? []
}
/**
* Get regular input names for a node type (for watching connection changes).
*/
const getInputNames = (nodeType: string): string[] => {
const nodeDefStore = useNodeDefStore()
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return []
const priceBadge = nodeDef.price_badge
if (!priceBadge) return []
return priceBadge.depends_on?.inputs ?? []
const priceBadge = getNodePriceBadge(nodeType)
return priceBadge?.depends_on?.inputs ?? []
}
/**
@@ -652,3 +678,97 @@ export const useNodePricing = () => {
pricingRevision: readonly(pricingTick) // reactive invalidation signal
}
}
/**
* Extract default value from an input spec.
*/
function extractDefaultFromSpec(spec: unknown[]): unknown {
const specOptions = spec[1] as Record<string, unknown> | undefined
// Check for explicit default
if (specOptions && 'default' in specOptions) {
return specOptions.default
}
// COMBO/DYNAMICCOMBO type with options array
if (
specOptions &&
Array.isArray(specOptions.options) &&
specOptions.options.length > 0
) {
const firstOption = specOptions.options[0]
// Dynamic combo: options are objects with 'key' property
if (
typeof firstOption === 'object' &&
firstOption !== null &&
'key' in firstOption
) {
return (firstOption as { key: unknown }).key
}
// Standard combo: options are primitive values
return firstOption
}
// COMBO type (old format): [["option1", "option2"], {...}]
if (Array.isArray(spec[0]) && spec[0].length > 0) {
return spec[0][0]
}
return null
}
/**
* Evaluate pricing for a node definition using default widget values.
* Used for NodePricingBadge where no LGraphNode instance exists.
* Results are memoized by node name since they are deterministic.
*/
export const evaluateNodeDefPricing = memoize(
async (nodeDef: ComfyNodeDef): Promise<string> => {
const priceBadge = nodeDef.price_badge
if (!priceBadge?.expr) return ''
// Reuse compiled expression cache
const rule = getCompiledRuleForNodeType(nodeDef.name, priceBadge)
if (!rule?._compiled) return ''
try {
// Merge all inputs for lookup
const allInputs = {
...(nodeDef.input?.required ?? {}),
...(nodeDef.input?.optional ?? {})
}
// Build widgets context using depends_on.widgets (matches buildJsonataContext)
const widgets: Record<string, NormalizedWidgetValue> = {}
for (const dep of priceBadge.depends_on?.widgets ?? []) {
const spec = allInputs[dep.name]
let rawValue: unknown = null
if (Array.isArray(spec)) {
rawValue = extractDefaultFromSpec(spec)
} else if (dep.type.toUpperCase() === 'COMBO') {
// For dynamic COMBO widgets without input spec, use a common default
// that works with most pricing expressions (e.g., resolution selectors)
rawValue = 'original'
}
widgets[dep.name] = normalizeWidgetValue(rawValue, dep.type)
}
// Build inputs context: assume all inputs are disconnected in preview
const inputs: Record<string, { connected: boolean }> = {}
for (const name of priceBadge.depends_on?.inputs ?? []) {
inputs[name] = { connected: false }
}
// Build inputGroups context: assume 0 connected inputs in preview
const inputGroups: Record<string, number> = {}
for (const groupName of priceBadge.depends_on?.input_groups ?? []) {
inputGroups[groupName] = 0
}
const context: JsonataEvalContext = { widgets, inputs, inputGroups }
const result = await rule._compiled.evaluate(context)
return formatPricingResult(result, { valueOnly: true })
} catch (e) {
console.error('[evaluateNodeDefPricing] error:', e)
return ''
}
},
{ getCacheKey: (nodeDef: ComfyNodeDef) => nodeDef.name }
)

View File

@@ -1,16 +1,25 @@
import { markRaw } from 'vue'
import { computed, markRaw, reactive } from 'vue'
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import NodeLibrarySidebarTabV2 from '@/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
return {
export function useNodeLibrarySidebarTab(): SidebarTabExtension {
const settingStore = useSettingStore()
const component = computed(() =>
settingStore.get('Comfy.NodeLibrary.NewDesign')
? markRaw(NodeLibrarySidebarTabV2)
: markRaw(NodeLibrarySidebarTab)
)
return reactive({
id: 'node-library',
icon: 'icon-[comfy--node]',
title: 'sideToolbar.nodeLibrary',
tooltip: 'sideToolbar.nodeLibrary',
label: 'sideToolbar.labels.nodes',
component: markRaw(NodeLibrarySidebarTab),
type: 'vue'
}
component,
type: 'vue' as const
})
}

View File

@@ -106,7 +106,7 @@ export interface LGraphConfig {
}
/** Options for {@link LGraph.add} method. */
interface GraphAddOptions {
export interface GraphAddOptions {
/** If true, skip recomputing execution order after adding the node. */
skipComputeOrder?: boolean
/** If true, the node will be semi-transparent and follow the cursor until placed or cancelled. */

View File

@@ -3620,6 +3620,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
oldValue: true,
newValue: false
})
this.state.selectionChanged = true
this.onSelectionChange?.(this.selected_nodes)
}
this.dirty_canvas = true

View File

@@ -107,7 +107,8 @@ export {
type GroupNodeConfigEntry,
type GroupNodeWorkflowData,
type LGraphTriggerAction,
type LGraphTriggerParam
type LGraphTriggerParam,
type GraphAddOptions
} from './LGraph'
export type { LGraphTriggerEvent } from './types/graphTriggers'
export { BadgePosition, LGraphBadge } from './LGraphBadge'

View File

@@ -20,6 +20,7 @@
"removeImage": "Remove image",
"removeVideo": "Remove video",
"removeTag": "Remove tag",
"remove": "Remove",
"chart": "Chart",
"chartLowercase": "chart",
"file": "file",
@@ -116,6 +117,7 @@
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",
"openManager": "Open Manager",
"manageExtensions": "Manage extensions",
"graphNavigation": "Graph navigation",
"dropYourFileOr": "Drop your file or",
"back": "Back",
@@ -174,6 +176,14 @@
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
"addNode": "Add a node...",
"filterBy": "Filter by:",
"filterByType": "Filter by {type}...",
"mostRelevant": "Most relevant",
"favorites": "Favorites",
"essentials": "Essentials",
"input": "Input",
"output": "Output",
"community": "Community",
"all": "All",
"versionMismatchWarning": "Version Compatibility Warning",
@@ -727,6 +737,7 @@
"logout": "Logout",
"queue": "Queue",
"nodeLibrary": "Node Library",
"nodes": "Nodes",
"workflows": "Workflows",
"templates": "Templates",
"assets": "Assets",
@@ -771,6 +782,9 @@
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
"nodeLibraryTab": {
"essentials": "Essentials",
"allNodes": "All nodes",
"custom": "Custom",
"groupBy": "Group By",
"sortMode": "Sort Mode",
"resetView": "Reset View to Default",
@@ -787,6 +801,9 @@
"originalDesc": "Keep original order",
"alphabetical": "Alphabetical",
"alphabeticalDesc": "Sort alphabetically within groups"
},
"sections": {
"favorites": "Favorites"
}
},
"modelLibrary": "Model Library",

View File

@@ -35,7 +35,7 @@ export const CORE_SETTINGS: SettingParams[] = [
experimental: true,
name: 'Node search box implementation',
type: 'combo',
options: ['default', 'litegraph (legacy)'],
options: ['default', 'v1 (legacy)', 'litegraph (legacy)'],
defaultValue: 'default'
},
{
@@ -72,7 +72,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.NodeSearchBoxImpl.ShowCategory',
category: ['Comfy', 'Node Search Box', 'ShowCategory'],
name: 'Show node category in search results',
tooltip: 'Only applies to the default implementation',
tooltip: 'Only applies to v1 (legacy)',
type: 'boolean',
defaultValue: true
},
@@ -80,7 +80,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.NodeSearchBoxImpl.ShowIdName',
category: ['Comfy', 'Node Search Box', 'ShowIdName'],
name: 'Show node id name in search results',
tooltip: 'Only applies to the default implementation',
tooltip: 'Does not apply to litegraph (legacy)',
type: 'boolean',
defaultValue: false
},
@@ -88,7 +88,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.NodeSearchBoxImpl.ShowNodeFrequency',
category: ['Comfy', 'Node Search Box', 'ShowNodeFrequency'],
name: 'Show node frequency in search results',
tooltip: 'Only applies to the default implementation',
tooltip: 'Only applies to v1 (legacy)',
type: 'boolean',
defaultValue: false
},
@@ -312,6 +312,13 @@ export const CORE_SETTINGS: SettingParams[] = [
}
},
// Bookmarks are stored in the settings store.
{
id: 'Comfy.NodeLibrary.NewDesign',
name: 'Use new node library design',
type: 'hidden',
defaultValue: false,
experimental: true
},
// Bookmarks are in format of category/display_name. e.g. "conditioning/CLIPTextEncode"
{
id: 'Comfy.NodeLibrary.Bookmarks',

View File

@@ -315,6 +315,7 @@ const zSettings = z.object({
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),
'Comfy.GroupSelectedNodes.Padding': z.number(),
'Comfy.Locale': z.string(),
'Comfy.NodeLibrary.NewDesign': z.boolean(),
'Comfy.NodeLibrary.Bookmarks': z.array(z.string()),
'Comfy.NodeLibrary.Bookmarks.V2': z.array(z.string()),
'Comfy.NodeLibrary.BookmarksCustomization': z.record(
@@ -326,7 +327,11 @@ const zSettings = z.object({
'Comfy.ModelLibrary.AutoLoadAll': z.boolean(),
'Comfy.ModelLibrary.NameFormat': z.enum(['filename', 'title']),
'Comfy.NodeSearchBoxImpl.NodePreview': z.boolean(),
'Comfy.NodeSearchBoxImpl': z.enum(['default', 'simple']),
'Comfy.NodeSearchBoxImpl': z.enum([
'default',
'v1 (legacy)',
'litegraph (legacy)'
]),
'Comfy.NodeSearchBoxImpl.ShowCategory': z.boolean(),
'Comfy.NodeSearchBoxImpl.ShowIdName': z.boolean(),
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency': z.boolean(),

View File

@@ -260,6 +260,7 @@ export const zComfyNodeDef = z.object({
description: z.string(),
help: z.string().optional(),
category: z.string(),
main_category: z.string().optional(),
output_node: z.boolean(),
python_module: z.string(),
deprecated: z.boolean().optional(),
@@ -287,7 +288,9 @@ export const zComfyNodeDef = z.object({
* Contains a JSONata expression to calculate pricing based on widget values
* and input connectivity.
*/
price_badge: zPriceBadge.optional()
price_badge: zPriceBadge.optional(),
/** Category for the Essentials tab. If set, the node appears in Essentials. */
essentials_category: z.string().optional()
})
export const zAutogrowOptions = z.object({

View File

@@ -243,6 +243,7 @@ export type GlobalSubgraphData = {
search_aliases?: string[]
}
data: string | Promise<string>
essentials_category?: string
}
function addHeaderEntry(headers: HeadersInit, key: string, value: string) {

View File

@@ -19,6 +19,7 @@ import {
createBounds
} from '@/lib/litegraph/src/litegraph'
import type {
GraphAddOptions,
IContextMenuValue,
Point,
Subgraph
@@ -843,8 +844,9 @@ export const useLitegraphService = () => {
function addNodeOnGraph(
nodeDef: ComfyNodeDefV1 | ComfyNodeDefV2,
options: Record<string, unknown> & { pos?: Point } = {}
): LGraphNode {
options: Record<string, unknown> & { pos?: Point } = {},
addOptions?: GraphAddOptions
): LGraphNode | null {
options.pos ??= getCanvasCenter()
if (nodeDef.name.startsWith(useSubgraphStore().typePrefix)) {
@@ -873,9 +875,9 @@ export const useLitegraphService = () => {
)
const graph = useWorkflowStore().activeSubgraph ?? app.graph
if (!graph || !node) return null
graph.add(node)
// @ts-expect-error fixme ts strict error
graph.add(node, addOptions)
return node
}

View File

@@ -3,16 +3,20 @@ import { buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
NodeGroupingStrategy,
NodeOrganizationOptions,
NodeSortStrategy
NodeSection,
NodeSortStrategy,
TabId
} 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'
const DEFAULT_ICON = 'pi pi-sort'
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export const DEFAULT_TAB_ID = 'all' as const
class NodeOrganizationService {
private readonly groupingStrategies: NodeGroupingStrategy[] = [
@@ -112,6 +116,98 @@ class NodeOrganizationService {
return this.sortingStrategies.find((strategy) => strategy.id === id)
}
organizeNodesByTab(
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
})
const folderOrder = [
'basics',
'text generation',
'image generation',
'video generation',
'image tools',
'video tools',
'audio',
'3D'
]
if (tree.children) {
const len = folderOrder.length
const originalIndex = new Map(
tree.children.map((child, i) => [child, i])
)
tree.children.sort((a, b) => {
const ai = folderOrder.indexOf(a.label ?? '')
const bi = folderOrder.indexOf(b.label ?? '')
const orderA = ai === -1 ? len + originalIndex.get(a)! : ai
const orderB = bi === -1 ? len + originalIndex.get(b)! : 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 '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)
}
return Array.from(groupedByMainCategory.entries()).map(
([mainCategory, categoryNodes]) => ({
title: upperCase(mainCategory),
tree: buildNodeDefTree(categoryNodes, {
pathExtractor: categoryPathExtractor
})
})
)
}
}
}
organizeNodes(
nodes: ComfyNodeDefImpl[],
options: NodeOrganizationOptions = {}
@@ -131,9 +227,9 @@ class NodeOrganizationService {
}
const sortedNodes =
sortingStrategy.id !== 'original'
? [...nodes].sort(sortingStrategy.compare)
: nodes
sortingStrategy.id === 'original'
? nodes
: [...nodes].sort(sortingStrategy.compare)
const tree = buildNodeDefTree(sortedNodes, {
pathExtractor: groupingStrategy.getNodePath

View File

@@ -22,7 +22,11 @@ import type {
import { useSettingStore } from '@/platform/settings/settingStore'
import { NodeSearchService } from '@/services/nodeSearchService'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import {
NodeSourceType,
getEssentialsCategory,
getNodeSource
} from '@/types/nodeSource'
import type { NodeSource } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { FuseSearchable, SearchAuxScore } from '@/utils/fuseUtil'
@@ -39,6 +43,7 @@ export class ComfyNodeDefImpl
* needs to write to it to assign a node to a custom folder.
*/
category: string
readonly main_category?: string
readonly python_module: string
readonly description: string
readonly help: string
@@ -82,6 +87,8 @@ export class ComfyNodeDefImpl
* or old names after renaming a node.
*/
readonly search_aliases?: string[]
/** Category for the Essentials tab. If set, the node appears in Essentials. */
readonly essentials_category?: string
// V2 fields
readonly inputs: Record<string, InputSpecV2>
@@ -136,6 +143,7 @@ export class ComfyNodeDefImpl
this.name = obj.name
this.display_name = obj.display_name
this.category = obj.category
this.main_category = obj.main_category
this.python_module = obj.python_module
this.description = obj.description
this.help = obj.help ?? ''
@@ -152,6 +160,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
)
// Initialize V2 fields
const defV2 = transformNodeDefV1ToV2(obj)
@@ -160,7 +173,11 @@ export class ComfyNodeDefImpl
this.hidden = defV2.hidden
// Initialize node source
this.nodeSource = getNodeSource(obj.python_module)
this.nodeSource = getNodeSource(
obj.python_module,
this.essentials_category,
this.name
)
}
get nodePath(): string {

View File

@@ -65,7 +65,7 @@ describe('useSearchBoxStore', () => {
describe('when user has legacy search box enabled', () => {
beforeEach(() => {
vi.mocked(mockSettingStore.get).mockReturnValue('legacy')
vi.mocked(mockSettingStore.get).mockReturnValue('litegraph (legacy)')
})
it('should show new search box is disabled', () => {
@@ -104,7 +104,7 @@ describe('useSearchBoxStore', () => {
describe('when user configures popover reference', () => {
beforeEach(() => {
vi.mocked(mockSettingStore.get).mockReturnValue('legacy')
vi.mocked(mockSettingStore.get).mockReturnValue('litegraph (legacy)')
})
it('should enable legacy search when popover is set', () => {

View File

@@ -10,10 +10,14 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
const settingStore = useSettingStore()
const { x, y } = useMouse()
const newSearchBoxEnabled = computed(
const useSearchBoxV2 = computed(
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
)
const newSearchBoxEnabled = computed(
() => settingStore.get('Comfy.NodeSearchBoxImpl') !== 'litegraph (legacy)'
)
const popoverRef = shallowRef<InstanceType<
typeof NodeSearchBoxPopover
> | null>(null)
@@ -42,6 +46,7 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
}
return {
useSearchBoxV2,
newSearchBoxEnabled,
setPopoverRef,
toggleVisible,

View File

@@ -1,7 +1,9 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
export type GroupingStrategyId = 'category' | 'module' | 'source'
export type SortingStrategyId = 'original' | 'alphabetical'
export type TabId = 'essentials' | 'all' | 'custom'
/**
* Strategy for grouping nodes into tree structure
@@ -42,3 +44,13 @@ export interface NodeOrganizationOptions {
groupBy?: string
sortBy?: string
}
/**
* A section of nodes with an optional header title
*/
export interface NodeSection {
/** Section title (i18n key), optional */
title?: string
/** Tree of nodes in this section */
tree: TreeNode
}

View File

@@ -72,4 +72,50 @@ describe('getNodeSource', () => {
badgeText: '?'
})
})
describe('essentials nodes', () => {
it('should identify essentials nodes when essentials_category is set', () => {
const result = getNodeSource('nodes.some_module', 'Image')
expect(result.type).toBe(NodeSourceType.Essentials)
expect(result.className).toBe('comfy-essentials')
})
it('should identify essentials nodes from custom_nodes module', () => {
const result = getNodeSource(
'custom_nodes.ComfyUI-Example@1.0.0',
'Video',
'SomeNode'
)
expect(result.type).toBe(NodeSourceType.Essentials)
expect(result.className).toBe('comfy-essentials')
expect(result.displayText).toBe('Example')
})
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'
)
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)
})
})
describe('blueprint nodes', () => {
it('should identify blueprint nodes', () => {
const result = getNodeSource('blueprint.my_blueprint')
expect(result).toEqual({
type: NodeSourceType.Blueprint,
className: 'blueprint',
displayText: 'Blueprint',
badgeText: 'bp'
})
})
})
})

View File

@@ -2,6 +2,7 @@ export enum NodeSourceType {
Core = 'core',
CustomNodes = 'custom_nodes',
Blueprint = 'blueprint',
Essentials = 'essentials',
Unknown = 'unknown'
}
@@ -25,12 +26,81 @@ const shortenNodeName = (name: string) => {
.replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '')
}
export const getNodeSource = (python_module?: string): NodeSource => {
// TODO: Remove this mock mapping once object_info/global_subgraphs returns essentials_category
const ESSENTIALS_CATEGORY_MOCK: Record<string, string> = {
// basics
LoadImage: 'basics',
SaveImage: 'basics',
LoadVideo: 'basics',
SaveVideo: 'basics',
Load3D: 'basics',
SaveGLB: 'basics',
CLIPTextEncode: 'basics',
// image tools
ImageBatch: 'image tools',
ImageCrop: 'image tools',
ImageScale: 'image tools',
ImageRotate: 'image tools',
ImageBlur: 'image tools',
ImageInvert: 'image tools',
Canny: 'image tools',
RecraftRemoveBackgroundNode: 'image tools',
// video tools
GetVideoComponents: 'video tools',
// image gen
LoraLoader: 'image generation',
// video gen
'SubgraphBlueprint.pose_to_video_ltx_2_0': 'video generation',
'SubgraphBlueprint.canny_to_video_ltx_2_0': 'video generation',
KlingLipSyncAudioToVideoNode: 'video generation',
// text gen
OpenAIChatNode: 'text generation',
// 3d
TencentTextToModelNode: '3D',
TencentImageToModelNode: '3D',
// audio
LoadAudio: 'audio',
SaveAudio: 'audio',
StabilityTextToAudio: 'audio'
}
/**
* 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 {
return (
essentials_category ?? (name ? ESSENTIALS_CATEGORY_MOCK[name] : undefined)
)
}
export const getNodeSource = (
python_module?: string,
essentials_category?: string,
name?: string
): NodeSource => {
const resolvedEssentialsCategory = getEssentialsCategory(
name,
essentials_category
)
if (!python_module) {
return UNKNOWN_NODE_SOURCE
}
const modules = python_module.split('.')
if (['nodes', 'comfy_extras', 'comfy_api_nodes'].includes(modules[0])) {
if (resolvedEssentialsCategory) {
const moduleName = modules[1] ?? modules[0] ?? 'essentials'
const displayName = shortenNodeName(moduleName.split('@')[0])
return {
type: NodeSourceType.Essentials,
className: 'comfy-essentials',
displayText: displayName,
badgeText: displayName
}
} else if (
['nodes', 'comfy_extras', 'comfy_api_nodes'].includes(modules[0])
) {
return {
type: NodeSourceType.Core,
className: 'comfy-core',
@@ -46,8 +116,6 @@ export const getNodeSource = (python_module?: string): NodeSource => {
}
} else if (modules[0] === 'custom_nodes') {
const moduleName = modules[1]
// Custom nodes installed via ComfyNodeRegistry will be in the format of
// custom_nodes.<custom node name>@<version>
const customNodeName = moduleName.split('@')[0]
const displayName = shortenNodeName(customNodeName)
return {

View File

@@ -1,14 +1,20 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { MenuItem } from 'primevue/menuitem'
import type { TreeNode as PrimeVueTreeNode } from 'primevue/treenode'
import type { InjectionKey, ModelRef } from 'vue'
import type { InjectionKey, ModelRef, Ref } from 'vue'
export interface TreeNode extends PrimeVueTreeNode {
label: string
children?: this[]
}
export interface NodeLibrarySection {
title?: string
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
}
export interface TreeExplorerNode<T = unknown> extends TreeNode {
readonly data?: T
data?: T
children?: this[]
icon?: string
/**
@@ -87,3 +93,7 @@ export const InjectKeyHandleEditLabelFunction: InjectionKey<
export const InjectKeyExpandedKeys: InjectionKey<
ModelRef<Record<string, boolean>>
> = Symbol()
export const InjectKeyContextMenuNode: InjectionKey<
Ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>
> = Symbol()

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import {
generateCategoryId,
getCategoryIcon,
getProviderBorderStyle,
getProviderIcon
} from './categoryUtil'
describe('getCategoryIcon', () => {
it('returns mapped icon for known category', () => {
expect(getCategoryIcon('all')).toBe('icon-[lucide--list]')
expect(getCategoryIcon('image')).toBe('icon-[lucide--image]')
expect(getCategoryIcon('video')).toBe('icon-[lucide--film]')
})
it('returns folder icon for unknown category', () => {
expect(getCategoryIcon('unknown-category')).toBe('icon-[lucide--folder]')
})
it('is case insensitive', () => {
expect(getCategoryIcon('ALL')).toBe('icon-[lucide--list]')
expect(getCategoryIcon('Image')).toBe('icon-[lucide--image]')
})
})
describe('getProviderIcon', () => {
it('returns icon class for simple provider name', () => {
expect(getProviderIcon('BFL')).toBe('icon-[comfy--bfl]')
expect(getProviderIcon('OpenAI')).toBe('icon-[comfy--openai]')
})
it('converts spaces to hyphens', () => {
expect(getProviderIcon('Stability AI')).toBe('icon-[comfy--stability-ai]')
expect(getProviderIcon('Moonvalley Marey')).toBe(
'icon-[comfy--moonvalley-marey]'
)
})
it('converts to lowercase', () => {
expect(getProviderIcon('GEMINI')).toBe('icon-[comfy--gemini]')
})
})
describe('getProviderBorderStyle', () => {
it('returns solid color for single-color providers', () => {
expect(getProviderBorderStyle('BFL')).toBe('#ffffff')
expect(getProviderBorderStyle('OpenAI')).toBe('#B6B6B6')
expect(getProviderBorderStyle('Bria')).toBe('#B6B6B6')
})
it('returns gradient for dual-color providers', () => {
expect(getProviderBorderStyle('Gemini')).toBe(
'linear-gradient(90deg, #3186FF, #FABC12)'
)
expect(getProviderBorderStyle('Stability AI')).toBe(
'linear-gradient(90deg, #9D39FF, #E80000)'
)
})
it('returns fallback color for unknown providers', () => {
expect(getProviderBorderStyle('Unknown Provider')).toBe('#525252')
})
it('handles provider names with spaces', () => {
expect(getProviderBorderStyle('Stability AI')).toBe(
'linear-gradient(90deg, #9D39FF, #E80000)'
)
expect(getProviderBorderStyle('Moonvalley Marey')).toBe('#DAD9C5')
})
})
describe('generateCategoryId', () => {
it('generates category ID from group and title', () => {
expect(generateCategoryId('Generation', 'Image')).toBe('generation-image')
})
it('converts spaces to hyphens', () => {
expect(generateCategoryId('API Nodes', 'Open Source')).toBe(
'api-nodes-open-source'
)
})
it('converts to lowercase', () => {
expect(generateCategoryId('GENERATION', 'VIDEO')).toBe('generation-video')
})
})

View File

@@ -51,6 +51,79 @@ export const getCategoryIcon = (categoryId: string): string => {
return iconMap[categoryId.toLowerCase()] || 'icon-[lucide--folder]'
}
/**
* Provider brand colors extracted from SVG icons.
* Each entry can be a single color or [color1, color2] for gradient.
*/
const PROVIDER_COLORS: Record<string, string | [string, string]> = {
bfl: '#ffffff',
bria: '#B6B6B6',
bytedance: ['#00C8D2', '#325AB4'],
gemini: ['#3186FF', '#FABC12'],
grok: '#B6B6B6',
hitpaw: '#B6B6B6',
ideogram: '#B6B6B6',
kling: ['#0BF2F9', '#FFF959'],
ltxv: '#B6B6B6',
luma: ['#004EFF', '#00FFFF'],
magnific: ['#EA5A3D', '#F1A64A'],
meshy: ['#67B700', '#FA418C'],
minimax: ['#E2167E', '#FE603C'],
'moonvalley-marey': '#DAD9C5',
openai: '#B6B6B6',
pixverse: ['#B465E6', '#E8632A'],
recraft: '#B6B6B6',
rodin: '#F7F7F7',
runway: '#B6B6B6',
sora: ['#6BB6FE', '#ffffff'],
'stability-ai': ['#9D39FF', '#E80000'],
tencent: ['#004BE5', '#00B3FE'],
topaz: '#B6B6B6',
tripo: ['#F6D85A', '#B6B6B6'],
veo: ['#4285F4', '#EB4335'],
vidu: ['#047FFE', '#40EDD8'],
wan: ['#6156EC', '#F4F3FD'],
wavespeed: '#B6B6B6'
}
/**
* Extracts the provider name from a node category path.
* e.g. "api/image/BFL" -> "BFL"
*/
export function getProviderName(category: string): string {
return category.split('/').at(-1) ?? ''
}
/**
* Returns the icon class for an API node provider (e.g., BFL, OpenAI, Stability AI)
* @param providerName - The provider name from the node category
* @returns The icon class string (e.g., 'icon-[comfy--bfl]')
*/
export function getProviderIcon(providerName: string): string {
const iconKey = providerName.toLowerCase().replaceAll(/\s+/g, '-')
return `icon-[comfy--${iconKey}]`
}
/**
* Returns the border color(s) for an API node provider badge.
* @param providerName - The provider name from the node category
* @returns CSS color string or gradient definition
*/
export function getProviderBorderStyle(providerName: string): string {
const iconKey = providerName.toLowerCase().replaceAll(/\s+/g, '-')
const colors = PROVIDER_COLORS[iconKey]
if (!colors) {
return '#525252' // neutral-600 fallback
}
if (Array.isArray(colors)) {
return `linear-gradient(90deg, ${colors[0]}, ${colors[1]})`
}
return colors
}
/**
* Generates a unique category ID from a category group and title
*/

View File

@@ -1,12 +1,9 @@
import _ from 'es-toolkit/compat'
import type {
ColorOption,
LGraph,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
LGraphCanvas,
LGraphGroup,
LGraphNode,
LiteGraph,
@@ -303,6 +300,10 @@ function compressSubgraphWidgetInputSlots(
}
}
export function getLinkTypeColor(typeName: string): string {
return LGraphCanvas.link_type_colors[typeName] ?? LiteGraph.LINK_COLOR
}
export function isLoad3dNode(node: LGraphNode) {
return (
node &&