Skip to content

Commit a8800b8

Browse files
[feat] make Escape key configurable for exiting subgraphs
Fixes #4208 - Escape hotkey is now configurable through the keybinding system. Added Exit Subgraph command that can be bound to any key combination. Escape key prioritizes closing dialogs before exiting subgraphs.
1 parent 0d8e4fe commit a8800b8

File tree

7 files changed

+515
-13
lines changed

7 files changed

+515
-13
lines changed

browser_tests/tests/subgraph.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,4 +642,117 @@ test.describe('Subgraph Operations', () => {
642642
expect(finalCount).toBe(parentCount)
643643
})
644644
})
645+
646+
test.describe('Configurable Escape Key', () => {
647+
test('Escape keybinding can be customized', async ({ comfyPage }) => {
648+
await comfyPage.loadWorkflow('subgraph')
649+
await comfyPage.nextFrame()
650+
651+
const subgraphNode = await comfyPage.getNodeRefById('1')
652+
await subgraphNode.navigateIntoSubgraph()
653+
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
654+
655+
// Verify we're in a subgraph
656+
expect(await isInSubgraph(comfyPage)).toBe(true)
657+
658+
// Open settings dialog
659+
await comfyPage.menu.openSettings()
660+
661+
// Navigate to keybindings
662+
await comfyPage.page.click('text=Keybinding')
663+
664+
// Find the Exit Subgraph keybinding
665+
const keybindingRow = comfyPage.page.locator(
666+
'tr:has-text("Exit Subgraph")'
667+
)
668+
await expect(keybindingRow).toBeVisible()
669+
670+
// Verify default binding is Escape
671+
const keyDisplay = keybindingRow.locator('.key-combo-display')
672+
await expect(keyDisplay).toHaveText('Escape')
673+
674+
// Change binding to Alt+Up
675+
const editButton = keybindingRow.locator(
676+
'button[aria-label="Edit keybinding"]'
677+
)
678+
await editButton.click()
679+
680+
// Record new keybinding
681+
await comfyPage.page.keyboard.press('Alt+ArrowUp')
682+
683+
// Save the keybinding
684+
const saveButton = keybindingRow.locator(
685+
'button[aria-label="Save keybinding"]'
686+
)
687+
await saveButton.click()
688+
689+
// Close settings
690+
await comfyPage.page.keyboard.press('Escape')
691+
await comfyPage.nextFrame()
692+
693+
// Test that Escape no longer exits subgraph
694+
await comfyPage.page.keyboard.press('Escape')
695+
await comfyPage.nextFrame()
696+
expect(await isInSubgraph(comfyPage)).toBe(true)
697+
698+
// Test that Alt+Up now exits subgraph
699+
await comfyPage.page.keyboard.press('Alt+ArrowUp')
700+
await comfyPage.nextFrame()
701+
expect(await isInSubgraph(comfyPage)).toBe(false)
702+
703+
// Reset keybinding to default
704+
await comfyPage.menu.openSettings()
705+
await comfyPage.page.click('text=Keybinding')
706+
707+
const resetButton = keybindingRow.locator(
708+
'button[aria-label="Reset to default"]'
709+
)
710+
await resetButton.click()
711+
712+
await comfyPage.page.keyboard.press('Escape')
713+
await comfyPage.nextFrame()
714+
715+
// Verify Escape works again
716+
await subgraphNode.navigateIntoSubgraph()
717+
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
718+
expect(await isInSubgraph(comfyPage)).toBe(true)
719+
720+
await comfyPage.page.keyboard.press('Escape')
721+
await comfyPage.nextFrame()
722+
expect(await isInSubgraph(comfyPage)).toBe(false)
723+
})
724+
725+
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
726+
comfyPage
727+
}) => {
728+
await comfyPage.loadWorkflow('subgraph')
729+
await comfyPage.nextFrame()
730+
731+
const subgraphNode = await comfyPage.getNodeRefById('1')
732+
await subgraphNode.navigateIntoSubgraph()
733+
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
734+
735+
// Verify we're in a subgraph
736+
expect(await isInSubgraph(comfyPage)).toBe(true)
737+
738+
// Open a dialog (settings)
739+
await comfyPage.menu.openSettings()
740+
await expect(comfyPage.page.locator('dialog[open]')).toBeVisible()
741+
742+
// Press Escape - should close dialog, not exit subgraph
743+
await comfyPage.page.keyboard.press('Escape')
744+
await comfyPage.nextFrame()
745+
746+
// Dialog should be closed
747+
await expect(comfyPage.page.locator('dialog[open]')).not.toBeVisible()
748+
749+
// Should still be in subgraph
750+
expect(await isInSubgraph(comfyPage)).toBe(true)
751+
752+
// Press Escape again - now should exit subgraph
753+
await comfyPage.page.keyboard.press('Escape')
754+
await comfyPage.nextFrame()
755+
expect(await isInSubgraph(comfyPage)).toBe(false)
756+
})
757+
})
645758
})

