diff --git a/i18n/en.json b/i18n/en.json index d265c9b9d8..63002c63a8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -468,6 +468,7 @@ "api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.", "api_key_empty": "Your API Key name shouldn't be empty", "api_keys": "API Keys", + "app_actions": "App Actions", "app_architecture_variant": "Variant (Architecture)", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", @@ -489,6 +490,7 @@ "are_you_sure_to_do_this": "Are you sure you want to do this?", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_actions": "Asset Actions", "asset_added_to_album": "Added to album", "asset_adding_to_album": "Adding to album…", "asset_description_updated": "Asset description has been updated", @@ -812,6 +814,7 @@ "delete_permanently_action_prompt": "{count} deleted permanently", "delete_shared_link": "Delete shared link", "delete_shared_link_dialog_title": "Delete Shared Link", + "delete_skip_trash": "Delete (skip trash)", "delete_tag": "Delete tag", "delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?", "delete_user": "Delete user", @@ -1090,6 +1093,8 @@ "find_them_fast": "Find them fast by name with search", "first": "First", "fix_incorrect_match": "Fix incorrect match", + "focus_next": "Focus next", + "focus_previous": "Focus previous", "folder": "Folder", "folder_not_found": "Folder not found", "folders": "Folders", @@ -1101,9 +1106,11 @@ "general": "General", "geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map", "get_help": "Get Help", + "get_my_immich_link": "Get My Immich Link", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "getting_started": "Getting Started", "go_back": "Go back", + "go_to_date": "Go to date", "go_to_folder": "Go to folder", "go_to_search": "Go to search", "gps": "GPS", @@ -1357,10 +1364,13 @@ "monthly_title_text_date_format": "MMMM y", "more": "More", "move": "Move", + "move_left": "Move left", "move_off_locked_folder": "Move out of locked folder", + "move_right": "Move right", "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", + "move_to_trash": "Move to trash", "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive", "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library", "moved_to_trash": "Moved to trash", @@ -1372,6 +1382,7 @@ "name_or_nickname": "Name or nickname", "navigate": "Navigate", "navigate_to_time": "Navigate to Time", + "navigation": "Navigation", "network_requirement_photos_upload": "Use cellular data to backup photos", "network_requirement_videos_upload": "Use cellular data to backup videos", "network_requirements": "Network Requirements", @@ -1391,7 +1402,10 @@ "new_version_available": "NEW VERSION AVAILABLE", "newest_first": "Newest first", "next": "Next", + "next_day": "Next day", "next_memory": "Next memory", + "next_month": "Next month", + "next_year": "Next year", "no": "No", "no_albums_message": "Create an album to organize your photos and videos", "no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.", @@ -1417,6 +1431,7 @@ "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", + "no_shortcuts": "No shortcuts", "no_uploads_in_progress": "No uploads in progress", "not_available": "N/A", "not_in_any_album": "Not in any album", @@ -1546,11 +1561,14 @@ "preset": "Preset", "preview": "Preview", "previous": "Previous", + "previous_day": "Previous day", "previous_memory": "Previous memory", + "previous_month": "Previous month", "previous_or_next_day": "Day forward/back", "previous_or_next_month": "Month forward/back", "previous_or_next_photo": "Photo forward/back", "previous_or_next_year": "Year forward/back", + "previous_year": "Previous year", "primary": "Primary", "privacy": "Privacy", "profile": "Profile", @@ -1600,6 +1618,7 @@ "purchase_settings_server_activated": "The server product key is managed by the admin", "query_asset_id": "Query Asset ID", "queue_status": "Queuing {count}/{total}", + "quick_actions": "Quick Actions", "rating": "Star rating", "rating_clear": "Clear rating", "rating_count": "{count, plural, one {# star} other {# stars}}", @@ -1781,6 +1800,7 @@ "selected": "Selected", "selected_count": "{count, plural, other {# selected}}", "selected_gps_coordinates": "Selected GPS Coordinates", + "selection": "Selection", "send_message": "Send message", "send_welcome_email": "Send welcome email", "server_endpoint": "Server Endpoint", @@ -2115,6 +2135,7 @@ "videos": "Videos", "videos_count": "{count, plural, one {# Video} other {# Videos}}", "view": "View", + "view_actions": "View Actions", "view_album": "View Album", "view_all": "View All", "view_all_users": "View all users", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 269b246de9..619ba5791d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -681,8 +681,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.34.0 - version: 0.34.2(@internationalized/date@3.8.2)(svelte@5.39.11) + specifier: ^0.35 + version: 0.35.0(@internationalized/date@3.8.2)(svelte@5.39.11) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -2726,8 +2726,8 @@ packages: cpu: [x64] os: [win32] - '@immich/ui@0.34.2': - resolution: {integrity: sha512-tWjEV1prSZ9VLes69Ha9jnnZj6tbv/9+PdQjsK+5zK7sQok/l7kyzAobj5z4XRD11XtGk/cAqi/ZOlqRWvZilA==} + '@immich/ui@0.35.0': + resolution: {integrity: sha512-dyjYLzJUNzR1aIQZEqPwfHutjuvK5t3wy3pp19Vm5bcQ4ira4Gk75flCgd9oEON5mlaxtlaUJVMeNor66bK9pg==} peerDependencies: svelte: ^5.0.0 @@ -14180,7 +14180,7 @@ snapshots: '@img/sharp-win32-x64@0.34.4': optional: true - '@immich/ui@0.34.2(@internationalized/date@3.8.2)(svelte@5.39.11)': + '@immich/ui@0.35.0(@internationalized/date@3.8.2)(svelte@5.39.11)': dependencies: '@mdi/js': 7.4.47 bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.39.11) diff --git a/web/package.json b/web/package.json index 5eacb72b85..f0c166af87 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.34.0", + "@immich/ui": "^0.35", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/lib/actions/__test__/focus-trap.spec.ts b/web/src/lib/actions/__test__/focus-trap.spec.ts index b03064a91d..c4d43dbc71 100644 --- a/web/src/lib/actions/__test__/focus-trap.spec.ts +++ b/web/src/lib/actions/__test__/focus-trap.spec.ts @@ -24,7 +24,7 @@ describe('focusTrap action', () => { it('supports backward focus wrapping', async () => { render(FocusTrapTest, { show: true }); await tick(); - await user.keyboard('{Shift>}{Tab}{/Shift}'); + await user.keyboard('{Shift}{Tab}{/Shift}'); expect(document.activeElement).toEqual(screen.getByTestId('three')); }); diff --git a/web/src/lib/actions/click-outside.ts b/web/src/lib/actions/click-outside.ts index 599a97af75..6b8025c46b 100644 --- a/web/src/lib/actions/click-outside.ts +++ b/web/src/lib/actions/click-outside.ts @@ -1,11 +1,21 @@ -import { matchesShortcut } from '$lib/actions/shortcut'; import type { ActionReturn } from 'svelte/action'; +import type { KeyCombo } from './input'; interface Options { onOutclick?: () => void; onEscape?: () => void; } +export const matchesShortcut = (event: KeyboardEvent, shortcut: KeyCombo) => { + return ( + shortcut.key.toLowerCase() === event.key.toLowerCase() && + Boolean(shortcut.alt) === event.altKey && + Boolean(shortcut.ctrl) === event.ctrlKey && + Boolean(shortcut.shift) === event.shiftKey && + Boolean(shortcut.meta) === event.metaKey + ); +}; + /** * Calls a function when a click occurs outside of the element, or when the escape key is pressed. * @param node diff --git a/web/src/lib/actions/context-menu-navigation.ts b/web/src/lib/actions/context-menu-navigation.ts index 89b7b76d24..b1445dcdd3 100644 --- a/web/src/lib/actions/context-menu-navigation.ts +++ b/web/src/lib/actions/context-menu-navigation.ts @@ -1,4 +1,4 @@ -import { shortcuts } from '$lib/actions/shortcut'; +import { onKeydown } from '$lib/actions/input'; import { tick } from 'svelte'; import type { Action } from 'svelte/action'; @@ -95,13 +95,22 @@ export const contextMenuNavigation: Action = (node, option currentEl?.click(); }; - const { destroy } = shortcuts(node, [ - { shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) }, - { shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) }, - { shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) }, - { shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) }, - { shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) }, - ]); + const unregisterUp = onKeydown('ArrowUp', (event) => (event.preventDefault(), moveSelection('up', event)))(node); + const unregisterDown = onKeydown( + 'ArrowDown', + (event) => (event.preventDefault(), moveSelection('down', event)), + )(node); + const unregisterEscape = onKeydown('Escape', (event) => onEscape(event))(node); + const unregisterSpace = onKeydown(' ', (event) => handleClick(event))(node); + const unregisterEnter = onKeydown(' ', (event) => handleClick(event))(node); + let destroy = () => { + unregisterUp(); + unregisterDown(); + unregisterEscape(); + unregisterSpace(); + unregisterEnter(); + destroy = () => void 0; + }; return { update(newOptions) { diff --git a/web/src/lib/actions/focus-trap.ts b/web/src/lib/actions/focus-trap.ts index 2b03282c2d..c0e12a5895 100644 --- a/web/src/lib/actions/focus-trap.ts +++ b/web/src/lib/actions/focus-trap.ts @@ -1,4 +1,4 @@ -import { shortcuts } from '$lib/actions/shortcut'; +import { onKeydown, shiftKey } from '$lib/actions/input'; import { getTabbable } from '$lib/utils/focus-util'; import { tick } from 'svelte'; @@ -39,33 +39,35 @@ export function focusTrap(container: HTMLElement, options?: Options) { ]; }; - const { destroy: destroyShortcuts } = shortcuts(container, [ - { - ignoreInputFields: false, - preventDefault: false, - shortcut: { key: 'Tab' }, - onShortcut: (event) => { - const [firstElement, lastElement] = getFocusableElements(); - if (document.activeElement === lastElement && withDefaults(options).active) { - event.preventDefault(); - firstElement?.focus(); - } - }, + const unregisterTab = onKeydown( + 'Tab', + (event) => { + const [firstElement, lastElement] = getFocusableElements(); + if (document.activeElement === lastElement && withDefaults(options).active) { + event.preventDefault(); + firstElement?.focus(); + } }, - { - ignoreInputFields: false, - preventDefault: false, - shortcut: { key: 'Tab', shift: true }, - onShortcut: (event) => { - const [firstElement, lastElement] = getFocusableElements(); - if (document.activeElement === firstElement && withDefaults(options).active) { - event.preventDefault(); - lastElement?.focus(); - } - }, - }, - ]); + { ignoreInputFields: false }, + )(container); + const unregisterShiftTab = onKeydown( + shiftKey('Tab'), + (event) => { + const [firstElement, lastElement] = getFocusableElements(); + if (document.activeElement === firstElement && withDefaults(options).active) { + event.preventDefault(); + lastElement?.focus(); + } + }, + { ignoreInputFields: false }, + )(container); + + let destroyShortcuts = () => { + unregisterTab(); + unregisterShiftTab(); + destroyShortcuts = () => void 0; + }; return { update(newOptions?: Options) { options = newOptions; diff --git a/web/src/lib/actions/input.ts b/web/src/lib/actions/input.ts new file mode 100644 index 0000000000..e15f0eb1da --- /dev/null +++ b/web/src/lib/actions/input.ts @@ -0,0 +1,113 @@ +export type KeyTargets = HTMLElement | Document | Window; +export type KeyDownListenerFactory = (element: KeyTargets) => (event: KeyboardEvent) => void; + +export type KeyCombo = { + key: string; + alt?: boolean; + ctrl?: boolean; + shift?: boolean; + meta?: boolean; +}; +export type KeyInput = string | string[] | KeyCombo | KeyCombo[]; + +const attachmentFactory = (listenerFactory: KeyDownListenerFactory) => (element: KeyTargets) => { + const listener = listenerFactory(element); + element.addEventListener('keydown', listener as EventListener); + return () => { + element.removeEventListener('keydown', listener as EventListener); + }; +}; + +function isKeyboardEvent(event: Event): event is KeyboardEvent { + return 'key' in event; +} + +/** Determines whether an event should be ignored. The event will be ignored if: + * - The element dispatching the event is not the same as the element which the event listener is attached to + * - The element dispatching the event is an input field + */ +export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => { + if (event.target === event.currentTarget) { + return false; + } + const type = (event.target as HTMLInputElement).type; + 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) => { + const isActive = isActiveFactory(); + if (!isActive() || !isKeyboardEvent(event) || ((options.ignoreInputFields ?? true) && shouldIgnoreEvent(event))) { + return; + } + + for (const currentShortcut of shortcuts) { + // 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; + + if ( + event.key === matchingKey && + !!currentShortcut.ctrl === event.ctrlKey && + !!currentShortcut.alt === event.altKey && + !!currentShortcut.shift === event.shiftKey + ) { + callback(event); + return; + } + } + }; + +export const alwaysTrueFactory = () => () => true; + +export const blurOnEnter = attachmentFactory(() => + keyDownListenerFactory(alwaysTrueFactory, {}, [{ key: 'Enter' }], (event: KeyboardEvent) => + (event.target as HTMLElement).blur(), + ), +); + +export const blurOnCtrlEnter = attachmentFactory(() => + keyDownListenerFactory(alwaysTrueFactory, {}, [{ key: 'Enter', ctrl: true }], (event: KeyboardEvent) => + (event.target as HTMLElement).blur(), + ), +); +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 isStringArray = (value: unknown): value is string[] => { + return Array.isArray(value) && typeof value[0] === 'string'; +}; + +export const normalizeKeyInput = (shortcut: KeyInput): KeyCombo[] => { + if (typeof shortcut === 'string') { + return [{ key: shortcut }]; + } else if (isStringArray(shortcut)) { + return shortcut.map((key) => ({ key })); + } else if (Array.isArray(shortcut)) { + return shortcut; + } else { + return [shortcut]; + } +}; + +const defaultOptions = { + ignoreInputFields: true, +}; + +export const onKeydown = ( + keyInput: KeyInput, + callback: (event: KeyboardEvent) => unknown, + options?: { + // default is true if unspecified + ignoreInputFields?: boolean; + }, +) => + attachmentFactory(() => + keyDownListenerFactory(alwaysTrueFactory, { ...defaultOptions, ...options }, normalizeKeyInput(keyInput), callback), + ); diff --git a/web/src/lib/actions/list-navigation.ts b/web/src/lib/actions/list-navigation.ts index cd4214f700..8b9281cb7f 100644 --- a/web/src/lib/actions/list-navigation.ts +++ b/web/src/lib/actions/list-navigation.ts @@ -1,4 +1,4 @@ -import { shortcuts } from '$lib/actions/shortcut'; +import { onKeydown } from '$lib/actions/input'; import type { Action } from 'svelte/action'; /** @@ -30,10 +30,17 @@ export const listNavigation: Action = ( } }; - const { destroy } = shortcuts(node, [ - { shortcut: { key: 'ArrowUp' }, onShortcut: () => moveFocus('up'), ignoreInputFields: false }, - { shortcut: { key: 'ArrowDown' }, onShortcut: () => moveFocus('down'), ignoreInputFields: false }, - ]); + const unregisterUp = onKeydown('ArrowUp', (event) => (event.preventDefault(), moveFocus('up')), { + ignoreInputFields: false, + })(node); + const unregisterDown = onKeydown('ArrowDown', (event) => (event.preventDefault(), moveFocus('down')), { + ignoreInputFields: false, + })(node); + let destroy = () => { + unregisterUp(); + unregisterDown(); + destroy = () => void 0; + }; return { update(newContainer) { diff --git a/web/src/lib/actions/shortcut.svelte.ts b/web/src/lib/actions/shortcut.svelte.ts new file mode 100644 index 0000000000..1b0c09332f --- /dev/null +++ b/web/src/lib/actions/shortcut.svelte.ts @@ -0,0 +1,311 @@ +import { + alwaysTrueFactory, + type KeyCombo, + keyDownListenerFactory, + type KeyDownListenerFactory, + type KeyInput, + normalizeKeyInput, +} from '$lib/actions/input'; +import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; +import { modalManager } from '@immich/ui'; +import { untrack } from 'svelte'; +import { t } from 'svelte-i18n'; +import type { Attachment } from 'svelte/attachments'; +import { SvelteMap } from 'svelte/reactivity'; +import { get } from 'svelte/store'; + +export enum Category { + Application = 'app_actions', + AssetActions = 'asset_actions', + ViewActions = 'view_actions', + QuickActions = 'quick_actions', + Navigation = 'navigation', + Selection = 'selection', +} + +export const getCategoryString = (category: Category) => get(t)(category); + +export const category = (category: Category, text: string, variant?: ShortcutVariant): ShortcutHelp => { + return { + variant, + category, + text, + }; +}; + +const explicitCategoryList = [ + Category.QuickActions, + Category.AssetActions, + Category.ViewActions, + Category.Selection, + Category.Navigation, + Category.Application, +]; + +export const sortCategories = (categories: Category[]) => + [...categories].sort((a, b) => { + const indexA = explicitCategoryList.indexOf(a); + const indexB = explicitCategoryList.indexOf(b); + return (indexA === -1 ? Infinity : indexA) - (indexB === -1 ? Infinity : indexB); + }); + +export enum ShortcutVariant { + SelectAll, + DeselectAll, + AddAlbum, + AddSharedAlbum, + PrevAsset, + NextAsset, + Delete, + PermDelete, + PreviousAsset, + PreviousDay, + NextDay, + PreviousMonth, + NextMonth, + PreviousYear, + NextYear, + Trash, + Search, + SearchFilter, + FocusNext, + FocusPrevious, +} + +type ShortcutHelp = { + variant?: ShortcutVariant; + category?: Category; + text: string; + info?: string; +}; +export type KeyboardHelp = ShortcutHelp & { key: string[][] }; + +type InternalKeyboardHelp = KeyboardHelp & { scope: number; $InternalHelpId: string }; +type KeyTargets = HTMLElement | Document | Window; + +const isMacOS = /Mac(intosh|Intel)/.test(globalThis.navigator.userAgent); + +// state variables +let helpArray: InternalKeyboardHelp[] = $state([]); +// eslint-disable-next-line svelte/no-unnecessary-state-wrap +let shortcutVariants = $state(new SvelteMap()); +let currentScope = $state(0); +let showingShortcuts = $state(false); + +const activeScopeShortcuts: KeyboardHelp[] = $derived( + helpArray.filter((helpObjectArrayObject) => helpObjectArrayObject.scope === currentScope), +); + +function isLetter(c: string) { + return c.toLowerCase() != c.toUpperCase(); +} + +const expandKeys = (shortcuts: KeyCombo[]) => { + return shortcuts.map((s) => { + const keys: string[] = []; + const keyIsLetter = isLetter(s.key); + if (s.shift && isMacOS) { + keys.push('⇧'); + } else if (s.shift) { + keys.push('Shift'); + } + if (s.ctrl && isMacOS) { + keys.push('⌃'); + } else if (s.ctrl) { + keys.push('Ctrl'); + } + if (s.alt && isMacOS) { + keys.push('⌥'); + } else if (s.alt) { + keys.push('Alt'); + } + if (s.meta && isMacOS) { + keys.push('⌘'); + } else if (s.meta) { + keys.push('❖'); + } + switch (s.key) { + case ' ': { + if (isMacOS) { + keys.push('␣'); + } else { + keys.push('space'); + } + break; + } + case 'ArrowLeft': { + keys.push('←'); + break; + } + case 'ArrowRight': { + keys.push('→'); + break; + } + case 'Escape': { + keys.push('esc'); + break; + } + case 'Delete': { + if (isMacOS) { + keys.push('⌦'); + } else { + keys.push('del'); + } + break; + } + default: { + if (keyIsLetter && s.shift && !s.alt && !s.ctrl && !s.meta) { + keys.splice(0); + keys.push(s.key.toUpperCase()); + } else { + keys.push(s.key); + } + } + } + return keys; + }); +}; + +function normalizeHelp(help: ShortcutHelp | string, shortcuts: KeyCombo[]): KeyboardHelp | null { + if (!help) { + return null; + } else if (typeof help === 'string') { + return { + text: help, + category: Category.Application, + key: expandKeys(shortcuts), + }; + } else { + return { category: Category.Application, ...help, key: expandKeys(shortcuts) }; + } +} + +function generateId() { + const timestamp = Date.now().toString(36); // Current timestamp in base 36 + const random = Math.random().toString(36).slice(2, 9); // Random string from Math.random() + return timestamp + random; +} + +export const attachmentFactory = + (help: KeyboardHelp | null, listenerFactory: KeyDownListenerFactory): Attachment => + (element: KeyTargets) => { + return untrack(() => { + const listener = listenerFactory(element); + const internalId = generateId(); + let helpObject: InternalKeyboardHelp; + if (help) { + helpObject = { + ...help, + scope: currentScope, + $InternalHelpId: internalId, + }; + helpArray.push(helpObject); + } + element.addEventListener('keydown', listener as EventListener); + return () => { + if (helpObject) { + const index = helpArray.findIndex((helpObject) => helpObject && helpObject.$InternalHelpId === internalId); + if (index !== -1) { + helpArray.splice(index, 1); + } + } + element.removeEventListener('keydown', listener as EventListener); + }; + }); + }; + +export const registerShortcutVariant = (first: ShortcutVariant, other: ShortcutVariant) => { + return () => { + shortcutVariants.set(first, other); + return () => { + shortcutVariants.delete(first); + }; + }; +}; + +export const shortcut = (input: KeyInput, help: ShortcutHelp | string, callback: (event: KeyboardEvent) => unknown) => { + const normalized = normalizeKeyInput(input); + return attachmentFactory(normalizeHelp(help, normalized), () => + keyDownListenerFactory(isActiveFactory, {}, normalized, callback), + ); +}; + +export const conditionalShortcut = (condition: () => boolean, shortcut: () => Attachment) => { + if (condition()) { + return shortcut(); + } + return () => void 0; +}; + +const isActiveFactory = () => { + const savedScope = currentScope; + return () => modalManager.openCount === 0 && savedScope === currentScope; +}; + +const pushScope = () => untrack(() => currentScope++); +const popScope = () => untrack(() => currentScope--); + +export const newShortcutScope = () => { + pushScope(); + return () => popScope(); +}; + +export const showShortcutsModal = async () => { + if (showingShortcuts) { + return; + } + showingShortcuts = true; + await modalManager.show(ShortcutsModal, { shortcutVariants, shortcuts: activeScopeShortcuts }); + showingShortcuts = false; +}; + +export const resetModal = () => { + // only used by ShortcutsModal - used to restore state after HMR. + // do not use for any other reason + showingShortcuts = false; +}; + +const startup = () => { + // add the default '?' shortcut to launch the help menu + const unregister = attachmentFactory( + { category: Category.Application, text: 'Open Shortcuts Help', key: [['?']] }, + () => keyDownListenerFactory(alwaysTrueFactory, {}, [{ key: '?', shift: true }], showShortcutsModal), + )(globalThis as unknown as Window); + // put global variants here + shortcutVariants.set(ShortcutVariant.AddAlbum, ShortcutVariant.AddSharedAlbum); + return unregister as () => void; +}; + +const registerHmr = () => { + const hot = import.meta.hot; + if (!hot) { + startup(); + return; + } + if (import.meta.hot!.data?.shortcut_state) { + const shortcut_state = import.meta.hot!.data.shortcut_state; + const _pairMap = new SvelteMap(); + for (const element of shortcut_state.pairMap.keys()) { + _pairMap.set(element, shortcut_state.pairMap.get(element)); + } + if (shortcut_state) { + helpArray = shortcut_state.helpArray; + showingShortcuts = shortcut_state.showingShortcuts; + currentScope = shortcut_state.currentScope; + shortcutVariants = _pairMap; + } + } + // startup() must be called after the hot-state has been restored + const unregister = startup(); + hot.on('vite:beforeUpdate', () => { + const shortcut_state = { + helpArray: [...$state.snapshot(helpArray)], + showingShortcuts, + currentScope, + pairMap: $state.snapshot(shortcutVariants), + }; + unregister(); + import.meta.hot!.data.shortcut_state = shortcut_state; + }); +}; +registerHmr(); diff --git a/web/src/lib/actions/shortcut.ts b/web/src/lib/actions/shortcut.ts deleted file mode 100644 index f7b3009403..0000000000 --- a/web/src/lib/actions/shortcut.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { ActionReturn } from 'svelte/action'; - -export type Shortcut = { - key: string; - alt?: boolean; - ctrl?: boolean; - shift?: boolean; - meta?: boolean; -}; - -export type ShortcutOptions = { - shortcut: Shortcut; - /** If true, the event handler will not execute if the event comes from an input field */ - ignoreInputFields?: boolean; - onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown; - preventDefault?: boolean; -}; - -export const shortcutLabel = (shortcut: Shortcut) => { - let label = ''; - - if (shortcut.ctrl) { - label += 'Ctrl '; - } - if (shortcut.alt) { - label += 'Alt '; - } - if (shortcut.meta) { - label += 'Cmd '; - } - if (shortcut.shift) { - label += '⇧'; - } - label += shortcut.key.toUpperCase(); - - return label; -}; - -/** Determines whether an event should be ignored. The event will be ignored if: - * - The element dispatching the event is not the same as the element which the event listener is attached to - * - The element dispatching the event is an input field - */ -export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => { - if (event.target === event.currentTarget) { - return false; - } - const type = (event.target as HTMLInputElement).type; - return ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type); -}; - -export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => { - return ( - shortcut.key.toLowerCase() === event.key.toLowerCase() && - Boolean(shortcut.alt) === event.altKey && - Boolean(shortcut.ctrl) === event.ctrlKey && - Boolean(shortcut.shift) === event.shiftKey && - Boolean(shortcut.meta) === event.metaKey - ); -}; - -/** Bind a single keyboard shortcut to node. */ -export const shortcut = ( - node: T, - option: ShortcutOptions, -): ActionReturn> => { - const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]); - - return { - update(newOption) { - shortcutsUpdate?.([newOption]); - }, - destroy, - }; -}; - -/** Binds multiple keyboard shortcuts to node */ -export const shortcuts = ( - node: T, - options: ShortcutOptions[], -): ActionReturn[]> => { - function onKeydown(event: KeyboardEvent) { - const ignoreShortcut = shouldIgnoreEvent(event); - for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) { - if (ignoreInputFields && ignoreShortcut) { - continue; - } - - if (matchesShortcut(event, shortcut)) { - if (preventDefault) { - event.preventDefault(); - } - onShortcut(event as KeyboardEvent & { currentTarget: T }); - return; - } - } - } - - node.addEventListener('keydown', onKeydown); - - return { - update(newOptions) { - options = newOptions; - }, - destroy() { - node.removeEventListener('keydown', onKeydown); - }, - }; -}; diff --git a/web/src/lib/components/album-page/album-title.svelte b/web/src/lib/components/album-page/album-title.svelte index 84463dc6d5..7c195459ad 100644 --- a/web/src/lib/components/album-page/album-title.svelte +++ b/web/src/lib/components/album-page/album-title.svelte @@ -1,5 +1,5 @@ e.currentTarget.blur() }} onblur={handleUpdateName} class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned ? 'hover:border-gray-400' @@ -46,4 +45,5 @@ disabled={!isOwned} title={$t('edit_title')} placeholder={$t('add_a_title')} + {@attach blurOnEnter} /> diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 8d169fbc8f..ad4bb0fa70 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,5 +1,5 @@ { - if (!$showAssetViewer && assetInteraction.selectionActive) { - cancelMultiselect(assetInteraction); - } - }, - }} + {@attach shortcut('Escape', category(Category.Application, $t('previous_or_next_photo')), () => { + if (!$showAssetViewer && assetInteraction.selectionActive) { + cancelMultiselect(assetInteraction); + } + })} />
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 2c6ac54ef7..75f2eac733 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,5 +1,5 @@ - + - import { shortcut } from '$lib/actions/shortcut'; + import { shiftKey } from '$lib/actions/input'; + import { Category, category, shortcut } from '$lib/actions/shortcut.svelte'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AssetAction } from '$lib/constants'; @@ -28,7 +29,13 @@ }; - + - import { shortcut } from '$lib/actions/shortcut'; + import { category, Category, shortcut } from '$lib/actions/shortcut.svelte'; import { IconButton } from '@immich/ui'; import { mdiArrowLeft } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -11,7 +11,7 @@ let { onClose }: Props = $props(); - + - import { shortcuts } from '$lib/actions/shortcut'; + import { shiftKey } from '$lib/actions/input'; + import { Category, category, registerShortcutVariant, shortcut, ShortcutVariant } from '$lib/actions/shortcut.svelte'; import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte'; import { - NotificationType, notificationController, + NotificationType, } from '$lib/components/shared-components/notification/notification'; import { AssetAction } from '$lib/constants'; import Portal from '$lib/elements/Portal.svelte'; @@ -75,10 +76,19 @@ trashOrDelete(asset.isTrashed) }, - { shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) }, - ]} + {@attach shortcut( + 'Delete', + asset.isTrashed + ? category(Category.AssetActions, $t('permanently_delete'), ShortcutVariant.Delete) + : category(Category.AssetActions, $t('move_to_trash'), ShortcutVariant.Delete), + () => trashOrDelete(asset.isTrashed), + )} + {@attach shortcut( + shiftKey('Delete'), + category(Category.AssetActions, $t('permanently_delete'), ShortcutVariant.PermDelete), + () => trashOrDelete(true), + )} + {@attach registerShortcutVariant(ShortcutVariant.Delete, ShortcutVariant.PermDelete)} /> - import { shortcut } from '$lib/actions/shortcut'; + import { shiftKey } from '$lib/actions/input'; + import { Category, category, shortcut } from '$lib/actions/shortcut.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; @@ -19,7 +20,7 @@ const onDownloadFile = async () => downloadFile(await getAssetInfo({ ...authManager.params, id: asset.id })); - + {#if !menuItem} - import { shortcut } from '$lib/actions/shortcut'; + import { Category, category, shortcut } from '$lib/actions/shortcut.svelte'; import { NotificationType, notificationController, @@ -8,10 +8,10 @@ import { handleError } from '$lib/utils/handle-error'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { updateAsset, type AssetResponseDto } from '@immich/sdk'; + import { IconButton } from '@immich/ui'; import { mdiHeart, mdiHeartOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { OnAction } from './action'; - import { IconButton } from '@immich/ui'; interface Props { asset: AssetResponseDto; @@ -46,7 +46,7 @@ }; - + - import { shortcuts } from '$lib/actions/shortcut'; + import { Category, category, shortcut, ShortcutVariant } from '$lib/actions/shortcut.svelte'; import { Icon } from '@immich/ui'; import { mdiChevronRight } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -13,10 +13,11 @@ diff --git a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte index 082d96b29e..3155a1dd57 100644 --- a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte @@ -1,5 +1,5 @@ diff --git a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte b/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte index 8ac087bca6..90c516c829 100644 --- a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte @@ -1,5 +1,5 @@ - + import { resolve } from '$app/paths'; import { autoGrowHeight } from '$lib/actions/autogrow'; - import { shortcut } from '$lib/actions/shortcut'; + import { onKeydown } from '$lib/actions/input'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants'; @@ -258,10 +258,7 @@ bind:value={message} use:autoGrowHeight={{ height: '5px', value: message }} placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')} - use:shortcut={{ - shortcut: { key: 'Enter' }, - onShortcut: () => handleSendComment(), - }} + {@attach onKeydown({ key: 'Enter' }, () => void handleSendComment())} class="h-[18px] {disabled ? 'cursor-not-allowed' : ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200" diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 0fe8610b3d..7efc331ef8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,5 +1,6 @@ - +
import { resolve } from '$app/paths'; - import { shortcut } from '$lib/actions/shortcut'; + import { shortcut } from '$lib/actions/shortcut.svelte'; import { AppRoute } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; @@ -34,7 +34,7 @@ }; - + {#if isOwner && !authManager.isSharedLink}
diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index 203f1c6587..10d82e37f6 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -1,5 +1,5 @@ - +
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 7f4c254a59..fe743bfbcd 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,5 +1,6 @@ {#if imageError}
diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte index ea4519bc53..eb3d2160ea 100644 --- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte +++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte @@ -1,5 +1,5 @@ - +
handleNextAsset() }, - { shortcut: { key: 'd' }, onShortcut: () => handleNextAsset() }, - { shortcut: { key: 'ArrowLeft' }, onShortcut: () => handlePreviousAsset() }, - { shortcut: { key: 'a' }, onShortcut: () => handlePreviousAsset() }, - { shortcut: { key: 'Escape' }, onShortcut: () => handleEscape() }, - ]} + {@attach shortcut(['ArrowRight', 'd'], $t('next'), handleNextAsset)} + {@attach shortcut(['ArrowLeft', 'a'], $t('previous'), handlePreviousAsset)} + {@attach shortcut(['Escape'], $t('timeline'), handleEscape)} /> {#if assetInteraction.selectionActive} diff --git a/web/src/lib/components/shared-components/autogrow-textarea.svelte b/web/src/lib/components/shared-components/autogrow-textarea.svelte index ac0255f4e1..537ee7f786 100644 --- a/web/src/lib/components/shared-components/autogrow-textarea.svelte +++ b/web/src/lib/components/shared-components/autogrow-textarea.svelte @@ -1,6 +1,6 @@ - + !!shortcut, + () => registerShortcut(shortcut!, { category: shortcutCategory, text, variant }, onClick), + )} +/> @@ -64,11 +69,6 @@
{text} - {#if shortcutLabel} - - {shortcutLabel} - - {/if}
{#if subtitle}

diff --git a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte index 7a2b646141..40618720fb 100644 --- a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte @@ -1,6 +1,6 @@ input?.select() }, - { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, - ]} + {@attach shortcut( + { key: 'k', ctrl: true }, + category(Category.Application, $t('search_your_photos'), ShortcutVariant.Search), + () => input?.select(), + )} + {@attach shortcut( + { key: 'k', ctrl: true, shift: true }, + category(Category.Application, $t('open_the_search_filters'), ShortcutVariant.SearchFilter), + onFilterClick, + )} + {@attach registerShortcutVariant(ShortcutVariant.Search, ShortcutVariant.SearchFilter)} />

@@ -247,14 +254,11 @@ aria-activedescendant={selectedId ?? ''} aria-expanded={showSuggestions && isSearchSuggestions} aria-autocomplete="list" - use:shortcuts={[ - { shortcut: { key: 'Escape' }, onShortcut: onEscape }, - { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, - { shortcut: { key: 'ArrowUp' }, onShortcut: () => onArrow(-1) }, - { shortcut: { key: 'ArrowDown' }, onShortcut: () => onArrow(1) }, - { shortcut: { key: 'Enter' }, onShortcut: onEnter, preventDefault: false }, - { shortcut: { key: 'ArrowDown', alt: true }, onShortcut: openDropdown }, - ]} + {@attach onKeydown('Enter', onEnter)} + {@attach onKeydown('Escape', onEscape)} + {@attach onKeydown('ArrowUp', () => onArrow(-1))} + {@attach onKeydown('ArrowDown', () => onArrow(1))} + {@attach onKeydown({ key: 'ArrowDown', alt: true }, openDropdown)} /> diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte index 1c04d855da..1cc3e76928 100644 --- a/web/src/lib/components/shared-components/theme-button.svelte +++ b/web/src/lib/components/shared-components/theme-button.svelte @@ -1,9 +1,11 @@ - handleToggleTheme() }} /> + {#if !themeManager.theme.system} {#await langs diff --git a/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte b/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte index 2f0f5b21b7..803d321259 100644 --- a/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte +++ b/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte @@ -1,4 +1,5 @@ - + {#if menuItem} diff --git a/web/src/lib/components/timeline/actions/TagAction.svelte b/web/src/lib/components/timeline/actions/TagAction.svelte index d2235d7c74..9cf24078a1 100644 --- a/web/src/lib/components/timeline/actions/TagAction.svelte +++ b/web/src/lib/components/timeline/actions/TagAction.svelte @@ -1,5 +1,5 @@ - + {#if menuItem} diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte index d3f151a974..530d0071df 100644 --- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte +++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte @@ -1,6 +1,14 @@ - + !!handleEscape, + () => + shortcut('Escape', category(Category.Selection, $t('deselect_all'), ShortcutVariant.DeselectAll), handleEscape!), + )} + {@attach registerShortcutVariant(ShortcutVariant.SelectAll, ShortcutVariant.DeselectAll)} + {@attach shortcut( + 'ArrowLeft', + category(Category.Navigation, $t('move_left'), ShortcutVariant.NextAsset), + handleMoveLeft, + )} + {@attach shortcut( + 'ArrowRight', + category(Category.Navigation, $t('move_right'), ShortcutVariant.PreviousAsset), + handleMoveRight, + )} + {@attach registerShortcutVariant(ShortcutVariant.NextAsset, ShortcutVariant.PreviousAsset)} + {@attach shortcut( + 'd', + category(Category.Navigation, $t('previous_day'), ShortcutVariant.PreviousDay), + handlePreviousDay, + )} + {@attach shortcut( + shiftKey('d'), + category(Category.Navigation, $t('next_day'), ShortcutVariant.NextDay), + handleNextDay, + )} + {@attach registerShortcutVariant(ShortcutVariant.PreviousDay, ShortcutVariant.NextDay)} + {@attach shortcut( + 'm', + category(Category.Navigation, $t('previous_month'), ShortcutVariant.PreviousMonth), + handlePreviousMonth, + )} + {@attach shortcut( + shiftKey('m'), + category(Category.Navigation, $t('next_month'), ShortcutVariant.NextMonth), + handleNextMonth, + )} + {@attach registerShortcutVariant(ShortcutVariant.PreviousMonth, ShortcutVariant.NextMonth)} + {@attach shortcut( + 'y', + category(Category.Navigation, $t('previous_year'), ShortcutVariant.PreviousYear), + handlePreviousYear, + )} + {@attach shortcut( + shiftKey('y'), + category(Category.Navigation, $t('next_year'), ShortcutVariant.NextYear), + handleNextYear, + )} + {@attach registerShortcutVariant(ShortcutVariant.PreviousYear, ShortcutVariant.NextYear)} + {@attach shortcut('g', category(Category.Navigation, $t('go_to_date')), handleNavigateToTime)} + {@attach shortcut( + 'Delete', + category(Category.Selection, isTrashEnabled ? $t('move_to_trash') : $t('delete'), ShortcutVariant.Trash), + handleDelete, + )} + {@attach shortcut( + shiftKey('Delete'), + category(Category.Selection, isTrashEnabled ? $t('delete_skip_trash') : $t('delete'), ShortcutVariant.Delete), + handleForceDelete, + )} + {@attach registerShortcutVariant(ShortcutVariant.Trash, ShortcutVariant.Delete)} + {@attach shortcut('s', category(Category.Selection, $t('stack')), handleStackAssets)} + {@attach shortcut(shiftKey('a'), category(Category.Selection, $t('archive')), handleToggleArchive)} +/> {#if isShowDeleteConfirmation} - import { shortcuts } from '$lib/actions/shortcut'; + 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'; import { authManager } from '$lib/managers/auth-manager.svelte'; @@ -105,16 +105,11 @@ onViewAsset(assets[0]), - }, - { shortcut: { key: 'd' }, onShortcut: onSelectNone }, - { shortcut: { key: 'c', shift: true }, onShortcut: handleResolve }, - { shortcut: { key: 's', shift: true }, onShortcut: handleStack }, - ]} + {@attach shortcut('a', $t('select_all'), onSelectAll)} + {@attach shortcut('a', $t('view'), () => onViewAsset(assets[0]))} + {@attach shortcut('d', $t('deselect_all'), onSelectNone)} + {@attach shortcut('c', $t('resolve_duplicates'), handleResolve)} + {@attach shortcut('c', $t('stack'), handleStack)} />
diff --git a/web/src/lib/elements/StarRating.svelte b/web/src/lib/elements/StarRating.svelte index f345dc86b7..0b024a4910 100644 --- a/web/src/lib/elements/StarRating.svelte +++ b/web/src/lib/elements/StarRating.svelte @@ -1,6 +1,6 @@ - +{#snippet row(col: Snippet<[KeyboardHelp]>, shortcut1: KeyboardHelp, shortcut2: KeyboardHelp | undefined)} +
+ {@render col(shortcut1)} + {#if shortcut2} + {@render col(shortcut2)} + {/if} +
+{/snippet} +{#snippet col(shortcut: KeyboardHelp)} +
+ {#each shortcut.key as key (key)} +
+ {#each key as sequence (sequence)} + + {sequence} + + {/each} +
+ {/each} +
+

{shortcut.text}

+{/snippet} + + (resetModal(), onClose())}> -
- {#if shortcuts.general.length > 0} +
+ {#if isEmpty}{$t('no_shortcuts')}{/if} + {#each categories as category (category)} + {@const actions = categorizedPrimaryShortcuts.get(category)!}
-

{$t('general')}

+

{getCategoryString(category)}

- {#each shortcuts.general as shortcut (shortcut.key.join('-'))} -
-
- {#each shortcut.key as key (key)} -

- {key} -

- {/each} -
-

{shortcut.action}

-
+ {#each actions as shortcut (shortcut)} + {@const paired = shortcut.variant + ? getSecondaryShortcut(shortcutVariants.get(shortcut.variant)) + : undefined} + {@render row(col, shortcut, paired)} {/each}
- {/if} - {#if shortcuts.actions.length > 0} -
-

{$t('actions')}

-
- {#each shortcuts.actions as shortcut (shortcut.key.join('-'))} -
-
- {#each shortcut.key as key (key)} -

- {key} -

- {/each} -
-
-

{shortcut.action}

- {#if shortcut.info} - - {/if} -
-
- {/each} -
-
- {/if} + {/each}
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 5a782c5a11..0ce3ceed57 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -2,8 +2,8 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { focusTrap } from '$lib/actions/focus-trap'; + import { blurOnEnter } from '$lib/actions/input'; import { scrollMemory } from '$lib/actions/scroll-memory'; - import { shortcut } from '$lib/actions/shortcut'; import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte'; import PeopleCard from '$lib/components/faces-page/people-card.svelte'; import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte'; @@ -377,7 +377,7 @@ class=" bg-white dark:bg-immich-dark-gray border-gray-100 placeholder-gray-400 text-center dark:border-gray-900 w-full rounded-2xl mt-2 py-2 text-sm text-primary" value={person.name} placeholder={$t('add_a_name')} - use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }} + {@attach blurOnEnter} onfocusin={() => onNameChangeInputFocus(person)} onfocusout={() => onNameChangeSubmit(newName, person)} oninput={(event) => onNameChangeInputUpdate(event)} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 748e0b7100..ce97400a74 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -1,5 +1,6 @@ - +
{#if assetInteraction.selectionActive} diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte index 43c214fd07..c9296ec395 100644 --- a/web/src/routes/(user)/user-settings/+page.svelte +++ b/web/src/routes/(user)/user-settings/+page.svelte @@ -1,8 +1,8 @@ @@ -251,7 +238,7 @@ color="secondary" icon={mdiKeyboard} title={$t('show_keyboard_shortcuts')} - onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })} + onclick={() => showShortcutsModal()} aria-label={$t('show_keyboard_shortcuts')} /> diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index c159c46136..0fa7897a02 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,7 +1,7 @@