1import { useEffect, useRef, useState } from "react";
2import { isEditableFocused } from "./lib/editableFocus";
3
4export interface ShortcutModifierState {
5 metaKey: boolean;
6 ctrlKey: boolean;
7 altKey: boolean;
8 shiftKey: boolean;
9}
10
11const EMPTY_SHORTCUT_MODIFIER_STATE: ShortcutModifierState = {
12 metaKey: false,
13 ctrlKey: false,
14 altKey: false,
15 shiftKey: false,
16};
17
18export function areShortcutModifierStatesEqual(
19 left: ShortcutModifierState,
20 right: ShortcutModifierState,
21): boolean {
22 return (
23 left.metaKey === right.metaKey &&
24 left.ctrlKey === right.ctrlKey &&
25 left.altKey === right.altKey &&
26 left.shiftKey === right.shiftKey
27 );
28}
29
30export function useShortcutModifierState(ignoreEditable = false): ShortcutModifierState {
31 const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE);
32 const stateRef = useRef(EMPTY_SHORTCUT_MODIFIER_STATE);
33
34 useEffect(() => {
35 const updateState = (next: ShortcutModifierState) => {
36 // Even a no-op state dispatch can cost work in the sidebar's large tree.
37 // Ordinary typing must return before dispatching a React update.
38 if (areShortcutModifierStatesEqual(stateRef.current, next)) return;
39 stateRef.current = next;
40 setState(next);
41 };
42 const onKeyboardEvent = (event: KeyboardEvent) => {
43 updateState(
44 ignoreEditable && isEditableFocused(event.target)
45 ? EMPTY_SHORTCUT_MODIFIER_STATE
46 : shortcutModifierStateAfterKeyboardEvent(stateRef.current, event),
47 );
48 };
49 // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup
50 // never reaches the page, so the tracked state stays "⌘ held" forever and
51 // the thread jump hints stick on screen. A paste is never jump intent, so
52 // treat it like a blur and reset. A physically held modifier re-registers
53 // on the next real key event.
54 const onResetEvent = () => {
55 updateState(EMPTY_SHORTCUT_MODIFIER_STATE);
56 };
57
58 const onFocus = (event: FocusEvent) => {
59 if (ignoreEditable && isEditableFocused(event.target)) onResetEvent();
60 };
61 window.addEventListener("focusin", onFocus);
62 window.addEventListener("keydown", onKeyboardEvent, true);
63 window.addEventListener("keyup", onKeyboardEvent, true);
64 window.addEventListener("paste", onResetEvent, true);
65 window.addEventListener("blur", onResetEvent);
66 return () => {
67 window.removeEventListener("focusin", onFocus);
68 window.removeEventListener("keydown", onKeyboardEvent, true);
69 window.removeEventListener("keyup", onKeyboardEvent, true);
70 window.removeEventListener("paste", onResetEvent, true);
71 window.removeEventListener("blur", onResetEvent);
72 };
73 }, [ignoreEditable]);
74
75 return state;
76}
77
78function normalizeModifierKey(key: string): keyof ShortcutModifierState | null {
79 switch (key) {
80 case "Meta":
81 case "OS":
82 case "Command":
83 return "metaKey";
84 case "Control":
85 return "ctrlKey";
86 case "Alt":
87 case "Option":
88 return "altKey";
89 case "Shift":
90 return "shiftKey";
91 default:
92 return null;
93 }
94}
95
96export function shortcutModifierStateAfterKeyboardEvent(
97 currentState: ShortcutModifierState,
98 event: KeyboardEvent,
99): ShortcutModifierState {
100 const normalizedModifierKey = normalizeModifierKey(event.key);
101 let nextState: ShortcutModifierState;
102 if (normalizedModifierKey) {
103 nextState = {
104 ...currentState,
105 [normalizedModifierKey]: event.type === "keydown",
106 };
107 } else {
108 // Flags on non-modifier keys may only clear a bit, never set one. After a
109 // dictation tool's synthetic ⌘V (Wispr Flow), the browser can keep
110 // reporting metaKey=true on real key events (Enter to submit) until the
111 // user physically taps ⌘. Trusting that flag would mark ⌘ as held and
112 // stick the thread jump hints. Setting a bit requires a real modifier
113 // keydown, handled above.
114 nextState = {
115 metaKey: currentState.metaKey && event.metaKey,
116 ctrlKey: currentState.ctrlKey && event.ctrlKey,
117 altKey: currentState.altKey && event.altKey,
118 shiftKey: currentState.shiftKey && event.shiftKey,
119 };
120 }
121
122 return areShortcutModifierStatesEqual(currentState, nextState) ? currentState : nextState;
123}