fix(web): fix lost scrollpos on deep link to timeline asset, scrub stop ()

* Work in progress - super quick asset store->state

* bugfix: deep linking to timeline, on scrub stop

* format, remove stale

* disable test, todo: fix test

* remove unused import

* Fix merge

* lint

* lint

* lint

* Default to non-wasm layout

* lint

* intobs fix

* fix rejected promise

* Review comments, static import wasm

* Back to dynamic

* try top-level-await

* back to the first solution, with more finesse

* comment out wasm for now

* back out the wasm/thumbhash/thumbnail changes

* lint

* Fully remove wasm

* lockfile

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-03-04 21:34:53 -05:00 committed by GitHub
parent 8b43066632
commit 56b85f7479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 362 additions and 305 deletions
web/src/lib/components/photos-page

View file

@ -4,7 +4,7 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets.store';
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte';
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
@ -117,7 +117,6 @@
const isViewportOrigin = () => {
return viewport.height === 0 && viewport.width === 0;
};
const isEqual = (a: ViewportXY, b: ViewportXY) => {
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
};
@ -130,7 +129,7 @@
}
if ($gridScrollTarget?.at) {
void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
element?.scrollTo({ top: 0 });
showSkeleton = false;
});
@ -166,7 +165,7 @@
if (assetGridUpdate) {
setTimeout(() => {
void $assetStore.updateViewport(safeViewport, true);
void assetStore.updateViewport(safeViewport, true);
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
@ -194,31 +193,10 @@
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);
const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
const deltaIndex = assetStore.buckets.indexOf(adjustedBucket);
if (deltaIndex < currentIndex) {
element?.scrollBy(0, delta);
@ -235,20 +213,23 @@
};
onMount(() => {
void $assetStore
void assetStore
.init({ bucketListener })
.then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport)));
.then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport)));
if (!enableRouting) {
showSkeleton = false;
}
const dispose = hmrSupport();
return () => {
$assetStore.disconnect();
$assetStore.destroy();
assetStore.disconnect();
assetStore.destroy();
dispose();
};
});
const _updateViewport = () => void assetStore.updateViewport(safeViewport);
const updateViewport = throttle(_updateViewport, 16);
function getOffset(bucketDate: string) {
let offset = 0;
for (let a = 0; a < assetStore.buckets.length; a++) {
@ -259,12 +240,10 @@
}
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);
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
const getMaxScroll = () => {
if (!element || !timelineElement) {
@ -292,7 +271,7 @@
scrollPercent: number,
bucketScrollPercent: number,
) => {
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) {
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();
@ -318,7 +297,7 @@
_scrollPercent: number,
bucketScrollPercent: number,
) => {
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) {
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;
}
@ -328,10 +307,7 @@
}
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 assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true });
await bucket.measuredPromise;
scrollToBucketAndOffset(bucket, bucketScrollPercent);
}
@ -354,7 +330,7 @@
return;
}
if ($assetStore.timelineHeight < safeViewport.height * 2) {
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);
@ -424,19 +400,15 @@
preMeasure.push(bucket);
}
showSkeleton = false;
$assetStore.clearPendingScroll();
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 });
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);
assetInteraction.clearMultiselect();
};
@ -461,7 +433,7 @@
const onStackAssets = async () => {
const ids = await stackAssets(assetInteraction.selectedAssetsArray);
if (ids) {
$assetStore.removeAssets(ids);
assetStore.removeAssets(ids);
onEscape();
}
};
@ -469,7 +441,7 @@
const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
if (ids) {
$assetStore.removeAssets(ids);
assetStore.removeAssets(ids);
deselectAllAssets();
}
};
@ -481,33 +453,33 @@
};
const handleSelectAsset = (asset: AssetResponseDto) => {
if (!$assetStore.albumAssets.has(asset.id)) {
if (!assetStore.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
};
function handleIntersect(bucket: AssetBucket) {
updateLastIntersectedBucketDate();
// updateLastIntersectedBucketDate();
const task = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
void $assetStore.loadBucket(bucket.bucketDate);
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
void assetStore.loadBucket(bucket.bucketDate);
};
$assetStore.taskManager.intersectedBucket(componentId, bucket, task);
assetStore.taskManager.intersectedBucket(componentId, bucket, task);
}
function handleSeparate(bucket: AssetBucket) {
const task = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
bucket.cancel();
};
$assetStore.taskManager.separatedBucket(componentId, bucket, task);
assetStore.taskManager.separatedBucket(componentId, bucket, task);
}
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 });
}
@ -516,9 +488,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 });
}
@ -527,10 +500,10 @@
};
const handleRandom = async () => {
const randomAsset = await $assetStore.getRandomAsset();
const randomAsset = await assetStore.getRandomAsset();
if (randomAsset) {
const preloadAsset = await $assetStore.getNextAsset(randomAsset);
const preloadAsset = await assetStore.getNextAsset(randomAsset);
assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
}
@ -664,8 +637,8 @@
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucketIndex = $assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
let endBucketIndex = $assetStore.getBucketIndexByAssetId(asset.id);
let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id);
if (startBucketIndex === null || endBucketIndex === null) {
return;
@ -677,8 +650,8 @@
// 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);
const bucket = assetStore.buckets[bucketIndex];
await assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.assets) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset);
@ -690,7 +663,7 @@
// Update date group selection
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
const bucket = $assetStore.buckets[bucketIndex];
const bucket = assetStore.buckets[bucketIndex];
// Split bucket into date groups and check each group
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
@ -718,14 +691,14 @@
return;
}
let start = $assetStore.assets.findIndex((a) => a.id === startAsset.id);
let end = $assetStore.assets.findIndex((a) => a.id === endAsset.id);
let start = assetStore.assets.findIndex((a) => a.id === startAsset.id);
let end = assetStore.assets.findIndex((a) => a.id === endAsset.id);
if (start > end) {
[start, end] = [end, start];
}
assetInteraction.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1));
assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1));
};
const onSelectStart = (e: Event) => {
@ -737,7 +710,7 @@
assetStore.taskManager.removeAllTasksForComponent(componentId);
});
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived($assetStore.initialized && $assetStore.buckets.length === 0);
let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
$effect(() => {
@ -773,7 +746,7 @@
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteraction) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
];
@ -824,7 +797,7 @@
{#if showShortcuts}
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if}
{#if $assetStore.buckets.length > 0}
{#if assetStore.buckets.length > 0}
<Scrubber
invisible={showSkeleton}
{assetStore}
@ -864,21 +837,33 @@
bind:this={timelineElement}
id="virtual-timeline"
class:invisible={showSkeleton}
style:height={$assetStore.timelineHeight + 'px'}
style:height={assetStore.timelineHeight + 'px'}
>
{#each $assetStore.buckets as bucket (bucket.viewId)}
{#each assetStore.buckets as bucket (bucket.viewId)}
{@const isPremeasure = preMeasure.includes(bucket)}
{@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure}
{@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure}
<div
class="bucket"
use:intersectionObserver={{
key: bucket.viewId,
onIntersect: () => handleIntersect(bucket),
onSeparate: () => handleSeparate(bucket),
top: BUCKET_INTERSECTION_ROOT_TOP,
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
root: element,
}}
style:overflow={bucket.measured ? 'visible' : 'clip'}
use:intersectionObserver={[
{
key: bucket.viewId,
onIntersect: () => handleIntersect(bucket),
onSeparate: () => handleSeparate(bucket),
top: BUCKET_INTERSECTION_ROOT_TOP,
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
root: element,
},
{
key: bucket.viewId + '.bucketintersection',
onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate),
top: '0px',
bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px',
left: '0px',
right: '0px',
},
]}
data-bucket-display={bucket.intersecting}
data-bucket-date={bucket.bucketDate}
style:height={bucket.bucketHeight + 'px'}
@ -949,6 +934,5 @@
.bucket {
contain: layout size;
transition: height 0.2s ease-out;
}
</style>