Review comments

This commit is contained in:
Min Idzelis 2025-05-15 01:36:22 +00:00
parent 4f92be7f4f
commit c2d37a912b
10 changed files with 210 additions and 239 deletions

View File

@ -207,7 +207,7 @@
onSelect?.(asset); onSelect?.(asset);
} }
if (document.activeElement === element && evt.key === 'Escape') { if (document.activeElement === element && evt.key === 'Escape') {
moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, true); moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, 'next');
} }
}} }}
onclick={handleClick} onclick={handleClick}

View File

@ -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 = () => 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<boolean>, asset: { id: string }) => { export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise<boolean>, asset: { id: string }) => {
const scrolled = await scrollToAsset(asset.id); const scrolled = await scrollToAsset(asset.id);
@ -27,8 +28,8 @@ export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise<boo
export const setFocusTo = async ( export const setFocusTo = async (
scrollToAsset: (id: string) => Promise<boolean>, scrollToAsset: (id: string) => Promise<boolean>,
store: AssetStore, store: AssetStore,
direction: 'next' | 'previous', direction: 'earlier' | 'later',
skip: 'day' | 'month' | 'year', magnitude: 'day' | 'month' | 'year',
) => { ) => {
const thumb = getFocusedThumb(); const thumb = getFocusedThumb();
if (!thumb) { if (!thumb) {
@ -36,7 +37,7 @@ export const setFocusTo = async (
// there are unfinished running invocations, so return early // there are unfinished running invocations, so return early
return; return;
} }
return direction === 'next' ? focusNextAsset() : focusPreviousAsset(); return direction === 'earlier' ? focusNextAsset() : focusPreviousAsset();
} }
const invocation = tracker.startInvocation(); const invocation = tracker.startInvocation();
@ -47,7 +48,9 @@ export const setFocusTo = async (
} }
try { try {
const asset = 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(); invocation.checkStillValid();
if (!asset) { if (!asset) {

View File

@ -382,26 +382,26 @@
}; };
const handlePrevious = async () => { const handlePrevious = async () => {
const previousAsset = await assetStore.getPreviousAsset($viewingAsset); const laterAsset = await assetStore.getLaterAsset($viewingAsset);
if (previousAsset) { if (laterAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset); const preloadAsset = await assetStore.getLaterAsset(laterAsset);
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []); assetViewingStore.setAsset(laterAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id }); await navigate({ targetRoute: 'current', assetId: laterAsset.id });
} }
return !!previousAsset; return !!laterAsset;
}; };
const handleNext = async () => { const handleNext = async () => {
const nextAsset = await assetStore.getNextAsset($viewingAsset); const earlierAsset = await assetStore.getEarlierAsset($viewingAsset);
if (nextAsset) { if (earlierAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset); const preloadAsset = await assetStore.getEarlierAsset(earlierAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []); assetViewingStore.setAsset(earlierAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id }); await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
} }
return !!nextAsset; return !!earlierAsset;
}; };
const handleRandom = async () => { const handleRandom = async () => {
@ -629,8 +629,9 @@
} }
}; };
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true); const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false); const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0); let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
@ -669,12 +670,12 @@
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: focusNextAsset }, { shortcut: { key: 'ArrowRight' }, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: focusPreviousAsset }, { shortcut: { key: 'ArrowLeft' }, onShortcut: focusPreviousAsset },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('next', 'day') }, { shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('previous', 'day') }, { shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('next', 'month') }, { shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('previous', 'month') }, { shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('next', 'year') }, { shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('previous', 'year') }, { shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) }, { shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
]; ];
@ -728,7 +729,7 @@
timezoneInput={false} timezoneInput={false}
onConfirm={async (dateString: string) => { onConfirm={async (dateString: string) => {
isShowSelectDate = false; isShowSelectDate = false;
const date = DateTime.fromISO(dateString); const date = DateTime.fromISO(dateString).toUTC();
const asset = await assetStore.getClosestAssetToDate(date); const asset = await assetStore.getClosestAssetToDate(date);
if (asset) { if (asset) {
await setFocusAsset(asset); await setFocusAsset(asset);

View File

@ -260,8 +260,9 @@
} }
}; };
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true); const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false); const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false; let isShortcutModalOpen = false;

View File

@ -25,9 +25,9 @@
shortcuts = { shortcuts = {
general: [ general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') }, { key: ['←', '→'], action: $t('previous_or_next_photo') },
{ key: ['D', 'd'], action: 'Next or Previous Day' }, { key: ['D', 'd'], action: 'Day forward/backward' },
{ key: ['M', 'm'], action: 'Next or Previous Month' }, { key: ['M', 'm'], action: 'Month forward/backward' },
{ key: ['Y', 'y'], action: 'Next or Previous Year' }, { key: ['Y', 'y'], action: 'Year forward/backward' },
{ key: ['x'], action: $t('select') }, { key: ['x'], action: $t('select') },
{ key: ['Esc'], action: $t('back_close_deselect') }, { key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') }, { key: ['Ctrl', 'k'], action: $t('search_your_photos') },

View File

@ -25,6 +25,9 @@
shortcuts = { shortcuts = {
general: [ general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') }, { 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: ['x'], action: $t('select') },
{ key: ['Esc'], action: $t('back_close_deselect') }, { key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') }, { key: ['Ctrl', 'k'], action: $t('search_your_photos') },

View File

@ -347,7 +347,7 @@ describe('AssetStore', () => {
}); });
}); });
describe('getPreviousAsset', () => { describe('getLaterAsset', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = { const bucketAssets: Record<string, AssetResponseDto[]> = {
'2024-03-01T00:00:00.000Z': assetFactory '2024-03-01T00:00:00.000Z': assetFactory
@ -374,8 +374,8 @@ describe('AssetStore', () => {
}); });
it('returns null for invalid assetId', async () => { it('returns null for invalid assetId', async () => {
expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow(); expect(() => assetStore.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined(); expect(await assetStore.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
}); });
it('returns previous assetId', async () => { it('returns previous assetId', async () => {
@ -384,7 +384,7 @@ describe('AssetStore', () => {
const a = bucket!.getAssets()[0]; const a = bucket!.getAssets()[0];
const b = bucket!.getAssets()[1]; const b = bucket!.getAssets()[1];
const previous = await assetStore.getPreviousAsset(b); const previous = await assetStore.getLaterAsset(b);
expect(previous).toEqual(a); expect(previous).toEqual(a);
}); });
@ -396,7 +396,7 @@ describe('AssetStore', () => {
const previousBucket = assetStore.getBucketByDate(2024, 3); const previousBucket = assetStore.getBucketByDate(2024, 3);
const a = bucket!.getAssets()[0]; const a = bucket!.getAssets()[0];
const b = previousBucket!.getAssets()[0]; const b = previousBucket!.getAssets()[0];
const previous = await assetStore.getPreviousAsset(a); const previous = await assetStore.getLaterAsset(a);
expect(previous).toEqual(b); expect(previous).toEqual(b);
}); });
@ -408,7 +408,7 @@ describe('AssetStore', () => {
const previousBucket = assetStore.getBucketByDate(2024, 3); const previousBucket = assetStore.getBucketByDate(2024, 3);
const a = bucket!.getAssets()[0]; const a = bucket!.getAssets()[0];
const b = previousBucket!.getAssets()[0]; const b = previousBucket!.getAssets()[0];
const previous = await assetStore.getPreviousAsset(a); const previous = await assetStore.getLaterAsset(a);
expect(previous).toEqual(b); expect(previous).toEqual(b);
expect(loadBucketSpy).toBeCalledTimes(1); expect(loadBucketSpy).toBeCalledTimes(1);
}); });
@ -420,12 +420,12 @@ describe('AssetStore', () => {
const [assetOne, assetTwo, assetThree] = assetStore.getAssets(); const [assetOne, assetTwo, assetThree] = assetStore.getAssets();
assetStore.removeAssets([assetTwo.id]); 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 () => { it('returns null when no more assets', async () => {
await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); 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();
}); });
}); });

View File

@ -36,6 +36,7 @@ export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
timelineAlbumId?: string; timelineAlbumId?: string;
deferInit?: boolean; deferInit?: boolean;
}; };
type AssetDescriptor = { id: string };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function updateObject(target: any, source: any): boolean { function updateObject(target: any, source: any): boolean {
@ -60,6 +61,7 @@ function updateObject(target: any, source: any): boolean {
} }
return updated; return updated;
} }
type Direction = 'forward' | 'backward';
export function assetSnapshot(asset: AssetResponseDto) { export function assetSnapshot(asset: AssetResponseDto) {
return $state.snapshot(asset); return $state.snapshot(asset);
@ -514,20 +516,16 @@ export class AssetBucket {
} }
} }
findById(id: string) { findAssetById(assetDescriptor: AssetDescriptor) {
return this.assets().find((asset) => asset.id === id); return this.assets().find((asset) => asset.id === assetDescriptor.id);
} }
findClosest(target: DateTime) { findClosest(target: DateTime) {
let closest = this.assets().next().value; let closest = undefined;
if (!closest) { let smallestDiff = Infinity;
return;
}
let smallestDiff = Math.abs(target.toMillis() - DateTime.fromISO(closest.localDateTime).toUTC().toMillis());
for (const current of this.assets()) { 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) { if (diff < smallestDiff) {
smallestDiff = diff; smallestDiff = diff;
closest = current; closest = current;
@ -584,6 +582,11 @@ type AssetStoreLayoutOptions = {
headerHeight?: number; headerHeight?: number;
gap?: number; gap?: number;
}; };
interface UpdateGeometryOptions {
invalidateHeight: boolean;
noDefer?: boolean;
}
export class AssetStore { export class AssetStore {
// --- public ---- // --- public ----
isInitialized = $state(false); isInitialized = $state(false);
@ -865,7 +868,7 @@ export class AssetStore {
clearDeferredLayout(bucket: AssetBucket) { clearDeferredLayout(bucket: AssetBucket) {
const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout); const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout);
if (hasDeferred) { if (hasDeferred) {
this.#updateGeometry(bucket, true, true); this.#updateGeometry(bucket, { invalidateHeight: true, noDefer: true });
for (const group of bucket.dateGroups) { for (const group of bucket.dateGroups) {
group.deferredLayout = false; group.deferredLayout = false;
} }
@ -987,7 +990,7 @@ export class AssetStore {
return; return;
} }
for (const bucket of this.buckets) { for (const bucket of this.buckets) {
this.#updateGeometry(bucket, changedWidth); this.#updateGeometry(bucket, { invalidateHeight: changedWidth });
} }
this.updateIntersections(); this.updateIntersections();
this.#createScrubBuckets(); this.#createScrubBuckets();
@ -1013,7 +1016,9 @@ export class AssetStore {
rowWidth: Math.floor(viewportWidth), rowWidth: Math.floor(viewportWidth),
}; };
} }
#updateGeometry(bucket: AssetBucket, invalidateHeight: boolean, noDefer: boolean = false) {
#updateGeometry(bucket: AssetBucket, options: UpdateGeometryOptions) {
const { invalidateHeight, noDefer = false } = options;
if (invalidateHeight) { if (invalidateHeight) {
bucket.isBucketHeightActual = false; bucket.isBucketHeightActual = false;
} }
@ -1193,7 +1198,7 @@ export class AssetStore {
} }
for (const bucket of updatedBuckets) { for (const bucket of updatedBuckets) {
bucket.sortDateGroups(); bucket.sortDateGroups();
this.#updateGeometry(bucket, true); this.#updateGeometry(bucket, { invalidateHeight: true });
} }
this.updateIntersections(); this.updateIntersections();
} }
@ -1275,7 +1280,7 @@ export class AssetStore {
} }
const changedGeometry = changedBuckets.size > 0; const changedGeometry = changedBuckets.size > 0;
for (const bucket of changedBuckets) { for (const bucket of changedBuckets) {
this.#updateGeometry(bucket, true); this.#updateGeometry(bucket, { invalidateHeight: true });
} }
if (changedGeometry) { if (changedGeometry) {
this.updateIntersections(); this.updateIntersections();
@ -1315,7 +1320,7 @@ export class AssetStore {
refreshLayout() { refreshLayout() {
for (const bucket of this.buckets) { for (const bucket of this.buckets) {
this.#updateGeometry(bucket, true); this.#updateGeometry(bucket, { invalidateHeight: true });
} }
this.updateIntersections(); this.updateIntersections();
} }
@ -1324,126 +1329,18 @@ export class AssetStore {
return this.buckets[0]?.getFirstAsset(); return this.buckets[0]?.getFirstAsset();
} }
async getPreviousAsset( async getLaterAsset(
idable: { id: string }, assetDescriptor: AssetDescriptor,
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset', magnitude: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<AssetResponseDto | undefined> { ): Promise<AssetResponseDto | undefined> {
const bucket = this.#findBucketForAsset(idable.id); return this.#getAssetWithOffset(assetDescriptor, magnitude, 'forward');
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);
}
}
} }
async #getPreviousDay(asset: AssetResponseDto, bucket: AssetBucket) { async getEarlierAsset(
let nextDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') + 1; assetDescriptor: AssetDescriptor,
const bucketIndex = this.buckets.indexOf(bucket); magnitude: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<AssetResponseDto | undefined> {
let nextDaygroup; return this.#getAssetWithOffset(assetDescriptor, magnitude, 'backward');
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 getClosestAssetToDate(date: DateTime) { async getClosestAssetToDate(date: DateTime) {
@ -1457,92 +1354,110 @@ export class AssetStore {
return asset; return asset;
} }
let bucketIndex = this.buckets.indexOf(bucket) + 1; const startIndex = this.buckets.indexOf(bucket);
while (bucketIndex < this.buckets.length) { for (let currentIndex = startIndex + 1; currentIndex < this.buckets.length; currentIndex++) {
bucket = this.buckets[bucketIndex]; bucket = this.buckets[currentIndex];
await this.loadBucket(bucket.bucketDate); await this.loadBucket(bucket.bucketDate);
const next = bucket.dateGroups[0]?.intersetingAssets[0]?.asset; const next = bucket.dateGroups[0]?.intersetingAssets[0]?.asset;
if (next) { if (next) {
return next; return next;
} }
bucketIndex++;
} }
} }
async getNextAsset( async #getAssetWithOffset(
idable: { id: string }, assetDescriptor: AssetDescriptor,
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset', magnitude: 'asset' | 'day' | 'month' | 'year' = 'asset',
direction: Direction,
): Promise<AssetResponseDto | undefined> { ): Promise<AssetResponseDto | undefined> {
const bucket = this.#findBucketForAsset(idable.id); const bucket = this.#findBucketForAsset(assetDescriptor.id);
if (!bucket) { if (!bucket) {
return; return;
} }
const asset = bucket.findById(idable.id); const asset = bucket.findAssetById(assetDescriptor);
if (!asset) { if (!asset) {
return; return;
} }
switch (magnitude) {
switch (skipTo) {
case 'day': { case 'day': {
return this.#getNextDay(asset, bucket); return this.#getAssetByDayOffset(asset, bucket, direction);
} }
case 'month': { case 'month': {
return this.#getNextMonth(asset, bucket); return this.#getAssetByMonthOffset(asset, bucket, direction);
} }
case 'year': { case 'year': {
return this.#getNextYear(asset, bucket); return this.#getAssetByYearOffset(asset, bucket, direction);
} }
case 'asset': { case 'asset': {
return this.#getNextAsset(asset, bucket); return this.#getAssetByAssetOffset(asset, bucket, direction);
} }
} }
} }
async #getNextDay(asset: AssetResponseDto, bucket: AssetBucket) { async #getAssetByDayOffset(asset: AssetResponseDto, bucket: AssetBucket, direction: Direction) {
let prevDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') - 1; const currentDate = DateTime.fromISO(asset.localDateTime).toUTC();
const bucketIndex = this.buckets.indexOf(bucket); 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; // If the target day is in the same month/bucket
while (prevDay >= 0) { if (targetDate.month === currentDate.month && targetDate.year === currentDate.year) {
prevDayGroup = bucket.findDateGroupByDay(prevDay); const targetDayGroup = bucket.findDateGroupByDay(targetDate.day);
if (prevDayGroup) { if (targetDayGroup) {
break; return targetDayGroup.intersetingAssets.at(0)?.asset;
} }
prevDay--;
} }
if (prevDayGroup === undefined) {
let nextBucketIndex = bucketIndex + 1; // Need to look through other buckets
while (nextBucketIndex < this.buckets.length) { const startIndex = this.buckets.indexOf(bucket);
const otherBucket = this.buckets[nextBucketIndex]; const endCondition = (currentIndex: number) =>
await this.loadBucket(otherBucket.bucketDate, { cancelable: false }); direction === 'forward' ? currentIndex >= 0 : currentIndex < this.buckets.length;
const next = otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset; const increment = direction === 'forward' ? -1 : 1; // -1 for newer buckets, +1 for older buckets
if (next) {
return next; for (let currentIndex = startIndex + increment; endCondition(currentIndex); currentIndex += increment) {
} const targetBucket = this.buckets[currentIndex];
nextBucketIndex++; 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 bucketIndex = this.buckets.indexOf(bucket);
const nextMonthBucketIndex = this.buckets[bucketIndex + 1]; const targetBucketIndex = bucketIndex + (direction === 'forward' ? -1 : 1);
if (nextMonthBucketIndex) { const targetBucket = this.buckets[targetBucketIndex];
await this.loadBucket(nextMonthBucketIndex.bucketDate, { cancelable: false });
return nextMonthBucketIndex.dateGroups[0]?.intersetingAssets[0]?.asset; if (targetBucket) {
await this.loadBucket(targetBucket.bucketDate, { cancelable: false });
return targetBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
} }
return; return;
} }
async #getNextYear(asset: AssetResponseDto, bucket: AssetBucket) { async #getAssetByYearOffset(asset: AssetResponseDto, bucket: AssetBucket, direction: Direction) {
const prevYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') - 1; const currentDate = DateTime.fromISO(asset.localDateTime).toUTC();
const targetYear = currentDate.get('year') + (direction === 'forward' ? 1 : -1);
const bucketIndex = this.buckets.indexOf(bucket); 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'); 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 }); await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset; return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
} }
@ -1550,38 +1465,51 @@ export class AssetStore {
return; return;
} }
async #getNextAsset(asset: AssetResponseDto, bucket: AssetBucket) { async #getAssetByAssetOffset(asset: AssetResponseDto, bucket: AssetBucket, direction: Direction) {
// Find which date group contains this asset // Find which date group contains this asset
for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) { for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) {
const group = bucket.dateGroups[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 (assetIndex !== -1) {
// If not the last asset in this group, return the next one // If not at the boundary of the group, return the next/previous asset in this group
if (assetIndex < group.intersetingAssets.length - 1) { const nextIndex = direction === 'forward' ? assetIndex - 1 : assetIndex + 1;
return group.intersetingAssets[assetIndex + 1].asset; 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 there are more date groups in this bucket, check the next/previous one
if (groupIndex < bucket.dateGroups.length - 1) { const nextGroupIndex = direction === 'forward' ? groupIndex - 1 : groupIndex + 1;
return bucket.dateGroups[groupIndex + 1].intersetingAssets[0]?.asset; 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; break;
} }
} }
let bucketIndex = this.buckets.indexOf(bucket) + 1; // Look through adjacent buckets until we find one with assets
while (bucketIndex < this.buckets.length) { const startIndex = this.buckets.indexOf(bucket);
bucket = this.buckets[bucketIndex]; const endCondition = (currentIndex: number) =>
await this.loadBucket(bucket.bucketDate); direction === 'forward' ? currentIndex >= 0 : currentIndex < this.buckets.length;
const next = bucket.dateGroups[0]?.intersetingAssets[0]?.asset; const increment = direction === 'forward' ? -1 : 1;
if (next) {
return next; 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) { isExcluded(asset: AssetResponseDto) {

View File

@ -12,7 +12,7 @@ export const setDefaultTabbleOptions = (options: TabbableOpts) => {
export const getTabbable = (container: Element, includeContainer: boolean = false) => export const getTabbable = (container: Element, includeContainer: boolean = false) =>
tabbable(container, { ...defaultOpts, includeContainer }); 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 focusElements = focusable(document.body, { includeContainer: true });
const current = document.activeElement as HTMLElement; const current = document.activeElement as HTMLElement;
const index = focusElements.indexOf(current); const index = focusElements.indexOf(current);
@ -29,7 +29,7 @@ export const moveFocus = (selector: (element: HTMLElement | SVGElement) => boole
const totalElements = focusElements.length; const totalElements = focusElements.length;
let i = index; let i = index;
do { do {
i = (i + (forwardDirection ? 1 : -1) + totalElements) % totalElements; i = (i + (direction === 'next' ? 1 : -1) + totalElements) % totalElements;
const next = focusElements[i]; const next = focusElements[i];
if (isTabbable(next) && selector(next)) { if (isTabbable(next) && selector(next)) {
next.focus(); next.focus();

View File

@ -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 { export class InvocationTracker {
/** Counter for the number of invocations that have been started */
invocationsStarted = 0; invocationsStarted = 0;
/** Counter for the number of invocations that have been completed */
invocationsEnded = 0; invocationsEnded = 0;
constructor() {} 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() { startInvocation() {
this.invocationsStarted++; this.invocationsStarted++;
const invocation = this.invocationsStarted; const invocation = this.invocationsStarted;
return { 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) { isInvalidInvocationError(error: unknown) {
return error instanceof Error && error.message === 'Invocation not valid'; 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: () => { checkStillValid: () => {
if (invocation !== this.invocationsStarted) { if (invocation !== this.invocationsStarted) {
throw new Error('Invocation not valid'); throw new Error('Invocation not valid');
} }
}, },
/**
* Marks this invocation as complete
*/
endInvocation: () => { endInvocation: () => {
this.invocationsEnded = invocation; this.invocationsEnded = invocation;
}, },
}; };
} }
/**
* Checks if there are any active invocations
* @returns {boolean} True if there are active invocations, false otherwise
*/
isActive() { isActive() {
return this.invocationsStarted !== this.invocationsEnded; return this.invocationsStarted !== this.invocationsEnded;
} }