src/components/breadcrumb/SubgraphBreadcrumb.vue

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
</template>
3333

3434
<script setup lang="ts">
35-
import { useEventListener } from '@vueuse/core'
3635
import Breadcrumb from 'primevue/breadcrumb'
3736
import type { MenuItem } from 'primevue/menuitem'
3837
import { computed, onUpdated, ref, watch } from 'vue'
@@ -98,18 +97,6 @@ const home = computed(() => ({
9897
}
9998
}))
10099
101-
// Escape exits from the current subgraph.
102-
useEventListener(document, 'keydown', (event) => {
103-
if (event.key === 'Escape') {
104-
const canvas = useCanvasStore().getCanvas()
105-
if (!canvas.graph) throw new TypeError('Canvas has no graph')
106-
107-
canvas.setGraph(
108-
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
109-
)
110-
}
111-
})
112-
113100
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
114101
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
115102
watch(breadcrumbElement, (el) => {

src/composables/useCoreCommands.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useExecutionStore } from '@/stores/executionStore'
2323
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
2424
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
2525
import { useSettingStore } from '@/stores/settingStore'
26+
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
2627
import { useToastStore } from '@/stores/toastStore'
2728
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
2829
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -805,6 +806,21 @@ export function useCoreCommands(): ComfyCommand[] {
805806
function: () => {
806807
bottomPanelStore.togglePanel('shortcuts')
807808
}
809+
},
810+
{
811+
id: 'Comfy.Graph.ExitSubgraph',
812+
icon: 'pi pi-arrow-up',
813+
label: 'Exit Subgraph',
814+
versionAdded: '1.20.1',
815+
function: () => {
816+
const canvas = useCanvasStore().getCanvas()
817+
const navigationStore = useSubgraphNavigationStore()
818+
if (!canvas.graph) return
819+
820+
canvas.setGraph(
821+
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
822+
)
823+
}
808824
}
809825
]
810826

src/constants/coreKeybindings.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,5 +190,11 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
190190
key: 'k'
191191
},
192192
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
193+
},
194+
{
195+
combo: {
196+
key: 'Escape'
197+
},
198+
commandId: 'Comfy.Graph.ExitSubgraph'
193199
}
194200
]

