From 5e17bbbf85dec0d1271266b2e0d909d98a9ff0fb Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:30:35 +0100 Subject: [PATCH] feat: expose litegraph internal keybindings (#9459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- src/composables/useCoreCommands.ts | 14 ++ src/lib/litegraph/src/LGraphCanvas.ts | 32 --- src/locales/en/main.json | 1 + src/platform/keybindings/defaults.ts | 47 +++++ .../keybindingService.forwarding.test.ts | 186 ++++++++++++------ src/platform/keybindings/keybindingService.ts | 35 ++-- src/scripts/app.ts | 14 -- 7 files changed, 194 insertions(+), 135 deletions(-) diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index a3575815a6..8210ea385a 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -905,6 +905,14 @@ export function useCoreCommands(): ComfyCommand[] { 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', icon: 'icon-[lucide--lasso-select]', @@ -919,6 +927,12 @@ export function useCoreCommands(): ComfyCommand[] { label: 'Delete Selected Items', versionAdded: '1.10.5', 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.setDirty(true, true) } diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 8ab6501be3..5974e12934 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -3791,13 +3791,6 @@ export class LGraphCanvas implements CustomEventDispatcher return } - private _noItemsSelected(): void { - const event = new CustomEvent('litegraph:no-items-selected', { - bubbles: true - }) - this.canvas.dispatchEvent(event) - } - /** * process a key event */ @@ -3842,31 +3835,6 @@ export class LGraphCanvas implements CustomEventDispatcher this.node_panel?.close() this.options_panel?.close() 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 diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 5244e49b9e..0bd6475cf7 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1262,6 +1262,7 @@ "Move Selected Nodes Right": "Move Selected Nodes Right", "Move Selected Nodes Up": "Move Selected Nodes Up", "Paste": "Paste", + "Paste with Connect": "Paste with Connect", "Reset View": "Reset View", "Resize Selected Nodes": "Resize Selected Nodes", "Select All": "Select All", diff --git a/src/platform/keybindings/defaults.ts b/src/platform/keybindings/defaults.ts index 87a0176bc9..448ebc3172 100644 --- a/src/platform/keybindings/defaults.ts +++ b/src/platform/keybindings/defaults.ts @@ -208,5 +208,52 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ key: 'Escape' }, 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' } ] diff --git a/src/platform/keybindings/keybindingService.forwarding.test.ts b/src/platform/keybindings/keybindingService.forwarding.test.ts index 8656d7ed8f..594a81acc7 100644 --- a/src/platform/keybindings/keybindingService.forwarding.test.ts +++ b/src/platform/keybindings/keybindingService.forwarding.test.ts @@ -1,22 +1,11 @@ import { createTestingPinia } from '@pinia/testing' 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 { app } from '@/scripts/app' import { useCommandStore } from '@/stores/commandStore' import { useDialogStore } from '@/stores/dialogStore' -vi.mock('@/scripts/app', () => { - return { - app: { - canvas: { - processKey: vi.fn() - } - } - } -}) - vi.mock('@/platform/settings/settingStore', () => ({ useSettingStore: vi.fn(() => ({ get: vi.fn(() => []) @@ -36,13 +25,15 @@ function createTestKeyboardEvent( ctrlKey?: boolean altKey?: boolean metaKey?: boolean + shiftKey?: boolean } = {} ): KeyboardEvent { const { target = document.body, ctrlKey = false, altKey = false, - metaKey = false + metaKey = false, + shiftKey = false } = options const event = new KeyboardEvent('keydown', { @@ -50,6 +41,7 @@ function createTestKeyboardEvent( ctrlKey, altKey, metaKey, + shiftKey, bubbles: true, cancelable: true }) @@ -60,8 +52,10 @@ function createTestKeyboardEvent( return event } -describe('keybindingService - Event Forwarding', () => { +describe('keybindingService - Canvas Keybindings', () => { let keybindingService: ReturnType + let canvasContainer: HTMLDivElement + let canvasChild: HTMLCanvasElement beforeEach(() => { vi.clearAllMocks() @@ -76,94 +70,156 @@ describe('keybindingService - Event Forwarding', () => { 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.registerCoreKeybindings() }) - it('should forward Delete key to canvas when no keybinding exists', async () => { - const event = createTestKeyboardEvent('Delete') + afterEach(() => { + canvasContainer.remove() + }) + + it('should execute DeleteSelectedItems for Delete key on canvas', async () => { + const event = createTestKeyboardEvent('Delete', { + target: canvasChild + }) await keybindingService.keybindHandler(event) - expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event) - expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith( + 'Comfy.Canvas.DeleteSelectedItems' + ) }) - it('should forward Backspace key to canvas when no keybinding exists', async () => { - const event = createTestKeyboardEvent('Backspace') + it('should execute DeleteSelectedItems for Backspace key on canvas', async () => { + const event = createTestKeyboardEvent('Backspace', { + target: canvasChild + }) await keybindingService.keybindHandler(event) - expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event) - expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith( + '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 event = createTestKeyboardEvent('Delete', { target: inputElement }) await keybindingService.keybindHandler(event) - expect(vi.mocked(app.canvas.processKey)).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 event = createTestKeyboardEvent('Delete', { target: textareaElement }) + const event = createTestKeyboardEvent('Delete', { + target: textareaElement + }) await keybindingService.keybindHandler(event) - expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled() expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() }) - it('should not forward Delete key when canvas processKey is not available', async () => { - // Temporarily replace processKey with undefined - testing edge case - const originalProcessKey = vi.mocked(app.canvas).processKey - vi.mocked(app.canvas).processKey = undefined! - - 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') + it('should execute SelectAll for Ctrl+A on canvas', async () => { + const event = createTestKeyboardEvent('a', { + ctrlKey: true, + target: canvasChild + }) await keybindingService.keybindHandler(event) - expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled() - expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith( + 'Comfy.Canvas.SelectAll' + ) }) - it('should not forward when modifier keys are pressed', async () => { - const event = createTestKeyboardEvent('Delete', { ctrlKey: true }) + it('should execute CopySelected for Ctrl+C on canvas', async () => { + 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) - expect(vi.mocked(app.canvas.processKey)).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() }) }) diff --git a/src/platform/keybindings/keybindingService.ts b/src/platform/keybindings/keybindingService.ts index 6b6047dec2..b11d6831a7 100644 --- a/src/platform/keybindings/keybindingService.ts +++ b/src/platform/keybindings/keybindingService.ts @@ -1,6 +1,5 @@ import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' -import { app } from '@/scripts/app' import { useCommandStore } from '@/stores/commandStore' import { useDialogStore } from '@/stores/dialogStore' @@ -15,16 +14,6 @@ export function useKeybindingService() { const settingStore = useSettingStore() 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) { const keyCombo = KeyComboImpl.fromEvent(event) if (keyCombo.isModifier) { @@ -44,7 +33,17 @@ export function useKeybindingService() { } 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 ( event.key === 'Escape' && !event.ctrlKey && @@ -74,18 +73,6 @@ export function useKeybindingService() { 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) { return } diff --git a/src/scripts/app.ts b/src/scripts/app.ts index d2b0679071..95eda43a4d 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -676,20 +676,6 @@ export class ComfyApp { e.stopImmediatePropagation() 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