mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
Compare commits
3 Commits
v1.45.7
...
glary/blue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ae5020bf | ||
|
|
f44f93907a | ||
|
|
ce1a44848d |
@@ -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()
|
||||
|
||||
134
src/composables/useCanvasSearchBoxMenu.test.ts
Normal file
134
src/composables/useCanvasSearchBoxMenu.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
105
src/composables/useCanvasSearchBoxMenu.ts
Normal file
105
src/composables/useCanvasSearchBoxMenu.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user