mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-06 08:00:05 +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:
@@ -13,6 +13,7 @@ import type {
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
id: string
|
||||
id: NodeId
|
||||
title: string
|
||||
type: string
|
||||
mode: number
|
||||
|
||||
@@ -1771,18 +1771,19 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
static onMenuNodeClone(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const nodes = canvas.selectedItems.size ? canvas.selectedItems : [node]
|
||||
const nodes = canvas.selectedItems.size ? [...canvas.selectedItems] : [node]
|
||||
if (nodes.length) LGraphCanvas.cloneNodes(nodes)
|
||||
}
|
||||
|
||||
static cloneNodes(nodes: Positionable[]) {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
|
||||
// Find top-left-most boundary
|
||||
let offsetX = Infinity
|
||||
@@ -1792,11 +1793,11 @@ export class LGraphCanvas
|
||||
throw new TypeError(
|
||||
'Invalid node encountered on clone. `pos` was null.'
|
||||
)
|
||||
if (item.pos[0] < offsetX) offsetX = item.pos[0]
|
||||
if (item.pos[1] < offsetY) offsetY = item.pos[1]
|
||||
offsetX = Math.min(offsetX, item.pos[0])
|
||||
offsetY = Math.min(offsetY, item.pos[1])
|
||||
}
|
||||
|
||||
canvas._deserializeItems(canvas._serializeItems(nodes), {
|
||||
return canvas._deserializeItems(canvas._serializeItems(nodes), {
|
||||
position: [offsetX + 5, offsetY + 5]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
/**
|
||||
* Lightweight, injectable transform state used by layout-aware components.
|
||||
*
|
||||
* Consumers use this interface to convert coordinates between LiteGraph's
|
||||
* canvas space and the DOM's screen space, access the current pan/zoom
|
||||
* (camera), and perform basic viewport culling checks.
|
||||
*
|
||||
* Coordinate mapping:
|
||||
* - screen = (canvas + offset) * scale
|
||||
* - canvas = screen / scale - offset
|
||||
*
|
||||
* The full implementation and additional helpers live in
|
||||
* `useTransformState()`. This interface deliberately exposes only the
|
||||
* minimal surface needed outside that composable.
|
||||
*
|
||||
* @example
|
||||
* const state = inject(TransformStateKey)!
|
||||
* const screen = state.canvasToScreen({ x: 100, y: 50 })
|
||||
*/
|
||||
export interface TransformState
|
||||
extends Pick<
|
||||
ReturnType<typeof useTransformState>,
|
||||
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
|
||||
> {}
|
||||
|
||||
export const TransformStateKey: InjectionKey<TransformState> =
|
||||
Symbol('transformState')
|
||||
@@ -17,10 +17,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, provide } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
@@ -32,14 +31,7 @@ interface TransformPaneProps {
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const {
|
||||
camera,
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
const { camera, transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const { isLOD } = useLOD(camera)
|
||||
|
||||
@@ -48,13 +40,6 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 512
|
||||
})
|
||||
|
||||
provide(TransformStateKey, {
|
||||
camera,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
transformUpdate: []
|
||||
}>()
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
@@ -64,7 +65,7 @@ interface Camera {
|
||||
z: number // scale/zoom
|
||||
}
|
||||
|
||||
export const useTransformState = () => {
|
||||
function useTransformStateIndividual() {
|
||||
// Reactive state mirroring LiteGraph's canvas transform
|
||||
const camera = reactive<Camera>({
|
||||
x: 0,
|
||||
@@ -91,7 +92,7 @@ export const useTransformState = () => {
|
||||
*
|
||||
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
|
||||
*/
|
||||
const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||
function syncWithCanvas(canvas: LGraphCanvas) {
|
||||
if (!canvas || !canvas.ds) return
|
||||
|
||||
// Mirror LiteGraph's transform state to Vue's reactive state
|
||||
@@ -112,7 +113,7 @@ export const useTransformState = () => {
|
||||
* @param point - Point in canvas coordinate system
|
||||
* @returns Point in screen coordinate system
|
||||
*/
|
||||
const canvasToScreen = (point: Point): Point => {
|
||||
function canvasToScreen(point: Point): Point {
|
||||
return {
|
||||
x: (point.x + camera.x) * camera.z,
|
||||
y: (point.y + camera.y) * camera.z
|
||||
@@ -138,10 +139,10 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Get node's screen bounds for culling
|
||||
const getNodeScreenBounds = (
|
||||
pos: ArrayLike<number>,
|
||||
size: ArrayLike<number>
|
||||
): DOMRect => {
|
||||
function getNodeScreenBounds(
|
||||
pos: [number, number],
|
||||
size: [number, number]
|
||||
): DOMRect {
|
||||
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
|
||||
const width = size[0] * camera.z
|
||||
const height = size[1] * camera.z
|
||||
@@ -150,23 +151,23 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Helper: Calculate zoom-adjusted margin for viewport culling
|
||||
const calculateAdjustedMargin = (baseMargin: number): number => {
|
||||
function calculateAdjustedMargin(baseMargin: number): number {
|
||||
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
|
||||
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
|
||||
return baseMargin
|
||||
}
|
||||
|
||||
// Helper: Check if node is too small to be visible at current zoom
|
||||
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
|
||||
function isNodeTooSmall(nodeSize: [number, number]): boolean {
|
||||
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
|
||||
return nodeScreenSize < 4
|
||||
}
|
||||
|
||||
// Helper: Calculate expanded viewport bounds with margin
|
||||
const getExpandedViewportBounds = (
|
||||
function getExpandedViewportBounds(
|
||||
viewport: { width: number; height: number },
|
||||
margin: number
|
||||
) => {
|
||||
) {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
return {
|
||||
@@ -178,11 +179,11 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Helper: Test if node intersects with viewport bounds
|
||||
const testViewportIntersection = (
|
||||
function testViewportIntersection(
|
||||
screenPos: { x: number; y: number },
|
||||
nodeSize: ArrayLike<number>,
|
||||
nodeSize: [number, number],
|
||||
bounds: { left: number; right: number; top: number; bottom: number }
|
||||
): boolean => {
|
||||
): boolean {
|
||||
const nodeRight = screenPos.x + nodeSize[0] * camera.z
|
||||
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
|
||||
|
||||
@@ -195,12 +196,12 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Check if node is within viewport with frustum and size-based culling
|
||||
const isNodeInViewport = (
|
||||
nodePos: ArrayLike<number>,
|
||||
nodeSize: ArrayLike<number>,
|
||||
function isNodeInViewport(
|
||||
nodePos: [number, number],
|
||||
nodeSize: [number, number],
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
): boolean => {
|
||||
): boolean {
|
||||
// Early exit for tiny nodes
|
||||
if (isNodeTooSmall(nodeSize)) return false
|
||||
|
||||
@@ -212,10 +213,10 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Get viewport bounds in canvas coordinates (for spatial index queries)
|
||||
const getViewportBounds = (
|
||||
function getViewportBounds(
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
) => {
|
||||
) {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
|
||||
@@ -244,3 +245,7 @@ export const useTransformState = () => {
|
||||
getViewportBounds
|
||||
}
|
||||
}
|
||||
|
||||
export const useTransformState = createSharedComposable(
|
||||
useTransformStateIndividual
|
||||
)
|
||||
|
||||
@@ -11,9 +11,9 @@ interface SpatialBounds {
|
||||
height: number
|
||||
}
|
||||
|
||||
interface PositionedNode {
|
||||
pos: ArrayLike<number>
|
||||
size: ArrayLike<number>
|
||||
export interface PositionedNode {
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
|
||||
import type { PositionedNode } from '@/renderer/core/spatial/boundsCalculator'
|
||||
|
||||
import type {
|
||||
IMinimapDataSource,
|
||||
@@ -29,10 +30,12 @@ export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
|
||||
}
|
||||
|
||||
// Convert MinimapNodeData to the format expected by calculateNodeBounds
|
||||
const compatibleNodes = nodes.map((node) => ({
|
||||
pos: [node.x, node.y],
|
||||
size: [node.width, node.height]
|
||||
}))
|
||||
const compatibleNodes = nodes.map(
|
||||
(node): PositionedNode => ({
|
||||
pos: [node.x, node.y],
|
||||
size: [node.width, node.height]
|
||||
})
|
||||
)
|
||||
|
||||
const bounds = calculateNodeBounds(compatibleNodes)
|
||||
if (!bounds) {
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
'outline-transparent outline-2',
|
||||
borderClass,
|
||||
outlineClass,
|
||||
cursorClass,
|
||||
{
|
||||
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
||||
bypassed,
|
||||
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
|
||||
muted,
|
||||
'will-change-transform': isDragging,
|
||||
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
|
||||
},
|
||||
|
||||
@@ -39,10 +39,10 @@
|
||||
zIndex: zIndex,
|
||||
opacity: nodeOpacity,
|
||||
'--component-node-background': nodeBodyBackgroundColor
|
||||
},
|
||||
dragStyle
|
||||
}
|
||||
]"
|
||||
v-bind="pointerHandlers"
|
||||
v-bind="remainingPointerHandlers"
|
||||
@pointerdown="nodeOnPointerdown"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu="handleContextMenu"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@@ -137,24 +137,31 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, onErrorCaptured, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onErrorCaptured, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LGraphEventMode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
|
||||
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
|
||||
@@ -188,16 +195,13 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
handleNodeCollapse,
|
||||
handleNodeTitleUpdate,
|
||||
handleNodeSelect,
|
||||
handleNodeRightClick
|
||||
} = useNodeEventHandlers()
|
||||
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
|
||||
const transformState = inject(TransformStateKey)
|
||||
const transformState = useTransformState()
|
||||
if (!transformState) {
|
||||
throw new Error(
|
||||
'TransformState must be provided for node resize functionality'
|
||||
@@ -272,10 +276,24 @@ onErrorCaptured((error) => {
|
||||
})
|
||||
|
||||
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
|
||||
() => nodeData,
|
||||
handleNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
|
||||
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
|
||||
const { startDrag } = useNodeDrag()
|
||||
|
||||
async function nodeOnPointerdown(event: PointerEvent) {
|
||||
if (event.altKey && lgraphNode.value) {
|
||||
const result = LGraphCanvas.cloneNodes([lgraphNode.value])
|
||||
if (result?.created?.length) {
|
||||
const [newNode] = result.created
|
||||
startDrag(event, `${newNode.id}`)
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
await nextTick()
|
||||
bringNodeToFront(`${newNode.id}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
onPointerdown(event)
|
||||
}
|
||||
|
||||
// Handle right-click context menu
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
@@ -283,7 +301,7 @@ const handleContextMenu = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
|
||||
// First handle the standard right-click behavior (selection)
|
||||
handleNodeRightClick(event as PointerEvent, nodeData)
|
||||
handleNodeRightClick(event as PointerEvent, nodeData.id)
|
||||
|
||||
// Show the node options menu at the cursor position
|
||||
const targetElement = event.currentTarget as HTMLElement
|
||||
@@ -422,6 +440,16 @@ const outlineClass = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const cursorClass = computed(() => {
|
||||
return cn(
|
||||
nodeData.flags?.pinned
|
||||
? 'cursor-default'
|
||||
: layoutStore.isDraggingVueNodes.value
|
||||
? 'cursor-grabbing'
|
||||
: 'cursor-grab'
|
||||
)
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
handleNodeCollapse(nodeData.id, !isCollapsed.value)
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
*/
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
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 { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
|
||||
function useNodeEventHandlersIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -27,12 +27,12 @@ function useNodeEventHandlersIndividual() {
|
||||
* Handle node selection events
|
||||
* Supports single selection and multi-select with Ctrl/Cmd
|
||||
*/
|
||||
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
|
||||
function handleNodeSelect(event: PointerEvent, nodeId: NodeId) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const multiSelect = isMultiSelectKey(event)
|
||||
@@ -53,7 +53,7 @@ function useNodeEventHandlersIndividual() {
|
||||
// Bring node to front when clicked (similar to LiteGraph behavior)
|
||||
// Skip if node is pinned to avoid unwanted movement
|
||||
if (!node.flags?.pinned) {
|
||||
bringNodeToFront(nodeData.id)
|
||||
bringNodeToFront(nodeId)
|
||||
}
|
||||
|
||||
// Update canvas selection tracking
|
||||
@@ -64,7 +64,7 @@ function useNodeEventHandlersIndividual() {
|
||||
* Handle node collapse/expand state changes
|
||||
* Uses LiteGraph's native collapse method for proper state management
|
||||
*/
|
||||
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
|
||||
function handleNodeCollapse(nodeId: NodeId, collapsed: boolean) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!nodeManager.value) return
|
||||
@@ -83,7 +83,7 @@ function useNodeEventHandlersIndividual() {
|
||||
* Handle node title updates
|
||||
* Updates the title in LiteGraph for persistence across sessions
|
||||
*/
|
||||
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
|
||||
function handleNodeTitleUpdate(nodeId: NodeId, newTitle: string) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!nodeManager.value) return
|
||||
@@ -95,41 +95,16 @@ function useNodeEventHandlersIndividual() {
|
||||
node.title = newTitle
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node double-click events
|
||||
* Can be used for custom actions like opening node editor
|
||||
*/
|
||||
const handleNodeDoubleClick = (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData
|
||||
) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
// Prevent default browser behavior
|
||||
event.preventDefault()
|
||||
|
||||
// TODO: add custom double-click behavior here
|
||||
// For now, ensure node is selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node right-click context menu events
|
||||
* Integrates with LiteGraph's context menu system
|
||||
*/
|
||||
const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => {
|
||||
function handleNodeRightClick(event: PointerEvent, nodeId: NodeId) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Prevent default context menu
|
||||
@@ -137,128 +112,17 @@ function useNodeEventHandlersIndividual() {
|
||||
|
||||
// Select the node if not already selected
|
||||
if (!node.selected) {
|
||||
handleNodeSelect(event, nodeData)
|
||||
handleNodeSelect(event, nodeId)
|
||||
}
|
||||
|
||||
// Let LiteGraph handle the context menu
|
||||
// The canvas will handle showing the appropriate context menu
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node drag start events
|
||||
* Prepares node for dragging and sets appropriate visual state
|
||||
*/
|
||||
const handleNodeDragStart = (event: DragEvent, nodeData: VueNodeData) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
// Ensure node is selected before dragging
|
||||
if (!node.selected) {
|
||||
// Create a synthetic pointer event for selection
|
||||
const syntheticEvent = new PointerEvent('pointerdown', {
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
bubbles: true
|
||||
})
|
||||
handleNodeSelect(syntheticEvent, nodeData)
|
||||
}
|
||||
|
||||
// Set drag data for potential drop operations
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/comfy-node-id', nodeData.id)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch select multiple nodes
|
||||
* Useful for selection toolbox or area selection
|
||||
*/
|
||||
const selectNodes = (nodeIds: string[], addToSelection = false) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
if (!addToSelection) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
}
|
||||
|
||||
nodeIds.forEach((nodeId) => {
|
||||
const node = nodeManager.value?.getNode(nodeId)
|
||||
if (node && canvasStore.canvas) {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
})
|
||||
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure node is selected for shift-drag operations
|
||||
* Handles special logic for promoting a node to selection when shift-dragging
|
||||
* @param event - The pointer event (for multi-select key detection)
|
||||
* @param nodeData - The node data for the node being dragged
|
||||
* @param wasSelectedAtPointerDown - Whether the node was selected when pointer-down occurred
|
||||
*/
|
||||
const ensureNodeSelectedForShiftDrag = (
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
wasSelectedAtPointerDown: boolean
|
||||
) => {
|
||||
if (wasSelectedAtPointerDown) return
|
||||
|
||||
const multiSelectKeyPressed = isMultiSelectKey(event)
|
||||
if (!multiSelectKeyPressed) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node || node.selected) return
|
||||
|
||||
const selectionCount = canvasStore.selectedItems.length
|
||||
const addToSelection = selectionCount > 0
|
||||
selectNodes([nodeData.id], addToSelection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect specific nodes
|
||||
*/
|
||||
const deselectNodes = (nodeIds: string[]) => {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
|
||||
nodeIds.forEach((nodeId) => {
|
||||
const node = nodeManager.value?.getNode(nodeId)
|
||||
if (node && canvasStore.canvas) {
|
||||
canvasStore.canvas.deselect(node)
|
||||
}
|
||||
})
|
||||
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
const deselectNode = (nodeId: string) => {
|
||||
const node = nodeManager.value?.getNode(nodeId)
|
||||
if (node) {
|
||||
canvasStore.canvas?.deselect(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNodeSelectionAfterPointerUp = (
|
||||
nodeId: string,
|
||||
{
|
||||
wasSelectedAtPointerDown,
|
||||
multiSelect
|
||||
}: {
|
||||
wasSelectedAtPointerDown: boolean
|
||||
multiSelect: boolean
|
||||
}
|
||||
) => {
|
||||
function toggleNodeSelectionAfterPointerUp(
|
||||
nodeId: NodeId,
|
||||
multiSelect: boolean
|
||||
) {
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
|
||||
if (!canvasStore.canvas || !nodeManager.value) return
|
||||
@@ -267,22 +131,19 @@ function useNodeEventHandlersIndividual() {
|
||||
if (!node) return
|
||||
|
||||
if (!multiSelect) {
|
||||
const multipleSelected = canvasStore.selectedItems.length > 1
|
||||
if (multipleSelected && wasSelectedAtPointerDown) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
return
|
||||
}
|
||||
|
||||
if (wasSelectedAtPointerDown) {
|
||||
if (node.selected) {
|
||||
canvasStore.canvas.deselect(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
} else {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
|
||||
// No action needed when the node was not previously selected since the pointer-down
|
||||
// handler already added it to the selection.
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -290,15 +151,9 @@ function useNodeEventHandlersIndividual() {
|
||||
handleNodeSelect,
|
||||
handleNodeCollapse,
|
||||
handleNodeTitleUpdate,
|
||||
handleNodeDoubleClick,
|
||||
handleNodeRightClick,
|
||||
handleNodeDragStart,
|
||||
|
||||
// Batch operations
|
||||
selectNodes,
|
||||
deselectNodes,
|
||||
deselectNode,
|
||||
ensureNodeSelectedForShiftDrag,
|
||||
toggleNodeSelectionAfterPointerUp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
|
||||
const forwardEventToCanvasMock = vi.fn()
|
||||
const deselectNodeMock = vi.fn()
|
||||
const selectNodesMock = vi.fn()
|
||||
const toggleNodeSelectionAfterPointerUpMock = vi.fn()
|
||||
const ensureNodeSelectedForShiftDragMock = vi.fn()
|
||||
const selectedItemsState: { items: Array<{ id?: string }> } = { items: [] }
|
||||
|
||||
// Mock the dependencies
|
||||
@@ -20,19 +20,18 @@ vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
startDrag: vi.fn(),
|
||||
endDrag: vi.fn().mockResolvedValue(undefined),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
isDraggingVueNodes: ref(false)
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeDrag', () => {
|
||||
const startDrag = vi.fn()
|
||||
const handleDrag = vi.fn()
|
||||
const endDrag = vi.fn()
|
||||
return {
|
||||
useNodeDrag: () => ({
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
@@ -44,14 +43,23 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
|
||||
() => ({
|
||||
useNodeEventHandlers: () => ({
|
||||
deselectNode: deselectNodeMock,
|
||||
selectNodes: selectNodesMock,
|
||||
toggleNodeSelectionAfterPointerUp: toggleNodeSelectionAfterPointerUpMock,
|
||||
ensureNodeSelectedForShiftDrag: ensureNodeSelectedForShiftDragMock
|
||||
})
|
||||
})
|
||||
() => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
const deselectNode = vi.fn()
|
||||
const selectNodes = vi.fn()
|
||||
const toggleNodeSelectionAfterPointerUp = vi.fn()
|
||||
const ensureNodeSelectedForShiftDrag = vi.fn()
|
||||
|
||||
return {
|
||||
useNodeEventHandlers: () => ({
|
||||
handleNodeSelect,
|
||||
deselectNode,
|
||||
selectNodes,
|
||||
toggleNodeSelectionAfterPointerUp,
|
||||
ensureNodeSelectedForShiftDrag
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
|
||||
@@ -65,19 +73,35 @@ vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const createMockVueNodeData = (
|
||||
overrides: Partial<VueNodeData> = {}
|
||||
): VueNodeData => ({
|
||||
id: 'test-node-123',
|
||||
title: 'Test Node',
|
||||
type: 'TestNodeType',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
...overrides
|
||||
const mockData = vi.hoisted(() => {
|
||||
const fakeNodeLayout: NodeLayout = {
|
||||
id: '',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
zIndex: 1,
|
||||
visible: true,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
}
|
||||
return { fakeNodeLayout }
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
|
||||
const isDraggingVueNodes = ref(false)
|
||||
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
|
||||
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
|
||||
const setSource = vi.fn()
|
||||
return {
|
||||
layoutStore: {
|
||||
isDraggingVueNodes,
|
||||
getNodeLayoutRef,
|
||||
setSource
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createPointerEvent = (
|
||||
@@ -107,46 +131,34 @@ const createMouseEvent = (
|
||||
|
||||
describe('useNodePointerInteractions', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
selectedItemsState.items = []
|
||||
setActivePinia(createPinia())
|
||||
// Reset layout store state between tests
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
setActivePinia(createTestingPinia())
|
||||
})
|
||||
|
||||
it('should only start drag on left-click', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const { startDrag } = useNodeDrag()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Right-click should not trigger selection
|
||||
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
|
||||
pointerHandlers.onPointerdown(rightClickEvent)
|
||||
|
||||
expect(mockOnNodeSelect).not.toHaveBeenCalled()
|
||||
expect(handleNodeSelect).not.toHaveBeenCalled()
|
||||
|
||||
// Left-click should trigger selection on pointer down
|
||||
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
|
||||
pointerHandlers.onPointerdown(leftClickEvent)
|
||||
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(leftClickEvent, mockNodeData)
|
||||
expect(startDrag).toHaveBeenCalledWith(leftClickEvent, 'test-node-123')
|
||||
})
|
||||
|
||||
it('should call onNodeSelect on pointer down', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
it.skip('should call onNodeSelect on pointer down', async () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Selection should happen on pointer down
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
@@ -155,9 +167,9 @@ describe('useNodePointerInteractions', () => {
|
||||
})
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith(downEvent, 'test-node-123')
|
||||
|
||||
mockOnNodeSelect.mockClear()
|
||||
vi.mocked(handleNodeSelect).mockClear()
|
||||
|
||||
// Even if we drag, selection already happened on pointer down
|
||||
pointerHandlers.onPointerup(
|
||||
@@ -165,35 +177,36 @@ describe('useNodePointerInteractions', () => {
|
||||
)
|
||||
|
||||
// onNodeSelect should not be called again on pointer up
|
||||
expect(mockOnNodeSelect).not.toHaveBeenCalled()
|
||||
expect(handleNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle drag termination via cancel and context menu', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Test pointer cancel - selection happens on pointer down
|
||||
pointerHandlers.onPointerdown(
|
||||
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
|
||||
)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Simulate drag by moving pointer beyond threshold
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', { clientX: 110, clientY: 110 })
|
||||
createPointerEvent('pointermove', {
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
buttons: 1
|
||||
})
|
||||
)
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
|
||||
|
||||
// Selection should have been called on pointer down only
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockOnNodeSelect.mockClear()
|
||||
vi.mocked(handleNodeSelect).mockClear()
|
||||
|
||||
// Test context menu during drag prevents default
|
||||
pointerHandlers.onPointerdown(
|
||||
@@ -201,7 +214,11 @@ describe('useNodePointerInteractions', () => {
|
||||
)
|
||||
// Simulate drag by moving pointer beyond threshold
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', { clientX: 110, clientY: 110 })
|
||||
createPointerEvent('pointermove', {
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
buttons: 1
|
||||
})
|
||||
)
|
||||
|
||||
const contextMenuEvent = createMouseEvent('contextmenu')
|
||||
@@ -212,36 +229,8 @@ describe('useNodePointerInteractions', () => {
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onNodeSelect when nodeData is null', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const nodeDataRef = ref<VueNodeData | null>(mockNodeData)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
nodeDataRef,
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Clear nodeData before pointer down
|
||||
nodeDataRef.value = null
|
||||
await nextTick()
|
||||
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
|
||||
expect(mockOnNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should integrate with layout store dragging state', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Pointer down alone shouldn't set dragging state
|
||||
pointerHandlers.onPointerdown(
|
||||
@@ -251,7 +240,11 @@ describe('useNodePointerInteractions', () => {
|
||||
|
||||
// Move pointer beyond threshold to start drag
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', { clientX: 110, clientY: 110 })
|
||||
createPointerEvent('pointermove', {
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
buttons: 1
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
|
||||
@@ -262,63 +255,8 @@ describe('useNodePointerInteractions', () => {
|
||||
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
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Pointer down should select node immediately
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
@@ -326,24 +264,25 @@ describe('useNodePointerInteractions', () => {
|
||||
clientY: 100
|
||||
})
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
// Selection should happen on pointer down (before move)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
|
||||
// Dragging state should NOT be active yet
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
|
||||
|
||||
const pointerMove = createPointerEvent('pointermove', {
|
||||
clientX: 150,
|
||||
clientY: 150,
|
||||
buttons: 1
|
||||
})
|
||||
// Move the pointer beyond threshold (start dragging)
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', { clientX: 150, clientY: 150 })
|
||||
)
|
||||
pointerHandlers.onPointermove(pointerMove)
|
||||
|
||||
// Now dragging state should be active
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
|
||||
|
||||
// Selection should still only have been called once (on pointer down)
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
// Selection should happen on pointer down (before move)
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith(pointerMove, 'test-node-123')
|
||||
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
// End drag
|
||||
pointerHandlers.onPointerup(
|
||||
@@ -351,17 +290,12 @@ describe('useNodePointerInteractions', () => {
|
||||
)
|
||||
|
||||
// Selection should still only have been called once
|
||||
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
|
||||
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('on ctrl+click: calls toggleNodeSelectionAfterPointerUp on pointer up (not pointer down)', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
|
||||
|
||||
// Pointer down with ctrl
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
@@ -372,7 +306,7 @@ describe('useNodePointerInteractions', () => {
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
// On pointer down: toggle handler should NOT be called yet
|
||||
expect(toggleNodeSelectionAfterPointerUpMock).not.toHaveBeenCalled()
|
||||
expect(toggleNodeSelectionAfterPointerUp).not.toHaveBeenCalled()
|
||||
|
||||
// Pointer up with ctrl (no drag - same position)
|
||||
const upEvent = createPointerEvent('pointerup', {
|
||||
@@ -383,116 +317,9 @@ describe('useNodePointerInteractions', () => {
|
||||
pointerHandlers.onPointerup(upEvent)
|
||||
|
||||
// On pointer up: toggle handler IS called with correct params
|
||||
expect(toggleNodeSelectionAfterPointerUpMock).toHaveBeenCalledWith(
|
||||
mockNodeData.id,
|
||||
{
|
||||
wasSelectedAtPointerDown: false,
|
||||
multiSelect: true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('on ctrl+drag: does NOT call toggleNodeSelectionAfterPointerUp', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
// Pointer down with ctrl
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
ctrlKey: true,
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
})
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
// Move beyond drag threshold
|
||||
pointerHandlers.onPointermove(
|
||||
createPointerEvent('pointermove', {
|
||||
ctrlKey: true,
|
||||
clientX: 110,
|
||||
clientY: 110
|
||||
})
|
||||
)
|
||||
|
||||
// Pointer up after drag
|
||||
const upEvent = createPointerEvent('pointerup', {
|
||||
ctrlKey: true,
|
||||
clientX: 110,
|
||||
clientY: 110
|
||||
})
|
||||
pointerHandlers.onPointerup(upEvent)
|
||||
|
||||
// When dragging: toggle handler should NOT be called
|
||||
expect(toggleNodeSelectionAfterPointerUpMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects node when shift drag starts without multi selection', async () => {
|
||||
selectedItemsState.items = []
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
const pointerDownEvent = createPointerEvent('pointerdown', {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
pointerHandlers.onPointerdown(pointerDownEvent)
|
||||
|
||||
const pointerMoveEvent = createPointerEvent('pointermove', {
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
pointerHandlers.onPointermove(pointerMoveEvent)
|
||||
|
||||
expect(ensureNodeSelectedForShiftDragMock).toHaveBeenCalledWith(
|
||||
pointerMoveEvent,
|
||||
mockNodeData,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('still ensures selection when shift drag starts with existing multi select', async () => {
|
||||
selectedItemsState.items = [{ id: 'a' }, { id: 'b' }]
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnNodeSelect = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnNodeSelect
|
||||
)
|
||||
|
||||
const pointerDownEvent = createPointerEvent('pointerdown', {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
pointerHandlers.onPointerdown(pointerDownEvent)
|
||||
|
||||
const pointerMoveEvent = createPointerEvent('pointermove', {
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
pointerHandlers.onPointermove(pointerMoveEvent)
|
||||
|
||||
expect(ensureNodeSelectedForShiftDragMock).toHaveBeenCalledWith(
|
||||
pointerMoveEvent,
|
||||
mockNodeData,
|
||||
false
|
||||
expect(toggleNodeSelectionAfterPointerUp).toHaveBeenCalledWith(
|
||||
'test-node-123',
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +384,8 @@ export function useSlotLinkInteraction({
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
event.stopPropagation()
|
||||
|
||||
dragContext.pendingPointerMove = {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
@@ -507,6 +509,7 @@ export function useSlotLinkInteraction({
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
event.stopPropagation()
|
||||
finishInteraction(event)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
|
||||
import type { Point, Size } from '@/renderer/core/layout/types'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
|
||||
import type { ResizeHandleDirection } from './resizeMath'
|
||||
import { createResizeSession, toCanvasDelta } from './resizeMath'
|
||||
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
interface UseNodeResizeOptions {
|
||||
/** Transform state for coordinate conversion */
|
||||
transformState: TransformState
|
||||
transformState: ReturnType<typeof useTransformState>
|
||||
}
|
||||
|
||||
interface ResizeCallbackPayload {
|
||||
|
||||
215
src/renderer/extensions/vueNodes/layout/useNodeDrag.ts
Normal file
215
src/renderer/extensions/vueNodes/layout/useNodeDrag.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
NodeBoundsUpdate,
|
||||
NodeId,
|
||||
Point
|
||||
} from '@/renderer/core/layout/types'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
|
||||
|
||||
function useNodeDragIndividual() {
|
||||
const mutations = useLayoutMutations()
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = useTransformState()
|
||||
|
||||
// Snap-to-grid functionality
|
||||
const { shouldSnap, applySnapToPosition } = useNodeSnap()
|
||||
|
||||
// Shift key sync for LiteGraph canvas preview
|
||||
const { trackShiftKey } = useShiftKeySync()
|
||||
|
||||
// Drag state
|
||||
let dragStartPos: Point | null = null
|
||||
let dragStartMouse: Point | null = null
|
||||
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||
let rafId: number | null = null
|
||||
let stopShiftSync: (() => void) | null = null
|
||||
|
||||
function startDrag(event: PointerEvent, nodeId: NodeId) {
|
||||
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||
if (!layout) return
|
||||
const position = layout.position ?? { x: 0, y: 0 }
|
||||
|
||||
// Track shift key state and sync to canvas for snap preview
|
||||
stopShiftSync = trackShiftKey(event)
|
||||
|
||||
dragStartPos = { ...position }
|
||||
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||
|
||||
const selectedNodes = toValue(selectedNodeIds)
|
||||
|
||||
// capture the starting positions of all other selected nodes
|
||||
if (selectedNodes?.has(nodeId) && selectedNodes.size > 1) {
|
||||
otherSelectedNodesStartPositions = new Map()
|
||||
|
||||
for (const id of selectedNodes) {
|
||||
// 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
|
||||
}
|
||||
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
}
|
||||
|
||||
function handleDrag(event: PointerEvent, nodeId: NodeId) {
|
||||
if (!dragStartPos || !dragStartMouse) {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle position updates using requestAnimationFrame for better performance
|
||||
if (rafId !== null) return // Skip if frame already scheduled
|
||||
|
||||
const { target, pointerId } = event
|
||||
if (target instanceof HTMLElement && !target.hasPointerCapture(pointerId)) {
|
||||
// Delay capture to drag to allow for the Node cloning
|
||||
target.setPointerCapture(pointerId)
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
|
||||
if (!dragStartPos || !dragStartMouse) return
|
||||
|
||||
// Calculate mouse delta in screen coordinates
|
||||
const mouseDelta = {
|
||||
x: event.clientX - dragStartMouse.x,
|
||||
y: event.clientY - dragStartMouse.y
|
||||
}
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
|
||||
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
|
||||
const canvasDelta = {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
|
||||
// Calculate new position for the current node
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
|
||||
// Apply mutation through the layout system (Vue batches DOM updates automatically)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function endDrag(event: PointerEvent, nodeId: NodeId | undefined) {
|
||||
// Apply snap to final position if snap was active (matches LiteGraph behavior)
|
||||
if (shouldSnap(event) && nodeId) {
|
||||
const boundsUpdates: NodeBoundsUpdate[] = []
|
||||
|
||||
// Snap main node
|
||||
const currentLayout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||
if (currentLayout) {
|
||||
const currentPos = currentLayout.position
|
||||
const snappedPos = applySnapToPosition({ ...currentPos })
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
|
||||
boundsUpdates.push({
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: currentLayout.size.width,
|
||||
height: currentLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Also snap other selected nodes
|
||||
// Capture all positions at the start to ensure consistent state
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
|
||||
if (nodeLayout) {
|
||||
const currentPos = { ...nodeLayout.position }
|
||||
const snappedPos = applySnapToPosition(currentPos)
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (
|
||||
snappedPos.x !== currentPos.x ||
|
||||
snappedPos.y !== currentPos.y
|
||||
) {
|
||||
boundsUpdates.push({
|
||||
nodeId: otherNodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: nodeLayout.size.width,
|
||||
height: nodeLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all snap updates in a single batched transaction
|
||||
if (boundsUpdates.length > 0) {
|
||||
layoutStore.batchUpdateNodeBounds(boundsUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync?.()
|
||||
stopShiftSync = null
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, ref, toValue } from 'vue'
|
||||
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeBoundsUpdate, Point } from '@/renderer/core/layout/types'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Composable for individual Vue node components
|
||||
@@ -18,16 +13,6 @@ import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useS
|
||||
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
const mutations = useLayoutMutations()
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = inject(TransformStateKey)
|
||||
|
||||
// Snap-to-grid functionality
|
||||
const { shouldSnap, applySnapToPosition } = useNodeSnap()
|
||||
|
||||
// Shift key sync for LiteGraph canvas preview
|
||||
const { trackShiftKey } = useShiftKeySync()
|
||||
|
||||
// Get the customRef for this node (shared write access)
|
||||
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
@@ -41,215 +26,9 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
const size = computed(
|
||||
() => layoutRef.value?.size ?? { width: 200, height: 100 }
|
||||
)
|
||||
const bounds = computed(
|
||||
() =>
|
||||
layoutRef.value?.bounds ?? {
|
||||
x: position.value.x,
|
||||
y: position.value.y,
|
||||
width: size.value.width,
|
||||
height: size.value.height
|
||||
}
|
||||
)
|
||||
const isVisible = computed(() => layoutRef.value?.visible ?? true)
|
||||
|
||||
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
|
||||
|
||||
// Drag state
|
||||
const isDragging = ref(false)
|
||||
let dragStartPos: Point | null = null
|
||||
let dragStartMouse: Point | null = null
|
||||
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||
let rafId: number | null = null
|
||||
let stopShiftSync: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Start dragging the node
|
||||
*/
|
||||
function startDrag(event: PointerEvent) {
|
||||
if (!layoutRef.value || !transformState) return
|
||||
|
||||
// Track shift key state and sync to canvas for snap preview
|
||||
stopShiftSync = trackShiftKey(event)
|
||||
|
||||
isDragging.value = true
|
||||
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)
|
||||
|
||||
// Capture pointer
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
event.target.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag movement
|
||||
*/
|
||||
const handleDrag = (event: PointerEvent) => {
|
||||
if (
|
||||
!isDragging.value ||
|
||||
!dragStartPos ||
|
||||
!dragStartMouse ||
|
||||
!transformState
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle position updates using requestAnimationFrame for better performance
|
||||
if (rafId !== null) return // Skip if frame already scheduled
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
|
||||
if (!dragStartPos || !dragStartMouse || !transformState) return
|
||||
|
||||
// Calculate mouse delta in screen coordinates
|
||||
const mouseDelta = {
|
||||
x: event.clientX - dragStartMouse.x,
|
||||
y: event.clientY - dragStartMouse.y
|
||||
}
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
|
||||
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
|
||||
const canvasDelta = {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
|
||||
// Calculate new position for the current node
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
|
||||
// Apply mutation through the layout system (Vue batches DOM updates automatically)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* End dragging
|
||||
*/
|
||||
function endDrag(event: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
// Apply snap to final position if snap was active (matches LiteGraph behavior)
|
||||
if (shouldSnap(event)) {
|
||||
const boundsUpdates: NodeBoundsUpdate[] = []
|
||||
|
||||
// Snap main node
|
||||
const currentLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (currentLayout) {
|
||||
const currentPos = currentLayout.position
|
||||
const snappedPos = applySnapToPosition({ ...currentPos })
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
|
||||
boundsUpdates.push({
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: currentLayout.size.width,
|
||||
height: currentLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Also snap other selected nodes
|
||||
// Capture all positions at the start to ensure consistent state
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
) {
|
||||
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
|
||||
if (nodeLayout) {
|
||||
const currentPos = { ...nodeLayout.position }
|
||||
const snappedPos = applySnapToPosition(currentPos)
|
||||
|
||||
// Only add update if position actually changed
|
||||
if (
|
||||
snappedPos.x !== currentPos.x ||
|
||||
snappedPos.y !== currentPos.y
|
||||
) {
|
||||
boundsUpdates.push({
|
||||
nodeId: otherNodeId,
|
||||
bounds: {
|
||||
x: snappedPos.x,
|
||||
y: snappedPos.y,
|
||||
width: nodeLayout.size.width,
|
||||
height: nodeLayout.size.height
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all snap updates in a single batched transaction
|
||||
if (boundsUpdates.length > 0) {
|
||||
layoutStore.batchUpdateNodeBounds(boundsUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync?.()
|
||||
stopShiftSync = null
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
// Release pointer
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
event.target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node position directly (without drag)
|
||||
*/
|
||||
@@ -260,33 +39,11 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
|
||||
return {
|
||||
// Reactive state (via customRef)
|
||||
layoutRef,
|
||||
position,
|
||||
size,
|
||||
bounds,
|
||||
isVisible,
|
||||
zIndex,
|
||||
isDragging,
|
||||
|
||||
// Mutations
|
||||
moveNodeTo,
|
||||
|
||||
// Drag handlers
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag,
|
||||
|
||||
// Computed styles for Vue templates
|
||||
nodeStyle: computed(
|
||||
(): CSSProperties => ({
|
||||
position: 'absolute' as const,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
width: `${size.value.width}px`,
|
||||
height: `${size.value.height}px`,
|
||||
zIndex: zIndex.value,
|
||||
cursor: isDragging.value ? 'grabbing' : 'grab'
|
||||
})
|
||||
)
|
||||
moveNodeTo
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user