mirror of
https://github.com/immich-app/immich.git
synced 2025-07-07 09:12:56 +02:00
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:
parent
07538299cf
commit
837b1e4929
50 changed files with 2947 additions and 843 deletions
web/src/lib/components/photos-page
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue