mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-24 16:54:51 +00:00
Compare commits
2 Commits
codex/fix-
...
rizumu/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fa9bd3564 | ||
|
|
33acb47e46 |
@@ -51,7 +51,8 @@ const mockHandleNativeDrop = vi.fn()
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
isDragging: ref(false)
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -13,15 +13,19 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const { mockStartDrag, mockHandleNativeDrop } = vi.hoisted(() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockHandleNativeDrop: vi.fn()
|
||||
}))
|
||||
const { mockStartDrag, mockHandleNativeDrop, mockIsPlacingNode } = vi.hoisted(
|
||||
() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockHandleNativeDrop: vi.fn(),
|
||||
mockIsPlacingNode: { value: false }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
isDragging: mockIsPlacingNode
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -7,28 +8,32 @@ const {
|
||||
mockAddNodeOnGraph,
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas
|
||||
mockCanvas,
|
||||
mockCanvasStore
|
||||
} = vi.hoisted(() => {
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
const mockSelectItems = vi.fn()
|
||||
// A real element so click-mode placement can hit-test the pointerup target.
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.getBoundingClientRect = vi.fn()
|
||||
const mockCanvas = {
|
||||
canvas: canvasElement as HTMLCanvasElement & {
|
||||
getBoundingClientRect: Mock
|
||||
},
|
||||
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
|
||||
selectItems: mockSelectItems
|
||||
}
|
||||
return {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
},
|
||||
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
|
||||
selectItems: mockSelectItems
|
||||
}
|
||||
mockCanvas,
|
||||
mockCanvasStore: { canvas: mockCanvas, isGhostPlacing: false }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
canvas: mockCanvas
|
||||
}))
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
@@ -45,10 +50,18 @@ describe('useNodeDragToCanvas', () => {
|
||||
display_name: 'Test Node'
|
||||
} as ComfyNodeDefImpl
|
||||
|
||||
// Stands in for a sidebar/properties panel that overlays the full-bleed canvas.
|
||||
let panelElement: HTMLElement
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
|
||||
mockCanvasStore.isGhostPlacing = false
|
||||
document.body.appendChild(mockCanvas.canvas)
|
||||
panelElement = document.createElement('div')
|
||||
document.body.appendChild(panelElement)
|
||||
|
||||
const module = await import('./useNodeDragToCanvas')
|
||||
useNodeDragToCanvas = module.useNodeDragToCanvas
|
||||
})
|
||||
@@ -56,6 +69,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
afterEach(() => {
|
||||
const { cleanupGlobalListeners } = useNodeDragToCanvas()
|
||||
cleanupGlobalListeners()
|
||||
mockCanvas.canvas.remove()
|
||||
panelElement.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -87,6 +102,27 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
expect(dragMode.value).toBe('native')
|
||||
})
|
||||
|
||||
// Vue nodes render inert while isGhostPlacing is set, so a release over
|
||||
// an existing node hit-tests the canvas instead of being cancelled.
|
||||
it('should mark ghost placement active for the duration of the drag', () => {
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(true)
|
||||
|
||||
cancelDrag()
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(false)
|
||||
})
|
||||
|
||||
it('should not clear ghost placement when cancelling without a drag', () => {
|
||||
const { cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
mockCanvasStore.isGhostPlacing = true
|
||||
cancelDrag()
|
||||
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelDrag', () => {
|
||||
@@ -153,6 +189,26 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
|
||||
})
|
||||
|
||||
// The component handling dragend unmounts with the listeners, so an
|
||||
// in-flight native drag can never complete — cancel it on cleanup or
|
||||
// the ghost-placement flag leaks and leaves all Vue nodes inert.
|
||||
it('should cancel an active native drag on cleanup', () => {
|
||||
const {
|
||||
startDrag,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners,
|
||||
isDragging
|
||||
} = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
cleanupGlobalListeners()
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(mockCanvasStore.isGhostPlacing).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursorPosition', () => {
|
||||
@@ -191,14 +247,18 @@ describe('useNodeDragToCanvas', () => {
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
mockCanvas.canvas.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
pos: [150, 150]
|
||||
})
|
||||
})
|
||||
|
||||
it('should not add node when pointer is outside canvas', () => {
|
||||
// FE-688: panels overlay the full-bleed canvas, so a release whose
|
||||
// coordinates fall inside the canvas rect can still land on a panel.
|
||||
// Hit-testing the target must cancel placement instead of stacking a
|
||||
// node hidden behind the panel.
|
||||
it('should not add node when released over a panel within canvas bounds', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
@@ -213,11 +273,11 @@ describe('useNodeDragToCanvas', () => {
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 600,
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
panelElement.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(false)
|
||||
@@ -266,7 +326,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
mockCanvas.canvas.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
@@ -291,7 +351,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
mockCanvas.canvas.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
@@ -404,7 +464,11 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
describe('blockCommitPointerDown', () => {
|
||||
function dispatchPointerDown(x: number, y: number) {
|
||||
function dispatchPointerDown(
|
||||
x: number,
|
||||
y: number,
|
||||
target: HTMLElement = mockCanvas.canvas
|
||||
) {
|
||||
const event = new PointerEvent('pointerdown', {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
@@ -412,7 +476,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
cancelable: true
|
||||
})
|
||||
const stopSpy = vi.spyOn(event, 'stopImmediatePropagation')
|
||||
document.dispatchEvent(event)
|
||||
target.dispatchEvent(event)
|
||||
return stopSpy
|
||||
}
|
||||
|
||||
@@ -448,12 +512,12 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when pointer is outside canvas', () => {
|
||||
it('should not stop propagation when pointer is over a panel', () => {
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
|
||||
expect(dispatchPointerDown(250, 250, panelElement)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ function trackNativeDragPosition(e: DragEvent) {
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
if (isDragging.value) useCanvasStore().isGhostPlacing = false
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
@@ -48,6 +49,17 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// The canvas is full-bleed and sidebar/properties panels are pointer-events-auto
|
||||
// overlays painted on top of it, so a point inside the canvas rect can still be
|
||||
// over a panel. Hit-test the actual event target instead, mirroring how native
|
||||
// drag treats the canvas as its only drop target: releasing over a panel cancels.
|
||||
function isCanvasTarget(target: EventTarget | null): boolean {
|
||||
const canvasElement = useCanvasStore().canvas?.canvas
|
||||
return (
|
||||
!!canvasElement && target instanceof Node && canvasElement.contains(target)
|
||||
)
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
const nodeDef = draggedNode.value
|
||||
if (!nodeDef) return false
|
||||
@@ -71,7 +83,7 @@ function endDrag(e: PointerEvent) {
|
||||
if (dragMode.value !== 'click') return
|
||||
|
||||
try {
|
||||
addNodeAtPosition(e.clientX, e.clientY)
|
||||
if (isCanvasTarget(e.target)) addNodeAtPosition(e.clientX, e.clientY)
|
||||
} finally {
|
||||
cancelDrag()
|
||||
}
|
||||
@@ -84,7 +96,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
// Prevent LiteGraph's empty-canvas hit-test from deselecting the placed node on pointerup.
|
||||
function blockCommitPointerDown(e: PointerEvent) {
|
||||
if (!isDragging.value || dragMode.value !== 'click') return
|
||||
if (!isOverCanvas(e.clientX, e.clientY)) return
|
||||
if (!isCanvasTarget(e.target)) return
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
@@ -109,7 +121,7 @@ function cleanupGlobalListeners() {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('dragover', trackNativeDragPosition)
|
||||
|
||||
if (isDragging.value && dragMode.value === 'click') {
|
||||
if (isDragging.value) {
|
||||
cancelDrag()
|
||||
}
|
||||
}
|
||||
@@ -119,6 +131,10 @@ export function useNodeDragToCanvas() {
|
||||
isDragging.value = true
|
||||
draggedNode.value = nodeDef
|
||||
dragMode.value = mode
|
||||
// Reuse the litegraph ghost-placement flag: Vue nodes render inert while
|
||||
// it is set, so the release hit-tests the canvas instead of an existing
|
||||
// node's DOM and placement over occupied areas isn't silently cancelled.
|
||||
useCanvasStore().isGhostPlacing = true
|
||||
}
|
||||
|
||||
function handleNativeDrop(clientX: number, clientY: number) {
|
||||
|
||||
@@ -7,11 +7,13 @@ import { useNodePreviewAndDrag } from './useNodePreviewAndDrag'
|
||||
|
||||
const mockStartDrag = vi.fn()
|
||||
const mockHandleNativeDrop = vi.fn()
|
||||
const mockIsPlacingNode = ref(false)
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
isDragging: mockIsPlacingNode
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -29,6 +31,7 @@ describe('useNodePreviewAndDrag', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsPlacingNode.value = false
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
@@ -52,6 +55,17 @@ describe('useNodePreviewAndDrag', () => {
|
||||
result.isDragging.value = true
|
||||
expect(result.showPreview.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should hide preview while a node is being placed elsewhere', () => {
|
||||
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
|
||||
const result = useNodePreviewAndDrag(nodeDef)
|
||||
|
||||
result.isHovered.value = true
|
||||
expect(result.showPreview.value).toBe(true)
|
||||
|
||||
mockIsPlacingNode.value = true
|
||||
expect(result.showPreview.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleMouseEnter', () => {
|
||||
|
||||
@@ -12,7 +12,11 @@ export function useNodePreviewAndDrag(
|
||||
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
|
||||
panelRef?: Ref<HTMLElement | null>
|
||||
) {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
const {
|
||||
startDrag,
|
||||
handleNativeDrop,
|
||||
isDragging: isPlacingNode
|
||||
} = useNodeDragToCanvas()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -21,7 +25,11 @@ export function useNodePreviewAndDrag(
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const showPreview = computed(() => isHovered.value && !isDragging.value)
|
||||
// Hide the hover preview while a node is being placed (click or drag) so it
|
||||
// doesn't compete with the cursor-following placement preview.
|
||||
const showPreview = computed(
|
||||
() => isHovered.value && !isDragging.value && !isPlacingNode.value
|
||||
)
|
||||
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
position: 'fixed',
|
||||
|
||||
Reference in New Issue
Block a user