refactor: shortcuts modal (#18175)

This commit is contained in:
Daniel Dietzler 2025-05-09 19:24:36 +02:00 committed by GitHub
parent 6a69dafd31
commit f21fe8716c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 153 additions and 140 deletions

View File

@ -1,33 +1,34 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util'; import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import Portal from '../shared-components/portal/portal.svelte'; import Portal from '../shared-components/portal/portal.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte'; import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte'; import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { page } from '$app/stores';
import type { UpdatePayload } from 'vite';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
interface Props { interface Props {
isSelectionMode?: boolean; isSelectionMode?: boolean;
@ -75,7 +76,6 @@
let element: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state();
let showShortcuts = $state(false);
let showSkeleton = $state(true); let showSkeleton = $state(true);
let scrubBucketPercent = $state(0); let scrubBucketPercent = $state(0);
let scrubBucket: { bucketDate: string | undefined } | undefined = $state(); let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
@ -623,6 +623,17 @@
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0); let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id)); let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
$effect(() => { $effect(() => {
if (isEmpty) { if (isEmpty) {
@ -638,7 +649,7 @@
const shortcuts: ShortcutOptions[] = [ const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement }, { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
@ -690,10 +701,6 @@
/> />
{/if} {/if}
{#if showShortcuts}
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if}
{#if assetStore.buckets.length > 0} {#if assetStore.buckets.length > 0}
<Scrubber <Scrubber
{assetStore} {assetStore}

View File

@ -1,28 +1,29 @@
<script lang="ts"> <script lang="ts">
import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions'; import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { featureFlags } from '$lib/stores/server-config.store'; import { focusNext } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { type AssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto } from '@immich/sdk';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
import Portal from '../portal/portal.svelte';
import { handlePromiseError } from '$lib/utils';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import Portal from '../portal/portal.svelte';
import { debounce } from 'lodash-es';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { focusNext } from '$lib/utils/focus-util';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: AssetResponseDto[];
@ -106,7 +107,6 @@
}; };
}); });
let showShortcuts = $state(false);
let currentViewAssetIndex = 0; let currentViewAssetIndex = 0;
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: AssetResponseDto | null = $state(null); let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
@ -263,6 +263,18 @@
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true); const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false); const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
let shortcutList = $derived( let shortcutList = $derived(
(() => { (() => {
if ($isViewerOpen) { if ($isViewerOpen) {
@ -270,7 +282,7 @@
} }
const shortcuts: ShortcutOptions[] = [ const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset }, { shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
@ -439,10 +451,6 @@
/> />
{/if} {/if}
{#if showShortcuts}
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if}
{#if assets.length > 0} {#if assets.length > 0}
<div <div
style:position="relative" style:position="relative"

View File

@ -1,94 +0,0 @@
<script lang="ts">
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import Icon from '../elements/icon.svelte';
import FullScreenModal from './full-screen-modal.svelte';
interface Shortcuts {
general: ExplainedShortcut[];
actions: ExplainedShortcut[];
}
interface ExplainedShortcut {
key: string[];
action: string;
info?: string;
}
interface Props {
onClose: () => void;
shortcuts?: Shortcuts;
}
let {
onClose,
shortcuts = {
general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
{ 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: ['⇧', '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>
<FullScreenModal title={$t('keyboard_shortcuts')} width="auto" {onClose}>
<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>{$t('general')}</h2>
<div class="text-sm">
{#each shortcuts.general as shortcut (shortcut.key.join('-'))}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key (key)}
<p class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2">
{key}
</p>
{/each}
</div>
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
</div>
{/each}
</div>
</div>
{/if}
{#if shortcuts.actions.length > 0}
<div class="p-4">
<h2>{$t('actions')}</h2>
<div class="text-sm">
{#each shortcuts.actions as shortcut (shortcut.key.join('-'))}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key (key)}
<p class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2">
{key}
</p>
{/each}
</div>
<div class="flex items-center gap-2">
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
{#if shortcut.info}
<Icon path={mdiInformationOutline} title={shortcut.info} />
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</FullScreenModal>

View File

@ -0,0 +1,100 @@
<script lang="ts">
import { Modal, ModalBody } from '@immich/ui';
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import Icon from '../components/elements/icon.svelte';
interface Shortcuts {
general: ExplainedShortcut[];
actions: ExplainedShortcut[];
}
interface ExplainedShortcut {
key: string[];
action: string;
info?: string;
}
interface Props {
onClose: () => void;
shortcuts?: Shortcuts;
}
let {
onClose,
shortcuts = {
general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
{ 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: ['⇧', '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>
<Modal title={$t('keyboard_shortcuts')} size="medium" {onClose}>
<ModalBody>
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
{#if shortcuts.general.length > 0}
<div class="p-4">
<h2>{$t('general')}</h2>
<div class="text-sm">
{#each shortcuts.general as shortcut (shortcut.key.join('-'))}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key (key)}
<p
class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
</div>
{/each}
</div>
</div>
{/if}
{#if shortcuts.actions.length > 0}
<div class="p-4">
<h2>{$t('actions')}</h2>
<div class="text-sm">
{#each shortcuts.actions as shortcut (shortcut.key.join('-'))}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key (key)}
<p
class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<div class="flex items-center gap-2">
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
{#if shortcut.info}
<Icon path={mdiInformationOutline} title={shortcut.info} />
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</ModalBody>
</Modal>

View File

@ -1,19 +1,18 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte'; import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { mdiKeyboard } from '@mdi/js'; import { mdiKeyboard } from '@mdi/js';
import type { PageData } from './$types';
import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props { interface Props {
data: PageData; data: PageData;
} }
let { data }: Props = $props(); let { data }: Props = $props();
let isShowKeyboardShortcut = $state(false);
</script> </script>
<UserPageLayout title={data.meta.title}> <UserPageLayout title={data.meta.title}>
@ -21,7 +20,7 @@
<CircleIconButton <CircleIconButton
icon={mdiKeyboard} icon={mdiKeyboard}
title={$t('show_keyboard_shortcuts')} title={$t('show_keyboard_shortcuts')}
onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)} onclick={() => modalManager.show(ShortcutsModal, {})}
/> />
{/snippet} {/snippet}
<section class="mx-4 flex place-content-center"> <section class="mx-4 flex place-content-center">
@ -30,7 +29,3 @@
</div> </div>
</section> </section>
</UserPageLayout> </UserPageLayout>
{#if isShowKeyboardShortcut}
<ShowShortcuts onClose={() => (isShowKeyboardShortcut = false)} />
{/if}

View File

@ -7,8 +7,9 @@
NotificationType, NotificationType,
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte';
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { stackAssets } from '$lib/utils/asset-utils'; import { stackAssets } from '$lib/utils/asset-utils';
@ -27,7 +28,6 @@
let { data = $bindable() }: Props = $props(); let { data = $bindable() }: Props = $props();
let isShowKeyboardShortcut = $state(false);
let isShowDuplicateInfo = $state(false); let isShowDuplicateInfo = $state(false);
interface Shortcuts { interface Shortcuts {
@ -185,7 +185,7 @@
color="secondary" color="secondary"
icon={mdiKeyboard} icon={mdiKeyboard}
title={$t('show_keyboard_shortcuts')} title={$t('show_keyboard_shortcuts')}
onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)} onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })}
aria-label={$t('show_keyboard_shortcuts')} aria-label={$t('show_keyboard_shortcuts')}
/> />
</HStack> </HStack>
@ -222,9 +222,6 @@
</div> </div>
</UserPageLayout> </UserPageLayout>
{#if isShowKeyboardShortcut}
<ShowShortcuts shortcuts={duplicateShortcuts} onClose={() => (isShowKeyboardShortcut = false)} />
{/if}
{#if isShowDuplicateInfo} {#if isShowDuplicateInfo}
<DuplicatesModal onClose={() => (isShowDuplicateInfo = false)} /> <DuplicatesModal onClose={() => (isShowDuplicateInfo = false)} />
{/if} {/if}