diff --git a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts index 6adee1e894..05373c5223 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts @@ -15,6 +15,24 @@ import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex' +import { isLGraphNode } from '@/utils/litegraphUtil' + +/** + * Check if multiple nodes are selected + * Optimized to return early when 2+ nodes found + */ +function hasMultipleNodesSelected(selectedItems: unknown[]): boolean { + let count = 0 + for (let i = 0; i < selectedItems.length; i++) { + if (isLGraphNode(selectedItems[i])) { + count++ + if (count >= 2) { + return true + } + } + } + return false +} function useNodeEventHandlersIndividual() { const canvasStore = useCanvasStore() @@ -26,11 +44,7 @@ function useNodeEventHandlersIndividual() { * Handle node selection events * Supports single selection and multi-select with Ctrl/Cmd */ - const handleNodeSelect = ( - event: PointerEvent, - nodeData: VueNodeData, - wasDragging: boolean - ) => { + const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => { if (!shouldHandleNodePointerEvents.value) return if (!canvasStore.canvas || !nodeManager.value) return @@ -48,12 +62,14 @@ function useNodeEventHandlersIndividual() { canvasStore.canvas.select(node) } } else { - // If it wasn't a drag: single-select the node - if (!wasDragging) { + const selectedMultipleNodes = hasMultipleNodesSelected( + canvasStore.selectedItems + ) + if (!selectedMultipleNodes) { + // Single-select the node canvasStore.canvas.deselectAll() canvasStore.canvas.select(node) } - // Regular click -> single select } // Bring node to front when clicked (similar to LiteGraph behavior) @@ -122,7 +138,7 @@ function useNodeEventHandlersIndividual() { // TODO: add custom double-click behavior here // For now, ensure node is selected if (!node.selected) { - handleNodeSelect(event, nodeData, false) + handleNodeSelect(event, nodeData) } } @@ -143,7 +159,7 @@ function useNodeEventHandlersIndividual() { // Select the node if not already selected if (!node.selected) { - handleNodeSelect(event, nodeData, false) + handleNodeSelect(event, nodeData) } // Let LiteGraph handle the context menu @@ -170,7 +186,7 @@ function useNodeEventHandlersIndividual() { metaKey: event.metaKey, bubbles: true }) - handleNodeSelect(syntheticEvent, nodeData, false) + handleNodeSelect(syntheticEvent, nodeData) } // Set drag data for potential drop operations diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts index f0c046ca08..b0c4d02f29 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts @@ -67,96 +67,85 @@ const createMouseEvent = ( } describe('useNodePointerInteractions', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() + // Reset layout store state between tests + const { layoutStore } = await import( + '@/renderer/core/layout/store/layoutStore' + ) + layoutStore.isDraggingVueNodes.value = false }) it('should only start drag on left-click', async () => { const mockNodeData = createMockVueNodeData() - const mockOnPointerUp = vi.fn() + const mockOnNodeSelect = vi.fn() const { pointerHandlers } = useNodePointerInteractions( ref(mockNodeData), - mockOnPointerUp + mockOnNodeSelect ) - // Right-click should not start drag + // Right-click should not trigger selection const rightClickEvent = createPointerEvent('pointerdown', { button: 2 }) pointerHandlers.onPointerdown(rightClickEvent) - expect(mockOnPointerUp).not.toHaveBeenCalled() + expect(mockOnNodeSelect).not.toHaveBeenCalled() - // Left-click should start drag and emit callback + // Left-click should trigger selection on pointer down const leftClickEvent = createPointerEvent('pointerdown', { button: 0 }) pointerHandlers.onPointerdown(leftClickEvent) - const pointerUpEvent = createPointerEvent('pointerup') - pointerHandlers.onPointerup(pointerUpEvent) - - expect(mockOnPointerUp).toHaveBeenCalledWith( - pointerUpEvent, - mockNodeData, - false // wasDragging = false (same position) - ) + expect(mockOnNodeSelect).toHaveBeenCalledWith(leftClickEvent, mockNodeData) }) - it('should distinguish drag from click based on distance threshold', async () => { + it('should call onNodeSelect on pointer down', async () => { const mockNodeData = createMockVueNodeData() - const mockOnPointerUp = vi.fn() + const mockOnNodeSelect = vi.fn() const { pointerHandlers } = useNodePointerInteractions( ref(mockNodeData), - mockOnPointerUp - ) - - // Test drag (distance > 4px) - pointerHandlers.onPointerdown( - createPointerEvent('pointerdown', { clientX: 100, clientY: 100 }) + mockOnNodeSelect ) - const dragUpEvent = createPointerEvent('pointerup', { - clientX: 200, - clientY: 200 + // Selection should happen on pointer down + const downEvent = createPointerEvent('pointerdown', { + clientX: 100, + clientY: 100 }) - pointerHandlers.onPointerup(dragUpEvent) - - expect(mockOnPointerUp).toHaveBeenCalledWith( - dragUpEvent, - mockNodeData, - true - ) - - mockOnPointerUp.mockClear() + pointerHandlers.onPointerdown(downEvent) - // Test click (same position) - const samePos = { clientX: 100, clientY: 100 } - pointerHandlers.onPointerdown(createPointerEvent('pointerdown', samePos)) + expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData) - const clickUpEvent = createPointerEvent('pointerup', samePos) - pointerHandlers.onPointerup(clickUpEvent) + mockOnNodeSelect.mockClear() - expect(mockOnPointerUp).toHaveBeenCalledWith( - clickUpEvent, - mockNodeData, - false + // Even if we drag, selection already happened on pointer down + pointerHandlers.onPointerup( + createPointerEvent('pointerup', { clientX: 200, clientY: 200 }) ) + + // onNodeSelect should not be called again on pointer up + expect(mockOnNodeSelect).not.toHaveBeenCalled() }) it('should handle drag termination via cancel and context menu', async () => { const mockNodeData = createMockVueNodeData() - const mockOnPointerUp = vi.fn() + const mockOnNodeSelect = vi.fn() const { pointerHandlers } = useNodePointerInteractions( ref(mockNodeData), - mockOnPointerUp + mockOnNodeSelect ) - // Test pointer cancel + // Test pointer cancel - selection happens on pointer down pointerHandlers.onPointerdown(createPointerEvent('pointerdown')) + expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) + pointerHandlers.onPointercancel(createPointerEvent('pointercancel')) - // Should not emit callback on cancel - expect(mockOnPointerUp).not.toHaveBeenCalled() + // Selection should have been called on pointer down only + expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) + + mockOnNodeSelect.mockClear() // Test context menu during drag prevents default pointerHandlers.onPointerdown(createPointerEvent('pointerdown')) @@ -169,36 +158,35 @@ describe('useNodePointerInteractions', () => { expect(preventDefaultSpy).toHaveBeenCalled() }) - it('should not emit callback when nodeData becomes null', async () => { + it('should not call onNodeSelect when nodeData is null', async () => { const mockNodeData = createMockVueNodeData() - const mockOnPointerUp = vi.fn() + const mockOnNodeSelect = vi.fn() const nodeDataRef = ref(mockNodeData) const { pointerHandlers } = useNodePointerInteractions( nodeDataRef, - mockOnPointerUp + mockOnNodeSelect ) - pointerHandlers.onPointerdown(createPointerEvent('pointerdown')) - - // Clear nodeData before pointerup + // Clear nodeData before pointer down nodeDataRef.value = null + await nextTick() - pointerHandlers.onPointerup(createPointerEvent('pointerup')) + pointerHandlers.onPointerdown(createPointerEvent('pointerdown')) - expect(mockOnPointerUp).not.toHaveBeenCalled() + expect(mockOnNodeSelect).not.toHaveBeenCalled() }) it('should integrate with layout store dragging state', async () => { const mockNodeData = createMockVueNodeData() - const mockOnPointerUp = vi.fn() + const mockOnNodeSelect = vi.fn() const { layoutStore } = await import( '@/renderer/core/layout/store/layoutStore' ) const { pointerHandlers } = useNodePointerInteractions( ref(mockNodeData), - mockOnPointerUp + mockOnNodeSelect ) // Start drag @@ -211,4 +199,93 @@ describe('useNodePointerInteractions', () => { await nextTick() expect(layoutStore.isDraggingVueNodes.value).toBe(false) }) + + it('should select node on pointer down with ctrl key for multi-select', async () => { + const mockNodeData = createMockVueNodeData() + const mockOnNodeSelect = vi.fn() + + const { pointerHandlers } = useNodePointerInteractions( + ref(mockNodeData), + mockOnNodeSelect + ) + + // Pointer down with ctrl key should pass the event with ctrl key set + const ctrlDownEvent = createPointerEvent('pointerdown', { + ctrlKey: true, + clientX: 100, + clientY: 100 + }) + pointerHandlers.onPointerdown(ctrlDownEvent) + + expect(mockOnNodeSelect).toHaveBeenCalledWith(ctrlDownEvent, mockNodeData) + expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) + }) + + it('should select pinned node on pointer down but not start drag', async () => { + const mockNodeData = createMockVueNodeData({ + flags: { pinned: true } + }) + const mockOnNodeSelect = vi.fn() + const { layoutStore } = await import( + '@/renderer/core/layout/store/layoutStore' + ) + + const { pointerHandlers } = useNodePointerInteractions( + ref(mockNodeData), + mockOnNodeSelect + ) + + // Pointer down on pinned node + const downEvent = createPointerEvent('pointerdown') + pointerHandlers.onPointerdown(downEvent) + + // Should select the node + expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData) + + // But should not start dragging + expect(layoutStore.isDraggingVueNodes.value).toBe(false) + }) + + it('should select node immediately when drag starts', async () => { + const mockNodeData = createMockVueNodeData() + const mockOnNodeSelect = vi.fn() + const { layoutStore } = await import( + '@/renderer/core/layout/store/layoutStore' + ) + + const { pointerHandlers } = useNodePointerInteractions( + ref(mockNodeData), + mockOnNodeSelect + ) + + // Pointer down should select node immediately + const downEvent = createPointerEvent('pointerdown', { + clientX: 100, + clientY: 100 + }) + pointerHandlers.onPointerdown(downEvent) + + // Selection should happen on pointer down (before move) + expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData) + expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) + + // Dragging state should be active + expect(layoutStore.isDraggingVueNodes.value).toBe(true) + + // Move the pointer (start dragging) + pointerHandlers.onPointermove( + createPointerEvent('pointermove', { clientX: 150, clientY: 150 }) + ) + + // Selection should still only have been called once (on pointer down) + expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) + + // End drag + pointerHandlers.onPointerup( + createPointerEvent('pointerup', { clientX: 150, clientY: 150 }) + ) + + // Selection should still only have been called once + expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts index e00e77c24d..d3d39b3f31 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts @@ -5,16 +5,9 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout' -// Treat tiny pointer jitter as a click, not a drag -const DRAG_THRESHOLD_PX = 4 - export function useNodePointerInteractions( nodeDataMaybe: MaybeRefOrGetter, - onPointerUp: ( - event: PointerEvent, - nodeData: VueNodeData, - wasDragging: boolean - ) => void + onNodeSelect: (event: PointerEvent, nodeData: VueNodeData) => void ) { const nodeData = computed(() => { const value = toValue(nodeDataMaybe) @@ -63,8 +56,11 @@ export function useNodePointerInteractions( return } - // Don't allow dragging if node is pinned (but still record position for selection) + // Record position for drag threshold calculation startPosition.value = { x: event.clientX, y: event.clientY } + + onNodeSelect(event, nodeData.value) + if (nodeData.value.flags?.pinned) { return } @@ -122,19 +118,11 @@ export function useNodePointerInteractions( handleDragTermination(event, 'drag end') } - // Don't emit node-click when canvas is in panning mode - forward to canvas instead + // Don't handle pointer events when canvas is in panning mode - forward to canvas instead if (!shouldHandleNodePointerEvents.value) { forwardEventToCanvas(event) return } - - // Emit node-click for selection handling in GraphCanvas - const dx = event.clientX - startPosition.value.x - const dy = event.clientY - startPosition.value.y - const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX - - if (!nodeData?.value) return - onPointerUp(event, nodeData.value, wasDragging) } /** diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts index 4800658f88..e06d13a8d6 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts @@ -7,7 +7,6 @@ import { createI18n } from 'vue-i18n' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' -import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' const mockData = vi.hoisted(() => ({ @@ -182,18 +181,4 @@ describe('LGraphNode', () => { expect(wrapper.classes()).toContain('animate-pulse') }) - - it('should emit node-click event on pointer up', async () => { - const { handleNodeSelect } = useNodeEventHandlers() - const wrapper = mountLGraphNode({ nodeData: mockNodeData }) - - await wrapper.trigger('pointerup') - - expect(handleNodeSelect).toHaveBeenCalledOnce() - expect(handleNodeSelect).toHaveBeenCalledWith( - expect.any(PointerEvent), - mockNodeData, - expect.any(Boolean) - ) - }) }) diff --git a/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts index dd08457eb7..f19104f0fa 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts @@ -102,7 +102,7 @@ describe('useNodeEventHandlers', () => { metaKey: false }) - handleNodeSelect(event, testNodeData, false) + handleNodeSelect(event, testNodeData) expect(canvas?.deselectAll).toHaveBeenCalledOnce() expect(canvas?.select).toHaveBeenCalledWith(mockNode) @@ -122,7 +122,7 @@ describe('useNodeEventHandlers', () => { metaKey: false }) - handleNodeSelect(ctrlClickEvent, testNodeData, false) + handleNodeSelect(ctrlClickEvent, testNodeData) expect(canvas?.deselectAll).not.toHaveBeenCalled() expect(canvas?.select).toHaveBeenCalledWith(mockNode) @@ -141,7 +141,7 @@ describe('useNodeEventHandlers', () => { metaKey: false }) - handleNodeSelect(ctrlClickEvent, testNodeData, false) + handleNodeSelect(ctrlClickEvent, testNodeData) expect(canvas?.deselect).toHaveBeenCalledWith(mockNode) expect(canvas?.select).not.toHaveBeenCalled() @@ -159,7 +159,7 @@ describe('useNodeEventHandlers', () => { metaKey: true }) - handleNodeSelect(metaClickEvent, testNodeData, false) + handleNodeSelect(metaClickEvent, testNodeData) expect(canvas?.select).toHaveBeenCalledWith(mockNode) expect(canvas?.deselectAll).not.toHaveBeenCalled() @@ -171,7 +171,7 @@ describe('useNodeEventHandlers', () => { mockNode!.flags.pinned = false const event = new PointerEvent('pointerdown') - handleNodeSelect(event, testNodeData, false) + handleNodeSelect(event, testNodeData) expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( 'node-1' @@ -184,7 +184,7 @@ describe('useNodeEventHandlers', () => { mockNode!.flags.pinned = true const event = new PointerEvent('pointerdown') - handleNodeSelect(event, testNodeData, false) + handleNodeSelect(event, testNodeData) expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled() })