From f029910dc7e1988598e300099ba8f1feb5246e81 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 28 May 2025 09:55:14 -0400 Subject: [PATCH] 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 --- i18n/en.json | 5 +- .../thumbnail/__test__/thumbnail.spec.ts | 32 +- .../assets/thumbnail/thumbnail.svelte | 75 +- .../lib/components/elements/date-input.svelte | 5 +- .../photos-page/actions/focus-actions.ts | 78 ++ .../photos-page/asset-date-group.svelte | 21 +- .../components/photos-page/asset-grid.svelte | 217 +++--- .../shared-components/change-date.svelte | 33 +- .../gallery-viewer/gallery-viewer.svelte | 7 +- .../scrubber/scrubber.svelte | 47 +- web/src/lib/modals/ShortcutsModal.svelte | 3 + web/src/lib/stores/assets-store.spec.ts | 248 ++++--- web/src/lib/stores/assets-store.svelte.ts | 681 +++++++++++------- web/src/lib/utils/asset-utils.ts | 4 +- web/src/lib/utils/focus-util.ts | 53 +- web/src/lib/utils/invocationTracker.ts | 53 ++ web/src/lib/utils/thumbnail-util.spec.ts | 11 +- web/src/lib/utils/thumbnail-util.ts | 7 +- web/src/lib/utils/timeline-util.ts | 88 ++- .../[[assetId=id]]/+page.svelte | 2 +- web/src/test-data/factories/asset-factory.ts | 5 +- 21 files changed, 1077 insertions(+), 598 deletions(-) create mode 100644 web/src/lib/components/photos-page/actions/focus-actions.ts create mode 100644 web/src/lib/utils/invocationTracker.ts diff --git a/i18n/en.json b/i18n/en.json index 10d332f3bf..d6f31a65f0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts index 1d9340970d..21466780e8 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts @@ -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(); - }); }); diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 5e71fc4678..1f9a29268f 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -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 @@
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" > + +
{#if (!loaded || thumbError) && asset.thumbhash} {/if} -
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" >
{/if} - -
{#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}
+ + diff --git a/web/src/lib/components/elements/date-input.svelte b/web/src/lib/components/elements/date-input.svelte index d5fb77a24f..a93d2e7cb8 100644 --- a/web/src/lib/components/elements/date-input.svelte +++ b/web/src/lib/components/elements/date-input.svelte @@ -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); }} /> diff --git a/web/src/lib/components/photos-page/actions/focus-actions.ts b/web/src/lib/components/photos-page/actions/focus-actions.ts new file mode 100644 index 0000000000..5085faa0a3 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/focus-actions.ts @@ -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(); +}; diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index f963221b35..49fac572e2 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -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(intersectable: R[]) { return intersectable.filter((int) => int.intersecting); } + + $effect.root(() => { + if (assetStore.scrollCompensation.bucket === bucket) { + onScrollCompensation(assetStore.scrollCompensation); + assetStore.clearScrollCompensation(); + } + }); -{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)} +{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.day)} {@const absoluteWidth = dateGroup.left} @@ -146,7 +153,7 @@ {/if} - + {dateGroup.groupTitle} @@ -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!} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index e98d71b831..d7a963102c 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -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); } }); @@ -735,6 +761,22 @@ /> {/if} +{#if isShowSelectDate} + { + isShowSelectDate = false; + const asset = await assetStore.getClosestAssetToDate((DateTime.fromISO(dateString) as DateTime).toObject()); + if (asset) { + setFocusAsset(asset); + } + }} + onCancel={() => (isShowSelectDate = false)} + /> +{/if} + {#if assetStore.buckets.length > 0} handleGroupSelect(assetStore, title, assets)} onSelectAssetCandidates={handleSelectAssetCandidates} onSelectAssets={handleSelectAssets} + onScrollCompensation={handleScrollCompensation} /> {/if} diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index 3dd3d2cd82..472e3b11a1 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -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 @@ (confirmed ? handleConfirm() : onCancel())} @@ -148,15 +157,17 @@ -
- handleOnSelect(option)} - /> -
+ {#if timezoneInput} +
+ handleOnSelect(option)} + /> +
+ {/if} {/snippet}
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 6b0a25a84b..4e40ad5208 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -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; diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte index 26a2e3f143..c18c7c83b0 100644 --- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -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} - {#each segments as segment (segment.date)} + {#each segments as segment (segment.year + '-' + segment.month)}
{#if !usingMobileDevice} {#if segment.hasLabel}
- {segment.date.year} + {segment.year}
{/if} {#if segment.hasDot} diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte index 2f16eaa817..dc355d42f1 100644 --- a/web/src/lib/modals/ShortcutsModal.svelte +++ b/web/src/lib/modals/ShortcutsModal.svelte @@ -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') }, diff --git a/web/src/lib/stores/assets-store.spec.ts b/web/src/lib/stores/assets-store.spec.ts index 7864c9618b..c6e7686cdc 100644 --- a/web/src/lib/stores/assets-store.spec.ts +++ b/web/src/lib/stores/assets-store.spec.ts @@ -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 = { '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 = 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 = { '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 = 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 = { '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 = 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); }); }); }); diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index ff06887900..36b4da573a 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -1,6 +1,5 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; -import { locale } from '$lib/stores/preferences.store'; import { CancellableTask } from '$lib/utils/cancellable-task'; import { getJustifiedLayoutFromAssets, @@ -8,7 +7,20 @@ import { type CommonLayoutOptions, type CommonPosition, } from '$lib/utils/layout-utils'; -import { formatDateGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util'; +import { + formatBucketTitle, + formatGroupTitle, + fromLocalDateTimeToObject, + fromTimelinePlainDate, + fromTimelinePlainDateTime, + fromTimelinePlainYearMonth, + plainDateTimeCompare, + toISOLocalDateTime, + toTimelineAsset, + type TimelinePlainDate, + type TimelinePlainDateTime, + type TimelinePlainYearMonth, +} from '$lib/utils/timeline-util'; import { TUNABLES } from '$lib/utils/tunables'; import { AssetOrder, @@ -20,7 +32,6 @@ import { type TimeBucketAssetResponseDto, } from '@immich/sdk'; import { clamp, debounce, isEqual, throttle } from 'lodash-es'; -import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; import { get, writable, type Unsubscriber } from 'svelte/store'; @@ -36,6 +47,7 @@ export type AssetStoreOptions = Omit & { timelineAlbumId?: string; deferInit?: boolean; }; +type AssetDescriptor = { id: string }; // eslint-disable-next-line @typescript-eslint/no-explicit-any function updateObject(target: any, source: any): boolean { @@ -48,7 +60,8 @@ function updateObject(target: any, source: any): boolean { if (!source.hasOwnProperty(key)) { continue; } - if (typeof target[key] === 'object') { + const isDate = target[key] instanceof Date; + if (typeof target[key] === 'object' && !isDate) { updated = updated || updateObject(target[key], source[key]); } else { // Otherwise, directly copy the value @@ -60,21 +73,17 @@ function updateObject(target: any, source: any): boolean { } return updated; } +type Direction = 'earlier' | 'later'; -export function assetSnapshot(asset: TimelineAsset): TimelineAsset { - return $state.snapshot(asset) as TimelineAsset; -} - -export function assetsSnapshot(assets: TimelineAsset[]): TimelineAsset[] { - return assets.map((a) => $state.snapshot(a)) as TimelineAsset[]; -} +export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset); +export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset)); export type TimelineAsset = { id: string; ownerId: string; ratio: number; thumbhash: string | null; - localDateTime: string; + localDateTime: TimelinePlainDateTime; visibility: AssetVisibility; isFavorite: boolean; isTrashed: boolean; @@ -99,8 +108,14 @@ class IntersectingAsset { } const store = this.#group.bucket.store; - const topWindow = store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP; - const bottomWindow = store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM; + + const scrollCompensation = store.scrollCompensation; + const scrollCompensationHeightDelta = scrollCompensation?.heightDelta ?? 0; + + const topWindow = + store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP + scrollCompensationHeightDelta; + const bottomWindow = + store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM + scrollCompensationHeightDelta; const positionTop = this.#group.absoluteDateGroupTop + this.position.top; const positionBottom = positionTop + this.position.height; @@ -123,19 +138,19 @@ class IntersectingAsset { type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; -type MoveAsset = { asset: TimelineAsset; year: number; month: number }; +type MoveAsset = { asset: TimelineAsset; date: TimelinePlainDate }; export class AssetDateGroup { // --- public readonly bucket: AssetBucket; readonly index: number; - readonly date: DateTime; - readonly dayOfMonth: number; - intersetingAssets: IntersectingAsset[] = $state([]); + readonly groupTitle: string; + readonly day: number; + intersectingAssets: IntersectingAsset[] = $state([]); height = $state(0); width = $state(0); - intersecting = $derived.by(() => this.intersetingAssets.some((asset) => asset.intersecting)); + intersecting = $derived.by(() => this.intersectingAssets.some((asset) => asset.intersecting)); // --- private top: number = $state(0); @@ -144,37 +159,44 @@ export class AssetDateGroup { col = $state(0); deferredLayout = false; - constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) { + constructor(bucket: AssetBucket, index: number, day: number, groupTitle: string) { this.index = index; this.bucket = bucket; - this.date = date; - this.dayOfMonth = dayOfMonth; + this.day = day; + this.groupTitle = groupTitle; } sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) { - this.intersetingAssets.sort((a, b) => { - const aDate = DateTime.fromISO(a.asset!.localDateTime).toUTC(); - const bDate = DateTime.fromISO(b.asset!.localDateTime).toUTC(); - - if (sortOrder === AssetOrder.Asc) { - return aDate.diff(bDate).milliseconds; - } - - return bDate.diff(aDate).milliseconds; - }); + const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc); + this.intersectingAssets.sort((a, b) => sortFn(a.asset.localDateTime, b.asset.localDateTime)); } getFirstAsset() { - return this.intersetingAssets[0]?.asset; + return this.intersectingAssets[0]?.asset; } getRandomAsset() { - const random = Math.floor(Math.random() * this.intersetingAssets.length); - return this.intersetingAssets[random]; + const random = Math.floor(Math.random() * this.intersectingAssets.length); + return this.intersectingAssets[random]; + } + + *assetsIterator(options: { startAsset?: TimelineAsset; direction?: Direction } = {}) { + const isEarlier = (options?.direction ?? 'earlier') === 'earlier'; + let assetIndex = options?.startAsset + ? this.intersectingAssets.findIndex((intersectingAsset) => intersectingAsset.asset.id === options.startAsset!.id) + : isEarlier + ? 0 + : this.intersectingAssets.length - 1; + + while (assetIndex >= 0 && assetIndex < this.intersectingAssets.length) { + const intersectingAsset = this.intersectingAssets[assetIndex]; + yield intersectingAsset.asset; + assetIndex += isEarlier ? 1 : -1; + } } getAssets() { - return this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!); + return this.intersectingAssets.map((intersectingasset) => intersectingasset.asset); } runAssetOperation(ids: Set, operation: AssetOperation) { @@ -191,27 +213,25 @@ export class AssetDateGroup { const moveAssets: MoveAsset[] = []; let changedGeometry = false; for (const assetId of unprocessedIds) { - const index = this.intersetingAssets.findIndex((ia) => ia.id == assetId); - if (index !== -1) { - const asset = this.intersetingAssets[index].asset!; - const oldTime = asset.localDateTime; - let { remove } = operation(asset); - const newTime = asset.localDateTime; - if (oldTime !== newTime) { - const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); - const year = utc.get('year'); - const month = utc.get('month'); - if (this.bucket.year !== year || this.bucket.month !== month) { - remove = true; - moveAssets.push({ asset, year, month }); - } - } - unprocessedIds.delete(assetId); - processedIds.add(assetId); - if (remove || this.bucket.store.isExcluded(asset)) { - this.intersetingAssets.splice(index, 1); - changedGeometry = true; - } + const index = this.intersectingAssets.findIndex((ia) => ia.id == assetId); + if (index === -1) { + continue; + } + + const asset = this.intersectingAssets[index].asset!; + const oldTime = { ...asset.localDateTime }; + let { remove } = operation(asset); + const newTime = asset.localDateTime; + if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) { + const { year, month, day } = newTime; + remove = true; + moveAssets.push({ asset, date: { year, month, day } }); + } + unprocessedIds.delete(assetId); + processedIds.add(assetId); + if (remove || this.bucket.store.isExcluded(asset)) { + this.intersectingAssets.splice(index, 1); + changedGeometry = true; } } return { moveAssets, processedIds, unprocessedIds, changedGeometry }; @@ -222,23 +242,19 @@ export class AssetDateGroup { this.deferredLayout = true; return; } - const assets = this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!); + const assets = this.intersectingAssets.map((intersetingAsset) => intersetingAsset.asset!); const geometry = getJustifiedLayoutFromAssets(assets, options); this.width = geometry.containerWidth; this.height = assets.length === 0 ? 0 : geometry.containerHeight; - for (let i = 0; i < this.intersetingAssets.length; i++) { + for (let i = 0; i < this.intersectingAssets.length; i++) { const position = getPosition(geometry, i); - this.intersetingAssets[i].position = position; + this.intersectingAssets[i].position = position; } } get absoluteDateGroupTop() { return this.bucket.top + this.top; } - - get groupTitle() { - return formatDateGroupTitle(this.date); - } } export interface Viewport { @@ -259,11 +275,11 @@ class AddContext { changedDateGroups = new Set(); newDateGroups = new Set(); - getDateGroup(year: number, month: number, day: number): AssetDateGroup | undefined { + getDateGroup({ year, month, day }: TimelinePlainDate): AssetDateGroup | undefined { return this.lookupCache[year]?.[month]?.[day]; } - setDateGroup(dateGroup: AssetDateGroup, year: number, month: number, day: number) { + setDateGroup(dateGroup: AssetDateGroup, { year, month, day }: TimelinePlainDate) { if (!this.lookupCache[year]) { this.lookupCache[year] = {}; } @@ -331,33 +347,27 @@ export class AssetBucket { bucketCount: number = $derived( this.isLoaded - ? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersetingAssets.length, 0) + ? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersectingAssets.length, 0) : this.#initialCount, ); loader: CancellableTask | undefined; isBucketHeightActual: boolean = $state(false); readonly bucketDateFormatted: string; - readonly bucketDate: string; - readonly month: number; - readonly year: number; + readonly yearMonth: TimelinePlainYearMonth; - constructor(store: AssetStore, utcDate: DateTime, initialCount: number, order: AssetOrder = AssetOrder.Desc) { + constructor( + store: AssetStore, + yearMonth: TimelinePlainYearMonth, + initialCount: number, + order: AssetOrder = AssetOrder.Desc, + ) { this.store = store; this.#initialCount = initialCount; this.#sortOrder = order; - const year = utcDate.get('year'); - const month = utcDate.get('month'); - const bucketDateFormatted = utcDate.toJSDate().toLocaleString(get(locale), { - month: 'short', - year: 'numeric', - timeZone: 'UTC', - }); - this.bucketDate = utcDate.toISO()!.toString(); - this.bucketDateFormatted = bucketDateFormatted; - this.month = month; - this.year = year; + this.yearMonth = yearMonth; + this.bucketDateFormatted = formatBucketTitle(fromTimelinePlainYearMonth(yearMonth)); this.loader = new CancellableTask( () => { @@ -373,13 +383,14 @@ export class AssetBucket { set intersecting(newValue: boolean) { const old = this.#intersecting; - if (old !== newValue) { - this.#intersecting = newValue; - if (newValue) { - void this.store.loadBucket(this.bucketDate); - } else { - this.cancel(); - } + if (old === newValue) { + return; + } + this.#intersecting = newValue; + if (newValue) { + void this.store.loadBucket(this.yearMonth); + } else { + this.cancel(); } } @@ -403,22 +414,12 @@ export class AssetBucket { ); } - containsAssetId(id: string) { - for (const group of this.dateGroups) { - const index = group.intersetingAssets.findIndex((a) => a.id == id); - if (index !== -1) { - return true; - } - } - return false; - } - sortDateGroups() { if (this.#sortOrder === AssetOrder.Asc) { - return this.dateGroups.sort((a, b) => a.date.diff(b.date).milliseconds); + return this.dateGroups.sort((a, b) => a.day - b.day); } - return this.dateGroups.sort((a, b) => b.date.diff(a.date).milliseconds); + return this.dateGroups.sort((a, b) => b.day - a.day); } runAssetOperation(ids: Set, operation: AssetOperation) { @@ -448,7 +449,7 @@ export class AssetBucket { idsProcessed.add(id); } combinedChangedGeometry = combinedChangedGeometry || changedGeometry; - if (group.intersetingAssets.length === 0) { + if (group.intersectingAssets.length === 0) { dateGroups.splice(index, 1); combinedChangedGeometry = true; } @@ -477,7 +478,7 @@ export class AssetBucket { isTrashed: bucketAssets.isTrashed[i], isVideo: !bucketAssets.isImage[i], livePhotoVideoId: bucketAssets.livePhotoVideoId[i], - localDateTime: bucketAssets.localDateTime[i], + localDateTime: fromLocalDateTimeToObject(bucketAssets.localDateTime[i]), ownerId: bucketAssets.ownerId[i], people, projectionType: bucketAssets.projectionType[i], @@ -509,29 +510,26 @@ export class AssetBucket { addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) { const { localDateTime } = timelineAsset; - const date = DateTime.fromISO(localDateTime).toUTC(); - const month = date.get('month'); - const year = date.get('year'); - if (this.month !== month || this.year !== year) { + const { year, month } = this.yearMonth; + if (month !== localDateTime.month || year !== localDateTime.year) { addContext.unprocessedAssets.push(timelineAsset); return; } - const day = date.get('day'); - let dateGroup = addContext.getDateGroup(year, month, day) || this.findDateGroupByDay(day); - + let dateGroup = addContext.getDateGroup(localDateTime) || this.findDateGroupByDay(localDateTime.day); if (dateGroup) { - addContext.setDateGroup(dateGroup, year, month, day); + addContext.setDateGroup(dateGroup, localDateTime); } else { - dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day); + const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime)); + dateGroup = new AssetDateGroup(this, this.dateGroups.length, localDateTime.day, groupTitle); this.dateGroups.push(dateGroup); - addContext.setDateGroup(dateGroup, year, month, day); + addContext.setDateGroup(dateGroup, localDateTime); addContext.newDateGroups.add(dateGroup); } const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset); - dateGroup.intersetingAssets.push(intersectingAsset); + dateGroup.intersectingAssets.push(intersectingAsset); addContext.changedDateGroups.add(dateGroup); } @@ -546,10 +544,14 @@ export class AssetBucket { /** The svelte key for this view model object */ get viewId() { - return this.bucketDate; + const { year, month } = this.yearMonth; + return year + '-' + month; } set bucketHeight(height: number) { + if (this.#bucketHeight === height) { + return; + } const { store, percent } = this; const index = store.buckets.indexOf(this); const bucketHeightDelta = height - this.#bucketHeight; @@ -575,10 +577,18 @@ export class AssetBucket { // size adjustment if (currentIndex > 0) { if (index < currentIndex) { - store.compensateScrollCallback?.({ delta: bucketHeightDelta }); - } else if (currentIndex == currentIndex && percent > 0) { + store.scrollCompensation = { + heightDelta: bucketHeightDelta, + scrollTop: undefined, + bucket: this, + }; + } else if (percent > 0) { const top = this.top + height * percent; - store.compensateScrollCallback?.({ top }); + store.scrollCompensation = { + heightDelta: undefined, + scrollTop: top, + bucket: this, + }; } } } @@ -597,22 +607,70 @@ export class AssetBucket { handleError(error, _$t('errors.failed_to_load_assets')); } - findDateGroupByDay(dayOfMonth: number) { - return this.dateGroups.find((group) => group.dayOfMonth === dayOfMonth); + findDateGroupForAsset(asset: TimelineAsset) { + for (const group of this.dateGroups) { + if (group.intersectingAssets.some((IntersectingAsset) => IntersectingAsset.id === asset.id)) { + return group; + } + } + } + + findDateGroupByDay(day: number) { + return this.dateGroups.find((group) => group.day === day); } findAssetAbsolutePosition(assetId: string) { this.store.clearDeferredLayout(this); - for (const group of this.dateGroups) { - const intersectingAsset = group.intersetingAssets.find((asset) => asset.id === assetId); + const intersectingAsset = group.intersectingAssets.find((asset) => asset.id === assetId); if (intersectingAsset) { - return this.top + group.top + intersectingAsset.position!.top + this.store.headerHeight; + if (!intersectingAsset.position) { + console.warn('No position for asset'); + break; + } + return this.top + group.top + intersectingAsset.position.top + this.store.headerHeight; } } return -1; } + *assetsIterator(options?: { startDateGroup?: AssetDateGroup; startAsset?: TimelineAsset; direction?: Direction }) { + const direction = options?.direction ?? 'earlier'; + let { startAsset } = options ?? {}; + const isEarlier = direction === 'earlier'; + let groupIndex = options?.startDateGroup + ? this.dateGroups.indexOf(options.startDateGroup) + : isEarlier + ? 0 + : this.dateGroups.length - 1; + + while (groupIndex >= 0 && groupIndex < this.dateGroups.length) { + const group = this.dateGroups[groupIndex]; + yield* group.assetsIterator({ startAsset, direction }); + startAsset = undefined; + groupIndex += isEarlier ? 1 : -1; + } + } + + findAssetById(assetDescriptor: AssetDescriptor) { + return this.assetsIterator().find((asset) => asset.id === assetDescriptor.id); + } + + findClosest(target: TimelinePlainDateTime) { + const targetDate = fromTimelinePlainDateTime(target); + let closest = undefined; + let smallestDiff = Infinity; + for (const current of this.assetsIterator()) { + const currentAssetDate = fromTimelinePlainDateTime(current.localDateTime); + const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds')); + if (diff < smallestDiff) { + smallestDiff = diff; + closest = current; + } + } + return closest; + } + cancel() { this.loader?.cancel(); } @@ -651,7 +709,8 @@ type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | Update export type LiteBucket = { bucketHeight: number; assetCount: number; - bucketDate: string; + year: number; + month: number; bucketDateFormattted: string; }; @@ -660,7 +719,6 @@ type AssetStoreLayoutOptions = { headerHeight?: number; gap?: number; }; - interface UpdateGeometryOptions { invalidateHeight: boolean; noDefer?: boolean; @@ -674,6 +732,7 @@ export class AssetStore { timelineHeight = $derived( this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0) + this.topSectionHeight, ); + count = $derived(this.buckets.reduce((accumulator, b) => accumulator + b.bucketCount, 0)); // todo - name this better albumAssets: Set = new SvelteSet(); @@ -683,7 +742,6 @@ export class AssetStore { scrubberTimelineHeight: number = $state(0); // -- should be private, but used by AssetBucket - compensateScrollCallback: (({ delta, top }: { delta?: number; top?: number }) => void) | undefined; topIntersectingBucket: AssetBucket | undefined = $state(); visibleWindow = $derived.by(() => ({ @@ -724,6 +782,15 @@ export class AssetStore { #suspendTransitions = $state(false); #resetScrolling = debounce(() => (this.#scrolling = false), 1000); #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000); + scrollCompensation: { + heightDelta: number | undefined; + scrollTop: number | undefined; + bucket: AssetBucket | undefined; + } = $state({ + heightDelta: 0, + scrollTop: 0, + bucket: undefined, + }); constructor() {} @@ -819,8 +886,34 @@ export class AssetStore { return this.#viewportHeight; } - getAssets() { - return this.buckets.flatMap((bucket) => bucket.getAssets()); + async *assetsIterator(options?: { + startBucket?: AssetBucket; + startDateGroup?: AssetDateGroup; + startAsset?: TimelineAsset; + direction?: Direction; + }) { + const direction = options?.direction ?? 'earlier'; + let { startDateGroup, startAsset } = options ?? {}; + for (const bucket of this.bucketsIterator({ direction, startBucket: options?.startBucket })) { + await this.loadBucket(bucket.yearMonth, { cancelable: false }); + yield* bucket.assetsIterator({ startDateGroup, startAsset, direction }); + // after the first bucket, we won't find startDateGroup or startAsset, so clear them + startDateGroup = startAsset = undefined; + } + } + + *bucketsIterator(options?: { direction?: Direction; startBucket?: AssetBucket }) { + const isEarlier = options?.direction === 'earlier'; + let startIndex = options?.startBucket + ? this.buckets.indexOf(options.startBucket) + : isEarlier + ? 0 + : this.buckets.length - 1; + + while (startIndex >= 0 && startIndex < this.buckets.length) { + yield this.buckets[startIndex]; + startIndex += isEarlier ? 1 : -1; + } } #addPendingChanges(...changes: PendingChange[]) { @@ -882,18 +975,37 @@ export class AssetStore { return batch; } - // todo: this should probably be a method isteat #findBucketForAsset(id: string) { for (const bucket of this.buckets) { - if (bucket.containsAssetId(id)) { + const asset = bucket.findAssetById({ id }); + if (asset) { + return { bucket, asset }; + } + } + } + + #findBucketForDate(targetYearMonth: TimelinePlainYearMonth) { + for (const bucket of this.buckets) { + const { year, month } = bucket.yearMonth; + if (month === targetYearMonth.month && year === targetYearMonth.year) { return bucket; } } } updateSlidingWindow(scrollTop: number) { - this.#scrollTop = scrollTop; - this.updateIntersections(); + if (this.#scrollTop !== scrollTop) { + this.#scrollTop = scrollTop; + this.updateIntersections(); + } + } + + clearScrollCompensation() { + this.scrollCompensation = { + heightDelta: undefined, + scrollTop: undefined, + bucket: undefined, + }; } updateIntersections() { @@ -903,11 +1015,11 @@ export class AssetStore { let topIntersectingBucket = undefined; for (const bucket of this.buckets) { this.#updateIntersection(bucket); - if (!topIntersectingBucket && bucket.actuallyIntersecting && bucket.isLoaded) { + if (!topIntersectingBucket && bucket.actuallyIntersecting) { topIntersectingBucket = bucket; } } - if (this.topIntersectingBucket !== topIntersectingBucket) { + if (topIntersectingBucket !== undefined && this.topIntersectingBucket !== topIntersectingBucket) { this.topIntersectingBucket = topIntersectingBucket; } for (const bucket of this.buckets) { @@ -977,10 +1089,6 @@ export class AssetStore { this.#pendingChanges = []; }, 2500); - setCompensateScrollCallback(compensateScrollCallback?: ({ delta, top }: { delta?: number; top?: number }) => void) { - this.compensateScrollCallback = compensateScrollCallback; - } - async #initialiazeTimeBuckets() { const timebuckets = await getTimeBuckets({ ...this.#options, @@ -988,8 +1096,13 @@ export class AssetStore { }); this.buckets = timebuckets.map((bucket) => { - const utcDate = DateTime.fromISO(bucket.timeBucket).toUTC(); - return new AssetBucket(this, utcDate, bucket.count, this.#options.order); + const date = new Date(bucket.timeBucket); + return new AssetBucket( + this, + { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }, + bucket.count, + this.#options.order, + ); }); this.albumAssets.clear(); this.#updateViewportGeometry(false); @@ -1023,6 +1136,7 @@ export class AssetStore { await this.#initialiazeTimeBuckets(); }, true); } + public destroy() { this.disconnect(); this.isInitialized = false; @@ -1073,7 +1187,8 @@ export class AssetStore { #createScrubBuckets() { this.scrubberBuckets = this.buckets.map((bucket) => ({ assetCount: bucket.bucketCount, - bucketDate: bucket.bucketDate, + year: bucket.yearMonth.year, + month: bucket.yearMonth.month, bucketDateFormattted: bucket.bucketDateFormatted, bucketHeight: bucket.bucketHeight, })); @@ -1165,16 +1280,12 @@ export class AssetStore { bucket.isBucketHeightActual = true; } - async loadBucket(bucketDate: string, options?: { cancelable: boolean }): Promise { + async loadBucket(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise { let cancelable = true; if (options) { cancelable = options.cancelable; } - - const date = DateTime.fromISO(bucketDate).toUTC(); - const year = date.get('year'); - const month = date.get('month'); - const bucket = this.getBucketByDate(year, month); + const bucket = this.getBucketByDate(yearMonth); if (!bucket) { return; } @@ -1189,12 +1300,13 @@ export class AssetStore { // so no need to load the bucket, it already has assets return; } + const timeBucket = toISOLocalDateTime(bucket.yearMonth); + const key = authManager.key; const bucketResponse = await getTimeBucket( { ...this.#options, - timeBucket: bucketDate, - - key: authManager.key, + timeBucket, + key, }, { signal }, ); @@ -1203,8 +1315,8 @@ export class AssetStore { const albumAssets = await getTimeBucket( { albumId: this.#options.timelineAlbumId, - timeBucket: bucketDate, - key: authManager.key, + timeBucket, + key, }, { signal }, ); @@ -1212,10 +1324,15 @@ export class AssetStore { this.albumAssets.add(id); } } - const unprocessed = bucket.addAssets(bucketResponse); - if (unprocessed.length > 0) { + const unprocessedAssets = bucket.addAssets(bucketResponse); + if (unprocessedAssets.length > 0) { console.error( - `Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`, + `Warning: getTimeBucket API returning assets not in requested month: ${bucket.yearMonth.month}, ${JSON.stringify( + unprocessedAssets.map((unprocessed) => ({ + id: unprocessed.id, + localDateTime: unprocessed.localDateTime, + })), + )}`, ); } this.#layoutBucket(bucket); @@ -1249,13 +1366,11 @@ export class AssetStore { const updatedBuckets = new Set(); const bucketCount = this.buckets.length; for (const asset of assets) { - const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); - const year = utc.get('year'); - const month = utc.get('month'); - let bucket = this.getBucketByDate(year, month); + let bucket = this.getBucketByDate(asset.localDateTime); if (!bucket) { - bucket = new AssetBucket(this, utc, 1, this.#options.order); + bucket = new AssetBucket(this, asset.localDateTime, 1, this.#options.order); + bucket.isLoaded = true; this.buckets.push(bucket); } @@ -1265,7 +1380,9 @@ export class AssetStore { if (this.buckets.length !== bucketCount) { this.buckets.sort((a, b) => { - return a.year === b.year ? b.month - a.month : b.year - a.year; + return a.yearMonth.year === b.yearMonth.year + ? b.yearMonth.month - a.yearMonth.month + : b.yearMonth.year - a.yearMonth.year; }); } @@ -1284,54 +1401,44 @@ export class AssetStore { this.updateIntersections(); } - getBucketByDate(year: number, month: number): AssetBucket | undefined { - return this.buckets.find((bucket) => bucket.year === year && bucket.month === month); + getBucketByDate(targetYearMonth: TimelinePlainYearMonth): AssetBucket | undefined { + return this.buckets.find( + (bucket) => bucket.yearMonth.year === targetYearMonth.year && bucket.yearMonth.month === targetYearMonth.month, + ); } async findBucketForAsset(id: string) { - await this.initTask.waitUntilCompletion(); - let bucket = this.#findBucketForAsset(id); - if (!bucket) { - const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key })); - if (!asset || this.isExcluded(asset)) { - return; - } - bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false }); + if (!this.isInitialized) { + await this.initTask.waitUntilCompletion(); } - - if (bucket && bucket?.containsAssetId(id)) { + let { bucket } = this.#findBucketForAsset(id) ?? {}; + if (bucket) { + return bucket; + } + const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key })); + if (!asset || this.isExcluded(asset)) { + return; + } + bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false }); + if (bucket?.findAssetById({ id })) { return bucket; } } - async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) { - let date = DateTime.fromISO(localDateTime).toUTC(); - // Only support TimeBucketSize.Month - date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); - const iso = date.toISO()!; - const year = date.get('year'); - const month = date.get('month'); - await this.loadBucket(iso, options); - return this.getBucketByDate(year, month); - } - - async #getBucketInfoForAsset(asset: { id: string; localDateTime: string }, options?: { cancelable: boolean }) { - const bucketInfo = this.#findBucketForAsset(asset.id); - if (bucketInfo) { - return bucketInfo; - } - await this.#loadBucketAtTime(asset.localDateTime, options); - return this.#findBucketForAsset(asset.id); + async #loadBucketAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) { + await this.loadBucket(yearMonth, options); + return this.getBucketByDate(yearMonth); } getBucketIndexByAssetId(assetId: string) { - return this.#findBucketForAsset(assetId); + const bucketInfo = this.#findBucketForAsset(assetId); + return bucketInfo?.bucket; } async getRandomBucket() { const random = Math.floor(Math.random() * this.buckets.length); const bucket = this.buckets[random]; - await this.loadBucket(bucket.bucketDate, { cancelable: false }); + await this.loadBucket(bucket.yearMonth, { cancelable: false }); return bucket; } @@ -1349,7 +1456,7 @@ export class AssetStore { const changedBuckets = new Set(); let idsToProcess = new Set(ids); const idsProcessed = new Set(); - const combinedMoveAssets: { asset: TimelineAsset; year: number; month: number }[][] = []; + const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = []; for (const bucket of this.buckets) { if (idsToProcess.size > 0) { const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation); @@ -1419,85 +1526,141 @@ export class AssetStore { return this.buckets[0]?.getFirstAsset(); } - async getPreviousAsset(asset: { id: string; localDateTime: string }): Promise { - let bucket = await this.#getBucketInfoForAsset(asset); + async getLaterAsset( + assetDescriptor: AssetDescriptor, + interval: 'asset' | 'day' | 'month' | 'year' = 'asset', + ): Promise { + return await this.#getAssetWithOffset(assetDescriptor, interval, 'later'); + } + + async getEarlierAsset( + assetDescriptor: AssetDescriptor, + interval: 'asset' | 'day' | 'month' | 'year' = 'asset', + ): Promise { + return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier'); + } + + async getClosestAssetToDate(dateTime: TimelinePlainDateTime) { + const bucket = this.#findBucketForDate(dateTime); if (!bucket) { return; } - - // Find which date group contains this asset - for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) { - const group = bucket.dateGroups[groupIndex]; - const assetIndex = group.intersetingAssets.findIndex((ia) => ia.id === asset.id); - - if (assetIndex !== -1) { - // If not the first asset in this group, return the previous one - if (assetIndex > 0) { - return group.intersetingAssets[assetIndex - 1].asset; - } - - // If there are previous date groups in this bucket, check the previous one - if (groupIndex > 0) { - const prevGroup = bucket.dateGroups[groupIndex - 1]; - return prevGroup.intersetingAssets.at(-1)?.asset; - } - - // Otherwise, we need to look in the previous bucket - break; - } + await this.loadBucket(dateTime, { cancelable: false }); + const asset = bucket.findClosest(dateTime); + if (asset) { + return asset; } - - let bucketIndex = this.buckets.indexOf(bucket) - 1; - while (bucketIndex >= 0) { - bucket = this.buckets[bucketIndex]; - if (!bucket) { - return; - } - await this.loadBucket(bucket.bucketDate); - const previous = bucket.lastDateGroup?.intersetingAssets.at(-1)?.asset; - if (previous) { - return previous; - } - bucketIndex--; + for await (const asset of this.assetsIterator({ startBucket: bucket })) { + return asset; } } - async getNextAsset(asset: { id: string; localDateTime: string }): Promise { - let bucket = await this.#getBucketInfoForAsset(asset); - if (!bucket) { - return; + async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) { + let { asset: startAsset, bucket: startBucket } = this.#findBucketForAsset(start.id) ?? {}; + if (!startBucket || !startAsset) { + return []; + } + let { asset: endAsset, bucket: endBucket } = this.#findBucketForAsset(end.id) ?? {}; + if (!endBucket || !endAsset) { + return []; + } + let direction: Direction = 'earlier'; + if (plainDateTimeCompare(true, startAsset.localDateTime, endAsset.localDateTime) < 0) { + // swap startAsset, startBucket with endAsset, endBucket + [startAsset, endAsset] = [endAsset, startAsset]; + [startBucket, endBucket] = [endBucket, startBucket]; + direction = 'earlier'; } - // Find which date group contains this asset - for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) { - const group = bucket.dateGroups[groupIndex]; - const assetIndex = group.intersetingAssets.findIndex((ia) => ia.id === asset.id); - - if (assetIndex !== -1) { - // If not the last asset in this group, return the next one - if (assetIndex < group.intersetingAssets.length - 1) { - return group.intersetingAssets[assetIndex + 1].asset; - } - - // If there are more date groups in this bucket, check the next one - if (groupIndex < bucket.dateGroups.length - 1) { - return bucket.dateGroups[groupIndex + 1].intersetingAssets[0]?.asset; - } - - // Otherwise, we need to look in the next bucket + const range: TimelineAsset[] = []; + const startDateGroup = startBucket.findDateGroupForAsset(startAsset); + for await (const targetAsset of this.assetsIterator({ + startBucket, + startDateGroup, + startAsset, + direction, + })) { + range.push(targetAsset); + if (targetAsset.id === endAsset.id) { break; } } + return range; + } - let bucketIndex = this.buckets.indexOf(bucket) + 1; - while (bucketIndex < this.buckets.length) { - bucket = this.buckets[bucketIndex]; - await this.loadBucket(bucket.bucketDate); - const next = bucket.dateGroups[0]?.intersetingAssets[0]?.asset; - if (next) { - return next; + async #getAssetWithOffset( + assetDescriptor: AssetDescriptor, + interval: 'asset' | 'day' | 'month' | 'year' = 'asset', + direction: Direction, + ): Promise { + const { asset, bucket } = this.#findBucketForAsset(assetDescriptor.id) ?? {}; + if (!bucket || !asset) { + return; + } + + switch (interval) { + case 'asset': { + return this.#getAssetByAssetOffset(asset, bucket, direction); + } + case 'day': { + return this.#getAssetByDayOffset(asset, bucket, direction); + } + case 'month': { + return this.#getAssetByMonthOffset(bucket, direction); + } + case 'year': { + return this.#getAssetByYearOffset(bucket, direction); + } + } + } + + async #getAssetByAssetOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) { + const dateGroup = bucket.findDateGroupForAsset(asset); + for await (const targetAsset of this.assetsIterator({ + startBucket: bucket, + startDateGroup: dateGroup, + startAsset: asset, + direction, + })) { + if (asset.id === targetAsset.id) { + continue; + } + return targetAsset; + } + } + + async #getAssetByDayOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) { + const dateGroup = bucket.findDateGroupForAsset(asset); + for await (const targetAsset of this.assetsIterator({ + startBucket: bucket, + startDateGroup: dateGroup, + startAsset: asset, + direction, + })) { + if (targetAsset.localDateTime.day !== asset.localDateTime.day) { + return targetAsset; + } + } + } + + // starting at bucket, go to the earlier/later bucket by month, returning the first asset in that bucket + async #getAssetByMonthOffset(bucket: AssetBucket, direction: Direction) { + for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) { + if (targetBucket.yearMonth.month !== bucket.yearMonth.month) { + for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) { + return targetAsset; + } + } + } + } + + async #getAssetByYearOffset(bucket: AssetBucket, direction: Direction) { + for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) { + if (targetBucket.yearMonth.year !== bucket.yearMonth.year) { + for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) { + return targetAsset; + } } - bucketIndex++; } } diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 3abd0a596c..1838cb8eb5 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -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); diff --git a/web/src/lib/utils/focus-util.ts b/web/src/lib/utils/focus-util.ts index c95ed3f31d..433864f490 100644 --- a/web/src/lib/utils/focus-util.ts +++ b/web/src/lib/utils/focus-util.ts @@ -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); }; diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts new file mode 100644 index 0000000000..ebc97dfde0 --- /dev/null +++ b/web/src/lib/utils/invocationTracker.ts @@ -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; + } +} diff --git a/web/src/lib/utils/thumbnail-util.spec.ts b/web/src/lib/utils/thumbnail-util.spec.ts index f3f9d51fad..65df82bee3 100644 --- a/web/src/lib/utils/thumbnail-util.spec.ts +++ b/web/src/lib/utils/thumbnail-util.spec.ts @@ -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, diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index 954cfa3314..2b5982d510 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -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; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index fca68a6aec..d8252271a2 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -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; @@ -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).toObject(); + +export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime => + DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime; + +export const fromTimelinePlainDate = (timelineYearMonth: TimelinePlainDate): DateTime => + DateTime.fromObject( + { year: timelineYearMonth.year, month: timelineYearMonth.month, day: timelineYearMonth.day }, + { zone: 'local', locale: get(locale) }, + ) as DateTime; + +export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearMonth): DateTime => + DateTime.fromObject( + { year: timelineYearMonth.year, month: timelineYearMonth.month }, + { zone: 'local', locale: get(locale) }, + ) as DateTime; + 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).toISO(); + +export function formatBucketTitle(_date: DateTime): string { + if (!_date.isValid) { + return _date.toString(); + } + const date = _date as DateTime; + 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; +}; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index ffeef43db1..0d2f9d79e3 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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))); } diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index f68c3a1a1a..bca6a61d7d 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -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({ 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);