mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 16:40:05 +00:00
let canvas continue to own selection state management
This commit is contained in:
@@ -44,7 +44,6 @@
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:selected="nodeData.selected"
|
||||
:readonly="false"
|
||||
:executing="executionStore.executingNodeId === nodeData.id"
|
||||
:error="
|
||||
@@ -79,6 +78,7 @@ import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
@@ -112,6 +112,7 @@ import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import TransformPane from '@/renderer/core/layout/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
@@ -189,6 +190,21 @@ const handleNodeSelect = nodeEventHandlers.handleNodeSelect
|
||||
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
|
||||
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
|
||||
|
||||
// Provide selection state to all Vue nodes
|
||||
const selectedNodeIds = ref(new Set<string>())
|
||||
provide(SelectedNodeIdsKey, selectedNodeIds)
|
||||
watch(
|
||||
() => canvasStore.selectedItems,
|
||||
(newSelectedItems) => {
|
||||
selectedNodeIds.value = new Set(
|
||||
newSelectedItems
|
||||
.filter((item) => item.id !== undefined)
|
||||
.map((item) => String(item.id))
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
})
|
||||
|
||||
@@ -33,12 +33,20 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
// Handle multi-select with Ctrl/Cmd key
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
canvasStore.canvas.deselectAllNodes()
|
||||
}
|
||||
const isMultiSelect = event.ctrlKey || event.metaKey
|
||||
|
||||
canvasStore.canvas.selectNode(node)
|
||||
if (isMultiSelect) {
|
||||
// Ctrl/Cmd+click -> toggle selection
|
||||
if (node.selected) {
|
||||
canvasStore.canvas.deselect(node)
|
||||
} else {
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
} else {
|
||||
// Regular click -> single select
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.canvas.select(node)
|
||||
}
|
||||
|
||||
// Bring node to front when clicked (similar to LiteGraph behavior)
|
||||
// Skip if node is pinned to avoid unwanted movement
|
||||
@@ -47,9 +55,6 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
layoutMutations.bringNodeToFront(nodeData.id)
|
||||
}
|
||||
|
||||
// Ensure node selection state is set
|
||||
node.selected = true
|
||||
|
||||
// Update canvas selection tracking
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
8
src/renderer/core/canvas/injectionKeys.ts
Normal file
8
src/renderer/core/canvas/injectionKeys.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 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')
|
||||
@@ -10,10 +10,13 @@
|
||||
'bg-white dark-theme:bg-[#15161A]',
|
||||
'min-w-[445px]',
|
||||
'lg-node absolute border border-solid rounded-2xl',
|
||||
'outline outline-transparent outline-2 hover:outline-black dark-theme:hover:outline-white',
|
||||
'outline outline-transparent outline-2',
|
||||
{
|
||||
'border-blue-500 ring-2 ring-blue-300': selected,
|
||||
'border-[#e1ded5] dark-theme:border-[#292A30]': !selected,
|
||||
'outline-black dark-theme:outline-white': isSelected
|
||||
},
|
||||
{
|
||||
'border-blue-500 ring-2 ring-blue-300': isSelected,
|
||||
'border-[#e1ded5] dark-theme:border-[#292A30]': !isSelected,
|
||||
'animate-pulse': executing,
|
||||
'opacity-50': nodeData.mode === 4,
|
||||
'border-red-500 bg-red-50': error,
|
||||
@@ -107,12 +110,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref, toRef, watch } from 'vue'
|
||||
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
|
||||
|
||||
// Import the VueNodeData type
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -129,7 +133,6 @@ interface LGraphNodeProps {
|
||||
position?: { x: number; y: number }
|
||||
size?: { width: number; height: number }
|
||||
readonly?: boolean
|
||||
selected?: boolean
|
||||
executing?: boolean
|
||||
progress?: number
|
||||
error?: string | null
|
||||
@@ -150,6 +153,14 @@ const emit = defineEmits<{
|
||||
'update:title': [nodeId: string, newTitle: string]
|
||||
}>()
|
||||
|
||||
// Inject selection state from parent
|
||||
const selectedNodeIds = inject(SelectedNodeIdsKey, ref(new Set<string>()))
|
||||
|
||||
// Computed selection state - only this node re-evaluates when its selection changes
|
||||
const isSelected = computed(() => {
|
||||
return selectedNodeIds.value.has(props.nodeData.id)
|
||||
})
|
||||
|
||||
// LOD (Level of Detail) system based on zoom level
|
||||
const zoomRef = toRef(() => props.zoomLevel ?? 1)
|
||||
const {
|
||||
@@ -193,7 +204,7 @@ const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
|
||||
// Watch for external changes to the collapsed state
|
||||
watch(
|
||||
() => props.nodeData.flags?.collapsed,
|
||||
(newCollapsed) => {
|
||||
(newCollapsed: boolean | undefined) => {
|
||||
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
|
||||
isCollapsed.value = newCollapsed
|
||||
}
|
||||
|
||||
294
tests-ui/tests/composables/graph/useNodeEventHandlers.test.ts
Normal file
294
tests-ui/tests/composables/graph/useNodeEventHandlers.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
|
||||
vi.mock('@/stores/graphStore')
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations')
|
||||
|
||||
interface MockCanvas {
|
||||
select: ReturnType<typeof vi.fn>
|
||||
deselect: ReturnType<typeof vi.fn>
|
||||
deselectAll: ReturnType<typeof vi.fn>
|
||||
updateSelectedItems: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface MockLGraphNode {
|
||||
id: string
|
||||
selected: boolean
|
||||
flags: {
|
||||
pinned: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface MockNodeManager {
|
||||
getNode: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface MockCanvasStore {
|
||||
canvas: MockCanvas | null
|
||||
selectedItems: Positionable[]
|
||||
updateSelectedItems: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface MockLayoutMutations
|
||||
extends Pick<LayoutMutations, 'setSource' | 'bringNodeToFront'> {
|
||||
setSource: ReturnType<typeof vi.fn>
|
||||
bringNodeToFront: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('useNodeEventHandlers', () => {
|
||||
let mockCanvas: MockCanvas
|
||||
let mockNode: MockLGraphNode
|
||||
let mockNodeManager: MockNodeManager
|
||||
let mockCanvasStore: MockCanvasStore
|
||||
let mockLayoutMutations: MockLayoutMutations
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mock LiteGraph node
|
||||
mockNode = {
|
||||
id: 'node-1',
|
||||
selected: false,
|
||||
flags: { pinned: false }
|
||||
}
|
||||
|
||||
// Mock canvas with select/deselect methods
|
||||
mockCanvas = {
|
||||
select: vi.fn(),
|
||||
deselect: vi.fn(),
|
||||
deselectAll: vi.fn(),
|
||||
updateSelectedItems: vi.fn()
|
||||
}
|
||||
|
||||
// Mock node manager
|
||||
mockNodeManager = {
|
||||
getNode: vi.fn().mockReturnValue(mockNode)
|
||||
}
|
||||
|
||||
// Mock canvas store
|
||||
mockCanvasStore = {
|
||||
canvas: mockCanvas,
|
||||
selectedItems: [],
|
||||
updateSelectedItems: vi.fn()
|
||||
}
|
||||
|
||||
// Mock layout mutations
|
||||
mockLayoutMutations = {
|
||||
setSource: vi.fn(),
|
||||
bringNodeToFront: vi.fn()
|
||||
}
|
||||
|
||||
// Setup module mocks
|
||||
const { useCanvasStore } = await import('@/stores/graphStore')
|
||||
const { useLayoutMutations } = await import(
|
||||
'@/renderer/core/layout/operations/layoutMutations'
|
||||
)
|
||||
|
||||
// @ts-expect-error - Test mocks only need minimal interface, full Pinia store type too complex
|
||||
vi.mocked(useCanvasStore).mockImplementation(() => mockCanvasStore)
|
||||
|
||||
// @ts-expect-error - Test mocks only need minimal interface, full LayoutMutations type too complex
|
||||
vi.mocked(useLayoutMutations).mockImplementation(() => mockLayoutMutations)
|
||||
})
|
||||
|
||||
describe('handleNodeSelect', () => {
|
||||
it('should select single node on regular click', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
const event = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
const nodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
handleNodeSelect(event, nodeData)
|
||||
|
||||
expect(mockCanvas.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should toggle selection on ctrl+click', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
// Test selecting unselected node with ctrl
|
||||
mockNode.selected = false
|
||||
|
||||
const ctrlClickEvent = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
ctrlKey: true,
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
const nodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
handleNodeSelect(ctrlClickEvent, nodeData)
|
||||
|
||||
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
|
||||
it('should deselect on ctrl+click of selected node', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
// Test deselecting selected node with ctrl
|
||||
mockNode.selected = true
|
||||
|
||||
const ctrlClickEvent = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
ctrlKey: true,
|
||||
metaKey: false
|
||||
})
|
||||
|
||||
const nodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
handleNodeSelect(ctrlClickEvent, nodeData)
|
||||
|
||||
expect(mockCanvas.deselect).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle meta key (Cmd) on Mac', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNode.selected = false
|
||||
|
||||
const metaClickEvent = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
ctrlKey: false,
|
||||
metaKey: true
|
||||
})
|
||||
|
||||
const nodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
handleNodeSelect(metaClickEvent, nodeData)
|
||||
|
||||
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should bring node to front when not pinned', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNode.flags.pinned = false
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
const nodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
handleNodeSelect(event, nodeData)
|
||||
|
||||
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
|
||||
'node-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not bring pinned node to front', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNode.flags.pinned = true
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
const nodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
handleNodeSelect(event, nodeData)
|
||||
|
||||
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')
|
||||
const nodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
handleNodeSelect(event, nodeData)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(mockCanvas.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing node gracefully', () => {
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNodeManager.getNode.mockReturnValue(null)
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
const nodeData = {
|
||||
id: 'missing-node',
|
||||
title: 'Missing Node',
|
||||
type: 'test'
|
||||
} as any
|
||||
|
||||
expect(() => {
|
||||
handleNodeSelect(event, nodeData)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(mockCanvas.select).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user