feat(web): Scroll to asset in gridview; increase gridview perf; reduce memory; scrollbar ticks in fixed position (#10646)

* Squashed

* Change strategy - now pre-measure buckets offscreen, so don't need to worry about sub-bucket scroll preservation

* Reduce jank on scroll, delay DOM updates until after scroll

* css opt, log measure time

* Trickle out queue while scrolling, flush when stopped

* yay

* Cleanup cleanup...

* everybody...

* everywhere...

* Clean up cleanup!

* Everybody do their share

* CLEANUP!

* package-lock ?

* dynamic measure, todo

* Fix web test

* type lint

* fix e2e

* e2e test

* Better scrollbar

* Tuning, and more tunables

* Tunable tweaks, more tunables

* Scrollbar dots and viewport events

* lint

* Tweaked tunnables, use requestIdleCallback for garbage tasks, bug fixes

* New tunables, and don't update url by default

* Bug fixes

* Bug fix, with debug

* Fix flickr, fix graybox bug, reduced debug

* Refactor/cleanup

* Fix

* naming

* Final cleanup

* review comment

* Forgot to update this after naming change

* scrubber works, with debug

* cleanup

* Rename scrollbar to scrubber

* rename  to

* left over rename and change to previous album bar

* bugfix addassets, comments

* missing destroy(), cleanup

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis
2024-08-21 22:15:21 -04:00
committed by GitHub
parent 07538299cf
commit 837b1e4929
50 changed files with 2947 additions and 843 deletions

View File

@@ -1,84 +1,69 @@
<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 type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { AssetStore, Viewport } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import {
calculateWidth,
formatGroupTitle,
fromLocalDateTime,
splitBucketIntoDateGroups,
} from '$lib/utils/timeline-util';
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store';
import { navigate } from '$lib/utils/navigation';
import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import justifiedLayout from 'justified-layout';
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, 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';
export let assets: AssetResponseDto[];
export let bucketDate: string;
export let bucketHeight: number;
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 assetInteractionStore: AssetInteractionStore;
export let onScrollTarget: ScrollTargetListener | undefined = undefined;
export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
const componentId = generateId();
$: bucketDate = bucket.bucketDate;
$: dateGroups = bucket.dateGroups;
const {
DATEGROUP: { INTERSECTION_DISABLED, INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
} = TUNABLES;
/* TODO figure out a way to calculate this*/
const TITLE_HEIGHT = 51;
const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore;
const dispatch = createEventDispatcher<{
select: { title: string; assets: AssetResponseDto[] };
selectAssets: AssetResponseDto;
selectAssetCandidates: AssetResponseDto | null;
shift: { heightDelta: number };
}>();
let isMouseOverGroup = false;
let actualBucketHeight: number;
let hoveredDateGroup = '';
$: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
$: geometry = (() => {
const geometry = [];
for (let group of assetsGroupByDate) {
const justifiedLayoutResult = justifiedLayout(
group.map((assetGroup) => getAssetRatio(assetGroup)),
{
boxSpacing: 2,
containerWidth: Math.floor(viewport.width),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
},
);
geometry.push({
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
});
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
if (isSelectionMode || $isMultiSelectState) {
assetSelectHandler(asset, assets, groupTitle);
return;
}
return geometry;
})();
void navigate({ targetRoute: 'current', assetId: asset.id });
};
$: {
if (actualBucketHeight && actualBucketHeight !== 0 && actualBucketHeight != bucketHeight) {
const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight);
if (heightDelta !== 0) {
scrollTimeline(heightDelta);
}
const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
if (assetGridElement && onScrollTarget) {
const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
onScrollTarget({ bucket, dateGroup, asset, offset });
}
}
function scrollTimeline(heightDelta: number) {
dispatch('shift', {
heightDelta,
});
}
};
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
@@ -104,93 +89,149 @@
dispatch('selectAssetCandidates', asset);
}
};
onDestroy(() => {
$assetStore.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}>
{#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)}
{@const asset = groupAssets[0]}
{@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))}
<!-- Asset Group By Date -->
<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)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex flex-col"
on:mouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(groupTitle, null);
}}
on:mouseleave={() => {
isMouseOverGroup = false;
assetMouseEventHandler(groupTitle, null);
id="date-group"
use:intersectionObserver={{
onIntersect: () => {
$assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
);
},
onSeparate: () => {
$assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () =>
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
);
},
top: INTERSECTION_ROOT_TOP,
bottom: INTERSECTION_ROOT_BOTTOM,
root: assetGridElement,
disabled: INTERSECTION_DISABLED,
}}
data-display={display}
data-date-group={dateGroup.date}
style:height={dateGroup.height + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'}
style:overflow={'clip'}
>
<!-- Date group 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"
style="width: {geometry[groupIndex].containerWidth}px"
>
{#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))}
{#if !display}
<Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
{/if}
{#if display}
<!-- Asset Group By Date -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:mouseenter={() =>
$assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = true;
assetMouseEventHandler(dateGroup.groupTitle, null);
},
})}
on:mouseleave={() => {
$assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = false;
assetMouseEventHandler(dateGroup.groupTitle, null);
},
});
}}
>
<!-- Date group title -->
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => handleSelectGroup(groupTitle, groupAssets)}
on:keydown={() => handleSelectGroup(groupTitle, groupAssets)}
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={dateGroup.geometry.containerWidth + 'px'}
>
{#if $selectedGroup.has(groupTitle)}
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
{:else}
<Icon path={mdiCircleOutline} size="24" color="#757575" />
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $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 $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={dateGroup.groupTitle}>
{dateGroup.groupTitle}
</span>
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={groupTitle}>
{groupTitle}
</span>
</div>
<!-- Image grid -->
<div
class="relative"
style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px"
>
{#each groupAssets as asset, index (asset.id)}
{@const box = geometry[groupIndex].boxes[index]}
<!-- Image grid -->
<div
class="absolute"
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
class="relative overflow-clip"
style:height={dateGroup.geometry.containerHeight + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset, event) => {
if (isSelectionMode || $isMultiSelectState) {
event.preventDefault();
assetSelectHandler(asset, groupAssets, groupTitle);
return;
}
assetViewingStore.setAsset(asset);
}}
on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)}
on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)}
selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
selectionCandidate={$assetSelectionCandidates.has(asset)}
disabled={$assetStore.albumAssets.has(asset.id)}
thumbnailWidth={box.width}
thumbnailHeight={box.height}
/>
{#each dateGroup.assets as asset, index (asset.id)}
{@const box = dateGroup.geometry.boxes[index]}
<!-- 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:width={box.width + 'px'}
style:height={box.height + 'px'}
style:top={box.top + 'px'}
style:left={box.left + '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={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
selectionCandidate={$assetSelectionCandidates.has(asset)}
disabled={$assetStore.albumAssets.has(asset.id)}
thumbnailWidth={box.width}
thumbnailHeight={box.height}
/>
</div>
{/each}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each}
</section>
<style>
#asset-group-by-date {
contain: layout;
contain: layout paint style;
}
</style>

