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);
}
if (document.activeElement === element && evt.key === 'Escape') {
moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, true);
moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, 'next');
}
}}
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 = () =>
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 }) => {
const scrolled = await scrollToAsset(asset.id);
@ -27,8 +28,8 @@ export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise<boo
export const setFocusTo = async (
scrollToAsset: (id: string) => Promise<boolean>,
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) {

View File

@ -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);

View File

@ -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;

View File

@ -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') },

View File

@ -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') },

View File

@ -347,7 +347,7 @@ describe('AssetStore', () => {
});
});
describe('getPreviousAsset', () => {
describe('getLaterAsset', () => {
let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = {
'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();
});
});

View File

@ -36,6 +36,7 @@ export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
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<AssetResponseDto | undefined> {
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<AssetResponseDto | undefined> {
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<AssetResponseDto | undefined> {
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) {

View File

@ -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();

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 {
/** 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;
}