mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 14:54:37 +00:00
[refactor] simplify vue nodes drag handling
This commit is contained in:
@@ -38,7 +38,7 @@
|
||||
backgroundColor: nodeBodyBackgroundColor,
|
||||
opacity: nodeOpacity
|
||||
},
|
||||
dragStyle
|
||||
{ cursor: nodeStyle.cursor }
|
||||
]"
|
||||
v-bind="pointerHandlers"
|
||||
@wheel="handleWheel"
|
||||
@@ -262,8 +262,10 @@ onErrorCaptured((error) => {
|
||||
})
|
||||
|
||||
// Use layout system for node position and dragging
|
||||
const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
|
||||
const { position, size, zIndex, resize, nodeStyle } = useNodeLayout(
|
||||
() => nodeData.id
|
||||
)
|
||||
const { pointerHandlers, isDragging } = useNodePointerInteractions(
|
||||
() => nodeData,
|
||||
handleNodeSelect
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
@@ -12,19 +13,9 @@ vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
startDrag: vi.fn(),
|
||||
endDrag: vi.fn().mockResolvedValue(undefined),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
isDraggingVueNodes: ref(false)
|
||||
}
|
||||
}))
|
||||
// Mock the layout system
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout')
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore')
|
||||
|
||||
const createMockVueNodeData = (
|
||||
overrides: Partial<VueNodeData> = {}
|
||||
@@ -42,173 +33,432 @@ const createMockVueNodeData = (
|
||||
})
|
||||
|
||||
const createPointerEvent = (
|
||||
eventType: string,
|
||||
overrides: Partial<PointerEventInit> = {}
|
||||
type: string,
|
||||
overrides: Partial<PointerEvent> = {}
|
||||
): PointerEvent => {
|
||||
return new PointerEvent(eventType, {
|
||||
const event = new PointerEvent(type, {
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
button: 0,
|
||||
...overrides
|
||||
})
|
||||
Object.defineProperty(event, 'target', {
|
||||
value: {
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn()
|
||||
},
|
||||
writable: false
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
const createMouseEvent = (
|
||||
eventType: string,
|
||||
overrides: Partial<MouseEventInit> = {}
|
||||
type: string,
|
||||
overrides: Partial<MouseEvent> = {}
|
||||
): MouseEvent => {
|
||||
return new MouseEvent(eventType, {
|
||||
button: 2, // Right click
|
||||
const event = new MouseEvent(type, {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
button: 0,
|
||||
...overrides
|
||||
})
|
||||
event.preventDefault = vi.fn()
|
||||
return event
|
||||
}
|
||||
|
||||
const createCompleteNodeLayoutMock = (overrides: any = {}) => ({
|
||||
layoutRef: ref(null),
|
||||
position: computed(() => ({ x: 0, y: 0 })),
|
||||
size: computed(() => ({ width: 200, height: 100 })),
|
||||
bounds: computed(() => ({ x: 0, y: 0, width: 200, height: 100 })),
|
||||
isVisible: computed(() => true),
|
||||
zIndex: computed(() => 0),
|
||||
moveTo: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
nodeStyle: computed(() => ({ cursor: 'grab' })),
|
||||
startDrag: vi.fn(),
|
||||
endDrag: vi.fn(),
|
||||
handleDrag: vi.fn(),
|
||||
isDragging: ref(false),
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('useNodePointerInteractions', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should only start drag on left-click', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Right-click should not start drag
|
||||
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
|
||||
pointerHandlers.onPointerdown(rightClickEvent)
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
|
||||
// Left-click should start drag and emit callback
|
||||
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
|
||||
pointerHandlers.onPointerdown(leftClickEvent)
|
||||
|
||||
const pointerUpEvent = createPointerEvent('pointerup')
|
||||
pointerHandlers.onPointerup(pointerUpEvent)
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
pointerUpEvent,
|
||||
mockNodeData,
|
||||
false // wasDragging = false (same position)
|
||||
)
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should distinguish drag from click based on distance threshold', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
// Tests for DrJKL's single source of truth architecture
|
||||
describe('DrJKL single source of truth architecture', () => {
|
||||
it('should use useNodeLayout.isDragging as the single authority', () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
// Create test-specific mocks
|
||||
const mockIsDragging = ref(false)
|
||||
|
||||
// Test drag (distance > 4px)
|
||||
pointerHandlers.onPointerdown(
|
||||
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
|
||||
)
|
||||
// Mock layout system for this test
|
||||
vi.mocked(useNodeLayout).mockReturnValue(
|
||||
createCompleteNodeLayoutMock({
|
||||
startDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = true
|
||||
return true
|
||||
}),
|
||||
endDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = false
|
||||
return Promise.resolve()
|
||||
}),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined),
|
||||
isDragging: mockIsDragging
|
||||
})
|
||||
)
|
||||
|
||||
const dragUpEvent = createPointerEvent('pointerup', {
|
||||
clientX: 200,
|
||||
clientY: 200
|
||||
const { isDragging } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// After refactor: isDragging should be directly from useNodeLayout
|
||||
// Not a computed from coordination object
|
||||
expect(isDragging).toBe(mockIsDragging) // Same ref, not computed
|
||||
})
|
||||
pointerHandlers.onPointerup(dragUpEvent)
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
dragUpEvent,
|
||||
mockNodeData,
|
||||
true
|
||||
)
|
||||
it('should eliminate coordination object entirely', () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
mockOnPointerUp.mockClear()
|
||||
// Create test-specific mocks
|
||||
const mockIsDragging = ref(false)
|
||||
|
||||
// Test click (same position)
|
||||
const samePos = { clientX: 100, clientY: 100 }
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown', samePos))
|
||||
// Mock layout system for this test
|
||||
vi.mocked(useNodeLayout).mockReturnValue(
|
||||
createCompleteNodeLayoutMock({
|
||||
startDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = true
|
||||
return true
|
||||
}),
|
||||
endDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = false
|
||||
return Promise.resolve()
|
||||
}),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined),
|
||||
isDragging: mockIsDragging
|
||||
})
|
||||
)
|
||||
|
||||
const clickUpEvent = createPointerEvent('pointerup', samePos)
|
||||
pointerHandlers.onPointerup(clickUpEvent)
|
||||
const result = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
clickUpEvent,
|
||||
mockNodeData,
|
||||
false
|
||||
)
|
||||
// After refactor: only simple properties should remain
|
||||
expect(result.isDragging).toBeDefined()
|
||||
expect(result.pointerHandlers).toBeDefined()
|
||||
expect(result.stopWatcher).toBeDefined()
|
||||
})
|
||||
|
||||
it('should use pure Vue reactivity for global state sync', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
// Create test-specific mocks
|
||||
const mockIsDragging = ref(false)
|
||||
const mockLayoutStoreRef = ref(false)
|
||||
|
||||
// Mock layout system for this test
|
||||
vi.mocked(useNodeLayout).mockReturnValue(
|
||||
createCompleteNodeLayoutMock({
|
||||
startDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = true
|
||||
return true
|
||||
}),
|
||||
endDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = false
|
||||
return Promise.resolve()
|
||||
}),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined),
|
||||
isDragging: mockIsDragging
|
||||
})
|
||||
)
|
||||
|
||||
// Mock layout store for this test
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
vi.mocked(layoutStore).isDraggingVueNodes = mockLayoutStoreRef
|
||||
|
||||
// Set up reactive connection manually since we're mocking
|
||||
// In real code, watch(isDragging, ...) does this automatically
|
||||
watch(
|
||||
mockIsDragging,
|
||||
(dragging) => {
|
||||
mockLayoutStoreRef.value = dragging
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Initial state
|
||||
expect(mockLayoutStoreRef.value).toBe(false)
|
||||
|
||||
// Start drag
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
|
||||
// Wait for Vue reactivity to propagate through watch()
|
||||
await nextTick()
|
||||
|
||||
// After refactor: global state should sync automatically via watch()
|
||||
// No manual syncGlobalDragState calls
|
||||
expect(mockLayoutStoreRef.value).toBe(true)
|
||||
|
||||
// End drag
|
||||
pointerHandlers.onPointerup(createPointerEvent('pointerup'))
|
||||
|
||||
// Wait for Vue reactivity to propagate through watch()
|
||||
await nextTick()
|
||||
|
||||
// Should sync back to false automatically
|
||||
expect(mockLayoutStoreRef.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should make startDrag return success boolean to fix race condition', () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
// Create test-specific mocks
|
||||
const mockIsDragging = ref(false)
|
||||
const mockStartDrag = vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = true
|
||||
return true
|
||||
})
|
||||
|
||||
// Mock layout system for this test
|
||||
vi.mocked(useNodeLayout).mockReturnValue(
|
||||
createCompleteNodeLayoutMock({
|
||||
startDrag: mockStartDrag,
|
||||
endDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = false
|
||||
return Promise.resolve()
|
||||
}),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined),
|
||||
isDragging: mockIsDragging
|
||||
})
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Test 1: startDrag should be called and return success
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
expect(mockStartDrag).toHaveBeenCalled()
|
||||
expect(mockStartDrag).toHaveReturnedWith(true)
|
||||
|
||||
// Reset for next test
|
||||
vi.clearAllMocks()
|
||||
mockIsDragging.value = false
|
||||
|
||||
// Test 2: If startDrag fails, drag state shouldn't be set
|
||||
mockStartDrag.mockReturnValue(false)
|
||||
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
expect(mockStartDrag).toHaveBeenCalled()
|
||||
expect(mockIsDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should have clean Vue-native implementation without manual sync', () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
// Create test-specific mocks
|
||||
const mockIsDragging = ref(false)
|
||||
|
||||
// Mock layout system for this test
|
||||
vi.mocked(useNodeLayout).mockReturnValue(
|
||||
createCompleteNodeLayoutMock({
|
||||
startDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = true
|
||||
return true
|
||||
}),
|
||||
endDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = false
|
||||
return Promise.resolve()
|
||||
}),
|
||||
handleDrag: vi.fn().mockResolvedValue(undefined),
|
||||
isDragging: mockIsDragging
|
||||
})
|
||||
)
|
||||
|
||||
const result = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// After refactor: clean API - just essential properties
|
||||
expect(result.isDragging).toBeDefined()
|
||||
expect(result.pointerHandlers).toBeDefined()
|
||||
expect(result.stopWatcher).toBeDefined()
|
||||
|
||||
// Should use isDragging directly for UI logic
|
||||
expect(result.isDragging.value).toBe(false)
|
||||
mockIsDragging.value = true
|
||||
expect(result.isDragging.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle drag termination via cancel and context menu', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
// Essential integration tests
|
||||
describe('basic functionality', () => {
|
||||
it('should only start drag on left-click', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
// Create test-specific mocks
|
||||
const mockIsDragging = ref(false)
|
||||
const mockStartDrag = vi.fn()
|
||||
|
||||
// Test pointer cancel
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
|
||||
vi.mocked(useNodeLayout).mockReturnValue(
|
||||
createCompleteNodeLayoutMock({
|
||||
startDrag: mockStartDrag,
|
||||
endDrag: vi.fn(),
|
||||
handleDrag: vi.fn(),
|
||||
isDragging: mockIsDragging
|
||||
})
|
||||
)
|
||||
|
||||
// Should not emit callback on cancel
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Test context menu during drag prevents default
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
// Right-click should not start drag
|
||||
const rightClick = createPointerEvent('pointerdown', { button: 2 })
|
||||
pointerHandlers.onPointerdown(rightClick)
|
||||
expect(mockStartDrag).not.toHaveBeenCalled()
|
||||
|
||||
const contextMenuEvent = createMouseEvent('contextmenu')
|
||||
const preventDefaultSpy = vi.spyOn(contextMenuEvent, 'preventDefault')
|
||||
// Left-click should start drag
|
||||
const leftClick = createPointerEvent('pointerdown', { button: 0 })
|
||||
pointerHandlers.onPointerdown(leftClick)
|
||||
expect(mockStartDrag).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
pointerHandlers.onContextmenu(contextMenuEvent)
|
||||
it('should distinguish drag from click based on distance threshold', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
})
|
||||
// Create test-specific mocks
|
||||
const mockIsDragging = ref(false)
|
||||
|
||||
it('should not emit callback when nodeData becomes null', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
const nodeDataRef = ref<VueNodeData | null>(mockNodeData)
|
||||
vi.mocked(useNodeLayout).mockReturnValue(
|
||||
createCompleteNodeLayoutMock({
|
||||
startDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = true
|
||||
return true
|
||||
}),
|
||||
endDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = false
|
||||
return Promise.resolve()
|
||||
}),
|
||||
handleDrag: vi.fn(),
|
||||
isDragging: mockIsDragging
|
||||
})
|
||||
)
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
nodeDataRef,
|
||||
mockOnPointerUp
|
||||
)
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
// Start at 100, 100
|
||||
pointerHandlers.onPointerdown(
|
||||
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
|
||||
)
|
||||
|
||||
// Clear nodeData before pointerup
|
||||
nodeDataRef.value = null
|
||||
// Move just 2 pixels (below threshold)
|
||||
pointerHandlers.onPointerup(
|
||||
createPointerEvent('pointerup', { clientX: 102, clientY: 102 })
|
||||
)
|
||||
|
||||
pointerHandlers.onPointerup(createPointerEvent('pointerup'))
|
||||
// Should be considered a click, not drag
|
||||
expect(mockOnPointerUp).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
mockNodeData,
|
||||
false // wasDragging = false
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
})
|
||||
it('should handle drag termination via cancel and context menu', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
it('should integrate with layout store dragging state', async () => {
|
||||
const mockNodeData = createMockVueNodeData()
|
||||
const mockOnPointerUp = vi.fn()
|
||||
const { layoutStore } = await import(
|
||||
'@/renderer/core/layout/store/layoutStore'
|
||||
)
|
||||
// Create test-specific mocks
|
||||
const mockIsDragging = ref(false)
|
||||
const mockEndDrag = vi.fn()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
vi.mocked(useNodeLayout).mockReturnValue(
|
||||
createCompleteNodeLayoutMock({
|
||||
startDrag: vi.fn().mockImplementation(() => {
|
||||
mockIsDragging.value = true
|
||||
return true
|
||||
}),
|
||||
endDrag: mockEndDrag,
|
||||
handleDrag: vi.fn(),
|
||||
isDragging: mockIsDragging
|
||||
})
|
||||
)
|
||||
|
||||
// Start drag
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
await nextTick()
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(mockNodeData),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// End drag
|
||||
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
|
||||
await nextTick()
|
||||
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
|
||||
// Start drag
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
expect(mockIsDragging.value).toBe(true)
|
||||
|
||||
// Context menu should end drag
|
||||
const contextMenu = createMouseEvent('contextmenu')
|
||||
pointerHandlers.onContextmenu(contextMenu)
|
||||
|
||||
expect(contextMenu.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not emit callback when nodeData becomes null', async () => {
|
||||
const mockOnPointerUp = vi.fn()
|
||||
|
||||
// Create test-specific mocks
|
||||
const mockIsDragging = ref(false)
|
||||
|
||||
vi.mocked(useNodeLayout).mockReturnValue(
|
||||
createCompleteNodeLayoutMock({
|
||||
startDrag: vi.fn(),
|
||||
endDrag: vi.fn(),
|
||||
handleDrag: vi.fn(),
|
||||
isDragging: mockIsDragging
|
||||
})
|
||||
)
|
||||
|
||||
// Start with null nodeData
|
||||
const { pointerHandlers } = useNodePointerInteractions(
|
||||
ref(null),
|
||||
mockOnPointerUp
|
||||
)
|
||||
|
||||
// Should not crash or call callback
|
||||
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
|
||||
pointerHandlers.onPointerup(createPointerEvent('pointerup'))
|
||||
|
||||
expect(mockOnPointerUp).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { type MaybeRefOrGetter, computed, onUnmounted, ref, toValue } from 'vue'
|
||||
import { type MaybeRefOrGetter, computed, ref, toValue, watch } 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'
|
||||
|
||||
// Position type for better type safety and consistency
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
// Treat tiny pointer jitter as a click, not a drag
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
const DRAG_THRESHOLD_SQUARED = DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX
|
||||
|
||||
export function useNodePointerInteractions(
|
||||
nodeDataMaybe: MaybeRefOrGetter<VueNodeData | null>,
|
||||
@@ -29,69 +36,64 @@ export function useNodePointerInteractions(
|
||||
|
||||
// Avoid potential null access during component initialization
|
||||
const nodeIdComputed = computed(() => nodeData.value?.id ?? '')
|
||||
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeIdComputed)
|
||||
const { startDrag, endDrag, handleDrag, isDragging } =
|
||||
useNodeLayout(nodeIdComputed)
|
||||
// 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(() => {
|
||||
if (nodeData.value?.flags?.pinned) {
|
||||
return { cursor: 'default' }
|
||||
}
|
||||
return { cursor: isDragging.value ? 'grabbing' : 'grab' }
|
||||
})
|
||||
const startPosition = ref({ x: 0, y: 0 })
|
||||
// Simple tracking for click vs drag detection (not part of coordination)
|
||||
const startPosition = ref<Position>({ x: 0, y: 0 })
|
||||
|
||||
// Automatic global state sync via Vue reactivity
|
||||
const stopWatcher = watch(
|
||||
isDragging,
|
||||
(dragging) => {
|
||||
layoutStore.isDraggingVueNodes.value = dragging
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// No longer needed - Vue reactivity handles sync automatically via watch()
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!nodeData.value) {
|
||||
console.warn(
|
||||
'LGraphNode: nodeData is null/undefined in handlePointerDown'
|
||||
'useNodePointerInteractions: nodeData is null in handlePointerDown'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Only start drag on left-click (button 0)
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
if (event.button !== 0) return
|
||||
|
||||
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't allow dragging if node is pinned (but still record position for selection)
|
||||
// Store start position for click vs drag detection
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
if (nodeData.value.flags?.pinned) {
|
||||
|
||||
// Single source of truth: only call layout system
|
||||
// Check return value to handle race conditions
|
||||
const dragStarted = startDrag(event)
|
||||
if (!dragStarted) {
|
||||
// startDrag failed - clear start position since no drag began
|
||||
startPosition.value = { x: 0, y: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
// Start drag using layout system
|
||||
isDragging.value = true
|
||||
|
||||
// Set Vue node dragging state for selection toolbox
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
|
||||
startDrag(event)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (isDragging.value) {
|
||||
void handleDrag(event)
|
||||
}
|
||||
if (!isDragging.value) return
|
||||
|
||||
handleDrag(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized cleanup function for drag state
|
||||
* Ensures consistent cleanup across all drag termination scenarios
|
||||
* No longer needed - cleanup happens automatically via useNodeLayout
|
||||
* and Vue reactivity watch()
|
||||
*/
|
||||
const cleanupDragState = () => {
|
||||
isDragging.value = false
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely ends drag operation with proper error handling
|
||||
@@ -100,20 +102,29 @@ export function useNodePointerInteractions(
|
||||
const safeDragEnd = async (event: PointerEvent): Promise<void> => {
|
||||
try {
|
||||
await endDrag(event)
|
||||
} catch (error) {
|
||||
console.error('Error during endDrag:', error)
|
||||
} finally {
|
||||
cleanupDragState()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
console.error(
|
||||
'useNodePointerInteractions: Error during endDrag -',
|
||||
errorMessage
|
||||
)
|
||||
}
|
||||
// Cleanup happens automatically via Vue reactivity when isDragging changes
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
safeDragEnd(event).catch((error: unknown) => {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
console.error(
|
||||
`useNodePointerInteractions: Failed to complete ${errorContext} -`,
|
||||
errorMessage
|
||||
)
|
||||
// No manual cleanup needed - Vue reactivity handles it automatically
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,18 +133,18 @@ export function useNodePointerInteractions(
|
||||
handleDragTermination(event, 'drag end')
|
||||
}
|
||||
|
||||
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
if (!nodeData?.value) return
|
||||
|
||||
// Emit node-click for selection handling in GraphCanvas
|
||||
const dx = event.clientX - startPosition.value.x
|
||||
const dy = event.clientY - startPosition.value.y
|
||||
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
|
||||
const wasDragging = dx * dx + dy * dy > DRAG_THRESHOLD_SQUARED
|
||||
|
||||
if (!nodeData?.value) return
|
||||
onPointerUp(event, nodeData.value, wasDragging)
|
||||
}
|
||||
|
||||
@@ -143,6 +154,7 @@ export function useNodePointerInteractions(
|
||||
*/
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
handleDragTermination(event, 'drag cancellation')
|
||||
}
|
||||
|
||||
@@ -154,16 +166,9 @@ export function useNodePointerInteractions(
|
||||
if (!isDragging.value) return
|
||||
|
||||
event.preventDefault()
|
||||
// Simply cleanup state without calling endDrag to avoid synthetic event creation
|
||||
cleanupDragState()
|
||||
// No manual cleanup needed - Vue reactivity handles it automatically
|
||||
}
|
||||
|
||||
// Cleanup on unmount to prevent resource leaks
|
||||
onUnmounted(() => {
|
||||
if (!isDragging.value) return
|
||||
cleanupDragState()
|
||||
})
|
||||
|
||||
const pointerHandlers = {
|
||||
onPointerdown: handlePointerDown,
|
||||
onPointermove: handlePointerMove,
|
||||
@@ -173,8 +178,8 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
dragStyle,
|
||||
pointerHandlers
|
||||
isDragging, // Single source of truth from useNodeLayout
|
||||
pointerHandlers,
|
||||
stopWatcher // Cleanup watcher to prevent memory leaks
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,9 +58,10 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
|
||||
/**
|
||||
* Start dragging the node
|
||||
* @returns {boolean} True if drag started successfully, false otherwise
|
||||
*/
|
||||
function startDrag(event: PointerEvent) {
|
||||
if (!layoutRef.value || !transformState) return
|
||||
function startDrag(event: PointerEvent): boolean {
|
||||
if (!layoutRef.value || !transformState) return false
|
||||
|
||||
isDragging.value = true
|
||||
dragStartPos = { ...position.value }
|
||||
@@ -88,8 +89,10 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
|
||||
// Capture pointer
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
if (!(event.target instanceof HTMLElement)) return false
|
||||
event.target.setPointerCapture(event.pointerId)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,14 +49,47 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
position: { x: 100, y: 50 },
|
||||
size: { width: 200, height: 100 },
|
||||
layoutRef: computed(() => null),
|
||||
position: computed(() => ({ x: 100, y: 50 })),
|
||||
size: computed(() => ({ width: 200, height: 100 })),
|
||||
bounds: computed(() => ({ x: 100, y: 50, width: 200, height: 100 })),
|
||||
isVisible: computed(() => true),
|
||||
zIndex: computed(() => 0),
|
||||
moveTo: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
startDrag: vi.fn(),
|
||||
handleDrag: vi.fn(),
|
||||
endDrag: vi.fn()
|
||||
endDrag: vi.fn(),
|
||||
isDragging: computed(() => false),
|
||||
nodeStyle: computed(() => ({ cursor: 'grab' }))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useNodePointerInteractions',
|
||||
() => ({
|
||||
useNodePointerInteractions: (nodeDataMaybe: any, onPointerUp: any) => ({
|
||||
isDragging: computed(() => false),
|
||||
pointerHandlers: {
|
||||
onPointerdown: vi.fn(),
|
||||
onPointermove: vi.fn(),
|
||||
onPointerup: (event: PointerEvent) => {
|
||||
const nodeData =
|
||||
typeof nodeDataMaybe === 'function'
|
||||
? nodeDataMaybe()
|
||||
: nodeDataMaybe
|
||||
if (nodeData && onPointerUp) {
|
||||
onPointerUp(event, nodeData, false) // false = wasDragging
|
||||
}
|
||||
},
|
||||
onPointercancel: vi.fn(),
|
||||
onContextmenu: vi.fn()
|
||||
},
|
||||
stopWatcher: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
|
||||
() => ({
|
||||
|
||||
Reference in New Issue
Block a user