View File

@@ -1,11 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { BucketPosition, isSelectingAllAssets, type AssetStore, type Viewport } from '$lib/stores/assets.store';
import {
AssetBucket,
AssetStore,
isSelectingAllAssets,
type BucketListener,
type ViewportXY,
} from '$lib/stores/assets.store';
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
@@ -13,19 +19,38 @@
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
import {
formatGroupTitle,
splitBucketIntoDateGroups,
type ScrubberListener,
type ScrollTargetListener,
} from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { throttle } from 'lodash-es';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import Scrollbar from '../shared-components/scrollbar/scrollbar.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 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';
export let isSelectionMode = false;
export let singleSelect = false;
/** `true` if this asset grid is responds to navigation events; if `true`, then look at the
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
additionally, update the page location/url with the asset as the asset-grid is scrolled */
export let enableRouting: boolean;
export let assetStore: AssetStore;
export let assetInteractionStore: AssetInteractionStore;
export let removeAction:
@@ -40,17 +65,32 @@
export let album: AlbumResponseDto | null = null;
export let isShowDeleteConfirmation = false;
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
assetInteractionStore;
const viewport: Viewport = { width: 0, height: 0 };
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets } = assetViewingStore;
const viewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
const safeViewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
const componentId = generateId();
let element: HTMLElement;
let timelineElement: HTMLElement;
let showShortcuts = false;
let showSkeleton = true;
let internalScroll = false;
let navigating = false;
let preMeasure: AssetBucket[] = [];
let lastIntersectedBucketDate: string | undefined;
let scrubBucketPercent = 0;
let scrubBucket: { bucketDate: string | undefined } | undefined;
let scrubOverallPercent: number = 0;
let topSectionHeight = 0;
let topSectionOffset = 0;
// 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60;
let leadout = false;
$: timelineY = element?.scrollTop || 0;
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
$: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0;
$: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id);
$: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived);
@@ -59,30 +99,329 @@
assetInteractionStore.clearMultiselect();
}
}
$: {
void assetStore.updateViewport(viewport);
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();
}
}
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 dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
onMount(async () => {
showSkeleton = false;
assetStore.connect();
await assetStore.init(viewport);
});
const isViewportOrigin = () => {
return viewport.height === 0 && viewport.width === 0;
};
onDestroy(() => {
if ($showAssetViewer) {
$showAssetViewer = false;
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;
}
assetStore.disconnect();
if ($gridScrollTarget?.at) {
void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
element.scrollTo({ top: 0 });
showSkeleton = false;
});
} else {
element.scrollTo({ top: 0 });
showSkeleton = false;
}
};
afterNavigate((nav) => {
const { complete, type } = nav;
if (type === 'enter') {
return;
}
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
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
// preventing skeleton from showing after hmr
if (import.meta && import.meta.hot) {
const afterApdate = (payload: UpdatePayload) => {
const assetGridUpdate = payload.updates.some(
(update) => update.path.endsWith('asset-grid.svelte') || update.path.endsWith('assets-store.ts'),
);
if (assetGridUpdate) {
setTimeout(() => {
void $assetStore.updateViewport(safeViewport, true);
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
void navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
} else {
element.scrollTo({ top: 0 });
showSkeleton = false;
}
}, 500);
}
};
import.meta.hot?.on('vite:afterUpdate', afterApdate);
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte'));
if (assetGridUpdate) {
assetStore.destroy();
}
});
return () => import.meta.hot?.off('vite:afterUpdate', afterApdate);
}
return () => void 0;
};
const _updateLastIntersectedBucketDate = () => {
let elem = document.elementFromPoint(safeViewport.x + 1, safeViewport.y + 1);
while (elem != null) {
if (elem.id === 'bucket') {
break;
}
elem = elem.parentElement;
}
if (elem) {
lastIntersectedBucketDate = (elem as HTMLElement).dataset.bucketDate;
}
};
const updateLastIntersectedBucketDate = throttle(_updateLastIntersectedBucketDate, 16, {
leading: false,
trailing: true,
});
const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
if (!lastIntersectedBucketDate) {
_updateLastIntersectedBucketDate();
}
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);
}
};
onMount(() => {
void $assetStore
.init({ bucketListener })
.then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport)));
if (!enableRouting) {
showSkeleton = false;
}
const dispose = hmrSupport();
return () => {
$assetStore.disconnect();
$assetStore.destroy();
dispose();
};
});
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 _updateViewport = () => void $assetStore.updateViewport(safeViewport);
const updateViewport = throttle(_updateViewport, 16);
const getMaxScrollPercent = () =>
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
const getMaxScroll = () =>
topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
const maxScrollPercent = getMaxScrollPercent();
const delta = bucket.bucketHeight * bucketScrollPercent;
const scrollTop = (topOffset + delta) * maxScrollPercent;
element.scrollTop = scrollTop;
};
const _onScrub: ScrubberListener = (
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
const maxScroll = getMaxScroll();
const offset = maxScroll * scrollPercent;
element.scrollTop = offset;
} else {
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
if (!bucket) {
return;
}
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);
if (!bucket.loaded) {
await assetStore.loadBucket(bucket.bucketDate);
}
// Wait here, and collect the deltas that are above offset, which affect offset position
await bucket.measuredPromise;
scrollToBucketAndOffset(bucket, bucketScrollPercent);
}
};
const _handleTimelineScroll = () => {
leadout = false;
if ($assetStore.timelineHeight < safeViewport.height * 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);
scrubBucket = undefined;
scrubBucketPercent = 0;
} else {
let top = element?.scrollTop;
if (top < topSectionHeight) {
// in the lead-in area
scrubBucket = undefined;
scrubBucketPercent = 0;
const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
return;
}
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;
if (next < 0) {
scrubBucket = bucket;
scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent);
found = true;
break;
}
top = next;
}
if (!found) {
leadout = true;
scrubBucket = undefined;
scrubBucketPercent = 0;
scrubOverallPercent = 1;
}
}
};
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;
await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets);
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => $assetStore.removeAssets(assetIds),
idsSelectedAssets,
);
assetInteractionStore.clearMultiselect();
};
@@ -107,7 +446,7 @@
const onStackAssets = async () => {
const ids = await stackAssets(Array.from($selectedAssets));
if (ids) {
assetStore.removeAssets(ids);
$assetStore.removeAssets(ids);
dispatch('escape');
}
};
@@ -115,7 +454,7 @@
const toggleArchive = async () => {
const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived);
if (ids) {
assetStore.removeAssets(ids);
$assetStore.removeAssets(ids);
deselectAllAssets();
}
};
@@ -135,7 +474,7 @@
{ shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') },
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) },
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
];
@@ -154,29 +493,33 @@
})();
const handleSelectAsset = (asset: AssetResponseDto) => {
if (!assetStore.albumAssets.has(asset.id)) {
if (!$assetStore.albumAssets.has(asset.id)) {
assetInteractionStore.selectAsset(asset);
}
};
async function intersectedHandler(event: CustomEvent) {
const element_ = event.detail.container as HTMLElement;
const target = element_.firstChild as HTMLElement;
if (target) {
const bucketDate = target.id.split('_')[1];
await assetStore.loadBucket(bucketDate, event.detail.position);
}
function intersectedHandler(bucket: AssetBucket) {
updateLastIntersectedBucketDate();
const intersectedTask = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
void $assetStore.loadBucket(bucket.bucketDate);
};
$assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask);
}
function handleScrollTimeline(event: CustomEvent) {
element.scrollBy(0, event.detail.heightDelta);
function seperatedHandler(bucket: AssetBucket) {
const seperatedTask = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
bucket.cancel();
};
$assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask);
}
const handlePrevious = async () => {
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
const previousAsset = await $assetStore.getPreviousAsset($viewingAsset);
if (previousAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
const preloadAsset = await $assetStore.getPreviousAsset(previousAsset);
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
}
@@ -185,10 +528,10 @@
};
const handleNext = async () => {
const nextAsset = await assetStore.getNextAsset($viewingAsset);
const nextAsset = await $assetStore.getNextAsset($viewingAsset);
if (nextAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset);
const preloadAsset = await $assetStore.getNextAsset(nextAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
}
@@ -196,7 +539,12 @@
return !!nextAsset;
};
const handleClose = () => assetViewingStore.showAssetViewer(false);
const handleClose = async ({ detail: { asset } }: { detail: { asset: AssetResponseDto } }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
const handleAction = async (action: Action) => {
switch (action.type) {
@@ -206,7 +554,7 @@
case AssetAction.DELETE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || handleClose();
(await handleNext()) || (await handlePrevious()) || (await handleClose({ detail: { asset: action.asset } }));
// delete after find the next one
assetStore.removeAssets([action.asset.id]);
@@ -232,20 +580,6 @@
}
};
let animationTick = false;
const handleTimelineScroll = () => {
if (animationTick) {
return;
}
animationTick = true;
window.requestAnimationFrame(() => {
timelineY = element?.scrollTop || 0;
animationTick = false;
});
};
let lastAssetMouseEvent: AssetResponseDto | null = null;
$: if (!lastAssetMouseEvent) {
@@ -355,7 +689,7 @@
// 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, BucketPosition.Unknown);
await $assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.assets) {
if (deselect) {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
@@ -370,11 +704,10 @@
const bucket = $assetStore.buckets[bucketIndex];
// Split bucket into date groups and check each group
const assetsGroupByDate = splitBucketIntoDateGroups(bucket.assets, $locale);
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
for (const dateGroup of assetsGroupByDate) {
const dateGroupTitle = formatGroupTitle(DateTime.fromISO(dateGroup[0].fileCreatedAt).startOf('day'));
if (dateGroup.every((a) => $selectedAssets.has(a))) {
const dateGroupTitle = formatGroupTitle(dateGroup.date);
if (dateGroup.assets.every((a) => $selectedAssets.has(a))) {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
} else {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
@@ -411,6 +744,9 @@
e.preventDefault();
}
};
onDestroy(() => {
assetStore.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} />
@@ -427,78 +763,97 @@
<ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
{/if}
<Scrollbar
<Scrubber
invisible={showSkeleton}
{assetStore}
height={viewport.height}
{timelineY}
on:scrollTimeline={({ detail }) => (element.scrollTop = detail)}
height={safeViewport.height}
timelineTopOffset={topSectionHeight}
timelineBottomOffset={bottomSectionHeight}
{leadout}
{scrubOverallPercent}
{scrubBucketPercent}
{scrubBucket}
{onScrub}
{stopScrub}
/>
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
<section
id="asset-grid"
class="scrollbar-hidden h-full overflow-y-auto outline-none pb-[60px] {isEmpty
? 'm-0'
: 'ml-4 tall:ml-0 md:mr-[60px]'}"
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
tabindex="-1"
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
bind:this={element}
on:scroll={handleTimelineScroll}
on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
>
<!-- skeleton -->
{#if showSkeleton}
<div class="mt-8 animate-pulse">
<div class="mb-2 h-4 w-24 rounded-full bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
<div class="flex w-[120%] flex-wrap">
{#each Array.from({ length: 100 }) as _}
<div class="m-[1px] h-[10em] w-[16em] bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
{/each}
</div>
</div>
{/if}
{#if element}
<section
use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
class:invisible={showSkeleton}
>
<slot />
<!-- (optional) empty placeholder -->
{#if isEmpty}
<!-- (optional) empty placeholder -->
<slot name="empty" />
{/if}
<section id="virtual-timeline" style:height={$assetStore.timelineHeight + 'px'}>
{#each $assetStore.buckets as bucket (bucket.bucketDate)}
<IntersectionObserver
on:intersected={intersectedHandler}
on:hidden={() => assetStore.cancelBucket(bucket)}
let:intersecting
top={750}
bottom={750}
root={element}
>
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
{#if intersecting}
<AssetDateGroup
{withStacked}
{showArchiveIcon}
{assetStore}
{assetInteractionStore}
{isSelectionMode}
{singleSelect}
on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
on:shift={handleScrollTimeline}
on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
assets={bucket.assets}
bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight}
{viewport}
/>
{/if}
</div>
</IntersectionObserver>
{/each}
</section>
{/if}
</section>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:invisible={showSkeleton}
style:height={$assetStore.timelineHeight + 'px'}
>
{#each $assetStore.buckets as bucket (bucket.bucketDate)}
{@const isPremeasure = preMeasure.includes(bucket)}
{@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure}
<div
id="bucket"
use:intersectionObserver={{
onIntersect: () => intersectedHandler(bucket),
onSeparate: () => seperatedHandler(bucket),
top: BUCKET_INTERSECTION_ROOT_TOP,
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
root: element,
}}
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}
<AssetDateGroup
assetGridElement={element}
renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
{withStacked}
{showArchiveIcon}
{assetStore}
{assetInteractionStore}
{isSelectionMode}
{singleSelect}
{onScrollTarget}
{onAssetInGrid}
{bucket}
viewport={safeViewport}
on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
/>
{/if}
</div>
{/each}
<div class="h-[60px]"></div>
</section>
</section>
<Portal target="body">
@@ -522,7 +877,7 @@
<style>
#asset-grid {
contain: layout;
contain: strict;
scrollbar-width: none;
}
</style>

View File

@@ -0,0 +1,89 @@
<script lang="ts" context="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';
export let assetStore: AssetStore;
export let bucket: AssetBucket;
export let onMeasured: () => void;
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: 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}
<div id="date-group" data-date-group={dateGroup.date}>
<div
use:resizeObserver={({ height }) => $assetStore.updateBucketDateGroup(bucket, dateGroup, { height: 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,4 +1,5 @@
<script lang="ts">
import { resizeObserver } from '$lib/actions/resize-observer';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { memoryStore } from '$lib/stores/memory.store';
@@ -38,7 +39,7 @@
id="memory-lane"
bind:this={memoryLaneElement}
class="relative mt-5 overflow-x-hidden whitespace-nowrap transition-all"
bind:offsetWidth
use:resizeObserver={({ width }) => (offsetWidth = width)}
on:scroll={onScroll}
>
{#if canScrollLeft || canScrollRight}
@@ -67,7 +68,7 @@
{/if}
</div>
{/if}
<div class="inline-block" bind:offsetWidth={innerWidth}>
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
{#each $memoryStore as memory, index (memory.yearsAgo)}
{#if memory.assets.length > 0}
<a

View File

@@ -0,0 +1,35 @@
<script lang="ts">
export let title: string | null = null;
export let height: string | null = null;
</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>
<style>
#skeleton {
background-image: url('/light_skeleton.png');
background-repeat: repeat;
background-size: 235px, 235px;
}
:global(.dark) #skeleton {
background-image: url('/dark_skeleton.png');
}
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#skeleton {
visibility: hidden;
animation: 0s linear 0.1s forwards delayedVisibility;
}
</style>