mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-11 00:38:37 +00:00
Compare commits
1 Commits
pysssss/mo
...
uy/node-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e424d7a16 |
188
src/components/searchbox/LinkReleaseContextMenu.vue
Normal file
188
src/components/searchbox/LinkReleaseContextMenu.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<ContextMenu
|
||||
ref="menu"
|
||||
:model="items"
|
||||
class="max-h-[80vh] overflow-y-auto md:max-h-none md:overflow-y-visible"
|
||||
:pt="{
|
||||
item: ({ context }) =>
|
||||
context.item.isSearch || context.item.isGroupLabel
|
||||
? { class: searchItemSurfaceClass }
|
||||
: undefined,
|
||||
itemContent: ({ context }) =>
|
||||
context.item.isSearch || context.item.isGroupLabel
|
||||
? { class: searchItemSurfaceClass }
|
||||
: undefined
|
||||
}"
|
||||
@hide="onHide"
|
||||
>
|
||||
<template #item="{ item, props, hasSubmenu }">
|
||||
<span
|
||||
v-if="item.isHeader"
|
||||
class="block truncate px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.isGroupLabel"
|
||||
class="block truncate px-3 pt-1 pb-0.5 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<div
|
||||
v-else-if="item.isSearch"
|
||||
class="px-1 py-1.5"
|
||||
@click.stop
|
||||
@keydown.capture="onSearchKeydown"
|
||||
>
|
||||
<SearchInput
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
size="md"
|
||||
:placeholder="t('contextMenu.Search')"
|
||||
:debounce-time="0"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
v-else
|
||||
v-bind="props.action"
|
||||
class="flex items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<i v-if="item.icon" :class="cn(item.icon, 'size-4')" />
|
||||
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||
<i
|
||||
v-if="hasSubmenu"
|
||||
class="icon-[lucide--chevron-right] size-4 opacity-60"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { buildLinkReleaseMenuItems } from './linkReleaseMenuModel'
|
||||
import type { LinkReleaseContext } from './linkReleaseMenuModel'
|
||||
|
||||
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectNode: [nodeDef: ComfyNodeDefImpl]
|
||||
addReroute: []
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const menu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const searchInput = ref<InstanceType<typeof SearchInput>>()
|
||||
const query = ref('')
|
||||
let actionTaken = false
|
||||
|
||||
const searchItemSurfaceClass =
|
||||
'bg-interface-menu-surface hover:bg-interface-menu-surface focus:bg-interface-menu-surface data-[p-focused=true]:bg-interface-menu-surface'
|
||||
|
||||
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 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 searchResults = computed<ComfyNodeDefImpl[]>(() => {
|
||||
const q = query.value.trim()
|
||||
if (!q || !typeFilter.value) return []
|
||||
return nodeDefStore.nodeSearchService.searchNode(q, [typeFilter.value], {
|
||||
limit: 20
|
||||
})
|
||||
})
|
||||
|
||||
function selectNode(nodeDef: ComfyNodeDefImpl) {
|
||||
actionTaken = true
|
||||
emit('selectNode', nodeDef)
|
||||
hide()
|
||||
}
|
||||
|
||||
function addReroute() {
|
||||
actionTaken = true
|
||||
emit('addReroute')
|
||||
hide()
|
||||
}
|
||||
|
||||
const items = computed<MenuItem[]>(() =>
|
||||
context
|
||||
? buildLinkReleaseMenuItems({
|
||||
context,
|
||||
compatibleNodes: compatibleNodes.value,
|
||||
defaultNodeDefs: defaultNodeDefs.value,
|
||||
query: query.value,
|
||||
searchResults: searchResults.value,
|
||||
t,
|
||||
handlers: { selectNode, addReroute }
|
||||
})
|
||||
: []
|
||||
)
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Enter') {
|
||||
const first = searchResults.value[0]
|
||||
if (first) selectNode(first)
|
||||
} else if (event.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
actionTaken = false
|
||||
query.value = ''
|
||||
menu.value?.show(event)
|
||||
requestAnimationFrame(() => searchInput.value?.focus())
|
||||
}
|
||||
|
||||
function hide() {
|
||||
menu.value?.hide()
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
if (actionTaken) {
|
||||
actionTaken = false
|
||||
return
|
||||
}
|
||||
emit('dismiss')
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -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,13 +153,16 @@ 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 } = {}
|
||||
) {
|
||||
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
|
||||
@@ -160,6 +177,14 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
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 +237,38 @@ 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) {
|
||||
connectNewNode(nodeDef)
|
||||
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 +344,6 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
|
||||
|
||||
// Resets litegraph state
|
||||
function reset() {
|
||||
listenerController?.abort()
|
||||
listenerController = null
|
||||
triggerEvent = null
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
|
||||
258
src/components/searchbox/linkReleaseMenuModel.test.ts
Normal file
258
src/components/searchbox/linkReleaseMenuModel.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
import {
|
||||
buildLinkReleaseMenuItems,
|
||||
getLinkReleaseHeaderLabel
|
||||
} from './linkReleaseMenuModel'
|
||||
import type {
|
||||
LinkReleaseContext,
|
||||
LinkReleaseMenuHandlers
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
function createHandlers(): LinkReleaseMenuHandlers {
|
||||
return {
|
||||
selectNode: vi.fn(),
|
||||
addReroute: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const identityT = (key: string) => key
|
||||
|
||||
const labelOf = (item: MenuItem) => item.label
|
||||
|
||||
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('buildLinkReleaseMenuItems', () => {
|
||||
function build(
|
||||
options: {
|
||||
context?: LinkReleaseContext
|
||||
compatibleNodes?: ComfyNodeDefImpl[]
|
||||
defaultNodeDefs?: ComfyNodeDefImpl[]
|
||||
query?: string
|
||||
searchResults?: ComfyNodeDefImpl[]
|
||||
handlers?: LinkReleaseMenuHandlers
|
||||
} = {}
|
||||
) {
|
||||
const handlers = options.handlers ?? createHandlers()
|
||||
const items = buildLinkReleaseMenuItems({
|
||||
context: options.context ?? createContext(),
|
||||
compatibleNodes: options.compatibleNodes ?? [],
|
||||
defaultNodeDefs: options.defaultNodeDefs ?? [],
|
||||
query: options.query ?? '',
|
||||
searchResults: options.searchResults ?? [],
|
||||
t: identityT,
|
||||
handlers
|
||||
})
|
||||
return { items, handlers }
|
||||
}
|
||||
|
||||
it('renders a disabled slot-type header as the first entry', () => {
|
||||
const { items } = build()
|
||||
expect(items[0]).toMatchObject({
|
||||
label: 'model | MODEL',
|
||||
isHeader: true,
|
||||
disabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a separator then search field after the header', () => {
|
||||
const { items } = build()
|
||||
expect(items[1]).toMatchObject({ separator: true })
|
||||
expect(items[2]).toMatchObject({ isSearch: true })
|
||||
expect(items[3]).toMatchObject({ separator: true })
|
||||
})
|
||||
|
||||
it('always has Add Reroute as the last item', () => {
|
||||
const { items } = build()
|
||||
expect(items.at(-1)?.label).toBe('contextMenu.Add Reroute')
|
||||
})
|
||||
|
||||
it('Add Reroute remains last when query is non-empty', () => {
|
||||
const { items } = build({ query: 'ksampler', searchResults: [ksampler] })
|
||||
expect(items.at(-1)?.label).toBe('contextMenu.Add Reroute')
|
||||
})
|
||||
|
||||
it('groups Reroute node def immediately before Add Reroute', () => {
|
||||
const { items, handlers } = build({
|
||||
defaultNodeDefs: [vaeDecode, rerouteNode]
|
||||
})
|
||||
expect(items.at(-1)?.label).toBe('contextMenu.Add Reroute')
|
||||
expect(items.at(-2)?.label).toBe('Reroute')
|
||||
expect(items.at(-3)?.separator).toBe(true)
|
||||
|
||||
items.at(-2)?.command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(rerouteNode)
|
||||
})
|
||||
|
||||
it('excludes Reroute node def from the suggestions section', () => {
|
||||
const { items } = build({ defaultNodeDefs: [rerouteNode, vaeDecode] })
|
||||
const addRerouteIdx = items.findIndex(
|
||||
(i) => i.label === 'contextMenu.Add Reroute'
|
||||
)
|
||||
const rerouteNodeIdx = items.findIndex((i) => i.label === 'Reroute')
|
||||
expect(rerouteNodeIdx).toBeGreaterThan(0)
|
||||
expect(rerouteNodeIdx).toBeLessThan(addRerouteIdx)
|
||||
expect(items.at(-2)?.label).toBe('Reroute')
|
||||
})
|
||||
|
||||
it('groups compatible nodes by source under Comfy Nodes, Extensions, Partner Nodes', () => {
|
||||
const ext = customNode('ExtNode', 'Ext Node')
|
||||
const partner = partnerNode('PartnerNode', 'Partner Node')
|
||||
|
||||
const { items, handlers } = build({
|
||||
compatibleNodes: [ksampler, ext, partner]
|
||||
})
|
||||
|
||||
const comfyGroup = items.find((i) => i.label === 'contextMenu.Comfy Nodes')
|
||||
const extGroup = items.find((i) => i.label === 'contextMenu.Extensions')
|
||||
const partnerGroup = items.find(
|
||||
(i) => i.label === 'contextMenu.Partner Nodes'
|
||||
)
|
||||
|
||||
expect(comfyGroup?.items?.map((i) => i.label)).toContain('KSampler')
|
||||
expect(extGroup?.items?.map((i) => i.label)).toContain('Ext Node')
|
||||
expect(partnerGroup?.items?.map((i) => i.label)).toContain('Partner Node')
|
||||
|
||||
comfyGroup?.items
|
||||
?.find((i) => i.label === 'KSampler')
|
||||
?.command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(ksampler)
|
||||
})
|
||||
|
||||
it('omits empty source groups', () => {
|
||||
const { items } = build({ compatibleNodes: [ksampler] })
|
||||
const labels = items.map(labelOf)
|
||||
expect(labels).toContain('contextMenu.Comfy Nodes')
|
||||
expect(labels).not.toContain('contextMenu.Extensions')
|
||||
expect(labels).not.toContain('contextMenu.Partner Nodes')
|
||||
})
|
||||
|
||||
it('sorts nodes alphabetically within each group', () => {
|
||||
const nodeB = coreNode('B')
|
||||
const nodeA = coreNode('A')
|
||||
const { items } = build({ compatibleNodes: [nodeB, nodeA] })
|
||||
const comfyGroup = items.find((i) => i.label === 'contextMenu.Comfy Nodes')
|
||||
expect(comfyGroup?.items?.[0]?.label).toBe('A')
|
||||
expect(comfyGroup?.items?.[1]?.label).toBe('B')
|
||||
})
|
||||
|
||||
it('wires Add Reroute to its handler', () => {
|
||||
const { items, handlers } = build()
|
||||
items
|
||||
.find((i) => i.label === 'contextMenu.Add Reroute')
|
||||
?.command?.({} as never)
|
||||
expect(handlers.addReroute).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lists suggestions before compatible node groups', () => {
|
||||
const { items, handlers } = build({
|
||||
defaultNodeDefs: [vaeDecode],
|
||||
compatibleNodes: [ksampler]
|
||||
})
|
||||
const suggestionIdx = items.findIndex((i) => i.label === 'VAE Decode')
|
||||
const comfyGroupIdx = items.findIndex(
|
||||
(i) => i.label === 'contextMenu.Comfy Nodes'
|
||||
)
|
||||
const rerouteIdx = items.findIndex(
|
||||
(i) => i.label === 'contextMenu.Add Reroute'
|
||||
)
|
||||
|
||||
expect(suggestionIdx).toBeGreaterThan(0)
|
||||
expect(suggestionIdx).toBeLessThan(comfyGroupIdx)
|
||||
expect(comfyGroupIdx).toBeLessThan(rerouteIdx)
|
||||
|
||||
items[suggestionIdx].command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(vaeDecode)
|
||||
})
|
||||
|
||||
it('has 3 separators with no compatible nodes, 4 with', () => {
|
||||
const { items: noCompat } = build({ compatibleNodes: [] })
|
||||
expect(noCompat.filter((i) => i.separator).length).toBe(3)
|
||||
|
||||
const { items: withCompat } = build({ compatibleNodes: [ksampler] })
|
||||
expect(withCompat.filter((i) => i.separator).length).toBe(4)
|
||||
})
|
||||
|
||||
it('shows search results when query is non-empty', () => {
|
||||
const { items, handlers } = build({
|
||||
query: 'ksampler',
|
||||
searchResults: [ksampler]
|
||||
})
|
||||
const labels = items.map(labelOf)
|
||||
expect(labels).not.toContain('contextMenu.Comfy Nodes')
|
||||
expect(labels).toContain('KSampler')
|
||||
|
||||
items.find((i) => i.label === 'KSampler')?.command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(ksampler)
|
||||
})
|
||||
|
||||
it('shows a disabled no-results row when query has no matches', () => {
|
||||
const { items } = build({ query: 'nonexistent', searchResults: [] })
|
||||
const noResults = items.find((i) => i.label === 'g.noResults')
|
||||
expect(noResults).toMatchObject({ disabled: true })
|
||||
})
|
||||
})
|
||||
208
src/components/searchbox/linkReleaseMenuModel.ts
Normal file
208
src/components/searchbox/linkReleaseMenuModel.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
declare module 'primevue/menuitem' {
|
||||
interface MenuItem {
|
||||
isHeader?: boolean
|
||||
isSearch?: boolean
|
||||
isGroupLabel?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinkReleaseMenuHandlers {
|
||||
selectNode: (nodeDef: ComfyNodeDefImpl) => void
|
||||
addReroute: () => void
|
||||
}
|
||||
|
||||
export interface LinkReleaseMenuModelOptions {
|
||||
context: LinkReleaseContext
|
||||
/** All nodes compatible with the slot type, for grouping into source buckets. */
|
||||
compatibleNodes: ComfyNodeDefImpl[]
|
||||
/** Quick-add node suggestions for the released slot type. */
|
||||
defaultNodeDefs: ComfyNodeDefImpl[]
|
||||
/** Current search field value. */
|
||||
query: string
|
||||
/** Slot-type-filtered search results when query is non-empty. */
|
||||
searchResults: ComfyNodeDefImpl[]
|
||||
t: (key: string) => string
|
||||
handlers: LinkReleaseMenuHandlers
|
||||
}
|
||||
|
||||
export function getLinkReleaseHeaderLabel(context: LinkReleaseContext): string {
|
||||
const { slotName, dataType } = context
|
||||
if (slotName && dataType) return `${slotName} | ${dataType}`
|
||||
return slotName || dataType
|
||||
}
|
||||
|
||||
function classifyNodes(nodes: ComfyNodeDefImpl[]): {
|
||||
comfy: ComfyNodeDefImpl[]
|
||||
extensions: ComfyNodeDefImpl[]
|
||||
partner: ComfyNodeDefImpl[]
|
||||
} {
|
||||
const comfy: ComfyNodeDefImpl[] = []
|
||||
const extensions: ComfyNodeDefImpl[] = []
|
||||
const partner: ComfyNodeDefImpl[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.api_node || node.category?.startsWith('api node')) {
|
||||
partner.push(node)
|
||||
} else if (
|
||||
node.nodeSource.type === NodeSourceType.Core ||
|
||||
node.nodeSource.type === NodeSourceType.Essentials
|
||||
) {
|
||||
comfy.push(node)
|
||||
} else {
|
||||
extensions.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return { comfy, extensions, partner }
|
||||
}
|
||||
|
||||
function toNodeGroupItem(
|
||||
label: string,
|
||||
nodes: ComfyNodeDefImpl[],
|
||||
selectNode: (nodeDef: ComfyNodeDefImpl) => void
|
||||
): MenuItem | null {
|
||||
if (!nodes.length) return null
|
||||
const sorted = [...nodes].sort((a, b) =>
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
)
|
||||
return {
|
||||
label,
|
||||
items: sorted.map((nodeDef) => ({
|
||||
label: nodeDef.display_name,
|
||||
command: () => selectNode(nodeDef)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function buildAddRerouteItem(
|
||||
t: (key: string) => string,
|
||||
handlers: LinkReleaseMenuHandlers
|
||||
): MenuItem {
|
||||
return {
|
||||
label: t('contextMenu.Add Reroute'),
|
||||
icon: 'icon-[lucide--git-fork]',
|
||||
command: handlers.addReroute
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultMenuItems(
|
||||
suggestions: ComfyNodeDefImpl[],
|
||||
compatibleNodes: ComfyNodeDefImpl[],
|
||||
t: (key: string) => string,
|
||||
handlers: LinkReleaseMenuHandlers
|
||||
): MenuItem[] {
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (suggestions.length) {
|
||||
items.push({ label: t('contextMenu.Most Relevant'), isGroupLabel: true })
|
||||
for (const nodeDef of suggestions) {
|
||||
items.push({
|
||||
label: nodeDef.display_name,
|
||||
command: () => handlers.selectNode(nodeDef)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { comfy, extensions, partner } = classifyNodes(compatibleNodes)
|
||||
const groups = [
|
||||
toNodeGroupItem(t('contextMenu.Comfy Nodes'), comfy, handlers.selectNode),
|
||||
toNodeGroupItem(
|
||||
t('contextMenu.Extensions'),
|
||||
extensions,
|
||||
handlers.selectNode
|
||||
),
|
||||
toNodeGroupItem(
|
||||
t('contextMenu.Partner Nodes'),
|
||||
partner,
|
||||
handlers.selectNode
|
||||
)
|
||||
].filter((g): g is MenuItem => g !== null)
|
||||
|
||||
if (groups.length) {
|
||||
items.push({ separator: true }, ...groups)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function buildSearchResultItems(
|
||||
searchResults: ComfyNodeDefImpl[],
|
||||
noResultsLabel: string,
|
||||
selectNode: (nodeDef: ComfyNodeDefImpl) => void
|
||||
): MenuItem[] {
|
||||
if (!searchResults.length) {
|
||||
return [{ label: noResultsLabel, disabled: true }]
|
||||
}
|
||||
return searchResults.map((nodeDef) => ({
|
||||
label: nodeDef.display_name,
|
||||
command: () => selectNode(nodeDef)
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildLinkReleaseMenuItems({
|
||||
context,
|
||||
compatibleNodes,
|
||||
defaultNodeDefs,
|
||||
query,
|
||||
searchResults,
|
||||
t,
|
||||
handlers
|
||||
}: LinkReleaseMenuModelOptions): MenuItem[] {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
const rerouteDef = defaultNodeDefs.find((d) => d.name === 'Reroute')
|
||||
const suggestions = defaultNodeDefs.filter((d) => d.name !== 'Reroute')
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: getLinkReleaseHeaderLabel(context),
|
||||
isHeader: true,
|
||||
disabled: true
|
||||
},
|
||||
{ separator: true },
|
||||
{ isSearch: true },
|
||||
{ separator: true }
|
||||
]
|
||||
|
||||
if (trimmedQuery) {
|
||||
items.push(
|
||||
...buildSearchResultItems(
|
||||
searchResults,
|
||||
t('g.noResults'),
|
||||
handlers.selectNode
|
||||
)
|
||||
)
|
||||
} else {
|
||||
items.push(
|
||||
...buildDefaultMenuItems(suggestions, compatibleNodes, t, handlers)
|
||||
)
|
||||
}
|
||||
|
||||
items.push({ separator: true })
|
||||
if (rerouteDef) {
|
||||
items.push({
|
||||
label: rerouteDef.display_name,
|
||||
command: () => handlers.selectNode(rerouteDef)
|
||||
})
|
||||
}
|
||||
items.push(buildAddRerouteItem(t, handlers))
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -170,6 +170,7 @@ export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
|
||||
export {
|
||||
findUsedSubgraphIds,
|
||||
getDirectSubgraphIds,
|
||||
isNodeSlot,
|
||||
isSubgraphInput,
|
||||
isSubgraphOutput
|
||||
} from './subgraph/subgraphUtils'
|
||||
|
||||
@@ -593,6 +593,11 @@
|
||||
"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",
|
||||
"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