mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-25 10:51:57 +01:00
425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
import { type } from "@tauri-apps/plugin-os";
|
|
import { debounce } from "@yaakapp-internal/lib";
|
|
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";
|
|
|
|
const HOLD_KEYS = ["Shift", "Control", "Command", "Alt", "Meta"];
|
|
const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"];
|
|
|
|
export type HotkeyAction =
|
|
| "app.zoom_in"
|
|
| "app.zoom_out"
|
|
| "app.zoom_reset"
|
|
| "command_palette.toggle"
|
|
| "editor.autocomplete"
|
|
| "environment_editor.toggle"
|
|
| "hotkeys.showHelp"
|
|
| "model.create"
|
|
| "model.duplicate"
|
|
| "request.send"
|
|
| "request.rename"
|
|
| "switcher.next"
|
|
| "switcher.prev"
|
|
| "switcher.toggle"
|
|
| "settings.show"
|
|
| "sidebar.filter"
|
|
| "sidebar.selected.delete"
|
|
| "sidebar.selected.duplicate"
|
|
| "sidebar.selected.move"
|
|
| "sidebar.selected.rename"
|
|
| "sidebar.expand_all"
|
|
| "sidebar.collapse_all"
|
|
| "sidebar.focus"
|
|
| "sidebar.context_menu"
|
|
| "url_bar.focus"
|
|
| "workspace_settings.show";
|
|
|
|
/** 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"],
|
|
"editor.autocomplete": ["Control+Space"],
|
|
"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": ["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.move": [],
|
|
"sidebar.selected.rename": ["Enter"],
|
|
"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"],
|
|
"editor.autocomplete": ["Control+Space"],
|
|
"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.move": [],
|
|
"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",
|
|
"editor.autocomplete": "Trigger Autocomplete",
|
|
"environment_editor.toggle": "Edit Environments",
|
|
"hotkeys.showHelp": "Show Keyboard Shortcuts",
|
|
"model.create": "New Request",
|
|
"model.duplicate": "Duplicate Request",
|
|
"request.rename": "Rename Active Request",
|
|
"request.send": "Send Active Request",
|
|
"switcher.next": "Go To Previous Request",
|
|
"switcher.prev": "Go To Next Request",
|
|
"switcher.toggle": "Toggle Request Switcher",
|
|
"settings.show": "Open Settings",
|
|
"sidebar.filter": "Filter Sidebar",
|
|
"sidebar.expand_all": "Expand All Folders",
|
|
"sidebar.collapse_all": "Collapse All Folders",
|
|
"sidebar.selected.delete": "Delete Selected Sidebar Item",
|
|
"sidebar.selected.duplicate": "Duplicate Selected Sidebar Item",
|
|
"sidebar.selected.move": "Move Selected to Workspace",
|
|
"sidebar.selected.rename": "Rename Selected Sidebar Item",
|
|
"sidebar.focus": "Focus or Toggle Sidebar",
|
|
"sidebar.context_menu": "Show Context Menu",
|
|
"url_bar.focus": "Focus URL",
|
|
"workspace_settings.show": "Open Workspace Settings",
|
|
};
|
|
|
|
const layoutInsensitiveKeys = [
|
|
"Equal",
|
|
"Minus",
|
|
"BracketLeft",
|
|
"BracketRight",
|
|
"Backquote",
|
|
"Space",
|
|
];
|
|
|
|
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);
|
|
priority?: number;
|
|
allowDefault?: boolean;
|
|
};
|
|
|
|
interface Callback {
|
|
action: HotkeyAction;
|
|
callback: (e: KeyboardEvent) => void;
|
|
options: HotKeyOptions;
|
|
}
|
|
|
|
const callbacksAtom = atom<Callback[]>([]);
|
|
const currentKeysAtom = atom<Set<string>>(new Set([]));
|
|
export const sortedCallbacksAtom = atom((get) =>
|
|
[...get(callbacksAtom)].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0)),
|
|
);
|
|
|
|
const clearCurrentKeysDebounced = debounce(() => {
|
|
jotaiStore.set(currentKeysAtom, new Set([]));
|
|
}, 5000);
|
|
|
|
export function useHotKey(
|
|
action: HotkeyAction | null,
|
|
callback: (e: KeyboardEvent) => void,
|
|
options: HotKeyOptions = {},
|
|
) {
|
|
useEffect(() => {
|
|
if (action == null) return;
|
|
jotaiStore.set(callbacksAtom, (prev) => {
|
|
const without = prev.filter((cb) => {
|
|
const isTheSame = cb.action === action && cb.options.priority === options.priority;
|
|
return !isTheSame;
|
|
});
|
|
const newCb: Callback = { action, callback, options };
|
|
return [...without, newCb];
|
|
});
|
|
return () => {
|
|
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
|
|
};
|
|
}, [action, callback, options]);
|
|
}
|
|
|
|
export function useSubscribeHotKeys() {
|
|
useEffect(() => {
|
|
document.addEventListener("keyup", handleKeyUp, { capture: true });
|
|
document.addEventListener("keydown", handleKeyDown, { capture: true });
|
|
return () => {
|
|
document.removeEventListener("keydown", handleKeyDown, { capture: true });
|
|
document.removeEventListener("keyup", handleKeyUp, { capture: true });
|
|
};
|
|
}, []);
|
|
}
|
|
|
|
function handleKeyUp(e: KeyboardEvent) {
|
|
const keyToRemove = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
|
const currentKeys = new Set(jotaiStore.get(currentKeysAtom));
|
|
currentKeys.delete(keyToRemove);
|
|
|
|
// Clear all keys if no longer holding modifier
|
|
// HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ;
|
|
// As you see, the ":" is not removed because it turned into ";" when shift was released
|
|
const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
|
if (!isHoldingModifier) {
|
|
currentKeys.clear();
|
|
}
|
|
|
|
jotaiStore.set(currentKeysAtom, currentKeys);
|
|
}
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
// Don't add key if not holding modifier
|
|
const isValidKeymapKey =
|
|
e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || SINGLE_WHITELIST.includes(e.key);
|
|
if (!isValidKeymapKey) {
|
|
return;
|
|
}
|
|
|
|
// Don't add hold keys
|
|
if (HOLD_KEYS.includes(e.key)) {
|
|
return;
|
|
}
|
|
|
|
const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
|
const currentKeys = new Set(jotaiStore.get(currentKeysAtom));
|
|
currentKeys.add(keyToAdd);
|
|
|
|
const currentKeysWithModifiers = new Set(currentKeys);
|
|
if (e.altKey) currentKeysWithModifiers.add("Alt");
|
|
if (e.ctrlKey) currentKeysWithModifiers.add("Control");
|
|
if (e.metaKey) currentKeysWithModifiers.add("Meta");
|
|
if (e.shiftKey) currentKeysWithModifiers.add("Shift");
|
|
|
|
// Don't trigger if the user is focused within an element that explicitly disableds hotkeys
|
|
if (document.activeElement?.closest("[data-disable-hotkey]")) {
|
|
return;
|
|
}
|
|
|
|
// Don't support certain single-key combinations within inputs
|
|
if (
|
|
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
|
|
currentKeysWithModifiers.size === 1 &&
|
|
(currentKeysWithModifiers.has("Backspace") || currentKeysWithModifiers.has("Delete"))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const executed: string[] = [];
|
|
for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
|
|
const enable = typeof options.enable === "function" ? options.enable() : options.enable;
|
|
if (enable === false) {
|
|
continue;
|
|
}
|
|
|
|
if (keysMatchAction(Array.from(currentKeysWithModifiers), action)) {
|
|
if (!options.allowDefault) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
callback(e);
|
|
executed.push(`${action} ${options.priority ?? 0}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (executed.length > 0) {
|
|
console.log("Executed hotkey", executed.join(", "));
|
|
jotaiStore.set(currentKeysAtom, new Set([]));
|
|
}
|
|
clearCurrentKeysDebounced();
|
|
}
|
|
|
|
export function useHotkeyLabel(action: HotkeyAction): string {
|
|
return hotkeyLabels[action];
|
|
}
|
|
|
|
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 === "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") {
|
|
labelParts.push("+");
|
|
} else if (p === "Equal") {
|
|
labelParts.push("=");
|
|
} else if (p === "Space") {
|
|
labelParts.push("Space");
|
|
} else {
|
|
labelParts.push(capitalize(p));
|
|
}
|
|
} else {
|
|
if (p === "Control") {
|
|
labelParts.push("Ctrl");
|
|
} else if (p === "Space") {
|
|
labelParts.push("Space");
|
|
} else {
|
|
labelParts.push(capitalize(p));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (os === "macos") {
|
|
return labelParts;
|
|
}
|
|
return [labelParts.join("+")];
|
|
}
|
|
|
|
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;
|
|
const sortedA = keysA
|
|
.map((k) => k.toLowerCase())
|
|
.sort()
|
|
.join("::");
|
|
const sortedB = keysB
|
|
.map((k) => k.toLowerCase())
|
|
.sort()
|
|
.join("::");
|
|
return sortedA === sortedB;
|
|
}
|
|
|
|
/** Build the full key combination from a KeyboardEvent including modifiers */
|
|
function getKeysFromEvent(e: KeyboardEvent): string[] {
|
|
const keys: string[] = [];
|
|
if (e.altKey) keys.push("Alt");
|
|
if (e.ctrlKey) keys.push("Control");
|
|
if (e.metaKey) keys.push("Meta");
|
|
if (e.shiftKey) keys.push("Shift");
|
|
|
|
// Add the actual key (use code for layout-insensitive keys)
|
|
const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
|
keys.push(keyToAdd);
|
|
|
|
return keys;
|
|
}
|
|
|
|
/** Check if a set of pressed keys matches any hotkey for the given action */
|
|
function keysMatchAction(keys: string[], action: HotkeyAction): boolean {
|
|
const hotkeys = getHotkeys();
|
|
const hkKeys = hotkeys[action];
|
|
if (!hkKeys || hkKeys.length === 0) return false;
|
|
|
|
for (const hkKey of hkKeys) {
|
|
const hotkeyParts = hkKey.split("+");
|
|
if (compareKeys(hotkeyParts, keys)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Check if a KeyboardEvent matches a hotkey action */
|
|
export function eventMatchesHotkey(e: KeyboardEvent, action: HotkeyAction): boolean {
|
|
const keys = getKeysFromEvent(e);
|
|
return keysMatchAction(keys, action);
|
|
}
|