Refactor: Composable disentangling (#5695)

## Summary

Prerequisite refactor/cleanup to use a global store instead of having
nodes throw up events to a parent component that stores a reference to a
singleton service that itself bootstraps and synchronizes with a
separate service to maintain a partially reactive but not fully reactive
set of states that describe some but not all aspects of the nodes on
either the litegraph, the vue side, or both.

## Changes

- **What**: Refactoring, the behavior should not change.
- **Dependencies**: A type utility to help with Vue component props

## Review Focus

Is there something about the current structure that this could affect
that would not be caught by our tests or using the application?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5695-Refactor-Composable-disentangling-2746d73d365081e6938ce656932f3e36)
by [Unito](https://www.unito.io)
This commit is contained in:
Alexander Brown
2025-09-20 13:06:42 -07:00
committed by GitHub
parent fd12591756
commit 8133bd4b7b
21 changed files with 339 additions and 400 deletions

1
.gitignore vendored
View File

@@ -44,6 +44,7 @@ components.d.ts
tests-ui/data/* tests-ui/data/*
tests-ui/ComfyUI_examples tests-ui/ComfyUI_examples
tests-ui/workflows/examples tests-ui/workflows/examples
coverage/
# Browser tests # Browser tests
/test-results/ /test-results/

View File

@@ -83,6 +83,13 @@ export default defineConfig([
'vue/no-restricted-class': ['error', '/^dark:/'], 'vue/no-restricted-class': ['error', '/^dark:/'],
'vue/multi-word-component-names': 'off', // TODO: fix 'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix 'vue/no-template-shadow': 'off', // TODO: fix
/* Toggle on to do additional until we can clean up existing violations.
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-properties': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/no-useless-v-bind': 'error',
// */
'vue/one-component-per-file': 'off', // TODO: fix 'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile 'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components // Restrict deprecated PrimeVue components

View File

@@ -96,6 +96,7 @@
"vite-plugin-html": "^3.2.2", "vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6", "vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"vue-component-type-helpers": "^3.0.7",
"vue-eslint-parser": "^10.2.0", "vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.7", "vue-tsc": "^3.0.7",
"zip-dir": "^2.0.0", "zip-dir": "^2.0.0",

3
pnpm-lock.yaml generated
View File

@@ -339,6 +339,9 @@ importers:
vitest: vitest:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2) version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
vue-component-type-helpers:
specifier: ^3.0.7
version: 3.0.7
vue-eslint-parser: vue-eslint-parser:
specifier: ^10.2.0 specifier: ^10.2.0
version: 10.2.0(eslint@9.35.0(jiti@2.4.2)) version: 10.2.0(eslint@9.35.0(jiti@2.4.2))

View File

