feat: enhance keyboard actions/help modal

This commit is contained in:
midzelis 2025-09-30 12:39:24 +00:00
parent cc1cd299f3
commit f5d6532051
52 changed files with 1067 additions and 674 deletions

View file

@ -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",

10
pnpm-lock.yaml generated
View file

@ -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)

View file

@ -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",

View file

@ -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'));
});

View file

@ -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

View file

@ -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<HTMLElement, Options> = (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) {

View file

@ -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;

View file

@ -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),
);

View file

@ -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<HTMLElement, HTMLElement | undefined> = (
}
};
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) {

View file

@ -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<ShortcutVariant, ShortcutVariant>());
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<KeyTargets> =>
(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<KeyTargets>) => {
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<ShortcutVariant, ShortcutVariant>();
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();

View file

@ -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<T = HTMLElement> = {
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 = <T extends HTMLElement>(
node: T,
option: ShortcutOptions<T>,
): ActionReturn<ShortcutOptions<T>> => {
const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]);
return {
update(newOption) {
shortcutsUpdate?.([newOption]);
},
destroy,
};
};
/** Binds multiple keyboard shortcuts to node */
export const shortcuts = <T extends HTMLElement>(
node: T,
options: ShortcutOptions<T>[],
): ActionReturn<ShortcutOptions<T>[]> => {
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);
},
};
};

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { blurOnEnter } from '$lib/actions/input';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { t } from 'svelte-i18n';
@ -36,7 +36,6 @@
</script>
<input
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => 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}
/>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import CastButton from '$lib/cast/cast-button.svelte';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
@ -48,14 +48,11 @@
</script>
<svelte:document
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
if (!$showAssetViewer && assetInteraction.selectionActive) {
cancelMultiselect(assetInteraction);
}
},
}}
{@attach shortcut('Escape', category(Category.Application, $t('previous_or_next_photo')), () => {
if (!$showAssetViewer && assetInteraction.selectionActive) {
cancelMultiselect(assetInteraction);
}
})}
/>
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
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';
import { AssetAction } from '$lib/constants';
@ -40,7 +40,15 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'l', shift: shared }, onShortcut: onClick }} />
<svelte:document
{@attach shortcut(
{ key: 'l', shift: shared },
shared
? category(Category.AssetActions, $t('add_to_shared_album'), ShortcutVariant.AddSharedAlbum)
: category(Category.AssetActions, $t('add_to_album'), ShortcutVariant.AddAlbum),
onClick,
)}
/>
<MenuOption
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}

View file

@ -1,5 +1,6 @@
<script lang="ts">
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 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'a', shift: true }, onShortcut: onArchive }} />
<svelte:document
{@attach shortcut(
shiftKey('a'),
category(Category.AssetActions, asset.isArchived ? $t('unarchive') : $t('to_archive')),
onArchive,
)}
/>
<MenuOption
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}

View file

@ -1,5 +1,5 @@
<script lang="ts">
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();
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<svelte:document {@attach shortcut('Escape', category(Category.Navigation, $t('go_back')), onClose)} />
<IconButton
color="secondary"

View file

@ -1,9 +1,10 @@
<script lang="ts">
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 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Delete' }, onShortcut: () => 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)}
/>
<IconButton

View file

@ -1,5 +1,6 @@
<script lang="ts">
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 }));
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
<svelte:document {@attach shortcut(shiftKey('d'), category(Category.QuickActions, $t('download')), onDownloadFile)} />
{#if !menuItem}
<IconButton

View file

@ -1,5 +1,5 @@
<script lang="ts">
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 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'f' }, onShortcut: toggleFavorite }} />
<svelte:document {@attach shortcut('f', category(Category.AssetActions, 'Toggle favorite'), toggleFavorite)} />
<IconButton
color="secondary"

View file

