Skip to content

Commit

Permalink
feat(Keyboard): complete main features
Browse files Browse the repository at this point in the history
  • Loading branch information
maxpatiiuk committed Jul 13, 2024
1 parent 7cef2df commit 2344997
Show file tree
Hide file tree
Showing 7 changed files with 432 additions and 112 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 */
Expand Down Expand Up @@ -88,7 +94,7 @@ export class BasePreferences<DEFINITIONS extends GenericPreferences> {
/**
* Fetch preferences from back-end and update local cache with fetched values
*/
async fetch(): Promise<ResourceWithData> {
public async fetch(): Promise<ResourceWithData> {
const entryPoint = await contextUnlockedPromise;
if (entryPoint === 'main') {
if (typeof this.resourcePromise === 'object') return this.resourcePromise;
Expand Down Expand Up @@ -365,12 +371,15 @@ export class BasePreferences<DEFINITIONS extends GenericPreferences> {
): 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) => {
Expand All @@ -387,6 +396,7 @@ export class BasePreferences<DEFINITIONS extends GenericPreferences> {
[category, subcategory, item, preferences]
);

// eslint-disable-next-line react-hooks/rules-of-hooks
const updatePref = React.useCallback(
(
newPref:
Expand Down Expand Up @@ -414,6 +424,73 @@ export class BasePreferences<DEFINITIONS extends GenericPreferences> {

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<unknown>;
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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RR<Platform, RA<string> | undefined>>;
export type KeyboardShortcuts = Partial<
RR<KeyboardPlatform, RA<string> | 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')
Expand All @@ -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
Expand All @@ -50,23 +63,18 @@ const listeners = new Map<string, () => 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);
});
Expand All @@ -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<string> | 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<ModifierKey>, keys: RA<string>): 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();
Expand All @@ -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<ModifierKey> =>
export const resolveModifiers = (event: KeyboardEvent): RA<ModifierKey> =>
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);
Expand All @@ -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<ModifierKey>): boolean {
if (modifiers.length === 0) return true;

if (modifiers.length === 1) return modifiers[0] === 'Shift';

return false;
}

document.addEventListener(
'keyup',
(event) => {
Expand Down Expand Up @@ -183,3 +240,9 @@ window.addEventListener(
document.addEventListener('visibilitychange', () => {
if (document.hidden) pressedKeys.length = 0;
});

export const exportsForTests = {
keysToString,
modifierKeys,
specialKeys,
};
Loading

0 comments on commit 2344997

Please sign in to comment.