Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion src/composables/useCoreCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ vi.mock('@/scripts/app', () => {
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
selectItems: vi.fn(),
deleteSelected: vi.fn(),
finalizeGhostPlacement: vi.fn(),
ds: mockDs,
setDirty: vi.fn()
setDirty: vi.fn(),
state: { ghostNodeId: null as number | null },
canvas: { dispatchEvent: vi.fn() }
}

return {
Expand Down Expand Up @@ -269,6 +273,7 @@ describe('useCoreCommands', () => {

// Reset app state
app.canvas.subgraph = undefined
app.canvas.state.ghostNodeId = null

// Mock settings store
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false))
Expand Down Expand Up @@ -607,4 +612,42 @@ describe('useCoreCommands', () => {
expect(mockShowAbout).toHaveBeenCalled()
})
})

describe('Ghost placement guards', () => {
function findCommand(id: string) {
return useCoreCommands().find((cmd) => cmd.id === id)!
}

describe('DeleteSelectedItems', () => {
it('cancels ghost placement when active and skips deletion', async () => {
app.canvas.state.ghostNodeId = 42

await findCommand('Comfy.Canvas.DeleteSelectedItems').function()

expect(app.canvas.finalizeGhostPlacement).toHaveBeenCalledWith(true)
expect(app.canvas.deleteSelected).not.toHaveBeenCalled()
})

it('deletes selected items when no ghost is active', async () => {
app.canvas.selectedItems = new Set([
{}
]) as typeof app.canvas.selectedItems

await findCommand('Comfy.Canvas.DeleteSelectedItems').function()

expect(app.canvas.finalizeGhostPlacement).not.toHaveBeenCalled()
expect(app.canvas.deleteSelected).toHaveBeenCalled()
})
})

describe('ExitSubgraph', () => {
it('cancels ghost placement when active and skips graph navigation', async () => {
app.canvas.state.ghostNodeId = 7

await findCommand('Comfy.Graph.ExitSubgraph').function()

expect(app.canvas.finalizeGhostPlacement).toHaveBeenCalledWith(true)
})
})
})
})
8 changes: 8 additions & 0 deletions src/composables/useCoreCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,10 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Delete Selected Items',
versionAdded: '1.10.5',
function: () => {
if (app.canvas.state.ghostNodeId != null) {
app.canvas.finalizeGhostPlacement(true)
return
}
if (app.canvas.selectedItems.size === 0) {
app.canvas.canvas.dispatchEvent(
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
Expand Down Expand Up @@ -1127,6 +1131,10 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.20.1',
function: () => {
const canvas = useCanvasStore().getCanvas()
if (canvas.state.ghostNodeId != null) {
canvas.finalizeGhostPlacement(true)
return
}
const navigationStore = useSubgraphNavigationStore()
if (!canvas.graph) return

Expand Down
115 changes: 115 additions & 0 deletions src/platform/keybindings/keybindingService.propagation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'

vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))

vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
dialogStack: []
}))
}))

function createKeyboardEvent(
key: string,
options: {
target?: Element
ctrlKey?: boolean
metaKey?: boolean
shiftKey?: boolean
altKey?: boolean
} = {}
): KeyboardEvent {
const { target = document.body, ...modifiers } = options
const event = new KeyboardEvent('keydown', {
key,
code: key === 'Enter' ? 'Enter' : key,
bubbles: true,
cancelable: true,
...modifiers
})
event.preventDefault = vi.fn()
event.stopPropagation = vi.fn()
event.composedPath = vi.fn(() => [target])
return event
}

describe('keybindingService - event propagation', () => {
let keybindingService: ReturnType<typeof useKeybindingService>

beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))

const commandStore = useCommandStore()
commandStore.execute = vi.fn()

vi.mocked(useDialogStore).mockReturnValue({
dialogStack: []
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
typeof useDialogStore
>)

keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})

it('stops propagation when Ctrl+Enter fires with a non-input element focused', async () => {
// Simulates a dropdown (combobox div) being focused when Ctrl+Enter is pressed.
// Without stopPropagation the event reaches the dropdown handler and expands it.
const dropdown = document.createElement('div')
dropdown.setAttribute('role', 'combobox')

const event = createKeyboardEvent('Enter', {
ctrlKey: true,
target: dropdown
})

await keybindingService.keybindHandler(event)

expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.QueuePrompt',
expect.any(Object)
)
expect(event.stopPropagation).toHaveBeenCalled()
})

it('does not stop propagation when no keybinding matches', async () => {
const event = createKeyboardEvent('F13') // no binding for this key

await keybindingService.keybindHandler(event)

expect(event.stopPropagation).not.toHaveBeenCalled()
})

it('does not stop propagation when key is reserved by text input and target is textarea', async () => {
const textarea = document.createElement('textarea')
const event = createKeyboardEvent('Enter', { target: textarea })

await keybindingService.keybindHandler(event)

expect(event.stopPropagation).not.toHaveBeenCalled()
})

it('does not stop propagation for bare Escape even when a keybinding matches', async () => {
// ExitSubgraph is bound to bare Escape. Element-level handlers (Reka UI
// menus, PrimeVue dialogs, BuilderFooterToolbar) need the event to reach
// them so they can close — stopPropagation would block them.
const event = createKeyboardEvent('Escape')

await keybindingService.keybindHandler(event)

expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Graph.ExitSubgraph'
)
expect(event.stopPropagation).not.toHaveBeenCalled()
})
})
10 changes: 10 additions & 0 deletions src/platform/keybindings/keybindingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ export function useKeybindingService() {
}

event.preventDefault()
// Bare Escape must keep propagating so element-level handlers (Reka UI
// menus, PrimeVue dialogs, BuilderFooterToolbar) can still close themselves.
const isBareEscape =
event.key === 'Escape' &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isBareEscape missing shiftKey check allows unintended propagation

Low Severity

The isBareEscape condition checks !event.ctrlKey, !event.altKey, and !event.metaKey but omits !event.shiftKey. If a user registers a custom Shift+Escape keybinding, the check would still evaluate to true, skipping stopPropagation(). This is the exact same class of bug this PR fixes for Ctrl+Enter — a matched keybinding failing to prevent element-level handlers from firing — just for a hypothetical Shift+Escape binding instead.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e6ec6c. Configure here.

if (!isBareEscape) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Bare Escape still executes the global keybinding before element-level handlers get a chance to consume it. Because GraphView now registers keybindHandler on window capture, this branch skips stopPropagation() but still falls through to commandStore.execute("Comfy.Graph.ExitSubgraph") before the event reaches controls such as SingleSelect/MultiSelect, whose Escape handlers call stopEscapeToDocument() at the target. In a subgraph with an open select, Escape now exits the subgraph and then closes the select; before this PR the target handler could stop the bubble-phase keybinding and only close the select. Could we preserve the old priority for bare Escape, for example by not handling it in the capture listener and letting a bubble-phase path run after target handlers, or otherwise skipping the command when an Escape-consuming overlay is the target?

event.stopPropagation()
}
const runCommandIds = new Set([
'Comfy.QueuePrompt',
'Comfy.QueuePromptFront',
Expand Down
4 changes: 3 additions & 1 deletion src/views/GraphView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,9 @@ onBeforeUnmount(() => {
executionStore.unbindExecutionEvents()
})

useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
useEventListener(window, 'keydown', useKeybindingService().keybindHandler, {
capture: true
})

const { wrapWithErrorHandling, wrapWithErrorHandlingAsync } = useErrorHandling()

Expand Down
Loading