mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +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>
|
||||
Reference in New Issue
Block a user