Compare commits

..

2 Commits

Author SHA1 Message Date
midzelis
488329d862 refactor(web): extract timeline selection logic into SelectableSegment and SelectableDay components
- Move asset selection, range selection, and keyboard interaction logic
  to SelectableSegment
  - Extract day group selection logic to SelectableDay component
  - Simplify Timeline component by removing selection-related state and
  handlers
  - Fix scroll compensation handling with dedicated while loop
  - Remove unused keyboard handlers from Scrubber component
2025-10-29 02:01:33 +00:00
midzelis
9656bc77a8 refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components 2025-10-29 01:14:37 +00:00
58 changed files with 1145 additions and 1620 deletions

View File

@@ -481,7 +481,6 @@
"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",
@@ -505,7 +504,6 @@
"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",
@@ -753,7 +751,6 @@
"copy_error": "Copy error",
"copy_file_path": "Copy file path",
"copy_image": "Copy Image",
"copy_image_to_clipboard": "Copy image to clipboard",
"copy_link": "Copy link",
"copy_link_to_clipboard": "Copy link to clipboard",
"copy_password": "Copy password",
@@ -834,7 +831,6 @@
"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",
@@ -1113,8 +1109,6 @@
"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",
@@ -1126,11 +1120,9 @@
"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",
@@ -1384,13 +1376,10 @@
"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",
@@ -1402,7 +1391,6 @@
"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",
@@ -1422,10 +1410,7 @@
"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.",
@@ -1451,7 +1436,6 @@
"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",
@@ -1585,14 +1569,11 @@
"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",
@@ -1638,7 +1619,6 @@
"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}}",
@@ -1826,7 +1806,6 @@
"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",
@@ -1990,7 +1969,6 @@
"start": "Start",
"start_date": "Start date",
"start_date_before_end_date": "Start date must be before end date",
"start_slideshow": "Start slideshow",
"state": "State",
"status": "Status",
"stop_casting": "Stop casting",
@@ -2165,7 +2143,6 @@
"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",

View File

