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

@ -33,7 +33,10 @@
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();
dragAndDropFilesStore.subscribe((value) => {
@ -42,9 +45,6 @@
dragAndDropFilesStore.set({ isDragging: false, files: [] });
}
});
onDestroy(() => {
assetStore.destroy();
});
</script>
<svelte:window

View file

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

View file

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

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants';
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
@ -22,19 +21,11 @@
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
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 { thumbhash } from '$lib/actions/thumbhash';
interface Props {
asset: AssetResponseDto;
dateGroup?: DateGroup | undefined;
assetStore?: AssetStore | undefined;
groupIndex?: number;
thumbnailSize?: number | undefined;
thumbnailWidth?: number | undefined;
@ -47,29 +38,16 @@
showArchiveIcon?: boolean;
showStackedIcon?: 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;
onRetrieveElement?: ((elment: HTMLElement) => void) | undefined;
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
handleFocus?: (() => void) | undefined;
class?: string;
overrideDisplayForTest?: boolean;
}
let {
asset,
dateGroup = undefined,
assetStore = undefined,
asset = $bindable(),
groupIndex = 0,
thumbnailSize = undefined,
thumbnailWidth = undefined,
@ -82,42 +60,21 @@
showArchiveIcon = false,
showStackedIcon = true,
disableMouseOver = false,
intersectionConfig = {},
retrieveElement = false,
onIntersected = undefined,
onClick = undefined,
onRetrieveElement = undefined,
onSelect = undefined,
onMouseEvent = undefined,
handleFocus = undefined,
class: className = '',
overrideDisplayForTest = false,
}: Props = $props();
let {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
const componentId = generateId();
let element: HTMLElement | undefined = $state();
let focussableElement: HTMLElement | undefined = $state();
let mouseOver = $state(false);
let intersecting = $state(false);
let lastRetrievedElement: HTMLElement | undefined = $state();
let loaded = $state(false);
$effect(() => {
if (!retrieveElement) {
lastRetrievedElement = undefined;
}
});
$effect(() => {
if (retrieveElement && element && lastRetrievedElement !== element) {
lastRetrievedElement = element;
onRetrieveElement?.(element);
}
});
$effect(() => {
if (focussed && document.activeElement !== focussableElement) {
focussableElement?.focus();
@ -126,13 +83,12 @@
let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235);
let display = $derived(intersecting);
const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
if (!disabled) {
onSelect?.(asset);
onSelect?.($state.snapshot(asset));
}
};
@ -141,7 +97,7 @@
onIconClickedHandler();
return;
}
onClick?.(asset);
onClick?.($state.snapshot(asset));
};
const handleClick = (e: MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
@ -152,68 +108,18 @@
callClickHandlers();
};
const _onMouseEnter = () => {
const onMouseEnter = () => {
mouseOver = true;
onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
};
const onMouseEnter = () => {
if (dateGroup && assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() });
} else {
_onMouseEnter();
}
};
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>
<div
bind:this={element}
use:intersectionObserver={{
...intersectionConfig,
onIntersect,
onSeparate,
}}
data-asset={asset.id}
data-int={intersecting}
style:width="{width}px"
style:height="{height}px"
class="focus-visible:outline-none flex overflow-hidden {disabled
@ -230,166 +136,164 @@
></canvas>
{/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. -->
<div
class="group"
class:cursor-not-allowed={disabled}
class:cursor-pointer={!disabled}
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
}}
tabindex={0}
onclick={handleClick}
role="link"
bind:this={focussableElement}
onfocus={handleFocus}
data-testid="container-with-tabindex"
>
{#if mouseOver && !disableMouseOver}
<!-- lazy show the url on mouse over-->
<a
class="absolute z-30 {className} top-[41px]"
style:cursor="unset"
style:width="{width}px"
style:height="{height}px"
href={currentUrlReplaceAssetId(asset.id)}
onclick={(evt) => evt.preventDefault()}
tabindex={-1}
aria-label="Thumbnail URL"
>
</a>
{/if}
<div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px">
<!-- Select asset button -->
{#if !readonly && (mouseOver || selected || selectionCandidate)}
<button
type="button"
onclick={onIconClickedHandler}
class="absolute p-2 focus:outline-none"
class:cursor-not-allowed={disabled}
role="checkbox"
tabindex={-1}
onfocus={handleFocus}
aria-checked={selected}
{disabled}
>
{#if disabled}
<Icon path={mdiCheckCircle} size="24" class="text-zinc-800" />
{:else if selected}
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
<Icon path={mdiCheckCircle} size="24" class="text-immich-primary" />
</div>
{:else}
<Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
{/if}
</button>
{/if}
</div>
<div
class="absolute h-full w-full select-none bg-transparent transition-transform"
class:scale-[0.85]={selected}
class:rounded-xl={selected}
<div
class="group"
style:width="{width}px"
style:height="{height}px"
class:cursor-not-allowed={disabled}
class:cursor-pointer={!disabled}
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
}}
tabindex={0}
onclick={handleClick}
role="link"
bind:this={focussableElement}
onfocus={handleFocus}
data-testid="container-with-tabindex"
>
{#if mouseOver && !disableMouseOver}
<!-- lazy show the url on mouse over-->
<a
class="absolute z-30 {className} top-[41px]"
style:cursor="unset"
style:width="{width}px"
style:height="{height}px"
href={currentUrlReplaceAssetId(asset.id)}
onclick={(evt) => evt.preventDefault()}
tabindex={-1}
aria-label="Thumbnail URL"
>
<!-- Gradient overlay on hover -->
<div
class="absolute z-10 h-full w-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100"
class:rounded-xl={selected}
></div>
<!-- Outline on focus -->
<div
class="absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary"
></div>
<!-- Favorite asset star -->
{#if !isSharedLink() && asset.isFavorite}
<div class="absolute bottom-2 left-2 z-10">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
{#if !isSharedLink() && showArchiveIcon && asset.isArchived}
<div class="absolute {asset.isFavorite ? 'bottom-10' : 'bottom-2'} left-2 z-10">
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pr-2 pt-2">
<Icon path={mdiRotate360} size="24" />
</span>
</div>
{/if}
<!-- Stacked asset -->
{#if asset.stack && showStackedIcon}
<div
class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == undefined
? 'top-0 right-0'
: 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white"
>
<span class="pr-2 pt-2 flex place-items-center gap-1">
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon path={mdiCameraBurst} size="24" />
</span>
</div>
{/if}
<ImageThumbnail
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
curve={selected}
onComplete={() => (loaded = true)}
/>
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={timeToSeconds(asset.duration)}
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
showTime={false}
curve={selected}
playbackOnIconHover
/>
</div>
{/if}
</div>
{#if selectionCandidate}
<div
class="absolute top-0 h-full w-full bg-immich-primary opacity-40"
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
></div>
</a>
{/if}
<div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px">
<!-- Select asset button -->
{#if !readonly && (mouseOver || selected || selectionCandidate)}
<button
type="button"
onclick={onIconClickedHandler}
class="absolute p-2 focus:outline-none"
class:cursor-not-allowed={disabled}
role="checkbox"
tabindex={-1}
onfocus={handleFocus}
aria-checked={selected}
{disabled}
>
{#if disabled}
<Icon path={mdiCheckCircle} size="24" class="text-zinc-800" />
{:else if selected}
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
<Icon path={mdiCheckCircle} size="24" class="text-immich-primary" />
</div>
{:else}
<Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
{/if}
</button>
{/if}
</div>
{/if}
<div
class="absolute h-full w-full select-none bg-transparent transition-transform"
class:scale-[0.85]={selected}
class:rounded-xl={selected}
>
<!-- Gradient overlay on hover -->
<div
class="absolute z-10 h-full w-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100"
class:rounded-xl={selected}
></div>
<!-- Outline on focus -->
<div
class="absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary"
></div>
<!-- Favorite asset star -->
{#if !isSharedLink() && asset.isFavorite}
<div class="absolute bottom-2 left-2 z-10">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
{#if !isSharedLink() && showArchiveIcon && asset.isArchived}
<div class="absolute {asset.isFavorite ? 'bottom-10' : 'bottom-2'} left-2 z-10">
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pr-2 pt-2">
<Icon path={mdiRotate360} size="24" />
</span>
</div>
{/if}
<!-- Stacked asset -->
{#if asset.stack && showStackedIcon}
<div
class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == undefined
? 'top-0 right-0'
: 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white"
>
<span class="pr-2 pt-2 flex place-items-center gap-1">
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon path={mdiCameraBurst} size="24" />
</span>
</div>
{/if}
<ImageThumbnail
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
curve={selected}
onComplete={() => (loaded = true)}
/>
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={timeToSeconds(asset.duration)}
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
showTime={false}
curve={selected}
playbackOnIconHover
/>
</div>
{/if}
</div>
{#if selectionCandidate}
<div
class="absolute top-0 h-full w-full bg-immich-primary opacity-40"
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
></div>
{/if}
</div>
</div>

View file

@ -3,12 +3,8 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
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 {
assetStore?: AssetStore | undefined;
url: string;
durationInSeconds?: number;
enablePlayback?: boolean;
@ -20,7 +16,6 @@
}
let {
assetStore = undefined,
url,
durationInSeconds = 0,
enablePlayback = $bindable(false),
@ -31,7 +26,6 @@
pauseIcon = mdiPauseCircleOutline,
}: Props = $props();
const componentId = generateId();
let remainingSeconds = $state(durationInSeconds);
let loading = $state(true);
let error = $state(false);
@ -49,42 +43,16 @@
}
});
const onMouseEnter = () => {
if (assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
if (playbackOnIconHover) {
enablePlayback = true;
}
},
});
} else {
if (playbackOnIconHover) {
enablePlayback = true;
}
if (playbackOnIconHover) {
enablePlayback = true;
}
};
const onMouseLeave = () => {
if (assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
if (playbackOnIconHover) {
enablePlayback = false;
}
},
});
} else {
if (playbackOnIconHover) {
enablePlayback = false;
}
if (playbackOnIconHover) {
enablePlayback = false;
}
};
onDestroy(() => {
assetStore?.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<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">
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets-store.svelte';
import { AssetBucket } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import {
findTotalOffset,
type DateGroup,
type ScrollTargetListener,
getDateLocaleString,
} from '$lib/utils/timeline-util';
import { getDateLocaleString } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
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 { scale } from 'svelte/transition';
export let element: HTMLElement | undefined = undefined;
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;
import { flip } from 'svelte/animate';
export let onScrollTarget: ScrollTargetListener | undefined = undefined;
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;
import { uploadAssetsStore } from '$lib/stores/upload';
const componentId = generateId();
$: bucketDate = bucket.bucketDate;
$: dateGroups = bucket.dateGroups;
let { isUploading } = uploadAssetsStore;
const {
DATEGROUP: { INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
} = TUNABLES;
/* TODO figure out a way to calculate this*/
const TITLE_HEIGHT = 51;
interface Props {
isSelectionMode: boolean;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
bucket: AssetBucket;
assetInteraction: AssetInteraction;
let isMouseOverGroup = false;
let hoveredDateGroup = '';
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
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) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(asset, assets, groupTitle);
@ -59,13 +54,6 @@
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 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
let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) =>
assetInteraction.selectedAssets.has(asset),
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
@ -83,7 +71,9 @@
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
};
const snapshotAssetArray = (assets: AssetResponseDto[]) => {
return assets.map((a) => $state.snapshot(a));
};
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
// Show multi select icon on hover on date group
hoveredDateGroup = groupTitle;
@ -96,155 +86,100 @@
const assetOnFocusHandler = (asset: AssetResponseDto) => {
assetInteraction.focussedAssetId = asset.id;
};
onDestroy(() => {
assetStore.taskManager.removeAllTasksForComponent(componentId);
});
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting);
}
</script>
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
{@const display =
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === assetStore.pendingScrollAssetId)}
{@const geometry = dateGroup.geometry!}
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)}
{@const absoluteWidth = dateGroup.left}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !bucket.store.suspendTransitions },
!bucket.store.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dateGroup.top}px,0)`}
onmouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(dateGroup.groupTitle, null);
}}
onmouseleave={() => {
isMouseOverGroup = false;
assetMouseEventHandler(dateGroup.groupTitle, null);
}}
>
<!-- Date group title -->
<div
id="date-group"
use:intersectionObserver={{
onIntersect: () => {
assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
);
},
onSeparate: () => {
assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
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"
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={dateGroup.width + 'px'}
>
{#if !display}
<Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
{/if}
{#if display}
<!-- Asset Group By Date -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
<div
on:mouseenter={() =>
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = true;
assetMouseEventHandler(dateGroup.groupTitle, null);
},
})}
on:mouseleave={() => {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = false;
assetMouseEventHandler(dateGroup.groupTitle, null);
},
});
}}
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
onclick={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
>
<!-- Date group title -->
<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"
style:width={geometry.containerWidth + 'px'}
>
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
>
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
{:else}
<Icon path={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDateLocaleString(dateGroup.date)}>
{dateGroup.groupTitle}
</span>
</div>
<!-- Image grid -->
<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}
class="absolute"
style:top={top + 'px'}
style:left={left + 'px'}
style:width={width + 'px'}
style:height={height + 'px'}
>
<Thumbnail
{dateGroup}
{assetStore}
intersectionConfig={{
root: assetGridElement,
bottom: renderThumbsAtBottomMargin,
top: renderThumbsAtTopMargin,
}}
retrieveElement={assetStore.pendingScrollAssetId === asset.id}
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{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)}
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
disabled={assetStore.albumAssets.has(asset.id)}
thumbnailWidth={width}
thumbnailHeight={height}
/>
</div>
{/each}
</div>
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
{:else}
<Icon path={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDateLocaleString(dateGroup.date)}>
{dateGroup.groupTitle}
</span>
</div>
{/each}
</section>
<!-- 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
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
focussed={assetInteraction.isFocussedAsset(asset)}
onClick={(asset) => onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, $state.snapshot(asset))}
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>
<!-- {/if} -->
{/each}
</div>
</section>
{/each}
<style>
#asset-group-by-date {
section {
contain: layout paint style;
}
</style>

View file

@ -4,38 +4,26 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte';
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
import { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import {
formatGroupTitle,
splitBucketIntoDateGroups,
type ScrubberListener,
type ScrollTargetListener,
} from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import { throttle } from 'lodash-es';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { onMount, type Snippet } from 'svelte';
import Portal from '../shared-components/portal/portal.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
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 { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { page } from '$app/stores';
import type { UpdatePayload } from 'vite';
import { generateId } from '$lib/utils/generate-id';
import { isTimelineScrolling } from '$lib/stores/timeline.store';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
interface Props {
@ -81,64 +69,41 @@
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 timelineElement: HTMLElement | undefined = $state();
let showShortcuts = $state(false);
let showSkeleton = $state(true);
let internalScroll = false;
let navigating = false;
let preMeasure: AssetBucket[] = $state([]);
let lastIntersectedBucketDate: string | undefined;
let scrubBucketPercent = $state(0);
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
let scrubOverallPercent: number = $state(0);
let topSectionHeight = $state(0);
let topSectionOffset = $state(0);
// 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60;
let leadout = $state(false);
const {
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;
}
const completeNav = async () => {
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 });
showSkeleton = false;
});
}
} else {
element?.scrollTo({ top: 0 });
showSkeleton = false;
}
};
beforeNavigate(() => (assetStore.suspendTransitions = true));
afterNavigate((nav) => {
const { complete, type } = nav;
if (type === 'enter') {
@ -147,10 +112,6 @@
complete.then(completeNav, completeNav);
});
beforeNavigate(() => {
navigating = true;
});
const hmrSupport = () => {
// when hmr happens, skeleton is initialized to true by default
// normally, loading asset-grid is part of a navigation event, and the completion of
@ -165,7 +126,6 @@
if (assetGridUpdate) {
setTimeout(() => {
void assetStore.updateViewport(safeViewport, true);
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
@ -193,94 +153,60 @@
return () => void 0;
};
const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
if (lastIntersectedBucketDate) {
const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
const deltaIndex = assetStore.buckets.indexOf(adjustedBucket);
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);
}
};
const updateIsScrolling = () => (assetStore.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta);
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
onMount(() => {
void assetStore
.init({ bucketListener })
.then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport)));
assetStore.setCompensateScrollCallback(compensateScrollCallback);
if (!enableRouting) {
showSkeleton = false;
}
const dispose = hmrSupport();
const disposeHmr = hmrSupport();
return () => {
assetStore.disconnect();
assetStore.destroy();
dispose();
assetStore.setCompensateScrollCallback();
disposeHmr();
};
});
const _updateViewport = () => void assetStore.updateViewport(safeViewport);
const updateViewport = throttle(_updateViewport, 16);
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 getMaxScrollPercent = () => {
const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight;
return (totalHeight - assetStore.viewportHeight) / totalHeight;
};
const getMaxScroll = () => {
if (!element || !timelineElement) {
return 0;
}
return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
};
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
const topOffset = bucket.top;
const maxScrollPercent = getMaxScrollPercent();
const delta = bucket.bucketHeight * bucketScrollPercent;
const scrollTop = (topOffset + delta) * maxScrollPercent;
if (!element) {
return;
if (element) {
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,
scrollPercent: 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
const maxScroll = getMaxScroll();
const offset = maxScroll * scrollPercent;
if (!element) {
return;
}
element.scrollTop = offset;
} else {
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
@ -290,47 +216,16 @@
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;
if (!element) {
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
const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
@ -338,8 +233,8 @@
scrubBucket = undefined;
scrubBucketPercent = 0;
} else {
let top = element?.scrollTop;
if (top < topSectionHeight) {
let top = element.scrollTop;
if (top < assetStore.topSectionHeight) {
// in the lead-in area
scrubBucket = undefined;
scrubBucketPercent = 0;
@ -352,18 +247,24 @@
let maxScrollPercent = getMaxScrollPercent();
let found = false;
// create virtual buckets....
const vbuckets = [
{ bucketHeight: topSectionHeight, bucketDate: undefined },
...assetStore.buckets,
{ bucketHeight: bottomSectionHeight, bucketDate: undefined },
];
for (const bucket of vbuckets) {
let next = top - bucket.bucketHeight * maxScrollPercent;
const bucketsLength = assetStore.buckets.length;
for (let i = -1; i < bucketsLength + 1; i++) {
let bucket: { bucketDate: string | undefined } | undefined;
let bucketHeight = 0;
if (i === -1) {
// lead-in
bucketHeight = assetStore.topSectionHeight;
} else if (i === bucketsLength) {
// lead-out
bucketHeight = bottomSectionHeight;
} else {
bucket = assetStore.buckets[i];
bucketHeight = assetStore.buckets[i].bucketHeight;
}
let next = top - bucketHeight * maxScrollPercent;
if (next < 0) {
scrubBucket = bucket;
scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent);
scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
found = true;
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) => {
isShowDeleteConfirmation = false;
@ -439,11 +312,9 @@
};
const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
if (ids) {
assetStore.removeAssets(ids);
deselectAllAssets();
}
await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
assetStore.updateAssets(assetInteraction.selectedAssetsArray);
deselectAllAssets();
};
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 previousAsset = await assetStore.getPreviousAsset($viewingAsset);
@ -610,7 +464,6 @@
if (!asset) {
return;
}
onSelect(asset);
if (singleSelect && element) {
@ -619,7 +472,7 @@
}
const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0;
const deselect = assetInteraction.selectedAssets.has(asset);
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
@ -637,39 +490,48 @@
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id);
let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = assetStore.getBucketIndexByAssetId(asset.id);
if (startBucketIndex === null || endBucketIndex === null) {
if (startBucket === null || endBucket === null) {
return;
}
if (endBucketIndex < startBucketIndex) {
[startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex];
}
// Select/deselect assets in all intermediate buckets
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
const bucket = assetStore.buckets[bucketIndex];
await assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.assets) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset);
} else {
handleSelectAsset(asset);
// Select/deselect assets in range (start,end]
let started = false;
for (const bucket of assetStore.buckets) {
if (bucket === startBucket) {
started = true;
}
if (bucket === endBucket) {
break;
}
if (started) {
await assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.getAssets()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset);
} else {
handleSelectAsset(asset);
}
}
}
}
// Update date group selection
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
const bucket = assetStore.buckets[bucketIndex];
started = false;
for (const bucket of assetStore.buckets) {
if (bucket === startBucket) {
started = true;
}
if (bucket === endBucket) {
break;
}
// Split bucket into date groups and check each group
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
for (const dateGroup of assetsGroupByDate) {
const dateGroupTitle = formatGroupTitle(dateGroup.date);
if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) {
for (const dateGroup of bucket.dateGroups) {
const dateGroupTitle = dateGroup.groupTitle;
if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dateGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle);
@ -691,14 +553,16 @@
return;
}
let start = assetStore.assets.findIndex((a) => a.id === startAsset.id);
let end = assetStore.assets.findIndex((a) => a.id === endAsset.id);
const assets = assetStore.getAssets();
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
if (start > end) {
[start, end] = [end, start];
}
assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1));
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
};
const onSelectStart = (e: Event) => {
@ -710,14 +574,14 @@
const focusNextAsset = async () => {
if (assetInteraction.focussedAssetId === null) {
const firstAsset = assetStore.getFirstAsset();
if (firstAsset !== null) {
if (firstAsset) {
assetInteraction.focussedAssetId = firstAsset.id;
}
} else {
const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
if (focussedAsset) {
const nextAsset = await assetStore.getNextAsset(focussedAsset);
if (nextAsset !== null) {
if (nextAsset) {
assetInteraction.focussedAssetId = nextAsset.id;
}
}
@ -726,7 +590,7 @@
const focusPreviousAsset = async () => {
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) {
const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
if (previousAsset) {
@ -736,11 +600,8 @@
}
};
onDestroy(() => {
assetStore.taskManager.removeAllTasksForComponent(componentId);
});
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));
$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(
(() => {
if ($isSearchEnabled || $showAssetViewer) {
@ -829,19 +673,34 @@
{#if showShortcuts}
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if}
{#if assetStore.buckets.length > 0}
<Scrubber
invisible={showSkeleton}
{assetStore}
height={safeViewport.height}
timelineTopOffset={topSectionHeight}
height={assetStore.viewportHeight}
timelineTopOffset={assetStore.topSectionHeight}
timelineBottomOffset={bottomSectionHeight}
{leadout}
{scrubOverallPercent}
{scrubBucketPercent}
{scrubBucket}
{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}
@ -850,90 +709,67 @@
id="asset-grid"
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
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}
onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
>
<section
use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
class:invisible={showSkeleton}
>
{@render children?.()}
{#if isEmpty}
<!-- (optional) empty placeholder -->
{@render empty?.()}
{/if}
</section>
<section
bind:this={timelineElement}
id="virtual-timeline"
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?.()}
{#if isEmpty}
<!-- (optional) empty placeholder -->
{@render empty?.()}
{/if}
</section>
{#each assetStore.buckets as bucket (bucket.viewId)}
{@const isPremeasure = preMeasure.includes(bucket)}
{@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure}
{@const display = bucket.intersecting}
{@const absoluteHeight = bucket.top}
<div
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'}
>
{#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}
{#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
class="bucket"
style:height={bucket.bucketHeight + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<AssetDateGroup
assetGridElement={element}
renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
{withStacked}
{showArchiveIcon}
{assetStore}
{assetInteraction}
{isSelectionMode}
{singleSelect}
{onScrollTarget}
{onAssetInGrid}
{bucket}
viewport={safeViewport}
onSelect={({ title, assets }) => handleGroupSelect(title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
/>
{/if}
</div>
</div>
{/if}
{/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>
@ -965,6 +801,9 @@
}
.bucket {
contain: layout size;
contain: layout size paint;
transform-style: flat;
backface-visibility: hidden;
transform-origin: center center;
}
</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">
interface Props {
title?: string | null;
height?: string | null;
height: number;
title: string;
}
let { title = null, height = null }: Props = $props();
let { height = 0, title }: Props = $props();
</script>
<div class="overflow-clip" style={`height: ${height}`}>
{#if title}
<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"
>
<span class="w-full truncate first-letter:capitalize">{title}</span>
</div>
{/if}
<div id="skeleton" style={`height: ${height}`}></div>
<div class="overflow-clip" style:height={height + 'px'}>
<div
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"
>
{title}
</div>
<div class="animate-pulse absolute w-full h-full" data-skeleton="true"></div>
</div>
<style>
#skeleton {
[data-skeleton] {
background-image: url('/light_skeleton.png');
background-repeat: repeat;
background-size: 235px, 235px;
}
:global(.dark) #skeleton {
:global(.dark) [data-skeleton] {
background-image: url('/dark_skeleton.png');
}
@keyframes delayedVisibility {
@ -32,8 +30,10 @@
visibility: visible;
}
}
#skeleton {
[data-skeleton] {
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>

View file

@ -69,7 +69,7 @@
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
<div
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'
}`}
>

View file

@ -8,13 +8,11 @@
import type { Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
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 { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { calculateWidth } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk';
import justifiedLayout from 'justified-layout';
import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
@ -22,6 +20,8 @@
import { handlePromiseError } from '$lib/utils';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.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 {
assets: AssetResponseDto[];
@ -53,11 +53,84 @@
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 currentViewAssetIndex = 0;
let shiftKeyIsDown = $state(false);
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) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
setAsset(assets[currentViewAssetIndex]);
@ -75,6 +148,7 @@
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
@ -90,7 +164,7 @@
if (!asset) {
return;
}
const deselect = assetInteraction.selectedAssets.has(asset);
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
@ -173,7 +247,7 @@
const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
if (ids) {
assets.filter((asset) => !ids.includes(asset.id));
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
}
};
@ -248,7 +322,7 @@
}
};
const handleRandom = async (): Promise<AssetResponseDto | null> => {
const handleRandom = async (): Promise<AssetResponseDto | undefined> => {
try {
let asset: AssetResponseDto | undefined;
if (onRandom) {
@ -261,14 +335,14 @@
}
if (!asset) {
return null;
return;
}
await navigateToAsset(asset);
return asset;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset'));
return null;
return;
}
};
@ -335,26 +409,6 @@
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
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(() => {
if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
@ -374,7 +428,13 @@
});
</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}
<DeleteAssetDialog
@ -389,43 +449,50 @@
{/if}
{#if assets.length > 0}
<div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px ">
{#each assets as asset, i (i)}
<div
class="absolute"
style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i]
.top}px; left: {geometry.boxes[i].left}px"
title={showAssetName ? asset.originalFileName : ''}
>
<Thumbnail
readonly={disableAssetSelect}
onClick={(asset) => {
if (assetInteraction.selectionActive) {
handleSelectAssets(asset);
return;
}
void viewAssetHandler(asset);
}}
onSelect={(asset) => handleSelectAssets(asset)}
onMouseEvent={() => assetMouseEventHandler(asset)}
handleFocus={() => assetOnFocusHandler(asset)}
onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
{showArchiveIcon}
{asset}
selected={assetInteraction.selectedAssets.has(asset)}
focussed={assetInteraction.isFocussedAsset(asset)}
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
thumbnailWidth={geometry.boxes[i].width}
thumbnailHeight={geometry.boxes[i].height}
/>
{#if showAssetName}
<div
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
>
{asset.originalFileName}
</div>
{/if}
</div>
<div
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
class="absolute"
style:overflow="clip"
style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
title={showAssetName ? asset.originalFileName : ''}
>
<Thumbnail
readonly={disableAssetSelect}
onClick={(asset) => {
if (assetInteraction.selectionActive) {
handleSelectAssets(asset);
return;
}
void viewAssetHandler(asset);
}}
onSelect={(asset) => handleSelectAssets(asset)}
onMouseEvent={() => assetMouseEventHandler(asset)}
handleFocus={() => assetOnFocusHandler(asset)}
{showArchiveIcon}
{asset}
selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
focussed={assetInteraction.isFocussedAsset(asset)}
thumbnailWidth={layout.width}
thumbnailHeight={layout.height}
/>
{#if showAssetName}
<div
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
>
{asset.originalFileName}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}

View file

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

View file

@ -121,15 +121,14 @@
<Portal target="body">
{#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"
transition:fade={{ duration: 150 }}
onmouseover={() => (hoverMessage = true)}
onmouseleave={() => (hoverMessage = false)}
onfocus={() => (hoverMessage = true)}
onblur={() => (hoverMessage = false)}
role="dialog"
tabindex="0"
>
<div class="flex justify-between place-items-center">
<div class="h-10 w-10">
@ -178,6 +177,12 @@
{$t('purchase_button_reminder')}
</Button>
</div>
</div>
</dialog>
{/if}
</Portal>
<style>
dialog {
margin: 0;
}
</style>

View file

@ -45,7 +45,7 @@
onclick={() => {}}
/>
</li>
{#each pathSegments as segment, index (segment)}
{#each pathSegments as segment, index (index)}
{@const isLastSegment = index === pathSegments.length - 1}
<li
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 = () => {
if (assets.length <= 0) {
return Promise.resolve(null);
return Promise.resolve(undefined);
}
const index = Math.floor(Math.random() * assets.length);
const asset = assets[index];