From 843cc4f0ffde5bfa085a47bb38ef907bb67b62ac Mon Sep 17 00:00:00 2001 From: Rizumu Ayaka Date: Tue, 12 May 2026 16:32:16 +0800 Subject: [PATCH] fix: disable global keybindings while a modal dialog is open Gate keybindHandler on dialogStore.dialogStack so background shortcuts no longer fire when a modal (e.g. Templates) is open. Keybindings whose event target is inside the dialog still execute, preserving dialog-scoped shortcuts. Fixes FE-642 --- .../keybindingService.dialog.test.ts | 117 ++++++++++++++++++ .../keybindingService.escape.test.ts | 4 +- src/platform/keybindings/keybindingService.ts | 12 ++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 src/platform/keybindings/keybindingService.dialog.test.ts diff --git a/src/platform/keybindings/keybindingService.dialog.test.ts b/src/platform/keybindings/keybindingService.dialog.test.ts new file mode 100644 index 0000000000..42c7b7a7cd --- /dev/null +++ b/src/platform/keybindings/keybindingService.dialog.test.ts @@ -0,0 +1,117 @@ +import { createPinia, setActivePinia } from 'pinia' +import { markRaw, reactive } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useKeybindingService } from '@/platform/keybindings/keybindingService' +import { useCommandStore } from '@/stores/commandStore' +import type { DialogInstance } from '@/stores/dialogStore' +import { useDialogStore } from '@/stores/dialogStore' + +function createTestDialogInstance( + key: string, + overrides: Partial = {} +): DialogInstance { + return { + key, + visible: true, + component: markRaw({ template: '
' }), + contentProps: {}, + dialogComponentProps: {}, + priority: 0, + ...overrides + } +} + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: vi.fn(() => ({ + get: vi.fn(() => []) + })) +})) + +vi.mock('@/stores/dialogStore', () => { + const dialogStack = reactive([]) + return { + useDialogStore: () => ({ dialogStack }) + } +}) + +vi.mock('@/scripts/app', () => ({ + app: { + canvas: null + } +})) + +describe('keybindingService - dialog gate', () => { + let keybindingService: ReturnType + let mockCommandExecute: ReturnType['execute'] + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + + const commandStore = useCommandStore() + mockCommandExecute = vi.fn() + commandStore.execute = mockCommandExecute + + const dialogStore = useDialogStore() + dialogStore.dialogStack.length = 0 + + keybindingService = useKeybindingService() + keybindingService.registerCoreKeybindings() + }) + + function createKeyboardEvent( + key: string, + target: HTMLElement = document.body + ): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true + }) + event.preventDefault = vi.fn() + event.composedPath = vi.fn(() => [target]) + return event + } + + it('executes a global keybinding when no dialog is open', async () => { + const event = createKeyboardEvent('w') + await keybindingService.keybindHandler(event) + + expect(mockCommandExecute).toHaveBeenCalledWith( + 'Workspace.ToggleSidebarTab.workflows' + ) + }) + + it('does NOT execute a global keybinding while a dialog is open', async () => { + const dialogStore = useDialogStore() + dialogStore.dialogStack.push(createTestDialogInstance('templates-dialog')) + + const event = createKeyboardEvent('w') + await keybindingService.keybindHandler(event) + + expect(mockCommandExecute).not.toHaveBeenCalled() + }) + + it('still executes a keybinding whose target lives inside the open dialog', async () => { + const dialogStore = useDialogStore() + dialogStore.dialogStack.push(createTestDialogInstance('templates-dialog')) + + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + const inner = document.createElement('button') + dialog.appendChild(inner) + document.body.appendChild(dialog) + + try { + const event = createKeyboardEvent('w', inner) + await keybindingService.keybindHandler(event) + + expect(mockCommandExecute).toHaveBeenCalledWith( + 'Workspace.ToggleSidebarTab.workflows' + ) + } finally { + document.body.removeChild(dialog) + } + }) +}) diff --git a/src/platform/keybindings/keybindingService.escape.test.ts b/src/platform/keybindings/keybindingService.escape.test.ts index 42b946a13a..c6b31a34e1 100644 --- a/src/platform/keybindings/keybindingService.escape.test.ts +++ b/src/platform/keybindings/keybindingService.escape.test.ts @@ -108,7 +108,7 @@ describe('keybindingService - Escape key handling', () => { expect(mockCommandExecute).not.toHaveBeenCalled() }) - it('should execute Escape keybinding with modifiers regardless of dialog state', async () => { + it('should NOT execute Escape keybinding with modifiers when a dialog is open', async () => { const dialogStore = useDialogStore() dialogStore.dialogStack.push(createTestDialogInstance('test-dialog')) @@ -125,7 +125,7 @@ describe('keybindingService - Escape key handling', () => { const event = createKeyboardEvent('Escape', { ctrlKey: true }) await keybindingService.keybindHandler(event) - expect(mockCommandExecute).toHaveBeenCalledWith('Test.CtrlEscape') + expect(mockCommandExecute).not.toHaveBeenCalled() }) it('should verify Escape keybinding exists in CORE_KEYBINDINGS', () => { diff --git a/src/platform/keybindings/keybindingService.ts b/src/platform/keybindings/keybindingService.ts index b11d6831a7..2bfd129974 100644 --- a/src/platform/keybindings/keybindingService.ts +++ b/src/platform/keybindings/keybindingService.ts @@ -55,6 +55,18 @@ export function useKeybindingService() { } } + /** + * Block global keybindings from triggering background actions while a + * modal dialog is open. Keybindings whose event target lives inside an + * open dialog still fire, so dialog-scoped shortcuts keep working. + */ + if (dialogStore.dialogStack.length > 0) { + const inDialog = target.closest?.('[role="dialog"]') != null + if (!inDialog) { + return + } + } + event.preventDefault() const runCommandIds = new Set([ 'Comfy.QueuePrompt',