Browse Source

refactor(web): asset grid stores (#3464)

* Refactor asset grid stores

* Iterate over buckets with for..of loop

* Rebase on top of main branch changes
Sergey Kondrikov 2 năm trước cách đây
mục cha
commit
5f9dfa9493

+ 4 - 2
web/src/lib/components/album-page/album-viewer.svelte

@@ -43,13 +43,15 @@
   import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
   import { handleError } from '../../utils/handle-error';
   import { downloadArchive } from '../../utils/asset-utils';
-  import { isViewingAssetStoreState } from '$lib/stores/asset-interaction.store';
+  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
 
   export let album: AlbumResponseDto;
   export let sharedLink: SharedLinkResponseDto | undefined = undefined;
 
   const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
 
+  let { isViewing: showAssetViewer } = assetViewingStore;
+
   let isShowAssetSelection = false;
 
   let isShowShareLinkModal = false;
@@ -141,7 +143,7 @@
   });
 
   const handleKeyboardPress = (event: KeyboardEvent) => {
-    if (!$isViewingAssetStoreState) {
+    if (!$showAssetViewer) {
       switch (event.key) {
         case 'Escape':
           if (isMultiSelectionMode) {

+ 8 - 3
web/src/lib/components/album-page/asset-selection.svelte

@@ -1,5 +1,4 @@
 <script lang="ts">
-  import { assetInteractionStore, assetsInAlbumStoreState, selectedAssets } from '$lib/stores/asset-interaction.store';
   import { locale } from '$lib/stores/preferences.store';
   import { openFileUploadDialog } from '$lib/utils/file-uploader';
   import type { AssetResponseDto } from '@api';
@@ -9,14 +8,20 @@
   import Button from '../elements/buttons/button.svelte';
   import AssetGrid from '../photos-page/asset-grid.svelte';
   import ControlAppBar from '../shared-components/control-app-bar.svelte';
+  import { createAssetStore } from '$lib/stores/assets.store';
+  import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
 
   const dispatch = createEventDispatcher();
 
+  const assetStore = createAssetStore();
+  const assetInteractionStore = createAssetInteractionStore();
+  const { selectedAssets, assetsInAlbumState } = assetInteractionStore;
+
   export let albumId: string;
   export let assetsInAlbum: AssetResponseDto[];
 
   onMount(() => {
-    $assetsInAlbumStoreState = assetsInAlbum;
+    $assetsInAlbumState = assetsInAlbum;
   });
 
   const addSelectedAssets = async () => {
@@ -64,6 +69,6 @@
     </svelte:fragment>
   </ControlAppBar>
   <section class="grid h-screen bg-immich-bg pl-[70px] pt-[100px] dark:bg-immich-dark-bg">
-    <AssetGrid isAlbumSelectionMode={true} />
+    <AssetGrid {assetStore} {assetInteractionStore} isAlbumSelectionMode={true} />
   </section>
 </section>

+ 4 - 3
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -17,13 +17,14 @@
   import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
   import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
 
-  import { assetStore } from '$lib/stores/assets.store';
   import { isShowDetail } from '$lib/stores/preferences.store';
   import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
   import NavigationArea from './navigation-area.svelte';
   import { browser } from '$app/environment';
   import { handleError } from '$lib/utils/handle-error';
+  import type { AssetStore } from '$lib/stores/assets.store';
 
+  export let assetStore: AssetStore | null = null;
   export let asset: AssetResponseDto;
   export let publicSharedKey = '';
   export let showNavigation = true;
@@ -134,7 +135,7 @@
 
       for (const asset of deletedAssets) {
         if (asset.status == 'SUCCESS') {
-          assetStore.removeAsset(asset.id);
+          assetStore?.removeAsset(asset.id);
         }
       }
     } catch (e) {
@@ -158,7 +159,7 @@
       });
 
       asset.isFavorite = data.isFavorite;
-      assetStore.updateAsset(asset.id, data.isFavorite);
+      assetStore?.updateAsset(asset.id, data.isFavorite);
 
       notificationController.show({
         type: NotificationType.Info,

+ 12 - 10
web/src/lib/components/photos-page/actions/select-all-assets.svelte

@@ -1,28 +1,30 @@
 <script lang="ts">
+  import { get } from 'svelte/store';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import SelectAll from 'svelte-material-icons/SelectAll.svelte';
   import TimerSand from 'svelte-material-icons/TimerSand.svelte';
-  import { assetInteractionStore } from '$lib/stores/asset-interaction.store';
-  import { assetGridState, assetStore } from '$lib/stores/assets.store';
   import { handleError } from '../../../utils/handle-error';
-  import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
+  import { BucketPosition } from '$lib/models/asset-grid-state';
+  import type { AssetStore } from '$lib/stores/assets.store';
+  import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
+
+  export let assetStore: AssetStore;
+  export let assetInteractionStore: AssetInteractionStore;
 
   let selecting = false;
 
   const handleSelectAll = async () => {
     try {
       selecting = true;
-      let _assetGridState = new AssetGridState();
-      assetGridState.subscribe((state) => {
-        _assetGridState = state;
-      });
 
-      for (let i = 0; i < _assetGridState.buckets.length; i++) {
-        await assetStore.getAssetsByBucket(_assetGridState.buckets[i].bucketDate, BucketPosition.Unknown);
-        for (const asset of _assetGridState.buckets[i].assets) {
+      const assetGridState = get(assetStore);
+      for (const bucket of assetGridState.buckets) {
+        await assetStore.getAssetsByBucket(bucket.bucketDate, BucketPosition.Unknown);
+        for (const asset of bucket.assets) {
           assetInteractionStore.addAssetToMultiselectGroup(asset);
         }
       }
+
       selecting = false;
     } catch (e) {
       handleError(e, 'Error selecting all assets');

+ 14 - 14
web/src/lib/components/photos-page/asset-date-group.svelte

@@ -1,13 +1,4 @@
 <script lang="ts">
-  import {
-    assetInteractionStore,
-    assetSelectionCandidates,
-    assetsInAlbumStoreState,
-    isMultiSelectStoreState,
-    selectedAssets,
-    selectedGroup,
-  } from '$lib/stores/asset-interaction.store';
-  import { assetStore } from '$lib/stores/assets.store';
   import { locale } from '$lib/stores/preferences.store';
   import { getAssetRatio } from '$lib/utils/asset-utils';
   import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
@@ -19,6 +10,9 @@
   import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
   import { fly } from 'svelte/transition';
   import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
+  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+  import type { AssetStore } from '$lib/stores/assets.store';
+  import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
 
   export let assets: AssetResponseDto[];
   export let bucketDate: string;
@@ -26,6 +20,12 @@
   export let isAlbumSelectionMode = false;
   export let viewportWidth: number;
 
+  export let assetStore: AssetStore;
+  export let assetInteractionStore: AssetInteractionStore;
+
+  const { selectedGroup, selectedAssets, assetsInAlbumState, assetSelectionCandidates, isMultiSelectState } =
+    assetInteractionStore;
+
   const dispatch = createEventDispatcher();
 
   let isMouseOverGroup = false;
@@ -94,10 +94,10 @@
       return;
     }
 
-    if ($isMultiSelectStoreState) {
+    if ($isMultiSelectState) {
       assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
     } else {
-      assetInteractionStore.setViewingAsset(asset);
+      assetViewingStore.setAssetId(asset.id);
     }
   };
 
@@ -137,7 +137,7 @@
     // Show multi select icon on hover on date group
     hoveredDateGroup = dateGroupTitle;
 
-    if ($isMultiSelectStoreState) {
+    if ($isMultiSelectState) {
       dispatch('selectAssetCandidates', { asset });
     }
   };
@@ -207,9 +207,9 @@
               on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
               on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
               on:mouse-event={() => assetMouseEventHandler(dateGroupTitle, asset)}
-              selected={$selectedAssets.has(asset) || $assetsInAlbumStoreState.some(({ id }) => id === asset.id)}
+              selected={$selectedAssets.has(asset) || $assetsInAlbumState.some(({ id }) => id === asset.id)}
               selectionCandidate={$assetSelectionCandidates.has(asset)}
-              disabled={$assetsInAlbumStoreState.some(({ id }) => id === asset.id)}
+              disabled={$assetsInAlbumState.some(({ id }) => id === asset.id)}
               thumbnailWidth={box.width}
               thumbnailHeight={box.height}
             />

+ 41 - 34
web/src/lib/components/photos-page/asset-grid.svelte

@@ -1,15 +1,6 @@
 <script lang="ts">
   import { BucketPosition } from '$lib/models/asset-grid-state';
-  import {
-    assetInteractionStore,
-    assetSelectionCandidates,
-    assetSelectionStart,
-    isMultiSelectStoreState,
-    isViewingAssetStoreState,
-    selectedAssets,
-    viewingAssetStoreState,
-  } from '$lib/stores/asset-interaction.store';
-  import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
+  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { locale } from '$lib/stores/preferences.store';
   import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
   import type { UserResponseDto } from '@api';
@@ -31,11 +22,20 @@
   import { browser } from '$app/environment';
   import { isSearchEnabled } from '$lib/stores/search.store';
   import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
+  import type { AssetStore } from '$lib/stores/assets.store';
+  import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
 
   export let user: UserResponseDto | undefined = undefined;
   export let isAlbumSelectionMode = false;
   export let showMemoryLane = false;
 
+  export let assetStore: AssetStore;
+  export let assetInteractionStore: AssetInteractionStore;
+
+  const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore;
+
+  let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
+
   let viewportHeight = 0;
   let viewportWidth = 0;
   let assetGridElement: HTMLElement;
@@ -61,7 +61,7 @@
     // Get asset bucket if bucket height is smaller than viewport height
     let bucketsToFetchInitially: string[] = [];
     let initialBucketsHeight = 0;
-    $assetGridState.buckets.every((bucket) => {
+    $assetStore.buckets.every((bucket) => {
       if (initialBucketsHeight < viewportHeight) {
         initialBucketsHeight += bucket.bucketHeight;
         bucketsToFetchInitially.push(bucket.bucketDate);
@@ -89,7 +89,7 @@
       return;
     }
 
-    if (!$isViewingAssetStoreState) {
+    if (!$showAssetViewer) {
       switch (event.key) {
         case 'Escape':
           assetInteractionStore.clearMultiselect();
@@ -121,12 +121,18 @@
     assetGridElement.scrollBy(0, event.detail.heightDelta);
   }
 
-  const navigateToPreviousAsset = () => {
-    assetInteractionStore.navigateAsset('previous');
+  const navigateToPreviousAsset = async () => {
+    const prevAsset = await assetStore.getAdjacentAsset($viewingAsset.id, 'previous');
+    if (prevAsset) {
+      assetViewingStore.setAssetId(prevAsset);
+    }
   };
 
-  const navigateToNextAsset = () => {
-    assetInteractionStore.navigateAsset('next');
+  const navigateToNextAsset = async () => {
+    const nextAsset = await assetStore.getAdjacentAsset($viewingAsset.id, 'next');
+    if (nextAsset) {
+      assetViewingStore.setAssetId(nextAsset);
+    }
   };
 
   let lastScrollPosition = 0;
@@ -228,8 +234,8 @@
     assetInteractionStore.clearAssetSelectionCandidates();
 
     if ($assetSelectionStart && rangeSelection) {
-      let startBucketIndex = $assetGridState.loadedAssets[$assetSelectionStart.id];
-      let endBucketIndex = $assetGridState.loadedAssets[asset.id];
+      let startBucketIndex = $assetStore.loadedAssets[$assetSelectionStart.id];
+      let endBucketIndex = $assetStore.loadedAssets[asset.id];
 
       if (endBucketIndex < startBucketIndex) {
         [startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex];
@@ -237,7 +243,7 @@
 
       // Select/deselect assets in all intermediate buckets
       for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
-        const bucket = $assetGridState.buckets[bucketIndex];
+        const bucket = $assetStore.buckets[bucketIndex];
         await assetStore.getAssetsByBucket(bucket.bucketDate, BucketPosition.Unknown);
         for (const asset of bucket.assets) {
           if (deselect) {
@@ -250,7 +256,7 @@
 
       // Update date group selection
       for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
-        const bucket = $assetGridState.buckets[bucketIndex];
+        const bucket = $assetStore.buckets[bucketIndex];
 
         // Split bucket into date groups and check each group
         const assetsGroupByDate = splitBucketIntoDateGroups(bucket.assets, $locale);
@@ -279,18 +285,18 @@
       return;
     }
 
-    let start = $assetGridState.assets.indexOf(rangeStart);
-    let end = $assetGridState.assets.indexOf(asset);
+    let start = $assetStore.assets.indexOf(rangeStart);
+    let end = $assetStore.assets.indexOf(asset);
 
     if (start > end) {
       [start, end] = [end, start];
     }
 
-    assetInteractionStore.setAssetSelectionCandidates($assetGridState.assets.slice(start, end + 1));
+    assetInteractionStore.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1));
   };
 
   const onSelectStart = (e: Event) => {
-    if ($isMultiSelectStoreState && shiftKeyIsDown) {
+    if ($isMultiSelectState && shiftKeyIsDown) {
       e.preventDefault();
     }
   };
@@ -302,8 +308,9 @@
   <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
 {/if}
 
-{#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight}
+{#if bucketInfo && viewportHeight && $assetStore.timelineHeight > viewportHeight}
   <Scrollbar
+    {assetStore}
     scrollbarHeight={viewportHeight}
     scrollTop={lastScrollPosition}
     on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
@@ -324,15 +331,12 @@
     {#if showMemoryLane}
       <MemoryLane />
     {/if}
-    <section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
-      {#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)}
+    <section id="virtual-timeline" style:height={$assetStore.timelineHeight + 'px'}>
+      {#each $assetStore.buckets as bucket, bucketIndex (bucketIndex)}
         <IntersectionObserver
           on:intersected={intersectedHandler}
           on:hidden={async () => {
-            // If bucket is hidden and in loading state, cancel the request
-            if ($loadingBucketState[bucket.bucketDate]) {
-              await assetStore.cancelBucketRequest(bucket.cancelToken, bucket.bucketDate);
-            }
+            await assetStore.cancelBucketRequest(bucket.cancelToken, bucket.bucketDate);
           }}
           let:intersecting
           top={750}
@@ -342,6 +346,8 @@
           <div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
             {#if intersecting}
               <AssetDateGroup
+                {assetStore}
+                {assetInteractionStore}
                 {isAlbumSelectionMode}
                 on:shift={handleScrollTimeline}
                 on:selectAssetCandidates={handleSelectAssetCandidates}
@@ -360,13 +366,14 @@
 </section>
 
 <Portal target="body">
-  {#if $isViewingAssetStoreState}
+  {#if $showAssetViewer}
     <AssetViewer
-      asset={$viewingAssetStoreState}
+      {assetStore}
+      asset={$viewingAsset}
       on:navigate-previous={navigateToPreviousAsset}
       on:navigate-next={navigateToNextAsset}
       on:close={() => {
-        assetInteractionStore.setIsViewingAsset(false);
+        assetViewingStore.showAssetViewer(false);
       }}
       on:archived={handleArchiveSuccess}
     />

+ 6 - 4
web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte

@@ -11,7 +11,7 @@
   import { flip } from 'svelte/animate';
   import { archivedAsset } from '$lib/stores/archived-asset.store';
   import { getThumbnailSize } from '$lib/utils/thumbnail-util';
-  import { isViewingAssetStoreState } from '$lib/stores/asset-interaction.store';
+  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
 
   export let assets: AssetResponseDto[];
   export let sharedLink: SharedLinkResponseDto | undefined = undefined;
@@ -20,6 +20,8 @@
   export let viewFrom: ViewFrom;
   export let showArchiveIcon = false;
 
+  let { isViewing: showAssetViewer } = assetViewingStore;
+
   let selectedAsset: AssetResponseDto;
   let currentViewAssetIndex = 0;
 
@@ -33,7 +35,7 @@
 
     currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
     selectedAsset = assets[currentViewAssetIndex];
-    $isViewingAssetStoreState = true;
+    $showAssetViewer = true;
     pushState(selectedAsset.id);
   };
 
@@ -81,7 +83,7 @@
   };
 
   const closeViewer = () => {
-    $isViewingAssetStoreState = false;
+    $showAssetViewer = false;
     history.pushState(null, '', `${$page.url.pathname}`);
   };
 
@@ -117,7 +119,7 @@
 {/if}
 
 <!-- Overlay Asset Viewer -->
-{#if $isViewingAssetStoreState}
+{#if $showAssetViewer}
   <AssetViewer
     asset={selectedAsset}
     publicSharedKey={sharedLink?.key}

+ 4 - 4
web/src/lib/components/shared-components/scrollbar/scrollbar.svelte

@@ -19,15 +19,15 @@
 <script lang="ts">
   import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
 
-  import { assetGridState } from '$lib/stores/assets.store';
-
   import { createEventDispatcher } from 'svelte';
   import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
+  import type { AssetStore } from '$lib/stores/assets.store';
 
   export let scrollTop = 0;
   export let scrollbarHeight = 0;
+  export let assetStore: AssetStore;
 
-  $: timelineHeight = $assetGridState.timelineHeight;
+  $: timelineHeight = $assetStore.timelineHeight;
   $: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight;
 
   let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
@@ -48,7 +48,7 @@
 
   $: {
     let result: SegmentScrollbarLayout[] = [];
-    for (const bucket of $assetGridState.buckets) {
+    for (const bucket of $assetStore.buckets) {
       let segmentLayout = new SegmentScrollbarLayout();
       segmentLayout.count = bucket.assets.length;
       segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight;

+ 76 - 126
web/src/lib/stores/asset-interaction.store.ts

@@ -1,49 +1,68 @@
-import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
-import { api, AssetResponseDto } from '@api';
 import { derived, writable } from 'svelte/store';
-import { assetGridState, assetStore } from './assets.store';
-
-// Asset Viewer
-export const viewingAssetStoreState = writable<AssetResponseDto>();
-export const isViewingAssetStoreState = writable<boolean>(false);
-
-/**
- * Multi-selection mode
- */
-export const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]);
-// Selected assets
-export const selectedAssets = writable<Set<AssetResponseDto>>(new Set());
-// Selected date groups
-export const selectedGroup = writable<Set<string>>(new Set());
-// If any asset selected
-export const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0);
-
-/**
- * Range selection
- */
-// 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
-export const assetSelectionCandidates = writable<Set<AssetResponseDto>>(new Set());
-// The beginning of the selection range
-export const assetSelectionStart = writable<AssetResponseDto | null>(null);
-
-function createAssetInteractionStore() {
-  let _assetGridState = new AssetGridState();
-  let _viewingAssetStoreState: AssetResponseDto;
+import type { AssetResponseDto } from '../../api/open-api';
+
+export interface AssetInteractionStore {
+  addAssetToMultiselectGroup: (asset: AssetResponseDto) => void;
+  removeAssetFromMultiselectGroup: (asset: AssetResponseDto) => void;
+  addGroupToMultiselectGroup: (group: string) => void;
+  removeGroupFromMultiselectGroup: (group: string) => void;
+  setAssetSelectionCandidates: (assets: AssetResponseDto[]) => void;
+  clearAssetSelectionCandidates: () => void;
+  setAssetSelectionStart: (asset: AssetResponseDto | null) => void;
+  clearMultiselect: () => void;
+  isMultiSelectState: {
+    subscribe: (run: (value: boolean) => void, invalidate?: (value?: boolean) => void) => () => void;
+  };
+  assetsInAlbumState: {
+    subscribe: (
+      run: (value: AssetResponseDto[]) => void,
+      invalidate?: (value?: AssetResponseDto[]) => void,
+    ) => () => void;
+    set: (value: AssetResponseDto[]) => void;
+  };
+  selectedAssets: {
+    subscribe: (
+      run: (value: Set<AssetResponseDto>) => void,
+      invalidate?: (value?: Set<AssetResponseDto>) => void,
+    ) => () => void;
+  };
+  selectedGroup: {
+    subscribe: (run: (value: Set<string>) => void, invalidate?: (value?: Set<string>) => void) => () => void;
+  };
+  assetSelectionCandidates: {
+    subscribe: (
+      run: (value: Set<AssetResponseDto>) => void,
+      invalidate?: (value?: Set<AssetResponseDto>) => void,
+    ) => () => void;
+  };
+  assetSelectionStart: {
+    subscribe: (
+      run: (value: AssetResponseDto | null) => void,
+      invalidate?: (value?: AssetResponseDto | null) => void,
+    ) => () => void;
+  };
+}
+
+export function createAssetInteractionStore(): AssetInteractionStore {
   let _selectedAssets: Set<AssetResponseDto>;
   let _selectedGroup: Set<string>;
   let _assetsInAlbums: AssetResponseDto[];
   let _assetSelectionCandidates: Set<AssetResponseDto>;
   let _assetSelectionStart: AssetResponseDto | null;
 
-  // Subscriber
-  assetGridState.subscribe((state) => {
-    _assetGridState = state;
-  });
+  const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]);
+  // Selected assets
+  const selectedAssets = writable<Set<AssetResponseDto>>(new Set());
+  // Selected date groups
+  const selectedGroup = writable<Set<string>>(new Set());
+  // If any asset selected
+  const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0);
 
-  viewingAssetStoreState.subscribe((asset) => {
-    _viewingAssetStoreState = asset;
-  });
+  // 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<Set<AssetResponseDto>>(new Set());
+  // The beginning of the selection range
+  const assetSelectionStart = writable<AssetResponseDto | null>(null);
 
   selectedAssets.subscribe((assets) => {
     _selectedAssets = assets;
@@ -64,89 +83,7 @@ function createAssetInteractionStore() {
   assetSelectionStart.subscribe((asset) => {
     _assetSelectionStart = asset;
   });
-  // Methods
-
-  /**
-   * Asset Viewer
-   */
-  const setViewingAsset = async (asset: AssetResponseDto) => {
-    setViewingAssetId(asset.id);
-  };
-
-  const setViewingAssetId = async (id: string) => {
-    const { data } = await api.assetApi.getAssetById({ id });
-    viewingAssetStoreState.set(data);
-    isViewingAssetStoreState.set(true);
-  };
-
-  const setIsViewingAsset = (isViewing: boolean) => {
-    isViewingAssetStoreState.set(isViewing);
-  };
-
-  const getNextAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
-    const currentBucket = _assetGridState.buckets[currentBucketIndex];
-    const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
-    if (assetIndex === -1) {
-      return null;
-    }
-
-    if (assetIndex + 1 < currentBucket.assets.length) {
-      return currentBucket.assets[assetIndex + 1];
-    }
-
-    const nextBucketIndex = currentBucketIndex + 1;
-    if (nextBucketIndex >= _assetGridState.buckets.length) {
-      return null;
-    }
-
-    const nextBucket = _assetGridState.buckets[nextBucketIndex];
-    await assetStore.getAssetsByBucket(nextBucket.bucketDate, BucketPosition.Unknown);
-
-    return nextBucket.assets[0] ?? null;
-  };
-
-  const getPrevAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
-    const currentBucket = _assetGridState.buckets[currentBucketIndex];
-    const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
-    if (assetIndex === -1) {
-      return null;
-    }
-
-    if (assetIndex > 0) {
-      return currentBucket.assets[assetIndex - 1];
-    }
-
-    const prevBucketIndex = currentBucketIndex - 1;
-    if (prevBucketIndex < 0) {
-      return null;
-    }
-
-    const prevBucket = _assetGridState.buckets[prevBucketIndex];
-    await assetStore.getAssetsByBucket(prevBucket.bucketDate, BucketPosition.Unknown);
 
-    return prevBucket.assets[prevBucket.assets.length - 1] ?? null;
-  };
-
-  const navigateAsset = async (direction: 'next' | 'previous') => {
-    const currentAssetId = _viewingAssetStoreState.id;
-    const currentBucketIndex = _assetGridState.loadedAssets[currentAssetId];
-    if (currentBucketIndex < 0 || currentBucketIndex >= _assetGridState.buckets.length) {
-      return;
-    }
-
-    const asset =
-      direction === 'next'
-        ? await getNextAsset(currentBucketIndex, currentAssetId)
-        : await getPrevAsset(currentBucketIndex, currentAssetId);
-
-    if (asset) {
-      setViewingAsset(asset);
-    }
-  };
-
-  /**
-   * Multiselect
-   */
   const addAssetToMultiselectGroup = (asset: AssetResponseDto) => {
     // Not select if in album already
     if (_assetsInAlbums.find((a) => a.id === asset.id)) {
@@ -205,10 +142,6 @@ function createAssetInteractionStore() {
   };
 
   return {
-    setViewingAsset,
-    setViewingAssetId,
-    setIsViewingAsset,
-    navigateAsset,
     addAssetToMultiselectGroup,
     removeAssetFromMultiselectGroup,
     addGroupToMultiselectGroup,
@@ -217,7 +150,24 @@ function createAssetInteractionStore() {
     clearAssetSelectionCandidates,
     setAssetSelectionStart,
     clearMultiselect,
+    isMultiSelectState: {
+      subscribe: isMultiSelectStoreState.subscribe,
+    },
+    assetsInAlbumState: {
+      subscribe: assetsInAlbumStoreState.subscribe,
+      set: assetsInAlbumStoreState.set,
+    },
+    selectedAssets: {
+      subscribe: selectedAssets.subscribe,
+    },
+    selectedGroup: {
+      subscribe: selectedGroup.subscribe,
+    },
+    assetSelectionCandidates: {
+      subscribe: assetSelectionCandidates.subscribe,
+    },
+    assetSelectionStart: {
+      subscribe: assetSelectionStart.subscribe,
+    },
   };
 }
-
-export const assetInteractionStore = createAssetInteractionStore();

+ 31 - 0
web/src/lib/stores/asset-viewing.store.ts

@@ -0,0 +1,31 @@
+import { writable } from 'svelte/store';
+import { api, type AssetResponseDto } from '@api';
+
+function createAssetViewingStore() {
+  const viewingAssetStoreState = writable<AssetResponseDto>();
+  const viewState = writable<boolean>(false);
+
+  const setAssetId = async (id: string) => {
+    const { data } = await api.assetApi.getAssetById({ id });
+    viewingAssetStoreState.set(data);
+    viewState.set(true);
+  };
+
+  const showAssetViewer = (show: boolean) => {
+    viewState.set(show);
+  };
+
+  return {
+    asset: {
+      subscribe: viewingAssetStoreState.subscribe,
+    },
+    isViewing: {
+      subscribe: viewState.subscribe,
+      set: viewState.set,
+    },
+    setAssetId,
+    showAssetViewer,
+  };
+}
+
+export const assetViewingStore = createAssetViewingStore();

+ 100 - 43
web/src/lib/stores/assets.store.ts

@@ -1,25 +1,34 @@
 import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
-import { api, AssetCountByTimeBucketResponseDto } from '@api';
+import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto } from '@api';
 import { writable } from 'svelte/store';
 
-/**
- * The state that holds information about the asset grid
- */
-export const assetGridState = writable<AssetGridState>(new AssetGridState());
-export const loadingBucketState = writable<{ [key: string]: boolean }>({});
+export interface AssetStore {
+  setInitialState: (
+    viewportHeight: number,
+    viewportWidth: number,
+    data: AssetCountByTimeBucketResponseDto,
+    userId: string | undefined,
+  ) => void;
+  getAssetsByBucket: (bucket: string, position: BucketPosition) => Promise<void>;
+  updateBucketHeight: (bucket: string, actualBucketHeight: number) => number;
+  cancelBucketRequest: (token: AbortController, bucketDate: string) => Promise<void>;
+  getAdjacentAsset: (assetId: string, direction: 'next' | 'previous') => Promise<string | null>;
+  removeAsset: (assetId: string) => void;
+  updateAsset: (assetId: string, isFavorite: boolean) => void;
+  subscribe: (run: (value: AssetGridState) => void, invalidate?: (value?: AssetGridState) => void) => () => void;
+}
 
-function createAssetStore() {
+export function createAssetStore(): AssetStore {
+  let _loadingBuckets: { [key: string]: boolean } = {};
   let _assetGridState = new AssetGridState();
-  assetGridState.subscribe((state) => {
-    _assetGridState = state;
-  });
 
-  let _loadingBucketState: { [key: string]: boolean } = {};
-  loadingBucketState.subscribe((state) => {
-    _loadingBucketState = state;
+  const { subscribe, set, update } = writable(new AssetGridState());
+
+  subscribe((state) => {
+    _assetGridState = state;
   });
 
-  const estimateViewportHeight = (assetCount: number, viewportWidth: number): number => {
+  const _estimateViewportHeight = (assetCount: number, viewportWidth: number): number => {
     // Ideally we would use the average aspect ratio for the photoset, however assume
     // a normal landscape aspect ratio of 3:2, then discount for the likelihood we
     // will be scaling down and coalescing.
@@ -39,25 +48,19 @@ function createAssetStore() {
     );
   };
 
-  /**
-   * Set initial state
-   * @param viewportHeight
-   * @param viewportWidth
-   * @param data
-   */
   const setInitialState = (
     viewportHeight: number,
     viewportWidth: number,
     data: AssetCountByTimeBucketResponseDto,
     userId: string | undefined,
   ) => {
-    assetGridState.set({
+    set({
       viewportHeight,
       viewportWidth,
       timelineHeight: 0,
       buckets: data.buckets.map((bucket) => ({
         bucketDate: bucket.timeBucket,
-        bucketHeight: estimateViewportHeight(bucket.count, viewportWidth),
+        bucketHeight: _estimateViewportHeight(bucket.count, viewportWidth),
         assets: [],
         cancelToken: new AbortController(),
         position: BucketPosition.Unknown,
@@ -67,8 +70,7 @@ function createAssetStore() {
       userId,
     });
 
-    // Update timeline height based on calculated bucket height
-    assetGridState.update((state) => {
+    update((state) => {
       state.timelineHeight = state.buckets.reduce((acc, b) => acc + b.bucketHeight, 0);
       return state;
     });
@@ -78,7 +80,7 @@ function createAssetStore() {
     try {
       const currentBucketData = _assetGridState.buckets.find((b) => b.bucketDate === bucket);
       if (currentBucketData?.assets && currentBucketData.assets.length > 0) {
-        assetGridState.update((state) => {
+        update((state) => {
           const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
           state.buckets[bucketIndex].position = position;
           return state;
@@ -86,10 +88,7 @@ function createAssetStore() {
         return;
       }
 
-      loadingBucketState.set({
-        ..._loadingBucketState,
-        [bucket]: true,
-      });
+      _loadingBuckets = { ..._loadingBuckets, [bucket]: true };
       const { data: assets } = await api.assetApi.getAssetByTimeBucket(
         {
           getAssetByTimeBucketDto: {
@@ -100,13 +99,9 @@ function createAssetStore() {
         },
         { signal: currentBucketData?.cancelToken.signal },
       );
-      loadingBucketState.set({
-        ..._loadingBucketState,
-        [bucket]: false,
-      });
+      _loadingBuckets = { ..._loadingBuckets, [bucket]: false };
 
-      // Update assetGridState with assets by time bucket
-      assetGridState.update((state) => {
+      update((state) => {
         const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
         state.buckets[bucketIndex].assets = assets;
         state.buckets[bucketIndex].position = position;
@@ -125,7 +120,7 @@ function createAssetStore() {
   };
 
   const removeAsset = (assetId: string) => {
-    assetGridState.update((state) => {
+    update((state) => {
       const bucketIndex = state.buckets.findIndex((b) => b.assets.some((a) => a.id === assetId));
       const assetIndex = state.buckets[bucketIndex].assets.findIndex((a) => a.id === assetId);
       state.buckets[bucketIndex].assets.splice(assetIndex, 1);
@@ -140,7 +135,7 @@ function createAssetStore() {
   };
 
   const _removeBucket = (bucketDate: string) => {
-    assetGridState.update((state) => {
+    update((state) => {
       const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucketDate);
       state.buckets.splice(bucketIndex, 1);
       state.assets = state.buckets.flatMap((b) => b.assets);
@@ -153,7 +148,7 @@ function createAssetStore() {
     let scrollTimeline = false;
     let heightDelta = 0;
 
-    assetGridState.update((state) => {
+    update((state) => {
       const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
       // Update timeline height based on the new bucket height
       const estimateBucketHeight = state.buckets[bucketIndex].bucketHeight;
@@ -177,9 +172,13 @@ function createAssetStore() {
   };
 
   const cancelBucketRequest = async (token: AbortController, bucketDate: string) => {
+    if (!_loadingBuckets[bucketDate]) {
+      return;
+    }
+
     token.abort();
-    // set new abort controller for bucket
-    assetGridState.update((state) => {
+
+    update((state) => {
       const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucketDate);
       state.buckets[bucketIndex].cancelToken = new AbortController();
       return state;
@@ -187,7 +186,7 @@ function createAssetStore() {
   };
 
   const updateAsset = (assetId: string, isFavorite: boolean) => {
-    assetGridState.update((state) => {
+    update((state) => {
       const bucketIndex = state.buckets.findIndex((b) => b.assets.some((a) => a.id === assetId));
       const assetIndex = state.buckets[bucketIndex].assets.findIndex((a) => a.id === assetId);
       state.buckets[bucketIndex].assets[assetIndex].isFavorite = isFavorite;
@@ -198,14 +197,72 @@ function createAssetStore() {
     });
   };
 
+  const _getNextAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
+    const currentBucket = _assetGridState.buckets[currentBucketIndex];
+    const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
+    if (assetIndex === -1) {
+      return null;
+    }
+
+    if (assetIndex + 1 < currentBucket.assets.length) {
+      return currentBucket.assets[assetIndex + 1];
+    }
+
+    const nextBucketIndex = currentBucketIndex + 1;
+    if (nextBucketIndex >= _assetGridState.buckets.length) {
+      return null;
+    }
+
+    const nextBucket = _assetGridState.buckets[nextBucketIndex];
+    await getAssetsByBucket(nextBucket.bucketDate, BucketPosition.Unknown);
+
+    return nextBucket.assets[0] ?? null;
+  };
+
+  const _getPrevAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
+    const currentBucket = _assetGridState.buckets[currentBucketIndex];
+    const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
+    if (assetIndex === -1) {
+      return null;
+    }
+
+    if (assetIndex > 0) {
+      return currentBucket.assets[assetIndex - 1];
+    }
+
+    const prevBucketIndex = currentBucketIndex - 1;
+    if (prevBucketIndex < 0) {
+      return null;
+    }
+
+    const prevBucket = _assetGridState.buckets[prevBucketIndex];
+    await getAssetsByBucket(prevBucket.bucketDate, BucketPosition.Unknown);
+
+    return prevBucket.assets[prevBucket.assets.length - 1] ?? null;
+  };
+
+  const getAdjacentAsset = async (assetId: string, direction: 'next' | 'previous'): Promise<string | null> => {
+    const currentBucketIndex = _assetGridState.loadedAssets[assetId];
+    if (currentBucketIndex < 0 || currentBucketIndex >= _assetGridState.buckets.length) {
+      return null;
+    }
+
+    const asset =
+      direction === 'next'
+        ? await _getNextAsset(currentBucketIndex, assetId)
+        : await _getPrevAsset(currentBucketIndex, assetId);
+
+    return asset?.id ?? null;
+  };
+
   return {
     setInitialState,
     getAssetsByBucket,
     removeAsset,
     updateBucketHeight,
     cancelBucketRequest,
+    getAdjacentAsset,
     updateAsset,
+    subscribe,
   };
 }
-
-export const assetStore = createAssetStore();

+ 10 - 13
web/src/routes/(user)/map/+page.svelte

@@ -3,11 +3,6 @@
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
   import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte';
   import Portal from '$lib/components/shared-components/portal/portal.svelte';
-  import {
-    assetInteractionStore,
-    isViewingAssetStoreState,
-    viewingAssetStoreState,
-  } from '$lib/stores/asset-interaction.store';
   import { mapSettings } from '$lib/stores/preferences.store';
   import { MapMarkerResponseDto, api } from '@api';
   import { isEqual, omit } from 'lodash-es';
@@ -15,9 +10,12 @@
   import Cog from 'svelte-material-icons/Cog.svelte';
   import type { PageData } from './$types';
   import { DateTime, Duration } from 'luxon';
+  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
 
   export let data: PageData;
 
+  let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
+
   let leaflet: typeof import('$lib/components/shared-components/leaflet');
   let mapMarkers: MapMarkerResponseDto[] = [];
   let abortController: AbortController;
@@ -34,8 +32,7 @@
     if (abortController) {
       abortController.abort();
     }
-    assetInteractionStore.clearMultiselect();
-    assetInteractionStore.setIsViewingAsset(false);
+    assetViewingStore.showAssetViewer(false);
   });
 
   async function loadMapMarkers() {
@@ -83,20 +80,20 @@
   }
 
   function onViewAssets(assetIds: string[], activeAssetIndex: number) {
-    assetInteractionStore.setViewingAssetId(assetIds[activeAssetIndex]);
+    assetViewingStore.setAssetId(assetIds[activeAssetIndex]);
     viewingAssets = assetIds;
     viewingAssetCursor = activeAssetIndex;
   }
 
   function navigateNext() {
     if (viewingAssetCursor < viewingAssets.length - 1) {
-      assetInteractionStore.setViewingAssetId(viewingAssets[++viewingAssetCursor]);
+      assetViewingStore.setAssetId(viewingAssets[++viewingAssetCursor]);
     }
   }
 
   function navigatePrevious() {
     if (viewingAssetCursor > 0) {
-      assetInteractionStore.setViewingAssetId(viewingAssets[--viewingAssetCursor]);
+      assetViewingStore.setAssetId(viewingAssets[--viewingAssetCursor]);
     }
   }
 </script>
@@ -142,14 +139,14 @@
 </UserPageLayout>
 
 <Portal target="body">
-  {#if $isViewingAssetStoreState}
+  {#if $showAssetViewer}
     <AssetViewer
-      asset={$viewingAssetStoreState}
+      asset={$viewingAsset}
       showNavigation={viewingAssets.length > 1}
       on:navigate-next={navigateNext}
       on:navigate-previous={navigatePrevious}
       on:close={() => {
-        assetInteractionStore.setIsViewingAsset(false);
+        assetViewingStore.showAssetViewer(false);
       }}
     />
   {/if}

+ 8 - 3
web/src/routes/(user)/partners/[userId]/+page.svelte

@@ -8,21 +8,26 @@
   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
   import { AppRoute } from '$lib/constants';
-  import { assetInteractionStore, isMultiSelectStoreState, selectedAssets } from '$lib/stores/asset-interaction.store';
   import { onDestroy } from 'svelte';
   import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
   import Plus from 'svelte-material-icons/Plus.svelte';
   import type { PageData } from './$types';
+  import { createAssetStore } from '$lib/stores/assets.store';
+  import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
 
   export let data: PageData;
 
+  const assetStore = createAssetStore();
+  const assetInteractionStore = createAssetInteractionStore();
+  const { isMultiSelectState, selectedAssets } = assetInteractionStore;
+
   onDestroy(() => {
     assetInteractionStore.clearMultiselect();
   });
 </script>
 
 <main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg">
-  {#if $isMultiSelectStoreState}
+  {#if $isMultiSelectState}
     <AssetSelectControlBar assets={$selectedAssets} clearSelect={assetInteractionStore.clearMultiselect}>
       <DownloadAction />
     </AssetSelectControlBar>
@@ -44,5 +49,5 @@
       </svelte:fragment>
     </ControlAppBar>
   {/if}
-  <AssetGrid user={data.partner} />
+  <AssetGrid {assetStore} {assetInteractionStore} user={data.partner} />
 </main>

+ 10 - 6
web/src/routes/(user)/photos/+page.svelte

@@ -11,8 +11,8 @@
   import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
   import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
-  import { assetInteractionStore, isMultiSelectStoreState, selectedAssets } from '$lib/stores/asset-interaction.store';
-  import { assetStore } from '$lib/stores/assets.store';
+  import { createAssetStore } from '$lib/stores/assets.store';
+  import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import { openFileUploadDialog } from '$lib/utils/file-uploader';
   import { api } from '@api';
   import { onDestroy, onMount } from 'svelte';
@@ -23,6 +23,10 @@
   export let data: PageData;
   let assetCount = 1;
 
+  const assetStore = createAssetStore();
+  const assetInteractionStore = createAssetInteractionStore();
+  const { isMultiSelectState, selectedAssets } = assetInteractionStore;
+
   onMount(async () => {
     const { data: stats } = await api.assetApi.getAssetStats();
     assetCount = stats.total;
@@ -39,12 +43,12 @@
   };
 </script>
 
-<UserPageLayout user={data.user} hideNavbar={$isMultiSelectStoreState} showUploadButton>
+<UserPageLayout user={data.user} hideNavbar={$isMultiSelectState} showUploadButton>
   <svelte:fragment slot="header">
-    {#if $isMultiSelectStoreState}
+    {#if $isMultiSelectState}
       <AssetSelectControlBar assets={$selectedAssets} clearSelect={assetInteractionStore.clearMultiselect}>
         <CreateSharedLink />
-        <SelectAllAssets />
+        <SelectAllAssets {assetStore} {assetInteractionStore} />
         <AssetSelectContextMenu icon={Plus} title="Add">
           <AddToAlbum />
           <AddToAlbum shared />
@@ -60,7 +64,7 @@
   </svelte:fragment>
   <svelte:fragment slot="content">
     {#if assetCount}
-      <AssetGrid showMemoryLane />
+      <AssetGrid {assetStore} {assetInteractionStore} showMemoryLane />
     {:else}
       <EmptyPlaceholder text="CLICK TO UPLOAD YOUR FIRST PHOTO" actionHandler={handleUpload} />
     {/if}

+ 4 - 2
web/src/routes/(user)/search/+page.svelte

@@ -25,10 +25,12 @@
   import { flip } from 'svelte/animate';
   import { onDestroy, onMount } from 'svelte';
   import { browser } from '$app/environment';
-  import { isViewingAssetStoreState } from '$lib/stores/asset-interaction.store';
+  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
 
   export let data: PageData;
 
+  let { isViewing: showAssetViewer } = assetViewingStore;
+
   // The GalleryViewer pushes it's own history state, which causes weird
   // behavior for history.back(). To prevent that we store the previous page
   // manually and navigate back to that.
@@ -48,7 +50,7 @@
   });
 
   const handleKeyboardPress = (event: KeyboardEvent) => {
-    if (!$isViewingAssetStoreState) {
+    if (!$showAssetViewer) {
       switch (event.key) {
         case 'Escape':
           goto(previousRoute);