Compare commits

...

6 Commits

Author SHA1 Message Date
uytieu
375a91b783 Style update
• adjust padding to be uniform in menu and sub menus
2026-06-10 23:49:57 -07:00
uytieu
7ff51372d0 Add Truncate and overflow
• Truncate text at end
• Hover to reveal full text above truncated text
• Fixed with on sub menus
2026-06-10 21:47:41 -07:00
uytieu
08dcbe352b Design update
• Update borders for header and footer
• Removed separator between list groups
• Truncation on flat lists on search
2026-06-10 17:33:52 -07:00
uytieu
2af773ff33 Make LinkReleaseCategoryKey non-exported
Remove the export modifier from LinkReleaseCategoryKey so the type is internal to the module. This tightens encapsulation for the searchbox link release model and prevents the type from being relied on externally.
2026-06-10 16:10:34 -07:00
uytieu
73dfe931b8 Use Reka dropdowns for link-release menu
Merged with asset manager linear style flat search
2026-06-10 16:03:03 -07:00
uytieu
4e424d7a16 Update to lite graph link release context menu
• Match current context menu styles
• Added inline search filtering of nodes
• Replaced click action with hover to see node submenus
• Moved most relevant nodes group under search and added group title for context
• Moved reroute action to button of menu
• fix: narrow fromSlot type before connectFloatingReroute
2026-06-10 09:52:49 -07:00
16 changed files with 1681 additions and 60 deletions

View 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)
})
})

View 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>

View 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') }

View 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()
})
})

View 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>

View 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' })
})
})

View 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>

View File

@@ -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()

View File

@@ -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()

View 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)
})
})

View 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
}

View 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([])
})
})

View 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
}

View File

@@ -170,6 +170,7 @@ export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
export {
findUsedSubgraphIds,
getDirectSubgraphIds,
isNodeSlot,
isSubgraphInput,
isSubgraphOutput
} from './subgraph/subgraphUtils'

View File

@@ -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",

View File

@@ -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