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

@ -2,7 +2,7 @@ import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observe
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory';
import { fireEvent, render } from '@testing-library/svelte';
import { render } from '@testing-library/svelte';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', {
@ -45,34 +45,4 @@ describe('Thumbnail component', () => {
const tabbables = getTabbable(container!);
expect(tabbables.length).toBe(0);
});
it('handleFocus should be called on focus of container', async () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const handleFocusSpy = vi.fn();
const { baseElement } = render(Thumbnail, {
asset,
handleFocus: handleFocusSpy,
});
const container = baseElement.querySelector('[data-thumbnail-focus-container]');
expect(container).not.toBeNull();
await fireEvent(container as HTMLElement, new FocusEvent('focus'));
expect(handleFocusSpy).toBeCalled();
});
it('element will be focussed if not already', async () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const handleFocusSpy = vi.fn();
const { baseElement } = render(Thumbnail, {
asset,
handleFocus: handleFocusSpy,
});
const container = baseElement.querySelector('[data-thumbnail-focus-container]');
expect(container).not.toBeNull();
await fireEvent(container as HTMLElement, new FocusEvent('focus'));
expect(handleFocusSpy).toBeCalled();
});
});

View file

@ -20,7 +20,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables';
import { onMount } from 'svelte';
@ -48,7 +48,6 @@
onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
handleFocus?: () => void;
}
let {
@ -67,7 +66,6 @@
onClick = undefined,
onSelect = undefined,
onMouseEvent = undefined,
handleFocus = undefined,
imageClass = '',
brokenAssetClass = '',
dimmed = false,
@ -140,12 +138,14 @@
let startX: number = 0;
let startY: number = 0;
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
let didPress = false;
const start = (evt: PointerEvent) => {
startX = evt.clientX;
startY = evt.clientY;
didPress = false;
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
timer = setTimeout(() => {
onLongPress();
element.addEventListener('contextmenu', preventContextMenu, { once: true });
@ -193,14 +193,41 @@
</script>
<div
data-asset={asset.id}
class={[
'focus-visible:outline-none flex overflow-hidden',
disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20',
]}
style:width="{width}px"
style:height="{height}px"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === element && evt.key === 'Escape') {
moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, 'next');
}
}}
onclick={handleClick}
bind:this={element}
data-asset={asset.id}
data-thumbnail-focus-container
tabindex={0}
role="link"
>
<!-- Outline on focus -->
<div
class={[
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
{ 'rounded-xl': selected },
]}
data-outline
></div>
{#if (!loaded || thumbError) && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
@ -211,36 +238,10 @@
></canvas>
{/if}
<!-- as of iOS17, there is a preference for long press speed, which is not available for mobile web.
The defaults are as follows:
fast: 200ms
default: 500ms
slow: ??ms
-->
<div
class={['group absolute -top-[0px] -bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
style:width="inherit"
style:height="inherit"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === element && evt.key === 'Escape') {
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
}
}}
onclick={handleClick}
bind:this={element}
onfocus={handleFocus}
data-thumbnail-focus-container
tabindex={0}
role="link"
>
<div
class={[
@ -265,13 +266,6 @@
{#if dimmed && !mouseOver}
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
{/if}
<!-- Outline on focus -->
<div
class={[
'absolute size-full group-focus-visible:outline-immich-primary focus:outline-4 -outline-offset-4 outline-immich-primary',
{ 'rounded-xl': selected },
]}
></div>
<!-- Favorite asset star -->
{#if !authManager.key && asset.isFavorite}
@ -372,7 +366,6 @@
class={['absolute p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
role="checkbox"
tabindex={-1}
onfocus={handleFocus}
aria-checked={selected}
{disabled}
>
@ -389,3 +382,9 @@
{/if}
</div>
</div>
<style>
[data-asset]:focus > [data-outline] {
outline-style: solid;
}
</style>

View file

@ -8,9 +8,11 @@
id?: string;
name?: string;
placeholder?: string;
autofocus?: boolean;
onkeydown?: (e: KeyboardEvent) => void;
}
let { type, value = $bindable(), max = undefined, ...rest }: Props = $props();
let { type, value = $bindable(), max = undefined, onkeydown, ...rest }: Props = $props();
let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
@ -30,5 +32,6 @@
if (e.key === 'Enter') {
value = updatedValue;
}
onkeydown?.(e);
}}
/>

View file

@ -0,0 +1,78 @@
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
import { moveFocus } from '$lib/utils/focus-util';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { tick } from 'svelte';
const tracker = new InvocationTracker();
const getFocusedThumb = () => {
const current = document.activeElement as HTMLElement | undefined;
if (current && current.dataset.thumbnailFocusContainer !== undefined) {
return current;
}
};
export const focusNextAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
export const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
const scrolled = scrollToAsset(asset);
if (scrolled) {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
}
};
export const setFocusTo = async (
scrollToAsset: (asset: TimelineAsset) => boolean,
store: AssetStore,
direction: 'earlier' | 'later',
interval: 'day' | 'month' | 'year' | 'asset',
) => {
if (tracker.isActive()) {
// there are unfinished running invocations, so return early
return;
}
const thumb = getFocusedThumb();
if (!thumb) {
return direction === 'earlier' ? focusNextAsset() : focusPreviousAsset();
}
const invocation = tracker.startInvocation();
const id = thumb.dataset.asset;
if (!thumb || !id) {
invocation.endInvocation();
return;
}
const asset =
direction === 'earlier'
? await store.getEarlierAsset({ id }, interval)
: await store.getLaterAsset({ id }, interval);
if (!invocation.isStillValid()) {
return;
}
if (!asset) {
invocation.endInvocation();
return;
}
const scrolled = scrollToAsset(asset);
if (scrolled) {
await tick();
if (!invocation.isStillValid()) {
return;
}
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
}
invocation.endInvocation();
};

View file

@ -10,15 +10,13 @@
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import { getDateLocaleString } from '$lib/utils/timeline-util';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fly, scale } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { flip } from 'svelte/animate';
import { uploadAssetsStore } from '$lib/stores/upload';
import { flip } from 'svelte/animate';
let { isUploading } = uploadAssetsStore;
@ -34,6 +32,7 @@
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
}
let {
@ -47,6 +46,7 @@
onSelect,
onSelectAssets,
onSelectAssetCandidates,
onScrollCompensation,
}: Props = $props();
let isMouseOverGroup = $state(false);
@ -84,7 +84,7 @@
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
if (assetStore.count == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
@ -103,9 +103,16 @@
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting);
}
$effect.root(() => {
if (assetStore.scrollCompensation.bucket === bucket) {
onScrollCompensation(assetStore.scrollCompensation);
assetStore.clearScrollCompensation();
}
});
</script>
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)}
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.day)}
{@const absoluteWidth = dateGroup.left}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@ -146,7 +153,7 @@
</div>
{/if}
<span class="w-full truncate first-letter:capitalize ms-2.5" title={getDateLocaleString(dateGroup.date)}>
<span class="w-full truncate first-letter:capitalize ms-2.5" title={dateGroup.groupTitle}>
{dateGroup.groupTitle}
</span>
</div>
@ -158,7 +165,7 @@
style:height={dateGroup.height + 'px'}
style:width={dateGroup.width + 'px'}
>
{#each filterIntersecting(dateGroup.intersetingAssets) as intersectingAsset (intersectingAsset.id)}
{#each filterIntersecting(dateGroup.intersectingAssets) as intersectingAsset (intersectingAsset.id)}
{@const position = intersectingAsset.position!}
{@const asset = intersectingAsset.asset!}

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}

View file

@ -6,13 +6,22 @@
import Combobox, { type ComboBoxOption } from './combobox.svelte';
interface Props {
title?: string;
initialDate?: DateTime;
initialTimeZone?: string;
timezoneInput?: boolean;
onCancel: () => void;
onConfirm: (date: string) => void;
}
let { initialDate = DateTime.now(), initialTimeZone = '', onCancel, onConfirm }: Props = $props();
let {
initialDate = DateTime.now(),
initialTimeZone = '',
title = $t('edit_date_and_time'),
timezoneInput = true,
onCancel,
onConfirm,
}: Props = $props();
type ZoneOption = {
/**
@ -135,7 +144,7 @@
<ConfirmModal
confirmColor="primary"
title={$t('edit_date_and_time')}
{title}
prompt="Please select a new date:"
disabled={!date.isValid}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
@ -148,15 +157,17 @@
<label for="datetime">{$t('date_and_time')}</label>
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
</div>
<div>
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => handleOnSelect(option)}
/>
</div>
{#if timezoneInput}
<div>
<Combobox
bind:selectedOption
label={$t('timezone')}
options={timezones}
placeholder={$t('search_timezone')}
onSelect={(option) => handleOnSelect(option)}
/>
</div>
{/if}
</div>
{/snippet}
</ConfirmModal>

View file

@ -14,7 +14,7 @@
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
@ -271,8 +271,9 @@
}
};
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;

View file

@ -3,10 +3,9 @@
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition';
@ -17,7 +16,7 @@
assetStore: AssetStore;
scrubOverallPercent?: number;
scrubBucketPercent?: number;
scrubBucket?: { bucketDate: string | undefined };
scrubBucket?: { year: number; month: number };
leadout?: boolean;
scrubberWidth?: number;
onScrub?: ScrubberListener;
@ -81,7 +80,7 @@
});
const toScrollFromBucketPercentage = (
scrubBucket: { bucketDate: string | undefined } | undefined,
scrubBucket: { year: number; month: number } | undefined,
scrubBucketPercent: number,
scrubOverallPercent: number,
) => {
@ -89,7 +88,7 @@
let offset = relativeTopOffset;
let match = false;
for (const segment of segments) {
if (segment.bucketDate === scrubBucket.bucketDate) {
if (segment.month === scrubBucket.month && segment.year === scrubBucket.year) {
offset += scrubBucketPercent * segment.height;
match = true;
break;
@ -120,8 +119,8 @@
count: number;
height: number;
dateFormatted: string;
bucketDate: string;
date: DateTime;
year: number;
month: number;
hasLabel: boolean;
hasDot: boolean;
};
@ -141,9 +140,9 @@
top,
count: bucket.assetCount,
height: toScrollY(scrollBarPercentage),
bucketDate: bucket.bucketDate,
date: fromLocalDateTime(bucket.bucketDate),
dateFormatted: bucket.bucketDateFormattted,
year: bucket.year,
month: bucket.month,
hasLabel: false,
hasDot: false,
};
@ -153,7 +152,7 @@
segment.hasLabel = true;
previousLabeledSegment = segment;
} else {
if (previousLabeledSegment?.date?.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
if (previousLabeledSegment?.year !== segment.year && height > MIN_YEAR_LABEL_DISTANCE) {
height = 0;
segment.hasLabel = true;
previousLabeledSegment = segment;
@ -182,7 +181,13 @@
}
return activeSegment?.dataset.label;
});
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
const bucketDate = $derived.by(() => {
if (!activeSegment?.dataset.timeSegmentBucketDate) {
return undefined;
}
const [year, month] = activeSegment.dataset.timeSegmentBucketDate.split('-').map(Number);
return { year, month };
});
const scrollSegment = $derived.by(() => {
const y = scrollY;
let cur = relativeTopOffset;
@ -289,12 +294,12 @@
const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) {
void startScrub?.(bucketDate, scrollPercent, bucketPercentY);
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
void startScrub?.(bucketDate!, scrollPercent, bucketPercentY);
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
}
if (wasDragging && !isDragging) {
void stopScrub?.(bucketDate, scrollPercent, bucketPercentY);
void stopScrub?.(bucketDate!, scrollPercent, bucketPercentY);
return;
}
@ -302,7 +307,7 @@
return;
}
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
};
const getTouch = (event: TouchEvent) => {
if (event.touches.length === 1) {
@ -404,7 +409,7 @@
}
if (next) {
event.preventDefault();
void onScrub?.(next.bucketDate, -1, 0);
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
return true;
}
}
@ -414,7 +419,7 @@
const next = segments[idx + 1];
if (next) {
event.preventDefault();
void onScrub?.(next.bucketDate, -1, 0);
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
return true;
}
}
@ -517,7 +522,7 @@
class="relative"
style:height={relativeTopOffset + 'px'}
data-id="lead-in"
data-time-segment-bucket-date={segments.at(0)?.date}
data-time-segment-bucket-date={segments.at(0)?.year + '-' + segments.at(0)?.month}
data-label={segments.at(0)?.dateFormatted}
>
{#if relativeTopOffset > 6}
@ -525,18 +530,18 @@
{/if}
</div>
<!-- Time Segment -->
{#each segments as segment (segment.date)}
{#each segments as segment (segment.year + '-' + segment.month)}
<div
class="relative"
data-id="time-segment"
data-time-segment-bucket-date={segment.date}
data-time-segment-bucket-date={segment.year + '-' + segment.month}
data-label={segment.dateFormatted}
style:height={segment.height + 'px'}
>
{#if !usingMobileDevice}
{#if segment.hasLabel}
<div class="absolute end-5 top-[-16px] text-[12px] dark:text-immich-dark-fg font-immich-mono">
{segment.date.year}
{segment.year}
</div>
{/if}
{#if segment.hasDot}