diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/UserDefinitions.test.ts b/specifyweb/frontend/js_src/lib/components/Keyboard/__tests__/UserDefinitions.test.ts similarity index 93% rename from specifyweb/frontend/js_src/lib/components/Preferences/__tests__/UserDefinitions.test.ts rename to specifyweb/frontend/js_src/lib/components/Keyboard/__tests__/UserDefinitions.test.ts index fb686781cc1..19d10f05283 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/UserDefinitions.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Keyboard/__tests__/UserDefinitions.test.ts @@ -1,7 +1,7 @@ -import type { KeyboardShortcuts, ModifierKey } from '../KeyboardContext'; -import { exportsForTests, keyJoinSymbol } from '../KeyboardContext'; -import type { GenericPreferences } from '../types'; -import { userPreferenceDefinitions } from '../UserDefinitions'; +import type { KeyboardShortcuts, ModifierKey } from '../context'; +import { exportsForTests, keyJoinSymbol } from '../context'; +import type { GenericPreferences } from '../../Preferences/types'; +import { userPreferenceDefinitions } from '../../Preferences/UserDefinitions'; const { keysToString, modifierKeys, specialKeys } = exportsForTests; diff --git a/specifyweb/frontend/js_src/lib/components/Keyboard/config.ts b/specifyweb/frontend/js_src/lib/components/Keyboard/config.ts new file mode 100644 index 00000000000..5799341bf94 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Keyboard/config.ts @@ -0,0 +1,72 @@ +import { preferencesText } from '../../localization/preferences'; +import type { 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> +>; + +type KeyboardPlatform = 'mac' | 'other' | 'windows'; +export const keyboardPlatform: KeyboardPlatform = + navigator.platform.toLowerCase().includes('mac') || + // Check for iphone || ipad || ipod + navigator.platform.toLowerCase().includes('ip') + ? 'mac' + : navigator.platform.toLowerCase().includes('win') + ? 'windows' + : 'other'; + +const modifierKeys = ['Alt', 'Ctrl', 'Meta', 'Shift'] as const; +export type ModifierKey = typeof modifierKeys[number]; +export const allModifierKeys = new Set([ + ...modifierKeys, + 'AltGraph', + 'CapsLock', +]); + +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', + ' ', + 'Escape', + 'Backspace', +]); diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardContext.tsx b/specifyweb/frontend/js_src/lib/components/Keyboard/context.tsx similarity index 60% rename from specifyweb/frontend/js_src/lib/components/Preferences/KeyboardContext.tsx rename to specifyweb/frontend/js_src/lib/components/Keyboard/context.tsx index 1b0fc9466af..2501aef8a95 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardContext.tsx +++ b/specifyweb/frontend/js_src/lib/components/Keyboard/context.tsx @@ -2,50 +2,10 @@ * Allows to register a key listener */ -import type { 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> ->; - -type KeyboardPlatform = 'mac' | 'other' | 'windows'; -export const keyboardPlatform: KeyboardPlatform = - navigator.platform.toLowerCase().includes('mac') || - // Check for iphone || ipad || ipod - navigator.platform.toLowerCase().includes('ip') - ? 'mac' - : navigator.platform.toLowerCase().includes('win') - ? 'windows' - : 'other'; - -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']); +import type { RA } from '../../utils/types'; +import type { KeyboardShortcuts, ModifierKey } from './config'; +import { allModifierKeys, specialKeyboardKeys } from './config'; +import { resolvePlatformShortcuts } from './utils'; /** * To keep the event listener as fast as possible, we are not looping though all @@ -89,41 +49,6 @@ 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 */ @@ -148,7 +73,7 @@ document.addEventListener('keydown', (event) => { // 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; + if (modifiers.length === 0 && specialKeyboardKeys.has(event.key)) return; const keyString = keysToString(modifiers, pressedKeys); const handler = interceptor ?? listeners.get(keyString); @@ -243,6 +168,4 @@ document.addEventListener('visibilitychange', () => { 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/Keyboard/shortcuts.tsx similarity index 82% rename from specifyweb/frontend/js_src/lib/components/Preferences/KeyboardShortcut.tsx rename to specifyweb/frontend/js_src/lib/components/Keyboard/shortcuts.tsx index 44a7ea568be..887d9b0f552 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/KeyboardShortcut.tsx +++ b/specifyweb/frontend/js_src/lib/components/Keyboard/shortcuts.tsx @@ -3,58 +3,36 @@ */ 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, RR } from '../../utils/types'; -import { localized } from '../../utils/types'; +import type { RA } from '../../utils/types'; import { removeItem, replaceItem, replaceKey } from '../../utils/utils'; import { Key } from '../Atoms'; import { Button } from '../Atoms/Button'; -import type { KeyboardShortcuts, ModifierKey } from './KeyboardContext'; +import type { PreferenceRendererProps } from '../Preferences/types'; +import type { KeyboardShortcuts } from './config'; +import { keyboardPlatform } from './config'; import { - keyboardPlatform, keyJoinSymbol, resolveModifiers, - resolvePlatformShortcuts, setKeyboardEventInterceptor, -} from './KeyboardContext'; -import type { PreferenceRendererProps } from './types'; +} from './context'; +import { localizeKeyboardShortcut, resolvePlatformShortcuts } from './utils'; /* * FIXME: create a mechanism for setting shortcuts for a page, and then displaying * those in the UI if present on the page + * + * FIXME: open key shortcut viewer on cmd+/ + * + * FIXME: localize some key shortcuts (arrow keys, home, etc) + * + * FIXME: add a mapping of allowed default keyboard shortcuts and use that in tests */ -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, diff --git a/specifyweb/frontend/js_src/lib/components/Keyboard/utils.ts b/specifyweb/frontend/js_src/lib/components/Keyboard/utils.ts new file mode 100644 index 00000000000..5de0fbdb7f5 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Keyboard/utils.ts @@ -0,0 +1,51 @@ +import type { LocalizedString } from 'typesafe-i18n'; + +import type { RA } from '../../utils/types'; +import { localized } from '../../utils/types'; +import type { KeyboardShortcuts, ModifierKey } from './config'; +import { keyboardModifierLocalization, keyboardPlatform } from './config'; +import { keyJoinSymbol } from './context'; + +const localizedKeyJoinSymbol = ' + '; +export const localizeKeyboardShortcut = (shortcut: string): LocalizedString => + localized( + shortcut + .split(keyJoinSymbol) + .map((key) => keyboardModifierLocalization[key as ModifierKey] ?? key) + .join(localizedKeyJoinSymbol) + ); + +/** + * 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); diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx index 35cb2e8e728..e08bca63b92 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx @@ -18,12 +18,12 @@ import { contextUnlockedPromise, foreverFetch, } from '../InitialContext'; -import { formatUrl } from '../Router/queryString'; import { bindKeyboardShortcut, resolvePlatformShortcuts, -} from './KeyboardContext'; -import { localizeKeyboardShortcut } from './KeyboardShortcut'; +} from '../Keyboard/context'; +import { localizeKeyboardShortcut } from '../Keyboard/shortcuts'; +import { formatUrl } from '../Router/queryString'; import type { GenericPreferences, PreferenceItem } from './types'; /* eslint-disable functional/no-this-expression */ diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 4113f7ce735..5fc49bf2461 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -37,13 +37,13 @@ import type { TableFields } from '../DataModel/helperTypes'; import { genericTables } from '../DataModel/tables'; import type { Collection, Tables } from '../DataModel/types'; import { error, softError } from '../Errors/assert'; +import type { KeyboardShortcuts } from '../Keyboard/context'; +import { KeyboardShortcutPreferenceItem } from '../Keyboard/shortcuts'; import type { StatLayout } from '../Statistics/types'; import { LanguagePreferencesItem, SchemaLanguagePreferenceItem, } from '../Toolbar/Language'; -import type { KeyboardShortcuts } from './KeyboardContext'; -import { KeyboardShortcutPreferenceItem } from './KeyboardShortcut'; import type { MenuPreferences, WelcomePageMode } from './Renderers'; import { CollectionSortOrderPreferenceItem,