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

@ -1417,7 +1417,10 @@
"preview": "Preview",
"previous": "Previous",
"previous_memory": "Previous memory",
"previous_or_next_photo": "Previous or next photo",
"previous_or_next_day": "Day forward/back",
"previous_or_next_month": "Month forward/back",
"previous_or_next_photo": "Photo forward/back",
"previous_or_next_year": "Year forward/back",
"primary": "Primary",
"privacy": "Privacy",
"profile": "Profile",

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}

View File

@ -25,6 +25,9 @@
shortcuts = {
general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
{ key: ['D', 'd'], action: $t('previous_or_next_day') },
{ key: ['M', 'm'], action: $t('previous_or_next_month') },
{ key: ['Y', 'y'], action: $t('previous_or_next_year') },
{ key: ['x'], action: $t('select') },
{ key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },

View File

@ -1,9 +1,18 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { AbortError } from '$lib/utils';
import { fromLocalDateTimeToObject } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { AssetStore, type TimelineAsset } from './assets-store.svelte';
async function getAssets(store: AssetStore) {
const assets = [];
for await (const asset of store.assetsIterator()) {
assets.push(asset);
}
return assets;
}
describe('AssetStore', () => {
beforeEach(() => {
vi.resetAllMocks();
@ -14,13 +23,13 @@ describe('AssetStore', () => {
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-03-01T00:00:00.000Z': timelineAssetFactory
.buildList(1)
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
'2024-02-01T00:00:00.000Z': timelineAssetFactory
.buildList(100)
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })),
'2024-01-01T00:00:00.000Z': timelineAssetFactory
.buildList(3)
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
};
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
@ -41,21 +50,21 @@ describe('AssetStore', () => {
it('should load buckets in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
});
it('calculates bucket height', () => {
const plainBuckets = assetStore.buckets.map((bucket) => ({
bucketDate: bucket.bucketDate,
year: bucket.yearMonth.year,
month: bucket.yearMonth.month,
bucketHeight: bucket.bucketHeight,
}));
expect(plainBuckets).toEqual(
expect.arrayContaining([
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 185.5 }),
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 12_016 }),
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
expect.objectContaining({ year: 2024, month: 3, bucketHeight: 185.5 }),
expect.objectContaining({ year: 2024, month: 2, bucketHeight: 12_016 }),
expect.objectContaining({ year: 2024, month: 1, bucketHeight: 286 }),
]),
);
});
@ -70,10 +79,10 @@ describe('AssetStore', () => {
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-01-03T00:00:00.000Z': timelineAssetFactory
.buildList(1)
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
'2024-01-01T00:00:00.000Z': timelineAssetFactory
.buildList(3)
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
};
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
@ -95,47 +104,47 @@ describe('AssetStore', () => {
});
it('loads a bucket', async () => {
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await assetStore.loadBucket({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('ignores invalid buckets', async () => {
await assetStore.loadBucket('2023-01-01T00:00:00.000Z');
await assetStore.loadBucket({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
});
it('cancels bucket loading', async () => {
const bucket = assetStore.getBucketByDate(2024, 1)!;
void assetStore.loadBucket(bucket!.bucketDate);
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 })!;
void assetStore.loadBucket({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(bucket!.loader!.cancelToken!, 'abort');
bucket?.cancel();
expect(abortSpy).toBeCalledTimes(1);
await assetStore.loadBucket(bucket!.bucketDate);
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
await assetStore.loadBucket({ year: 2024, month: 1 });
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('prevents loading buckets multiple times', async () => {
await Promise.all([
assetStore.loadBucket('2024-01-01T00:00:00.000Z'),
assetStore.loadBucket('2024-01-01T00:00:00.000Z'),
assetStore.loadBucket({ year: 2024, month: 1 }),
assetStore.loadBucket({ year: 2024, month: 1 }),
]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
await assetStore.loadBucket({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
});
it('allows loading a canceled bucket', async () => {
const bucket = assetStore.getBucketByDate(2024, 1)!;
const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 })!;
const loadPromise = assetStore.loadBucket({ year: 2024, month: 1 });
bucket.cancel();
await loadPromise;
expect(bucket?.getAssets().length).toEqual(0);
await assetStore.loadBucket(bucket.bucketDate);
await assetStore.loadBucket({ year: 2024, month: 1 });
expect(bucket!.getAssets().length).toEqual(3);
});
});
@ -152,48 +161,50 @@ describe('AssetStore', () => {
it('is empty initially', () => {
expect(assetStore.buckets.length).toEqual(0);
expect(assetStore.getAssets().length).toEqual(0);
expect(assetStore.count).toEqual(0);
});
it('adds assets to new bucket', () => {
const asset = timelineAssetFactory.build({
localDateTime: '2024-01-20T12:00:00.000Z',
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
assetStore.addAssets([asset]);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.getAssets().length).toEqual(1);
expect(assetStore.count).toEqual(1);
expect(assetStore.buckets[0].getAssets().length).toEqual(1);
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
expect(assetStore.getAssets()[0].id).toEqual(asset.id);
expect(assetStore.buckets[0].yearMonth.year).toEqual(2024);
expect(assetStore.buckets[0].yearMonth.month).toEqual(1);
expect(assetStore.buckets[0].getFirstAsset().id).toEqual(asset.id);
});
it('adds assets to existing bucket', () => {
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
localDateTime: '2024-01-20T12:00:00.000Z',
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
assetStore.addAssets([assetOne]);
assetStore.addAssets([assetTwo]);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.getAssets().length).toEqual(2);
expect(assetStore.count).toEqual(2);
expect(assetStore.buckets[0].getAssets().length).toEqual(2);
expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
expect(assetStore.buckets[0].yearMonth.year).toEqual(2024);
expect(assetStore.buckets[0].yearMonth.month).toEqual(1);
});
it('orders assets in buckets by descending date', () => {
const assetOne = timelineAssetFactory.build({
localDateTime: '2024-01-20T12:00:00.000Z',
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
const assetTwo = timelineAssetFactory.build({
localDateTime: '2024-01-15T12:00:00.000Z',
localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'),
});
const assetThree = timelineAssetFactory.build({
localDateTime: '2024-01-16T12:00:00.000Z',
localDateTime: fromLocalDateTimeToObject('2024-01-16T12:00:00.000Z'),
});
assetStore.addAssets([assetOne, assetTwo, assetThree]);
const bucket = assetStore.getBucketByDate(2024, 1);
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
expect(bucket).not.toBeNull();
expect(bucket?.getAssets().length).toEqual(3);
expect(bucket?.getAssets()[0].id).toEqual(assetOne.id);
@ -202,15 +213,26 @@ describe('AssetStore', () => {
});
it('orders buckets by descending date', () => {
const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' });
const assetThree = timelineAssetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' });
const assetOne = timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
const assetTwo = timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2024-04-20T12:00:00.000Z'),
});
const assetThree = timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2023-01-20T12:00:00.000Z'),
});
assetStore.addAssets([assetOne, assetTwo, assetThree]);
expect(assetStore.buckets.length).toEqual(3);
expect(assetStore.buckets[0].bucketDate).toEqual('2024-04-01T00:00:00.000Z');
expect(assetStore.buckets[1].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
expect(assetStore.buckets[2].bucketDate).toEqual('2023-01-01T00:00:00.000Z');
expect(assetStore.buckets[0].yearMonth.year).toEqual(2024);
expect(assetStore.buckets[0].yearMonth.month).toEqual(4);
expect(assetStore.buckets[1].yearMonth.year).toEqual(2024);
expect(assetStore.buckets[1].yearMonth.month).toEqual(1);
expect(assetStore.buckets[2].yearMonth.year).toEqual(2023);
expect(assetStore.buckets[2].yearMonth.month).toEqual(1);
});
it('updates existing asset', () => {
@ -220,7 +242,7 @@ describe('AssetStore', () => {
assetStore.addAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(assetStore.getAssets().length).toEqual(1);
expect(assetStore.count).toEqual(1);
});
// disabled due to the wasm Justified Layout import
@ -231,7 +253,7 @@ describe('AssetStore', () => {
const assetStore = new AssetStore();
await assetStore.updateOptions({ isTrashed: true });
assetStore.addAssets([asset, trashedAsset]);
expect(assetStore.getAssets()).toEqual([trashedAsset]);
expect(await getAssets(assetStore)).toEqual([trashedAsset]);
});
});
@ -249,7 +271,7 @@ describe('AssetStore', () => {
assetStore.updateAssets([timelineAssetFactory.build()]);
expect(assetStore.buckets.length).toEqual(0);
expect(assetStore.getAssets().length).toEqual(0);
expect(assetStore.count).toEqual(0);
});
it('updates an asset', () => {
@ -257,29 +279,31 @@ describe('AssetStore', () => {
const updatedAsset = { ...asset, isFavorite: true };
assetStore.addAssets([asset]);
expect(assetStore.getAssets().length).toEqual(1);
expect(assetStore.getAssets()[0].isFavorite).toEqual(false);
expect(assetStore.count).toEqual(1);
expect(assetStore.buckets[0].getFirstAsset().isFavorite).toEqual(false);
assetStore.updateAssets([updatedAsset]);
expect(assetStore.getAssets().length).toEqual(1);
expect(assetStore.getAssets()[0].isFavorite).toEqual(true);
expect(assetStore.count).toEqual(1);
expect(assetStore.buckets[0].getFirstAsset().isFavorite).toEqual(true);
});
it('asset moves buckets when asset date changes', () => {
const asset = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
const asset = timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
const updatedAsset = { ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-20T12:00:00.000Z') };
assetStore.addAssets([asset]);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(1);
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })).not.toBeUndefined();
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(1);
assetStore.updateAssets([updatedAsset]);
expect(assetStore.buckets.length).toEqual(2);
expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
expect(assetStore.getBucketByDate(2024, 3)).not.toBeUndefined();
expect(assetStore.getBucketByDate(2024, 3)?.getAssets().length).toEqual(1);
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })).not.toBeUndefined();
expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(0);
expect(assetStore.getBucketByDate({ year: 2024, month: 3 })).not.toBeUndefined();
expect(assetStore.getBucketByDate({ year: 2024, month: 3 })?.getAssets().length).toEqual(1);
});
});
@ -294,32 +318,36 @@ describe('AssetStore', () => {
});
it('ignores invalid IDs', () => {
assetStore.addAssets(timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
assetStore.addAssets(
timelineAssetFactory.buildList(2, { localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z') }),
);
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
expect(assetStore.getAssets().length).toEqual(2);
expect(assetStore.count).toEqual(2);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.buckets[0].getAssets().length).toEqual(2);
});
it('removes asset from bucket', () => {
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
localDateTime: '2024-01-20T12:00:00.000Z',
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetOne.id]);
expect(assetStore.getAssets().length).toEqual(1);
expect(assetStore.count).toEqual(1);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.buckets[0].getAssets().length).toEqual(1);
});
it('does not remove bucket when empty', () => {
const assets = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
const assets = timelineAssetFactory.buildList(2, {
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
assetStore.addAssets(assets);
assetStore.removeAssets(assets.map((asset) => asset.id));
expect(assetStore.getAssets().length).toEqual(0);
expect(assetStore.count).toEqual(0);
expect(assetStore.buckets.length).toEqual(1);
});
});
@ -339,28 +367,28 @@ describe('AssetStore', () => {
it('populated store returns first asset', () => {
const assetOne = timelineAssetFactory.build({
localDateTime: '2024-01-20T12:00:00.000Z',
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
const assetTwo = timelineAssetFactory.build({
localDateTime: '2024-01-15T12:00:00.000Z',
localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'),
});
assetStore.addAssets([assetOne, assetTwo]);
expect(assetStore.getFirstAsset()).toEqual(assetOne);
});
});
describe('getPreviousAsset', () => {
describe('getLaterAsset', () => {
let assetStore: AssetStore;
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-03-01T00:00:00.000Z': timelineAssetFactory
.buildList(1)
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
'2024-02-01T00:00:00.000Z': timelineAssetFactory
.buildList(6)
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })),
'2024-01-01T00:00:00.000Z': timelineAssetFactory
.buildList(3)
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
};
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
@ -378,58 +406,59 @@ describe('AssetStore', () => {
});
it('returns null for invalid assetId', async () => {
expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
expect(() => assetStore.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
expect(await assetStore.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
});
it('returns previous assetId', async () => {
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
const bucket = assetStore.getBucketByDate(2024, 1);
await assetStore.loadBucket({ year: 2024, month: 1 });
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
const a = bucket!.getAssets()[0];
const b = bucket!.getAssets()[1];
const previous = await assetStore.getPreviousAsset(b);
const previous = await assetStore.getLaterAsset(b);
expect(previous).toEqual(a);
});
it('returns previous assetId spanning multiple buckets', async () => {
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
await assetStore.loadBucket({ year: 2024, month: 2 });
await assetStore.loadBucket({ year: 2024, month: 3 });
const bucket = assetStore.getBucketByDate(2024, 2);
const previousBucket = assetStore.getBucketByDate(2024, 3);
const bucket = assetStore.getBucketByDate({ year: 2024, month: 2 });
const previousBucket = assetStore.getBucketByDate({ year: 2024, month: 3 });
const a = bucket!.getAssets()[0];
const b = previousBucket!.getAssets()[0];
const previous = await assetStore.getPreviousAsset(a);
const previous = await assetStore.getLaterAsset(a);
expect(previous).toEqual(b);
});
it('loads previous bucket', async () => {
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
const bucket = assetStore.getBucketByDate(2024, 2);
const previousBucket = assetStore.getBucketByDate(2024, 3);
const a = bucket!.getAssets()[0];
const b = previousBucket!.getAssets()[0];
const previous = await assetStore.getPreviousAsset(a);
await assetStore.loadBucket({ year: 2024, month: 2 });
const bucket = assetStore.getBucketByDate({ year: 2024, month: 2 });
const previousBucket = assetStore.getBucketByDate({ year: 2024, month: 3 });
const a = bucket!.getFirstAsset();
const b = previousBucket!.getFirstAsset();
const loadBucketSpy = vi.spyOn(bucket!.loader!, 'execute');
const previousBucketSpy = vi.spyOn(previousBucket!.loader!, 'execute');
const previous = await assetStore.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadBucketSpy).toBeCalledTimes(1);
expect(loadBucketSpy).toBeCalledTimes(0);
expect(previousBucketSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
await assetStore.loadBucket({ year: 2024, month: 1 });
await assetStore.loadBucket({ year: 2024, month: 2 });
await assetStore.loadBucket({ year: 2024, month: 3 });
const [assetOne, assetTwo, assetThree] = assetStore.getAssets();
const [assetOne, assetTwo, assetThree] = await getAssets(assetStore);
assetStore.removeAssets([assetTwo.id]);
expect(await assetStore.getPreviousAsset(assetThree)).toEqual(assetOne);
expect(await assetStore.getLaterAsset(assetThree)).toEqual(assetOne);
});
it('returns null when no more assets', async () => {
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
expect(await assetStore.getPreviousAsset(assetStore.getAssets()[0])).toBeUndefined();
await assetStore.loadBucket({ year: 2024, month: 3 });
expect(await assetStore.getLaterAsset(assetStore.buckets[0].getFirstAsset())).toBeUndefined();
});
});
@ -444,26 +473,37 @@ describe('AssetStore', () => {
});
it('returns null for invalid buckets', () => {
expect(assetStore.getBucketByDate(-1, -1)).toBeUndefined();
expect(assetStore.getBucketByDate(2024, 3)).toBeUndefined();
expect(assetStore.getBucketByDate({ year: -1, month: -1 })).toBeUndefined();
expect(assetStore.getBucketByDate({ year: 2024, month: 3 })).toBeUndefined();
});
it('returns the bucket index', () => {
const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
const assetOne = timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
const assetTwo = timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'),
});
assetStore.addAssets([assetOne, assetTwo]);
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
});
it('ignores removed buckets', () => {
const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
const assetOne = timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
const assetTwo = timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'),
});
assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetTwo.id]);
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -477,13 +477,13 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
try {
for (const bucket of assetStore.buckets) {
await assetStore.loadBucket(bucket.bucketDate);
await assetStore.loadBucket(bucket.yearMonth);
if (!get(isSelectingAllAssets)) {
assetInteraction.clearMultiselect();
break; // Cancelled
}
assetInteraction.selectAssets(assetsSnapshot(bucket.getAssets()));
assetInteraction.selectAssets(assetsSnapshot([...bucket.assetsIterator()]));
for (const dateGroup of bucket.dateGroups) {
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle);

View File

@ -12,28 +12,43 @@ export const setDefaultTabbleOptions = (options: TabbableOpts) => {
export const getTabbable = (container: Element, includeContainer: boolean = false) =>
tabbable(container, { ...defaultOpts, includeContainer });
export const focusNext = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
const focusElements = focusable(document.body, { includeContainer: true });
const current = document.activeElement as HTMLElement;
const index = focusElements.indexOf(current);
if (index === -1) {
for (const element of focusElements) {
if (selector(element)) {
element.focus();
return;
}
}
focusElements[0].focus();
export const moveFocus = (
selector: (element: HTMLElement | SVGElement) => boolean,
direction: 'previous' | 'next',
): void => {
const focusableElements = focusable(document.body, { includeContainer: true });
if (focusableElements.length === 0) {
return;
}
const totalElements = focusElements.length;
let i = index;
const currentElement = document.activeElement as HTMLElement | null;
const currentIndex = currentElement ? focusableElements.indexOf(currentElement) : -1;
// If no element is focused, focus the first matching element or the first focusable element
if (currentIndex === -1) {
const firstMatchingElement = focusableElements.find((element) => selector(element));
if (firstMatchingElement) {
firstMatchingElement.focus();
} else if (focusableElements[0]) {
focusableElements[0].focus();
}
return;
}
// Calculate the step direction
const step = direction === 'next' ? 1 : -1;
const totalElements = focusableElements.length;
// Search for the next focusable element that matches the selector
let nextIndex = currentIndex;
do {
i = (i + (forwardDirection ? 1 : -1) + totalElements) % totalElements;
const next = focusElements[i];
if (isTabbable(next) && selector(next)) {
next.focus();
nextIndex = (nextIndex + step + totalElements) % totalElements;
const candidateElement = focusableElements[nextIndex];
if (isTabbable(candidateElement) && selector(candidateElement)) {
candidateElement.focus();
break;
}
} while (i !== index);
} while (nextIndex !== currentIndex);
};

View File

@ -0,0 +1,53 @@
/**
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
* This class helps manage concurrent operations by tracking which invocations are active
* and allowing operations to check if they're still valid.
*/
export class InvocationTracker {
/** Counter for the number of invocations that have been started */
invocationsStarted = 0;
/** Counter for the number of invocations that have been completed */
invocationsEnded = 0;
constructor() {}
/**
* Starts a new invocation and returns an object with utilities to manage the invocation lifecycle.
* @returns An object containing methods to manage the invocation:
* - isInvalidInvocationError: Checks if an error is an invalid invocation error
* - checkStillValid: Throws an error if the invocation is no longer valid
* - endInvocation: Marks the invocation as complete
*/
startInvocation() {
this.invocationsStarted++;
const invocation = this.invocationsStarted;
return {
/**
* Throws an error if this invocation is no longer valid
* @throws {Error} If the invocation is no longer valid
*/
isStillValid: () => {
if (invocation !== this.invocationsStarted) {
return false;
}
return true;
},
/**
* Marks this invocation as complete
*/
endInvocation: () => {
this.invocationsEnded = invocation;
},
};
}
/**
* Checks if there are any active invocations
* @returns True if there are active invocations, false otherwise
*/
isActive() {
return this.invocationsStarted !== this.invocationsEnded;
}
}

View File

@ -56,12 +56,21 @@ describe('getAltText', () => {
people?: Person[];
expected: string;
}) => {
const testDate = new Date('2024-01-01T12:00:00.000Z');
const asset: TimelineAsset = {
id: 'test-id',
ownerId: 'test-owner',
ratio: 1,
thumbhash: null,
localDateTime: '2024-01-01T12:00:00.000Z',
localDateTime: {
year: testDate.getUTCFullYear(),
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
day: testDate.getUTCDate(),
hour: testDate.getUTCHours(),
minute: testDate.getUTCMinutes(),
second: testDate.getUTCSeconds(),
millisecond: testDate.getUTCMilliseconds(),
},
visibility: AssetVisibility.Timeline,
isFavorite: false,
isTrashed: false,

View File

@ -1,8 +1,8 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store';
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
import { t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
import { fromLocalDateTime } from './timeline-util';
/**
* Calculate thumbnail size based on number of assets and viewport width
@ -40,7 +40,10 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
export const getAltText = derived(t, ($t) => {
return (asset: TimelineAsset) => {
const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
const date = fromTimelinePlainDateTime(asset.localDateTime).toJSDate().toLocaleString(get(locale), {
dateStyle: 'long',
timeZone: 'UTC',
});
const hasPlace = asset.city && asset.country;
const peopleCount = asset.people.length;

View File

@ -1,15 +1,12 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { memoize } from 'lodash-es';
import { DateTime, type LocaleOptions } from 'luxon';
import { get } from 'svelte/store';
export type ScrubberListener = (
bucketDate: string | undefined,
bucketDate: { year: number; month: number },
overallScrollPercent: number,
bucketScrollPercent: number,
) => void | Promise<void>;
@ -17,8 +14,44 @@ export type ScrubberListener = (
export const fromLocalDateTime = (localDateTime: string) =>
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
export const fromLocalDateTimeToObject = (localDateTime: string): TimelinePlainDateTime =>
(fromLocalDateTime(localDateTime) as DateTime<true>).toObject();
export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime<true> =>
DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime<true>;
export const fromTimelinePlainDate = (timelineYearMonth: TimelinePlainDate): DateTime<true> =>
DateTime.fromObject(
{ year: timelineYearMonth.year, month: timelineYearMonth.month, day: timelineYearMonth.day },
{ zone: 'local', locale: get(locale) },
) as DateTime<true>;
export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearMonth): DateTime<true> =>
DateTime.fromObject(
{ year: timelineYearMonth.year, month: timelineYearMonth.month },
{ zone: 'local', locale: get(locale) },
) as DateTime<true>;
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
DateTime.fromISO(dateTimeOriginal, { zone: timeZone });
DateTime.fromISO(dateTimeOriginal, { zone: timeZone, locale: get(locale) });
export const toISOLocalDateTime = (timelineYearMonth: TimelinePlainYearMonth): string =>
(fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toISO();
export function formatBucketTitle(_date: DateTime): string {
if (!_date.isValid) {
return _date.toString();
}
const date = _date as DateTime<true>;
return date.toLocaleString(
{
month: 'short',
year: 'numeric',
timeZone: 'UTC',
},
{ locale: get(locale) },
);
}
export function formatGroupTitle(_date: DateTime): string {
if (!_date.isValid) {
@ -60,8 +93,6 @@ export function formatGroupTitle(_date: DateTime): string {
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
export const formatDateGroupTitle = memoize(formatGroupTitle);
export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => {
if (isTimelineAsset(unknownAsset)) {
return unknownAsset;
@ -78,7 +109,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
ownerId: assetResponse.ownerId,
ratio,
thumbhash: assetResponse.thumbhash,
localDateTime: assetResponse.localDateTime,
localDateTime: fromLocalDateTimeToObject(assetResponse.localDateTime),
isFavorite: assetResponse.isFavorite,
visibility: assetResponse.visibility,
isTrashed: assetResponse.isTrashed,
@ -93,5 +124,46 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
people,
};
};
export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
(unknownAsset as TimelineAsset).ratio !== undefined;
export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTime, b: TimelinePlainDateTime) => {
const [aDateTime, bDateTime] = ascending ? [a, b] : [b, a];
if (aDateTime.year !== bDateTime.year) {
return aDateTime.year - bDateTime.year;
}
if (aDateTime.month !== bDateTime.month) {
return aDateTime.month - bDateTime.month;
}
if (aDateTime.day !== bDateTime.day) {
return aDateTime.day - bDateTime.day;
}
if (aDateTime.hour !== bDateTime.hour) {
return aDateTime.hour - bDateTime.hour;
}
if (aDateTime.minute !== bDateTime.minute) {
return aDateTime.minute - bDateTime.minute;
}
if (aDateTime.second !== bDateTime.second) {
return aDateTime.second - bDateTime.second;
}
return aDateTime.millisecond - bDateTime.millisecond;
};
export type TimelinePlainDateTime = TimelinePlainDate & {
hour: number;
minute: number;
second: number;
millisecond: number;
};
export type TimelinePlainDate = TimelinePlainYearMonth & {
day: number;
};
export type TimelinePlainYearMonth = {
year: number;
month: number;
};

View File

@ -174,7 +174,7 @@
const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle
? await assetStore.getRandomAsset()
: assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset;
: assetStore.buckets[0]?.dateGroups[0]?.intersectingAssets[0]?.asset;
if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
}

View File

@ -1,4 +1,5 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { fromLocalDateTimeToObject, fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
import { faker } from '@faker-js/faker';
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts';
@ -33,7 +34,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
ratio: Sync.each(() => faker.number.int()),
ownerId: Sync.each(() => faker.string.uuid()),
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
localDateTime: Sync.each(() => faker.date.past().toISOString()),
localDateTime: Sync.each(() => fromLocalDateTimeToObject(faker.date.past().toISOString())),
isFavorite: Sync.each(() => faker.datatype.boolean()),
visibility: AssetVisibility.Timeline,
isTrashed: false,
@ -76,7 +77,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
bucketAssets.isImage.push(asset.isImage);
bucketAssets.isTrashed.push(asset.isTrashed);
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
bucketAssets.localDateTime.push(asset.localDateTime);
bucketAssets.localDateTime.push(fromTimelinePlainDateTime(asset.localDateTime).toISO());
bucketAssets.ownerId.push(asset.ownerId);
bucketAssets.projectionType.push(asset.projectionType!);
bucketAssets.ratio.push(asset.ratio);