From 23449972e59e7f9db263de56f85454f1c1f78eea Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 13 Jul 2024 14:43:54 -0700 Subject: [PATCH] feat(Keyboard): complete main features --- .../Preferences/BasePreferences.tsx | 79 ++++++++- .../Preferences/KeyboardContext.tsx | 141 ++++++++++----- .../Preferences/KeyboardShortcut.tsx | 162 +++++++++++------- .../Preferences/UserDefinitions.tsx | 1 - .../__tests__/UserDefinitions.test.ts | 135 +++++++++++++++ .../lib/components/Preferences/index.tsx | 5 +- .../js_src/lib/localization/preferences.ts | 21 ++- 7 files changed, 432 insertions(+), 112 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Preferences/__tests__/UserDefinitions.test.ts diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx index 258cc711421..35cb2e8e728 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx @@ -10,6 +10,7 @@ import { parseValue } from '../../utils/parser/parse'; import type { GetOrSet, RA } from '../../utils/types'; import { filterArray, setDevelopmentGlobal } from '../../utils/types'; import { keysToLowerCase, replaceKey } from '../../utils/utils'; +import { formatDisjunction } from '../Atoms/Internationalization'; import { SECOND } from '../Atoms/timeUnits'; import { softFail } from '../Errors/Crash'; import { @@ -18,6 +19,11 @@ import { foreverFetch, } from '../InitialContext'; import { formatUrl } from '../Router/queryString'; +import { + bindKeyboardShortcut, + resolvePlatformShortcuts, +} from './KeyboardContext'; +import { localizeKeyboardShortcut } from './KeyboardShortcut'; import type { GenericPreferences, PreferenceItem } from './types'; /* eslint-disable functional/no-this-expression */ @@ -88,7 +94,7 @@ export class BasePreferences { /** * Fetch preferences from back-end and update local cache with fetched values */ - async fetch(): Promise { + public async fetch(): Promise { const entryPoint = await contextUnlockedPromise; if (entryPoint === 'main') { if (typeof this.resourcePromise === 'object') return this.resourcePromise; @@ -365,12 +371,15 @@ export class BasePreferences { ): GetOrSet< DEFINITIONS[CATEGORY]['subCategories'][SUBCATEGORY]['items'][ITEM]['defaultValue'] > { + // eslint-disable-next-line react-hooks/rules-of-hooks const preferences = React.useContext(this.Context) ?? this; + // eslint-disable-next-line react-hooks/rules-of-hooks const [localValue, setLocalValue] = React.useState< DEFINITIONS[CATEGORY]['subCategories'][SUBCATEGORY]['items'][ITEM]['defaultValue'] >(() => preferences.get(category, subcategory, item)); + // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect( () => preferences.events.on('update', (payload) => { @@ -387,6 +396,7 @@ export class BasePreferences { [category, subcategory, item, preferences] ); + // eslint-disable-next-line react-hooks/rules-of-hooks const updatePref = React.useCallback( ( newPref: @@ -414,6 +424,73 @@ export class BasePreferences { return [localValue, updatePref] as const; } + + /** + * 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. + */ + // eslint-disable-next-line max-params + public useKeyboardShortcut< + CATEGORY extends string & keyof DEFINITIONS, + SUBCATEGORY extends CATEGORY extends keyof DEFINITIONS + ? string & keyof DEFINITIONS[CATEGORY]['subCategories'] + : never, + ITEM extends string & + keyof DEFINITIONS[CATEGORY]['subCategories'][SUBCATEGORY]['items'] + >( + category: CATEGORY, + subcategory: SUBCATEGORY, + item: ITEM, + callback: (() => void) | undefined + ): string { + const [shortcuts] = this.use(category, subcategory, item); + + const hasCallback = typeof callback === 'function'; + // eslint-disable-next-line react-hooks/rules-of-hooks + const callbackRef = React.useRef(callback); + callbackRef.current = callback; + + // Calling hook conditionally like this is fine as this condition is constant during runtime of a page + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + const definition = this.definition( + category, + subcategory, + item + ) as PreferenceItem; + if ( + !('renderer' in definition) || + definition.renderer.name !== 'KeyboardShortcutPreferenceItem' + ) + throw new Error( + `Trying to listen to keyboard shortcut on a non-keyboard shortcut preference '${category}.${subcategory}.${item}'` + ); + }); + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect( + () => + hasCallback + ? bindKeyboardShortcut(shortcuts, () => callbackRef.current?.()) + : undefined, + [hasCallback, shortcuts] + ); + + // eslint-disable-next-line react-hooks/rules-of-hooks + return React.useMemo(() => { + const platformShortcuts = resolvePlatformShortcuts(shortcuts) ?? []; + return platformShortcuts.length > 0 + ? ` (${formatDisjunction( + platformShortcuts.map(localizeKeyboardShortcut) + )})` + : ''; + }, [shortcuts]); + } } /* eslint-enable functional/no-this-expression */ diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardContext.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardContext.tsx index 5edc428f0ca..1b0fc9466af 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardContext.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardContext.tsx @@ -18,10 +18,12 @@ import type { RA, RR } from '../../utils/types'; * - If keyboard shortcut was not explicitly set, the default shortcut, if any * will be used */ -export type KeyboardShortcuts = Partial | undefined>>; +export type KeyboardShortcuts = Partial< + RR | undefined> +>; -type Platform = 'mac' | 'other' | 'windows'; -const platform: Platform = +type KeyboardPlatform = 'mac' | 'other' | 'windows'; +export const keyboardPlatform: KeyboardPlatform = navigator.platform.toLowerCase().includes('mac') || // Check for iphone || ipad || ipod navigator.platform.toLowerCase().includes('ip') @@ -30,9 +32,20 @@ const platform: Platform = ? 'windows' : 'other'; -const modifierKeys = ['alt', 'control', 'meta', 'shift']; -type ModifierKey = typeof modifierKeys[number]; -const modifierKeyNames = new Set(modifierKeys); +const modifierKeys = ['Alt', 'Ctrl', 'Meta', 'Shift'] as const; +export type ModifierKey = typeof modifierKeys[number]; +const allModifierKeys = new Set([...modifierKeys, 'AltGraph', 'CapsLock']); + +/** + * 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) + */ +const specialKeys = new Set(['Enter', 'Tab', ' ', 'Escape', 'Backspace']); /** * To keep the event listener as fast as possible, we are not looping though all @@ -50,23 +63,18 @@ const listeners = new Map void>(); let interceptor: ((keys: string) => void) | undefined; export function setKeyboardEventInterceptor( callback: typeof interceptor -): void { +): () => void { interceptor = callback; + return (): void => { + if (interceptor === callback) interceptor = undefined; + }; } -/* - * FIXME: handle case when key shortcut is not set for current platform - * FIXME: add test for default preferences containing non-existing shortcuts - * FIXME: add test for having shortcuts sorted - * FIXME: add test for not binding defaults to Enter/Tab/Space/Escape other forbidden keys (ask ChatGPT for full list) - * FIXME: make the useKeyboardShortcut() hook also return a localized keyboard - * shortcut string for usage in UI tooltips - */ export function bindKeyboardShortcut( shortcut: KeyboardShortcuts, callback: () => void ): () => void { - const shortcuts = shortcut[platform] ?? []; + const shortcuts = resolvePlatformShortcuts(shortcut) ?? []; shortcuts.forEach((string) => { listeners.set(string, callback); }); @@ -81,33 +89,73 @@ export function bindKeyboardShortcut( }); } +/** + * 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 ('platform' 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); + +/** + * Assumes keys and modifiers are sorted + */ const keysToString = (modifiers: RA, keys: RA): string => - [...modifiers, ...keys].join('+'); + [...modifiers, ...keys].join(keyJoinSymbol); +export const keyJoinSymbol = '+'; // eslint-disable-next-line functional/prefer-readonly-type const pressedKeys: string[] = []; document.addEventListener('keydown', (event) => { if (shouldIgnoreKeyPress(event)) return; - if (pressedKeys.includes(event.key)) return; - pressedKeys.push(event.key); - pressedKeys.sort(); + if (!pressedKeys.includes(event.key)) { + pressedKeys.push(event.key); + pressedKeys.sort(); + } const modifiers = resolveModifiers(event); const isEntering = isInInput(event); - // FIXME: should this include alt too? - const noModifiers = modifiers.length === 0 || modifiers[0] === 'Shift'; - // Ignore single key shortcuts when in an input field - const ignore = noModifiers && isEntering; + 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 && specialKeys.has(event.key)) return; const keyString = keysToString(modifiers, pressedKeys); const handler = interceptor ?? listeners.get(keyString); if (typeof handler === 'function') { handler(keyString); /* - * Do this only after calling handler, so that if handler throws an + * 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(); @@ -120,28 +168,22 @@ function shouldIgnoreKeyPress(event: KeyboardEvent): boolean { if (event.isComposing || event.repeat) return true; - /** - * 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) - */ - if (key === 'Tab') return true; // See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key#value if (key === 'Dead' || key === 'Unidentified') return true; - // FIXME: ignore all modifiers: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#modifier_keys - const isModifier = modifierKeyNames.has(event.key.toLowerCase()); + + // Do not allow binding a key shortcut to a modifier key only + const isModifier = allModifierKeys.has(event.key); return !isModifier; } -const resolveModifiers = (event: KeyboardEvent): RA => +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, + Alt: event.altKey, + Ctrl: event.ctrlKey, + Meta: event.metaKey, + Shift: event.shiftKey, }) .filter(([_, isPressed]) => isPressed) .map(([modifier]) => modifier); @@ -156,6 +198,21 @@ function isInInput(event: KeyboardEvent): boolean { ); } +/** + * 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) => { @@ -183,3 +240,9 @@ window.addEventListener( document.addEventListener('visibilitychange', () => { if (document.hidden) pressedKeys.length = 0; }); + +export const exportsForTests = { + keysToString, + modifierKeys, + specialKeys, +}; diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardShortcut.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardShortcut.tsx index 06d20831df3..44a7ea568be 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardShortcut.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardShortcut.tsx @@ -2,43 +2,76 @@ * Logic for setting and listening to keyboard shortcuts */ -import { platform, Platform } from '@floating-ui/react'; import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; import { useTriggerState } from '../../hooks/useTriggerState'; import { commonText } from '../../localization/common'; import { preferencesText } from '../../localization/preferences'; import { listen } from '../../utils/events'; -import type { RA } from '../../utils/types'; -import { - removeItem, - replaceItem, - replaceKey, - sortFunction, -} from '../../utils/utils'; +import type { RA, RR } from '../../utils/types'; +import { localized } from '../../utils/types'; +import { removeItem, replaceItem, replaceKey } from '../../utils/utils'; +import { Key } from '../Atoms'; import { Button } from '../Atoms/Button'; -import type { - KeyboardShortcutBinding, - KeyboardShortcuts, +import type { KeyboardShortcuts, ModifierKey } from './KeyboardContext'; +import { + keyboardPlatform, + keyJoinSymbol, + resolveModifiers, + resolvePlatformShortcuts, + setKeyboardEventInterceptor, } from './KeyboardContext'; import type { PreferenceRendererProps } from './types'; -const modifierLocalization = { - alt: preferencesText.alt(), - ctrl: preferencesText.ctrl(), - meta: preferencesText.meta(), - shift: preferencesText.shift(), +/* + * FIXME: create a mechanism for setting shortcuts for a page, and then displaying + * those in the UI if present on the page + */ + +const modifierLocalization: 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(), }; +const localizedKeyJoinSymbol = ' + '; +export const localizeKeyboardShortcut = (shortcut: string): LocalizedString => + localized( + shortcut + .split(keyJoinSymbol) + .map((key) => modifierLocalization[key as ModifierKey] ?? key) + .join(localizedKeyJoinSymbol) + ); + export function KeyboardShortcutPreferenceItem({ value, onChange: handleChange, }: PreferenceRendererProps): JSX.Element { 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)); + 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 (
@@ -69,7 +102,7 @@ export function KeyboardShortcutPreferenceItem({ {!isEditing && ( { - setShortcuts([...shortcuts, { modifiers: [], keys: [] }]); + setShortcuts([...shortcuts, '']); setEditingIndex(shortcuts.length); }} > @@ -80,56 +113,48 @@ export function KeyboardShortcutPreferenceItem({
); } +// This is used in BasePreferences.useKeyboardShortcut to validate that the pref you are trying to listen to is actually a keyboard shortcut +if (process.env.NODE_ENV !== 'production') + Object.defineProperty(KeyboardShortcutPreferenceItem, 'name', { + value: 'KeyboardShortcutPreferenceItem', + }); function EditKeyboardShortcut({ shortcut, onSave: handleSave, onEditStart: handleEditStart, }: { - readonly shortcut: KeyboardShortcutBinding; - readonly onSave: - | ((shortcut: KeyboardShortcutBinding | undefined) => void) - | undefined; + readonly shortcut: string; + readonly onSave: ((shortcut: string | undefined) => void) | undefined; readonly onEditStart: (() => void) | undefined; }): JSX.Element { const [localState, setLocalState] = useTriggerState(shortcut); - const { modifiers, keys } = localState; + const parts = localState.split(keyJoinSymbol); const isEditing = typeof handleSave === 'function'; React.useEffect(() => { if (isEditing) { - setLocalState({ modifiers: [], keys: [] }); - return listen( - document, - 'keydown', - (event) => { - if (ignoreKeyPress(event, false)) return; - - if (event.key === 'Enter') handleSave(activeValue.current); - else { - const modifiers = resolveModifiers(event); - setLocalState((localState) => ({ - modifiers: Array.from( - // eslint-disable-next-line unicorn/consistent-destructuring - new Set([...localState.modifiers, ...modifiers]) - ).sort(sortFunction((key) => key)), - // eslint-disable-next-line unicorn/consistent-destructuring - keys: Array.from(new Set([...localState.keys, event.key])).sort( - sortFunction((key) => key) - ), - })); - } - - event.preventDefault(); - event.stopPropagation(); - }, - { capture: true } - ); + setLocalState(''); + const keyboardInterceptor = setKeyboardEventInterceptor(setLocalState); + /* + * Save the shortcut when Enter key is pressed. + * Keyboard interceptor won't react to single Enter key press as it + * is a special key, unless a modifier key is present. + */ + const enterListener = listen(document, 'keydown', (event) => { + if (event.key === 'Enter' && resolveModifiers(event).length === 0) + handleSave(activeValue.current); + }); + return () => { + keyboardInterceptor(); + enterListener(); + }; } return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEditing, setLocalState]); - const isEmpty = modifiers.length === 0 && keys.length === 0; + const isEmpty = parts.length === 0; const activeValue = React.useRef(localState); activeValue.current = isEmpty ? shortcut : localState; @@ -140,13 +165,19 @@ function EditKeyboardShortcut({ aria-live={isEditing ? 'polite' : undefined} className="flex flex-1 flex-wrap items-center gap-2" > - {isEditing && isEmpty ? preferencesText.pressKeys() : undefined} - {modifiers.map((modifier) => ( - - ))} - {keys.map((key) => ( - - ))} + {isEmpty ? ( + isEditing ? ( + preferencesText.pressKeys() + ) : ( + preferencesText.noKeyAssigned() + ) + ) : ( + + {shortcut.split(keyJoinSymbol).map((key) => ( + {localizeKeyboardShortcut(key)} + ))} + + )} {isEditing && ( handleSave(undefined)}> @@ -157,7 +188,12 @@ function EditKeyboardShortcut({ aria-pressed={isEditing ? true : undefined} onClick={ isEditing - ? (): void => handleSave(activeValue.current) + ? (): void => + handleSave( + activeValue.current.length === 0 + ? shortcut + : activeValue.current + ) : handleEditStart } > @@ -166,7 +202,3 @@ function EditKeyboardShortcut({ ); } - -function Key({ label }: { readonly label: string }): JSX.Element { - return {label}; -} diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 55819547793..b59be91ef4b 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -43,7 +43,6 @@ import { SchemaLanguagePreferenceItem, } from '../Toolbar/Language'; import type { KeyboardShortcuts } from './KeyboardContext'; -import { KeyboardShortcutBinding } from './KeyboardContext'; import { KeyboardShortcutPreferenceItem } from './KeyboardShortcut'; import type { MenuPreferences, WelcomePageMode } from './Renderers'; import { diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/UserDefinitions.test.ts b/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/UserDefinitions.test.ts new file mode 100644 index 00000000000..fb686781cc1 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/UserDefinitions.test.ts @@ -0,0 +1,135 @@ +import type { KeyboardShortcuts, ModifierKey } from '../KeyboardContext'; +import { exportsForTests, keyJoinSymbol } from '../KeyboardContext'; +import type { GenericPreferences } from '../types'; +import { userPreferenceDefinitions } from '../UserDefinitions'; + +const { keysToString, modifierKeys, specialKeys } = exportsForTests; + +test('Validate default keyboard shortcuts in userPreferenceDefinitions', () => { + Object.entries(userPreferenceDefinitions as GenericPreferences).forEach( + ([category, definition]) => + Object.entries(definition.subCategories).forEach( + ([subCategory, subDefinition]) => + Object.entries(subDefinition.items).forEach( + ([item, itemDefinition]) => { + const isKeyboardShortcut = + 'renderer' in itemDefinition && + itemDefinition.renderer.name === + 'KeyboardShortcutPreferenceItem'; + if (!isKeyboardShortcut) return; + + const defaultValue = + itemDefinition.defaultValue as KeyboardShortcuts; + + if (typeof defaultValue !== 'object') return; + + Object.entries(defaultValue).forEach(([platform, shortcuts]) => + shortcuts?.forEach((shortcut) => { + const error = validateShortcut(shortcut, platform); + if (error !== undefined) + // eslint-disable-next-line functional/no-throw-statement + throw new Error( + `Invalid default value for a keyboard shortcut for ${category}.${subCategory}.${item} for platform ${platform} (value: ${shortcut}). Error: ${String( + error + )}` + ); + }) + ); + } + ) + ) + ); + + // Useless assertion to have at least one assertion in the test + expect(1).toBe(1); +}); + +function validateShortcut( + shortcut: string, + platform: keyof KeyboardShortcuts +): string | undefined { + const parts = shortcut.split(keyJoinSymbol); + if (parts.length === 0) return 'unexpected empty shortcut'; + + /* + * FIXME: add test against default preferences containing non-existing shortcuts + */ + const shortcutModifierKeys = parts + .map((part) => part as ModifierKey) + .filter((part) => modifierKeys.includes(part)) + .sort(); + const nonModifierKeys = parts + .filter((part) => !modifierKeys.includes(part as ModifierKey)) + .sort(); + const normalizedShortcut = keysToString( + shortcutModifierKeys, + nonModifierKeys + ); + if (normalizedShortcut !== shortcut) + return `shortcut is not normalized: ${normalizedShortcut} (expected keys to be sorted, with modifier keys before non-modifiers)`; + + if (shortcutModifierKeys.length === 0) { + const specialKey = nonModifierKeys.find((key) => specialKeys.has(key)); + if (typeof specialKey === 'string') + return `can't use special reserved keys as default shortcuts, unless prefixed with a modifier key. Found ${specialKey}`; + } + + if (platform !== 'mac' && shortcutModifierKeys.includes('Meta')) + return `can't use meta (cmd) key in non-mac platform as those are reserved for system shortcuts`; + + if (nonModifierKeys.length === 0) + return 'shortcut must contain at least one non-modifier key'; + + return undefined; +} + +/** + * To guard against typos, this list defines the list of keys that may appear in + * default keyboard shortcuts. If some key is missing, feel free to add it to + * this list + * + * See a full list of possible keys browsers could capture here: + * https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#special_values + */ +const allowed = new Set([ + // Not including modifier keys as the above check will only check non-modifiers + ...Array.from(specialKeys), + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'End', + 'Home', + 'PageDown', + 'PageUp', + 'Insert', + 'F1', + 'F2', + 'F3', + 'F4', + 'F5', + 'F6', + 'F7', + 'F8', + 'F9', + 'F10', + 'F11', + 'F12', + 'BrowserBack', + 'BrowserForward', + 'BrowserHome', + 'BrowserRefresh', + 'BrowserrSearch', + 'Add', + 'Multiply', + 'Subtract', + 'Decimal', + 'Divide', + ...'01234567890-=qwertyuiop[]asdfghjkl;\'zxcvbnm,./\\`~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?'.split( + '' + ), + /* + * This list doesn't include more obscure keys, but we probably shouldn't be + * using those in key shortcuts anyway + */ +]); diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx index dff12b62910..8be65edf0cd 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx @@ -168,7 +168,7 @@ export function PreferencesContent({ ( [category, { title, description = undefined, subCategories }], index - ) => ( + ): JSX.Element => ( {key}, + // eslint-disable-next-line react/no-unstable-nested-components + key: (key): JSX.Element => {key}, }} string={text} /> diff --git a/specifyweb/frontend/js_src/lib/localization/preferences.ts b/specifyweb/frontend/js_src/lib/localization/preferences.ts index b13598f4beb..f87d346bf6e 100644 --- a/specifyweb/frontend/js_src/lib/localization/preferences.ts +++ b/specifyweb/frontend/js_src/lib/localization/preferences.ts @@ -1987,17 +1987,30 @@ export const preferencesText = createDictionary({ 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', }, - meta: { - comment: 'Meta key on the keyboard', - 'en-us': 'Meta', + 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', }, - pressKeys: { 'en-us': 'Press desired key combinations...' }, + pressKeys: { 'en-us': 'Press desired key combination...' }, + noKeyAssigned: { 'en-us': 'No key binding assigned' }, } as const);