mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 07:00:23 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user