Compare commits

...

33 Commits

Author SHA1 Message Date
pythongosssss
c00e285768 fix tests 2026-03-25 08:40:27 -07:00
pythongosssss
8f41bc7527 update font size 2026-03-24 08:47:31 -07:00
pythongosssss
11b62c48e3 fix 2026-03-24 08:37:38 -07:00
pythongosssss
cc3d3f1d25 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-24 08:31:57 -07:00
pythongosssss
92e65aaaa7 remove dead code 2026-03-23 07:09:18 -07:00
pythongosssss
f82f8624e1 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-20 14:53:55 -07:00
pythongosssss
c46316d248 feedback 2026-03-20 14:51:33 -07:00
pythongosssss
8e5dc15e5d Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-19 12:45:01 -07:00
pythongosssss
da2fedebcf fix incorrectly collapsing parent category to root 2026-03-18 09:19:47 -07:00
pythongosssss
2a531ff80b fix test 2026-03-18 06:58:13 -07:00
pythongosssss
b6234b96af rework expand/collapse, prevent requiring double left arrow to collapse 2026-03-18 06:55:29 -07:00
pythongosssss
bd66617d3f Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-18 04:27:25 -07:00
pythongosssss
98eac41f07 fix highlighting cross word and remove padding 2026-03-17 08:00:00 -07:00
pythongosssss
307a1c77c0 ensure canvas gets focus after ghost placement 2026-03-17 07:40:28 -07:00
pythongosssss
bbd1e60f7b cap description size 2026-03-17 07:19:47 -07:00
pythongosssss
9100058fc1 fix dialog height 2026-03-17 06:53:47 -07:00
pythongosssss
04c00aadd8 remove left categories and add as filter buttons 2026-03-16 09:05:05 -07:00
pythongosssss
2f1615c505 fix test 2026-03-16 08:36:53 -07:00
pythongosssss
cf4dfceaee - fix dialog stealing focus
- fix tab vs click chevron focus visibility
2026-03-16 08:21:46 -07:00
pythongosssss
dbb70323bf fix bad merge 2026-03-16 08:07:41 -07:00
pythongosssss
6689510591 fix 2026-03-16 08:06:36 -07:00
pythongosssss
82e62694a9 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-16 07:53:48 -07:00
pythongosssss
d49f263536 add e2e test for moving ghost 2026-03-16 07:47:00 -07:00
pythongosssss
d30bb01b4b fix: prevent other nodes intercepting ghost node capture 2026-03-16 06:50:11 -07:00
pythongosssss
320cd82f0d rename custom to extensions 2026-03-16 06:12:38 -07:00
pythongosssss
8a30211bea - dont clear category + allow category searching
- loop focus in dialog
2026-03-16 06:10:26 -07:00
pythongosssss
12fd0981a8 hide extensions/custom categories when no custom nodes 2026-03-12 13:51:35 -07:00
pythongosssss
0772f2a7fe - make test more specific
- fix expanding after manual categ collapse
2026-03-12 13:19:07 -07:00
pythongosssss
08666d8e81 rabbit
- update plural item selected entry
- update mock bookmarts to default empty
- fix test testing already sorted data
- prevent autoExpand already expanded
- fix aria role
- add test + fix path matching
2026-03-12 05:05:42 -07:00
pythongosssss
d18243e085 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-12 04:39:45 -07:00
pythongosssss
3cba424e52 additional search feedback
- improved keyboard navigation and aria
- fixed alignment of elements
- updated fonts and sizes
- more tidy + nits
- tests
2026-03-12 04:18:20 -07:00
pythongosssss
0f3b2e0455 fix test 2026-03-10 08:19:42 -07:00
pythongosssss
fd31f9d0ed additional node search updates
- add root filter buttons
- replace input/output selection with popover
- replace price badge with one from node header
- fix bug with hovering selecting item under mouse automatically
- fix tailwind merge with custom sizes removing them
- general tidy/refactor/test
2026-03-10 07:36:40 -07:00
36 changed files with 1873 additions and 1045 deletions

View File

@@ -5,12 +5,14 @@ import type { ComfyPage } from '../ComfyPage'
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator
readonly filterSearch: 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.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
}

View File

