Skip to content

Commit 790c64f

Browse files
fix: space bar panning over Vue nodes in standard nav mode
Bridge LGraphCanvas.read_only to Vue reactivity via onReadOnlyChanged callback so shouldHandleNodePointerEvents computed updates when space key toggles panning mode. Also forward pointermove and wheel events to canvas during panning. Fixes #7806 Amp-Thread-ID: https://ampcode.com/threads/T-019c796c-e83c-769d-85f4-20a349994bad
1 parent 38edba7 commit 790c64f

File tree

7 files changed

+202
-12
lines changed

7 files changed

+202
-12
lines changed

src/lib/litegraph/src/LGraphCanvas.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
401401
set read_only(value: boolean) {
402402
this.state.readOnly = value
403403
this._updateCursorStyle()
404+
this.onReadOnlyChanged?.(value)
404405
}
405406

407+
onReadOnlyChanged?: (readOnly: boolean) => void
408+
406409
get isDragging(): boolean {
407410
return this.state.draggingItems
408411
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createTestingPinia } from '@pinia/testing'
2+
import { setActivePinia } from 'pinia'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { nextTick } from 'vue'
5+
6+
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
7+
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
8+
9+
vi.mock('@/scripts/app', () => ({
10+
app: { canvas: null }
11+
}))
12+
13+
vi.mock('@vueuse/core', async (importOriginal) => {
14+
const actual = await importOriginal()
15+
return {
16+
...(actual as Record<string, unknown>),
17+
useEventListener: vi.fn()
18+
}
19+
})
20+
21+
function createMockCanvas(
22+
readOnly = false
23+
): LGraphCanvas & { onReadOnlyChanged?: (v: boolean) => void } {
24+
return {
25+
read_only: readOnly,
26+
canvas: document.createElement('canvas'),
27+
onReadOnlyChanged: undefined
28+
} as unknown as LGraphCanvas & {
29+
onReadOnlyChanged?: (v: boolean) => void
30+
}
31+
}
32+
33+
describe('useCanvasStore', () => {
34+
beforeEach(() => {
35+
setActivePinia(createTestingPinia({ stubActions: false }))
36+
vi.clearAllMocks()
37+
})
38+
39+
describe('isReadOnly', () => {
40+
it('syncs initial read_only value when canvas is set', async () => {
41+
const store = useCanvasStore()
42+
const mockCanvas = createMockCanvas(true)
43+
44+
store.canvas = mockCanvas as unknown as LGraphCanvas
45+
await nextTick()
46+
47+
expect(store.isReadOnly).toBe(true)
48+
})
49+
50+
it('updates isReadOnly when onReadOnlyChanged callback fires', async () => {
51+
const store = useCanvasStore()
52+
const mockCanvas = createMockCanvas(false)
53+
54+
store.canvas = mockCanvas as unknown as LGraphCanvas
55+
await nextTick()
56+
57+
expect(store.isReadOnly).toBe(false)
58+
59+
// Simulate space key press → LGraphCanvas sets read_only = true
60+
mockCanvas.onReadOnlyChanged?.(true)
61+
62+
expect(store.isReadOnly).toBe(true)
63+
64+
// Simulate space key release
65+
mockCanvas.onReadOnlyChanged?.(false)
66+
67+
expect(store.isReadOnly).toBe(false)
68+
})
69+
70+
it('registers onReadOnlyChanged callback on the canvas', async () => {
71+
const store = useCanvasStore()
72+
const mockCanvas = createMockCanvas(false)
73+
74+
store.canvas = mockCanvas as unknown as LGraphCanvas
75+
await nextTick()
76+
77+
expect(mockCanvas.onReadOnlyChanged).toBeDefined()
78+
expect(typeof mockCanvas.onReadOnlyChanged).toBe('function')
79+
})
80+
})
81+
})