@ -1,5 +1,5 @@
<script lang="ts">
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 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNextAsset },
{ shortcut: { key: 'd' }, onShortcut: onNextAsset },
]}
{@attach shortcut(
['ArrowRight', 'd'],
category(Category.Navigation, $t('view_next_asset'), ShortcutVariant.NextAsset),
onNextAsset,
)}
/>
<NavigationArea onClick={onNextAsset} label={$t('view_next_asset')}>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { category, Category, shortcut, ShortcutVariant } from '$lib/actions/shortcut.svelte';
import { Icon } from '@immich/ui';
import { mdiChevronLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -13,10 +13,11 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPreviousAsset },
{ shortcut: { key: 'a' }, onShortcut: onPreviousAsset },
]}
{@attach shortcut(
['ArrowLeft', 'a'],
category(Category.Navigation, $t('view_previous_asset'), ShortcutVariant.PrevAsset),
onPreviousAsset,
)}
/>
<NavigationArea onClick={onPreviousAsset} label={$t('view_previous_asset')}>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { category, Category, shortcut } from '$lib/actions/shortcut.svelte';
import { IconButton } from '@immich/ui';
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -11,7 +11,7 @@
let { onShowDetail }: Props = $props();
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
<svelte:document {@attach shortcut('i', category(Category.QuickActions, $t('info')), onShowDetail)} />
<IconButton
color="secondary"

View file

@ -1,7 +1,7 @@
<script lang="ts">
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"

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { focusTrap } from '$lib/actions/focus-trap';
import { newShortcutScope, registerShortcutVariant, ShortcutVariant } from '$lib/actions/shortcut.svelte';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
@ -381,9 +382,15 @@
handlePromiseError(handleGetAllAlbums());
}
});
const endScope = newShortcutScope();
onMount(() => endScope);
</script>
<svelte:document bind:fullscreenElement />
<svelte:document
bind:fullscreenElement
{@attach registerShortcutVariant(ShortcutVariant.PrevAsset, ShortcutVariant.NextAsset)}
/>
<section
id="immich-asset-viewer"

View file

@ -1,6 +1,6 @@
<script lang="ts">
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 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleAddTag }} />
<svelte:document {@attach shortcut('t', $t('tag_assets'), handleAddTag)} />
{#if isOwner && !authManager.isSharedLink}
<section class="px-4 mt-4">

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { shortcut } from '$lib/actions/shortcut.svelte';
import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
import { websocketEvents } from '$lib/stores/websocket';
import { type AssetResponseDto } from '@immich/sdk';
@ -39,7 +39,7 @@
const onConfirm = () => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog());
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<svelte:document {@attach shortcut('Escape', $t('close'), onClose)} />
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<div class="flex place-items-center gap-2">

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { ctrlKey, metaKey } from '$lib/actions/input';
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
@ -205,13 +206,13 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true },
{ shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true },
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
]}
{@attach shortcut('z', category(Category.ViewActions, $t('zoom_image')), zoomToggle)}
{@attach shortcut('s', category(Category.ViewActions, 'Start slideshow'), onPlaySlideshow)}
{@attach shortcut(
[ctrlKey('c'), metaKey('c')],
category(Category.QuickActions, 'Copy image to clipboard'),
onCopyShortcut,
)}
/>
{#if imageError}
<div class="h-full w-full">

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
import { ProgressBarStatus } from '$lib/constants';
import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte';
@ -136,22 +136,16 @@
<svelte:document
onmousemove={showControlBar}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNext },
{
shortcut: { key: ' ' },
onShortcut: () => {
if (progressBarStatus === ProgressBarStatus.Paused) {
progressBar?.play();
} else {
progressBar?.pause();
}
},
preventDefault: true,
},
]}
{@attach shortcut('Escape', category(Category.Application, $t('exit_slideshow')), onClose)}
{@attach shortcut('ArrowLeft', category(Category.Navigation, $t('previous')), onClose)}
{@attach shortcut('ArrowRight', category(Category.Navigation, $t('next')), onClose)}
{@attach shortcut(' ', $t('pause'), () => {
if (progressBarStatus === ProgressBarStatus.Paused) {
progressBar?.play();
} else {
progressBar?.pause();
}
})}
/>
{/* @ts-expect-error https://github.com/Rezi/svelte-gestures/issues/38#issuecomment-3315953573 */ null}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { shortcut } from '$lib/actions/shortcut.svelte';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
import {
@ -107,7 +107,7 @@
let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]);
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<svelte:document {@attach shortcut('Escape', $t('close'), onClose)} />
<div
class="fixed top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"

View file

