mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-22 19:34:03 +02:00
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.
366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
import {
|
|
Heading,
|
|
HStack,
|
|
Icon,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
VStack,
|
|
} from "@yaakapp-internal/ui";
|
|
import classNames from "classnames";
|
|
import { fuzzyMatch } from "fuzzbunny";
|
|
import { useAtomValue } from "jotai";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
defaultHotkeys,
|
|
formatHotkeyString,
|
|
getHotkeyScope,
|
|
type HotkeyAction,
|
|
hotkeyActions,
|
|
hotkeysAtom,
|
|
useHotkeyLabel,
|
|
} from "../../hooks/useHotKey";
|
|
import { capitalize } from "../../lib/capitalize";
|
|
import { showDialog } from "../../lib/dialog";
|
|
import { Button } from "../core/Button";
|
|
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
|
import { HotkeyRaw } from "../core/Hotkey";
|
|
import { IconButton } from "../core/IconButton";
|
|
import { PlainInput } from "../core/PlainInput";
|
|
|
|
const HOLD_KEYS = ["Shift", "Control", "Alt", "Meta"];
|
|
const LAYOUT_INSENSITIVE_KEYS = [
|
|
"Equal",
|
|
"Minus",
|
|
"BracketLeft",
|
|
"BracketRight",
|
|
"Backquote",
|
|
"Space",
|
|
];
|
|
|
|
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
|
|
function eventToHotkeyString(e: KeyboardEvent): string | null {
|
|
// Don't capture modifier-only key presses
|
|
if (HOLD_KEYS.includes(e.key)) {
|
|
return null;
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
|
|
// Add modifiers in consistent order (Meta, Control, Alt, Shift)
|
|
if (e.metaKey) {
|
|
parts.push("Meta");
|
|
}
|
|
if (e.ctrlKey) {
|
|
parts.push("Control");
|
|
}
|
|
if (e.altKey) {
|
|
parts.push("Alt");
|
|
}
|
|
if (e.shiftKey) {
|
|
parts.push("Shift");
|
|
}
|
|
|
|
// Get the main key - use the same logic as useHotKey.ts
|
|
const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;
|
|
parts.push(key);
|
|
|
|
return parts.join("+");
|
|
}
|
|
|
|
export function SettingsHotkeys() {
|
|
const settings = useAtomValue(settingsAtom);
|
|
const hotkeys = useAtomValue(hotkeysAtom);
|
|
const [filter, setFilter] = useState("");
|
|
|
|
const filteredActions = useMemo(() => {
|
|
if (!filter.trim()) {
|
|
return hotkeyActions;
|
|
}
|
|
return hotkeyActions.filter((action) => {
|
|
const scope = getHotkeyScope(action).replace(/_/g, " ");
|
|
const label = action.replace(/[_.]/g, " ");
|
|
const searchText = `${scope} ${label}`;
|
|
return fuzzyMatch(searchText, filter) != null;
|
|
});
|
|
}, [filter]);
|
|
|
|
if (settings == null) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<VStack space={3} className="mb-4">
|
|
<div className="mb-3">
|
|
<Heading>Keyboard Shortcuts</Heading>
|
|
<p className="text-text-subtle">
|
|
Click the menu button to add, remove, or reset keyboard shortcuts.
|
|
</p>
|
|
</div>
|
|
<PlainInput
|
|
label="Filter"
|
|
placeholder="Filter shortcuts..."
|
|
defaultValue={filter}
|
|
onChange={setFilter}
|
|
hideLabel
|
|
containerClassName="max-w-xs"
|
|
/>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableHeaderCell>Scope</TableHeaderCell>
|
|
<TableHeaderCell>Action</TableHeaderCell>
|
|
<TableHeaderCell>Shortcut</TableHeaderCell>
|
|
<TableHeaderCell></TableHeaderCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
|
<TableBody key={filter}>
|
|
{filteredActions.map((action) => (
|
|
<HotkeyRow
|
|
key={action}
|
|
action={action}
|
|
currentKeys={hotkeys[action] ?? []}
|
|
defaultKeys={defaultHotkeys[action]}
|
|
onSave={async (keys) => {
|
|
const newHotkeys = { ...settings.hotkeys };
|
|
if (arraysEqual(keys, defaultHotkeys[action])) {
|
|
// Remove from settings if it matches default (use default)
|
|
delete newHotkeys[action];
|
|
} else {
|
|
// Store the keys (including empty array to disable)
|
|
newHotkeys[action] = keys;
|
|
}
|
|
await patchModel(settings, { hotkeys: newHotkeys });
|
|
}}
|
|
onReset={async () => {
|
|
const newHotkeys = { ...settings.hotkeys };
|
|
delete newHotkeys[action];
|
|
await patchModel(settings, { hotkeys: newHotkeys });
|
|
}}
|
|
/>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
interface HotkeyRowProps {
|
|
action: HotkeyAction;
|
|
currentKeys: string[];
|
|
defaultKeys: string[];
|
|
onSave: (keys: string[]) => Promise<void>;
|
|
onReset: () => Promise<void>;
|
|
}
|
|
|
|
function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {
|
|
const label = useHotkeyLabel(action);
|
|
const scope = capitalize(getHotkeyScope(action).replace(/_/g, " "));
|
|
const isCustomized = !arraysEqual(currentKeys, defaultKeys);
|
|
const isDisabled = currentKeys.length === 0;
|
|
|
|
const handleStartRecording = useCallback(() => {
|
|
showDialog({
|
|
id: `record-hotkey-${action}`,
|
|
title: label,
|
|
size: "sm",
|
|
render: ({ hide }) => (
|
|
<RecordHotkeyDialog
|
|
label={label}
|
|
onSave={async (key) => {
|
|
await onSave([...currentKeys, key]);
|
|
hide();
|
|
}}
|
|
onCancel={hide}
|
|
/>
|
|
),
|
|
});
|
|
}, [action, label, currentKeys, onSave]);
|
|
|
|
const handleRemove = useCallback(
|
|
async (keyToRemove: string) => {
|
|
const newKeys = currentKeys.filter((k) => k !== keyToRemove);
|
|
await onSave(newKeys);
|
|
},
|
|
[currentKeys, onSave],
|
|
);
|
|
|
|
const handleClearAll = useCallback(async () => {
|
|
await onSave([]);
|
|
}, [onSave]);
|
|
|
|
// Build dropdown items dynamically
|
|
const dropdownItems: DropdownItem[] = [
|
|
{
|
|
label: "Add Keyboard Shortcut",
|
|
leftSlot: <Icon icon="plus" />,
|
|
onSelect: handleStartRecording,
|
|
},
|
|
];
|
|
|
|
// Add remove options for each existing shortcut
|
|
if (!isDisabled) {
|
|
currentKeys.forEach((key) => {
|
|
dropdownItems.push({
|
|
label: (
|
|
<HStack space={1.5}>
|
|
<span>Remove</span>
|
|
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
|
|
</HStack>
|
|
),
|
|
leftSlot: <Icon icon="trash" />,
|
|
onSelect: () => handleRemove(key),
|
|
});
|
|
});
|
|
|
|
if (currentKeys.length > 1) {
|
|
dropdownItems.push(
|
|
{
|
|
type: "separator",
|
|
},
|
|
{
|
|
label: "Remove All Shortcuts",
|
|
leftSlot: <Icon icon="trash" />,
|
|
onSelect: handleClearAll,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isCustomized) {
|
|
dropdownItems.push({
|
|
type: "separator",
|
|
});
|
|
dropdownItems.push({
|
|
label: "Reset to Default",
|
|
leftSlot: <Icon icon="refresh" />,
|
|
onSelect: onReset,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<TableRow>
|
|
<TableCell>
|
|
<span className="text-sm text-text-subtlest">{scope}</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm">{label}</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<HStack space={1.5} className="py-1">
|
|
{isDisabled ? (
|
|
<span className="text-text-subtlest">Disabled</span>
|
|
) : (
|
|
currentKeys.map((k) => (
|
|
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
|
|
))
|
|
)}
|
|
</HStack>
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
<Dropdown items={dropdownItems}>
|
|
<IconButton
|
|
icon="ellipsis_vertical"
|
|
size="sm"
|
|
title="Hotkey actions"
|
|
className="ml-auto text-text-subtlest"
|
|
/>
|
|
</Dropdown>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
function arraysEqual(a: string[], b: string[]): boolean {
|
|
if (a.length !== b.length) return false;
|
|
const sortedA = [...a].sort();
|
|
const sortedB = [...b].sort();
|
|
return sortedA.every((v, i) => v === sortedB[i]);
|
|
}
|
|
|
|
interface RecordHotkeyDialogProps {
|
|
label: string;
|
|
onSave: (key: string) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {
|
|
const [recordedKey, setRecordedKey] = useState<string | null>(null);
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!isFocused) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (e.key === "Escape") {
|
|
onCancel();
|
|
return;
|
|
}
|
|
|
|
const hotkeyString = eventToHotkeyString(e);
|
|
if (hotkeyString) {
|
|
setRecordedKey(hotkeyString);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
|
};
|
|
}, [isFocused, onCancel]);
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (recordedKey) {
|
|
onSave(recordedKey);
|
|
}
|
|
}, [recordedKey, onSave]);
|
|
|
|
return (
|
|
<VStack space={4}>
|
|
<div>
|
|
<p className="text-text-subtle mb-2">
|
|
Record a key combination for <span className="font-semibold">{label}</span>
|
|
</p>
|
|
<button
|
|
type="button"
|
|
data-disable-hotkey
|
|
aria-label="Keyboard shortcut input"
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.currentTarget.focus();
|
|
}}
|
|
className={classNames(
|
|
"flex items-center justify-center",
|
|
"px-4 py-2 rounded-lg bg-surface-highlight border outline-hidden cursor-default w-full",
|
|
"border-border-subtle focus:border-border-focus",
|
|
)}
|
|
>
|
|
{recordedKey ? (
|
|
<HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />
|
|
) : (
|
|
<span className="text-text-subtlest">Press keys...</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<HStack space={2} justifyContent="end">
|
|
<Button color="secondary" onClick={onCancel}>
|
|
Cancel
|
|
</Button>
|
|
<Button color="primary" onClick={handleSave} disabled={!recordedKey}>
|
|
Save
|
|
</Button>
|
|
</HStack>
|
|
</VStack>
|
|
);
|
|
}
|