diff --git a/i18n/en.json b/i18n/en.json index 91fdc93cae..cc752b418c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/web/src/lib/actions/input.ts b/web/src/lib/actions/input.ts index e15f0eb1da..db2580faea 100644 --- a/web/src/lib/actions/input.ts +++ b/web/src/lib/actions/input.ts @@ -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 = { + // 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(obj: unknown): obj is Promise { + 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+' 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; diff --git a/web/src/lib/actions/shortcut.svelte.ts b/web/src/lib/actions/shortcut.svelte.ts index 1b0c09332f..6425a89ab0 100644 --- a/web/src/lib/actions/shortcut.svelte.ts +++ b/web/src/lib/actions/shortcut.svelte.ts @@ -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 = () => { diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index 75f2eac733..391c6f96e2 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -1,4 +1,5 @@ - + {#if !themeManager.theme.system} {#await langs diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 9184c7af6d..36b8c79d7d 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -1,4 +1,5 @@