@@ -1,21 +1,11 @@
import { matchesShortcut } from '$lib/actions/shortcut';
import type { ActionReturn } from 'svelte/action';
import type { KeyCombo } from './input';
interface Options {
onOutclick?: () => void;
onEscape?: () => void;
}
export const matchesShortcut = (event: KeyboardEvent, shortcut: KeyCombo) => {
return (
shortcut.key.toLowerCase() === event.key.toLowerCase() &&
Boolean(shortcut.alt) === event.altKey &&
Boolean(shortcut.ctrl) === event.ctrlKey &&
Boolean(shortcut.shift) === event.shiftKey &&
Boolean(shortcut.meta) === event.metaKey
);
};
/**
* Calls a function when a click occurs outside of the element, or when the escape key is pressed.
* @param node

View File

@@ -1,4 +1,4 @@
import { onKeydown } from '$lib/actions/input';
import { shortcuts } from '$lib/actions/shortcut';
import { tick } from 'svelte';
import type { Action } from 'svelte/action';
@@ -95,22 +95,13 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option
currentEl?.click();
};
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;
};
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) },
]);
return {
update(newOptions) {

View File

@@ -1,229 +0,0 @@
export type ShortcutCallback = (event: KeyboardEvent) => false | unknown;
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[];
// OS/Browser well known keyboard shortcuts (do not bind to these keys)
const RESERVED_SHORTCUTS: Record<string, KeyCombo[]> = {
// macOS keybindings
mac: [
{ key: 'q', meta: true }, // Quit
{ key: 'w', meta: true }, // Close window
{ key: 'h', meta: true }, // Hide
{ key: 'm', meta: true }, // Minimize
{ key: 'Tab', meta: true }, // App switcher
{ key: ' ', meta: true }, // Spotlight
{ key: 'F3', ctrl: true }, // Mission Control
],
// Windows keybindings
win: [
{ key: 'F4', alt: true }, // Close window
{ key: 'Delete', ctrl: true, alt: true },
{ key: 'Meta' }, // Start menu
{ key: 'l', meta: true }, // Lock
{ key: 'd', meta: true }, // Desktop
{ key: 'Tab', meta: true }, // Task switcher
],
// Linux keybindings
linux: [{ key: 'F4', alt: true }, { key: 'Delete', ctrl: true, alt: true }, { key: 'Meta' }],
// Browser-specific keybindings (cross-platform)
browser: [
{ key: 't', ctrl: true },
{ key: 'w', ctrl: true },
{ key: 'n', ctrl: true },
{ key: 'n', ctrl: true, shift: true },
{ key: 't', ctrl: true, shift: true },
{ key: 'p', ctrl: true, shift: true },
],
};
const ALL_RESERVED_SHORTCUTS = [
...RESERVED_SHORTCUTS.mac,
...RESERVED_SHORTCUTS.win,
...RESERVED_SHORTCUTS.linux,
...RESERVED_SHORTCUTS.browser,
];
const attachmentFactory = (listenerFactory: KeyDownListenerFactory) => (element: KeyTargets) => {
const listener = listenerFactory(element);
element.addEventListener('keydown', listener as EventListener);
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);
};
function isPromise<T = unknown>(obj: unknown): obj is Promise<T> {
return (
obj !== null &&
(typeof obj === 'object' || typeof obj === 'function') &&
typeof (obj as { then?: unknown }).then === 'function'
);
}
function checkForReservedShortcuts(
shortcuts: KeyCombo[],
handler: (event: KeyboardEvent) => void,
): (event: KeyboardEvent) => void {
const formatCombo = (combo: KeyCombo) => {
const parts = [];
if (combo.ctrl) {
parts.push('Ctrl');
}
if (combo.alt) {
parts.push('Alt');
}
if (combo.shift) {
parts.push('Shift');
}
if (combo.meta) {
parts.push('Meta');
}
parts.push(combo.key);
return parts.join('+');
};
for (const shortcut of shortcuts) {
for (const reserved of ALL_RESERVED_SHORTCUTS) {
// Check if shortcuts match (comparing all properties)
if (
shortcut.key.toLowerCase() === reserved.key.toLowerCase() &&
!!shortcut.ctrl === !!reserved.ctrl &&
!!shortcut.alt === !!reserved.alt &&
!!shortcut.shift === !!reserved.shift &&
!!shortcut.meta === !!reserved.meta
) {
console.error(
`[Keyboard Shortcut Warning] Attempting to register reserved shortcut: ${formatCombo(shortcut)}. ` +
`This shortcut is reserved by the OS or browser and may not work as expected.`,
);
return () => void 0;
}
}
}
return handler;
}
export const keyDownListenerFactory = (
isActiveFactory: () => () => boolean,
options: { ignoreInputFields?: boolean },
shortcuts: KeyCombo[],
callback: ShortcutCallback,
) =>
checkForReservedShortcuts(shortcuts, (event: KeyboardEvent) => {
const isActive = isActiveFactory();
if (!isActive() || !isKeyboardEvent(event) || ((options.ignoreInputFields ?? true) && shouldIgnoreEvent(event))) {
return;
}
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;
// On mac, pressing 'alt+<somekey>' transforms the key to a special character
// but the code property has the physical key. If the code starts with Key/Digit
// extract the key from the code to consistently process alt keys on all platforms.
let baseKey = event.key;
const code = event.code;
if (code.startsWith('Key')) {
baseKey = code.slice(3).toLowerCase();
} else if (code.startsWith('Digit')) {
baseKey = code.slice(5);
}
if (
baseKey !== matchingKey ||
!!currentShortcut.ctrl !== event.ctrlKey ||
!!currentShortcut.alt !== event.altKey ||
!!currentShortcut.shift !== event.shiftKey
) {
continue;
}
const result = callback(event);
if (isPromise(result) || result === false) {
// An event handler must be syncronous to call preventDefault
// if a handler is a promise then it can't rely on the automatic
// preventDefault() behavior, and must manually do that itself.
return;
}
// result must be true or void, in both cases, the event is 'handled'
// so we should prevent the default behavior (prevent OS/browser shortcuts)
event.preventDefault();
return;
}
});
export const alwaysTrueFactory = () => () => true;
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 altKey = (key: string) => ({ key, alt: true });
export const ctrlKey = (key: string) => ({ key, ctrl: true });
export const shiftKey = (key: string) => ({ key, shift: true });
export const metaKey = (key: string) => ({ key, meta: true });
export const ctrlShiftKey = (key: string) => ({ key, ctrl: true, shift: true });
export const isStringArray = (value: unknown): value is string[] => {
return Array.isArray(value) && typeof value[0] === 'string';
};
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: ShortcutCallback,
options?: {
// default is true if unspecified
ignoreInputFields?: boolean;
},
) =>
attachmentFactory(() =>
keyDownListenerFactory(alwaysTrueFactory, { ...defaultOptions, ...options }, normalizeKeyInput(keyInput), callback),
);

View File

@@ -1,4 +1,4 @@
import { onKeydown } from '$lib/actions/input';
import { shortcuts } from '$lib/actions/shortcut';
import type { Action } from 'svelte/action';
/**
@@ -30,17 +30,10 @@ export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
}
};
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;
};
const { destroy } = shortcuts(node, [
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => moveFocus('up'), ignoreInputFields: false },
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => moveFocus('down'), ignoreInputFields: false },
]);
return {
update(newContainer) {

View File

@@ -1,315 +0,0 @@
import {
alwaysTrueFactory,
type KeyCombo,
keyDownListenerFactory,
type KeyDownListenerFactory,
type KeyInput,
normalizeKeyInput,
type ShortcutCallback,
} 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: ShortcutCallback) => {
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;
try {
await modalManager.show(ShortcutsModal, { shortcutVariants, shortcuts: activeScopeShortcuts });
} finally {
showingShortcuts = false;
}
};
export const resetModal = () => {
// only used by ShortcutsModal - used to restore state after HMR.
// do not use for any other reason
showingShortcuts = false;
};
const startup = () => {
// add the default '?' shortcut to launch the help menu
const unregister = attachmentFactory(
{ category: Category.Application, text: 'Open Shortcuts Help', key: [['?']] },
() => keyDownListenerFactory(alwaysTrueFactory, {}, [{ key: '?', shift: true }], showShortcutsModal),
)(globalThis as unknown as Window);
// put global variants here
shortcutVariants.set(ShortcutVariant.AddAlbum, ShortcutVariant.AddSharedAlbum);
return unregister as () => void;
};
const registerHmr = () => {
const hot = import.meta.hot;
if (!hot) {
startup();
return;
}
if (import.meta.hot!.data?.shortcut_state) {
const shortcut_state = import.meta.hot!.data.shortcut_state;
const _pairMap = new SvelteMap<ShortcutVariant, ShortcutVariant>();
for (const element of shortcut_state.pairMap.keys()) {
_pairMap.set(element, shortcut_state.pairMap.get(element));
}
if (shortcut_state) {
helpArray = shortcut_state.helpArray;
showingShortcuts = shortcut_state.showingShortcuts;
currentScope = shortcut_state.currentScope;
shortcutVariants = _pairMap;
}
}
// startup() must be called after the hot-state has been restored
const unregister = startup();
hot.on('vite:beforeUpdate', () => {
const shortcut_state = {
helpArray: [...$state.snapshot(helpArray)],
showingShortcuts,
currentScope,
pairMap: $state.snapshot(shortcutVariants),
};
unregister();
import.meta.hot!.data.shortcut_state = shortcut_state;
});
};
registerHmr();

View File

@@ -0,0 +1,108 @@
import type { ActionReturn } from 'svelte/action';
export type Shortcut = {
key: string;
alt?: boolean;
ctrl?: boolean;
shift?: boolean;
meta?: boolean;
};
export type ShortcutOptions<T = HTMLElement> = {
shortcut: Shortcut;
/** If true, the event handler will not execute if the event comes from an input field */
ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
preventDefault?: boolean;
};
export const shortcutLabel = (shortcut: Shortcut) => {
let label = '';
if (shortcut.ctrl) {
label += 'Ctrl ';
}
if (shortcut.alt) {
label += 'Alt ';
}
if (shortcut.meta) {
label += 'Cmd ';
}
if (shortcut.shift) {
label += '⇧';
}
label += shortcut.key.toUpperCase();
return label;
};
/** Determines whether an event should be ignored. The event will be ignored if:
* - The element dispatching the event is not the same as the element which the event listener is attached to
* - The element dispatching the event is an input field
*/
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
if (event.target === event.currentTarget) {
return false;
}
const type = (event.target as HTMLInputElement).type;
return ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type);
};
export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {
return (
shortcut.key.toLowerCase() === event.key.toLowerCase() &&
Boolean(shortcut.alt) === event.altKey &&
Boolean(shortcut.ctrl) === event.ctrlKey &&
Boolean(shortcut.shift) === event.shiftKey &&
Boolean(shortcut.meta) === event.metaKey
);
};
/** Bind a single keyboard shortcut to node. */
export const shortcut = <T extends HTMLElement>(
node: T,
option: ShortcutOptions<T>,
): ActionReturn<ShortcutOptions<T>> => {
const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]);
return {
update(newOption) {
shortcutsUpdate?.([newOption]);
},
destroy,
};
};
/** Binds multiple keyboard shortcuts to node */
export const shortcuts = <T extends HTMLElement>(
node: T,
options: ShortcutOptions<T>[],
): ActionReturn<ShortcutOptions<T>[]> => {
function onKeydown(event: KeyboardEvent) {
const ignoreShortcut = shouldIgnoreEvent(event);
for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) {
if (ignoreInputFields && ignoreShortcut) {
continue;
}
if (matchesShortcut(event, shortcut)) {
if (preventDefault) {
event.preventDefault();
}
onShortcut(event as KeyboardEvent & { currentTarget: T });
return;
}
}
}
node.addEventListener('keydown', onKeydown);
return {
update(newOptions) {
options = newOptions;
},
destroy() {
node.removeEventListener('keydown', onKeydown);
},
};
};

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { blurOnEnter } from '$lib/actions/input';
import { shortcut } from '$lib/actions/shortcut';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { t } from 'svelte-i18n';
@@ -36,6 +36,7 @@
</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'
@@ -45,5 +46,4 @@
disabled={!isOwned}
title={$t('edit_title')}
placeholder={$t('add_a_title')}
{@attach blurOnEnter}
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { shiftKey } from '$lib/actions/input';
import { Category, category, registerShortcutVariant, shortcut, ShortcutVariant } from '$lib/actions/shortcut.svelte';
import { shortcuts } from '$lib/actions/shortcut';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
@@ -64,19 +63,10 @@
</script>
<svelte:document
{@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)}
use:shortcuts={[
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete(asset.isTrashed) },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
]}
/>
<IconButton

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { shiftKey } from '$lib/actions/input';
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import { shortcut } from '$lib/actions/shortcut';
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';
@@ -20,7 +19,7 @@
const onDownloadFile = async () => downloadFile(await getAssetInfo({ ...authManager.params, id: asset.id }));
</script>
<svelte:document {@attach shortcut(shiftKey('d'), category(Category.QuickActions, $t('download')), onDownloadFile)} />
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
{#if !menuItem}
<IconButton

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import { shortcut } from '$lib/actions/shortcut';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -39,7 +39,7 @@
};
</script>
<svelte:document {@attach shortcut('f', category(Category.AssetActions, 'Toggle favorite'), toggleFavorite)} />
<svelte:document use:shortcut={{ shortcut: { key: 'f' }, onShortcut: toggleFavorite }} />
<IconButton
color="secondary"

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { category, Category, shortcut } from '$lib/actions/shortcut.svelte';
import { shortcut } from '$lib/actions/shortcut';
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 {@attach shortcut('i', category(Category.QuickActions, $t('info')), onShowDetail)} />
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
<IconButton
color="secondary"

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { autoGrowHeight } from '$lib/actions/autogrow';
import { onKeydown } from '$lib/actions/input';
import { shortcut } from '$lib/actions/shortcut';
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';
@@ -254,7 +254,10 @@
bind:value={message}
use:autoGrowHeight={{ height: '5px', value: message }}
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
{@attach onKeydown({ key: 'Enter' }, () => void handleSendComment())}
use:shortcut={{
shortcut: { key: 'Enter' },
onShortcut: () => handleSendComment(),
}}
class="h-[18px] {disabled
? 'cursor-not-allowed'
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"

