Files
yaak-mountain-loop/apps/yaak-client/hooks/useHotKey.ts
T
Gregory Schier 33f32cccf6 Leave browser zoom to the browser (#578)
The web build bound Cmd/Ctrl +, - and 0 and then did nothing with them, so zoom appeared broken. Those keys are the browser's own page zoom, which scales the whole page and remembers it per site.

Adds an interfaceZoom capability: true on desktop, where the host zooms the webview, false in a browser. When false the app binds nothing and the hotkeys screen drops the three rows it can't configure.
2026-08-17 11:29:20 -07:00

443 lines
14 KiB
TypeScript

import { platform } from "@yaakapp-internal/platform";
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"
| "cookies_editor.show"
| "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"],
"cookies_editor.show": ["Meta+Shift+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"],
"cookies_editor.show": ["Control+Shift+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[]> =
platform.osType() === "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: Partial<Record<HotkeyAction, string[]>> = {};
for (const action of hotkeyActions) {
merged[action] = defaultHotkeys[action];
}
for (const [action, keys] of Object.entries(customHotkeys)) {
if (action in merged && Array.isArray(keys)) {
merged[action as HotkeyAction] = keys;
}
}
return merged;
});
/** Helper function to get current hotkeys from the store */
function getHotkeys(): Partial<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",
"cookies_editor.show": "Show Cookies",
"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",
];
/** Zoom is the browser's own on these keys, so the app has no such action there. */
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
/**
* The actions this host actually has. An action left out of here has no keys in
* `hotkeysAtom`, so it never matches and never claims the keystroke.
*/
export const hotkeyActions: HotkeyAction[] = (
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
)
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
.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 = platform.osType();
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") {
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
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);
}