Compare commits

...

1 Commits

Author SHA1 Message Date
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
7 changed files with 749 additions and 59 deletions

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

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

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

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

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

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