src/services/keybindingService.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
22
import { useCommandStore } from '@/stores/commandStore'
3+
import { useDialogStore } from '@/stores/dialogStore'
34
import {
45
KeyComboImpl,
56
KeybindingImpl,
@@ -11,6 +12,7 @@ export const useKeybindingService = () => {
1112
const keybindingStore = useKeybindingStore()
1213
const commandStore = useCommandStore()
1314
const settingStore = useSettingStore()
15+
const dialogStore = useDialogStore()
1416

1517
const keybindHandler = async function (event: KeyboardEvent) {
1618
const keyCombo = KeyComboImpl.fromEvent(event)
@@ -32,6 +34,19 @@ export const useKeybindingService = () => {
3234

3335
const keybinding = keybindingStore.getKeybinding(keyCombo)
3436
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
37+
// Special handling for Escape key - let dialogs handle it first
38+
if (
39+
event.key === 'Escape' &&
40+
!event.ctrlKey &&
41+
!event.altKey &&
42+
!event.metaKey
43+
) {
44+
// If dialogs are open, don't execute the keybinding - let the dialog handle it
45+
if (dialogStore.dialogStack.length > 0) {
46+
return
47+
}
48+
}
49+
3550
// Prevent default browser behavior first, then execute the command
3651
event.preventDefault()
3752
await commandStore.execute(keybinding.commandId)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { createPinia, setActivePinia } from 'pinia'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
import { useCoreCommands } from '@/composables/useCoreCommands'
5+
import { useCanvasStore } from '@/stores/graphStore'
6+
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
7+
8+
// Mock the stores
9+
vi.mock('@/stores/graphStore')
10+
vi.mock('@/stores/subgraphNavigationStore')
11+
12+
// Mock other dependencies
13+
vi.mock('@/scripts/app', () => ({
14+
app: {}
15+
}))
16+
17+
vi.mock('@/scripts/api', () => ({
18+
api: {
19+
apiURL: vi.fn(() => 'http://localhost:8188')
20+
}
21+
}))
22+
23+
vi.mock('@/stores/firebaseAuthStore', () => ({
24+
useFirebaseAuthStore: vi.fn(() => ({}))
25+
}))
26+
27+
vi.mock('@/composables/auth/useFirebaseAuth', () => ({
28+
useFirebaseAuth: vi.fn(() => null)
29+
}))
30+
31+
vi.mock('firebase/auth', () => ({
32+
setPersistence: vi.fn(),
33+
browserLocalPersistence: {},
34+
onAuthStateChanged: vi.fn()
35+
}))
36+
37+
vi.mock('@/services/workflowService', () => ({
38+
useWorkflowService: vi.fn(() => ({}))
39+
}))
40+
41+
vi.mock('@/services/dialogService', () => ({
42+
useDialogService: vi.fn(() => ({}))
43+
}))
44+
45+
vi.mock('@/services/litegraphService', () => ({
46+
useLitegraphService: vi.fn(() => ({}))
47+
}))
48+
49+
vi.mock('@/stores/executionStore', () => ({
50+
useExecutionStore: vi.fn(() => ({}))
51+
}))
52+
53+
vi.mock('@/stores/toastStore', () => ({
54+
useToastStore: vi.fn(() => ({}))
55+
}))
56+
57+
vi.mock('@/stores/workflowStore', () => ({
58+
useWorkflowStore: vi.fn(() => ({}))
59+
}))
60+
61+
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
62+
useColorPaletteStore: vi.fn(() => ({}))
63+
}))
64+
65+
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
66+
useFirebaseAuthActions: vi.fn(() => ({}))
67+
}))
68+
69+
describe('useCoreCommands - ExitSubgraph', () => {
70+
let mockCanvas: any
71+
let mockSetGraph: ReturnType<typeof vi.fn>
72+
let mockGetCanvas: ReturnType<typeof vi.fn>
73+
74+
beforeEach(() => {
75+
vi.clearAllMocks()
76+
setActivePinia(createPinia())
77+
78+
// Mock canvas and its methods
79+
mockSetGraph = vi.fn()
80+
mockCanvas = {
81+
graph: {
82+
rootGraph: { id: 'root-graph' }
83+
},
84+
setGraph: mockSetGraph
85+
}
86+
87+
mockGetCanvas = vi.fn(() => mockCanvas)
88+
89+
// Mock canvasStore
90+
vi.mocked(useCanvasStore).mockReturnValue({
91+
getCanvas: mockGetCanvas
92+
} as any)
93+
})
94+
95+
it('should exit to parent subgraph when navigation stack has multiple items', () => {
96+
// Mock navigation stack with multiple subgraphs
97+
const parentSubgraph = { id: 'parent-subgraph' }
98+
const currentSubgraph = { id: 'current-subgraph' }
99+
100+
vi.mocked(useSubgraphNavigationStore).mockReturnValue({
101+
navigationStack: [parentSubgraph, currentSubgraph]
102+
} as any)
103+
104+
const commands = useCoreCommands()
105+
const exitSubgraphCommand = commands.find(
106+
(cmd) => cmd.id === 'Comfy.Graph.ExitSubgraph'
107+
)!
108+
109+
// Execute the command
110+
void exitSubgraphCommand.function()
111+
112+
expect(mockGetCanvas).toHaveBeenCalled()
113+
expect(mockSetGraph).toHaveBeenCalledWith(parentSubgraph)
114+
})
115+
116+
it('should exit to root graph when navigation stack has only current subgraph', () => {
117+
// Mock navigation stack with only current subgraph
118+
const currentSubgraph = { id: 'current-subgraph' }
119+
120+
vi.mocked(useSubgraphNavigationStore).mockReturnValue({
121+
navigationStack: [currentSubgraph]
122+
} as any)
123+
124+
const commands = useCoreCommands()
125+
const exitSubgraphCommand = commands.find(
126+
(cmd) => cmd.id === 'Comfy.Graph.ExitSubgraph'
127+
)!
128+
129+
// Execute the command
130+
void exitSubgraphCommand.function()
131+
132+
expect(mockGetCanvas).toHaveBeenCalled()
133+
expect(mockSetGraph).toHaveBeenCalledWith(mockCanvas.graph.rootGraph)
134+
})
135+
136+
it('should do nothing when canvas.graph is null', () => {
137+
// Mock canvas with null graph
138+
mockCanvas.graph = null
139+
140+
const commands = useCoreCommands()
141+
const exitSubgraphCommand = commands.find(
142+
(cmd) => cmd.id === 'Comfy.Graph.ExitSubgraph'
143+
)!
144+
145+
// Execute the command
146+
void exitSubgraphCommand.function()
147+
148+
expect(mockGetCanvas).toHaveBeenCalled()
149+
expect(mockSetGraph).not.toHaveBeenCalled()
150+
})
151+
152+
it('should have correct metadata', () => {
153+
const commands = useCoreCommands()
154+
const exitSubgraphCommand = commands.find(
155+
(cmd) => cmd.id === 'Comfy.Graph.ExitSubgraph'
156+
)!
157+
158+
expect(exitSubgraphCommand).toBeDefined()
159+
expect(exitSubgraphCommand.icon).toBe('pi pi-arrow-up')
160+
expect(exitSubgraphCommand.label).toBe('Exit Subgraph')
161+
expect(exitSubgraphCommand.versionAdded).toBe('1.20.1')
162+
})
163+
})

0 commit comments

Comments
 (0)