View File

@@ -1,6 +1,5 @@
<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';
@@ -387,15 +386,9 @@
handlePromiseError(handleGetAllAlbums());
}
});
const endScope = newShortcutScope();
onMount(() => endScope);
</script>
<svelte:document
bind:fullscreenElement
{@attach registerShortcutVariant(ShortcutVariant.PrevAsset, ShortcutVariant.NextAsset)}
/>
<svelte:document bind:fullscreenElement />
<section
id="immich-asset-viewer"

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut.svelte';
import { shortcut } from '$lib/actions/shortcut';
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 {@attach shortcut('Escape', $t('close'), onClose)} />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<div class="flex place-items-center gap-2">

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { ctrlKey, metaKey } from '$lib/actions/input';
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import { shortcuts } from '$lib/actions/shortcut';
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 +204,13 @@
</script>
<svelte:document
{@attach shortcut('z', category(Category.ViewActions, $t('zoom_image')), zoomToggle)}
{@attach shortcut('s', category(Category.ViewActions, $t('start_slideshow')), onPlaySlideshow)}
{@attach shortcut(
[ctrlKey('c'), metaKey('c')],
category(Category.QuickActions, $t('copy_image_to_clipboard')),
onCopyShortcut,
)}
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 },
]}
/>
{#if imageError}
<div class="h-full w-full">

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import { shortcuts } from '$lib/actions/shortcut';
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,16 +136,22 @@
<svelte:document
onmousemove={showControlBar}
{@attach shortcut('Escape', category(Category.Application, $t('exit_slideshow')), onClose)}
{@attach shortcut('ArrowLeft', category(Category.Navigation, $t('previous')), onPrevious)}
{@attach shortcut('ArrowRight', category(Category.Navigation, $t('next')), onNext)}
{@attach shortcut(' ', $t('pause'), () => {
if (progressBarStatus === ProgressBarStatus.Paused) {
progressBar?.play();
} else {
progressBar?.pause();
}
})}
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,
},
]}
/>
{/* @ts-expect-error https://github.com/Rezi/svelte-gestures/issues/38#issuecomment-3315953573 */ null}

