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
This commit is contained in:
Johnpaul
2026-01-05 22:09:11 +01:00
parent fc082d84b9
commit 53bdf7f6c3
4 changed files with 55 additions and 141 deletions

View File

@@ -1995,6 +1995,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
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<LGraphCanvasEventMap>
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)

View File

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

View File

@@ -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<string>

View File

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