From c2d37a912b44415e493ee4e55860a84bf3dd671d Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 15 May 2025 01:36:22 +0000 Subject: [PATCH] Review comments --- .../assets/thumbnail/thumbnail.svelte | 2 +- .../photos-page/actions/focus-actions.ts | 15 +- .../components/photos-page/asset-grid.svelte | 43 +-- .../gallery-viewer/gallery-viewer.svelte | 5 +- .../shared-components/show-shortcuts.svelte | 6 +- web/src/lib/modals/ShortcutsModal.svelte | 3 + web/src/lib/stores/assets-store.spec.ts | 16 +- web/src/lib/stores/assets-store.svelte.ts | 320 +++++++----------- web/src/lib/utils/focus-util.ts | 4 +- web/src/lib/utils/invocationTracker.ts | 35 ++ 10 files changed, 210 insertions(+), 239 deletions(-) diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 588e6dff7a..d1d588e2e2 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -207,7 +207,7 @@ onSelect?.(asset); } if (document.activeElement === element && evt.key === 'Escape') { - moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, true); + moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, 'next'); } }} onclick={handleClick} diff --git a/web/src/lib/components/photos-page/actions/focus-actions.ts b/web/src/lib/components/photos-page/actions/focus-actions.ts index b489ac3984..f1128621d9 100644 --- a/web/src/lib/components/photos-page/actions/focus-actions.ts +++ b/web/src/lib/components/photos-page/actions/focus-actions.ts @@ -12,9 +12,10 @@ const getFocusedThumb = () => { } }; -export const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true); +export const focusNextAsset = () => + moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next'); export const focusPreviousAsset = () => - moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false); + moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous'); export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise, asset: { id: string }) => { const scrolled = await scrollToAsset(asset.id); @@ -27,8 +28,8 @@ export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise Promise, store: AssetStore, - direction: 'next' | 'previous', - skip: 'day' | 'month' | 'year', + direction: 'earlier' | 'later', + magnitude: 'day' | 'month' | 'year', ) => { const thumb = getFocusedThumb(); if (!thumb) { @@ -36,7 +37,7 @@ export const setFocusTo = async ( // there are unfinished running invocations, so return early return; } - return direction === 'next' ? focusNextAsset() : focusPreviousAsset(); + return direction === 'earlier' ? focusNextAsset() : focusPreviousAsset(); } const invocation = tracker.startInvocation(); @@ -47,7 +48,9 @@ export const setFocusTo = async ( } try { const asset = - direction === 'next' ? await store.getNextAsset({ id }, skip) : await store.getPreviousAsset({ id }, skip); + direction === 'earlier' + ? await store.getEarlierAsset({ id }, magnitude) + : await store.getLaterAsset({ id }, magnitude); invocation.checkStillValid(); if (!asset) { diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 2cddae60b4..cc91333eec 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -382,26 +382,26 @@ }; const handlePrevious = async () => { - const previousAsset = await assetStore.getPreviousAsset($viewingAsset); + const laterAsset = await assetStore.getLaterAsset($viewingAsset); - if (previousAsset) { - const preloadAsset = await assetStore.getPreviousAsset(previousAsset); - assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []); - await navigate({ targetRoute: 'current', assetId: previousAsset.id }); + if (laterAsset) { + const preloadAsset = await assetStore.getLaterAsset(laterAsset); + assetViewingStore.setAsset(laterAsset, preloadAsset ? [preloadAsset] : []); + 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); - assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []); - await navigate({ targetRoute: 'current', assetId: nextAsset.id }); + const earlierAsset = await assetStore.getEarlierAsset($viewingAsset); + if (earlierAsset) { + const preloadAsset = await assetStore.getEarlierAsset(earlierAsset); + assetViewingStore.setAsset(earlierAsset, preloadAsset ? [preloadAsset] : []); + await navigate({ targetRoute: 'current', assetId: earlierAsset.id }); } - return !!nextAsset; + return !!earlierAsset; }; const handleRandom = async () => { @@ -629,8 +629,9 @@ } }; - const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true); - const focusPreviousAsset = () => moveFocus((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 isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0); @@ -669,12 +670,12 @@ { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) }, { shortcut: { key: 'ArrowRight' }, onShortcut: focusNextAsset }, { shortcut: { key: 'ArrowLeft' }, onShortcut: focusPreviousAsset }, - { shortcut: { key: 'D' }, onShortcut: () => setFocusTo('next', 'day') }, - { shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('previous', 'day') }, - { shortcut: { key: 'M' }, onShortcut: () => setFocusTo('next', 'month') }, - { shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('previous', 'month') }, - { shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('next', 'year') }, - { shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('previous', 'year') }, + { 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) }, ]; @@ -728,7 +729,7 @@ timezoneInput={false} onConfirm={async (dateString: string) => { isShowSelectDate = false; - const date = DateTime.fromISO(dateString); + const date = DateTime.fromISO(dateString).toUTC(); const asset = await assetStore.getClosestAssetToDate(date); if (asset) { await setFocusAsset(asset); 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 c6c3385178..e88507280b 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 @@ -260,8 +260,9 @@ } }; - const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true); - const focusPreviousAsset = () => moveFocus((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/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index 69a5ac519c..82f47d7d7a 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -25,9 +25,9 @@ shortcuts = { general: [ { key: ['←', '→'], action: $t('previous_or_next_photo') }, - { key: ['D', 'd'], action: 'Next or Previous Day' }, - { key: ['M', 'm'], action: 'Next or Previous Month' }, - { key: ['Y', 'y'], action: 'Next or Previous Year' }, + { key: ['D', 'd'], action: 'Day forward/backward' }, + { key: ['M', 'm'], action: 'Month forward/backward' }, + { key: ['Y', 'y'], action: 'Year forward/backward' }, { 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/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte index 2f16eaa817..5debc780dd 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: 'Day forward/backward' }, + { key: ['M', 'm'], action: 'Month forward/backward' }, + { key: ['Y', 'y'], action: 'Year forward/backward' }, { 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 3d0292bde8..b3f5b0993a 100644 --- a/web/src/lib/stores/assets-store.spec.ts +++ b/web/src/lib/stores/assets-store.spec.ts @@ -347,7 +347,7 @@ describe('AssetStore', () => { }); }); - describe('getPreviousAsset', () => { + describe('getLaterAsset', () => { let assetStore: AssetStore; const bucketAssets: Record = { '2024-03-01T00:00:00.000Z': assetFactory @@ -374,8 +374,8 @@ 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 () => { @@ -384,7 +384,7 @@ describe('AssetStore', () => { 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); }); @@ -396,7 +396,7 @@ describe('AssetStore', () => { const previousBucket = assetStore.getBucketByDate(2024, 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); }); @@ -408,7 +408,7 @@ describe('AssetStore', () => { const previousBucket = assetStore.getBucketByDate(2024, 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); expect(loadBucketSpy).toBeCalledTimes(1); }); @@ -420,12 +420,12 @@ describe('AssetStore', () => { const [assetOne, assetTwo, assetThree] = assetStore.getAssets(); 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(); + expect(await assetStore.getLaterAsset(assetStore.getAssets()[0])).toBeUndefined(); }); }); diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index dbc73a9b56..9359fd34b5 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -36,6 +36,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 { @@ -60,6 +61,7 @@ function updateObject(target: any, source: any): boolean { } return updated; } +type Direction = 'forward' | 'backward'; export function assetSnapshot(asset: AssetResponseDto) { return $state.snapshot(asset); @@ -514,20 +516,16 @@ export class AssetBucket { } } - findById(id: string) { - return this.assets().find((asset) => asset.id === id); + findAssetById(assetDescriptor: AssetDescriptor) { + return this.assets().find((asset) => asset.id === assetDescriptor.id); } findClosest(target: DateTime) { - let closest = this.assets().next().value; - if (!closest) { - return; - } - - let smallestDiff = Math.abs(target.toMillis() - DateTime.fromISO(closest.localDateTime).toUTC().toMillis()); - + let closest = undefined; + let smallestDiff = Infinity; for (const current of this.assets()) { - const diff = Math.abs(target.toMillis() - DateTime.fromISO(current.localDateTime).toUTC().toMillis()); + const currentDate = DateTime.fromISO(current.localDateTime).toUTC(); + const diff = Math.abs(target.diff(currentDate).milliseconds); if (diff < smallestDiff) { smallestDiff = diff; closest = current; @@ -584,6 +582,11 @@ type AssetStoreLayoutOptions = { headerHeight?: number; gap?: number; }; +interface UpdateGeometryOptions { + invalidateHeight: boolean; + noDefer?: boolean; +} + export class AssetStore { // --- public ---- isInitialized = $state(false); @@ -865,7 +868,7 @@ export class AssetStore { clearDeferredLayout(bucket: AssetBucket) { const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout); if (hasDeferred) { - this.#updateGeometry(bucket, true, true); + this.#updateGeometry(bucket, { invalidateHeight: true, noDefer: true }); for (const group of bucket.dateGroups) { group.deferredLayout = false; } @@ -987,7 +990,7 @@ export class AssetStore { return; } for (const bucket of this.buckets) { - this.#updateGeometry(bucket, changedWidth); + this.#updateGeometry(bucket, { invalidateHeight: changedWidth }); } this.updateIntersections(); this.#createScrubBuckets(); @@ -1013,7 +1016,9 @@ export class AssetStore { rowWidth: Math.floor(viewportWidth), }; } - #updateGeometry(bucket: AssetBucket, invalidateHeight: boolean, noDefer: boolean = false) { + + #updateGeometry(bucket: AssetBucket, options: UpdateGeometryOptions) { + const { invalidateHeight, noDefer = false } = options; if (invalidateHeight) { bucket.isBucketHeightActual = false; } @@ -1193,7 +1198,7 @@ export class AssetStore { } for (const bucket of updatedBuckets) { bucket.sortDateGroups(); - this.#updateGeometry(bucket, true); + this.#updateGeometry(bucket, { invalidateHeight: true }); } this.updateIntersections(); } @@ -1275,7 +1280,7 @@ export class AssetStore { } const changedGeometry = changedBuckets.size > 0; for (const bucket of changedBuckets) { - this.#updateGeometry(bucket, true); + this.#updateGeometry(bucket, { invalidateHeight: true }); } if (changedGeometry) { this.updateIntersections(); @@ -1315,7 +1320,7 @@ export class AssetStore { refreshLayout() { for (const bucket of this.buckets) { - this.#updateGeometry(bucket, true); + this.#updateGeometry(bucket, { invalidateHeight: true }); } this.updateIntersections(); } @@ -1324,126 +1329,18 @@ export class AssetStore { return this.buckets[0]?.getFirstAsset(); } - async getPreviousAsset( - idable: { id: string }, - skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset', + async getLaterAsset( + assetDescriptor: AssetDescriptor, + magnitude: 'asset' | 'day' | 'month' | 'year' = 'asset', ): Promise { - const bucket = this.#findBucketForAsset(idable.id); - if (!bucket) { - return; - } - const asset = bucket.findById(idable.id); - if (!asset) { - return; - } - switch (skipTo) { - case 'day': { - return this.#getPreviousDay(asset, bucket); - } - case 'month': { - return this.#getPreviousMonth(asset, bucket); - } - case 'year': { - return this.#getPreviousYear(asset, bucket); - } - case 'asset': { - return this.#getPreviousAsset(asset, bucket); - } - } + return this.#getAssetWithOffset(assetDescriptor, magnitude, 'forward'); } - async #getPreviousDay(asset: AssetResponseDto, bucket: AssetBucket) { - let nextDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') + 1; - const bucketIndex = this.buckets.indexOf(bucket); - - let nextDaygroup; - while (nextDay <= 31) { - nextDaygroup = bucket.findDateGroupByDay(nextDay); - if (nextDaygroup) { - break; - } - nextDay++; - } - if (nextDaygroup === undefined) { - let previousBucketIndex = bucketIndex - 1; - while (previousBucketIndex >= 0) { - bucket = this.buckets[previousBucketIndex]; - if (!bucket) { - return; - } - await this.loadBucket(bucket.bucketDate, { cancelable: false }); - const previous = bucket.lastDateGroup?.intersetingAssets.at(0)?.asset; - if (previous) { - return previous; - } - previousBucketIndex--; - } - } else { - return nextDaygroup.intersetingAssets.at(0)?.asset; - } - } - - async #getPreviousMonth(asset: AssetResponseDto, bucket: AssetBucket) { - const bucketIndex = this.buckets.indexOf(bucket); - const previousBucket = this.buckets[bucketIndex - 1]; - if (previousBucket) { - await this.loadBucket(previousBucket.bucketDate, { cancelable: false }); - return previousBucket.dateGroups[0]?.intersetingAssets[0]?.asset; - } - return; - } - - async #getPreviousYear(asset: AssetResponseDto, bucket: AssetBucket) { - const nextYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') + 1; - const bIdx = this.buckets.indexOf(bucket); - - for (let idx = bIdx; idx >= 0; idx--) { - const otherBucket = this.buckets[idx]; - const otherBucketYear = DateTime.fromISO(otherBucket.bucketDate).toUTC().get('year'); - if (otherBucketYear >= nextYear) { - await this.loadBucket(otherBucket.bucketDate, { cancelable: false }); - return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset; - } - } - return; - } - - async #getPreviousAsset(asset: AssetResponseDto, bucket: AssetBucket) { - // 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; - } - } - - 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--; - } + async getEarlierAsset( + assetDescriptor: AssetDescriptor, + magnitude: 'asset' | 'day' | 'month' | 'year' = 'asset', + ): Promise { + return this.#getAssetWithOffset(assetDescriptor, magnitude, 'backward'); } async getClosestAssetToDate(date: DateTime) { @@ -1457,92 +1354,110 @@ export class AssetStore { return asset; } - let bucketIndex = this.buckets.indexOf(bucket) + 1; - while (bucketIndex < this.buckets.length) { - bucket = this.buckets[bucketIndex]; + const startIndex = this.buckets.indexOf(bucket); + for (let currentIndex = startIndex + 1; currentIndex < this.buckets.length; currentIndex++) { + bucket = this.buckets[currentIndex]; await this.loadBucket(bucket.bucketDate); const next = bucket.dateGroups[0]?.intersetingAssets[0]?.asset; if (next) { return next; } - bucketIndex++; } } - async getNextAsset( - idable: { id: string }, - skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset', + async #getAssetWithOffset( + assetDescriptor: AssetDescriptor, + magnitude: 'asset' | 'day' | 'month' | 'year' = 'asset', + direction: Direction, ): Promise { - const bucket = this.#findBucketForAsset(idable.id); + const bucket = this.#findBucketForAsset(assetDescriptor.id); if (!bucket) { return; } - const asset = bucket.findById(idable.id); + const asset = bucket.findAssetById(assetDescriptor); if (!asset) { return; } - - switch (skipTo) { + switch (magnitude) { case 'day': { - return this.#getNextDay(asset, bucket); + return this.#getAssetByDayOffset(asset, bucket, direction); } case 'month': { - return this.#getNextMonth(asset, bucket); + return this.#getAssetByMonthOffset(asset, bucket, direction); } case 'year': { - return this.#getNextYear(asset, bucket); + return this.#getAssetByYearOffset(asset, bucket, direction); } case 'asset': { - return this.#getNextAsset(asset, bucket); + return this.#getAssetByAssetOffset(asset, bucket, direction); } } } - async #getNextDay(asset: AssetResponseDto, bucket: AssetBucket) { - let prevDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') - 1; - const bucketIndex = this.buckets.indexOf(bucket); + async #getAssetByDayOffset(asset: AssetResponseDto, bucket: AssetBucket, direction: Direction) { + const currentDate = DateTime.fromISO(asset.localDateTime).toUTC(); + const targetDate = + direction === 'forward' + ? currentDate.plus({ days: 1 }) // Moving forward in time (previous in UI) + : currentDate.minus({ days: 1 }); // Moving backward in time (next in UI) - let prevDayGroup; - while (prevDay >= 0) { - prevDayGroup = bucket.findDateGroupByDay(prevDay); - if (prevDayGroup) { - break; + // If the target day is in the same month/bucket + if (targetDate.month === currentDate.month && targetDate.year === currentDate.year) { + const targetDayGroup = bucket.findDateGroupByDay(targetDate.day); + if (targetDayGroup) { + return targetDayGroup.intersetingAssets.at(0)?.asset; } - prevDay--; } - if (prevDayGroup === undefined) { - let nextBucketIndex = bucketIndex + 1; - while (nextBucketIndex < this.buckets.length) { - const otherBucket = this.buckets[nextBucketIndex]; - await this.loadBucket(otherBucket.bucketDate, { cancelable: false }); - const next = otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset; - if (next) { - return next; - } - nextBucketIndex++; + + // Need to look through other buckets + const startIndex = this.buckets.indexOf(bucket); + const endCondition = (currentIndex: number) => + direction === 'forward' ? currentIndex >= 0 : currentIndex < this.buckets.length; + const increment = direction === 'forward' ? -1 : 1; // -1 for newer buckets, +1 for older buckets + + for (let currentIndex = startIndex + increment; endCondition(currentIndex); currentIndex += increment) { + const targetBucket = this.buckets[currentIndex]; + await this.loadBucket(targetBucket.bucketDate, { cancelable: false }); + if (targetBucket.dateGroups.length > 0) { + return targetBucket.dateGroups[0]?.intersetingAssets[0]?.asset; } - } else { - return prevDayGroup.intersetingAssets.at(0)?.asset; } + return undefined; } - async #getNextMonth(asset: AssetResponseDto, bucket: AssetBucket) { + async #getAssetByMonthOffset(asset: AssetResponseDto, bucket: AssetBucket, direction: Direction) { const bucketIndex = this.buckets.indexOf(bucket); - const nextMonthBucketIndex = this.buckets[bucketIndex + 1]; - if (nextMonthBucketIndex) { - await this.loadBucket(nextMonthBucketIndex.bucketDate, { cancelable: false }); - return nextMonthBucketIndex.dateGroups[0]?.intersetingAssets[0]?.asset; + const targetBucketIndex = bucketIndex + (direction === 'forward' ? -1 : 1); + const targetBucket = this.buckets[targetBucketIndex]; + + if (targetBucket) { + await this.loadBucket(targetBucket.bucketDate, { cancelable: false }); + return targetBucket.dateGroups[0]?.intersetingAssets[0]?.asset; } return; } - async #getNextYear(asset: AssetResponseDto, bucket: AssetBucket) { - const prevYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') - 1; + async #getAssetByYearOffset(asset: AssetResponseDto, bucket: AssetBucket, direction: Direction) { + const currentDate = DateTime.fromISO(asset.localDateTime).toUTC(); + const targetYear = currentDate.get('year') + (direction === 'forward' ? 1 : -1); const bucketIndex = this.buckets.indexOf(bucket); - for (let idx = bucketIndex; idx < this.buckets.length - 1; idx++) { - const otherBucket = this.buckets[idx]; + + // Define search range based on direction + const startIndex = bucketIndex; + const endCondition = (currentIndex: number) => + direction === 'forward' ? currentIndex >= 0 : currentIndex < this.buckets.length - 1; + const increment = direction === 'forward' ? -1 : 1; + + for (let currentIndex = startIndex; endCondition(currentIndex); currentIndex += increment) { + const otherBucket = this.buckets[currentIndex]; const otherBucketYear = DateTime.fromISO(otherBucket.bucketDate).toUTC().get('year'); - if (otherBucketYear <= prevYear) { + + const yearCondition = + direction === 'forward' + ? otherBucketYear >= targetYear // Looking for newer years + : otherBucketYear <= targetYear; // Looking for older years + + if (yearCondition) { await this.loadBucket(otherBucket.bucketDate, { cancelable: false }); return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset; } @@ -1550,38 +1465,51 @@ export class AssetStore { return; } - async #getNextAsset(asset: AssetResponseDto, bucket: AssetBucket) { + async #getAssetByAssetOffset(asset: AssetResponseDto, bucket: AssetBucket, direction: Direction) { // 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); + const assetIndex = group.intersetingAssets.findIndex((intersectingAsset) => intersectingAsset.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 not at the boundary of the group, return the next/previous asset in this group + const nextIndex = direction === 'forward' ? assetIndex - 1 : assetIndex + 1; + if (direction === 'forward' ? assetIndex > 0 : assetIndex < group.intersetingAssets.length - 1) { + return group.intersetingAssets[nextIndex].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; + // If there are more date groups in this bucket, check the next/previous one + const nextGroupIndex = direction === 'forward' ? groupIndex - 1 : groupIndex + 1; + if (direction === 'forward' ? groupIndex > 0 : groupIndex < bucket.dateGroups.length - 1) { + const adjacentGroup = bucket.dateGroups[nextGroupIndex]; + return direction === 'forward' + ? adjacentGroup.intersetingAssets.at(-1)?.asset + : adjacentGroup.intersetingAssets[0]?.asset; } - // Otherwise, we need to look in the next bucket + // Otherwise, we need to look in the adjacent bucket break; } } - 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; + // Look through adjacent buckets until we find one with assets + const startIndex = this.buckets.indexOf(bucket); + const endCondition = (currentIndex: number) => + direction === 'forward' ? currentIndex >= 0 : currentIndex < this.buckets.length; + const increment = direction === 'forward' ? -1 : 1; + + for (let currentIndex = startIndex + increment; endCondition(currentIndex); currentIndex += increment) { + const adjacentBucket = this.buckets[currentIndex]; + await this.loadBucket(adjacentBucket.bucketDate); + + if (adjacentBucket.dateGroups.length > 0) { + return direction === 'forward' + ? adjacentBucket.lastDateGroup?.intersetingAssets.at(-1)?.asset + : adjacentBucket.dateGroups[0]?.intersetingAssets[0]?.asset; } - bucketIndex++; } + + return undefined; } isExcluded(asset: AssetResponseDto) { diff --git a/web/src/lib/utils/focus-util.ts b/web/src/lib/utils/focus-util.ts index ae3f853040..2136b81001 100644 --- a/web/src/lib/utils/focus-util.ts +++ b/web/src/lib/utils/focus-util.ts @@ -12,7 +12,7 @@ export const setDefaultTabbleOptions = (options: TabbableOpts) => { export const getTabbable = (container: Element, includeContainer: boolean = false) => tabbable(container, { ...defaultOpts, includeContainer }); -export const moveFocus = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => { +export const moveFocus = (selector: (element: HTMLElement | SVGElement) => boolean, direction: 'previous' | 'next') => { const focusElements = focusable(document.body, { includeContainer: true }); const current = document.activeElement as HTMLElement; const index = focusElements.indexOf(current); @@ -29,7 +29,7 @@ export const moveFocus = (selector: (element: HTMLElement | SVGElement) => boole const totalElements = focusElements.length; let i = index; do { - i = (i + (forwardDirection ? 1 : -1) + totalElements) % totalElements; + i = (i + (direction === 'next' ? 1 : -1) + totalElements) % totalElements; const next = focusElements[i]; if (isTabbable(next) && selector(next)) { next.focus(); diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts index 2dc289b06f..61b0afe16c 100644 --- a/web/src/lib/utils/invocationTracker.ts +++ b/web/src/lib/utils/invocationTracker.ts @@ -1,25 +1,60 @@ +/** + * 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 {Object} 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 { + /** + * Checks if an error is an invalid invocation error + * @param {unknown} error - The error to check + * @returns {boolean} True if the error is an invalid invocation error + */ isInvalidInvocationError(error: unknown) { return error instanceof Error && error.message === 'Invocation not valid'; }, + + /** + * Throws an error if this invocation is no longer valid + * @throws {Error} If the invocation is no longer valid + */ checkStillValid: () => { if (invocation !== this.invocationsStarted) { throw new Error('Invocation not valid'); } }, + + /** + * Marks this invocation as complete + */ endInvocation: () => { this.invocationsEnded = invocation; }, }; } + + /** + * Checks if there are any active invocations + * @returns {boolean} True if there are active invocations, false otherwise + */ isActive() { return this.invocationsStarted !== this.invocationsEnded; }