feat: timeline performance (#16446)

* Squash - feature complete

* remove need to init assetstore

* More optimizations. No need to init. Fix tests

* lint

* add missing selector for e2e

* e2e selectors again

* Update: fully reactive store, some transitions, bugfixes

* merge fallout

* Test fallout

* safari quirk

* security

* lint

* lint

* Bug fixes

* lint/format

* accidental commit

* lock

* null check, more throttle

* revert long duration

* Fix intersection bounds

* Fix bugs in intersection calculation

* lint, tweak scrubber ui a tiny bit

* bugfix - deselecting asset doesnt work

* fix not loading bucket, scroll off-by-1 error, jsdoc, naming
This commit is contained in:
Min Idzelis 2025-03-18 10:14:46 -04:00 committed by GitHub
parent dd263b010c
commit e96ffd43e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 2318 additions and 2764 deletions

View File

@ -45,7 +45,7 @@ test.describe('Shared Links', () => {
await page.goto(`/share/${sharedLink.key}`); await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator(`[data-asset-id="${asset.id}"]`).hover(); await page.locator(`[data-asset-id="${asset.id}"]`).hover();
await page.waitForSelector('#asset-group-by-date svg'); await page.waitForSelector('[data-group] svg');
await page.getByRole('checkbox').click(); await page.getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download' }).click(); await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING', { exact: true }).waitFor(); await page.getByText('DOWNLOADING', { exact: true }).waitFor();

10
web/package-lock.json generated
View File

@ -70,8 +70,8 @@
"prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^5.14.0", "rollup-plugin-visualizer": "^5.14.0",
"svelte": "^5.17.4", "svelte": "^5.22.6",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.5",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",
@ -9579,9 +9579,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.2.1", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz",
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==", "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -8,7 +8,7 @@
"build:stats": "BUILD_STATS=true vite build", "build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package", "package": "svelte-kit package",
"preview": "vite preview", "preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore'", "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte",
"check:typescript": "tsc --noEmit", "check:typescript": "tsc --noEmit",
"check:watch": "npm run check:svelte -- --watch", "check:watch": "npm run check:svelte -- --watch",
"check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript", "check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript",
@ -56,8 +56,8 @@
"prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^5.14.0", "rollup-plugin-visualizer": "^5.14.0",
"svelte": "^5.17.4", "svelte": "^5.22.6",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.5",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -135,32 +135,13 @@ input:focus-visible {
} }
/* width */ /* width */
.immich-scrollbar::-webkit-scrollbar { .immich-scrollbar {
width: 8px; scrollbar-width: thin;
}
/* Track */
.immich-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 16px;
}
/* Handle */
.immich-scrollbar::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408);
border-radius: 16px;
}
/* Handle on hover */
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4250afad;
border-radius: 16px;
} }
/* Hidden scrollbar */ /* Hidden scrollbar */
/* width */ /* width */
.scrollbar-hidden::-webkit-scrollbar { .scrollbar-hidden {
display: none;
scrollbar-width: none; scrollbar-width: none;
} }

View File

@ -13,6 +13,7 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem
type OnSeparateCallback = (element: HTMLElement) => unknown; type OnSeparateCallback = (element: HTMLElement) => unknown;
type IntersectionObserverActionProperties = { type IntersectionObserverActionProperties = {
key?: string; key?: string;
disabled?: boolean;
/** Function to execute when the element leaves the viewport */ /** Function to execute when the element leaves the viewport */
onSeparate?: OnSeparateCallback; onSeparate?: OnSeparateCallback;
/** Function to execute when the element enters the viewport */ /** Function to execute when the element enters the viewport */
@ -83,9 +84,16 @@ const observe = (key: HTMLElement | string, target: HTMLElement, properties: Int
}; };
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) { function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
if (properties.disabled) {
const config = elementToConfig.get(key);
const { observer } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
} else {
elementToConfig.set(key, properties); elementToConfig.set(key, properties);
observe(key, element, properties); observe(key, element, properties);
} }
}
function _intersectionObserver( function _intersectionObserver(
key: HTMLElement | string, key: HTMLElement | string,

View File

@ -1,4 +1,4 @@
type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void; export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
let observer: ResizeObserver; let observer: ResizeObserver;
let callbacks: WeakMap<HTMLElement, OnResizeCallback>; let callbacks: WeakMap<HTMLElement, OnResizeCallback>;

View File

@ -33,7 +33,10 @@
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore({ albumId: album.id, order: album.order }); const assetStore = new AssetStore();
$effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order }));
onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
dragAndDropFilesStore.subscribe((value) => { dragAndDropFilesStore.subscribe((value) => {
@ -42,9 +45,6 @@
dragAndDropFilesStore.set({ isDragging: false, files: [] }); dragAndDropFilesStore.set({ isDragging: false, files: [] });
} }
}); });
onDestroy(() => {
assetStore.destroy();
});
</script> </script>
<svelte:window <svelte:window

View File

@ -64,7 +64,7 @@
onClose: (dto: { asset: AssetResponseDto }) => void; onClose: (dto: { asset: AssetResponseDto }) => void;
onNext: () => Promise<HasAsset>; onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>; onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<AssetResponseDto | null>; onRandom: () => Promise<AssetResponseDto | undefined>;
copyImage?: () => Promise<void>; copyImage?: () => Promise<void>;
} }

View File

@ -4,7 +4,6 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import { mdiEyeOffOutline } from '@mdi/js'; import { mdiEyeOffOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { interface Props {
@ -37,7 +36,6 @@
circle = false, circle = false,
hidden = false, hidden = false,
border = false, border = false,
preload = true,
hiddenIconClass = 'text-white', hiddenIconClass = 'text-white',
onComplete = undefined, onComplete = undefined,
}: Props = $props(); }: Props = $props();
@ -49,8 +47,6 @@
let loaded = $state(false); let loaded = $state(false);
let errored = $state(false); let errored = $state(false);
let img = $state<HTMLImageElement>();
const setLoaded = () => { const setLoaded = () => {
loaded = true; loaded = true;
onComplete?.(); onComplete?.();
@ -59,11 +55,13 @@
errored = true; errored = true;
onComplete?.(); onComplete?.();
}; };
onMount(() => {
if (img?.complete) { function mount(elem: HTMLImageElement) {
setLoaded(); if (elem.complete) {
loaded = true;
onComplete?.();
}
} }
});
let optionalClasses = $derived( let optionalClasses = $derived(
[ [
@ -82,10 +80,9 @@
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} /> <BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
{:else} {:else}
<img <img
bind:this={img} use:mount
onload={setLoaded} onload={setLoaded}
onerror={setErrored} onerror={setErrored}
loading={preload ? 'eager' : 'lazy'}
style:width={widthStyle} style:width={widthStyle}
style:height={heightStyle} style:height={heightStyle}
style:filter={hidden ? 'grayscale(50%)' : 'none'} style:filter={hidden ? 'grayscale(50%)' : 'none'}

View File

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants'; import { ProjectionType } from '$lib/constants';
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils'; import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
@ -22,19 +21,11 @@
import ImageThumbnail from './image-thumbnail.svelte'; import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import type { DateGroup } from '$lib/utils/timeline-util';
import { generateId } from '$lib/utils/generate-id';
import { onDestroy } from 'svelte';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import { thumbhash } from '$lib/actions/thumbhash'; import { thumbhash } from '$lib/actions/thumbhash';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
dateGroup?: DateGroup | undefined;
assetStore?: AssetStore | undefined;
groupIndex?: number; groupIndex?: number;
thumbnailSize?: number | undefined; thumbnailSize?: number | undefined;
thumbnailWidth?: number | undefined; thumbnailWidth?: number | undefined;
@ -47,29 +38,16 @@
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
showStackedIcon?: boolean; showStackedIcon?: boolean;
disableMouseOver?: boolean; disableMouseOver?: boolean;
intersectionConfig?: {
root?: HTMLElement;
bottom?: string;
top?: string;
left?: string;
priority?: number;
disabled?: boolean;
};
retrieveElement?: boolean;
onIntersected?: (() => void) | undefined;
onClick?: ((asset: AssetResponseDto) => void) | undefined; onClick?: ((asset: AssetResponseDto) => void) | undefined;
onRetrieveElement?: ((elment: HTMLElement) => void) | undefined;
onSelect?: ((asset: AssetResponseDto) => void) | undefined; onSelect?: ((asset: AssetResponseDto) => void) | undefined;
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined; onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
handleFocus?: (() => void) | undefined; handleFocus?: (() => void) | undefined;
class?: string; class?: string;
overrideDisplayForTest?: boolean;
} }
let { let {
asset, asset = $bindable(),
dateGroup = undefined,
assetStore = undefined,
groupIndex = 0, groupIndex = 0,
thumbnailSize = undefined, thumbnailSize = undefined,
thumbnailWidth = undefined, thumbnailWidth = undefined,
@ -82,42 +60,21 @@
showArchiveIcon = false, showArchiveIcon = false,
showStackedIcon = true, showStackedIcon = true,
disableMouseOver = false, disableMouseOver = false,
intersectionConfig = {},
retrieveElement = false,
onIntersected = undefined,
onClick = undefined, onClick = undefined,
onRetrieveElement = undefined,
onSelect = undefined, onSelect = undefined,
onMouseEvent = undefined, onMouseEvent = undefined,
handleFocus = undefined, handleFocus = undefined,
class: className = '', class: className = '',
overrideDisplayForTest = false,
}: Props = $props(); }: Props = $props();
let { let {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES; } = TUNABLES;
const componentId = generateId();
let element: HTMLElement | undefined = $state();
let focussableElement: HTMLElement | undefined = $state(); let focussableElement: HTMLElement | undefined = $state();
let mouseOver = $state(false); let mouseOver = $state(false);
let intersecting = $state(false);
let lastRetrievedElement: HTMLElement | undefined = $state();
let loaded = $state(false); let loaded = $state(false);
$effect(() => {
if (!retrieveElement) {
lastRetrievedElement = undefined;
}
});
$effect(() => {
if (retrieveElement && element && lastRetrievedElement !== element) {
lastRetrievedElement = element;
onRetrieveElement?.(element);
}
});
$effect(() => { $effect(() => {
if (focussed && document.activeElement !== focussableElement) { if (focussed && document.activeElement !== focussableElement) {
focussableElement?.focus(); focussableElement?.focus();
@ -126,13 +83,12 @@
let width = $derived(thumbnailSize || thumbnailWidth || 235); let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235);
let display = $derived(intersecting);
const onIconClickedHandler = (e?: MouseEvent) => { const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation(); e?.stopPropagation();
e?.preventDefault(); e?.preventDefault();
if (!disabled) { if (!disabled) {
onSelect?.(asset); onSelect?.($state.snapshot(asset));
} }
}; };
@ -141,7 +97,7 @@
onIconClickedHandler(); onIconClickedHandler();
return; return;
} }
onClick?.(asset); onClick?.($state.snapshot(asset));
}; };
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
@ -152,68 +108,18 @@
callClickHandlers(); callClickHandlers();
}; };
const _onMouseEnter = () => { const onMouseEnter = () => {
mouseOver = true; mouseOver = true;
onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex }); onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
}; };
const onMouseEnter = () => {
if (dateGroup && assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() });
} else {
_onMouseEnter();
}
};
const onMouseLeave = () => { const onMouseLeave = () => {
if (dateGroup && assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) });
} else {
mouseOver = false; mouseOver = false;
}
}; };
const _onIntersect = () => {
intersecting = true;
onIntersected?.();
};
const onIntersect = () => {
if (intersecting === true) {
return;
}
if (dateGroup && assetStore) {
assetStore.taskManager.intersectedThumbnail(componentId, dateGroup, asset, () => void _onIntersect());
} else {
void _onIntersect();
}
};
const onSeparate = () => {
if (intersecting === false) {
return;
}
if (dateGroup && assetStore) {
assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
} else {
intersecting = false;
}
};
onDestroy(() => {
assetStore?.taskManager.removeAllTasksForComponent(componentId);
});
</script> </script>
<div <div
bind:this={element}
use:intersectionObserver={{
...intersectionConfig,
onIntersect,
onSeparate,
}}
data-asset={asset.id} data-asset={asset.id}
data-int={intersecting}
style:width="{width}px" style:width="{width}px"
style:height="{height}px" style:height="{height}px"
class="focus-visible:outline-none flex overflow-hidden {disabled class="focus-visible:outline-none flex overflow-hidden {disabled
@ -230,11 +136,12 @@
></canvas> ></canvas>
{/if} {/if}
{#if display || overrideDisplayForTest}
<!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates <!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
the navigation url on scroll. Replace this with button for now. --> the navigation url on scroll. Replace this with button for now. -->
<div <div
class="group" class="group"
style:width="{width}px"
style:height="{height}px"
class:cursor-not-allowed={disabled} class:cursor-not-allowed={disabled}
class:cursor-pointer={!disabled} class:cursor-pointer={!disabled}
onmouseenter={onMouseEnter} onmouseenter={onMouseEnter}
@ -359,7 +266,6 @@
{#if asset.type === AssetTypeEnum.Video} {#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full"> <div class="absolute top-0 h-full w-full">
<VideoThumbnail <VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })} url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover} enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected} curve={selected}
@ -372,7 +278,6 @@
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId} {#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full"> <div class="absolute top-0 h-full w-full">
<VideoThumbnail <VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })} url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
pauseIcon={mdiMotionPauseOutline} pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline} playIcon={mdiMotionPlayOutline}
@ -391,5 +296,4 @@
></div> ></div>
{/if} {/if}
</div> </div>
{/if}
</div> </div>

View File

@ -3,12 +3,8 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { generateId } from '$lib/utils/generate-id';
import { onDestroy } from 'svelte';
interface Props { interface Props {
assetStore?: AssetStore | undefined;
url: string; url: string;
durationInSeconds?: number; durationInSeconds?: number;
enablePlayback?: boolean; enablePlayback?: boolean;
@ -20,7 +16,6 @@
} }
let { let {
assetStore = undefined,
url, url,
durationInSeconds = 0, durationInSeconds = 0,
enablePlayback = $bindable(false), enablePlayback = $bindable(false),
@ -31,7 +26,6 @@
pauseIcon = mdiPauseCircleOutline, pauseIcon = mdiPauseCircleOutline,
}: Props = $props(); }: Props = $props();
const componentId = generateId();
let remainingSeconds = $state(durationInSeconds); let remainingSeconds = $state(durationInSeconds);
let loading = $state(true); let loading = $state(true);
let error = $state(false); let error = $state(false);
@ -49,42 +43,16 @@
} }
}); });
const onMouseEnter = () => { const onMouseEnter = () => {
if (assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
if (playbackOnIconHover) { if (playbackOnIconHover) {
enablePlayback = true; enablePlayback = true;
} }
},
});
} else {
if (playbackOnIconHover) {
enablePlayback = true;
}
}
}; };
const onMouseLeave = () => { const onMouseLeave = () => {
if (assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
if (playbackOnIconHover) { if (playbackOnIconHover) {
enablePlayback = false; enablePlayback = false;
} }
},
});
} else {
if (playbackOnIconHover) {
enablePlayback = false;
}
}
}; };
onDestroy(() => {
assetStore?.taskManager.removeAllTasksForComponent(componentId);
});
</script> </script>
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white"> <div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">

View File

