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';
|
import type { ActionReturn } from 'svelte/action';
|
||||||
|
|
||||||
|
interface ExplainedShortcut {
|
||||||
|
key: string[];
|
||||||
|
action: string;
|
||||||
|
info?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type Shortcut = {
|
export type Shortcut = {
|
||||||
key: string;
|
key: string;
|
||||||
alt?: boolean;
|
alt?: boolean;
|
||||||
@ -14,6 +20,7 @@ export type ShortcutOptions<T = HTMLElement> = {
|
|||||||
ignoreInputFields?: boolean;
|
ignoreInputFields?: boolean;
|
||||||
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
|
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
|
||||||
preventDefault?: boolean;
|
preventDefault?: boolean;
|
||||||
|
explainedShortcut?: ExplainedShortcut;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shortcutLabel = (shortcut: Shortcut) => {
|
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">
|
<script lang="ts">
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
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 SearchResultsAssetViewer from '$lib/components/search/SearchResultsAssetViewer.svelte';
|
||||||
|
|
||||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||||
@ -33,6 +34,7 @@
|
|||||||
{#snippet assetViewer({ onViewerClose })}
|
{#snippet assetViewer({ onViewerClose })}
|
||||||
<SearchResultsAssetViewer timelineManager={searchResultsManager} {onViewerClose} />
|
<SearchResultsAssetViewer timelineManager={searchResultsManager} {onViewerClose} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
<SearchKeyboardShortcuts {assetInteraction} timelineManager={searchResultsManager} />
|
||||||
<Photostream
|
<Photostream
|
||||||
bind:this={viewer}
|
bind:this={viewer}
|
||||||
{stylePaddingHorizontalPx}
|
{stylePaddingHorizontalPx}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user