Compare commits

...

3 Commits

Author SHA1 Message Date
Glary-Bot
71ae5020bf fix: openAtEvent falls back to showing search box when popover unset
Defends against the (rare) case where the menu callback fires before
NodeSearchBoxPopover has mounted and registered its ref. Previously a
silent no-op; now sets visible=true to ensure the search UI appears.
2026-05-12 05:28:43 +00:00
Glary-Bot
f44f93907a fix: forward right-click event so node lands at click position
Addresses review feedback:

- Add openAtEvent() action to searchBoxStore that calls
  popoverRef.showSearchBox(event), preserving the originating
  CanvasPointerEvent so getNewNodeLocation uses canvasX/canvasY
  instead of falling back to canvas center.
- Plumb the originating event from the menu callback via
  previousMenu.getFirstEvent() in useCanvasSearchBoxMenu.
- Make the prototype patch idempotent via a wrapper symbol marker
  so repeated invocations (HMR, remount) do not stack wrappers.
- Behavioral tests cover: event forwarding, fallback when no
  event, idempotency, and flag-off no-op.
2026-05-12 05:20:12 +00:00
Glary-Bot
ce1a44848d feat: experimental flag to replace canvas Add Node menu with search box
Behind 'Comfy.NodeSearchBox.ReplaceCanvasMenu' (default off), the right-click
canvas menu's 'Add Node' entry opens the V2 search box instead of the
LiteGraph category submenu. The V2 search box already exposes subgraph
blueprints, partner nodes, core nodes, and extensions, giving feature
parity with the left-panel node library.
2026-05-12 04:57:46 +00:00
8 changed files with 308 additions and 0 deletions

View File

@@ -146,6 +146,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useCanvasSearchBoxMenu } from '@/composables/useCanvasSearchBoxMenu'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
@@ -459,6 +460,7 @@ useLitegraphSettings()
useNodeBadge()
useGlobalLitegraph()
useCanvasSearchBoxMenu()
useContextMenuTranslation()
useCopy()
usePaste()

View File

@@ -0,0 +1,134 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasSearchBoxMenu } from '@/composables/useCanvasSearchBoxMenu'
import type {
ContextMenu,
IContextMenuValue
} from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { createMockCanvas } from '@/utils/__tests__/litegraphTestUtils'
describe('useCanvasSearchBoxMenu', () => {
let originalGetCanvasMenuOptions: typeof LGraphCanvas.prototype.getCanvasMenuOptions
let mockCanvas: LGraphCanvas
beforeEach(() => {
setActivePinia(createPinia())
originalGetCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items: (IContextMenuValue<string> | null)[] = [
{
content: 'Add Node',
has_submenu: true,
callback: LGraphCanvas.onMenuAdd
},
{ content: 'Add Group', callback: vi.fn() }
]
return items as (IContextMenuValue | null)[]
}
mockCanvas = createMockCanvas({
constructor: { prototype: LGraphCanvas.prototype } as typeof LGraphCanvas
} as Partial<LGraphCanvas>)
})
afterEach(() => {
LGraphCanvas.prototype.getCanvasMenuOptions = originalGetCanvasMenuOptions
vi.restoreAllMocks()
})
function invokeAddNodeCallback(
addNode: IContextMenuValue,
previousMenu?: Partial<ContextMenu<unknown>>
) {
void addNode.callback?.call(
addNode as never,
undefined,
undefined,
undefined as never,
previousMenu as ContextMenu<unknown> | undefined
)
}
it('leaves the default Add Node entry untouched when the setting is off', () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation((id) =>
id === 'Comfy.NodeSearchBox.ReplaceCanvasMenu' ? false : undefined
)
useCanvasSearchBoxMenu()
const items = LGraphCanvas.prototype.getCanvasMenuOptions.call(mockCanvas)
const addNode = items.find((i) => i?.content === 'Add Node')
expect(addNode?.callback).toBe(LGraphCanvas.onMenuAdd)
expect(addNode?.has_submenu).toBe(true)
})
it('forwards the original right-click event to the search box so the node lands at the click position', () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation((id) =>
id === 'Comfy.NodeSearchBox.ReplaceCanvasMenu' ? true : undefined
)
const openAtEvent = vi.spyOn(useSearchBoxStore(), 'openAtEvent')
const toggleVisible = vi.spyOn(useSearchBoxStore(), 'toggleVisible')
const triggerEvent = {
canvasX: 123,
canvasY: 456
} as unknown as CanvasPointerEvent
const previousMenu = {
getFirstEvent: () => triggerEvent
} as unknown as Partial<ContextMenu<unknown>>
useCanvasSearchBoxMenu()
const items = LGraphCanvas.prototype.getCanvasMenuOptions.call(mockCanvas)
const addNode = items.find((i) => i?.content === 'Add Node')
expect(addNode).toBeTruthy()
expect(addNode?.has_submenu).toBe(false)
invokeAddNodeCallback(addNode!, previousMenu)
expect(openAtEvent).toHaveBeenCalledTimes(1)
expect(openAtEvent).toHaveBeenCalledWith(triggerEvent)
expect(toggleVisible).not.toHaveBeenCalled()
})
it('falls back to toggleVisible when no originating event is available', () => {
vi.spyOn(useSettingStore(), 'get').mockReturnValue(true)
const openAtEvent = vi.spyOn(useSearchBoxStore(), 'openAtEvent')
const toggleVisible = vi.spyOn(useSearchBoxStore(), 'toggleVisible')
useCanvasSearchBoxMenu()
const items = LGraphCanvas.prototype.getCanvasMenuOptions.call(mockCanvas)
const addNode = items.find((i) => i?.content === 'Add Node')
invokeAddNodeCallback(addNode!, undefined)
expect(openAtEvent).not.toHaveBeenCalled()
expect(toggleVisible).toHaveBeenCalledTimes(1)
})
it('preserves other canvas menu entries', () => {
vi.spyOn(useSettingStore(), 'get').mockReturnValue(true)
useCanvasSearchBoxMenu()
const items = LGraphCanvas.prototype.getCanvasMenuOptions.call(mockCanvas)
const contents = items.map((i) => i?.content)
expect(contents).toEqual(['Add Node', 'Add Group'])
})
it('is idempotent across repeated invocations (HMR, remount)', () => {
useCanvasSearchBoxMenu()
const firstPatch = LGraphCanvas.prototype.getCanvasMenuOptions
useCanvasSearchBoxMenu()
useCanvasSearchBoxMenu()
expect(LGraphCanvas.prototype.getCanvasMenuOptions).toBe(firstPatch)
})
})