@ -3,7 +3,7 @@
import { page } from '$app/state';
import { intersectionObserver } from '$lib/actions/intersection-observer';
import { resizeObserver } from '$lib/actions/resize-observer';
import { shortcuts } from '$lib/actions/shortcut';
import { shortcut } from '$lib/actions/shortcut.svelte';
import MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte';
import MemoryVideoViewer from '$lib/components/memory-page/memory-video-viewer.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
@ -312,15 +312,9 @@
</script>
<svelte:document
use:shortcuts={$isViewing
? []
: [
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => 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}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { autoGrowHeight } from '$lib/actions/autogrow';
import { shortcut } from '$lib/actions/shortcut';
import { blurOnCtrlEnter } from '$lib/actions/input';
interface Props {
content?: string;
@ -26,10 +26,7 @@
class="resize-none {className}"
onfocusout={updateContent}
{placeholder}
use:shortcut={{
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),
}}
{@attach blurOnCtrlEnter}
use:autoGrowHeight={{ value: newContent }}
data-testid="autogrow-textarea">{content}</textarea
>

View file

@ -21,7 +21,7 @@
<script lang="ts">
import { focusOutside } from '$lib/actions/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import { onKeydown } from '$lib/actions/input';
import { generateId } from '$lib/utils/generate-id';
import { Icon, IconButton, Label } from '@immich/ui';
import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
@ -255,15 +255,10 @@
<div
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
use:focusOutside={{ onFocusOut: deactivate }}
use:shortcuts={[
{
shortcut: { key: 'Escape' },
onShortcut: (event) => {
event.stopPropagation();
closeDropdown();
},
},
]}
{@attach onKeydown({ key: 'Escape' }, (event) => {
event.stopPropagation();
closeDropdown();
})}
>
<div>
{#if isActive}
@ -295,44 +290,27 @@
type="text"
value={searchQuery}
use:forceFocusInput
use:shortcuts={[
{
shortcut: { key: 'ArrowUp' },
onShortcut: () => {
openDropdown();
void incrementSelectedIndex(-1);
},
},
{
shortcut: { key: 'ArrowDown' },
onShortcut: () => {
openDropdown();
void incrementSelectedIndex(1);
},
},
{
shortcut: { key: 'ArrowDown', alt: true },
onShortcut: () => {
openDropdown();
},
},
{
shortcut: { key: 'Enter' },
onShortcut: () => {
if (selectedIndex !== undefined && filteredOptions.length > 0) {
handleSelect(filteredOptions[selectedIndex]);
}
closeDropdown();
},
},
{
shortcut: { key: 'Escape' },
onShortcut: (event) => {
event.stopPropagation();
closeDropdown();
},
},
]}
{@attach onKeydown({ key: 'ArrowUp' }, () => {
openDropdown();
void incrementSelectedIndex(-1);
})}
{@attach onKeydown({ key: 'ArrowDown' }, () => {
openDropdown();
void incrementSelectedIndex(1);
})}
{@attach onKeydown({ key: 'ArrowDown', alt: true }, () => {
openDropdown();
})}
{@attach onKeydown({ key: 'Enter' }, () => {
if (selectedIndex !== undefined && filteredOptions.length > 0) {
handleSelect(filteredOptions[selectedIndex]);
}
closeDropdown();
})}
{@attach onKeydown({ key: 'Escape' }, (event) => {
event.stopPropagation();
closeDropdown();
})}
/>
<div

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { shortcuts } from '$lib/actions/shortcut';
import { onKeydown } from '$lib/actions/input';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
@ -174,20 +174,7 @@
/>
</div>
{#if isOpen || !hideContent}
<div
use:shortcuts={[
{
shortcut: { key: 'Tab' },
onShortcut: closeDropdown,
preventDefault: false,
},
{
shortcut: { key: 'Tab', shift: true },
onShortcut: closeDropdown,
preventDefault: false,
},
]}
>
<div {@attach onKeydown([{ key: 'Tab' }, { key: 'Tab', shift: true }], closeDropdown)}>
<ContextMenu
{direction}
ariaActiveDescendant={$selectedIdStore}

View file

@ -1,6 +1,11 @@
<script lang="ts">
import type { Shortcut } from '$lib/actions/shortcut';
import { shortcut as bindShortcut, shortcutLabel as computeShortcutLabel } from '$lib/actions/shortcut';
import type { KeyCombo } from '$lib/actions/input';
import {
Category,
conditionalShortcut,
shortcut as registerShortcut,
ShortcutVariant,
} from '$lib/actions/shortcut.svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { generateId } from '$lib/utils/generate-id';
import { Icon } from '@immich/ui';
@ -12,8 +17,9 @@
activeColor?: string;
textColor?: string;
onClick: () => void;
shortcut?: Shortcut | null;
shortcutLabel?: string;
shortcut?: KeyCombo | null;
shortcutCategory?: Category;
variant?: ShortcutVariant;
}
let {
@ -24,7 +30,8 @@
textColor = 'text-immich-fg dark:text-immich-dark-bg',
onClick,
shortcut = null,
shortcutLabel = '',
shortcutCategory,
variant,
}: Props = $props();
let id: string = generateId();
@ -35,16 +42,14 @@
$optionClickCallbackStore?.();
onClick();
};
if (shortcut && !shortcutLabel) {
shortcutLabel = computeShortcutLabel(shortcut);
}
const bindShortcutIfSet = shortcut
? (n: HTMLElement) => bindShortcut(n, { shortcut, onShortcut: onClick })
: () => {};
</script>
<svelte:document use:bindShortcutIfSet />
<svelte:document
{@attach conditionalShortcut(
() => !!shortcut,
() => registerShortcut(shortcut!, { category: shortcutCategory, text, variant }, onClick),
)}
/>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
@ -64,11 +69,6 @@
<div class="w-full">
<div class="flex justify-between">
{text}
{#if shortcutLabel}
<span class="text-gray-500 ps-4">
{shortcutLabel}
</span>
{/if}
</div>
{#if subtitle}
<p class="text-xs text-gray-500">

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { shortcuts } from '$lib/actions/shortcut';
import { onKeydown } from '$lib/actions/input';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { generateId } from '$lib/utils/generate-id';
@ -80,16 +80,7 @@
selectedId: $selectedIdStore,
selectionChanged: (id) => ($selectedIdStore = id),
}}
use:shortcuts={[
{
shortcut: { key: 'Tab' },
onShortcut: closeContextMenu,
},
{
shortcut: { key: 'Tab', shift: true },
onShortcut: closeContextMenu,
},
]}
{@attach onKeydown([{ key: 'Tab' }, { key: 'Tab', shift: true }], closeContextMenu)}
>
<section class="fixed start-0 top-0 flex h-dvh w-dvw" {oncontextmenu} role="presentation">
<ContextMenu

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { shouldIgnoreEvent } from '$lib/actions/shortcut';
import { shouldIgnoreEvent } from '$lib/actions/input';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { fileUploadHandler } from '$lib/utils/file-uploader';

View file

@ -1,12 +1,21 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import { ctrlKey, shiftKey } from '$lib/actions/input';
import {
Category,
category,
conditionalShortcut,
newShortcutScope,
registerShortcutVariant,
shortcut,
ShortcutVariant,
} from '$lib/actions/shortcut.svelte';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
@ -20,11 +29,9 @@
import { navigate } from '$lib/utils/navigation';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
interface Props {
initialAssetId?: string;
@ -282,54 +289,12 @@
}
};
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusNextAsset = () => {
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
};
const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
...(arrowNavigation
? [
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
]
: []),
];
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
})(),
);
const handleNext = async (): Promise<boolean> => {
try {
let asset: { id: string } | undefined;
@ -465,8 +430,51 @@
onkeydown={onKeyDown}
onkeyup={onKeyUp}
onselectstart={onSelectStart}
use:shortcuts={shortcutList}
onscroll={() => updateSlidingWindow()}
{@attach newShortcutScope}
{@attach shortcut('/', $t('places'), () => goto(AppRoute.EXPLORE))}
{@attach shortcut(
ctrlKey('a'),
category(Category.Selection, $t('select_all'), ShortcutVariant.SelectAll),
selectAllAssets,
)}
{@attach shortcut(
[{ key: 'Escape' }, ctrlKey('d')],
category(Category.Selection, $t('deselect_all'), ShortcutVariant.DeselectAll),
deselectAllAssets,
)}
{@attach registerShortcutVariant(ShortcutVariant.SelectAll, ShortcutVariant.DeselectAll)}
{@attach shortcut(
'Delete',
category(Category.Selection, isTrashEnabled ? $t('move_to_trash') : $t('delete'), ShortcutVariant.Trash),
onDelete,
)}
{@attach shortcut(
shiftKey('Delete'),
category(Category.Selection, isTrashEnabled ? $t('delete_skip_trash') : $t('delete'), ShortcutVariant.Delete),
onForceDelete,
)}
{@attach registerShortcutVariant(ShortcutVariant.Trash, ShortcutVariant.Delete)}
{@attach shortcut(shiftKey('a'), category(Category.Selection, $t('archive')), toggleArchive)}
{@attach conditionalShortcut(
() => arrowNavigation,
() =>
shortcut(
'ArrowRight',
category(Category.Navigation, $t('focus_next'), ShortcutVariant.FocusNext),
focusNextAsset,
),
)}
{@attach conditionalShortcut(
() => arrowNavigation,
() =>
shortcut(
'ArrowLeft',
category(Category.Navigation, $t('focus_previous'), ShortcutVariant.FocusPrevious),
focusPreviousAsset,
),
)}
{@attach registerShortcutVariant(ShortcutVariant.FocusNext, ShortcutVariant.FocusPrevious)}
/>
{#if isShowDeleteConfirmation}

View file

@ -1,7 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusOutside } from '$lib/actions/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import { 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';
import { searchStore } from '$lib/stores/search.svelte';
@ -206,11 +207,17 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => 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)}
/>
<div class="w-full relative z-auto" use:focusOutside={{ onFocusOut }} tabindex="-1">
@ -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)}
/>
<!-- SEARCH HISTORY BOX -->

