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