Open History to see the last commit
1import {
2 isModifierPairShortcut,
3 type SnapShotModifier,
4 type SnapShotShortcut,
5} from "@t3tools/contracts";
6import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";
7import { 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
33export function useSnapShotShortcutRecorder({
34 shortcut,
35 shortcutLabel,
36 disabled = false,
37 allowModifierPairs = true,
38 onRecord,
39 onStart,
40 onError,
41}: {
42 shortcut: SnapShotShortcut;
43 shortcutLabel?: string | undefined;
44 disabled?: boolean;
45 allowModifierPairs?: boolean;
46 onRecord: (shortcut: SnapShotShortcut) => void;
47 onStart?: () => void;
48 onError: (message: string) => void;
49}) {
50 const bridge = getDesktopSnapShotBridge();
51 const displayShortcut = shortcutLabel ? parseDesktopSnapShotShortcut(shortcutLabel) : shortcut;
52 const [recording, setRecording] = useState(false);
53 const [requests] = useState(createRecordingRequestTracker);
54 const heldModifierCodes = useRef(new Set<string>());
55 const stopRecording = () => {
56 requests.clear();
57 heldModifierCodes.current.clear();
58 setRecording(false);
59 void bridge?.setSnapShotShortcutSuppressed(false).catch(() => undefined);
60 };
61 const startRecording = async () => {
62 if (!bridge || disabled) return;
63 const request = requests.tryBegin();
64 if (!request) return;
65 heldModifierCodes.current.clear();
66 onStart?.();
67 try {
68 await bridge.setSnapShotShortcutSuppressed(true);
69 if (requests.owns(request)) setRecording(true);
70 } catch (error) {
71 if (!requests.owns(request)) return;
72 requests.clear();
73 onError(error instanceof Error ? error.message : "Could not start shortcut recording.");
74 }
75 };
76 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the desktop bridge's SnapShot shortcut suppression on unmount
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}