V2 Node Search (+ hidden Node Library changes) (#8987)

## Summary

Redesigned node search with categories

## Changes

- **What**: Adds a v2 search component, leaving the existing
implementation untouched
- It also brings onboard the incomplete node library & preview changes,
disabled and behind a hidden setting
- **Breaking**: Changes the 'default' value of the node search setting
to v2, adding v1 (legacy) as an option

## Screenshots (if applicable)




https://github.com/user-attachments/assets/2ab797df-58f0-48e8-8b20-2a1809e3735f

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8987-V2-Node-Search-hidden-Node-Library-changes-30c6d73d36508160902bcb92553f147c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
pythongosssss
2026-02-20 09:10:03 +00:00
committed by GitHub
parent 8f5cdead73
commit 6902e38e6a
183 changed files with 7972 additions and 127 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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