Skip to content

Commit 891ee51

Browse files
AustinMrozDrJKLampagent
authored
Support space bar pan while dragging vue nodes (#12943)
Pressing spacebar while dragging vue nodes will now initiate a pan operation. Followup to #8998 --------- Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Amp <amp@ampcode.com>
1 parent 450cc7f commit 891ee51

9 files changed

Lines changed: 264 additions & 27 deletions

File tree

browser_tests/fixtures/ComfyMouse.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
113113
)
114114
}
115115

116+
async hold(...args: Parameters<Mouse['down']>) {
117+
await this.mouse.down(...args)
118+
const release = new AsyncDisposableStack()
119+
release.defer(() => this.mouse.up(...args))
120+
return release
121+
}
122+
116123
//#region Pass-through
117124
async click(...args: Parameters<Mouse['click']>) {
118125
return await this.mouse.click(...args)

browser_tests/fixtures/helpers/KeyboardHelper.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ export class KeyboardHelper {
1414
await nextFrame(this.page)
1515
}
1616

17+
async hold(key: string): Promise<AsyncDisposableStack> {
18+
await this.page.keyboard.down(key)
19+
const release = new AsyncDisposableStack()
20+
release.defer(() => this.page.keyboard.up(key))
21+
return release
22+
}
23+
1724
async delete(locator?: Locator | null): Promise<void> {
1825
await this.press('Delete', locator)
1926
}

browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts

Lines changed: 164 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,37 +27,180 @@ test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => {
2727
}
2828
)
2929

