Compare commits

...

6 Commits

Author SHA1 Message Date
Austin Mroz
0988cb1d87 Fix tests 2026-06-15 19:47:12 -07:00
Austin Mroz
00eade5e18 Add e2e link pan test 2026-06-15 16:25:48 -07:00
Austin Mroz
f58f0ce2d3 Fix space-bar pan while moving vue links 2026-06-15 15:51:29 -07:00
Connor Byrne
a4749501b1 refactor: use canvas event for read-only sync instead of callback
Replace LGraphCanvas.onReadOnlyChanged callback property with a
'litegraph:read-only-changed' CustomEvent dispatched on the canvas
DOM element. canvasStore now subscribes via useEventListener,
matching the existing pattern used for litegraph:set-graph,
subgraph-opened, subgraph-converted, and litegraph:ghost-placement.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8998#discussion_r2831838643
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8998#discussion_r3184409804
2026-06-15 15:51:23 -07:00
bymyself
5ed7948745 fix: space bar panning over Vue nodes in standard nav mode
Bridge LGraphCanvas.read_only to Vue reactivity via onReadOnlyChanged
callback so the existing CSS pointer-events-auto/none toggle on
LGraphNode.vue and NodeWidgets.vue re-evaluates when space key
toggles panning mode. Events then fall through to the LiteGraph
canvas naturally — no per-handler forwarding or force flags needed.

Fixes #7806

Amp-Thread-ID: https://ampcode.com/threads/T-019c796c-e83c-769d-85f4-20a349994bad
2026-06-15 15:51:17 -07:00
Alexander Brown
5535e93ef3 Restrict Node.js engine version to <26 (#12858)
## Summary

We have a few dependencies that have conflicts with Node 26 still.
2026-06-15 18:15:25 +00:00
11 changed files with 139 additions and 12 deletions

View File

@@ -1270,3 +1270,38 @@ test(
})
}
)
test('Spacebar pan', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
const initialOffset = await comfyPage.canvasOps.getOffset()
await test.step('Setup link drag', async () => {
await comfyPage.searchBoxV2.addNode('Load Diffusion')
const loadNode = await comfyPage.vueNodes.getFixtureByTitle('Load Diff')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await loadNode.getSlot('MODEL').hover()
await comfyPage.page.mouse.down()
await ksampler.getSlot('model').hover()
expect(await comfyPage.canvasOps.getOffset()).toEqual(initialOffset)
})
await test.step('Holding space initiates a pan', async () => {
await comfyPage.page.keyboard.down(' ')
await comfyPage.page.mouse.move(100, 100)
await comfyPage.page.keyboard.up(' ')
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(initialOffset)
})
await test.step('Mouse remains over model after pan', async () => {
await comfyPage.page.mouse.up()
await expect
.poll(() =>
comfyPage.page.evaluate(
() => graph?.nodes?.at(-1)?.outputs?.[0]?.links?.length === 1
)
)
.toBe(true)
})
})

View File

@@ -206,7 +206,7 @@
"zod-to-json-schema": "catalog:"
},
"engines": {
"node": ">=25",
"node": ">=25 <26",
"pnpm": ">=11.3"
},
"packageManager": "pnpm@11.3.0"

View File

@@ -410,8 +410,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
set read_only(value: boolean) {
const changed = this.state.readOnly !== value
this.state.readOnly = value
this._updateCursorStyle()
if (changed) {
this.dispatchEvent('litegraph:read-only-changed', { readOnly: value })
}
}
get isDragging(): boolean {
@@ -3972,7 +3976,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this._previously_dragging_canvas === null) {
this._previously_dragging_canvas = this.dragging_canvas
}
this.dragging_canvas = this.pointer.isDown
this.dragging_canvas =
this.pointer.isDown || !!this.linkConnector.renderLinks.length
block_default = true
} else if (e.key === 'Escape') {
// esc

View File

@@ -59,4 +59,9 @@ export interface LGraphCanvasEventMap {
active: boolean
nodeId: NodeId
}
/** The canvas read-only state has changed. */
'litegraph:read-only-changed': {
readOnly: boolean
}
}

View File

@@ -1,8 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
vi.mock('@/composables/useAppMode', () => ({
@@ -36,6 +38,30 @@ vi.mock('@/scripts/app', () => ({
}
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
useEventListener: vi.fn(
(
target: EventTarget,
event: string,
handler: EventListenerOrEventListenerObject
) => {
target.addEventListener(event, handler)
return () => target.removeEventListener(event, handler)
}
)
}
})
function createMockCanvas(readOnly = false): LGraphCanvas {
return {
read_only: readOnly,
canvas: document.createElement('canvas')
} as unknown as LGraphCanvas
}
describe('useCanvasStore', () => {
let store: ReturnType<typeof useCanvasStore>
@@ -90,4 +116,42 @@ describe('useCanvasStore', () => {
expect(store.selectedNodeIds).toHaveLength(0)
})
describe('isReadOnly', () => {
it('syncs initial read_only value when canvas is set', async () => {
const mockCanvas = createMockCanvas(true)
store.canvas = mockCanvas as unknown as LGraphCanvas
await nextTick()
expect(store.isReadOnly).toBe(true)
})
it('updates isReadOnly when litegraph:read-only-changed event fires', async () => {
const mockCanvas = createMockCanvas(false)
store.canvas = mockCanvas as unknown as LGraphCanvas
await nextTick()
expect(store.isReadOnly).toBe(false)
// Simulate space key press → LGraphCanvas sets read_only = true
mockCanvas.canvas.dispatchEvent(
new CustomEvent('litegraph:read-only-changed', {
detail: { readOnly: true }
})
)
expect(store.isReadOnly).toBe(true)
// Simulate space key release
mockCanvas.canvas.dispatchEvent(
new CustomEvent('litegraph:read-only-changed', {
detail: { readOnly: false }
})
)
expect(store.isReadOnly).toBe(false)
})
})
})

View File

@@ -56,6 +56,7 @@ export const useCanvasStore = defineStore('canvas', () => {
setMode(val ? 'app' : 'graph')
}
})
const isReadOnly = ref(false)
// Set up scale synchronization when canvas is available
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
@@ -131,6 +132,16 @@ export const useCanvasStore = defineStore('canvas', () => {
whenever(
() => canvas.value,
(newCanvas) => {
isReadOnly.value = newCanvas.read_only
useEventListener(
newCanvas.canvas,
'litegraph:read-only-changed',
(event: CustomEvent<{ readOnly: boolean }>) => {
isReadOnly.value = event.detail.readOnly
}
)
useEventListener(
newCanvas.canvas,
'litegraph:set-graph',
@@ -176,6 +187,7 @@ export const useCanvasStore = defineStore('canvas', () => {
rerouteSelected,
appScalePercentage,
linearMode,
isReadOnly,
updateSelectedItems,
getCanvas,
setAppZoomFromPercentage,

View File

@@ -13,7 +13,8 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => {
return {
useCanvasStore: vi.fn(() => ({
getCanvas,
setCursorStyle
setCursorStyle,
isReadOnly: false
}))
}
})

View File

@@ -27,9 +27,7 @@ export function useCanvasInteractions() {
* Whether Vue node components should handle pointer events.
* Returns false when canvas is in read-only/panning mode (e.g., space key held for panning).
*/
const shouldHandleNodePointerEvents = computed(
() => !(canvasStore.canvas?.read_only ?? false)
)
const shouldHandleNodePointerEvents = computed(() => !canvasStore.isReadOnly)
/**
* Returns true if the wheel event target is inside an element that should

View File

@@ -52,6 +52,10 @@ vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ isReadOnly: false })
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {

View File

@@ -23,6 +23,7 @@ import {
resolveNodeSurfaceSlotCandidate,
resolveSlotTargetCandidate
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
@@ -122,6 +123,7 @@ export function useSlotLinkInteraction({
setCompatibleForKey,
clearCompatible
} = useSlotLinkDragUIState()
const canvasStore = useCanvasStore()
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
@@ -414,9 +416,11 @@ export function useSlotLinkInteraction({
const canvas = app.canvas
const node = canvas.graph?.getNodeById(nodeId)
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
if (!pointerSession.matches(event) || canvasStore.isReadOnly) return
event.stopPropagation()
app.canvas.last_mouse = [event.clientX, event.clientY]
autoPan?.updatePointer(event.clientX, event.clientY)
if (canvas.subgraph && node) {

View File

@@ -193,14 +193,13 @@ export const useAppModeStore = defineStore('appMode', () => {
let unwatchReadOnly: (() => void) | undefined
function enforceReadOnly(inSelect: boolean) {
const { state } = getCanvas()
if (!state) return
state.readOnly = inSelect
const canvas = getCanvas()
canvas.read_only = inSelect
unwatchReadOnly?.()
if (inSelect)
unwatchReadOnly = watch(
() => state.readOnly,
() => (state.readOnly = true)
() => canvas.read_only,
() => (canvas.read_only = true)
)
}