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_photos": "Add photos",
"add_tag": "Add tag", "add_tag": "Add tag",
"add_to": "Add to…", "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": "Add to album",
"add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}",
@ -755,6 +753,7 @@
"copy_error": "Copy error", "copy_error": "Copy error",
"copy_file_path": "Copy file path", "copy_file_path": "Copy file path",
"copy_image": "Copy Image", "copy_image": "Copy Image",
"copy_image_to_clipboard": "Copy image to clipboard",
"copy_link": "Copy link", "copy_link": "Copy link",
"copy_link_to_clipboard": "Copy link to clipboard", "copy_link_to_clipboard": "Copy link to clipboard",
"copy_password": "Copy password", "copy_password": "Copy password",
@ -1991,6 +1990,7 @@
"start": "Start", "start": "Start",
"start_date": "Start date", "start_date": "Start date",
"start_date_before_end_date": "Start date must be before end date", "start_date_before_end_date": "Start date must be before end date",
"start_slideshow": "Start slideshow",
"state": "State", "state": "State",
"status": "Status", "status": "Status",
"stop_casting": "Stop casting", "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 KeyTargets = HTMLElement | Document | Window;
export type KeyDownListenerFactory = (element: KeyTargets) => (event: KeyboardEvent) => void; export type KeyDownListenerFactory = (element: KeyTargets) => (event: KeyboardEvent) => void;
@ -10,6 +12,46 @@ export type KeyCombo = {
}; };
export type KeyInput = string | string[] | KeyCombo | 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 attachmentFactory = (listenerFactory: KeyDownListenerFactory) => (element: KeyTargets) => {
const listener = listenerFactory(element); const listener = listenerFactory(element);
element.addEventListener('keydown', listener as EventListener); 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); return ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type);
}; };
export const keyDownListenerFactory = function isPromise<T = unknown>(obj: unknown): obj is Promise<T> {
( return (
isActiveFactory: () => () => boolean, obj !== null &&
options: { ignoreInputFields?: boolean }, (typeof obj === 'object' || typeof obj === 'function') &&
shortcuts: KeyCombo[], typeof (obj as { then?: unknown }).then === 'function'
callback: (event: KeyboardEvent) => unknown, );
) => }
(event: KeyboardEvent) => {
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(); const isActive = isActiveFactory();
if (!isActive() || !isKeyboardEvent(event) || ((options.ignoreInputFields ?? true) && shouldIgnoreEvent(event))) { if (!isActive() || !isKeyboardEvent(event) || ((options.ignoreInputFields ?? true) && shouldIgnoreEvent(event))) {
return; 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 // 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; 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 ( if (
event.key === matchingKey && baseKey !== matchingKey ||
!!currentShortcut.ctrl === event.ctrlKey && !!currentShortcut.ctrl !== event.ctrlKey ||
!!currentShortcut.alt === event.altKey && !!currentShortcut.alt !== event.altKey ||
!!currentShortcut.shift === event.shiftKey !!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; 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; export const alwaysTrueFactory = () => () => true;
@ -76,9 +190,11 @@ export const blurOnCtrlEnter = attachmentFactory(() =>
(event.target as HTMLElement).blur(), (event.target as HTMLElement).blur(),
), ),
); );
export const altKey = (key: string) => ({ key, alt: true });
export const ctrlKey = (key: string) => ({ key, ctrl: true }); export const ctrlKey = (key: string) => ({ key, ctrl: true });
export const shiftKey = (key: string) => ({ key, shift: true }); export const shiftKey = (key: string) => ({ key, shift: true });
export const metaKey = (key: string) => ({ key, meta: 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[] => { export const isStringArray = (value: unknown): value is string[] => {
return Array.isArray(value) && typeof value[0] === 'string'; return Array.isArray(value) && typeof value[0] === 'string';
@ -102,7 +218,7 @@ const defaultOptions = {
export const onKeydown = ( export const onKeydown = (
keyInput: KeyInput, keyInput: KeyInput,
callback: (event: KeyboardEvent) => unknown, callback: ShortcutCallback,
options?: { options?: {
// default is true if unspecified // default is true if unspecified
ignoreInputFields?: boolean; ignoreInputFields?: boolean;

View file

@ -5,6 +5,7 @@ import {
type KeyDownListenerFactory, type KeyDownListenerFactory,
type KeyInput, type KeyInput,
normalizeKeyInput, normalizeKeyInput,
type ShortcutCallback,
} from '$lib/actions/input'; } from '$lib/actions/input';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { modalManager } from '@immich/ui'; 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); const normalized = normalizeKeyInput(input);
return attachmentFactory(normalizeHelp(help, normalized), () => return attachmentFactory(normalizeHelp(help, normalized), () =>
keyDownListenerFactory(isActiveFactory, {}, normalized, callback), keyDownListenerFactory(isActiveFactory, {}, normalized, callback),
@ -255,8 +256,11 @@ export const showShortcutsModal = async () => {
return; return;
} }
showingShortcuts = true; showingShortcuts = true;
await modalManager.show(ShortcutsModal, { shortcutVariants, shortcuts: activeScopeShortcuts }); try {
showingShortcuts = false; await modalManager.show(ShortcutsModal, { shortcutVariants, shortcuts: activeScopeShortcuts });
} finally {
showingShortcuts = false;
}
}; };
export const resetModal = () => { export const resetModal = () => {

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ctrlKey } from '$lib/actions/input'; import { ctrlShiftKey } from '$lib/actions/input';
import { shortcut } from '$lib/actions/shortcut.svelte'; import { shortcut } from '$lib/actions/shortcut.svelte';
import { defaultLang, langs, Theme } from '$lib/constants'; import { defaultLang, langs, Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte'; import { themeManager } from '$lib/managers/theme-manager.svelte';
@ -17,7 +17,7 @@
}; };
</script> </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} {#if !themeManager.theme.system}
{#await langs {#await langs

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { shiftKey } from '$lib/actions/input';
import { shortcut } from '$lib/actions/shortcut.svelte'; import { shortcut } from '$lib/actions/shortcut.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte'; import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
@ -108,8 +109,8 @@
{@attach shortcut('a', $t('select_all'), onSelectAll)} {@attach shortcut('a', $t('select_all'), onSelectAll)}
{@attach shortcut('s', $t('view'), () => onViewAsset(assets[0]))} {@attach shortcut('s', $t('view'), () => onViewAsset(assets[0]))}
{@attach shortcut('d', $t('deselect_all'), onSelectNone)} {@attach shortcut('d', $t('deselect_all'), onSelectNone)}
{@attach shortcut({ key: 'c', shift: true }, $t('resolve_duplicates'), handleResolve)} {@attach shortcut(shiftKey('c'), $t('resolve_duplicates'), handleResolve)}
{@attach shortcut({ key: 's', shift: true }, $t('stack'), handleStack)} {@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"> <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"> <script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation'; import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { ctrlShiftKey } from '$lib/actions/input';
import { shortcut } from '$lib/actions/shortcut.svelte'; import { shortcut } from '$lib/actions/shortcut.svelte';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte'; import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
@ -140,9 +141,7 @@
</svelte:head> </svelte:head>
<svelte:document <svelte:document
{@attach shortcut({ key: 'm', ctrl: true, shift: true }, $t('get_my_immich_link'), () => {@attach shortcut(ctrlShiftKey('m'), $t('get_my_immich_link'), () => copyToClipboard(getMyImmichLink().toString()))}
copyToClipboard(getMyImmichLink().toString()),
)}
/> />
{#if page.data.error} {#if page.data.error}