30-
test.describe('spacebar panning', () => {
31-
test.beforeEach(async ({ comfyPage }) => {
32-
await comfyPage.settings.setSetting(
33-
'Comfy.Canvas.NavigationMode',
34-
'standard'
35-
)
36-
await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple')
37-
})
30+
test('spacebar panning', async ({ comfyPage, comfyMouse }) => {
31+
await comfyPage.settings.setSetting(
32+
'Comfy.Canvas.NavigationMode',
33+
'standard'
34+
)
35+
await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple')
36+
const node = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
37+
const [nodeRef] = await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
38+
if (!nodeRef) throw new Error('KSampler is not rendered')
39+
const softExpect = expect.configure({ soft: true })
3840

39-
test('Space + left-drag on a Vue node pans canvas', async ({
40-
comfyPage,
41-
comfyMouse
42-
}) => {
43-
const node = comfyPage.vueNodes.getNodeByTitle('KSampler')
41+
await test.step('Space + click on a node starts a pan', async () => {
4442
const offsetBefore = await comfyPage.canvasOps.getOffset()
4543

4644
await comfyPage.canvas.focus()
47-
await comfyPage.page.keyboard.down('Space')
48-
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
49-
try {
50-
await comfyMouse.dragElementBy(node, { x: 140, y: 90 })
51-
} finally {
52-
await comfyPage.page.keyboard.up('Space')
53-
}
45+
await using releaseSpace = await comfyPage.keyboard.hold('Space')
46+
await softExpect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
47+
await comfyMouse.dragElementBy(node.root, { x: -300, y: 0 })
48+
await releaseSpace.disposeAsync()
5449

55-
await expect
50+
await softExpect
5651
.poll(() => comfyPage.canvasOps.getOffset())
5752
.not.toEqual(offsetBefore)
5853
})
54+
55+
await test.step('Space switches node dragging to canvas panning', async () => {
56+
await node.header.hover()
57+
await using mouseRelease = await comfyMouse.hold()
58+
await comfyPage.page.mouse.move(500, 500, { steps: 5 })
59+
const offsetBeforePan = await comfyPage.canvasOps.getOffset()
60+
61+
await using spaceRelease = await comfyPage.keyboard.hold('Space')
62+
await comfyPage.page.mouse.move(400, 400, { steps: 5 })
63+
await softExpect
64+
.poll(() => comfyPage.canvasOps.getOffset())
65+
.not.toEqual(offsetBeforePan)
66+
67+
await test.step('Releasing Space resumes node dragging', async () => {
68+
await spaceRelease.disposeAsync()
69+
const offsetAfterPan = await comfyPage.canvasOps.getOffset()
70+
const positionBeforeResume = [
71+
...(await nodeRef.getProperty<[number, number]>('pos'))
72+
]
73+
await comfyPage.page.mouse.move(500, 500, { steps: 5 })
74+
await comfyPage.nextFrame()
75+
76+
softExpect(await comfyPage.canvasOps.getOffset()).toEqual(
77+
offsetAfterPan
78+
)
79+
await softExpect
80+
.poll(async () => [
81+
...(await nodeRef.getProperty<[number, number]>('pos'))
82+
])
83+
.not.toEqual(positionBeforeResume)
84+
await mouseRelease.disposeAsync()
85+
})
86+
})
5987
})
6088

89+
test(
90+
'Space in a focused text widget does not start canvas panning',
91+
{ tag: ['@canvas', '@widget'] },
92+
async ({ comfyPage }) => {
93+
await comfyPage.workflow.loadWorkflow('inputs/string_input')
94+
const input = comfyPage.vueNodes
95+
.getWidgetByName('Node With String Input', 'string_input')
96+
.first()
97+
98+
await input.focus()
99+
await input.press('Space')
100+
101+
await expect
102+
.poll(async () => [
103+
await input.inputValue(),
104+
await comfyPage.canvasOps.isReadOnly()
105+
])
106+
.toEqual([' ', false])
107+
}
108+
)
109+
110+
test(
111+
'Space in a focused native select does not start canvas panning',
112+
{ tag: ['@canvas', '@widget'] },
113+
async ({ comfyPage, comfyMouse }) => {
114+
await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple')
115+
const node = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
116+
const [nodeRef] = await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
117+
if (!nodeRef) throw new Error('KSampler is not rendered')
118+
const positionBeforeDrag = [
119+
...(await nodeRef.getProperty<[number, number]>('pos'))
120+
]
121+
await node.root.evaluate((element) => {
122+
const select = document.createElement('select')
123+
select.ariaLabel = 'Native select'
124+
select.append(document.createElement('option'))
125+
element.append(select)
126+
})
127+
const select = node.root.getByRole('combobox', {
128+
name: 'Native select'
129+
})
130+
131+
await test.step('Hold and drag the node', async () => {
132+
await node.header.hover()
133+
await using mouseRelease = await comfyMouse.hold()
134+
await comfyPage.page.mouse.move(500, 500, { steps: 5 })
135+
await expect
136+
.poll(async () => [
137+
...(await nodeRef.getProperty<[number, number]>('pos'))
138+
])
139+
.not.toEqual(positionBeforeDrag)
140+
141+
await test.step('Press Space in the focused native select', async () => {
142+
await select.focus()
143+
await using spaceRelease = await comfyPage.keyboard.hold('Space')
144+
145+
await expect(select).toBeFocused()
146+
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
147+
await spaceRelease.disposeAsync()
148+
})
149+
150+
await mouseRelease.disposeAsync()
151+
})
152+
}
153+
)
154+
155+
test(
156+
'releasing the pointer during Space-pan ends the node drag',
157+
{ tag: ['@canvas', '@node'] },
158+
async ({ comfyPage, comfyMouse }) => {
159+
await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple')
160+
const node = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
161+
const [nodeRef] = await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
162+
const headerBox = await node.header.boundingBox()
163+
if (!nodeRef || !headerBox) throw new Error('KSampler is not rendered')
164+
const start = {
165+
x: headerBox.x + headerBox.width / 2,
166+
y: headerBox.y + headerBox.height / 2
167+
}
168+
169+
const positionAfterRelease =
170+
await test.step('Release the pointer while Space-panning', async () => {
171+
await comfyPage.page.mouse.move(start.x, start.y)
172+
await using mouseRelease = await comfyMouse.hold()
173+
await comfyPage.page.mouse.move(start.x + 40, start.y + 40, {
174+
steps: 5
175+
})
176+
await using spaceRelease = await comfyPage.keyboard.hold('Space')
177+
await comfyPage.page.mouse.move(start.x + 80, start.y + 80, {
178+
steps: 5
179+
})
180+
await mouseRelease.disposeAsync()
181+
await spaceRelease.disposeAsync()
182+
183+
return [...(await nodeRef.getProperty<[number, number]>('pos'))]
184+
})
185+
186+
await test.step('Further pointer movement leaves the node in place', async () => {
187+
const headerAfterRelease = await node.header.boundingBox()
188+
if (!headerAfterRelease) throw new Error('KSampler is not rendered')
189+
await comfyPage.page.mouse.move(
190+
headerAfterRelease.x + 5,
191+
headerAfterRelease.y + 5
192+
)
193+
await comfyPage.nextFrame()
194+
195+
await expect
196+
.poll(async () => [
197+
...(await nodeRef.getProperty<[number, number]>('pos'))
198+
])
199+
.toEqual(positionAfterRelease)
200+
})
201+
}
202+
)
203+
61204
test(
62205
'@mobile Can pan with touch',
63206
{ tag: '@screenshot' },

src/components/graph/GraphCanvas.vue

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
@pointerdown.capture="forwardPointerDownPanEvent"
7171
@pointerup.capture="forwardPointerUpPanEvent"
7272
@pointermove.capture="forwardPointerMovePanEvent"
73+
@keydown.space="forwardSpaceKeyEvent"
7374
>
7475
<!-- Vue nodes rendered based on graph nodes -->
7576
<LGraphNode
@@ -619,6 +620,21 @@ function forwardPointerUpPanEvent(e: PointerEvent) {
619620
forwardPanEvent(e, isMiddleButtonEvent)
620621
}
621622
623+
function forwardSpaceKeyEvent(e: KeyboardEvent) {
624+
const target = e.target
625+
if (
626+
!layoutStore.isDraggingVueNodes.value ||
627+
target instanceof HTMLInputElement ||
628+
target instanceof HTMLTextAreaElement ||
629+
target instanceof HTMLSelectElement ||
630+
target instanceof HTMLButtonElement ||
631+
(target instanceof HTMLElement && target.isContentEditable)
632+
)
633+
return
634+
635+
comfyApp.canvas?.processKey(e)
636+
}
637+
622638
function forwardPanEvent(
623639
e: PointerEvent,
624640
isMiddleInput: (event: PointerEvent) => boolean

src/lib/litegraph/src/LGraphCanvas.linkDragAutoPan.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
77
layoutStore: {
88
querySlotAtPoint: vi.fn(),
99
queryRerouteAtPoint: vi.fn(),
10+
queryLinkSegmentAtPoint: vi.fn(),
1011
getNodeLayoutRef: vi.fn(() => ({ value: null })),
1112
getSlotLayout: vi.fn()
1213
}
@@ -58,6 +59,44 @@ describe('LGraphCanvas link drag auto-pan', () => {
5859
expect(canvas['_autoPan']).not.toBeNull()
5960
})
6061

62+
it('resumes auto-pan after Space panning during a link drag', () => {
63+
canvas.processMouseDown(
64+
new PointerEvent('pointerdown', {
65+
button: 0,
66+
buttons: 1,
67+
clientX: 400,
68+
clientY: 300,
69+
isPrimary: true
70+
})
71+
)
72+
canvas.linkConnector.state.connectingTo = 'output'
73+
startLinkDrag()
74+
canvas.processMouseMove(
75+
new PointerEvent('pointermove', {
76+
buttons: 1,
77+
clientX: 5,
78+
clientY: 300,
79+
isPrimary: true
80+
})
81+
)
82+
const keydown = new KeyboardEvent('keydown', { key: ' ' })
83+
const keyup = new KeyboardEvent('keyup', { key: ' ' })
84+
Object.defineProperty(keydown, 'target', { value: canvasElement })
85+
Object.defineProperty(keyup, 'target', { value: canvasElement })
86+
87+
canvas.processKey(keydown)
88+
const offsetWhileSpacePanning = [...canvas.ds.offset]
89+
90+
vi.advanceTimersByTime(16)
91+
92+
expect([...canvas.ds.offset]).toEqual(offsetWhileSpacePanning)
93+
94+
canvas.processKey(keyup)
95+
vi.advanceTimersByTime(16)
96+
97+
expect([...canvas.ds.offset]).not.toEqual(offsetWhileSpacePanning)
98+
})
99+
61100
it('keeps graph_mouse consistent with offset after auto-pan', () => {
62101
canvas.mouse[0] = 5
63102
canvas.mouse[1] = 300

src/lib/litegraph/src/LGraphCanvas.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4008,6 +4008,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
40084008
if (e.key === ' ') {
40094009
// space
40104010
this.read_only = true
4011+
this._autoPan?.stop()
40114012
if (this._previously_dragging_canvas === null) {
40124013
this._previously_dragging_canvas = this.dragging_canvas
40134014
}
@@ -4037,6 +4038,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
40374038
this.dragging_canvas =
40384039
(this._previously_dragging_canvas ?? false) && this.pointer.isDown
40394040
this._previously_dragging_canvas = null
4041+
if (
4042+
this.pointer.isDown &&
4043+
(this.isDragging || this.linkConnector.isConnecting)
4044+
) {
4045+
this._autoPan?.updatePointer(this.mouse[0], this.mouse[1])
4046+
this._autoPan?.start()
4047+
}
40404048
}
40414049

40424050
for (const node of Object.values(this.selected_nodes)) {

src/renderer/core/canvas/useCanvasInteractions.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,12 @@ export function useCanvasInteractions() {
148148
return
149149
}
150150

151-
// Create new event with same properties
152-
const EventConstructor = event.constructor as
153-
| typeof MouseEvent
154-
| typeof PointerEvent
155-
const newEvent = new EventConstructor(event.type, event)
156-
canvasEl.dispatchEvent(newEvent)
151+
if (event instanceof PointerEvent) {
152+
canvasEl.dispatchEvent(new PointerEvent(event.type, event))
153+
return
154+
}
155+
156+
canvasEl.dispatchEvent(new MouseEvent(event.type, event))
157157
}
158158

159159
return {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ export function useNodePointerInteractions(
142142
const canHandlePointer = shouldHandleNodePointerEvents.value
143143
if (!canHandlePointer) {
144144
forwardEventToCanvas(event)
145+
if (hasDraggingStarted || layoutStore.isDraggingVueNodes.value) {
146+
safeDragEnd(event)
147+
}
145148
return
146149
}
147150
const wasDragging = layoutStore.isDraggingVueNodes.value

src/renderer/extensions/vueNodes/layout/useNodeDrag.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,20 @@ function useNodeDragIndividual() {
196196
if (!dragStartPos || !dragStartMouse) {
197197
return
198198
}
199+
if (canvasStore.isReadOnly) {
200+
autoPan?.stop()
201+
const canvas = canvasStore.getCanvas()
202+
const delta = [event.clientX - lastPointerX, event.clientY - lastPointerY]
203+
204+
canvas.ds.offset[0] += delta[0] / canvas.ds.scale
205+
canvas.ds.offset[1] += delta[1] / canvas.ds.scale
206+
canvas.setDirty(true, true)
207+
lastPointerX = event.clientX
208+
lastPointerY = event.clientY
209+
dragStartMouse.x += delta[0]
210+
dragStartMouse.y += delta[1]
211+
return
212+
}
199213

200214
// Throttle position updates using requestAnimationFrame for better performance
201215
if (rafId !== null) return // Skip if frame already scheduled

0 commit comments

Comments
 (0)