@ -1,56 +1,51 @@
<script lang="ts"> <script lang="ts">
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import Skeleton from '$lib/components/photos-page/skeleton.svelte'; import { AssetBucket } from '$lib/stores/assets-store.svelte';
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { import { getDateLocaleString } from '$lib/utils/timeline-util';
findTotalOffset,
type DateGroup,
type ScrollTargetListener,
getDateLocaleString,
} from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; 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'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { scale } from 'svelte/transition';
export let element: HTMLElement | undefined = undefined; import { flip } from 'svelte/animate';
export let isSelectionMode = false;
export let viewport: Viewport;
export let singleSelect = false;
export let withStacked = false;
export let showArchiveIcon = false;
export let assetGridElement: HTMLElement | undefined = undefined;
export let renderThumbsAtBottomMargin: string | undefined = undefined;
export let renderThumbsAtTopMargin: string | undefined = undefined;
export let assetStore: AssetStore;
export let bucket: AssetBucket;
export let assetInteraction: AssetInteraction;
export let onScrollTarget: ScrollTargetListener | undefined = undefined; import { uploadAssetsStore } from '$lib/stores/upload';
export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
export let onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
export let onSelectAssets: (asset: AssetResponseDto) => void;
export let onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
const componentId = generateId(); let { isUploading } = uploadAssetsStore;
$: bucketDate = bucket.bucketDate;
$: dateGroups = bucket.dateGroups;
const { interface Props {
DATEGROUP: { INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM }, isSelectionMode: boolean;
} = TUNABLES; singleSelect: boolean;
/* TODO figure out a way to calculate this*/ withStacked: boolean;
const TITLE_HEIGHT = 51; showArchiveIcon: boolean;
bucket: AssetBucket;
assetInteraction: AssetInteraction;
let isMouseOverGroup = false; onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
let hoveredDateGroup = ''; onSelectAssets: (asset: AssetResponseDto) => void;
onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
}
let {
isSelectionMode,
singleSelect,
withStacked,
showArchiveIcon,
bucket = $bindable(),
assetInteraction,
onSelect,
onSelectAssets,
onSelectAssetCandidates,
}: Props = $props();
let isMouseOverGroup = $state(false);
let hoveredDateGroup = $state();
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
if (isSelectionMode || assetInteraction.selectionActive) { if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(asset, assets, groupTitle); assetSelectHandler(asset, assets, groupTitle);
@ -59,13 +54,6 @@
void navigate({ targetRoute: 'current', assetId: asset.id }); void navigate({ targetRoute: 'current', assetId: asset.id });
}; };
const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
if (assetGridElement && onScrollTarget) {
const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
onScrollTarget({ bucket, dateGroup, asset, offset });
}
};
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets }); const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => { const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
@ -73,7 +61,7 @@
// Check if all assets are selected in a group to toggle the group selection's icon // Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) =>
assetInteraction.selectedAssets.has(asset), assetInteraction.hasSelectedAsset(asset.id),
).length; ).length;
// if all assets are selected in a group, add the group to selected group // if all assets are selected in a group, add the group to selected group
@ -83,7 +71,9 @@
assetInteraction.removeGroupFromMultiselectGroup(groupTitle); assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
} }
}; };
const snapshotAssetArray = (assets: AssetResponseDto[]) => {
return assets.map((a) => $state.snapshot(a));
};
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => { const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
// Show multi select icon on hover on date group // Show multi select icon on hover on date group
hoveredDateGroup = groupTitle; hoveredDateGroup = groupTitle;
@ -96,77 +86,43 @@
const assetOnFocusHandler = (asset: AssetResponseDto) => { const assetOnFocusHandler = (asset: AssetResponseDto) => {
assetInteraction.focussedAssetId = asset.id; assetInteraction.focussedAssetId = asset.id;
}; };
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
onDestroy(() => { return intersectable.filter((int) => int.intersecting);
assetStore.taskManager.removeAllTasksForComponent(componentId); }
});
</script> </script>
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}> {#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)}
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)} {@const absoluteWidth = dateGroup.left}
{@const display =
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === assetStore.pendingScrollAssetId)}
{@const geometry = dateGroup.geometry!}
<div <!-- svelte-ignore a11y_no_static_element_interactions -->
id="date-group" <section
use:intersectionObserver={{ class={[
onIntersect: () => { { 'transition-all': !bucket.store.suspendTransitions },
assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () => !bucket.store.suspendTransitions && `delay-${transitionDuration}`,
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }), ]}
); data-group
}, style:position="absolute"
onSeparate: () => { style:transform={`translate3d(${absoluteWidth}px,${dateGroup.top}px,0)`}
assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () => onmouseenter={() => {
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
);
},
top: INTERSECTION_ROOT_TOP,
bottom: INTERSECTION_ROOT_BOTTOM,
root: assetGridElement,
}}
data-display={display}
data-date-group={dateGroup.date}
style:height={dateGroup.height + 'px'}
style:width={geometry.containerWidth + 'px'}
style:overflow="clip"
>
{#if !display}
<Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
{/if}
{#if display}
<!-- Asset Group By Date -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:mouseenter={() =>
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = true; isMouseOverGroup = true;
assetMouseEventHandler(dateGroup.groupTitle, null); assetMouseEventHandler(dateGroup.groupTitle, null);
}, }}
})} onmouseleave={() => {
on:mouseleave={() => {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = false; isMouseOverGroup = false;
assetMouseEventHandler(dateGroup.groupTitle, null); assetMouseEventHandler(dateGroup.groupTitle, null);
},
});
}} }}
> >
<!-- Date group title --> <!-- Date group title -->
<div <div
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" class="flex z-[100] 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={geometry.containerWidth + 'px'} style:width={dateGroup.width + 'px'}
> >
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))} {#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
<div <div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }} transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer" class="inline-block px-2 hover:cursor-pointer"
on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} onclick={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} onkeydown={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
> >
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)} {#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
<Icon path={mdiCheckCircle} size="24" color="#4250af" /> <Icon path={mdiCheckCircle} size="24" color="#4250af" />
@ -182,69 +138,48 @@
</div> </div>
<!-- Image grid --> <!-- Image grid -->
<div class="relative overflow-clip" style:height={dateGroup.height + 'px'} style:width={dateGroup.width + 'px'}>
{#each filterIntersecting(dateGroup.intersetingAssets) as intersectingAsset (intersectingAsset.id)}
{@const position = intersectingAsset.position!}
{@const asset = intersectingAsset.asset!}
<!-- {#if intersectingAsset.intersecting} -->
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div <div
class="relative overflow-clip"
style:height={geometry.containerHeight + 'px'}
style:width={geometry.containerWidth + 'px'}
>
{#each dateGroup.assets as asset, i (asset.id)}
<!-- getting these together here in this order is very cache-efficient -->
{@const top = geometry.getTop(i)}
{@const left = geometry.getLeft(i)}
{@const width = geometry.getWidth(i)}
{@const height = geometry.getHeight(i)}
<!-- update ASSET_GRID_PADDING-->
<div
use:intersectionObserver={{
onIntersect: () => onAssetInGrid?.(asset),
top: `${-TITLE_HEIGHT}px`,
bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`,
right: `${-(viewport.width - 1)}px`,
root: assetGridElement,
}}
data-asset-id={asset.id} data-asset-id={asset.id}
class="absolute" class="absolute"
style:top={top + 'px'} style:top={position.top + 'px'}
style:left={left + 'px'} style:left={position.left + 'px'}
style:width={width + 'px'} style:width={position.width + 'px'}
style:height={height + 'px'} style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
> >
<Thumbnail <Thumbnail
{dateGroup}
{assetStore}
intersectionConfig={{
root: assetGridElement,
bottom: renderThumbsAtBottomMargin,
top: renderThumbsAtTopMargin,
}}
retrieveElement={assetStore.pendingScrollAssetId === asset.id}
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
showStackedIcon={withStacked} showStackedIcon={withStacked}
{showArchiveIcon} {showArchiveIcon}
{asset} {asset}
{groupIndex} {groupIndex}
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
selected={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)}
handleFocus={() => assetOnFocusHandler(asset)}
focussed={assetInteraction.isFocussedAsset(asset)} focussed={assetInteraction.isFocussedAsset(asset)}
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} onClick={(asset) => onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)}
disabled={assetStore.albumAssets.has(asset.id)} onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)}
thumbnailWidth={width} onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, $state.snapshot(asset))}
thumbnailHeight={height} selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
handleFocus={() => assetOnFocusHandler(asset)}
disabled={dateGroup.bucket.store.albumAssets.has(asset.id)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/> />
</div> </div>
<!-- {/if} -->
{/each} {/each}
</div> </div>
</div>
{/if}
</div>
{/each}
</section> </section>
{/each}
<style> <style>
#asset-group-by-date { section {
contain: layout paint style; contain: layout paint style;
} }
</style> </style>

View File

@ -4,38 +4,26 @@
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte'; import { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
import { locale, showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store'; import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions'; import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { import { type ScrubberListener } from '$lib/utils/timeline-util';
formatGroupTitle,
splitBucketIntoDateGroups,
type ScrubberListener,
type ScrollTargetListener,
} from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import { throttle } from 'lodash-es'; import { onMount, type Snippet } from 'svelte';
import { onDestroy, onMount, type Snippet } from 'svelte';
import Portal from '../shared-components/portal/portal.svelte'; import Portal from '../shared-components/portal/portal.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte'; import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte'; import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { resizeObserver } from '$lib/actions/resize-observer';
import MeasureDateGroup from '$lib/components/photos-page/measure-date-group.svelte';
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Skeleton from '$lib/components/photos-page/skeleton.svelte'; import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { UpdatePayload } from 'vite'; 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'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
interface Props { interface Props {
@ -81,64 +69,41 @@
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
const componentId = generateId();
let element: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state();
let showShortcuts = $state(false); let showShortcuts = $state(false);
let showSkeleton = $state(true); let showSkeleton = $state(true);
let internalScroll = false;
let navigating = false;
let preMeasure: AssetBucket[] = $state([]);
let lastIntersectedBucketDate: string | undefined;
let scrubBucketPercent = $state(0); let scrubBucketPercent = $state(0);
let scrubBucket: { bucketDate: string | undefined } | undefined = $state(); let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
let scrubOverallPercent: number = $state(0); let scrubOverallPercent: number = $state(0);
let topSectionHeight = $state(0);
let topSectionOffset = $state(0);
// 60 is the bottom spacer element at 60px // 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60; let bottomSectionHeight = 60;
let leadout = $state(false); let leadout = $state(false);
const { const completeNav = async () => {
ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW },
BUCKET: {
INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP,
INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM,
},
THUMBNAIL: {
INTERSECTION_ROOT_TOP: THUMBNAIL_INTERSECTION_ROOT_TOP,
INTERSECTION_ROOT_BOTTOM: THUMBNAIL_INTERSECTION_ROOT_BOTTOM,
},
} = TUNABLES;
const isViewportOrigin = () => {
return viewport.height === 0 && viewport.width === 0;
};
const isEqual = (a: ViewportXY, b: ViewportXY) => {
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
};
const completeNav = () => {
navigating = false;
if (internalScroll) {
internalScroll = false;
return;
}
if ($gridScrollTarget?.at) { if ($gridScrollTarget?.at) {
void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => { try {
const bucket = await assetStore.findBucketForAsset($gridScrollTarget.at);
if (bucket) {
const height = bucket.findAssetAbsolutePosition($gridScrollTarget.at);
if (height) {
element?.scrollTo({ top: height });
showSkeleton = false;
assetStore.updateIntersections();
}
}
} catch {
element?.scrollTo({ top: 0 }); element?.scrollTo({ top: 0 });
showSkeleton = false; showSkeleton = false;
}); }
} else { } else {
element?.scrollTo({ top: 0 }); element?.scrollTo({ top: 0 });
showSkeleton = false; showSkeleton = false;
} }
}; };
beforeNavigate(() => (assetStore.suspendTransitions = true));
afterNavigate((nav) => { afterNavigate((nav) => {
const { complete, type } = nav; const { complete, type } = nav;
if (type === 'enter') { if (type === 'enter') {
@ -147,10 +112,6 @@
complete.then(completeNav, completeNav); complete.then(completeNav, completeNav);
}); });
beforeNavigate(() => {
navigating = true;
});
const hmrSupport = () => { const hmrSupport = () => {
// when hmr happens, skeleton is initialized to true by default // when hmr happens, skeleton is initialized to true by default
// normally, loading asset-grid is part of a navigation event, and the completion of // normally, loading asset-grid is part of a navigation event, and the completion of
@ -165,7 +126,6 @@
if (assetGridUpdate) { if (assetGridUpdate) {
setTimeout(() => { setTimeout(() => {
void assetStore.updateViewport(safeViewport, true);
const asset = $page.url.searchParams.get('at'); const asset = $page.url.searchParams.get('at');
if (asset) { if (asset) {
$gridScrollTarget = { at: asset }; $gridScrollTarget = { at: asset };
@ -193,94 +153,60 @@
return () => void 0; return () => void 0;
}; };
const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => { const updateIsScrolling = () => (assetStore.scrolling = true);
if (lastIntersectedBucketDate) { // note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate); const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
const deltaIndex = assetStore.buckets.indexOf(adjustedBucket); const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta);
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
if (deltaIndex < currentIndex) {
element?.scrollBy(0, delta);
}
}
};
const bucketListener: BucketListener = (event) => {
const { type } = event;
if (type === 'bucket-height') {
const { bucket, delta } = event;
scrollTolastIntersectedBucket(bucket, delta);
}
};
onMount(() => { onMount(() => {
void assetStore assetStore.setCompensateScrollCallback(compensateScrollCallback);
.init({ bucketListener })
.then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport)));
if (!enableRouting) { if (!enableRouting) {
showSkeleton = false; showSkeleton = false;
} }
const dispose = hmrSupport(); const disposeHmr = hmrSupport();
return () => { return () => {
assetStore.disconnect(); assetStore.setCompensateScrollCallback();
assetStore.destroy(); disposeHmr();
dispose();
}; };
}); });
const _updateViewport = () => void assetStore.updateViewport(safeViewport); const getMaxScrollPercent = () => {
const updateViewport = throttle(_updateViewport, 16); const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight;
return (totalHeight - assetStore.viewportHeight) / totalHeight;
function getOffset(bucketDate: string) { };
let offset = 0;
for (let a = 0; a < assetStore.buckets.length; a++) {
if (assetStore.buckets[a].bucketDate === bucketDate) {
break;
}
offset += assetStore.buckets[a].bucketHeight;
}
return offset;
}
const getMaxScrollPercent = () =>
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
const getMaxScroll = () => { const getMaxScroll = () => {
if (!element || !timelineElement) { if (!element || !timelineElement) {
return 0; return 0;
} }
return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
}; };
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => { const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset; const topOffset = bucket.top;
const maxScrollPercent = getMaxScrollPercent(); const maxScrollPercent = getMaxScrollPercent();
const delta = bucket.bucketHeight * bucketScrollPercent; const delta = bucket.bucketHeight * bucketScrollPercent;
const scrollTop = (topOffset + delta) * maxScrollPercent; const scrollTop = (topOffset + delta) * maxScrollPercent;
if (!element) { if (element) {
return;
}
element.scrollTop = scrollTop; element.scrollTop = scrollTop;
}
}; };
const _onScrub: ScrubberListener = ( // note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const onScrub: ScrubberListener = (
bucketDate: string | undefined, bucketDate: string | undefined,
scrollPercent: number, scrollPercent: number,
bucketScrollPercent: number, bucketScrollPercent: number,
) => { ) => {
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) { if (!bucketDate || assetStore.timelineHeight < assetStore.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
const offset = maxScroll * scrollPercent; const offset = maxScroll * scrollPercent;
if (!element) { if (!element) {
return; return;
} }
element.scrollTop = offset; element.scrollTop = offset;
} else { } else {
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate); const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
@ -290,47 +216,16 @@
scrollToBucketAndOffset(bucket, bucketScrollPercent); scrollToBucketAndOffset(bucket, bucketScrollPercent);
} }
}; };
const onScrub = throttle(_onScrub, 16, { leading: false, trailing: true });
const stopScrub: ScrubberListener = async (
bucketDate: string | undefined,
_scrollPercent: number,
bucketScrollPercent: number,
) => {
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
return;
}
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
if (!bucket) {
return;
}
if (bucket && !bucket.measured) {
preMeasure.push(bucket);
await assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true });
await bucket.measuredPromise;
scrollToBucketAndOffset(bucket, bucketScrollPercent);
}
};
let scrollObserverTimer: NodeJS.Timeout;
const _handleTimelineScroll = () => {
$isTimelineScrolling = true;
if (scrollObserverTimer) {
clearTimeout(scrollObserverTimer);
}
scrollObserverTimer = setTimeout(() => {
$isTimelineScrolling = false;
}, 1000);
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const handleTimelineScroll = () => {
leadout = false; leadout = false;
if (!element) { if (!element) {
return; return;
} }
if (assetStore.timelineHeight < safeViewport.height * 2) { if (assetStore.timelineHeight < assetStore.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
@ -338,8 +233,8 @@
scrubBucket = undefined; scrubBucket = undefined;
scrubBucketPercent = 0; scrubBucketPercent = 0;
} else { } else {
let top = element?.scrollTop; let top = element.scrollTop;
if (top < topSectionHeight) { if (top < assetStore.topSectionHeight) {
// in the lead-in area // in the lead-in area
scrubBucket = undefined; scrubBucket = undefined;
scrubBucketPercent = 0; scrubBucketPercent = 0;
@ -352,18 +247,24 @@
let maxScrollPercent = getMaxScrollPercent(); let maxScrollPercent = getMaxScrollPercent();
let found = false; let found = false;
// create virtual buckets.... const bucketsLength = assetStore.buckets.length;
const vbuckets = [ for (let i = -1; i < bucketsLength + 1; i++) {
{ bucketHeight: topSectionHeight, bucketDate: undefined }, let bucket: { bucketDate: string | undefined } | undefined;
...assetStore.buckets, let bucketHeight = 0;
{ bucketHeight: bottomSectionHeight, bucketDate: undefined }, if (i === -1) {
]; // lead-in
bucketHeight = assetStore.topSectionHeight;
for (const bucket of vbuckets) { } else if (i === bucketsLength) {
let next = top - bucket.bucketHeight * maxScrollPercent; // lead-out
bucketHeight = bottomSectionHeight;
} else {
bucket = assetStore.buckets[i];
bucketHeight = assetStore.buckets[i].bucketHeight;
}
let next = top - bucketHeight * maxScrollPercent;
if (next < 0) { if (next < 0) {
scrubBucket = bucket; scrubBucket = bucket;
scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent); scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
found = true; found = true;
break; break;
} }
@ -377,34 +278,6 @@
} }
} }
}; };
const handleTimelineScroll = throttle(_handleTimelineScroll, 16, { leading: false, trailing: true });
const _onAssetInGrid = async (asset: AssetResponseDto) => {
if (!enableRouting || navigating || internalScroll) {
return;
}
$gridScrollTarget = { at: asset.id };
internalScroll = true;
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
};
const onAssetInGrid = NAVIGATE_ON_ASSET_IN_VIEW
? throttle(_onAssetInGrid, 16, { leading: false, trailing: true })
: () => void 0;
const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => {
element?.scrollTo({ top: offset });
if (!bucket.measured) {
preMeasure.push(bucket);
}
showSkeleton = false;
assetStore.clearPendingScroll();
// set intersecting true manually here, to reduce flicker that happens when
// clearing pending scroll, but the intersection observer hadn't yet had time to run
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
};
const trashOrDelete = async (force: boolean = false) => { const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false; isShowDeleteConfirmation = false;
@ -439,11 +312,9 @@
}; };
const toggleArchive = async () => { const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
if (ids) { assetStore.updateAssets(assetInteraction.selectedAssetsArray);
assetStore.removeAssets(ids);
deselectAllAssets(); deselectAllAssets();
}
}; };
const focusElement = () => { const focusElement = () => {
@ -458,23 +329,6 @@
} }
}; };
function handleIntersect(bucket: AssetBucket) {
// updateLastIntersectedBucketDate();
const task = () => {
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
void assetStore.loadBucket(bucket.bucketDate);
};
assetStore.taskManager.intersectedBucket(componentId, bucket, task);
}
function handleSeparate(bucket: AssetBucket) {
const task = () => {
assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
bucket.cancel();
};
assetStore.taskManager.separatedBucket(componentId, bucket, task);
}
const handlePrevious = async () => { const handlePrevious = async () => {
const previousAsset = await assetStore.getPreviousAsset($viewingAsset); const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
@ -610,7 +464,6 @@
if (!asset) { if (!asset) {
return; return;
} }
onSelect(asset); onSelect(asset);
if (singleSelect && element) { if (singleSelect && element) {
@ -619,7 +472,7 @@
} }
const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0; const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0;
const deselect = assetInteraction.selectedAssets.has(asset); const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets // Select/deselect already loaded assets
if (deselect) { if (deselect) {
@ -637,22 +490,25 @@
assetInteraction.clearAssetSelectionCandidates(); assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) { if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id); let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id); let endBucket = assetStore.getBucketIndexByAssetId(asset.id);
if (startBucketIndex === null || endBucketIndex === null) { if (startBucket === null || endBucket === null) {
return; return;
} }
if (endBucketIndex < startBucketIndex) { // Select/deselect assets in range (start,end]
[startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex]; let started = false;
for (const bucket of assetStore.buckets) {
if (bucket === startBucket) {
started = true;
} }
if (bucket === endBucket) {
// Select/deselect assets in all intermediate buckets break;
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) { }
const bucket = assetStore.buckets[bucketIndex]; if (started) {
await assetStore.loadBucket(bucket.bucketDate); await assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.assets) { for (const asset of bucket.getAssets()) {
if (deselect) { if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset); assetInteraction.removeAssetFromMultiselectGroup(asset);
} else { } else {
@ -660,16 +516,22 @@
} }
} }
} }
}
// Update date group selection // Update date group selection
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) { started = false;
const bucket = assetStore.buckets[bucketIndex]; for (const bucket of assetStore.buckets) {
if (bucket === startBucket) {
started = true;
}
if (bucket === endBucket) {
break;
}
// Split bucket into date groups and check each group // Split bucket into date groups and check each group
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale); for (const dateGroup of bucket.dateGroups) {
for (const dateGroup of assetsGroupByDate) { const dateGroupTitle = dateGroup.groupTitle;
const dateGroupTitle = formatGroupTitle(dateGroup.date); if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) {
assetInteraction.addGroupToMultiselectGroup(dateGroupTitle); assetInteraction.addGroupToMultiselectGroup(dateGroupTitle);
} else { } else {
assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle); assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle);
@ -691,14 +553,16 @@
return; return;
} }
let start = assetStore.assets.findIndex((a) => a.id === startAsset.id); const assets = assetStore.getAssets();
let end = assetStore.assets.findIndex((a) => a.id === endAsset.id);
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
if (start > end) { if (start > end) {
[start, end] = [end, start]; [start, end] = [end, start];
} }
assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1)); assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
}; };
const onSelectStart = (e: Event) => { const onSelectStart = (e: Event) => {
@ -710,14 +574,14 @@
const focusNextAsset = async () => { const focusNextAsset = async () => {
if (assetInteraction.focussedAssetId === null) { if (assetInteraction.focussedAssetId === null) {
const firstAsset = assetStore.getFirstAsset(); const firstAsset = assetStore.getFirstAsset();
if (firstAsset !== null) { if (firstAsset) {
assetInteraction.focussedAssetId = firstAsset.id; assetInteraction.focussedAssetId = firstAsset.id;
} }
} else { } else {
const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId); const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
if (focussedAsset) { if (focussedAsset) {
const nextAsset = await assetStore.getNextAsset(focussedAsset); const nextAsset = await assetStore.getNextAsset(focussedAsset);
if (nextAsset !== null) { if (nextAsset) {
assetInteraction.focussedAssetId = nextAsset.id; assetInteraction.focussedAssetId = nextAsset.id;
} }
} }
@ -726,7 +590,7 @@
const focusPreviousAsset = async () => { const focusPreviousAsset = async () => {
if (assetInteraction.focussedAssetId !== null) { if (assetInteraction.focussedAssetId !== null) {
const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId); const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
if (focussedAsset) { if (focussedAsset) {
const previousAsset = await assetStore.getPreviousAsset(focussedAsset); const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
if (previousAsset) { if (previousAsset) {
@ -736,11 +600,8 @@
} }
}; };
onDestroy(() => {
assetStore.taskManager.removeAllTasksForComponent(componentId);
});
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0); let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
$effect(() => { $effect(() => {
@ -749,23 +610,6 @@
} }
}); });
$effect(() => {
if (element && isViewportOrigin()) {
const rect = element.getBoundingClientRect();
viewport.height = rect.height;
viewport.width = rect.width;
viewport.x = rect.x;
viewport.y = rect.y;
}
if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
safeViewport.height = viewport.height;
safeViewport.width = viewport.width;
safeViewport.x = viewport.x;
safeViewport.y = viewport.y;
updateViewport();
}
});
let shortcutList = $derived( let shortcutList = $derived(
(() => { (() => {
if ($isSearchEnabled || $showAssetViewer) { if ($isSearchEnabled || $showAssetViewer) {
@ -829,19 +673,34 @@
{#if showShortcuts} {#if showShortcuts}
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} /> <ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if} {/if}
{#if assetStore.buckets.length > 0} {#if assetStore.buckets.length > 0}
<Scrubber <Scrubber
invisible={showSkeleton} invisible={showSkeleton}
{assetStore} {assetStore}
height={safeViewport.height} height={assetStore.viewportHeight}
timelineTopOffset={topSectionHeight} timelineTopOffset={assetStore.topSectionHeight}
timelineBottomOffset={bottomSectionHeight} timelineBottomOffset={bottomSectionHeight}
{leadout} {leadout}
{scrubOverallPercent} {scrubOverallPercent}
{scrubBucketPercent} {scrubBucketPercent}
{scrubBucket} {scrubBucket}
{onScrub} {onScrub}
{stopScrub} onScrubKeyDown={(evt) => {
evt.preventDefault();
let amount = 50;
if (shiftKeyIsDown) {
amount = 500;
}
if (evt.key === 'ArrowUp') {
amount = -amount;
if (shiftKeyIsDown) {
element?.scrollBy({ top: amount, behavior: 'smooth' });
}
} else if (evt.key === 'ArrowDown') {
element?.scrollBy({ top: amount, behavior: 'smooth' });
}
}}
/> />
{/if} {/if}
@ -850,13 +709,23 @@
id="asset-grid" id="asset-grid"
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}" class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
tabindex="-1" tabindex="-1"
use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))} bind:clientHeight={assetStore.viewportHeight}
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
bind:this={element} bind:this={element}
onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
> >
<section <section
use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))} bind:this={timelineElement}
id="virtual-timeline"
class:invisible={showSkeleton} class:invisible={showSkeleton}
style:height={assetStore.timelineHeight + 'px'}
>
<section
use:resizeObserver={topSectionResizeObserver}
class:invisible={showSkeleton}
style:position="absolute"
style:left="0"
style:right="0"
> >
{@render children?.()} {@render children?.()}
{#if isEmpty} {#if isEmpty}
@ -865,75 +734,42 @@
{/if} {/if}
</section> </section>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:invisible={showSkeleton}
style:height={assetStore.timelineHeight + 'px'}
>
{#each assetStore.buckets as bucket (bucket.viewId)} {#each assetStore.buckets as bucket (bucket.viewId)}
{@const isPremeasure = preMeasure.includes(bucket)} {@const display = bucket.intersecting}
{@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure} {@const absoluteHeight = bucket.top}
{#if !bucket.isLoaded}
<div
style:height={bucket.bucketHeight + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} />
</div>
{:else if display}
<div <div
class="bucket" class="bucket"
style:overflow={bucket.measured ? 'visible' : 'clip'}
use:intersectionObserver={[
{
key: bucket.viewId,
onIntersect: () => handleIntersect(bucket),
onSeparate: () => handleSeparate(bucket),
top: BUCKET_INTERSECTION_ROOT_TOP,
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
root: element,
},
{
key: bucket.viewId + '.bucketintersection',
onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate),
top: '0px',
bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px',
left: '0px',
right: '0px',
},
]}
data-bucket-display={bucket.intersecting}
data-bucket-date={bucket.bucketDate}
style:height={bucket.bucketHeight + 'px'} style:height={bucket.bucketHeight + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
> >
{#if display && !bucket.measured}
<MeasureDateGroup
{bucket}
{assetStore}
onMeasured={() => (preMeasure = preMeasure.filter((b) => b !== bucket))}
></MeasureDateGroup>
{/if}
{#if !display || !bucket.measured}
<Skeleton height={bucket.bucketHeight + 'px'} title={`${bucket.bucketDateFormattted}`} />
{/if}
{#if display && bucket.measured}
<AssetDateGroup <AssetDateGroup
assetGridElement={element}
renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
{withStacked} {withStacked}
{showArchiveIcon} {showArchiveIcon}
{assetStore}
{assetInteraction} {assetInteraction}
{isSelectionMode} {isSelectionMode}
{singleSelect} {singleSelect}
{onScrollTarget}
{onAssetInGrid}
{bucket} {bucket}
viewport={safeViewport}
onSelect={({ title, assets }) => handleGroupSelect(title, assets)} onSelect={({ title, assets }) => handleGroupSelect(title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates} onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets} onSelectAssets={handleSelectAssets}
/> />
{/if}
</div> </div>
{/if}
{/each} {/each}
<div class="h-[60px]"></div> <!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> -->
</section> </section>
</section> </section>
@ -965,6 +801,9 @@
} }
.bucket { .bucket {
contain: layout size; contain: layout size paint;
transform-style: flat;
backface-visibility: hidden;
transform-origin: center center;
} }
</style> </style>

View File

@ -1,91 +0,0 @@
<script lang="ts" module>
const recentTimes: number[] = [];
// TODO: track average time to measure, and use this to populate TUNABLES.ASSETS_STORE.CHECK_INTERVAL_MS
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function adjustTunables(avg: number) {}
function addMeasure(time: number) {
recentTimes.push(time);
if (recentTimes.length > 10) {
recentTimes.shift();
}
const sum = recentTimes.reduce((acc: number, val: number) => {
return acc + val;
}, 0);
const avg = sum / recentTimes.length;
adjustTunables(avg);
}
</script>
<script lang="ts">
import { resizeObserver } from '$lib/actions/resize-observer';
import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets-store.svelte';
interface Props {
assetStore: AssetStore;
bucket: AssetBucket;
onMeasured: () => void;
}
let { assetStore, bucket, onMeasured }: Props = $props();
async function _measure(element: Element) {
try {
await bucket.complete;
const t1 = Date.now();
let heightPending = bucket.dateGroups.some((group) => !group.heightActual);
if (heightPending) {
const listener: BucketListener = (event) => {
const { type } = event;
if (type === 'height') {
const { bucket: changedBucket } = event;
if (changedBucket === bucket && type === 'height') {
heightPending = bucket.dateGroups.some((group) => !group.heightActual);
if (!heightPending) {
const height = element.getBoundingClientRect().height;
if (height !== 0) {
assetStore.updateBucket(bucket.bucketDate, { height, measured: true });
}
onMeasured();
assetStore.removeListener(listener);
const t2 = Date.now();
addMeasure((t2 - t1) / bucket.bucketCount);
}
}
}
};
assetStore.addListener(listener);
}
} catch {
// ignore if complete rejects (canceled load)
}
}
function measure(element: Element) {
void _measure(element);
}
</script>
<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure>
{#each bucket.dateGroups as dateGroup (dateGroup.date)}
<div id="date-group" data-date-group={dateGroup.date}>
<div use:resizeObserver={({ height }) => assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}>
<div
class="flex z-[100] sticky top-[-1px] pt-7 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'}
>
<span class="w-full truncate first-letter:capitalize">
{dateGroup.groupTitle}
</span>
</div>
<div
class="relative overflow-clip"
style:height={dateGroup.geometry!.containerHeight + 'px'}
style:width={dateGroup.geometry!.containerWidth + 'px'}
style:visibility="hidden"
></div>
</div>
</div>
{/each}
</section>

View File

@ -1,30 +1,28 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
title?: string | null; height: number;
height?: string | null; title: string;
} }
let { title = null, height = null }: Props = $props(); let { height = 0, title }: Props = $props();
</script> </script>
<div class="overflow-clip" style={`height: ${height}`}> <div class="overflow-clip" style:height={height + 'px'}>
{#if title}
<div <div
class="flex z-[100] sticky top-0 pt-7 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" class="flex z-[100] 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"
> >
<span class="w-full truncate first-letter:capitalize">{title}</span> {title}
</div> </div>
{/if} <div class="animate-pulse absolute w-full h-full" data-skeleton="true"></div>
<div id="skeleton" style={`height: ${height}`}></div>
</div> </div>
<style> <style>
#skeleton { [data-skeleton] {
background-image: url('/light_skeleton.png'); background-image: url('/light_skeleton.png');
background-repeat: repeat; background-repeat: repeat;
background-size: 235px, 235px; background-size: 235px, 235px;
} }
:global(.dark) #skeleton { :global(.dark) [data-skeleton] {
background-image: url('/dark_skeleton.png'); background-image: url('/dark_skeleton.png');
} }
@keyframes delayedVisibility { @keyframes delayedVisibility {
@ -32,8 +30,10 @@
visibility: visible; visibility: visible;
} }
} }
#skeleton { [data-skeleton] {
visibility: hidden; visibility: hidden;
animation: 0s linear 0.1s forwards delayedVisibility; animation:
0s linear 0.1s forwards delayedVisibility,
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
} }
</style> </style>

View File

@ -69,7 +69,7 @@
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent"> <div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
<div <div
id="asset-selection-app-bar" id="asset-selection-app-bar"
class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${ class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 my-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
forceDark && 'bg-immich-dark-gray text-white' forceDark && 'bg-immich-dark-gray text-white'
}`} }`}
> >

View File

@ -8,13 +8,11 @@
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets } from '$lib/utils/actions'; import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { calculateWidth } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto } from '@immich/sdk';
import justifiedLayout from 'justified-layout';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ShowShortcuts from '../show-shortcuts.svelte'; import ShowShortcuts from '../show-shortcuts.svelte';
@ -22,6 +20,8 @@
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { debounce } from 'lodash-es';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: AssetResponseDto[];
@ -53,11 +53,84 @@
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
let geometry: CommonJustifiedLayout | undefined = $state();
$effect(() => {
const _assets = assets;
updateSlidingWindow();
geometry = getJustifiedLayoutFromAssets(_assets, {
spacing: 2,
heightTolerance: 0.15,
rowHeight: 235,
rowWidth: Math.floor(viewport.width),
});
});
let assetLayouts = $derived.by(() => {
const assetLayout = [];
let containerHeight = 0;
let containerWidth = 0;
if (geometry) {
containerHeight = geometry.containerHeight;
containerWidth = geometry.containerWidth;
for (const [i, asset] of assets.entries()) {
const layout = {
asset,
top: geometry.getTop(i),
left: geometry.getLeft(i),
width: geometry.getWidth(i),
height: geometry.getHeight(i),
};
// 54 is the content height of the asset-selection-app-bar
const layoutTopWithOffset = layout.top + 54;
const layoutBottom = layoutTopWithOffset + layout.height;
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
assetLayout.push({ ...layout, display });
}
}
return {
assetLayout,
containerHeight,
containerWidth,
};
});
let showShortcuts = $state(false); let showShortcuts = $state(false);
let currentViewAssetIndex = 0; let currentViewAssetIndex = 0;
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: AssetResponseDto | null = $state(null); let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
let slidingWindow = $state({ top: 0, bottom: 0 });
const updateSlidingWindow = () => {
const v = $state.snapshot(viewport);
const top = document.scrollingElement?.scrollTop || 0;
const bottom = top + v.height;
const w = {
top,
bottom,
};
slidingWindow = w;
};
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
let lastIntersectedHeight = 0;
$effect(() => {
// notify we got to (near) the end of scroll
const scrollPercentage =
((slidingWindow.bottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0))) *
100;
if (scrollPercentage > 90) {
const intersectedHeight = geometry?.containerHeight || 0;
if (lastIntersectedHeight !== intersectedHeight) {
debouncedOnIntersected();
lastIntersectedHeight = intersectedHeight;
}
}
});
const viewAssetHandler = async (asset: AssetResponseDto) => { const viewAssetHandler = async (asset: AssetResponseDto) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id); currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
setAsset(assets[currentViewAssetIndex]); setAsset(assets[currentViewAssetIndex]);
@ -75,6 +148,7 @@
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') { if (event.key === 'Shift') {
event.preventDefault(); event.preventDefault();
shiftKeyIsDown = true; shiftKeyIsDown = true;
} }
}; };
@ -90,7 +164,7 @@
if (!asset) { if (!asset) {
return; return;
} }
const deselect = assetInteraction.selectedAssets.has(asset); const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets // Select/deselect already loaded assets
if (deselect) { if (deselect) {
@ -173,7 +247,7 @@
const toggleArchive = async () => { const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
if (ids) { if (ids) {
assets.filter((asset) => !ids.includes(asset.id)); assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets(); deselectAllAssets();
} }
}; };
@ -248,7 +322,7 @@
} }
}; };
const handleRandom = async (): Promise<AssetResponseDto | null> => { const handleRandom = async (): Promise<AssetResponseDto | undefined> => {
try { try {
let asset: AssetResponseDto | undefined; let asset: AssetResponseDto | undefined;
if (onRandom) { if (onRandom) {
@ -261,14 +335,14 @@
} }
if (!asset) { if (!asset) {
return null; return;
} }
await navigateToAsset(asset); await navigateToAsset(asset);
return asset; return asset;
} catch (error) { } catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset')); handleError(error, $t('errors.cannot_navigate_next_asset'));
return null; return;
} }
}; };
@ -335,26 +409,6 @@
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
let geometry = $derived(
(() => {
const justifiedLayoutResult = justifiedLayout(
assets.map((asset) => getAssetRatio(asset)),
{
boxSpacing: 2,
containerWidth: Math.floor(viewport.width),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
},
);
return {
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
};
})(),
);
$effect(() => { $effect(() => {
if (!lastAssetMouseEvent) { if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates(); assetInteraction.clearAssetSelectionCandidates();
@ -374,7 +428,13 @@
}); });
</script> </script>
<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} /> <svelte:window
onkeydown={onKeyDown}
onkeyup={onKeyUp}
onselectstart={onSelectStart}
use:shortcuts={shortcutList}
onscroll={() => updateSlidingWindow()}
/>
{#if isShowDeleteConfirmation} {#if isShowDeleteConfirmation}
<DeleteAssetDialog <DeleteAssetDialog
@ -389,12 +449,19 @@
{/if} {/if}
{#if assets.length > 0} {#if assets.length > 0}
<div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px "> <div
{#each assets as asset, i (i)} style:position="relative"
style:height={assetLayouts.containerHeight + 'px'}
style:width={assetLayouts.containerWidth - 1 + 'px'}
>
{#each assetLayouts.assetLayout as layout (layout.asset.id)}
{@const asset = layout.asset}
{#if layout.display}
<div <div
class="absolute" class="absolute"
style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i] style:overflow="clip"
.top}px; left: {geometry.boxes[i].left}px" style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
title={showAssetName ? asset.originalFileName : ''} title={showAssetName ? asset.originalFileName : ''}
> >
<Thumbnail <Thumbnail
@ -409,14 +476,13 @@
onSelect={(asset) => handleSelectAssets(asset)} onSelect={(asset) => handleSelectAssets(asset)}
onMouseEvent={() => assetMouseEventHandler(asset)} onMouseEvent={() => assetMouseEventHandler(asset)}
handleFocus={() => assetOnFocusHandler(asset)} handleFocus={() => assetOnFocusHandler(asset)}
onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
{showArchiveIcon} {showArchiveIcon}
{asset} {asset}
selected={assetInteraction.selectedAssets.has(asset)} selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
focussed={assetInteraction.isFocussedAsset(asset)} focussed={assetInteraction.isFocussedAsset(asset)}
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} thumbnailWidth={layout.width}
thumbnailWidth={geometry.boxes[i].width} thumbnailHeight={layout.height}
thumbnailHeight={geometry.boxes[i].height}
/> />
{#if showAssetName} {#if showAssetName}
<div <div
@ -426,6 +492,7 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets-store.svelte'; import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
import { DateTime } from 'luxon';
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util'; import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import { onMount } from 'svelte'; import { DateTime } from 'luxon';
import { isTimelineScrolling } from '$lib/stores/timeline.store';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
interface Props { interface Props {
@ -15,11 +13,12 @@
invisible?: boolean; invisible?: boolean;
scrubOverallPercent?: number; scrubOverallPercent?: number;
scrubBucketPercent?: number; scrubBucketPercent?: number;
scrubBucket?: { bucketDate: string | undefined } | undefined; scrubBucket?: { bucketDate: string | undefined };
leadout?: boolean; leadout?: boolean;
onScrub?: ScrubberListener | undefined; onScrub?: ScrubberListener;
startScrub?: ScrubberListener | undefined; onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
stopScrub?: ScrubberListener | undefined; startScrub?: ScrubberListener;
stopScrub?: ScrubberListener;
} }
let { let {
@ -27,25 +26,22 @@
timelineBottomOffset = 0, timelineBottomOffset = 0,
height = 0, height = 0,
assetStore, assetStore,
invisible = false,
scrubOverallPercent = 0, scrubOverallPercent = 0,
scrubBucketPercent = 0, scrubBucketPercent = 0,
scrubBucket = undefined, scrubBucket = undefined,
leadout = false, leadout = false,
onScrub = undefined, onScrub = undefined,
onScrubKeyDown = undefined,
startScrub = undefined, startScrub = undefined,
stopScrub = undefined, stopScrub = undefined,
}: Props = $props(); }: Props = $props();
let isHover = $state(false); let isHover = $state(false);
let isDragging = $state(false); let isDragging = $state(false);
let hoverLabel: string | undefined = $state();
let bucketDate: string | undefined;
let hoverY = $state(0); let hoverY = $state(0);
let clientY = 0; let clientY = 0;
let windowHeight = $state(0); let windowHeight = $state(0);
let scrollBar: HTMLElement | undefined = $state(); let scrollBar: HTMLElement | undefined = $state();
let segments: Segment[] = $state([]);
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2); const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2); const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
@ -87,28 +83,11 @@
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2; return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
} }
}; };
let scrollY = $state(0); let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
$effect(() => { let timelineFullHeight = $derived(assetStore.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
});
let timelineFullHeight = $derived(assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight)); let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight)); let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
const listener: BucketListener = (event) => {
const { type } = event;
if (type === 'viewport') {
segments = calculateSegments(assetStore.buckets);
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
}
};
onMount(() => {
assetStore.addListener(listener);
return () => assetStore.removeListener(listener);
});
type Segment = { type Segment = {
count: number; count: number;
height: number; height: number;
@ -119,7 +98,7 @@
hasDot: boolean; hasDot: boolean;
}; };
const calculateSegments = (buckets: AssetBucket[]) => { const calculateSegments = (buckets: LiteBucket[]) => {
let height = 0; let height = 0;
let dotHeight = 0; let dotHeight = 0;
@ -127,11 +106,10 @@
let previousLabeledSegment: Segment | undefined; let previousLabeledSegment: Segment | undefined;
for (const [i, bucket] of buckets.entries()) { for (const [i, bucket] of buckets.entries()) {
const scrollBarPercentage = const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
bucket.bucketHeight / (assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
const segment = { const segment = {
count: bucket.assets.length, count: bucket.assetCount,
height: toScrollY(scrollBarPercentage), height: toScrollY(scrollBarPercentage),
bucketDate: bucket.bucketDate, bucketDate: bucket.bucketDate,
date: fromLocalDateTime(bucket.bucketDate), date: fromLocalDateTime(bucket.bucketDate),
@ -161,14 +139,23 @@
segments.push(segment); segments.push(segment);
} }
hoverLabel = segments[0]?.dateFormatted;
return segments; return segments;
}; };
let activeSegment: HTMLElement | undefined = $state();
const updateLabel = (segment: HTMLElement) => { const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
hoverLabel = segment.dataset.label; const hoverLabel = $derived(activeSegment?.dataset.label);
bucketDate = segment.dataset.timeSegmentBucketDate; const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
}; const scrollHoverLabel = $derived.by(() => {
const y = scrollY;
let cur = 0;
for (const segment of segments) {
if (y <= cur + segment.height + relativeTopOffset) {
return segment.dateFormatted;
}
cur += segment.height;
}
return '';
});
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => { const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
const wasDragging = isDragging; const wasDragging = isDragging;
@ -189,7 +176,8 @@
const segment = elems.find(({ id }) => id === 'time-segment'); const segment = elems.find(({ id }) => id === 'time-segment');
let bucketPercentY = 0; let bucketPercentY = 0;
if (segment) { if (segment) {
updateLabel(segment as HTMLElement); activeSegment = segment as HTMLElement;
const sr = segment.getBoundingClientRect(); const sr = segment.getBoundingClientRect();
const sy = sr.y; const sy = sr.y;
const relativeY = clientY - sy; const relativeY = clientY - sy;
@ -197,9 +185,9 @@
} else { } else {
const leadin = elems.find(({ id }) => id === 'lead-in'); const leadin = elems.find(({ id }) => id === 'lead-in');
if (leadin) { if (leadin) {
updateLabel(leadin as HTMLElement); activeSegment = leadin as HTMLElement;
} else { } else {
bucketDate = undefined; activeSegment = undefined;
bucketPercentY = 0; bucketPercentY = 0;
} }
} }
@ -230,27 +218,34 @@
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
/> />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
transition:fly={{ x: 50, duration: 250 }} transition:fly={{ x: 50, duration: 250 }}
tabindex="-1"
role="scrollbar"
aria-controls="time-label"
aria-valuenow={scrollY + HOVER_DATE_HEIGHT}
aria-valuemax={toScrollY(100)}
aria-valuemin={toScrollY(0)}
id="immich-scrubbable-scrollbar" id="immich-scrubbable-scrollbar"
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize" class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
style:padding-top={HOVER_DATE_HEIGHT + 'px'} style:padding-top={HOVER_DATE_HEIGHT + 'px'}
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'} style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
class:invisible
style:width={isDragging ? '100vw' : '60px'} style:width={isDragging ? '100vw' : '60px'}
style:height={height + 'px'} style:height={height + 'px'}
style:background-color={isDragging ? 'transparent' : 'transparent'} style:background-color={isDragging ? 'transparent' : 'transparent'}
draggable="false"
bind:this={scrollBar} bind:this={scrollBar}
onmouseenter={() => (isHover = true)} onmouseenter={() => (isHover = true)}
onmouseleave={() => (isHover = false)} onmouseleave={() => (isHover = false)}
onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)}
> >
{#if hoverLabel && (isHover || isDragging)} {#if hoverLabel && (isHover || isDragging)}
<div <div
id="time-label" id="time-label"
class="truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg" class={[
{ 'border-b-2': isDragging },
{ 'rounded-bl-md': !isDragging },
'truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg',
]}
style:top="{hoverY + 2}px" style:top="{hoverY + 2}px"
> >
{hoverLabel} {hoverLabel}
@ -262,12 +257,12 @@
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
style:top="{scrollY + HOVER_DATE_HEIGHT}px" style:top="{scrollY + HOVER_DATE_HEIGHT}px"
> >
{#if $isTimelineScrolling && scrubBucket?.bucketDate} {#if assetStore.scrolling && scrollHoverLabel}
<p <p
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg" class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
> >
{assetStore.getBucketByDate(scrubBucket.bucketDate)?.bucketDateFormattted} {scrollHoverLabel}
</p> </p>
{/if} {/if}
</div> </div>

View File

@ -121,15 +121,14 @@
<Portal target="body"> <Portal target="body">
{#if showMessage} {#if showMessage}
<div <dialog
open
class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6" class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
onmouseover={() => (hoverMessage = true)} onmouseover={() => (hoverMessage = true)}
onmouseleave={() => (hoverMessage = false)} onmouseleave={() => (hoverMessage = false)}
onfocus={() => (hoverMessage = true)} onfocus={() => (hoverMessage = true)}
onblur={() => (hoverMessage = false)} onblur={() => (hoverMessage = false)}
role="dialog"
tabindex="0"
> >
<div class="flex justify-between place-items-center"> <div class="flex justify-between place-items-center">
<div class="h-10 w-10"> <div class="h-10 w-10">
@ -178,6 +177,12 @@
{$t('purchase_button_reminder')} {$t('purchase_button_reminder')}
</Button> </Button>
</div> </div>
</div> </dialog>
{/if} {/if}
</Portal> </Portal>
<style>
dialog {
margin: 0;
}
</style>

View File

@ -45,7 +45,7 @@
onclick={() => {}} onclick={() => {}}
/> />
</li> </li>
{#each pathSegments as segment, index (segment)} {#each pathSegments as segment, index (index)}
{@const isLastSegment = index === pathSegments.length - 1} {@const isLastSegment = index === pathSegments.length - 1}
<li <li
class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary" class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"

View File

@ -62,7 +62,7 @@
const onRandom = () => { const onRandom = () => {
if (assets.length <= 0) { if (assets.length <= 0) {
return Promise.resolve(null); return Promise.resolve(undefined);
} }
const index = Math.floor(Math.random() * assets.length); const index = Math.floor(Math.random() * assets.length);
const asset = assets[index]; const asset = assets[index];

View File

@ -358,15 +358,24 @@ export enum SettingInputFieldType {
COLOR = 'color', COLOR = 'color',
} }
export enum AlbumPageViewMode { export const AlbumPageViewMode = {
LINK_SHARING = 'link-sharing', LINK_SHARING: 'link-sharing',
SELECT_USERS = 'select-users', SELECT_USERS: 'select-users',
SELECT_THUMBNAIL = 'select-thumbnail', SELECT_THUMBNAIL: 'select-thumbnail',
SELECT_ASSETS = 'select-assets', SELECT_ASSETS: 'select-assets',
VIEW_USERS = 'view-users', VIEW_USERS: 'view-users',
VIEW = 'view', VIEW: 'view',
OPTIONS = 'options', OPTIONS: 'options',
} };
export type AlbumPageViewMode =
| typeof AlbumPageViewMode.LINK_SHARING
| typeof AlbumPageViewMode.SELECT_USERS
| typeof AlbumPageViewMode.SELECT_THUMBNAIL
| typeof AlbumPageViewMode.SELECT_ASSETS
| typeof AlbumPageViewMode.VIEW_USERS
| typeof AlbumPageViewMode.VIEW
| typeof AlbumPageViewMode.OPTIONS;
export enum PersonPageViewMode { export enum PersonPageViewMode {
VIEW_ASSETS = 'view-assets', VIEW_ASSETS = 'view-assets',

View File

@ -5,8 +5,14 @@ import { fromStore } from 'svelte/store';
export class AssetInteraction { export class AssetInteraction {
readonly selectedAssets = new SvelteSet<AssetResponseDto>(); readonly selectedAssets = new SvelteSet<AssetResponseDto>();
hasSelectedAsset(assetId: string) {
return [...this.selectedAssets.values()].some((asset) => asset.id === assetId);
}
readonly selectedGroup = new SvelteSet<string>(); readonly selectedGroup = new SvelteSet<string>();
assetSelectionCandidates = $state(new SvelteSet<AssetResponseDto>()); assetSelectionCandidates = $state(new SvelteSet<AssetResponseDto>());
hasSelectionCandidate(assetId: string) {
return [...this.assetSelectionCandidates.values()].some((asset) => asset.id === assetId);
}
assetSelectionStart = $state<AssetResponseDto | null>(null); assetSelectionStart = $state<AssetResponseDto | null>(null);
focussedAssetId = $state<string | null>(null); focussedAssetId = $state<string | null>(null);
@ -32,7 +38,10 @@ export class AssetInteraction {
} }
removeAssetFromMultiselectGroup(asset: AssetResponseDto) { removeAssetFromMultiselectGroup(asset: AssetResponseDto) {
this.selectedAssets.delete(asset); const selectedAsset = [...this.selectedAssets.values()].find((a) => a.id === asset.id);
if (selectedAsset) {
this.selectedAssets.delete(selectedAsset);
}
} }
addGroupToMultiselectGroup(group: string) { addGroupToMultiselectGroup(group: string) {

View File

@ -12,21 +12,25 @@ describe('AssetStore', () => {
describe('init', () => { describe('init', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = { const bucketAssets: Record<string, AssetResponseDto[]> = {
'2024-03-01T00:00:00.000Z': assetFactory.buildList(1), '2024-03-01T00:00:00.000Z': assetFactory
'2024-02-01T00:00:00.000Z': assetFactory.buildList(100), .buildList(1)
'2024-01-01T00:00:00.000Z': assetFactory.buildList(3), .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
'2024-02-01T00:00:00.000Z': assetFactory
.buildList(100)
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
'2024-01-01T00:00:00.000Z': assetFactory
.buildList(3)
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
}; };
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore({}); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([ sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' }, { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]); ]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
await assetStore.init();
await assetStore.updateViewport({ width: 1588, height: 1000 }); await assetStore.updateViewport({ width: 1588, height: 1000 });
}); });
@ -37,51 +41,57 @@ describe('AssetStore', () => {
}); });
it('calculates bucket height', () => { it('calculates bucket height', () => {
expect(assetStore.buckets).toEqual( const plainBuckets = assetStore.buckets.map((bucket) => ({
bucketDate: bucket.bucketDate,
bucketHeight: bucket.bucketHeight,
}));
expect(plainBuckets).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 286 }), expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 304 }),
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3811 }), expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4515.333_333_333_333 }),
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
]), ]),
); );
}); });
it('calculates timeline height', () => { it('calculates timeline height', () => {
expect(assetStore.timelineHeight).toBe(4383); expect(assetStore.timelineHeight).toBe(5105.333_333_333_333);
}); });
}); });
describe('loadBucket', () => { describe('loadBucket', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = { const bucketAssets: Record<string, AssetResponseDto[]> = {
'2024-01-03T00:00:00.000Z': assetFactory.buildList(1), '2024-01-03T00:00:00.000Z': assetFactory
'2024-01-01T00:00:00.000Z': assetFactory.buildList(3), .buildList(1)
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
'2024-01-01T00:00:00.000Z': assetFactory
.buildList(3)
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
}; };
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore({}); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([ sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-01-03T00:00:00.000Z' }, { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]); ]);
sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => { sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => {
// Allow request to be aborted
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
if (signal?.aborted) { if (signal?.aborted) {
throw new AbortError(); throw new AbortError();
} }
return bucketAssets[timeBucket]; return bucketAssets[timeBucket];
}); });
await assetStore.init(); await assetStore.updateViewport({ width: 1588, height: 0 });
await assetStore.updateViewport({ width: 0, height: 0 });
}); });
it('loads a bucket', async () => { it('loads a bucket', async () => {
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0); expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3); expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
}); });
it('ignores invalid buckets', async () => { it('ignores invalid buckets', async () => {
@ -90,15 +100,13 @@ describe('AssetStore', () => {
}); });
it('cancels bucket loading', async () => { it('cancels bucket loading', async () => {
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate(2024, 1)!;
const loadPromise = assetStore.loadBucket(bucket!.bucketDate); void assetStore.loadBucket(bucket!.bucketDate);
const abortSpy = vi.spyOn(bucket!.loader!.cancelToken!, 'abort');
const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort');
bucket?.cancel(); bucket?.cancel();
expect(abortSpy).toBeCalledTimes(1); expect(abortSpy).toBeCalledTimes(1);
await assetStore.loadBucket(bucket!.bucketDate);
await loadPromise; expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
}); });
it('prevents loading buckets multiple times', async () => { it('prevents loading buckets multiple times', async () => {
@ -113,15 +121,15 @@ describe('AssetStore', () => {
}); });
it('allows loading a canceled bucket', async () => { it('allows loading a canceled bucket', async () => {
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate(2024, 1)!;
const loadPromise = assetStore.loadBucket(bucket!.bucketDate); const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
bucket?.cancel(); bucket.cancel();
await loadPromise; await loadPromise;
expect(bucket?.assets.length).toEqual(0); expect(bucket?.getAssets().length).toEqual(0);
await assetStore.loadBucket(bucket!.bucketDate); await assetStore.loadBucket(bucket.bucketDate);
expect(bucket!.assets.length).toEqual(3); expect(bucket!.getAssets().length).toEqual(3);
}); });
}); });
@ -129,15 +137,15 @@ describe('AssetStore', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore({}); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([]); sdkMock.getTimeBuckets.mockResolvedValue([]);
await assetStore.init();
await assetStore.updateViewport({ width: 1588, height: 1000 }); await assetStore.updateViewport({ width: 1588, height: 1000 });
}); });
it('is empty initially', () => { it('is empty initially', () => {
expect(assetStore.buckets.length).toEqual(0); expect(assetStore.buckets.length).toEqual(0);
expect(assetStore.assets.length).toEqual(0); expect(assetStore.getAssets().length).toEqual(0);
}); });
it('adds assets to new bucket', () => { it('adds assets to new bucket', () => {
@ -148,10 +156,10 @@ describe('AssetStore', () => {
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
expect(assetStore.buckets.length).toEqual(1); expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.assets.length).toEqual(1); expect(assetStore.getAssets().length).toEqual(1);
expect(assetStore.buckets[0].assets.length).toEqual(1); expect(assetStore.buckets[0].getAssets().length).toEqual(1);
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z'); expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
expect(assetStore.assets[0].id).toEqual(asset.id); expect(assetStore.getAssets()[0].id).toEqual(asset.id);
}); });
it('adds assets to existing bucket', () => { it('adds assets to existing bucket', () => {
@ -163,8 +171,8 @@ describe('AssetStore', () => {
assetStore.addAssets([assetTwo]); assetStore.addAssets([assetTwo]);
expect(assetStore.buckets.length).toEqual(1); expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.assets.length).toEqual(2); expect(assetStore.getAssets().length).toEqual(2);
expect(assetStore.buckets[0].assets.length).toEqual(2); expect(assetStore.buckets[0].getAssets().length).toEqual(2);
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z'); expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
}); });
@ -183,12 +191,12 @@ describe('AssetStore', () => {
}); });
assetStore.addAssets([assetOne, assetTwo, assetThree]); assetStore.addAssets([assetOne, assetTwo, assetThree]);
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate(2024, 1);
expect(bucket).not.toBeNull(); expect(bucket).not.toBeNull();
expect(bucket?.assets.length).toEqual(3); expect(bucket?.getAssets().length).toEqual(3);
expect(bucket?.assets[0].id).toEqual(assetOne.id); expect(bucket?.getAssets()[0].id).toEqual(assetOne.id);
expect(bucket?.assets[1].id).toEqual(assetThree.id); expect(bucket?.getAssets()[1].id).toEqual(assetThree.id);
expect(bucket?.assets[2].id).toEqual(assetTwo.id); expect(bucket?.getAssets()[2].id).toEqual(assetTwo.id);
}); });
it('orders buckets by descending date', () => { it('orders buckets by descending date', () => {
@ -210,17 +218,18 @@ describe('AssetStore', () => {
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]); expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(assetStore.assets.length).toEqual(1); expect(assetStore.getAssets().length).toEqual(1);
}); });
// disabled due to the wasm Justified Layout import // disabled due to the wasm Justified Layout import
it.skip('ignores trashed assets when isTrashed is true', () => { it('ignores trashed assets when isTrashed is true', async () => {
const asset = assetFactory.build({ isTrashed: false }); const asset = assetFactory.build({ isTrashed: false });
const trashedAsset = assetFactory.build({ isTrashed: true }); const trashedAsset = assetFactory.build({ isTrashed: true });
const assetStore = new AssetStore({ isTrashed: true }); const assetStore = new AssetStore();
await assetStore.updateOptions({ isTrashed: true });
assetStore.addAssets([asset, trashedAsset]); assetStore.addAssets([asset, trashedAsset]);
expect(assetStore.assets).toEqual([trashedAsset]); expect(assetStore.getAssets()).toEqual([trashedAsset]);
}); });
}); });
@ -228,9 +237,9 @@ describe('AssetStore', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore({}); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([]); sdkMock.getTimeBuckets.mockResolvedValue([]);
await assetStore.init();
await assetStore.updateViewport({ width: 1588, height: 1000 }); await assetStore.updateViewport({ width: 1588, height: 1000 });
}); });
@ -238,7 +247,7 @@ describe('AssetStore', () => {
assetStore.updateAssets([assetFactory.build()]); assetStore.updateAssets([assetFactory.build()]);
expect(assetStore.buckets.length).toEqual(0); expect(assetStore.buckets.length).toEqual(0);
expect(assetStore.assets.length).toEqual(0); expect(assetStore.getAssets().length).toEqual(0);
}); });
it('updates an asset', () => { it('updates an asset', () => {
@ -246,26 +255,29 @@ describe('AssetStore', () => {
const updatedAsset = { ...asset, isFavorite: true }; const updatedAsset = { ...asset, isFavorite: true };
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
expect(assetStore.assets.length).toEqual(1); expect(assetStore.getAssets().length).toEqual(1);
expect(assetStore.assets[0].isFavorite).toEqual(false); expect(assetStore.getAssets()[0].isFavorite).toEqual(false);
assetStore.updateAssets([updatedAsset]); assetStore.updateAssets([updatedAsset]);
expect(assetStore.assets.length).toEqual(1); expect(assetStore.getAssets().length).toEqual(1);
expect(assetStore.assets[0].isFavorite).toEqual(true); expect(assetStore.getAssets()[0].isFavorite).toEqual(true);
}); });
it('replaces bucket date when asset date changes', () => { it('asset moves buckets when asset date changes', () => {
const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' }; const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
expect(assetStore.buckets.length).toEqual(1); expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).not.toBeNull(); expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(1);
assetStore.updateAssets([updatedAsset]); assetStore.updateAssets([updatedAsset]);
expect(assetStore.buckets.length).toEqual(1); expect(assetStore.buckets.length).toEqual(2);
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).toBeNull(); expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).not.toBeNull(); expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
expect(assetStore.getBucketByDate(2024, 3)).not.toBeUndefined();
expect(assetStore.getBucketByDate(2024, 3)?.getAssets().length).toEqual(1);
}); });
}); });
@ -273,9 +285,9 @@ describe('AssetStore', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore({}); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([]); sdkMock.getTimeBuckets.mockResolvedValue([]);
await assetStore.init();
await assetStore.updateViewport({ width: 1588, height: 1000 }); await assetStore.updateViewport({ width: 1588, height: 1000 });
}); });
@ -283,9 +295,9 @@ describe('AssetStore', () => {
assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' })); assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
assetStore.removeAssets(['', 'invalid', '4c7d9acc']); assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
expect(assetStore.assets.length).toEqual(2); expect(assetStore.getAssets().length).toEqual(2);
expect(assetStore.buckets.length).toEqual(1); expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.buckets[0].assets.length).toEqual(2); expect(assetStore.buckets[0].getAssets().length).toEqual(2);
}); });
it('removes asset from bucket', () => { it('removes asset from bucket', () => {
@ -293,18 +305,18 @@ describe('AssetStore', () => {
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetOne.id]); assetStore.removeAssets([assetOne.id]);
expect(assetStore.assets.length).toEqual(1); expect(assetStore.getAssets().length).toEqual(1);
expect(assetStore.buckets.length).toEqual(1); expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.buckets[0].assets.length).toEqual(1); expect(assetStore.buckets[0].getAssets().length).toEqual(1);
}); });
it('removes bucket when empty', () => { it('does not remove bucket when empty', () => {
const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
assetStore.addAssets(assets); assetStore.addAssets(assets);
assetStore.removeAssets(assets.map((asset) => asset.id)); assetStore.removeAssets(assets.map((asset) => asset.id));
expect(assetStore.assets.length).toEqual(0); expect(assetStore.getAssets().length).toEqual(0);
expect(assetStore.buckets.length).toEqual(0); expect(assetStore.buckets.length).toEqual(1);
}); });
}); });
@ -312,14 +324,13 @@ describe('AssetStore', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore({}); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([]); sdkMock.getTimeBuckets.mockResolvedValue([]);
await assetStore.init();
await assetStore.updateViewport({ width: 0, height: 0 }); await assetStore.updateViewport({ width: 0, height: 0 });
}); });
it('empty store returns null', () => { it('empty store returns null', () => {
expect(assetStore.getFirstAsset()).toBeNull(); expect(assetStore.getFirstAsset()).toBeUndefined();
}); });
it('populated store returns first asset', () => { it('populated store returns first asset', () => {
@ -339,13 +350,19 @@ describe('AssetStore', () => {
describe('getPreviousAsset', () => { describe('getPreviousAsset', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = { const bucketAssets: Record<string, AssetResponseDto[]> = {
'2024-03-01T00:00:00.000Z': assetFactory.buildList(1), '2024-03-01T00:00:00.000Z': assetFactory
'2024-02-01T00:00:00.000Z': assetFactory.buildList(6), .buildList(1)
'2024-01-01T00:00:00.000Z': assetFactory.buildList(3), .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
'2024-02-01T00:00:00.000Z': assetFactory
.buildList(6)
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
'2024-01-01T00:00:00.000Z': assetFactory
.buildList(3)
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
}; };
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore({}); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([ sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' }, { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
@ -353,38 +370,46 @@ describe('AssetStore', () => {
]); ]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
await assetStore.init(); await assetStore.updateViewport({ width: 1588, height: 1000 });
await assetStore.updateViewport({ width: 0, height: 0 });
}); });
it('returns null for invalid assetId', async () => { it('returns null for invalid assetId', async () => {
expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow(); expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeNull(); expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
}); });
it('returns previous assetId', async () => { it('returns previous assetId', async () => {
await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate(2024, 1);
expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]); const a = bucket!.getAssets()[0];
const b = bucket!.getAssets()[1];
const previous = await assetStore.getPreviousAsset(b);
expect(previous).toEqual(a);
}); });
it('returns previous assetId spanning multiple buckets', async () => { it('returns previous assetId spanning multiple buckets', async () => {
await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate(2024, 2);
const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z'); const previousBucket = assetStore.getBucketByDate(2024, 3);
expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]); const a = bucket!.getAssets()[0];
const b = previousBucket!.getAssets()[0];
const previous = await assetStore.getPreviousAsset(a);
expect(previous).toEqual(b);
}); });
it('loads previous bucket', async () => { it('loads previous bucket', async () => {
await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket'); const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate(2024, 2);
const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z'); const previousBucket = assetStore.getBucketByDate(2024, 3);
expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]); const a = bucket!.getAssets()[0];
const b = previousBucket!.getAssets()[0];
const previous = await assetStore.getPreviousAsset(a);
expect(previous).toEqual(b);
expect(loadBucketSpy).toBeCalledTimes(1); expect(loadBucketSpy).toBeCalledTimes(1);
}); });
@ -393,14 +418,14 @@ describe('AssetStore', () => {
await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
const [assetOne, assetTwo, assetThree] = assetStore.assets; const [assetOne, assetTwo, assetThree] = assetStore.getAssets();
assetStore.removeAssets([assetTwo.id]); assetStore.removeAssets([assetTwo.id]);
expect(await assetStore.getPreviousAsset(assetThree)).toEqual(assetOne); expect(await assetStore.getPreviousAsset(assetThree)).toEqual(assetOne);
}); });
it('returns null when no more assets', async () => { it('returns null when no more assets', async () => {
await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull(); expect(await assetStore.getPreviousAsset(assetStore.getAssets()[0])).toBeUndefined();
}); });
}); });
@ -408,15 +433,15 @@ describe('AssetStore', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore({}); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([]); sdkMock.getTimeBuckets.mockResolvedValue([]);
await assetStore.init();
await assetStore.updateViewport({ width: 0, height: 0 }); await assetStore.updateViewport({ width: 0, height: 0 });
}); });
it('returns null for invalid buckets', () => { it('returns null for invalid buckets', () => {
expect(assetStore.getBucketByDate('invalid')).toBeNull(); expect(assetStore.getBucketByDate(-1, -1)).toBeUndefined();
expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).toBeNull(); expect(assetStore.getBucketByDate(2024, 3)).toBeUndefined();
}); });
it('returns the bucket index', () => { it('returns the bucket index', () => {
@ -424,8 +449,8 @@ describe('AssetStore', () => {
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)).toEqual(0); expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(1); expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
}); });
it('ignores removed buckets', () => { it('ignores removed buckets', () => {
@ -434,7 +459,7 @@ describe('AssetStore', () => {
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetTwo.id]); assetStore.removeAssets([assetTwo.id]);
expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(0); expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
}); });
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
export const isTimelineScrolling = writable(false);

View File

@ -1,465 +0,0 @@
import type { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
import { generateId } from '$lib/utils/generate-id';
import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';
import { type DateGroup } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { type AssetResponseDto } from '@immich/sdk';
import { clamp } from 'lodash-es';
type Task = () => void;
class InternalTaskManager {
assetStore: AssetStore;
componentTasks = new Map<string, Set<string>>();
priorityQueue = new KeyedPriorityQueue<string, Task>();
idleQueue = new Map<string, Task>();
taskCleaners = new Map<string, Task>();
queueTimer: ReturnType<typeof setTimeout> | undefined;
lastIdle: number | undefined;
constructor(assetStore: AssetStore) {
this.assetStore = assetStore;
}
destroy() {
this.componentTasks.clear();
this.priorityQueue.clear();
this.idleQueue.clear();
this.taskCleaners.clear();
clearTimeout(this.queueTimer);
if (this.lastIdle) {
cancelIdleCB(this.lastIdle);
}
}
getOrCreateComponentTasks(componentId: string) {
let componentTaskSet = this.componentTasks.get(componentId);
if (!componentTaskSet) {
componentTaskSet = new Set<string>();
this.componentTasks.set(componentId, componentTaskSet);
}
return componentTaskSet;
}
deleteFromComponentTasks(componentId: string, taskId: string) {
if (this.componentTasks.has(componentId)) {
const componentTaskSet = this.componentTasks.get(componentId);
componentTaskSet?.delete(taskId);
if (componentTaskSet?.size === 0) {
this.componentTasks.delete(componentId);
}
}
}
drainIntersectedQueue() {
let count = 0;
for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) {
t.value();
if (this.taskCleaners.has(t.key)) {
this.taskCleaners.get(t.key)!();
this.taskCleaners.delete(t.key);
}
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS);
break;
}
}
}
scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) {
clearTimeout(this.queueTimer);
this.queueTimer = setTimeout(() => {
const delta = Date.now() - this.assetStore.lastScrollTime;
if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
let amount = clamp(
1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR),
1,
TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2,
);
const nextDelay = clamp(
amount > 1
? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR)
: TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS,
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY,
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY,
);
while (amount > 0) {
this.priorityQueue.shift()?.value();
amount--;
}
if (this.priorityQueue.length > 0) {
this.scheduleDrainIntersectedQueue(nextDelay);
}
} else {
this.drainIntersectedQueue();
}
}, delay);
}
removeAllTasksForComponent(componentId: string) {
if (this.componentTasks.has(componentId)) {
const tasksIds = this.componentTasks.get(componentId) || [];
for (const taskId of tasksIds) {
this.priorityQueue.remove(taskId);
this.idleQueue.delete(taskId);
if (this.taskCleaners.has(taskId)) {
const cleanup = this.taskCleaners.get(taskId);
this.taskCleaners.delete(taskId);
cleanup!();
}
}
}
this.componentTasks.delete(componentId);
}
queueScrollSensitiveTask({
task,
cleanup,
componentId,
priority = 10,
taskId = generateId(),
}: {
task: Task;
cleanup?: Task;
componentId: string;
priority?: number;
taskId?: string;
}) {
this.priorityQueue.push(taskId, task, priority);
if (cleanup) {
this.taskCleaners.set(taskId, cleanup);
}
this.getOrCreateComponentTasks(componentId).add(taskId);
const lastTime = this.assetStore.lastScrollTime;
const delta = Date.now() - lastTime;
if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
this.scheduleDrainIntersectedQueue();
} else {
// flush the queue early
clearTimeout(this.queueTimer);
this.drainIntersectedQueue();
}
}
scheduleDrainSeparatedQueue() {
if (this.lastIdle) {
cancelIdleCB(this.lastIdle);
}
this.lastIdle = idleCB(
() => {
let count = 0;
let entry = this.idleQueue.entries().next().value;
while (entry) {
const [taskId, task] = entry;
this.idleQueue.delete(taskId);
task();
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
break;
}
entry = this.idleQueue.entries().next().value;
}
if (this.idleQueue.size > 0) {
this.scheduleDrainSeparatedQueue();
}
},
{ timeout: 1000 },
);
}
queueSeparateTask({
task,
cleanup,
componentId,
taskId,
}: {
task: Task;
cleanup: Task;
componentId: string;
taskId: string;
}) {
this.idleQueue.set(taskId, task);
this.taskCleaners.set(taskId, cleanup);
this.getOrCreateComponentTasks(componentId).add(taskId);
this.scheduleDrainSeparatedQueue();
}
removeIntersectedTask(taskId: string) {
const removed = this.priorityQueue.remove(taskId);
if (this.taskCleaners.has(taskId)) {
const cleanup = this.taskCleaners.get(taskId);
this.taskCleaners.delete(taskId);
cleanup!();
}
return removed;
}
removeSeparateTask(taskId: string) {
const removed = this.idleQueue.delete(taskId);
if (this.taskCleaners.has(taskId)) {
const cleanup = this.taskCleaners.get(taskId);
this.taskCleaners.delete(taskId);
cleanup!();
}
return removed;
}
}
export class AssetGridTaskManager {
private internalManager: InternalTaskManager;
constructor(assetStore: AssetStore) {
this.internalManager = new InternalTaskManager(assetStore);
}
tasks: Map<AssetBucket, BucketTask> = new Map();
queueScrollSensitiveTask({
task,
cleanup,
componentId,
priority = 10,
taskId = generateId(),
}: {
task: Task;
cleanup?: Task;
componentId: string;
priority?: number;
taskId?: string;
}) {
return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId });
}
removeAllTasksForComponent(componentId: string) {
return this.internalManager.removeAllTasksForComponent(componentId);
}
destroy() {
return this.internalManager.destroy();
}
private getOrCreateBucketTask(bucket: AssetBucket) {
let bucketTask = this.tasks.get(bucket);
if (!bucketTask) {
bucketTask = this.createBucketTask(bucket);
}
return bucketTask;
}
private createBucketTask(bucket: AssetBucket) {
const bucketTask = new BucketTask(this.internalManager, this, bucket);
this.tasks.set(bucket, bucketTask);
return bucketTask;
}
intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) {
const bucketTask = this.getOrCreateBucketTask(bucket);
bucketTask.scheduleIntersected(componentId, task);
}
separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) {
const bucketTask = this.getOrCreateBucketTask(bucket);
bucketTask.scheduleSeparated(componentId, separated);
}
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
}
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
bucketTask.separatedDateGroup(componentId, dateGroup, separated);
}
intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
}
separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.separatedThumbnail(componentId, asset, separated);
}
}
class IntersectionTask {
internalTaskManager: InternalTaskManager;
separatedKey;
intersectedKey;
priority;
intersected: Task | undefined;
separated: Task | undefined;
constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
this.internalTaskManager = internalTaskManager;
this.separatedKey = keyPrefix + ':s:' + key;
this.intersectedKey = keyPrefix + ':i:' + key;
this.priority = priority;
}
trackIntersectedTask(componentId: string, task: Task) {
const execTask = () => {
if (this.separated) {
return;
}
task?.();
};
this.intersected = execTask;
const cleanup = () => {
this.intersected = undefined;
this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey);
};
return { task: execTask, cleanup };
}
trackSeparatedTask(componentId: string, task: Task) {
const execTask = () => {
if (this.intersected) {
return;
}
task?.();
};
this.separated = execTask;
const cleanup = () => {
this.separated = undefined;
this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey);
};
return { task: execTask, cleanup };
}
removePendingSeparated() {
if (this.separated) {
this.internalTaskManager.removeSeparateTask(this.separatedKey);
}
}
removePendingIntersected() {
if (this.intersected) {
this.internalTaskManager.removeIntersectedTask(this.intersectedKey);
}
}
scheduleIntersected(componentId: string, intersected: Task) {
this.removePendingSeparated();
if (this.intersected) {
return;
}
const { task, cleanup } = this.trackIntersectedTask(componentId, intersected);
this.internalTaskManager.queueScrollSensitiveTask({
task,
cleanup,
componentId,
priority: this.priority,
taskId: this.intersectedKey,
});
}
scheduleSeparated(componentId: string, separated: Task) {
this.removePendingIntersected();
if (this.separated) {
return;
}
const { task, cleanup } = this.trackSeparatedTask(componentId, separated);
this.internalTaskManager.queueSeparateTask({
task,
cleanup,
componentId,
taskId: this.separatedKey,
});
}
}
class BucketTask extends IntersectionTask {
assetBucket: AssetBucket;
assetGridTaskManager: AssetGridTaskManager;
// indexed by dateGroup's date
dateTasks: Map<DateGroup, DateGroupTask> = new Map();
constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) {
super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY);
this.assetBucket = assetBucket;
this.assetGridTaskManager = parent;
}
getOrCreateDateGroupTask(dateGroup: DateGroup) {
let dateGroupTask = this.dateTasks.get(dateGroup);
if (!dateGroupTask) {
dateGroupTask = this.createDateGroupTask(dateGroup);
}
return dateGroupTask;
}
createDateGroupTask(dateGroup: DateGroup) {
const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup);
this.dateTasks.set(dateGroup, dateGroupTask);
return dateGroupTask;
}
removePendingSeparated() {
super.removePendingSeparated();
for (const dateGroupTask of this.dateTasks.values()) {
dateGroupTask.removePendingSeparated();
}
}
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.scheduleIntersected(componentId, intersected);
}
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.scheduleSeparated(componentId, separated);
}
}
class DateGroupTask extends IntersectionTask {
dateGroup: DateGroup;
bucketTask: BucketTask;
// indexed by thumbnail's asset
thumbnailTasks: Map<AssetResponseDto, ThumbnailTask> = new Map();
constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) {
super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY);
this.dateGroup = dateGroup;
this.bucketTask = parent;
}
removePendingSeparated() {
super.removePendingSeparated();
for (const thumbnailTask of this.thumbnailTasks.values()) {
thumbnailTask.removePendingSeparated();
}
}
getOrCreateThumbnailTask(asset: AssetResponseDto) {
let thumbnailTask = this.thumbnailTasks.get(asset);
if (!thumbnailTask) {
thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset);
this.thumbnailTasks.set(asset, thumbnailTask);
}
return thumbnailTask;
}
intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) {
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
thumbnailTask.scheduleIntersected(componentId, intersected);
}
separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) {
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
thumbnailTask.scheduleSeparated(componentId, separated);
}
}
class ThumbnailTask extends IntersectionTask {
asset: AssetResponseDto;
dateGroupTask: DateGroupTask;
constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) {
super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY);
this.asset = asset;
this.dateGroupTask = parent;
}
}

View File

@ -476,7 +476,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
if (!get(isSelectingAllAssets)) { if (!get(isSelectingAllAssets)) {
break; // Cancelled break; // Cancelled
} }
assetInteraction.selectAssets(bucket.assets); assetInteraction.selectAssets(bucket.getAssets().map((a) => $state.snapshot(a)));
// We use setTimeout to allow the UI to update. Otherwise, this may // We use setTimeout to allow the UI to update. Otherwise, this may
// cause a long delay between the start of 'select all' and the // cause a long delay between the start of 'select all' and the

View File

@ -0,0 +1,135 @@
export class CancellableTask {
cancelToken: AbortController | null = null;
cancellable: boolean = true;
/**
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
*/
complete!: Promise<unknown>;
executed: boolean = false;
private loadedSignal: (() => void) | undefined;
private canceledSignal: (() => void) | undefined;
constructor(
private loadedCallback?: () => void,
private canceledCallback?: () => void,
private errorCallback?: (error: unknown) => void,
) {
this.complete = new Promise<void>((resolve, reject) => {
this.loadedSignal = resolve;
this.canceledSignal = reject;
}).catch(
() =>
// if no-one waits on complete its rejected a uncaught rejection message is logged.
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
void 0,
);
}
get loading() {
return !!this.cancelToken;
}
async waitUntilCompletion() {
if (this.executed) {
return 'DONE';
}
// if there is a cancel token, task is currently executing, so wait on the promise. If it
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
// in either case, we wait on the promise.
await this.complete;
return 'WAITED';
}
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
if (this.executed) {
return 'DONE';
}
// if promise is pending, wait on previous request instead.
if (this.cancelToken) {
// if promise is pending, and preventCancel is requested,
// do not allow transition from prevent cancel to allow cancel.
if (this.cancellable && !cancellable) {
this.cancellable = cancellable;
}
await this.complete;
return 'WAITED';
}
this.cancellable = cancellable;
const cancelToken = (this.cancelToken = new AbortController());
try {
await f(cancelToken.signal);
this.#transitionToExecuted();
return 'LOADED';
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).name === 'AbortError') {
// abort error is not treated as an error, but as a cancelation.
return 'CANCELED';
}
this.#transitionToErrored(error);
return 'ERRORED';
} finally {
this.cancelToken = null;
}
}
private init() {
this.cancelToken = null;
this.executed = false;
// create a promise, and store its resolve/reject callbacks. The loadedSignal callback
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
// callback will be called if the bucket is canceled before it was loaded, rejecting the
// promise.
this.complete = new Promise<void>((resolve, reject) => {
this.loadedSignal = resolve;
this.canceledSignal = reject;
}).catch(
() =>
// if no-one waits on complete its rejected a uncaught rejection message is logged.
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
void 0,
);
}
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
async reset() {
this.#transitionToCancelled();
if (this.cancelToken) {
await this.waitUntilCompletion();
}
this.init();
}
cancel() {
this.#transitionToCancelled();
}
#transitionToCancelled() {
if (this.executed) {
return;
}
if (!this.cancellable) {
return;
}
this.cancelToken?.abort();
this.canceledSignal?.();
this.init();
this.canceledCallback?.();
}
#transitionToExecuted() {
this.executed = true;
this.loadedSignal?.();
this.loadedCallback?.();
}
#transitionToErrored(error: unknown) {
this.cancelToken = null;
this.canceledSignal?.();
this.init();
this.errorCallback?.(error);
}
}

View File

@ -1,22 +0,0 @@
interface RequestIdleCallback {
didTimeout?: boolean;
timeRemaining?(): DOMHighResTimeStamp;
}
interface RequestIdleCallbackOptions {
timeout?: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) {
const start = Date.now();
return setTimeout(() => {
cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) });
}, 100);
}
function fake_cancelIdleCallback(id: number) {
return clearTimeout(id);
}
export const idleCB = globalThis.requestIdleCallback || fake_requestIdleCallback;
export const cancelIdleCB = globalThis.cancelIdleCallback || fake_cancelIdleCallback;

View File

@ -1,50 +0,0 @@
export class KeyedPriorityQueue<K, T> {
private items: { key: K; value: T; priority: number }[] = [];
private set: Set<K> = new Set();
clear() {
this.items = [];
this.set.clear();
}
remove(key: K) {
const removed = this.set.delete(key);
if (removed) {
const idx = this.items.findIndex((i) => i.key === key);
if (idx !== -1) {
this.items.splice(idx, 1);
}
}
return removed;
}
push(key: K, value: T, priority: number) {
if (this.set.has(key)) {
return this.length;
}
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].priority > priority) {
this.set.add(key);
this.items.splice(i, 0, { key, value, priority });
return this.length;
}
}
this.set.add(key);
return this.items.push({ key, value, priority });
}
shift() {
let item = this.items.shift();
while (item) {
if (this.set.has(item.key)) {
this.set.delete(item.key);
return item;
}
item = this.items.shift();
}
}
get length() {
return this.set.size;
}
}

View File

@ -49,18 +49,21 @@ export function getJustifiedLayoutFromAssets(
type Geometry = ReturnType<typeof createJustifiedLayout>; type Geometry = ReturnType<typeof createJustifiedLayout>;
class Adapter { class Adapter {
result; result;
width;
constructor(result: Geometry) { constructor(result: Geometry) {
this.result = result; this.result = result;
this.width = 0;
for (const box of this.result.boxes) {
if (box.top < 100) {
this.width = box.left + box.width;
} else {
break;
}
}
} }
get containerWidth() { get containerWidth() {
let width = 0; return this.width;
for (const box of this.result.boxes) {
if (box.top < 100) {
width = box.left + box.width;
}
}
return width;
} }
get containerHeight() { get containerHeight() {
@ -84,12 +87,6 @@ class Adapter {
} }
} }
export const emptyGeometry = new Adapter({
containerHeight: 0,
widowCount: 0,
boxes: [],
});
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) { export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) {
const adapter = { const adapter = {
targetRowHeight: options.rowHeight, targetRowHeight: options.rowHeight,
@ -104,3 +101,26 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou
); );
return new Adapter(result); return new Adapter(result);
} }
export const emptyGeometry = () =>
new Adapter({
containerHeight: 0,
widowCount: 0,
boxes: [],
});
export type CommonPosition = {
top: number;
left: number;
width: number;
height: number;
};
export function getPosition(geometry: CommonJustifiedLayout, boxIdx: number): CommonPosition {
const top = geometry.getTop(boxIdx);
const left = geometry.getLeft(boxIdx);
const width = geometry.getWidth(boxIdx);
const height = geometry.getHeight(boxIdx);
return { top, left, width, height };
}

View File

@ -1,21 +0,0 @@
export class PriorityQueue<T> {
private items: { value: T; priority: number }[] = [];
push(value: T, priority: number) {
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].priority > priority) {
this.items.splice(i, 0, { value, priority });
return this.length;
}
}
return this.items.push({ value, priority });
}
shift() {
return this.items.shift();
}
get length() {
return this.items.length;
}
}

View File

@ -1,21 +1,24 @@
import type { AssetBucket } from '$lib/stores/assets-store.svelte'; import type { AssetBucket } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { emptyGeometry, type CommonJustifiedLayout } from '$lib/utils/layout-utils'; import { type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { groupBy, memoize, sortBy } from 'lodash-es'; import { memoize } from 'lodash-es';
import { DateTime, type LocaleOptions } from 'luxon'; import { DateTime, type LocaleOptions } from 'luxon';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
export type DateGroup = { export type DateGroup = {
bucket: AssetBucket;
index: number;
row: number;
col: number;
date: DateTime; date: DateTime;
groupTitle: string; groupTitle: string;
assets: AssetResponseDto[]; assets: AssetResponseDto[];
assetsIntersecting: boolean[];
height: number; height: number;
heightActual: boolean;
intersecting: boolean; intersecting: boolean;
geometry: CommonJustifiedLayout; geometry: CommonJustifiedLayout;
bucket: AssetBucket;
}; };
export type ScrubberListener = ( export type ScrubberListener = (
bucketDate: string | undefined, bucketDate: string | undefined,
@ -40,6 +43,31 @@ export const fromLocalDateTime = (localDateTime: string) =>
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) => export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
DateTime.fromISO(dateTimeOriginal, { zone: timeZone }); DateTime.fromISO(dateTimeOriginal, { zone: timeZone });
export type LayoutBox = {
aspectRatio: number;
top: number;
width: number;
height: number;
left: number;
forcedAspectRatio?: boolean;
};
export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
let offset = 0;
while (element.offsetParent && element !== stop) {
offset += element.offsetTop;
element = element.offsetParent as HTMLElement;
}
return offset;
}
export const groupDateFormat: Intl.DateTimeFormatOptions = {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
};
export function formatGroupTitle(_date: DateTime): string { export function formatGroupTitle(_date: DateTime): string {
if (!_date.isValid) { if (!_date.isValid) {
return _date.toString(); return _date.toString();
@ -73,56 +101,7 @@ export function formatGroupTitle(_date: DateTime): string {
return getDateLocaleString(date); return getDateLocaleString(date);
} }
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
const formatDateGroupTitle = memoize(formatGroupTitle); export const formatDateGroupTitle = memoize(formatGroupTitle);
export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
const grouped = groupBy(bucket.assets, (asset) =>
getDateLocaleString(fromLocalDateTime(asset.localDateTime), { locale }),
);
const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0]));
return sorted.map((group) => {
const date = fromLocalDateTime(group[0].localDateTime).startOf('day');
return {
date,
groupTitle: formatDateGroupTitle(date),
assets: group,
height: 0,
heightActual: false,
intersecting: false,
geometry: emptyGeometry,
bucket,
};
});
}
export type LayoutBox = {
aspectRatio: number;
top: number;
width: number;
height: number;
left: number;
forcedAspectRatio?: boolean;
};
export function calculateWidth(boxes: LayoutBox[]): number {
let width = 0;
for (const box of boxes) {
if (box.top < 100) {
width = box.left + box.width;
}
}
return width;
}
export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
let offset = 0;
while (element.offsetParent && element !== stop) {
offset += element.offsetTop;
element = element.offsetParent as HTMLElement;
}
return offset;
}

View File

@ -10,56 +10,17 @@ function getNumber(string: string | null, fallback: number) {
} }
return Number.parseInt(string); return Number.parseInt(string);
} }
function getFloat(string: string | null, fallback: number) {
if (string === null) {
return fallback;
}
return Number.parseFloat(string);
}
export const TUNABLES = { export const TUNABLES = {
LAYOUT: { LAYOUT: {
WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false), WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false),
}, },
SCROLL_TASK_QUEUE: { TIMELINE: {
TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25), INTERSECTION_EXPAND_TOP: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5), INTERSECTION_EXPAND_BOTTOM: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500),
TRICKLE_ACCELERATED_MIN_DELAY: getNumber(
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'),
8,
),
TRICKLE_ACCELERATED_MAX_DELAY: getNumber(
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'),
2000,
),
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15),
DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16),
MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200),
CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16),
},
INTERSECTION_OBSERVER_QUEUE: {
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15),
THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16),
THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true),
}, },
ASSET_GRID: { ASSET_GRID: {
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false), NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
}, },
BUCKET: {
PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2),
INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%',
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%',
},
DATEGROUP: {
PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4),
INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false),
INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%',
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%',
},
THUMBNAIL: {
PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8),
INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%',
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%',
},
IMAGE_THUMBNAIL: { IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150), THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150),
}, },

View File

@ -100,7 +100,7 @@
let oldAt: AssetGridRouteSearchParams | null | undefined = $state(); let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
let backUrl: string = $state(AppRoute.ALBUMS); let backUrl: string = $state(AppRoute.ALBUMS);
let viewMode = $state(AlbumPageViewMode.VIEW); let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
let isCreatingSharedAlbum = $state(false); let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false); let isShowActivity = $state(false);
let isLiked: ActivityResponseDto | null = $state(null); let isLiked: ActivityResponseDto | null = $state(null);
@ -203,7 +203,9 @@
const handleStartSlideshow = async () => { const handleStartSlideshow = async () => {
const asset = const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle ? await assetStore.getRandomAsset() : assetStore.assets[0]; $slideshowNavigation === SlideshowNavigation.Shuffle
? await assetStore.getRandomAsset()
: assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset;
if (asset) { if (asset) {
setAsset(asset); setAsset(asset);
$slideshowState = SlideshowState.PlaySlideshow; $slideshowState = SlideshowState.PlaySlideshow;
@ -211,6 +213,7 @@
}; };
const handleEscape = async () => { const handleEscape = async () => {
assetStore.suspendTransitions = true;
if (viewMode === AlbumPageViewMode.SELECT_USERS) { if (viewMode === AlbumPageViewMode.SELECT_USERS) {
viewMode = AlbumPageViewMode.VIEW; viewMode = AlbumPageViewMode.VIEW;
return; return;
@ -270,11 +273,8 @@
}; };
const setModeToView = async () => { const setModeToView = async () => {
assetStore.suspendTransitions = true;
viewMode = AlbumPageViewMode.VIEW; viewMode = AlbumPageViewMode.VIEW;
assetStore.destroy();
assetStore = new AssetStore({ albumId, order: albumOrder });
timelineStore.destroy();
timelineStore = new AssetStore({ isArchived: false }, albumId);
await navigate( await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } }, { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } },
{ replaceState: true, forceNavigate: true }, { replaceState: true, forceNavigate: true },
@ -394,14 +394,8 @@
} }
}); });
onDestroy(() => {
assetStore.destroy();
timelineStore.destroy();
});
let album = $state(data.album); let album = $state(data.album);
let albumId = $derived(album.id); let albumId = $derived(album.id);
let albumKey = $derived(`${albumId}_${albumOrder}`);
$effect(() => { $effect(() => {
if (!album.isActivityEnabled && $numberOfComments === 0) { if (!album.isActivityEnabled && $numberOfComments === 0) {
@ -409,8 +403,18 @@
} }
}); });
let assetStore = $derived(new AssetStore({ albumId, order: albumOrder })); let assetStore = new AssetStore();
let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId)); $effect(() => {
if (viewMode === AlbumPageViewMode.VIEW) {
void assetStore.updateOptions({ albumId, order: albumOrder });
} else if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId });
}
});
onDestroy(() => assetStore.destroy());
// let timelineStore = new AssetStore();
// $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }));
// onDestroy(() => timelineStore.destroy());
let isOwned = $derived($user.id == album.ownerId); let isOwned = $derived($user.id == album.ownerId);
@ -429,6 +433,22 @@
handlePromiseError(getNumberOfComments()); handlePromiseError(getNumberOfComments());
} }
}); });
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
const isSelectionMode = $derived(
viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL,
);
const singleSelect = $derived(
viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL,
);
const showArchiveIcon = $derived(viewMode !== AlbumPageViewMode.SELECT_ASSETS);
const onSelect = ({ id }: { id: string }) => {
if (viewMode !== AlbumPageViewMode.SELECT_ASSETS) {
void handleUpdateThumbnail(id);
}
};
const currentAssetIntersection = $derived(
viewMode === AlbumPageViewMode.SELECT_ASSETS ? timelineInteraction : assetInteraction,
);
</script> </script>
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}> <div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
@ -445,7 +465,14 @@
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
{#if assetInteraction.isAllUserOwned} {#if assetInteraction.isAllUserOwned}
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} /> <FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
assetStore.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction>
{/if} {/if}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{album.albumName}.zip" /> <DownloadAction menuItem filename="{album.albumName}.zip" />
@ -482,6 +509,7 @@
<CircleIconButton <CircleIconButton
title={$t('add_photos')} title={$t('add_photos')}
onclick={async () => { onclick={async () => {
assetStore.suspendTransitions = true;
viewMode = AlbumPageViewMode.SELECT_ASSETS; viewMode = AlbumPageViewMode.SELECT_ASSETS;
oldAt = { at: $gridScrollTarget?.at }; oldAt = { at: $gridScrollTarget?.at };
await navigate( await navigate(
@ -576,28 +604,19 @@
{/if} {/if}
<main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"> <main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
<!-- Use key because AssetGrid can't deal with changing stores -->
{#key albumKey}
{#if viewMode === AlbumPageViewMode.SELECT_ASSETS}
<AssetGrid <AssetGrid
enableRouting={false} enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
assetStore={timelineStore}
assetInteraction={timelineInteraction}
isSelectionMode={true}
/>
{:else}
<AssetGrid
enableRouting={true}
{album} {album}
{assetStore} {assetStore}
{assetInteraction} assetInteraction={currentAssetIntersection}
isShared={album.albumUsers.length > 0} {isShared}
isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} {isSelectionMode}
singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} {singleSelect}
showArchiveIcon {showArchiveIcon}
onSelect={({ id }) => handleUpdateThumbnail(id)} {onSelect}
onEscape={handleEscape} onEscape={handleEscape}
> >
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
{#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL} {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL}
<!-- ALBUM TITLE --> <!-- ALBUM TITLE -->
<section class="pt-8 md:pt-24"> <section class="pt-8 md:pt-24">
@ -682,8 +701,8 @@
</div> </div>
</section> </section>
{/if} {/if}
</AssetGrid>
{/if} {/if}
</AssetGrid>
{#if showActivityStatus} {#if showActivityStatus}
<div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end"> <div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end">
@ -696,7 +715,6 @@
/> />
</div> </div>
{/if} {/if}
{/key}
</main> </main>
</div> </div>
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer} {#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}

View File

@ -12,20 +12,23 @@
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { mdiPlus, mdiDotsVertical } from '@mdi/js'; import { mdiPlus, mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
} }
let { data }: Props = $props(); let { data }: Props = $props();
const assetStore = new AssetStore();
void assetStore.updateOptions({ isArchived: true });
onDestroy(() => assetStore.destroy());
const assetStore = new AssetStore({ isArchived: true });
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
const handleEscape = () => { const handleEscape = () => {
@ -34,10 +37,6 @@
return; return;
} }
}; };
onDestroy(() => {
assetStore.destroy();
});
</script> </script>
{#if assetInteraction.selectionActive} {#if assetInteraction.selectionActive}
@ -45,14 +44,28 @@
assets={assetInteraction.selectedAssets} assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()} clearSelect={() => assetInteraction.clearMultiselect()}
> >
<ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} /> <ArchiveAction
unarchive
onArchive={(ids, isArchived) =>
assetStore.updateAssetOperation(ids, (asset) => {
asset.isArchived = isArchived;
return { remove: false };
})}
/>
<CreateSharedLink /> <CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteraction} /> <SelectAllAssets {assetStore} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} /> <FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
assetStore.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />

View File

@ -29,7 +29,10 @@
let { data }: Props = $props(); let { data }: Props = $props();
const assetStore = new AssetStore({ isFavorite: true }); const assetStore = new AssetStore();
void assetStore.updateOptions({ isFavorite: true });
onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
const handleEscape = () => { const handleEscape = () => {
@ -38,10 +41,6 @@
return; return;
} }
}; };
onDestroy(() => {
assetStore.destroy();
});
</script> </script>
<!-- Multiselection mode app bar --> <!-- Multiselection mode app bar -->

View File

@ -98,7 +98,19 @@
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} /> <AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} />
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} shared /> <AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} shared />
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={triggerAssetUpdate} /> <FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => {
if (data.pathAssets && data.pathAssets.length > 0) {
for (const id of ids) {
const asset = data.pathAssets.find((asset) => asset.id === id);
if (asset) {
asset.isFavorite = isFavorite;
}
}
}
}}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />

View File

@ -123,7 +123,7 @@
async function navigateRandom() { async function navigateRandom() {
if (viewingAssets.length <= 0) { if (viewingAssets.length <= 0) {
return null; return undefined;
} }
const index = Math.floor(Math.random() * viewingAssets.length); const index = Math.floor(Math.random() * viewingAssets.length);
const asset = await setAssetId(viewingAssets[index]); const asset = await setAssetId(viewingAssets[index]);

View File

@ -21,7 +21,9 @@
let { data }: Props = $props(); let { data }: Props = $props();
const assetStore = new AssetStore({ userId: data.partner.id, isArchived: false, withStacked: true }); const assetStore = new AssetStore();
$effect(() => void assetStore.updateOptions({ userId: data.partner.id, isArchived: false, withStacked: true }));
onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
const handleEscape = () => { const handleEscape = () => {
@ -30,10 +32,6 @@
return; return;
} }
}; };
onDestroy(() => {
assetStore.destroy();
});
</script> </script>
<main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg"> <main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg">

View File

@ -456,10 +456,10 @@
</UserPageLayout> </UserPageLayout>
{#if selectHidden} {#if selectHidden}
<div <dialog
open
transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }} transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }}
class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg" class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="manage-visibility-title" aria-labelledby="manage-visibility-title"
use:focusTrap use:focusTrap
@ -471,5 +471,5 @@
onClose={() => (selectHidden = false)} onClose={() => (selectHidden = false)}
{loadNextPage} {loadNextPage}
/> />
</div> </dialog>
{/if} {/if}

View File

@ -74,14 +74,9 @@
let numberOfAssets = $state(data.statistics.assets); let numberOfAssets = $state(data.statistics.assets);
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
const assetStoreOptions = { isArchived: false, personId: data.person.id }; const assetStore = new AssetStore();
const assetStore = new AssetStore(assetStoreOptions); $effect(() => void assetStore.updateOptions({ isArchived: false, personId: data.person.id }));
onDestroy(() => assetStore.destroy());
$effect(() => {
// Check to trigger rebuild the timeline when navigating between people from the info panel
assetStoreOptions.personId = data.person.id;
handlePromiseError(assetStore.updateOptions(assetStoreOptions));
});
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
@ -360,9 +355,6 @@
await updateAssetCount(); await updateAssetCount();
}; };
onDestroy(() => {
assetStore.destroy();
});
let person = $derived(data.person); let person = $derived(data.person);
let thumbnailData = $derived(getPeopleThumbnailUrl(person)); let thumbnailData = $derived(getPeopleThumbnailUrl(person));
@ -418,7 +410,14 @@
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} /> <FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
assetStore.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" /> <DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
<MenuOption <MenuOption

View File

@ -33,7 +33,10 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true }); const assetStore = new AssetStore();
void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true });
onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
let selectedAssets = $derived(assetInteraction.selectedAssetsArray); let selectedAssets = $derived(assetInteraction.selectedAssetsArray);
@ -67,10 +70,6 @@
assetStore.updateAssets([still]); assetStore.updateAssets([still]);
}; };
onDestroy(() => {
assetStore.destroy();
});
beforeNavigate(() => { beforeNavigate(() => {
isFaceEditMode.value = false; isFaceEditMode.value = false;
}); });
@ -88,7 +87,14 @@
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} /> <FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
assetStore.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
{#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected} {#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected}

View File

@ -136,13 +136,17 @@
nextPage = 1; nextPage = 1;
searchResultAssets = []; searchResultAssets = [];
searchResultAlbums = []; searchResultAlbums = [];
await loadNextPage(); await loadNextPage(true);
} }
const loadNextPage = async () => { // eslint-disable-next-line svelte/valid-prop-names-in-kit-pages
export const loadNextPage = async (force?: boolean) => {
if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) { if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) {
return; return;
} }
if (isLoading && !force) {
return;
}
isLoading = true; isLoading = true;
const searchDto: SearchTerms = { const searchDto: SearchTerms = {
@ -232,9 +236,6 @@
return tagNames.join(', '); return tagNames.join(', ');
} }
// eslint-disable-next-line no-self-assign
const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets);
const onAddToAlbum = (assetIds: string[]) => { const onAddToAlbum = (assetIds: string[]) => {
if (terms.isNotInAlbum.toString() == 'true') { if (terms.isNotInAlbum.toString() == 'true') {
const assetIdSet = new Set(assetIds); const assetIdSet = new Set(assetIds);
@ -262,13 +263,23 @@
<AddToAlbum {onAddToAlbum} /> <AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} /> <AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={triggerAssetUpdate} /> <FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => {
for (const id of ids) {
const asset = searchResultAssets.find((asset) => asset.id === id);
if (asset) {
asset.isFavorite = isFavorite;
}
}
}}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={triggerAssetUpdate} /> <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem /> <TagAction menuItem />
{/if} {/if}
@ -281,6 +292,10 @@
{:else} {:else}
<div class="fixed z-[100] top-0 left-0 w-full"> <div class="fixed z-[100] top-0 left-0 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}> <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div
class="-z-[1] bg-immich-bg dark:bg-immich-dark-bg"
style="position:absolute;top:0;left:0;right:0;bottom:0;"
></div>
<div class="w-full flex-1 pl-4"> <div class="w-full flex-1 pl-4">
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} /> <SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
</div> </div>
@ -329,11 +344,10 @@
{/if} {/if}
<section <section
class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4" class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
bind:clientHeight={viewport.height} bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width} bind:clientWidth={viewport.width}
> >
<section class="immich-scrollbar relative overflow-y-auto">
{#if searchResultAlbums.length > 0} {#if searchResultAlbums.length > 0}
<section> <section>
<div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums').toUpperCase()}</div> <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums').toUpperCase()}</div>
@ -344,7 +358,7 @@
</div> </div>
</section> </section>
{/if} {/if}
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg"> <section id="search-content">
{#if searchResultAssets.length > 0} {#if searchResultAssets.length > 0}
<GalleryViewer <GalleryViewer
assets={searchResultAssets} assets={searchResultAssets}
@ -370,4 +384,3 @@
{/if} {/if}
</section> </section>
</section> </section>
</section>

View File

@ -24,6 +24,7 @@
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { onDestroy } from 'svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@ -39,8 +40,9 @@
const buildMap = (tags: TagResponseDto[]) => { const buildMap = (tags: TagResponseDto[]) => {
return Object.fromEntries(tags.map((tag) => [tag.value, tag])); return Object.fromEntries(tags.map((tag) => [tag.value, tag]));
}; };
const assetStore = new AssetStore();
const assetStore = new AssetStore({}); $effect(() => void assetStore.updateOptions({ deferInit: !tag, tagId }));
onDestroy(() => assetStore.destroy());
let tags = $state<TagResponseDto[]>([]); let tags = $state<TagResponseDto[]>([]);
$effect(() => { $effect(() => {
@ -52,10 +54,6 @@
let tagId = $derived(tag?.id); let tagId = $derived(tag?.id);
let tree = $derived(buildTree(tags.map((tag) => tag.value))); let tree = $derived(buildTree(tags.map((tag) => tag.value)));
$effect.pre(() => {
void assetStore.updateOptions({ tagId });
});
const handleNavigation = async (tag: string) => { const handleNavigation = async (tag: string) => {
await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`)); await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
}; };

