mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
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:
parent
b5593823a2
commit
f029910dc7
21 changed files with 1077 additions and 598 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue