Compare commits

...

1 Commits

Author SHA1 Message Date
Rizumu Ayaka
3e34506515 fix: cancel click-to-place node when released over a panel
The full-bleed canvas sits under the sidebar/properties panels, so the
click-to-place release path used geometric bounds and could drop a hidden
node behind a panel (FE-688). Hit-test the actual event target against the
canvas element instead, mirroring how native drag treats the canvas as its
only drop target. Mark ghost placement active during the drag so existing
Vue nodes render inert and releases over occupied canvas areas still place.
Suppress the hover preview while a node is being placed.

Claude-Session: https://claude.ai/code/session_0134SELvedJXhGvsX1RvWezW
2026-06-24 22:51:29 +08:00
7 changed files with 167 additions and 166 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

@@ -14,6 +14,12 @@ vi.mock(
})
)
// startDrag/cancelDrag toggle the litegraph ghost-placement flag; stub the
// store so the composable runs without an active Pinia in this component test.
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({ isGhostPlacing: false, canvas: undefined }))
}))
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
function moveMouse(clientX: number, clientY: number) {

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,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Mock } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'
@@ -7,30 +8,33 @@ const {
mockAddNodeOnGraph,
mockConvertEventToCanvasOffset,
mockSelectItems,
mockCanvas,
mockToastAdd
mockCanvasStore,
mockToastAdd,
canvasElement
} = vi.hoisted(() => {
const canvasElement = document.createElement(
'canvas'
) as HTMLCanvasElement & { getBoundingClientRect: Mock }
canvasElement.getBoundingClientRect = vi.fn()
const mockConvertEventToCanvasOffset = vi.fn()
const mockSelectItems = vi.fn()
const mockCanvas = {
canvas: canvasElement,
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
selectItems: mockSelectItems
}
return {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockSelectItems,
mockToastAdd: vi.fn(),
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
},
convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
selectItems: mockSelectItems
}
canvasElement,
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,8 +49,11 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
const CANVAS_RECT = { left: 0, right: 500, top: 0, bottom: 500 }
describe('useNodeDragToCanvas', () => {
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
let panelElement: HTMLElement
const mockNodeDef = {
name: 'TestNode',
@@ -57,6 +64,11 @@ describe('useNodeDragToCanvas', () => {
vi.resetModules()
vi.resetAllMocks()
document.body.appendChild(canvasElement)
panelElement = document.createElement('div')
document.body.appendChild(panelElement)
mockCanvasStore.isGhostPlacing = false
const module = await import('./useNodeDragToCanvas')
useNodeDragToCanvas = module.useNodeDragToCanvas
})
@@ -64,9 +76,24 @@ describe('useNodeDragToCanvas', () => {
afterEach(() => {
const { cancelDrag } = useNodeDragToCanvas()
cancelDrag()
canvasElement.remove()
panelElement.remove()
vi.restoreAllMocks()
})
// The canvas is full-bleed under the sidebar/properties panels, so the click
// path commits based on the event target rather than geometry. Dispatch on the
// real element so `isCanvasTarget` (canvas.contains(target)) behaves as in the app.
function dispatchPointerUp(
x: number,
y: number,
target: EventTarget = canvasElement
) {
target.dispatchEvent(
new PointerEvent('pointerup', { clientX: x, clientY: y, bubbles: true })
)
}
describe('startDrag', () => {
it('should set isDragging to true and store the node definition', () => {
const { isDragging, draggedNode, startDrag } = useNodeDragToCanvas()
@@ -96,6 +123,27 @@ describe('useNodeDragToCanvas', () => {
})
})
describe('ghost placement flag', () => {
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', () => {
mockCanvasStore.isGhostPlacing = true
const { cancelDrag } = useNodeDragToCanvas()
cancelDrag()
expect(mockCanvasStore.isGhostPlacing).toBe(true)
})
})
describe('drag listener lifecycle', () => {
it('should attach document listeners on startDrag', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
@@ -167,47 +215,42 @@ describe('useNodeDragToCanvas', () => {
})
describe('endDrag behavior', () => {
it('should add node when pointer is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
it('should add node when released over the canvas', () => {
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
document.dispatchEvent(pointerEvent)
dispatchPointerUp(250, 250)
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
pos: [150, 150]
})
})
it('should not add node when pointer is outside canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
it('should not add node when released outside the canvas', () => {
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
clientX: 600,
clientY: 250,
bubbles: true
})
document.dispatchEvent(pointerEvent)
dispatchPointerUp(600, 250, panelElement)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(isDragging.value).toBe(false)
})
it('should not add node when released over a panel within canvas bounds', () => {
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
// FE-688: the panel overlays the full-bleed canvas, so a release at a
// point inside the canvas rect but on the panel must not place a hidden
// node behind the panel.
dispatchPointerUp(250, 250, panelElement)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(isDragging.value).toBe(false)
@@ -236,12 +279,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should select the placed node when one is returned from the graph', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const placedNode = { id: 1 }
mockAddNodeOnGraph.mockReturnValue(placedNode)
@@ -249,24 +287,13 @@ describe('useNodeDragToCanvas', () => {
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
dispatchPointerUp(250, 250)
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
})
it('should apply the requested widget values to the placed node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const widget = { name: 'ckpt_name', value: '' }
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
@@ -276,24 +303,13 @@ describe('useNodeDragToCanvas', () => {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
dispatchPointerUp(250, 250)
expect(widget.value).toBe('model.safetensors')
})
it('should warn but still place the node when a requested widget is missing', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const placedNode = { id: 1, widgets: [] }
mockAddNodeOnGraph.mockReturnValue(placedNode)
@@ -306,13 +322,7 @@ describe('useNodeDragToCanvas', () => {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
dispatchPointerUp(250, 250)
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
expect(mockToastAdd).toHaveBeenCalledWith(
@@ -327,12 +337,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should show an error toast when the graph fails to add the node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -340,13 +345,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
dispatchPointerUp(250, 250)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
@@ -357,12 +356,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should not call selectItems when graph returns no node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -370,35 +364,19 @@ describe('useNodeDragToCanvas', () => {
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
dispatchPointerUp(250, 250)
expect(mockSelectItems).not.toHaveBeenCalled()
})
it('should not add node on pointerup when in native drag mode', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
document.dispatchEvent(pointerEvent)
dispatchPointerUp(250, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(isDragging.value).toBe(true)
@@ -407,12 +385,7 @@ describe('useNodeDragToCanvas', () => {
describe('handleNativeDrop', () => {
it('should add node when drop position is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
@@ -426,12 +399,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should not add node when drop position is outside canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
@@ -443,12 +411,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should not add node when dragMode is click', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
@@ -460,12 +423,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should reset drag state after drop', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
@@ -478,7 +436,11 @@ describe('useNodeDragToCanvas', () => {
})
describe('blockCommitPointerDown', () => {
function dispatchPointerDown(x: number, y: number) {
function dispatchPointerDown(
x: number,
y: number,
target: EventTarget = canvasElement
) {
const event = new PointerEvent('pointerdown', {
clientX: x,
clientY: y,
@@ -486,17 +448,12 @@ describe('useNodeDragToCanvas', () => {
cancelable: true
})
const stopSpy = vi.spyOn(event, 'stopImmediatePropagation')
document.dispatchEvent(event)
target.dispatchEvent(event)
return stopSpy
}
beforeEach(() => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
})
it('should stop propagation when in click-drag mode over canvas', () => {
@@ -521,22 +478,17 @@ 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 } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
expect(dispatchPointerDown(250, 250, panelElement)).not.toHaveBeenCalled()
})
})
describe('native drag position tracking', () => {
beforeEach(() => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
canvasElement.getBoundingClientRect.mockReturnValue(CANVAS_RECT)
mockConvertEventToCanvasOffset.mockReturnValue([300, 300])
})

View File

@@ -66,6 +66,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
@@ -101,7 +112,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()
}
@@ -114,7 +125,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()
}
@@ -139,6 +150,7 @@ function cleanupGlobalListeners() {
}
function cancelDrag() {
if (isDragging.value) useCanvasStore().isGhostPlacing = false
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
@@ -162,6 +174,10 @@ export function useNodeDragToCanvas() {
dragMode.value = mode
pendingWidgetValues.value = widgetValues
pendingSource.value = source
// 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
setupGlobalListeners()
}

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

@@ -25,7 +25,11 @@ export function useNodePreviewAndDrag(
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
panelRef?: Ref<HTMLElement | null>
): UseNodePreviewAndDragReturn {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
const {
startDrag,
handleNativeDrop,
isDragging: isPlacingNode
} = useNodeDragToCanvas()
const settingStore = useSettingStore()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
@@ -34,7 +38,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',