feat: expose litegraph internal keybindings (#9459)

## Summary

Migrate hardcoded litegraph canvas keybindings (Ctrl+A/C/V, Delete,
Backspace) into the customizable keybinding system so users can remap
them via Settings > Keybindings.

## Changes

- **What**: Register Ctrl+A (SelectAll), Ctrl+C (CopySelected), Ctrl+V
(PasteFromClipboard), Ctrl+Shift+V (PasteFromClipboardWithConnect),
Delete/Backspace (DeleteSelectedItems) as core keybindings in
`defaults.ts`. Add new `PasteFromClipboardWithConnect` command. Remove
hardcoded handling from litegraph `processKey()`, the `app.ts` Ctrl+C/V
monkey-patch, and the `keybindingService` canvas forwarding logic.

Fixes #1082
Fixes #2015

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9459-feat-expose-litegraph-internal-keybindings-31b6d73d3650819a8499fd96c8a6678f)
by [Unito](https://www.unito.io)
This commit is contained in:
Johnpaul Chiwetelu
2026-03-06 18:30:35 +01:00
committed by GitHub
parent 7cb07f9b2d
commit 5e17bbbf85
7 changed files with 194 additions and 135 deletions

View File

@@ -905,6 +905,14 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.pasteFromClipboard() app.canvas.pasteFromClipboard()
} }
}, },
{
id: 'Comfy.Canvas.PasteFromClipboardWithConnect',
icon: 'icon-[lucide--clipboard-paste]',
label: () => t('Paste with Connect'),
function: () => {
app.canvas.pasteFromClipboard({ connectInputs: true })
}
},
{ {
id: 'Comfy.Canvas.SelectAll', id: 'Comfy.Canvas.SelectAll',
icon: 'icon-[lucide--lasso-select]', icon: 'icon-[lucide--lasso-select]',
@@ -919,6 +927,12 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Delete Selected Items', label: 'Delete Selected Items',
versionAdded: '1.10.5', versionAdded: '1.10.5',
function: () => { function: () => {
if (app.canvas.selectedItems.size === 0) {
app.canvas.canvas.dispatchEvent(
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
)
return
}
app.canvas.deleteSelected() app.canvas.deleteSelected()
app.canvas.setDirty(true, true) app.canvas.setDirty(true, true)
} }

View File

@@ -3791,13 +3791,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return return
} }
private _noItemsSelected(): void {
const event = new CustomEvent('litegraph:no-items-selected', {
bubbles: true
})
this.canvas.dispatchEvent(event)
}
/** /**
* process a key event * process a key event
*/ */
@@ -3842,31 +3835,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.node_panel?.close() this.node_panel?.close()
this.options_panel?.close() this.options_panel?.close()
if (this.node_panel || this.options_panel) block_default = true if (this.node_panel || this.options_panel) block_default = true
} else if (e.keyCode === 65 && e.ctrlKey) {
// select all Control A
this.selectItems()
block_default = true
} else if (e.keyCode === 67 && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
// copy
if (this.selected_nodes) {
this.copyToClipboard()
block_default = true
}
} else if (e.keyCode === 86 && (e.metaKey || e.ctrlKey)) {
// paste
this.pasteFromClipboard({ connectInputs: e.shiftKey })
} else if (e.key === 'Delete' || e.key === 'Backspace') {
// delete or backspace
// @ts-expect-error EventTarget.localName is not in standard types
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
if (this.selectedItems.size === 0) {
this._noItemsSelected()
return
}
this.deleteSelected()
block_default = true
}
} }
// TODO // TODO

View File

@@ -1262,6 +1262,7 @@
"Move Selected Nodes Right": "Move Selected Nodes Right", "Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up", "Move Selected Nodes Up": "Move Selected Nodes Up",
"Paste": "Paste", "Paste": "Paste",
"Paste with Connect": "Paste with Connect",
"Reset View": "Reset View", "Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes", "Resize Selected Nodes": "Resize Selected Nodes",
"Select All": "Select All", "Select All": "Select All",

View File

