diff --git a/src/src/components/Atoms/Internationalization.tsx b/src/src/components/Atoms/Internationalization.tsx index 2466193..2740fe0 100644 --- a/src/src/components/Atoms/Internationalization.tsx +++ b/src/src/components/Atoms/Internationalization.tsx @@ -122,6 +122,13 @@ const numberFormatter = new Intl.NumberFormat(LANGUAGE); export const formatNumber = (number: number): string => numberFormatter.format(number); +const disjunctionFormatter = new Intl.ListFormat(LANGUAGE, { + style: 'long', + type: 'disjunction', +}); +export const formatDisjunction = (list: RA): string => + disjunctionFormatter.format(list); + /* eslint-disable @typescript-eslint/no-magic-numbers */ export const MILLISECONDS = 1; export const SECOND = 1000 * MILLISECONDS; diff --git a/src/src/components/Atoms/index.tsx b/src/src/components/Atoms/index.tsx index bffed35..4cfdb28 100644 --- a/src/src/components/Atoms/index.tsx +++ b/src/src/components/Atoms/index.tsx @@ -234,3 +234,9 @@ export const Widget = wrap( 'section', 'flex flex-col gap-2 rounded bg-white dark:bg-neutral-800', ); + +export const Key = wrap( + 'Key', + 'kbd', + 'bg-gray-200 border-1 dark:border-none dark:bg-neutral-700 rounded-sm mx-1 p-0.5 text-xl', +); diff --git a/src/src/components/Contexts/Contexts.tsx b/src/src/components/Contexts/Contexts.tsx index 5899442..75bb9aa 100644 --- a/src/src/components/Contexts/Contexts.tsx +++ b/src/src/components/Contexts/Contexts.tsx @@ -8,7 +8,6 @@ import { PreferencesProvider } from '../Preferences/Context'; import { AuthenticationProvider } from './AuthContext'; import { CalendarsSpy } from './CalendarsContext'; import { TrackCurrentView } from './CurrentViewContext'; -import { KeyboardListener } from './KeyboardContext'; import { SettingsProvider } from './SettingsContext'; import { VersionsContextProvider } from './VersionsContext'; import { output } from '../Errors/exceptions'; @@ -60,9 +59,7 @@ export function Contexts({ - - {children} - + {children} diff --git a/src/src/components/Contexts/KeyboardContext.tsx b/src/src/components/Contexts/KeyboardContext.tsx deleted file mode 100644 index c1a9721..0000000 --- a/src/src/components/Contexts/KeyboardContext.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React from 'react'; - -import { listen } from '../../utils/events'; -import type { RA } from '../../utils/types'; -import { sortFunction } from '../../utils/utils'; -import type { - KeyboardShortcut, - KeyboardShortcuts, -} from '../Molecules/KeyboardShortcut'; -import { - modifierKeyNames, - platform, - resolveModifiers, -} from '../Molecules/KeyboardShortcut'; -import { output } from '../Errors/exceptions'; - -/** - * Allows to register a key listener - */ -export const KeyboardContext = React.createContext< - (shortcut: KeyboardShortcuts, callback: () => void) => () => void ->(() => output.throw('KeyboardListener is not initialized')); -KeyboardContext.displayName = 'KeyboardContext'; - -export function KeyboardListener({ - children, -}: { - readonly children: JSX.Element; -}): JSX.Element { - const listenersRef = React.useRef< - RA<{ - readonly shortcuts: RA; - readonly callback: () => void; - }> - >([]); - - const handleKeyboardShortcut = React.useCallback( - (shortcut: KeyboardShortcuts, callback: () => void) => { - const shortcuts: RA = shortcut[platform] ?? []; - if (shortcuts.length === 0) return () => undefined; - const entry = { shortcuts, callback }; - listenersRef.current = [...listenersRef.current, entry]; - return () => { - listenersRef.current = listenersRef.current.filter( - (listener) => listener !== entry, - ); - }; - }, - [], - ); - - const pressedKeys = React.useRef([]); - - React.useEffect( - () => - listen(document, 'keydown', (event) => { - if (event.key === undefined) return; - const key = - event.key.length === 1 ? event.key.toUpperCase() : event.key; - if (modifierKeyNames.has(event.key)) return; - pressedKeys.current = Array.from( - new Set([...pressedKeys.current, key]), - ).sort(sortFunction((key) => key)); - checkListeners(resolveModifiers(event)); - }), - [], - ); - - React.useEffect( - () => - listen(document, 'keyup', (event) => { - if (event.key === undefined) return; - const pressedKey = - event.key.length === 1 ? event.key.toUpperCase() : event.key; - pressedKeys.current = pressedKeys.current - .filter((key) => key !== pressedKey) - .sort(sortFunction((key) => key)); - checkListeners(resolveModifiers(event)); - }), - [], - ); - - const checkListeners = React.useCallback( - (modifiers: KeyboardShortcut['modifiers']) => { - let isEntering: boolean | undefined = undefined; - - function isInputting(): boolean { - isEntering ??= isInInput(); - return isEntering; - } - - listenersRef.current - .filter((listener) => - listener.shortcuts.some((shortcut) => { - if ( - shortcut.modifiers.join(',') !== modifiers.join(',') || - shortcut.keys.join(',') !== pressedKeys.current.join(',') - ) - return false; - // Ignore single key shortcuts when in an input field - return !( - shortcut.modifiers.filter((modifier) => modifier !== 'shift') - .length === 0 && isInputting() - ); - }), - ) - .forEach((listener) => listener.callback()); - }, - [], - ); - - return ( - - {children} - - ); -} - -const isInInput = (): boolean => - document.activeElement?.tagName === 'INPUT' || - document.activeElement?.tagName === 'TEXTAREA' || - document.activeElement?.getAttribute('role') === 'textbox'; diff --git a/src/src/components/Core/App.tsx b/src/src/components/Core/App.tsx index c2bba65..aaad6a1 100644 --- a/src/src/components/Core/App.tsx +++ b/src/src/components/Core/App.tsx @@ -6,7 +6,6 @@ import { Button } from '../Atoms'; import { AuthContext } from '../Contexts/AuthContext'; import { CalendarsContext } from '../Contexts/CalendarsContext'; import { CurrentViewContext } from '../Contexts/CurrentViewContext'; -import { KeyboardContext } from '../Contexts/KeyboardContext'; import { Dashboard } from '../Dashboard'; import { useEvents } from '../EventsStore'; import { OverlayPortal } from '../Molecules/Portal'; @@ -16,12 +15,12 @@ import { CondenseInterface } from '../PowerTools/CondenseInterface'; import { GhostEvents } from '../PowerTools/GhostEvents'; import { HideEditAll } from '../PowerTools/HideEditAll'; import { PreferencesPage } from '../Preferences'; -import { usePref } from '../Preferences/usePref'; import { FirstAuthScreen } from './FirstAuthScreen'; import { useStorage } from '../../hooks/useStorage'; import { DevModeConsoleOverlay } from '../DebugOverlay/DevModeConsoleOverlay'; import { domReadingEligibleViews } from '../DomReading'; import { ThemeDetector } from '../Contexts/ThemeColor'; +import { useKeyboardShortcut } from '../KeyboardShortcuts/hooks'; /** * Entrypoint react component for the extension @@ -32,15 +31,15 @@ export function App(): JSX.Element | null { ); const isOpen = state !== 'closed'; - const [openOverlayShortcut] = usePref('feature', 'openOverlayShortcut'); - const [closeOverlayShortcut] = usePref('feature', 'closeOverlayShortcut'); - const handleKeyboardShortcut = React.useContext(KeyboardContext); - React.useEffect( - () => - isOpen - ? handleKeyboardShortcut(closeOverlayShortcut, () => setState('closed')) - : handleKeyboardShortcut(openOverlayShortcut, () => setState('main')), - [isOpen, handleKeyboardShortcut, closeOverlayShortcut, openOverlayShortcut], + useKeyboardShortcut( + 'feature', + 'openOverlayShortcut', + isOpen ? undefined : () => setState('main'), + ); + useKeyboardShortcut( + 'feature', + 'closeOverlayShortcut', + isOpen ? () => setState('closed') : undefined, ); const [domReadingEnabled, setDomReadingEnabled] = React.useState(true); diff --git a/src/src/components/Core/__tests__/App.test.tsx b/src/src/components/Core/__tests__/App.test.tsx index a96e294..b0dbe24 100644 --- a/src/src/components/Core/__tests__/App.test.tsx +++ b/src/src/components/Core/__tests__/App.test.tsx @@ -6,7 +6,7 @@ import { CurrentViewContext } from '../../Contexts/CurrentViewContext'; import { testTime } from '../../../tests/helpers'; import { act } from '@testing-library/react'; import { VersionsContextProvider } from '../../Contexts/VersionsContext'; -import { KeyboardListener } from '../../Contexts/KeyboardContext'; +import { KeyboardListener } from '../../KeyboardShortcuts/context'; test('does not render until current date is extracted', () => act(() => { diff --git a/src/src/components/DebugOverlay/DevModeConsoleOverlay.tsx b/src/src/components/DebugOverlay/DevModeConsoleOverlay.tsx index a780bbc..5aa9b03 100644 --- a/src/src/components/DebugOverlay/DevModeConsoleOverlay.tsx +++ b/src/src/components/DebugOverlay/DevModeConsoleOverlay.tsx @@ -28,7 +28,7 @@ export function DevModeConsoleOverlay() { type === 'log' ? 'bg-white dark:bg-black' : type === 'warn' - ? 'bg-orange-300 dark:bg-orange-800' + ? 'bg-orange-300 dark:bg-orange-600' : 'bg-red-400 dark:bg-red-700' } > diff --git a/src/src/components/EventsStore/index.ts b/src/src/components/EventsStore/index.ts index a0863d6..c539f2e 100644 --- a/src/src/components/EventsStore/index.ts +++ b/src/src/components/EventsStore/index.ts @@ -6,6 +6,7 @@ import { formatUrl } from '../../utils/queryString'; import type { IR, R, RA, WritableArray } from '../../utils/types'; import { findLastIndex, group, sortFunction } from '../../utils/utils'; import { + formatDisjunction, HOUR, MILLISECONDS_IN_DAY, MINUTE, @@ -293,7 +294,7 @@ function readDom({ ([calendarId]) => !knownIds.has(calendarId), ); if (unknownCalendarId) { - return `Incorrectly retrieved event calendar id as "${unknownCalendarId[0]}" (calendar by such ID does not exist). Known calendar IDs: ${Array.from(knownIds).join(', ')}`; + return `Incorrectly retrieved event calendar id as "${unknownCalendarId[0]}" (calendar by such ID does not exist). Known calendar IDs: ${formatDisjunction(Array.from(knownIds))}`; } return allDurations; diff --git a/src/src/components/KeyboardShortcuts/Shortcuts.tsx b/src/src/components/KeyboardShortcuts/Shortcuts.tsx new file mode 100644 index 0000000..2ae2551 --- /dev/null +++ b/src/src/components/KeyboardShortcuts/Shortcuts.tsx @@ -0,0 +1,168 @@ +/** + * Logic for setting and listening to keyboard shortcuts + */ + +import React from 'react'; + +import { useTriggerState } from '../../hooks/useTriggerState'; +import { commonText } from '../../localization/common'; +import { preferencesText } from '../../localization/preferences'; +import type { RA } from '../../utils/types'; +import { removeItem, replaceItem, replaceKey } from '../../utils/utils'; +import { Button, Key } from '../Atoms'; +import type { PreferenceRenderer } from '../Preferences/definitions'; +import { keyboardPlatform, type KeyboardShortcuts } from './config'; +import { keyJoinSymbol, setKeyboardEventInterceptor } from './context'; +import { + resolvePlatformShortcuts, + localizeKeyboardShortcut, + localizedKeyJoinSymbol, +} from './utils'; +import { useLegacyKeyboardShortcutHandler } from './hooks'; + +export const SetKeyboardShortcuts: PreferenceRenderer = ({ + value: rawValue, + onChange: handleChange, + definition: { defaultValue }, +}) => { + const value = useLegacyKeyboardShortcutHandler( + rawValue, + handleChange, + defaultValue, + ); + + const [editingIndex, setEditingIndex] = React.useState(false); + const isEditing = typeof editingIndex === 'number'; + const shortcuts = resolvePlatformShortcuts(value) ?? []; + const setShortcuts = (shortcuts: RA): void => + handleChange(replaceKey(value, keyboardPlatform, shortcuts)); + + // Do not allow saving an empty shortcut + const hasEmptyShortcut = !isEditing && shortcuts.includes(''); + React.useEffect(() => { + if (hasEmptyShortcut) + setShortcuts(shortcuts.filter((shortcut) => shortcut !== '')); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasEmptyShortcut]); + + return ( +
+ {shortcuts.map((shortcut, index) => ( + setEditingIndex(index) + : undefined + } + onSave={ + editingIndex === index + ? (shortcut): void => { + setShortcuts( + shortcut === undefined + ? removeItem(shortcuts, index) + : replaceItem(shortcuts, index, shortcut), + ); + setEditingIndex(false); + } + : undefined + } + /> + ))} +
+ {!isEditing && ( + { + setShortcuts([...shortcuts, '']); + setEditingIndex(shortcuts.length); + }} + > + {commonText('add')} + + )} +
+
+ ); +}; + +function EditKeyboardShortcut({ + shortcut, + onSave: handleSave, + onEditStart: handleEditStart, +}: { + readonly shortcut: string; + readonly onSave: ((shortcut: string | undefined) => void) | undefined; + readonly onEditStart: (() => void) | undefined; +}): JSX.Element { + const [localState, setLocalState] = useTriggerState(shortcut); + const parts = localState.length === 0 ? [] : localState.split(keyJoinSymbol); + const isEditing = typeof handleSave === 'function'; + + React.useEffect(() => { + if (isEditing) { + // Allows user to press Enter to finish setting keyboard shortcut. + saveButtonRef.current?.focus(); + setLocalState(''); + return setKeyboardEventInterceptor(setLocalState); + } + return undefined; + }, [isEditing, setLocalState]); + + const isEmpty = parts.length === 0; + const activeValue = React.useRef(localState); + activeValue.current = isEmpty ? shortcut : localState; + + const saveButtonRef = React.useRef(null); + + const localizedParts = React.useMemo( + () => localizeKeyboardShortcut(localState).split(localizedKeyJoinSymbol), + [localState], + ); + + return ( +
+
+ {isEmpty ? ( + isEditing ? ( + preferencesText('pressKeys') + ) : ( + preferencesText('noKeyAssigned') + ) + ) : ( + + {localizedParts.map((key, index) => ( + + {key} + + ))} + + )} +
+ {isEditing && ( + handleSave(undefined)}> + {commonText('remove')} + + )} + + handleSave( + activeValue.current.length === 0 + ? shortcut + : activeValue.current, + ) + : handleEditStart + } + > + {isEditing ? commonText('save') : commonText('edit')} + +
+ ); +} diff --git a/src/src/components/KeyboardShortcuts/config.ts b/src/src/components/KeyboardShortcuts/config.ts new file mode 100644 index 0000000..278c7bc --- /dev/null +++ b/src/src/components/KeyboardShortcuts/config.ts @@ -0,0 +1,196 @@ +import { preferencesText } from '../../localization/preferences'; +import type { IR, RA, RR } from '../../utils/types'; + +/** + * Because operating systems, browsers and browser extensions define many + * keyboard shortcuts, many of which differ between operating systems, the set + * of free keyboard shortcuts is quite small so it's difficult to have one + * shortcut that works on all 3 platforms. + * + * To provide flexibility, without complicating the UI for people who only use + * Specify on a single platform, we do the following: + * - UI allows you to set keyboard shortcuts for the current platform only + * - If you set keyboard shortcut on any platform, that shortcut is used on all + * platforms, unless you explicitly edited the shortcut on the other platform + * - If keyboard shortcut was not explicitly set, the default shortcut, if any + * will be used + */ +export type KeyboardShortcuts = Partial< + RR | undefined> +>; + +export type LegacyKeyboardShortcuts = Partial< + RR> +>; +export type LegacyKeyboardShortcut = { + readonly modifiers: RA<'alt' | 'ctrl' | 'meta' | 'shift'>; + readonly keys: RA; +}; + +type KeyboardPlatform = 'mac' | 'other' | 'windows'; +export const keyboardPlatform: KeyboardPlatform = + process.env.NODE_ENV === 'test' + ? 'other' + : navigator.platform.toLowerCase().includes('mac') || + // Check for iphone || ipad || ipod + navigator.platform.toLowerCase().includes('ip') + ? 'mac' + : navigator.platform.toLowerCase().includes('win') + ? 'windows' + : 'other'; + +export const modifierKeys = ['Alt', 'Ctrl', 'Meta', 'Shift'] as const; +export type ModifierKey = (typeof modifierKeys)[number]; +export const allModifierKeys = new Set([ + ...modifierKeys, + 'AltGraph', + 'CapsLock', + 'MetaLeft', + 'MetaRight', + 'ShiftLeft', + 'ShiftRight', + 'ControlLeft', + 'ControlRight', + 'AltLeft', + 'AltRight', +]); + +export const keyboardModifierLocalization: RR = { + Alt: + keyboardPlatform === 'mac' + ? preferencesText('macOption') + : preferencesText('alt'), + Ctrl: + keyboardPlatform === 'mac' + ? preferencesText('macControl') + : preferencesText('ctrl'), + // This key should never appear in non-mac platforms + Meta: preferencesText('macMeta'), + Shift: + keyboardPlatform === 'mac' + ? preferencesText('macShift') + : preferencesText('shift'), +}; + +/** + * Do not allow binding a keyboard shortcut that includes only one of these + * keys, without any modifier. + * + * For example, do not allow binding keyboard shortcuts to Tab key. That key is + * important for accessibility and for keyboard navigation. Without it + * you won't be able to tab your way to the "Save" button to save the + * keyboard shortcut) + */ +export const specialKeyboardKeys = new Set([ + 'Enter', + 'Tab', + 'Space', + 'Escape', + 'Backspace', +]); + +/** + * Because we are listening to key codes that correspond to US English letters, + * we should show keys in the UI in US English to avoid confusion. + * (otherwise Cmd+O is ambiguous as it's not clear if it refers to English O or + * local language О). + * See https://github.com/specify/specify7/issues/1746#issuecomment-2227113839 + * + * For some keys, it is less confusing to see a symbol (like arrow keys), rather + * than 'ArrowUp', thus symbols are used for those keys. + * See http://xahlee.info/comp/unicode_computing_symbols.html + * + * Try not to define keyboard shortcuts for keys that may be in a different + * place in other keyboard layouts (the positions of special symbols wary a lot) + * + * See full list of key codes: + * https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values + */ +export const keyLocalizations: IR = { + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', + ArrowUp: '↑', + Backquote: '`', + Backslash: '\\', + Backspace: '⌫', + BracketLeft: '[', + BracketRight: ']', + Comma: ',', + Digit0: '0', + Digit1: '1', + Digit2: '2', + Digit3: '3', + Digit4: '4', + Digit5: '5', + Digit6: '6', + Digit7: '7', + Digit8: '8', + Digit9: '9', + // "return" key is used over Enter on macOS keyboards + Enter: keyboardPlatform === 'mac' ? '↵' : 'Enter', + Equal: '=', + KeyA: 'a', + KeyB: 'b', + KeyC: 'c', + KeyD: 'd', + KeyE: 'e', + KeyF: 'f', + KeyG: 'g', + KeyH: 'h', + KeyI: 'i', + KeyJ: 'j', + KeyK: 'k', + KeyL: 'l', + KeyM: 'm', + KeyN: 'n', + KeyO: 'o', + KeyP: 'p', + KeyQ: 'q', + KeyR: 'r', + KeyS: 's', + KeyT: 't', + KeyU: 'u', + KeyV: 'v', + KeyW: 'w', + KeyX: 'x', + KeyY: 'y', + KeyZ: 'z', + Minus: '-', + Period: '.', + Quote: "'", + Semicolon: ';', + Slash: '/', +}; + +/** + * Like keyLocalizations, but applied if keyboard shortcut involves Shift key + */ +export const shiftKeyLocalizations: Record = { + KeyA: 'A', + KeyB: 'B', + KeyC: 'C', + KeyD: 'D', + KeyE: 'E', + KeyF: 'F', + KeyG: 'G', + KeyH: 'H', + KeyI: 'I', + KeyJ: 'J', + KeyK: 'K', + KeyL: 'L', + KeyM: 'M', + KeyN: 'N', + KeyO: 'O', + KeyP: 'P', + KeyQ: 'Q', + KeyR: 'R', + KeyS: 'S', + KeyT: 'T', + KeyU: 'U', + KeyV: 'V', + KeyW: 'W', + KeyX: 'X', + KeyY: 'Y', + KeyZ: 'Z', +}; diff --git a/src/src/components/KeyboardShortcuts/context.tsx b/src/src/components/KeyboardShortcuts/context.tsx new file mode 100644 index 0000000..15fc041 --- /dev/null +++ b/src/src/components/KeyboardShortcuts/context.tsx @@ -0,0 +1,198 @@ +/** + * Allows to register a key listener + */ + +import type { RA, WritableArray } from '../../utils/types'; +import { + allModifierKeys, + specialKeyboardKeys, + type KeyboardShortcuts, + type ModifierKey, +} from './config'; +import { resolvePlatformShortcuts } from './utils'; + +/** + * To keep the event listener as fast as possible, we are not looping though all + * set keyboard shortcuts and checking if any matches the set value - instead, + * the registered shortcuts are stored in this hashmap, making it very easy + * to check if a listener for current key combination exists. + * + * At the same time, some of our UI can be nested (imagine the record selector + * in a record set listening for next/previous record keyboard shortcut, and + * then the user opens a modal that also listens for the same shortcut) - to + * have things work correctly, we store listeners as a stack, with the most + * recently added listener (last one) being the active one. + */ +const listeners = new Map void>>(); + +/** + * When setting a keyboard shortcut in user preferences, we want to: + * - Prevent any other shortcut from reacting + * - Read what keys were pressed + */ +let interceptor: ((keys: string) => void) | undefined; +export function setKeyboardEventInterceptor( + callback: typeof interceptor, +): () => void { + interceptor = callback; + return (): void => { + if (interceptor === callback) interceptor = undefined; + }; +} + +export function bindKeyboardShortcut( + shortcut: KeyboardShortcuts, + callback: () => void, +): () => void { + const shortcuts = resolvePlatformShortcuts(shortcut) ?? []; + shortcuts.forEach((string) => { + const shortcutListeners = listeners.get(string); + if (shortcutListeners === undefined) listeners.set(string, [callback]); + else shortcutListeners.push(callback); + }); + return () => + shortcuts.forEach((string) => { + const activeListeners = listeners.get(string)!; + const lastIndex = activeListeners.lastIndexOf(callback); + if (lastIndex !== -1) activeListeners.splice(lastIndex, 1); + if (activeListeners.length === 0) listeners.delete(string); + }); +} + +/** + * Assumes keys and modifiers are sorted + */ +const keysToString = (modifiers: RA, keys: RA): string => + [...modifiers, ...keys].join(keyJoinSymbol); +export const keyJoinSymbol = '+'; + +// eslint-disable-next-line functional/prefer-readonly-type +const pressedKeys: string[] = []; + +// Keep this code fast as it's in the hot path +document.addEventListener('keydown', (event) => { + if (shouldIgnoreKeyPress(event)) return; + + const modifiers = resolveModifiers(event); + const isEntering = isInInput(event); + const isPrintable = isPrintableModifier(modifiers); + // Ignore shortcuts that result in printed characters when in an input field + const ignore = isPrintable && isEntering; + if (ignore) return; + if (modifiers.length === 0 && specialKeyboardKeys.has(event.code)) return; + + if (!pressedKeys.includes(event.code)) { + pressedKeys.push(event.code); + pressedKeys.sort(); + } + + const keyString = keysToString(modifiers, pressedKeys); + const handler = interceptor ?? listeners.get(keyString)?.at(-1); + if (typeof handler === 'function') { + handler(keyString); + /* + * Do this only after calling the handler, so that if handler throws an + * exception, the event can still be handled normally by the browser + */ + event.preventDefault(); + event.stopPropagation(); + } + + /** + * For key combinations involving arrows, the keyup is not fired reliably + * on macOS (i.e for Cmd+Shift+ArrowUp), thus we need to clear the pressed + * keys. This means you can't have a shortcut like Cmd+Shift+KeyQ+ArrowUp + */ + if (keyString.includes('Arrow')) pressedKeys.length = 0; +}); + +function shouldIgnoreKeyPress(event: KeyboardEvent): boolean { + const code = event.code; + + if (event.isComposing || event.repeat) return true; + + // See https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values + if ( + code === 'Dead' || + code === 'Unidentified' || + code === 'Unidentified' || + code === '' + ) + return true; + + // Do not allow binding a key shortcut directly to a modifier key + return allModifierKeys.has(event.code); +} + +export const resolveModifiers = (event: KeyboardEvent): RA => + Object.entries({ + // This order is important - keep it alphabetical + Alt: event.altKey, + Ctrl: event.ctrlKey, + Meta: event.metaKey, + Shift: event.shiftKey, + }) + .filter(([_, isPressed]) => isPressed) + .map(([modifier]) => modifier); + +function isInInput(event: KeyboardEvent): boolean { + // Check if the event target is an editable element. + const target = event.target as HTMLElement; + return ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ); +} + +/** + * On all platforms, shift key + letter produces a printable character (i.e shift+a = A) + * + * On mac, option (alt) key is used for producing printable characters too, but + * according to ChatGPT, in browser applications it is expected that keyboard + * shortcuts take precedence over printing characters. + */ +function isPrintableModifier(modifiers: RA): boolean { + if (modifiers.length === 0) return true; + + if (modifiers.length === 1) return modifiers[0] === 'Shift'; + + return false; +} + +document.addEventListener( + 'keyup', + (event) => { + const index = pressedKeys.indexOf(event.code); + if (index !== -1) pressedKeys.splice(index, 1); + + /* + * If un-pressed any modifier, consider current shortcut finished. + * + * This is a workaround for an issue on macOS where in a shortcut like + * Cmd+Shift+ArrowUp, the keyup even is fired for Cmd and Shift, but not + * for ArrowUp + */ + const isModifier = allModifierKeys.has(event.code); + if (isModifier) pressedKeys.length = 0; + }, + { passive: true }, +); + +/** + * While key up should normally catch key release, that may not always be the + * case: + * - If key up occurred outside the browser window + * - If key up occurred inside of browser devtools + * - If key up was intercepted by something else (i.e browser extension) + */ +window.addEventListener( + 'blur', + () => { + pressedKeys.length = 0; + }, + { passive: true }, +); +document.addEventListener('visibilitychange', () => { + if (document.hidden) pressedKeys.length = 0; +}); diff --git a/src/src/components/KeyboardShortcuts/hooks.tsx b/src/src/components/KeyboardShortcuts/hooks.tsx new file mode 100644 index 0000000..3f80107 --- /dev/null +++ b/src/src/components/KeyboardShortcuts/hooks.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import type { KeyboardShortcuts, LegacyKeyboardShortcuts } from './config'; +import { bindKeyboardShortcut } from './context'; +import { resolvePlatformShortcuts, localizeKeyboardShortcut } from './utils'; +import type { + preferenceDefinitions, + Preferences, +} from '../Preferences/definitions'; +import { usePref } from '../Preferences/usePref'; +import { formatDisjunction } from '../Atoms/Internationalization'; +import { output } from '../Errors/exceptions'; + +/** + * React Hook for reacting to keyboard shortcuts user pressed for a given + * action. + * + * The hook also returns a localized string representing the keyboard + * shortcut - this string can be displayed in UI tooltips. + */ +export function useKeyboardShortcut< + CATEGORY extends keyof Preferences, + ITEM extends CATEGORY extends keyof typeof preferenceDefinitions + ? string & keyof Preferences[CATEGORY]['items'] + : never, +>(category: CATEGORY, item: ITEM, callback: (() => void) | undefined): string { + const [currentShortcuts, setShortcuts] = usePref(category, item); + + const resolvedShortcut = useLegacyKeyboardShortcutHandler( + currentShortcuts, + setShortcuts, + undefined, + ); + + const hasCallback = typeof callback === 'function'; + + // eslint-disable-next-line react-hooks/rules-of-hooks + useManualKeyboardShortcut(resolvedShortcut, callback); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const localizedShortcut = useKeyboardShortcutLabel(resolvedShortcut); + + return hasCallback ? localizedShortcut : ''; +} + +function useManualKeyboardShortcut( + shortcuts: KeyboardShortcuts | undefined, + callback: (() => void) | undefined, +): void { + const callbackRef = React.useRef(callback); + callbackRef.current = callback; + const hasCallback = typeof callback === 'function'; + React.useEffect( + () => + typeof shortcuts === 'object' && hasCallback + ? bindKeyboardShortcut(shortcuts, () => callbackRef.current?.()) + : undefined, + [hasCallback, shortcuts], + ); +} + +/** + * Provides a localized keyboard shortcut string, which can be used in the UI + * in the "title" attribute. + */ +function useKeyboardShortcutLabel( + shortcuts: KeyboardShortcuts | undefined, +): string { + return React.useMemo(() => { + const platformShortcuts = + shortcuts === undefined ? [] : resolvePlatformShortcuts(shortcuts) ?? []; + return platformShortcuts.length > 0 + ? ` (${formatDisjunction( + platformShortcuts.map(localizeKeyboardShortcut), + )})` + : ''; + }, [shortcuts]); +} + +export function useLegacyKeyboardShortcutHandler( + currentShortcuts: T, + setShortcuts: (value: T) => void, + defaultValue: T, +): T { + // Migrate from the legacy keyboard shortcut format + const ambiguousShortcuts = currentShortcuts as + | KeyboardShortcuts + | undefined + | LegacyKeyboardShortcuts; + const isLegacyShortcut = React.useMemo( + () => + Object.values(ambiguousShortcuts ?? {}).some((entry) => { + const typedEntries = entry as + | LegacyKeyboardShortcuts[keyof LegacyKeyboardShortcuts] + | KeyboardShortcuts[keyof KeyboardShortcuts]; + return typedEntries?.some((shortcut) => typeof shortcut !== 'string'); + }), + [ambiguousShortcuts], + ); + const resolvedShortcut = isLegacyShortcut ? defaultValue : currentShortcuts; + React.useEffect(() => { + if (!isLegacyShortcut) return; + /** + * Main change is that before we were using event.key. Now we use + * event.code. I could do a best effort mapping between the two, but I don't + * want to risk leaving things in a broken state. Given that I only had 2 + * keyboard shortcuts before, seems worth the tradeoff to just unset them. + */ + output.warn( + `Unsetting legacy shortcut as shortcut storage format changed: `, + currentShortcuts, + ); + setShortcuts(defaultValue); + }, [currentShortcuts, isLegacyShortcut, defaultValue]); + + return resolvedShortcut; +} diff --git a/src/src/components/KeyboardShortcuts/utils.ts b/src/src/components/KeyboardShortcuts/utils.ts new file mode 100644 index 0000000..905a02f --- /dev/null +++ b/src/src/components/KeyboardShortcuts/utils.ts @@ -0,0 +1,74 @@ +import type { RA, WritableArray } from '../../utils/types'; +import type { KeyboardShortcuts, ModifierKey } from './config'; +import { + keyboardModifierLocalization, + keyboardPlatform, + keyLocalizations, + shiftKeyLocalizations, +} from './config'; +import { keyJoinSymbol } from './context'; + +export const localizedKeyJoinSymbol = ' + '; +export function localizeKeyboardShortcut(shortcut: string): string { + const parts = shortcut.split(keyJoinSymbol); + const hasShift = parts.includes('Shift'); + + const modifiers: WritableArray = []; + const nonModifiers: WritableArray = []; + // eslint-disable-next-line functional/no-loop-statement + for (const key of parts) { + const localizedModifier = keyboardModifierLocalization[key as ModifierKey]; + if (typeof localizedModifier === 'string') + modifiers.push(localizedModifier); + else { + nonModifiers.push( + (hasShift ? shiftKeyLocalizations[key] : undefined) ?? + keyLocalizations[key] ?? + key, + ); + } + } + + // If there is only one non-modifier key, then join the keys without separator + const resolved = + keyboardPlatform === 'mac' && nonModifiers.length === 1 + ? `${modifiers.join('')}${nonModifiers[0]}` + : [...modifiers, ...nonModifiers].join(localizedKeyJoinSymbol); + + return resolved; +} + +/** + * If there is a keyboard shortcut defined for current system, use it + * (also, if current system explicitly has empty array of shortcuts, use it). + * + * Otherwise, use the keyboard shortcut from one of the other platforms if set, + * but change meta to ctrl and vice versa as necessary. + */ +export function resolvePlatformShortcuts( + shortcut: KeyboardShortcuts, +): RA | undefined { + if (keyboardPlatform in shortcut) return shortcut[keyboardPlatform]; + else if ('other' in shortcut) + return keyboardPlatform === 'windows' + ? shortcut.other + : shortcut.other?.map(replaceCtrlWithMeta); + else if ('windows' in shortcut) + return keyboardPlatform === 'other' + ? shortcut.other + : shortcut.other?.map(replaceCtrlWithMeta); + else if ('mac' in shortcut) return shortcut.other?.map(replaceMetaWithCtrl); + else return undefined; +} + +const replaceCtrlWithMeta = (shortcut: string): string => + shortcut + .split(keyJoinSymbol) + .map((key) => (key === 'Ctrl' ? 'Meta' : key)) + .join(keyJoinSymbol); + +const replaceMetaWithCtrl = (shortcut: string): string => + shortcut + .split(keyJoinSymbol) + .map((key) => (key === 'Meta' ? 'Ctrl' : key)) + .join(keyJoinSymbol); diff --git a/src/src/components/Molecules/KeyboardShortcut.tsx b/src/src/components/Molecules/KeyboardShortcut.tsx deleted file mode 100644 index 7334584..0000000 --- a/src/src/components/Molecules/KeyboardShortcut.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Logic for setting and listening to keyboard shortcuts - */ - -import React from 'react'; - -import { useTriggerState } from '../../hooks/useTriggerState'; -import { commonText } from '../../localization/common'; -import { preferencesText } from '../../localization/preferences'; -import { listen } from '../../utils/events'; -import type { RA, RR } from '../../utils/types'; -import { - removeItem, - replaceItem, - replaceKey, - sortFunction, -} from '../../utils/utils'; -import { Button } from '../Atoms'; -import type { PreferenceRenderer } from '../Preferences/definitions'; - -const modifierLocalization = { - alt: preferencesText('alt'), - ctrl: preferencesText('ctrl'), - meta: preferencesText('meta'), - shift: preferencesText('shift'), -}; - -export type KeyboardShortcuts = Partial>>; - -export type Platform = 'linux' | 'macOS' | 'windows'; - -export type KeyboardShortcut = { - readonly modifiers: RA; - readonly keys: RA; -}; - -export const modifierKeyNames = new Set(['Alt', 'Control', 'Meta', 'Shift']); - -export const SetKeyboardShortcuts: PreferenceRenderer = ({ - value, - onChange: handleChange, -}) => { - const [editingIndex, setEditingIndex] = React.useState(false); - const isEditing = typeof editingIndex === 'number'; - const shortcuts = value[platform] ?? []; - const setShortcuts = (shortcuts: RA): void => - handleChange( - replaceKey( - value, - platform, - shortcuts.length === 0 ? undefined : shortcuts, - ), - ); - - return ( -
- {shortcuts.map((shortcut, index) => ( - setEditingIndex(index) - : undefined - } - onSave={ - editingIndex === index - ? (shortcut): void => { - setShortcuts( - shortcut === undefined - ? removeItem(shortcuts, index) - : replaceItem(shortcuts, index, shortcut), - ); - setEditingIndex(false); - } - : undefined - } - /> - ))} -
- {!isEditing && ( - { - setEditingIndex(shortcuts.length); - setShortcuts([...shortcuts, { modifiers: [], keys: [] }]); - }} - > - {commonText('add')} - - )} -
-
- ); -}; - -function SetKeyboardShortcut({ - shortcut, - onSave: handleSave, - onEdit: handleEdit, -}: { - readonly shortcut: KeyboardShortcut; - readonly onSave: - | ((shortcut: KeyboardShortcut | undefined) => void) - | undefined; - readonly onEdit: (() => void) | undefined; -}): JSX.Element { - const [localState, setLocalState] = useTriggerState(shortcut); - const { modifiers, keys } = localState; - const isEditing = typeof handleSave === 'function'; - - React.useEffect(() => { - if (isEditing) { - setLocalState({ modifiers: [], keys: [] }); - return listen( - document, - 'keydown', - (event) => { - const key = - event.key.length === 1 ? event.key.toUpperCase() : event.key; - if (modifierKeyNames.has(event.key)) return; - const modifiers = resolveModifiers(event); - setLocalState((localState) => ({ - modifiers: Array.from( - new Set([...localState.modifiers, ...modifiers]), - ).sort(sortFunction((key) => key)), - keys: Array.from(new Set([...localState.keys, key])).sort( - sortFunction((key) => key), - ), - })); - event.preventDefault(); - event.stopPropagation(); - }, - { capture: true }, - ); - } - return undefined; - }, [isEditing, setLocalState]); - - const isEmpty = modifiers.length === 0 && keys.length === 0; - return ( -
-
- {isEditing && isEmpty ? preferencesText('pressKeys') : undefined} - {modifiers.map((modifier) => ( - - ))} - {keys.map((key) => ( - - ))} -
- {isEditing && ( - handleSave(undefined)}> - {commonText('remove')} - - )} - handleSave(isEmpty ? { ...shortcut } : localState) - : handleEdit - } - > - {isEditing ? commonText('save') : commonText('edit')} - -
- ); -} - -export const resolveModifiers = ( - event: KeyboardEvent, -): RA => - Object.entries({ - alt: event.altKey, - ctrl: event.ctrlKey, - meta: event.metaKey, - shift: event.shiftKey, - }) - .filter(([_, isPressed]) => isPressed) - .map(([modifier]) => modifier) - .sort(sortFunction((modifier) => modifier)); - -function Key({ label }: { readonly label: string }): JSX.Element { - return {label}; -} - -export const platform: Platform = - navigator.platform.toLowerCase().includes('mac') || - navigator.platform.toLowerCase().includes('ip') - ? 'macOS' - : navigator.platform.toLowerCase().includes('win') - ? 'windows' - : 'linux'; diff --git a/src/src/components/Preferences/definitions.tsx b/src/src/components/Preferences/definitions.tsx index 014e4e4..be47e5d 100644 --- a/src/src/components/Preferences/definitions.tsx +++ b/src/src/components/Preferences/definitions.tsx @@ -6,8 +6,8 @@ import { commonText } from '../../localization/common'; import { preferencesText } from '../../localization/preferences'; import type { IR } from '../../utils/types'; import { ensure } from '../../utils/types'; -import type { KeyboardShortcuts } from '../Molecules/KeyboardShortcut'; -import { SetKeyboardShortcuts } from '../Molecules/KeyboardShortcut'; +import type { KeyboardShortcuts } from '../KeyboardShortcuts/config'; +import { SetKeyboardShortcuts } from '../KeyboardShortcuts/Shortcuts'; import { BooleanPref, pickListPref, rangePref } from './Renderers'; /** @@ -33,10 +33,31 @@ export type PreferenceRenderer = (props: { /** * This is used to enforce the same generic value be used inside a PreferenceItem */ -const defineItem = ( +const definePref = ( definition: PreferenceItem, ): PreferenceItem => definition; +const defineKeyboardShortcut = ( + title: string, + /** + * If defined a keyboard shortcut for one platform, it will be automatically + * transformed (`ctrl -> cmd`) for the other platforms. + * + * Thus, you should define keyboard shortcuts for the "other" platform only, + * unless you actually want to use different keyboard shortcuts on different + * systems. + */ + defaultValue: KeyboardShortcuts | string, +): PreferenceItem => + definePref({ + title, + defaultValue: + typeof defaultValue === 'string' + ? { other: [defaultValue] } + : defaultValue, + renderer: SetKeyboardShortcuts, + }); + export type GenericPreferencesCategories = IR<{ readonly title: string; readonly description?: string; @@ -54,7 +75,7 @@ export const preferenceDefinitions = { behavior: { title: preferencesText('behavior'), items: { - ignoreAllDayEvents: defineItem({ + ignoreAllDayEvents: definePref({ title: preferencesText('ignoreAllDayEvents'), renderer: BooleanPref, defaultValue: true, @@ -64,25 +85,15 @@ export const preferenceDefinitions = { feature: { title: preferencesText('features'), items: { - openOverlayShortcut: defineItem({ - title: preferencesText('openOverlayShortcut'), - renderer: SetKeyboardShortcuts, - defaultValue: { - linux: [{ modifiers: [], keys: ['`'] }], - macOS: [{ modifiers: [], keys: ['`'] }], - windows: [{ modifiers: [], keys: ['`'] }], - }, - }), - closeOverlayShortcut: defineItem({ - title: preferencesText('closeOverlayShortcut'), - renderer: SetKeyboardShortcuts, - defaultValue: { - linux: [{ modifiers: [], keys: ['`'] }], - macOS: [{ modifiers: [], keys: ['`'] }], - windows: [{ modifiers: [], keys: ['`'] }], - }, - }), - ghostEventShortcut: defineItem<'cmd' | 'ctrl' | 'none' | 'shift'>({ + openOverlayShortcut: defineKeyboardShortcut( + preferencesText('openOverlayShortcut'), + 'Backquote', + ), + closeOverlayShortcut: defineKeyboardShortcut( + preferencesText('closeOverlayShortcut'), + 'Backquote', + ), + ghostEventShortcut: definePref<'cmd' | 'ctrl' | 'none' | 'shift'>({ title: preferencesText('ghostEvent'), description: preferencesText('ghostEventDescription'), renderer: pickListPref<'cmd' | 'ctrl' | 'none' | 'shift'>( @@ -90,12 +101,12 @@ export const preferenceDefinitions = { ), defaultValue: 'shift' as const, }), - ghostEventOpacity: defineItem({ + ghostEventOpacity: definePref({ title: preferencesText('ghostedEventOpacity'), renderer: rangePref({ min: 0, max: 100, step: 1 }), defaultValue: 30, }), - condenseInterface: defineItem({ + condenseInterface: definePref({ title: preferencesText('condenseInterface'), renderer: BooleanPref, defaultValue: false, @@ -105,13 +116,13 @@ export const preferenceDefinitions = { recurringEvents: { title: preferencesText('recurringEvents'), items: { - hideEditAll: defineItem({ + hideEditAll: definePref({ title: preferencesText('hideEditAll'), description: preferencesText('hideEditAllDescription'), renderer: BooleanPref, defaultValue: false, }), - lessInvasiveDialog: defineItem({ + lessInvasiveDialog: definePref({ title: preferencesText('lessInvasiveDialog'), description: preferencesText('lessInvasiveDialogDescription'), renderer: BooleanPref, @@ -122,7 +133,7 @@ export const preferenceDefinitions = { export: { title: commonText('dataExport'), items: { - format: defineItem<'csv' | 'json' | 'tsv'>({ + format: definePref<'csv' | 'json' | 'tsv'>({ title: preferencesText('exportFormat'), renderer: pickListPref<'csv' | 'json' | 'tsv'>([ { value: 'json', title: preferencesText('json') }, diff --git a/src/src/localization/preferences.tsx b/src/src/localization/preferences.tsx index 3150a2d..36d04ad 100644 --- a/src/src/localization/preferences.tsx +++ b/src/src/localization/preferences.tsx @@ -22,12 +22,8 @@ export const preferencesText = createDictionary({ ignoreAllDayEvents: { 'en-us': 'Ignore All-Day Events' }, openOverlayShortcut: { 'en-us': 'Open Overlay Shortcut' }, closeOverlayShortcut: { 'en-us': 'Close Overlay Shortcut' }, - ctrl: { 'en-us': 'Ctrl' }, - cmd: { 'en-us': 'Cmd' }, - shift: { 'en-us': 'Shift' }, - alt: { 'en-us': 'Alt' }, - meta: { 'en-us': 'Cmd' }, pressKeys: { 'en-us': 'Press some keys...' }, + noKeyAssigned: { 'en-us': 'No key assigned' }, recurringEvents: { 'en-us': 'Recurring Events' }, lessInvasiveDialog: { 'en-us': 'Less Invasive "Edit recurring event" Dialog', @@ -58,4 +54,35 @@ export const preferencesText = createDictionary({ condenseInterface: { 'en-us': 'Condense Interface', }, + alt: { + comment: 'Alt key on the keyboard', + 'en-us': 'Alt', + }, + macOption: { + comment: 'Option key on the macOS keyboard', + 'en-us': '⌥', + }, + ctrl: { + comment: 'Ctrl key on the keyboard', + 'en-us': 'Ctrl', + }, + macControl: { + comment: 'Control key on the macOS keyboard', + 'en-us': '⌃', + }, + macMeta: { + comment: 'Meta/Command key on the macOS keyboard', + 'en-us': '⌘', + }, + macShift: { + comment: 'Shift key on the macOS keyboard', + 'en-us': '⇧', + }, + shift: { + comment: 'Shift key on the keyboard', + 'en-us': 'Shift', + }, + keyboardShortcuts: { + 'en-us': 'Keyboard Shortcuts', + }, });