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