From b5022d80d6cdb4a27e33970522868d6ea58e3744 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 14 Dec 2024 19:30:33 +0100 Subject: [PATCH] refactor(web): asset interaction (#14662) * refactor(web): asset interaction * feedback --- .../components/album-page/album-viewer.svelte | 19 ++-- .../memory-page/memory-viewer.svelte | 29 +++---- .../actions/select-all-assets.svelte | 10 +-- .../photos-page/asset-date-group.svelte | 26 +++--- .../components/photos-page/asset-grid.svelte | 77 ++++++++--------- .../individual-shared-viewer.svelte | 17 ++-- .../gallery-viewer/gallery-viewer.svelte | 62 +++++++------ web/src/lib/stores/asset-interaction.store.ts | 86 ------------------- .../stores/asset-interaction.svelte.spec.ts | 40 +++++++++ .../lib/stores/asset-interaction.svelte.ts | 66 ++++++++++++++ web/src/lib/utils/asset-utils.ts | 10 +-- .../[[assetId=id]]/+page.svelte | 77 +++++++++-------- .../[[assetId=id]]/+page.svelte | 22 ++--- .../[[assetId=id]]/+page.svelte | 26 +++--- .../[[assetId=id]]/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 12 ++- .../[[assetId=id]]/+page.svelte | 44 +++++----- .../(user)/photos/[[assetId=id]]/+page.svelte | 52 +++++------ .../[[assetId=id]]/+page.svelte | 33 ++++--- .../[[assetId=id]]/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 22 ++--- 21 files changed, 375 insertions(+), 367 deletions(-) delete mode 100644 web/src/lib/stores/asset-interaction.store.ts create mode 100644 web/src/lib/stores/asset-interaction.svelte.spec.ts create mode 100644 web/src/lib/stores/asset-interaction.svelte.ts diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 1dc43c5b61..02544e3e07 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -4,7 +4,6 @@ import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; @@ -20,6 +19,7 @@ import AlbumSummary from './album-summary.svelte'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { sharedLink: SharedLinkResponseDto; @@ -34,8 +34,7 @@ let { isViewing: showAssetViewer } = assetViewingStore; const assetStore = new AssetStore({ albumId: album.id, order: album.order }); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; + const assetInteraction = new AssetInteraction(); dragAndDropFilesStore.subscribe((value) => { if (value.isDragging && value.files.length > 0) { @@ -52,8 +51,8 @@ use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: () => { - if (!$showAssetViewer && $isMultiSelectState) { - cancelMultiselect(assetInteractionStore); + if (!$showAssetViewer && assetInteraction.selectionActive) { + cancelMultiselect(assetInteraction); } }, }} @@ -61,13 +60,13 @@ />
- {#if $isMultiSelectState} + {#if assetInteraction.selectionActive} assetInteractionStore.clearMultiselect()} + assets={assetInteraction.selectedAssets} + clearSelect={() => assetInteraction.clearMultiselect()} > - + {#if sharedLink.allowDownload} {/if} @@ -102,7 +101,7 @@
- +

(0, { duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0), }); @@ -130,7 +129,7 @@ const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); const handleEscape = async () => goto(AppRoute.PHOTOS); - const handleSelectAll = () => assetInteractionStore.selectAssets(current?.memory.assets || []); + const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets || []); const handleAction = async (action: 'reset' | 'pause' | 'play') => { switch (action) { case 'play': { @@ -212,10 +211,6 @@ current = loadFromParams($memories, target); }); - let isMultiSelectionMode = $derived($selectedAssets.size > 0); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); - $effect(() => { handlePromiseError(handleProgress($progressBarController)); }); @@ -223,7 +218,6 @@ $effect(() => { handlePromiseError(handleAction(galleryInView ? 'pause' : 'play')); }); - let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); -{#if isMultiSelectionMode} +{#if assetInteraction.selectionActive}
- cancelMultiselect(assetInteractionStore)}> + cancelMultiselect(assetInteraction)} + > @@ -249,14 +246,14 @@ - + - - {#if $preferences.tags.enabled && isAllUserOwned} + + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {/if} @@ -490,7 +487,7 @@ onPrevious={handlePreviousAsset} assets={current.memory.assets} {viewport} - {assetInteractionStore} + {assetInteraction} />

diff --git a/web/src/lib/components/photos-page/actions/select-all-assets.svelte b/web/src/lib/components/photos-page/actions/select-all-assets.svelte index cc27f3ebbe..9e7c2b9163 100644 --- a/web/src/lib/components/photos-page/actions/select-all-assets.svelte +++ b/web/src/lib/components/photos-page/actions/select-all-assets.svelte @@ -1,24 +1,24 @@ diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index b2780cc1a0..586491ef47 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -2,7 +2,6 @@ import { intersectionObserver } from '$lib/actions/intersection-observer'; import Icon from '$lib/components/elements/icon.svelte'; import Skeleton from '$lib/components/photos-page/skeleton.svelte'; - import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store'; import { navigate } from '$lib/utils/navigation'; import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util'; @@ -13,6 +12,7 @@ import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import { TUNABLES } from '$lib/utils/tunables'; import { generateId } from '$lib/utils/generate-id'; + import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; export let element: HTMLElement | undefined = undefined; export let isSelectionMode = false; @@ -25,7 +25,7 @@ export let renderThumbsAtTopMargin: string | undefined = undefined; export let assetStore: AssetStore; export let bucket: AssetBucket; - export let assetInteractionStore: AssetInteractionStore; + export let assetInteraction: AssetInteraction; export let onScrollTarget: ScrollTargetListener | undefined = undefined; export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined; @@ -43,13 +43,11 @@ /* TODO figure out a way to calculate this*/ const TITLE_HEIGHT = 51; - const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore; - let isMouseOverGroup = false; let hoveredDateGroup = ''; const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { - if (isSelectionMode || $isMultiSelectState) { + if (isSelectionMode || assetInteraction.selectionActive) { assetSelectHandler(asset, assets, groupTitle); return; } @@ -69,13 +67,15 @@ onSelectAssets(asset); // Check if all assets are selected in a group to toggle the group selection's icon - let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length; + let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => + assetInteraction.selectedAssets.has(asset), + ).length; // if all assets are selected in a group, add the group to selected group if (selectedAssetsInGroupCount == assetsInDateGroup.length) { - assetInteractionStore.addGroupToMultiselectGroup(groupTitle); + assetInteraction.addGroupToMultiselectGroup(groupTitle); } else { - assetInteractionStore.removeGroupFromMultiselectGroup(groupTitle); + assetInteraction.removeGroupFromMultiselectGroup(groupTitle); } }; @@ -83,7 +83,7 @@ // Show multi select icon on hover on date group hoveredDateGroup = groupTitle; - if ($isMultiSelectState) { + if (assetInteraction.selectionActive) { onSelectAssetCandidates(asset); } }; @@ -151,14 +151,14 @@ class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" style:width={dateGroup.geometry.containerWidth + 'px'} > - {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))} + {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} > - {#if $selectedGroup.has(dateGroup.groupTitle)} + {#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)} {:else} @@ -212,8 +212,8 @@ onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} - selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} - selectionCandidate={$assetSelectionCandidates.has(asset)} + selected={assetInteraction.selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} + selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} disabled={$assetStore.albumAssets.has(asset.id)} thumbnailWidth={box.width} thumbnailHeight={box.height} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 5055cdcf4b..cc64c6f02b 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -3,7 +3,6 @@ import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import type { Action } from '$lib/components/asset-viewer/actions/action'; import { AppRoute, AssetAction } from '$lib/constants'; - import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets.store'; import { locale, showDeleteModal } from '$lib/stores/preferences.store'; @@ -37,6 +36,7 @@ import type { UpdatePayload } from 'vite'; import { generateId } from '$lib/utils/generate-id'; import { isTimelineScrolling } from '$lib/stores/timeline.store'; + import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { isSelectionMode?: boolean; @@ -46,7 +46,7 @@ additionally, update the page location/url with the asset as the asset-grid is scrolled */ enableRouting: boolean; assetStore: AssetStore; - assetInteractionStore: AssetInteractionStore; + assetInteraction: AssetInteraction; removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null; withStacked?: boolean; showArchiveIcon?: boolean; @@ -64,7 +64,7 @@ singleSelect = false, enableRouting, assetStore = $bindable(), - assetInteractionStore, + assetInteraction, removeAction = null, withStacked = false, showArchiveIcon = false, @@ -78,8 +78,6 @@ }: Props = $props(); let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; - const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = - assetInteractionStore; const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); @@ -437,11 +435,11 @@ (assetIds) => $assetStore.removeAssets(assetIds), idsSelectedAssets, ); - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); }; const onDelete = () => { - const hasTrashedAsset = Array.from($selectedAssets).some((asset) => asset.isTrashed); + const hasTrashedAsset = assetInteraction.selectedAssetsArray.some((asset) => asset.isTrashed); if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) { isShowDeleteConfirmation = true; @@ -459,7 +457,7 @@ }; const onStackAssets = async () => { - const ids = await stackAssets(Array.from($selectedAssets)); + const ids = await stackAssets(assetInteraction.selectedAssetsArray); if (ids) { $assetStore.removeAssets(ids); onEscape(); @@ -467,7 +465,7 @@ }; const toggleArchive = async () => { - const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived); + const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); if (ids) { $assetStore.removeAssets(ids); deselectAllAssets(); @@ -482,7 +480,7 @@ const handleSelectAsset = (asset: AssetResponseDto) => { if (!$assetStore.albumAssets.has(asset.id)) { - assetInteractionStore.selectAsset(asset); + assetInteraction.selectAsset(asset); } }; @@ -573,7 +571,7 @@ let shiftKeyIsDown = $state(false); const deselectAllAssets = () => { - cancelMultiselect(assetInteractionStore); + cancelMultiselect(assetInteraction); }; const onKeyDown = (event: KeyboardEvent) => { @@ -606,13 +604,13 @@ }; const handleGroupSelect = (group: string, assets: AssetResponseDto[]) => { - if ($selectedGroup.has(group)) { - assetInteractionStore.removeGroupFromMultiselectGroup(group); + if (assetInteraction.selectedGroup.has(group)) { + assetInteraction.removeGroupFromMultiselectGroup(group); for (const asset of assets) { - assetInteractionStore.removeAssetFromMultiselectGroup(asset); + assetInteraction.removeAssetFromMultiselectGroup(asset); } } else { - assetInteractionStore.addGroupToMultiselectGroup(group); + assetInteraction.addGroupToMultiselectGroup(group); for (const asset of assets) { handleSelectAsset(asset); } @@ -631,26 +629,26 @@ return; } - const rangeSelection = $assetSelectionCandidates.size > 0; - const deselect = $selectedAssets.has(asset); + const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0; + const deselect = assetInteraction.selectedAssets.has(asset); // Select/deselect already loaded assets if (deselect) { - for (const candidate of $assetSelectionCandidates || []) { - assetInteractionStore.removeAssetFromMultiselectGroup(candidate); + for (const candidate of assetInteraction.assetSelectionCandidates) { + assetInteraction.removeAssetFromMultiselectGroup(candidate); } - assetInteractionStore.removeAssetFromMultiselectGroup(asset); + assetInteraction.removeAssetFromMultiselectGroup(asset); } else { - for (const candidate of $assetSelectionCandidates || []) { + for (const candidate of assetInteraction.assetSelectionCandidates) { handleSelectAsset(candidate); } handleSelectAsset(asset); } - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); - if ($assetSelectionStart && rangeSelection) { - let startBucketIndex = $assetStore.getBucketIndexByAssetId($assetSelectionStart.id); + if (assetInteraction.assetSelectionStart && rangeSelection) { + let startBucketIndex = $assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id); let endBucketIndex = $assetStore.getBucketIndexByAssetId(asset.id); if (startBucketIndex === null || endBucketIndex === null) { @@ -667,7 +665,7 @@ await $assetStore.loadBucket(bucket.bucketDate); for (const asset of bucket.assets) { if (deselect) { - assetInteractionStore.removeAssetFromMultiselectGroup(asset); + assetInteraction.removeAssetFromMultiselectGroup(asset); } else { handleSelectAsset(asset); } @@ -682,16 +680,16 @@ const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale); for (const dateGroup of assetsGroupByDate) { const dateGroupTitle = formatGroupTitle(dateGroup.date); - if (dateGroup.assets.every((a) => $selectedAssets.has(a))) { - assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle); + if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) { + assetInteraction.addGroupToMultiselectGroup(dateGroupTitle); } else { - assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle); + assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle); } } } } - assetInteractionStore.setAssetSelectionStart(deselect ? null : asset); + assetInteraction.setAssetSelectionStart(deselect ? null : asset); }; const selectAssetCandidates = (endAsset: AssetResponseDto) => { @@ -699,7 +697,7 @@ return; } - const startAsset = $assetSelectionStart; + const startAsset = assetInteraction.assetSelectionStart; if (!startAsset) { return; } @@ -711,11 +709,11 @@ [start, end] = [end, start]; } - assetInteractionStore.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1)); + assetInteraction.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1)); }; const onSelectStart = (e: Event) => { - if ($isMultiSelectState && shiftKeyIsDown) { + if (assetInteraction.selectionActive && shiftKeyIsDown) { e.preventDefault(); } }; @@ -724,12 +722,11 @@ }); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isEmpty = $derived($assetStore.initialized && $assetStore.buckets.length === 0); - let idsSelectedAssets = $derived([...$selectedAssets].map(({ id }) => id)); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); $effect(() => { if (isEmpty) { - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); } }); @@ -760,12 +757,12 @@ { shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, - { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) }, + { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteraction) }, { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement }, { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement }, ]; - if ($isMultiSelectState) { + if (assetInteraction.selectionActive) { shortcuts.push( { shortcut: { key: 'Delete' }, onShortcut: onDelete }, { shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete }, @@ -781,13 +778,13 @@ $effect(() => { if (!lastAssetMouseEvent) { - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); } }); $effect(() => { if (!shiftKeyIsDown) { - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); } }); @@ -889,7 +886,7 @@ {withStacked} {showArchiveIcon} {assetStore} - {assetInteractionStore} + {assetInteraction} {isSelectionMode} {singleSelect} {onScrollTarget} diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 5d625cef9d..ebc4b49001 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -15,11 +15,11 @@ import ControlAppBar from '../shared-components/control-app-bar.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte'; import { cancelMultiselect } from '$lib/utils/asset-utils'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import type { Viewport } from '$lib/stores/assets.store'; import { t } from 'svelte-i18n'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { sharedLink: SharedLinkResponseDto; @@ -29,12 +29,10 @@ let { sharedLink = $bindable(), isOwned }: Props = $props(); const viewport: Viewport = $state({ width: 0, height: 0 }); - const assetInteractionStore = createAssetInteractionStore(); - const { selectedAssets } = assetInteractionStore; + const assetInteraction = new AssetInteraction(); let innerWidth: number = $state(0); let assets = $derived(sharedLink.assets); - let isMultiSelectionMode = $derived($selectedAssets.size > 0); dragAndDropFilesStore.subscribe((value) => { if (value.isDragging && value.files.length > 0) { @@ -73,15 +71,18 @@ }; const handleSelectAll = () => { - assetInteractionStore.selectAssets(assets); + assetInteraction.selectAssets(assets); };
- {#if isMultiSelectionMode} - cancelMultiselect(assetInteractionStore)}> + {#if assetInteraction.selectionActive} + cancelMultiselect(assetInteraction)} + > {#if sharedLink?.allowDownload} @@ -112,6 +113,6 @@ {/if}
- +
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index eda340e7e2..8f8a067a90 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -5,7 +5,6 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import { AppRoute, AssetAction } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import type { Viewport } from '$lib/stores/assets.store'; import { showDeleteModal } from '$lib/stores/preferences.store'; import { deleteAssets } from '$lib/utils/actions'; @@ -22,10 +21,11 @@ import Portal from '../portal/portal.svelte'; import { handlePromiseError } from '$lib/utils'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; + import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { assets: AssetResponseDto[]; - assetInteractionStore: AssetInteractionStore; + assetInteraction: AssetInteraction; disableAssetSelect?: boolean; showArchiveIcon?: boolean; viewport: Viewport; @@ -38,7 +38,7 @@ let { assets = $bindable(), - assetInteractionStore = $bindable(), + assetInteraction, disableAssetSelect = false, showArchiveIcon = false, viewport, @@ -51,11 +51,8 @@ let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; - const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore; - let showShortcuts = $state(false); let currentViewAssetIndex = 0; - let isMultiSelectionMode = $derived($selectedAssets.size > 0); let shiftKeyIsDown = $state(false); let lastAssetMouseEvent: AssetResponseDto | null = $state(null); @@ -66,11 +63,11 @@ }; const selectAllAssets = () => { - assetInteractionStore.selectAssets(assets); + assetInteraction.selectAssets(assets); }; const deselectAllAssets = () => { - cancelMultiselect(assetInteractionStore); + cancelMultiselect(assetInteraction); }; const onKeyDown = (event: KeyboardEvent) => { @@ -91,23 +88,23 @@ if (!asset) { return; } - const deselect = $selectedAssets.has(asset); + const deselect = assetInteraction.selectedAssets.has(asset); // Select/deselect already loaded assets if (deselect) { - for (const candidate of $assetSelectionCandidates || []) { - assetInteractionStore.removeAssetFromMultiselectGroup(candidate); + for (const candidate of assetInteraction.assetSelectionCandidates) { + assetInteraction.removeAssetFromMultiselectGroup(candidate); } - assetInteractionStore.removeAssetFromMultiselectGroup(asset); + assetInteraction.removeAssetFromMultiselectGroup(asset); } else { - for (const candidate of $assetSelectionCandidates || []) { - assetInteractionStore.selectAsset(candidate); + for (const candidate of assetInteraction.assetSelectionCandidates) { + assetInteraction.selectAsset(candidate); } - assetInteractionStore.selectAsset(asset); + assetInteraction.selectAsset(asset); } - assetInteractionStore.clearAssetSelectionCandidates(); - assetInteractionStore.setAssetSelectionStart(deselect ? null : asset); + assetInteraction.clearAssetSelectionCandidates(); + assetInteraction.setAssetSelectionStart(deselect ? null : asset); }; const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { @@ -122,7 +119,7 @@ return; } - const startAsset = $assetSelectionStart; + const startAsset = assetInteraction.assetSelectionStart; if (!startAsset) { return; } @@ -134,17 +131,17 @@ [start, end] = [end, start]; } - assetInteractionStore.setAssetSelectionCandidates(assets.slice(start, end + 1)); + assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1)); }; const onSelectStart = (e: Event) => { - if ($isMultiSelectState && shiftKeyIsDown) { + if (assetInteraction.selectionActive && shiftKeyIsDown) { e.preventDefault(); } }; const onDelete = () => { - const hasTrashedAsset = Array.from($selectedAssets).some((asset) => asset.isTrashed); + const hasTrashedAsset = assetInteraction.selectedAssetsArray.some((asset) => asset.isTrashed); if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) { isShowDeleteConfirmation = true; @@ -168,11 +165,11 @@ (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))), idsSelectedAssets, ); - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); }; const toggleArchive = async () => { - const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived); + const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); if (ids) { assets.filter((asset) => !ids.includes(asset.id)); deselectAllAssets(); @@ -191,7 +188,7 @@ { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() }, ]; - if ($isMultiSelectState) { + if (assetInteraction.selectionActive) { shortcuts.push( { shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets }, { shortcut: { key: 'Delete' }, onShortcut: onDelete }, @@ -266,14 +263,13 @@ }; const assetMouseEventHandler = (asset: AssetResponseDto | null) => { - if ($isMultiSelectState) { + if (assetInteraction.selectionActive) { handleSelectAssetCandidates(asset); } }; let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); - let idsSelectedAssets = $derived([...$selectedAssets].map(({ id }) => id)); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); let geometry = $derived( (() => { @@ -297,13 +293,13 @@ $effect(() => { if (!lastAssetMouseEvent) { - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); } }); $effect(() => { if (!shiftKeyIsDown) { - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); } }); @@ -318,7 +314,7 @@ {#if isShowDeleteConfirmation} (isShowDeleteConfirmation = false)} onConfirm={() => handlePromiseError(trashOrDelete(true))} /> @@ -340,7 +336,7 @@ { - if (isMultiSelectionMode) { + if (assetInteraction.selectionActive) { handleSelectAssets(asset); return; } @@ -351,8 +347,8 @@ onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} {showArchiveIcon} {asset} - selected={$selectedAssets.has(asset)} - selectionCandidate={$assetSelectionCandidates.has(asset)} + selected={assetInteraction.selectedAssets.has(asset)} + selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} thumbnailWidth={geometry.boxes[i].width} thumbnailHeight={geometry.boxes[i].height} /> diff --git a/web/src/lib/stores/asset-interaction.store.ts b/web/src/lib/stores/asset-interaction.store.ts deleted file mode 100644 index f7db5382b0..0000000000 --- a/web/src/lib/stores/asset-interaction.store.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { AssetResponseDto } from '@immich/sdk'; -import { derived, readonly, writable } from 'svelte/store'; - -export type AssetInteractionStore = ReturnType; - -export function createAssetInteractionStore() { - const selectedAssets = writable(new Set()); - const selectedGroup = writable(new Set()); - const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0); - - // Candidates for the range selection. This set includes only loaded assets, so it improves highlight - // performance. From the user's perspective, range is highlighted almost immediately - const assetSelectionCandidates = writable(new Set()); - // The beginning of the selection range - const assetSelectionStart = writable(null); - - const selectAsset = (asset: AssetResponseDto) => { - selectedAssets.update(($selectedAssets) => $selectedAssets.add(asset)); - }; - - const selectAssets = (assets: AssetResponseDto[]) => { - selectedAssets.update(($selectedAssets) => { - for (const asset of assets) { - $selectedAssets.add(asset); - } - return $selectedAssets; - }); - }; - - const removeAssetFromMultiselectGroup = (asset: AssetResponseDto) => { - selectedAssets.update(($selectedAssets) => { - $selectedAssets.delete(asset); - return $selectedAssets; - }); - }; - - const addGroupToMultiselectGroup = (group: string) => { - selectedGroup.update(($selectedGroup) => $selectedGroup.add(group)); - }; - - const removeGroupFromMultiselectGroup = (group: string) => { - selectedGroup.update(($selectedGroup) => { - $selectedGroup.delete(group); - return $selectedGroup; - }); - }; - - const setAssetSelectionStart = (asset: AssetResponseDto | null) => { - assetSelectionStart.set(asset); - }; - - const setAssetSelectionCandidates = (assets: AssetResponseDto[]) => { - assetSelectionCandidates.set(new Set(assets)); - }; - - const clearAssetSelectionCandidates = () => { - assetSelectionCandidates.set(new Set()); - }; - - const clearMultiselect = () => { - // Multi-selection - selectedAssets.set(new Set()); - selectedGroup.set(new Set()); - - // Range selection - assetSelectionCandidates.set(new Set()); - assetSelectionStart.set(null); - }; - - return { - selectAsset, - selectAssets, - removeAssetFromMultiselectGroup, - addGroupToMultiselectGroup, - removeGroupFromMultiselectGroup, - setAssetSelectionCandidates, - clearAssetSelectionCandidates, - setAssetSelectionStart, - clearMultiselect, - isMultiSelectState: readonly(isMultiSelectStoreState), - selectedAssets: readonly(selectedAssets), - selectedGroup: readonly(selectedGroup), - assetSelectionCandidates: readonly(assetSelectionCandidates), - assetSelectionStart: readonly(assetSelectionStart), - }; -} diff --git a/web/src/lib/stores/asset-interaction.svelte.spec.ts b/web/src/lib/stores/asset-interaction.svelte.spec.ts new file mode 100644 index 0000000000..5d3043b37c --- /dev/null +++ b/web/src/lib/stores/asset-interaction.svelte.spec.ts @@ -0,0 +1,40 @@ +import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; +import { resetSavedUser, user } from '$lib/stores/user.store'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { userAdminFactory } from '@test-data/factories/user-factory'; + +describe('AssetInteraction', () => { + let assetInteraction: AssetInteraction; + + beforeEach(() => { + assetInteraction = new AssetInteraction(); + }); + + it('calculates derived values from selection', () => { + assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: true, isTrashed: true })); + assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: false, isTrashed: false })); + + expect(assetInteraction.selectionActive).toBe(true); + expect(assetInteraction.isAllTrashed).toBe(false); + expect(assetInteraction.isAllArchived).toBe(false); + expect(assetInteraction.isAllFavorite).toBe(true); + }); + + it('updates isAllUserOwned when the active user changes', () => { + const [user1, user2] = userAdminFactory.buildList(2); + assetInteraction.selectAsset(assetFactory.build({ ownerId: user1.id })); + + const cleanup = $effect.root(() => { + expect(assetInteraction.isAllUserOwned).toBe(false); + + user.set(user1); + expect(assetInteraction.isAllUserOwned).toBe(true); + + user.set(user2); + expect(assetInteraction.isAllUserOwned).toBe(false); + }); + + cleanup(); + resetSavedUser(); + }); +}); diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts new file mode 100644 index 0000000000..4397c7f71f --- /dev/null +++ b/web/src/lib/stores/asset-interaction.svelte.ts @@ -0,0 +1,66 @@ +import { user } from '$lib/stores/user.store'; +import type { AssetResponseDto, UserAdminResponseDto } from '@immich/sdk'; +import { SvelteSet } from 'svelte/reactivity'; +import { fromStore } from 'svelte/store'; + +export class AssetInteraction { + readonly selectedAssets = new SvelteSet(); + readonly selectedGroup = new SvelteSet(); + assetSelectionCandidates = $state(new SvelteSet()); + assetSelectionStart = $state(null); + + selectionActive = $derived(this.selectedAssets.size > 0); + selectedAssetsArray = $derived([...this.selectedAssets]); + + private user = fromStore(user); + private userId = $derived(this.user.current?.id); + + isAllTrashed = $derived(this.selectedAssetsArray.every((asset) => asset.isTrashed)); + isAllArchived = $derived(this.selectedAssetsArray.every((asset) => asset.isArchived)); + isAllFavorite = $derived(this.selectedAssetsArray.every((asset) => asset.isFavorite)); + isAllUserOwned = $derived(this.selectedAssetsArray.every((asset) => asset.ownerId === this.userId)); + + selectAsset(asset: AssetResponseDto) { + this.selectedAssets.add(asset); + } + + selectAssets(assets: AssetResponseDto[]) { + for (const asset of assets) { + this.selectedAssets.add(asset); + } + } + + removeAssetFromMultiselectGroup(asset: AssetResponseDto) { + this.selectedAssets.delete(asset); + } + + addGroupToMultiselectGroup(group: string) { + this.selectedGroup.add(group); + } + + removeGroupFromMultiselectGroup(group: string) { + this.selectedGroup.delete(group); + } + + setAssetSelectionStart(asset: AssetResponseDto | null) { + this.assetSelectionStart = asset; + } + + setAssetSelectionCandidates(assets: AssetResponseDto[]) { + this.assetSelectionCandidates = new SvelteSet(assets); + } + + clearAssetSelectionCandidates() { + this.assetSelectionCandidates.clear(); + } + + clearMultiselect() { + // Multi-selection + this.selectedAssets.clear(); + this.selectedGroup.clear(); + + // Range selection + this.assetSelectionCandidates.clear(); + this.assetSelectionStart = null; + } +} diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 37041ecbc4..5b06a66597 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte'; import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; -import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; +import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; @@ -460,7 +460,7 @@ export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: S } }; -export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => { +export const selectAllAssets = async (assetStore: AssetStore, assetInteraction: AssetInteraction) => { if (get(isSelectingAllAssets)) { // Selection is already ongoing return; @@ -474,7 +474,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt if (!get(isSelectingAllAssets)) { break; // Cancelled } - assetInteractionStore.selectAssets(bucket.assets); + assetInteraction.selectAssets(bucket.assets); // We use setTimeout to allow the UI to update. Otherwise, this may // cause a long delay between the start of 'select all' and the @@ -489,9 +489,9 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt } }; -export const cancelMultiselect = (assetInteractionStore: AssetInteractionStore) => { +export const cancelMultiselect = (assetInteraction: AssetInteraction) => { isSelectingAllAssets.set(false); - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); }; export const toggleArchive = async (asset: AssetResponseDto) => { diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5c63d8e1a3..0f6c62a5fa 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -35,7 +35,6 @@ import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import { AppRoute, AlbumPageViewMode } from '$lib/constants'; import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -87,6 +86,7 @@ import { onDestroy } from 'svelte'; import { confirmAlbumDelete } from '$lib/utils/album-utils'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -107,11 +107,8 @@ let reactions: ActivityResponseDto[] = $state([]); let albumOrder: AssetOrder | undefined = $state(data.album.order); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; - - const timelineInteractionStore = createAssetInteractionStore(); - const { selectedAssets: timelineSelected } = timelineInteractionStore; + const assetInteraction = new AssetInteraction(); + const timelineInteraction = new AssetInteraction(); afterNavigate(({ from }) => { let url: string | undefined = from?.url?.pathname; @@ -234,8 +231,8 @@ if ($showAssetViewer) { return; } - if ($isMultiSelectState) { - cancelMultiselect(assetInteractionStore); + if (assetInteraction.selectionActive) { + cancelMultiselect(assetInteraction); return; } await goto(backUrl); @@ -245,9 +242,8 @@ const refreshAlbum = async () => { album = await getAlbumInfo({ id: album.id, withoutAssets: true }); }; - const handleAddAssets = async () => { - const assetIds = [...$timelineSelected].map((asset) => asset.id); + const assetIds = timelineInteraction.selectedAssetsArray.map((asset) => asset.id); try { const results = await addAssetsToAlbum({ @@ -263,7 +259,7 @@ await refreshAlbum(); - timelineInteractionStore.clearMultiselect(); + timelineInteraction.clearMultiselect(); await setModeToView(); } catch (error) { handleError(error, $t('errors.error_adding_assets_to_album')); @@ -284,13 +280,13 @@ }; const handleCloseSelectAssets = async () => { - timelineInteractionStore.clearMultiselect(); + timelineInteraction.clearMultiselect(); await setModeToView(); }; const handleSelectFromComputer = async () => { await openFileUploadDialog({ albumId: album.id }); - timelineInteractionStore.clearMultiselect(); + timelineInteraction.clearMultiselect(); await setModeToView(); }; @@ -359,16 +355,16 @@ } viewMode = AlbumPageViewMode.VIEW; - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); await updateThumbnail(assetId); }; const updateThumbnailUsingCurrentSelection = async () => { - if ($selectedAssets.size === 1) { - const assetId = [...$selectedAssets][0].id; - assetInteractionStore.clearMultiselect(); - await updateThumbnail(assetId); + if (assetInteraction.selectedAssets.size === 1) { + const [firstAsset] = assetInteraction.selectedAssets; + assetInteraction.clearMultiselect(); + await updateThumbnail(firstAsset.id); } }; @@ -410,9 +406,6 @@ let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId)); let isOwned = $derived($user.id == album.ownerId); - let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); let showActivityStatus = $derived( album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0), @@ -433,40 +426,50 @@
- {#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> + {#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > - + - {#if isAllUserOwned} - assetStore.triggerUpdate()} /> + {#if assetInteraction.isAllUserOwned} + assetStore.triggerUpdate()} + /> {/if} - {#if isAllUserOwned} + {#if assetInteraction.isAllUserOwned} - {#if $selectedAssets.size === 1} + {#if assetInteraction.selectedAssets.size === 1} updateThumbnailUsingCurrentSelection()} /> {/if} - assetStore.triggerUpdate()} /> + assetStore.triggerUpdate()} + /> {/if} - {#if $preferences.tags.enabled && isAllUserOwned} + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {/if} - {#if isOwned || isAllUserOwned} + {#if isOwned || assetInteraction.isAllUserOwned} {/if} - {#if isAllUserOwned} + {#if assetInteraction.isAllUserOwned} {/if} @@ -540,10 +543,10 @@ {#snippet leading()}

- {#if $timelineSelected.size === 0} + {#if !timelineInteraction.selectionActive} {$t('add_to_album')} {:else} - {$t('selected_count', { values: { count: $timelineSelected.size } })} + {$t('selected_count', { values: { count: timelineInteraction.selectedAssets.size } })} {/if}

{/snippet} @@ -556,7 +559,7 @@ > {$t('select_from_computer')} - {/snippet} @@ -579,7 +582,7 @@ {:else} @@ -587,7 +590,7 @@ enableRouting={true} {album} {assetStore} - {assetInteractionStore} + {assetInteraction} isShared={album.albumUsers.length > 0} isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3402dff960..5301364ccb 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -12,12 +12,12 @@ import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { AssetAction } from '$lib/constants'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; import type { PageData } from './$types'; import { mdiPlus, mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -26,26 +26,26 @@ let { data }: Props = $props(); const assetStore = new AssetStore({ isArchived: true }); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; - - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); + const assetInteraction = new AssetInteraction(); onDestroy(() => { assetStore.destroy(); }); -{#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> +{#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > assetStore.removeAssets(assetIds)} /> - + - assetStore.triggerUpdate()} /> + assetStore.triggerUpdate()} /> assetStore.removeAssets(assetIds)} /> @@ -53,8 +53,8 @@ {/if} - - + + {#snippet empty()} {/snippet} diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6635eda6e9..33a03292cd 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -14,7 +14,6 @@ import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { AssetAction } from '$lib/constants'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; import type { PageData } from './$types'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; @@ -22,6 +21,7 @@ import { onDestroy } from 'svelte'; import { preferences } from '$lib/stores/user.store'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -30,10 +30,7 @@ let { data }: Props = $props(); const assetStore = new AssetStore({ isFavorite: true }); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; - - let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + const assetInteraction = new AssetInteraction(); onDestroy(() => { assetStore.destroy(); @@ -41,11 +38,14 @@ -{#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> +{#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > assetStore.removeAssets(assetIds)} /> - + @@ -54,7 +54,11 @@ - assetStore.removeAssets(assetIds)} /> + assetStore.removeAssets(assetIds)} + /> {#if $preferences.tags.enabled} {/if} @@ -63,8 +67,8 @@ {/if} - - + + {#snippet empty()} {/snippet} diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 065b28c674..5119905652 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -3,7 +3,6 @@ import { page } from '$app/stores'; import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; @@ -17,6 +16,7 @@ import type { PageData } from './$types'; import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -31,7 +31,7 @@ let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree)); - const assetInteractionStore = createAssetInteractionStore(); + const assetInteraction = new AssetInteraction(); onMount(async () => { await foldersStore.fetchUniquePaths(); @@ -80,7 +80,7 @@
{ - assetInteractionStore.clearMultiselect(); assetStore.destroy(); });
- {#if $isMultiSelectState} - + {#if assetInteraction.selectionActive} + @@ -50,5 +48,5 @@ {/snippet} {/if} - +
diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 143a19dd5c..6788c678ed 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -27,7 +27,6 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; import { websocketEvents } from '$lib/stores/websocket'; @@ -58,8 +57,9 @@ import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; - import { preferences, user } from '$lib/stores/user.store'; + import { preferences } from '$lib/stores/user.store'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -78,8 +78,7 @@ handlePromiseError(assetStore.updateOptions(assetStoreOptions)); }); - const assetInteractionStore = createAssetInteractionStore(); - const { selectedAssets, isMultiSelectState } = assetInteractionStore; + const assetInteraction = new AssetInteraction(); let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS); let isEditingName = $state(false); @@ -123,8 +122,8 @@ if ($showAssetViewer || viewMode === PersonPageViewMode.SUGGEST_MERGE) { return; } - if ($isMultiSelectState) { - assetInteractionStore.clearMultiselect(); + if (assetInteraction.selectionActive) { + assetInteraction.clearMultiselect(); return; } else { await goto(previousRoute); @@ -149,8 +148,8 @@ }); const handleUnmerge = () => { - $assetStore.removeAssets([...$selectedAssets].map((a) => a.id)); - assetInteractionStore.clearMultiselect(); + $assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id)); + assetInteraction.clearMultiselect(); viewMode = PersonPageViewMode.VIEW_ASSETS; }; @@ -194,7 +193,7 @@ handleError(error, $t('errors.unable_to_set_feature_photo')); } - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); viewMode = PersonPageViewMode.VIEW_ASSETS; }; @@ -336,15 +335,11 @@ handlePromiseError(updateAssetCount()); } }); - - let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived)); - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); - let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); {#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} a.id)} + assetIds={assetInteraction.selectedAssetsArray.map((a) => a.id)} personAssets={person} onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} onConfirm={handleUnmerge} @@ -375,15 +370,18 @@ {/if}
- {#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> + {#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > - + - assetStore.triggerUpdate()} /> + assetStore.triggerUpdate()} /> - $assetStore.removeAssets(assetIds)} /> - {#if $preferences.tags.enabled && isAllUserOwned} + $assetStore.removeAssets(assetIds)} + /> + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {/if} $assetStore.removeAssets(assetIds)} /> @@ -453,7 +455,7 @@ { - const selection = [...$selectedAssets]; - isAllOwned = selection.every((asset) => asset.ownerId === $user.id); - isAllFavorite = selection.every((asset) => asset.isFavorite); - isAssetStackSelected = selection.length === 1 && !!selection[0].stack; - const isLivePhoto = selection.length === 1 && !!selection[0].livePhotoVideoId; + let selectedAssets = $derived(assetInteraction.selectedAssetsArray); + let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack); + let isLinkActionAvailable = $derived.by(() => { + const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId; const isLivePhotoCandidate = - selection.length === 2 && - selection.some((asset) => asset.type === AssetTypeEnum.Image) && - selection.some((asset) => asset.type === AssetTypeEnum.Video); - isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate); - }); + selectedAssets.length === 2 && + selectedAssets.some((asset) => asset.type === AssetTypeEnum.Image) && + selectedAssets.some((asset) => asset.type === AssetTypeEnum.Video); + return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate); + }); const handleEscape = () => { if ($showAssetViewer) { return; } - if ($isMultiSelectState) { - assetInteractionStore.clearMultiselect(); + if (assetInteraction.selectionActive) { + assetInteraction.clearMultiselect(); return; } }; @@ -78,22 +70,22 @@ }); -{#if $isMultiSelectState} +{#if assetInteraction.selectionActive} assetInteractionStore.clearMultiselect()} + assets={assetInteraction.selectedAssets} + clearSelect={() => assetInteraction.clearMultiselect()} > - + - assetStore.triggerUpdate()} /> + assetStore.triggerUpdate()} /> - {#if $selectedAssets.size > 1 || isAssetStackSelected} + {#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected} assetStore.removeAssets(assetIds)} @@ -103,7 +95,7 @@ {#if isLinkActionAvailable} @@ -121,11 +113,11 @@ {/if} - + ; - - let isMultiSelectionMode = $derived($selectedAssets.size > 0); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); let searchQuery = $derived($page.url.searchParams.get(QueryParameter.QUERY)); onMount(() => { @@ -86,8 +81,8 @@ return; } - if (isMultiSelectionMode) { - $selectedAssets = new Set(); + if (assetInteraction.selectionActive) { + assetInteraction.selectedAssets.clear(); return; } if (!$preventRaceConditionSearchBar) { @@ -131,7 +126,7 @@ searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); }; const handleSelectAll = () => { - assetInteractionStore.selectAssets(searchResultAssets); + assetInteraction.selectAssets(searchResultAssets); }; async function onSearchQueryUpdate() { @@ -231,29 +226,31 @@ function getObjectKeys(obj: T): (keyof T)[] { return Object.keys(obj) as (keyof T)[]; } - let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
- {#if isMultiSelectionMode} + {#if assetInteraction.selectionActive}
- cancelMultiselect(assetInteractionStore)}> + cancelMultiselect(assetInteraction)} + > - + - - {#if $preferences.tags.enabled && isAllUserOwned} + + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {/if} @@ -333,7 +330,7 @@ {#if searchResultAssets.length > 0} { return Object.fromEntries(tags.map((tag) => [tag.value, tag])); @@ -198,7 +198,7 @@
{#if tag} - + {#snippet empty()} {/snippet} diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 8803ea38c8..7f97d3772b 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -15,7 +15,6 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; @@ -26,6 +25,7 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -39,8 +39,7 @@ const options = { isTrashed: true }; const assetStore = new AssetStore(options); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; + const assetInteraction = new AssetInteraction(); const handleEmptyTrash = async () => { const isConfirmed = await dialogController.show({ @@ -93,25 +92,28 @@ }); -{#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> - +{#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > + assetStore.removeAssets(assetIds)} /> assetStore.removeAssets(assetIds)} /> {/if} {#if $featureFlags.loaded && $featureFlags.trash} - + {#snippet buttons()}
- +
{$t('restore_all')}
- handleEmptyTrash()} disabled={$isMultiSelectState}> + handleEmptyTrash()} disabled={assetInteraction.selectionActive}>
{$t('empty_trash')} @@ -120,7 +122,7 @@
{/snippet} - +

{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}