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) {
|
||||
options = newOptions;
|
||||
if (withDefaults(options).active) {
|
||||
setInitialFocus();
|
||||
void setInitialFocus();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
destroyShortcuts?.();
|
||||
if (triggerElement instanceof HTMLElement) {
|
||||
console.log('destroy triggerElement', triggerElement.textContent);
|
||||
triggerElement.focus();
|
||||
}
|
||||
},
|
||||
|
@ -26,7 +26,7 @@ export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
|
||||
|
||||
const element = children.at(newIndex);
|
||||
if (element instanceof HTMLElement) {
|
||||
element.focus();
|
||||
// element.focus();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -46,7 +46,6 @@
|
||||
onClick?: ((asset: AssetResponseDto) => void) | undefined;
|
||||
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
|
||||
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
|
||||
handleFocus?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
let {
|
||||
@ -65,7 +64,6 @@
|
||||
onClick = undefined,
|
||||
onSelect = undefined,
|
||||
onMouseEvent = undefined,
|
||||
handleFocus = undefined,
|
||||
imageClass = '',
|
||||
brokenAssetClass = '',
|
||||
dimmed = false,
|
||||
@ -177,18 +175,42 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-asset={asset.id}
|
||||
class={[
|
||||
'focus-visible:outline-none flex overflow-hidden',
|
||||
disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20',
|
||||
]}
|
||||
style:width="{width}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}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
class="absolute object-cover"
|
||||
class="absolute object-cover z-30"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
@ -202,29 +224,9 @@
|
||||
slow: ??ms
|
||||
-->
|
||||
<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: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 -->
|
||||
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
|
||||
@ -246,7 +248,6 @@
|
||||
class={['absolute z-20 p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
|
||||
role="checkbox"
|
||||
tabindex={-1}
|
||||
onfocus={handleFocus}
|
||||
aria-checked={selected}
|
||||
{disabled}
|
||||
>
|
||||
@ -285,13 +286,6 @@
|
||||
{#if dimmed && !mouseOver}
|
||||
<div id="a" class={['absolute h-full w-full z-30 bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||
{/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 -->
|
||||
{#if !isSharedLink() && asset.isFavorite}
|
||||
@ -371,3 +365,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-asset]:focus > [data-outline] {
|
||||
outline-style: solid;
|
||||
}
|
||||
</style>
|
||||
|
@ -27,7 +27,7 @@
|
||||
if (targetEl) {
|
||||
const element = getTabbable(targetEl)[0];
|
||||
if (element) {
|
||||
element.focus();
|
||||
// element.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -8,9 +8,11 @@
|
||||
id?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
onkeydown?: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let { type, value = $bindable(), max = undefined, ...rest }: Props = $props();
|
||||
let { type, value = $bindable(), max = undefined, onkeydown, ...rest }: Props = $props();
|
||||
|
||||
let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
|
||||
|
||||
@ -30,5 +32,6 @@
|
||||
if (e.key === 'Enter') {
|
||||
value = updatedValue;
|
||||
}
|
||||
onkeydown?.(e);
|
||||
}}
|
||||
/>
|
||||
|
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 { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
|
@ -14,7 +14,7 @@
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
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 Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||
@ -26,7 +26,15 @@
|
||||
import type { UpdatePayload } from 'vite';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.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 {
|
||||
isSelectionMode?: boolean;
|
||||
@ -76,6 +84,7 @@
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
let showShortcuts = $state(false);
|
||||
let showSkeleton = $state(true);
|
||||
let isShowSelectDate = $state(false);
|
||||
let scrubBucketPercent = $state(0);
|
||||
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
|
||||
let scrubOverallPercent: number = $state(0);
|
||||
@ -101,26 +110,37 @@
|
||||
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 scrollTarget = $gridScrollTarget?.at;
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
try {
|
||||
const bucket = await assetStore.findBucketForAsset(scrollTarget);
|
||||
if (bucket) {
|
||||
const height = bucket.findAssetAbsolutePosition(scrollTarget);
|
||||
if (height) {
|
||||
scrollTo(height);
|
||||
assetStore.updateIntersections();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore errors - asset may not be in the store
|
||||
}
|
||||
scrolled = await scrollToAsset(scrollTarget);
|
||||
}
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
scrollToTop();
|
||||
}
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
beforeNavigate(() => (assetStore.suspendTransitions = true));
|
||||
|
||||
afterNavigate((nav) => {
|
||||
const { complete } = nav;
|
||||
complete.then(completeNav, completeNav);
|
||||
@ -347,12 +367,6 @@
|
||||
deselectAllAssets();
|
||||
};
|
||||
|
||||
const focusElement = () => {
|
||||
if (document.activeElement === document.body) {
|
||||
element?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAsset = (asset: AssetResponseDto) => {
|
||||
if (!assetStore.albumAssets.has(asset.id)) {
|
||||
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 isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
|
||||
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(
|
||||
(() => {
|
||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||
@ -632,10 +646,15 @@
|
||||
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
|
||||
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
|
||||
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
|
||||
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
|
||||
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: 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: 'G' }, onShortcut: () => (isShowSelectDate = true) },
|
||||
];
|
||||
|
||||
if (assetInteraction.selectionActive) {
|
||||
@ -685,6 +704,20 @@
|
||||
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
||||
{/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}
|
||||
<Scrubber
|
||||
{assetStore}
|
||||
|
@ -417,10 +417,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const assetOnFocusHandler = (asset: AssetResponseDto) => {
|
||||
assetInteraction.focussedAssetId = asset.id;
|
||||
};
|
||||
|
||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||
|
||||
@ -490,12 +486,10 @@
|
||||
}}
|
||||
onSelect={(asset) => handleSelectAssets(asset)}
|
||||
onMouseEvent={() => assetMouseEventHandler(asset)}
|
||||
handleFocus={() => assetOnFocusHandler(asset)}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
focussed={assetInteraction.isFocussedAsset(asset.id)}
|
||||
thumbnailWidth={layout.width}
|
||||
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 = {
|
||||
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: ['x'], action: $t('select') },
|
||||
{ key: ['Esc'], action: $t('back_close_deselect') },
|
||||
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
|
||||
|
@ -92,7 +92,7 @@ class IntersectingAsset {
|
||||
});
|
||||
|
||||
position: CommonPosition | undefined = $state();
|
||||
asset: AssetResponseDto | undefined = $state();
|
||||
asset: AssetResponseDto = <AssetResponseDto>$state();
|
||||
id: string | undefined = $derived(this.asset?.id);
|
||||
|
||||
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
|
||||
@ -504,6 +504,36 @@ export class AssetBucket {
|
||||
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() {
|
||||
this.loader?.cancel();
|
||||
}
|
||||
@ -749,7 +779,6 @@ export class AssetStore {
|
||||
return batch;
|
||||
}
|
||||
|
||||
// todo: this should probably be a method isteat
|
||||
#findBucketForAsset(id: string) {
|
||||
for (const bucket of this.buckets) {
|
||||
if (bucket.containsAssetId(id)) {
|
||||
@ -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) {
|
||||
this.#scrollTop = scrollTop;
|
||||
this.updateIntersections();
|
||||
@ -1161,15 +1199,6 @@ export class AssetStore {
|
||||
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) {
|
||||
return this.#findBucketForAsset(assetId);
|
||||
}
|
||||
@ -1265,11 +1294,74 @@ export class AssetStore {
|
||||
return this.buckets[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
|
||||
let bucket = await this.#getBucketInfoForAsset(asset);
|
||||
async getPreviousAsset(
|
||||
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 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
|
||||
for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) {
|
||||
@ -1308,12 +1400,96 @@ export class AssetStore {
|
||||
}
|
||||
}
|
||||
|
||||
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
|
||||
let bucket = await this.#getBucketInfoForAsset(asset);
|
||||
async getClosestAssetToDate(date: DateTime) {
|
||||
let bucket = this.#findBucketForDate(date);
|
||||
if (!bucket) {
|
||||
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
|
||||
for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) {
|
||||
const group = bucket.dateGroups[groupIndex];
|
||||
|
@ -13,10 +13,19 @@ export const getTabbable = (container: Element, includeContainer: boolean = fals
|
||||
tabbable(container, { ...defaultOpts, includeContainer });
|
||||
|
||||
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 index = focusElements.indexOf(current);
|
||||
|
||||
if (index === -1) {
|
||||
for (const element of focusElements) {
|
||||
if (selector(element)) {
|
||||
element.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
focusElements[0].focus();
|
||||
return;
|
||||
}
|
||||
if (forwardDirection) {
|
||||
let i = index + 1;
|
||||
while (i !== index) {
|
||||
@ -34,7 +43,7 @@ export const focusNext = (selector: (element: HTMLElement | SVGElement) => boole
|
||||
}
|
||||
} else {
|
||||
let i = index - 1;
|
||||
while (i !== index) {
|
||||
while (i !== index && i >= 0) {
|
||||
const next = focusElements[i];
|
||||
if (!isTabbable(next) || !selector(next)) {
|
||||
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