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:
Alexander Brown
2025-11-21 14:16:03 -08:00
committed by GitHub
parent a8d6f7baff
commit 9da82f47ef
22 changed files with 574 additions and 1568 deletions

View File

@@ -557,7 +557,7 @@ export class ComfyPage {
async dragAndDrop(source: Position, target: Position) { async dragAndDrop(source: Position, target: Position) {
await this.page.mouse.move(source.x, source.y) await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down() await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y) await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up() await this.page.mouse.up()
await this.nextFrame() await this.nextFrame()
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -13,6 +13,7 @@ import type {
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget' import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore' import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
} }
export interface VueNodeData { export interface VueNodeData {
id: string id: NodeId
title: string title: string
type: string type: string
mode: number mode: number

View File

@@ -1771,18 +1771,19 @@ export class LGraphCanvas
} }
static onMenuNodeClone( static onMenuNodeClone(
// @ts-expect-error - unused parameter _value: IContextMenuValue,
value: IContextMenuValue, _options: IContextMenuOptions,
// @ts-expect-error - unused parameter _e: MouseEvent,
options: IContextMenuOptions, _menu: ContextMenu,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
node: LGraphNode node: LGraphNode
): void { ): void {
const canvas = LGraphCanvas.active_canvas 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 // Find top-left-most boundary
let offsetX = Infinity let offsetX = Infinity
@@ -1792,11 +1793,11 @@ export class LGraphCanvas
throw new TypeError( throw new TypeError(
'Invalid node encountered on clone. `pos` was null.' 'Invalid node encountered on clone. `pos` was null.'
) )
if (item.pos[0] < offsetX) offsetX = item.pos[0] offsetX = Math.min(offsetX, item.pos[0])
if (item.pos[1] < offsetY) offsetY = item.pos[1] offsetY = Math.min(offsetY, item.pos[1])
} }
canvas._deserializeItems(canvas._serializeItems(nodes), { return canvas._deserializeItems(canvas._serializeItems(nodes), {
position: [offsetX + 5, offsetY + 5] position: [offsetX + 5, offsetY + 5]
}) })
} }

View File

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

View File

@@ -17,10 +17,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRafFn } from '@vueuse/core' import { useRafFn } from '@vueuse/core'
import { computed, provide } from 'vue' import { computed } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph' import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling' import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD' import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
@@ -32,14 +31,7 @@ interface TransformPaneProps {
const props = defineProps<TransformPaneProps>() const props = defineProps<TransformPaneProps>()
const { const { camera, transformStyle, syncWithCanvas } = useTransformState()
camera,
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
isNodeInViewport
} = useTransformState()
const { isLOD } = useLOD(camera) const { isLOD } = useLOD(camera)
@@ -48,13 +40,6 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 512 settleDelay: 512
}) })
provide(TransformStateKey, {
camera,
canvasToScreen,
screenToCanvas,
isNodeInViewport
})
const emit = defineEmits<{ const emit = defineEmits<{
transformUpdate: [] transformUpdate: []
}>() }>()

View File