src/renderer/core/canvas/canvasStore.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const useCanvasStore = defineStore('canvas', () => {
4141
const appScalePercentage = ref(100)
4242

4343
const linearMode = ref(false)
44+
const isReadOnly = ref(false)
4445

4546
// Set up scale synchronization when canvas is available
4647
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
@@ -115,6 +116,11 @@ export const useCanvasStore = defineStore('canvas', () => {
115116
whenever(
116117
() => canvas.value,
117118
(newCanvas) => {
119+
isReadOnly.value = newCanvas.read_only
120+
newCanvas.onReadOnlyChanged = (value: boolean) => {
121+
isReadOnly.value = value
122+
}
123+
118124
useEventListener(
119125
newCanvas.canvas,
120126
'litegraph:set-graph',
@@ -141,6 +147,7 @@ export const useCanvasStore = defineStore('canvas', () => {
141147
rerouteSelected,
142148
appScalePercentage,
143149
linearMode,
150+
isReadOnly,
144151
updateSelectedItems,
145152
getCanvas,
146153
setAppZoomFromPercentage,

src/renderer/core/canvas/useCanvasInteractions.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { reactive } from 'vue'
23

34
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
45
import { useSettingStore } from '@/platform/settings/settingStore'
56
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
67
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
78

8-
// Mock stores
9+
// Mock stores — use reactive object so computed properties in the SUT
10+
// re-evaluate when `isReadOnly` changes (mimics Pinia's auto-unwrapping).
911
vi.mock('@/renderer/core/canvas/canvasStore', () => {
1012
const getCanvas = vi.fn()
1113
const setCursorStyle = vi.fn()
14+
const state = reactive({
15+
canvas: { read_only: false } as { read_only: boolean },
16+
isReadOnly: false
17+
})
1218
return {
1319
useCanvasStore: vi.fn(() => ({
1420
getCanvas,
15-
setCursorStyle
21+
setCursorStyle,
22+
get canvas() {
23+
return state.canvas
24+
},
25+
get isReadOnly() {
26+
return state.isReadOnly
27+
},
28+
set isReadOnly(v: boolean) {
29+
state.isReadOnly = v
30+
}
1631
}))
1732
}
1833
})
@@ -59,6 +74,8 @@ function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
5974
describe('useCanvasInteractions', () => {
6075
beforeEach(() => {
6176
vi.resetAllMocks()
77+
const store = useCanvasStore()
78+
store.isReadOnly = false
6279
})
6380

6481
describe('handlePointer', () => {
@@ -200,6 +217,21 @@ describe('useCanvasInteractions', () => {
200217
document.body.removeChild(captureElement)
201218
})
202219

220+
it('should forward wheel events in standard mode when in panning mode (space held)', () => {
221+
const { get } = useSettingStore()
222+
vi.mocked(get).mockReturnValue('standard')
223+
const store = useCanvasStore()
224+
store.isReadOnly = true
225+
226+
const { handleWheel } = useCanvasInteractions()
227+
228+
const mockEvent = createMockWheelEvent()
229+
handleWheel(mockEvent)
230+
231+
expect(mockEvent.preventDefault).toHaveBeenCalled()
232+
expect(mockEvent.stopPropagation).toHaveBeenCalled()
233+
})
234+
203235
it('should forward ctrl+wheel to canvas when capture element IS focused in standard mode', () => {
204236
const { get } = useSettingStore()
205237
vi.mocked(get).mockReturnValue('standard')

src/renderer/core/canvas/useCanvasInteractions.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function useCanvasInteractions() {
2323
* Returns false when canvas is in read-only/panning mode (e.g., space key held for panning).
2424
*/
2525
const shouldHandleNodePointerEvents = computed(
26-
() => !(canvasStore.canvas?.read_only ?? false)
26+
() => !canvasStore.isReadOnly
2727
)
2828

2929
/**
@@ -50,17 +50,18 @@ export function useCanvasInteractions() {
5050
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
5151
*/
5252
const handleWheel = (event: WheelEvent) => {
53-
if (!shouldForwardWheelEvent(event)) return
53+
const isPanning = !shouldHandleNodePointerEvents.value
54+
if (!isPanning && !shouldForwardWheelEvent(event)) return
5455

5556
// In standard mode, Ctrl+wheel should go to canvas for zoom
5657
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
57-
forwardEventToCanvas(event)
58+
forwardEventToCanvas(event, { force: true })
5859
return
5960
}
6061

61-
// In legacy mode, all wheel events go to canvas for zoom
62-
if (!isStandardNavMode.value) {
63-
forwardEventToCanvas(event)
62+
// In legacy mode or panning mode, forward wheel events to canvas
63+
if (!isStandardNavMode.value || isPanning) {
64+
forwardEventToCanvas(event, { force: isPanning })
6465
return
6566
}
6667

@@ -97,10 +98,12 @@ export function useCanvasInteractions() {
9798
* Forwards an event to the LiteGraph canvas
9899
*/
99100
const forwardEventToCanvas = (
100-
event: WheelEvent | PointerEvent | MouseEvent
101+
event: WheelEvent | PointerEvent | MouseEvent,
102+
{ force = false }: { force?: boolean } = {}
101103
) => {
102104
// Honor wheel capture only when the element is focused
103-
if (event instanceof WheelEvent && !shouldForwardWheelEvent(event)) return
105+
if (event instanceof WheelEvent && !force && !shouldForwardWheelEvent(event))
106+
return
104107

105108
const canvasEl = app.canvas?.canvas
106109
if (!canvasEl) return

src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
99
import type { NodeLayout } from '@/renderer/core/layout/types'
1010
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
1111

12-
const forwardEventToCanvasMock = vi.fn()
12+
const { forwardEventToCanvasMock } = vi.hoisted(() => ({
13+
forwardEventToCanvasMock: vi.fn()
14+
}))
15+
const shouldHandleNodePointerEventsRef = ref(true)
1316
const selectedItemsState: { items: Array<{ id?: string }> } = { items: [] }
1417

1518
// Mock the dependencies
1619
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
1720
useCanvasInteractions: () => ({
1821
forwardEventToCanvas: forwardEventToCanvasMock,
19-
shouldHandleNodePointerEvents: ref(true)
22+
shouldHandleNodePointerEvents: shouldHandleNodePointerEventsRef
2023
})
2124
}))
2225

@@ -135,6 +138,7 @@ describe('useNodePointerInteractions', () => {
135138
beforeEach(async () => {
136139
vi.resetAllMocks()
137140
selectedItemsState.items = []
141+
shouldHandleNodePointerEventsRef.value = true
138142
setActivePinia(createTestingPinia())
139143
})
140144

@@ -295,6 +299,54 @@ describe('useNodePointerInteractions', () => {
295299
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
296300
})
297301

302+
it('should forward pointermove to canvas when in panning mode (space held)', () => {
303+
shouldHandleNodePointerEventsRef.value = false
304+
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
305+
306+
const moveEvent = createPointerEvent('pointermove', {
307+
clientX: 150,
308+
clientY: 150,
309+
buttons: 1
310+
})
311+
pointerHandlers.onPointermove(moveEvent)
312+
313+
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(moveEvent)
314+
})
315+
316+
it('should clean up drag state when panning mode activates mid-drag', () => {
317+
const { endDrag } = useNodeDrag()
318+
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
319+
320+
// Start a drag
321+
pointerHandlers.onPointerdown(
322+
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
323+
)
324+
pointerHandlers.onPointermove(
325+
createPointerEvent('pointermove', {
326+
clientX: 110,
327+
clientY: 110,
328+
buttons: 1
329+
})
330+
)
331+
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
332+
333+
// Space pressed mid-drag — panning mode activates
334+
shouldHandleNodePointerEventsRef.value = false
335+
336+
const moveEvent = createPointerEvent('pointermove', {
337+
clientX: 120,
338+
clientY: 120,
339+
buttons: 1
340+
})
341+
pointerHandlers.onPointermove(moveEvent)
342+
343+
// Drag should be cleaned up
344+
expect(endDrag).toHaveBeenCalled()
345+
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
346+
// Event should still be forwarded to canvas
347+
expect(forwardEventToCanvasMock).toHaveBeenCalledWith(moveEvent)
348+
})
349+
298350
it('on ctrl+click: calls toggleNodeSelectionAfterPointerUp on pointer up (not pointer down)', async () => {
299351
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
300352
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()

src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ export function useNodePointerInteractions(
6565
function onPointermove(event: PointerEvent) {
6666
if (forwardMiddlePointerIfNeeded(event)) return
6767

68+
// Forward pointermove to canvas when in panning mode (e.g., space key held)
69+
if (!shouldHandleNodePointerEvents.value) {
70+
if (hasDraggingStarted || layoutStore.isDraggingVueNodes.value) {
71+
safeDragEnd(event)
72+
}
73+
forwardEventToCanvas(event)
74+
return
75+
}
76+
6877
// Don't activate drag while resizing
6978
if (layoutStore.isResizingVueNodes.value) return
7079

@@ -129,6 +138,9 @@ export function useNodePointerInteractions(
129138
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
130139
const canHandlePointer = shouldHandleNodePointerEvents.value
131140
if (!canHandlePointer) {
141+
if (hasDraggingStarted || layoutStore.isDraggingVueNodes.value) {
142+
safeDragEnd(event)
143+
}
132144
forwardEventToCanvas(event)
133145
return
134146
}

0 commit comments

Comments
 (0)