Compare commits

..

2 Commits

Author SHA1 Message Date
uytieu
d5b8447190 sticky group titles 2026-06-11 12:55:29 -07:00
uytieu
86926d84ec Grouped filtering
• Not flat
• Removed repeated copy
2026-06-11 12:47:04 -07:00
4 changed files with 138 additions and 71 deletions

View File

@@ -19,7 +19,7 @@ vi.mock('./linkReleaseMenuModel', () => ({
getLinkReleaseHeaderLabel: () => '',
getLinkReleaseSuggestions: () => groups.suggestions,
buildLinkReleaseNodeCategories: () => groups.categories,
searchLinkReleaseNodes: () => [],
buildLinkReleaseSearchSections: () => [],
filterNodesByName: () => []
}))

View File

@@ -13,6 +13,7 @@
align="start"
:side-offset="4"
:collision-padding="8"
:avoid-collisions="false"
:class="contentClass"
@open-auto-focus.prevent="focusSearch"
@close-auto-focus.prevent
@@ -54,27 +55,39 @@
<div :class="scrollClass">
<template v-if="trimmedQuery">
<DropdownMenuItem
v-for="match in searchResults"
:key="`${match.category.key}:${match.node.name}`"
:class="itemClass"
@select="selectNode(match.node)"
<template
v-for="(section, index) in searchSections"
:key="section.key"
>
<i
:class="cn(match.category.icon, 'size-4 shrink-0 opacity-80')"
<DropdownMenuSeparator
v-if="index > 0"
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<span class="flex min-w-0 flex-1 items-center gap-1">
<span class="shrink-0 text-muted-foreground">
{{ t(match.category.labelKey) }}:
</span>
<MiddleTruncate
:text="match.node.display_name"
class="min-w-0 flex-1"
/>
</span>
</DropdownMenuItem>
<div>
<DropdownMenuLabel
class="sticky top-0 z-10 flex items-center gap-2 bg-interface-menu-surface p-2 text-xs font-medium text-muted-foreground uppercase"
>
<i
v-if="section.icon"
:class="cn(section.icon, 'size-4 shrink-0 opacity-80')"
/>
<span class="truncate">{{ t(section.labelKey) }}</span>
</DropdownMenuLabel>
<DropdownMenuItem
v-for="nodeDef in section.nodes"
:key="`${section.key}:${nodeDef.name}`"
:class="itemClass"
@select="selectNode(nodeDef)"
>
<MiddleTruncate
:text="nodeDef.display_name"
class="min-w-0 flex-1"
/>
</DropdownMenuItem>
</div>
</template>
<div
v-if="searchResults.length === 0"
v-if="searchSections.length === 0"
class="p-2 text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
@@ -167,14 +180,11 @@ import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import MiddleTruncate from './MiddleTruncate.vue'
import {
buildLinkReleaseNodeCategories,
buildLinkReleaseSearchSections,
getLinkReleaseHeaderLabel,
getLinkReleaseSuggestions,
searchLinkReleaseNodes
} from './linkReleaseMenuModel'
import type {
LinkReleaseContext,
LinkReleaseNodeMatch
getLinkReleaseSuggestions
} from './linkReleaseMenuModel'
import type { LinkReleaseContext } from './linkReleaseMenuModel'
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
@@ -193,6 +203,13 @@ const searchInput = ref<HTMLInputElement>()
const query = ref('')
let actionTaken = false
const MENU_MARGIN = 8
// Height reserved when placing the menu so the default (no-query) content opens
// fully in view; only longer result lists fall back to internal scrolling.
const DEFAULT_MENU_HEIGHT = 420
// Matches the content's max-w-sm (24rem).
const MENU_WIDTH = 384
const contentClass =
'z-1700 flex max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const scrollClass = 'overflow-y-auto overflow-x-hidden scrollbar-custom'
@@ -247,8 +264,12 @@ const categories = computed(() =>
buildLinkReleaseNodeCategories(compatibleNodes.value)
)
const searchResults = computed<LinkReleaseNodeMatch[]>(() =>
searchLinkReleaseNodes(categories.value, trimmedQuery.value)
const searchSections = computed(() =>
buildLinkReleaseSearchSections(
suggestions.value,
categories.value,
trimmedQuery.value
)
)
function selectNode(nodeDef: ComfyNodeDefImpl) {
@@ -308,15 +329,24 @@ function onRootSearchKeydown(event: KeyboardEvent) {
event.preventDefault()
focusFirstItem(event.currentTarget as HTMLElement)
} else if (event.key === 'Enter' && trimmedQuery.value) {
const first = searchResults.value[0]
if (first) selectNode(first.node)
const first = searchSections.value[0]?.nodes[0]
if (first) selectNode(first)
}
}
function show(event: MouseEvent) {
actionTaken = false
query.value = ''
position.value = { x: event.clientX, y: event.clientY }
// Lift the menu up (and nudge it left) only when the cursor is too close to
// an edge for the default-height menu to fit. Computed once on open so the
// menu never moves while filtering. Node placement is unaffected because it
// uses the original canvas coords, not this client position.
const maxX = window.innerWidth - MENU_WIDTH - MENU_MARGIN
const maxY = window.innerHeight - DEFAULT_MENU_HEIGHT - MENU_MARGIN
position.value = {
x: Math.min(event.clientX, Math.max(MENU_MARGIN, maxX)),
y: Math.min(event.clientY, Math.max(MENU_MARGIN, maxY))
}
void nextTick(() => {
open.value = true
})

View File

@@ -5,10 +5,10 @@ import { NodeSourceType } from '@/types/nodeSource'
import {
buildLinkReleaseNodeCategories,
buildLinkReleaseSearchSections,
filterNodesByName,
getLinkReleaseHeaderLabel,
getLinkReleaseSuggestions,
searchLinkReleaseNodes
getLinkReleaseSuggestions
} from './linkReleaseMenuModel'
import type { LinkReleaseContext } from './linkReleaseMenuModel'
@@ -150,39 +150,57 @@ describe('filterNodesByName', () => {
})
})
describe('searchLinkReleaseNodes', () => {
describe('buildLinkReleaseSearchSections', () => {
const loadImage = coreNode('LoadImage', 'Load Image')
const suggestions = getLinkReleaseSuggestions([loadImage, ksampler])
const categories = buildLinkReleaseNodeCategories([
coreNode('LoadImage', 'Load Image'),
loadImage,
customNode('ImageBlend', 'Image Blend'),
partnerNode('ImageGen', 'Image Gen'),
coreNode('KSampler')
ksampler
])
it('returns no matches for a blank query', () => {
expect(searchLinkReleaseNodes(categories, ' ')).toEqual([])
it('returns no sections for a blank query', () => {
expect(
buildLinkReleaseSearchSections(suggestions, categories, ' ')
).toEqual([])
})
it('flattens matching nodes across categories, tagged with their category', () => {
const matches = searchLinkReleaseNodes(categories, 'image')
expect(matches.map((m) => m.node.name)).toEqual([
'LoadImage',
'ImageBlend',
'ImageGen'
])
expect(matches.map((m) => m.category.key)).toEqual([
it('shows a node in both Most Relevant and its category section', () => {
const sections = buildLinkReleaseSearchSections(
suggestions,
categories,
'load image'
)
expect(sections[0].key).toBe('suggestions')
expect(sections[0].nodes.map((n) => n.name)).toContain('LoadImage')
const comfy = sections.find((s) => s.key === 'comfy')
expect(comfy?.nodes.map((n) => n.name)).toContain('LoadImage')
})
it('orders sections Most Relevant then comfy, extensions, partner', () => {
const sections = buildLinkReleaseSearchSections(
suggestions,
categories,
'image'
)
expect(sections.map((s) => s.key)).toEqual([
'suggestions',
'comfy',
'extensions',
'partner'
])
})
it('matches display name case-insensitively', () => {
const matches = searchLinkReleaseNodes(categories, 'ksampler')
expect(matches.map((m) => m.node.name)).toEqual(['KSampler'])
expect(matches[0].category.key).toBe('comfy')
})
it('returns an empty list when nothing matches', () => {
expect(searchLinkReleaseNodes(categories, 'zzz')).toEqual([])
it('omits empty sections and returns nothing when no match', () => {
const sections = buildLinkReleaseSearchSections(
suggestions,
categories,
'ksampler'
)
expect(sections.map((s) => s.key)).toEqual(['suggestions', 'comfy'])
expect(
buildLinkReleaseSearchSections(suggestions, categories, 'zzz')
).toEqual([])
})
})

View File

@@ -111,31 +111,50 @@ export function filterNodesByName(
)
}
/** A node surfaced by the root flat-value search, tagged with its category. */
export interface LinkReleaseNodeMatch {
category: LinkReleaseNodeCategory
node: ComfyNodeDefImpl
/** A filtered group of nodes shown while searching the root menu. */
export interface LinkReleaseSearchSection {
key: 'suggestions' | LinkReleaseCategoryKey
/** i18n key for the section heading. */
labelKey: string
/** Iconify class shown beside the heading (omitted for Most Relevant). */
icon?: string
nodes: ComfyNodeDefImpl[]
}
/**
* Flat-value search across every category submenu: when the root search has
* text we surface matching nodes inline (tagged with their category) so a node
* can be picked straight from the root without first drilling into a submenu.
* Results preserve category order, then per-category display-name order.
* Search that mirrors the no-query layout: a Most Relevant section of matching
* suggestions followed by a section per category with matching nodes. A node
* present in both Most Relevant and a category appears in each section.
* Empty sections are omitted; section order is Most Relevant, then categories.
*/
export function searchLinkReleaseNodes(
export function buildLinkReleaseSearchSections(
suggestions: ComfyNodeDefImpl[],
categories: LinkReleaseNodeCategory[],
query: string
): LinkReleaseNodeMatch[] {
const trimmed = query.trim().toLowerCase()
if (!trimmed) return []
const matches: LinkReleaseNodeMatch[] = []
): LinkReleaseSearchSection[] {
if (!query.trim()) return []
const sections: LinkReleaseSearchSection[] = []
const relevant = filterNodesByName(suggestions, query)
if (relevant.length) {
sections.push({
key: 'suggestions',
labelKey: 'contextMenu.Most Relevant',
nodes: relevant
})
}
for (const category of categories) {
for (const node of category.nodes) {
if (node.display_name.toLowerCase().includes(trimmed)) {
matches.push({ category, node })
}
const nodes = filterNodesByName(category.nodes, query)
if (nodes.length) {
sections.push({
key: category.key,
labelKey: category.labelKey,
icon: category.icon,
nodes
})
}
}
return matches
return sections
}