@@ -208,5 +208,52 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'Escape' key: 'Escape'
}, },
commandId: 'Comfy.Graph.ExitSubgraph' commandId: 'Comfy.Graph.ExitSubgraph'
},
{
combo: {
ctrl: true,
key: 'a'
},
commandId: 'Comfy.Canvas.SelectAll',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'c'
},
commandId: 'Comfy.Canvas.CopySelected',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboard',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
shift: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboardWithConnect',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Delete'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Backspace'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
} }
] ]

View File

@@ -1,22 +1,11 @@
import { createTestingPinia } from '@pinia/testing' import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia' import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeybindingService } from '@/platform/keybindings/keybindingService' import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
vi.mock('@/scripts/app', () => {
return {
app: {
canvas: {
processKey: vi.fn()
}
}
}
})
vi.mock('@/platform/settings/settingStore', () => ({ vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({ useSettingStore: vi.fn(() => ({
get: vi.fn(() => []) get: vi.fn(() => [])
@@ -36,13 +25,15 @@ function createTestKeyboardEvent(
ctrlKey?: boolean ctrlKey?: boolean
altKey?: boolean altKey?: boolean
metaKey?: boolean metaKey?: boolean
shiftKey?: boolean
} = {} } = {}
): KeyboardEvent { ): KeyboardEvent {
const { const {
target = document.body, target = document.body,
ctrlKey = false, ctrlKey = false,
altKey = false, altKey = false,
metaKey = false metaKey = false,
shiftKey = false
} = options } = options
const event = new KeyboardEvent('keydown', { const event = new KeyboardEvent('keydown', {
@@ -50,6 +41,7 @@ function createTestKeyboardEvent(
ctrlKey, ctrlKey,
altKey, altKey,
metaKey, metaKey,
shiftKey,
bubbles: true, bubbles: true,
cancelable: true cancelable: true
}) })
@@ -60,8 +52,10 @@ function createTestKeyboardEvent(
return event return event
} }
describe('keybindingService - Event Forwarding', () => { describe('keybindingService - Canvas Keybindings', () => {
let keybindingService: ReturnType<typeof useKeybindingService> let keybindingService: ReturnType<typeof useKeybindingService>
let canvasContainer: HTMLDivElement
let canvasChild: HTMLCanvasElement
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -76,94 +70,156 @@ describe('keybindingService - Event Forwarding', () => {
typeof useDialogStore typeof useDialogStore
>) >)
canvasContainer = document.createElement('div')
canvasContainer.id = 'graph-canvas-container'
canvasChild = document.createElement('canvas')
canvasContainer.appendChild(canvasChild)
document.body.appendChild(canvasContainer)
keybindingService = useKeybindingService() keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings() keybindingService.registerCoreKeybindings()
}) })
it('should forward Delete key to canvas when no keybinding exists', async () => { afterEach(() => {
const event = createTestKeyboardEvent('Delete') canvasContainer.remove()
})
it('should execute DeleteSelectedItems for Delete key on canvas', async () => {
const event = createTestKeyboardEvent('Delete', {
target: canvasChild
})
await keybindingService.keybindHandler(event) await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event) expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() 'Comfy.Canvas.DeleteSelectedItems'
)
}) })
it('should forward Backspace key to canvas when no keybinding exists', async () => { it('should execute DeleteSelectedItems for Backspace key on canvas', async () => {
const event = createTestKeyboardEvent('Backspace') const event = createTestKeyboardEvent('Backspace', {
target: canvasChild
})
await keybindingService.keybindHandler(event) await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event) expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() 'Comfy.Canvas.DeleteSelectedItems'
)
}) })
it('should not forward Delete key when typing in input field', async () => { it('should not execute DeleteSelectedItems when typing in input field', async () => {
const inputElement = document.createElement('input') const inputElement = document.createElement('input')
const event = createTestKeyboardEvent('Delete', { target: inputElement }) const event = createTestKeyboardEvent('Delete', { target: inputElement })
await keybindingService.keybindHandler(event) await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
}) })
it('should not forward Delete key when typing in textarea', async () => { it('should not execute DeleteSelectedItems when typing in textarea', async () => {
const textareaElement = document.createElement('textarea') const textareaElement = document.createElement('textarea')
const event = createTestKeyboardEvent('Delete', { target: textareaElement }) const event = createTestKeyboardEvent('Delete', {
target: textareaElement
})
await keybindingService.keybindHandler(event) await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
}) })
it('should not forward Delete key when canvas processKey is not available', async () => { it('should execute SelectAll for Ctrl+A on canvas', async () => {
// Temporarily replace processKey with undefined - testing edge case const event = createTestKeyboardEvent('a', {
const originalProcessKey = vi.mocked(app.canvas).processKey ctrlKey: true,
vi.mocked(app.canvas).processKey = undefined! target: canvasChild
})
const event = createTestKeyboardEvent('Delete')
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore processKey for other tests
vi.mocked(app.canvas).processKey = originalProcessKey
}
})
it('should not forward Delete key when canvas is not available', async () => {
const originalCanvas = vi.mocked(app).canvas
vi.mocked(app).canvas = null!
const event = createTestKeyboardEvent('Delete')
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore canvas for other tests
vi.mocked(app).canvas = originalCanvas
}
})
it('should not forward non-canvas keys', async () => {
const event = createTestKeyboardEvent('Enter')
await keybindingService.keybindHandler(event) await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled() expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() 'Comfy.Canvas.SelectAll'
)
}) })
it('should not forward when modifier keys are pressed', async () => { it('should execute CopySelected for Ctrl+C on canvas', async () => {
const event = createTestKeyboardEvent('Delete', { ctrlKey: true }) const event = createTestKeyboardEvent('c', {
ctrlKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.CopySelected'
)
})
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboard'
)
})
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
shiftKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboardWithConnect'
)
})
it('should execute graph-canvas bindings by normalizing to graph-canvas-container', async () => {
const event = createTestKeyboardEvent('=', {
altKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.ZoomIn'
)
})
it('should not execute graph-canvas bindings when target is outside canvas', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('=', {
altKey: true,
target: outsideDiv
})
await keybindingService.keybindHandler(event) await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
})
it('should not execute canvas commands when target is outside canvas container', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('Delete', {
target: outsideDiv
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
}) })
}) })

