mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-29 21:51:59 +02:00
Add configurable hotkeys support (#343)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { debounce } from '@yaakapp-internal/lib';
|
||||
import { atom } from 'jotai';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { capitalize } from '../lib/capitalize';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
@@ -13,7 +14,7 @@ export type HotkeyAction =
|
||||
| 'app.zoom_out'
|
||||
| 'app.zoom_reset'
|
||||
| 'command_palette.toggle'
|
||||
| 'environmentEditor.toggle'
|
||||
| 'environment_editor.toggle'
|
||||
| 'hotkeys.showHelp'
|
||||
| 'model.create'
|
||||
| 'model.duplicate'
|
||||
@@ -34,39 +35,94 @@ export type HotkeyAction =
|
||||
| 'url_bar.focus'
|
||||
| 'workspace_settings.show';
|
||||
|
||||
const hotkeys: Record<HotkeyAction, string[]> = {
|
||||
'app.zoom_in': ['CmdCtrl+Equal'],
|
||||
'app.zoom_out': ['CmdCtrl+Minus'],
|
||||
'app.zoom_reset': ['CmdCtrl+0'],
|
||||
'command_palette.toggle': ['CmdCtrl+k'],
|
||||
'environmentEditor.toggle': ['CmdCtrl+Shift+E', 'CmdCtrl+Shift+e'],
|
||||
'request.rename': type() === 'macos' ? ['Control+Shift+r'] : ['F2'],
|
||||
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||
'hotkeys.showHelp': ['CmdCtrl+Shift+/', 'CmdCtrl+Shift+?'], // when shift is pressed, it might be a question mark
|
||||
'model.create': ['CmdCtrl+n'],
|
||||
'model.duplicate': ['CmdCtrl+d'],
|
||||
/** Default hotkeys for macOS (uses Meta for Cmd) */
|
||||
const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
|
||||
'app.zoom_in': ['Meta+Equal'],
|
||||
'app.zoom_out': ['Meta+Minus'],
|
||||
'app.zoom_reset': ['Meta+0'],
|
||||
'command_palette.toggle': ['Meta+k'],
|
||||
'environment_editor.toggle': ['Meta+Shift+e'],
|
||||
'request.rename': ['Control+Shift+r'],
|
||||
'request.send': ['Meta+Enter', 'Meta+r'],
|
||||
'hotkeys.showHelp': ['Meta+Shift+/'],
|
||||
'model.create': ['Meta+n'],
|
||||
'model.duplicate': ['Meta+d'],
|
||||
'switcher.next': ['Control+Shift+Tab'],
|
||||
'switcher.prev': ['Control+Tab'],
|
||||
'switcher.toggle': ['CmdCtrl+p'],
|
||||
'settings.show': ['CmdCtrl+,'],
|
||||
'sidebar.filter': ['CmdCtrl+f'],
|
||||
'sidebar.expand_all': ['CmdCtrl+Shift+Equal'],
|
||||
'sidebar.collapse_all': ['CmdCtrl+Shift+Minus'],
|
||||
'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'],
|
||||
'sidebar.selected.duplicate': ['CmdCtrl+d'],
|
||||
'switcher.toggle': ['Meta+p'],
|
||||
'settings.show': ['Meta+,'],
|
||||
'sidebar.filter': ['Meta+f'],
|
||||
'sidebar.expand_all': ['Meta+Shift+Equal'],
|
||||
'sidebar.collapse_all': ['Meta+Shift+Minus'],
|
||||
'sidebar.selected.delete': ['Delete', 'Meta+Backspace'],
|
||||
'sidebar.selected.duplicate': ['Meta+d'],
|
||||
'sidebar.selected.rename': ['Enter'],
|
||||
'sidebar.focus': ['CmdCtrl+b'],
|
||||
'sidebar.context_menu': type() === 'macos' ? ['Control+Enter'] : ['Alt+Insert'],
|
||||
'url_bar.focus': ['CmdCtrl+l'],
|
||||
'workspace_settings.show': ['CmdCtrl+;'],
|
||||
'sidebar.focus': ['Meta+b'],
|
||||
'sidebar.context_menu': ['Control+Enter'],
|
||||
'url_bar.focus': ['Meta+l'],
|
||||
'workspace_settings.show': ['Meta+;'],
|
||||
};
|
||||
|
||||
/** Default hotkeys for Windows/Linux (uses Control for Ctrl) */
|
||||
const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
|
||||
'app.zoom_in': ['Control+Equal'],
|
||||
'app.zoom_out': ['Control+Minus'],
|
||||
'app.zoom_reset': ['Control+0'],
|
||||
'command_palette.toggle': ['Control+k'],
|
||||
'environment_editor.toggle': ['Control+Shift+e'],
|
||||
'request.rename': ['F2'],
|
||||
'request.send': ['Control+Enter', 'Control+r'],
|
||||
'hotkeys.showHelp': ['Control+Shift+/'],
|
||||
'model.create': ['Control+n'],
|
||||
'model.duplicate': ['Control+d'],
|
||||
'switcher.next': ['Control+Shift+Tab'],
|
||||
'switcher.prev': ['Control+Tab'],
|
||||
'switcher.toggle': ['Control+p'],
|
||||
'settings.show': ['Control+,'],
|
||||
'sidebar.filter': ['Control+f'],
|
||||
'sidebar.expand_all': ['Control+Shift+Equal'],
|
||||
'sidebar.collapse_all': ['Control+Shift+Minus'],
|
||||
'sidebar.selected.delete': ['Delete', 'Control+Backspace'],
|
||||
'sidebar.selected.duplicate': ['Control+d'],
|
||||
'sidebar.selected.rename': ['Enter'],
|
||||
'sidebar.focus': ['Control+b'],
|
||||
'sidebar.context_menu': ['Alt+Insert'],
|
||||
'url_bar.focus': ['Control+l'],
|
||||
'workspace_settings.show': ['Control+;'],
|
||||
};
|
||||
|
||||
/** Get the default hotkeys for the current platform */
|
||||
export const defaultHotkeys: Record<HotkeyAction, string[]> =
|
||||
type() === 'macos' ? defaultHotkeysMac : defaultHotkeysOther;
|
||||
|
||||
/** Atom that provides the effective hotkeys by merging defaults with user settings */
|
||||
export const hotkeysAtom = atom((get) => {
|
||||
const settings = get(settingsAtom);
|
||||
const customHotkeys = settings?.hotkeys ?? {};
|
||||
|
||||
// Merge default hotkeys with custom hotkeys from settings
|
||||
// Custom hotkeys override defaults for the same action
|
||||
// An empty array means the hotkey is intentionally disabled
|
||||
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||
if (action in defaultHotkeys && Array.isArray(keys)) {
|
||||
merged[action as HotkeyAction] = keys;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
/** Helper function to get current hotkeys from the store */
|
||||
function getHotkeys(): Record<HotkeyAction, string[]> {
|
||||
return jotaiStore.get(hotkeysAtom);
|
||||
}
|
||||
|
||||
const hotkeyLabels: Record<HotkeyAction, string> = {
|
||||
'app.zoom_in': 'Zoom In',
|
||||
'app.zoom_out': 'Zoom Out',
|
||||
'app.zoom_reset': 'Zoom to Actual Size',
|
||||
'command_palette.toggle': 'Toggle Command Palette',
|
||||
'environmentEditor.toggle': 'Edit Environments',
|
||||
'environment_editor.toggle': 'Edit Environments',
|
||||
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
||||
'model.create': 'New Request',
|
||||
'model.duplicate': 'Duplicate Request',
|
||||
@@ -90,7 +146,16 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
|
||||
|
||||
const layoutInsensitiveKeys = ['Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote'];
|
||||
|
||||
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
|
||||
export const hotkeyActions: HotkeyAction[] = (
|
||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||
).sort((a, b) => {
|
||||
const scopeA = a.split('.')[0] || '';
|
||||
const scopeB = b.split('.')[0] || '';
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
|
||||
export type HotKeyOptions = {
|
||||
enable?: boolean | (() => boolean);
|
||||
@@ -200,6 +265,7 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
const executed: string[] = [];
|
||||
const hotkeys = getHotkeys();
|
||||
outer: for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
|
||||
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
|
||||
if (hkAction !== action) {
|
||||
@@ -212,8 +278,7 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
|
||||
for (const hkKey of hkKeys) {
|
||||
const keys = hkKey.split('+');
|
||||
const adjustedKeys = keys.map(resolveHotkeyKey);
|
||||
if (compareKeys(adjustedKeys, Array.from(currentKeysWithModifiers))) {
|
||||
if (compareKeys(keys, Array.from(currentKeysWithModifiers))) {
|
||||
if (!options.allowDefault) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -233,34 +298,38 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
clearCurrentKeysDebounced();
|
||||
}
|
||||
|
||||
export function useHotKeyLabel(action: HotkeyAction): string {
|
||||
export function useHotkeyLabel(action: HotkeyAction): string {
|
||||
return hotkeyLabels[action];
|
||||
}
|
||||
|
||||
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
|
||||
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
|
||||
if (trigger == null) {
|
||||
return null;
|
||||
}
|
||||
export function getHotkeyScope(action: HotkeyAction): string {
|
||||
const scope = action.split('.')[0];
|
||||
return scope || '';
|
||||
}
|
||||
|
||||
export function formatHotkeyString(trigger: string): string[] {
|
||||
const os = type();
|
||||
const parts = trigger.split('+');
|
||||
const labelParts: string[] = [];
|
||||
|
||||
for (const p of parts) {
|
||||
if (os === 'macos') {
|
||||
if (p === 'CmdCtrl') {
|
||||
if (p === 'Meta') {
|
||||
labelParts.push('⌘');
|
||||
} else if (p === 'Shift') {
|
||||
labelParts.push('⇧');
|
||||
} else if (p === 'Control') {
|
||||
labelParts.push('⌃');
|
||||
} else if (p === 'Alt') {
|
||||
labelParts.push('⌥');
|
||||
} else if (p === 'Enter') {
|
||||
labelParts.push('↩');
|
||||
} else if (p === 'Tab') {
|
||||
labelParts.push('⇥');
|
||||
} else if (p === 'Backspace') {
|
||||
labelParts.push('⌫');
|
||||
} else if (p === 'Delete') {
|
||||
labelParts.push('⌦');
|
||||
} else if (p === 'Minus') {
|
||||
labelParts.push('-');
|
||||
} else if (p === 'Plus') {
|
||||
@@ -271,7 +340,7 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
|
||||
labelParts.push(capitalize(p));
|
||||
}
|
||||
} else {
|
||||
if (p === 'CmdCtrl') {
|
||||
if (p === 'Control') {
|
||||
labelParts.push('Ctrl');
|
||||
} else {
|
||||
labelParts.push(capitalize(p));
|
||||
@@ -285,12 +354,15 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
|
||||
return [labelParts.join('+')];
|
||||
}
|
||||
|
||||
const resolveHotkeyKey = (key: string) => {
|
||||
const os = type();
|
||||
if (key === 'CmdCtrl' && os === 'macos') return 'Meta';
|
||||
if (key === 'CmdCtrl') return 'Control';
|
||||
return key;
|
||||
};
|
||||
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
|
||||
if (trigger == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatHotkeyString(trigger);
|
||||
}
|
||||
|
||||
function compareKeys(keysA: string[], keysB: string[]) {
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
Reference in New Issue
Block a user