mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-02 12:11:58 +00:00
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>
This commit is contained in:
@@ -1,37 +1,22 @@
|
||||
import { computed, onUnmounted, ref, toValue } from 'vue'
|
||||
import { onScopeDispose, ref, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
|
||||
export function useNodePointerInteractions(
|
||||
nodeDataMaybe: MaybeRefOrGetter<VueNodeData | null>,
|
||||
onNodeSelect: (event: PointerEvent, nodeData: VueNodeData) => void
|
||||
nodeIdRef: MaybeRefOrGetter<string>
|
||||
) {
|
||||
const nodeData = computed(() => {
|
||||
const value = toValue(nodeDataMaybe)
|
||||
if (!value) {
|
||||
console.warn(
|
||||
'useNodePointerInteractions: nodeDataMaybe resolved to null/undefined'
|
||||
)
|
||||
return null
|
||||
}
|
||||
return value
|
||||
})
|
||||
|
||||
// Avoid potential null access during component initialization
|
||||
const nodeIdComputed = computed(() => nodeData.value?.id ?? '')
|
||||
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeIdComputed)
|
||||
const { startDrag, endDrag, handleDrag } = useNodeDrag()
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
|
||||
useCanvasInteractions()
|
||||
const { toggleNodeSelectionAfterPointerUp, ensureNodeSelectedForShiftDrag } =
|
||||
const { handleNodeSelect, toggleNodeSelectionAfterPointerUp } =
|
||||
useNodeEventHandlers()
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
@@ -41,33 +26,15 @@ export function useNodePointerInteractions(
|
||||
return true
|
||||
}
|
||||
|
||||
// Drag state for styling
|
||||
const isDragging = ref(false)
|
||||
const isPointerDown = ref(false)
|
||||
const wasSelectedAtPointerDown = ref(false) // Track if node was selected when pointer down occurred
|
||||
const dragStyle = computed(() => {
|
||||
if (nodeData.value?.flags?.pinned) {
|
||||
return { cursor: 'default' }
|
||||
}
|
||||
return { cursor: isDragging.value ? 'grabbing' : 'grab' }
|
||||
})
|
||||
const startPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const DRAG_THRESHOLD = 3 // pixels
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!nodeData.value) {
|
||||
console.warn(
|
||||
'LGraphNode: nodeData is null/undefined in handlePointerDown'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
function onPointerdown(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
|
||||
// Only start drag on left-click (button 0)
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
if (event.button !== 0) return
|
||||
|
||||
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
@@ -75,69 +42,67 @@ export function useNodePointerInteractions(
|
||||
return
|
||||
}
|
||||
|
||||
// Track if node was selected before this pointer down
|
||||
// IMPORTANT: Read from actual LGraphNode, not nodeData, to get correct state
|
||||
const lgNode = nodeManager.value?.getNode(nodeData.value.id)
|
||||
wasSelectedAtPointerDown.value = lgNode?.selected ?? false
|
||||
|
||||
onNodeSelect(event, nodeData.value)
|
||||
|
||||
if (nodeData.value.flags?.pinned) {
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
if (!nodeId) {
|
||||
console.warn(
|
||||
'LGraphNode: nodeData is null/undefined in handlePointerDown'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Record position for drag threshold calculation
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
isPointerDown.value = true
|
||||
// IMPORTANT: Read from actual LGraphNode to get correct state
|
||||
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't start drag yet - wait for pointer move to exceed threshold
|
||||
startDrag(event)
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
|
||||
startDrag(event, nodeId)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
function onPointermove(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
|
||||
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
|
||||
return
|
||||
}
|
||||
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
|
||||
const lmbDown = event.buttons & 1
|
||||
if (lmbDown && multiSelect && !layoutStore.isDraggingVueNodes.value) {
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
handleNodeSelect(event, nodeId)
|
||||
startDrag(event, nodeId)
|
||||
return
|
||||
}
|
||||
// Check if we should start dragging (pointer moved beyond threshold)
|
||||
if (isPointerDown.value && !isDragging.value) {
|
||||
if (lmbDown && !layoutStore.isDraggingVueNodes.value) {
|
||||
const dx = event.clientX - startPosition.value.x
|
||||
const dy = event.clientY - startPosition.value.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance > DRAG_THRESHOLD && nodeData.value) {
|
||||
// Start drag
|
||||
isDragging.value = true
|
||||
if (distance > DRAG_THRESHOLD) {
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
ensureNodeSelectedForShiftDrag(
|
||||
event,
|
||||
nodeData.value,
|
||||
wasSelectedAtPointerDown.value
|
||||
)
|
||||
handleNodeSelect(event, nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragging.value) {
|
||||
void handleDrag(event)
|
||||
if (layoutStore.isDraggingVueNodes.value) {
|
||||
handleDrag(event, nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized cleanup function for drag state
|
||||
* Ensures consistent cleanup across all drag termination scenarios
|
||||
*/
|
||||
const cleanupDragState = () => {
|
||||
isDragging.value = false
|
||||
isPointerDown.value = false
|
||||
wasSelectedAtPointerDown.value = false
|
||||
function cleanupDragState() {
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely ends drag operation with proper error handling
|
||||
* @param event - PointerEvent to end the drag with
|
||||
*/
|
||||
const safeDragEnd = async (event: PointerEvent): Promise<void> => {
|
||||
function safeDragEnd(event: PointerEvent) {
|
||||
try {
|
||||
await endDrag(event)
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
endDrag(event, nodeId)
|
||||
} catch (error) {
|
||||
console.error('Error during endDrag:', error)
|
||||
} finally {
|
||||
@@ -145,61 +110,39 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common drag termination handler with fallback cleanup
|
||||
*/
|
||||
const handleDragTermination = (event: PointerEvent, errorContext: string) => {
|
||||
safeDragEnd(event).catch((error) => {
|
||||
console.error(`Failed to complete ${errorContext}:`, error)
|
||||
cleanupDragState() // Fallback cleanup
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
function onPointerup(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
|
||||
const wasDragging = isDragging.value
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
const canHandlePointer = shouldHandleNodePointerEvents.value
|
||||
|
||||
if (wasDragging) {
|
||||
handleDragTermination(event, 'drag end')
|
||||
} else {
|
||||
// Clean up pointer state even if not dragging
|
||||
isPointerDown.value = false
|
||||
const wasSelected = wasSelectedAtPointerDown.value
|
||||
wasSelectedAtPointerDown.value = false
|
||||
|
||||
if (nodeData.value && canHandlePointer) {
|
||||
toggleNodeSelectionAfterPointerUp(nodeData.value.id, {
|
||||
wasSelectedAtPointerDown: wasSelected,
|
||||
multiSelect
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
|
||||
const canHandlePointer = shouldHandleNodePointerEvents.value
|
||||
if (!canHandlePointer) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
const wasDragging = layoutStore.isDraggingVueNodes.value
|
||||
|
||||
if (wasDragging) {
|
||||
safeDragEnd(event)
|
||||
return
|
||||
}
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
|
||||
const nodeId = toValue(nodeIdRef)
|
||||
if (nodeId) {
|
||||
toggleNodeSelectionAfterPointerUp(nodeId, multiSelect)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pointer cancellation events (e.g., touch cancelled by browser)
|
||||
* Ensures drag state is properly cleaned up when pointer interaction is interrupted
|
||||
*/
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (!isDragging.value) return
|
||||
handleDragTermination(event, 'drag cancellation')
|
||||
function onPointercancel(event: PointerEvent) {
|
||||
if (!layoutStore.isDraggingVueNodes.value) return
|
||||
safeDragEnd(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles right-click during drag operations
|
||||
* Cancels the current drag to prevent context menu from appearing while dragging
|
||||
*/
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
function onContextmenu(event: MouseEvent) {
|
||||
if (!layoutStore.isDraggingVueNodes.value) return
|
||||
|
||||
event.preventDefault()
|
||||
// Simply cleanup state without calling endDrag to avoid synthetic event creation
|
||||
@@ -207,22 +150,19 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
|
||||
// Cleanup on unmount to prevent resource leaks
|
||||
onUnmounted(() => {
|
||||
if (!isDragging.value) return
|
||||
onScopeDispose(() => {
|
||||
cleanupDragState()
|
||||
})
|
||||
|
||||
const pointerHandlers = {
|
||||
onPointerdown: handlePointerDown,
|
||||
onPointermove: handlePointerMove,
|
||||
onPointerup: handlePointerUp,
|
||||
onPointercancel: handlePointerCancel,
|
||||
onContextmenu: handleContextMenu
|
||||
}
|
||||
onPointerdown,
|
||||
onPointermove,
|
||||
onPointerup,
|
||||
onPointercancel,
|
||||
onContextmenu
|
||||
} as const
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
dragStyle,
|
||||
pointerHandlers
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user