mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: keyboard navigation to timeline (#17798)
* feat: improve focus * feat: keyboard nav * feat: improve focus * typo * test * fix test * lint * bad merge * lint * inadvertent * lint * fix: flappy e2e test * bad merge and fix tests * use modulus in loop * tests * react to modal dialog refactor * regression due to deferLayout * Review comments * Re-use change-date instead of new component * bad merge * Review comments * rework moveFocus * lint * Fix outline * use Date * Finish up removing/reducing date parsing * lint * title * strings * Rework dates, rework earlier/later algorithm * bad merge * fix tests * Fix race in scroll comp * consolidate scroll methods * Review comments * console.log * Edge cases in scroll compensation * edge case, optimizations * review comments * lint * lint * More edge cases * lint --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
b5593823a2
commit
f029910dc7
21 changed files with 1077 additions and 598 deletions
|
|
@ -2,7 +2,7 @@ import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observe
|
|||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { fireEvent, render } from '@testing-library/svelte';
|
||||
import { render } from '@testing-library/svelte';
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
|
|
@ -45,34 +45,4 @@ describe('Thumbnail component', () => {
|
|||
const tabbables = getTabbable(container!);
|
||||
expect(tabbables.length).toBe(0);
|
||||
});
|
||||
|
||||
it('handleFocus should be called on focus of container', async () => {
|
||||
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
|
||||
const handleFocusSpy = vi.fn();
|
||||
const { baseElement } = render(Thumbnail, {
|
||||
asset,
|
||||
handleFocus: handleFocusSpy,
|
||||
});
|
||||
|
||||
const container = baseElement.querySelector('[data-thumbnail-focus-container]');
|
||||
expect(container).not.toBeNull();
|
||||
await fireEvent(container as HTMLElement, new FocusEvent('focus'));
|
||||
|
||||
expect(handleFocusSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('element will be focussed if not already', async () => {
|
||||
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
|
||||
const handleFocusSpy = vi.fn();
|
||||
const { baseElement } = render(Thumbnail, {
|
||||
asset,
|
||||
handleFocus: handleFocusSpy,
|
||||
});
|
||||
|
||||
const container = baseElement.querySelector('[data-thumbnail-focus-container]');
|
||||
expect(container).not.toBeNull();
|
||||
await fireEvent(container as HTMLElement, new FocusEvent('focus'));
|
||||
|
||||
expect(handleFocusSpy).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/stores/assets-store.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';
|
||||
|
|
@ -48,7 +48,6 @@
|
|||
onClick?: (asset: TimelineAsset) => void;
|
||||
onSelect?: (asset: TimelineAsset) => void;
|
||||
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
|
||||
handleFocus?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -67,7 +66,6 @@
|
|||
onClick = undefined,
|
||||
onSelect = undefined,
|
||||
onMouseEvent = undefined,
|
||||
handleFocus = undefined,
|
||||
imageClass = '',
|
||||
brokenAssetClass = '',
|
||||
dimmed = false,
|
||||
|
|
@ -140,12 +138,14 @@
|
|||
|
||||
let startX: number = 0;
|
||||
let startY: number = 0;
|
||||
|
||||
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 });
|
||||
|
|
@ -193,14 +193,41 @@
|
|||
</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') {
|
||||
moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, 'next');
|
||||
}
|
||||
}}
|
||||
onclick={handleClick}
|
||||
bind:this={element}
|
||||
data-asset={asset.id}
|
||||
data-thumbnail-focus-container
|
||||
tabindex={0}
|
||||
role="link"
|
||||
>
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none absolute z-1 size-full outline-hidden 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 }}
|
||||
|
|
@ -211,36 +238,10 @@
|
|||
></canvas>
|
||||
{/if}
|
||||
|
||||
<!-- 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
|
||||
-->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div
|
||||
class={[
|
||||
|
|
@ -265,13 +266,6 @@
|
|||
{#if dimmed && !mouseOver}
|
||||
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||
{/if}
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'absolute size-full group-focus-visible:outline-immich-primary focus:outline-4 -outline-offset-4 outline-immich-primary',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
></div>
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
{#if !authManager.key && asset.isFavorite}
|
||||
|
|
@ -372,7 +366,6 @@
|
|||
class={['absolute p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
|
||||
role="checkbox"
|
||||
tabindex={-1}
|
||||
onfocus={handleFocus}
|
||||
aria-checked={selected}
|
||||
{disabled}
|
||||
>
|
||||
|
|
@ -389,3 +382,9 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-asset]:focus > [data-outline] {
|
||||
outline-style: solid;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
78
web/src/lib/components/photos-page/actions/focus-actions.ts
Normal file
78
web/src/lib/components/photos-page/actions/focus-actions.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
|
||||
const getFocusedThumb = () => {
|
||||
const current = document.activeElement as HTMLElement | undefined;
|
||||
if (current && current.dataset.thumbnailFocusContainer !== undefined) {
|
||||
return current;
|
||||
}
|
||||
};
|
||||
|
||||
export const focusNextAsset = () =>
|
||||
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
|
||||
|
||||
export const focusPreviousAsset = () =>
|
||||
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
|
||||
|
||||
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
||||
|
||||
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
|
||||
const scrolled = scrollToAsset(asset);
|
||||
if (scrolled) {
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const setFocusTo = async (
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean,
|
||||
store: AssetStore,
|
||||
direction: 'earlier' | 'later',
|
||||
interval: 'day' | 'month' | 'year' | 'asset',
|
||||
) => {
|
||||
if (tracker.isActive()) {
|
||||
// there are unfinished running invocations, so return early
|
||||
return;
|
||||
}
|
||||
const thumb = getFocusedThumb();
|
||||
if (!thumb) {
|
||||
return direction === 'earlier' ? focusNextAsset() : focusPreviousAsset();
|
||||
}
|
||||
|
||||
const invocation = tracker.startInvocation();
|
||||
const id = thumb.dataset.asset;
|
||||
if (!thumb || !id) {
|
||||
invocation.endInvocation();
|
||||
return;
|
||||
}
|
||||
|
||||
const asset =
|
||||
direction === 'earlier'
|
||||
? await store.getEarlierAsset({ id }, interval)
|
||||
: await store.getLaterAsset({ id }, interval);
|
||||
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
invocation.endInvocation();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrolled = scrollToAsset(asset);
|
||||
if (scrolled) {
|
||||
await tick();
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
}
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
}
|
||||
|
||||
invocation.endInvocation();
|
||||
};
|
||||
|
|
@ -10,15 +10,13 @@
|
|||
type TimelineAsset,
|
||||
} from '$lib/stores/assets-store.svelte';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
|
|
@ -34,6 +32,7 @@
|
|||
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||
onSelectAssets: (asset: TimelineAsset) => void;
|
||||
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
||||
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -47,6 +46,7 @@
|
|||
onSelect,
|
||||
onSelectAssets,
|
||||
onSelectAssetCandidates,
|
||||
onScrollCompensation,
|
||||
}: Props = $props();
|
||||
|
||||
let isMouseOverGroup = $state(false);
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||
}
|
||||
|
||||
if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
|
||||
if (assetStore.count == assetInteraction.selectedAssets.length) {
|
||||
isSelectingAllAssets.set(true);
|
||||
} else {
|
||||
isSelectingAllAssets.set(false);
|
||||
|
|
@ -103,9 +103,16 @@
|
|||
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
|
||||
return intersectable.filter((int) => int.intersecting);
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
if (assetStore.scrollCompensation.bucket === bucket) {
|
||||
onScrollCompensation(assetStore.scrollCompensation);
|
||||
assetStore.clearScrollCompensation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)}
|
||||
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.day)}
|
||||
{@const absoluteWidth = dateGroup.left}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
|
@ -146,7 +153,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="w-full truncate first-letter:capitalize ms-2.5" title={getDateLocaleString(dateGroup.date)}>
|
||||
<span class="w-full truncate first-letter:capitalize ms-2.5" title={dateGroup.groupTitle}>
|
||||
{dateGroup.groupTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -158,7 +165,7 @@
|
|||
style:height={dateGroup.height + 'px'}
|
||||
style:width={dateGroup.width + 'px'}
|
||||
>
|
||||
{#each filterIntersecting(dateGroup.intersetingAssets) as intersectingAsset (intersectingAsset.id)}
|
||||
{#each filterIntersecting(dateGroup.intersectingAssets) as intersectingAsset (intersectingAsset.id)}
|
||||
{@const position = intersectingAsset.position!}
|
||||
{@const asset = intersectingAsset.asset!}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@
|
|||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import {
|
||||
setFocusToAsset as setFocusAssetInit,
|
||||
setFocusTo as setFocusToInit,
|
||||
} from '$lib/components/photos-page/actions/focus-actions';
|
||||
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
||||
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
||||
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
||||
|
|
@ -27,10 +32,10 @@
|
|||
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 { navigate } from '$lib/utils/navigation';
|
||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { type ScrubberListener, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
|
||||
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
|
|
@ -90,8 +95,9 @@
|
|||
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
let showSkeleton = $state(true);
|
||||
let isShowSelectDate = $state(false);
|
||||
let scrubBucketPercent = $state(0);
|
||||
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
|
||||
let scrubBucket: TimelinePlainYearMonth | undefined = $state();
|
||||
let scrubOverallPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
|
||||
|
|
@ -116,42 +122,69 @@
|
|||
});
|
||||
|
||||
const scrollTo = (top: number) => {
|
||||
element?.scrollTo({ top });
|
||||
showSkeleton = false;
|
||||
if (element) {
|
||||
element.scrollTo({ top });
|
||||
}
|
||||
};
|
||||
const scrollTop = (top: number) => {
|
||||
if (element) {
|
||||
element.scrollTop = top;
|
||||
}
|
||||
};
|
||||
const scrollBy = (y: number) => {
|
||||
if (element) {
|
||||
element.scrollBy(0, y);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
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
|
||||
const getAssetHeight = (assetId: string, bucket: AssetBucket) => {
|
||||
// the following method may trigger any layouts, so need to
|
||||
// handle any scroll compensation that may have been set
|
||||
const height = bucket!.findAssetAbsolutePosition(assetId);
|
||||
|
||||
while (assetStore.scrollCompensation.bucket) {
|
||||
handleScrollCompensation(assetStore.scrollCompensation);
|
||||
assetStore.clearScrollCompensation();
|
||||
}
|
||||
return false;
|
||||
return height;
|
||||
};
|
||||
|
||||
const scrollToAssetId = async (assetId: string) => {
|
||||
const bucket = await assetStore.findBucketForAsset(assetId);
|
||||
if (!bucket) {
|
||||
return false;
|
||||
}
|
||||
const height = getAssetHeight(assetId, bucket);
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollToAsset = (asset: TimelineAsset) => {
|
||||
const bucket = assetStore.getBucketIndexByAssetId(asset.id);
|
||||
if (!bucket) {
|
||||
return false;
|
||||
}
|
||||
const height = getAssetHeight(asset.id, bucket);
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
return true;
|
||||
};
|
||||
|
||||
const completeNav = async () => {
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollToAsset(scrollTarget);
|
||||
scrolled = await scrollToAssetId(scrollTarget);
|
||||
}
|
||||
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
scrollToTop();
|
||||
}
|
||||
showSkeleton = false;
|
||||
};
|
||||
|
||||
beforeNavigate(() => (assetStore.suspendTransitions = true));
|
||||
|
|
@ -185,6 +218,7 @@
|
|||
} else {
|
||||
scrollToTop();
|
||||
}
|
||||
showSkeleton = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
|
@ -204,23 +238,28 @@
|
|||
const updateIsScrolling = () => (assetStore.scrolling = true);
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
|
||||
const compensateScrollCallback = ({ delta, top }: { delta?: number; top?: number }) => {
|
||||
if (delta) {
|
||||
element?.scrollBy(0, delta);
|
||||
} else if (top) {
|
||||
element?.scrollTo({ top });
|
||||
|
||||
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
|
||||
if (heightDelta !== undefined) {
|
||||
scrollBy(heightDelta);
|
||||
} else if (scrollTop !== undefined) {
|
||||
scrollTo(scrollTop);
|
||||
}
|
||||
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
|
||||
// the above calls. However, this delay is enough time to set the intersecting property
|
||||
// of the bucket to false, then true, which causes the DOM nodes to be recreated,
|
||||
// causing bad perf, and also, disrupting focus of those elements.
|
||||
updateSlidingWindow();
|
||||
};
|
||||
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
assetStore.setCompensateScrollCallback(compensateScrollCallback);
|
||||
if (!enableRouting) {
|
||||
showSkeleton = false;
|
||||
}
|
||||
const disposeHmr = hmrSupport();
|
||||
return () => {
|
||||
assetStore.setCompensateScrollCallback();
|
||||
disposeHmr();
|
||||
};
|
||||
});
|
||||
|
|
@ -241,16 +280,14 @@
|
|||
const topOffset = bucket.top;
|
||||
const maxScrollPercent = getMaxScrollPercent();
|
||||
const delta = bucket.bucketHeight * bucketScrollPercent;
|
||||
const scrollTop = (topOffset + delta) * maxScrollPercent;
|
||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
if (element) {
|
||||
element.scrollTop = scrollTop;
|
||||
}
|
||||
scrollTop(scrollToTop);
|
||||
};
|
||||
|
||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||
const onScrub: ScrubberListener = (
|
||||
bucketDate: string | undefined,
|
||||
bucketDate: { year: number; month: number } | undefined,
|
||||
scrollPercent: number,
|
||||
bucketScrollPercent: number,
|
||||
) => {
|
||||
|
|
@ -258,12 +295,11 @@
|
|||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
const offset = maxScroll * scrollPercent;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.scrollTop = offset;
|
||||
scrollTop(offset);
|
||||
} else {
|
||||
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
|
||||
const bucket = assetStore.buckets.find(
|
||||
(bucket) => bucket.yearMonth.year === bucketDate.year && bucket.yearMonth.month === bucketDate.month,
|
||||
);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -303,7 +339,7 @@
|
|||
|
||||
const bucketsLength = assetStore.buckets.length;
|
||||
for (let i = -1; i < bucketsLength + 1; i++) {
|
||||
let bucket: { bucketDate: string | undefined } | undefined;
|
||||
let bucket: TimelinePlainYearMonth | undefined;
|
||||
let bucketHeight = 0;
|
||||
if (i === -1) {
|
||||
// lead-in
|
||||
|
|
@ -312,7 +348,7 @@
|
|||
// lead-out
|
||||
bucketHeight = bottomSectionHeight;
|
||||
} else {
|
||||
bucket = assetStore.buckets[i];
|
||||
bucket = assetStore.buckets[i].yearMonth;
|
||||
bucketHeight = assetStore.buckets[i].bucketHeight;
|
||||
}
|
||||
|
||||
|
|
@ -326,7 +362,7 @@
|
|||
|
||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
|
||||
scrubBucket = assetStore.buckets[i + 1];
|
||||
scrubBucket = assetStore.buckets[i + 1].yearMonth;
|
||||
scrubBucketPercent = 0;
|
||||
}
|
||||
|
||||
|
|
@ -385,12 +421,6 @@
|
|||
deselectAllAssets();
|
||||
};
|
||||
|
||||
const focusElement = () => {
|
||||
if (document.activeElement === document.body) {
|
||||
element?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||
if (!assetStore.albumAssets.has(asset.id)) {
|
||||
assetInteraction.selectAsset(asset);
|
||||
|
|
@ -398,37 +428,36 @@
|
|||
};
|
||||
|
||||
const handlePrevious = async () => {
|
||||
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
|
||||
const laterAsset = await assetStore.getLaterAsset($viewingAsset);
|
||||
|
||||
if (previousAsset) {
|
||||
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
|
||||
const asset = await getAssetInfo({ id: previousAsset.id, key: authManager.key });
|
||||
if (laterAsset) {
|
||||
const preloadAsset = await assetStore.getLaterAsset(laterAsset);
|
||||
const asset = await getAssetInfo({ id: laterAsset.id, key: authManager.key });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
|
||||
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);
|
||||
const asset = await getAssetInfo({ id: nextAsset.id, key: authManager.key });
|
||||
const earlierAsset = await assetStore.getEarlierAsset($viewingAsset);
|
||||
if (earlierAsset) {
|
||||
const preloadAsset = await assetStore.getEarlierAsset(earlierAsset);
|
||||
const asset = await getAssetInfo({ id: earlierAsset.id, key: authManager.key });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
|
||||
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
|
||||
}
|
||||
|
||||
return !!nextAsset;
|
||||
return !!earlierAsset;
|
||||
};
|
||||
|
||||
const handleRandom = async () => {
|
||||
const randomAsset = await assetStore.getRandomAsset();
|
||||
|
||||
if (randomAsset) {
|
||||
const preloadAsset = await assetStore.getNextAsset(randomAsset);
|
||||
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
assetViewingStore.setAsset(asset);
|
||||
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
||||
return asset;
|
||||
}
|
||||
|
|
@ -514,7 +543,7 @@
|
|||
|
||||
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
|
||||
if (asset) {
|
||||
selectAssetCandidates(asset);
|
||||
void selectAssetCandidates(asset);
|
||||
}
|
||||
lastAssetMouseEvent = asset;
|
||||
};
|
||||
|
|
@ -532,7 +561,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
|
||||
if (assetStore.count == assetInteraction.selectedAssets.length) {
|
||||
isSelectingAllAssets.set(true);
|
||||
} else {
|
||||
isSelectingAllAssets.set(false);
|
||||
|
|
@ -545,8 +574,8 @@
|
|||
}
|
||||
onSelect(asset);
|
||||
|
||||
if (singleSelect && element) {
|
||||
element.scrollTop = 0;
|
||||
if (singleSelect) {
|
||||
scrollTop(0);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -583,8 +612,8 @@
|
|||
break;
|
||||
}
|
||||
if (started) {
|
||||
await assetStore.loadBucket(bucket.bucketDate);
|
||||
for (const asset of bucket.getAssets()) {
|
||||
await assetStore.loadBucket(bucket.yearMonth);
|
||||
for (const asset of bucket.assetsIterator()) {
|
||||
if (deselect) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
} else {
|
||||
|
|
@ -623,7 +652,7 @@
|
|||
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
||||
};
|
||||
|
||||
const selectAssetCandidates = (endAsset: TimelineAsset) => {
|
||||
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
|
||||
if (!shiftKeyIsDown) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -633,16 +662,8 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const assets = assetsSnapshot(assetStore.getAssets());
|
||||
|
||||
let start = assets.findIndex((a) => a.id === startAsset.id);
|
||||
let end = assets.findIndex((a) => a.id === endAsset.id);
|
||||
|
||||
if (start > end) {
|
||||
[start, end] = [end, start];
|
||||
}
|
||||
|
||||
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
|
||||
const assets = assetsSnapshot(await assetStore.retrieveRange(startAsset, endAsset));
|
||||
assetInteraction.setAssetSelectionCandidates(assets);
|
||||
};
|
||||
|
||||
const onSelectStart = (e: Event) => {
|
||||
|
|
@ -651,9 +672,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));
|
||||
|
|
@ -675,6 +693,9 @@
|
|||
}
|
||||
});
|
||||
|
||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, assetStore);
|
||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
||||
|
||||
let shortcutList = $derived(
|
||||
(() => {
|
||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||
|
|
@ -686,10 +707,15 @@
|
|||
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||
{ 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: () => setFocusTo('earlier', 'asset') },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
|
||||
{ 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) },
|
||||
];
|
||||
|
||||
if (assetInteraction.selectionActive) {
|
||||
|
|
@ -720,7 +746,7 @@
|
|||
|
||||
$effect(() => {
|
||||
if (shiftKeyIsDown && lastAssetMouseEvent) {
|
||||
selectAssetCandidates(lastAssetMouseEvent);
|
||||
void selectAssetCandidates(lastAssetMouseEvent);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -735,6 +761,22 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShowSelectDate}
|
||||
<ChangeDate
|
||||
title="Navigate to Time"
|
||||
initialDate={DateTime.now()}
|
||||
timezoneInput={false}
|
||||
onConfirm={async (dateString: string) => {
|
||||
isShowSelectDate = false;
|
||||
const asset = await assetStore.getClosestAssetToDate((DateTime.fromISO(dateString) as DateTime<true>).toObject());
|
||||
if (asset) {
|
||||
setFocusAsset(asset);
|
||||
}
|
||||
}}
|
||||
onCancel={() => (isShowSelectDate = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if assetStore.buckets.length > 0}
|
||||
<Scrubber
|
||||
{assetStore}
|
||||
|
|
@ -828,6 +870,7 @@
|
|||
onSelect={({ title, assets }) => handleGroupSelect(assetStore, title, assets)}
|
||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||
onSelectAssets={handleSelectAssets}
|
||||
onScrollCompensation={handleScrollCompensation}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,22 @@
|
|||
import Combobox, { type ComboBoxOption } from './combobox.svelte';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
initialDate?: DateTime;
|
||||
initialTimeZone?: string;
|
||||
timezoneInput?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (date: string) => void;
|
||||
}
|
||||
|
||||
let { initialDate = DateTime.now(), initialTimeZone = '', onCancel, onConfirm }: Props = $props();
|
||||
let {
|
||||
initialDate = DateTime.now(),
|
||||
initialTimeZone = '',
|
||||
title = $t('edit_date_and_time'),
|
||||
timezoneInput = true,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: Props = $props();
|
||||
|
||||
type ZoneOption = {
|
||||
/**
|
||||
|
|
@ -135,7 +144,7 @@
|
|||
|
||||
<ConfirmModal
|
||||
confirmColor="primary"
|
||||
title={$t('edit_date_and_time')}
|
||||
{title}
|
||||
prompt="Please select a new date:"
|
||||
disabled={!date.isValid}
|
||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||
|
|
@ -148,15 +157,17 @@
|
|||
<label for="datetime">{$t('date_and_time')}</label>
|
||||
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
|
||||
</div>
|
||||
<div>
|
||||
<Combobox
|
||||
bind:selectedOption
|
||||
label={$t('timezone')}
|
||||
options={timezones}
|
||||
placeholder={$t('search_timezone')}
|
||||
onSelect={(option) => handleOnSelect(option)}
|
||||
/>
|
||||
</div>
|
||||
{#if timezoneInput}
|
||||
<div>
|
||||
<Combobox
|
||||
bind:selectedOption
|
||||
label={$t('timezone')}
|
||||
options={timezones}
|
||||
placeholder={$t('search_timezone')}
|
||||
onSelect={(option) => handleOnSelect(option)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
import { handlePromiseError } from '$lib/utils';
|
||||
import { deleteAssets } from '$lib/utils/actions';
|
||||
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { focusNext } from '$lib/utils/focus-util';
|
||||
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';
|
||||
|
|
@ -271,8 +271,9 @@
|
|||
}
|
||||
};
|
||||
|
||||
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, 'next');
|
||||
const focusPreviousAsset = () =>
|
||||
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
|
||||
|
||||
let isShortcutModalOpen = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@
|
|||
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { mdiPlay } from '@mdi/js';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
|
|
@ -17,7 +16,7 @@
|
|||
assetStore: AssetStore;
|
||||
scrubOverallPercent?: number;
|
||||
scrubBucketPercent?: number;
|
||||
scrubBucket?: { bucketDate: string | undefined };
|
||||
scrubBucket?: { year: number; month: number };
|
||||
leadout?: boolean;
|
||||
scrubberWidth?: number;
|
||||
onScrub?: ScrubberListener;
|
||||
|
|
@ -81,7 +80,7 @@
|
|||
});
|
||||
|
||||
const toScrollFromBucketPercentage = (
|
||||
scrubBucket: { bucketDate: string | undefined } | undefined,
|
||||
scrubBucket: { year: number; month: number } | undefined,
|
||||
scrubBucketPercent: number,
|
||||
scrubOverallPercent: number,
|
||||
) => {
|
||||
|
|
@ -89,7 +88,7 @@
|
|||
let offset = relativeTopOffset;
|
||||
let match = false;
|
||||
for (const segment of segments) {
|
||||
if (segment.bucketDate === scrubBucket.bucketDate) {
|
||||
if (segment.month === scrubBucket.month && segment.year === scrubBucket.year) {
|
||||
offset += scrubBucketPercent * segment.height;
|
||||
match = true;
|
||||
break;
|
||||
|
|
@ -120,8 +119,8 @@
|
|||
count: number;
|
||||
height: number;
|
||||
dateFormatted: string;
|
||||
bucketDate: string;
|
||||
date: DateTime;
|
||||
year: number;
|
||||
month: number;
|
||||
hasLabel: boolean;
|
||||
hasDot: boolean;
|
||||
};
|
||||
|
|
@ -141,9 +140,9 @@
|
|||
top,
|
||||
count: bucket.assetCount,
|
||||
height: toScrollY(scrollBarPercentage),
|
||||
bucketDate: bucket.bucketDate,
|
||||
date: fromLocalDateTime(bucket.bucketDate),
|
||||
dateFormatted: bucket.bucketDateFormattted,
|
||||
year: bucket.year,
|
||||
month: bucket.month,
|
||||
hasLabel: false,
|
||||
hasDot: false,
|
||||
};
|
||||
|
|
@ -153,7 +152,7 @@
|
|||
segment.hasLabel = true;
|
||||
previousLabeledSegment = segment;
|
||||
} else {
|
||||
if (previousLabeledSegment?.date?.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
|
||||
if (previousLabeledSegment?.year !== segment.year && height > MIN_YEAR_LABEL_DISTANCE) {
|
||||
height = 0;
|
||||
segment.hasLabel = true;
|
||||
previousLabeledSegment = segment;
|
||||
|
|
@ -182,7 +181,13 @@
|
|||
}
|
||||
return activeSegment?.dataset.label;
|
||||
});
|
||||
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
|
||||
const bucketDate = $derived.by(() => {
|
||||
if (!activeSegment?.dataset.timeSegmentBucketDate) {
|
||||
return undefined;
|
||||
}
|
||||
const [year, month] = activeSegment.dataset.timeSegmentBucketDate.split('-').map(Number);
|
||||
return { year, month };
|
||||
});
|
||||
const scrollSegment = $derived.by(() => {
|
||||
const y = scrollY;
|
||||
let cur = relativeTopOffset;
|
||||
|
|
@ -289,12 +294,12 @@
|
|||
|
||||
const scrollPercent = toTimelineY(hoverY);
|
||||
if (wasDragging === false && isDragging) {
|
||||
void startScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
void startScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
||||
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
||||
}
|
||||
|
||||
if (wasDragging && !isDragging) {
|
||||
void stopScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
void stopScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -302,7 +307,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
|
||||
};
|
||||
const getTouch = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
|
|
@ -404,7 +409,7 @@
|
|||
}
|
||||
if (next) {
|
||||
event.preventDefault();
|
||||
void onScrub?.(next.bucketDate, -1, 0);
|
||||
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -414,7 +419,7 @@
|
|||
const next = segments[idx + 1];
|
||||
if (next) {
|
||||
event.preventDefault();
|
||||
void onScrub?.(next.bucketDate, -1, 0);
|
||||
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -517,7 +522,7 @@
|
|||
class="relative"
|
||||
style:height={relativeTopOffset + 'px'}
|
||||
data-id="lead-in"
|
||||
data-time-segment-bucket-date={segments.at(0)?.date}
|
||||
data-time-segment-bucket-date={segments.at(0)?.year + '-' + segments.at(0)?.month}
|
||||
data-label={segments.at(0)?.dateFormatted}
|
||||
>
|
||||
{#if relativeTopOffset > 6}
|
||||
|
|
@ -525,18 +530,18 @@
|
|||
{/if}
|
||||
</div>
|
||||
<!-- Time Segment -->
|
||||
{#each segments as segment (segment.date)}
|
||||
{#each segments as segment (segment.year + '-' + segment.month)}
|
||||
<div
|
||||
class="relative"
|
||||
data-id="time-segment"
|
||||
data-time-segment-bucket-date={segment.date}
|
||||
data-time-segment-bucket-date={segment.year + '-' + segment.month}
|
||||
data-label={segment.dateFormatted}
|
||||
style:height={segment.height + 'px'}
|
||||
>
|
||||
{#if !usingMobileDevice}
|
||||
{#if segment.hasLabel}
|
||||
<div class="absolute end-5 top-[-16px] text-[12px] dark:text-immich-dark-fg font-immich-mono">
|
||||
{segment.date.year}
|
||||
{segment.year}
|
||||
</div>
|
||||
{/if}
|
||||
{#if segment.hasDot}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue