mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 14:54:37 +00:00
- Document current implementation using event.key - Analyze problems with non-English keyboard layouts - Research event.key vs event.code technical differences - Propose solution using event.code for layout independence - Detail migration strategy for backward compatibility - Include testing strategy and implementation phases Relates to #5252
1040 lines
29 KiB
Markdown
1040 lines
29 KiB
Markdown
# Keyboard Shortcut System Research & Implementation
|
||
|
||
## Table of Contents
|
||
1. [Problem Statement](#problem-statement)
|
||
2. [Current Implementation (Main Branch)](#current-implementation-main-branch)
|
||
3. [Problems with Current Implementation](#problems-with-current-implementation)
|
||
4. [Event.key vs Event.code: Technical Analysis](#eventkey-vs-eventcode-technical-analysis)
|
||
5. [Proposed Solution](#proposed-solution)
|
||
6. [Implementation Details](#implementation-details)
|
||
7. [Migration Strategy](#migration-strategy)
|
||
8. [Testing Strategy](#testing-strategy)
|
||
9. [Related Work](#related-work)
|
||
|
||
---
|
||
|
||
## Problem Statement
|
||
|
||
### Issue #5252: Keyboard Shortcuts Don't Work on Non-English Keyboard Layouts
|
||
|
||
**Reported Problem:**
|
||
Users with non-English (non-Latin) keyboard layouts cannot use keyboard shortcuts in ComfyUI. For example:
|
||
- Pressing the physical `R` key on a Russian keyboard produces the Cyrillic character "к" instead of "r"
|
||
- `Ctrl+S` (save) doesn't work because the system receives "Ctrl+ы" instead
|
||
- All single-key shortcuts (`R`, `Q`, `W`, `N`, etc.) fail to work
|
||
|
||
**Impact:**
|
||
- Application is unusable for international users who use non-Latin keyboard layouts
|
||
- Affects users in Russia, China, Japan, Korea, Greece, Arabic-speaking countries, and many others
|
||
- Forces users to switch to English keyboard layout to use the application
|
||
|
||
**Root Cause:**
|
||
The keybinding system uses `event.key` which returns different values depending on the active keyboard layout, while shortcuts are defined using English characters.
|
||
|
||
---
|
||
|
||
## Current Implementation (Main Branch)
|
||
|
||
### Architecture Overview
|
||
|
||
The keyboard shortcut system consists of three main components:
|
||
|
||
1. **KeyComboImpl** (`src/stores/keybindingStore.ts`): Represents a key combination
|
||
2. **KeybindingImpl** (`src/stores/keybindingStore.ts`): Associates a key combo with a command
|
||
3. **KeybindingService** (`src/services/keybindingService.ts`): Handles keyboard events and executes commands
|
||
|
||
### How It Works (Main Branch)
|
||
|
||
#### 1. Key Event Capture
|
||
|
||
```typescript
|
||
// src/stores/keybindingStore.ts (main branch)
|
||
static fromEvent(event: KeyboardEvent) {
|
||
return new KeyComboImpl({
|
||
key: event.key, // ← Uses event.key (layout-dependent)
|
||
ctrl: event.ctrlKey || event.metaKey,
|
||
alt: event.altKey,
|
||
shift: event.shiftKey
|
||
})
|
||
}
|
||
```
|
||
|
||
**What happens:**
|
||
- User presses physical key `R` on Russian layout
|
||
- `event.key` returns "к" (Cyrillic character)
|
||
- System looks for keybinding with key "к"
|
||
- No match found (keybinding is registered with "r")
|
||
|
||
#### 2. Keybinding Registration
|
||
|
||
```typescript
|
||
// src/constants/coreKeybindings.ts (main branch)
|
||
{
|
||
combo: {
|
||
key: 'r' // ← Defined using lowercase 'r'
|
||
},
|
||
commandId: 'Comfy.RefreshNodeDefinitions'
|
||
}
|
||
```
|
||
|
||
**Comparison Logic:**
|
||
```typescript
|
||
equals(other: unknown): boolean {
|
||
return this.key.toUpperCase() === raw.key.toUpperCase() &&
|
||
this.ctrl === raw.ctrl &&
|
||
this.alt === raw.alt &&
|
||
this.shift === raw.shift
|
||
}
|
||
```
|
||
|
||
The system compares using `toUpperCase()`, but this doesn't help when `event.key` returns a completely different character from a different alphabet.
|
||
|
||
#### 3. Event Flow
|
||
|
||
```
|
||
User Presses Key → KeyboardEvent Generated → KeyComboImpl.fromEvent()
|
||
→ event.key extracted → Lookup in keybinding store → Command execution
|
||
```
|
||
|
||
### Data Structures
|
||
|
||
**Main Branch Schema:**
|
||
```typescript
|
||
interface KeyCombo {
|
||
key: string // The character/key name from event.key
|
||
ctrl?: boolean // Ctrl or Cmd modifier
|
||
alt?: boolean // Alt/Option modifier
|
||
shift?: boolean // Shift modifier
|
||
}
|
||
```
|
||
|
||
**Example Keybindings:**
|
||
```typescript
|
||
// Letter keys: lowercase
|
||
{ key: 'r' }
|
||
{ key: 'q' }
|
||
{ key: 'w' }
|
||
|
||
// Special keys: proper case
|
||
{ key: 'Enter' }
|
||
{ key: 'Escape' }
|
||
|
||
// Punctuation: as-is
|
||
{ key: '=' }
|
||
{ key: '-' }
|
||
```
|
||
|
||
---
|
||
|
||
## Problems with Current Implementation
|
||
|
||
### 1. **Keyboard Layout Dependency (Primary Issue)**
|
||
|
||
**Problem:** `event.key` returns different values based on keyboard layout.
|
||
|
||
| Physical Key | QWERTY (English) | Russian | German (QWERTZ) | French (AZERTY) |
|
||
|--------------|------------------|---------|-----------------|-----------------|
|
||
| Top-left letter | Q | Й | Q | A |
|
||
| Next to Q | W | Ц | W | Z |
|
||
| Third letter | E | У | E | E |
|
||
| Below Q | A | Ф | A | Q |
|
||
| Below W | S | Ы | S | S |
|
||
|
||
**Example Failure:**
|
||
```typescript
|
||
// Keybinding registered as:
|
||
{ key: 'r', commandId: 'Comfy.RefreshNodeDefinitions' }
|
||
|
||
// User presses R key on Russian keyboard:
|
||
event.key = 'к' // Cyrillic 'k'
|
||
event.code = 'KeyR' // Physical key position
|
||
|
||
// Lookup fails because 'к' !== 'r'
|
||
```
|
||
|
||
### 2. **Case Sensitivity Issues**
|
||
|
||
**Problem:** Inconsistent handling of uppercase/lowercase.
|
||
|
||
```typescript
|
||
// Registration uses lowercase
|
||
{ key: 'r' }
|
||
|
||
// But event.key can be uppercase with Shift
|
||
event.key = 'R' // With Shift pressed
|
||
|
||
// System uses toUpperCase() for comparison
|
||
// This works for English but not for all layouts
|
||
```
|
||
|
||
### 3. **Special Key Inconsistency**
|
||
|
||
**Problem:** Mixed conventions for special keys.
|
||
|
||
```typescript
|
||
// Some use event.key format:
|
||
event.key === 'Escape' // In keybindingService.ts line 39, 62
|
||
|
||
// Others use event.code format in definitions:
|
||
{ key: 'Enter' } // Works because Enter is same in both
|
||
```
|
||
|
||
### 4. **No Migration Path**
|
||
|
||
**Problem:** Changing from `event.key` to `event.code` breaks existing user keybindings.
|
||
|
||
User settings store keybindings in old format:
|
||
```typescript
|
||
// User's custom keybinding (stored in settings)
|
||
{
|
||
combo: { key: 'a', ctrl: true },
|
||
commandId: 'MyCustomCommand'
|
||
}
|
||
|
||
// After switching to event.code, this won't match:
|
||
// event.code = 'KeyA' (new)
|
||
// stored key = 'a' (old)
|
||
```
|
||
|
||
All users will lose their custom keybindings after the update.
|
||
|
||
### 5. **Extension Compatibility**
|
||
|
||
**Problem:** Extensions register keybindings using the old format.
|
||
|
||
```typescript
|
||
// Extension code (current)
|
||
api.registerKeybinding({
|
||
combo: { key: 'g', ctrl: true },
|
||
commandId: 'MyExtension.SomeCommand'
|
||
})
|
||
```
|
||
|
||
Switching to `event.code` without migration breaks all extension keybindings.
|
||
|
||
---
|
||
|
||
## Event.key vs Event.code: Technical Analysis
|
||
|
||
### What is `event.key`?
|
||
|
||
**Definition:** The actual character or key value being produced, taking into account keyboard layout, shift state, and other modifiers.
|
||
|
||
**Purpose:** Best for **text input** - when you care about what character the user is typing.
|
||
|
||
**Characteristics:**
|
||
- **Layout-dependent:** Returns different values based on active keyboard layout
|
||
- **Shift-sensitive:** Returns uppercase letters when Shift is pressed
|
||
- **Localized:** Returns localized characters (Cyrillic, Arabic, Chinese, etc.)
|
||
- **Character-oriented:** Represents what would be typed in a text field
|
||
|
||
**Examples:**
|
||
|
||
| Keyboard Layout | Physical Key | event.key (no Shift) | event.key (with Shift) |
|
||
|-----------------|--------------|----------------------|------------------------|
|
||
| US QWERTY | S | s | S |
|
||
| Russian | S | ы | Ы |
|
||
| Greek | S | σ | Σ |
|
||
| Arabic | S | س | ش |
|
||
|
||
**Advantages:**
|
||
- Natural for text input
|
||
- Shows what user "means" to type
|
||
- Works with IME (Input Method Editor) for Asian languages
|
||
|
||
**Disadvantages for Shortcuts:**
|
||
- Breaks keyboard shortcuts on non-English layouts
|
||
- Inconsistent across keyboard layouts
|
||
- Requires users to think about layout when defining shortcuts
|
||
|
||
### What is `event.code`?
|
||
|
||
**Definition:** The physical key being pressed on the keyboard, regardless of keyboard layout, shift state, or other modifiers.
|
||
|
||
**Purpose:** Best for **keyboard shortcuts** - when you care about which physical key was pressed.
|
||
|
||
**Characteristics:**
|
||
- **Layout-independent:** Always returns the same value regardless of keyboard layout
|
||
- **Shift-insensitive:** Returns the same value whether Shift is pressed or not
|
||
- **Physical:** Represents the physical key position on the keyboard
|
||
- **Position-oriented:** Based on standard US QWERTY key positions
|
||
|
||
**Examples:**
|
||
|
||
| Keyboard Layout | Physical Key | event.code | Notes |
|
||
|-----------------|--------------|------------|-------|
|
||
| US QWERTY | S | KeyS | Standard |
|
||
| Russian | S | KeyS | Same physical key |
|
||
| Greek | S | KeyS | Same physical key |
|
||
| Arabic | S | KeyS | Same physical key |
|
||
| French AZERTY | S | KeyS | Same physical key |
|
||
|
||
**Standard `event.code` Values:**
|
||
|
||
```typescript
|
||
// Letter keys
|
||
'KeyA' through 'KeyZ'
|
||
|
||
// Digit keys (top row)
|
||
'Digit0' through 'Digit9'
|
||
|
||
// Function keys
|
||
'F1' through 'F12'
|
||
|
||
// Special keys (same in both event.key and event.code)
|
||
'Enter', 'Escape', 'Tab', 'Space', 'Backspace', 'Delete'
|
||
|
||
// Arrow keys
|
||
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'
|
||
|
||
// Modifiers (with left/right distinction)
|
||
'ControlLeft', 'ControlRight', 'ShiftLeft', 'ShiftRight',
|
||
'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight'
|
||
|
||
// Punctuation (based on US QWERTY positions)
|
||
'Minus', 'Equal', 'BracketLeft', 'BracketRight', 'Backslash',
|
||
'Semicolon', 'Quote', 'Backquote', 'Comma', 'Period', 'Slash'
|
||
```
|
||
|
||
**Advantages for Shortcuts:**
|
||
- Consistent across all keyboard layouts
|
||
- User muscle memory works regardless of layout
|
||
- Standard way to define keyboard shortcuts
|
||
- Used by most professional applications (VS Code, Chrome DevTools, etc.)
|
||
|
||
**Disadvantages:**
|
||
- Less intuitive for text input
|
||
- Need to map codes to display names for UI
|
||
- Punctuation keys labeled by US QWERTY positions
|
||
|
||
### Comparison Table
|
||
|
||
| Aspect | event.key | event.code |
|
||
|--------|-----------|-----------|
|
||
| **Purpose** | Text input | Keyboard shortcuts |
|
||
| **Layout** | Dependent | Independent |
|
||
| **Shift state** | Affects value | No effect |
|
||
| **Consistency** | Varies by layout | Always consistent |
|
||
| **Display** | Ready for display | Needs mapping |
|
||
| **Example (S key)** | 's', 'S', 'ы', 'Ы', 'σ', etc. | 'KeyS' |
|
||
| **Best for** | Typing text | Application commands |
|
||
|
||
### Real-World Examples
|
||
|
||
#### VS Code (uses `event.code`)
|
||
```typescript
|
||
// VS Code keybinding definition
|
||
{
|
||
"key": "ctrl+s", // Display
|
||
"command": "workbench.action.files.save"
|
||
}
|
||
|
||
// Internally uses event.code for matching
|
||
// Works on all keyboard layouts
|
||
```
|
||
|
||
#### Browser DevTools (uses `event.code`)
|
||
```typescript
|
||
// Ctrl+Shift+I opens DevTools
|
||
// Works regardless of keyboard layout
|
||
// Uses physical key positions
|
||
```
|
||
|
||
#### Slack (uses `event.code`)
|
||
```typescript
|
||
// Ctrl+K opens quick switcher
|
||
// Works on Russian, Arabic, Chinese keyboards
|
||
// Based on physical key position
|
||
```
|
||
|
||
### Why `event.code` is the Right Choice
|
||
|
||
1. **International Support:** Works for users worldwide without forcing them to switch layouts
|
||
2. **Muscle Memory:** Users can rely on physical key positions
|
||
3. **Industry Standard:** Used by VS Code, Chrome, Figma, and other professional tools
|
||
4. **Consistency:** Same shortcut works the same way everywhere
|
||
5. **Predictability:** Shortcuts behave the same regardless of OS language
|
||
|
||
### Edge Cases
|
||
|
||
#### 1. Non-Standard Keyboards
|
||
- **Dvorak/Colemak:** Physical positions differ, but `event.code` still based on QWERTY
|
||
- **Solution:** Allow users to remap shortcuts if needed
|
||
- **Impact:** Small percentage of users, worth the tradeoff
|
||
|
||
#### 2. Punctuation Keys
|
||
- **Problem:** Punctuation varies greatly across layouts
|
||
- **Example:** US QWERTY `-` is at different position on other layouts
|
||
- **Solution:** Use `event.code` ('Minus') for consistency
|
||
- **Display:** Show the symbol from current layout in UI
|
||
|
||
#### 3. Special Characters
|
||
- **Problem:** Keys like `[`, `]`, `\` may not exist on some keyboards
|
||
- **Solution:** Avoid using them for core shortcuts, allow user customization
|
||
- **Alternative:** Use modifier+letter combinations which are universal
|
||
|
||
---
|
||
|
||
## Proposed Solution
|
||
|
||
### Core Principle
|
||
|
||
**Use `event.code` for shortcut matching, `event.key` for display purposes.**
|
||
|
||
### Implementation Strategy
|
||
|
||
1. **Change KeyComboImpl.fromEvent()**: Use `event.code` instead of `event.key`
|
||
2. **Update Core Keybindings**: Migrate all definitions to `event.code` format
|
||
3. **Add Display Mapping**: Create `getDisplayKey()` to convert codes to readable names
|
||
4. **Implement Migration**: Auto-migrate user keybindings from old to new format
|
||
5. **Update Tests**: Fix all tests to use `event.code` format
|
||
6. **Update Documentation**: Guide extension developers to use new format
|
||
|
||
### Key Changes
|
||
|
||
#### Before (Main Branch):
|
||
```typescript
|
||
static fromEvent(event: KeyboardEvent) {
|
||
return new KeyComboImpl({
|
||
key: event.key, // Layout-dependent
|
||
ctrl: event.ctrlKey || event.metaKey,
|
||
alt: event.altKey,
|
||
shift: event.shiftKey
|
||
})
|
||
}
|
||
```
|
||
|
||
#### After (PR #5265):
|
||
```typescript
|
||
static fromEvent(event: KeyboardEvent) {
|
||
return new KeyComboImpl({
|
||
key: event.code, // Layout-independent
|
||
ctrl: event.ctrlKey || event.metaKey,
|
||
alt: event.altKey,
|
||
shift: event.shiftKey
|
||
})
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Implementation Details
|
||
|
||
### 1. KeyComboImpl Changes
|
||
|
||
```typescript
|
||
export class KeyComboImpl implements KeyCombo {
|
||
key: string // Now stores event.code format
|
||
ctrl: boolean
|
||
alt: boolean
|
||
shift: boolean
|
||
|
||
static fromEvent(event: KeyboardEvent) {
|
||
return new KeyComboImpl({
|
||
key: event.code, // Changed from event.key
|
||
ctrl: event.ctrlKey || event.metaKey,
|
||
alt: event.altKey,
|
||
shift: event.shiftKey
|
||
})
|
||
}
|
||
|
||
get isModifier(): boolean {
|
||
// Updated to use event.code format
|
||
return [
|
||
'ControlLeft', 'ControlRight',
|
||
'MetaLeft', 'MetaRight',
|
||
'AltLeft', 'AltRight',
|
||
'ShiftLeft', 'ShiftRight'
|
||
].includes(this.key)
|
||
}
|
||
|
||
getDisplayKey(): string {
|
||
// Convert event.code to readable display name
|
||
const keyMap: Record<string, string> = {
|
||
// Letters: KeyA → A
|
||
KeyA: 'A', KeyB: 'B', /* ... */ KeyZ: 'Z',
|
||
|
||
// Numbers: Digit0 → 0
|
||
Digit0: '0', Digit1: '1', /* ... */ Digit9: '9',
|
||
|
||
// Function keys: F1 → F1 (unchanged)
|
||
F1: 'F1', F2: 'F2', /* ... */ F12: 'F12',
|
||
|
||
// Special keys: mostly unchanged
|
||
Enter: 'Enter', Escape: 'Escape', Space: 'Space',
|
||
|
||
// Arrows: use symbols
|
||
ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
|
||
|
||
// Punctuation: based on US QWERTY
|
||
Minus: '-', Equal: '=', BracketLeft: '[', BracketRight: ']'
|
||
// ... more mappings
|
||
}
|
||
return keyMap[this.key] || this.key
|
||
}
|
||
|
||
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.getDisplayKey()) // Use display name
|
||
return sequences
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2. Core Keybindings Migration
|
||
|
||
**Before:**
|
||
```typescript
|
||
// src/constants/coreKeybindings.ts (main branch)
|
||
{
|
||
combo: { key: 'r' },
|
||
commandId: 'Comfy.RefreshNodeDefinitions'
|
||
},
|
||
{
|
||
combo: { key: 'q' },
|
||
commandId: 'Workspace.ToggleSidebarTab.queue'
|
||
},
|
||
```
|
||
|
||
**After:**
|
||
```typescript
|
||
// src/constants/coreKeybindings.ts (PR branch)
|
||
{
|
||
combo: { key: 'KeyR' }, // event.code format
|
||
commandId: 'Comfy.RefreshNodeDefinitions'
|
||
},
|
||
{
|
||
combo: { key: 'KeyQ' }, // event.code format
|
||
commandId: 'Workspace.ToggleSidebarTab.queue'
|
||
},
|
||
```
|
||
|
||
### 3. Escape Key Handling
|
||
|
||
**Before:**
|
||
```typescript
|
||
// src/services/keybindingService.ts (main branch)
|
||
if (event.key === 'Escape') {
|
||
// Handle escape
|
||
}
|
||
```
|
||
|
||
**After:**
|
||
```typescript
|
||
// src/services/keybindingService.ts (PR branch)
|
||
if (event.code === 'Escape') {
|
||
// Handle escape
|
||
}
|
||
```
|
||
|
||
Note: 'Escape' is the same in both `event.key` and `event.code`, but we use `event.code` for consistency.
|
||
|
||
### 4. Modifier Key Detection
|
||
|
||
**Before:**
|
||
```typescript
|
||
get isModifier(): boolean {
|
||
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
|
||
}
|
||
```
|
||
|
||
**After:**
|
||
```typescript
|
||
get isModifier(): boolean {
|
||
return [
|
||
'ControlLeft', 'ControlRight',
|
||
'MetaLeft', 'MetaRight',
|
||
'AltLeft', 'AltRight',
|
||
'ShiftLeft', 'ShiftRight'
|
||
].includes(this.key)
|
||
}
|
||
```
|
||
|
||
The `event.code` format distinguishes between left and right modifier keys.
|
||
|
||
---
|
||
|
||
## Migration Strategy
|
||
|
||
### Problem: Breaking Change
|
||
|
||
Switching from `event.key` to `event.code` is a **breaking change** for:
|
||
1. Users with custom keybindings
|
||
2. Extensions that register keybindings
|
||
3. Saved workflow keybindings
|
||
|
||
### Solution: Automatic Migration
|
||
|
||
Implement a migration utility that runs when loading user settings:
|
||
|
||
```typescript
|
||
// src/utils/keybindingMigration.ts
|
||
export function migrateKeyCombo(combo: KeyCombo): KeyCombo {
|
||
// Check if already in event.code format
|
||
if (isCodeFormat(combo.key)) {
|
||
return combo
|
||
}
|
||
|
||
// Migrate from event.key to event.code
|
||
const migrationMap: Record<string, string> = {
|
||
// Lowercase letters
|
||
'a': 'KeyA', 'b': 'KeyB', /* ... */ 'z': 'KeyZ',
|
||
|
||
// Uppercase letters
|
||
'A': 'KeyA', 'B': 'KeyB', /* ... */ 'Z': 'KeyZ',
|
||
|
||
// Numbers
|
||
'0': 'Digit0', '1': 'Digit1', /* ... */ '9': 'Digit9',
|
||
|
||
// Special keys (most are same)
|
||
'Enter': 'Enter',
|
||
'Escape': 'Escape',
|
||
'escape': 'Escape',
|
||
' ': 'Space',
|
||
'space': 'Space',
|
||
'Space': 'Space',
|
||
|
||
// Punctuation
|
||
'-': 'Minus',
|
||
'=': 'Equal',
|
||
'[': 'BracketLeft',
|
||
']': 'BracketRight',
|
||
// ... more mappings
|
||
}
|
||
|
||
return {
|
||
...combo,
|
||
key: migrationMap[combo.key] || combo.key
|
||
}
|
||
}
|
||
|
||
function isCodeFormat(key: string): boolean {
|
||
// Check if already in event.code format
|
||
const codePatterns = [
|
||
/^Key[A-Z]$/, // KeyA-KeyZ
|
||
/^Digit[0-9]$/, // Digit0-Digit9
|
||
/^F[0-9]{1,2}$/, // F1-F12
|
||
/^Arrow(Up|Down|Left|Right)$/,
|
||
/^(Enter|Escape|Space|Tab|Backspace|Delete)$/,
|
||
/^(Control|Shift|Alt|Meta)(Left|Right)$/,
|
||
/^(Minus|Equal|Bracket(Left|Right)|Backslash)$/,
|
||
/^(Semicolon|Quote|Backquote|Comma|Period|Slash)$/
|
||
]
|
||
|
||
return codePatterns.some(pattern => pattern.test(key))
|
||
}
|
||
|
||
export function migrateKeybindings(keybindings: Keybinding[]): {
|
||
keybindings: Keybinding[]
|
||
changed: boolean
|
||
} {
|
||
let changed = false
|
||
const migrated = keybindings.map(binding => {
|
||
const newCombo = migrateKeyCombo(binding.combo)
|
||
if (newCombo.key !== binding.combo.key) {
|
||
changed = true
|
||
}
|
||
return {
|
||
...binding,
|
||
combo: newCombo
|
||
}
|
||
})
|
||
|
||
return { keybindings: migrated, changed }
|
||
}
|
||
```
|
||
|
||
### Integration into KeybindingService
|
||
|
||
```typescript
|
||
// src/services/keybindingService.ts
|
||
function registerUserKeybindings() {
|
||
// Load user keybindings from settings
|
||
let unsetBindings = settingStore.get('Comfy.Keybinding.UnsetBindings')
|
||
let newBindings = settingStore.get('Comfy.Keybinding.NewBindings')
|
||
|
||
// Migrate if needed
|
||
const migratedUnset = migrateKeybindings(unsetBindings)
|
||
const migratedNew = migrateKeybindings(newBindings)
|
||
|
||
// Save back if changed
|
||
if (migratedUnset.changed) {
|
||
await settingStore.set('Comfy.Keybinding.UnsetBindings', migratedUnset.keybindings)
|
||
console.log('Migrated unset keybindings to new format')
|
||
}
|
||
|
||
if (migratedNew.changed) {
|
||
await settingStore.set('Comfy.Keybinding.NewBindings', migratedNew.keybindings)
|
||
console.log('Migrated custom keybindings to new format')
|
||
}
|
||
|
||
// Register as usual
|
||
for (const keybinding of migratedUnset.keybindings) {
|
||
keybindingStore.unsetKeybinding(new KeybindingImpl(keybinding))
|
||
}
|
||
for (const keybinding of migratedNew.keybindings) {
|
||
keybindingStore.addUserKeybinding(new KeybindingImpl(keybinding))
|
||
}
|
||
}
|
||
```
|
||
|
||
### Migration Behavior
|
||
|
||
1. **First Load After Update:**
|
||
- System detects old format keybindings
|
||
- Automatically migrates to new format
|
||
- Saves migrated version to settings
|
||
- User keybindings continue working seamlessly
|
||
|
||
2. **Subsequent Loads:**
|
||
- Keybindings already in new format
|
||
- No migration needed
|
||
- Normal operation
|
||
|
||
3. **User Notification:**
|
||
- Optional: Show toast notification about migration
|
||
- Log migration to console for debugging
|
||
- No user action required
|
||
|
||
---
|
||
|
||
## Testing Strategy
|
||
|
||
### Unit Tests
|
||
|
||
#### 1. KeyComboImpl Tests
|
||
|
||
```typescript
|
||
// tests-ui/tests/stores/keybindingStore.test.ts
|
||
|
||
describe('KeyComboImpl.fromEvent with event.code', () => {
|
||
it('should create combo from keyboard event using code', () => {
|
||
const event = new KeyboardEvent('keydown', {
|
||
key: 'r', // What would be typed (layout-dependent)
|
||
code: 'KeyR', // Physical key (layout-independent)
|
||
bubbles: true
|
||
})
|
||
|
||
const combo = KeyComboImpl.fromEvent(event)
|
||
expect(combo.key).toBe('KeyR') // Should use code, not key
|
||
})
|
||
|
||
it('should work with non-English layouts', () => {
|
||
const event = new KeyboardEvent('keydown', {
|
||
key: 'к', // Cyrillic character (Russian layout)
|
||
code: 'KeyR', // Same physical key
|
||
bubbles: true
|
||
})
|
||
|
||
const combo = KeyComboImpl.fromEvent(event)
|
||
expect(combo.key).toBe('KeyR') // Should still be KeyR
|
||
})
|
||
|
||
it('should detect modifier keys correctly', () => {
|
||
const leftControl = new KeyComboImpl({ key: 'ControlLeft' })
|
||
const rightControl = new KeyComboImpl({ key: 'ControlRight' })
|
||
|
||
expect(leftControl.isModifier).toBe(true)
|
||
expect(rightControl.isModifier).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('getDisplayKey', () => {
|
||
it('should convert letter codes to display names', () => {
|
||
const combo = new KeyComboImpl({ key: 'KeyA' })
|
||
expect(combo.getDisplayKey()).toBe('A')
|
||
})
|
||
|
||
it('should convert digit codes to display names', () => {
|
||
const combo = new KeyComboImpl({ key: 'Digit5' })
|
||
expect(combo.getDisplayKey()).toBe('5')
|
||
})
|
||
|
||
it('should keep special keys unchanged', () => {
|
||
const combo = new KeyComboImpl({ key: 'Enter' })
|
||
expect(combo.getDisplayKey()).toBe('Enter')
|
||
})
|
||
|
||
it('should convert arrows to symbols', () => {
|
||
const combo = new KeyComboImpl({ key: 'ArrowUp' })
|
||
expect(combo.getDisplayKey()).toBe('↑')
|
||
})
|
||
})
|
||
```
|
||
|
||
#### 2. Migration Tests
|
||
|
||
```typescript
|
||
// tests-ui/tests/utils/keybindingMigration.test.ts
|
||
|
||
describe('migrateKeyCombo', () => {
|
||
it('should migrate lowercase letters', () => {
|
||
const old = { key: 'r' }
|
||
const migrated = migrateKeyCombo(old)
|
||
expect(migrated.key).toBe('KeyR')
|
||
})
|
||
|
||
it('should migrate uppercase letters', () => {
|
||
const old = { key: 'R' }
|
||
const migrated = migrateKeyCombo(old)
|
||
expect(migrated.key).toBe('KeyR')
|
||
})
|
||
|
||
it('should not migrate if already in code format', () => {
|
||
const already = { key: 'KeyR' }
|
||
const migrated = migrateKeyCombo(already)
|
||
expect(migrated.key).toBe('KeyR')
|
||
})
|
||
|
||
it('should migrate digit characters', () => {
|
||
const old = { key: '5' }
|
||
const migrated = migrateKeyCombo(old)
|
||
expect(migrated.key).toBe('Digit5')
|
||
})
|
||
|
||
it('should handle special keys', () => {
|
||
const escape = migrateKeyCombo({ key: 'escape' })
|
||
expect(escape.key).toBe('Escape')
|
||
|
||
const space = migrateKeyCombo({ key: ' ' })
|
||
expect(space.key).toBe('Space')
|
||
})
|
||
})
|
||
|
||
describe('migrateKeybindings', () => {
|
||
it('should migrate array of keybindings', () => {
|
||
const old = [
|
||
{ combo: { key: 'r' }, commandId: 'Test1' },
|
||
{ combo: { key: 's', ctrl: true }, commandId: 'Test2' }
|
||
]
|
||
|
||
const { keybindings, changed } = migrateKeybindings(old)
|
||
|
||
expect(changed).toBe(true)
|
||
expect(keybindings[0].combo.key).toBe('KeyR')
|
||
expect(keybindings[1].combo.key).toBe('KeyS')
|
||
})
|
||
|
||
it('should detect when no migration needed', () => {
|
||
const already = [
|
||
{ combo: { key: 'KeyR' }, commandId: 'Test1' }
|
||
]
|
||
|
||
const { keybindings, changed } = migrateKeybindings(already)
|
||
|
||
expect(changed).toBe(false)
|
||
expect(keybindings[0].combo.key).toBe('KeyR')
|
||
})
|
||
})
|
||
```
|
||
|
||
### Browser Tests (E2E)
|
||
|
||
#### 1. Basic Keybinding Test
|
||
|
||
```typescript
|
||
// browser_tests/tests/keybindings.spec.ts
|
||
|
||
test('keyboard shortcuts work on all layouts', async ({ page }) => {
|
||
// Note: Playwright normalizes keyboard events
|
||
// We test the actual user experience
|
||
|
||
await page.keyboard.press('r')
|
||
// Should trigger refresh node definitions
|
||
|
||
await page.keyboard.press('Control+s')
|
||
// Should trigger save workflow
|
||
|
||
await page.keyboard.press('Escape')
|
||
// Should close dialogs
|
||
})
|
||
```
|
||
|
||
#### 2. Non-English Layout Simulation
|
||
|
||
```typescript
|
||
test('shortcuts work with simulated non-English layout', async ({ page }) => {
|
||
// Simulate Russian layout by sending events with Cyrillic characters
|
||
await page.evaluate(() => {
|
||
const event = new KeyboardEvent('keydown', {
|
||
key: 'к', // Cyrillic (Russian)
|
||
code: 'KeyR', // Physical key
|
||
bubbles: true
|
||
})
|
||
document.dispatchEvent(event)
|
||
})
|
||
|
||
// Should still trigger the command bound to 'R' key
|
||
await expect(page.locator('.node-library')).toBeVisible()
|
||
})
|
||
```
|
||
|
||
### Manual Testing Checklist
|
||
|
||
- [ ] Test on English QWERTY keyboard
|
||
- [ ] Test on Russian keyboard layout
|
||
- [ ] Test on Arabic keyboard layout
|
||
- [ ] Test on Chinese keyboard with IME
|
||
- [ ] Test on French AZERTY keyboard
|
||
- [ ] Test on German QWERTZ keyboard
|
||
- [ ] Test on Japanese keyboard with IME
|
||
- [ ] Test all core keybindings work
|
||
- [ ] Test custom user keybindings migrate correctly
|
||
- [ ] Test extension keybindings still work
|
||
- [ ] Test Escape key in dialogs
|
||
- [ ] Test modifier combinations (Ctrl+Shift+Key)
|
||
- [ ] Test that text input still works normally
|
||
|
||
---
|
||
|
||
## Related Work
|
||
|
||
### PR #4285 (Closed)
|
||
|
||
**Title:** "Fix keybinding layout independence for international users / issue #2340 (the non-english keyboard bug)"
|
||
|
||
**Approach:** Added both `key` and `code` properties to KeyCombo schema.
|
||
|
||
**Why it was closed:**
|
||
- Added complexity by maintaining both properties
|
||
- Unclear which property to use for matching
|
||
- Incomplete migration strategy
|
||
- Not merged into main
|
||
|
||
### PR #5007 (Merged)
|
||
|
||
**Title:** "Translated Keyboard Shortcuts"
|
||
|
||
**Approach:** Added internationalization for keyboard shortcut display names.
|
||
|
||
**Relation:** This PR focused on **displaying** shortcuts in different languages, but didn't fix the actual **functionality** on non-English layouts. PR #5265 completes the solution by making shortcuts actually work, while PR #5007 made them readable.
|
||
|
||
### PR #5265 (Current)
|
||
|
||
**Title:** "[fix] Change keyboard event handling from event.key to event.code"
|
||
|
||
**Approach:** Switch entirely to `event.code` for matching, add `getDisplayKey()` for display.
|
||
|
||
**Status:** Open, needs backward compatibility solution (migration).
|
||
|
||
**Key Improvements over #4285:**
|
||
- Simpler approach (one property for matching)
|
||
- Clear migration path
|
||
- Better documentation
|
||
- Complete solution
|
||
|
||
---
|
||
|
||
## Recommendations
|
||
|
||
### 1. Implementation Phases
|
||
|
||
**Phase 1: Core Changes (Current PR)**
|
||
- ✅ Switch KeyComboImpl.fromEvent() to use event.code
|
||
- ✅ Update core keybindings to event.code format
|
||
- ✅ Add getDisplayKey() method
|
||
- ✅ Update Escape key checks to use event.code
|
||
- ✅ Update isModifier to check event.code format
|
||
|
||
**Phase 2: Migration (To Be Added)**
|
||
- [ ] Create keybindingMigration.ts utility
|
||
- [ ] Add migration logic to registerUserKeybindings()
|
||
- [ ] Add migration tests
|
||
- [ ] Add migration documentation
|
||
|
||
**Phase 3: Testing & Documentation**
|
||
- [ ] Update all unit tests
|
||
- [ ] Update browser tests
|
||
- [ ] Manual testing on various layouts
|
||
- [ ] Update extension developer documentation
|
||
- [ ] Update user documentation
|
||
|
||
**Phase 4: Extension Support**
|
||
- [ ] Add deprecation warnings for old format
|
||
- [ ] Update extension examples
|
||
- [ ] Provide migration guide for extension developers
|
||
- [ ] Consider backwards compatibility shim
|
||
|
||
### 2. Documentation Updates Needed
|
||
|
||
1. **Extension Developer Guide:**
|
||
- How to register keybindings using event.code
|
||
- Migration guide for existing extensions
|
||
- Examples of common shortcuts
|
||
|
||
2. **User Guide:**
|
||
- Explain that shortcuts are based on physical key positions
|
||
- Provide tips for users with non-standard layouts
|
||
- Document how to customize shortcuts
|
||
|
||
3. **CLAUDE.md Updates:**
|
||
- Add the keybinding guidelines already present
|
||
- Document the event.code standard
|
||
- Provide examples for future contributors
|
||
|
||
### 3. Future Enhancements
|
||
|
||
1. **Visual Keybinding Editor:**
|
||
- Let users press keys to set shortcuts
|
||
- Show physical key position visually
|
||
- Preview shortcuts on different layouts
|
||
|
||
2. **Layout Profiles:**
|
||
- Preset configurations for common layouts
|
||
- Community-contributed optimizations
|
||
- Easy switching between profiles
|
||
|
||
3. **Conflict Detection:**
|
||
- Warn about shortcuts that conflict with browser/OS
|
||
- Suggest alternatives for unavailable keys
|
||
- Show which shortcuts are already in use
|
||
|
||
---
|
||
|
||
## Conclusion
|
||
|
||
### Summary of Analysis
|
||
|
||
1. **Problem:** Current implementation uses `event.key`, which breaks on non-English keyboard layouts
|
||
2. **Root Cause:** `event.key` is layout-dependent, while shortcuts are defined in English
|
||
3. **Solution:** Switch to `event.code` (layout-independent physical key positions)
|
||
4. **Trade-off:** Breaking change for existing user keybindings requires migration
|
||
5. **Benefit:** Universal support for all keyboard layouts worldwide
|
||
|
||
### Why This Is the Right Solution
|
||
|
||
1. **Industry Standard:** VS Code, Chrome DevTools, and other professional tools use `event.code`
|
||
2. **User Experience:** Muscle memory works regardless of keyboard layout
|
||
3. **International Support:** Makes ComfyUI usable for millions of non-English users
|
||
4. **Future-Proof:** Will work with any future keyboard layout
|
||
5. **Maintainable:** Simpler, more consistent codebase
|
||
|
||
### Recommended Next Steps
|
||
|
||
1. **Complete Migration Implementation:** Add the migration utility to this PR
|
||
2. **Test Thoroughly:** Verify migration works with various saved keybindings
|
||
3. **Update Documentation:** Ensure developers know how to use new format
|
||
4. **Merge PR #5265:** Get this fix to users as soon as possible
|
||
5. **Monitor Feedback:** Watch for any edge cases after release
|
||
6. **Support Extensions:** Help extension developers migrate their keybindings
|
||
|
||
### Success Metrics
|
||
|
||
After implementation, we should see:
|
||
- ✅ Zero reports of "shortcuts don't work on my keyboard layout"
|
||
- ✅ No user action required after update (migration is automatic)
|
||
- ✅ Extension compatibility maintained
|
||
- ✅ Test coverage for all keyboard event handling
|
||
- ✅ Clear documentation for future contributors
|
||
|
||
---
|
||
|
||
**Document Version:** 1.0
|
||
**Date:** 2025-10-16
|
||
**PR:** #5265
|
||
**Issue:** #5252
|
||
**Author:** Research conducted for ComfyUI Frontend Team
|