* initlal commit * Ghostwriter always enabled * rename code * ghostwriter panel * separate component * ui improvements * single thread * copy improvement * dont pop up keyboard shortcuts * markdown renderer * ghostwriter button placement * better UX * ghostwriter copy * meta shortcut * better settings menu * formatting * doocumentation * add tests * race condition * race condition 2 * pass title * more comments * comments * formtting
69 lines
2.3 KiB
TypeScript
69 lines
2.3 KiB
TypeScript
import { useEffect, useMemo, useRef } from "react";
|
|
import { tinykeys } from "tinykeys";
|
|
|
|
type KeyBindingMap = Record<string, (event: KeyboardEvent) => void>;
|
|
|
|
/**
|
|
* Elements that should swallow keyboard shortcuts so single-key bindings
|
|
* don't fire while the user is typing in a form control.
|
|
*/
|
|
const INPUT_TAG_NAMES = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
|
const NON_SHIFT_MODIFIER_PATTERN = /(?:^|\+)(\$mod|Control|Meta|Alt)(?:\+|$)/;
|
|
|
|
function isEditableTarget(event: KeyboardEvent): boolean {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
if (INPUT_TAG_NAMES.has(target.tagName)) return true;
|
|
if (target.isContentEditable) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Thin React wrapper around `tinykeys`.
|
|
*
|
|
* - Automatically unsubscribes on unmount.
|
|
* - Guards against firing inside inputs/textareas/contenteditable elements
|
|
* (so shortcuts don't conflict with the command bar, tailoring editor, etc.).
|
|
* - Uses a stable ref for handler updates without rebinding.
|
|
* - Rebuilds bindings when the key set changes.
|
|
*
|
|
* Non-shift modifier shortcuts (e.g. "$mod+K") bypass the input guard because
|
|
* the user explicitly held a non-text modifier. Shift-only shortcuts (e.g.
|
|
* "Shift+?") stay blocked in inputs so typing punctuation doesn't trigger app
|
|
* hotkeys.
|
|
*/
|
|
export function useHotkeys(
|
|
bindings: KeyBindingMap,
|
|
options: { enabled?: boolean } = {},
|
|
) {
|
|
const { enabled = true } = options;
|
|
const bindingsRef = useRef(bindings);
|
|
bindingsRef.current = bindings;
|
|
const bindingSignature = useMemo(
|
|
() => Object.keys(bindings).sort().join("|"),
|
|
[bindings],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
// Build a guarded version of every binding.
|
|
const guarded: KeyBindingMap = {};
|
|
const bindingKeys = bindingSignature ? bindingSignature.split("|") : [];
|
|
for (const key of bindingKeys) {
|
|
const hasNonShiftModifier = key
|
|
.split(" ")
|
|
.some((sequence) => NON_SHIFT_MODIFIER_PATTERN.test(sequence));
|
|
|
|
guarded[key] = (event: KeyboardEvent) => {
|
|
// Skip single-key shortcuts when the user is typing in an input.
|
|
if (!hasNonShiftModifier && isEditableTarget(event)) return;
|
|
bindingsRef.current[key]?.(event);
|
|
};
|
|
}
|
|
|
|
const unsubscribe = tinykeys(window, guarded);
|
|
return unsubscribe;
|
|
}, [enabled, bindingSignature]);
|
|
}
|