View file

@ -1,9 +1,11 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { ctrlKey } 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';
import { lang } from '$lib/stores/preferences.store';
import { ThemeSwitcher } from '@immich/ui';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
const handleToggleTheme = () => {
@ -15,7 +17,7 @@
};
</script>
<svelte:window use:shortcut={{ shortcut: { key: 't', alt: true }, onShortcut: () => handleToggleTheme() }} />
<svelte:window {@attach shortcut(ctrlKey('t'), $t('dark_theme'), handleToggleTheme)} />
{#if !themeManager.theme.system}
{#await langs

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { Category, ShortcutVariant } from '$lib/actions/shortcut.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
@ -11,9 +12,10 @@
interface Props {
shared?: boolean;
onAddToAlbum?: OnAddToAlbum;
shortcutCategory?: Category;
}
let { shared = false, onAddToAlbum = () => {} }: Props = $props();
let { shared = false, onAddToAlbum = () => {}, shortcutCategory }: Props = $props();
const { getAssets } = getAssetControlContext();
@ -43,4 +45,6 @@
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
shortcut={{ key: 'l', shift: shared }}
{shortcutCategory}
variant={shared ? ShortcutVariant.AddSharedAlbum : ShortcutVariant.AddAlbum}
/>

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
@ -13,9 +12,10 @@
interface Props {
filename?: string;
menuItem?: boolean;
shortcutCategory?: Category;
}
let { filename = 'immich.zip', menuItem = false }: Props = $props();
let { filename = 'immich.zip', menuItem = false, shortcutCategory }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
@ -33,7 +33,13 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: handleDownloadFiles }} />
<svelte:document
{@attach shortcut(
{ key: 'd', shift: true },
category(shortcutCategory ?? Category.QuickActions, $t('download')),
handleDownloadFiles,
)}
/>
{#if menuItem}
<MenuOption text={$t('download')} icon={mdiDownload} onClick={handleDownloadFiles} />

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { Category, shortcut } from '$lib/actions/shortcut.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { IconButton, modalManager } from '@immich/ui';
@ -9,9 +9,10 @@
interface Props {
menuItem?: boolean;
shortcutCategory?: Category;
}
let { menuItem = false }: Props = $props();
let { menuItem = false, shortcutCategory }: Props = $props();
const text = $t('tag');
const icon = mdiTagMultipleOutline;
@ -20,15 +21,17 @@
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
if (assets.length === 0) {
return;
}
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (success) {
clearSelect();
}
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleTagAssets }} />
<svelte:document {@attach shortcut('t', { text, category: shortcutCategory }, handleTagAssets)} />
{#if menuItem}
<MenuOption {text} {icon} onClick={handleTagAssets} />

View file

@ -1,6 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import { ctrlKey, shiftKey } from '$lib/actions/input';
import {
Category,
category,
conditionalShortcut,
registerShortcutVariant,
shortcut,
ShortcutVariant,
} from '$lib/actions/shortcut.svelte';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import {
setFocusToAsset as setFocusAssetInit,
@ -10,9 +18,7 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
@ -21,6 +27,7 @@
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
timelineManager: TimelineManager;
@ -34,65 +41,26 @@
timelineManager = $bindable(),
assetInteraction,
isShowDeleteConfirmation = $bindable(false),
onEscape,
onEscape: handleEscape,
scrollToAsset,
}: Props = $props();
const { isViewing: showAssetViewer } = assetViewingStore;
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
);
assetInteraction.clearMultiselect();
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const onStackAssets = async () => {
const result = await stackAssets(assetInteraction.selectedAssets);
updateStackedAssetInTimeline(timelineManager, result);
onEscape?.();
};
const toggleArchive = async () => {
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
});
deselectAllAssets();
};
let shiftKeyIsDown = $state(false);
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
$effect(() => {
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
// Event handlers
const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
@ -121,77 +89,161 @@
}
};
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
$effect(() => {
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
// Shortcut handlers
const handleExploreNavigation = () => goto(AppRoute.EXPLORE);
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
const handleSelectAllAssets = () => selectAllAssets(timelineManager, assetInteraction);
const handleOpenDateModal = async () => {
const handleMoveLeft = () => setFocusTo('later', 'asset');
const handleMoveRight = () => setFocusTo('earlier', 'asset');
const handlePreviousDay = () => setFocusTo('earlier', 'day');
const handleNextDay = () => setFocusTo('later', 'day');
const handlePreviousMonth = () => setFocusTo('earlier', 'day');
const handleNextMonth = () => setFocusTo('later', 'day');
const handlePreviousYear = () => setFocusTo('earlier', 'year');
const handleNextYear = () => setFocusTo('later', 'year');
const handleNavigateToTime = async () => {
const asset = await modalManager.show(NavigateToDateModal, { timelineManager });
if (asset) {
setFocusAsset(asset);
}
};
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
);
assetInteraction.clearMultiselect();
};
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
const handleDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
return shortcuts;
})(),
);
const handleForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const handleStackAssets = async () => {
const result = await stackAssets(assetInteraction.selectedAssets);
updateStackedAssetInTimeline(timelineManager, result);
handleEscape?.();
};
const handleToggleArchive = async () => {
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
});
deselectAllAssets();
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
<svelte:document
onkeydown={onKeyDown}
onkeyup={onKeyUp}
onselectstart={onSelectStart}
{@attach shortcut('/', $t('explore'), handleExploreNavigation)}
{@attach shortcut(
ctrlKey('a'),
category(Category.Selection, $t('select_all'), ShortcutVariant.SelectAll),
handleSelectAllAssets,
)}
{@attach conditionalShortcut(
() => !!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}
<DeleteAssetDialog

View file

@ -1,5 +1,5 @@
<script lang="ts">
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 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'a' }, onShortcut: onSelectAll },
{
shortcut: { key: 's' },
onShortcut: () => 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)}
/>
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-216 mx-auto mb-4">

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { focusOutside } from '$lib/actions/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import { onKeydown } from '$lib/actions/input';
import { generateId } from '$lib/utils/generate-id';
import { Icon } from '@immich/ui';
import { t } from 'svelte-i18n';
@ -60,10 +60,7 @@
class="text-primary w-fit cursor-default"
onmouseleave={() => setHoverRating(0)}
use:focusOutside={{ onFocusOut: reset }}
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
]}
{@attach onKeydown(['ArrowLeft', 'ArrowRight'], (event) => event.stopPropagation())}
>
<legend class="sr-only">{$t('rating')}</legend>
<div class="flex flex-row" data-testid="star-container">