View File

@@ -121,6 +121,7 @@
const onMouseLeave = () => {
mouseOver = false;
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
};
let timer: ReturnType<typeof setTimeout> | null = null;

View File

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

View File

@@ -3,7 +3,7 @@
import { page } from '$app/state';
import { intersectionObserver } from '$lib/actions/intersection-observer';
import { resizeObserver } from '$lib/actions/resize-observer';
import { shortcut } from '$lib/actions/shortcut.svelte';
import { shortcuts } from '$lib/actions/shortcut';
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';
@@ -305,9 +305,15 @@
</script>
<svelte:document
{@attach shortcut(['ArrowRight', 'd'], $t('next'), handleNextAsset)}
{@attach shortcut(['ArrowLeft', 'a'], $t('previous'), handlePreviousAsset)}
{@attach shortcut(['Escape'], $t('timeline'), handleEscape)}
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() },
]}
/>
{#if assetInteraction.selectionActive}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ctrlKey, shiftKey } from '$lib/actions/input';
import {
Category,
category,
conditionalShortcut,
newShortcutScope,
registerShortcutVariant,
shortcut,
ShortcutVariant,
} from '$lib/actions/shortcut.svelte';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
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';
@@ -29,8 +20,11 @@
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 DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
interface Props {
initialAssetId?: string;
assets: TimelineAsset[] | AssetResponseDto[];
@@ -254,10 +248,53 @@
};
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;
@@ -393,51 +430,8 @@
onkeydown={onKeyDown}
onkeyup={onKeyUp}
onselectstart={onSelectStart}
use:shortcuts={shortcutList}
onscroll={() => updateSlidingWindow()}
{@attach newShortcutScope}
{@attach shortcut('/', $t('places'), () => goto(AppRoute.EXPLORE))}
{@attach shortcut(
ctrlKey('a'),
category(Category.Selection, $t('select_all'), ShortcutVariant.SelectAll),
selectAllAssets,
)}
{@attach shortcut(
[{ key: 'Escape' }, ctrlKey('d')],
category(Category.Selection, $t('deselect_all'), ShortcutVariant.DeselectAll),
deselectAllAssets,
)}
{@attach registerShortcutVariant(ShortcutVariant.SelectAll, ShortcutVariant.DeselectAll)}
{@attach shortcut(
'Delete',
category(Category.Selection, isTrashEnabled ? $t('move_to_trash') : $t('delete'), ShortcutVariant.Trash),
onDelete,
)}
{@attach shortcut(
shiftKey('Delete'),
category(Category.Selection, isTrashEnabled ? $t('delete_skip_trash') : $t('delete'), ShortcutVariant.Delete),
onForceDelete,
)}
{@attach registerShortcutVariant(ShortcutVariant.Trash, ShortcutVariant.Delete)}
{@attach shortcut(shiftKey('a'), category(Category.Selection, $t('archive')), toggleArchive)}
{@attach conditionalShortcut(
() => arrowNavigation,
() =>
shortcut(
'ArrowRight',
category(Category.Navigation, $t('focus_next'), ShortcutVariant.FocusNext),
focusNextAsset,
),
)}
{@attach conditionalShortcut(
() => arrowNavigation,
() =>
shortcut(
'ArrowLeft',
category(Category.Navigation, $t('focus_previous'), ShortcutVariant.FocusPrevious),
focusPreviousAsset,
),
)}
{@attach registerShortcutVariant(ShortcutVariant.FocusNext, ShortcutVariant.FocusPrevious)}
/>
{#if isShowDeleteConfirmation}

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusOutside } from '$lib/actions/focus-outside';
import { ctrlKey, ctrlShiftKey, onKeydown } from '$lib/actions/input';
import { Category, category, registerShortcutVariant, shortcut, ShortcutVariant } from '$lib/actions/shortcut.svelte';
import { shortcuts } from '$lib/actions/shortcut';
import { AppRoute } from '$lib/constants';
import SearchFilterModal from '$lib/modals/SearchFilterModal.svelte';
import { searchStore } from '$lib/stores/search.svelte';
@@ -211,17 +210,11 @@
</script>
<svelte:document
{@attach shortcut(
ctrlKey('k'),
category(Category.Application, $t('search_your_photos'), ShortcutVariant.Search),
() => input?.select(),
)}
{@attach shortcut(
ctrlShiftKey('k'),
category(Category.Application, $t('open_the_search_filters'), ShortcutVariant.SearchFilter),
onFilterClick,
)}
{@attach registerShortcutVariant(ShortcutVariant.Search, ShortcutVariant.SearchFilter)}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}
/>
<div class="w-full relative z-auto" use:focusOutside={{ onFocusOut }} tabindex="-1">
@@ -258,11 +251,14 @@
aria-activedescendant={selectedId ?? ''}
aria-expanded={showSuggestions && isSearchSuggestions}
aria-autocomplete="list"
{@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)}
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 },
]}
/>
<!-- SEARCH HISTORY BOX -->