@@ -1,6 +1,7 @@
import type { Locator } from '@playwright/test'
import type {
GraphAddOptions,
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
@@ -33,6 +34,45 @@ export class NodeOperationsHelper {
})
}
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
})
}
/**
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and cursorPosition is provided, a synthetic MouseEvent is created
* as the dragEvent.
* @param cursorPosition - Client coordinates for ghost placement dragEvent
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
cursorPosition?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, cursor]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && cursor) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: cursor.x,
clientY: cursor.y
})
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, cursorPosition ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)

View File

@@ -23,18 +23,14 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
const nodeRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
return { nodeId: nodeRef.id, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
@@ -82,7 +78,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
@@ -158,5 +153,53 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('moving ghost onto existing node and clicking places correctly', async ({
comfyPage
}) => {
// Get existing KSampler node from the default workflow
const [ksamplerRef] =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
const ksamplerPos = await ksamplerRef.getPosition()
const ksamplerSize = await ksamplerRef.getSize()
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
// Start ghost placement away from the existing node
const startX = 50
const startY = 50
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
await comfyPage.nextFrame()
const ghostRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: startX, y: startY }
)
await comfyPage.nextFrame()
// Move ghost onto the existing node
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
await comfyPage.nextFrame()
// Click to finalize — on top of the existing node
await comfyPage.page.mouse.click(targetX, targetY)
await comfyPage.nextFrame()
// Ghost should be placed (no longer ghost)
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
expect(ghostResult).not.toBeNull()
expect(ghostResult!.ghost).toBe(false)
// Ghost node should have moved from its start position toward where we clicked
const ghostPos = await ghostRef.getPosition()
expect(
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
).toBe(true)
// Existing node should NOT be selected
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
expect(selectedIds).not.toContain(ksamplerRef.id)
})
})
}

View File

@@ -54,7 +54,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
})
test.describe('Category navigation', () => {
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
test('Bookmarked filter shows only bookmarked nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
@@ -64,7 +66,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('favorites').click()
await searchBoxV2.filterBarButton('Bookmarked').click()
await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
@@ -100,7 +102,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
// Type to narrow and select MODEL
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()

View File

@@ -95,7 +95,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()

View File

@@ -619,8 +619,6 @@
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;
}
@utility scrollbar-hide {

View File

@@ -200,6 +200,13 @@ describe('formatUtil', () => {
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
)
})
it('should highlight cross-word matches', () => {
const result = highlightQuery('convert image to mask', 'geto', false)
expect(result).toBe(
'convert ima<span class="highlight">ge to</span> mask'
)
})
})
describe('getFilenameDetails', () => {

View File

@@ -74,10 +74,14 @@ export function highlightQuery(
text = DOMPurify.sanitize(text)
}
// Escape special regex characters in the query string
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Escape special regex characters, then join with optional
// whitespace so cross-word matches (e.g. "geto" → "imaGE TO") are
// highlighted correctly.
const pattern = Array.from(query)
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\s*')
const regex = new RegExp(`(${escapedQuery})`, 'gi')
const regex = new RegExp(`(${pattern})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}

View File

@@ -1,9 +1,17 @@
import { clsx } from 'clsx'
import type { ClassArray } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { extendTailwindMerge } from 'tailwind-merge'
export type { ClassValue } from 'clsx'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': ['text-xxs', 'text-xxxs']
}
}
})
export function cn(...inputs: ClassArray) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,28 @@
<template>
<span
:class="
cn(
'flex h-5 shrink-0 items-center bg-component-node-widget-background p-1 text-xs',
rest ? 'rounded-l-full pr-1' : 'rounded-full'
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<span class="truncate" v-text="text" />
</span>
<span
v-if="rest"
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
>
<span class="pr-2" v-text="rest" />
</span>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
defineProps<{
text: string
rest?: string
}>()
</script>

View File

@@ -1,9 +1,14 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
<div
ref="previewWrapperRef"
class="origin-top-left"
:style="{ transform: `scale(${scaleFactor})` }"
>
<LGraphNodePreview :node-def="nodeDef" position="relative" />
</div>
</div>
@@ -18,21 +23,21 @@
<!-- Category Path -->
<p
v-if="showCategoryPath && nodeDef.category"
class="-mt-1 text-xs text-muted-foreground"
class="-mt-1 truncate text-xs text-muted-foreground"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
{{ categoryPath }}
</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 empty:hidden">
<NodePricingBadge :node-def="nodeDef" />
<div class="flex flex-wrap gap-2 overflow-hidden empty:hidden">
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
<NodeProviderBadge :node-def="nodeDef" />
</div>
<!-- Description -->
<p
v-if="nodeDef.description"
class="m-0 text-[11px] leading-normal font-normal text-muted-foreground"
class="m-0 max-h-[30vh] overflow-y-auto text-xs/normal font-normal text-muted-foreground"
>
{{ nodeDef.description }}
</p>
@@ -99,17 +104,20 @@ 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 BASE_WIDTH_PX = 200
const BASE_SCALE = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24
const {
nodeDef,
showInputsAndOutputs = true,
showCategoryPath = false
showCategoryPath = false,
scaleFactor = 0.5
} = defineProps<{
nodeDef: ComfyNodeDefImpl
showInputsAndOutputs?: boolean
showCategoryPath?: boolean
scaleFactor?: number
}>()
const previewContainerRef = ref<HTMLElement>()
@@ -118,11 +126,13 @@ const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
const scaledHeight = entry.contentRect.height * SCALE_FACTOR
const scaledHeight = entry.contentRect.height * scaleFactor
previewContainerRef.value.style.height = `${scaledHeight + PREVIEW_CONTAINER_PADDING_PX}px`
}
})
const categoryPath = computed(() => nodeDef.category?.replaceAll('/', ' / '))
const inputs = computed(() => {
if (!nodeDef.inputs) return []
return Object.entries(nodeDef.inputs)

View File

@@ -1,18 +1,13 @@
<template>
<BadgePill
v-if="nodeDef.api_node"
v-show="priceLabel"
:text="priceLabel"
icon="icon-[comfy--credits]"
border-style="#f59e0b"
filled
/>
<span v-if="nodeDef.api_node && priceLabel">
<CreditBadge :text="priceLabel" />
</span>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import BadgePill from '@/components/common/BadgePill.vue'
import CreditBadge from '@/components/node/CreditBadge.vue'
import { evaluateNodeDefPricing } from '@/composables/node/useNodePricing'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -7,7 +7,7 @@
:pt="{
root: {
class: useSearchBoxV2
? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent mt-[10vh] max-md:w-[95%] max-md:min-w-0 overflow-visible'
? 'w-full max-w-[56rem] min-w-[32rem] max-md:min-w-0 bg-transparent border-0 overflow-visible'
: 'invisible-dialog-root'
},
mask: {
@@ -36,7 +36,9 @@
v-if="hoveredNodeDef && enableNodePreview"
:key="hoveredNodeDef.name"
:node-def="hoveredNodeDef"
:scale-factor="0.625"
show-category-path
inert
class="absolute top-0 left-full ml-3"
/>
</div>

View File

@@ -1,32 +1,47 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchCategorySidebar, {
DEFAULT_CATEGORY
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import {
createMockNodeDef,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => undefined),
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
describe('NodeSearchCategorySidebar', () => {
let wrapper: VueWrapper
beforeEach(() => {
vi.restoreAllMocks()
setupTestPinia()
})
afterEach(() => {
wrapper?.unmount()
})
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: 'most-relevant', ...props },
global: { plugins: [testI18n] }
wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: DEFAULT_CATEGORY, ...props },
global: { plugins: [testI18n] },
attachTo: document.body
})
await nextTick()
return wrapper
@@ -46,30 +61,29 @@ describe('NodeSearchCategorySidebar', () => {
}
describe('preset categories', () => {
it('should render all preset categories', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
essentials_category: 'basic',
python_module: 'comfy_essentials'
})
])
await nextTick()
it('should always show Most relevant', async () => {
const wrapper = await createWrapper()
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Recents')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Blueprints')
expect(wrapper.text()).toContain('Partner')
expect(wrapper.text()).toContain('Comfy')
expect(wrapper.text()).toContain('Extensions')
})
it('should not show Favorites in sidebar', async () => {
vi.spyOn(useNodeBookmarkStore(), 'bookmarks', 'get').mockReturnValue([
'some-bookmark'
])
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('Favorites')
})
it('should not show source categories in sidebar', async () => {
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('Extensions')
expect(wrapper.text()).not.toContain('Essentials')
})
it('should mark the selected preset category as selected', async () => {
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const wrapper = await createWrapper({
selectedCategory: DEFAULT_CATEGORY
})
const mostRelevantBtn = wrapper.find(
'[data-testid="category-most-relevant"]'
@@ -77,17 +91,6 @@ describe('NodeSearchCategorySidebar', () => {
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', () => {
@@ -127,7 +130,8 @@ describe('NodeSearchCategorySidebar', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' })
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' }),
createMockNodeDef({ name: 'Node4', category: 'loaders' })
])
await nextTick()
@@ -166,7 +170,8 @@ describe('NodeSearchCategorySidebar', () => {
it('should emit update:selectedCategory when subcategory is clicked', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
@@ -202,11 +207,14 @@ describe('NodeSearchCategorySidebar', () => {
it('should emit selected subcategory when expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const wrapper = await createWrapper({
selectedCategory: DEFAULT_CATEGORY
})
// Expand and click subcategory
await clickCategory(wrapper, 'sampling', true)
@@ -217,7 +225,16 @@ describe('NodeSearchCategorySidebar', () => {
})
})
it('should support deeply nested categories (3+ levels)', async () => {
describe('hidePresets prop', () => {
it('should hide preset categories when hidePresets is true', async () => {
const wrapper = await createWrapper({ hidePresets: true })
expect(wrapper.text()).not.toContain('Most relevant')
expect(wrapper.text()).not.toContain('Custom')
})
})
it('should emit autoExpand for single root and support deeply nested categories', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'api' }),
createMockNodeDef({ name: 'Node2', category: 'api/image' }),
@@ -227,14 +244,14 @@ describe('NodeSearchCategorySidebar', () => {
const wrapper = await createWrapper()
// Only top-level visible initially
// Single root emits autoExpand
expect(wrapper.emitted('autoExpand')?.[0]).toEqual(['api'])
// Simulate parent handling autoExpand
await wrapper.setProps({ selectedCategory: 'api' })
await nextTick()
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')
@@ -262,4 +279,202 @@ describe('NodeSearchCategorySidebar', () => {
expect(wrapper.emitted('update:selectedCategory')![0][0]).toBe('sampling')
})
describe('keyboard navigation', () => {
it('should expand a collapsed tree node on ArrowRight', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
// Should have emitted select for sampling, expanding it
expect(wrapper.emitted('update:selectedCategory')).toBeTruthy()
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'sampling'
])
})
it('should collapse an expanded tree node on ArrowLeft', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
// First expand sampling by clicking
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Collapse toggles internal state; children should be hidden
expect(wrapper.text()).not.toContain('advanced')
})
it('should focus first child on ArrowRight when already expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
const advancedBtn = wrapper.find(
'[data-testid="category-sampling/advanced"]'
)
expect(advancedBtn.element).toBe(document.activeElement)
})
it('should focus parent on ArrowLeft from a leaf or collapsed node', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
const advancedBtn = wrapper.find(
'[data-testid="category-sampling/advanced"]'
)
await advancedBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
expect(samplingBtn.element).toBe(document.activeElement)
})
it('should collapse sampling on ArrowLeft, not just its expanded child', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({
name: 'Node2',
category: 'sampling/custom_sampling'
}),
createMockNodeDef({
name: 'Node3',
category: 'sampling/custom_sampling/child'
}),
createMockNodeDef({ name: 'Node4', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
// Step 1: Expand sampling
await clickCategory(wrapper, 'sampling', true)
await wrapper.setProps({ selectedCategory: 'sampling' })
await nextTick()
expect(wrapper.text()).toContain('custom_sampling')
// Step 2: Expand custom_sampling
await clickCategory(wrapper, 'custom_sampling', true)
await wrapper.setProps({ selectedCategory: 'sampling/custom_sampling' })
await nextTick()
expect(wrapper.text()).toContain('child')
// Step 3: Navigate back to sampling (keyboard focus only)
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
;(samplingBtn.element as HTMLElement).focus()
await nextTick()
// Step 4: Press left on sampling
await samplingBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Sampling should collapse entirely — custom_sampling should not be visible
expect(wrapper.text()).not.toContain('custom_sampling')
})
it('should collapse 4-deep tree to parent of level 2 on ArrowLeft', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'N1', category: 'a' }),
createMockNodeDef({ name: 'N2', category: 'a/b' }),
createMockNodeDef({ name: 'N3', category: 'a/b/c' }),
createMockNodeDef({ name: 'N4', category: 'a/b/c/d' }),
createMockNodeDef({ name: 'N5', category: 'other' })
])
await nextTick()
const wrapper = await createWrapper()
// Expand a → a/b → a/b/c → a/b/c/d
await clickCategory(wrapper, 'a', true)
await wrapper.setProps({ selectedCategory: 'a' })
await nextTick()
expect(wrapper.text()).toContain('b')
await clickCategory(wrapper, 'b', true)
await wrapper.setProps({ selectedCategory: 'a/b' })
await nextTick()
expect(wrapper.text()).toContain('c')
await clickCategory(wrapper, 'c', true)
await wrapper.setProps({ selectedCategory: 'a/b/c' })
await nextTick()
expect(wrapper.text()).toContain('d')
// Focus level 2 (a/b) and press ArrowLeft
const bBtn = wrapper.find('[data-testid="category-a/b"]')
;(bBtn.element as HTMLElement).focus()
await nextTick()
await bBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Level 2 and below should collapse, but level 1 (a) stays expanded
// so 'b' is still visible but 'c' and 'd' are not
expect(wrapper.text()).toContain('b')
expect(wrapper.text()).not.toContain('c')
expect(wrapper.text()).not.toContain('d')
})
it('should set aria-expanded on tree nodes with children', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
const samplingTreeItem = wrapper
.find('[data-testid="category-sampling"]')
.element.closest('[role="treeitem"]')!
expect(samplingTreeItem.getAttribute('aria-expanded')).toBe('false')
// Leaf node should not have aria-expanded
const loadersTreeItem = wrapper
.find('[data-testid="category-loaders"]')
.element.closest('[role="treeitem"]')!
expect(loadersTreeItem.getAttribute('aria-expanded')).toBeNull()
})
})
})

View File

@@ -1,52 +1,62 @@
<template>
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
<RovingFocusGroup
as="div"
orientation="vertical"
:loop="true"
class="group/categories flex min-h-0 flex-col overflow-y-auto py-2.5 select-none"
>
<!-- Preset categories -->
<div class="flex flex-col px-1">
<button
<div v-if="!hidePresets" class="flex flex-col px-3">
<RovingFocusItem
v-for="preset in topCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
as-child
>
{{ preset.label }}
</button>
</div>
<!-- Source categories -->
<div class="my-2 flex flex-col border-y border-border-subtle px-1 py-2">
<button
v-for="preset in sourceCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
<button
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
</RovingFocusItem>
</div>
<!-- Category tree -->
<div class="flex flex-col px-1">
<div
role="tree"
:aria-label="t('g.category')"
:class="
cn(
'flex flex-col px-3',
!hidePresets && 'mt-2 border-t border-border-subtle pt-2'
)
"
>
<NodeSearchCategoryTreeNode
v-for="category in categoryTree"
:key="category.key"
:node="category"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
:expanded-category="expandedCategory"
:hide-chevrons="hideChevrons"
@select="selectCategory"
@collapse="collapseCategory"
/>
</div>
</div>
</RovingFocusGroup>
</template>
<script lang="ts">
export const DEFAULT_CATEGORY = 'most-relevant'
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RovingFocusGroup, RovingFocusItem } from 'reka-ui'
import NodeSearchCategoryTreeNode, {
CATEGORY_SELECTED_CLASS,
@@ -54,52 +64,45 @@ import NodeSearchCategoryTreeNode, {
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const {
hideChevrons = false,
hidePresets = false,
nodeDefs,
rootLabel,
rootKey
} = defineProps<{
hideChevrons?: boolean
hidePresets?: boolean
nodeDefs?: ComfyNodeDefImpl[]
rootLabel?: string
rootKey?: string
}>()
const selectedCategory = defineModel<string>('selectedCategory', {
required: true
})
const emit = defineEmits<{
autoExpand: [key: string]
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
{ id: 'most-relevant', label: t('g.mostRelevant') },
{ id: 'recents', label: t('g.recents') },
{ id: 'favorites', label: t('g.favorites') }
{ id: DEFAULT_CATEGORY, label: t('g.mostRelevant') }
])
const hasEssentialNodes = computed(() =>
nodeDefStore.visibleNodeDefs.some(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
)
const sourceCategories = computed(() => {
const categories = []
if (hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push(
{
id: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
},
{ id: 'partner', label: t('g.partner') },
{ id: 'comfy', label: t('g.comfy') },
{ id: 'extensions', label: t('g.extensions') }
)
return categories
})
const categoryTree = computed<CategoryNode[]>(() => {
const tree = nodeOrganizationService.organizeNodes(
nodeDefStore.visibleNodeDefs,
{ groupBy: 'category' }
)
const defs = nodeDefs ?? nodeDefStore.visibleNodeDefs
const tree = nodeOrganizationService.organizeNodes(defs, {
groupBy: 'category'
})
const stripRootPrefix = (key: string) => key.replace(/^root\//, '')
@@ -114,28 +117,82 @@ const categoryTree = computed<CategoryNode[]>(() => {
}
}
return (tree.children ?? [])
const nodes = (tree.children ?? [])
.filter((node): node is TreeNode => !node.leaf)
.map(mapNode)
if (rootLabel && nodes.length > 1) {
const key = rootKey ?? rootLabel.toLowerCase()
function prefixKeys(node: CategoryNode): CategoryNode {
return {
key: key + '/' + node.key,
label: node.label,
...(node.children?.length
? { children: node.children.map(prefixKeys) }
: {})
}
}
return [{ key, label: rootLabel, children: nodes.map(prefixKeys) }]
}
return nodes
})
// Notify parent when there is only a single root category to auto-expand
watch(
categoryTree,
(nodes) => {
if (nodes.length === 1 && nodes[0].children?.length) {
const rootKey = nodes[0].key
if (
selectedCategory.value !== rootKey &&
!selectedCategory.value.startsWith(rootKey + '/')
) {
emit('autoExpand', rootKey)
}
}
},
{ immediate: true }
)
function categoryBtnClass(id: string) {
return cn(
'cursor-pointer rounded-sm border-none bg-transparent px-3 py-2.5 text-left text-sm transition-colors',
'cursor-pointer rounded-lg border-none bg-transparent py-2.5 pr-3 text-left font-inter text-sm transition-colors',
hideChevrons ? 'pl-3' : 'pl-9',
selectedCategory.value === id
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
}
const selectedCollapsed = ref(false)
const expandedCategory = ref(selectedCategory.value)
let lastEmittedCategory = ''
watch(selectedCategory, (val) => {
if (val !== lastEmittedCategory) {
expandedCategory.value = val
}
lastEmittedCategory = ''
})
function parentCategory(key: string): string {
const i = key.lastIndexOf('/')
return i > 0 ? key.slice(0, i) : ''
}
function selectCategory(categoryId: string) {
if (selectedCategory.value === categoryId) {
selectedCollapsed.value = !selectedCollapsed.value
if (expandedCategory.value === categoryId) {
expandedCategory.value = parentCategory(categoryId)
} else {
selectedCollapsed.value = false
selectedCategory.value = categoryId
expandedCategory.value = categoryId
}
lastEmittedCategory = categoryId
selectedCategory.value = categoryId
}
function collapseCategory(categoryId: string) {
expandedCategory.value = parentCategory(categoryId)
lastEmittedCategory = categoryId
selectedCategory.value = categoryId
}
</script>

View File

@@ -1,32 +1,66 @@
<template>
<button
type="button"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
<div
:class="
cn(
'w-full cursor-pointer rounded-sm border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
selectedCategory === node.key &&
isExpanded &&
node.children?.length &&
'rounded-lg bg-secondary-background'
)
"
@click="$emit('select', node.key)"
>
{{ node.label }}
</button>
<template v-if="isExpanded && node.children?.length">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
@select="$emit('select', $event)"
/>
</template>
<RovingFocusItem as-child>
<button
ref="buttonEl"
type="button"
role="treeitem"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:aria-expanded="node.children?.length ? isExpanded : undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
:class="
cn(
'flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent py-2.5 pr-3 text-left font-inter text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
"
@click="$emit('select', node.key)"
@keydown.right.prevent="handleRight"
@keydown.left.prevent="handleLeft"
>
<i
v-if="!hideChevrons"
:class="
cn(
'size-4 shrink-0 text-muted-foreground transition-[transform,opacity] duration-150',
node.children?.length
? 'icon-[lucide--chevron-down] opacity-0 group-hover/categories:opacity-100 group-has-focus-visible/categories:opacity-100'
: '',
node.children?.length && !isExpanded && '-rotate-90'
)
"
/>
<span class="flex-1 truncate">{{ node.label }}</span>
</button>
</RovingFocusItem>
<div v-if="isExpanded && node.children?.length" role="group">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
ref="childRefs"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:expanded-category="expandedCategory"
:hide-chevrons="hideChevrons"
:focus-parent="() => buttonEl?.focus()"
@select="$emit('select', $event)"
@collapse="$emit('collapse', $event)"
/>
</div>
</div>
</template>
<script lang="ts">
@@ -37,13 +71,14 @@ export interface CategoryNode {
}
export const CATEGORY_SELECTED_CLASS =
'bg-secondary-background-hover font-semibold text-foreground'
'bg-secondary-background-hover text-foreground'
export const CATEGORY_UNSELECTED_CLASS =
'text-muted-foreground hover:bg-secondary-background-hover hover:text-foreground'
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { RovingFocusItem } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
@@ -51,20 +86,53 @@ const {
node,
depth = 0,
selectedCategory,
selectedCollapsed = false
expandedCategory,
hideChevrons = false,
focusParent
} = defineProps<{
node: CategoryNode
depth?: number
selectedCategory: string
selectedCollapsed?: boolean
expandedCategory: string
hideChevrons?: boolean
focusParent?: () => void
}>()
defineEmits<{
const emit = defineEmits<{
select: [key: string]
collapse: [key: string]
}>()
const isExpanded = computed(() => {
if (selectedCategory === node.key) return !selectedCollapsed
return selectedCategory.startsWith(node.key + '/')
})
const buttonEl = ref<HTMLButtonElement>()
const childRefs = ref<{ focus?: () => void }[]>([])
defineExpose({ focus: () => buttonEl.value?.focus() })
const isExpanded = computed(
() =>
expandedCategory === node.key || expandedCategory.startsWith(node.key + '/')
)
function handleRight() {
if (!node.children?.length) return
if (!isExpanded.value) {
emit('select', node.key)
return
}
nextTick(() => {
childRefs.value[0]?.focus?.()
})
}
function handleLeft() {
if (node.children?.length && isExpanded.value) {
if (expandedCategory.startsWith(node.key + '/')) {
emit('collapse', node.key)
} else {
emit('select', node.key)
}
return
}
focusParent?.()
}
</script>

View File

@@ -3,8 +3,8 @@ 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 NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import {
createMockNodeDef,
setupTestPinia,
@@ -55,13 +55,35 @@ describe('NodeSearchContent', () => {
return wrapper
}
function mockBookmarks(
isBookmarked: boolean | ((node: ComfyNodeDefImpl) => boolean) = true,
bookmarkList: string[] = []
) {
const bookmarkStore = useNodeBookmarkStore()
if (typeof isBookmarked === 'function') {
vi.spyOn(bookmarkStore, 'isBookmarked').mockImplementation(isBookmarked)
} else {
vi.spyOn(bookmarkStore, 'isBookmarked').mockReturnValue(isBookmarked)
}
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue(bookmarkList)
}
function clickFilterButton(wrapper: VueWrapper, text: string) {
const btn = wrapper
.findComponent(NodeSearchFilterBar)
.findAll('button')
.find((b) => b.text() === text)
expect(btn, `Expected filter button "${text}"`).toBeDefined()
return btn!.trigger('click')
}
async function setupFavorites(
nodes: Parameters<typeof createMockNodeDef>[0][]
) {
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
mockBookmarks(true, ['placeholder'])
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
return wrapper
}
@@ -106,12 +128,13 @@ describe('NodeSearchContent', () => {
display_name: 'Regular Node'
})
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
mockBookmarks(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode',
['BookmarkedNode']
)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
const items = getNodeItems(wrapper)
@@ -123,83 +146,15 @@ describe('NodeSearchContent', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
mockBookmarks(false, ['placeholder'])
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
expect(wrapper.text()).toContain('No results')
})
it('should show only CustomNodes when Extensions 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-extensions"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Custom Node')
})
it('should hide Essentials category when no essential nodes exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
const wrapper = await createWrapper()
expect(wrapper.find('[data-testid="category-essentials"]').exists()).toBe(
false
)
})
it('should show only essential nodes when Essentials is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
await nextTick()
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-essentials"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Essential Node')
})
it('should include subcategory nodes when parent category is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
@@ -230,8 +185,137 @@ describe('NodeSearchContent', () => {
})
})
describe('root filter (filter bar categories)', () => {
it('should show only non-Core nodes when Extensions root filter is active', 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()
const extensionsBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Extensions'))
expect(extensionsBtn).toBeTruthy()
await extensionsBtn!.trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Custom Node')
})
it('should show only essential nodes when Essentials root filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
await nextTick()
const wrapper = await createWrapper()
const filterBar = wrapper.findComponent(NodeSearchFilterBar)
const essentialsBtn = filterBar
.findAll('button')
.find((b) => b.text().includes('Essentials'))
expect(essentialsBtn).toBeTruthy()
await essentialsBtn!.trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Essential Node')
})
it('should show only API nodes when Partner Nodes root filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ApiNode',
display_name: 'API Node',
api_node: true
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
const wrapper = await createWrapper()
const filterBar = wrapper.findComponent(NodeSearchFilterBar)
const partnerBtn = filterBar
.findAll('button')
.find((b) => b.text().includes('Partner'))
expect(partnerBtn).toBeTruthy()
await partnerBtn!.trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('API Node')
})
it('should toggle root filter off when clicking the active category button', 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()
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['CoreNode'],
useNodeDefStore().nodeDefsByName['CustomNode']
])
const wrapper = await createWrapper()
const filterBar = wrapper.findComponent(NodeSearchFilterBar)
const extensionsBtn = filterBar
.findAll('button')
.find((b) => b.text().includes('Extensions'))!
// Activate
await extensionsBtn.trigger('click')
await nextTick()
expect(getNodeItems(wrapper)).toHaveLength(1)
// Deactivate (toggle off)
await extensionsBtn.trigger('click')
await nextTick()
expect(getNodeItems(wrapper)).toHaveLength(2)
})
})
describe('search and category interaction', () => {
it('should override category to most-relevant when search query is active', async () => {
it('should search within selected category', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
@@ -256,13 +340,14 @@ describe('NodeSearchContent', () => {
await nextTick()
const texts = getNodeItems(wrapper).map((i) => i.text())
expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(true)
expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(false)
})
it('should clear search query when category changes', async () => {
it('should preserve search query when category changes', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
])
mockBookmarks(true, ['placeholder'])
const wrapper = await createWrapper()
@@ -271,9 +356,9 @@ describe('NodeSearchContent', () => {
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('test query')
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('')
expect((input.element as HTMLInputElement).value).toBe('test query')
})
it('should reset selected index when search query changes', async () => {
@@ -306,11 +391,10 @@ describe('NodeSearchContent', () => {
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
await wrapper
.find('[data-testid="category-most-relevant"]')
.trigger('click')
// Toggle Bookmarked off (back to default) then on again to reset index
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
@@ -373,19 +457,63 @@ describe('NodeSearchContent', () => {
})
})
it('should select item on hover', async () => {
it('should select item on hover via pointermove', 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 results[1].trigger('pointermove')
await nextTick()
expect(results[1].attributes('aria-selected')).toBe('true')
})
it('should navigate results with ArrowDown/ArrowUp from a focused result item', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' },
{ name: 'Node3', display_name: 'Node Three' }
])
const results = getResultItems(wrapper)
await results[0].trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await getResultItems(wrapper)[1].trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getResultItems(wrapper)[2].attributes('aria-selected')).toBe(
'true'
)
await getResultItems(wrapper)[2].trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
})
it('should select node with Enter from a focused result item', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await getResultItems(wrapper)[0].trigger('keydown', { key: 'Enter' })
await nextTick()
expect(wrapper.emitted('addNode')).toBeTruthy()
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
})
it('should add node on click', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
@@ -413,10 +541,10 @@ describe('NodeSearchContent', () => {
})
it('should emit null hoverNode when no results', async () => {
mockBookmarks(false, ['placeholder'])
const wrapper = await createWrapper()
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
const emitted = wrapper.emitted('hoverNode')!
@@ -509,221 +637,4 @@ describe('NodeSearchContent', () => {
})
})
})
describe('filter selection mode', () => {
function setupNodesWithTypes() {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
display_name: 'Image Node',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE']
}),
createMockNodeDef({
name: 'LatentNode',
display_name: 'Latent Node',
input: { required: { latent: ['LATENT', {}] } },
output: ['LATENT']
}),
createMockNodeDef({
name: 'ModelNode',
display_name: 'Model Node',
input: { required: { model: ['MODEL', {}] } },
output: ['MODEL']
})
])
}
function findFilterBarButton(wrapper: VueWrapper, label: string) {
return wrapper
.findAll('button[aria-pressed]')
.find((b) => b.text() === label)
}
async function enterFilterMode(wrapper: VueWrapper) {
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
}
function getFilterOptions(wrapper: VueWrapper) {
return wrapper.findAll('[data-testid="filter-option"]')
}
function getFilterOptionTexts(wrapper: VueWrapper) {
return getFilterOptions(wrapper).map(
(o) =>
o
.findAll('span')[0]
?.text()
.replace(/^[•·]\s*/, '')
.trim() ?? ''
)
}
function hasSidebar(wrapper: VueWrapper) {
return wrapper.findComponent(NodeSearchCategorySidebar).exists()
}
it('should enter filter mode when a filter chip is selected', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
expect(hasSidebar(wrapper)).toBe(true)
await enterFilterMode(wrapper)
expect(hasSidebar(wrapper)).toBe(false)
expect(getFilterOptions(wrapper).length).toBeGreaterThan(0)
})
it('should show available filter options sorted alphabetically', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).toContain('LATENT')
expect(texts).toContain('MODEL')
expect(texts).toEqual([...texts].sort())
})
it('should filter options when typing in filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper.find('input[type="text"]').setValue('IMAGE')
await nextTick()
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).not.toContain('MODEL')
})
it('should show no results when filter query has no matches', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper.find('input[type="text"]').setValue('NONEXISTENT_TYPE')
await nextTick()
expect(wrapper.text()).toContain('No results')
})
it('should emit addFilter when a filter option is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const imageOption = getFilterOptions(wrapper).find((o) =>
o.text().includes('IMAGE')
)
await imageOption!.trigger('click')
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should exit filter mode after applying a filter', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await getFilterOptions(wrapper)[0].trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
it('should emit addFilter when Enter is pressed on selected option', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper
.find('input[type="text"]')
.trigger('keydown', { key: 'Enter' })
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const input = wrapper.find('input[type="text"]')
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getFilterOptions(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
})
it('should toggle filter mode off when same chip is clicked again', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
it('should reset filter query when re-entering filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const input = wrapper.find('input[type="text"]')
await input.setValue('IMAGE')
await nextTick()
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
await enterFilterMode(wrapper)
expect((input.element as HTMLInputElement).value).toBe('')
})
it('should exit filter mode when cancel button is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
expect(hasSidebar(wrapper)).toBe(false)
const cancelBtn = wrapper.find('[data-testid="cancel-filter"]')
await cancelBtn.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
})
})

