From 8f301ec94b0b3144b19d3140b55cc9c0ab10f22f Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:41:18 -0800 Subject: [PATCH] Fix hit detection on vue node slots (#8609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Vue node slots extend outside the bounds of the node: image When clicking on the outer half of the slot, the matching node is not found as the click was technically not over a node, however in reality the action should still be associated with the node the slot is for. This specifically fixes middle click to add reroute not working on the outer half of the slot. ## Changes - **What**: - If the event is not over a node, check if is over a Vue slot, if so, use the node associated with that slot. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8609-Fix-hit-detection-on-vue-node-slots-2fd6d73d3650815c8328f9ea8fa66b0c) by [Unito](https://www.unito.io) ## Summary by CodeRabbit * **Tests** * Added comprehensive test suite for slot hit-detection in Vue nodes mode, covering standard and fallback interaction paths. * **Bug Fixes** * Improved hit-detection accuracy for slots that extend beyond node boundaries in Vue mode, ensuring clicks map to the correct node. --- .../src/LGraphCanvas.slotHitDetection.test.ts | 210 ++++++++++++++++++ src/lib/litegraph/src/LGraphCanvas.ts | 14 +- 2 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts diff --git a/src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts b/src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts new file mode 100644 index 0000000000..f61a56b87f --- /dev/null +++ b/src/lib/litegraph/src/LGraphCanvas.slotHitDetection.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + LGraph, + LGraphCanvas, + LGraphNode, + LiteGraph +} from '@/lib/litegraph/src/litegraph' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' + +vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ + layoutStore: { + querySlotAtPoint: vi.fn(), + queryRerouteAtPoint: vi.fn(), + getNodeLayoutRef: vi.fn(() => ({ value: null })), + getSlotLayout: vi.fn() + } +})) + +describe('LGraphCanvas slot hit detection', () => { + let graph: LGraph + let canvas: LGraphCanvas + let node: LGraphNode + let canvasElement: HTMLCanvasElement + + beforeEach(() => { + vi.clearAllMocks() + + canvasElement = document.createElement('canvas') + canvasElement.width = 800 + canvasElement.height = 600 + + const ctx = { + save: vi.fn(), + restore: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + fillText: vi.fn(), + measureText: vi.fn().mockReturnValue({ width: 50 }), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fill: vi.fn(), + closePath: vi.fn(), + arc: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + clearRect: vi.fn(), + setTransform: vi.fn(), + roundRect: vi.fn(), + getTransform: vi + .fn() + .mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), + font: '', + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + globalAlpha: 1, + textAlign: 'left' as CanvasTextAlign, + textBaseline: 'alphabetic' as CanvasTextBaseline + } as unknown as CanvasRenderingContext2D + + canvasElement.getContext = vi.fn().mockReturnValue(ctx) + canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 0, + top: 0, + width: 800, + height: 600 + }) + + graph = new LGraph() + canvas = new LGraphCanvas(canvasElement, graph, { + skip_render: true + }) + + // Create a test node with an output slot + node = new LGraphNode('Test Node') + node.pos = [100, 100] + node.size = [150, 80] + node.addOutput('output', 'number') + graph.add(node) + + // Enable Vue nodes mode for the test + LiteGraph.vueNodesMode = true + }) + + afterEach(() => { + LiteGraph.vueNodesMode = false + }) + + describe('processMouseDown slot fallback in Vue nodes mode', () => { + it('should query layoutStore.querySlotAtPoint when clicking outside node bounds', () => { + // Click position outside node bounds (node is at 100,100 with size 150x80) + // So node covers x: 100-250, y: 100-180 + // Click at x=255 is outside the right edge + const clickX = 255 + const clickY = 120 + + // Verify the click is outside the node bounds + expect(node.isPointInside(clickX, clickY)).toBe(false) + expect(graph.getNodeOnPos(clickX, clickY)).toBeNull() + + // Mock the slot query to return our node's slot + vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({ + nodeId: String(node.id), + index: 0, + type: 'output', + position: { x: 252, y: 120 }, + bounds: { x: 246, y: 110, width: 20, height: 20 } + }) + + // Call processMouseDown - this should trigger the slot fallback + canvas.processMouseDown( + new MouseEvent('pointerdown', { + button: 1, // Middle button + clientX: clickX, + clientY: clickY + }) + ) + + // The fix should query the layout store when no node is found at click position + expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({ + x: clickX, + y: clickY + }) + }) + + it('should NOT query layoutStore when node is found directly at click position', () => { + // Initialize node's bounding rect + node.updateArea() + + // Populate visible_nodes (normally done during render) + canvas.visible_nodes = [node] + + // Click inside the node bounds + const clickX = 150 + const clickY = 140 + + // Verify the click is inside the node bounds + expect(node.isPointInside(clickX, clickY)).toBe(true) + expect(graph.getNodeOnPos(clickX, clickY)).toBe(node) + + // Call processMouseDown + canvas.processMouseDown( + new MouseEvent('pointerdown', { + button: 1, + clientX: clickX, + clientY: clickY + }) + ) + + // Should NOT query the layout store since node was found directly + expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled() + }) + + it('should NOT query layoutStore when not in Vue nodes mode', () => { + LiteGraph.vueNodesMode = false + + const clickX = 255 + const clickY = 120 + + // Call processMouseDown + canvas.processMouseDown( + new MouseEvent('pointerdown', { + button: 1, + clientX: clickX, + clientY: clickY + }) + ) + + // Should NOT query the layout store in non-Vue mode + expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled() + }) + + it('should find node via slot query for input slots extending beyond left edge', () => { + node.addInput('input', 'number') + + // Click position left of node (node starts at x=100) + const clickX = 95 + const clickY = 140 + + // Verify outside bounds + expect(node.isPointInside(clickX, clickY)).toBe(false) + + vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({ + nodeId: String(node.id), + index: 0, + type: 'input', + position: { x: 98, y: 140 }, + bounds: { x: 88, y: 130, width: 20, height: 20 } + }) + + canvas.processMouseDown( + new MouseEvent('pointerdown', { + button: 1, + clientX: clickX, + clientY: clickY + }) + ) + + expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({ + x: clickX, + y: clickY + }) + }) + }) +}) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index a7ec4e597b..cc88b14f2e 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -2197,9 +2197,21 @@ export class LGraphCanvas implements CustomEventDispatcher if (!is_inside) return - const node = + let node = graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) ?? undefined + // In Vue nodes mode, slots extend beyond node bounds due to CSS transforms. + // If no node was found, check if the click is on a slot and use its owning node. + if (!node && LiteGraph.vueNodesMode) { + const slotLayout = layoutStore.querySlotAtPoint({ + x: e.canvasX, + y: e.canvasY + }) + if (slotLayout) { + node = graph.getNodeById(slotLayout.nodeId) ?? undefined + } + } + this.mouse[0] = x this.mouse[1] = y this.graph_mouse[0] = e.canvasX