1import { useSyncExternalStore } 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
18function 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
30function createShortcutModifierStore(ignoreEditable: boolean) {
31 let state = EMPTY_SHORTCUT_MODIFIER_STATE;
32 const listeners = new Set<() => void>();
33 let stopTracking: (() => void) | null = null;
34
35 const updateState = (next: ShortcutModifierState) => {
36 if (areShortcutModifierStatesEqual(state, next)) return;
37 state = next;
38 for (const listener of listeners) listener();
39 };
40 const onKeyboardEvent = (event: KeyboardEvent) => {
41 updateState(
42 ignoreEditable && isEditableFocused(event.target)
43 ? EMPTY_SHORTCUT_MODIFIER_STATE
44 : shortcutModifierStateAfterKeyboardEvent(state, event),
45 );
46 };
47 // Dictation tools can omit Meta keyup after synthetic paste, leaving jump hints stuck.
48 const onResetEvent = () => {
49 updateState(EMPTY_SHORTCUT_MODIFIER_STATE);
50 };
51 const onFocus = (event: FocusEvent) => {
52 if (ignoreEditable && isEditableFocused(event.target)) onResetEvent();
53 };
54 const startTracking = () => {
55 window.addEventListener("focusin", onFocus);
56 window.addEventListener("keydown", onKeyboardEvent, true);
57 window.addEventListener("keyup", onKeyboardEvent, true);
58 window.addEventListener("paste", onResetEvent, true);
59 window.addEventListener("blur", onResetEvent);
60 return () => {
61 window.removeEventListener("focusin", onFocus);
62 window.removeEventListener("keydown", onKeyboardEvent, true);
63 window.removeEventListener("keyup", onKeyboardEvent, true);
64 window.removeEventListener("paste", onResetEvent, true);
65 window.removeEventListener("blur", onResetEvent);
66 state = EMPTY_SHORTCUT_MODIFIER_STATE;
67 };
68 };
69
70 return {
71 subscribe: (listener: () => void) => {
72 listeners.add(listener);
73 stopTracking ??= startTracking();
74 return () => {
75 listeners.delete(listener);
76 if (listeners.size > 0) return;
77 stopTracking?.();
78 stopTracking = null;
79 };
80 },
81 getSnapshot: () => state,
82 };
83}
84
85/** Shared by every subscriber; read it from a snapshot to re-render only when a derived value changes. */
86export const shortcutModifierStore = createShortcutModifierStore(false);
87const editableIgnoringShortcutModifierStore = createShortcutModifierStore(true);
88
89export function useShortcutModifierState(ignoreEditable = false): ShortcutModifierState {
90 const store = ignoreEditable ? editableIgnoringShortcutModifierStore : shortcutModifierStore;
91 return useSyncExternalStore(store.subscribe, store.getSnapshot);
92}
93
94function normalizeModifierKey(key: string): keyof ShortcutModifierState | null {
95 switch (key) {
96 case "Meta":
97 case "OS":
98 case "Command":
99 return "metaKey";
100 case "Control":
101 return "ctrlKey";
102 case "Alt":
103 case "Option":
104 return "altKey";
105 case "Shift":
106 return "shiftKey";
107 default:
108 return null;
109 }
110}
111
112export function shortcutModifierStateAfterKeyboardEvent(
113 currentState: ShortcutModifierState,
114 event: KeyboardEvent,
115): ShortcutModifierState {
116 const normalizedModifierKey = normalizeModifierKey(event.key);
117 let nextState: ShortcutModifierState;
118 if (normalizedModifierKey) {
119 nextState = {
120 ...currentState,
121 [normalizedModifierKey]: event.type === "keydown",
122 };
123 } else {
124 // Dictation paste can poison real events with metaKey=true until a physical Meta press.
125 nextState = {
126 metaKey: currentState.metaKey && event.metaKey,
127 ctrlKey: currentState.ctrlKey && event.ctrlKey,
128 altKey: currentState.altKey && event.altKey,
129 shiftKey: currentState.shiftKey && event.shiftKey,
130 };
131 }
132
133 return areShortcutModifierStatesEqual(currentState, nextState) ? currentState : nextState;
134}