mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
new v2 node search box with categories - input/output/source filters - uses new preview, extracted price + provider badges - adds as ghost - tests
This commit is contained in:
@@ -14,6 +14,7 @@ import { ComfyTemplates } from '../helpers/templates'
|
||||
import { ComfyMouse } from './ComfyMouse'
|
||||
import { VueNodeHelpers } from './VueNodeHelpers'
|
||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
@@ -166,6 +167,7 @@ export class ComfyPage {
|
||||
|
||||
// Components
|
||||
public readonly searchBox: ComfyNodeSearchBox
|
||||
public readonly searchBoxV2: ComfyNodeSearchBoxV2
|
||||
public readonly menu: ComfyMenu
|
||||
public readonly actionbar: ComfyActionbar
|
||||
public readonly templates: ComfyTemplates
|
||||
@@ -210,6 +212,7 @@ export class ComfyPage {
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
this.templates = new ComfyTemplates(page)
|
||||
|
||||
36
browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts
Normal file
36
browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
readonly results: Locator
|
||||
readonly filterOptions: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.dialog = page.getByRole('search')
|
||||
this.input = this.dialog.locator('input[type="text"]')
|
||||
this.results = this.dialog.getByTestId('result-item')
|
||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||
}
|
||||
|
||||
categoryButton(categoryId: string): Locator {
|
||||
return this.dialog.getByTestId(`category-${categoryId}`)
|
||||
}
|
||||
|
||||
filterBarButton(name: string): Locator {
|
||||
return this.dialog.getByRole('button', { name })
|
||||
}
|
||||
|
||||
async reload(comfyPage: ComfyPage) {
|
||||
// Temporary until we have a different flag for the new search box
|
||||
await comfyPage.page.goto(comfyPage.url + '?nodeRedesign=true')
|
||||
await comfyPage.canvas.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window.app && window.app.extensionManager
|
||||
)
|
||||
await comfyPage.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
149
browser_tests/tests/nodeSearchBoxV2.spec.ts
Normal file
149
browser_tests/tests/nodeSearchBoxV2.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
await comfyPage.searchBoxV2.reload(comfyPage)
|
||||
})
|
||||
|
||||
test('Can open search and add node', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can add first default result with Enter', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
// Default results should be visible without typing
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
|
||||
// Enter should add the first (selected) result
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSampler'
|
||||
])
|
||||
await searchBoxV2.reload(comfyPage)
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.categoryButton('favorites').click()
|
||||
|
||||
await expect(searchBoxV2.results).toHaveCount(1)
|
||||
await expect(searchBoxV2.results.first()).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('Category filters results to matching nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.categoryButton('sampling').click()
|
||||
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
const count = await searchBoxV2.results.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Filter workflow', () => {
|
||||
test('Can filter by input type via filter bar', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
// Click "Input" filter chip in the filter bar
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
|
||||
// Filter options should appear
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
|
||||
// Type to narrow and select MODEL
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Filter chip should appear and results should be filtered
|
||||
await expect(
|
||||
searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..')
|
||||
).toContainText('MODEL')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('Can navigate and select with keyboard', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.input.fill('KSampler')
|
||||
const results = searchBoxV2.results
|
||||
await expect(results.first()).toBeVisible()
|
||||
|
||||
// First result selected by default
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// ArrowDown moves selection
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// ArrowUp moves back
|
||||
await comfyPage.page.keyboard.press('ArrowUp')
|
||||
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Enter selects and adds node
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(searchBoxV2.input).not.toBeVisible()
|
||||
|
||||
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(newCount).toBe(initialCount + 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,24 +15,18 @@
|
||||
{{ nodeDef.display_name }}
|
||||
</h3>
|
||||
|
||||
<!-- Badges -->
|
||||
<div
|
||||
v-if="nodeDef.api_node && (creditsLabel || categoryLabel)"
|
||||
class="flex flex-wrap gap-2"
|
||||
<!-- Category Path -->
|
||||
<p
|
||||
v-if="showCategoryPath && nodeDef.category"
|
||||
class="text-xs text-neutral-400 -mt-1"
|
||||
>
|
||||
<BadgePill
|
||||
v-show="nodeDef.api_node && creditsLabel"
|
||||
:text="creditsLabel"
|
||||
icon="icon-[comfy--credits]"
|
||||
border-style="#f59e0b"
|
||||
filled
|
||||
/>
|
||||
<BadgePill
|
||||
v-show="nodeDef.api_node && categoryLabel"
|
||||
:text="categoryLabel"
|
||||
:icon="getProviderIcon(categoryLabel ?? '')"
|
||||
:border-style="getProviderBorderStyle(categoryLabel ?? '')"
|
||||
/>
|
||||
{{ nodeDef.category.replaceAll('/', ' > ') }}
|
||||
</p>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-2 empty:hidden">
|
||||
<NodePricingBadge :node-def="nodeDef" />
|
||||
<NodeProviderBadge :node-def="nodeDef" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@@ -98,19 +92,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { evaluateNodeDefPricing } from '@/composables/node/useNodePricing'
|
||||
import BadgePill from '@/components/common/BadgePill.vue'
|
||||
import { getProviderBorderStyle, getProviderIcon } from '@/utils/categoryUtil'
|
||||
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 { nodeDef, showInputsAndOutputs = true } = defineProps<{
|
||||
const {
|
||||
nodeDef,
|
||||
showInputsAndOutputs = true,
|
||||
showCategoryPath = false
|
||||
} = defineProps<{
|
||||
nodeDef: ComfyNodeDefImpl
|
||||
showInputsAndOutputs?: boolean
|
||||
showCategoryPath?: boolean
|
||||
}>()
|
||||
|
||||
const previewContainerRef = ref<HTMLElement>()
|
||||
@@ -124,23 +122,6 @@ useResizeObserver(previewWrapperRef, (entries) => {
|
||||
}
|
||||
})
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
if (!nodeDef.category) return ''
|
||||
return nodeDef.category.split('/').at(-1) ?? ''
|
||||
})
|
||||
|
||||
const creditsLabel = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (nodeDef.api_node) {
|
||||
try {
|
||||
creditsLabel.value = await evaluateNodeDefPricing(nodeDef)
|
||||
} catch {
|
||||
creditsLabel.value = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const inputs = computed(() => {
|
||||
if (!nodeDef.inputs) return []
|
||||
return Object.entries(nodeDef.inputs)
|
||||
|
||||
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) => {
|
||||
const 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,14 @@
|
||||
: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'
|
||||
: 'invisible-dialog-root',
|
||||
style: useSearchBoxV2 ? 'overflow: visible' : undefined
|
||||
},
|
||||
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 +25,25 @@
|
||||
@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"
|
||||
:width="208"
|
||||
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 +55,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 +74,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 +87,17 @@ 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()
|
||||
const enableNodePreview = computed(
|
||||
() =>
|
||||
useSearchBoxV2.value &&
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
|
||||
windowWidth.value >= 1320
|
||||
)
|
||||
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,19 @@ 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) {
|
||||
|
||||
62
src/components/searchbox/v2/CategoryTreeNode.vue
Normal file
62
src/components/searchbox/v2/CategoryTreeNode.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:data-testid="`category-${node.key}`"
|
||||
:aria-current="selectedCategory === node.key || undefined"
|
||||
:style="{ paddingLeft: `${0.75 + depth * 0.75}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
|
||||
? 'bg-highlight font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:bg-highlight hover:text-foreground'
|
||||
)
|
||||
"
|
||||
@click="$emit('select', node.key)"
|
||||
>
|
||||
{{ node.label }}
|
||||
</button>
|
||||
<template v-if="isExpanded && node.children?.length">
|
||||
<CategoryTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.key"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:selected-category="selectedCategory"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export interface CategoryNode {
|
||||
key: string
|
||||
label: string
|
||||
children?: CategoryNode[]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
node,
|
||||
depth = 0,
|
||||
selectedCategory
|
||||
} = defineProps<{
|
||||
node: CategoryNode
|
||||
depth?: number
|
||||
selectedCategory: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [key: string]
|
||||
}>()
|
||||
|
||||
const isExpanded = computed(
|
||||
() =>
|
||||
selectedCategory === node.key || selectedCategory.startsWith(node.key + '/')
|
||||
)
|
||||
</script>
|
||||
251
src/components/searchbox/v2/NodeSearchCategorySidebar.test.ts
Normal file
251
src/components/searchbox/v2/NodeSearchCategorySidebar.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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(() => {
|
||||
setupTestPinia()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
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)))
|
||||
await btn?.trigger('click')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('preset categories', () => {
|
||||
it('should render all preset categories', async () => {
|
||||
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 highlight 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')
|
||||
})
|
||||
})
|
||||
93
src/components/searchbox/v2/NodeSearchCategorySidebar.vue
Normal file
93
src/components/searchbox/v2/NodeSearchCategorySidebar.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<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 presetCategories"
|
||||
:key="preset.id"
|
||||
type="button"
|
||||
:data-testid="`category-${preset.id}`"
|
||||
:aria-current="selectedCategory === preset.id || undefined"
|
||||
:class="cn(categoryBtnClass(preset.id), preset.class)"
|
||||
@click="selectCategory(preset.id)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category tree -->
|
||||
<div class="mt-3 flex flex-col px-1">
|
||||
<CategoryTreeNode
|
||||
v-for="category in categoryTree"
|
||||
:key="category.key"
|
||||
:node="category"
|
||||
:selected-category="selectedCategory"
|
||||
@select="selectCategory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CategoryTreeNode from '@/components/searchbox/v2/CategoryTreeNode.vue'
|
||||
import type { CategoryNode } from '@/components/searchbox/v2/CategoryTreeNode.vue'
|
||||
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
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 presetCategories = computed(() => [
|
||||
{ id: 'most-relevant', label: t('g.mostRelevant') },
|
||||
{ id: 'favorites', label: t('g.favorites') },
|
||||
{ id: 'essentials', label: t('g.essentials'), class: 'mt-3' },
|
||||
{ id: 'custom', label: t('g.custom') }
|
||||
])
|
||||
|
||||
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, padding = 'px-3') {
|
||||
return cn(
|
||||
'cursor-pointer border-none bg-transparent rounded py-2.5 text-left text-sm transition-colors',
|
||||
padding,
|
||||
selectedCategory.value === id
|
||||
? 'bg-highlight font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:bg-highlight hover:text-foreground'
|
||||
)
|
||||
}
|
||||
|
||||
function selectCategory(categoryId: string) {
|
||||
selectedCategory.value = categoryId
|
||||
}
|
||||
</script>
|
||||
617
src/components/searchbox/v2/NodeSearchContent.test.ts
Normal file
617
src/components/searchbox/v2/NodeSearchContent.test.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
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'
|
||||
]
|
||||
},
|
||||
NodeSearchFilterChip: true
|
||||
}
|
||||
}
|
||||
})
|
||||
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 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.findAllComponents({ name: 'NodeSearchFilterChip' }).length
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
350
src/components/searchbox/v2/NodeSearchContent.vue
Normal file
350
src/components/searchbox/v2/NodeSearchContent.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
class="flex max-h-[50vh] min-h-[400px] w-full flex-col overflow-hidden rounded-lg bg-base-background"
|
||||
>
|
||||
<!-- Search input row -->
|
||||
<div class="px-4 py-3">
|
||||
<div
|
||||
class="flex cursor-text flex-wrap items-center gap-2 rounded-lg bg-secondary-background px-4 py-3"
|
||||
@click="inputRef?.focus()"
|
||||
>
|
||||
<!-- Active filter label (filter selection mode) -->
|
||||
<span
|
||||
v-if="activeFilter"
|
||||
class="shrink-0 rounded bg-highlight px-2 py-1 -my-1 text-sm opacity-80 text-foreground"
|
||||
>
|
||||
{{ activeFilter.label }}:
|
||||
</span>
|
||||
<!-- Applied filter chips -->
|
||||
<template v-if="!activeFilter">
|
||||
<NodeSearchFilterChip
|
||||
v-for="filter in filters"
|
||||
:key="filter.filterDef.id"
|
||||
:label="t(`g.${filter.filterDef.id}`)"
|
||||
:text="filter.value"
|
||||
:type-color="
|
||||
filter.filterDef.id === 'input' ||
|
||||
filter.filterDef.id === 'output'
|
||||
? getTypeColor(filter.value)
|
||||
: undefined
|
||||
"
|
||||
@remove="emit('removeFilter', filter)"
|
||||
/>
|
||||
</template>
|
||||
<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-[14px] outline-none placeholder:text-muted-foreground"
|
||||
@keydown.down.prevent="onKeyDown"
|
||||
@keydown.up.prevent="onKeyUp"
|
||||
@keydown.enter.prevent="onKeyEnter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter header row -->
|
||||
<div class="flex items-center">
|
||||
<div class="w-40 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-40 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(
|
||||
'cursor-pointer px-4 py-2',
|
||||
index === selectedIndex && 'bg-highlight'
|
||||
)
|
||||
"
|
||||
@click="onAddNode(node, $event)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<NodeSearchListItem
|
||||
:node-def="node"
|
||||
:current-query="searchQuery"
|
||||
show-description
|
||||
show-source-badge
|
||||
:hide-bookmark-icon="selectedCategory === '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, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
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 NodeSearchFilterChip from '@/components/searchbox/v2/NodeSearchFilterChip.vue'
|
||||
import NodeSearchFilterPanel from '@/components/searchbox/v2/NodeSearchFilterPanel.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
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]
|
||||
}>()
|
||||
|
||||
function getTypeColor(typeName: string): string | undefined {
|
||||
return LGraphCanvas.link_type_colors[typeName]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const dialogRef = ref<HTMLElement>()
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
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('')
|
||||
|
||||
const inputValue = computed({
|
||||
get: () => (activeFilter.value ? filterQuery.value : searchQuery.value),
|
||||
set: (value: string) => {
|
||||
if (activeFilter.value) {
|
||||
filterQuery.value = value
|
||||
} else {
|
||||
searchQuery.value = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const inputPlaceholder = computed(() =>
|
||||
activeFilter.value
|
||||
? t('g.filterByType', { type: activeFilter.value.label.toLowerCase() })
|
||||
: t('g.addNode')
|
||||
)
|
||||
|
||||
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(() => inputRef.value?.focus())
|
||||
}
|
||||
|
||||
function onFilterApply(value: string) {
|
||||
if (!activeFilter.value) return
|
||||
emit('addFilter', { filterDef: activeFilter.value.filter, value })
|
||||
activeFilter.value = null
|
||||
filterQuery.value = ''
|
||||
unlockDialogHeight()
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
}
|
||||
|
||||
function cancelFilter() {
|
||||
activeFilter.value = null
|
||||
filterQuery.value = ''
|
||||
unlockDialogHeight()
|
||||
nextTick(() => inputRef.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':
|
||||
return []
|
||||
case 'custom':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type !== NodeSourceType.Core
|
||||
)
|
||||
break
|
||||
default:
|
||||
results = allNodes.filter(
|
||||
(n) =>
|
||||
n.category === selectedCategory.value ||
|
||||
n.category.startsWith(selectedCategory.value + '/')
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
return filters.length > 0 ? results.filter(matchesFilters) : results
|
||||
})
|
||||
|
||||
const hoveredNodeDef = computed(
|
||||
() => displayedResults.value[selectedIndex.value] ?? null
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
emit('hoverNode', hoveredNodeDef.value)
|
||||
})
|
||||
|
||||
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) {
|
||||
onAddNode(node)
|
||||
}
|
||||
}
|
||||
|
||||
function onAddNode(nodeDef: ComfyNodeDefImpl, event?: MouseEvent) {
|
||||
emit('addNode', nodeDef, event)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
</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(() => {
|
||||
setupTestPinia()
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: { required: { image: ['IMAGE', {}] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
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>
|
||||
28
src/components/searchbox/v2/NodeSearchFilterChip.vue
Normal file
28
src/components/searchbox/v2/NodeSearchFilterChip.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-highlight px-2 py-1 -my-1"
|
||||
>
|
||||
<span class="text-sm opacity-80">{{ label }}:</span>
|
||||
<span v-if="typeColor" :style="{ color: typeColor }">•</span>
|
||||
<span class="text-sm">{{ text }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 cursor-pointer border-none bg-transparent text-muted-foreground hover:text-base-foreground rounded-full aspect-square"
|
||||
@click="$emit('remove', $event)"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
text: string
|
||||
typeColor?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
remove: [event: Event]
|
||||
}>()
|
||||
</script>
|
||||
94
src/components/searchbox/v2/NodeSearchFilterPanel.vue
Normal file
94
src/components/searchbox/v2/NodeSearchFilterPanel.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<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-highlight'
|
||||
)
|
||||
"
|
||||
@click="emit('apply', option)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<span class="text-base font-semibold text-foreground">
|
||||
<span class="text-2xl mr-1" :style="{ color: getTypeColor(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 { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
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 getTypeColor(typeName: string): string | undefined {
|
||||
return LGraphCanvas.link_type_colors[typeName]
|
||||
}
|
||||
|
||||
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>
|
||||
134
src/components/searchbox/v2/NodeSearchListItem.vue
Normal file
134
src/components/searchbox/v2/NodeSearchListItem.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div
|
||||
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<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-sm 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>
|
||||
<span v-if="nodeDef.description" class="truncate">
|
||||
{{ nodeDef.description }}
|
||||
</span>
|
||||
</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 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
@@ -715,57 +716,59 @@ function extractDefaultFromSpec(spec: unknown[]): unknown {
|
||||
|
||||
/**
|
||||
* Evaluate pricing for a node definition using default widget values.
|
||||
* Used for NodePreviewCard where no LGraphNode instance exists.
|
||||
* Used for NodePricingBadge where no LGraphNode instance exists.
|
||||
* Results are memoized by node name since they are deterministic.
|
||||
*/
|
||||
export async function evaluateNodeDefPricing(
|
||||
nodeDef: ComfyNodeDef
|
||||
): Promise<string> {
|
||||
const priceBadge = nodeDef.price_badge
|
||||
if (!priceBadge?.expr) return ''
|
||||
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 ''
|
||||
// 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'
|
||||
try {
|
||||
// Merge all inputs for lookup
|
||||
const allInputs = {
|
||||
...(nodeDef.input?.required ?? {}),
|
||||
...(nodeDef.input?.optional ?? {})
|
||||
}
|
||||
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 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 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
|
||||
}
|
||||
// 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 }
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
// 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 }
|
||||
)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -175,6 +175,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",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createBounds
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
GraphAddOptions,
|
||||
IContextMenuValue,
|
||||
Point,
|
||||
Subgraph
|
||||
@@ -843,7 +844,8 @@ export const useLitegraphService = () => {
|
||||
|
||||
function addNodeOnGraph(
|
||||
nodeDef: ComfyNodeDefV1 | ComfyNodeDefV2,
|
||||
options: Record<string, unknown> & { pos?: Point } = {}
|
||||
options: Record<string, unknown> & { pos?: Point } = {},
|
||||
addOptions?: GraphAddOptions
|
||||
): LGraphNode | null {
|
||||
options.pos ??= getCanvasCenter()
|
||||
|
||||
@@ -873,9 +875,9 @@ export const useLitegraphService = () => {
|
||||
)
|
||||
|
||||
const graph = useWorkflowStore().activeSubgraph ?? app.graph
|
||||
if (!graph) return null
|
||||
if (!graph || !node) return null
|
||||
|
||||
graph.add(node)
|
||||
graph.add(node, addOptions)
|
||||
return node
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
|
||||
const settingStore = useSettingStore()
|
||||
const { x, y } = useMouse()
|
||||
|
||||
// Feature flag for the new search box V2 with category sidebar
|
||||
const useSearchBoxV2 = ref(
|
||||
new URLSearchParams(window.location.search).get('nodeRedesign') === 'true'
|
||||
)
|
||||
|
||||
const newSearchBoxEnabled = computed(
|
||||
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
|
||||
)
|
||||
@@ -42,6 +47,7 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
useSearchBoxV2,
|
||||
newSearchBoxEnabled,
|
||||
setPopoverRef,
|
||||
toggleVisible,
|
||||
|
||||
@@ -86,6 +86,14 @@ const PROVIDER_COLORS: Record<string, string | [string, string]> = {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user