Files
ComfyUI_frontend/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts
Alexander Brown 9da82f47ef Feat: Alt+Drag to clone - Vue Nodes (#6789)
## Summary

Replicate the alt+drag to clone behavior present in litegraph.

## Changes

- **What**: Simplify the interaction/drag handling, now with less state!
- **What**: Alt+Click+Drag a node to clone it

## Screenshots (if applicable)



https://github.com/user-attachments/assets/469e33c2-de0c-4e64-a344-1e9d9339d528



<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6789-WIP-Alt-Drag-to-clone-Vue-Nodes-2b16d73d36508102a871ffe97ed2831f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-21 14:16:03 -08:00

304 lines
9.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, shallowRef } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
const canvasSelectedItems = vi.hoisted(() => [] as Array<{ id?: string }>)
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const canvas: Partial<LGraphCanvas> = {
select: vi.fn(),
deselect: vi.fn(),
deselectAll: vi.fn()
}
const updateSelectedItems = vi.fn()
const canvasStoreInstance = {
canvas: canvas as LGraphCanvas,
updateSelectedItems,
selectedItems: canvasSelectedItems
}
return {
useCanvasStore: vi.fn(() => canvasStoreInstance)
}
})
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
}))
}))
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => {
const setSource = vi.fn()
const bringNodeToFront = vi.fn()
return {
useLayoutMutations: vi.fn(() => ({
setSource,
bringNodeToFront
}))
}
})
vi.mock('@/composables/graph/useGraphNodeManager', () => {
const mockNode = {
id: 'node-1',
selected: false,
flags: { pinned: false }
}
const nodeManager = shallowRef({
getNode: vi.fn(() => mockNode as Partial<LGraphNode> as LGraphNode)
} as Partial<GraphNodeManager> as GraphNodeManager)
return {
useGraphNodeManager: vi.fn(() => nodeManager)
}
})
vi.mock('@/composables/graph/useVueNodeLifecycle', () => {
const nodeManager = useGraphNodeManager(undefined as unknown as LGraph)
return {
useVueNodeLifecycle: vi.fn(() => ({
nodeManager
}))
}
})
describe('useNodeEventHandlers', () => {
const { nodeManager: mockNodeManager } = useVueNodeLifecycle()
const mockNode = mockNodeManager.value!.getNode('fake_id')
const mockLayoutMutations = useLayoutMutations()
const testNodeId = 'node-1'
beforeEach(async () => {
vi.resetAllMocks()
canvasSelectedItems.length = 0
})
describe('handleNodeSelect', () => {
it('should select single node on regular click', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
const event = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: false
})
handleNodeSelect(event, testNodeId)
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce()
})
it('on pointer down with ctrl+click: selects node immediately', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = false
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: true,
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeId)
// On pointer down with multi-select: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
// Selection happens immediately so dragging includes this node
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselect).not.toHaveBeenCalled()
})
it('on pointer down with ctrl+click of selected node: brings node to front only', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = true
mockNode!.flags.pinned = false
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: true,
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeId)
// On pointer down: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
// But don't deselect yet (deferred to pointer up)
expect(canvas?.deselect).not.toHaveBeenCalled()
expect(canvas?.select).not.toHaveBeenCalled()
})
it('on pointer down with meta key (Cmd): selects node immediately', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = false
mockNode!.flags.pinned = false
const metaClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: true
})
handleNodeSelect(metaClickEvent, testNodeId)
// On pointer down with meta key: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
// Selection happens immediately
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.deselect).not.toHaveBeenCalled()
})
it('on pointer down with shift key: selects node immediately', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = false
mockNode!.flags.pinned = false
const shiftClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
shiftKey: true
})
handleNodeSelect(shiftClickEvent, testNodeId)
// On pointer down with shift: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
// Selection happens immediately for shift-click as well
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.deselect).not.toHaveBeenCalled()
})
it('keeps existing multi-selection when dragging selected node without modifiers', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' }, { id: 'node-2' })
const event = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: false
})
handleNodeSelect(event, testNodeId)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).not.toHaveBeenCalled()
})
it('should bring node to front when not pinned', () => {
const { handleNodeSelect } = useNodeEventHandlers()
mockNode!.flags.pinned = false
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeId)
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
})
it('should not bring pinned node to front', () => {
const { handleNodeSelect } = useNodeEventHandlers()
mockNode!.flags.pinned = true
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeId)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
})
})
describe('toggleNodeSelectionAfterPointerUp', () => {
it('on pointer up with multi-select: deselects node that was selected at pointer down', () => {
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
mockNode!.selected = true
toggleNodeSelectionAfterPointerUp('node-1', true)
expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce()
})
it('on pointer up with multi-select and node not previously selected: no-op', () => {
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
mockNode!.selected = true
toggleNodeSelectionAfterPointerUp('node-1', true)
expect(canvas?.select).not.toHaveBeenCalled()
expect(updateSelectedItems).toHaveBeenCalled()
})
it('on pointer up without multi-select: collapses multi-selection to clicked node', () => {
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' }, { id: 'node-2' })
toggleNodeSelectionAfterPointerUp('node-1', false)
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce()
})
it('on pointer up without multi-select: keeps single selection intact', () => {
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' })
toggleNodeSelectionAfterPointerUp('node-1', false)
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalled()
})
})
})