cross-platform key issues

This commit is contained in:
midzelis 2025-10-27 21:23:29 +00:00
parent b29c199491
commit 27e39dc6be
8 changed files with 151 additions and 30 deletions

View file

@ -25,8 +25,6 @@
"add_photos": "Add photos",
"add_tag": "Add tag",
"add_to": "Add to…",
"start_slideshow": "Start slideshow",
"copy_image_to_clipboard": "Copy image to clipboard",
"add_to_album": "Add to album",
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
@ -755,6 +753,7 @@
"copy_error": "Copy error",
"copy_file_path": "Copy file path",
"copy_image": "Copy Image",
"copy_image_to_clipboard": "Copy image to clipboard",
"copy_link": "Copy link",
"copy_link_to_clipboard": "Copy link to clipboard",
"copy_password": "Copy password",
@ -1991,6 +1990,7 @@
"start": "Start",
"start_date": "Start date",
"start_date_before_end_date": "Start date must be before end date",
"start_slideshow": "Start slideshow",
"state": "State",
"status": "Status",
"stop_casting": "Stop casting",

View file

@ -1,3 +1,5 @@
export type ShortcutCallback = (event: KeyboardEvent) => false | unknown;
export type KeyTargets = HTMLElement | Document | Window;
export type KeyDownListenerFactory = (element: KeyTargets) => (event: KeyboardEvent) => void;
@ -10,6 +12,46 @@ export type KeyCombo = {
};
export type KeyInput = string | string[] | KeyCombo | KeyCombo[];
// OS/Browser well known keyboard shortcuts (do not bind to these keys)
const RESERVED_SHORTCUTS: Record<string, KeyCombo[]> = {
// macOS keybindings
mac: [
{ key: 'q', meta: true }, // Quit
{ key: 'w', meta: true }, // Close window
{ key: 'h', meta: true }, // Hide
{ key: 'm', meta: true }, // Minimize
{ key: 'Tab', meta: true }, // App switcher
{ key: ' ', meta: true }, // Spotlight
{ key: 'F3', ctrl: true }, // Mission Control
],
// Windows keybindings
win: [
{ key: 'F4', alt: true }, // Close window
{ key: 'Delete', ctrl: true, alt: true },
{ key: 'Meta' }, // Start menu
{ key: 'l', meta: true }, // Lock
{ key: 'd', meta: true }, // Desktop
{ key: 'Tab', meta: true }, // Task switcher
],
// Linux keybindings
linux: [{ key: 'F4', alt: true }, { key: 'Delete', ctrl: true, alt: true }, { key: 'Meta' }],
// Browser-specific keybindings (cross-platform)
browser: [
{ key: 't', ctrl: true },
{ key: 'w', ctrl: true },
{ key: 'n', ctrl: true },
{ key: 'n', ctrl: true, shift: true },
{ key: 't', ctrl: true, shift: true },
{ key: 'p', ctrl: true, shift: true },
],
};
const ALL_RESERVED_SHORTCUTS = [
...RESERVED_SHORTCUTS.mac,
...RESERVED_SHORTCUTS.win,
...RESERVED_SHORTCUTS.linux,
...RESERVED_SHORTCUTS.browser,
];
const attachmentFactory = (listenerFactory: KeyDownListenerFactory) => (element: KeyTargets) => {
const listener = listenerFactory(element);
element.addEventListener('keydown', listener as EventListener);
@ -34,14 +76,64 @@ export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolea
return ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type);
};
export const keyDownListenerFactory =
(
isActiveFactory: () => () => boolean,
options: { ignoreInputFields?: boolean },
shortcuts: KeyCombo[],
callback: (event: KeyboardEvent) => unknown,
) =>
(event: KeyboardEvent) => {
function isPromise<T = unknown>(obj: unknown): obj is Promise<T> {
return (
obj !== null &&
(typeof obj === 'object' || typeof obj === 'function') &&
typeof (obj as { then?: unknown }).then === 'function'
);
}
function checkForReservedShortcuts(
shortcuts: KeyCombo[],
handler: (event: KeyboardEvent) => void,
): (event: KeyboardEvent) => void {
const formatCombo = (combo: KeyCombo) => {
const parts = [];
if (combo.ctrl) {
parts.push('Ctrl');
}
if (combo.alt) {
parts.push('Alt');
}
if (combo.shift) {
parts.push('Shift');
}
if (combo.meta) {
parts.push('Meta');
}
parts.push(combo.key);
return parts.join('+');
};
for (const shortcut of shortcuts) {
for (const reserved of ALL_RESERVED_SHORTCUTS) {
// Check if shortcuts match (comparing all properties)
if (
shortcut.key.toLowerCase() === reserved.key.toLowerCase() &&
!!shortcut.ctrl === !!reserved.ctrl &&
!!shortcut.alt === !!reserved.alt &&
!!shortcut.shift === !!reserved.shift &&
!!shortcut.meta === !!reserved.meta
) {
console.error(
`[Keyboard Shortcut Warning] Attempting to register reserved shortcut: ${formatCombo(shortcut)}. ` +
`This shortcut is reserved by the OS or browser and may not work as expected.`,
);
return () => void 0;
}
}
}
return handler;
}
export const keyDownListenerFactory = (
isActiveFactory: () => () => boolean,
options: { ignoreInputFields?: boolean },
shortcuts: KeyCombo[],
callback: ShortcutCallback,
) =>
checkForReservedShortcuts(shortcuts, (event: KeyboardEvent) => {
const isActive = isActiveFactory();
if (!isActive() || !isKeyboardEvent(event) || ((options.ignoreInputFields ?? true) && shouldIgnoreEvent(event))) {
return;
@ -51,17 +143,39 @@ export const keyDownListenerFactory =
// pressing 'shift' will cause keyEvents to use capital key - adjust shortcut key to be capital to match
const matchingKey = currentShortcut.shift ? currentShortcut.key.toUpperCase() : currentShortcut.key;
// On mac, pressing 'alt+<somekey>' transforms the key to a special character
// but the code property has the physical key. If the code starts with Key/Digit
// extract the key from the code to consistently process alt keys on all platforms.
let baseKey = event.key;
const code = event.code;
if (code.startsWith('Key')) {
baseKey = code.slice(3).toLowerCase();
} else if (code.startsWith('Digit')) {
baseKey = code.slice(5);
}
if (
event.key === matchingKey &&
!!currentShortcut.ctrl === event.ctrlKey &&
!!currentShortcut.alt === event.altKey &&
!!currentShortcut.shift === event.shiftKey
baseKey !== matchingKey ||
!!currentShortcut.ctrl !== event.ctrlKey ||
!!currentShortcut.alt !== event.altKey ||
!!currentShortcut.shift !== event.shiftKey
) {
callback(event);
continue;
}
const result = callback(event);
if (isPromise(result) || result === false) {
// An event handler must be syncronous to call preventDefault
// if a handler is a promise then it can't rely on the automatic
// preventDefault() behavior, and must manually do that itself.
return;
}
// result must be true or void, in both cases, the event is 'handled'
// so we should prevent the default behavior (prevent OS/browser shortcuts)
event.preventDefault();
return;
}
};
});
export const alwaysTrueFactory = () => () => true;
@ -76,9 +190,11 @@ export const blurOnCtrlEnter = attachmentFactory(() =>
(event.target as HTMLElement).blur(),
),
);
export const altKey = (key: string) => ({ key, alt: true });
export const ctrlKey = (key: string) => ({ key, ctrl: true });
export const shiftKey = (key: string) => ({ key, shift: true });
export const metaKey = (key: string) => ({ key, meta: true });
export const ctrlShiftKey = (key: string) => ({ key, ctrl: true, shift: true });
export const isStringArray = (value: unknown): value is string[] => {
return Array.isArray(value) && typeof value[0] === 'string';
@ -102,7 +218,7 @@ const defaultOptions = {
export const onKeydown = (
keyInput: KeyInput,
callback: (event: KeyboardEvent) => unknown,
callback: ShortcutCallback,
options?: {
// default is true if unspecified
ignoreInputFields?: boolean;

View file

@ -5,6 +5,7 @@ import {
type KeyDownListenerFactory,
type KeyInput,
normalizeKeyInput,
type ShortcutCallback,
} from '$lib/actions/input';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { modalManager } from '@immich/ui';
@ -223,7 +224,7 @@ export const registerShortcutVariant = (first: ShortcutVariant, other: ShortcutV
};
};
export const shortcut = (input: KeyInput, help: ShortcutHelp | string, callback: (event: KeyboardEvent) => unknown) => {
export const shortcut = (input: KeyInput, help: ShortcutHelp | string, callback: ShortcutCallback) => {
const normalized = normalizeKeyInput(input);
return attachmentFactory(normalizeHelp(help, normalized), () =>
keyDownListenerFactory(isActiveFactory, {}, normalized, callback),
@ -255,8 +256,11 @@ export const showShortcutsModal = async () => {
return;
}
showingShortcuts = true;
await modalManager.show(ShortcutsModal, { shortcutVariants, shortcuts: activeScopeShortcuts });
showingShortcuts = false;
try {
await modalManager.show(ShortcutsModal, { shortcutVariants, shortcuts: activeScopeShortcuts });
} finally {
showingShortcuts = false;
}
};
export const resetModal = () => {

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { shiftKey } from '$lib/actions/input';
import { Category, category, shortcut, ShortcutVariant } from '$lib/actions/shortcut.svelte';
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
@ -42,7 +43,7 @@
<svelte:document
{@attach shortcut(
{ key: 'l', shift: shared },
shared ? shiftKey('l') : 'l',
shared
? category(Category.AssetActions, $t('add_to_shared_album'), ShortcutVariant.AddSharedAlbum)
: category(Category.AssetActions, $t('add_to_album'), ShortcutVariant.AddAlbum),

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusOutside } from '$lib/actions/focus-outside';
import { ctrlKey, onKeydown } from '$lib/actions/input';
import { ctrlKey, ctrlShiftKey, onKeydown } from '$lib/actions/input';
import { Category, category, registerShortcutVariant, shortcut, ShortcutVariant } from '$lib/actions/shortcut.svelte';
import { AppRoute } from '$lib/constants';
import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte';
@ -217,7 +217,7 @@
() => input?.select(),
)}
{@attach shortcut(
{ key: 'k', ctrl: true, shift: true },
ctrlShiftKey('k'),
category(Category.Application, $t('open_the_search_filters'), ShortcutVariant.SearchFilter),
onFilterClick,
)}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { ctrlKey } from '$lib/actions/input';
import { ctrlShiftKey } from '$lib/actions/input';
import { shortcut } from '$lib/actions/shortcut.svelte';
import { defaultLang, langs, Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
@ -17,7 +17,7 @@
};
</script>
<svelte:window {@attach shortcut(ctrlKey('t'), $t('dark_theme'), handleToggleTheme)} />
<svelte:window {@attach shortcut(ctrlShiftKey('t'), $t('dark_theme'), handleToggleTheme)} />
{#if !themeManager.theme.system}
{#await langs

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { shiftKey } from '$lib/actions/input';
import { shortcut } from '$lib/actions/shortcut.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import Portal from '$lib/elements/Portal.svelte';
@ -108,8 +109,8 @@
{@attach shortcut('a', $t('select_all'), onSelectAll)}
{@attach shortcut('s', $t('view'), () => onViewAsset(assets[0]))}
{@attach shortcut('d', $t('deselect_all'), onSelectNone)}
{@attach shortcut({ key: 'c', shift: true }, $t('resolve_duplicates'), handleResolve)}
{@attach shortcut({ key: 's', shift: true }, $t('stack'), handleStack)}
{@attach shortcut(shiftKey('c'), $t('resolve_duplicates'), handleResolve)}
{@attach shortcut(shiftKey('s'), $t('stack'), handleStack)}
/>
<div class="rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-256 mx-auto mb-4 py-6 px-0.2">

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { ctrlShiftKey } from '$lib/actions/input';
import { shortcut } from '$lib/actions/shortcut.svelte';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
@ -140,9 +141,7 @@
</svelte:head>
<svelte:document
{@attach shortcut({ key: 'm', ctrl: true, shift: true }, $t('get_my_immich_link'), () =>
copyToClipboard(getMyImmichLink().toString()),
)}
{@attach shortcut(ctrlShiftKey('m'), $t('get_my_immich_link'), () => copyToClipboard(getMyImmichLink().toString()))}
/>
{#if page.data.error}