[refactor] Extract keybinding functionality into @comfyorg/keybinding package

Create framework-agnostic keybinding package following domain-driven design patterns.
Move pure business logic to package while keeping Vue integration in workbench layer.

Changes:
- Add @comfyorg/keybinding package with KeyComboImpl and KeybindingImpl classes
- Move core keybindings and reserved key constants to package
- Update workbench layer to import from package with backward compatibility
- Update all imports across codebase to use package exports
- Maintain existing API surface for consumers
This commit is contained in:
bymyself
2025-10-12 20:51:31 -07:00
parent 42ffdb2141
commit a476be3933
20 changed files with 200 additions and 137 deletions

View File

@@ -0,0 +1,29 @@
{
"name": "@comfyorg/keybinding",
"version": "1.0.0",
"type": "module",
"description": "Framework-agnostic keybinding system for ComfyUI",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"nx": {
"tags": [
"scope:shared",
"type:lib"
]
},
"dependencies": {
"zod": "^3.22.4"
},
"devDependencies": {
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,212 @@
import type { Keybinding } from '../types/keybinding'
export const CORE_KEYBINDINGS: Keybinding[] = [
{
combo: {
ctrl: true,
key: 'Enter'
},
commandId: 'Comfy.QueuePrompt'
},
{
combo: {
ctrl: true,
shift: true,
key: 'Enter'
},
commandId: 'Comfy.QueuePromptFront'
},
{
combo: {
ctrl: true,
alt: true,
key: 'Enter'
},
commandId: 'Comfy.Interrupt'
},
{
combo: {
key: 'r'
},
commandId: 'Comfy.RefreshNodeDefinitions'
},
{
combo: {
key: 'q'
},
commandId: 'Workspace.ToggleSidebarTab.queue'
},
{
combo: {
key: 'w'
},
commandId: 'Workspace.ToggleSidebarTab.workflows'
},
{
combo: {
key: 'n'
},
commandId: 'Workspace.ToggleSidebarTab.node-library'
},
{
combo: {
key: 'm'
},
commandId: 'Workspace.ToggleSidebarTab.model-library'
},
{
combo: {
key: 's',
ctrl: true
},
commandId: 'Comfy.SaveWorkflow'
},
{
combo: {
key: 'o',
ctrl: true
},
commandId: 'Comfy.OpenWorkflow'
},
{
combo: {
key: 'g',
ctrl: true
},
commandId: 'Comfy.Graph.GroupSelectedNodes'
},
{
combo: {
key: ',',
ctrl: true
},
commandId: 'Comfy.ShowSettingsDialog'
},
// For '=' both holding shift and not holding shift
{
combo: {
key: '=',
alt: true
},
commandId: 'Comfy.Canvas.ZoomIn',
targetElementId: 'graph-canvas'
},
{
combo: {
key: '+',
alt: true,
shift: true
},
commandId: 'Comfy.Canvas.ZoomIn',
targetElementId: 'graph-canvas'
},
// For number pad '+'
{
combo: {
key: '+',
alt: true
},
commandId: 'Comfy.Canvas.ZoomIn',
targetElementId: 'graph-canvas'
},
{
combo: {
key: '-',
alt: true
},
commandId: 'Comfy.Canvas.ZoomOut',
targetElementId: 'graph-canvas'
},
{
combo: {
key: '.'
},
commandId: 'Comfy.Canvas.FitView',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'p'
},
commandId: 'Comfy.Canvas.ToggleSelected.Pin',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'c',
alt: true
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Collapse',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'b',
ctrl: true
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Bypass',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'm',
ctrl: true
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Mute',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: '`',
ctrl: true
},
commandId: 'Workspace.ToggleBottomPanelTab.logs-terminal'
},
{
combo: {
key: 'f'
},
commandId: 'Workspace.ToggleFocusMode'
},
{
combo: {
key: 'e',
ctrl: true,
shift: true
},
commandId: 'Comfy.Graph.ConvertToSubgraph'
},
{
combo: {
key: 'm',
alt: true
},
commandId: 'Comfy.Canvas.ToggleMinimap'
},
{
combo: {
ctrl: true,
shift: true,
key: 'k'
},
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
},
{
combo: {
key: 'v'
},
commandId: 'Comfy.Canvas.Unlock'
},
{
combo: {
key: 'h'
},
commandId: 'Comfy.Canvas.Lock'
},
{
combo: {
key: 'Escape'
},
commandId: 'Comfy.Graph.ExitSubgraph'
}
]

View File

@@ -0,0 +1,35 @@
export const RESERVED_BY_TEXT_INPUT = new Set([
'Ctrl + a',
'Ctrl + c',
'Ctrl + v',
'Ctrl + x',
'Ctrl + z',
'Ctrl + y',
'Ctrl + p',
'Enter',
'Shift + Enter',
'Ctrl + Backspace',
'Ctrl + Delete',
'Home',
'Ctrl + Home',
'Ctrl + Shift + Home',
'End',
'Ctrl + End',
'Ctrl + Shift + End',
'PageUp',
'PageDown',
'Shift + PageUp',
'Shift + PageDown',
'ArrowLeft',
'Ctrl + ArrowLeft',
'Shift + ArrowLeft',
'Ctrl + Shift + ArrowLeft',
'ArrowRight',
'Ctrl + ArrowRight',
'Shift + ArrowRight',
'Ctrl + Shift + ArrowRight',
'ArrowUp',
'Shift + ArrowUp',
'ArrowDown',
'Shift + ArrowDown'
])

View File

@@ -0,0 +1,11 @@
// Types
export type * from './types/keybinding'
export { zKeybinding } from './types/keybinding'
// Models (Implementation classes)
export { KeyComboImpl } from './models/KeyCombo'
export { KeybindingImpl } from './models/Keybinding'
// Constants
export { CORE_KEYBINDINGS } from './constants/coreKeybindings'
export { RESERVED_BY_TEXT_INPUT } from './constants/reservedKeyCombos'

View File

@@ -0,0 +1,83 @@
import { RESERVED_BY_TEXT_INPUT } from '../constants/reservedKeyCombos'
import type { KeyCombo } from '../types/keybinding'
export class KeyComboImpl implements KeyCombo {
key: string
// ctrl or meta(cmd on mac)
ctrl: boolean
alt: boolean
shift: boolean
constructor(obj: KeyCombo) {
this.key = obj.key
this.ctrl = obj.ctrl ?? false
this.alt = obj.alt ?? false
this.shift = obj.shift ?? false
}
static fromEvent(event: KeyboardEvent) {
return new KeyComboImpl({
key: event.key,
ctrl: event.ctrlKey || event.metaKey,
alt: event.altKey,
shift: event.shiftKey
})
}
equals(other: unknown): boolean {
return other instanceof KeyComboImpl
? this.key.toUpperCase() === other.key.toUpperCase() &&
this.ctrl === other.ctrl &&
this.alt === other.alt &&
this.shift === other.shift
: false
}
serialize(): string {
return `${this.key.toUpperCase()}:${this.ctrl}:${this.alt}:${this.shift}`
}
toString(): string {
return this.getKeySequences().join(' + ')
}
get hasModifier(): boolean {
return this.ctrl || this.alt || this.shift
}
get isModifier(): boolean {
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
}
get modifierCount(): number {
const modifiers = [this.ctrl, this.alt, this.shift]
return modifiers.reduce((acc, cur) => acc + Number(cur), 0)
}
get isShiftOnly(): boolean {
return this.shift && this.modifierCount === 1
}
get isReservedByTextInput(): boolean {
return (
!this.hasModifier ||
this.isShiftOnly ||
RESERVED_BY_TEXT_INPUT.has(this.toString())
)
}
getKeySequences(): string[] {
const sequences: string[] = []
if (this.ctrl) {
sequences.push('Ctrl')
}
if (this.alt) {
sequences.push('Alt')
}
if (this.shift) {
sequences.push('Shift')
}
sequences.push(this.key)
return sequences
}
}

View File

@@ -0,0 +1,22 @@
import type { Keybinding } from '../types/keybinding'
import { KeyComboImpl } from './KeyCombo'
export class KeybindingImpl implements Keybinding {
commandId: string
combo: KeyComboImpl
targetElementId?: string
constructor(obj: Keybinding) {
this.commandId = obj.commandId
this.combo = new KeyComboImpl(obj.combo)
this.targetElementId = obj.targetElementId
}
equals(other: unknown): boolean {
return other instanceof KeybindingImpl
? this.commandId === other.commandId &&
this.combo.equals(other.combo) &&
this.targetElementId === other.targetElementId
: false
}
}

View File

@@ -0,0 +1,25 @@
import { z } from 'zod'
// KeyCombo schema
const zKeyCombo = z.object({
key: z.string(),
ctrl: z.boolean().optional(),
alt: z.boolean().optional(),
shift: z.boolean().optional(),
meta: z.boolean().optional()
})
// Keybinding schema
export const zKeybinding = z.object({
commandId: z.string(),
combo: zKeyCombo,
// Optional target element ID to limit keybinding to.
// Note: Currently only used to distinguish between global keybindings
// and litegraph canvas keybindings.
// Do NOT use this field in extensions as it has no effect.
targetElementId: z.string().optional()
})
// Infer types from schemas
export type KeyCombo = z.infer<typeof zKeyCombo>
export type Keybinding = z.infer<typeof zKeybinding>

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}