diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 54632c087..2b01e3260 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -168,13 +168,18 @@ interface LGraphNodeProps { const props = defineProps() const emit = defineEmits<{ - 'node-click': [event: PointerEvent, nodeData: VueNodeData] + 'node-click': [ + event: PointerEvent, + nodeData: VueNodeData, + wasDragging: boolean + ] 'slot-click': [ event: PointerEvent, nodeData: VueNodeData, slotIndex: number, isInput: boolean ] + dragStart: [event: DragEvent, nodeData: VueNodeData] 'update:collapsed': [nodeId: string, collapsed: boolean] 'update:title': [nodeId: string, newTitle: string] }>() @@ -231,6 +236,10 @@ const isDragging = ref(false) const dragStyle = computed(() => ({ cursor: isDragging.value ? 'grabbing' : 'grab' })) +const lastY = ref(0) +const lastX = ref(0) +// Treat tiny pointer jitter as a click, not a drag +const DRAG_THRESHOLD_PX = 4 // Track collapsed state const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false) @@ -276,9 +285,8 @@ const handlePointerDown = (event: PointerEvent) => { // Start drag using layout system isDragging.value = true startDrag(event) - - // Emit node-click for selection handling in GraphCanvas - emit('node-click', event, props.nodeData) + lastY.value = event.clientY + lastX.value = event.clientX } const handlePointerMove = (event: PointerEvent) => { @@ -292,6 +300,11 @@ const handlePointerUp = (event: PointerEvent) => { isDragging.value = false void endDrag(event) } + // Emit node-click for selection handling in GraphCanvas + const dx = event.clientX - lastX.value + const dy = event.clientY - lastY.value + const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX + emit('node-click', event, props.nodeData, wasDragging) } const handleCollapse = () => { diff --git a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts index 5b354bf25..8cd7e2e5a 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts @@ -26,7 +26,11 @@ export function useNodeEventHandlers(nodeManager: Ref) { * Handle node selection events * Supports single selection and multi-select with Ctrl/Cmd */ - const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => { + const handleNodeSelect = ( + event: PointerEvent, + nodeData: VueNodeData, + wasDragging: boolean + ) => { if (!canvasStore.canvas || !nodeManager.value) return const node = nodeManager.value.getNode(nodeData.id) @@ -42,9 +46,12 @@ export function useNodeEventHandlers(nodeManager: Ref) { canvasStore.canvas.select(node) } } else { + // If it wasn't a drag: single-select the node + if (!wasDragging) { + canvasStore.canvas.deselectAll() + canvasStore.canvas.select(node) + } // Regular click -> single select - canvasStore.canvas.deselectAll() - canvasStore.canvas.select(node) } // Bring node to front when clicked (similar to LiteGraph behavior) @@ -107,7 +114,7 @@ export function useNodeEventHandlers(nodeManager: Ref) { // TODO: add custom double-click behavior here // For now, ensure node is selected if (!node.selected) { - handleNodeSelect(event, nodeData) + handleNodeSelect(event, nodeData, false) } } @@ -126,7 +133,7 @@ export function useNodeEventHandlers(nodeManager: Ref) { // Select the node if not already selected if (!node.selected) { - handleNodeSelect(event, nodeData) + handleNodeSelect(event, nodeData, false) } // Let LiteGraph handle the context menu @@ -151,7 +158,7 @@ export function useNodeEventHandlers(nodeManager: Ref) { metaKey: event.metaKey, bubbles: true }) - handleNodeSelect(syntheticEvent, nodeData) + handleNodeSelect(syntheticEvent, nodeData, false) } // Set drag data for potential drop operations diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts index 407a14243..995d83d6f 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts @@ -6,6 +6,7 @@ */ import { computed, inject } from 'vue' +import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { LayoutSource, type Point } from '@/renderer/core/layout/types' @@ -54,6 +55,9 @@ export function useNodeLayout(nodeId: string) { let isDragging = false let dragStartPos: Point | null = null let dragStartMouse: Point | null = null + let otherSelectedNodesStartPositions: Map | null = null + + const selectedNodeIds = inject(SelectedNodeIdsKey, null) /** * Start dragging the node @@ -65,6 +69,24 @@ export function useNodeLayout(nodeId: string) { dragStartPos = { ...position.value } dragStartMouse = { x: event.clientX, y: event.clientY } + // capture the starting positions of all other selected nodes + if (selectedNodeIds?.value?.has(nodeId) && selectedNodeIds.value.size > 1) { + otherSelectedNodesStartPositions = new Map() + + // Iterate through all selected node IDs + for (const id of selectedNodeIds.value) { + // Skip the current node being dragged + if (id === nodeId) continue + + const nodeLayout = layoutStore.getNodeLayoutRef(id).value + if (nodeLayout) { + otherSelectedNodesStartPositions.set(id, { ...nodeLayout.position }) + } + } + } else { + otherSelectedNodesStartPositions = null + } + // Set mutation source mutations.setSource(LayoutSource.Vue) @@ -95,7 +117,7 @@ export function useNodeLayout(nodeId: string) { y: canvasWithDelta.y - canvasOrigin.y } - // Calculate new position + // Calculate new position for the current node const newPosition = { x: dragStartPos.x + canvasDelta.x, y: dragStartPos.y + canvasDelta.y @@ -103,6 +125,20 @@ export function useNodeLayout(nodeId: string) { // Apply mutation through the layout system mutations.moveNode(nodeId, newPosition) + + // If we're dragging multiple selected nodes, move them all together + if ( + otherSelectedNodesStartPositions && + otherSelectedNodesStartPositions.size > 0 + ) { + for (const [otherNodeId, startPos] of otherSelectedNodesStartPositions) { + const newOtherPosition = { + x: startPos.x + canvasDelta.x, + y: startPos.y + canvasDelta.y + } + mutations.moveNode(otherNodeId, newOtherPosition) + } + } } /** @@ -114,6 +150,7 @@ export function useNodeLayout(nodeId: string) { isDragging = false dragStartPos = null dragStartMouse = null + otherSelectedNodesStartPositions = null // Release pointer const target = event.target as HTMLElement 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 7acebce00..861a301ab 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts @@ -103,13 +103,13 @@ describe('LGraphNode', () => { expect(wrapper.classes()).toContain('animate-pulse') }) - it('should emit node-click event on pointer down', async () => { + it('should emit node-click event on pointer up', async () => { const wrapper = mountLGraphNode({ nodeData: mockNodeData }) - await wrapper.trigger('pointerdown') + await wrapper.trigger('pointerup') expect(wrapper.emitted('node-click')).toHaveLength(1) - expect(wrapper.emitted('node-click')?.[0]).toHaveLength(2) + expect(wrapper.emitted('node-click')?.[0]).toHaveLength(3) expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData) }) }) 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 51a0ae235..6d33ad9b7 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts @@ -110,7 +110,7 @@ describe('useNodeEventHandlers', () => { metaKey: false }) - handleNodeSelect(event, testNodeData) + handleNodeSelect(event, testNodeData, false) expect(mockCanvas.deselectAll).toHaveBeenCalledOnce() expect(mockCanvas.select).toHaveBeenCalledWith(mockNode) @@ -130,7 +130,7 @@ describe('useNodeEventHandlers', () => { metaKey: false }) - handleNodeSelect(ctrlClickEvent, testNodeData) + handleNodeSelect(ctrlClickEvent, testNodeData, false) expect(mockCanvas.deselectAll).not.toHaveBeenCalled() expect(mockCanvas.select).toHaveBeenCalledWith(mockNode) @@ -149,7 +149,7 @@ describe('useNodeEventHandlers', () => { metaKey: false }) - handleNodeSelect(ctrlClickEvent, testNodeData) + handleNodeSelect(ctrlClickEvent, testNodeData, false) expect(mockCanvas.deselect).toHaveBeenCalledWith(mockNode) expect(mockCanvas.select).not.toHaveBeenCalled() @@ -167,7 +167,7 @@ describe('useNodeEventHandlers', () => { metaKey: true }) - handleNodeSelect(metaClickEvent, testNodeData) + handleNodeSelect(metaClickEvent, testNodeData, false) expect(mockCanvas.select).toHaveBeenCalledWith(mockNode) expect(mockCanvas.deselectAll).not.toHaveBeenCalled() @@ -180,7 +180,7 @@ describe('useNodeEventHandlers', () => { mockNode.flags.pinned = false const event = new PointerEvent('pointerdown') - handleNodeSelect(event, testNodeData) + handleNodeSelect(event, testNodeData, false) expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( 'node-1' @@ -194,7 +194,7 @@ describe('useNodeEventHandlers', () => { mockNode.flags.pinned = true const event = new PointerEvent('pointerdown') - handleNodeSelect(event, testNodeData) + handleNodeSelect(event, testNodeData, false) expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled() }) @@ -207,7 +207,7 @@ describe('useNodeEventHandlers', () => { const event = new PointerEvent('pointerdown') expect(() => { - handleNodeSelect(event, testNodeData) + handleNodeSelect(event, testNodeData, false) }).not.toThrow() expect(mockCanvas.select).not.toHaveBeenCalled() @@ -227,7 +227,7 @@ describe('useNodeEventHandlers', () => { } as any expect(() => { - handleNodeSelect(event, nodeData) + handleNodeSelect(event, nodeData, false) }).not.toThrow() expect(mockCanvas.select).not.toHaveBeenCalled()