View File

@@ -1,107 +1,129 @@
<template>
<div
ref="dialogRef"
class="flex max-h-[50vh] min-h-[400px] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
ref="searchInputRef"
v-model:search-query="searchQuery"
v-model:filter-query="filterQuery"
:filters="filters"
:active-filter="activeFilter"
@remove-filter="emit('removeFilter', $event)"
@cancel-filter="cancelFilter"
@navigate-down="onKeyDown"
@navigate-up="onKeyUp"
@select-current="onKeyEnter"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
class="flex-1"
:active-chip-key="activeFilter?.key"
@select-chip="onSelectFilterChip"
/>
</div>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar (hidden in filter mode) -->
<NodeSearchCategorySidebar
v-if="!activeFilter"
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
<FocusScope as-child loop>
<div
ref="dialogRef"
class="flex h-[min(80vh,750px)] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
ref="searchInputRef"
v-model:search-query="searchQuery"
:filters="filters"
@remove-filter="emit('removeFilter', $event)"
@navigate-down="navigateResults(1)"
@navigate-up="navigateResults(-1)"
@select-current="selectCurrentResult"
/>
<!-- Filter options list (filter selection mode) -->
<NodeSearchFilterPanel
v-if="activeFilter"
ref="filterPanelRef"
v-model:query="filterQuery"
:chip="activeFilter"
@apply="onFilterApply"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
class="flex-1"
:filters="filters"
:active-category="rootFilter"
:has-favorites="nodeBookmarkStore.bookmarks.length > 0"
:has-essential-nodes="nodeAvailability.essential"
:has-blueprint-nodes="nodeAvailability.blueprint"
:has-partner-nodes="nodeAvailability.partner"
:has-custom-nodes="nodeAvailability.custom"
@toggle-filter="onToggleFilter"
@clear-filter-group="onClearFilterGroup"
@focus-search="nextTick(() => searchInputRef?.focus())"
@select-category="onSelectCategory"
/>
</div>
<!-- Results list (normal mode) -->
<div
v-else
id="results-list"
role="listbox"
class="flex-1 overflow-y-auto py-2"
>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar -->
<NodeSearchCategorySidebar
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
:hide-chevrons="!anyTreeCategoryHasChildren"
:hide-presets="rootFilter !== null"
:node-defs="rootFilteredNodeDefs"
:root-label="rootFilterLabel"
:root-key="rootFilter ?? undefined"
@auto-expand="selectedCategory = $event"
/>
<!-- Results list -->
<div
v-for="(node, index) in displayedResults"
:id="`result-item-${index}`"
:key="node.name"
role="option"
data-testid="result-item"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex h-14 cursor-pointer items-center px-4',
index === selectedIndex && 'bg-secondary-background-hover'
)
"
@click="emit('addNode', node, $event)"
@mouseenter="selectedIndex = index"
id="results-list"
role="listbox"
tabindex="-1"
class="flex-1 overflow-y-auto py-2 pr-3 pl-1 select-none"
@pointermove="onPointerMove"
>
<NodeSearchListItem
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="effectiveCategory !== 'essentials'"
:hide-bookmark-icon="effectiveCategory === 'favorites'"
/>
</div>
<div
v-if="displayedResults.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
<div
v-for="(node, index) in displayedResults"
:id="`result-item-${index}`"
:key="node.name"
role="option"
data-testid="result-item"
:tabindex="index === selectedIndex ? 0 : -1"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex h-14 cursor-pointer items-center rounded-lg px-4 outline-none focus-visible:ring-2 focus-visible:ring-primary',
index === selectedIndex && 'bg-secondary-background'
)
"
@click="emit('addNode', node, $event)"
@keydown.down.prevent="navigateResults(1, true)"
@keydown.up.prevent="navigateResults(-1, true)"
@keydown.enter.prevent="selectCurrentResult"
>
<NodeSearchListItem
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="rootFilter !== 'essentials'"
:hide-bookmark-icon="effectiveCategory === 'favorites'"
/>
</div>
<div
v-if="displayedResults.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
</div>
</div>
</div>
</div>
</FocusScope>
</template>
<script setup lang="ts">
import { FocusScope } from 'reka-ui'
import { computed, nextTick, ref, watch } 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 NodeSearchFilterPanel from '@/components/searchbox/v2/NodeSearchFilterPanel.vue'
import NodeSearchCategorySidebar, {
DEFAULT_CATEGORY
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
BLUEPRINT_CATEGORY,
isCustomNode,
isEssentialNode
} from '@/types/nodeSource'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import { cn } from '@/utils/tailwindUtil'
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
{
essentials: isEssentialNode,
comfy: (n) => !isCustomNode(n),
custom: isCustomNode
}
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
}>()
@@ -113,57 +135,102 @@ const emit = defineEmits<{
hoverNode: [nodeDef: ComfyNodeDefImpl | null]
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeAvailability = computed(() => {
let essential = false
let blueprint = false
let partner = false
let custom = false
for (const n of nodeDefStore.visibleNodeDefs) {
if (!essential && flags.nodeLibraryEssentialsEnabled && isEssentialNode(n))
essential = true
if (!blueprint && n.category.startsWith(BLUEPRINT_CATEGORY))
blueprint = true
if (!partner && n.api_node) partner = true
if (!custom && isCustomNode(n)) custom = true
if (essential && blueprint && partner && custom) break
}
return { essential, blueprint, partner, custom }
})
const dialogRef = ref<HTMLElement>()
const searchInputRef = ref<InstanceType<typeof NodeSearchInput>>()
const filterPanelRef = ref<InstanceType<typeof NodeSearchFilterPanel>>()
const searchQuery = ref('')
const selectedCategory = ref('most-relevant')
const selectedCategory = ref(DEFAULT_CATEGORY)
const selectedIndex = ref(0)
const activeFilter = ref<FilterChip | null>(null)
const filterQuery = ref('')
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<string | null>(null)
function lockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = `${dialogRef.value.offsetHeight}px`
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {
case 'favorites':
return t('g.bookmarked')
case BLUEPRINT_CATEGORY:
return t('g.blueprints')
case 'partner-nodes':
return t('g.partner')
case 'essentials':
return t('g.essentials')
case 'comfy':
return t('g.comfy')
case 'custom':
return t('g.extensions')
default:
return undefined
}
})
const rootFilteredNodeDefs = computed(() => {
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
const allNodes = nodeDefStore.visibleNodeDefs
const sourceFilter = sourceCategoryFilters[rootFilter.value]
if (sourceFilter) return allNodes.filter(sourceFilter)
switch (rootFilter.value) {
case 'favorites':
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
case BLUEPRINT_CATEGORY:
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
case 'partner-nodes':
return allNodes.filter((n) => n.api_node)
default:
return allNodes
}
})
function onToggleFilter(
filterDef: FuseFilter<ComfyNodeDefImpl, string>,
value: string
) {
const existing = filters.find(
(f) => f.filterDef.id === filterDef.id && f.value === value
)
if (existing) {
emit('removeFilter', existing)
} else {
emit('addFilter', { filterDef, value })
}
}
function unlockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = ''
function onClearFilterGroup(filterId: string) {
for (const f of filters.filter((f) => f.filterDef.id === filterId)) {
emit('removeFilter', f)
}
}
function onSelectFilterChip(chip: FilterChip) {
if (activeFilter.value?.key === chip.key) {
cancelFilter()
return
function onSelectCategory(category: string) {
if (rootFilter.value === category) {
rootFilter.value = null
} else {
rootFilter.value = category
}
lockDialogHeight()
activeFilter.value = chip
filterQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
function onFilterApply(value: string) {
if (!activeFilter.value) return
emit('addFilter', { filterDef: activeFilter.value.filter, value })
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
nextTick(() => searchInputRef.value?.focus())
}
function cancelFilter() {
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
selectedCategory.value = DEFAULT_CATEGORY
nextTick(() => searchInputRef.value?.focus())
}
@@ -176,67 +243,70 @@ const searchResults = computed(() => {
})
})
const effectiveCategory = computed(() =>
searchQuery.value ? 'most-relevant' : selectedCategory.value
)
const effectiveCategory = computed(() => 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))
// Check if any tree category has children (for chevron visibility)
const anyTreeCategoryHasChildren = computed(() =>
rootFilteredNodeDefs.value.some((n) => n.category.includes('/'))
)
function getMostRelevantResults(baseNodes: ComfyNodeDefImpl[]) {
if (searchQuery.value || filters.length > 0) {
const searched = searchResults.value
if (!rootFilter.value) return searched
const rootSet = new Set(baseNodes.map((n) => n.name))
return searched.filter((n) => rootSet.has(n.name))
}
return rootFilter.value ? baseNodes : nodeFrequencyStore.topNodeDefs
}
function getCategoryResults(baseNodes: ComfyNodeDefImpl[], category: string) {
if (rootFilter.value && category === rootFilter.value) return baseNodes
const rootPrefix = rootFilter.value ? rootFilter.value + '/' : ''
const categoryPath = category.startsWith(rootPrefix)
? category.slice(rootPrefix.length)
: category
return baseNodes.filter((n) => {
const nodeCategory = n.category.startsWith(rootPrefix)
? n.category.slice(rootPrefix.length)
: n.category
return (
nodeCategory === categoryPath ||
nodeCategory.startsWith(categoryPath + '/')
)
})
}
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
const allNodes = nodeDefStore.visibleNodeDefs
const baseNodes = rootFilteredNodeDefs.value
const category = effectiveCategory.value
let results: ComfyNodeDefImpl[]
switch (effectiveCategory.value) {
case 'most-relevant':
return searchResults.value
case 'favorites':
results = allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
break
case 'essentials':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
break
case 'recents':
return searchResults.value
case 'blueprints':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Blueprint
)
break
case 'partner':
results = allNodes.filter((n) => n.api_node)
break
case 'comfy':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Core
)
break
case 'extensions':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.CustomNodes
)
break
default:
results = allNodes.filter(
(n) =>
n.category === effectiveCategory.value ||
n.category.startsWith(effectiveCategory.value + '/')
)
break
if (category === DEFAULT_CATEGORY) return getMostRelevantResults(baseNodes)
const hasSearch = searchQuery.value || filters.length > 0
let source: ComfyNodeDefImpl[]
if (hasSearch) {
const searched = searchResults.value
if (rootFilter.value) {
const rootSet = new Set(baseNodes.map((n) => n.name))
source = searched.filter((n) => rootSet.has(n.name))
} else {
source = searched
}
} else {
source = baseNodes
}
return filters.length > 0 ? results.filter(matchesFilters) : results
const sourceFilter = sourceCategoryFilters[category]
if (sourceFilter) return source.filter(sourceFilter)
return getCategoryResults(source, category)
})
const hoveredNodeDef = computed(
@@ -251,42 +321,28 @@ watch(
{ immediate: true }
)
watch([selectedCategory, searchQuery, () => filters], () => {
watch([selectedCategory, searchQuery, rootFilter, () => filters.length], () => {
selectedIndex.value = 0
})
function onKeyDown() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(1)
} else {
navigateResults(1)
}
function onPointerMove(event: PointerEvent) {
const item = (event.target as HTMLElement).closest('[role=option]')
if (!item) return
const index = Number(item.id.replace('result-item-', ''))
if (!isNaN(index) && index !== selectedIndex.value)
selectedIndex.value = index
}
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) {
function navigateResults(direction: number, focusItem = false) {
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' })
const el = dialogRef.value?.querySelector(
`#result-item-${newIndex}`
) as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
if (focusItem) el?.focus()
})
}
}

