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

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