mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-11 16:59:20 +00:00
Compare commits
6 Commits
pysssss/sw
...
uy/node-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375a91b783 | ||
|
|
7ff51372d0 | ||
|
|
08dcbe352b | ||
|
|
2af773ff33 | ||
|
|
73dfe931b8 | ||
|
|
4e424d7a16 |
71
src/components/searchbox/LinkReleaseContextMenu.test.ts
Normal file
71
src/components/searchbox/LinkReleaseContextMenu.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
|
||||
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
|
||||
|
||||
const { groups } = vi.hoisted(() => ({
|
||||
groups: {
|
||||
suggestions: [] as ComfyNodeDefImpl[],
|
||||
categories: [] as LinkReleaseNodeCategory[]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./linkReleaseMenuModel', () => ({
|
||||
getLinkReleaseHeaderLabel: () => '',
|
||||
getLinkReleaseSuggestions: () => groups.suggestions,
|
||||
buildLinkReleaseNodeCategories: () => groups.categories,
|
||||
searchLinkReleaseNodes: () => [],
|
||||
filterNodesByName: () => []
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
const stubs = {
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuLabel: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div role="menuitem"><slot /></div>' },
|
||||
DropdownMenuSeparator: { template: '<hr data-testid="menu-separator" />' },
|
||||
LinkReleaseNodeSubmenu: { template: '<div data-testid="submenu" />' },
|
||||
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
|
||||
}
|
||||
|
||||
function suggestion(name: string): ComfyNodeDefImpl {
|
||||
return { name, display_name: name } as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function nodeCategory(key: 'comfy' | 'extensions'): LinkReleaseNodeCategory {
|
||||
return { key, labelKey: key, icon: '', nodes: [suggestion('Node')] }
|
||||
}
|
||||
|
||||
function renderMenu() {
|
||||
return render(LinkReleaseContextMenu, {
|
||||
props: { context: null },
|
||||
global: { plugins: [i18n, createTestingPinia()], stubs }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinkReleaseContextMenu group divider', () => {
|
||||
it('renders a divider between the suggestions and categories groups', () => {
|
||||
groups.suggestions = [suggestion('KSampler')]
|
||||
groups.categories = [nodeCategory('comfy')]
|
||||
renderMenu()
|
||||
|
||||
expect(screen.getAllByTestId('menu-separator')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('omits the group divider when only one group is present', () => {
|
||||
groups.suggestions = []
|
||||
groups.categories = [nodeCategory('comfy')]
|
||||
renderMenu()
|
||||
|
||||
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
328
src/components/searchbox/LinkReleaseContextMenu.vue
Normal file
328
src/components/searchbox/LinkReleaseContextMenu.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<DropdownMenuRoot :open="open" @update:open="onOpenChange">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed size-0"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="4"
|
||||
:collision-padding="8"
|
||||
:class="contentClass"
|
||||
@open-auto-focus.prevent="focusSearch"
|
||||
@close-auto-focus.prevent
|
||||
@entry-focus="onEntryFocus"
|
||||
@keydown.capture="redirectTypingToSearch"
|
||||
>
|
||||
<DropdownMenuLabel
|
||||
v-if="headerLabel"
|
||||
class="block shrink-0 truncate p-2 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ headerLabel }}
|
||||
</DropdownMenuLabel>
|
||||
<div class="p-.5 shrink-0">
|
||||
<div
|
||||
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
type="text"
|
||||
:placeholder="t('contextMenu.Search')"
|
||||
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
|
||||
@keydown="onRootSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
|
||||
<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)"
|
||||
>
|
||||
<i
|
||||
:class="cn(match.category.icon, 'size-4 shrink-0 opacity-80')"
|
||||
/>
|
||||
<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
|
||||
v-if="searchResults.length === 0"
|
||||
class="p-1 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResults') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="suggestions.length">
|
||||
<DropdownMenuLabel
|
||||
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ t('contextMenu.Most Relevant') }}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
v-for="nodeDef in suggestions"
|
||||
:key="nodeDef.name"
|
||||
:class="itemClass"
|
||||
@select="selectNode(nodeDef)"
|
||||
>
|
||||
<MiddleTruncate
|
||||
:text="nodeDef.display_name"
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<DropdownMenuSeparator
|
||||
v-if="suggestions.length && categories.length"
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
|
||||
<template v-if="categories.length">
|
||||
<DropdownMenuLabel
|
||||
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ t('contextMenu.Compatible Nodes') }}
|
||||
</DropdownMenuLabel>
|
||||
<LinkReleaseNodeSubmenu
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
:category
|
||||
:item-class="itemClass"
|
||||
:content-class="submenuContentClass"
|
||||
:scroll-class="submenuScrollClass"
|
||||
@select="selectNode"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="!trimmedQuery">
|
||||
<DropdownMenuSeparator
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
:class="cn(itemClass, 'shrink-0')"
|
||||
@select="addReroute"
|
||||
>
|
||||
<i class="icon-[lucide--git-fork] size-4 shrink-0 opacity-80" />
|
||||
<span class="flex-1 truncate">
|
||||
{{ t('contextMenu.Add Reroute') }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
|
||||
import MiddleTruncate from './MiddleTruncate.vue'
|
||||
import {
|
||||
buildLinkReleaseNodeCategories,
|
||||
getLinkReleaseHeaderLabel,
|
||||
getLinkReleaseSuggestions,
|
||||
searchLinkReleaseNodes
|
||||
} from './linkReleaseMenuModel'
|
||||
import type {
|
||||
LinkReleaseContext,
|
||||
LinkReleaseNodeMatch
|
||||
} from './linkReleaseMenuModel'
|
||||
|
||||
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectNode: [nodeDef: ComfyNodeDefImpl]
|
||||
addReroute: []
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const open = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const searchInput = ref<HTMLInputElement>()
|
||||
const query = ref('')
|
||||
let actionTaken = false
|
||||
|
||||
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 scrollbar-custom'
|
||||
const submenuContentClass =
|
||||
'z-1700 flex w-sm max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const submenuScrollClass =
|
||||
'overflow-y-auto scrollbar-custom max-h-[min(calc(var(--reka-dropdown-menu-content-available-height)-3.5rem),80vh)]'
|
||||
const itemClass =
|
||||
'flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
|
||||
|
||||
const headerLabel = computed(() =>
|
||||
context ? getLinkReleaseHeaderLabel(context) : ''
|
||||
)
|
||||
|
||||
const trimmedQuery = computed(() => query.value.trim())
|
||||
|
||||
const typeFilter = computed(() => {
|
||||
if (!context) return null
|
||||
const svc = nodeDefStore.nodeSearchService
|
||||
return {
|
||||
filterDef: context.isFromOutput
|
||||
? svc.inputTypeFilter
|
||||
: svc.outputTypeFilter,
|
||||
value: context.dataType
|
||||
}
|
||||
})
|
||||
|
||||
const compatibleNodes = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!typeFilter.value) return []
|
||||
return nodeDefStore.nodeSearchService.searchNode('', [typeFilter.value], {
|
||||
limit: 500
|
||||
})
|
||||
})
|
||||
|
||||
const defaultNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!context?.dataType) return []
|
||||
const table = context.isFromOutput
|
||||
? LiteGraph.slot_types_default_out
|
||||
: LiteGraph.slot_types_default_in
|
||||
const types = table?.[context.dataType] ?? []
|
||||
return types
|
||||
.map((type) => nodeDefStore.allNodeDefsByName[type])
|
||||
.filter((nodeDef): nodeDef is ComfyNodeDefImpl => Boolean(nodeDef))
|
||||
})
|
||||
|
||||
const suggestions = computed(() =>
|
||||
getLinkReleaseSuggestions(defaultNodeDefs.value)
|
||||
)
|
||||
const categories = computed(() =>
|
||||
buildLinkReleaseNodeCategories(compatibleNodes.value)
|
||||
)
|
||||
|
||||
const searchResults = computed<LinkReleaseNodeMatch[]>(() =>
|
||||
searchLinkReleaseNodes(categories.value, trimmedQuery.value)
|
||||
)
|
||||
|
||||
function selectNode(nodeDef: ComfyNodeDefImpl) {
|
||||
actionTaken = true
|
||||
emit('selectNode', nodeDef)
|
||||
hide()
|
||||
}
|
||||
|
||||
function addReroute() {
|
||||
actionTaken = true
|
||||
emit('addReroute')
|
||||
hide()
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
function isPrintableKey(event: KeyboardEvent) {
|
||||
return (
|
||||
event.key.length === 1 &&
|
||||
event.key !== ' ' &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
)
|
||||
}
|
||||
|
||||
// When the keyboard focus is on a menu item, funnel printable keystrokes into
|
||||
// the search field instead of letting Reka run its item type-ahead.
|
||||
function redirectTypingToSearch(event: KeyboardEvent) {
|
||||
if (event.target === searchInput.value || !isPrintableKey(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
query.value += event.key
|
||||
focusSearch()
|
||||
}
|
||||
|
||||
// Reka refocuses the first item (scrolling the list to the top) whenever the
|
||||
// menu regains focus, which fires as the pointer leaves an item while scrolling.
|
||||
function onEntryFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function focusFirstItem(target: HTMLElement) {
|
||||
const menu = target.closest<HTMLElement>('[role="menu"]')
|
||||
menu
|
||||
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
|
||||
?.focus()
|
||||
}
|
||||
|
||||
function onRootSearchKeydown(event: KeyboardEvent) {
|
||||
// Let Reka close the menu natively on Escape.
|
||||
if (event.key === 'Escape') return
|
||||
event.stopPropagation()
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusFirstItem(event.currentTarget as HTMLElement)
|
||||
} else if (event.key === 'Enter' && trimmedQuery.value) {
|
||||
const first = searchResults.value[0]
|
||||
if (first) selectNode(first.node)
|
||||
}
|
||||
}
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
actionTaken = false
|
||||
query.value = ''
|
||||
position.value = { x: event.clientX, y: event.clientY }
|
||||
void nextTick(() => {
|
||||
open.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
open.value = value
|
||||
if (value) return
|
||||
if (!actionTaken) emit('dismiss')
|
||||
actionTaken = false
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
120
src/components/searchbox/LinkReleaseNodeSubmenu.stories.ts
Normal file
120
src/components/searchbox/LinkReleaseNodeSubmenu.stories.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
|
||||
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
|
||||
|
||||
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 submenuContentClass =
|
||||
'z-1700 flex w-sm max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
|
||||
const submenuScrollClass =
|
||||
'overflow-y-auto scrollbar-custom max-h-[min(calc(var(--reka-dropdown-menu-content-available-height)-3.5rem),80vh)]'
|
||||
const itemClass =
|
||||
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
|
||||
|
||||
function node(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return { name, display_name } as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
const category: LinkReleaseNodeCategory = {
|
||||
key: 'comfy',
|
||||
labelKey: 'contextMenu.Comfy Nodes',
|
||||
icon: 'icon-[lucide--box]',
|
||||
nodes: [
|
||||
node('KSampler'),
|
||||
node('VAEDecode', 'VAE Decode'),
|
||||
node('VAEEncode', 'VAE Encode'),
|
||||
node('CLIPTextEncode', 'CLIP Text Encode'),
|
||||
node('LoadImage', 'Load Image'),
|
||||
node('SaveImage', 'Save Image'),
|
||||
node('EmptyLatentImage', 'Empty Latent Image'),
|
||||
node(
|
||||
'StableCascade_StageB_Conditioning',
|
||||
'StableCascade_StageB_Conditioning'
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const meta: Meta<typeof LinkReleaseNodeSubmenu> = {
|
||||
title: 'Components/Searchbox/LinkReleaseNodeSubmenu',
|
||||
component: LinkReleaseNodeSubmenu
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function renderAnchored(side: 'left' | 'right'): Story['render'] {
|
||||
return () => ({
|
||||
components: {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
LinkReleaseNodeSubmenu
|
||||
},
|
||||
setup() {
|
||||
const anchorStyle =
|
||||
side === 'right'
|
||||
? 'position: fixed; top: 64px; right: 16px;'
|
||||
: 'position: fixed; top: 64px; left: 16px;'
|
||||
return {
|
||||
anchorStyle,
|
||||
contentClass,
|
||||
submenuContentClass,
|
||||
submenuScrollClass,
|
||||
itemClass,
|
||||
category,
|
||||
side
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div style="height: 480px;">
|
||||
<DropdownMenuRoot default-open>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button :style="anchorStyle" class="rounded-md border border-interface-menu-stroke bg-interface-menu-surface px-3 py-1.5 text-sm text-base-foreground">
|
||||
Compatible Nodes
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:class="contentClass"
|
||||
:side="side === 'right' ? 'bottom' : 'bottom'"
|
||||
:align="side === 'right' ? 'end' : 'start'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="block truncate px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase">
|
||||
Compatible Nodes
|
||||
</DropdownMenuLabel>
|
||||
<LinkReleaseNodeSubmenu
|
||||
:category="category"
|
||||
:item-class="itemClass"
|
||||
:content-class="submenuContentClass"
|
||||
:scroll-class="submenuScrollClass"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** Anchored near the LEFT edge: the submenu opens to the RIGHT (normal). */
|
||||
export const OpensRight: Story = { render: renderAnchored('left') }
|
||||
|
||||
/**
|
||||
* Anchored near the RIGHT edge: with no room on the right, Floating UI flips the
|
||||
* submenu to the LEFT, landing flush against the parent menu's left edge.
|
||||
*/
|
||||
export const FlipsLeft: Story = { render: renderAnchored('right') }
|
||||
67
src/components/searchbox/LinkReleaseNodeSubmenu.test.ts
Normal file
67
src/components/searchbox/LinkReleaseNodeSubmenu.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
|
||||
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
const category: LinkReleaseNodeCategory = {
|
||||
key: 'comfy',
|
||||
labelKey: 'Comfy Nodes',
|
||||
icon: 'icon-[lucide--box]',
|
||||
nodes: [{ name: 'KSampler', display_name: 'KSampler' } as ComfyNodeDefImpl]
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
DropdownMenuSub: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSubTrigger: {
|
||||
template: '<button data-testid="sub-trigger"><slot /></button>'
|
||||
},
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSubContent: { template: '<div role="menu"><slot /></div>' },
|
||||
DropdownMenuSeparator: { template: '<hr />' },
|
||||
DropdownMenuItem: { template: '<div role="menuitem"><slot /></div>' },
|
||||
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
|
||||
}
|
||||
|
||||
function renderSubmenu() {
|
||||
return render(LinkReleaseNodeSubmenu, {
|
||||
props: { category, itemClass: '', contentClass: '', scrollClass: '' },
|
||||
global: { plugins: [i18n], stubs }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinkReleaseNodeSubmenu keyboard handling', () => {
|
||||
it('steps into the submenu search on ArrowRight', async () => {
|
||||
renderSubmenu()
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('steps into the submenu search on Enter', async () => {
|
||||
renderSubmenu()
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not move focus to the search on other keys', async () => {
|
||||
renderSubmenu()
|
||||
await userEvent.click(screen.getByTestId('sub-trigger'))
|
||||
await userEvent.keyboard('a')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('textbox')).not.toHaveFocus()
|
||||
})
|
||||
})
|
||||
204
src/components/searchbox/LinkReleaseNodeSubmenu.vue
Normal file
204
src/components/searchbox/LinkReleaseNodeSubmenu.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<DropdownMenuSub v-model:open="open">
|
||||
<DropdownMenuSubTrigger
|
||||
:class="triggerClass"
|
||||
@focus="open = true"
|
||||
@keydown="onTriggerKeydown"
|
||||
@blur="onTriggerBlur"
|
||||
>
|
||||
<i :class="cn(category.icon, 'size-4 shrink-0 opacity-80')" />
|
||||
<span class="flex-1 truncate">{{ t(category.labelKey) }}</span>
|
||||
<span
|
||||
class="rounded-full bg-interface-menu-keybind-surface-default px-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ category.nodes.length }}
|
||||
</span>
|
||||
<i class="icon-[lucide--chevron-right] size-4 shrink-0 opacity-60" />
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<!--
|
||||
Opens to the right of the trigger; when there's no room, Floating UI
|
||||
flips it to the LEFT. Because submenus default to prioritize-position
|
||||
(offset -> flip -> shift), the flipped panel lands flush against the
|
||||
parent menu's left edge by its OWN width (no PrimeVue-style overlap that
|
||||
shifts by the parent item width). side-offset is negative so it overlaps
|
||||
the parent edge by 2px to bridge the hover gap, and collision-padding
|
||||
keeps an 8px viewport margin so it flips before touching the edge
|
||||
(mirrors DockFilterMenu's SUB_OVERLAP / M).
|
||||
-->
|
||||
<DropdownMenuSubContent
|
||||
:class="contentClass"
|
||||
side="right"
|
||||
align="start"
|
||||
:side-offset="-2"
|
||||
:align-offset="-5"
|
||||
:collision-padding="8"
|
||||
update-position-strategy="optimized"
|
||||
@open-auto-focus.prevent
|
||||
@entry-focus="onEntryFocus"
|
||||
@keydown.capture="redirectTypingToSearch"
|
||||
>
|
||||
<div class="p-.5 shrink-0">
|
||||
<div
|
||||
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
type="text"
|
||||
:placeholder="
|
||||
t('g.searchPlaceholder', { subject: t(category.labelKey) })
|
||||
"
|
||||
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator
|
||||
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
|
||||
/>
|
||||
<div :class="scrollClass">
|
||||
<DropdownMenuItem
|
||||
v-for="nodeDef in filteredNodes"
|
||||
:key="nodeDef.name"
|
||||
:class="itemClass"
|
||||
@select="emit('select', nodeDef)"
|
||||
>
|
||||
<MiddleTruncate
|
||||
:text="nodeDef.display_name"
|
||||
class="min-w-0 flex-1 self-stretch"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<div
|
||||
v-if="filteredNodes.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResults') }}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import MiddleTruncate from './MiddleTruncate.vue'
|
||||
import { filterNodesByName } from './linkReleaseMenuModel'
|
||||
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
|
||||
|
||||
const { category, itemClass, contentClass, scrollClass } = defineProps<{
|
||||
category: LinkReleaseNodeCategory
|
||||
itemClass: string
|
||||
contentClass: string
|
||||
scrollClass: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [nodeDef: ComfyNodeDefImpl]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const searchInput = ref<HTMLInputElement>()
|
||||
|
||||
const triggerClass = computed(() =>
|
||||
cn(itemClass, 'data-[state=open]:bg-interface-menu-component-surface-hovered')
|
||||
)
|
||||
|
||||
const filteredNodes = computed(() =>
|
||||
filterNodesByName(category.nodes, query.value)
|
||||
)
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) query.value = ''
|
||||
})
|
||||
|
||||
function focusSearch() {
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
function submenuContent() {
|
||||
return searchInput.value?.closest<HTMLElement>('[role="menu"]') ?? null
|
||||
}
|
||||
|
||||
// Step into the open submenu, landing on its search field.
|
||||
function onTriggerKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowRight' && event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
open.value = true
|
||||
void nextTick(focusSearch)
|
||||
}
|
||||
|
||||
// Close the preview when focus leaves the trigger to a sibling item rather
|
||||
// than into the submenu content.
|
||||
function onTriggerBlur(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && submenuContent()?.contains(next)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function isPrintableKey(event: KeyboardEvent) {
|
||||
return (
|
||||
event.key.length === 1 &&
|
||||
event.key !== ' ' &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
)
|
||||
}
|
||||
|
||||
// When the keyboard focus is on a submenu item, funnel printable keystrokes
|
||||
// into this submenu's search field instead of Reka's item type-ahead.
|
||||
function redirectTypingToSearch(event: KeyboardEvent) {
|
||||
if (event.target === searchInput.value || !isPrintableKey(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
query.value += event.key
|
||||
focusSearch()
|
||||
}
|
||||
|
||||
// Reka refocuses the first item (scrolling the list to the top) whenever the
|
||||
// menu regains focus, which fires as the pointer leaves an item while scrolling.
|
||||
function onEntryFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function focusFirstNode(target: HTMLElement) {
|
||||
const panel = target.closest<HTMLElement>('[role="menu"]')
|
||||
panel
|
||||
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
|
||||
?.focus()
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
// Let Reka handle submenu/menu navigation keys natively.
|
||||
if (event.key === 'Escape' || event.key === 'ArrowLeft') return
|
||||
event.stopPropagation()
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusFirstNode(event.currentTarget as HTMLElement)
|
||||
} else if (event.key === 'Enter') {
|
||||
const first = filteredNodes.value[0]
|
||||
if (first) emit('select', first)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
150
src/components/searchbox/MiddleTruncate.test.ts
Normal file
150
src/components/searchbox/MiddleTruncate.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MiddleTruncate from './MiddleTruncate.vue'
|
||||
import * as overflow from './isTextOverflowing'
|
||||
|
||||
function stubRect(el: HTMLElement, rect: Partial<DOMRect>) {
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
...rect
|
||||
}) as DOMRect
|
||||
}
|
||||
|
||||
describe('MiddleTruncate', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 1024
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(document.documentElement, 'clientWidth')
|
||||
})
|
||||
|
||||
it('renders the full text inline', () => {
|
||||
render(MiddleTruncate, { props: { text: 'KSampler' } })
|
||||
expect(screen.getByText('KSampler')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not reveal a tooltip when the text fits', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(0)
|
||||
render(MiddleTruncate, { props: { text: 'KSampler' } })
|
||||
await userEvent.hover(screen.getByText('KSampler'))
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals the full text on hover when truncated', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
|
||||
const longName = 'ONNX Detector (SEGS/legacy) - use BBOXDetector'
|
||||
render(MiddleTruncate, { props: { text: longName } })
|
||||
const el = screen.getByText(longName)
|
||||
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
|
||||
await userEvent.hover(el)
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(longName)
|
||||
})
|
||||
|
||||
it('reveals when hovering anywhere on the parent menu item', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
|
||||
const longName = 'ONNX Detector (SEGS/legacy) - use BBOXDetector'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem"><MiddleTruncate text="${longName}" /></div>`
|
||||
})
|
||||
stubRect(screen.getByText(longName), {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 120,
|
||||
height: 20
|
||||
})
|
||||
await userEvent.hover(screen.getByRole('menuitem'))
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(longName)
|
||||
})
|
||||
|
||||
it('sizes the reveal to the parent menu item height', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
|
||||
const nodeName = 'A long truncated node name'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
|
||||
})
|
||||
stubRect(screen.getByText(nodeName), {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 100,
|
||||
height: 20
|
||||
})
|
||||
stubRect(screen.getByRole('menuitem'), {
|
||||
left: 0,
|
||||
top: 10,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 36
|
||||
})
|
||||
await userEvent.hover(screen.getByText(nodeName))
|
||||
expect(screen.getByRole('tooltip')).toHaveStyle({ height: '36px' })
|
||||
})
|
||||
|
||||
it('anchors the reveal to the left when it fits to the right', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(50)
|
||||
const nodeName = 'Fits To The Right'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
|
||||
})
|
||||
stubRect(screen.getByText(nodeName), {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 100,
|
||||
height: 20
|
||||
})
|
||||
stubRect(screen.getByRole('menuitem'), {
|
||||
left: 0,
|
||||
top: 10,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 36
|
||||
})
|
||||
await userEvent.hover(screen.getByText(nodeName))
|
||||
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '10px' })
|
||||
})
|
||||
|
||||
it('flips to a right anchor when revealing rightward would overflow', async () => {
|
||||
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(600)
|
||||
const nodeName = 'A very long node name near the right edge'
|
||||
render({
|
||||
components: { MiddleTruncate },
|
||||
template: `<div role="menuitem" style="padding-right: 16px"><MiddleTruncate text="${nodeName}" /></div>`
|
||||
})
|
||||
stubRect(screen.getByText(nodeName), {
|
||||
left: 850,
|
||||
top: 20,
|
||||
width: 150,
|
||||
height: 20
|
||||
})
|
||||
stubRect(screen.getByRole('menuitem'), {
|
||||
left: 840,
|
||||
top: 10,
|
||||
right: 1000,
|
||||
width: 160,
|
||||
height: 36
|
||||
})
|
||||
await userEvent.hover(screen.getByText(nodeName))
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
// Anchored to the item's right edge (1024 - 1000), independent of its padding.
|
||||
expect(tooltip).toHaveStyle({ right: '24px' })
|
||||
expect(tooltip).not.toHaveStyle({ left: '850px' })
|
||||
})
|
||||
})
|
||||
156
src/components/searchbox/MiddleTruncate.vue
Normal file
156
src/components/searchbox/MiddleTruncate.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<span
|
||||
ref="elRef"
|
||||
v-bind="$attrs"
|
||||
:class="cn('block min-w-0 truncate', revealed && 'text-transparent')"
|
||||
@pointerenter="reveal"
|
||||
@pointermove="reveal"
|
||||
@pointerleave="onPointerLeave"
|
||||
@focusin="reveal"
|
||||
@focusout="hide"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
<Teleport to="body">
|
||||
<span
|
||||
v-if="revealed && revealStyle"
|
||||
role="tooltip"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none fixed z-99999 inline-flex items-center rounded-lg bg-interface-menu-component-surface-hovered pr-3 text-sm whitespace-nowrap text-base-foreground shadow-interface',
|
||||
revealRect?.anchor === 'right' && 'pl-3'
|
||||
)
|
||||
"
|
||||
:style="revealStyle"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { measureTextWidth } from './isTextOverflowing'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { text } = defineProps<{ text: string }>()
|
||||
|
||||
// Gap kept between the reveal and the viewport edge (mirrors the menu's
|
||||
// collision-padding) and the reveal's own far-side padding (`pl-3`/`pr-3`).
|
||||
const VIEWPORT_MARGIN = 8
|
||||
const REVEAL_PADDING = 12
|
||||
|
||||
type RevealRect = {
|
||||
top: number
|
||||
height: number
|
||||
minWidth: number
|
||||
maxWidth: number
|
||||
anchor: 'left' | 'right'
|
||||
offset: number
|
||||
}
|
||||
|
||||
const elRef = ref<HTMLElement>()
|
||||
const revealed = ref(false)
|
||||
const revealRect = ref<RevealRect>()
|
||||
|
||||
const revealStyle = computed(() => {
|
||||
const rect = revealRect.value
|
||||
if (!rect) return undefined
|
||||
return {
|
||||
top: `${rect.top}px`,
|
||||
height: `${rect.height}px`,
|
||||
minWidth: `${rect.minWidth}px`,
|
||||
maxWidth: `${rect.maxWidth}px`,
|
||||
width: 'max-content',
|
||||
[rect.anchor]: `${rect.offset}px`
|
||||
}
|
||||
})
|
||||
|
||||
const menuItem = computed(
|
||||
() =>
|
||||
elRef.value?.closest<HTMLElement>('[role="menuitem"]') ??
|
||||
elRef.value?.parentElement ??
|
||||
null
|
||||
)
|
||||
|
||||
function getRevealRect(el: HTMLElement, textWidth: number): RevealRect {
|
||||
const textRect = el.getBoundingClientRect()
|
||||
const item = menuItem.value
|
||||
const itemRect = item?.getBoundingClientRect()
|
||||
const paddingRight = item
|
||||
? Number.parseFloat(getComputedStyle(item).paddingRight) || 0
|
||||
: 0
|
||||
const rightInset = itemRect ? itemRect.right - paddingRight : textRect.right
|
||||
const itemRight = itemRect ? itemRect.right : textRect.right
|
||||
const viewportWidth = document.documentElement.clientWidth
|
||||
const top = itemRect?.top ?? textRect.top
|
||||
const height = itemRect?.height ?? textRect.height
|
||||
const minWidth = Math.max(textRect.width, rightInset - textRect.left)
|
||||
const neededWidth = Math.max(minWidth, textWidth + REVEAL_PADDING)
|
||||
const fitsRight =
|
||||
textRect.left + neededWidth <= viewportWidth - VIEWPORT_MARGIN
|
||||
|
||||
if (fitsRight) {
|
||||
return {
|
||||
top,
|
||||
height,
|
||||
minWidth,
|
||||
maxWidth: viewportWidth - VIEWPORT_MARGIN - textRect.left,
|
||||
anchor: 'left',
|
||||
offset: textRect.left
|
||||
}
|
||||
}
|
||||
return {
|
||||
top,
|
||||
height,
|
||||
minWidth,
|
||||
maxWidth: itemRight - VIEWPORT_MARGIN,
|
||||
anchor: 'right',
|
||||
offset: Math.max(VIEWPORT_MARGIN, viewportWidth - itemRight)
|
||||
}
|
||||
}
|
||||
|
||||
function reveal() {
|
||||
const el = elRef.value
|
||||
if (!el) {
|
||||
revealed.value = false
|
||||
return
|
||||
}
|
||||
const textWidth = measureTextWidth(el)
|
||||
if (textWidth <= el.clientWidth + 0.5) {
|
||||
revealed.value = false
|
||||
return
|
||||
}
|
||||
revealRect.value = getRevealRect(el, textWidth)
|
||||
revealed.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
revealed.value = false
|
||||
}
|
||||
|
||||
function isStillOverMenuItem(related: EventTarget | null) {
|
||||
const item = menuItem.value
|
||||
return (
|
||||
related instanceof Node &&
|
||||
item != null &&
|
||||
(item === related || item.contains(related))
|
||||
)
|
||||
}
|
||||
|
||||
function onPointerLeave(event: PointerEvent) {
|
||||
if (isStillOverMenuItem(event.relatedTarget)) return
|
||||
hide()
|
||||
}
|
||||
|
||||
useEventListener(menuItem, 'pointerenter', reveal)
|
||||
useEventListener(menuItem, 'pointermove', reveal)
|
||||
useEventListener(menuItem, 'pointerleave', (event: PointerEvent) => {
|
||||
if (isStillOverMenuItem(event.relatedTarget)) return
|
||||
hide()
|
||||
})
|
||||
</script>
|
||||
@@ -54,6 +54,7 @@ describe('NodeSearchBoxPopover', () => {
|
||||
let emitAddFilter: EmitAddFilter | null = null
|
||||
let emitAddNodeV1: EmitAddNode | null = null
|
||||
let emitAddNodeV2: EmitAddNode | null = null
|
||||
let emitSelectNode: ((nodeDef: ComfyNodeDefImpl) => void) | null = null
|
||||
|
||||
const NodeSearchBoxStub = defineComponent({
|
||||
name: 'NodeSearchBox',
|
||||
@@ -87,6 +88,17 @@ describe('NodeSearchBoxPopover', () => {
|
||||
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
|
||||
})
|
||||
|
||||
const LinkReleaseContextMenuStub = defineComponent({
|
||||
name: 'LinkReleaseContextMenu',
|
||||
props: { context: { type: Object, default: null } },
|
||||
emits: ['selectNode', 'addReroute', 'dismiss'],
|
||||
setup(_, { emit }) {
|
||||
emitSelectNode = (nodeDef) => emit('selectNode', nodeDef)
|
||||
return {}
|
||||
},
|
||||
template: '<div data-testid="link-release-menu" />'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
@@ -104,6 +116,7 @@ describe('NodeSearchBoxPopover', () => {
|
||||
stubs: {
|
||||
NodeSearchBox: NodeSearchBoxStub,
|
||||
NodeSearchContent: NodeSearchContentStub,
|
||||
LinkReleaseContextMenu: LinkReleaseContextMenuStub,
|
||||
NodePreviewCard: true,
|
||||
Dialog: {
|
||||
template: '<div><slot name="container" /></div>',
|
||||
@@ -127,6 +140,11 @@ describe('NodeSearchBoxPopover', () => {
|
||||
if (!emitAddNodeV2)
|
||||
throw new Error('NodeSearchContent stub did not mount')
|
||||
return emitAddNodeV2
|
||||
},
|
||||
get emitSelectNode() {
|
||||
if (!emitSelectNode)
|
||||
throw new Error('LinkReleaseContextMenu stub did not mount')
|
||||
return emitSelectNode
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,6 +300,53 @@ describe('NodeSearchBoxPopover', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting a node from the link-release menu', () => {
|
||||
function setupCanvas() {
|
||||
const selectNode = vi.fn()
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = {
|
||||
graph: { nodes: [] },
|
||||
allow_searchbox: false,
|
||||
setDirty: vi.fn(),
|
||||
selectNode,
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
reset: vi.fn(),
|
||||
disconnectLinks: vi.fn(),
|
||||
connectToNode: vi.fn()
|
||||
}
|
||||
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
|
||||
return { selectNode }
|
||||
}
|
||||
|
||||
it('auto-selects the placed node on the canvas', async () => {
|
||||
const node = { id: 7 }
|
||||
const { emitSelectNode } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'default'
|
||||
})
|
||||
const { selectNode } = setupCanvas()
|
||||
addNodeOnGraph.mockReturnValue(node)
|
||||
|
||||
emitSelectNode({ name: 'KSampler' } as ComfyNodeDefImpl)
|
||||
await nextTick()
|
||||
|
||||
expect(selectNode).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('does not select when the node could not be created', async () => {
|
||||
const { emitSelectNode } = renderComponent({
|
||||
'Comfy.NodeSearchBoxImpl': 'default'
|
||||
})
|
||||
const { selectNode } = setupCanvas()
|
||||
addNodeOnGraph.mockReturnValue(null)
|
||||
|
||||
emitSelectNode({ name: 'KSampler' } as ComfyNodeDefImpl)
|
||||
await nextTick()
|
||||
|
||||
expect(selectNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultRootFilter on dialog open', () => {
|
||||
function setGraphNodes(nodes: unknown[]) {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -52,6 +52,13 @@
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
<LinkReleaseContextMenu
|
||||
ref="linkReleaseMenu"
|
||||
:context="linkReleaseContext"
|
||||
@select-node="connectNodeFromMenu"
|
||||
@add-reroute="addRerouteFromMenu"
|
||||
@dismiss="reset"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -63,7 +70,11 @@ import { computed, ref, toRaw, watch, watchEffect } from 'vue'
|
||||
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
isNodeSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
@@ -81,11 +92,12 @@ import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
|
||||
import type { LinkReleaseContext } from './linkReleaseMenuModel'
|
||||
import NodeSearchContent from './v2/NodeSearchContent.vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
|
||||
let triggerEvent: CanvasPointerEvent | null = null
|
||||
let listenerController: AbortController | null = null
|
||||
let disconnectOnReset = false
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
@@ -108,6 +120,8 @@ const enableNodePreview = computed(
|
||||
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
|
||||
)
|
||||
const defaultRootFilter = ref<RootCategoryId | null>(null)
|
||||
const linkReleaseMenu = ref<InstanceType<typeof LinkReleaseContextMenu>>()
|
||||
const linkReleaseContext = ref<LinkReleaseContext | null>(null)
|
||||
watch(visible, (isVisible) => {
|
||||
if (!isVisible) return
|
||||
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
|
||||
@@ -139,16 +153,19 @@ function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
function connectNewNode(
|
||||
nodeDef: ComfyNodeDefImpl,
|
||||
options: { ghost?: boolean; dragEvent?: MouseEvent } = {}
|
||||
): LGraphNode | null {
|
||||
const { ghost = false, dragEvent } = options
|
||||
const node = withNodeAddSource('search_modal', () =>
|
||||
litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
{ ghost, dragEvent }
|
||||
)
|
||||
)
|
||||
if (!node) return
|
||||
if (!node) return null
|
||||
|
||||
if (disconnectOnReset && triggerEvent) {
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
@@ -160,6 +177,16 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
connectNewNode(nodeDef, {
|
||||
ghost: useSearchBoxV2.value && followCursor,
|
||||
dragEvent
|
||||
})
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
@@ -212,62 +239,39 @@ function showContextMenu(e: CanvasPointerEvent) {
|
||||
const firstLink = getFirstLink()
|
||||
if (!firstLink) return
|
||||
|
||||
const { node, fromSlot, toType } = firstLink
|
||||
const commonOptions = {
|
||||
e,
|
||||
allow_searchbox: true,
|
||||
showSearchBox: () => {
|
||||
cancelResetOnContextClose()
|
||||
showSearchBox(e)
|
||||
}
|
||||
const { fromSlot, toType } = firstLink
|
||||
linkReleaseContext.value = {
|
||||
dataType: fromSlot.type?.toString() ?? '',
|
||||
slotName: fromSlot.name ?? '',
|
||||
isFromOutput: toType === 'input'
|
||||
}
|
||||
const afterRerouteId = firstLink.fromReroute?.id
|
||||
const connectionOptions =
|
||||
toType === 'input'
|
||||
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
|
||||
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const menu = canvas.showConnectionMenu({
|
||||
...connectionOptions,
|
||||
...commonOptions
|
||||
})
|
||||
|
||||
if (!menu) {
|
||||
console.warn('No menu was returned from showConnectionMenu')
|
||||
return
|
||||
}
|
||||
|
||||
triggerEvent = e
|
||||
listenerController = new AbortController()
|
||||
const { signal } = listenerController
|
||||
const options = { once: true, signal }
|
||||
linkReleaseMenu.value?.show(e)
|
||||
}
|
||||
|
||||
// Connect the node after it is created via context menu
|
||||
useEventListener(
|
||||
canvas.canvas,
|
||||
'connect-new-default-node',
|
||||
(createEvent) => {
|
||||
if (!(createEvent instanceof CustomEvent))
|
||||
throw new Error('Invalid event')
|
||||
function connectNodeFromMenu(nodeDef: ComfyNodeDefImpl) {
|
||||
const node = connectNewNode(nodeDef)
|
||||
if (node) canvasStore.getCanvas().selectNode(node)
|
||||
reset()
|
||||
}
|
||||
|
||||
const node: unknown = createEvent.detail?.node
|
||||
if (!(node instanceof LGraphNode)) throw new Error('Invalid node')
|
||||
|
||||
disconnectOnReset = false
|
||||
createEvent.preventDefault()
|
||||
canvas.linkConnector.connectToNode(node, e)
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
// Reset when the context menu is closed
|
||||
const cancelResetOnContextClose = useEventListener(
|
||||
menu.controller.signal,
|
||||
'abort',
|
||||
reset,
|
||||
options
|
||||
)
|
||||
function addRerouteFromMenu() {
|
||||
const firstLink = getFirstLink()
|
||||
const node = firstLink?.node
|
||||
if (
|
||||
firstLink &&
|
||||
triggerEvent &&
|
||||
node instanceof LGraphNode &&
|
||||
isNodeSlot(firstLink.fromSlot)
|
||||
) {
|
||||
node.connectFloatingReroute(
|
||||
[triggerEvent.canvasX, triggerEvent.canvasY],
|
||||
firstLink.fromSlot,
|
||||
firstLink.fromReroute?.id
|
||||
)
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
// Disable litegraph's default behavior of release link and search box.
|
||||
@@ -343,8 +347,6 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
|
||||
|
||||
// Resets litegraph state
|
||||
function reset() {
|
||||
listenerController?.abort()
|
||||
listenerController = null
|
||||
triggerEvent = null
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
|
||||
45
src/components/searchbox/isTextOverflowing.test.ts
Normal file
45
src/components/searchbox/isTextOverflowing.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isTextOverflowing } from './isTextOverflowing'
|
||||
|
||||
const CHAR_WIDTH = 10
|
||||
|
||||
function setup(text: string, contentWidth: number) {
|
||||
const el = document.createElement('span')
|
||||
el.textContent = text
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: contentWidth
|
||||
})
|
||||
vi.spyOn(window, 'getComputedStyle').mockReturnValue(
|
||||
{} as CSSStyleDeclaration
|
||||
)
|
||||
vi.spyOn(
|
||||
HTMLSpanElement.prototype,
|
||||
'getBoundingClientRect'
|
||||
).mockImplementation(function (this: HTMLSpanElement) {
|
||||
return { width: (this.textContent?.length ?? 0) * CHAR_WIDTH } as DOMRect
|
||||
})
|
||||
return el
|
||||
}
|
||||
|
||||
describe('isTextOverflowing', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns false when the text fits the content width', () => {
|
||||
const el = setup('KSampler', 200)
|
||||
expect(isTextOverflowing(el)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the full text is wider than the content width', () => {
|
||||
const el = setup('ONNX Detector (SEGS/legacy) - use BBOXDetector', 120)
|
||||
expect(isTextOverflowing(el)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a zero-width element', () => {
|
||||
const el = setup('anything', 0)
|
||||
expect(isTextOverflowing(el)).toBe(false)
|
||||
})
|
||||
})
|
||||
46
src/components/searchbox/isTextOverflowing.ts
Normal file
46
src/components/searchbox/isTextOverflowing.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
const FONT_PROPS = [
|
||||
'fontStyle',
|
||||
'fontVariant',
|
||||
'fontWeight',
|
||||
'fontStretch',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'letterSpacing',
|
||||
'textTransform',
|
||||
'wordSpacing'
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Measures the full, unclipped width of an element's text by rendering it in a
|
||||
* hidden clone that copies the element's font metrics. `scrollWidth` is
|
||||
* unreliable for `text-overflow: ellipsis` in Chrome (it often reports equal to
|
||||
* `clientWidth`), so the clone is the source of truth.
|
||||
*/
|
||||
export function measureTextWidth(el: HTMLElement): number {
|
||||
const style = getComputedStyle(el)
|
||||
const clone = document.createElement('span')
|
||||
clone.textContent = el.textContent ?? ''
|
||||
clone.style.position = 'fixed'
|
||||
clone.style.top = '-9999px'
|
||||
clone.style.left = '-9999px'
|
||||
clone.style.visibility = 'hidden'
|
||||
clone.style.whiteSpace = 'nowrap'
|
||||
for (const prop of FONT_PROPS) clone.style[prop] = style[prop]
|
||||
|
||||
document.body.appendChild(clone)
|
||||
const textWidth = clone.getBoundingClientRect().width
|
||||
clone.remove()
|
||||
|
||||
return textWidth
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether a single-line, ellipsis-truncated element is actually
|
||||
* clipping its text by comparing its full text width against the available
|
||||
* content width.
|
||||
*/
|
||||
export function isTextOverflowing(el: HTMLElement): boolean {
|
||||
const contentWidth = el.clientWidth
|
||||
if (contentWidth <= 0) return false
|
||||
return measureTextWidth(el) > contentWidth + 0.5
|
||||
}
|
||||
188
src/components/searchbox/linkReleaseMenuModel.test.ts
Normal file
188
src/components/searchbox/linkReleaseMenuModel.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
import {
|
||||
buildLinkReleaseNodeCategories,
|
||||
filterNodesByName,
|
||||
getLinkReleaseHeaderLabel,
|
||||
getLinkReleaseSuggestions,
|
||||
searchLinkReleaseNodes
|
||||
} from './linkReleaseMenuModel'
|
||||
import type { LinkReleaseContext } from './linkReleaseMenuModel'
|
||||
|
||||
function coreNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: false
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function customNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.CustomNodes },
|
||||
api_node: false
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function partnerNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: true
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
const ksampler = coreNode('KSampler')
|
||||
const vaeDecode = coreNode('VAEDecode', 'VAE Decode')
|
||||
const rerouteNode = coreNode('Reroute')
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<LinkReleaseContext> = {}
|
||||
): LinkReleaseContext {
|
||||
return {
|
||||
dataType: 'MODEL',
|
||||
slotName: 'model',
|
||||
isFromOutput: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('getLinkReleaseHeaderLabel', () => {
|
||||
it('combines slot name and data type', () => {
|
||||
const label = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: 'model', dataType: 'MODEL' })
|
||||
)
|
||||
expect(label).toBe('model | MODEL')
|
||||
})
|
||||
|
||||
it('falls back to whichever value is present', () => {
|
||||
const onlyType = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: '', dataType: 'IMAGE' })
|
||||
)
|
||||
const onlyName = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: 'clip', dataType: '' })
|
||||
)
|
||||
expect(onlyType).toBe('IMAGE')
|
||||
expect(onlyName).toBe('clip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLinkReleaseSuggestions', () => {
|
||||
it('excludes the Reroute node', () => {
|
||||
const suggestions = getLinkReleaseSuggestions([rerouteNode, vaeDecode])
|
||||
expect(suggestions.map((n) => n.name)).toEqual(['VAEDecode'])
|
||||
})
|
||||
|
||||
it('preserves the incoming order of remaining nodes', () => {
|
||||
const suggestions = getLinkReleaseSuggestions([vaeDecode, ksampler])
|
||||
expect(suggestions.map((n) => n.name)).toEqual(['VAEDecode', 'KSampler'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLinkReleaseNodeCategories', () => {
|
||||
it('groups nodes by source into comfy, extensions and partner buckets', () => {
|
||||
const ext = customNode('ExtNode', 'Ext Node')
|
||||
const partner = partnerNode('PartnerNode', 'Partner Node')
|
||||
|
||||
const categories = buildLinkReleaseNodeCategories([ksampler, ext, partner])
|
||||
const byKey = Object.fromEntries(categories.map((c) => [c.key, c]))
|
||||
|
||||
expect(byKey.comfy.nodes.map((n) => n.name)).toContain('KSampler')
|
||||
expect(byKey.extensions.nodes.map((n) => n.name)).toContain('ExtNode')
|
||||
expect(byKey.partner.nodes.map((n) => n.name)).toContain('PartnerNode')
|
||||
})
|
||||
|
||||
it('omits empty buckets', () => {
|
||||
const categories = buildLinkReleaseNodeCategories([ksampler])
|
||||
expect(categories.map((c) => c.key)).toEqual(['comfy'])
|
||||
})
|
||||
|
||||
it('orders buckets comfy, extensions, partner', () => {
|
||||
const categories = buildLinkReleaseNodeCategories([
|
||||
partnerNode('P'),
|
||||
customNode('E'),
|
||||
coreNode('C')
|
||||
])
|
||||
expect(categories.map((c) => c.key)).toEqual([
|
||||
'comfy',
|
||||
'extensions',
|
||||
'partner'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts nodes alphabetically by display name within a bucket', () => {
|
||||
const categories = buildLinkReleaseNodeCategories([
|
||||
coreNode('B'),
|
||||
coreNode('A')
|
||||
])
|
||||
expect(categories[0].nodes.map((n) => n.display_name)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('classifies api-category nodes as partner', () => {
|
||||
const apiNode = {
|
||||
name: 'ApiThing',
|
||||
display_name: 'Api Thing',
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: false,
|
||||
category: 'api node/openai'
|
||||
} as ComfyNodeDefImpl
|
||||
|
||||
const categories = buildLinkReleaseNodeCategories([apiNode])
|
||||
expect(categories.map((c) => c.key)).toEqual(['partner'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterNodesByName', () => {
|
||||
it('returns all nodes when query is blank', () => {
|
||||
expect(filterNodesByName([ksampler, vaeDecode], ' ')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('matches display name case-insensitively', () => {
|
||||
const result = filterNodesByName([ksampler, vaeDecode], 'vae')
|
||||
expect(result.map((n) => n.name)).toEqual(['VAEDecode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchLinkReleaseNodes', () => {
|
||||
const categories = buildLinkReleaseNodeCategories([
|
||||
coreNode('LoadImage', 'Load Image'),
|
||||
customNode('ImageBlend', 'Image Blend'),
|
||||
partnerNode('ImageGen', 'Image Gen'),
|
||||
coreNode('KSampler')
|
||||
])
|
||||
|
||||
it('returns no matches for a blank query', () => {
|
||||
expect(searchLinkReleaseNodes(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([
|
||||
'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([])
|
||||
})
|
||||
})
|
||||
141
src/components/searchbox/linkReleaseMenuModel.ts
Normal file
141
src/components/searchbox/linkReleaseMenuModel.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
export interface LinkReleaseContext {
|
||||
/** The data type of the slot the link was dragged from (e.g. "MODEL"). */
|
||||
dataType: string
|
||||
/** The name of the slot the link was dragged from (e.g. "model"). */
|
||||
slotName: string
|
||||
/**
|
||||
* Whether the released link originates from an output slot, meaning the new
|
||||
* node will be connected to via one of its inputs.
|
||||
*/
|
||||
isFromOutput: boolean
|
||||
}
|
||||
|
||||
type LinkReleaseCategoryKey = 'comfy' | 'extensions' | 'partner'
|
||||
|
||||
export interface LinkReleaseNodeCategory {
|
||||
key: LinkReleaseCategoryKey
|
||||
/** i18n key for the group heading. */
|
||||
labelKey: string
|
||||
/** Iconify class shown beside the group label. */
|
||||
icon: string
|
||||
/** Nodes in the group, sorted alphabetically by display name. */
|
||||
nodes: ComfyNodeDefImpl[]
|
||||
}
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
LinkReleaseCategoryKey,
|
||||
{ labelKey: string; icon: string }
|
||||
> = {
|
||||
comfy: { labelKey: 'contextMenu.Comfy Nodes', icon: 'icon-[lucide--box]' },
|
||||
extensions: {
|
||||
labelKey: 'contextMenu.Extensions',
|
||||
icon: 'icon-[lucide--puzzle]'
|
||||
},
|
||||
partner: {
|
||||
labelKey: 'contextMenu.Partner Nodes',
|
||||
icon: 'icon-[lucide--handshake]'
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: LinkReleaseCategoryKey[] = [
|
||||
'comfy',
|
||||
'extensions',
|
||||
'partner'
|
||||
]
|
||||
|
||||
export function getLinkReleaseHeaderLabel(context: LinkReleaseContext): string {
|
||||
const { slotName, dataType } = context
|
||||
if (slotName && dataType) return `${slotName} | ${dataType}`
|
||||
return slotName || dataType
|
||||
}
|
||||
|
||||
function classifyNode(node: ComfyNodeDefImpl): LinkReleaseCategoryKey {
|
||||
if (node.api_node || node.category?.startsWith('api node')) return 'partner'
|
||||
if (
|
||||
node.nodeSource.type === NodeSourceType.Core ||
|
||||
node.nodeSource.type === NodeSourceType.Essentials
|
||||
) {
|
||||
return 'comfy'
|
||||
}
|
||||
return 'extensions'
|
||||
}
|
||||
|
||||
function byDisplayName(a: ComfyNodeDefImpl, b: ComfyNodeDefImpl): number {
|
||||
return a.display_name.localeCompare(b.display_name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Group slot-compatible nodes into source buckets for the cascading menu.
|
||||
* Empty buckets are omitted and each bucket's nodes are sorted by display name.
|
||||
*/
|
||||
export function buildLinkReleaseNodeCategories(
|
||||
compatibleNodes: ComfyNodeDefImpl[]
|
||||
): LinkReleaseNodeCategory[] {
|
||||
const buckets: Record<LinkReleaseCategoryKey, ComfyNodeDefImpl[]> = {
|
||||
comfy: [],
|
||||
extensions: [],
|
||||
partner: []
|
||||
}
|
||||
|
||||
for (const node of compatibleNodes) {
|
||||
buckets[classifyNode(node)].push(node)
|
||||
}
|
||||
|
||||
return CATEGORY_ORDER.filter((key) => buckets[key].length > 0).map((key) => ({
|
||||
key,
|
||||
labelKey: CATEGORY_META[key].labelKey,
|
||||
icon: CATEGORY_META[key].icon,
|
||||
nodes: [...buckets[key]].sort(byDisplayName)
|
||||
}))
|
||||
}
|
||||
|
||||
/** Quick-add suggestions for the released slot, excluding the Reroute node. */
|
||||
export function getLinkReleaseSuggestions(
|
||||
defaultNodeDefs: ComfyNodeDefImpl[]
|
||||
): ComfyNodeDefImpl[] {
|
||||
return defaultNodeDefs.filter((nodeDef) => nodeDef.name !== 'Reroute')
|
||||
}
|
||||
|
||||
/** Case-insensitive filter of a node list by display name. */
|
||||
export function filterNodesByName(
|
||||
nodes: ComfyNodeDefImpl[],
|
||||
query: string
|
||||
): ComfyNodeDefImpl[] {
|
||||
const trimmed = query.trim().toLowerCase()
|
||||
if (!trimmed) return nodes
|
||||
return nodes.filter((nodeDef) =>
|
||||
nodeDef.display_name.toLowerCase().includes(trimmed)
|
||||
)
|
||||
}
|
||||
|
||||
/** A node surfaced by the root flat-value search, tagged with its category. */
|
||||
export interface LinkReleaseNodeMatch {
|
||||
category: LinkReleaseNodeCategory
|
||||
node: 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.
|
||||
*/
|
||||
export function searchLinkReleaseNodes(
|
||||
categories: LinkReleaseNodeCategory[],
|
||||
query: string
|
||||
): LinkReleaseNodeMatch[] {
|
||||
const trimmed = query.trim().toLowerCase()
|
||||
if (!trimmed) return []
|
||||
const matches: LinkReleaseNodeMatch[] = []
|
||||
for (const category of categories) {
|
||||
for (const node of category.nodes) {
|
||||
if (node.display_name.toLowerCase().includes(trimmed)) {
|
||||
matches.push({ category, node })
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
@@ -170,6 +170,7 @@ export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
|
||||
export {
|
||||
findUsedSubgraphIds,
|
||||
getDirectSubgraphIds,
|
||||
isNodeSlot,
|
||||
isSubgraphInput,
|
||||
isSubgraphOutput
|
||||
} from './subgraph/subgraphUtils'
|
||||
|
||||
@@ -593,6 +593,12 @@
|
||||
"Bypass": "Bypass",
|
||||
"Copy (Clipspace)": "Copy (Clipspace)",
|
||||
"Add Node": "Add Node",
|
||||
"Add Reroute": "Add Reroute",
|
||||
"Most Relevant": "Most Relevant",
|
||||
"Comfy Nodes": "Comfy Nodes",
|
||||
"Extensions": "Extensions",
|
||||
"Partner Nodes": "Partner Nodes",
|
||||
"Compatible Nodes": "Compatible Nodes",
|
||||
"Add Group": "Add Group",
|
||||
"Manage Group Nodes": "Manage Group Nodes",
|
||||
"Add Group For Selected Nodes": "Add Group For Selected Nodes",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SubgraphNode,
|
||||
createBounds
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { overlapBounding } from '@/lib/litegraph/src/measure'
|
||||
import type {
|
||||
CreateNodeOptions,
|
||||
GraphAddOptions,
|
||||
@@ -944,9 +945,39 @@ export const useLitegraphService = () => {
|
||||
if (!graph || !node) return null
|
||||
|
||||
graph.add(node, addOptions)
|
||||
if (!addOptions?.ghost) {
|
||||
resolveOverlap(node, graph)
|
||||
centerOnNewNode(node)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
const OVERLAP_GAP = 20
|
||||
const OVERLAP_MAX_ITER = 100
|
||||
|
||||
function resolveOverlap(
|
||||
node: LGraphNode,
|
||||
graph: { nodes: LGraphNode[] }
|
||||
): void {
|
||||
node.updateArea()
|
||||
let iter = 0
|
||||
while (
|
||||
iter++ < OVERLAP_MAX_ITER &&
|
||||
graph.nodes.some(
|
||||
(n) =>
|
||||
n.id !== node.id && overlapBounding(node.boundingRect, n.boundingRect)
|
||||
)
|
||||
) {
|
||||
node.pos[1] += node.size[1] + OVERLAP_GAP
|
||||
node.updateArea()
|
||||
}
|
||||
}
|
||||
|
||||
function centerOnNewNode(node: LGraphNode): void {
|
||||
node.updateArea()
|
||||
app.canvas?.animateToBounds(node.boundingRect, { zoom: 0 })
|
||||
}
|
||||
|
||||
function getCanvasCenter(): Point {
|
||||
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
|
||||
const visibleArea = app.canvas?.ds?.visible_area
|
||||
|
||||
Reference in New Issue
Block a user