View File

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

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { uploadAssetsStore } from '$lib/stores/upload';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
let { isUploading } = uploadAssetsStore;
interface Props {
viewerAssets: ViewerAsset[];
width: number;
height: number;
manager: VirtualScrollManager;
thumbnail: Snippet<
[
{
asset: TimelineAsset;
position: CommonPosition;
},
]
>;
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
}
let { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
const transitionDuration = $derived.by(() => (manager.suspendTransitions && !$isUploading ? 0 : 150));
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting);
}
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
{@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)}
</div>
{/each}
</div>
<style>
[data-image-grid] {
user-select: none;
}
</style>

View File

@@ -0,0 +1,189 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import type { Snippet } from 'svelte';
interface Props {
content: Snippet<
[
{
onAssetOpen: (asset: TimelineAsset) => void;
onAssetSelect: (asset: TimelineAsset) => void;
onAssetHover: (asset: TimelineAsset | null) => void;
},
]
>;
isSelectionMode: boolean;
singleSelect: boolean;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetSelect?: (asset: TimelineAsset) => void;
}
let { content, isSelectionMode, singleSelect, assetInteraction, timelineManager, onAssetOpen, onAssetSelect }: Props =
$props();
let shiftKeyIsDown = $state(false);
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
let lastMouseHoverAsset: TimelineAsset | null = $state(null);
$effect(() => {
if (shiftKeyIsDown && lastMouseHoverAsset) {
void selectAssetCandidates(lastMouseHoverAsset);
}
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const defaultAssetOpen = async (asset: TimelineAsset) => {
if (isSelectionMode || assetInteraction.selectionActive) {
await handleAssetSelect(asset);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleOnAssetOpen = (asset: TimelineAsset) => {
if (onAssetOpen) {
onAssetOpen(asset, () => void defaultAssetOpen(asset));
return;
}
void defaultAssetOpen(asset);
};
// called when clicking asset with shift key pressed or with mouse
const handleAssetSelect = async (asset: TimelineAsset) => {
await handleSelectAssets(asset);
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
assetInteraction.clearAssetSelectionCandidates();
if (lastMouseHoverAsset) {
void selectAssetCandidates(lastMouseHoverAsset);
return;
}
if (!assetInteraction.assetSelectionStart) {
assetInteraction.setAssetSelectionStart(assetInteraction.selectedAssets.at(-1) ?? null);
}
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
assetInteraction.clearAssetSelectionCandidates();
}
};
const handleOnHover = (asset: TimelineAsset | null) => {
if (asset) {
if (assetInteraction.selectionActive) {
void selectAssetCandidates(asset);
}
lastMouseHoverAsset = asset;
}
};
const handleSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
onAssetSelect?.(asset);
if (singleSelect) {
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
const assets = await timelineManager.retrieveRange(assetInteraction.assetSelectionStart, asset);
for (const asset of assets) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
return null;
};
const handleSelectAsset = (asset: TimelineAsset) => {
if ('albumAssets' in timelineManager) {
const tm = timelineManager as TimelineManager;
if (tm.albumAssets.has(asset.id)) {
return;
}
}
assetInteraction.selectAsset(asset);
};
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
{@render content({
onAssetOpen: handleOnAssetOpen,
onAssetSelect: (asset) => {
void handleSelectAssets(asset);
},
onAssetHover: handleOnHover,
})}

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { Snippet } from 'svelte';
interface Props {
content: Snippet<
[
{
onDayGroupSelect: (dayGroup: DayGroup, asset: TimelineAsset[]) => void;
onDayGroupAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
},
]
>;
onAssetSelect: (asset: TimelineAsset) => void;
assetInteraction: AssetInteraction;
}
let { content, assetInteraction, onAssetSelect }: Props = $props();
// called when clicking asset with shift key pressed or with mouse
const onDayGroupAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => {
onAssetSelect(asset);
const assetsInDayGroup = dayGroup.getAssets();
const groupTitle = dayGroup.groupTitle;
// Check if all assets are selected in a group to toggle the group selection's icon
const selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
};
const onDayGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
const group = dayGroup.groupTitle;
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
onAssetSelect(asset);
}
}
};
</script>
{@render content({
onDayGroupSelect,
onDayGroupAssetSelect,
})}

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import type { Snippet } from 'svelte';
interface Props {
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
manager: VirtualScrollManager;
onDayGroupSelect: (daygroup: DayGroup, assets: TimelineAsset[]) => void;
}
let {
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
monthGroup,
manager,
onDayGroupSelect,
}: Props = $props();
let { isUploading } = uploadAssetsStore;
let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state();
const transitionDuration = $derived.by(() =>
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting);
}
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
});
return getDateLocaleString(date);
};
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => {
isMouseOverGroup = true;
hoveredDayGroup = dayGroup.groupTitle;
}}
onmouseleave={() => {
isMouseOverGroup = false;
hoveredDayGroup = null;
}}
>
<!-- Month title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
>
{#if isDayGroupSelected}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
</span>
</div>
<AssetLayout
{manager}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}
{customThumbnailLayout}
>
{#snippet thumbnail({ asset, position })}
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
{/snippet}
</AssetLayout>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
</style>

View File

@@ -2,6 +2,10 @@
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import AssetSelectionController from '$lib/components/timeline/AssetSelectionController.svelte';
import DaySelectionController from '$lib/components/timeline/DaySelectionController.svelte';
import Month from '$lib/components/timeline/Month.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
@@ -9,15 +13,12 @@
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
@@ -25,7 +26,6 @@
import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import TimelineDateGroup from './TimelineDateGroup.svelte';
interface Props {
isSelectionMode?: boolean;
@@ -50,22 +50,12 @@
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void;
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void;
children?: Snippet;
empty?: Snippet;
customLayout?: Snippet<[TimelineAsset]>;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
}
let {
@@ -82,12 +72,13 @@
album = null,
person = null,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
onAssetSelect,
onAssetOpen,
onEscape = () => {},
children,
empty,
customLayout,
onThumbnailClick,
customThumbnailLayout,
}: Props = $props();
timelineManager = new TimelineManager();
@@ -254,7 +245,6 @@
});
const updateIsScrolling = () => (timelineManager.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
@@ -362,172 +352,6 @@
}
};
const handleSelectAsset = (asset: TimelineAsset) => {
if (!timelineManager.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
};
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
void selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const handleGroupSelect = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => {
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
handleSelectAsset(asset);
}
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const handleSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
onSelect(asset);
if (singleSelect) {
timelineManager.scrollTo(0);
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
if (startBucket === null || endBucket === null) {
return;
}
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
}
if (monthGroup === endBucket) {
break;
}
}
}
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
$effect(() => {
if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (!shiftKeyIsDown) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
void selectAssetCandidates(lastAssetMouseEvent);
}
});
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
@@ -536,8 +360,6 @@
});
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
<HotModuleReload
onAfterUpdate={() => {
const asset = page.url.searchParams.get('at');
@@ -573,21 +395,6 @@
{viewportTopMonth}
{onScrub}
bind:scrubberWidth
onScrubKeyDown={(evt) => {
evt.preventDefault();
let amount = 50;
if (shiftKeyIsDown) {
amount = 500;
}
if (evt.key === 'ArrowUp') {
amount = -amount;
if (shiftKeyIsDown) {
scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
}
} else if (evt.key === 'ArrowDown') {
scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
}
}}
/>
{/if}
@@ -643,20 +450,56 @@
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<TimelineDateGroup
{withStacked}
{showArchiveIcon}
{assetInteraction}
<AssetSelectionController
{timelineManager}
{assetInteraction}
{isSelectionMode}
{singleSelect}
{monthGroup}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
{customLayout}
{onThumbnailClick}
/>
{onAssetOpen}
{onAssetSelect}
>
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
<DaySelectionController {assetInteraction} {onAssetSelect}>
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
<Month
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
manager={timelineManager}
{onDayGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={() => onAssetOpen(asset)}
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
onMouseEvent={(isMouseOver) => {
if (isMouseOver) {
onAssetHover(asset);
} else {
onAssetHover(null);
}
}}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</Month>
{/snippet}
</DaySelectionController>
{/snippet}
</AssetSelectionController>
</div>
{/if}
{/each}

View File

@@ -1,246 +0,0 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { type Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
interface Props {
isSelectionMode: boolean;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
customLayout?: Snippet<[TimelineAsset]>;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
}
let {
isSelectionMode,
singleSelect,
withStacked,
showArchiveIcon,
monthGroup = $bindable(),
assetInteraction,
timelineManager,
customLayout,
onSelect,
onSelectAssets,
onSelectAssetCandidates,
onThumbnailClick,
}: Props = $props();
let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state();
const transitionDuration = $derived.by(() =>
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[],
groupTitle: string,
) => {
onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group
hoveredDayGroup = groupTitle;
if (assetInteraction.selectionActive) {
onSelectAssetCandidates(asset);
}
};
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting);
}
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
});
return getDateLocaleString(date);
};
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(dayGroup.groupTitle, null);
}}
onmouseleave={() => {
isMouseOverGroup = false;
assetMouseEventHandler(dayGroup.groupTitle, null);
}}
>
<!-- Date group title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
>
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
</span>
</div>
<!-- Image grid -->
<div
data-image-grid
class="relative overflow-clip"
style:height={dayGroup.height + 'px'}
style:width={dayGroup.width + 'px'}
>
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- {#if viewerAsset.intersecting} -->
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) ||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{#if customLayout}
{@render customLayout(asset)}
{/if}
</div>
<!-- {/if} -->
{/each}
</div>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
[data-image-grid] {
user-select: none;
}
</style>

View File

@@ -1,5 +1,4 @@
<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';
@@ -12,10 +11,9 @@
interface Props {
shared?: boolean;
onAddToAlbum?: OnAddToAlbum;
shortcutCategory?: Category;
}
let { shared = false, onAddToAlbum = () => {}, shortcutCategory }: Props = $props();
let { shared = false, onAddToAlbum = () => {} }: Props = $props();
const { getAssets } = getAssetControlContext();
@@ -45,6 +43,4 @@
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
shortcut={{ key: 'l', shift: shared }}
{shortcutCategory}
variant={shared ? ShortcutVariant.AddSharedAlbum : ShortcutVariant.AddAlbum}
/>

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Category, shortcut } from '$lib/actions/shortcut.svelte';
import { shortcut } from '$lib/actions/shortcut';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { IconButton, modalManager } from '@immich/ui';
@@ -9,10 +9,9 @@
interface Props {
menuItem?: boolean;
shortcutCategory?: Category;
}
let { menuItem = false, shortcutCategory }: Props = $props();
let { menuItem = false }: Props = $props();
const text = $t('tag');
const icon = mdiTagMultipleOutline;
@@ -21,17 +20,15 @@
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 {@attach shortcut('t', { text, category: shortcutCategory }, handleTagAssets)} />
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleTagAssets }} />
{#if menuItem}
<MenuOption {text} {icon} onClick={handleTagAssets} />

View File

@@ -1,14 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ctrlKey, shiftKey } from '$lib/actions/input';
import {
Category,
category,
conditionalShortcut,
registerShortcutVariant,
shortcut,
ShortcutVariant,
} from '$lib/actions/shortcut.svelte';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import {
setFocusToAsset as setFocusAssetInit,
@@ -18,7 +10,9 @@
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';
@@ -27,7 +21,6 @@
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;
@@ -41,26 +34,65 @@
timelineManager = $bindable(),
assetInteraction,
isShowDeleteConfirmation = $bindable(false),
onEscape: handleEscape,
onEscape,
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 isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
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;
@@ -89,161 +121,77 @@
}
};
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));
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
// Shortcut handlers
const handleExploreNavigation = () => goto(AppRoute.EXPLORE);
$effect(() => {
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const handleSelectAllAssets = () => selectAllAssets(timelineManager, assetInteraction);
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
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 handleOpenDateModal = async () => {
const asset = await modalManager.show(NavigateToDateModal, { timelineManager });
if (asset) {
setFocusAsset(asset);
}
};
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();
};
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const handleDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
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 });
}
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
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 },
);
}
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();
};
return shortcuts;
})(),
);
</script>
<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)}
/>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { shiftKey } from '$lib/actions/input';
import { shortcut } from '$lib/actions/shortcut.svelte';
import { shortcuts } from '$lib/actions/shortcut';
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';
@@ -106,11 +105,16 @@
</script>
<svelte:document
{@attach shortcut('a', $t('select_all'), onSelectAll)}
{@attach shortcut('s', $t('view'), () => onViewAsset(assets[0]))}
{@attach shortcut('d', $t('deselect_all'), onSelectNone)}
{@attach shortcut(shiftKey('c'), $t('resolve_duplicates'), handleResolve)}
{@attach shortcut(shiftKey('s'), $t('stack'), handleStack)}
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 },
]}
/>
<div class="rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-256 mx-auto mb-4 py-6 px-0.2">

