mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 17:37:46 +00:00
V2 Node Search (+ hidden Node Library changes) (#8987)
## Summary Redesigned node search with categories ## Changes - **What**: Adds a v2 search component, leaving the existing implementation untouched - It also brings onboard the incomplete node library & preview changes, disabled and behind a hidden setting - **Breaking**: Changes the 'default' value of the node search setting to v2, adding v1 (legacy) as an option ## Screenshots (if applicable) https://github.com/user-attachments/assets/2ab797df-58f0-48e8-8b20-2a1809e3735f ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8987-V2-Node-Search-hidden-Node-Library-changes-30c6d73d36508160902bcb92553f147c) by [Unito](https://www.unito.io) --------- Co-authored-by: Yourz <crazilou@vip.qq.com> Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
342
src/composables/node/useNodeDragToCanvas.test.ts
Normal file
342
src/composables/node/useNodeDragToCanvas.test.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'
|
||||
|
||||
const { mockAddNodeOnGraph, mockConvertEventToCanvasOffset, mockCanvas } =
|
||||
vi.hoisted(() => {
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
return {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
},
|
||||
convertEventToCanvasOffset: mockConvertEventToCanvasOffset
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
canvas: mockCanvas
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
addNodeOnGraph: mockAddNodeOnGraph
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useNodeDragToCanvas', () => {
|
||||
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
|
||||
|
||||
const mockNodeDef = {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node'
|
||||
} as ComfyNodeDefImpl
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
|
||||
const module = await import('./useNodeDragToCanvas')
|
||||
useNodeDragToCanvas = module.useNodeDragToCanvas
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const { cleanupGlobalListeners } = useNodeDragToCanvas()
|
||||
cleanupGlobalListeners()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('startDrag', () => {
|
||||
it('should set isDragging to true and store the node definition', () => {
|
||||
const { isDragging, draggedNode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(draggedNode.value).toBeNull()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(isDragging.value).toBe(true)
|
||||
expect(draggedNode.value).toBe(mockNodeDef)
|
||||
})
|
||||
|
||||
it('should set dragMode to click by default', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
|
||||
it('should set dragMode to native when specified', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
expect(dragMode.value).toBe('native')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelDrag', () => {
|
||||
it('should reset isDragging and draggedNode', () => {
|
||||
const { isDragging, draggedNode, startDrag, cancelDrag } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
expect(isDragging.value).toBe(true)
|
||||
|
||||
cancelDrag()
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(draggedNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should reset dragMode to click', () => {
|
||||
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
expect(dragMode.value).toBe('native')
|
||||
|
||||
cancelDrag()
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupGlobalListeners', () => {
|
||||
it('should add event listeners to document', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointermove',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'keydown',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should only setup listeners once', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
const callCount = addEventListenerSpy.mock.calls.length
|
||||
|
||||
setupGlobalListeners()
|
||||
|
||||
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursorPosition', () => {
|
||||
it('should update on pointermove', () => {
|
||||
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
|
||||
const pointerEvent = new PointerEvent('pointermove', {
|
||||
clientX: 100,
|
||||
clientY: 200
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('endDrag behavior', () => {
|
||||
it('should add node when pointer is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
pos: [150, 150]
|
||||
})
|
||||
})
|
||||
|
||||
it('should not add node when pointer is outside canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 600,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should cancel drag on Escape key', () => {
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(isDragging.value).toBe(true)
|
||||
|
||||
const keyEvent = new KeyboardEvent('keydown', { key: 'Escape' })
|
||||
document.dispatchEvent(keyEvent)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not cancel drag on other keys', () => {
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
|
||||
document.dispatchEvent(keyEvent)
|
||||
|
||||
expect(isDragging.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should not add node on pointerup when in native drag mode', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleNativeDrop', () => {
|
||||
it('should add node when drop position is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
pos: [200, 200]
|
||||
})
|
||||
})
|
||||
|
||||
it('should not add node when drop position is outside canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(600, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not add node when dragMode is click', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'click')
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset drag state after drop', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging, dragMode } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
})
|
||||
116
src/composables/node/useNodeDragToCanvas.ts
Normal file
116
src/composables/node/useNodeDragToCanvas.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
type DragMode = 'click' | 'native'
|
||||
|
||||
const isDragging = ref(false)
|
||||
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
|
||||
const cursorPosition = ref({ x: 0, y: 0 })
|
||||
const dragMode = ref<DragMode>('click')
|
||||
let listenersSetup = false
|
||||
|
||||
function updatePosition(e: PointerEvent) {
|
||||
cursorPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
if (!draggedNode.value) return false
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) return false
|
||||
|
||||
const canvasElement = canvas.canvas as HTMLCanvasElement
|
||||
const rect = canvasElement.getBoundingClientRect()
|
||||
const isOverCanvas =
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
|
||||
if (isOverCanvas) {
|
||||
const pos = canvas.convertEventToCanvasOffset({
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const litegraphService = useLitegraphService()
|
||||
litegraphService.addNodeOnGraph(draggedNode.value, { pos })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function endDrag(e: PointerEvent) {
|
||||
if (!isDragging.value || !draggedNode.value) return
|
||||
if (dragMode.value !== 'click') return
|
||||
|
||||
try {
|
||||
addNodeAtPosition(e.clientX, e.clientY)
|
||||
} finally {
|
||||
cancelDrag()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancelDrag()
|
||||
}
|
||||
|
||||
function setupGlobalListeners() {
|
||||
if (listenersSetup) return
|
||||
listenersSetup = true
|
||||
|
||||
document.addEventListener('pointermove', updatePosition)
|
||||
document.addEventListener('pointerup', endDrag, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
|
||||
function cleanupGlobalListeners() {
|
||||
if (!listenersSetup) return
|
||||
listenersSetup = false
|
||||
|
||||
document.removeEventListener('pointermove', updatePosition)
|
||||
document.removeEventListener('pointerup', endDrag, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
|
||||
if (isDragging.value && dragMode.value === 'click') {
|
||||
cancelDrag()
|
||||
}
|
||||
}
|
||||
|
||||
export function useNodeDragToCanvas() {
|
||||
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
|
||||
isDragging.value = true
|
||||
draggedNode.value = nodeDef
|
||||
dragMode.value = mode
|
||||
}
|
||||
|
||||
function handleNativeDrop(clientX: number, clientY: number) {
|
||||
if (dragMode.value !== 'native') return
|
||||
try {
|
||||
addNodeAtPosition(clientX, clientY)
|
||||
} finally {
|
||||
cancelDrag()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
startDrag,
|
||||
cancelDrag,
|
||||
handleNativeDrop,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
}
|
||||
}
|
||||
179
src/composables/node/useNodePreviewAndDrag.test.ts
Normal file
179
src/composables/node/useNodePreviewAndDrag.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import { useNodePreviewAndDrag } from './useNodePreviewAndDrag'
|
||||
|
||||
const mockStartDrag = vi.fn()
|
||||
const mockHandleNativeDrop = vi.fn()
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn().mockReturnValue('left')
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useNodePreviewAndDrag', () => {
|
||||
const mockNodeDef = {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node'
|
||||
} as ComfyNodeDefImpl
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with correct default values', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
expect(result.isHovered.value).toBe(false)
|
||||
expect(result.isDragging.value).toBe(false)
|
||||
expect(result.showPreview.value).toBe(false)
|
||||
expect(result.previewRef.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should compute showPreview based on hover and drag state', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
result.isHovered.value = true
|
||||
expect(result.showPreview.value).toBe(true)
|
||||
|
||||
result.isDragging.value = true
|
||||
expect(result.showPreview.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleMouseEnter', () => {
|
||||
it('should set isHovered to true when nodeDef exists', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
const mockElement = document.createElement('div')
|
||||
vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({
|
||||
top: 100,
|
||||
left: 50,
|
||||
right: 150,
|
||||
bottom: 200,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 50,
|
||||
y: 100,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
const mockEvent = { currentTarget: mockElement } as unknown as MouseEvent
|
||||
result.handleMouseEnter(mockEvent)
|
||||
|
||||
expect(result.isHovered.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should not set isHovered when nodeDef is undefined', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(undefined)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
const mockElement = document.createElement('div')
|
||||
const mockEvent = { currentTarget: mockElement } as unknown as MouseEvent
|
||||
result.handleMouseEnter(mockEvent)
|
||||
|
||||
expect(result.isHovered.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleMouseLeave', () => {
|
||||
it('should set isHovered to false', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
result.isHovered.value = true
|
||||
result.handleMouseLeave()
|
||||
|
||||
expect(result.isHovered.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDragStart', () => {
|
||||
it('should call startDrag with native mode when nodeDef exists', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
const mockDataTransfer = {
|
||||
effectAllowed: '',
|
||||
setData: vi.fn(),
|
||||
setDragImage: vi.fn()
|
||||
}
|
||||
const mockEvent = {
|
||||
dataTransfer: mockDataTransfer
|
||||
} as unknown as DragEvent
|
||||
|
||||
result.handleDragStart(mockEvent)
|
||||
|
||||
expect(result.isDragging.value).toBe(true)
|
||||
expect(result.isHovered.value).toBe(false)
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, 'native')
|
||||
expect(mockDataTransfer.effectAllowed).toBe('copy')
|
||||
expect(mockDataTransfer.setData).toHaveBeenCalledWith(
|
||||
'application/x-comfy-node',
|
||||
'TestNode'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not start drag when nodeDef is undefined', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(undefined)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
const mockEvent = { dataTransfer: null } as DragEvent
|
||||
result.handleDragStart(mockEvent)
|
||||
|
||||
expect(result.isDragging.value).toBe(false)
|
||||
expect(mockStartDrag).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDragEnd', () => {
|
||||
it('should call handleNativeDrop with drop coordinates', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
result.isDragging.value = true
|
||||
|
||||
const mockEvent = {
|
||||
clientX: 100,
|
||||
clientY: 200
|
||||
} as unknown as DragEvent
|
||||
|
||||
result.handleDragEnd(mockEvent)
|
||||
|
||||
expect(result.isDragging.value).toBe(false)
|
||||
expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
|
||||
})
|
||||
|
||||
it('should always call handleNativeDrop regardless of dropEffect', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
result.isDragging.value = true
|
||||
|
||||
const mockEvent = {
|
||||
dataTransfer: { dropEffect: 'none' },
|
||||
clientX: 300,
|
||||
clientY: 400
|
||||
} as unknown as DragEvent
|
||||
|
||||
result.handleDragEnd(mockEvent)
|
||||
|
||||
expect(result.isDragging.value).toBe(false)
|
||||
expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400)
|
||||
})
|
||||
})
|
||||
})
|
||||
149
src/composables/node/useNodePreviewAndDrag.ts
Normal file
149
src/composables/node/useNodePreviewAndDrag.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const PREVIEW_WIDTH = 200
|
||||
const PREVIEW_MARGIN = 16
|
||||
|
||||
export function useNodePreviewAndDrag(
|
||||
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
|
||||
options?: { panelRef?: Ref<HTMLElement | null> }
|
||||
) {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const showPreview = computed(() => isHovered.value && !isDragging.value)
|
||||
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000
|
||||
})
|
||||
|
||||
function calculatePreviewPosition(rect: DOMRect) {
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth
|
||||
|
||||
let left: number
|
||||
if (sidebarLocation.value === 'left') {
|
||||
left = rect.right + PREVIEW_MARGIN
|
||||
if (left + PREVIEW_WIDTH > viewportWidth) {
|
||||
left = rect.left - PREVIEW_MARGIN - PREVIEW_WIDTH
|
||||
}
|
||||
} else {
|
||||
left = rect.left - PREVIEW_MARGIN - PREVIEW_WIDTH
|
||||
if (left < 0) {
|
||||
left = rect.right + PREVIEW_MARGIN
|
||||
}
|
||||
}
|
||||
|
||||
return { left, viewportHeight }
|
||||
}
|
||||
|
||||
function handleMouseEnter(e: MouseEvent) {
|
||||
if (!nodeDef.value) return
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
const horizontalRect =
|
||||
options?.panelRef?.value?.getBoundingClientRect() ?? rect
|
||||
const { left, viewportHeight } = calculatePreviewPosition(horizontalRect)
|
||||
|
||||
let top = rect.top
|
||||
|
||||
nodePreviewStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000,
|
||||
opacity: 0
|
||||
}
|
||||
isHovered.value = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (previewRef.value) {
|
||||
const previewRect = previewRef.value.getBoundingClientRect()
|
||||
const previewHeight = previewRect.height
|
||||
|
||||
const mouseY = rect.top + rect.height / 2
|
||||
top = mouseY - previewHeight * 0.3
|
||||
|
||||
const minTop = PREVIEW_MARGIN
|
||||
const maxTop = viewportHeight - previewHeight - PREVIEW_MARGIN
|
||||
top = Math.max(minTop, Math.min(top, maxTop))
|
||||
|
||||
nodePreviewStyle.value = {
|
||||
...nodePreviewStyle.value,
|
||||
top: `${top}px`,
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
isHovered.value = false
|
||||
}
|
||||
|
||||
function createEmptyDragImage(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.style.position = 'absolute'
|
||||
el.style.left = '-9999px'
|
||||
el.style.top = '-9999px'
|
||||
el.style.width = '1px'
|
||||
el.style.height = '1px'
|
||||
return el
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent) {
|
||||
if (!nodeDef.value) return
|
||||
|
||||
isDragging.value = true
|
||||
isHovered.value = false
|
||||
|
||||
startDrag(nodeDef.value, 'native')
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
e.dataTransfer.setData('application/x-comfy-node', nodeDef.value.name)
|
||||
|
||||
const dragImage = createEmptyDragImage()
|
||||
document.body.appendChild(dragImage)
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(dragImage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(e: DragEvent) {
|
||||
isDragging.value = false
|
||||
handleNativeDrop(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
return {
|
||||
previewRef,
|
||||
isHovered,
|
||||
isDragging,
|
||||
showPreview,
|
||||
nodePreviewStyle,
|
||||
sidebarLocation,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleDragStart,
|
||||
handleDragEnd
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import {
|
||||
evaluateNodeDefPricing,
|
||||
formatCreditsListValue,
|
||||
formatCreditsRangeValue,
|
||||
formatCreditsValue,
|
||||
formatPricingResult,
|
||||
useNodePricing
|
||||
} from '@/composables/node/useNodePricing'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { PriceBadge } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -673,18 +680,19 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for PricingResult missing type field', async () => {
|
||||
it('should handle legacy format without type field', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestMissingTypeNode',
|
||||
// Returns object without type field
|
||||
'TestLegacyFormatNode',
|
||||
// Returns object without type field (legacy format)
|
||||
priceBadge('{"usd":0.05}')
|
||||
)
|
||||
|
||||
getNodeDisplayPrice(node)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('')
|
||||
// Legacy format {usd: number} is supported
|
||||
expect(price).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should return empty string for non-object result', async () => {
|
||||
@@ -855,3 +863,362 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// formatPricingResult Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('formatPricingResult', () => {
|
||||
describe('type: usd', () => {
|
||||
it('should format usd result', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: 0.05 })
|
||||
expect(result).toBe('10.6 credits/Run')
|
||||
})
|
||||
|
||||
it('should return valueOnly format', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'usd', usd: 0.05 },
|
||||
{ valueOnly: true }
|
||||
)
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should handle approximate prefix in valueOnly mode', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'usd', usd: 0.05, format: { approximate: true } },
|
||||
{ valueOnly: true }
|
||||
)
|
||||
expect(result).toBe('~10.6')
|
||||
})
|
||||
|
||||
it('should return empty for null usd', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: null as never })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type: range_usd', () => {
|
||||
it('should format range result', () => {
|
||||
const result = formatPricingResult({
|
||||
type: 'range_usd',
|
||||
min_usd: 0.05,
|
||||
max_usd: 0.1
|
||||
})
|
||||
expect(result).toBe('10.6-21.1 credits/Run')
|
||||
})
|
||||
|
||||
it('should return valueOnly format', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.1 },
|
||||
{ valueOnly: true }
|
||||
)
|
||||
expect(result).toBe('10.6-21.1')
|
||||
})
|
||||
|
||||
it('should collapse range when min equals max', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.05 },
|
||||
{ valueOnly: true }
|
||||
)
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type: list_usd', () => {
|
||||
it('should format list result', () => {
|
||||
const result = formatPricingResult({
|
||||
type: 'list_usd',
|
||||
usd: [0.05, 0.1, 0.15]
|
||||
})
|
||||
expect(result).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/)
|
||||
})
|
||||
|
||||
it('should return valueOnly format', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'list_usd', usd: [0.05, 0.1] },
|
||||
{ valueOnly: true }
|
||||
)
|
||||
expect(result).toBe('10.6/21.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type: text', () => {
|
||||
it('should return text as-is', () => {
|
||||
const result = formatPricingResult({ type: 'text', text: 'Free' })
|
||||
expect(result).toBe('Free')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy format', () => {
|
||||
it('should handle {usd: number} without type field', () => {
|
||||
const result = formatPricingResult({ usd: 0.05 })
|
||||
expect(result).toBe('10.6 credits/Run')
|
||||
})
|
||||
|
||||
it('should return valueOnly for legacy format', () => {
|
||||
const result = formatPricingResult({ usd: 0.05 }, { valueOnly: true })
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('should return empty for invalid type', () => {
|
||||
const result = formatPricingResult({ type: 'invalid' })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty for null', () => {
|
||||
const result = formatPricingResult(null)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty for undefined', () => {
|
||||
const result = formatPricingResult(undefined)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// formatCreditsValue / Range / List Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('formatCreditsValue', () => {
|
||||
it('should format USD to credits', () => {
|
||||
expect(formatCreditsValue(0.05)).toBe('10.6')
|
||||
expect(formatCreditsValue(1.0)).toBe('211')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCreditsRangeValue', () => {
|
||||
it('should format min-max range', () => {
|
||||
expect(formatCreditsRangeValue(0.05, 0.1)).toBe('10.6-21.1')
|
||||
})
|
||||
|
||||
it('should collapse when min equals max', () => {
|
||||
expect(formatCreditsRangeValue(0.05, 0.05)).toBe('10.6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCreditsListValue', () => {
|
||||
it('should join values with separator', () => {
|
||||
expect(formatCreditsListValue([0.05, 0.1])).toBe('10.6/21.1')
|
||||
})
|
||||
|
||||
it('should use custom separator', () => {
|
||||
expect(formatCreditsListValue([0.05, 0.1], ' | ')).toBe('10.6 | 21.1')
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// evaluateNodeDefPricing Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('evaluateNodeDefPricing', () => {
|
||||
const createMockNodeDef = (
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef =>
|
||||
({
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: '',
|
||||
category: 'test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
python_module: 'test',
|
||||
...overrides
|
||||
}) as ComfyNodeDef
|
||||
|
||||
it('should return empty for node without price_badge', async () => {
|
||||
const nodeDef = createMockNodeDef()
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should evaluate static expression', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'StaticPriceNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd":0.05}',
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should use default value from input spec', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'DefaultValueNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd": widgets.count * 0.01}',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'count', type: 'INT' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {
|
||||
count: ['INT', { default: 10 }]
|
||||
}
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
|
||||
})
|
||||
|
||||
it('should use first option for COMBO without default', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'ComboNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'mode', type: 'COMBO' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {
|
||||
mode: [['standard', 'pro'], {}]
|
||||
}
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
// First option is "standard", not "pro", so should be 0.05 USD
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should use "original" as fallback for dynamic COMBO without input', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'DynamicComboNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: `(
|
||||
$prices := {"original": 0.05, "720p": 0.03};
|
||||
{"type":"usd","usd": $lookup($prices, widgets.resolution)}
|
||||
)`,
|
||||
depends_on: {
|
||||
widgets: [{ name: 'resolution', type: 'COMBO' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {
|
||||
// resolution widget is NOT in inputs (dynamically created)
|
||||
}
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
// Fallback to "original" = 0.05 USD
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should handle dynamic combo with options array', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'DynamicOptionsNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd": widgets.model = "model_a" ? 0.05 : 0.10}',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'model', type: 'COMFY_DYNAMICCOMBO_V3' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {
|
||||
model: [
|
||||
'COMFY_DYNAMICCOMBO_V3',
|
||||
{ options: [{ key: 'model_a' }, { key: 'model_b' }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
// First option key is "model_a" = 0.05 USD
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should assume inputs disconnected in preview', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'InputConnectedNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
|
||||
depends_on: {
|
||||
widgets: [],
|
||||
inputs: ['image'],
|
||||
input_groups: []
|
||||
}
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
// In preview, inputs are assumed disconnected
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should assume inputGroups have 0 count in preview', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'InputGroupNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd": 0.05 + inputGroups.videos * 0.02}',
|
||||
depends_on: {
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
input_groups: ['videos']
|
||||
}
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
// 0.05 + 0 * 0.02 = 0.05 USD
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should return empty on JSONata error', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'ErrorNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '$lookup(undefined, "key")',
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle range_usd result', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'RangeNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"range_usd","min_usd":0.05,"max_usd":0.10}',
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
expect(result).toBe('10.6-21.1')
|
||||
})
|
||||
|
||||
it('should handle approximate format in valueOnly mode', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'ApproximateNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd":0.05,"format":{"approximate":true}}',
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
expect(result).toBe('~10.6')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// - async evaluation + cache,
|
||||
// - reactive tick to update UI when async evaluation completes.
|
||||
|
||||
import { memoize } from 'es-toolkit'
|
||||
import { readonly, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
|
||||
@@ -47,7 +48,7 @@ type CreditFormatOptions = {
|
||||
separator?: string
|
||||
}
|
||||
|
||||
const formatCreditsValue = (usd: number): string => {
|
||||
export const formatCreditsValue = (usd: number): string => {
|
||||
// Use raw credits value (before rounding) to determine decimal display
|
||||
const rawCredits = usd * CREDITS_PER_USD
|
||||
return formatCredits({
|
||||
@@ -68,23 +69,37 @@ const formatCreditsLabel = (
|
||||
): string =>
|
||||
`${makePrefix(approximate)}${formatCreditsValue(usd)} credits${makeSuffix(suffix)}${appendNote(note)}`
|
||||
|
||||
export const formatCreditsRangeValue = (
|
||||
minUsd: number,
|
||||
maxUsd: number
|
||||
): string => {
|
||||
const min = formatCreditsValue(minUsd)
|
||||
const max = formatCreditsValue(maxUsd)
|
||||
return min === max ? min : `${min}-${max}`
|
||||
}
|
||||
|
||||
const formatCreditsRangeLabel = (
|
||||
minUsd: number,
|
||||
maxUsd: number,
|
||||
{ suffix, note, approximate }: CreditFormatOptions = {}
|
||||
): string => {
|
||||
const min = formatCreditsValue(minUsd)
|
||||
const max = formatCreditsValue(maxUsd)
|
||||
const rangeValue = min === max ? min : `${min}-${max}`
|
||||
const rangeValue = formatCreditsRangeValue(minUsd, maxUsd)
|
||||
return `${makePrefix(approximate)}${rangeValue} credits${makeSuffix(suffix)}${appendNote(note)}`
|
||||
}
|
||||
|
||||
export const formatCreditsListValue = (
|
||||
usdValues: number[],
|
||||
separator = '/'
|
||||
): string => {
|
||||
const parts = usdValues.map((value) => formatCreditsValue(value))
|
||||
return parts.join(separator)
|
||||
}
|
||||
|
||||
const formatCreditsListLabel = (
|
||||
usdValues: number[],
|
||||
{ suffix, note, approximate, separator }: CreditFormatOptions = {}
|
||||
): string => {
|
||||
const parts = usdValues.map((value) => formatCreditsValue(value))
|
||||
const value = parts.join(separator ?? '/')
|
||||
const value = formatCreditsListValue(usdValues, separator)
|
||||
return `${makePrefix(approximate)}${value} credits${makeSuffix(suffix)}${appendNote(note)}`
|
||||
}
|
||||
|
||||
@@ -130,7 +145,6 @@ type JsonataPricingRule = {
|
||||
input_groups: string[]
|
||||
}
|
||||
expr: string
|
||||
result_defaults?: CreditFormatOptions
|
||||
}
|
||||
|
||||
type CompiledJsonataPricingRule = JsonataPricingRule & {
|
||||
@@ -283,10 +297,39 @@ const buildSignature = (
|
||||
// -----------------------------
|
||||
// Result formatting
|
||||
// -----------------------------
|
||||
const formatPricingResult = (
|
||||
|
||||
type FormatPricingResultOptions = {
|
||||
/** If true, return only the value without "credits/Run" suffix */
|
||||
valueOnly?: boolean
|
||||
defaults?: CreditFormatOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a PricingResult into a display string.
|
||||
* @param result - The pricing result from JSONata evaluation
|
||||
* @param options - Formatting options
|
||||
* @returns Formatted string, e.g. "10 credits/Run" or "10" if valueOnly
|
||||
*/
|
||||
export const formatPricingResult = (
|
||||
result: unknown,
|
||||
defaults: CreditFormatOptions = {}
|
||||
options: FormatPricingResultOptions = {}
|
||||
): string => {
|
||||
const { valueOnly = false, defaults = {} } = options
|
||||
|
||||
// Handle legacy format: { usd: number } without type field
|
||||
if (
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
!('type' in result) &&
|
||||
'usd' in result
|
||||
) {
|
||||
const r = result as { usd: unknown }
|
||||
const usd = asFiniteNumber(r.usd)
|
||||
if (usd === null) return ''
|
||||
if (valueOnly) return formatCreditsValue(usd)
|
||||
return formatCreditsLabel(usd, defaults)
|
||||
}
|
||||
|
||||
if (!isPricingResult(result)) {
|
||||
if (result !== undefined && result !== null) {
|
||||
console.warn('[pricing/jsonata] invalid result format:', result)
|
||||
@@ -302,6 +345,10 @@ const formatPricingResult = (
|
||||
const usd = asFiniteNumber(result.usd)
|
||||
if (usd === null) return ''
|
||||
const fmt = { ...defaults, ...(result.format ?? {}) }
|
||||
if (valueOnly) {
|
||||
const prefix = fmt.approximate ? '~' : ''
|
||||
return `${prefix}${formatCreditsValue(usd)}`
|
||||
}
|
||||
return formatCreditsLabel(usd, fmt)
|
||||
}
|
||||
|
||||
@@ -310,6 +357,10 @@ const formatPricingResult = (
|
||||
const maxUsd = asFiniteNumber(result.max_usd)
|
||||
if (minUsd === null || maxUsd === null) return ''
|
||||
const fmt = { ...defaults, ...(result.format ?? {}) }
|
||||
if (valueOnly) {
|
||||
const prefix = fmt.approximate ? '~' : ''
|
||||
return `${prefix}${formatCreditsRangeValue(minUsd, maxUsd)}`
|
||||
}
|
||||
return formatCreditsRangeLabel(minUsd, maxUsd, fmt)
|
||||
}
|
||||
|
||||
@@ -324,6 +375,10 @@ const formatPricingResult = (
|
||||
if (usdValues.length === 0) return ''
|
||||
|
||||
const fmt = { ...defaults, ...(result.format ?? {}) }
|
||||
if (valueOnly) {
|
||||
const prefix = fmt.approximate ? '~' : ''
|
||||
return `${prefix}${formatCreditsListValue(usdValues)}`
|
||||
}
|
||||
return formatCreditsListLabel(usdValues, fmt)
|
||||
}
|
||||
|
||||
@@ -418,8 +473,6 @@ const cache = new WeakMap<LGraphNode, CacheEntry>()
|
||||
const desiredSig = new WeakMap<LGraphNode, string>()
|
||||
const inflight = new WeakMap<LGraphNode, InflightEntry>()
|
||||
|
||||
const DEBUG_JSONATA_PRICING = false
|
||||
|
||||
const scheduleEvaluation = (
|
||||
node: LGraphNode,
|
||||
rule: CompiledJsonataPricingRule,
|
||||
@@ -433,31 +486,17 @@ const scheduleEvaluation = (
|
||||
|
||||
if (!rule._compiled) return
|
||||
|
||||
const nodeName = getNodeConstructorData(node)?.name ?? ''
|
||||
|
||||
const promise = Promise.resolve(rule._compiled.evaluate(ctx))
|
||||
.then((res) => {
|
||||
const label = formatPricingResult(res, rule.result_defaults ?? {})
|
||||
const label = formatPricingResult(res)
|
||||
|
||||
// Ignore stale results: if the node changed while we were evaluating,
|
||||
// desiredSig will no longer match.
|
||||
if (desiredSig.get(node) !== sig) return
|
||||
|
||||
cache.set(node, { sig, label })
|
||||
|
||||
if (DEBUG_JSONATA_PRICING) {
|
||||
console.warn('[pricing/jsonata] resolved', nodeName, {
|
||||
sig,
|
||||
res,
|
||||
label
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('[pricing/jsonata] evaluation failed', nodeName, err)
|
||||
}
|
||||
|
||||
.catch(() => {
|
||||
// Cache empty to avoid retry-spam for same signature
|
||||
if (desiredSig.get(node) === sig) {
|
||||
cache.set(node, { sig, label: '' })
|
||||
@@ -497,6 +536,14 @@ const getRuleForNode = (
|
||||
return compiled ?? undefined
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Helper to get price badge from node type
|
||||
// -----------------------------
|
||||
const getNodePriceBadge = (nodeType: string): PriceBadge | undefined => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return nodeDefStore.nodeDefsByName[nodeType]?.price_badge
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Public composable API
|
||||
// -----------------------------
|
||||
@@ -550,11 +597,7 @@ export const useNodePricing = () => {
|
||||
* returns union of widget dependencies + input dependencies for a node type.
|
||||
*/
|
||||
const getRelevantWidgetNames = (nodeType: string): string[] => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return []
|
||||
|
||||
const priceBadge = nodeDef.price_badge
|
||||
const priceBadge = getNodePriceBadge(nodeType)
|
||||
if (!priceBadge) return []
|
||||
|
||||
const dependsOn = priceBadge.depends_on ?? {
|
||||
@@ -563,10 +606,9 @@ export const useNodePricing = () => {
|
||||
input_groups: []
|
||||
}
|
||||
|
||||
// Extract widget names
|
||||
const widgetNames = (dependsOn.widgets ?? []).map((w) => w.name)
|
||||
|
||||
// Keep stable output (dedupe while preserving order)
|
||||
// Dedupe while preserving order
|
||||
const out: string[] = []
|
||||
for (const n of [
|
||||
...widgetNames,
|
||||
@@ -582,11 +624,7 @@ export const useNodePricing = () => {
|
||||
* Check if a node type has dynamic pricing (depends on widgets, inputs, or input_groups).
|
||||
*/
|
||||
const hasDynamicPricing = (nodeType: string): boolean => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return false
|
||||
|
||||
const priceBadge = nodeDef.price_badge
|
||||
const priceBadge = getNodePriceBadge(nodeType)
|
||||
if (!priceBadge) return false
|
||||
|
||||
const dependsOn = priceBadge.depends_on
|
||||
@@ -603,28 +641,16 @@ export const useNodePricing = () => {
|
||||
* Get input_groups prefixes for a node type (for watching connection changes).
|
||||
*/
|
||||
const getInputGroupPrefixes = (nodeType: string): string[] => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return []
|
||||
|
||||
const priceBadge = nodeDef.price_badge
|
||||
if (!priceBadge) return []
|
||||
|
||||
return priceBadge.depends_on?.input_groups ?? []
|
||||
const priceBadge = getNodePriceBadge(nodeType)
|
||||
return priceBadge?.depends_on?.input_groups ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get regular input names for a node type (for watching connection changes).
|
||||
*/
|
||||
const getInputNames = (nodeType: string): string[] => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return []
|
||||
|
||||
const priceBadge = nodeDef.price_badge
|
||||
if (!priceBadge) return []
|
||||
|
||||
return priceBadge.depends_on?.inputs ?? []
|
||||
const priceBadge = getNodePriceBadge(nodeType)
|
||||
return priceBadge?.depends_on?.inputs ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,3 +678,97 @@ export const useNodePricing = () => {
|
||||
pricingRevision: readonly(pricingTick) // reactive invalidation signal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract default value from an input spec.
|
||||
*/
|
||||
function extractDefaultFromSpec(spec: unknown[]): unknown {
|
||||
const specOptions = spec[1] as Record<string, unknown> | undefined
|
||||
|
||||
// Check for explicit default
|
||||
if (specOptions && 'default' in specOptions) {
|
||||
return specOptions.default
|
||||
}
|
||||
// COMBO/DYNAMICCOMBO type with options array
|
||||
if (
|
||||
specOptions &&
|
||||
Array.isArray(specOptions.options) &&
|
||||
specOptions.options.length > 0
|
||||
) {
|
||||
const firstOption = specOptions.options[0]
|
||||
// Dynamic combo: options are objects with 'key' property
|
||||
if (
|
||||
typeof firstOption === 'object' &&
|
||||
firstOption !== null &&
|
||||
'key' in firstOption
|
||||
) {
|
||||
return (firstOption as { key: unknown }).key
|
||||
}
|
||||
// Standard combo: options are primitive values
|
||||
return firstOption
|
||||
}
|
||||
// COMBO type (old format): [["option1", "option2"], {...}]
|
||||
if (Array.isArray(spec[0]) && spec[0].length > 0) {
|
||||
return spec[0][0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate pricing for a node definition using default widget values.
|
||||
* Used for NodePricingBadge where no LGraphNode instance exists.
|
||||
* Results are memoized by node name since they are deterministic.
|
||||
*/
|
||||
export const evaluateNodeDefPricing = memoize(
|
||||
async (nodeDef: ComfyNodeDef): Promise<string> => {
|
||||
const priceBadge = nodeDef.price_badge
|
||||
if (!priceBadge?.expr) return ''
|
||||
|
||||
// Reuse compiled expression cache
|
||||
const rule = getCompiledRuleForNodeType(nodeDef.name, priceBadge)
|
||||
if (!rule?._compiled) return ''
|
||||
|
||||
try {
|
||||
// Merge all inputs for lookup
|
||||
const allInputs = {
|
||||
...(nodeDef.input?.required ?? {}),
|
||||
...(nodeDef.input?.optional ?? {})
|
||||
}
|
||||
|
||||
// Build widgets context using depends_on.widgets (matches buildJsonataContext)
|
||||
const widgets: Record<string, NormalizedWidgetValue> = {}
|
||||
for (const dep of priceBadge.depends_on?.widgets ?? []) {
|
||||
const spec = allInputs[dep.name]
|
||||
let rawValue: unknown = null
|
||||
if (Array.isArray(spec)) {
|
||||
rawValue = extractDefaultFromSpec(spec)
|
||||
} else if (dep.type.toUpperCase() === 'COMBO') {
|
||||
// For dynamic COMBO widgets without input spec, use a common default
|
||||
// that works with most pricing expressions (e.g., resolution selectors)
|
||||
rawValue = 'original'
|
||||
}
|
||||
widgets[dep.name] = normalizeWidgetValue(rawValue, dep.type)
|
||||
}
|
||||
|
||||
// Build inputs context: assume all inputs are disconnected in preview
|
||||
const inputs: Record<string, { connected: boolean }> = {}
|
||||
for (const name of priceBadge.depends_on?.inputs ?? []) {
|
||||
inputs[name] = { connected: false }
|
||||
}
|
||||
|
||||
// Build inputGroups context: assume 0 connected inputs in preview
|
||||
const inputGroups: Record<string, number> = {}
|
||||
for (const groupName of priceBadge.depends_on?.input_groups ?? []) {
|
||||
inputGroups[groupName] = 0
|
||||
}
|
||||
|
||||
const context: JsonataEvalContext = { widgets, inputs, inputGroups }
|
||||
const result = await rule._compiled.evaluate(context)
|
||||
return formatPricingResult(result, { valueOnly: true })
|
||||
} catch (e) {
|
||||
console.error('[evaluateNodeDefPricing] error:', e)
|
||||
return ''
|
||||
}
|
||||
},
|
||||
{ getCacheKey: (nodeDef: ComfyNodeDef) => nodeDef.name }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user