feat: keyboard navigation to timeline (#17798)

* feat: improve focus

* feat: keyboard nav

* feat: improve focus

* typo

* test

* fix test

* lint

* bad merge

* lint

* inadvertent

* lint

* fix: flappy e2e test

* bad merge and fix tests

* use modulus in loop

* tests

* react to modal dialog refactor

* regression due to deferLayout

* Review comments

* Re-use change-date instead of new component

* bad merge

* Review comments

* rework moveFocus

* lint

* Fix outline

* use Date

* Finish up removing/reducing date parsing

* lint

* title

* strings

* Rework dates, rework earlier/later algorithm

* bad merge

* fix tests

* Fix race in scroll comp

* consolidate scroll methods

* Review comments

* console.log

* Edge cases in scroll compensation

* edge case, optimizations

* review comments

* lint

* lint

* More edge cases

* lint

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-05-28 09:55:14 -04:00 committed by GitHub
parent b5593823a2
commit f029910dc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1077 additions and 598 deletions

View file

@ -4,7 +4,12 @@
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
} from '$lib/components/photos-page/actions/focus-actions';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
@ -27,10 +32,10 @@
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import { type ScrubberListener, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import Portal from '../shared-components/portal/portal.svelte';
@ -90,8 +95,9 @@
let timelineElement: HTMLElement | undefined = $state();
let showSkeleton = $state(true);
let isShowSelectDate = $state(false);
let scrubBucketPercent = $state(0);
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
let scrubBucket: TimelinePlainYearMonth | undefined = $state();
let scrubOverallPercent: number = $state(0);
let scrubberWidth = $state(0);
@ -116,42 +122,69 @@
});
const scrollTo = (top: number) => {
element?.scrollTo({ top });
showSkeleton = false;
if (element) {
element.scrollTo({ top });
}
};
const scrollTop = (top: number) => {
if (element) {
element.scrollTop = top;
}
};
const scrollBy = (y: number) => {
if (element) {
element.scrollBy(0, y);
}
};
const scrollToTop = () => {
scrollTo(0);
};
const scrollToAsset = async (assetId: string) => {
try {
const bucket = await assetStore.findBucketForAsset(assetId);
if (bucket) {
const height = bucket.findAssetAbsolutePosition(assetId);
if (height) {
scrollTo(height);
assetStore.updateIntersections();
return true;
}
}
} catch {
// ignore errors - asset may not be in the store
const getAssetHeight = (assetId: string, bucket: AssetBucket) => {
// the following method may trigger any layouts, so need to
// handle any scroll compensation that may have been set
const height = bucket!.findAssetAbsolutePosition(assetId);
while (assetStore.scrollCompensation.bucket) {
handleScrollCompensation(assetStore.scrollCompensation);
assetStore.clearScrollCompensation();
}
return false;
return height;
};
const scrollToAssetId = async (assetId: string) => {
const bucket = await assetStore.findBucketForAsset(assetId);
if (!bucket) {
return false;
}
const height = getAssetHeight(assetId, bucket);
scrollTo(height);
updateSlidingWindow();
return true;
};
const scrollToAsset = (asset: TimelineAsset) => {
const bucket = assetStore.getBucketIndexByAssetId(asset.id);
if (!bucket) {
return false;
}
const height = getAssetHeight(asset.id, bucket);
scrollTo(height);
updateSlidingWindow();
return true;
};
const completeNav = async () => {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollToAsset(scrollTarget);
scrolled = await scrollToAssetId(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollToTop();
}
showSkeleton = false;
};
beforeNavigate(() => (assetStore.suspendTransitions = true));
@ -185,6 +218,7 @@
} else {
scrollToTop();
}
showSkeleton = false;
}, 500);
}
};
@ -204,23 +238,28 @@
const updateIsScrolling = () => (assetStore.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
const compensateScrollCallback = ({ delta, top }: { delta?: number; top?: number }) => {
if (delta) {
element?.scrollBy(0, delta);
} else if (top) {
element?.scrollTo({ top });
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
if (heightDelta !== undefined) {
scrollBy(heightDelta);
} else if (scrollTop !== undefined) {
scrollTo(scrollTop);
}
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
// the above calls. However, this delay is enough time to set the intersecting property
// of the bucket to false, then true, which causes the DOM nodes to be recreated,
// causing bad perf, and also, disrupting focus of those elements.
updateSlidingWindow();
};
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
onMount(() => {
assetStore.setCompensateScrollCallback(compensateScrollCallback);
if (!enableRouting) {
showSkeleton = false;
}
const disposeHmr = hmrSupport();
return () => {
assetStore.setCompensateScrollCallback();
disposeHmr();
};
});
@ -241,16 +280,14 @@
const topOffset = bucket.top;
const maxScrollPercent = getMaxScrollPercent();
const delta = bucket.bucketHeight * bucketScrollPercent;
const scrollTop = (topOffset + delta) * maxScrollPercent;
const scrollToTop = (topOffset + delta) * maxScrollPercent;
if (element) {
element.scrollTop = scrollTop;
}
scrollTop(scrollToTop);
};
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const onScrub: ScrubberListener = (
bucketDate: string | undefined,
bucketDate: { year: number; month: number } | undefined,
scrollPercent: number,
bucketScrollPercent: number,
) => {
@ -258,12 +295,11 @@
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll();
const offset = maxScroll * scrollPercent;
if (!element) {
return;
}
element.scrollTop = offset;
scrollTop(offset);
} else {
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
const bucket = assetStore.buckets.find(
(bucket) => bucket.yearMonth.year === bucketDate.year && bucket.yearMonth.month === bucketDate.month,
);
if (!bucket) {
return;
}
@ -303,7 +339,7 @@
const bucketsLength = assetStore.buckets.length;
for (let i = -1; i < bucketsLength + 1; i++) {
let bucket: { bucketDate: string | undefined } | undefined;
let bucket: TimelinePlainYearMonth | undefined;
let bucketHeight = 0;
if (i === -1) {
// lead-in
@ -312,7 +348,7 @@
// lead-out
bucketHeight = bottomSectionHeight;
} else {
bucket = assetStore.buckets[i];
bucket = assetStore.buckets[i].yearMonth;
bucketHeight = assetStore.buckets[i].bucketHeight;
}
@ -326,7 +362,7 @@
// compensate for lost precision/rounding errors advance to the next bucket, if present
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
scrubBucket = assetStore.buckets[i + 1];
scrubBucket = assetStore.buckets[i + 1].yearMonth;
scrubBucketPercent = 0;
}
@ -385,12 +421,6 @@
deselectAllAssets();
};
const focusElement = () => {
if (document.activeElement === document.body) {
element?.focus();
}
};
const handleSelectAsset = (asset: TimelineAsset) => {
if (!assetStore.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
@ -398,37 +428,36 @@
};
const handlePrevious = async () => {
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
const laterAsset = await assetStore.getLaterAsset($viewingAsset);
if (previousAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
const asset = await getAssetInfo({ id: previousAsset.id, key: authManager.key });
if (laterAsset) {
const preloadAsset = await assetStore.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ id: laterAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
return !!previousAsset;
return !!laterAsset;
};
const handleNext = async () => {
const nextAsset = await assetStore.getNextAsset($viewingAsset);
if (nextAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset);
const asset = await getAssetInfo({ id: nextAsset.id, key: authManager.key });
const earlierAsset = await assetStore.getEarlierAsset($viewingAsset);
if (earlierAsset) {
const preloadAsset = await assetStore.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ id: earlierAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
return !!nextAsset;
return !!earlierAsset;
};
const handleRandom = async () => {
const randomAsset = await assetStore.getRandomAsset();
if (randomAsset) {
const preloadAsset = await assetStore.getNextAsset(randomAsset);
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
}
@ -514,7 +543,7 @@
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
selectAssetCandidates(asset);
void selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
@ -532,7 +561,7 @@
}
}
if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
if (assetStore.count == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
@ -545,8 +574,8 @@
}
onSelect(asset);
if (singleSelect && element) {
element.scrollTop = 0;
if (singleSelect) {
scrollTop(0);
return;
}
@ -583,8 +612,8 @@
break;
}
if (started) {
await assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.getAssets()) {
await assetStore.loadBucket(bucket.yearMonth);
for (const asset of bucket.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
@ -623,7 +652,7 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = (endAsset: TimelineAsset) => {
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
@ -633,16 +662,8 @@
return;
}
const assets = assetsSnapshot(assetStore.getAssets());
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
if (start > end) {
[start, end] = [end, start];
}
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
const assets = assetsSnapshot(await assetStore.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
const onSelectStart = (e: Event) => {
@ -651,9 +672,6 @@
}
};
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
@ -675,6 +693,9 @@
}
});
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, assetStore);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
@ -686,10 +707,15 @@
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
];
if (assetInteraction.selectionActive) {
@ -720,7 +746,7 @@
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
selectAssetCandidates(lastAssetMouseEvent);
void selectAssetCandidates(lastAssetMouseEvent);
}
});
</script>
@ -735,6 +761,22 @@
/>
{/if}
{#if isShowSelectDate}
<ChangeDate
title="Navigate to Time"
initialDate={DateTime.now()}
timezoneInput={false}
onConfirm={async (dateString: string) => {
isShowSelectDate = false;
const asset = await assetStore.getClosestAssetToDate((DateTime.fromISO(dateString) as DateTime<true>).toObject());
if (asset) {
setFocusAsset(asset);
}
}}
onCancel={() => (isShowSelectDate = false)}
/>
{/if}
{#if assetStore.buckets.length > 0}
<Scrubber
{assetStore}
@ -828,6 +870,7 @@
onSelect={({ title, assets }) => handleGroupSelect(assetStore, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
onScrollCompensation={handleScrollCompensation}
/>
</div>
{/if}