mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
feat: keyboard nav
This commit is contained in:
parent
83eced6f78
commit
12a76a750f
@ -70,13 +70,12 @@ export function focusTrap(container: HTMLElement, options?: Options) {
|
|||||||
update(newOptions?: Options) {
|
update(newOptions?: Options) {
|
||||||
options = newOptions;
|
options = newOptions;
|
||||||
if (withDefaults(options).active) {
|
if (withDefaults(options).active) {
|
||||||
setInitialFocus();
|
void setInitialFocus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyShortcuts?.();
|
destroyShortcuts?.();
|
||||||
if (triggerElement instanceof HTMLElement) {
|
if (triggerElement instanceof HTMLElement) {
|
||||||
console.log('destroy triggerElement', triggerElement.textContent);
|
|
||||||
triggerElement.focus();
|
triggerElement.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -26,7 +26,7 @@ export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
|
|||||||
|
|
||||||
const element = children.at(newIndex);
|
const element = children.at(newIndex);
|
||||||
if (element instanceof HTMLElement) {
|
if (element instanceof HTMLElement) {
|
||||||
element.focus();
|
// element.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -46,7 +46,6 @@
|
|||||||
onClick?: ((asset: AssetResponseDto) => void) | undefined;
|
onClick?: ((asset: AssetResponseDto) => void) | undefined;
|
||||||
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
|
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
|
||||||
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
|
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
|
||||||
handleFocus?: (() => void) | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -65,7 +64,6 @@
|
|||||||
onClick = undefined,
|
onClick = undefined,
|
||||||
onSelect = undefined,
|
onSelect = undefined,
|
||||||
onMouseEvent = undefined,
|
onMouseEvent = undefined,
|
||||||
handleFocus = undefined,
|
|
||||||
imageClass = '',
|
imageClass = '',
|
||||||
brokenAssetClass = '',
|
brokenAssetClass = '',
|
||||||
dimmed = false,
|
dimmed = false,
|
||||||
@ -177,18 +175,42 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-asset={asset.id}
|
|
||||||
class={[
|
class={[
|
||||||
'focus-visible:outline-none flex overflow-hidden',
|
'focus-visible:outline-none flex overflow-hidden',
|
||||||
disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20',
|
disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20',
|
||||||
]}
|
]}
|
||||||
style:width="{width}px"
|
style:width="{width}px"
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
|
onmouseenter={onMouseEnter}
|
||||||
|
onmouseleave={onMouseLeave}
|
||||||
|
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
|
||||||
|
onkeydown={(evt) => {
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
callClickHandlers();
|
||||||
|
}
|
||||||
|
if (evt.key === 'x') {
|
||||||
|
onSelect?.(asset);
|
||||||
|
}
|
||||||
|
if (document.activeElement === element && evt.key === 'Escape') {
|
||||||
|
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onclick={handleClick}
|
||||||
|
bind:this={element}
|
||||||
|
data-asset={asset.id}
|
||||||
|
data-thumbnail-focus-container
|
||||||
|
tabindex={0}
|
||||||
|
role="link"
|
||||||
>
|
>
|
||||||
|
<!-- Outline on focus -->
|
||||||
|
<div
|
||||||
|
class={['absolute z-40 size-full outline-4 -outline-offset-4 outline-immich-primary', { 'rounded-xl': selected }]}
|
||||||
|
data-outline
|
||||||
|
></div>
|
||||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||||
<canvas
|
<canvas
|
||||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||||
class="absolute object-cover"
|
class="absolute object-cover z-30"
|
||||||
style:width="{width}px"
|
style:width="{width}px"
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||||
@ -202,29 +224,9 @@
|
|||||||
slow: ??ms
|
slow: ??ms
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
class={['group absolute top-[0px] bottom-[0px]', { 'curstor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||||
style:width="inherit"
|
style:width="inherit"
|
||||||
style:height="inherit"
|
style:height="inherit"
|
||||||
onmouseenter={onMouseEnter}
|
|
||||||
onmouseleave={onMouseLeave}
|
|
||||||
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
|
|
||||||
onkeydown={(evt) => {
|
|
||||||
if (evt.key === 'Enter') {
|
|
||||||
callClickHandlers();
|
|
||||||
}
|
|
||||||
if (evt.key === 'x') {
|
|
||||||
onSelect?.(asset);
|
|
||||||
}
|
|
||||||
if (document.activeElement === element && evt.key === 'Escape') {
|
|
||||||
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onclick={handleClick}
|
|
||||||
bind:this={element}
|
|
||||||
onfocus={handleFocus}
|
|
||||||
data-thumbnail-focus-container
|
|
||||||
tabindex={0}
|
|
||||||
role="link"
|
|
||||||
>
|
>
|
||||||
<!-- Select asset button -->
|
<!-- Select asset button -->
|
||||||
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
|
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
|
||||||
@ -246,7 +248,6 @@
|
|||||||
class={['absolute z-20 p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
|
class={['absolute z-20 p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
tabindex={-1}
|
tabindex={-1}
|
||||||
onfocus={handleFocus}
|
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
{disabled}
|
{disabled}
|
||||||
>
|
>
|
||||||
@ -285,13 +286,6 @@
|
|||||||
{#if dimmed && !mouseOver}
|
{#if dimmed && !mouseOver}
|
||||||
<div id="a" class={['absolute h-full w-full z-30 bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
<div id="a" class={['absolute h-full w-full z-30 bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Outline on focus -->
|
|
||||||
<div
|
|
||||||
class={[
|
|
||||||
'absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary',
|
|
||||||
{ 'rounded-xl': selected },
|
|
||||||
]}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Favorite asset star -->
|
<!-- Favorite asset star -->
|
||||||
{#if !isSharedLink() && asset.isFavorite}
|
{#if !isSharedLink() && asset.isFavorite}
|
||||||
@ -371,3 +365,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[data-asset]:focus > [data-outline] {
|
||||||
|
outline-style: solid;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
if (targetEl) {
|
if (targetEl) {
|
||||||
const element = getTabbable(targetEl)[0];
|
const element = getTabbable(targetEl)[0];
|
||||||
if (element) {
|
if (element) {
|
||||||
element.focus();
|
// element.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -8,9 +8,11 @@
|
|||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
autofocus?: boolean;
|
||||||
|
onkeydown?: (e: KeyboardEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { type, value = $bindable(), max = undefined, ...rest }: Props = $props();
|
let { type, value = $bindable(), max = undefined, onkeydown, ...rest }: Props = $props();
|
||||||
|
|
||||||
let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
|
let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
|
||||||
|
|
||||||
@ -30,5 +32,6 @@
|
|||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
value = updatedValue;
|
value = updatedValue;
|
||||||
}
|
}
|
||||||
|
onkeydown?.(e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
70
web/src/lib/components/photos-page/actions/focus-actions.ts
Normal file
70
web/src/lib/components/photos-page/actions/focus-actions.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
|
import { focusNext } from '$lib/utils/focus-util';
|
||||||
|
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||||
|
import { retry } from '$lib/utils/retry';
|
||||||
|
|
||||||
|
const waitForElement = retry((query: string) => document.querySelector(query) as HTMLElement, 10, 100);
|
||||||
|
const tracker = new InvocationTracker();
|
||||||
|
const getFocusedThumb = () => {
|
||||||
|
const current = document.activeElement as HTMLElement;
|
||||||
|
if (current && current.dataset.thumbnailFocusContainer !== undefined) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
|
||||||
|
export const focusPreviousAsset = () =>
|
||||||
|
focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
|
||||||
|
|
||||||
|
export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise<boolean>, asset: { id: string }) => {
|
||||||
|
const scrolled = await scrollToAsset(asset.id);
|
||||||
|
if (scrolled) {
|
||||||
|
const element = await waitForElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||||
|
element?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setFocusTo = async (
|
||||||
|
scrollToAsset: (id: string) => Promise<boolean>,
|
||||||
|
store: AssetStore,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
skip: 'day' | 'month' | 'year',
|
||||||
|
) => {
|
||||||
|
const thumb = getFocusedThumb();
|
||||||
|
if (!thumb) {
|
||||||
|
if (tracker.isActive()) {
|
||||||
|
// there are unfinished running invocations, so return early
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return direction === 'next' ? focusNextAsset() : focusPreviousAsset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const invocation = tracker.startInvocation();
|
||||||
|
try {
|
||||||
|
if (thumb) {
|
||||||
|
const id = thumb?.dataset.asset;
|
||||||
|
if (id) {
|
||||||
|
const asset =
|
||||||
|
direction === 'next' ? await store.getNextAsset({ id }, skip) : await store.getPreviousAsset({ id }, skip);
|
||||||
|
invocation.checkStillValid();
|
||||||
|
if (asset) {
|
||||||
|
const scrolled = await scrollToAsset(asset.id);
|
||||||
|
invocation.checkStillValid();
|
||||||
|
if (scrolled) {
|
||||||
|
invocation.checkStillValid();
|
||||||
|
const element = await waitForElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||||
|
invocation.checkStillValid();
|
||||||
|
element?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invocation.endInvocation();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (invocation.isInvalidInvocationError(error)) {
|
||||||
|
// expected
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
@ -19,6 +19,7 @@
|
|||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
|
|
||||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
let { isUploading } = uploadAssetsStore;
|
let { isUploading } = uploadAssetsStore;
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { onMount, tick, type Snippet } from 'svelte';
|
||||||
import Portal from '../shared-components/portal/portal.svelte';
|
import Portal from '../shared-components/portal/portal.svelte';
|
||||||
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||||
@ -26,7 +26,15 @@
|
|||||||
import type { UpdatePayload } from 'vite';
|
import type { UpdatePayload } from 'vite';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { focusNext } from '$lib/utils/focus-util';
|
|
||||||
|
import SelectDate from '$lib/components/shared-components/select-date.svelte';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import {
|
||||||
|
focusNextAsset,
|
||||||
|
focusPreviousAsset,
|
||||||
|
setFocusTo as setFocusToInit,
|
||||||
|
setFocusToAsset as setFocusAssetInit,
|
||||||
|
} from '$lib/components/photos-page/actions/focus-actions';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
@ -76,6 +84,7 @@
|
|||||||
let timelineElement: HTMLElement | undefined = $state();
|
let timelineElement: HTMLElement | undefined = $state();
|
||||||
let showShortcuts = $state(false);
|
let showShortcuts = $state(false);
|
||||||
let showSkeleton = $state(true);
|
let showSkeleton = $state(true);
|
||||||
|
let isShowSelectDate = $state(false);
|
||||||
let scrubBucketPercent = $state(0);
|
let scrubBucketPercent = $state(0);
|
||||||
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
|
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
|
||||||
let scrubOverallPercent: number = $state(0);
|
let scrubOverallPercent: number = $state(0);
|
||||||
@ -101,26 +110,37 @@
|
|||||||
scrollTo(0);
|
scrollTo(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scrollToAsset = async (assetId: string) => {
|
||||||
|
try {
|
||||||
|
const bucket = await assetStore.findBucketForAsset(assetId);
|
||||||
|
if (bucket) {
|
||||||
|
const height = bucket.findAssetAbsolutePosition(assetId);
|
||||||
|
if (height) {
|
||||||
|
scrollTo(height);
|
||||||
|
assetStore.updateIntersections();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors - asset may not be in the store
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const completeNav = async () => {
|
const completeNav = async () => {
|
||||||
const scrollTarget = $gridScrollTarget?.at;
|
const scrollTarget = $gridScrollTarget?.at;
|
||||||
|
let scrolled = false;
|
||||||
if (scrollTarget) {
|
if (scrollTarget) {
|
||||||
try {
|
scrolled = await scrollToAsset(scrollTarget);
|
||||||
const bucket = await assetStore.findBucketForAsset(scrollTarget);
|
}
|
||||||
if (bucket) {
|
if (!scrolled) {
|
||||||
const height = bucket.findAssetAbsolutePosition(scrollTarget);
|
// if the asset is not found, scroll to the top
|
||||||
if (height) {
|
scrollToTop();
|
||||||
scrollTo(height);
|
|
||||||
assetStore.updateIntersections();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore errors - asset may not be in the store
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
scrollToTop();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeNavigate(() => (assetStore.suspendTransitions = true));
|
beforeNavigate(() => (assetStore.suspendTransitions = true));
|
||||||
|
|
||||||
afterNavigate((nav) => {
|
afterNavigate((nav) => {
|
||||||
const { complete } = nav;
|
const { complete } = nav;
|
||||||
complete.then(completeNav, completeNav);
|
complete.then(completeNav, completeNav);
|
||||||
@ -347,12 +367,6 @@
|
|||||||
deselectAllAssets();
|
deselectAllAssets();
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusElement = () => {
|
|
||||||
if (document.activeElement === document.body) {
|
|
||||||
element?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAsset = (asset: AssetResponseDto) => {
|
const handleSelectAsset = (asset: AssetResponseDto) => {
|
||||||
if (!assetStore.albumAssets.has(asset.id)) {
|
if (!assetStore.albumAssets.has(asset.id)) {
|
||||||
assetInteraction.selectAsset(asset);
|
assetInteraction.selectAsset(asset);
|
||||||
@ -608,9 +622,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
|
|
||||||
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
|
|
||||||
|
|
||||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
|
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
|
||||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||||
@ -621,6 +632,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, assetStore);
|
||||||
|
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
||||||
|
|
||||||
let shortcutList = $derived(
|
let shortcutList = $derived(
|
||||||
(() => {
|
(() => {
|
||||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||||
@ -632,10 +646,15 @@
|
|||||||
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
||||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
||||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
|
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
|
||||||
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
|
{ shortcut: { key: 'ArrowRight' }, onShortcut: focusNextAsset },
|
||||||
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
|
{ shortcut: { key: 'ArrowLeft' }, onShortcut: focusPreviousAsset },
|
||||||
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
|
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('next', 'day') },
|
||||||
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
|
{ 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: 'G' }, onShortcut: () => (isShowSelectDate = true) },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (assetInteraction.selectionActive) {
|
if (assetInteraction.selectionActive) {
|
||||||
@ -685,6 +704,20 @@
|
|||||||
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isShowSelectDate}
|
||||||
|
<SelectDate
|
||||||
|
initialDate={DateTime.now()}
|
||||||
|
onConfirm={async (date: DateTime) => {
|
||||||
|
isShowSelectDate = false;
|
||||||
|
const asset = await assetStore.getClosestAssetToDate(date);
|
||||||
|
if (asset) {
|
||||||
|
await setFocusAsset(asset);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => (isShowSelectDate = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if assetStore.buckets.length > 0}
|
{#if assetStore.buckets.length > 0}
|
||||||
<Scrubber
|
<Scrubber
|
||||||
{assetStore}
|
{assetStore}
|
||||||
|
@ -417,10 +417,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetOnFocusHandler = (asset: AssetResponseDto) => {
|
|
||||||
assetInteraction.focussedAssetId = asset.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||||
|
|
||||||
@ -490,12 +486,10 @@
|
|||||||
}}
|
}}
|
||||||
onSelect={(asset) => handleSelectAssets(asset)}
|
onSelect={(asset) => handleSelectAssets(asset)}
|
||||||
onMouseEvent={() => assetMouseEventHandler(asset)}
|
onMouseEvent={() => assetMouseEventHandler(asset)}
|
||||||
handleFocus={() => assetOnFocusHandler(asset)}
|
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{asset}
|
{asset}
|
||||||
selected={assetInteraction.hasSelectedAsset(asset.id)}
|
selected={assetInteraction.hasSelectedAsset(asset.id)}
|
||||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
focussed={assetInteraction.isFocussedAsset(asset.id)}
|
|
||||||
thumbnailWidth={layout.width}
|
thumbnailWidth={layout.width}
|
||||||
thumbnailHeight={layout.height}
|
thumbnailHeight={layout.height}
|
||||||
/>
|
/>
|
||||||
|
52
web/src/lib/components/shared-components/select-date.svelte
Normal file
52
web/src/lib/components/shared-components/select-date.svelte
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import ConfirmDialog from './dialog/confirm-dialog.svelte';
|
||||||
|
import DateInput from '../elements/date-input.svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialDate?: DateTime;
|
||||||
|
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (date: DateTime) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { initialDate = DateTime.now(), onCancel, onConfirm }: Props = $props();
|
||||||
|
|
||||||
|
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm"));
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const value = date;
|
||||||
|
if (value) {
|
||||||
|
onConfirm(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
|
||||||
|
let date = $derived(DateTime.fromISO(selectedDate));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ConfirmDialog confirmColor="primary" title="Go to date" disabled={!date.isValid} onConfirm={handleConfirm} {onCancel}>
|
||||||
|
{#snippet promptSnippet()}
|
||||||
|
<div class="flex flex-col text-left gap-2">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="datetime">{$t('date_and_time')}</label>
|
||||||
|
<DateInput
|
||||||
|
class="immich-form-input"
|
||||||
|
id="datetime"
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={selectedDate}
|
||||||
|
autofocus
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleConfirm();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</ConfirmDialog>
|
@ -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: 'Next or Previous Day' },
|
||||||
|
{ key: ['M', 'm'], action: 'Next or Previous Month' },
|
||||||
|
{ key: ['Y', 'y'], action: 'Next or Previous Year' },
|
||||||
{ 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') },
|
||||||
|
@ -92,7 +92,7 @@ class IntersectingAsset {
|
|||||||
});
|
});
|
||||||
|
|
||||||
position: CommonPosition | undefined = $state();
|
position: CommonPosition | undefined = $state();
|
||||||
asset: AssetResponseDto | undefined = $state();
|
asset: AssetResponseDto = <AssetResponseDto>$state();
|
||||||
id: string | undefined = $derived(this.asset?.id);
|
id: string | undefined = $derived(this.asset?.id);
|
||||||
|
|
||||||
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
|
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
|
||||||
@ -504,6 +504,36 @@ export class AssetBucket {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*assets() {
|
||||||
|
for (const group of this.dateGroups) {
|
||||||
|
for (const asset of group.intersetingAssets) {
|
||||||
|
yield asset.asset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string) {
|
||||||
|
return this.assets().find((asset) => asset.id === 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());
|
||||||
|
|
||||||
|
for (const current of this.assets()) {
|
||||||
|
const diff = Math.abs(target.toMillis() - DateTime.fromISO(current.localDateTime).toUTC().toMillis());
|
||||||
|
if (diff < smallestDiff) {
|
||||||
|
smallestDiff = diff;
|
||||||
|
closest = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
this.loader?.cancel();
|
this.loader?.cancel();
|
||||||
}
|
}
|
||||||
@ -749,7 +779,6 @@ export class AssetStore {
|
|||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: this should probably be a method isteat
|
|
||||||
#findBucketForAsset(id: string) {
|
#findBucketForAsset(id: string) {
|
||||||
for (const bucket of this.buckets) {
|
for (const bucket of this.buckets) {
|
||||||
if (bucket.containsAssetId(id)) {
|
if (bucket.containsAssetId(id)) {
|
||||||
@ -758,6 +787,15 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#findBucketForDate(date: DateTime) {
|
||||||
|
for (const bucket of this.buckets) {
|
||||||
|
const bucketDate = DateTime.fromISO(bucket.bucketDate).toUTC();
|
||||||
|
if (bucketDate.hasSame(date, 'month') && bucketDate.hasSame(date, 'year')) {
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateSlidingWindow(scrollTop: number) {
|
updateSlidingWindow(scrollTop: number) {
|
||||||
this.#scrollTop = scrollTop;
|
this.#scrollTop = scrollTop;
|
||||||
this.updateIntersections();
|
this.updateIntersections();
|
||||||
@ -1161,15 +1199,6 @@ export class AssetStore {
|
|||||||
return this.getBucketByDate(year, month);
|
return this.getBucketByDate(year, month);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) {
|
|
||||||
const bucketInfo = this.#findBucketForAsset(asset.id);
|
|
||||||
if (bucketInfo) {
|
|
||||||
return bucketInfo;
|
|
||||||
}
|
|
||||||
await this.#loadBucketAtTime(asset.localDateTime, options);
|
|
||||||
return this.#findBucketForAsset(asset.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
getBucketIndexByAssetId(assetId: string) {
|
getBucketIndexByAssetId(assetId: string) {
|
||||||
return this.#findBucketForAsset(assetId);
|
return this.#findBucketForAsset(assetId);
|
||||||
}
|
}
|
||||||
@ -1265,11 +1294,74 @@ export class AssetStore {
|
|||||||
return this.buckets[0]?.getFirstAsset();
|
return this.buckets[0]?.getFirstAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
|
async getPreviousAsset(
|
||||||
let bucket = await this.#getBucketInfoForAsset(asset);
|
idable: { id: string },
|
||||||
|
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||||
|
): Promise<AssetResponseDto | undefined> {
|
||||||
|
let bucket = this.#findBucketForAsset(idable.id);
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const asset = bucket.findById(idable.id);
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (skipTo) {
|
||||||
|
case 'day': {
|
||||||
|
let nextDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') + 1;
|
||||||
|
const bIdx = this.buckets.indexOf(bucket);
|
||||||
|
|
||||||
|
let nextDaygroup;
|
||||||
|
while (nextDay <= 31) {
|
||||||
|
nextDaygroup = bucket.findDateGroupByDay(nextDay);
|
||||||
|
if (nextDaygroup) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
nextDay++;
|
||||||
|
}
|
||||||
|
if (nextDaygroup === undefined) {
|
||||||
|
let bucketIndex = bIdx - 1;
|
||||||
|
while (bucketIndex >= 0) {
|
||||||
|
bucket = this.buckets[bucketIndex];
|
||||||
|
if (!bucket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.loadBucket(bucket.bucketDate, { cancelable: false });
|
||||||
|
const previous = bucket.lastDateGroup?.intersetingAssets.at(0)?.asset;
|
||||||
|
if (previous) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
bucketIndex--;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nextDaygroup.intersetingAssets.at(0)?.asset;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
const bIdx = this.buckets.indexOf(bucket);
|
||||||
|
const otherBucket = this.buckets[bIdx - 1];
|
||||||
|
if (otherBucket) {
|
||||||
|
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
|
||||||
|
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'year': {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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++) {
|
||||||
@ -1308,12 +1400,96 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
|
async getClosestAssetToDate(date: DateTime) {
|
||||||
let bucket = await this.#getBucketInfoForAsset(asset);
|
let bucket = this.#findBucketForDate(date);
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await this.loadBucket(bucket.bucketDate, { cancelable: false });
|
||||||
|
const asset = bucket.findClosest(date);
|
||||||
|
if (asset) {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
bucketIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNextAsset(
|
||||||
|
idable: { id: string },
|
||||||
|
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||||
|
): Promise<AssetResponseDto | undefined> {
|
||||||
|
let bucket = this.#findBucketForAsset(idable.id);
|
||||||
|
if (!bucket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const asset = bucket.findById(idable.id);
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (skipTo) {
|
||||||
|
case 'day': {
|
||||||
|
let prevDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') - 1;
|
||||||
|
const bIdx = this.buckets.indexOf(bucket);
|
||||||
|
|
||||||
|
let prevDayGroup;
|
||||||
|
while (prevDay >= 0) {
|
||||||
|
prevDayGroup = bucket.findDateGroupByDay(prevDay);
|
||||||
|
if (prevDayGroup) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prevDay--;
|
||||||
|
}
|
||||||
|
if (prevDayGroup === undefined) {
|
||||||
|
let bucketIndex = bIdx + 1;
|
||||||
|
while (bucketIndex < this.buckets.length) {
|
||||||
|
const otherBucket = this.buckets[bucketIndex];
|
||||||
|
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
|
||||||
|
const next = otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
|
||||||
|
if (next) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
bucketIndex++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return prevDayGroup.intersetingAssets.at(0)?.asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
const bIdx = this.buckets.indexOf(bucket);
|
||||||
|
const otherBucket = this.buckets[bIdx + 1];
|
||||||
|
if (otherBucket) {
|
||||||
|
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
|
||||||
|
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'year': {
|
||||||
|
const prevYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') - 1;
|
||||||
|
const bIdx = this.buckets.indexOf(bucket);
|
||||||
|
for (let idx = bIdx; idx < this.buckets.length - 1; idx++) {
|
||||||
|
const otherBucket = this.buckets[idx];
|
||||||
|
const otherBucketYear = DateTime.fromISO(otherBucket.bucketDate).toUTC().get('year');
|
||||||
|
if (otherBucketYear <= prevYear) {
|
||||||
|
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
|
||||||
|
const a = otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
// 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];
|
||||||
|
@ -13,10 +13,19 @@ export const getTabbable = (container: Element, includeContainer: boolean = fals
|
|||||||
tabbable(container, { ...defaultOpts, includeContainer });
|
tabbable(container, { ...defaultOpts, includeContainer });
|
||||||
|
|
||||||
export const focusNext = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
|
export const focusNext = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
|
||||||
const focusElements = focusable(document.body);
|
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);
|
||||||
|
if (index === -1) {
|
||||||
|
for (const element of focusElements) {
|
||||||
|
if (selector(element)) {
|
||||||
|
element.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focusElements[0].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (forwardDirection) {
|
if (forwardDirection) {
|
||||||
let i = index + 1;
|
let i = index + 1;
|
||||||
while (i !== index) {
|
while (i !== index) {
|
||||||
@ -34,7 +43,7 @@ export const focusNext = (selector: (element: HTMLElement | SVGElement) => boole
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let i = index - 1;
|
let i = index - 1;
|
||||||
while (i !== index) {
|
while (i !== index && i >= 0) {
|
||||||
const next = focusElements[i];
|
const next = focusElements[i];
|
||||||
if (!isTabbable(next) || !selector(next)) {
|
if (!isTabbable(next) || !selector(next)) {
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
|
26
web/src/lib/utils/invocationTracker.ts
Normal file
26
web/src/lib/utils/invocationTracker.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export class InvocationTracker {
|
||||||
|
invocationsStarted = 0;
|
||||||
|
invocationsEnded = 0;
|
||||||
|
constructor() {}
|
||||||
|
startInvocation() {
|
||||||
|
this.invocationsStarted++;
|
||||||
|
const invocation = this.invocationsStarted;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isInvalidInvocationError(error: unknown) {
|
||||||
|
return error instanceof Error && error.message === 'Invocation not valid';
|
||||||
|
},
|
||||||
|
checkStillValid: () => {
|
||||||
|
if (invocation !== this.invocationsStarted) {
|
||||||
|
throw new Error('Invocation not valid');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
endInvocation: () => {
|
||||||
|
this.invocationsEnded = invocation;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
isActive() {
|
||||||
|
return this.invocationsStarted !== this.invocationsEnded;
|
||||||
|
}
|
||||||
|
}
|
30
web/src/lib/utils/retry.ts
Normal file
30
web/src/lib/utils/retry.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export const retry = <R, A = unknown>(
|
||||||
|
fn: (arg: A) => R,
|
||||||
|
interval: number = 10,
|
||||||
|
timeout: number = 1000,
|
||||||
|
): ((args: A) => Promise<R | null>) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
return (args: A): Promise<R | null> => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<R | null>((resolve) => {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
const attempt = () => {
|
||||||
|
const result = fn(args);
|
||||||
|
if (result) {
|
||||||
|
resolve(result);
|
||||||
|
} else if (Date.now() - start > timeout) {
|
||||||
|
resolve(null);
|
||||||
|
} else {
|
||||||
|
timer = setTimeout(attempt, interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
attempt();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user