From 53bdf7f6c3042eb8089b330dd87af86ab27bd28e Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Mon, 5 Jan 2026 22:09:11 +0100 Subject: [PATCH] fix: enable spacebar panning during slot link drag Move spacebar detection to document-level listener in LGraphCanvas when vueNodesMode is enabled. Implement direct panning in useSlotLinkInteraction when spacebar is held during connection drag, bypassing litegraph event handling for smoother panning while maintaining link position updates. Fixes #7806 --- src/lib/litegraph/src/LGraphCanvas.ts | 7 ++ .../useNodePointerInteractions.test.ts | 111 ------------------ .../composables/useNodePointerInteractions.ts | 29 +---- .../composables/useSlotLinkInteraction.ts | 49 +++++++- 4 files changed, 55 insertions(+), 141 deletions(-) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 7792260a4..33cdea34b 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -1995,6 +1995,10 @@ export class LGraphCanvas implements CustomEventDispatcher this._key_callback = this.processKey.bind(this) canvas.addEventListener('keydown', this._key_callback, true) + // In Vue nodes mode, also listen on document for keydown since Vue elements may have focus + if (LiteGraph.vueNodesMode) { + document.addEventListener('keydown', this._key_callback, true) + } // keyup event must be bound on the document document.addEventListener('keyup', this._key_callback, true) @@ -2026,6 +2030,9 @@ export class LGraphCanvas implements CustomEventDispatcher canvas.removeEventListener('pointerdown', this._mousedown_callback!) canvas.removeEventListener('wheel', this._mousewheel_callback!) canvas.removeEventListener('keydown', this._key_callback!) + if (LiteGraph.vueNodesMode) { + document.removeEventListener('keydown', this._key_callback!) + } document.removeEventListener('keyup', this._key_callback!) canvas.removeEventListener('contextmenu', this._doNothing) canvas.removeEventListener('dragenter', this._doReturnTrue) diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts index de21e674a..47fe3a848 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts @@ -13,41 +13,6 @@ import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag' const forwardEventToCanvasMock = vi.fn() const selectedItemsState: { items: Array<{ id?: string }> } = { items: [] } -const mockCanvas = vi.hoisted(() => { - const canvasElement = document.createElement('canvas') - return { - canvas: canvasElement, - read_only: false, - dragging_canvas: false, - pointer: { isDown: false } - } -}) - -// Mock useMagicKeys and useActiveElement from VueUse -// Use vi.hoisted to store refs in an object that's available during mock hoisting -const vueUseMocks = vi.hoisted(() => ({ - spaceKey: null as { value: boolean } | null, - activeElement: null as { value: Element | null } | null -})) - -vi.mock('@vueuse/core', async () => { - const { ref: vueRef } = await import('vue') - vueUseMocks.spaceKey = vueRef(false) - vueUseMocks.activeElement = vueRef(null) - return { - useMagicKeys: () => ({ space: vueUseMocks.spaceKey }), - useActiveElement: () => vueUseMocks.activeElement - } -}) - -vi.mock('@/scripts/app', () => ({ - app: { - get canvas() { - return mockCanvas - } - } -})) - // Mock the dependencies vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({ useCanvasInteractions: () => ({ @@ -360,80 +325,4 @@ describe('useNodePointerInteractions', () => { true ) }) - - describe('spacebar panning via useMagicKeys', () => { - beforeEach(() => { - mockCanvas.read_only = false - mockCanvas.dragging_canvas = false - vueUseMocks.spaceKey!.value = false - vueUseMocks.activeElement!.value = null - }) - - it('sets read_only=true when spacebar is pressed on non-canvas element', async () => { - const vueNodeElement = document.createElement('div') - vueUseMocks.activeElement!.value = vueNodeElement - - useNodePointerInteractions('test-node-123') - - // Simulate spacebar press - vueUseMocks.spaceKey!.value = true - await nextTick() - - expect(mockCanvas.read_only).toBe(true) - }) - - it('resets read_only=false when spacebar is released', async () => { - const vueNodeElement = document.createElement('div') - vueUseMocks.activeElement!.value = vueNodeElement - - useNodePointerInteractions('test-node-123') - - // Press and release spacebar - vueUseMocks.spaceKey!.value = true - await nextTick() - vueUseMocks.spaceKey!.value = false - await nextTick() - - expect(mockCanvas.read_only).toBe(false) - expect(mockCanvas.dragging_canvas).toBe(false) - }) - - it('does not set read_only when canvas has focus', async () => { - vueUseMocks.activeElement!.value = mockCanvas.canvas - - useNodePointerInteractions('test-node-123') - - vueUseMocks.spaceKey!.value = true - await nextTick() - - // Should NOT change read_only (litegraph handles it directly) - expect(mockCanvas.read_only).toBe(false) - }) - - it('does not set read_only when input element has focus', async () => { - const inputElement = document.createElement('input') - vueUseMocks.activeElement!.value = inputElement - - useNodePointerInteractions('test-node-123') - - vueUseMocks.spaceKey!.value = true - await nextTick() - - // Should NOT change read_only (avoid interfering with text input) - expect(mockCanvas.read_only).toBe(false) - }) - - it('does not set read_only when textarea element has focus', async () => { - const textareaElement = document.createElement('textarea') - vueUseMocks.activeElement!.value = textareaElement - - useNodePointerInteractions('test-node-123') - - vueUseMocks.spaceKey!.value = true - await nextTick() - - // Should NOT change read_only (avoid interfering with text input) - expect(mockCanvas.read_only).toBe(false) - }) - }) }) diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts index 61ca6dcf2..44cb981eb 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts @@ -1,8 +1,6 @@ -import { onScopeDispose, ref, toValue, watch } from 'vue' +import { onScopeDispose, ref, toValue } from 'vue' import type { MaybeRefOrGetter } from 'vue' -import { useActiveElement, useMagicKeys } from '@vueuse/core' - import { isMiddlePointerInput } from '@/base/pointerUtils' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' @@ -10,31 +8,6 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag' import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils' -import { app } from '@/scripts/app' - -function isEditableElement(el: Element | null): boolean { - if (!el) return false - const tag = el.tagName.toUpperCase() - if (tag === 'INPUT' || tag === 'TEXTAREA') return true - if (el instanceof HTMLElement && el.isContentEditable) return true - return false -} - -// Forward spacebar key events to litegraph for panning when Vue nodes have focus -const { space } = useMagicKeys() -const activeElement = useActiveElement() - -watch(space, (isPressed) => { - const canvas = app.canvas - if (!canvas) return - - // Skip if canvas has focus (litegraph handles it) or if in editable element - if (activeElement.value === canvas.canvas) return - if (isEditableElement(activeElement.value || null)) return - - // pointer events will bubble to litegraph - canvas.read_only = isPressed -}) export function useNodePointerInteractions( nodeIdRef: MaybeRefOrGetter diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index 1e4539d0d..b2854fc99 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -2,6 +2,7 @@ import { tryOnScopeDispose, useEventListener } from '@vueuse/core' import type { Fn } from '@vueuse/core' import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' +import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' import { LLink } from '@/lib/litegraph/src/LLink' @@ -122,12 +123,18 @@ export function useSlotLinkInteraction({ clearCompatible } = useSlotLinkDragUIState() const conversion = useSharedCanvasPositionConversion() + const { shouldHandleNodePointerEvents } = useCanvasInteractions() const pointerSession = createPointerSession() let activeAdapter: LinkConnectorAdapter | null = null // Per-drag drag-state context (non-reactive caches + RAF batching) const dragContext = createSlotLinkDragContext() + // Track if we've initiated panning mode during this drag session + let isPanningDuringLinkDrag = false + // Track last mouse position for panning delta calculation + let lastPanningMouse: [number, number] | null = null + const resolveRenderLinkSource = (link: RenderLink): Point | null => { if (link.fromReroute) { const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id) @@ -293,6 +300,8 @@ export function useSlotLinkInteraction({ raf.cancel() dragContext.dispose() clearCompatible() + isPanningDuringLinkDrag = false + lastPanningMouse = null } const updatePointerState = (event: PointerEvent) => { @@ -410,8 +419,44 @@ export function useSlotLinkInteraction({ const handlePointerMove = (event: PointerEvent) => { if (!pointerSession.matches(event)) return - // When in panning mode (read_only), let events bubble to litegraph - if (app.canvas?.read_only) return + // Skip synthetic events dispatched to canvas (forwarded events) to prevent infinite loop + // But allow trusted events even if they happen to be over the canvas + if (!event.isTrusted && event.target === app.canvas?.canvas) return + + // When in panning mode (read_only), handle panning directly + if (!shouldHandleNodePointerEvents.value) { + const canvas = app.canvas + if (!canvas) return + + // Initialize panning state on first move in panning mode + if (!isPanningDuringLinkDrag) { + isPanningDuringLinkDrag = true + lastPanningMouse = [event.clientX, event.clientY] + canvas.dragging_canvas = true + } + + // Calculate delta and apply panning + if (lastPanningMouse) { + const delta = [ + event.clientX - lastPanningMouse[0], + event.clientY - lastPanningMouse[1] + ] + canvas.ds.offset[0] += delta[0] / canvas.ds.scale + canvas.ds.offset[1] += delta[1] / canvas.ds.scale + canvas.setDirty(true, true) + } + lastPanningMouse = [event.clientX, event.clientY] + return + } + + // If we were in panning mode and now we're not, end the panning session + if (isPanningDuringLinkDrag) { + isPanningDuringLinkDrag = false + lastPanningMouse = null + if (app.canvas) { + app.canvas.dragging_canvas = false + } + } event.stopPropagation()