View File

@ -36,8 +36,10 @@
handlePromiseError(goto(AppRoute.PHOTOS)); handlePromiseError(goto(AppRoute.PHOTOS));
} }
const options = { isTrashed: true }; const assetStore = new AssetStore();
const assetStore = new AssetStore(options); void assetStore.updateOptions({ isTrashed: true });
onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
const handleEmptyTrash = async () => { const handleEmptyTrash = async () => {
@ -56,9 +58,6 @@
message: $t('assets_permanently_deleted_count', { values: { count } }), message: $t('assets_permanently_deleted_count', { values: { count } }),
type: NotificationType.Info, type: NotificationType.Info,
}); });
// reset asset grid (TODO fix in asset store that it should reset when it is empty)
await assetStore.updateOptions(options);
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_empty_trash')); handleError(error, $t('errors.unable_to_empty_trash'));
} }
@ -80,7 +79,10 @@
}); });
// reset asset grid (TODO fix in asset store that it should reset when it is empty) // reset asset grid (TODO fix in asset store that it should reset when it is empty)
await assetStore.updateOptions(options); // note - this is still a problem, but updateOptions with the same value will not
// do anything, so need to flip it for it to reload/reinit
// await assetStore.updateOptions({ deferInit: true, isTrashed: true });
// await assetStore.updateOptions({ deferInit: false, isTrashed: true });
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_restore_trash')); handleError(error, $t('errors.unable_to_restore_trash'));
} }
@ -92,10 +94,6 @@
return; return;
} }
}; };
onDestroy(() => {
assetStore.destroy();
});
</script> </script>
{#if assetInteraction.selectionActive} {#if assetInteraction.selectionActive}

View File

@ -4,7 +4,7 @@
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"module": "es2020", "module": "es2022",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,