View File

@@ -0,0 +1,105 @@
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type {
ContextMenu,
IContextMenuValue
} from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
const REPLACE_SETTING_ID = 'Comfy.NodeSearchBox.ReplaceCanvasMenu'
const WRAPPER_MARK = Symbol('useCanvasSearchBoxMenu.wrapper')
/**
* When the experimental "replace canvas menu" setting is enabled, the
* right-click canvas menu's "Add Node" entry opens the Vue node search box
* (which already includes blueprints, partner nodes, core nodes, and
* extensions) instead of the legacy LiteGraph category submenu.
*
* The replacement is identified by callback identity against
* {@link LGraphCanvas.onMenuAdd} so it remains stable across the translation
* wrapper installed by {@link useContextMenuTranslation}. The original
* right-click event is forwarded via {@link ContextMenu.getFirstEvent} so the
* resulting node lands at the click position instead of canvas center.
*
* Installation is idempotent: repeated calls (e.g. HMR remounts) do not stack
* wrappers because the wrapper is tagged and detected on re-entry.
*/
export const useCanvasSearchBoxMenu = () => {
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
const previousGetCanvasMenuOptions =
LGraphCanvas.prototype.getCanvasMenuOptions
if (isOurWrapper(previousGetCanvasMenuOptions)) return
const wrapped: typeof previousGetCanvasMenuOptions = function (
this: LGraphCanvas,
...args
) {
const items = previousGetCanvasMenuOptions.apply(this, args)
const settingStore = useSettingStore()
if (!settingStore.get(REPLACE_SETTING_ID)) return items
return items.map((item) =>
isLegacyAddNode(item) ? buildSearchBoxAddNode(item) : item
)
}
markAsOurWrapper(wrapped)
LGraphCanvas.prototype.getCanvasMenuOptions = wrapped
legacyMenuCompat.registerWrapper(
'getCanvasMenuOptions',
wrapped,
previousGetCanvasMenuOptions,
LGraphCanvas.prototype
)
}
function isOurWrapper(fn: unknown): boolean {
return !!fn && (fn as { [WRAPPER_MARK]?: true })[WRAPPER_MARK] === true
}
function markAsOurWrapper(fn: object) {
Object.defineProperty(fn, WRAPPER_MARK, {
value: true,
enumerable: false,
configurable: false,
writable: false
})
}
function isLegacyAddNode(
item: IContextMenuValue | null
): item is IContextMenuValue {
return (
!!item &&
typeof item === 'object' &&
item.callback === LGraphCanvas.onMenuAdd
)
}
function buildSearchBoxAddNode(original: IContextMenuValue): IContextMenuValue {
return {
...original,
has_submenu: false,
submenu: undefined,
callback: (
_value?: unknown,
_options?: unknown,
_event?: MouseEvent,
previousMenu?: ContextMenu<unknown>
) => {
const triggerEvent = previousMenu?.getFirstEvent() as
| CanvasPointerEvent
| undefined
const store = useSearchBoxStore()
if (triggerEvent) {
store.openAtEvent(triggerEvent)
} else {
store.toggleVisible()
}
}
}
}

