mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
cross-platform key issues
This commit is contained in:
parent
b29c199491
commit
27e39dc6be
8 changed files with 151 additions and 30 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue