Compare commits

...

2 Commits

Author SHA1 Message Date
Rizumu Ayaka
3fa9bd3564 fix: cancel any active library drag on listener cleanup
cleanupGlobalListeners only cancelled click-mode drags, so a native
drag interrupted by unmount left isDragging and the ghost-placement
flag set, keeping all Vue nodes inert.
2026-06-12 18:20:53 +09:00
Rizumu Ayaka
33acb47e46 fix: cancel click-to-place node when released over a panel
The graph canvas is full-bleed and sidebar/properties panels are
pointer-events-auto overlays on top of it, so the geometric
isOverCanvas check treated releases over a panel as on-canvas and
stacked nodes hidden behind it. Hit-test the pointerup target against
the canvas element instead, mirroring how native drag cancels when
dropped on a panel.

Also reuse the litegraph ghost-placement flag during library placement
so Vue nodes render inert (release over an existing node places instead
of silently cancelling), and hide hover previews while a placement is
in progress.

Fixes FE-688
2026-06-12 18:05:02 +09:00
6 changed files with 140 additions and 33 deletions

View File

@@ -51,7 +51,8 @@ const mockHandleNativeDrop = vi.fn()
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
startDrag: mockStartDrag,
handleNativeDrop: mockHandleNativeDrop
handleNativeDrop: mockHandleNativeDrop,
isDragging: ref(false)
})
}))

View File

@@ -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
})
}))

View File

@@ -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()
})
})

View File

@@ -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) {

View File

@@ -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', () => {

View File

@@ -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',