Stirling-PDF/frontend/src/utils/hotkeys.ts
Anthony Stirling d86a13cc89
shortcuts and config menu (#4530)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-01 20:22:04 +01:00

191 lines
4.5 KiB
TypeScript

import { KeyboardEvent as ReactKeyboardEvent } from 'react';
export interface HotkeyBinding {
code: string;
alt?: boolean;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}
const MODIFIER_CODES = new Set([
'ShiftLeft',
'ShiftRight',
'ControlLeft',
'ControlRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
]);
const CODE_LABEL_MAP: Record<string, string> = {
Minus: '-',
Equal: '=',
Backquote: '`',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
IntlBackslash: '\\',
Semicolon: ';',
Quote: '\'',
Comma: ',',
Period: '.',
Slash: '/',
Space: 'Space',
Tab: 'Tab',
Escape: 'Esc',
Enter: 'Enter',
NumpadEnter: 'Num Enter',
NumpadAdd: 'Num +',
NumpadSubtract: 'Num -',
NumpadMultiply: 'Num *',
NumpadDivide: 'Num /',
NumpadDecimal: 'Num .',
NumpadComma: 'Num ,',
NumpadEqual: 'Num =',
};
export const isMacLike = (): boolean => {
if (typeof navigator === 'undefined') {
return false;
}
const platform = navigator.platform?.toLowerCase() ?? '';
const userAgent = navigator.userAgent?.toLowerCase() ?? '';
return /mac|iphone|ipad|ipod/.test(platform) || /mac|iphone|ipad|ipod/.test(userAgent);
};
export const isModifierCode = (code: string): boolean => MODIFIER_CODES.has(code);
const isFunctionKey = (code: string): boolean => /^F\d{1,2}$/.test(code);
export const bindingEquals = (a?: HotkeyBinding | null, b?: HotkeyBinding | null): boolean => {
if (!a && !b) return true;
if (!a || !b) return false;
return (
a.code === b.code &&
Boolean(a.alt) === Boolean(b.alt) &&
Boolean(a.ctrl) === Boolean(b.ctrl) &&
Boolean(a.meta) === Boolean(b.meta) &&
Boolean(a.shift) === Boolean(b.shift)
);
};
export const bindingMatchesEvent = (binding: HotkeyBinding, event: KeyboardEvent): boolean => {
return (
event.code === binding.code &&
event.altKey === Boolean(binding.alt) &&
event.ctrlKey === Boolean(binding.ctrl) &&
event.metaKey === Boolean(binding.meta) &&
event.shiftKey === Boolean(binding.shift)
);
};
export const eventToBinding = (event: KeyboardEvent | ReactKeyboardEvent): HotkeyBinding | null => {
const code = event.code;
if (!code || isModifierCode(code)) {
return null;
}
const binding: HotkeyBinding = {
code,
alt: event.altKey,
ctrl: event.ctrlKey,
meta: event.metaKey,
shift: event.shiftKey,
};
// Require at least one modifier to avoid clashing with text input
if (!binding.alt && !binding.ctrl && !binding.meta) {
return null;
}
return binding;
};
const getKeyLabel = (code: string): string => {
if (CODE_LABEL_MAP[code]) {
return CODE_LABEL_MAP[code];
}
if (code.startsWith('Key')) {
return code.slice(3);
}
if (code.startsWith('Digit')) {
return code.slice(5);
}
if (code.startsWith('Numpad')) {
const remainder = code.slice(6);
if (/^[0-9]$/.test(remainder)) {
return `Num ${remainder}`;
}
return `Num ${remainder}`;
}
// Match function keys (F1-F12)
if (isFunctionKey(code)) {
return code;
}
switch (code) {
case 'ArrowUp':
return '↑';
case 'ArrowDown':
return '↓';
case 'ArrowLeft':
return '←';
case 'ArrowRight':
return '→';
default:
return code;
}
};
export const getDisplayParts = (binding: HotkeyBinding | null | undefined, macLike: boolean): string[] => {
if (!binding) return [];
const parts: string[] = [];
if (binding.meta) {
parts.push(macLike ? '⌘' : 'Win');
}
if (binding.ctrl) {
parts.push(macLike ? '⌃' : 'Ctrl');
}
if (binding.alt) {
parts.push(macLike ? '⌥' : 'Alt');
}
if (binding.shift) {
parts.push(macLike ? '⇧' : 'Shift');
}
parts.push(getKeyLabel(binding.code));
return parts;
};
export const serializeBindings = (bindings: Record<string, HotkeyBinding>): string => {
return JSON.stringify(bindings);
};
export const deserializeBindings = (value: string | null | undefined): Record<string, HotkeyBinding> => {
if (!value) {
return {};
}
try {
const parsed = JSON.parse(value) as Record<string, HotkeyBinding>;
if (typeof parsed !== 'object' || parsed === null) {
return {};
}
return parsed;
} catch (error) {
console.warn('Failed to parse stored hotkey bindings', error);
return {};
}
};
export const normalizeBinding = (binding: HotkeyBinding): HotkeyBinding => ({
code: binding.code,
alt: Boolean(binding.alt),
ctrl: Boolean(binding.ctrl),
meta: Boolean(binding.meta),
shift: Boolean(binding.shift),
});