@@ -52,6 +52,7 @@
import { computed, reactive, readonly } from 'vue' import { computed, reactive, readonly } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph' import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { createSharedComposable } from '@vueuse/core'
interface Point { interface Point {
x: number x: number
@@ -64,7 +65,7 @@ interface Camera {
z: number // scale/zoom z: number // scale/zoom
} }
export const useTransformState = () => { function useTransformStateIndividual() {
// Reactive state mirroring LiteGraph's canvas transform // Reactive state mirroring LiteGraph's canvas transform
const camera = reactive<Camera>({ const camera = reactive<Camera>({
x: 0, x: 0,
@@ -91,7 +92,7 @@ export const useTransformState = () => {
* *
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state * @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
*/ */
const syncWithCanvas = (canvas: LGraphCanvas) => { function syncWithCanvas(canvas: LGraphCanvas) {
if (!canvas || !canvas.ds) return if (!canvas || !canvas.ds) return
// Mirror LiteGraph's transform state to Vue's reactive state // Mirror LiteGraph's transform state to Vue's reactive state
@@ -112,7 +113,7 @@ export const useTransformState = () => {
* @param point - Point in canvas coordinate system * @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system * @returns Point in screen coordinate system
*/ */
const canvasToScreen = (point: Point): Point => { function canvasToScreen(point: Point): Point {
return { return {
x: (point.x + camera.x) * camera.z, x: (point.x + camera.x) * camera.z,
y: (point.y + camera.y) * camera.z y: (point.y + camera.y) * camera.z
@@ -138,10 +139,10 @@ export const useTransformState = () => {
} }
// Get node's screen bounds for culling // Get node's screen bounds for culling
const getNodeScreenBounds = ( function getNodeScreenBounds(
pos: ArrayLike<number>, pos: [number, number],
size: ArrayLike<number> size: [number, number]
): DOMRect => { ): DOMRect {
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] }) const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
const width = size[0] * camera.z const width = size[0] * camera.z
const height = size[1] * camera.z const height = size[1] * camera.z
@@ -150,23 +151,23 @@ export const useTransformState = () => {
} }
// Helper: Calculate zoom-adjusted margin for viewport culling // 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 < 0.1) return Math.min(baseMargin * 5, 2.0)
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05) if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
return baseMargin return baseMargin
} }
// Helper: Check if node is too small to be visible at current zoom // 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 const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
return nodeScreenSize < 4 return nodeScreenSize < 4
} }
// Helper: Calculate expanded viewport bounds with margin // Helper: Calculate expanded viewport bounds with margin
const getExpandedViewportBounds = ( function getExpandedViewportBounds(
viewport: { width: number; height: number }, viewport: { width: number; height: number },
margin: number margin: number
) => { ) {
const marginX = viewport.width * margin const marginX = viewport.width * margin
const marginY = viewport.height * margin const marginY = viewport.height * margin
return { return {
@@ -178,11 +179,11 @@ export const useTransformState = () => {
} }
// Helper: Test if node intersects with viewport bounds // Helper: Test if node intersects with viewport bounds
const testViewportIntersection = ( function testViewportIntersection(
screenPos: { x: number; y: number }, screenPos: { x: number; y: number },
nodeSize: ArrayLike<number>, nodeSize: [number, number],
bounds: { left: number; right: number; top: number; bottom: number } bounds: { left: number; right: number; top: number; bottom: number }
): boolean => { ): boolean {
const nodeRight = screenPos.x + nodeSize[0] * camera.z const nodeRight = screenPos.x + nodeSize[0] * camera.z
const nodeBottom = screenPos.y + nodeSize[1] * 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 // Check if node is within viewport with frustum and size-based culling
const isNodeInViewport = ( function isNodeInViewport(
nodePos: ArrayLike<number>, nodePos: [number, number],
nodeSize: ArrayLike<number>, nodeSize: [number, number],
viewport: { width: number; height: number }, viewport: { width: number; height: number },
margin: number = 0.2 margin: number = 0.2
): boolean => { ): boolean {
// Early exit for tiny nodes // Early exit for tiny nodes
if (isNodeTooSmall(nodeSize)) return false if (isNodeTooSmall(nodeSize)) return false
@@ -212,10 +213,10 @@ export const useTransformState = () => {
} }
// Get viewport bounds in canvas coordinates (for spatial index queries) // Get viewport bounds in canvas coordinates (for spatial index queries)
const getViewportBounds = ( function getViewportBounds(
viewport: { width: number; height: number }, viewport: { width: number; height: number },
margin: number = 0.2 margin: number = 0.2
) => { ) {
const marginX = viewport.width * margin const marginX = viewport.width * margin
const marginY = viewport.height * margin const marginY = viewport.height * margin
@@ -244,3 +245,7 @@ export const useTransformState = () => {
getViewportBounds getViewportBounds
} }
} }
export const useTransformState = createSharedComposable(
useTransformStateIndividual
)

View File

@@ -11,9 +11,9 @@ interface SpatialBounds {
height: number height: number
} }
interface PositionedNode { export interface PositionedNode {
pos: ArrayLike<number> pos: [number, number]
size: ArrayLike<number> size: [number, number]
} }
/** /**

View File

@@ -1,5 +1,6 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph' import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator' import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
import type { PositionedNode } from '@/renderer/core/spatial/boundsCalculator'
import type { import type {
IMinimapDataSource, IMinimapDataSource,
@@ -29,10 +30,12 @@ export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
} }
// Convert MinimapNodeData to the format expected by calculateNodeBounds // Convert MinimapNodeData to the format expected by calculateNodeBounds
const compatibleNodes = nodes.map((node) => ({ const compatibleNodes = nodes.map(
pos: [node.x, node.y], (node): PositionedNode => ({
size: [node.width, node.height] pos: [node.x, node.y],
})) size: [node.width, node.height]
})
)
const bounds = calculateNodeBounds(compatibleNodes) const bounds = calculateNodeBounds(compatibleNodes)
if (!bounds) { if (!bounds) {

View File

@@ -19,12 +19,12 @@
'outline-transparent outline-2', 'outline-transparent outline-2',
borderClass, borderClass,
outlineClass, outlineClass,
cursorClass,
{ {
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0': 'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
bypassed, bypassed,
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0': 'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
muted, muted,
'will-change-transform': isDragging,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver 'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
}, },
@@ -39,10 +39,10 @@
zIndex: zIndex, zIndex: zIndex,
opacity: nodeOpacity, opacity: nodeOpacity,
'--component-node-background': nodeBodyBackgroundColor '--component-node-background': nodeBodyBackgroundColor
}, }
dragStyle
]" ]"
v-bind="pointerHandlers" v-bind="remainingPointerHandlers"
@pointerdown="nodeOnPointerdown"
@wheel="handleWheel" @wheel="handleWheel"
@contextmenu="handleContextMenu" @contextmenu="handleContextMenu"
@dragover.prevent="handleDragOver" @dragover.prevent="handleDragOver"
@@ -137,24 +137,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' 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 { useI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu' import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n' 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 { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' 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 SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions' 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 { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState' 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 { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState' import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils' import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
@@ -188,16 +195,13 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n() const { t } = useI18n()
const { const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
handleNodeCollapse, useNodeEventHandlers()
handleNodeTitleUpdate, const { bringNodeToFront } = useNodeZIndex()
handleNodeSelect,
handleNodeRightClick
} = useNodeEventHandlers()
useVueElementTracking(() => nodeData.id, 'node') useVueElementTracking(() => nodeData.id, 'node')
const transformState = inject(TransformStateKey) const transformState = useTransformState()
if (!transformState) { if (!transformState) {
throw new Error( throw new Error(
'TransformState must be provided for node resize functionality' 'TransformState must be provided for node resize functionality'
@@ -272,10 +276,24 @@ onErrorCaptured((error) => {
}) })
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id) const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions( const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
() => nodeData, const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
handleNodeSelect 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 // Handle right-click context menu
const handleContextMenu = (event: MouseEvent) => { const handleContextMenu = (event: MouseEvent) => {
@@ -283,7 +301,7 @@ const handleContextMenu = (event: MouseEvent) => {
event.stopPropagation() event.stopPropagation()
// First handle the standard right-click behavior (selection) // 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 // Show the node options menu at the cursor position
const targetElement = event.currentTarget as HTMLElement 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 // Event handlers
const handleCollapse = () => { const handleCollapse = () => {
handleNodeCollapse(nodeData.id, !isCollapsed.value) handleNodeCollapse(nodeData.id, !isCollapsed.value)

View File

@@ -10,12 +10,12 @@
*/ */
import { createSharedComposable } from '@vueuse/core' import { createSharedComposable } from '@vueuse/core'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex' import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils' import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
import type { NodeId } from '@/renderer/core/layout/types'
function useNodeEventHandlersIndividual() { function useNodeEventHandlersIndividual() {
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
@@ -27,12 +27,12 @@ function useNodeEventHandlersIndividual() {
* Handle node selection events * Handle node selection events
* Supports single selection and multi-select with Ctrl/Cmd * 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 (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id) const node = nodeManager.value.getNode(nodeId)
if (!node) return if (!node) return
const multiSelect = isMultiSelectKey(event) const multiSelect = isMultiSelectKey(event)
@@ -53,7 +53,7 @@ function useNodeEventHandlersIndividual() {
// Bring node to front when clicked (similar to LiteGraph behavior) // Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned to avoid unwanted movement // Skip if node is pinned to avoid unwanted movement
if (!node.flags?.pinned) { if (!node.flags?.pinned) {
bringNodeToFront(nodeData.id) bringNodeToFront(nodeId)
} }
// Update canvas selection tracking // Update canvas selection tracking
@@ -64,7 +64,7 @@ function useNodeEventHandlersIndividual() {
* Handle node collapse/expand state changes * Handle node collapse/expand state changes
* Uses LiteGraph's native collapse method for proper state management * 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 (!shouldHandleNodePointerEvents.value) return
if (!nodeManager.value) return if (!nodeManager.value) return
@@ -83,7 +83,7 @@ function useNodeEventHandlersIndividual() {
* Handle node title updates * Handle node title updates
* Updates the title in LiteGraph for persistence across sessions * 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 (!shouldHandleNodePointerEvents.value) return
if (!nodeManager.value) return if (!nodeManager.value) return
@@ -95,41 +95,16 @@ function useNodeEventHandlersIndividual() {
node.title = newTitle 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 * Handle node right-click context menu events
* Integrates with LiteGraph's context menu system * Integrates with LiteGraph's context menu system
*/ */
const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => { function handleNodeRightClick(event: PointerEvent, nodeId: NodeId) {
if (!shouldHandleNodePointerEvents.value) return if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id) const node = nodeManager.value.getNode(nodeId)
if (!node) return if (!node) return
// Prevent default context menu // Prevent default context menu
@@ -137,128 +112,17 @@ function useNodeEventHandlersIndividual() {
// Select the node if not already selected // Select the node if not already selected
if (!node.selected) { if (!node.selected) {
handleNodeSelect(event, nodeData) handleNodeSelect(event, nodeId)
} }
// Let LiteGraph handle the context menu // Let LiteGraph handle the context menu
// The canvas will handle showing the appropriate context menu // The canvas will handle showing the appropriate context menu
} }
/** function toggleNodeSelectionAfterPointerUp(
* Handle node drag start events nodeId: NodeId,
* Prepares node for dragging and sets appropriate visual state multiSelect: boolean
*/ ) {
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
}
) => {
if (!shouldHandleNodePointerEvents.value) return if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return if (!canvasStore.canvas || !nodeManager.value) return
@@ -267,22 +131,19 @@ function useNodeEventHandlersIndividual() {
if (!node) return if (!node) return
if (!multiSelect) { if (!multiSelect) {
const multipleSelected = canvasStore.selectedItems.length > 1 canvasStore.canvas.deselectAll()
if (multipleSelected && wasSelectedAtPointerDown) { canvasStore.canvas.select(node)
canvasStore.canvas.deselectAll() canvasStore.updateSelectedItems()
canvasStore.canvas.select(node)
canvasStore.updateSelectedItems()
}
return return
} }
if (wasSelectedAtPointerDown) { if (node.selected) {
canvasStore.canvas.deselect(node) 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 canvasStore.updateSelectedItems()
// handler already added it to the selection.
} }
return { return {
@@ -290,15 +151,9 @@ function useNodeEventHandlersIndividual() {
handleNodeSelect, handleNodeSelect,
handleNodeCollapse, handleNodeCollapse,
handleNodeTitleUpdate, handleNodeTitleUpdate,
handleNodeDoubleClick,
handleNodeRightClick, handleNodeRightClick,
handleNodeDragStart,
// Batch operations // Batch operations
selectNodes,
deselectNodes,
deselectNode,
ensureNodeSelectedForShiftDrag,
toggleNodeSelectionAfterPointerUp toggleNodeSelectionAfterPointerUp
} }
} }

View File

@@ -1,15 +1,15 @@
import { createPinia, setActivePinia } from 'pinia' import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue' import { nextTick, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions' 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 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: [] } const selectedItemsState: { items: Array<{ id?: string }> } = { items: [] }
// Mock the dependencies // Mock the dependencies
@@ -20,19 +20,18 @@ vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
}) })
})) }))
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({ vi.mock('@/renderer/extensions/vueNodes/layout/useNodeDrag', () => {
useNodeLayout: () => ({ const startDrag = vi.fn()
startDrag: vi.fn(), const handleDrag = vi.fn()
endDrag: vi.fn().mockResolvedValue(undefined), const endDrag = vi.fn()
handleDrag: vi.fn().mockResolvedValue(undefined) return {
}) useNodeDrag: () => ({
})) startDrag,
handleDrag,
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ endDrag
layoutStore: { })
isDraggingVueNodes: ref(false)
} }
})) })
vi.mock('@/renderer/core/canvas/canvasStore', () => ({ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ useCanvasStore: () => ({
@@ -44,14 +43,23 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock( vi.mock(
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers', '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
() => ({ () => {
useNodeEventHandlers: () => ({ const handleNodeSelect = vi.fn()
deselectNode: deselectNodeMock, const deselectNode = vi.fn()
selectNodes: selectNodesMock, const selectNodes = vi.fn()
toggleNodeSelectionAfterPointerUp: toggleNodeSelectionAfterPointerUpMock, const toggleNodeSelectionAfterPointerUp = vi.fn()
ensureNodeSelectedForShiftDrag: ensureNodeSelectedForShiftDragMock const ensureNodeSelectedForShiftDrag = vi.fn()
})
}) return {
useNodeEventHandlers: () => ({
handleNodeSelect,
deselectNode,
selectNodes,
toggleNodeSelectionAfterPointerUp,
ensureNodeSelectedForShiftDrag
})
}
}
) )
vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({ vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
@@ -65,19 +73,35 @@ vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
}) })
})) }))
const createMockVueNodeData = ( const mockData = vi.hoisted(() => {
overrides: Partial<VueNodeData> = {} const fakeNodeLayout: NodeLayout = {
): VueNodeData => ({ id: '',
id: 'test-node-123', position: { x: 0, y: 0 },
title: 'Test Node', size: { width: 100, height: 100 },
type: 'TestNodeType', zIndex: 1,
mode: 0, visible: true,
selected: false, bounds: {
executing: false, x: 0,
inputs: [], y: 0,
outputs: [], width: 100,
widgets: [], height: 100
...overrides }
}
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 = ( const createPointerEvent = (
@@ -107,46 +131,34 @@ const createMouseEvent = (
describe('useNodePointerInteractions', () => { describe('useNodePointerInteractions', () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks() vi.restoreAllMocks()
selectedItemsState.items = [] selectedItemsState.items = []
setActivePinia(createPinia()) setActivePinia(createTestingPinia())
// Reset layout store state between tests
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
layoutStore.isDraggingVueNodes.value = false
}) })
it('should only start drag on left-click', async () => { it('should only start drag on left-click', async () => {
const mockNodeData = createMockVueNodeData() const { handleNodeSelect } = useNodeEventHandlers()
const mockOnNodeSelect = vi.fn() const { startDrag } = useNodeDrag()
const { pointerHandlers } = useNodePointerInteractions( const { pointerHandlers } = useNodePointerInteractions('test-node-123')
ref(mockNodeData),
mockOnNodeSelect
)
// Right-click should not trigger selection // Right-click should not trigger selection
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 }) const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
pointerHandlers.onPointerdown(rightClickEvent) pointerHandlers.onPointerdown(rightClickEvent)
expect(mockOnNodeSelect).not.toHaveBeenCalled() expect(handleNodeSelect).not.toHaveBeenCalled()
// Left-click should trigger selection on pointer down // Left-click should trigger selection on pointer down
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 }) const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
pointerHandlers.onPointerdown(leftClickEvent) pointerHandlers.onPointerdown(leftClickEvent)
expect(mockOnNodeSelect).toHaveBeenCalledWith(leftClickEvent, mockNodeData) expect(startDrag).toHaveBeenCalledWith(leftClickEvent, 'test-node-123')
}) })
it('should call onNodeSelect on pointer down', async () => { it.skip('should call onNodeSelect on pointer down', async () => {
const mockNodeData = createMockVueNodeData() const { handleNodeSelect } = useNodeEventHandlers()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions( const { pointerHandlers } = useNodePointerInteractions('test-node-123')
ref(mockNodeData),
mockOnNodeSelect
)
// Selection should happen on pointer down // Selection should happen on pointer down
const downEvent = createPointerEvent('pointerdown', { const downEvent = createPointerEvent('pointerdown', {
@@ -155,9 +167,9 @@ describe('useNodePointerInteractions', () => {
}) })
pointerHandlers.onPointerdown(downEvent) 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 // Even if we drag, selection already happened on pointer down
pointerHandlers.onPointerup( pointerHandlers.onPointerup(
@@ -165,35 +177,36 @@ describe('useNodePointerInteractions', () => {
) )
// onNodeSelect should not be called again on pointer up // 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 () => { it('should handle drag termination via cancel and context menu', async () => {
const mockNodeData = createMockVueNodeData() const { handleNodeSelect } = useNodeEventHandlers()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions( const { pointerHandlers } = useNodePointerInteractions('test-node-123')
ref(mockNodeData),
mockOnNodeSelect
)
// Test pointer cancel - selection happens on pointer down // Test pointer cancel - selection happens on pointer down
pointerHandlers.onPointerdown( pointerHandlers.onPointerdown(
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 }) createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
) )
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
// Simulate drag by moving pointer beyond threshold // Simulate drag by moving pointer beyond threshold
pointerHandlers.onPointermove( pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 110, clientY: 110 }) createPointerEvent('pointermove', {
clientX: 110,
clientY: 110,
buttons: 1
})
) )
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
pointerHandlers.onPointercancel(createPointerEvent('pointercancel')) pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
// Selection should have been called on pointer down only // 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 // Test context menu during drag prevents default
pointerHandlers.onPointerdown( pointerHandlers.onPointerdown(
@@ -201,7 +214,11 @@ describe('useNodePointerInteractions', () => {
) )
// Simulate drag by moving pointer beyond threshold // Simulate drag by moving pointer beyond threshold
pointerHandlers.onPointermove( pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 110, clientY: 110 }) createPointerEvent('pointermove', {
clientX: 110,
clientY: 110,
buttons: 1
})
) )
const contextMenuEvent = createMouseEvent('contextmenu') const contextMenuEvent = createMouseEvent('contextmenu')
@@ -212,36 +229,8 @@ describe('useNodePointerInteractions', () => {
expect(preventDefaultSpy).toHaveBeenCalled() 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 () => { it('should integrate with layout store dragging state', async () => {
const mockNodeData = createMockVueNodeData() const { pointerHandlers } = useNodePointerInteractions('test-node-123')
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down alone shouldn't set dragging state // Pointer down alone shouldn't set dragging state
pointerHandlers.onPointerdown( pointerHandlers.onPointerdown(
@@ -251,7 +240,11 @@ describe('useNodePointerInteractions', () => {
// Move pointer beyond threshold to start drag // Move pointer beyond threshold to start drag
pointerHandlers.onPointermove( pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 110, clientY: 110 }) createPointerEvent('pointermove', {
clientX: 110,
clientY: 110,
buttons: 1
})
) )
await nextTick() await nextTick()
expect(layoutStore.isDraggingVueNodes.value).toBe(true) expect(layoutStore.isDraggingVueNodes.value).toBe(true)
@@ -262,63 +255,8 @@ describe('useNodePointerInteractions', () => {
expect(layoutStore.isDraggingVueNodes.value).toBe(false) 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 () => { it('should select node immediately when drag starts', async () => {
const mockNodeData = createMockVueNodeData() const { pointerHandlers } = useNodePointerInteractions('test-node-123')
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down should select node immediately // Pointer down should select node immediately
const downEvent = createPointerEvent('pointerdown', { const downEvent = createPointerEvent('pointerdown', {
@@ -326,24 +264,25 @@ describe('useNodePointerInteractions', () => {
clientY: 100 clientY: 100
}) })
pointerHandlers.onPointerdown(downEvent) pointerHandlers.onPointerdown(downEvent)
const { handleNodeSelect } = useNodeEventHandlers()
// Selection should happen on pointer down (before move)
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
// Dragging state should NOT be active yet // Dragging state should NOT be active yet
expect(layoutStore.isDraggingVueNodes.value).toBe(false) expect(layoutStore.isDraggingVueNodes.value).toBe(false)
const pointerMove = createPointerEvent('pointermove', {
clientX: 150,
clientY: 150,
buttons: 1
})
// Move the pointer beyond threshold (start dragging) // Move the pointer beyond threshold (start dragging)
pointerHandlers.onPointermove( pointerHandlers.onPointermove(pointerMove)
createPointerEvent('pointermove', { clientX: 150, clientY: 150 })
)
// Now dragging state should be active // Now dragging state should be active
expect(layoutStore.isDraggingVueNodes.value).toBe(true) expect(layoutStore.isDraggingVueNodes.value).toBe(true)
// Selection should still only have been called once (on pointer down) // Selection should happen on pointer down (before move)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) expect(handleNodeSelect).toHaveBeenCalledWith(pointerMove, 'test-node-123')
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
// End drag // End drag
pointerHandlers.onPointerup( pointerHandlers.onPointerup(
@@ -351,17 +290,12 @@ describe('useNodePointerInteractions', () => {
) )
// Selection should still only have been called once // 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 () => { it('on ctrl+click: calls toggleNodeSelectionAfterPointerUp on pointer up (not pointer down)', async () => {
const mockNodeData = createMockVueNodeData() const { pointerHandlers } = useNodePointerInteractions('test-node-123')
const mockOnNodeSelect = vi.fn() const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down with ctrl // Pointer down with ctrl
const downEvent = createPointerEvent('pointerdown', { const downEvent = createPointerEvent('pointerdown', {
@@ -372,7 +306,7 @@ describe('useNodePointerInteractions', () => {
pointerHandlers.onPointerdown(downEvent) pointerHandlers.onPointerdown(downEvent)
// On pointer down: toggle handler should NOT be called yet // 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) // Pointer up with ctrl (no drag - same position)
const upEvent = createPointerEvent('pointerup', { const upEvent = createPointerEvent('pointerup', {
@@ -383,116 +317,9 @@ describe('useNodePointerInteractions', () => {
pointerHandlers.onPointerup(upEvent) pointerHandlers.onPointerup(upEvent)
// On pointer up: toggle handler IS called with correct params // On pointer up: toggle handler IS called with correct params
expect(toggleNodeSelectionAfterPointerUpMock).toHaveBeenCalledWith( expect(toggleNodeSelectionAfterPointerUp).toHaveBeenCalledWith(
mockNodeData.id, 'test-node-123',
{ true
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
) )
}) })
}) })

View File

@@ -1,37 +1,22 @@
import { computed, onUnmounted, ref, toValue } from 'vue' import { onScopeDispose, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue' import type { MaybeRefOrGetter } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils' import { isMiddlePointerInput } from '@/base/pointerUtils'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore' 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 { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils' import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
export function useNodePointerInteractions( export function useNodePointerInteractions(
nodeDataMaybe: MaybeRefOrGetter<VueNodeData | null>, nodeIdRef: MaybeRefOrGetter<string>
onNodeSelect: (event: PointerEvent, nodeData: VueNodeData) => void
) { ) {
const nodeData = computed(() => { const { startDrag, endDrag, handleDrag } = useNodeDrag()
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)
// Use canvas interactions for proper wheel event handling and pointer event capture control // Use canvas interactions for proper wheel event handling and pointer event capture control
const { forwardEventToCanvas, shouldHandleNodePointerEvents } = const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
useCanvasInteractions() useCanvasInteractions()
const { toggleNodeSelectionAfterPointerUp, ensureNodeSelectedForShiftDrag } = const { handleNodeSelect, toggleNodeSelectionAfterPointerUp } =
useNodeEventHandlers() useNodeEventHandlers()
const { nodeManager } = useVueNodeLifecycle() const { nodeManager } = useVueNodeLifecycle()
@@ -41,33 +26,15 @@ export function useNodePointerInteractions(
return true 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 startPosition = ref({ x: 0, y: 0 })
const DRAG_THRESHOLD = 3 // pixels const DRAG_THRESHOLD = 3 // pixels
const handlePointerDown = (event: PointerEvent) => { function onPointerdown(event: PointerEvent) {
if (!nodeData.value) {
console.warn(
'LGraphNode: nodeData is null/undefined in handlePointerDown'
)
return
}
if (forwardMiddlePointerIfNeeded(event)) return if (forwardMiddlePointerIfNeeded(event)) return
// Only start drag on left-click (button 0) // Only start drag on left-click (button 0)
if (event.button !== 0) { if (event.button !== 0) return
return
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead // Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) { if (!shouldHandleNodePointerEvents.value) {
@@ -75,69 +42,67 @@ export function useNodePointerInteractions(
return return
} }
// Track if node was selected before this pointer down const nodeId = toValue(nodeIdRef)
// IMPORTANT: Read from actual LGraphNode, not nodeData, to get correct state if (!nodeId) {
const lgNode = nodeManager.value?.getNode(nodeData.value.id) console.warn(
wasSelectedAtPointerDown.value = lgNode?.selected ?? false 'LGraphNode: nodeData is null/undefined in handlePointerDown'
)
onNodeSelect(event, nodeData.value)
if (nodeData.value.flags?.pinned) {
return return
} }
// Record position for drag threshold calculation // IMPORTANT: Read from actual LGraphNode to get correct state
startPosition.value = { x: event.clientX, y: event.clientY } if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
isPointerDown.value = true return
}
// Don't start drag yet - wait for pointer move to exceed threshold startPosition.value = { x: event.clientX, y: event.clientY }
startDrag(event)
startDrag(event, nodeId)
} }
const handlePointerMove = (event: PointerEvent) => { function onPointermove(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return 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) // 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 dx = event.clientX - startPosition.value.x
const dy = event.clientY - startPosition.value.y const dy = event.clientY - startPosition.value.y
const distance = Math.sqrt(dx * dx + dy * dy) const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > DRAG_THRESHOLD && nodeData.value) { if (distance > DRAG_THRESHOLD) {
// Start drag
isDragging.value = true
layoutStore.isDraggingVueNodes.value = true layoutStore.isDraggingVueNodes.value = true
ensureNodeSelectedForShiftDrag( handleNodeSelect(event, nodeId)
event,
nodeData.value,
wasSelectedAtPointerDown.value
)
} }
} }
if (isDragging.value) { if (layoutStore.isDraggingVueNodes.value) {
void handleDrag(event) handleDrag(event, nodeId)
} }
} }
/** function cleanupDragState() {
* 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
layoutStore.isDraggingVueNodes.value = false layoutStore.isDraggingVueNodes.value = false
} }
/** function safeDragEnd(event: PointerEvent) {
* Safely ends drag operation with proper error handling
* @param event - PointerEvent to end the drag with
*/
const safeDragEnd = async (event: PointerEvent): Promise<void> => {
try { try {
await endDrag(event) const nodeId = toValue(nodeIdRef)
endDrag(event, nodeId)
} catch (error) { } catch (error) {
console.error('Error during endDrag:', error) console.error('Error during endDrag:', error)
} finally { } finally {
@@ -145,61 +110,39 @@ export function useNodePointerInteractions(
} }
} }
/** function onPointerup(event: PointerEvent) {
* 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) => {
if (forwardMiddlePointerIfNeeded(event)) return 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 // Don't handle pointer events when canvas is in panning mode - forward to canvas instead
const canHandlePointer = shouldHandleNodePointerEvents.value
if (!canHandlePointer) { if (!canHandlePointer) {
forwardEventToCanvas(event) forwardEventToCanvas(event)
return return
} }
const wasDragging = layoutStore.isDraggingVueNodes.value
if (wasDragging) {
safeDragEnd(event)
return
}
const multiSelect = isMultiSelectKey(event)
const nodeId = toValue(nodeIdRef)
if (nodeId) {
toggleNodeSelectionAfterPointerUp(nodeId, multiSelect)
}
} }
/** function onPointercancel(event: PointerEvent) {
* Handles pointer cancellation events (e.g., touch cancelled by browser) if (!layoutStore.isDraggingVueNodes.value) return
* Ensures drag state is properly cleaned up when pointer interaction is interrupted safeDragEnd(event)
*/
const handlePointerCancel = (event: PointerEvent) => {
if (!isDragging.value) return
handleDragTermination(event, 'drag cancellation')
} }
/** /**
* Handles right-click during drag operations * Handles right-click during drag operations
* Cancels the current drag to prevent context menu from appearing while dragging * Cancels the current drag to prevent context menu from appearing while dragging
*/ */
const handleContextMenu = (event: MouseEvent) => { function onContextmenu(event: MouseEvent) {
if (!isDragging.value) return if (!layoutStore.isDraggingVueNodes.value) return
event.preventDefault() event.preventDefault()
// Simply cleanup state without calling endDrag to avoid synthetic event creation // 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 // Cleanup on unmount to prevent resource leaks
onUnmounted(() => { onScopeDispose(() => {
if (!isDragging.value) return
cleanupDragState() cleanupDragState()
}) })
const pointerHandlers = { const pointerHandlers = {
onPointerdown: handlePointerDown, onPointerdown,
onPointermove: handlePointerMove, onPointermove,
onPointerup: handlePointerUp, onPointerup,
onPointercancel: handlePointerCancel, onPointercancel,
onContextmenu: handleContextMenu onContextmenu
} } as const
return { return {
isDragging,
dragStyle,
pointerHandlers pointerHandlers
} }
} }

View File

@@ -384,6 +384,8 @@ export function useSlotLinkInteraction({
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return if (!pointerSession.matches(event)) return
event.stopPropagation()
dragContext.pendingPointerMove = { dragContext.pendingPointerMove = {
clientX: event.clientX, clientX: event.clientX,
clientY: event.clientY, clientY: event.clientY,
@@ -507,6 +509,7 @@ export function useSlotLinkInteraction({
} }
const handlePointerUp = (event: PointerEvent) => { const handlePointerUp = (event: PointerEvent) => {
event.stopPropagation()
finishInteraction(event) finishInteraction(event)
} }

View File

@@ -1,17 +1,17 @@
import { useEventListener } from '@vueuse/core' import { useEventListener } from '@vueuse/core'
import { ref } from 'vue' import { ref } from 'vue'
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
import type { Point, Size } from '@/renderer/core/layout/types' import type { Point, Size } from '@/renderer/core/layout/types'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap' import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync' import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import type { ResizeHandleDirection } from './resizeMath' import type { ResizeHandleDirection } from './resizeMath'
import { createResizeSession, toCanvasDelta } from './resizeMath' import { createResizeSession, toCanvasDelta } from './resizeMath'
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
interface UseNodeResizeOptions { interface UseNodeResizeOptions {
/** Transform state for coordinate conversion */ /** Transform state for coordinate conversion */
transformState: TransformState transformState: ReturnType<typeof useTransformState>
} }
interface ResizeCallbackPayload { interface ResizeCallbackPayload {

View 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
}
}

View File

@@ -1,15 +1,10 @@
import { storeToRefs } from 'pinia' import { computed, toValue } from 'vue'
import { computed, inject, ref, toValue } from 'vue' import type { MaybeRefOrGetter } from 'vue'
import type { CSSProperties, 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 { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeBoundsUpdate, Point } from '@/renderer/core/layout/types' import type { Point } from '@/renderer/core/layout/types'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
/** /**
* Composable for individual Vue node components * Composable for individual Vue node components
@@ -18,16 +13,6 @@ import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useS
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) { export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
const nodeId = toValue(nodeIdMaybe) const nodeId = toValue(nodeIdMaybe)
const mutations = useLayoutMutations() 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) // Get the customRef for this node (shared write access)
const layoutRef = layoutStore.getNodeLayoutRef(nodeId) const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
@@ -41,215 +26,9 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
const size = computed( const size = computed(
() => layoutRef.value?.size ?? { width: 200, height: 100 } () => 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) 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) * Update node position directly (without drag)
*/ */
@@ -260,33 +39,11 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
return { return {
// Reactive state (via customRef) // Reactive state (via customRef)
layoutRef,
position, position,
size, size,
bounds,
isVisible,
zIndex, zIndex,
isDragging,
// Mutations // Mutations
moveNodeTo, 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'
})
)
} }
} }

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
// Create a mock canvas context for transform testing // Create a mock canvas context for transform testing
function createMockCanvasContext() { function createMockCanvasContext() {
@@ -27,10 +28,12 @@ function createMockCanvasContext() {
} }
describe('useTransformState', () => { describe('useTransformState', () => {
let transformState: ReturnType<typeof useTransformState> const transformState = useTransformState()
beforeEach(() => { beforeEach(() => {
transformState = useTransformState() transformState.syncWithCanvas({
ds: { offset: [0, 0] }
} as unknown as LGraphCanvas)
}) })
describe('initial state', () => { describe('initial state', () => {
@@ -179,8 +182,8 @@ describe('useTransformState', () => {
it('should calculate correct screen bounds for a node', () => { it('should calculate correct screen bounds for a node', () => {
const { getNodeScreenBounds } = transformState const { getNodeScreenBounds } = transformState
const nodePos = [10, 20] const nodePos: [number, number] = [10, 20]
const nodeSize = [200, 100] const nodeSize: [number, number] = [200, 100]
const bounds = getNodeScreenBounds(nodePos, nodeSize) const bounds = getNodeScreenBounds(nodePos, nodeSize)
// Top-left: canvasToScreen(10, 20) = (220, 140) // Top-left: canvasToScreen(10, 20) = (220, 140)
@@ -206,8 +209,8 @@ describe('useTransformState', () => {
it('should return true for nodes inside viewport', () => { it('should return true for nodes inside viewport', () => {
const { isNodeInViewport } = transformState const { isNodeInViewport } = transformState
const nodePos = [100, 100] const nodePos: [number, number] = [100, 100]
const nodeSize = [200, 100] const nodeSize: [number, number] = [200, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true) expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
}) })
@@ -232,8 +235,8 @@ describe('useTransformState', () => {
const { isNodeInViewport } = transformState const { isNodeInViewport } = transformState
// Node slightly outside but within margin // Node slightly outside but within margin
const nodePos = [-50, -50] const nodePos: [number, number] = [-50, -50]
const nodeSize = [100, 100] const nodeSize: [number, number] = [100, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true) expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
}) })
@@ -242,8 +245,8 @@ describe('useTransformState', () => {
const { isNodeInViewport } = transformState const { isNodeInViewport } = transformState
// Node is in viewport but too small // Node is in viewport but too small
const nodePos = [100, 100] const nodePos: [number, number] = [100, 100]
const nodeSize = [3, 3] // Less than 4 pixels const nodeSize: [number, number] = [3, 3] // Less than 4 pixels
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false) expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
}) })

View File

@@ -1,483 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
// Mock canvas context for testing
const createMockCanvasContext = () => ({
ds: {
offset: [0, 0] as [number, number],
scale: 1
}
})
// Skip this entire suite on CI to avoid flaky performance timing
const isCI = Boolean(process.env.CI)
const describeIfNotCI = isCI ? describe.skip : describe
describeIfNotCI.skip('Transform Performance', () => {
let transformState: ReturnType<typeof useTransformState>
let mockCanvas: any
beforeEach(() => {
transformState = useTransformState()
mockCanvas = createMockCanvasContext()
})
describe('coordinate conversion performance', () => {
it('should handle large batches of coordinate conversions efficiently', () => {
// Set up a realistic transform state
mockCanvas.ds.offset = [500, 300]
mockCanvas.ds.scale = 1.5
transformState.syncWithCanvas(mockCanvas)
const conversionCount = 10000
const points = Array.from({ length: conversionCount }, () => ({
x: Math.random() * 5000,
y: Math.random() * 3000
}))
// Benchmark canvas to screen conversions
const canvasToScreenStart = performance.now()
const screenPoints = points.map((point) =>
transformState.canvasToScreen(point)
)
const canvasToScreenTime = performance.now() - canvasToScreenStart
// Benchmark screen to canvas conversions
const screenToCanvasStart = performance.now()
const backToCanvas = screenPoints.map((point) =>
transformState.screenToCanvas(point)
)
const screenToCanvasTime = performance.now() - screenToCanvasStart
// Performance expectations
expect(canvasToScreenTime).toBeLessThan(20) // 10k conversions in under 20ms
expect(screenToCanvasTime).toBeLessThan(20) // 10k conversions in under 20ms
// Verify accuracy of round-trip conversion
const maxError = points.reduce((max, original, i) => {
const converted = backToCanvas[i]
const errorX = Math.abs(original.x - converted.x)
const errorY = Math.abs(original.y - converted.y)
return Math.max(max, errorX, errorY)
}, 0)
expect(maxError).toBeLessThan(0.001) // Sub-pixel accuracy
})
it('should maintain performance across different zoom levels', () => {
const zoomLevels = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]
const conversionCount = 1000
const testPoints = Array.from({ length: conversionCount }, () => ({
x: Math.random() * 2000,
y: Math.random() * 1500
}))
const performanceResults: number[] = []
zoomLevels.forEach((scale) => {
mockCanvas.ds.scale = scale
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
testPoints.forEach((point) => {
const screen = transformState.canvasToScreen(point)
transformState.screenToCanvas(screen)
})
const duration = performance.now() - startTime
performanceResults.push(duration)
})
// Performance should be consistent across zoom levels
const maxTime = Math.max(...performanceResults)
const minTime = Math.min(...performanceResults)
const variance = (maxTime - minTime) / minTime
expect(maxTime).toBeLessThan(20) // All zoom levels under 20ms
expect(variance).toBeLessThan(3.0) // Less than 300% variance between zoom levels
})
it('should handle extreme coordinate values efficiently', () => {
// Test with very large coordinate values
const extremePoints = [
{ x: -100000, y: -100000 },
{ x: 100000, y: 100000 },
{ x: 0, y: 0 },
{ x: -50000, y: 50000 },
{ x: 1e6, y: -1e6 }
]
// Test at extreme zoom levels
const extremeScales = [0.001, 1000]
extremeScales.forEach((scale) => {
mockCanvas.ds.scale = scale
mockCanvas.ds.offset = [1000, 500]
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
// Convert each point 100 times
extremePoints.forEach((point) => {
for (let i = 0; i < 100; i++) {
const screen = transformState.canvasToScreen(point)
transformState.screenToCanvas(screen)
}
})
const duration = performance.now() - startTime
expect(duration).toBeLessThan(5) // Should handle extremes efficiently
expect(
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).x)
).toBe(true)
expect(
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).y)
).toBe(true)
})
})
})
describe('viewport culling performance', () => {
it('should efficiently determine node visibility for large numbers of nodes', () => {
// Set up realistic viewport
const viewport = { width: 1920, height: 1080 }
// Generate many node positions
const nodeCount = 1000
const nodes = Array.from({ length: nodeCount }, () => ({
pos: [Math.random() * 10000, Math.random() * 6000] as ArrayLike<number>,
size: [
150 + Math.random() * 100,
100 + Math.random() * 50
] as ArrayLike<number>
}))
// Test at different zoom levels and positions
const testConfigs = [
{ scale: 0.5, offset: [0, 0] },
{ scale: 1.0, offset: [2000, 1000] },
{ scale: 2.0, offset: [-1000, -500] }
]
testConfigs.forEach((config) => {
mockCanvas.ds.scale = config.scale
mockCanvas.ds.offset = config.offset
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
// Test viewport culling for all nodes
const visibleNodes = nodes.filter((node) =>
transformState.isNodeInViewport(node.pos, node.size, viewport)
)
const cullTime = performance.now() - startTime
expect(cullTime).toBeLessThan(10) // 1000 nodes culled in under 10ms
expect(visibleNodes.length).toBeLessThan(nodeCount) // Some culling should occur
expect(visibleNodes.length).toBeGreaterThanOrEqual(0) // Sanity check
})
})
it('should optimize culling with adaptive margins', () => {
const viewport = { width: 1280, height: 720 }
const testNode = {
pos: [1300, 100] as ArrayLike<number>, // Just outside viewport
size: [200, 100] as ArrayLike<number>
}
// Test margin adaptation at different zoom levels
const zoomTests = [
{ scale: 0.05, expectedVisible: true }, // Low zoom, larger margin
{ scale: 1.0, expectedVisible: true }, // Normal zoom, standard margin
{ scale: 4.0, expectedVisible: false } // High zoom, tighter margin
]
const marginTests: boolean[] = []
const timings: number[] = []
zoomTests.forEach((test) => {
mockCanvas.ds.scale = test.scale
mockCanvas.ds.offset = [0, 0]
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
const isVisible = transformState.isNodeInViewport(
testNode.pos,
testNode.size,
viewport,
0.2 // 20% margin
)
const duration = performance.now() - startTime
marginTests.push(isVisible)
timings.push(duration)
})
// All culling operations should be very fast
timings.forEach((time) => {
expect(time).toBeLessThan(0.1) // Individual culling under 0.1ms
})
// Verify adaptive behavior (margins should work as expected)
expect(marginTests[0]).toBe(zoomTests[0].expectedVisible)
expect(marginTests[2]).toBe(zoomTests[2].expectedVisible)
})
it('should handle size-based culling efficiently', () => {
// Test nodes of various sizes
const nodeSizes = [
[1, 1], // Tiny node
[5, 5], // Small node
[50, 50], // Medium node
[200, 100], // Large node
[500, 300] // Very large node
]
const viewport = { width: 1920, height: 1080 }
// Position all nodes in viewport center
const centerPos = [960, 540] as ArrayLike<number>
nodeSizes.forEach((size) => {
// Test at very low zoom where size culling should activate
mockCanvas.ds.scale = 0.01 // Very low zoom
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
const isVisible = transformState.isNodeInViewport(
centerPos,
size as ArrayLike<number>,
viewport
)
const cullTime = performance.now() - startTime
expect(cullTime).toBeLessThan(0.1) // Size culling under 0.1ms
// At 0.01 zoom, nodes need to be 400+ pixels to show as 4+ screen pixels
const screenSize = Math.max(size[0], size[1]) * 0.01
if (screenSize < 4) {
expect(isVisible).toBe(false)
} else {
expect(isVisible).toBe(true)
}
})
})
})
describe('transform state synchronization', () => {
it('should efficiently sync with canvas state changes', () => {
const syncCount = 1000
const transformUpdates = Array.from({ length: syncCount }, (_, i) => ({
offset: [Math.sin(i * 0.1) * 1000, Math.cos(i * 0.1) * 500],
scale: 0.5 + Math.sin(i * 0.05) * 0.4 // Scale between 0.1 and 0.9
}))
const startTime = performance.now()
transformUpdates.forEach((update) => {
mockCanvas.ds.offset = update.offset
mockCanvas.ds.scale = update.scale
transformState.syncWithCanvas(mockCanvas)
})
const syncTime = performance.now() - startTime
expect(syncTime).toBeLessThan(15) // 1000 syncs in under 15ms
// Verify final state is correct
const lastUpdate = transformUpdates[transformUpdates.length - 1]
expect(transformState.camera.x).toBe(lastUpdate.offset[0])
expect(transformState.camera.y).toBe(lastUpdate.offset[1])
expect(transformState.camera.z).toBe(lastUpdate.scale)
})
it('should generate CSS transform strings efficiently', () => {
const transformCount = 10000
// Set up varying transform states
const transforms = Array.from({ length: transformCount }, (_, i) => {
mockCanvas.ds.offset = [i * 10, i * 5]
mockCanvas.ds.scale = 0.5 + (i % 100) / 100
transformState.syncWithCanvas(mockCanvas)
return transformState.transformStyle.value
})
const startTime = performance.now()
// Access transform styles (triggers computed property)
transforms.forEach((style) => {
expect(style.transform).toContain('scale(')
expect(style.transform).toContain('translate(')
expect(style.transformOrigin).toBe('0 0')
})
const accessTime = performance.now() - startTime
expect(accessTime).toBeLessThan(200) // 10k style accesses in under 200ms
})
})
describe('bounds calculation performance', () => {
it('should calculate node screen bounds efficiently', () => {
// Set up realistic transform
mockCanvas.ds.offset = [200, 100]
mockCanvas.ds.scale = 1.5
transformState.syncWithCanvas(mockCanvas)
const nodeCount = 1000
const nodes = Array.from({ length: nodeCount }, () => ({
pos: [Math.random() * 5000, Math.random() * 3000] as ArrayLike<number>,
size: [
100 + Math.random() * 200,
80 + Math.random() * 120
] as ArrayLike<number>
}))
const startTime = performance.now()
const bounds = nodes.map((node) =>
transformState.getNodeScreenBounds(node.pos, node.size)
)
const calcTime = performance.now() - startTime
expect(calcTime).toBeLessThan(15) // 1000 bounds calculations in under 15ms
expect(bounds).toHaveLength(nodeCount)
// Verify bounds are reasonable
bounds.forEach((bound) => {
expect(bound.width).toBeGreaterThan(0)
expect(bound.height).toBeGreaterThan(0)
expect(Number.isFinite(bound.x)).toBe(true)
expect(Number.isFinite(bound.y)).toBe(true)
})
})
it('should calculate viewport bounds efficiently', () => {
const viewportSizes = [
{ width: 800, height: 600 },
{ width: 1920, height: 1080 },
{ width: 3840, height: 2160 },
{ width: 1280, height: 720 }
]
const margins = [0, 0.1, 0.2, 0.5]
const combinations = viewportSizes.flatMap((viewport) =>
margins.map((margin) => ({ viewport, margin }))
)
const startTime = performance.now()
const allBounds = combinations.map(({ viewport, margin }) => {
mockCanvas.ds.offset = [Math.random() * 1000, Math.random() * 500]
mockCanvas.ds.scale = 0.5 + Math.random() * 2
transformState.syncWithCanvas(mockCanvas)
return transformState.getViewportBounds(viewport, margin)
})
const calcTime = performance.now() - startTime
expect(calcTime).toBeLessThan(5) // All viewport calculations in under 5ms
expect(allBounds).toHaveLength(combinations.length)
// Verify bounds are reasonable
allBounds.forEach((bounds) => {
expect(bounds.width).toBeGreaterThan(0)
expect(bounds.height).toBeGreaterThan(0)
expect(Number.isFinite(bounds.x)).toBe(true)
expect(Number.isFinite(bounds.y)).toBe(true)
})
})
})
describe('real-world performance scenarios', () => {
it('should handle smooth panning performance', () => {
// Simulate smooth 60fps panning for 2 seconds
const frameCount = 120 // 2 seconds at 60fps
const panDistance = 2000 // Pan 2000 pixels
const frames: number[] = []
for (let frame = 0; frame < frameCount; frame++) {
const progress = frame / (frameCount - 1)
const x = progress * panDistance
const y = Math.sin(progress * Math.PI * 2) * 200 // Slight vertical wave
mockCanvas.ds.offset = [x, y]
const frameStart = performance.now()
// Typical operations during panning
transformState.syncWithCanvas(mockCanvas)
const style = transformState.transformStyle.value // Access transform style
expect(style.transform).toContain('translate') // Verify style is valid
// Simulate some coordinate conversions (mouse tracking, etc.)
for (let i = 0; i < 5; i++) {
const screen = transformState.canvasToScreen({
x: x + i * 100,
y: y + i * 50
})
transformState.screenToCanvas(screen)
}
const frameTime = performance.now() - frameStart
frames.push(frameTime)
// Each frame should be well under 16.67ms for 60fps
expect(frameTime).toBeLessThan(1) // Conservative: under 1ms per frame
}
const totalTime = frames.reduce((sum, time) => sum + time, 0)
const avgFrameTime = totalTime / frameCount
expect(avgFrameTime).toBeLessThan(0.5) // Average frame time under 0.5ms
expect(totalTime).toBeLessThan(60) // Total panning overhead under 60ms
})
it('should handle zoom performance with viewport updates', () => {
// Simulate smooth zoom from 0.1x to 10x
const zoomSteps = 100
const viewport = { width: 1920, height: 1080 }
const zoomTimes: number[] = []
for (let step = 0; step < zoomSteps; step++) {
const zoomLevel = Math.pow(10, (step / (zoomSteps - 1)) * 2 - 1) // 0.1 to 10
mockCanvas.ds.scale = zoomLevel
const stepStart = performance.now()
// Operations during zoom
transformState.syncWithCanvas(mockCanvas)
// Viewport bounds calculation (for culling)
transformState.getViewportBounds(viewport, 0.2)
// Test a few nodes for visibility
for (let i = 0; i < 10; i++) {
transformState.isNodeInViewport(
[i * 200, i * 150],
[200, 100],
viewport
)
}
const stepTime = performance.now() - stepStart
zoomTimes.push(stepTime)
}
const maxZoomTime = Math.max(...zoomTimes)
const avgZoomTime =
zoomTimes.reduce((sum, time) => sum + time, 0) / zoomSteps
expect(maxZoomTime).toBeLessThan(2) // No zoom step over 2ms
expect(avgZoomTime).toBeLessThan(1) // Average zoom step under 1ms
})
})
})

View File

@@ -6,7 +6,6 @@ import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
@@ -15,6 +14,17 @@ const mockData = vi.hoisted(() => ({
mockExecuting: false mockExecuting: false
})) }))
vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
return {
useTransformState: () => ({
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
})
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => { vi.mock('@/renderer/core/canvas/canvasStore', () => {
const getCanvas = vi.fn() const getCanvas = vi.fn()
const useCanvasStore = () => ({ const useCanvasStore = () => ({
@@ -105,14 +115,7 @@ function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
}), }),
i18n i18n
], ],
provide: {
[TransformStateKey as symbol]: {
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
}
},
stubs: { stubs: {
NodeHeader: true, NodeHeader: true,
NodeSlots: true, NodeSlots: true,
@@ -172,14 +175,6 @@ describe('LGraphNode', () => {
}), }),
i18n i18n
], ],
provide: {
[TransformStateKey as symbol]: {
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
}
},
stubs: { stubs: {
NodeSlots: true, NodeSlots: true,
NodeWidgets: true, NodeWidgets: true,

View File

@@ -2,10 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, shallowRef } from 'vue' import { computed, shallowRef } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
GraphNodeManager,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import type { import type {
LGraph, LGraph,
@@ -81,18 +78,10 @@ describe('useNodeEventHandlers', () => {
const mockNode = mockNodeManager.value!.getNode('fake_id') const mockNode = mockNodeManager.value!.getNode('fake_id')
const mockLayoutMutations = useLayoutMutations() const mockLayoutMutations = useLayoutMutations()
const testNodeData: VueNodeData = { const testNodeId = 'node-1'
id: 'node-1',
title: 'Test Node',
type: 'test',
mode: 0,
selected: false,
executing: false
}
beforeEach(async () => { beforeEach(async () => {
vi.restoreAllMocks() vi.resetAllMocks()
vi.clearAllMocks()
canvasSelectedItems.length = 0 canvasSelectedItems.length = 0
}) })
@@ -107,7 +96,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false metaKey: false
}) })
handleNodeSelect(event, testNodeData) handleNodeSelect(event, testNodeId)
expect(canvas?.deselectAll).toHaveBeenCalledOnce() expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode) expect(canvas?.select).toHaveBeenCalledWith(mockNode)
@@ -126,7 +115,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false metaKey: false
}) })
handleNodeSelect(ctrlClickEvent, testNodeData) handleNodeSelect(ctrlClickEvent, testNodeId)
// On pointer down with multi-select: bring to front // On pointer down with multi-select: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
@@ -152,7 +141,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false metaKey: false
}) })
handleNodeSelect(ctrlClickEvent, testNodeData) handleNodeSelect(ctrlClickEvent, testNodeId)
// On pointer down: bring to front // On pointer down: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
@@ -177,7 +166,7 @@ describe('useNodeEventHandlers', () => {
metaKey: true metaKey: true
}) })
handleNodeSelect(metaClickEvent, testNodeData) handleNodeSelect(metaClickEvent, testNodeId)
// On pointer down with meta key: bring to front // On pointer down with meta key: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
@@ -202,7 +191,7 @@ describe('useNodeEventHandlers', () => {
shiftKey: true shiftKey: true
}) })
handleNodeSelect(shiftClickEvent, testNodeData) handleNodeSelect(shiftClickEvent, testNodeId)
// On pointer down with shift: bring to front // On pointer down with shift: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
@@ -228,7 +217,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false metaKey: false
}) })
handleNodeSelect(event, testNodeData) handleNodeSelect(event, testNodeId)
expect(canvas?.deselectAll).not.toHaveBeenCalled() expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).not.toHaveBeenCalled() expect(canvas?.select).not.toHaveBeenCalled()
@@ -240,7 +229,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.flags.pinned = false mockNode!.flags.pinned = false
const event = new PointerEvent('pointerdown') const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData) handleNodeSelect(event, testNodeId)
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1' 'node-1'
@@ -253,7 +242,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.flags.pinned = true mockNode!.flags.pinned = true
const event = new PointerEvent('pointerdown') const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData) handleNodeSelect(event, testNodeId)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled() expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
}) })
@@ -266,10 +255,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.selected = true mockNode!.selected = true
toggleNodeSelectionAfterPointerUp('node-1', { toggleNodeSelectionAfterPointerUp('node-1', true)
wasSelectedAtPointerDown: true,
multiSelect: true
})
expect(canvas?.deselect).toHaveBeenCalledWith(mockNode) expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce() expect(updateSelectedItems).toHaveBeenCalledOnce()
@@ -281,13 +267,10 @@ describe('useNodeEventHandlers', () => {
mockNode!.selected = true mockNode!.selected = true
toggleNodeSelectionAfterPointerUp('node-1', { toggleNodeSelectionAfterPointerUp('node-1', true)
wasSelectedAtPointerDown: false,
multiSelect: true
})
expect(canvas?.select).not.toHaveBeenCalled() expect(canvas?.select).not.toHaveBeenCalled()
expect(updateSelectedItems).not.toHaveBeenCalled() expect(updateSelectedItems).toHaveBeenCalled()
}) })
it('on pointer up without multi-select: collapses multi-selection to clicked node', () => { it('on pointer up without multi-select: collapses multi-selection to clicked node', () => {
@@ -297,10 +280,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.selected = true mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' }, { id: 'node-2' }) canvasSelectedItems.push({ id: 'node-1' }, { id: 'node-2' })
toggleNodeSelectionAfterPointerUp('node-1', { toggleNodeSelectionAfterPointerUp('node-1', false)
wasSelectedAtPointerDown: true,
multiSelect: false
})
expect(canvas?.deselectAll).toHaveBeenCalledOnce() expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode) expect(canvas?.select).toHaveBeenCalledWith(mockNode)
@@ -314,88 +294,10 @@ describe('useNodeEventHandlers', () => {
mockNode!.selected = true mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' }) canvasSelectedItems.push({ id: 'node-1' })
toggleNodeSelectionAfterPointerUp('node-1', { toggleNodeSelectionAfterPointerUp('node-1', false)
wasSelectedAtPointerDown: true,
multiSelect: false
})
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).not.toHaveBeenCalled()
expect(updateSelectedItems).not.toHaveBeenCalled()
})
})
describe('ensureNodeSelectedForShiftDrag', () => {
it('does nothing when multi-select key is not pressed', () => {
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
const event = new PointerEvent('pointermove', { shiftKey: false })
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
expect(canvas?.select).not.toHaveBeenCalled()
expect(canvas?.deselectAll).not.toHaveBeenCalled()
})
it('selects node and clears existing selection when shift-dragging with no other selections', () => {
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = false
const event = new PointerEvent('pointermove', { shiftKey: true })
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode) expect(canvas?.select).toHaveBeenCalledWith(mockNode)
}) expect(updateSelectedItems).toHaveBeenCalled()
it('adds node to existing multi-selection without clearing other nodes', () => {
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
const { canvas, selectedItems } = useCanvasStore()
// Create mock Positionable objects for existing selection
const mockExisting1 = {
id: 'existing-1',
pos: [0, 0] as [number, number],
move: vi.fn(),
snapToGrid: vi.fn(),
boundingRect: vi.fn(() => [0, 0, 100, 100] as const)
} as unknown as LGraphNode
const mockExisting2 = {
id: 'existing-2',
pos: [0, 0] as [number, number],
move: vi.fn(),
snapToGrid: vi.fn(),
boundingRect: vi.fn(() => [0, 0, 100, 100] as const)
} as unknown as LGraphNode
selectedItems.push(mockExisting1, mockExisting2)
mockNode!.selected = false
if (canvas?.select) vi.mocked(canvas.select).mockClear()
if (canvas?.deselectAll) vi.mocked(canvas.deselectAll).mockClear()
const event = new PointerEvent('pointermove', { shiftKey: true })
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
})
it('does nothing if node is already selected (selection happened on pointer down)', () => {
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = true
const event = new PointerEvent('pointermove', { shiftKey: true })
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
expect(canvas?.select).not.toHaveBeenCalled()
expect(canvas?.deselectAll).not.toHaveBeenCalled()
}) })
}) })
}) })