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