View File

@@ -12,7 +12,11 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => undefined),
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
@@ -33,51 +37,79 @@ describe(NodeSearchFilterBar, () => {
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchFilterBar, {
props,
global: { plugins: [testI18n] }
global: {
plugins: [testI18n],
stubs: {
NodeSearchTypeFilterPopover: {
template: '<div data-testid="popover"><slot /></div>',
props: ['chip', 'selectedValues']
}
}
}
})
await nextTick()
return wrapper
}
it('should render all filter chips', async () => {
it('should render Extensions button and Input/Output popover triggers', async () => {
const wrapper = await createWrapper({ hasCustomNodes: true })
const buttons = wrapper.findAll('button')
const texts = buttons.map((b) => b.text())
expect(texts).toContain('Extensions')
expect(texts).toContain('Input')
expect(texts).toContain('Output')
})
it('should always render Comfy button', async () => {
const wrapper = await createWrapper()
const texts = wrapper.findAll('button').map((b) => b.text())
expect(texts).toContain('Comfy')
})
it('should render conditional category buttons when matching nodes exist', async () => {
const wrapper = await createWrapper({
hasFavorites: true,
hasEssentialNodes: true,
hasBlueprintNodes: true,
hasPartnerNodes: true
})
const texts = wrapper.findAll('button').map((b) => b.text())
expect(texts).toContain('Bookmarked')
expect(texts).toContain('Blueprints')
expect(texts).toContain('Partner')
expect(texts).toContain('Essentials')
})
it('should not render Extensions button when no custom nodes exist', async () => {
const wrapper = await createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(6)
expect(buttons[0].text()).toBe('Blueprints')
expect(buttons[1].text()).toBe('Partner Nodes')
expect(buttons[2].text()).toBe('Essentials')
expect(buttons[3].text()).toBe('Extensions')
expect(buttons[4].text()).toBe('Input')
expect(buttons[5].text()).toBe('Output')
const texts = buttons.map((b) => b.text())
expect(texts).not.toContain('Extensions')
})
it('should mark active chip as pressed when activeChipKey matches', async () => {
const wrapper = await createWrapper({ activeChipKey: 'input' })
it('should emit selectCategory when category button is clicked', async () => {
const wrapper = await createWrapper({ hasCustomNodes: true })
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
expect(inputBtn?.attributes('aria-pressed')).toBe('true')
const extensionsBtn = wrapper
.findAll('button')
.find((b) => b.text() === 'Extensions')!
await extensionsBtn.trigger('click')
expect(wrapper.emitted('selectCategory')![0]).toEqual(['custom'])
})
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 apply active styling when activeCategory matches', async () => {
const wrapper = await createWrapper({
activeCategory: 'custom',
hasCustomNodes: true
})
})
it('should emit selectChip with chip data when clicked', async () => {
const wrapper = await createWrapper()
const extensionsBtn = wrapper
.findAll('button')
.find((b) => b.text() === 'Extensions')!
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()
})
expect(extensionsBtn.attributes('aria-pressed')).toBe('true')
})
})

View File

@@ -1,22 +1,43 @@
<template>
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex items-center gap-2.5 px-3">
<!-- Category filter buttons -->
<button
v-for="chip in chips"
:key="chip.key"
v-for="btn in categoryButtons"
:key="btn.id"
type="button"
:aria-pressed="activeChipKey === chip.key"
:class="
cn(
'flex-auto cursor-pointer rounded-md border border-secondary-background px-3 py-1 text-sm transition-colors',
activeChipKey === chip.key
? 'text-foreground bg-secondary-background'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
"
@click="emit('selectChip', chip)"
:aria-pressed="activeCategory === btn.id"
:class="chipClass(activeCategory === btn.id)"
@click="emit('selectCategory', btn.id)"
>
{{ chip.label }}
{{ btn.label }}
</button>
<div class="h-5 w-px shrink-0 bg-border-subtle" />
<!-- Type filter popovers (Input / Output) -->
<NodeSearchTypeFilterPopover
v-for="tf in typeFilters"
:key="tf.chip.key"
:chip="tf.chip"
:selected-values="tf.values"
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
@escape-close="emit('focusSearch')"
>
<button type="button" :class="chipClass(false, tf.values.length > 0)">
<span v-if="tf.values.length > 0" class="flex items-center">
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>
</span>
{{ tf.chip.label }}
<i class="icon-[lucide--chevron-down] size-3.5" />
</button>
</NodeSearchTypeFilterPopover>
</div>
</template>
@@ -35,53 +56,97 @@ export interface FilterChip {
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { BLUEPRINT_CATEGORY } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { activeChipKey = null } = defineProps<{
activeChipKey?: string | null
const {
filters = [],
activeCategory = null,
hasFavorites = false,
hasEssentialNodes = false,
hasBlueprintNodes = false,
hasPartnerNodes = false,
hasCustomNodes = false
} = defineProps<{
filters?: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeCategory?: string | null
hasFavorites?: boolean
hasEssentialNodes?: boolean
hasBlueprintNodes?: boolean
hasPartnerNodes?: boolean
hasCustomNodes?: boolean
}>()
const emit = defineEmits<{
selectChip: [chip: FilterChip]
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
clearFilterGroup: [filterId: string]
focusSearch: []
selectCategory: [category: string]
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const chips = computed<FilterChip[]>(() => {
const searchService = nodeDefStore.nodeSearchService
return [
{
key: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints'),
filter: searchService.nodeSourceFilter
},
{
key: 'partnerNodes',
label: t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'),
filter: searchService.nodeSourceFilter
},
{
key: 'essentials',
label: t('g.essentials'),
filter: searchService.nodeSourceFilter
},
{
key: 'extensions',
label: t('g.extensions'),
filter: searchService.nodeSourceFilter
},
{
key: 'input',
label: t('g.input'),
filter: searchService.inputTypeFilter
},
{
key: 'output',
label: t('g.output'),
filter: searchService.outputTypeFilter
}
]
const MAX_VISIBLE_DOTS = 4
const categoryButtons = computed(() => {
const buttons: { id: string; label: string }[] = []
if (hasFavorites) {
buttons.push({ id: 'favorites', label: t('g.bookmarked') })
}
if (hasBlueprintNodes) {
buttons.push({ id: BLUEPRINT_CATEGORY, label: t('g.blueprints') })
}
if (hasPartnerNodes) {
buttons.push({ id: 'partner-nodes', label: t('g.partner') })
}
if (hasEssentialNodes) {
buttons.push({ id: 'essentials', label: t('g.essentials') })
}
buttons.push({ id: 'comfy', label: t('g.comfy') })
if (hasCustomNodes) {
buttons.push({ id: 'custom', label: t('g.extensions') })
}
return buttons
})
const inputChip = computed<FilterChip>(() => ({
key: 'input',
label: t('g.input'),
filter: nodeDefStore.nodeSearchService.inputTypeFilter
}))
const outputChip = computed<FilterChip>(() => ({
key: 'output',
label: t('g.output'),
filter: nodeDefStore.nodeSearchService.outputTypeFilter
}))
const selectedInputValues = computed(() =>
filters.filter((f) => f.filterDef.id === 'input').map((f) => f.value)
)
const selectedOutputValues = computed(() =>
filters.filter((f) => f.filterDef.id === 'output').map((f) => f.value)
)
const typeFilters = computed(() => [
{ chip: inputChip.value, values: selectedInputValues.value },
{ chip: outputChip.value, values: selectedOutputValues.value }
])
function chipClass(isActive: boolean, hasSelections = false) {
return cn(
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
isActive
? 'border-base-foreground bg-base-foreground text-base-background'
: hasSelections
? 'border-base-foreground/60 bg-transparent text-base-foreground/60 hover:border-base-foreground/60 hover:text-base-foreground/60'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
}
</script>

View File

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

View File

@@ -1,7 +1,6 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import {
setupTestPinia,
@@ -18,7 +17,11 @@ vi.mock('@/utils/litegraphUtil', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: 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()
}))
}))
@@ -39,20 +42,6 @@ function createFilter(
}
}
function createActiveFilter(label: string): FilterChip {
return {
key: label.toLowerCase(),
label,
filter: {
id: label.toLowerCase(),
matches: vi.fn(() => true)
} as Partial<FuseFilter<ComfyNodeDefImpl, string>> as FuseFilter<
ComfyNodeDefImpl,
string
>
}
}
describe('NodeSearchInput', () => {
beforeEach(() => {
setupTestPinia()
@@ -62,51 +51,27 @@ describe('NodeSearchInput', () => {
function createWrapper(
props: Partial<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
searchQuery: string
filterQuery: string
}> = {}
) {
return mount(NodeSearchInput, {
props: {
filters: [],
activeFilter: null,
searchQuery: '',
filterQuery: '',
...props
},
global: { plugins: [testI18n] }
})
}
it('should route input to searchQuery when no active filter', async () => {
it('should route input to searchQuery', async () => {
const wrapper = createWrapper()
await wrapper.find('input').setValue('test search')
expect(wrapper.emitted('update:searchQuery')![0]).toEqual(['test search'])
})
it('should route input to filterQuery when active filter is set', async () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('input').setValue('IMAGE')
expect(wrapper.emitted('update:filterQuery')![0]).toEqual(['IMAGE'])
expect(wrapper.emitted('update:searchQuery')).toBeUndefined()
})
it('should show filter label placeholder when active filter is set', () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
expect(
(wrapper.find('input').element as HTMLInputElement).placeholder
).toContain('input')
})
it('should show add node placeholder when no active filter', () => {
it('should show add node placeholder', () => {
const wrapper = createWrapper()
expect(
@@ -114,16 +79,7 @@ describe('NodeSearchInput', () => {
).toContain('Add a node')
})
it('should hide filter chips when active filter is set', () => {
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')],
activeFilter: createActiveFilter('Input')
})
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(0)
})
it('should show filter chips when no active filter', () => {
it('should show filter chips when filters are present', () => {
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')]
})
@@ -131,16 +87,6 @@ describe('NodeSearchInput', () => {
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(1)
})
it('should emit cancelFilter when cancel button is clicked', async () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('[data-testid="cancel-filter"]').trigger('click')
expect(wrapper.emitted('cancelFilter')).toHaveLength(1)
})
it('should emit selectCurrent on Enter', async () => {
const wrapper = createWrapper()

View File

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

View File

@@ -2,46 +2,77 @@
<div
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
>
<div class="flex flex-col gap-0.5 overflow-hidden">
<div class="text-foreground flex items-center gap-2 font-semibold">
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<!-- Row 1: Name (left) + badges (right) -->
<div class="text-foreground flex items-center gap-2 text-sm">
<span v-if="isBookmarked && !hideBookmarkIcon">
<i class="pi pi-bookmark-fill mr-1 text-sm" />
</span>
<span v-html="highlightQuery(nodeDef.display_name, currentQuery)" />
<span v-if="showIdName">&nbsp;</span>
<span
class="truncate"
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
/>
<span
v-if="showIdName"
class="rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
class="shrink-0 rounded-sm 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" />
<template v-if="showDescription">
<div class="flex-1" />
<div class="flex shrink-0 items-center gap-1">
<span
v-if="showSourceBadge && !isCustom"
aria-hidden="true"
class="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-secondary-background-hover/80"
>
<ComfyLogo :size="10" mode="fill" color="currentColor" />
</span>
<span
v-else-if="showSourceBadge && isCustom"
:class="badgePillClass"
>
<span class="truncate text-[10px]">
{{ nodeDef.nodeSource.displayText }}
</span>
</span>
<span
v-if="nodeDef.api_node && providerName"
:class="badgePillClass"
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
/>
<i
aria-hidden="true"
:class="cn(getProviderIcon(providerName), 'size-3')"
/>
</span>
</div>
</template>
<template v-else>
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
</template>
</div>
<div
v-if="showDescription"
class="flex items-center gap-1 text-[11px] text-muted-foreground"
class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
>
<span
v-if="
showSourceBadge &&
nodeDef.nodeSource.type !== NodeSourceType.Core &&
nodeDef.nodeSource.type !== NodeSourceType.Unknown
"
class="border-border mr-0.5 inline-flex shrink-0 rounded-sm border bg-base-foreground/5 px-1.5 py-0.5 text-xs text-base-foreground/70"
>
{{ nodeDef.nodeSource.displayText }}
<span v-if="showCategory" class="max-w-2/5 shrink-0 truncate">
{{ nodeDef.category.replaceAll('/', ' / ') }}
</span>
<TextTicker v-if="nodeDef.description">
<span
v-if="nodeDef.description && showCategory"
class="h-3 w-px shrink-0 bg-border-default"
/>
<TextTicker v-if="nodeDef.description" class="min-w-0 flex-1">
{{ nodeDef.description }}
</TextTicker>
</div>
<div
v-else-if="showCategory"
class="option-category truncate text-sm font-light text-muted"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
</div>
</div>
<div v-if="!showDescription" class="flex items-center gap-1">
<span
@@ -82,14 +113,20 @@
import { computed } from 'vue'
import TextTicker from '@/components/common/TextTicker.vue'
import ComfyLogo from '@/components/icons/ComfyLogo.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 {
isCustomNode as isCustomNodeDef,
NodeSourceType
} from '@/types/nodeSource'
import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const {
nodeDef,
@@ -105,6 +142,9 @@ const {
hideBookmarkIcon?: boolean
}>()
const badgePillClass =
'flex h-[18px] max-w-28 shrink-0 items-center justify-center gap-1 rounded-full bg-secondary-background-hover/80 px-2'
const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
@@ -122,4 +162,6 @@ const nodeFrequency = computed(() =>
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
const providerName = computed(() => getProviderName(nodeDef.category))
const isCustom = computed(() => isCustomNodeDef(nodeDef))
</script>

View File

@@ -0,0 +1,168 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
function createMockChip(
data: string[] = ['IMAGE', 'LATENT', 'MODEL']
): FilterChip {
return {
key: 'input',
label: 'Input',
filter: {
id: 'input',
matches: vi.fn(),
fuseSearch: {
search: vi.fn((query: string) =>
data.filter((d) => d.toLowerCase().includes(query.toLowerCase()))
),
data
}
} as unknown as FilterChip['filter']
}
}
describe(NodeSearchTypeFilterPopover, () => {
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
wrapper?.unmount()
})
function createWrapper(
props: {
chip?: FilterChip
selectedValues?: string[]
} = {}
) {
wrapper = mount(NodeSearchTypeFilterPopover, {
props: {
chip: props.chip ?? createMockChip(),
selectedValues: props.selectedValues ?? []
},
slots: {
default: '<button data-testid="trigger">Input</button>'
},
global: {
plugins: [testI18n]
},
attachTo: document.body
})
return wrapper
}
async function openPopover(w: ReturnType<typeof mount>) {
await w.find('[data-testid="trigger"]').trigger('click')
await nextTick()
await nextTick()
}
function getOptions() {
return wrapper.findAll('[role="option"]')
}
it('should render the trigger slot', () => {
createWrapper()
expect(wrapper.find('[data-testid="trigger"]').exists()).toBe(true)
})
it('should show popover content when trigger is clicked', async () => {
createWrapper()
await openPopover(wrapper)
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
})
it('should display all options sorted alphabetically', async () => {
createWrapper({ chip: createMockChip(['MODEL', 'IMAGE', 'LATENT']) })
await openPopover(wrapper)
const options = getOptions()
expect(options).toHaveLength(3)
const texts = options.map((o) => o.text().trim())
expect(texts[0]).toContain('IMAGE')
expect(texts[1]).toContain('LATENT')
expect(texts[2]).toContain('MODEL')
})
it('should show selected count text', async () => {
createWrapper({ selectedValues: ['IMAGE', 'LATENT'] })
await openPopover(wrapper)
expect(wrapper.text()).toContain('2 items selected')
})
it('should show clear all button only when values are selected', async () => {
createWrapper({ selectedValues: [] })
await openPopover(wrapper)
const buttons = wrapper.findAll('button')
const clearBtn = buttons.find((b) => b.text().includes('Clear all'))
expect(clearBtn).toBeUndefined()
})
it('should show clear all button when values are selected', async () => {
createWrapper({ selectedValues: ['IMAGE'] })
await openPopover(wrapper)
const buttons = wrapper.findAll('button')
const clearBtn = buttons.find((b) => b.text().includes('Clear all'))
expect(clearBtn).toBeTruthy()
})
it('should emit clear when clear all button is clicked', async () => {
createWrapper({ selectedValues: ['IMAGE'] })
await openPopover(wrapper)
const clearBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Clear all'))!
await clearBtn.trigger('click')
await nextTick()
expect(wrapper.emitted('clear')).toHaveLength(1)
})
it('should emit toggle when an option is clicked', async () => {
createWrapper()
await openPopover(wrapper)
const options = getOptions()
await options[0].trigger('click')
await nextTick()
expect(wrapper.emitted('toggle')).toBeTruthy()
expect(wrapper.emitted('toggle')![0][0]).toBe('IMAGE')
})
it('should filter options via search input', async () => {
createWrapper()
await openPopover(wrapper)
const searchInput = wrapper.find('input')
await searchInput.setValue('IMAGE')
await nextTick()
const options = getOptions()
expect(options).toHaveLength(1)
expect(options[0].text()).toContain('IMAGE')
})
it('should show no results when search matches nothing', async () => {
createWrapper()
await openPopover(wrapper)
const searchInput = wrapper.find('input')
await searchInput.setValue('NONEXISTENT')
await nextTick()
expect(getOptions()).toHaveLength(0)
expect(wrapper.text()).toContain('No results')
})
})

View File

@@ -0,0 +1,175 @@
<template>
<PopoverRoot v-model:open="open" @update:open="onOpenChange">
<PopoverTrigger as-child>
<slot />
</PopoverTrigger>
<PopoverContent
side="bottom"
:side-offset="4"
:collision-padding="10"
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-64 rounded-lg border border-border-default bg-base-background px-4 py-1 shadow-interface will-change-[transform,opacity]"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
@escape-key-down.prevent
@keydown.escape.stop="closeWithEscape"
>
<ListboxRoot
multiple
selection-behavior="toggle"
:model-value="selectedValues"
@update:model-value="onSelectionChange"
>
<div
class="mt-2 flex h-8 items-center gap-2 rounded-sm border border-border-default px-2"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
/>
<ListboxFilter
ref="searchFilterRef"
v-model="searchQuery"
:placeholder="t('g.search')"
class="text-foreground size-full border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div class="flex items-center justify-between py-3">
<span class="text-sm text-muted-foreground">
{{
t(
'g.itemsSelected',
{ count: selectedValues.length },
selectedValues.length
)
}}
</span>
<button
v-if="selectedValues.length > 0"
type="button"
class="cursor-pointer border-none bg-transparent font-inter text-sm text-base-foreground"
@click="emit('clear')"
>
{{ t('g.clearAll') }}
</button>
</div>
<div class="h-px bg-border-default" />
<ListboxContent class="max-h-64 overflow-y-auto py-3">
<ListboxItem
v-for="option in filteredOptions"
:key="option"
:value="option"
data-testid="filter-option"
class="text-foreground flex cursor-pointer items-center gap-2 rounded-sm px-1 py-2 text-sm outline-none data-highlighted:bg-secondary-background-hover"
>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-default',
selectedSet.has(option) &&
'text-primary-foreground border-primary bg-primary'
)
"
>
<i
v-if="selectedSet.has(option)"
class="icon-[lucide--check] size-3"
/>
</span>
<span class="truncate">{{ option }}</span>
<span
class="mr-1 ml-auto text-lg leading-none"
:style="{ color: getLinkTypeColor(option) }"
>
&bull;
</span>
</ListboxItem>
<div
v-if="filteredOptions.length === 0"
class="px-1 py-4 text-center text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
</div>
</ListboxContent>
</ListboxRoot>
</PopoverContent>
</PopoverRoot>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AcceptableValue } from 'reka-ui'
import {
ListboxContent,
ListboxFilter,
ListboxItem,
ListboxRoot,
PopoverContent,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { chip, selectedValues } = defineProps<{
chip: FilterChip
selectedValues: string[]
}>()
const emit = defineEmits<{
toggle: [value: string]
clear: []
escapeClose: []
}>()
const { t } = useI18n()
const open = ref(false)
const closedWithEscape = ref(false)
const searchQuery = ref('')
const searchFilterRef = ref<InstanceType<typeof ListboxFilter>>()
function onOpenChange(isOpen: boolean) {
if (!isOpen) searchQuery.value = ''
}
const selectedSet = computed(() => new Set(selectedValues))
function onSelectionChange(value: AcceptableValue) {
const newValues = value as string[]
const added = newValues.find((v) => !selectedSet.value.has(v))
const removed = selectedValues.find((v) => !newValues.includes(v))
const toggled = added ?? removed
if (toggled) emit('toggle', toggled)
}
const filteredOptions = computed(() => {
const { fuseSearch } = chip.filter
if (searchQuery.value) {
return fuseSearch.search(searchQuery.value).slice(0, 64)
}
return fuseSearch.data.slice().sort()
})
function closeWithEscape() {
closedWithEscape.value = true
open.value = false
}
function onOpenAutoFocus(event: Event) {
event.preventDefault()
const el = searchFilterRef.value?.$el as HTMLInputElement | undefined
el?.focus()
}
function onCloseAutoFocus(event: Event) {
if (closedWithEscape.value) {
event.preventDefault()
closedWithEscape.value = false
emit('escapeClose')
}
}
</script>

View File

@@ -39,7 +39,9 @@ export const testI18n = createI18n({
mostRelevant: 'Most relevant',
recents: 'Recents',
favorites: 'Favorites',
bookmarked: 'Bookmarked',
essentials: 'Essentials',
category: 'Category',
custom: 'Custom',
comfy: 'Comfy',
partner: 'Partner',
@@ -49,15 +51,13 @@ export const testI18n = createI18n({
input: 'Input',
output: 'Output',
source: 'Source',
search: 'Search'
},
sideToolbar: {
nodeLibraryTab: {
filterOptions: {
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes'
}
}
search: 'Search',
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes',
remove: 'Remove',
itemsSelected:
'No items selected | {count} item selected | {count} items selected',
clearAll: 'Clear all'
}
}
}

View File

@@ -2222,6 +2222,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this.state.ghostNodeId != null) {
if (e.button === 0) this.finalizeGhostPlacement(false)
if (e.button === 2) this.finalizeGhostPlacement(true)
this.canvas.focus()
e.stopPropagation()
e.preventDefault()
return
@@ -3679,6 +3680,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
this.state.ghostNodeId = node.id
this.dispatchEvent('litegraph:ghost-placement', {
active: true,
nodeId: node.id
})
this.deselectAll()
this.select(node)
@@ -3709,6 +3714,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.state.ghostNodeId = null
this.isDragging = false
this.dispatchEvent('litegraph:ghost-placement', {
active: false,
nodeId
})
this._autoPan?.stop()
this._autoPan = null

View File

@@ -1,7 +1,7 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -53,4 +53,10 @@ export interface LGraphCanvasEventMap {
node: LGraphNode
button: LGraphButton
}
/** Ghost placement mode has started or ended. */
'litegraph:ghost-placement': {
active: boolean
nodeId: NodeId
}
}

View File

@@ -207,6 +207,7 @@
"filterByType": "Filter by {type}...",
"mostRelevant": "Most relevant",
"favorites": "Favorites",
"bookmarked": "Bookmarked",
"essentials": "Essentials",
"input": "Input",
"output": "Output",
@@ -366,6 +367,8 @@
"preloadErrorTitle": "Loading Error",
"recents": "Recents",
"partner": "Partner",
"blueprints": "Blueprints",
"partnerNodes": "Partner Nodes",
"collapseAll": "Collapse all",
"expandAll": "Expand all"
},

View File

@@ -313,8 +313,7 @@
"tooltip": "Only applies to the default implementation"
},
"Comfy_NodeSearchBoxImpl_ShowCategory": {
"name": "Show node category in search results",
"tooltip": "Only applies to v1 (legacy)"
"name": "Show node category in search results"
},
"Comfy_NodeSearchBoxImpl_ShowIdName": {
"name": "Show node id name in search results",

View File

@@ -6,6 +6,7 @@ import type { Raw } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
LGraph,
LGraphCanvas,
@@ -14,6 +15,7 @@ import type {
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/promotionUtils'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { app } from '@/scripts/app'
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
@@ -114,6 +116,7 @@ export const useCanvasStore = defineStore('canvas', () => {
const currentGraph = shallowRef<LGraph | null>(null)
const isInSubgraph = ref(false)
const isGhostPlacing = ref(false)
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
@@ -148,6 +151,18 @@ export const useCanvasStore = defineStore('canvas', () => {
(e: CustomEvent<{ subgraphNode: SubgraphNode }>) =>
promoteRecommendedWidgets(e.detail.subgraphNode)
)
useEventListener(
newCanvas.canvas,
'litegraph:ghost-placement',
(e: CustomEvent<{ active: boolean; nodeId: NodeId }>) => {
isGhostPlacing.value = e.detail.active
if (e.detail.active) {
const mutations = useLayoutMutations()
mutations.bringNodeToFront(String(e.detail.nodeId))
}
}
)
},
{ immediate: true }
)
@@ -167,6 +182,7 @@ export const useCanvasStore = defineStore('canvas', () => {
initScaleSync,
cleanupScaleSync,
currentGraph,
isInSubgraph
isInSubgraph,
isGhostPlacing
}
})

View File

@@ -16,7 +16,9 @@
cursorClass,
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
shouldHandleNodePointerEvents &&
!nodeData.flags?.ghost &&
!isGhostPlacing
? 'pointer-events-auto'
: 'pointer-events-none'
)
@@ -27,6 +29,7 @@
zIndex: zIndex,
opacity: nodeOpacity
}"
:inert="isGhostPlacing"
v-bind="remainingPointerHandlers"
@pointerdown="nodeOnPointerdown"
@wheel="handleWheel"
@@ -347,7 +350,7 @@ const { bringNodeToFront } = useNodeZIndex()
useVueElementTracking(() => nodeData.id, 'node')
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const { selectedNodeIds, isGhostPlacing } = storeToRefs(useCanvasStore())
const isSelected = computed(() => {
return selectedNodeIds.value.has(nodeData.id)
})

View File

@@ -55,25 +55,12 @@
</div>
</div>
<template v-for="badge in priceBadges ?? []" :key="badge.required">
<span
:class="
cn(
'flex h-5 shrink-0 items-center bg-component-node-widget-background p-1 text-xs',
badge.rest ? 'rounded-l-full pr-1' : 'rounded-full'
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<span class="truncate" v-text="badge.required" />
</span>
<span
v-if="badge.rest"
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
>
<span class="pr-2" v-text="badge.rest" />
</span>
</template>
<CreditBadge
v-for="badge in priceBadges ?? []"
:key="badge.required"
:text="badge.required"
:rest="badge.rest"
/>
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
<i
v-if="isPinned"
@@ -88,6 +75,7 @@
import { computed, onErrorCaptured, ref, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import CreditBadge from '@/components/node/CreditBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import {
NodeSourceType,
getNodeSource,
isCustomNode,
isEssentialNode
} from '@/types/nodeSource'
import type { NodeSource } from '@/types/nodeSource'
describe('getNodeSource', () => {
it('should return UNKNOWN_NODE_SOURCE when python_module is undefined', () => {
@@ -108,3 +114,44 @@ describe('getNodeSource', () => {
})
})
})
function makeNode(type: NodeSourceType): { nodeSource: NodeSource } {
return {
nodeSource: {
type,
className: '',
displayText: '',
badgeText: ''
}
}
}
describe('isEssentialNode', () => {
it('returns true for Essentials nodes', () => {
expect(isEssentialNode(makeNode(NodeSourceType.Essentials))).toBe(true)
})
it.for([
NodeSourceType.Core,
NodeSourceType.CustomNodes,
NodeSourceType.Blueprint,
NodeSourceType.Unknown
])('returns false for %s nodes', (type) => {
expect(isEssentialNode(makeNode(type))).toBe(false)
})
})
describe('isCustomNode', () => {
it('returns true for CustomNodes', () => {
expect(isCustomNode(makeNode(NodeSourceType.CustomNodes))).toBe(true)
})
it.for([
NodeSourceType.Core,
NodeSourceType.Essentials,
NodeSourceType.Unknown,
NodeSourceType.Blueprint
])('returns false for %s nodes', (type) => {
expect(isCustomNode(makeNode(type))).toBe(false)
})
})

View File

@@ -1,3 +1,5 @@
export const BLUEPRINT_CATEGORY = 'Subgraph Blueprints'
export enum NodeSourceType {
Core = 'core',
CustomNodes = 'custom_nodes',
@@ -76,6 +78,18 @@ export function getNodeSource(
}
}
interface NodeDefLike {
nodeSource: NodeSource
}
export function isEssentialNode(node: NodeDefLike): boolean {
return node.nodeSource.type === NodeSourceType.Essentials
}
export function isCustomNode(node: NodeDefLike): boolean {
return node.nodeSource.type === NodeSourceType.CustomNodes
}
export enum NodeBadgeMode {
None = 'None',
ShowAll = 'Show all',