mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
feat: enhance keyboard actions/help modal
This commit is contained in:
parent
cc1cd299f3
commit
f5d6532051
52 changed files with 1067 additions and 674 deletions
21
i18n/en.json
21
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_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_key_empty": "Your API Key name shouldn't be empty",
|
||||||
"api_keys": "API Keys",
|
"api_keys": "API Keys",
|
||||||
|
"app_actions": "App Actions",
|
||||||
"app_architecture_variant": "Variant (Architecture)",
|
"app_architecture_variant": "Variant (Architecture)",
|
||||||
"app_bar_signout_dialog_content": "Are you sure you want to sign out?",
|
"app_bar_signout_dialog_content": "Are you sure you want to sign out?",
|
||||||
"app_bar_signout_dialog_ok": "Yes",
|
"app_bar_signout_dialog_ok": "Yes",
|
||||||
|
|
@ -489,6 +490,7 @@
|
||||||
"are_you_sure_to_do_this": "Are you sure you want to do this?",
|
"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_delete_err_read_only": "Cannot delete read only asset(s), skipping",
|
||||||
"asset_action_share_err_offline": "Cannot fetch offline 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_added_to_album": "Added to album",
|
||||||
"asset_adding_to_album": "Adding to album…",
|
"asset_adding_to_album": "Adding to album…",
|
||||||
"asset_description_updated": "Asset description has been updated",
|
"asset_description_updated": "Asset description has been updated",
|
||||||
|
|
@ -812,6 +814,7 @@
|
||||||
"delete_permanently_action_prompt": "{count} deleted permanently",
|
"delete_permanently_action_prompt": "{count} deleted permanently",
|
||||||
"delete_shared_link": "Delete shared link",
|
"delete_shared_link": "Delete shared link",
|
||||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||||
|
"delete_skip_trash": "Delete (skip trash)",
|
||||||
"delete_tag": "Delete tag",
|
"delete_tag": "Delete tag",
|
||||||
"delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?",
|
"delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?",
|
||||||
"delete_user": "Delete user",
|
"delete_user": "Delete user",
|
||||||
|
|
@ -1090,6 +1093,8 @@
|
||||||
"find_them_fast": "Find them fast by name with search",
|
"find_them_fast": "Find them fast by name with search",
|
||||||
"first": "First",
|
"first": "First",
|
||||||
"fix_incorrect_match": "Fix incorrect match",
|
"fix_incorrect_match": "Fix incorrect match",
|
||||||
|
"focus_next": "Focus next",
|
||||||
|
"focus_previous": "Focus previous",
|
||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"folder_not_found": "Folder not found",
|
"folder_not_found": "Folder not found",
|
||||||
"folders": "Folders",
|
"folders": "Folders",
|
||||||
|
|
@ -1101,9 +1106,11 @@
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
|
"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_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",
|
"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",
|
"getting_started": "Getting Started",
|
||||||
"go_back": "Go back",
|
"go_back": "Go back",
|
||||||
|
"go_to_date": "Go to date",
|
||||||
"go_to_folder": "Go to folder",
|
"go_to_folder": "Go to folder",
|
||||||
"go_to_search": "Go to search",
|
"go_to_search": "Go to search",
|
||||||
"gps": "GPS",
|
"gps": "GPS",
|
||||||
|
|
@ -1357,10 +1364,13 @@
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM y",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
"move": "Move",
|
"move": "Move",
|
||||||
|
"move_left": "Move left",
|
||||||
"move_off_locked_folder": "Move out of locked folder",
|
"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_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||||
"move_to_locked_folder": "Move to 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_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_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
|
||||||
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
|
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
|
||||||
"moved_to_trash": "Moved to trash",
|
"moved_to_trash": "Moved to trash",
|
||||||
|
|
@ -1372,6 +1382,7 @@
|
||||||
"name_or_nickname": "Name or nickname",
|
"name_or_nickname": "Name or nickname",
|
||||||
"navigate": "Navigate",
|
"navigate": "Navigate",
|
||||||
"navigate_to_time": "Navigate to Time",
|
"navigate_to_time": "Navigate to Time",
|
||||||
|
"navigation": "Navigation",
|
||||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||||
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
||||||
"network_requirements": "Network Requirements",
|
"network_requirements": "Network Requirements",
|
||||||
|
|
@ -1391,7 +1402,10 @@
|
||||||
"new_version_available": "NEW VERSION AVAILABLE",
|
"new_version_available": "NEW VERSION AVAILABLE",
|
||||||
"newest_first": "Newest first",
|
"newest_first": "Newest first",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
|
"next_day": "Next day",
|
||||||
"next_memory": "Next memory",
|
"next_memory": "Next memory",
|
||||||
|
"next_month": "Next month",
|
||||||
|
"next_year": "Next year",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"no_albums_message": "Create an album to organize your photos and videos",
|
"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.",
|
"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": "No results",
|
||||||
"no_results_description": "Try a synonym or more general keyword",
|
"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_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",
|
"no_uploads_in_progress": "No uploads in progress",
|
||||||
"not_available": "N/A",
|
"not_available": "N/A",
|
||||||
"not_in_any_album": "Not in any album",
|
"not_in_any_album": "Not in any album",
|
||||||
|
|
@ -1546,11 +1561,14 @@
|
||||||
"preset": "Preset",
|
"preset": "Preset",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
|
"previous_day": "Previous day",
|
||||||
"previous_memory": "Previous memory",
|
"previous_memory": "Previous memory",
|
||||||
|
"previous_month": "Previous month",
|
||||||
"previous_or_next_day": "Day forward/back",
|
"previous_or_next_day": "Day forward/back",
|
||||||
"previous_or_next_month": "Month forward/back",
|
"previous_or_next_month": "Month forward/back",
|
||||||
"previous_or_next_photo": "Photo forward/back",
|
"previous_or_next_photo": "Photo forward/back",
|
||||||
"previous_or_next_year": "Year forward/back",
|
"previous_or_next_year": "Year forward/back",
|
||||||
|
"previous_year": "Previous year",
|
||||||
"primary": "Primary",
|
"primary": "Primary",
|
||||||
"privacy": "Privacy",
|
"privacy": "Privacy",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
|
|
@ -1600,6 +1618,7 @@
|
||||||
"purchase_settings_server_activated": "The server product key is managed by the admin",
|
"purchase_settings_server_activated": "The server product key is managed by the admin",
|
||||||
"query_asset_id": "Query Asset ID",
|
"query_asset_id": "Query Asset ID",
|
||||||
"queue_status": "Queuing {count}/{total}",
|
"queue_status": "Queuing {count}/{total}",
|
||||||
|
"quick_actions": "Quick Actions",
|
||||||
"rating": "Star rating",
|
"rating": "Star rating",
|
||||||
"rating_clear": "Clear rating",
|
"rating_clear": "Clear rating",
|
||||||
"rating_count": "{count, plural, one {# star} other {# stars}}",
|
"rating_count": "{count, plural, one {# star} other {# stars}}",
|
||||||
|
|
@ -1781,6 +1800,7 @@
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"selected_count": "{count, plural, other {# selected}}",
|
"selected_count": "{count, plural, other {# selected}}",
|
||||||
"selected_gps_coordinates": "Selected GPS Coordinates",
|
"selected_gps_coordinates": "Selected GPS Coordinates",
|
||||||
|
"selection": "Selection",
|
||||||
"send_message": "Send message",
|
"send_message": "Send message",
|
||||||
"send_welcome_email": "Send welcome email",
|
"send_welcome_email": "Send welcome email",
|
||||||
"server_endpoint": "Server Endpoint",
|
"server_endpoint": "Server Endpoint",
|
||||||
|
|
@ -2115,6 +2135,7 @@
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
|
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
|
"view_actions": "View Actions",
|
||||||
"view_album": "View Album",
|
"view_album": "View Album",
|
||||||
"view_all": "View All",
|
"view_all": "View All",
|
||||||
"view_all_users": "View all users",
|
"view_all_users": "View all users",
|
||||||
|
|
|
||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
|
|
@ -681,8 +681,8 @@ importers:
|
||||||
specifier: file:../open-api/typescript-sdk
|
specifier: file:../open-api/typescript-sdk
|
||||||
version: link:../open-api/typescript-sdk
|
version: link:../open-api/typescript-sdk
|
||||||
'@immich/ui':
|
'@immich/ui':
|
||||||
specifier: ^0.34.0
|
specifier: ^0.35
|
||||||
version: 0.34.2(@internationalized/date@3.8.2)(svelte@5.39.11)
|
version: 0.35.0(@internationalized/date@3.8.2)(svelte@5.39.11)
|
||||||
'@mapbox/mapbox-gl-rtl-text':
|
'@mapbox/mapbox-gl-rtl-text':
|
||||||
specifier: 0.2.3
|
specifier: 0.2.3
|
||||||
version: 0.2.3(mapbox-gl@1.13.3)
|
version: 0.2.3(mapbox-gl@1.13.3)
|
||||||
|
|
@ -2726,8 +2726,8 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@immich/ui@0.34.2':
|
'@immich/ui@0.35.0':
|
||||||
resolution: {integrity: sha512-tWjEV1prSZ9VLes69Ha9jnnZj6tbv/9+PdQjsK+5zK7sQok/l7kyzAobj5z4XRD11XtGk/cAqi/ZOlqRWvZilA==}
|
resolution: {integrity: sha512-dyjYLzJUNzR1aIQZEqPwfHutjuvK5t3wy3pp19Vm5bcQ4ira4Gk75flCgd9oEON5mlaxtlaUJVMeNor66bK9pg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
|
|
@ -14180,7 +14180,7 @@ snapshots:
|
||||||
'@img/sharp-win32-x64@0.34.4':
|
'@img/sharp-win32-x64@0.34.4':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
'@mdi/js': 7.4.47
|
'@mdi/js': 7.4.47
|
||||||
bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.39.11)
|
bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.39.11)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@immich/ui": "^0.34.0",
|
"@immich/ui": "^0.35",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.11.5",
|
"@photo-sphere-viewer/core": "^5.11.5",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ describe('focusTrap action', () => {
|
||||||
it('supports backward focus wrapping', async () => {
|
it('supports backward focus wrapping', async () => {
|
||||||
render(FocusTrapTest, { show: true });
|
render(FocusTrapTest, { show: true });
|
||||||
await tick();
|
await tick();
|
||||||
await user.keyboard('{Shift>}{Tab}{/Shift}');
|
await user.keyboard('{Shift}{Tab}{/Shift}');
|
||||||
expect(document.activeElement).toEqual(screen.getByTestId('three'));
|
expect(document.activeElement).toEqual(screen.getByTestId('three'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
import { matchesShortcut } from '$lib/actions/shortcut';
|
|
||||||
import type { ActionReturn } from 'svelte/action';
|
import type { ActionReturn } from 'svelte/action';
|
||||||
|
import type { KeyCombo } from './input';
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
onOutclick?: () => void;
|
onOutclick?: () => void;
|
||||||
onEscape?: () => 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.
|
* Calls a function when a click occurs outside of the element, or when the escape key is pressed.
|
||||||
* @param node
|
* @param node
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { onKeydown } from '$lib/actions/input';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import type { Action } from 'svelte/action';
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
|
@ -95,13 +95,22 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option
|
||||||
currentEl?.click();
|
currentEl?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const { destroy } = shortcuts(node, [
|
const unregisterUp = onKeydown('ArrowUp', (event) => (event.preventDefault(), moveSelection('up', event)))(node);
|
||||||
{ shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) },
|
const unregisterDown = onKeydown(
|
||||||
{ shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) },
|
'ArrowDown',
|
||||||
{ shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) },
|
(event) => (event.preventDefault(), moveSelection('down', event)),
|
||||||
{ shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) },
|
)(node);
|
||||||
{ shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) },
|
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 {
|
return {
|
||||||
update(newOptions) {
|
update(newOptions) {
|
||||||
|
|
|
||||||
|
|
@ -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 { getTabbable } from '$lib/utils/focus-util';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
|
@ -39,33 +39,35 @@ export function focusTrap(container: HTMLElement, options?: Options) {
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const { destroy: destroyShortcuts } = shortcuts(container, [
|
const unregisterTab = onKeydown(
|
||||||
{
|
'Tab',
|
||||||
ignoreInputFields: false,
|
(event) => {
|
||||||
preventDefault: false,
|
const [firstElement, lastElement] = getFocusableElements();
|
||||||
shortcut: { key: 'Tab' },
|
if (document.activeElement === lastElement && withDefaults(options).active) {
|
||||||
onShortcut: (event) => {
|
event.preventDefault();
|
||||||
const [firstElement, lastElement] = getFocusableElements();
|
firstElement?.focus();
|
||||||
if (document.activeElement === lastElement && withDefaults(options).active) {
|
}
|
||||||
event.preventDefault();
|
|
||||||
firstElement?.focus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{ ignoreInputFields: false },
|
||||||
ignoreInputFields: false,
|
)(container);
|
||||||
preventDefault: false,
|
|
||||||
shortcut: { key: 'Tab', shift: true },
|
|
||||||
onShortcut: (event) => {
|
|
||||||
const [firstElement, lastElement] = getFocusableElements();
|
|
||||||
if (document.activeElement === firstElement && withDefaults(options).active) {
|
|
||||||
event.preventDefault();
|
|
||||||
lastElement?.focus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
update(newOptions?: Options) {
|
update(newOptions?: Options) {
|
||||||
options = newOptions;
|
options = newOptions;
|
||||||
|
|
|
||||||
113
web/src/lib/actions/input.ts
Normal file
113
web/src/lib/actions/input.ts
Normal 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),
|
||||||
|
);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { onKeydown } from '$lib/actions/input';
|
||||||
import type { Action } from 'svelte/action';
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,10 +30,17 @@ export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { destroy } = shortcuts(node, [
|
const unregisterUp = onKeydown('ArrowUp', (event) => (event.preventDefault(), moveFocus('up')), {
|
||||||
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => moveFocus('up'), ignoreInputFields: false },
|
ignoreInputFields: false,
|
||||||
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => moveFocus('down'), ignoreInputFields: false },
|
})(node);
|
||||||
]);
|
const unregisterDown = onKeydown('ArrowDown', (event) => (event.preventDefault(), moveFocus('down')), {
|
||||||
|
ignoreInputFields: false,
|
||||||
|
})(node);
|
||||||
|
let destroy = () => {
|
||||||
|
unregisterUp();
|
||||||
|
unregisterDown();
|
||||||
|
destroy = () => void 0;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update(newContainer) {
|
update(newContainer) {
|
||||||
|
|
|
||||||
311
web/src/lib/actions/shortcut.svelte.ts
Normal file
311
web/src/lib/actions/shortcut.svelte.ts
Normal 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();
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { blurOnEnter } from '$lib/actions/input';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateAlbumInfo } from '@immich/sdk';
|
import { updateAlbumInfo } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
@ -36,7 +36,6 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
|
|
||||||
onblur={handleUpdateName}
|
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
|
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'
|
? 'hover:border-gray-400'
|
||||||
|
|
@ -46,4 +45,5 @@
|
||||||
disabled={!isOwned}
|
disabled={!isOwned}
|
||||||
title={$t('edit_title')}
|
title={$t('edit_title')}
|
||||||
placeholder={$t('add_a_title')}
|
placeholder={$t('add_a_title')}
|
||||||
|
{@attach blurOnEnter}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<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 CastButton from '$lib/cast/cast-button.svelte';
|
||||||
import AlbumMap from '$lib/components/album-page/album-map.svelte';
|
import AlbumMap from '$lib/components/album-page/album-map.svelte';
|
||||||
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
|
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
|
||||||
|
|
@ -48,14 +48,11 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcut={{
|
{@attach shortcut('Escape', category(Category.Application, $t('previous_or_next_photo')), () => {
|
||||||
shortcut: { key: 'Escape' },
|
if (!$showAssetViewer && assetInteraction.selectionActive) {
|
||||||
onShortcut: () => {
|
cancelMultiselect(assetInteraction);
|
||||||
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)">
|
<main class="relative h-dvh overflow-hidden px-2 md:px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height)">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<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 type { OnAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
|
|
@ -40,7 +40,15 @@
|
||||||
};
|
};
|
||||||
</script>
|
</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
|
<MenuOption
|
||||||
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
|
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<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 type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
|
|
@ -28,7 +29,13 @@
|
||||||
};
|
};
|
||||||
</script>
|
</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
|
<MenuOption
|
||||||
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
|
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { category, Category, shortcut } from '$lib/actions/shortcut.svelte';
|
||||||
import { IconButton } from '@immich/ui';
|
import { IconButton } from '@immich/ui';
|
||||||
import { mdiArrowLeft } from '@mdi/js';
|
import { mdiArrowLeft } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
let { onClose }: Props = $props();
|
let { onClose }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
<svelte:document {@attach shortcut('Escape', category(Category.Navigation, $t('go_back')), onClose)} />
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script lang="ts">
|
<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 DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||||
import {
|
import {
|
||||||
NotificationType,
|
|
||||||
notificationController,
|
notificationController,
|
||||||
|
NotificationType,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
|
|
@ -75,10 +76,19 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcuts={[
|
{@attach shortcut(
|
||||||
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete(asset.isTrashed) },
|
'Delete',
|
||||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
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
|
<IconButton
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<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 MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
|
@ -19,7 +20,7 @@
|
||||||
const onDownloadFile = async () => downloadFile(await getAssetInfo({ ...authManager.params, id: asset.id }));
|
const onDownloadFile = async () => downloadFile(await getAssetInfo({ ...authManager.params, id: asset.id }));
|
||||||
</script>
|
</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}
|
{#if !menuItem}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
|
||||||
import {
|
import {
|
||||||
NotificationType,
|
NotificationType,
|
||||||
notificationController,
|
notificationController,
|
||||||
|
|
@ -8,10 +8,10 @@
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { IconButton } from '@immich/ui';
|
||||||
import { mdiHeart, mdiHeartOutline } from '@mdi/js';
|
import { mdiHeart, mdiHeartOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { OnAction } from './action';
|
import type { OnAction } from './action';
|
||||||
import { IconButton } from '@immich/ui';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 'f' }, onShortcut: toggleFavorite }} />
|
<svelte:document {@attach shortcut('f', category(Category.AssetActions, 'Toggle favorite'), toggleFavorite)} />
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<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 { Icon } from '@immich/ui';
|
||||||
import { mdiChevronRight } from '@mdi/js';
|
import { mdiChevronRight } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
@ -13,10 +13,11 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcuts={[
|
{@attach shortcut(
|
||||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNextAsset },
|
['ArrowRight', 'd'],
|
||||||
{ shortcut: { key: 'd' }, onShortcut: onNextAsset },
|
category(Category.Navigation, $t('view_next_asset'), ShortcutVariant.NextAsset),
|
||||||
]}
|
onNextAsset,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NavigationArea onClick={onNextAsset} label={$t('view_next_asset')}>
|
<NavigationArea onClick={onNextAsset} label={$t('view_next_asset')}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<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 { Icon } from '@immich/ui';
|
||||||
import { mdiChevronLeft } from '@mdi/js';
|
import { mdiChevronLeft } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
@ -13,10 +13,11 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcuts={[
|
{@attach shortcut(
|
||||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPreviousAsset },
|
['ArrowLeft', 'a'],
|
||||||
{ shortcut: { key: 'a' }, onShortcut: onPreviousAsset },
|
category(Category.Navigation, $t('view_previous_asset'), ShortcutVariant.PrevAsset),
|
||||||
]}
|
onPreviousAsset,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NavigationArea onClick={onPreviousAsset} label={$t('view_previous_asset')}>
|
<NavigationArea onClick={onPreviousAsset} label={$t('view_previous_asset')}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { category, Category, shortcut } from '$lib/actions/shortcut.svelte';
|
||||||
import { IconButton } from '@immich/ui';
|
import { IconButton } from '@immich/ui';
|
||||||
import { mdiInformationOutline } from '@mdi/js';
|
import { mdiInformationOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
let { onShowDetail }: Props = $props();
|
let { onShowDetail }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
|
<svelte:document {@attach shortcut('i', category(Category.QuickActions, $t('info')), onShowDetail)} />
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
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 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 MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
|
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||||
|
|
@ -258,10 +258,7 @@
|
||||||
bind:value={message}
|
bind:value={message}
|
||||||
use:autoGrowHeight={{ height: '5px', value: message }}
|
use:autoGrowHeight={{ height: '5px', value: message }}
|
||||||
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
|
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
|
||||||
use:shortcut={{
|
{@attach onKeydown({ key: 'Enter' }, () => void handleSendComment())}
|
||||||
shortcut: { key: 'Enter' },
|
|
||||||
onShortcut: () => handleSendComment(),
|
|
||||||
}}
|
|
||||||
class="h-[18px] {disabled
|
class="h-[18px] {disabled
|
||||||
? 'cursor-not-allowed'
|
? 'cursor-not-allowed'
|
||||||
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
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 type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
|
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
|
||||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||||
|
|
@ -381,9 +382,15 @@
|
||||||
handlePromiseError(handleGetAllAlbums());
|
handlePromiseError(handleGetAllAlbums());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const endScope = newShortcutScope();
|
||||||
|
onMount(() => endScope);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document bind:fullscreenElement />
|
<svelte:document
|
||||||
|
bind:fullscreenElement
|
||||||
|
{@attach registerShortcutVariant(ShortcutVariant.PrevAsset, ShortcutVariant.NextAsset)}
|
||||||
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="immich-asset-viewer"
|
id="immich-asset-viewer"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleAddTag }} />
|
<svelte:document {@attach shortcut('t', $t('tag_assets'), handleAddTag)} />
|
||||||
|
|
||||||
{#if isOwner && !authManager.isSharedLink}
|
{#if isOwner && !authManager.isSharedLink}
|
||||||
<section class="px-4 mt-4">
|
<section class="px-4 mt-4">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<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 { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
import { type AssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto } from '@immich/sdk';
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
const onConfirm = () => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog());
|
const onConfirm = () => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog());
|
||||||
</script>
|
</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">
|
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||||
<div class="flex place-items-center gap-2">
|
<div class="flex place-items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<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 { zoomImageAction } from '$lib/actions/zoom-image';
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
|
|
@ -205,13 +206,13 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcuts={[
|
{@attach shortcut('z', category(Category.ViewActions, $t('zoom_image')), zoomToggle)}
|
||||||
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true },
|
{@attach shortcut('s', category(Category.ViewActions, 'Start slideshow'), onPlaySlideshow)}
|
||||||
{ shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true },
|
{@attach shortcut(
|
||||||
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
[ctrlKey('c'), metaKey('c')],
|
||||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
category(Category.QuickActions, 'Copy image to clipboard'),
|
||||||
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
|
onCopyShortcut,
|
||||||
]}
|
)}
|
||||||
/>
|
/>
|
||||||
{#if imageError}
|
{#if imageError}
|
||||||
<div class="h-full w-full">
|
<div class="h-full w-full">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<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 ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
|
||||||
import { ProgressBarStatus } from '$lib/constants';
|
import { ProgressBarStatus } from '$lib/constants';
|
||||||
import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte';
|
import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte';
|
||||||
|
|
@ -136,22 +136,16 @@
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
onmousemove={showControlBar}
|
onmousemove={showControlBar}
|
||||||
use:shortcuts={[
|
{@attach shortcut('Escape', category(Category.Application, $t('exit_slideshow')), onClose)}
|
||||||
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
|
{@attach shortcut('ArrowLeft', category(Category.Navigation, $t('previous')), onClose)}
|
||||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
|
{@attach shortcut('ArrowRight', category(Category.Navigation, $t('next')), onClose)}
|
||||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNext },
|
{@attach shortcut(' ', $t('pause'), () => {
|
||||||
{
|
if (progressBarStatus === ProgressBarStatus.Paused) {
|
||||||
shortcut: { key: ' ' },
|
progressBar?.play();
|
||||||
onShortcut: () => {
|
} else {
|
||||||
if (progressBarStatus === ProgressBarStatus.Paused) {
|
progressBar?.pause();
|
||||||
progressBar?.play();
|
}
|
||||||
} else {
|
})}
|
||||||
progressBar?.pause();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
preventDefault: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* @ts-expect-error https://github.com/Rezi/svelte-gestures/issues/38#issuecomment-3315953573 */ null}
|
{/* @ts-expect-error https://github.com/Rezi/svelte-gestures/issues/38#issuecomment-3315953573 */ null}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<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 ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
|
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
|
||||||
import {
|
import {
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]);
|
let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
<svelte:document {@attach shortcut('Escape', $t('close'), onClose)} />
|
||||||
|
|
||||||
<div
|
<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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||||
import { resizeObserver } from '$lib/actions/resize-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 MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte';
|
||||||
import MemoryVideoViewer from '$lib/components/memory-page/memory-video-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';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
|
|
@ -312,15 +312,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcuts={$isViewing
|
{@attach shortcut(['ArrowRight', 'd'], $t('next'), handleNextAsset)}
|
||||||
? []
|
{@attach shortcut(['ArrowLeft', 'a'], $t('previous'), handlePreviousAsset)}
|
||||||
: [
|
{@attach shortcut(['Escape'], $t('timeline'), handleEscape)}
|
||||||
{ 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() },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
import { autoGrowHeight } from '$lib/actions/autogrow';
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { blurOnCtrlEnter } from '$lib/actions/input';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|
@ -26,10 +26,7 @@
|
||||||
class="resize-none {className}"
|
class="resize-none {className}"
|
||||||
onfocusout={updateContent}
|
onfocusout={updateContent}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
use:shortcut={{
|
{@attach blurOnCtrlEnter}
|
||||||
shortcut: { key: 'Enter', ctrl: true },
|
|
||||||
onShortcut: (e) => e.currentTarget.blur(),
|
|
||||||
}}
|
|
||||||
use:autoGrowHeight={{ value: newContent }}
|
use:autoGrowHeight={{ value: newContent }}
|
||||||
data-testid="autogrow-textarea">{content}</textarea
|
data-testid="autogrow-textarea">{content}</textarea
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { focusOutside } from '$lib/actions/focus-outside';
|
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 { generateId } from '$lib/utils/generate-id';
|
||||||
import { Icon, IconButton, Label } from '@immich/ui';
|
import { Icon, IconButton, Label } from '@immich/ui';
|
||||||
import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
|
import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
|
||||||
|
|
@ -255,15 +255,10 @@
|
||||||
<div
|
<div
|
||||||
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
||||||
use:focusOutside={{ onFocusOut: deactivate }}
|
use:focusOutside={{ onFocusOut: deactivate }}
|
||||||
use:shortcuts={[
|
{@attach onKeydown({ key: 'Escape' }, (event) => {
|
||||||
{
|
event.stopPropagation();
|
||||||
shortcut: { key: 'Escape' },
|
closeDropdown();
|
||||||
onShortcut: (event) => {
|
})}
|
||||||
event.stopPropagation();
|
|
||||||
closeDropdown();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{#if isActive}
|
{#if isActive}
|
||||||
|
|
@ -295,44 +290,27 @@
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
use:forceFocusInput
|
use:forceFocusInput
|
||||||
use:shortcuts={[
|
{@attach onKeydown({ key: 'ArrowUp' }, () => {
|
||||||
{
|
openDropdown();
|
||||||
shortcut: { key: 'ArrowUp' },
|
void incrementSelectedIndex(-1);
|
||||||
onShortcut: () => {
|
})}
|
||||||
openDropdown();
|
{@attach onKeydown({ key: 'ArrowDown' }, () => {
|
||||||
void incrementSelectedIndex(-1);
|
openDropdown();
|
||||||
},
|
void incrementSelectedIndex(1);
|
||||||
},
|
})}
|
||||||
{
|
{@attach onKeydown({ key: 'ArrowDown', alt: true }, () => {
|
||||||
shortcut: { key: 'ArrowDown' },
|
openDropdown();
|
||||||
onShortcut: () => {
|
})}
|
||||||
openDropdown();
|
{@attach onKeydown({ key: 'Enter' }, () => {
|
||||||
void incrementSelectedIndex(1);
|
if (selectedIndex !== undefined && filteredOptions.length > 0) {
|
||||||
},
|
handleSelect(filteredOptions[selectedIndex]);
|
||||||
},
|
}
|
||||||
{
|
closeDropdown();
|
||||||
shortcut: { key: 'ArrowDown', alt: true },
|
})}
|
||||||
onShortcut: () => {
|
{@attach onKeydown({ key: 'Escape' }, (event) => {
|
||||||
openDropdown();
|
event.stopPropagation();
|
||||||
},
|
closeDropdown();
|
||||||
},
|
})}
|
||||||
{
|
|
||||||
shortcut: { key: 'Enter' },
|
|
||||||
onShortcut: () => {
|
|
||||||
if (selectedIndex !== undefined && filteredOptions.length > 0) {
|
|
||||||
handleSelect(filteredOptions[selectedIndex]);
|
|
||||||
}
|
|
||||||
closeDropdown();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shortcut: { key: 'Escape' },
|
|
||||||
onShortcut: (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
closeDropdown();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
|
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 ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||||
|
|
@ -174,20 +174,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if isOpen || !hideContent}
|
{#if isOpen || !hideContent}
|
||||||
<div
|
<div {@attach onKeydown([{ key: 'Tab' }, { key: 'Tab', shift: true }], closeDropdown)}>
|
||||||
use:shortcuts={[
|
|
||||||
{
|
|
||||||
shortcut: { key: 'Tab' },
|
|
||||||
onShortcut: closeDropdown,
|
|
||||||
preventDefault: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shortcut: { key: 'Tab', shift: true },
|
|
||||||
onShortcut: closeDropdown,
|
|
||||||
preventDefault: false,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
{direction}
|
{direction}
|
||||||
ariaActiveDescendant={$selectedIdStore}
|
ariaActiveDescendant={$selectedIdStore}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Shortcut } from '$lib/actions/shortcut';
|
import type { KeyCombo } from '$lib/actions/input';
|
||||||
import { shortcut as bindShortcut, shortcutLabel as computeShortcutLabel } from '$lib/actions/shortcut';
|
import {
|
||||||
|
Category,
|
||||||
|
conditionalShortcut,
|
||||||
|
shortcut as registerShortcut,
|
||||||
|
ShortcutVariant,
|
||||||
|
} from '$lib/actions/shortcut.svelte';
|
||||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
import { generateId } from '$lib/utils/generate-id';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
|
|
@ -12,8 +17,9 @@
|
||||||
activeColor?: string;
|
activeColor?: string;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
shortcut?: Shortcut | null;
|
shortcut?: KeyCombo | null;
|
||||||
shortcutLabel?: string;
|
shortcutCategory?: Category;
|
||||||
|
variant?: ShortcutVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -24,7 +30,8 @@
|
||||||
textColor = 'text-immich-fg dark:text-immich-dark-bg',
|
textColor = 'text-immich-fg dark:text-immich-dark-bg',
|
||||||
onClick,
|
onClick,
|
||||||
shortcut = null,
|
shortcut = null,
|
||||||
shortcutLabel = '',
|
shortcutCategory,
|
||||||
|
variant,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let id: string = generateId();
|
let id: string = generateId();
|
||||||
|
|
@ -35,16 +42,14 @@
|
||||||
$optionClickCallbackStore?.();
|
$optionClickCallbackStore?.();
|
||||||
onClick();
|
onClick();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shortcut && !shortcutLabel) {
|
|
||||||
shortcutLabel = computeShortcutLabel(shortcut);
|
|
||||||
}
|
|
||||||
const bindShortcutIfSet = shortcut
|
|
||||||
? (n: HTMLElement) => bindShortcut(n, { shortcut, onShortcut: onClick })
|
|
||||||
: () => {};
|
|
||||||
</script>
|
</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_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||||
|
|
@ -64,11 +69,6 @@
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
{text}
|
{text}
|
||||||
{#if shortcutLabel}
|
|
||||||
<span class="text-gray-500 ps-4">
|
|
||||||
{shortcutLabel}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
|
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 ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
import { generateId } from '$lib/utils/generate-id';
|
||||||
|
|
@ -80,16 +80,7 @@
|
||||||
selectedId: $selectedIdStore,
|
selectedId: $selectedIdStore,
|
||||||
selectionChanged: (id) => ($selectedIdStore = id),
|
selectionChanged: (id) => ($selectedIdStore = id),
|
||||||
}}
|
}}
|
||||||
use:shortcuts={[
|
{@attach onKeydown([{ key: 'Tab' }, { key: 'Tab', shift: true }], closeContextMenu)}
|
||||||
{
|
|
||||||
shortcut: { key: 'Tab' },
|
|
||||||
onShortcut: closeContextMenu,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shortcut: { key: 'Tab', shift: true },
|
|
||||||
onShortcut: closeContextMenu,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<section class="fixed start-0 top-0 flex h-dvh w-dvw" {oncontextmenu} role="presentation">
|
<section class="fixed start-0 top-0 flex h-dvh w-dvw" {oncontextmenu} role="presentation">
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
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 { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||||
import { fileUploadHandler } from '$lib/utils/file-uploader';
|
import { fileUploadHandler } from '$lib/utils/file-uploader';
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
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 type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
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 { AppRoute, AssetAction } from '$lib/constants';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
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 type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
|
|
@ -20,11 +29,9 @@
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { modalManager } from '@immich/ui';
|
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||||
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialAssetId?: string;
|
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 = () =>
|
const focusPreviousAsset = () =>
|
||||||
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
|
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> => {
|
const handleNext = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
let asset: { id: string } | undefined;
|
let asset: { id: string } | undefined;
|
||||||
|
|
@ -465,8 +430,51 @@
|
||||||
onkeydown={onKeyDown}
|
onkeydown={onKeyDown}
|
||||||
onkeyup={onKeyUp}
|
onkeyup={onKeyUp}
|
||||||
onselectstart={onSelectStart}
|
onselectstart={onSelectStart}
|
||||||
use:shortcuts={shortcutList}
|
|
||||||
onscroll={() => updateSlidingWindow()}
|
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}
|
{#if isShowDeleteConfirmation}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { focusOutside } from '$lib/actions/focus-outside';
|
import { focusOutside } from '$lib/actions/focus-outside';
|
||||||
import { 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 { AppRoute } from '$lib/constants';
|
||||||
import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte';
|
import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte';
|
||||||
import { searchStore } from '$lib/stores/search.svelte';
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
|
|
@ -206,11 +207,17 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcuts={[
|
{@attach shortcut(
|
||||||
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
{ key: 'k', ctrl: true },
|
||||||
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() },
|
category(Category.Application, $t('search_your_photos'), ShortcutVariant.Search),
|
||||||
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
|
() => 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">
|
<div class="w-full relative z-auto" use:focusOutside={{ onFocusOut }} tabindex="-1">
|
||||||
|
|
@ -247,14 +254,11 @@
|
||||||
aria-activedescendant={selectedId ?? ''}
|
aria-activedescendant={selectedId ?? ''}
|
||||||
aria-expanded={showSuggestions && isSearchSuggestions}
|
aria-expanded={showSuggestions && isSearchSuggestions}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
use:shortcuts={[
|
{@attach onKeydown('Enter', onEnter)}
|
||||||
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
{@attach onKeydown('Escape', onEscape)}
|
||||||
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
|
{@attach onKeydown('ArrowUp', () => onArrow(-1))}
|
||||||
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => onArrow(-1) },
|
{@attach onKeydown('ArrowDown', () => onArrow(1))}
|
||||||
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => onArrow(1) },
|
{@attach onKeydown({ key: 'ArrowDown', alt: true }, openDropdown)}
|
||||||
{ shortcut: { key: 'Enter' }, onShortcut: onEnter, preventDefault: false },
|
|
||||||
{ shortcut: { key: 'ArrowDown', alt: true }, onShortcut: openDropdown },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- SEARCH HISTORY BOX -->
|
<!-- SEARCH HISTORY BOX -->
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<script lang="ts">
|
<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 { defaultLang, langs, Theme } from '$lib/constants';
|
||||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||||
import { lang } from '$lib/stores/preferences.store';
|
import { lang } from '$lib/stores/preferences.store';
|
||||||
import { ThemeSwitcher } from '@immich/ui';
|
import { ThemeSwitcher } from '@immich/ui';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
const handleToggleTheme = () => {
|
const handleToggleTheme = () => {
|
||||||
|
|
@ -15,7 +17,7 @@
|
||||||
};
|
};
|
||||||
</script>
|
</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}
|
{#if !themeManager.theme.system}
|
||||||
{#await langs
|
{#await langs
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Category, ShortcutVariant } from '$lib/actions/shortcut.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||||
|
|
@ -11,9 +12,10 @@
|
||||||
interface Props {
|
interface Props {
|
||||||
shared?: boolean;
|
shared?: boolean;
|
||||||
onAddToAlbum?: OnAddToAlbum;
|
onAddToAlbum?: OnAddToAlbum;
|
||||||
|
shortcutCategory?: Category;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { shared = false, onAddToAlbum = () => {} }: Props = $props();
|
let { shared = false, onAddToAlbum = () => {}, shortcutCategory }: Props = $props();
|
||||||
|
|
||||||
const { getAssets } = getAssetControlContext();
|
const { getAssets } = getAssetControlContext();
|
||||||
|
|
||||||
|
|
@ -43,4 +45,6 @@
|
||||||
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
|
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
|
||||||
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
|
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
|
||||||
shortcut={{ key: 'l', shift: shared }}
|
shortcut={{ key: 'l', shift: shared }}
|
||||||
|
{shortcutCategory}
|
||||||
|
variant={shared ? ShortcutVariant.AddSharedAlbum : ShortcutVariant.AddAlbum}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<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 { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
|
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
|
||||||
|
|
@ -13,9 +12,10 @@
|
||||||
interface Props {
|
interface Props {
|
||||||
filename?: string;
|
filename?: string;
|
||||||
menuItem?: boolean;
|
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();
|
const { getAssets, clearSelect } = getAssetControlContext();
|
||||||
|
|
||||||
|
|
@ -33,7 +33,13 @@
|
||||||
};
|
};
|
||||||
</script>
|
</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}
|
{#if menuItem}
|
||||||
<MenuOption text={$t('download')} icon={mdiDownload} onClick={handleDownloadFiles} />
|
<MenuOption text={$t('download')} icon={mdiDownload} onClick={handleDownloadFiles} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<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 { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
||||||
import { IconButton, modalManager } from '@immich/ui';
|
import { IconButton, modalManager } from '@immich/ui';
|
||||||
|
|
@ -9,9 +9,10 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
|
shortcutCategory?: Category;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { menuItem = false }: Props = $props();
|
let { menuItem = false, shortcutCategory }: Props = $props();
|
||||||
|
|
||||||
const text = $t('tag');
|
const text = $t('tag');
|
||||||
const icon = mdiTagMultipleOutline;
|
const icon = mdiTagMultipleOutline;
|
||||||
|
|
@ -20,15 +21,17 @@
|
||||||
|
|
||||||
const handleTagAssets = async () => {
|
const handleTagAssets = async () => {
|
||||||
const assets = [...getOwnedAssets()];
|
const assets = [...getOwnedAssets()];
|
||||||
|
if (assets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
|
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
clearSelect();
|
clearSelect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleTagAssets }} />
|
<svelte:document {@attach shortcut('t', { text, category: shortcutCategory }, handleTagAssets)} />
|
||||||
|
|
||||||
{#if menuItem}
|
{#if menuItem}
|
||||||
<MenuOption {text} {icon} onClick={handleTagAssets} />
|
<MenuOption {text} {icon} onClick={handleTagAssets} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
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 DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||||
import {
|
import {
|
||||||
setFocusToAsset as setFocusAssetInit,
|
setFocusToAsset as setFocusAssetInit,
|
||||||
|
|
@ -10,9 +18,7 @@
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
|
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
|
||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.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 { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { searchStore } from '$lib/stores/search.svelte';
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
|
|
@ -21,6 +27,7 @@
|
||||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { modalManager } from '@immich/ui';
|
import { modalManager } from '@immich/ui';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timelineManager: TimelineManager;
|
timelineManager: TimelineManager;
|
||||||
|
|
@ -34,65 +41,26 @@
|
||||||
timelineManager = $bindable(),
|
timelineManager = $bindable(),
|
||||||
assetInteraction,
|
assetInteraction,
|
||||||
isShowDeleteConfirmation = $bindable(false),
|
isShowDeleteConfirmation = $bindable(false),
|
||||||
onEscape,
|
onEscape: handleEscape,
|
||||||
scrollToAsset,
|
scrollToAsset,
|
||||||
}: Props = $props();
|
}: 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);
|
let shiftKeyIsDown = $state(false);
|
||||||
|
|
||||||
const deselectAllAssets = () => {
|
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
cancelMultiselect(assetInteraction);
|
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) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (searchStore.isSearchEnabled) {
|
if (searchStore.isSearchEnabled) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -121,77 +89,161 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
const deselectAllAssets = () => {
|
||||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
cancelMultiselect(assetInteraction);
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
// Shortcut handlers
|
||||||
if (isEmpty) {
|
const handleExploreNavigation = () => goto(AppRoute.EXPLORE);
|
||||||
assetInteraction.clearMultiselect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
const handleSelectAllAssets = () => selectAllAssets(timelineManager, assetInteraction);
|
||||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
|
||||||
|
|
||||||
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 });
|
const asset = await modalManager.show(NavigateToDateModal, { timelineManager });
|
||||||
if (asset) {
|
if (asset) {
|
||||||
setFocusAsset(asset);
|
setFocusAsset(asset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let shortcutList = $derived(
|
const trashOrDelete = async (force: boolean = false) => {
|
||||||
(() => {
|
isShowDeleteConfirmation = false;
|
||||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
await deleteAssets(
|
||||||
return [];
|
!(isTrashEnabled && !force),
|
||||||
}
|
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||||
|
assetInteraction.selectedAssets,
|
||||||
|
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
|
||||||
|
);
|
||||||
|
assetInteraction.clearMultiselect();
|
||||||
|
};
|
||||||
|
|
||||||
const shortcuts: ShortcutOptions[] = [
|
const handleDelete = () => {
|
||||||
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
|
||||||
{ 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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assetInteraction.selectionActive) {
|
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
|
||||||
shortcuts.push(
|
isShowDeleteConfirmation = true;
|
||||||
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
return;
|
||||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
|
}
|
||||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
handlePromiseError(trashOrDelete(hasTrashedAsset));
|
||||||
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
|
};
|
||||||
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</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}
|
{#if isShowDeleteConfirmation}
|
||||||
<DeleteAssetDialog
|
<DeleteAssetDialog
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<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 DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
|
@ -105,16 +105,11 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcuts={[
|
{@attach shortcut('a', $t('select_all'), onSelectAll)}
|
||||||
{ shortcut: { key: 'a' }, onShortcut: onSelectAll },
|
{@attach shortcut('a', $t('view'), () => onViewAsset(assets[0]))}
|
||||||
{
|
{@attach shortcut('d', $t('deselect_all'), onSelectNone)}
|
||||||
shortcut: { key: 's' },
|
{@attach shortcut('c', $t('resolve_duplicates'), handleResolve)}
|
||||||
onShortcut: () => onViewAsset(assets[0]),
|
{@attach shortcut('c', $t('stack'), handleStack)}
|
||||||
},
|
|
||||||
{ shortcut: { key: 'd' }, onShortcut: onSelectNone },
|
|
||||||
{ shortcut: { key: 'c', shift: true }, onShortcut: handleResolve },
|
|
||||||
{ shortcut: { key: 's', shift: true }, onShortcut: 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">
|
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-216 mx-auto mb-4">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { focusOutside } from '$lib/actions/focus-outside';
|
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 { generateId } from '$lib/utils/generate-id';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
@ -60,10 +60,7 @@
|
||||||
class="text-primary w-fit cursor-default"
|
class="text-primary w-fit cursor-default"
|
||||||
onmouseleave={() => setHoverRating(0)}
|
onmouseleave={() => setHoverRating(0)}
|
||||||
use:focusOutside={{ onFocusOut: reset }}
|
use:focusOutside={{ onFocusOut: reset }}
|
||||||
use:shortcuts={[
|
{@attach onKeydown(['ArrowLeft', 'ArrowRight'], (event) => event.stopPropagation())}
|
||||||
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
|
|
||||||
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<legend class="sr-only">{$t('rating')}</legend>
|
<legend class="sr-only">{$t('rating')}</legend>
|
||||||
<div class="flex flex-row" data-testid="star-container">
|
<div class="flex flex-row" data-testid="star-container">
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,103 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Icon, Modal, ModalBody } from '@immich/ui';
|
import {
|
||||||
import { mdiInformationOutline } from '@mdi/js';
|
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';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
interface Shortcuts {
|
|
||||||
general: ExplainedShortcut[];
|
|
||||||
actions: ExplainedShortcut[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExplainedShortcut {
|
|
||||||
key: string[];
|
|
||||||
action: string;
|
|
||||||
info?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
shortcuts?: Shortcuts;
|
shortcutVariants: Map<ShortcutVariant, ShortcutVariant>;
|
||||||
|
shortcuts: KeyboardHelp[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { onClose, shortcutVariants, shortcuts }: Props = $props();
|
||||||
onClose,
|
|
||||||
shortcuts = {
|
const secondaryIds = $derived(new Set(shortcutVariants.values()));
|
||||||
general: [
|
const isEmpty = $derived(shortcuts.length === 0);
|
||||||
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
|
|
||||||
{ key: ['D', 'd'], action: $t('previous_or_next_day') },
|
const primaryShortcuts = $derived(
|
||||||
{ key: ['M', 'm'], action: $t('previous_or_next_month') },
|
shortcuts.filter((shortcut) => {
|
||||||
{ key: ['Y', 'y'], action: $t('previous_or_next_year') },
|
if (shortcut.variant) {
|
||||||
{ key: ['g'], action: $t('navigate_to_time') },
|
if (!secondaryIds.has(shortcut.variant)) {
|
||||||
{ key: ['x'], action: $t('select') },
|
return true;
|
||||||
{ key: ['Esc'], action: $t('back_close_deselect') },
|
}
|
||||||
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
|
return false;
|
||||||
{ key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
|
}
|
||||||
],
|
return true;
|
||||||
actions: [
|
}),
|
||||||
{ key: ['f'], action: $t('favorite_or_unfavorite_photo') },
|
);
|
||||||
{ key: ['i'], action: $t('show_or_hide_info') },
|
|
||||||
{ key: ['s'], action: $t('stack_selected_photos') },
|
const categories = $derived.by(() =>
|
||||||
{ key: ['l'], action: $t('add_to_album') },
|
sortCategories([...new Set(primaryShortcuts.filter((s) => !!s.category).flatMap((s) => s.category!))]),
|
||||||
{ key: ['t'], action: $t('tag_assets') },
|
);
|
||||||
{ key: ['⇧', 'l'], action: $t('add_to_shared_album') },
|
|
||||||
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
|
const categorizedPrimaryShortcuts = $derived.by(() => {
|
||||||
{ key: ['⇧', 'd'], action: $t('download') },
|
const categoryMap = new SvelteMap<string, KeyboardHelp[]>();
|
||||||
{ key: ['Space'], action: $t('play_or_pause_video') },
|
for (const c of categories) {
|
||||||
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
|
categoryMap.set(
|
||||||
],
|
c,
|
||||||
},
|
primaryShortcuts.filter((s) => s.category === c),
|
||||||
}: Props = $props();
|
);
|
||||||
|
}
|
||||||
|
return categoryMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSecondaryShortcut = (variant: ShortcutVariant | undefined) => {
|
||||||
|
if (!variant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return shortcuts.find((short) => short.variant === variant);
|
||||||
|
};
|
||||||
</script>
|
</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>
|
<ModalBody>
|
||||||
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
|
<div class="px-4 pb-4 grid grid-auto-fit-200 gap-5 mt-1">
|
||||||
{#if shortcuts.general.length > 0}
|
{#if isEmpty}{$t('no_shortcuts')}{/if}
|
||||||
|
{#each categories as category (category)}
|
||||||
|
{@const actions = categorizedPrimaryShortcuts.get(category)!}
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h2>{$t('general')}</h2>
|
<h2>{getCategoryString(category)}</h2>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
{#each shortcuts.general as shortcut (shortcut.key.join('-'))}
|
{#each actions as shortcut (shortcut)}
|
||||||
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
|
{@const paired = shortcut.variant
|
||||||
<div class="flex justify-self-end">
|
? getSecondaryShortcut(shortcutVariants.get(shortcut.variant))
|
||||||
{#each shortcut.key as key (key)}
|
: undefined}
|
||||||
<p
|
{@render row(col, shortcut, paired)}
|
||||||
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/each}
|
||||||
{#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}
|
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
|
import { blurOnEnter } from '$lib/actions/input';
|
||||||
import { scrollMemory } from '$lib/actions/scroll-memory';
|
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 ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
|
||||||
import PeopleCard from '$lib/components/faces-page/people-card.svelte';
|
import PeopleCard from '$lib/components/faces-page/people-card.svelte';
|
||||||
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.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"
|
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}
|
value={person.name}
|
||||||
placeholder={$t('add_a_name')}
|
placeholder={$t('add_a_name')}
|
||||||
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
|
{@attach blurOnEnter}
|
||||||
onfocusin={() => onNameChangeInputFocus(person)}
|
onfocusin={() => onNameChangeInputFocus(person)}
|
||||||
onfocusout={() => onNameChangeSubmit(newName, person)}
|
onfocusout={() => onNameChangeSubmit(newName, person)}
|
||||||
oninput={(event) => onNameChangeInputUpdate(event)}
|
oninput={(event) => onNameChangeInputUpdate(event)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { beforeNavigate } from '$app/navigation';
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
import { Category } from '$lib/actions/shortcut.svelte';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
|
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
|
|
@ -106,7 +107,7 @@
|
||||||
</Timeline>
|
</Timeline>
|
||||||
</UserPageLayout>
|
</UserPageLayout>
|
||||||
|
|
||||||
{#if assetInteraction.selectionActive}
|
<div class={[!assetInteraction.selectionActive && 'hidden']}>
|
||||||
<AssetSelectControlBar
|
<AssetSelectControlBar
|
||||||
ownerId={$user.id}
|
ownerId={$user.id}
|
||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
|
|
@ -115,8 +116,8 @@
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum />
|
<AddToAlbum shortcutCategory={Category.Selection} />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shortcutCategory={Category.Selection} shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction
|
<FavoriteAction
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
|
@ -127,7 +128,7 @@
|
||||||
})}
|
})}
|
||||||
></FavoriteAction>
|
></FavoriteAction>
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction shortcutCategory={Category.Selection} menuItem />
|
||||||
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
||||||
<StackAction
|
<StackAction
|
||||||
unstack={isAssetStackSelected}
|
unstack={isAssetStackSelected}
|
||||||
|
|
@ -148,7 +149,7 @@
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
{#if $preferences.tags.enabled}
|
{#if $preferences.tags.enabled}
|
||||||
<TagAction menuItem />
|
<TagAction shortcutCategory={Category.Selection} menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<DeleteAssets
|
<DeleteAssets
|
||||||
menuItem
|
menuItem
|
||||||
|
|
@ -160,4 +161,4 @@
|
||||||
<AssetJobActions />
|
<AssetJobActions />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{/if}
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
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 AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.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';
|
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||||
|
|
@ -255,7 +255,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window bind:scrollY />
|
<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>
|
<section>
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { showShortcutsModal } from '$lib/actions/shortcut.svelte';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
|
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
|
||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
import { Container, IconButton } from '@immich/ui';
|
||||||
import { Container, IconButton, modalManager } from '@immich/ui';
|
|
||||||
import { mdiKeyboard } from '@mdi/js';
|
import { mdiKeyboard } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon={mdiKeyboard}
|
icon={mdiKeyboard}
|
||||||
aria-label={$t('show_keyboard_shortcuts')}
|
aria-label={$t('show_keyboard_shortcuts')}
|
||||||
onclick={() => modalManager.show(ShortcutsModal, {})}
|
onclick={showShortcutsModal}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<Container size="medium" center>
|
<Container size="medium" center>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
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 UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import {
|
import {
|
||||||
notificationController,
|
notificationController,
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
|
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
|
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
|
||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
|
|
@ -39,26 +38,16 @@
|
||||||
|
|
||||||
let { data = $bindable() }: Props = $props();
|
let { data = $bindable() }: Props = $props();
|
||||||
|
|
||||||
interface Shortcuts {
|
// const duplicateShortcuts: Shortcuts = {
|
||||||
general: ExplainedShortcut[];
|
// general: [],
|
||||||
actions: ExplainedShortcut[];
|
// actions: [
|
||||||
}
|
// { key: ['a'], action: $t('select_all_duplicates') },
|
||||||
interface ExplainedShortcut {
|
// { key: ['s'], action: $t('view') },
|
||||||
key: string[];
|
// { key: ['d'], action: $t('unselect_all_duplicates') },
|
||||||
action: string;
|
// { key: ['⇧', 'c'], action: $t('resolve_duplicates') },
|
||||||
info?: string;
|
// { 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);
|
let duplicates = $state(data.duplicates);
|
||||||
const { isViewing: showAssetViewer } = assetViewingStore;
|
const { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
@ -216,10 +205,8 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcuts={[
|
{@attach shortcut('ArrowLeft', $t('previous'), handlePreviousShortcut)}
|
||||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
|
{@attach shortcut('ArrowRight', $t('next'), handleNextShortcut)}
|
||||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
|
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
|
||||||
|
|
@ -251,7 +238,7 @@
|
||||||
color="secondary"
|
color="secondary"
|
||||||
icon={mdiKeyboard}
|
icon={mdiKeyboard}
|
||||||
title={$t('show_keyboard_shortcuts')}
|
title={$t('show_keyboard_shortcuts')}
|
||||||
onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })}
|
onclick={() => showShortcutsModal()}
|
||||||
aria-label={$t('show_keyboard_shortcuts')}
|
aria-label={$t('show_keyboard_shortcuts')}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut.svelte';
|
||||||
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
||||||
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
|
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
|
||||||
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
|
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
|
||||||
|
|
@ -137,10 +137,9 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcut={{
|
{@attach shortcut({ key: 'm', ctrl: true, shift: true }, $t('get_my_immich_link'), () =>
|
||||||
shortcut: { ctrl: true, shift: true, key: 'm' },
|
copyToClipboard(getMyImmichLink().toString()),
|
||||||
onShortcut: () => copyToClipboard(getMyImmichLink().toString()),
|
)}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if page.data.error}
|
{#if page.data.error}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue