1import {
2 isModifierPairShortcut,
3 type SnapShotModifier,
4 type SnapShotShortcut,
5} from "@t3tools/contracts";
6import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";
7import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
8import { getDesktopSnapShotBridge } from "../../lib/desktopSnapShot";
9import {
10 formatSnapShotShortcutLabel,
11 parseDesktopSnapShotShortcut,
12} from "../../lib/snapShotShortcut";
13import { SnapShotShortcutKeys } from "../desktop/SnapShotShortcutKeys";
14import { Button } from "../ui/button";
15import { keybindingFromKeyboardEvent } from "./KeybindingsSettings.logic";
16import { createRecordingRequestTracker } from "./SnapShotSettings.logic";
17
18const MODIFIER_FROM_KEY: Readonly<Record<string, SnapShotModifier>> = {
19 Shift: "shift",
20 Meta: "meta",
21 OS: "meta",
22 Control: "control",
23 Alt: "alt",
24 AltGraph: "alt",
25};
26const MODIFIER_CODES: Readonly<Record<SnapShotModifier, readonly [string, string]>> = {
27 shift: ["ShiftLeft", "ShiftRight"],
28 meta: ["MetaLeft", "MetaRight"],
29 control: ["ControlLeft", "ControlRight"],
30 alt: ["AltLeft", "AltRight"],
31};
32
33/** The same recorder for inline changes and config-backed setup, without saving either. */
34export function useSnapShotShortcutRecorder({
35 shortcut,
36 shortcutLabel,
37 disabled = false,
38 allowModifierPairs = true,
39 onRecord,
40 onStart,
41 onError,
42}: {
43 shortcut: SnapShotShortcut;
44 shortcutLabel?: string | undefined;
45 disabled?: boolean;
46 allowModifierPairs?: boolean;
47 onRecord: (shortcut: SnapShotShortcut) => void;
48 onStart?: () => void;
49 onError: (message: string) => void;
50}) {
51 const bridge = getDesktopSnapShotBridge();
52 const displayShortcut = shortcutLabel ? parseDesktopSnapShotShortcut(shortcutLabel) : shortcut;
53 const [recording, setRecording] = useState(false);
54 const [requests] = useState(createRecordingRequestTracker);
55 const heldModifierCodes = useRef(new Set<string>());
56 const stopRecording = useCallback(() => {
57 requests.clear();
58 heldModifierCodes.current.clear();
59 setRecording(false);
60 void bridge?.setSnapShotShortcutSuppressed(false).catch(() => undefined);
61 }, [bridge, requests]);
62 const startRecording = async () => {
63 if (!bridge || disabled) return;
64 const request = requests.tryBegin();
65 if (!request) return;
66 heldModifierCodes.current.clear();
67 onStart?.();
68 try {
69 await bridge.setSnapShotShortcutSuppressed(true);
70 if (requests.owns(request)) setRecording(true);
71 } catch (error) {
72 if (!requests.owns(request)) return;
73 requests.clear();
74 onError(error instanceof Error ? error.message : "Could not start shortcut recording.");
75 }
76 };
77 useEffect(
78 () => () => {
79 requests.clear();
80 void bridge?.setSnapShotShortcutSuppressed(false).catch(() => undefined);
81 },
82 [bridge, requests],
83 );
84 const recordShortcut = (event: KeyboardEvent<HTMLButtonElement>) => {
85 if (!recording || event.key === "Tab" || event.repeat) return;
86 event.preventDefault();
87 event.stopPropagation();
88 if (event.key === "Escape") {
89 stopRecording();
90 return;
91 }
92 const modifier = MODIFIER_FROM_KEY[event.key];
93 if (modifier) {
94 const held = heldModifierCodes.current;
95 held.add(event.code);
96 const [left, right] = MODIFIER_CODES[modifier];
97 if (held.has(left) && held.has(right)) {
98 if (!allowModifierPairs) {
99 onError("Add a letter, number, or function key to your shortcut.");
100 return;
101 }
102 stopRecording();
103 onRecord(
104 modifier === "shift" ? { kind: "both-shift-keys" } : { kind: "modifier-pair", modifier },
105 );
106 }
107 return;
108 }
109 // A global shortcut without a modifier would take that key from every app.
110 if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) return;
111 const input = keybindingFromKeyboardEvent(event, navigator.platform);
112 if (!input) return;
113 const next = parseKeybindingShortcut(input);
114 if (!next) return;
115 stopRecording();
116 onRecord(next);
117 };
118
119 return {
120 recording,
121 stopRecording,
122 input: (
123 <Button
124 type="button"
125 size="xs"
126 variant={recording ? "secondary" : "outline"}
127 disabled={disabled}
128 aria-label={
129 displayShortcut
130 ? `Record snapshot shortcut, currently ${formatSnapShotShortcutLabel(displayShortcut)}`
131 : "Change snapshot shortcut"
132 }
133 aria-pressed={recording}
134 data-keybinding-capture=""
135 onClick={() => void startRecording()}
136 onKeyDown={recordShortcut}
137 onKeyUp={(event) => heldModifierCodes.current.delete(event.code)}
138 onBlur={stopRecording}
139 >
140 {recording ? (
141 "Press shortcut…"
142 ) : !displayShortcut ? (
143 "Change shortcut"
144 ) : !allowModifierPairs && isModifierPairShortcut(displayShortcut) ? (
145 "Choose shortcut"
146 ) : (
147 <SnapShotShortcutKeys shortcut={displayShortcut} />
148 )}
149 </Button>
150 ),
151 };
152}