Review comments

This commit is contained in:
Min Idzelis 2025-05-08 02:14:13 +00:00
parent 47ffe946c4
commit b038917f7a
8 changed files with 177 additions and 172 deletions

View File

@ -19,7 +19,7 @@
import { thumbhash } from '$lib/actions/thumbhash';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables';
import { onMount } from 'svelte';
@ -136,17 +136,13 @@
let startX: number = 0;
let startY: number = 0;
// As of iOS17, there is a preference for long press speed, which is not available for mobile web.
// The defaults are as follows:
// fast: 200ms
// default: 500ms
// slow: ??ms
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
let didPress = false;
const start = (evt: PointerEvent) => {
startX = evt.clientX;
startY = evt.clientY;
didPress = false;
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
timer = setTimeout(() => {
onLongPress();
element.addEventListener('contextmenu', preventContextMenu, { once: true });
@ -211,7 +207,7 @@
onSelect?.(asset);
}
if (document.activeElement === element && evt.key === 'Escape') {
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, true);
}
}}
onclick={handleClick}
@ -243,25 +239,6 @@
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}
data-thumbnail-focus-container
tabindex={0}
role="link"
>
<!-- Select asset button -->
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}

View File

@ -1,5 +1,5 @@
import type { AssetStore } from '$lib/stores/assets-store.svelte';
import { focusNext } from '$lib/utils/focus-util';
import { moveFocus } from '$lib/utils/focus-util';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { retry } from '$lib/utils/retry';
@ -12,9 +12,9 @@ const getFocusedThumb = () => {
}
};
export const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
export const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
export const focusPreviousAsset = () =>
focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise<boolean>, asset: { id: string }) => {
const scrolled = await scrollToAsset(asset.id);
@ -40,25 +40,30 @@ export const setFocusTo = async (
}
const invocation = tracker.startInvocation();
const id = thumb.dataset.asset;
if (!thumb || !id) {
invocation.endInvocation();
return;
}
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();
}
}
}
const asset =
direction === 'next' ? await store.getNextAsset({ id }, skip) : await store.getPreviousAsset({ id }, skip);
invocation.checkStillValid();
if (!asset) {
invocation.endInvocation();
return;
}
const scrolled = await scrollToAsset(asset.id);
invocation.checkStillValid();
if (scrolled) {
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)) {

View File

@ -21,7 +21,7 @@
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { moveFocus } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
@ -629,8 +629,8 @@
}
};
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);

View File

@ -1,28 +1,28 @@
<script lang="ts">
import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { featureFlags } from '$lib/stores/server-config.store';
import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
import { type AssetResponseDto } from '@immich/sdk';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
import Portal from '../portal/portal.svelte';
import { handlePromiseError } from '$lib/utils';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { debounce } from 'lodash-es';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { focusNext } from '$lib/utils/focus-util';
import Portal from '../portal/portal.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
interface Props {
assets: AssetResponseDto[];
@ -260,8 +260,8 @@
}
};
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let shortcutList = $derived(
(() => {

View File

@ -12,18 +12,16 @@
}
let { initialDate = DateTime.now(), onCancel, onConfirm }: Props = $props();
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm"));
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
const date = $derived(DateTime.fromISO(selectedDate));
const handleConfirm = () => {
const value = date;
if (value) {
onConfirm(value);
if (date) {
onConfirm(date);
}
};
// 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

View File

@ -322,13 +322,7 @@ export class AssetBucket {
}
containsAssetId(id: string) {
for (const group of this.dateGroups) {
const index = group.intersetingAssets.findIndex((a) => a.id == id);
if (index !== -1) {
return true;
}
}
return false;
return this.assets().some((asset) => asset.id == id);
}
sortDateGroups() {
@ -1333,7 +1327,7 @@ export class AssetStore {
idable: { id: string },
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<AssetResponseDto | undefined> {
let bucket = this.#findBucketForAsset(idable.id);
const bucket = this.#findBucketForAsset(idable.id);
if (!bucket) {
return;
}
@ -1343,61 +1337,77 @@ export class AssetStore {
}
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;
return this.#getPreviousDay(asset, bucket);
}
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;
return this.#getPreviousMonth(asset, bucket);
}
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;
return this.#getPreviousYear(asset, bucket);
}
case 'asset': {
return this.#getPreviousAsset(asset, bucket);
}
}
}
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];
@ -1462,7 +1472,7 @@ export class AssetStore {
idable: { id: string },
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<AssetResponseDto | undefined> {
let bucket = this.#findBucketForAsset(idable.id);
const bucket = this.#findBucketForAsset(idable.id);
if (!bucket) {
return;
}
@ -1473,58 +1483,73 @@ export class AssetStore {
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;
return this.#getNextDay(asset, bucket);
}
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;
return this.#getNextMonth(asset, bucket);
}
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;
return this.#getNextYear(asset, bucket);
}
case 'asset': {
return this.#getNextAsset(asset, bucket);
}
}
}
async #getNextDay(asset: AssetResponseDto, bucket: AssetBucket) {
let prevDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') - 1;
const bucketIndex = this.buckets.indexOf(bucket);
let prevDayGroup;
while (prevDay >= 0) {
prevDayGroup = bucket.findDateGroupByDay(prevDay);
if (prevDayGroup) {
break;
}
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++;
}
} else {
return prevDayGroup.intersetingAssets.at(0)?.asset;
}
}
async #getNextMonth(asset: AssetResponseDto, bucket: AssetBucket) {
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;
}
return;
}
async #getNextYear(asset: AssetResponseDto, bucket: AssetBucket) {
const prevYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') - 1;
const bucketIndex = this.buckets.indexOf(bucket);
for (let idx = bucketIndex; 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 });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
}
return;
}
async #getNextAsset(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];

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 focusNext = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
export const moveFocus = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
const focusElements = focusable(document.body, { includeContainer: true });
const current = document.activeElement as HTMLElement;
const index = focusElements.indexOf(current);

View File

@ -1,4 +1,4 @@
export const retry = <R, A = unknown>(
export const retry = <R, A>(
fn: (arg: A) => R,
interval: number = 10,
timeout: number = 1000,