mirror of
https://github.com/immich-app/immich.git
synced 2025-09-29 15:31:13 -04:00
feat: search results keyboard actions
This commit is contained in:
parent
cc20b282f4
commit
009e1def3e
@ -1,5 +1,11 @@
|
||||
import type { ActionReturn } from 'svelte/action';
|
||||
|
||||
interface ExplainedShortcut {
|
||||
key: string[];
|
||||
action: string;
|
||||
info?: string;
|
||||
}
|
||||
|
||||
export type Shortcut = {
|
||||
key: string;
|
||||
alt?: boolean;
|
||||
@ -14,6 +20,7 @@ export type ShortcutOptions<T = HTMLElement> = {
|
||||
ignoreInputFields?: boolean;
|
||||
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
|
||||
preventDefault?: boolean;
|
||||
explainedShortcut?: ExplainedShortcut;
|
||||
};
|
||||
|
||||
export const shortcutLabel = (shortcut: Shortcut) => {
|
||||
|
237
web/src/lib/components/search/SearchKeyboardShortcuts.svelte
Normal file
237
web/src/lib/components/search/SearchKeyboardShortcuts.svelte
Normal file
@ -0,0 +1,237 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.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 { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { deleteAssets } from '$lib/utils/actions';
|
||||
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
let { isViewing: isViewerOpen } = assetViewingStore;
|
||||
|
||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||
|
||||
interface Props {
|
||||
timelineManager: PhotostreamManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
|
||||
isShowDeleteConfirmation?: boolean;
|
||||
|
||||
onReload?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
let {
|
||||
timelineManager,
|
||||
assetInteraction,
|
||||
isShowDeleteConfirmation = false,
|
||||
|
||||
onReload,
|
||||
}: Props = $props();
|
||||
|
||||
const selectAllAssets = () => {
|
||||
const allAssets = timelineManager.months.flatMap((segment) => segment.assets);
|
||||
assetInteraction.selectAssets(allAssets);
|
||||
};
|
||||
|
||||
const deselectAllAssets = () => {
|
||||
cancelMultiselect(assetInteraction);
|
||||
};
|
||||
|
||||
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 trashOrDelete = async (force: boolean = false) => {
|
||||
isShowDeleteConfirmation = false;
|
||||
await deleteAssets(
|
||||
!(isTrashEnabled && !force),
|
||||
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||
assetInteraction.selectedAssets,
|
||||
onReload,
|
||||
);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
const assets = assetInteraction.selectedAssets;
|
||||
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||
const ids = await archiveAssets(assets, visibility);
|
||||
const idSet = new Set(ids);
|
||||
if (ids) {
|
||||
for (const asset of assets) {
|
||||
if (idSet.has(asset.id)) {
|
||||
asset.visibility = visibility;
|
||||
}
|
||||
}
|
||||
deselectAllAssets();
|
||||
}
|
||||
};
|
||||
|
||||
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, { shortcuts: getShortcuts() });
|
||||
isShortcutModalOpen = false;
|
||||
};
|
||||
|
||||
const getShortcuts = () => {
|
||||
const general = Object.values(generalShortcuts);
|
||||
const actions = Object.values(actionsShortcuts);
|
||||
return {
|
||||
general: general
|
||||
.filter((general) => 'explainedShortcut' in general)
|
||||
.map((generalShortcut) => generalShortcut.explainedShortcut!),
|
||||
actions: actions.filter((action) => 'explainedShortcut' in action).map((action) => action.explainedShortcut!),
|
||||
};
|
||||
};
|
||||
|
||||
const generalShortcuts = {
|
||||
OPEN_HELP: {
|
||||
shortcut: { key: '?', shift: true },
|
||||
onShortcut: handleOpenShortcutModal,
|
||||
explainedShortcut: {
|
||||
key: ['⇧', '?'],
|
||||
action: 'Open this dialog',
|
||||
},
|
||||
},
|
||||
EXPLORE: {
|
||||
shortcut: { key: '/' },
|
||||
onShortcut: () => goto(AppRoute.EXPLORE),
|
||||
explainedShortcut: {
|
||||
key: ['/'],
|
||||
action: $t('explore'),
|
||||
},
|
||||
},
|
||||
SELECT_ALL: {
|
||||
shortcut: { key: 'A', ctrl: true },
|
||||
onShortcut: () => selectAllAssets(),
|
||||
explainedShortcut: {
|
||||
key: ['Ctrl', 'a'],
|
||||
action: $t('select_all'),
|
||||
},
|
||||
},
|
||||
ARROW_RIGHT: {
|
||||
shortcut: { key: 'ArrowRight' },
|
||||
preventDefault: false,
|
||||
onShortcut: focusNextAsset,
|
||||
explainedShortcut: {
|
||||
key: ['←', '→'],
|
||||
action: $t('previous_or_next_photo'),
|
||||
},
|
||||
},
|
||||
ARROW_LEFT: {
|
||||
shortcut: { key: 'ArrowLeft' },
|
||||
preventDefault: false,
|
||||
onShortcut: focusPreviousAsset,
|
||||
},
|
||||
};
|
||||
const actionsShortcuts = {
|
||||
ESCAPE: {
|
||||
shortcut: { key: 'Escape' },
|
||||
onShortcut: deselectAllAssets,
|
||||
explainedShortcut: {
|
||||
key: ['Esc'],
|
||||
action: $t('back_close_deselect'),
|
||||
},
|
||||
},
|
||||
DELETE: {
|
||||
shortcut: { key: 'Delete' },
|
||||
onShortcut: onDelete,
|
||||
explainedShortcut: {
|
||||
key: ['Del'],
|
||||
action: $t('trash_delete_asset'),
|
||||
info: $t('shift_to_permanent_delete'),
|
||||
},
|
||||
},
|
||||
FORCE_DELETE: {
|
||||
shortcut: { key: 'Delete', shift: true },
|
||||
onShortcut: onForceDelete,
|
||||
},
|
||||
DESELECT_ALL: {
|
||||
shortcut: { key: 'D', ctrl: true },
|
||||
onShortcut: () => deselectAllAssets(),
|
||||
explainedShortcut: {
|
||||
key: ['Ctrl', 'd'],
|
||||
action: $t('deselect_all'),
|
||||
},
|
||||
},
|
||||
TOGGLE_ARCHIVE: {
|
||||
shortcut: { key: 'a', shift: true },
|
||||
onShortcut: toggleArchive,
|
||||
explainedShortcut: {
|
||||
key: ['⇧', 'a'],
|
||||
action: $t('select_all'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const shortcutList = $derived(
|
||||
(() => {
|
||||
if ($isViewerOpen) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const shortcuts: ShortcutOptions[] = [
|
||||
generalShortcuts.OPEN_HELP,
|
||||
generalShortcuts.EXPLORE,
|
||||
generalShortcuts.SELECT_ALL,
|
||||
generalShortcuts.ARROW_RIGHT,
|
||||
generalShortcuts.ARROW_LEFT,
|
||||
];
|
||||
|
||||
if (assetInteraction.selectionActive) {
|
||||
shortcuts.push(
|
||||
actionsShortcuts.ESCAPE,
|
||||
actionsShortcuts.DELETE,
|
||||
actionsShortcuts.FORCE_DELETE,
|
||||
actionsShortcuts.DESELECT_ALL,
|
||||
actionsShortcuts.TOGGLE_ARCHIVE,
|
||||
);
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
})(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcuts={shortcutList} />
|
||||
|
||||
{#if isShowDeleteConfirmation}
|
||||
<DeleteAssetDialog
|
||||
size={assetInteraction.selectedAssets.length}
|
||||
onCancel={() => (isShowDeleteConfirmation = false)}
|
||||
onConfirm={() => handlePromiseError(trashOrDelete(true))}
|
||||
/>
|
||||
{/if}
|
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import SearchKeyboardShortcuts from '$lib/components/search/SearchKeyboardShortcuts.svelte';
|
||||
import SearchResultsAssetViewer from '$lib/components/search/SearchResultsAssetViewer.svelte';
|
||||
|
||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||
@ -33,6 +34,7 @@
|
||||
{#snippet assetViewer({ onViewerClose })}
|
||||
<SearchResultsAssetViewer timelineManager={searchResultsManager} {onViewerClose} />
|
||||
{/snippet}
|
||||
<SearchKeyboardShortcuts {assetInteraction} timelineManager={searchResultsManager} />
|
||||
<Photostream
|
||||
bind:this={viewer}
|
||||
{stylePaddingHorizontalPx}
|
||||
|
Loading…
x
Reference in New Issue
Block a user