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
This commit is contained in:
Rizumu Ayaka
2026-05-12 16:32:16 +08:00
parent 25c2d828c0
commit 843cc4f0ff
3 changed files with 131 additions and 2 deletions

View File

@@ -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> = {}
): DialogInstance {
return {
key,
visible: true,
component: markRaw({ template: '<div />' }),
contentProps: {},
dialogComponentProps: {},
priority: 0,
...overrides
}
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))
vi.mock('@/stores/dialogStore', () => {
const dialogStack = reactive<DialogInstance[]>([])
return {
useDialogStore: () => ({ dialogStack })
}
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: null
}
}))
describe('keybindingService - dialog gate', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let mockCommandExecute: ReturnType<typeof useCommandStore>['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)
}
})
})

View File

@@ -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', () => {

View File

@@ -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',