View File

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

View File

@@ -1,103 +1,104 @@
<script lang="ts">
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 { Icon, Modal, ModalBody } from '@immich/ui';
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteMap } from 'svelte/reactivity';
interface Shortcuts {
general: ExplainedShortcut[];
actions: ExplainedShortcut[];
}
interface ExplainedShortcut {
key: string[];
action: string;
info?: string;
}
interface Props {
onClose: () => void;
shortcutVariants: Map<ShortcutVariant, ShortcutVariant>;
shortcuts: KeyboardHelp[];
shortcuts?: Shortcuts;
}
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);
};
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();
</script>
{#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())}>
<Modal title={$t('keyboard_shortcuts')} size="medium" {onClose}>
<ModalBody>
<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="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
{#if shortcuts.general.length > 0}
<div class="p-4">
<h2>{getCategoryString(category)}</h2>
<h2>{$t('general')}</h2>
<div class="text-sm">
{#each actions as shortcut (shortcut)}
{@const paired = shortcut.variant
? getSecondaryShortcut(shortcutVariants.get(shortcut.variant))
: undefined}
{@render row(col, shortcut, paired)}
{#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}
</div>
</div>
{/each}
{/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}
</div>
</ModalBody>
</Modal>

View File

@@ -441,7 +441,7 @@
{isSelectionMode}
{singleSelect}
{showArchiveIcon}
{onSelect}
onAssetSelect={onSelect}
onEscape={handleEscape}
>
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}

View File

@@ -2,8 +2,8 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { focusTrap } from '$lib/actions/focus-trap';
import { blurOnEnter } from '$lib/actions/input';
import { scrollMemory } from '$lib/actions/scroll-memory';
import { shortcut } from '$lib/actions/shortcut';
import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
import PeopleCard from '$lib/components/faces-page/people-card.svelte';
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
@@ -364,7 +364,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')}
{@attach blurOnEnter}
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
onfocusin={() => onNameChangeInputFocus(person)}
onfocusout={() => onNameChangeSubmit(newName, person)}
oninput={(event) => onNameChangeInputUpdate(event)}

View File

@@ -371,7 +371,7 @@
{assetInteraction}
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
onSelect={handleSelectFeaturePhoto}
onAssetSelect={handleSelectFeaturePhoto}
onEscape={handleEscape}
>
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}

View File

@@ -1,6 +1,5 @@
<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';
@@ -107,7 +106,7 @@
</Timeline>
</UserPageLayout>
<div class={[!assetInteraction.selectionActive && 'hidden']}>
{#if assetInteraction.selectionActive}
<AssetSelectControlBar
ownerId={$user.id}
assets={assetInteraction.selectedAssets}
@@ -116,8 +115,8 @@
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum shortcutCategory={Category.Selection} />
<AddToAlbum shortcutCategory={Category.Selection} shared />
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
@@ -128,7 +127,7 @@
})}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction shortcutCategory={Category.Selection} menuItem />
<DownloadAction menuItem />
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
@@ -149,7 +148,7 @@
<ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
{#if $preferences.tags.enabled}
<TagAction shortcutCategory={Category.Selection} menuItem />
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
@@ -161,4 +160,4 @@
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{/if}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { Category, category, shortcut } from '$lib/actions/shortcut.svelte';
import { shortcut } from '$lib/actions/shortcut';
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';
@@ -256,7 +256,7 @@
</script>
<svelte:window bind:scrollY />
<svelte:document {@attach shortcut('Escape', category(Category.Navigation, $t('go_back')), onEscape)} />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
<section>
{#if assetInteraction.selectionActive}

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { showShortcutsModal } from '$lib/actions/shortcut.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
import { Container, IconButton } from '@immich/ui';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Container, IconButton, modalManager } 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={showShortcutsModal}
onclick={() => modalManager.show(ShortcutsModal, {})}
/>
{/snippet}
<Container size="medium" center>

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut, showShortcutsModal } from '$lib/actions/shortcut.svelte';
import { shortcuts } from '$lib/actions/shortcut';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
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';
@@ -34,6 +35,27 @@
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') },
],
};
let duplicates = $state(data.duplicates);
const { isViewing: showAssetViewer } = assetViewingStore;
@@ -185,8 +207,10 @@
</script>
<svelte:document
{@attach shortcut('ArrowLeft', $t('previous'), handlePreviousShortcut)}
{@attach shortcut('ArrowRight', $t('next'), handleNextShortcut)}
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
]}
/>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
@@ -218,7 +242,7 @@
color="secondary"
icon={mdiKeyboard}
title={$t('show_keyboard_shortcuts')}
onclick={() => showShortcutsModal()}
onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })}
aria-label={$t('show_keyboard_shortcuts')}
/>
</HStack>

View File

@@ -5,7 +5,6 @@
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
@@ -110,17 +109,7 @@
return !!asset.latitude && !!asset.longitude;
};
const handleThumbnailClick = (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => {
const handleAssetOpen = (asset: TimelineAsset, defaultAssetOpen: () => void) => {
if (hasGps(asset)) {
locationUpdated = true;
setTimeout(() => {
@@ -128,9 +117,9 @@
}, 1500);
location = { latitude: asset.latitude!, longitude: asset.longitude! };
void setQueryValue('at', asset.id);
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
return;
}
defaultAssetOpen();
};
</script>
@@ -194,9 +183,9 @@
removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape}
withStacked
onThumbnailClick={handleThumbnailClick}
onAssetOpen={handleAssetOpen}
>
{#snippet customLayout(asset: TimelineAsset)}
{#snippet customThumbnailLayout(asset: TimelineAsset)}
{#if hasGps(asset)}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
{asset.city || $t('gps')}

View File

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