View File

@@ -1,6 +1,5 @@
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
@@ -15,16 +14,6 @@ export function useKeybindingService() {
const settingStore = useSettingStore() const settingStore = useSettingStore()
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
function shouldForwardToCanvas(event: KeyboardEvent): boolean {
if (event.ctrlKey || event.altKey || event.metaKey) {
return false
}
const canvasKeys = ['Delete', 'Backspace']
return canvasKeys.includes(event.key)
}
async function keybindHandler(event: KeyboardEvent) { async function keybindHandler(event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event) const keyCombo = KeyComboImpl.fromEvent(event)
if (keyCombo.isModifier) { if (keyCombo.isModifier) {
@@ -44,7 +33,17 @@ export function useKeybindingService() {
} }
const keybinding = keybindingStore.getKeybinding(keyCombo) const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding && keybinding.targetElementId !== 'graph-canvas') { if (keybinding) {
const targetElementId =
keybinding.targetElementId === 'graph-canvas'
? 'graph-canvas-container'
: keybinding.targetElementId
if (targetElementId) {
const container = document.getElementById(targetElementId)
if (!container?.contains(target)) {
return
}
}
if ( if (
event.key === 'Escape' && event.key === 'Escape' &&
!event.ctrlKey && !event.ctrlKey &&
@@ -74,18 +73,6 @@ export function useKeybindingService() {
return return
} }
if (!keybinding && shouldForwardToCanvas(event)) {
const canvas = app.canvas
if (
canvas &&
canvas.processKey &&
typeof canvas.processKey === 'function'
) {
canvas.processKey(event)
return
}
}
if (event.ctrlKey || event.altKey || event.metaKey) { if (event.ctrlKey || event.altKey || event.metaKey) {
return return
} }

View File

@@ -676,20 +676,6 @@ export class ComfyApp {
e.stopImmediatePropagation() e.stopImmediatePropagation()
return return
} }
// Ctrl+C Copy
if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
return
}
// Ctrl+V Paste
if (
(e.key === 'v' || e.key == 'V') &&
(e.metaKey || e.ctrlKey) &&
!e.shiftKey
) {
return
}
} }
// Fall through to Litegraph defaults // Fall through to Litegraph defaults