View File

@@ -328,6 +328,10 @@
"name": "Show node frequency in search results",
"tooltip": "Only applies to v1 (legacy)"
},
"Comfy_NodeSearchBox_ReplaceCanvasMenu": {
"name": "Replace canvas right-click \"Add Node\" with search box",
"tooltip": "When enabled, the right-click canvas menu opens the node search box instead of the LiteGraph category submenu. The search box includes blueprints, partner nodes, core nodes, and extensions."
},
"Comfy_NodeSuggestions_number": {
"name": "Number of nodes suggestions",
"tooltip": "Only for litegraph searchbox/context menu"

View File

@@ -38,6 +38,17 @@ export const CORE_SETTINGS: SettingParams[] = [
options: ['default', 'v1 (legacy)', 'litegraph (legacy)'],
defaultValue: 'default'
},
{
id: 'Comfy.NodeSearchBox.ReplaceCanvasMenu',
category: ['Comfy', 'Node Search Box', 'ReplaceCanvasMenu'],
experimental: true,
name: 'Replace canvas right-click "Add Node" with search box',
tooltip:
'When enabled, the right-click canvas menu opens the node search box instead of the LiteGraph category submenu. The search box includes blueprints, partner nodes, core nodes, and extensions.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.46.0'
},
{
id: 'Comfy.LinkRelease.Action',
category: ['LiteGraph', 'LinkRelease', 'Action'],

View File

@@ -344,6 +344,7 @@ const zSettings = z.object({
'Comfy.NodeSearchBoxImpl.ShowCategory': z.boolean(),
'Comfy.NodeSearchBoxImpl.ShowIdName': z.boolean(),
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency': z.boolean(),
'Comfy.NodeSearchBox.ReplaceCanvasMenu': z.boolean(),
'Comfy.NodeSuggestions.number': z.number(),
'Comfy.Node.BypassAllLinksOnDelete': z.boolean(),
'Comfy.Node.Opacity': z.number(),

View File

@@ -3,6 +3,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type NodeSearchBoxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import type { useSettingStore } from '@/platform/settings/settingStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
@@ -135,4 +136,43 @@ describe('useSearchBoxStore', () => {
expect(store.visible).toBe(false)
})
})
describe('openAtEvent', () => {
const event = {
canvasX: 123,
canvasY: 456
} as unknown as CanvasPointerEvent
it('forwards the event to the popover when one is registered', () => {
vi.mocked(mockSettingStore.get).mockReturnValue('default')
const store = useSearchBoxStore()
const mockPopover = createMockPopover()
store.setPopoverRef(mockPopover)
store.openAtEvent(event)
expect(vi.mocked(mockPopover.showSearchBox)).toHaveBeenCalledWith(event)
expect(store.visible).toBe(false)
})
it('falls back to showing the new search box when no popover is registered', () => {
vi.mocked(mockSettingStore.get).mockReturnValue('default')
const store = useSearchBoxStore()
store.setPopoverRef(null)
store.openAtEvent(event)
expect(store.visible).toBe(true)
})
it('does nothing when the legacy litegraph search box is selected and no popover is registered', () => {
vi.mocked(mockSettingStore.get).mockReturnValue('litegraph (legacy)')
const store = useSearchBoxStore()
store.setPopoverRef(null)
store.openAtEvent(event)
expect(store.visible).toBe(false)
})
})
})

View File

@@ -45,11 +45,22 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
)
}
function openAtEvent(event: CanvasPointerEvent) {
if (popoverRef.value) {
popoverRef.value.showSearchBox(event)
return
}
if (newSearchBoxEnabled.value) {
visible.value = true
}
}
return {
useSearchBoxV2,
newSearchBoxEnabled,
setPopoverRef,
toggleVisible,
openAtEvent,
visible
}
})