View file

@ -1,104 +1,103 @@
<script lang="ts">
import { Icon, Modal, ModalBody } from '@immich/ui';
import { mdiInformationOutline } from '@mdi/js';
import {
getCategoryString,
resetModal,
ShortcutVariant,
sortCategories,
type KeyboardHelp,
} from '$lib/actions/shortcut.svelte';
import { Kbd, Modal, ModalBody } from '@immich/ui';
import { type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Shortcuts {
general: ExplainedShortcut[];
actions: ExplainedShortcut[];
}
interface ExplainedShortcut {
key: string[];
action: string;
info?: string;
}
import { SvelteMap } from 'svelte/reactivity';
interface Props {
onClose: () => void;
shortcuts?: Shortcuts;
shortcutVariants: Map<ShortcutVariant, ShortcutVariant>;
shortcuts: KeyboardHelp[];
}
let {
onClose,
shortcuts = {
general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
{ key: ['D', 'd'], action: $t('previous_or_next_day') },
{ key: ['M', 'm'], action: $t('previous_or_next_month') },
{ key: ['Y', 'y'], action: $t('previous_or_next_year') },
{ key: ['g'], action: $t('navigate_to_time') },
{ key: ['x'], action: $t('select') },
{ key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
{ key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
],
actions: [
{ key: ['f'], action: $t('favorite_or_unfavorite_photo') },
{ key: ['i'], action: $t('show_or_hide_info') },
{ key: ['s'], action: $t('stack_selected_photos') },
{ key: ['l'], action: $t('add_to_album') },
{ key: ['t'], action: $t('tag_assets') },
{ key: ['⇧', 'l'], action: $t('add_to_shared_album') },
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
{ key: ['⇧', 'd'], action: $t('download') },
{ key: ['Space'], action: $t('play_or_pause_video') },
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
],
},
}: Props = $props();
let { onClose, shortcutVariants, shortcuts }: Props = $props();
const secondaryIds = $derived(new Set(shortcutVariants.values()));
const isEmpty = $derived(shortcuts.length === 0);
const primaryShortcuts = $derived(
shortcuts.filter((shortcut) => {
if (shortcut.variant) {
if (!secondaryIds.has(shortcut.variant)) {
return true;
}
return false;
}
return true;
}),
);
const categories = $derived.by(() =>
sortCategories([...new Set(primaryShortcuts.filter((s) => !!s.category).flatMap((s) => s.category!))]),
);
const categorizedPrimaryShortcuts = $derived.by(() => {
const categoryMap = new SvelteMap<string, KeyboardHelp[]>();
for (const c of categories) {
categoryMap.set(
c,
primaryShortcuts.filter((s) => s.category === c),
);
}
return categoryMap;
});
const getSecondaryShortcut = (variant: ShortcutVariant | undefined) => {
if (!variant) {
return;
}
return shortcuts.find((short) => short.variant === variant);
};
</script>
<Modal title={$t('keyboard_shortcuts')} size="medium" {onClose}>
{#snippet row(col: Snippet<[KeyboardHelp]>, shortcut1: KeyboardHelp, shortcut2: KeyboardHelp | undefined)}
<div class="grid grid-cols-[15%_35%_15%_35%] items-start gap-4 pt-4 text-sm">
{@render col(shortcut1)}
{#if shortcut2}
{@render col(shortcut2)}
{/if}
</div>
{/snippet}
{#snippet col(shortcut: KeyboardHelp)}
<div>
{#each shortcut.key as key (key)}
<div class="flex justify-self-end [&:not(:first-child)]:mt-2">
{#each key as sequence (sequence)}
<Kbd>
{sequence}
</Kbd>
{/each}
</div>
{/each}
</div>
<p>{shortcut.text}</p>
{/snippet}
<Modal title={$t('keyboard_shortcuts')} size="large" onClose={() => (resetModal(), onClose())}>
<ModalBody>
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
{#if shortcuts.general.length > 0}
<div class="px-4 pb-4 grid grid-auto-fit-200 gap-5 mt-1">
{#if isEmpty}{$t('no_shortcuts')}{/if}
{#each categories as category (category)}
{@const actions = categorizedPrimaryShortcuts.get(category)!}
<div class="p-4">
<h2>{$t('general')}</h2>
<h2>{getCategoryString(category)}</h2>
<div class="text-sm">
{#each shortcuts.general as shortcut (shortcut.key.join('-'))}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key (key)}
<p
class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
</div>
{#each actions as shortcut (shortcut)}
{@const paired = shortcut.variant
? getSecondaryShortcut(shortcutVariants.get(shortcut.variant))
: undefined}
{@render row(col, shortcut, paired)}
{/each}
</div>
</div>
{/if}
{#if shortcuts.actions.length > 0}
<div class="p-4">
<h2>{$t('actions')}</h2>
<div class="text-sm">
{#each shortcuts.actions as shortcut (shortcut.key.join('-'))}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key (key)}
<p
class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<div class="flex items-center gap-2">
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
{#if shortcut.info}
<Icon icon={mdiInformationOutline} title={shortcut.info} />
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
</ModalBody>
</Modal>

View file

@ -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)}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { Category } from '$lib/actions/shortcut.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
@ -106,7 +107,7 @@
</Timeline>
</UserPageLayout>
{#if assetInteraction.selectionActive}
<div class={[!assetInteraction.selectionActive && 'hidden']}>
<AssetSelectControlBar
ownerId={$user.id}
assets={assetInteraction.selectedAssets}
@ -115,8 +116,8 @@
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
<AddToAlbum shortcutCategory={Category.Selection} />
<AddToAlbum shortcutCategory={Category.Selection} shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
@ -127,7 +128,7 @@
})}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<DownloadAction shortcutCategory={Category.Selection} menuItem />
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
@ -148,7 +149,7 @@
<ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
{#if $preferences.tags.enabled}
<TagAction menuItem />
<TagAction shortcutCategory={Category.Selection} menuItem />
{/if}
<DeleteAssets
menuItem
@ -160,4 +161,4 @@
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
@ -255,7 +255,7 @@
</script>
<svelte:window bind:scrollY />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
<svelte:document {@attach shortcut('Escape', category(Category.Navigation, $t('go_back')), onEscape)} />
<section>
{#if assetInteraction.selectionActive}

View file

@ -1,8 +1,8 @@
<script lang="ts">
import { showShortcutsModal } from '$lib/actions/shortcut.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Container, IconButton, modalManager } from '@immich/ui';
import { Container, IconButton } from '@immich/ui';
import { mdiKeyboard } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@ -22,7 +22,7 @@
variant="ghost"
icon={mdiKeyboard}
aria-label={$t('show_keyboard_shortcuts')}
onclick={() => modalManager.show(ShortcutsModal, {})}
onclick={showShortcutsModal}
/>
{/snippet}
<Container size="medium" center>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcuts } from '$lib/actions/shortcut';
import { shortcut, showShortcutsModal } from '$lib/actions/shortcut.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import {
notificationController,
@ -10,7 +10,6 @@
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import { AppRoute } from '$lib/constants';
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
@ -39,26 +38,16 @@
let { data = $bindable() }: Props = $props();
interface Shortcuts {
general: ExplainedShortcut[];
actions: ExplainedShortcut[];
}
interface ExplainedShortcut {
key: string[];
action: string;
info?: string;
}
const duplicateShortcuts: Shortcuts = {
general: [],
actions: [
{ key: ['a'], action: $t('select_all_duplicates') },
{ key: ['s'], action: $t('view') },
{ key: ['d'], action: $t('unselect_all_duplicates') },
{ key: ['⇧', 'c'], action: $t('resolve_duplicates') },
{ key: ['⇧', 's'], action: $t('stack_duplicates') },
],
};
// const duplicateShortcuts: Shortcuts = {
// general: [],
// actions: [
// { key: ['a'], action: $t('select_all_duplicates') },
// { key: ['s'], action: $t('view') },
// { key: ['d'], action: $t('unselect_all_duplicates') },
// { key: ['⇧', 'c'], action: $t('resolve_duplicates') },
// { key: ['⇧', 's'], action: $t('stack_duplicates') },
// ],
// };
let duplicates = $state(data.duplicates);
const { isViewing: showAssetViewer } = assetViewingStore;
@ -216,10 +205,8 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
]}
{@attach shortcut('ArrowLeft', $t('previous'), handlePreviousShortcut)}
{@attach shortcut('ArrowRight', $t('next'), handleNextShortcut)}
/>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
@ -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')}
/>
</HStack>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
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';
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
@ -137,10 +137,9 @@
</svelte:head>
<svelte:document
use:shortcut={{
shortcut: { ctrl: true, shift: true, key: 'm' },
onShortcut: () => copyToClipboard(getMyImmichLink().toString()),
}}
{@attach shortcut({ key: 'm', ctrl: true, shift: true }, $t('get_my_immich_link'), () =>
copyToClipboard(getMyImmichLink().toString()),
)}
/>
{#if page.data.error}