@@ -33,7 +33,7 @@
<!-- TransformPane for Vue node rendering --> <!-- TransformPane for Vue node rendering -->
<TransformPane <TransformPane
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady" v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas" :canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate" @transform-update="handleTransformUpdate"
@wheel.capture="canvasInteractions.forwardEventToCanvas" @wheel.capture="canvasInteractions.forwardEventToCanvas"
@@ -79,7 +79,6 @@ import {
nextTick, nextTick,
onMounted, onMounted,
onUnmounted, onUnmounted,
provide,
ref, ref,
shallowRef, shallowRef,
watch, watch,
@@ -117,7 +116,6 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave' import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence' import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer' import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue' import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
@@ -171,20 +169,14 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
// Feature flags // Feature flags
const { shouldRenderVueNodes } = useVueFeatureFlags() const { shouldRenderVueNodes } = useVueFeatureFlags()
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
// Vue node system // Vue node system
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled) const vueNodeLifecycle = useVueNodeLifecycle()
const viewportCulling = useViewportCulling( const viewportCulling = useViewportCulling()
isVueNodesEnabled, const nodeEventHandlers = useNodeEventHandlers()
vueNodeLifecycle.vueNodeData,
vueNodeLifecycle.nodeDataTrigger,
vueNodeLifecycle.nodeManager
)
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
const handleVueNodeLifecycleReset = async () => { const handleVueNodeLifecycleReset = async () => {
if (isVueNodesEnabled.value) { if (shouldRenderVueNodes.value) {
vueNodeLifecycle.disposeNodeManagerAndSyncs() vueNodeLifecycle.disposeNodeManagerAndSyncs()
await nextTick() await nextTick()
vueNodeLifecycle.initializeNodeManager() vueNodeLifecycle.initializeNodeManager()
@@ -216,17 +208,6 @@ const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
canvasStore.selectedItems
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
provide(SelectedNodeIdsKey, selectedNodeIds)
// Provide execution state to all Vue nodes // Provide execution state to all Vue nodes
useExecutionStateProvider() useExecutionStateProvider()

View File

@@ -68,7 +68,7 @@ interface SpatialMetrics {
nodesInIndex: number nodesInIndex: number
} }
interface GraphNodeManager { export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes // Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<string, VueNodeData> vueNodeData: ReadonlyMap<string, VueNodeData>
nodeState: ReadonlyMap<string, NodeState> nodeState: ReadonlyMap<string, NodeState>

View File

@@ -6,26 +6,20 @@
* 2. Set display none on element to avoid cascade resolution overhead * 2. Set display none on element to avoid cascade resolution overhead
* 3. Only run when transform changes (event driven) * 3. Only run when transform changes (event driven)
*/ */
import { type Ref, computed } from 'vue' import { computed } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app as comfyApp } from '@/scripts/app' import { app as comfyApp } from '@/scripts/app'
interface NodeManager { export function useViewportCulling() {
getNode: (id: string) => any
}
export function useViewportCulling(
isVueNodesEnabled: Ref<boolean>,
vueNodeData: Ref<ReadonlyMap<string, VueNodeData>>,
nodeDataTrigger: Ref<number>,
nodeManager: Ref<NodeManager | null>
) {
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { vueNodeData, nodeDataTrigger, nodeManager } = useVueNodeLifecycle()
const allNodes = computed(() => { const allNodes = computed(() => {
if (!isVueNodesEnabled.value) return [] if (!shouldRenderVueNodes.value) return []
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
return Array.from(vueNodeData.value.values()) return Array.from(vueNodeData.value.values())
}) })
@@ -84,7 +78,7 @@ export function useViewportCulling(
* Uses RAF to batch updates for smooth performance * Uses RAF to batch updates for smooth performance
*/ */
const handleTransformUpdate = () => { const handleTransformUpdate = () => {
if (!isVueNodesEnabled.value) return if (!shouldRenderVueNodes.value) return
// Cancel previous RAF if still pending // Cancel previous RAF if still pending
if (rafId !== null) { if (rafId !== null) {

View File

@@ -8,13 +8,16 @@
* - Reactive state management for node data, positions, and sizes * - Reactive state management for node data, positions, and sizes
* - Memory management and proper cleanup * - Memory management and proper cleanup
*/ */
import { type Ref, computed, readonly, ref, shallowRef, watch } from 'vue' import { createSharedComposable } from '@vueuse/core'
import { computed, readonly, ref, shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { import type {
GraphNodeManager,
NodeState, NodeState,
VueNodeData VueNodeData
} from '@/composables/graph/useGraphNodeManager' } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -24,13 +27,12 @@ import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync' import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
import { app as comfyApp } from '@/scripts/app' import { app as comfyApp } from '@/scripts/app'
export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) { function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations() const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<ReturnType<typeof useGraphNodeManager> | null>( const nodeManager = shallowRef<GraphNodeManager | null>(null)
null
)
const cleanupNodeManager = shallowRef<(() => void) | null>(null) const cleanupNodeManager = shallowRef<(() => void) | null>(null)
// Sync management // Sync management
@@ -145,7 +147,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Watch for Vue nodes enabled state changes // Watch for Vue nodes enabled state changes
watch( watch(
() => () =>
isVueNodesEnabled.value && shouldRenderVueNodes.value &&
Boolean(comfyApp.canvas?.graph || comfyApp.graph), Boolean(comfyApp.canvas?.graph || comfyApp.graph),
(enabled) => { (enabled) => {
if (enabled) { if (enabled) {
@@ -159,7 +161,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Consolidated watch for slot layout sync management // Consolidated watch for slot layout sync management
watch( watch(
[() => canvasStore.canvas, () => isVueNodesEnabled.value], [() => canvasStore.canvas, () => shouldRenderVueNodes.value],
([canvas, vueMode], [, oldVueMode]) => { ([canvas, vueMode], [, oldVueMode]) => {
const modeChanged = vueMode !== oldVueMode const modeChanged = vueMode !== oldVueMode
@@ -191,7 +193,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Handle case where Vue nodes are enabled but graph starts empty // Handle case where Vue nodes are enabled but graph starts empty
const setupEmptyGraphListener = () => { const setupEmptyGraphListener = () => {
if ( if (
isVueNodesEnabled.value && shouldRenderVueNodes.value &&
comfyApp.graph && comfyApp.graph &&
!nodeManager.value && !nodeManager.value &&
comfyApp.graph._nodes.length === 0 comfyApp.graph._nodes.length === 0
@@ -202,7 +204,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
comfyApp.graph.onNodeAdded = originalOnNodeAdded comfyApp.graph.onNodeAdded = originalOnNodeAdded
// Initialize node manager if needed // Initialize node manager if needed
if (isVueNodesEnabled.value && !nodeManager.value) { if (shouldRenderVueNodes.value && !nodeManager.value) {
initializeNodeManager() initializeNodeManager()
} }
@@ -248,3 +250,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
cleanup cleanup
} }
} }
export const useVueNodeLifecycle = createSharedComposable(
useVueNodeLifecycleIndividual
)

View File

@@ -2,16 +2,17 @@
* Vue-related feature flags composable * Vue-related feature flags composable
* Manages local settings-driven flags and LiteGraph integration * Manages local settings-driven flags and LiteGraph integration
*/ */
import { createSharedComposable } from '@vueuse/core'
import { computed, watch } from 'vue' import { computed, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { LiteGraph } from '../lib/litegraph/src/litegraph' import { LiteGraph } from '../lib/litegraph/src/litegraph'
export const useVueFeatureFlags = () => { function useVueFeatureFlagsIndividual() {
const settingStore = useSettingStore() const settingStore = useSettingStore()
const isVueNodesEnabled = computed(() => { const shouldRenderVueNodes = computed(() => {
try { try {
return settingStore.get('Comfy.VueNodes.Enabled') ?? false return settingStore.get('Comfy.VueNodes.Enabled') ?? false
} catch { } catch {
@@ -19,20 +20,20 @@ export const useVueFeatureFlags = () => {
} }
}) })
// Whether Vue nodes should render
const shouldRenderVueNodes = computed(() => isVueNodesEnabled.value)
// Sync the Vue nodes flag with LiteGraph global settings
const syncVueNodesFlag = () => {
LiteGraph.vueNodesMode = isVueNodesEnabled.value
}
// Watch for changes and update LiteGraph immediately // Watch for changes and update LiteGraph immediately
watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true }) watch(
shouldRenderVueNodes,
() => {
LiteGraph.vueNodesMode = shouldRenderVueNodes.value
},
{ immediate: true }
)
return { return {
isVueNodesEnabled, shouldRenderVueNodes
shouldRenderVueNodes,
syncVueNodesFlag
} }
} }
export const useVueFeatureFlags = createSharedComposable(
useVueFeatureFlagsIndividual
)

View File

@@ -99,6 +99,16 @@ export const useCanvasStore = defineStore('canvas', () => {
const currentGraph = shallowRef<LGraph | null>(null) const currentGraph = shallowRef<LGraph | null>(null)
const isInSubgraph = ref(false) const isInSubgraph = ref(false)
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
selectedItems.value
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
whenever( whenever(
() => canvas.value, () => canvas.value,
(newCanvas) => { (newCanvas) => {
@@ -122,6 +132,7 @@ export const useCanvasStore = defineStore('canvas', () => {
return { return {
canvas, canvas,
selectedItems, selectedItems,
selectedNodeIds,
nodeSelected, nodeSelected,
groupSelected, groupSelected,
rerouteSelected, rerouteSelected,

View File

@@ -2,13 +2,6 @@ import type { InjectionKey, Ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema' import type { NodeProgressState } from '@/schemas/apiSchema'
/**
* Injection key for providing selected node IDs to Vue node components.
* Contains a reactive Set of selected node IDs (as strings).
*/
export const SelectedNodeIdsKey: InjectionKey<Ref<Set<string>>> =
Symbol('selectedNodeIds')
/** /**
* Injection key for providing executing node IDs to Vue node components. * Injection key for providing executing node IDs to Vue node components.
* Contains a reactive Set of currently executing node IDs (as strings). * Contains a reactive Set of currently executing node IDs (as strings).

View File

@@ -139,12 +139,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia'
import { import {
computed, computed,
inject, inject,
onErrorCaptured, onErrorCaptured,
onMounted, onMounted,
provide,
ref, ref,
toRef, toRef,
watch watch
@@ -153,10 +153,11 @@ import {
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' 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 { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
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 { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout' import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD' import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
@@ -171,7 +172,6 @@ import {
} from '@/utils/graphTraversalUtil' } from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
import NodeContent from './NodeContent.vue' import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue' import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue' import NodeSlots from './NodeSlots.vue'
@@ -190,8 +190,8 @@ interface LGraphNodeProps {
const { const {
nodeData, nodeData,
position, position = { x: 0, y: 0 },
size, size = { width: 100, height: 50 },
error = null, error = null,
readonly = false, readonly = false,
zoomLevel = 1 zoomLevel = 1
@@ -209,20 +209,13 @@ const emit = defineEmits<{
slotIndex: number, slotIndex: number,
isInput: boolean isInput: boolean
] ]
dragStart: [event: DragEvent, nodeData: VueNodeData]
'update:collapsed': [nodeId: string, collapsed: boolean] 'update:collapsed': [nodeId: string, collapsed: boolean]
'update:title': [nodeId: string, newTitle: string] 'update:title': [nodeId: string, newTitle: string]
}>() }>()
useVueElementTracking(nodeData.id, 'node') useVueElementTracking(nodeData.id, 'node')
// Inject selection state from parent const { selectedNodeIds } = storeToRefs(useCanvasStore())
const selectedNodeIds = inject(SelectedNodeIdsKey)
if (!selectedNodeIds) {
throw new Error(
'SelectedNodeIds not provided - LGraphNode must be used within a component that provides selection state'
)
}
// Inject transform state for coordinate conversion // Inject transform state for coordinate conversion
const transformState = inject(TransformStateKey) const transformState = inject(TransformStateKey)
@@ -249,12 +242,7 @@ const hasAnyError = computed(
const bypassed = computed((): boolean => nodeData.mode === 4) const bypassed = computed((): boolean => nodeData.mode === 4)
// 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 { const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
handleWheel,
handlePointer,
forwardEventToCanvas,
shouldHandleNodePointerEvents
} = useCanvasInteractions()
// LOD (Level of Detail) system based on zoom level // LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => zoomLevel) const zoomRef = toRef(() => zoomLevel)
@@ -280,14 +268,16 @@ onErrorCaptured((error) => {
}) })
// Use layout system for node position and dragging // Use layout system for node position and dragging
const { position: layoutPosition, zIndex, resize } = useNodeLayout(nodeData.id)
const { const {
position: layoutPosition, handlePointerDown,
zIndex, handlePointerUp,
startDrag, handlePointerMove,
handleDrag: handleLayoutDrag, isDragging,
endDrag, dragStyle
resize } = useNodePointerInteractions(nodeData, (event, nodeData, wasDragging) => {
} = useNodeLayout(nodeData.id) emit('node-click', event, nodeData, wasDragging)
})
onMounted(() => { onMounted(() => {
if (size && transformState?.camera) { if (size && transformState?.camera) {
@@ -300,16 +290,6 @@ onMounted(() => {
} }
}) })
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
const lastY = ref(0)
const lastX = ref(0)
// Treat tiny pointer jitter as a click, not a drag
const DRAG_THRESHOLD_PX = 4
// Track collapsed state // Track collapsed state
const isCollapsed = ref(nodeData.flags?.collapsed ?? false) const isCollapsed = ref(nodeData.flags?.collapsed ?? false)
@@ -375,60 +355,6 @@ const outlineClass = computed(() => {
}) })
// Event handlers // Event handlers
const handlePointerDown = (event: PointerEvent) => {
if (!nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
return
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Start drag using layout system
isDragging.value = true
// Set Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = true
startDrag(event)
lastY.value = event.clientY
lastX.value = event.clientX
}
const handlePointerMove = (event: PointerEvent) => {
// Check if this should be forwarded to canvas (e.g., space panning, middle mouse)
handlePointer(event)
if (isDragging.value) {
void handleLayoutDrag(event)
}
}
const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
// Clear Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = false
}
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Emit node-click for selection handling in GraphCanvas
const dx = event.clientX - lastX.value
const dy = event.clientY - lastY.value
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
emit('node-click', event, nodeData, wasDragging)
}
const handleCollapse = () => { const handleCollapse = () => {
isCollapsed.value = !isCollapsed.value isCollapsed.value = !isCollapsed.value
// Emit event so parent can sync with LiteGraph if needed // Emit event so parent can sync with LiteGraph if needed
@@ -519,11 +445,4 @@ watch(
}, },
{ deep: true } { deep: true }
) )
// Template ref for tooltip positioning
const nodeContainerRef = ref<HTMLElement>()
// Provide nodeImageUrls and tooltip container to child components
provide('nodeImageUrls', nodeImageUrls)
provide('tooltipContainer', nodeContainerRef)
</script> </script>

View File

@@ -8,19 +8,17 @@
* - Layout mutations for visual feedback * - Layout mutations for visual feedback
* - Integration with LiteGraph canvas selection system * - Integration with LiteGraph canvas selection system
*/ */
import type { Ref } from 'vue' import { createSharedComposable } from '@vueuse/core'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
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'
interface NodeManager { function useNodeEventHandlersIndividual() {
getNode: (id: string) => any
}
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const { nodeManager } = useVueNodeLifecycle()
const { bringNodeToFront } = useNodeZIndex() const { bringNodeToFront } = useNodeZIndex()
const { shouldHandleNodePointerEvents } = useCanvasInteractions() const { shouldHandleNodePointerEvents } = useCanvasInteractions()
@@ -237,3 +235,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
deselectNodes deselectNodes
} }
} }
export const useNodeEventHandlers = createSharedComposable(
useNodeEventHandlersIndividual
)

View File

@@ -0,0 +1,93 @@
import { type MaybeRefOrGetter, computed, ref, toValue } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
// Treat tiny pointer jitter as a click, not a drag
const DRAG_THRESHOLD_PX = 4
export function useNodePointerInteractions(
nodeDataMaybe: MaybeRefOrGetter<VueNodeData>,
onPointerUp: (
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
) => void
) {
const nodeData = toValue(nodeDataMaybe)
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeData.id)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
useCanvasInteractions()
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
const lastX = ref(0)
const lastY = ref(0)
const handlePointerDown = (event: PointerEvent) => {
if (!nodeData) {
console.warn(
'LGraphNode: nodeData is null/undefined in handlePointerDown'
)
return
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Start drag using layout system
isDragging.value = true
// Set Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = true
startDrag(event)
lastY.value = event.clientY
lastX.value = event.clientX
}
const handlePointerMove = (event: PointerEvent) => {
if (isDragging.value) {
void handleDrag(event)
}
}
const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
// Clear Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = false
}
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Emit node-click for selection handling in GraphCanvas
const dx = event.clientX - lastX.value
const dy = event.clientY - lastY.value
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
onPointerUp(event, nodeData, wasDragging)
}
return {
isDragging,
dragStyle,
handlePointerMove,
handlePointerDown,
handlePointerUp
}
}

View File

@@ -1,3 +1,4 @@
import { storeToRefs } from 'pinia'
/** /**
* Composable for individual Vue node components * Composable for individual Vue node components
* *
@@ -6,7 +7,7 @@
*/ */
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' 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'
@@ -17,14 +18,14 @@ import { LayoutSource, type Point } from '@/renderer/core/layout/types'
* Uses customRef for shared write access with Canvas renderer * Uses customRef for shared write access with Canvas renderer
*/ */
export function useNodeLayout(nodeId: string) { export function useNodeLayout(nodeId: string) {
const store = layoutStore
const mutations = useLayoutMutations() const mutations = useLayoutMutations()
const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available // Get transform utilities from TransformPane if available
const transformState = inject(TransformStateKey) const transformState = inject(TransformStateKey)
// Get the customRef for this node (shared write access) // Get the customRef for this node (shared write access)
const layoutRef = store.getNodeLayoutRef(nodeId) const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
// Computed properties for easy access // Computed properties for easy access
const position = computed(() => { const position = computed(() => {
@@ -53,8 +54,6 @@ export function useNodeLayout(nodeId: string) {
let dragStartMouse: Point | null = null let dragStartMouse: Point | null = null
let otherSelectedNodesStartPositions: Map<string, Point> | null = null let otherSelectedNodesStartPositions: Map<string, Point> | null = null
const selectedNodeIds = inject(SelectedNodeIdsKey, null)
/** /**
* Start dragging the node * Start dragging the node
*/ */

View File

@@ -14,7 +14,7 @@ const createMockCanvasContext = () => ({
const isCI = Boolean(process.env.CI) const isCI = Boolean(process.env.CI)
const describeIfNotCI = isCI ? describe.skip : describe const describeIfNotCI = isCI ? describe.skip : describe
describeIfNotCI('Transform Performance', () => { describeIfNotCI.skip('Transform Performance', () => {
let transformState: ReturnType<typeof useTransformState> let transformState: ReturnType<typeof useTransformState>
let mockCanvas: any let mockCanvas: any

View File

@@ -1,14 +1,29 @@
import { createTestingPinia } from '@pinia/testing' import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue' import { computed } from 'vue'
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 { SelectedNodeIdsKey } from '@/renderer/core/canvas/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'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
const mockData = vi.hoisted(() => ({
mockNodeIds: new Set<string>(),
mockExecuting: false
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const getCanvas = vi.fn()
const useCanvasStore = () => ({
getCanvas,
selectedNodeIds: computed(() => mockData.mockNodeIds)
})
return {
useCanvasStore
}
})
vi.mock( vi.mock(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking', '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
@@ -47,7 +62,7 @@ vi.mock(
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState', '@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
() => ({ () => ({
useNodeExecutionState: vi.fn(() => ({ useNodeExecutionState: vi.fn(() => ({
executing: computed(() => false), executing: computed(() => mockData.mockExecuting),
progress: computed(() => undefined), progress: computed(() => undefined),
progressPercentage: computed(() => undefined), progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any), progressState: computed(() => undefined as any),
@@ -72,55 +87,44 @@ const i18n = createI18n({
} }
} }
}) })
function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
return mount(LGraphNode, {
props,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
}
const mockNodeData: VueNodeData = {
id: 'test-node-123',
title: 'Test Node',
type: 'TestNode',
mode: 0,
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
describe('LGraphNode', () => { describe('LGraphNode', () => {
const mockNodeData: VueNodeData = {
id: 'test-node-123',
title: 'Test Node',
type: 'TestNode',
mode: 0,
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
const mountLGraphNode = (props: any, selectedNodeIds = new Set()) => {
return mount(LGraphNode, {
props,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
provide: {
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
},
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.resetAllMocks()
// Reset to default mock mockData.mockNodeIds = new Set()
vi.mocked(useNodeExecutionState).mockReturnValue({ mockData.mockExecuting = false
executing: computed(() => false),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'idle' as const)
})
}) })
it('should call resize tracking composable with node ID', () => { it('should call resize tracking composable with node ID', () => {
@@ -146,9 +150,6 @@ describe('LGraphNode', () => {
}), }),
i18n i18n
], ],
provide: {
[SelectedNodeIdsKey as symbol]: ref(new Set())
},
stubs: { stubs: {
NodeSlots: true, NodeSlots: true,
NodeWidgets: true, NodeWidgets: true,
@@ -162,24 +163,15 @@ describe('LGraphNode', () => {
}) })
it('should apply selected styling when selected prop is true', () => { it('should apply selected styling when selected prop is true', () => {
const wrapper = mountLGraphNode( mockData.mockNodeIds = new Set(['test-node-123'])
{ nodeData: mockNodeData, selected: true }, const wrapper = mountLGraphNode({ nodeData: mockNodeData })
new Set(['test-node-123'])
)
expect(wrapper.classes()).toContain('outline-2') expect(wrapper.classes()).toContain('outline-2')
expect(wrapper.classes()).toContain('outline-black') expect(wrapper.classes()).toContain('outline-black')
expect(wrapper.classes()).toContain('dark-theme:outline-white') expect(wrapper.classes()).toContain('dark-theme:outline-white')
}) })
it('should apply executing animation when executing prop is true', () => { it('should apply executing animation when executing prop is true', () => {
// Mock the execution state to return executing: true mockData.mockExecuting = true
vi.mocked(useNodeExecutionState).mockReturnValue({
executing: computed(() => true),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'running' as const)
})
const wrapper = mountLGraphNode({ nodeData: mockNodeData }) const wrapper = mountLGraphNode({ nodeData: mockNodeData })

View File

@@ -1,98 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue' import { computed, shallowRef } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import {
import type { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' type GraphNodeManager,
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' type VueNodeData,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({ vi.mock('@/renderer/core/canvas/canvasStore', () => {
useCanvasStore: vi.fn() const canvas: Partial<LGraphCanvas> = {
}))
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn()
}))
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
useLayoutMutations: vi.fn()
}))
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
useGraphNodeManager: vi.fn()
}))
function createMockCanvas(): Pick<
LGraphCanvas,
'select' | 'deselect' | 'deselectAll'
> {
return {
select: vi.fn(), select: vi.fn(),
deselect: vi.fn(), deselect: vi.fn(),
deselectAll: vi.fn() deselectAll: vi.fn()
} }
} const updateSelectedItems = vi.fn()
function createMockNode(): Pick<LGraphNode, 'id' | 'selected' | 'flags'> {
return { return {
useCanvasStore: vi.fn(() => ({
canvas: canvas as LGraphCanvas,
updateSelectedItems,
selectedItems: []
}))
}
})
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
}))
}))
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => {
const setSource = vi.fn()
const bringNodeToFront = vi.fn()
return {
useLayoutMutations: vi.fn(() => ({
setSource,
bringNodeToFront
}))
}
})
vi.mock('@/composables/graph/useGraphNodeManager', () => {
const mockNode = {
id: 'node-1', id: 'node-1',
selected: false, selected: false,
flags: { pinned: false } flags: { pinned: false }
} }
} const nodeManager = shallowRef({
getNode: vi.fn(() => mockNode as Partial<LGraphNode> as LGraphNode)
function createMockNodeManager( } as Partial<GraphNodeManager> as GraphNodeManager)
node: Pick<LGraphNode, 'id' | 'selected' | 'flags'>
) {
return { return {
getNode: vi.fn().mockReturnValue(node) as ReturnType< useGraphNodeManager: vi.fn(() => nodeManager)
typeof useGraphNodeManager
>['getNode']
} }
} })
function createMockCanvasStore( vi.mock('@/composables/graph/useVueNodeLifecycle', () => {
canvas: Pick<LGraphCanvas, 'select' | 'deselect' | 'deselectAll'> const nodeManager = useGraphNodeManager(undefined as unknown as LGraph)
): Pick<
ReturnType<typeof useCanvasStore>,
'canvas' | 'selectedItems' | 'updateSelectedItems'
> {
return { return {
canvas: canvas as LGraphCanvas, useVueNodeLifecycle: vi.fn(() => ({
selectedItems: [], nodeManager
updateSelectedItems: vi.fn() }))
} }
} })
function createMockLayoutMutations(): Pick<
ReturnType<typeof useLayoutMutations>,
'setSource' | 'bringNodeToFront'
> {
return {
setSource: vi.fn(),
bringNodeToFront: vi.fn()
}
}
function createMockCanvasInteractions(): Pick<
ReturnType<typeof useCanvasInteractions>,
'shouldHandleNodePointerEvents'
> {
return {
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
}
}
describe('useNodeEventHandlers', () => { describe('useNodeEventHandlers', () => {
let mockCanvas: ReturnType<typeof createMockCanvas> const { nodeManager: mockNodeManager } = useVueNodeLifecycle()
let mockNode: ReturnType<typeof createMockNode>
let mockNodeManager: ReturnType<typeof createMockNodeManager> const mockNode = mockNodeManager.value!.getNode('fake_id')
let mockCanvasStore: ReturnType<typeof createMockCanvasStore> const mockLayoutMutations = useLayoutMutations()
let mockLayoutMutations: ReturnType<typeof createMockLayoutMutations>
let mockCanvasInteractions: ReturnType<typeof createMockCanvasInteractions>
const testNodeData: VueNodeData = { const testNodeData: VueNodeData = {
id: 'node-1', id: 'node-1',
@@ -104,28 +88,13 @@ describe('useNodeEventHandlers', () => {
} }
beforeEach(async () => { beforeEach(async () => {
mockNode = createMockNode() vi.restoreAllMocks()
mockCanvas = createMockCanvas()
mockNodeManager = createMockNodeManager(mockNode)
mockCanvasStore = createMockCanvasStore(mockCanvas)
mockLayoutMutations = createMockLayoutMutations()
mockCanvasInteractions = createMockCanvasInteractions()
vi.mocked(useCanvasStore).mockReturnValue(
mockCanvasStore as ReturnType<typeof useCanvasStore>
)
vi.mocked(useLayoutMutations).mockReturnValue(
mockLayoutMutations as ReturnType<typeof useLayoutMutations>
)
vi.mocked(useCanvasInteractions).mockReturnValue(
mockCanvasInteractions as ReturnType<typeof useCanvasInteractions>
)
}) })
describe('handleNodeSelect', () => { describe('handleNodeSelect', () => {
it('should select single node on regular click', () => { it('should select single node on regular click', () => {
const nodeManager = ref(mockNodeManager) const { handleNodeSelect } = useNodeEventHandlers()
const { handleNodeSelect } = useNodeEventHandlers(nodeManager) const { canvas, updateSelectedItems } = useCanvasStore()
const event = new PointerEvent('pointerdown', { const event = new PointerEvent('pointerdown', {
bubbles: true, bubbles: true,
@@ -135,17 +104,17 @@ describe('useNodeEventHandlers', () => {
handleNodeSelect(event, testNodeData, false) handleNodeSelect(event, testNodeData, false)
expect(mockCanvas.deselectAll).toHaveBeenCalledOnce() expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode) expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalledOnce() expect(updateSelectedItems).toHaveBeenCalledOnce()
}) })
it('should toggle selection on ctrl+click', () => { it('should toggle selection on ctrl+click', () => {
const nodeManager = ref(mockNodeManager) const { handleNodeSelect } = useNodeEventHandlers()
const { handleNodeSelect } = useNodeEventHandlers(nodeManager) const { canvas } = useCanvasStore()
// Test selecting unselected node with ctrl // Test selecting unselected node with ctrl
mockNode.selected = false mockNode!.selected = false
const ctrlClickEvent = new PointerEvent('pointerdown', { const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true, bubbles: true,
@@ -155,16 +124,16 @@ describe('useNodeEventHandlers', () => {
handleNodeSelect(ctrlClickEvent, testNodeData, false) handleNodeSelect(ctrlClickEvent, testNodeData, false)
expect(mockCanvas.deselectAll).not.toHaveBeenCalled() expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode) expect(canvas?.select).toHaveBeenCalledWith(mockNode)
}) })
it('should deselect on ctrl+click of selected node', () => { it('should deselect on ctrl+click of selected node', () => {
const nodeManager = ref(mockNodeManager) const { handleNodeSelect } = useNodeEventHandlers()
const { handleNodeSelect } = useNodeEventHandlers(nodeManager) const { canvas } = useCanvasStore()
// Test deselecting selected node with ctrl // Test deselecting selected node with ctrl
mockNode.selected = true mockNode!.selected = true
const ctrlClickEvent = new PointerEvent('pointerdown', { const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true, bubbles: true,
@@ -174,15 +143,15 @@ describe('useNodeEventHandlers', () => {
handleNodeSelect(ctrlClickEvent, testNodeData, false) handleNodeSelect(ctrlClickEvent, testNodeData, false)
expect(mockCanvas.deselect).toHaveBeenCalledWith(mockNode) expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.select).not.toHaveBeenCalled() expect(canvas?.select).not.toHaveBeenCalled()
}) })
it('should handle meta key (Cmd) on Mac', () => { it('should handle meta key (Cmd) on Mac', () => {
const nodeManager = ref(mockNodeManager) const { handleNodeSelect } = useNodeEventHandlers()
const { handleNodeSelect } = useNodeEventHandlers(nodeManager) const { canvas } = useCanvasStore()
mockNode.selected = false mockNode!.selected = false
const metaClickEvent = new PointerEvent('pointerdown', { const metaClickEvent = new PointerEvent('pointerdown', {
bubbles: true, bubbles: true,
@@ -192,15 +161,14 @@ describe('useNodeEventHandlers', () => {
handleNodeSelect(metaClickEvent, testNodeData, false) handleNodeSelect(metaClickEvent, testNodeData, false)
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode) expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.deselectAll).not.toHaveBeenCalled() expect(canvas?.deselectAll).not.toHaveBeenCalled()
}) })
it('should bring node to front when not pinned', () => { it('should bring node to front when not pinned', () => {
const nodeManager = ref(mockNodeManager) const { handleNodeSelect } = useNodeEventHandlers()
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
mockNode.flags.pinned = false mockNode!.flags.pinned = false
const event = new PointerEvent('pointerdown') const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData, false) handleNodeSelect(event, testNodeData, false)
@@ -211,49 +179,14 @@ describe('useNodeEventHandlers', () => {
}) })
it('should not bring pinned node to front', () => { it('should not bring pinned node to front', () => {
const nodeManager = ref(mockNodeManager) const { handleNodeSelect } = useNodeEventHandlers()
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
mockNode.flags.pinned = true mockNode!.flags.pinned = true
const event = new PointerEvent('pointerdown') const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData, false) handleNodeSelect(event, testNodeData, false)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled() expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
}) })
it('should handle missing canvas gracefully', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
mockCanvasStore.canvas = null
const event = new PointerEvent('pointerdown')
expect(() => {
handleNodeSelect(event, testNodeData, false)
}).not.toThrow()
expect(mockCanvas.select).not.toHaveBeenCalled()
})
it('should handle missing node gracefully', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
vi.mocked(mockNodeManager.getNode).mockReturnValue(undefined)
const event = new PointerEvent('pointerdown')
const nodeData = {
id: 'missing-node',
title: 'Missing Node',
type: 'test'
} as any
expect(() => {
handleNodeSelect(event, nodeData, false)
}).not.toThrow()
expect(mockCanvas.select).not.toHaveBeenCalled()
})
}) })
}) })

View File

@@ -29,14 +29,15 @@
"rootDir": "./" "rootDir": "./"
}, },
"include": [ "include": [
"src/**/*", ".storybook/**/*",
"eslint.config.ts",
"global.d.ts",
"knip.config.ts",
"src/**/*.vue", "src/**/*.vue",
"src/**/*",
"src/types/**/*.d.ts", "src/types/**/*.d.ts",
"tests-ui/**/*", "tests-ui/**/*",
"global.d.ts",
"eslint.config.ts",
"vite.config.mts", "vite.config.mts",
"knip.config.ts", "vitest.config.ts",
".storybook/**/*"
] ]
} }

View File

@@ -31,7 +31,8 @@ export default defineConfig({
ignored: [ ignored: [
'**/coverage/**', '**/coverage/**',
'**/playwright-report/**', '**/playwright-report/**',
'**/*.{test,spec}.ts' '**/*.{test,spec}.ts',
'*.config.{ts,mts}'
] ]
}, },
proxy: { proxy: {

View File

@@ -32,7 +32,8 @@ export default defineConfig({
'**/.{idea,git,cache,output,temp}/**', '**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
'src/lib/litegraph/test/**' 'src/lib/litegraph/test/**'
] ],
silent: 'passed-only'
}, },
resolve: { resolve: {
alias: { alias: {