mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-26 09:19:43 +00:00
Feat: Alt+Drag to clone - Vue Nodes (#6789)
## Summary Replicate the alt+drag to clone behavior present in litegraph. ## Changes - **What**: Simplify the interaction/drag handling, now with less state! - **What**: Alt+Click+Drag a node to clone it ## Screenshots (if applicable) https://github.com/user-attachments/assets/469e33c2-de0c-4e64-a344-1e9d9339d528 <!-- Add screenshots or video recording to help explain your changes --> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6789-WIP-Alt-Drag-to-clone-Vue-Nodes-2b16d73d36508102a871ffe97ed2831f) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { InjectionKey } from 'vue'
|
|
||||||
|
|
||||||
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight, injectable transform state used by layout-aware components.
|
|
||||||
*
|
|
||||||
* Consumers use this interface to convert coordinates between LiteGraph's
|
|
||||||
* canvas space and the DOM's screen space, access the current pan/zoom
|
|
||||||
* (camera), and perform basic viewport culling checks.
|
|
||||||
*
|
|
||||||
* Coordinate mapping:
|
|
||||||
* - screen = (canvas + offset) * scale
|
|
||||||
* - canvas = screen / scale - offset
|
|
||||||
*
|
|
||||||
* The full implementation and additional helpers live in
|
|
||||||
* `useTransformState()`. This interface deliberately exposes only the
|
|
||||||
* minimal surface needed outside that composable.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const state = inject(TransformStateKey)!
|
|
||||||
* const screen = state.canvasToScreen({ x: 100, y: 50 })
|
|
||||||
*/
|
|
||||||
export interface TransformState
|
|
||||||
extends Pick<
|
|
||||||
ReturnType<typeof useTransformState>,
|
|
||||||
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
|
|
||||||
> {}
|
|
||||||
|
|
||||||
export const TransformStateKey: InjectionKey<TransformState> =
|
|
||||||
Symbol('transformState')
|
|
||||||
@@ -17,10 +17,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<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: []
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
215
src/renderer/extensions/vueNodes/layout/useNodeDrag.ts
Normal file
215
src/renderer/extensions/vueNodes/layout/useNodeDrag.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { toValue } from 'vue'
|
||||||
|
|
||||||
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||||
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||||
|
import type {
|
||||||
|
NodeBoundsUpdate,
|
||||||
|
NodeId,
|
||||||
|
Point
|
||||||
|
} from '@/renderer/core/layout/types'
|
||||||
|
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||||
|
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||||
|
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||||
|
import { createSharedComposable } from '@vueuse/core'
|
||||||
|
|
||||||
|
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
|
||||||
|
|
||||||
|
function useNodeDragIndividual() {
|
||||||
|
const mutations = useLayoutMutations()
|
||||||
|
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||||
|
|
||||||
|
// Get transform utilities from TransformPane if available
|
||||||
|
const transformState = useTransformState()
|
||||||
|
|
||||||
|
// Snap-to-grid functionality
|
||||||
|
const { shouldSnap, applySnapToPosition } = useNodeSnap()
|
||||||
|
|
||||||
|
// Shift key sync for LiteGraph canvas preview
|
||||||
|
const { trackShiftKey } = useShiftKeySync()
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
let dragStartPos: Point | null = null
|
||||||
|
let dragStartMouse: Point | null = null
|
||||||
|
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||||
|
let rafId: number | null = null
|
||||||
|
let stopShiftSync: (() => void) | null = null
|
||||||
|
|
||||||
|
function startDrag(event: PointerEvent, nodeId: NodeId) {
|
||||||
|
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||||
|
if (!layout) return
|
||||||
|
const position = layout.position ?? { x: 0, y: 0 }
|
||||||
|
|
||||||
|
// Track shift key state and sync to canvas for snap preview
|
||||||
|
stopShiftSync = trackShiftKey(event)
|
||||||
|
|
||||||
|
dragStartPos = { ...position }
|
||||||
|
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||||
|
|
||||||
|
const selectedNodes = toValue(selectedNodeIds)
|
||||||
|
|
||||||
|
// capture the starting positions of all other selected nodes
|
||||||
|
if (selectedNodes?.has(nodeId) && selectedNodes.size > 1) {
|
||||||
|
otherSelectedNodesStartPositions = new Map()
|
||||||
|
|
||||||
|
for (const id of selectedNodes) {
|
||||||
|
// Skip the current node being dragged
|
||||||
|
if (id === nodeId) continue
|
||||||
|
|
||||||
|
const nodeLayout = layoutStore.getNodeLayoutRef(id).value
|
||||||
|
if (nodeLayout) {
|
||||||
|
otherSelectedNodesStartPositions.set(id, { ...nodeLayout.position })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
otherSelectedNodesStartPositions = null
|
||||||
|
}
|
||||||
|
|
||||||
|
mutations.setSource(LayoutSource.Vue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrag(event: PointerEvent, nodeId: NodeId) {
|
||||||
|
if (!dragStartPos || !dragStartMouse) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle position updates using requestAnimationFrame for better performance
|
||||||
|
if (rafId !== null) return // Skip if frame already scheduled
|
||||||
|
|
||||||
|
const { target, pointerId } = event
|
||||||
|
if (target instanceof HTMLElement && !target.hasPointerCapture(pointerId)) {
|
||||||
|
// Delay capture to drag to allow for the Node cloning
|
||||||
|
target.setPointerCapture(pointerId)
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = null
|
||||||
|
|
||||||
|
if (!dragStartPos || !dragStartMouse) return
|
||||||
|
|
||||||
|
// Calculate mouse delta in screen coordinates
|
||||||
|
const mouseDelta = {
|
||||||
|
x: event.clientX - dragStartMouse.x,
|
||||||
|
y: event.clientY - dragStartMouse.y
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to canvas coordinates
|
||||||
|
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
|
||||||
|
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
|
||||||
|
const canvasDelta = {
|
||||||
|
x: canvasWithDelta.x - canvasOrigin.x,
|
||||||
|
y: canvasWithDelta.y - canvasOrigin.y
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new position for the current node
|
||||||
|
const newPosition = {
|
||||||
|
x: dragStartPos.x + canvasDelta.x,
|
||||||
|
y: dragStartPos.y + canvasDelta.y
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply mutation through the layout system (Vue batches DOM updates automatically)
|
||||||
|
mutations.moveNode(nodeId, newPosition)
|
||||||
|
|
||||||
|
// If we're dragging multiple selected nodes, move them all together
|
||||||
|
if (
|
||||||
|
otherSelectedNodesStartPositions &&
|
||||||
|
otherSelectedNodesStartPositions.size > 0
|
||||||
|
) {
|
||||||
|
for (const [
|
||||||
|
otherNodeId,
|
||||||
|
startPos
|
||||||
|
] of otherSelectedNodesStartPositions) {
|
||||||
|
const newOtherPosition = {
|
||||||
|
x: startPos.x + canvasDelta.x,
|
||||||
|
y: startPos.y + canvasDelta.y
|
||||||
|
}
|
||||||
|
mutations.moveNode(otherNodeId, newOtherPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(event: PointerEvent, nodeId: NodeId | undefined) {
|
||||||
|
// Apply snap to final position if snap was active (matches LiteGraph behavior)
|
||||||
|
if (shouldSnap(event) && nodeId) {
|
||||||
|
const boundsUpdates: NodeBoundsUpdate[] = []
|
||||||
|
|
||||||
|
// Snap main node
|
||||||
|
const currentLayout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||||
|
if (currentLayout) {
|
||||||
|
const currentPos = currentLayout.position
|
||||||
|
const snappedPos = applySnapToPosition({ ...currentPos })
|
||||||
|
|
||||||
|
// Only add update if position actually changed
|
||||||
|
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
|
||||||
|
boundsUpdates.push({
|
||||||
|
nodeId,
|
||||||
|
bounds: {
|
||||||
|
x: snappedPos.x,
|
||||||
|
y: snappedPos.y,
|
||||||
|
width: currentLayout.size.width,
|
||||||
|
height: currentLayout.size.height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also snap other selected nodes
|
||||||
|
// Capture all positions at the start to ensure consistent state
|
||||||
|
if (
|
||||||
|
otherSelectedNodesStartPositions &&
|
||||||
|
otherSelectedNodesStartPositions.size > 0
|
||||||
|
) {
|
||||||
|
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
|
||||||
|
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
|
||||||
|
if (nodeLayout) {
|
||||||
|
const currentPos = { ...nodeLayout.position }
|
||||||
|
const snappedPos = applySnapToPosition(currentPos)
|
||||||
|
|
||||||
|
// Only add update if position actually changed
|
||||||
|
if (
|
||||||
|
snappedPos.x !== currentPos.x ||
|
||||||
|
snappedPos.y !== currentPos.y
|
||||||
|
) {
|
||||||
|
boundsUpdates.push({
|
||||||
|
nodeId: otherNodeId,
|
||||||
|
bounds: {
|
||||||
|
x: snappedPos.x,
|
||||||
|
y: snappedPos.y,
|
||||||
|
width: nodeLayout.size.width,
|
||||||
|
height: nodeLayout.size.height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply all snap updates in a single batched transaction
|
||||||
|
if (boundsUpdates.length > 0) {
|
||||||
|
layoutStore.batchUpdateNodeBounds(boundsUpdates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dragStartPos = null
|
||||||
|
dragStartMouse = null
|
||||||
|
otherSelectedNodesStartPositions = null
|
||||||
|
|
||||||
|
// Stop tracking shift key state
|
||||||
|
stopShiftSync?.()
|
||||||
|
stopShiftSync = null
|
||||||
|
|
||||||
|
// Cancel any pending animation frame
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
rafId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDrag,
|
||||||
|
handleDrag,
|
||||||
|
endDrag
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import { storeToRefs } from 'pinia'
|
import { computed, 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'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user