Drag Multiple Vue Nodes (#5459)

* feat: enhance dragging functionality to support multiple selected nodes

* feat: enhance node selection handling to support drag state detection

* feat: enhance node selection handling to support drag state detection

* fix: update event trigger from pointer down to pointer up in LGraphNode tests
This commit is contained in:
Johnpaul Chiwetelu
2025-09-12 01:25:23 +01:00
committed by snomiao
parent e4e10fceeb
commit 8d100de223
5 changed files with 79 additions and 22 deletions

View File

@@ -168,13 +168,18 @@ interface LGraphNodeProps {
const props = defineProps<LGraphNodeProps>()
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 = () => {

View File

@@ -26,7 +26,11 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
* 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<NodeManager | null>) {
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<NodeManager | null>) {
// 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<NodeManager | null>) {
// 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<NodeManager | null>) {
metaKey: event.metaKey,
bubbles: true
})
handleNodeSelect(syntheticEvent, nodeData)
handleNodeSelect(syntheticEvent, nodeData, false)
}
// Set drag data for potential drop operations

View File

@@ -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<string, Point> | 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

View File

@@ -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)
})
})

View File

@@ -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()