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

@ -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"