mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 20:25:32 -04:00
Keyboard Accessible Scrubber
This commit is contained in:
parent
e6cffac6cc
commit
8706f7cfe3
@ -1,4 +1,5 @@
|
|||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
|
import { getFocusable } from '$lib/utils/focus-util';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
@ -8,9 +9,6 @@ interface Options {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectors =
|
|
||||||
'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)';
|
|
||||||
|
|
||||||
export function focusTrap(container: HTMLElement, options?: Options) {
|
export function focusTrap(container: HTMLElement, options?: Options) {
|
||||||
const triggerElement = document.activeElement;
|
const triggerElement = document.activeElement;
|
||||||
|
|
||||||
@ -21,7 +19,7 @@ export function focusTrap(container: HTMLElement, options?: Options) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setInitialFocus = () => {
|
const setInitialFocus = () => {
|
||||||
const focusableElement = container.querySelector<HTMLElement>(selectors);
|
const focusableElement = getFocusable(container)[0];
|
||||||
// Use tick() to ensure focus trap works correctly inside <Portal />
|
// Use tick() to ensure focus trap works correctly inside <Portal />
|
||||||
void tick().then(() => focusableElement?.focus());
|
void tick().then(() => focusableElement?.focus());
|
||||||
};
|
};
|
||||||
@ -30,11 +28,11 @@ export function focusTrap(container: HTMLElement, options?: Options) {
|
|||||||
setInitialFocus();
|
setInitialFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => {
|
const getFocusableElements = () => {
|
||||||
const focusableElements = container.querySelectorAll<HTMLElement>(selectors);
|
const focusableElements = getFocusable(container);
|
||||||
return [
|
return [
|
||||||
focusableElements.item(0), //
|
focusableElements.at(0), //
|
||||||
focusableElements.item(focusableElements.length - 1),
|
focusableElements.at(-1),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -595,7 +595,7 @@
|
|||||||
id="stack-slideshow"
|
id="stack-slideshow"
|
||||||
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
|
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
|
||||||
>
|
>
|
||||||
<div class="relative w-full whitespace-nowrap">
|
<div class="relative w-full">
|
||||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||||
<div
|
<div
|
||||||
class={['inline-block px-1 relative transition-all pb-2']}
|
class={['inline-block px-1 relative transition-all pb-2']}
|
||||||
@ -603,6 +603,7 @@
|
|||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
||||||
|
brokenAssetClass="text-xs"
|
||||||
dimmed={stackedAsset.id !== asset.id}
|
dimmed={stackedAsset.id !== asset.id}
|
||||||
asset={stackedAsset}
|
asset={stackedAsset}
|
||||||
onClick={(stackedAsset) => {
|
onClick={(stackedAsset) => {
|
||||||
|
@ -184,7 +184,9 @@
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{#if imageError}
|
{#if imageError}
|
||||||
<BrokenAsset class="text-xl" />
|
<div class="h-full w-full">
|
||||||
|
<BrokenAsset class="text-xl h-full w-full" />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- svelte-ignore a11y_missing_attribute -->
|
<!-- svelte-ignore a11y_missing_attribute -->
|
||||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100 dark:bg-gray-700 dark:text-gray-100 p-4 {className}"
|
class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4 {className}"
|
||||||
style:width
|
style:width
|
||||||
style:height
|
style:height
|
||||||
>
|
>
|
||||||
|
@ -21,7 +21,8 @@
|
|||||||
border?: boolean;
|
border?: boolean;
|
||||||
hiddenIconClass?: string;
|
hiddenIconClass?: string;
|
||||||
class?: ClassValue;
|
class?: ClassValue;
|
||||||
onComplete?: (() => void) | undefined;
|
brokenAssetClass?: ClassValue;
|
||||||
|
onComplete?: ((errored: boolean) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -39,6 +40,7 @@
|
|||||||
hiddenIconClass = 'text-white',
|
hiddenIconClass = 'text-white',
|
||||||
onComplete = undefined,
|
onComplete = undefined,
|
||||||
class: imageClass = '',
|
class: imageClass = '',
|
||||||
|
brokenAssetClass = '',
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -50,17 +52,17 @@
|
|||||||
|
|
||||||
const setLoaded = () => {
|
const setLoaded = () => {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
onComplete?.();
|
onComplete?.(false);
|
||||||
};
|
};
|
||||||
const setErrored = () => {
|
const setErrored = () => {
|
||||||
errored = true;
|
errored = true;
|
||||||
onComplete?.();
|
onComplete?.(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
function mount(elem: HTMLImageElement) {
|
function mount(elem: HTMLImageElement) {
|
||||||
if (elem.complete) {
|
if (elem.complete) {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
onComplete?.();
|
onComplete?.(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,6 +73,7 @@
|
|||||||
shadow && 'shadow-lg',
|
shadow && 'shadow-lg',
|
||||||
(circle || !heightStyle) && 'aspect-square',
|
(circle || !heightStyle) && 'aspect-square',
|
||||||
border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary',
|
border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary',
|
||||||
|
brokenAssetClass,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' '),
|
.join(' '),
|
||||||
|
@ -25,6 +25,7 @@
|
|||||||
import ImageThumbnail from './image-thumbnail.svelte';
|
import ImageThumbnail from './image-thumbnail.svelte';
|
||||||
import VideoThumbnail from './video-thumbnail.svelte';
|
import VideoThumbnail from './video-thumbnail.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { getFocusable } from '$lib/utils/focus-util';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
@ -41,6 +42,7 @@
|
|||||||
showArchiveIcon?: boolean;
|
showArchiveIcon?: boolean;
|
||||||
showStackedIcon?: boolean;
|
showStackedIcon?: boolean;
|
||||||
imageClass?: ClassValue;
|
imageClass?: ClassValue;
|
||||||
|
brokenAssetClass?: ClassValue;
|
||||||
dimmed?: boolean;
|
dimmed?: boolean;
|
||||||
onClick?: ((asset: AssetResponseDto) => void) | undefined;
|
onClick?: ((asset: AssetResponseDto) => void) | undefined;
|
||||||
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
|
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
|
||||||
@ -67,6 +69,7 @@
|
|||||||
onMouseEvent = undefined,
|
onMouseEvent = undefined,
|
||||||
handleFocus = undefined,
|
handleFocus = undefined,
|
||||||
imageClass = '',
|
imageClass = '',
|
||||||
|
brokenAssetClass = '',
|
||||||
dimmed = false,
|
dimmed = false,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@ -78,6 +81,7 @@
|
|||||||
let focussableElement: HTMLElement | undefined = $state();
|
let focussableElement: HTMLElement | undefined = $state();
|
||||||
let mouseOver = $state(false);
|
let mouseOver = $state(false);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
let thumbError = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (focussed && document.activeElement !== focussableElement) {
|
if (focussed && document.activeElement !== focussableElement) {
|
||||||
@ -189,10 +193,10 @@
|
|||||||
style:width="{width}px"
|
style:width="{width}px"
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
>
|
>
|
||||||
{#if !loaded && asset.thumbhash}
|
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||||
<canvas
|
<canvas
|
||||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||||
class="absolute object-cover z-10"
|
class="absolute object-cover z-40"
|
||||||
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 }}
|
||||||
@ -219,10 +223,30 @@
|
|||||||
if (evt.key === 'x') {
|
if (evt.key === 'x') {
|
||||||
onSelect?.(asset);
|
onSelect?.(asset);
|
||||||
}
|
}
|
||||||
|
if (document.activeElement === focussableElement && evt.key === 'Escape') {
|
||||||
|
const focusable = getFocusable(document);
|
||||||
|
const index = focusable.indexOf(focussableElement);
|
||||||
|
|
||||||
|
let i = index + 1;
|
||||||
|
while (i !== index) {
|
||||||
|
const next = focusable[i];
|
||||||
|
if (next.dataset.thumbnailFocusContainer !== undefined) {
|
||||||
|
if (i === focusable.length - 1) {
|
||||||
|
i = 0;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.focus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onclick={handleClick}
|
onclick={handleClick}
|
||||||
bind:this={focussableElement}
|
bind:this={focussableElement}
|
||||||
onfocus={handleFocus}
|
onfocus={handleFocus}
|
||||||
|
data-thumbnail-focus-container
|
||||||
data-testid="container-with-tabindex"
|
data-testid="container-with-tabindex"
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
role="link"
|
role="link"
|
||||||
@ -332,12 +356,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
class={imageClass}
|
class={imageClass}
|
||||||
|
{brokenAssetClass}
|
||||||
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||||
altText={$getAltText(asset)}
|
altText={$getAltText(asset)}
|
||||||
widthStyle="{width}px"
|
widthStyle="{width}px"
|
||||||
heightStyle="{height}px"
|
heightStyle="{height}px"
|
||||||
curve={selected}
|
curve={selected}
|
||||||
onComplete={() => (loaded = true)}
|
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||||
/>
|
/>
|
||||||
{#if asset.type === AssetTypeEnum.Video}
|
{#if asset.type === AssetTypeEnum.Video}
|
||||||
<div class="absolute top-0 h-full w-full">
|
<div class="absolute top-0 h-full w-full">
|
||||||
|
@ -78,12 +78,18 @@
|
|||||||
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);
|
||||||
|
let scrubberWidth = $state(0);
|
||||||
|
|
||||||
// 60 is the bottom spacer element at 60px
|
// 60 is the bottom spacer element at 60px
|
||||||
let bottomSectionHeight = 60;
|
let bottomSectionHeight = 60;
|
||||||
let leadout = $state(false);
|
let leadout = $state(false);
|
||||||
|
|
||||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||||
|
const maxMd = $derived(mobileDevice.maxMd);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
assetStore.rowHeight = maxMd ? 100 : 235;
|
||||||
|
});
|
||||||
|
|
||||||
const scrollTo = (top: number) => {
|
const scrollTo = (top: number) => {
|
||||||
element?.scrollTo({ top });
|
element?.scrollTo({ top });
|
||||||
@ -273,10 +279,21 @@
|
|||||||
bucket = assetStore.buckets[i];
|
bucket = assetStore.buckets[i];
|
||||||
bucketHeight = assetStore.buckets[i].bucketHeight;
|
bucketHeight = assetStore.buckets[i].bucketHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
let next = top - bucketHeight * maxScrollPercent;
|
let next = top - bucketHeight * maxScrollPercent;
|
||||||
if (next < 0 && bucket) {
|
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
||||||
|
if (next < -1 && bucket) {
|
||||||
scrubBucket = bucket;
|
scrubBucket = bucket;
|
||||||
scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
|
|
||||||
|
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||||
|
scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent));
|
||||||
|
|
||||||
|
// compensate for lost precision/rouding errors advance to the next bucket, if present
|
||||||
|
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
|
||||||
|
scrubBucket = assetStore.buckets[i + 1];
|
||||||
|
scrubBucketPercent = 0;
|
||||||
|
}
|
||||||
|
|
||||||
found = true;
|
found = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -704,6 +721,7 @@
|
|||||||
{scrubBucketPercent}
|
{scrubBucketPercent}
|
||||||
{scrubBucket}
|
{scrubBucket}
|
||||||
{onScrub}
|
{onScrub}
|
||||||
|
bind:scrubberWidth
|
||||||
onScrubKeyDown={(evt) => {
|
onScrubKeyDown={(evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
let amount = 50;
|
let amount = 50;
|
||||||
@ -725,15 +743,11 @@
|
|||||||
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
||||||
<section
|
<section
|
||||||
id="asset-grid"
|
id="asset-grid"
|
||||||
class={[
|
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ml-0': !isEmpty }]}
|
||||||
'scrollbar-hidden h-full overflow-y-auto outline-none',
|
style:margin-right={scrubberWidth + 'px'}
|
||||||
{ 'm-0': isEmpty },
|
|
||||||
{ 'ml-0': !isEmpty },
|
|
||||||
{ 'mr-[60px]': !isEmpty && !usingMobileDevice },
|
|
||||||
]}
|
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={assetStore.viewportHeight}
|
bind:clientHeight={assetStore.viewportHeight}
|
||||||
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
|
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v - scrubberWidth), updateSlidingWindow())}
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||||
>
|
>
|
||||||
@ -767,7 +781,6 @@
|
|||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
style:width="100%"
|
style:width="100%"
|
||||||
style:padding-left="10px"
|
|
||||||
>
|
>
|
||||||
<Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
|
<Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
|
||||||
</div>
|
</div>
|
||||||
@ -794,6 +807,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
<!-- spacer for leadout -->
|
||||||
<div
|
<div
|
||||||
class="h-[60px]"
|
class="h-[60px]"
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div class="animate-pulse absolute w-full h-full" data-skeleton="true"></div>
|
<div class="animate-pulse absolute h-full ml-[10px]" style:width="calc(100% - 10px)" data-skeleton="true"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -22,7 +22,7 @@
|
|||||||
background-repeat: repeat;
|
background-repeat: repeat;
|
||||||
background-size: 235px, 235px;
|
background-size: 235px, 235px;
|
||||||
}
|
}
|
||||||
@media (max-width: 850px) {
|
@media (max-width: 767px) {
|
||||||
[data-skeleton] {
|
[data-skeleton] {
|
||||||
background-size: 100px, 100px;
|
background-size: 100px, 100px;
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
|
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
|
import { getFocusable } from '$lib/utils/focus-util';
|
||||||
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
import { mdiPlay } from '@mdi/js';
|
import { mdiPlay } from '@mdi/js';
|
||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
@ -18,6 +19,7 @@
|
|||||||
scrubBucketPercent?: number;
|
scrubBucketPercent?: number;
|
||||||
scrubBucket?: { bucketDate: string | undefined };
|
scrubBucket?: { bucketDate: string | undefined };
|
||||||
leadout?: boolean;
|
leadout?: boolean;
|
||||||
|
scrubberWidth?: number;
|
||||||
onScrub?: ScrubberListener;
|
onScrub?: ScrubberListener;
|
||||||
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
|
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
|
||||||
startScrub?: ScrubberListener;
|
startScrub?: ScrubberListener;
|
||||||
@ -37,6 +39,7 @@
|
|||||||
onScrubKeyDown = undefined,
|
onScrubKeyDown = undefined,
|
||||||
startScrub = undefined,
|
startScrub = undefined,
|
||||||
stopScrub = undefined,
|
stopScrub = undefined,
|
||||||
|
scrubberWidth = $bindable(),
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isHover = $state(false);
|
let isHover = $state(false);
|
||||||
@ -52,24 +55,30 @@
|
|||||||
const toTimelineY = (scrollY: number) => scrollY / (height - (PADDING_TOP + PADDING_BOTTOM));
|
const toTimelineY = (scrollY: number) => scrollY / (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||||
|
|
||||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||||
|
|
||||||
|
const MOBILE_WIDTH = 20;
|
||||||
|
const DESKTOP_WIDTH = 60;
|
||||||
|
const HOVER_DATE_HEIGHT = 31.75;
|
||||||
|
const PADDING_TOP = $derived(usingMobileDevice ? 25 : HOVER_DATE_HEIGHT);
|
||||||
|
const PADDING_BOTTOM = $derived(usingMobileDevice ? 25 : 10);
|
||||||
|
const MIN_YEAR_LABEL_DISTANCE = 16;
|
||||||
|
const MIN_DOT_DISTANCE = 8;
|
||||||
|
|
||||||
const width = $derived.by(() => {
|
const width = $derived.by(() => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
return '100vw';
|
return '100vw';
|
||||||
}
|
}
|
||||||
if (usingMobileDevice) {
|
if (usingMobileDevice) {
|
||||||
if (assetStore.scrolling) {
|
if (assetStore.scrolling) {
|
||||||
return '20px';
|
return MOBILE_WIDTH + 'px';
|
||||||
}
|
}
|
||||||
return '0px';
|
return '0px';
|
||||||
}
|
}
|
||||||
return '60px';
|
return DESKTOP_WIDTH + 'px';
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
|
||||||
});
|
});
|
||||||
|
|
||||||
const HOVER_DATE_HEIGHT = 31.75;
|
|
||||||
const PADDING_TOP = $derived(usingMobileDevice ? 25 : HOVER_DATE_HEIGHT);
|
|
||||||
const PADDING_BOTTOM = $derived(usingMobileDevice ? 25 : 10);
|
|
||||||
const MIN_YEAR_LABEL_DISTANCE = 16;
|
|
||||||
const MIN_DOT_DISTANCE = 8;
|
|
||||||
|
|
||||||
const toScrollFromBucketPercentage = (
|
const toScrollFromBucketPercentage = (
|
||||||
scrubBucket: { bucketDate: string | undefined } | undefined,
|
scrubBucket: { bucketDate: string | undefined } | undefined,
|
||||||
@ -90,18 +99,16 @@
|
|||||||
if (!match) {
|
if (!match) {
|
||||||
offset += scrubBucketPercent * relativeBottomOffset;
|
offset += scrubBucketPercent * relativeBottomOffset;
|
||||||
}
|
}
|
||||||
// 2px is the height of the indicator
|
return offset;
|
||||||
return offset - 2;
|
|
||||||
} else if (leadout) {
|
} else if (leadout) {
|
||||||
let offset = relativeTopOffset;
|
let offset = relativeTopOffset;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
offset += segment.height;
|
offset += segment.height;
|
||||||
}
|
}
|
||||||
offset += scrubOverallPercent * relativeBottomOffset;
|
offset += scrubOverallPercent * relativeBottomOffset;
|
||||||
return offset - 2;
|
return offset;
|
||||||
} else {
|
} else {
|
||||||
// 2px is the height of the indicator
|
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||||
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM)) - 2;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
|
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
|
||||||
@ -126,10 +133,12 @@
|
|||||||
let segments: Segment[] = [];
|
let segments: Segment[] = [];
|
||||||
let previousLabeledSegment: Segment | undefined;
|
let previousLabeledSegment: Segment | undefined;
|
||||||
|
|
||||||
|
let top = 0;
|
||||||
for (const [i, bucket] of buckets.entries()) {
|
for (const [i, bucket] of buckets.entries()) {
|
||||||
const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
|
const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
|
||||||
|
|
||||||
const segment = {
|
const segment = {
|
||||||
|
top,
|
||||||
count: bucket.assetCount,
|
count: bucket.assetCount,
|
||||||
height: toScrollY(scrollBarPercentage),
|
height: toScrollY(scrollBarPercentage),
|
||||||
bucketDate: bucket.bucketDate,
|
bucketDate: bucket.bucketDate,
|
||||||
@ -138,7 +147,7 @@
|
|||||||
hasLabel: false,
|
hasLabel: false,
|
||||||
hasDot: false,
|
hasDot: false,
|
||||||
};
|
};
|
||||||
|
top += segment.height;
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
segment.hasDot = true;
|
segment.hasDot = true;
|
||||||
segment.hasLabel = true;
|
segment.hasLabel = true;
|
||||||
@ -174,41 +183,59 @@
|
|||||||
return activeSegment?.dataset.label;
|
return activeSegment?.dataset.label;
|
||||||
});
|
});
|
||||||
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
|
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
|
||||||
const scrollHoverLabel = $derived.by(() => {
|
const scrollSegment = $derived.by(() => {
|
||||||
const y = scrollY;
|
const y = scrollY;
|
||||||
let cur = 0;
|
let cur = relativeTopOffset;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
if (y <= cur + segment.height + relativeTopOffset) {
|
if (y < cur + segment.height) {
|
||||||
return segment.dateFormatted;
|
return segment;
|
||||||
}
|
}
|
||||||
cur += segment.height;
|
cur += segment.height;
|
||||||
}
|
}
|
||||||
return '';
|
return null;
|
||||||
});
|
});
|
||||||
|
const scrollHoverLabel = $derived(scrollSegment?.dateFormatted || '');
|
||||||
|
|
||||||
const findElement = (elements: Element[], ...ids: string[]) => {
|
const findElementBestY = (elements: Element[], y: number, ...ids: string[]) => {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const result = elements.find((element) => {
|
const filtered = elements.filter((element) => {
|
||||||
if (element instanceof HTMLElement && element.dataset.id) {
|
if (element instanceof HTMLElement && element.dataset.id) {
|
||||||
return ids.includes(element.dataset.id);
|
return ids.includes(element.dataset.id);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
}) as HTMLElement[];
|
||||||
return result as HTMLElement | undefined;
|
const imperfect = [];
|
||||||
|
for (const element of filtered) {
|
||||||
|
const boundingClientRect = element.getBoundingClientRect();
|
||||||
|
if (boundingClientRect.y > y) {
|
||||||
|
imperfect.push({
|
||||||
|
element,
|
||||||
|
boundingClientRect,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (y <= boundingClientRect.y + boundingClientRect.height) {
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
boundingClientRect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imperfect.at(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActive = (x: number, y: number) => {
|
const getActive = (x: number, y: number) => {
|
||||||
const elements = document.elementsFromPoint(x, y);
|
const elements = document.elementsFromPoint(x, y);
|
||||||
const element = findElement(elements, 'time-segment', 'lead-in', 'lead-out');
|
const bestElement = findElementBestY(elements, y, 'time-segment', 'lead-in', 'lead-out');
|
||||||
|
|
||||||
if (element) {
|
if (bestElement) {
|
||||||
const segment = element as HTMLElement;
|
const segment = bestElement.element;
|
||||||
const sr = segment.getBoundingClientRect();
|
const boundingClientRect = bestElement.boundingClientRect;
|
||||||
const sy = sr.y;
|
const sy = boundingClientRect.y;
|
||||||
const relativeY = y - sy;
|
const relativeY = y - sy;
|
||||||
const bucketPercentY = relativeY / sr.height;
|
const bucketPercentY = relativeY / boundingClientRect.height;
|
||||||
return {
|
return {
|
||||||
isOnPaddingTop: false,
|
isOnPaddingTop: false,
|
||||||
isOnPaddingBottom: false,
|
isOnPaddingBottom: false,
|
||||||
@ -218,14 +245,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check if padding
|
// check if padding
|
||||||
const bar = findElement(elements, 'immich-scrubbable-scrollbar');
|
const bar = findElementBestY(elements, 0, 'immich-scrubbable-scrollbar');
|
||||||
let isOnPaddingTop = false;
|
let isOnPaddingTop = false;
|
||||||
let isOnPaddingBottom = false;
|
let isOnPaddingBottom = false;
|
||||||
|
|
||||||
if (bar) {
|
if (bar) {
|
||||||
const segment = bar as HTMLElement;
|
const sr = bar.boundingClientRect;
|
||||||
const sr = segment.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (y < sr.top + PADDING_TOP) {
|
if (y < sr.top + PADDING_TOP) {
|
||||||
isOnPaddingTop = true;
|
isOnPaddingTop = true;
|
||||||
}
|
}
|
||||||
@ -293,7 +318,7 @@
|
|||||||
}
|
}
|
||||||
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
|
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
|
||||||
const isHoverScrollbar =
|
const isHoverScrollbar =
|
||||||
findElement(elements, 'immich-scrubbable-scrollbar', 'time-label', 'lead-in', 'lead-out') !== undefined;
|
findElementBestY(elements, 0, 'immich-scrubbable-scrollbar', 'time-label', 'lead-in', 'lead-out') !== undefined;
|
||||||
|
|
||||||
isHover = isHoverScrollbar;
|
isHover = isHoverScrollbar;
|
||||||
|
|
||||||
@ -338,6 +363,70 @@
|
|||||||
document.addEventListener('touchend', onTouchEnd, true);
|
document.addEventListener('touchend', onTouchEnd, true);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isTabEvent = (event: KeyboardEvent) => event?.key === 'Tab';
|
||||||
|
const isTabForward = (event: KeyboardEvent) => isTabEvent(event) && !event.shiftKey;
|
||||||
|
const isTabBackward = (event: KeyboardEvent) => isTabEvent(event) && event.shiftKey;
|
||||||
|
const isArrowUp = (event: KeyboardEvent) => event?.key === 'ArrowUp';
|
||||||
|
const isArrowDown = (event: KeyboardEvent) => event?.key === 'ArrowDown';
|
||||||
|
|
||||||
|
const handleFocus = (event: KeyboardEvent) => {
|
||||||
|
const forward = isTabForward(event);
|
||||||
|
const backward = isTabBackward(event);
|
||||||
|
if (forward || backward) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const focusable = getFocusable(document);
|
||||||
|
if (scrollBar) {
|
||||||
|
const index = focusable.indexOf(scrollBar);
|
||||||
|
if (index !== -1) {
|
||||||
|
let next: HTMLElement;
|
||||||
|
next = forward
|
||||||
|
? (focusable[(index + 1) % focusable.length] as HTMLElement)
|
||||||
|
: (focusable[(index - 1) % focusable.length] as HTMLElement);
|
||||||
|
next.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleAccessibility = (event: KeyboardEvent) => {
|
||||||
|
if (isTabEvent(event)) {
|
||||||
|
handleFocus(event);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isArrowUp(event)) {
|
||||||
|
let next;
|
||||||
|
if (scrollSegment) {
|
||||||
|
const idx = segments.indexOf(scrollSegment);
|
||||||
|
next = idx === -1 ? segments.at(-2) : segments[idx - 1];
|
||||||
|
} else {
|
||||||
|
next = segments.at(-2);
|
||||||
|
}
|
||||||
|
if (next) {
|
||||||
|
event.preventDefault();
|
||||||
|
void onScrub?.(next.bucketDate, -1, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isArrowDown(event) && scrollSegment) {
|
||||||
|
const idx = segments.indexOf(scrollSegment);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const next = segments[idx + 1];
|
||||||
|
if (next) {
|
||||||
|
event.preventDefault();
|
||||||
|
void onScrub?.(next.bucketDate, -1, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const keydown = (event: KeyboardEvent) => {
|
||||||
|
let handled = handleAccessibility(event);
|
||||||
|
if (!handled) {
|
||||||
|
onScrubKeyDown?.(event, event.currentTarget as HTMLElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@ -349,9 +438,10 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
transition:fly={{ x: 50, duration: 250 }}
|
transition:fly={{ x: 50, duration: 250 }}
|
||||||
tabindex="-1"
|
tabindex="0"
|
||||||
role="scrollbar"
|
role="scrollbar"
|
||||||
aria-controls="time-label"
|
aria-controls="time-label"
|
||||||
|
aria-valuetext={hoverLabel}
|
||||||
aria-valuenow={scrollY + PADDING_TOP}
|
aria-valuenow={scrollY + PADDING_TOP}
|
||||||
aria-valuemax={toScrollY(1)}
|
aria-valuemax={toScrollY(1)}
|
||||||
aria-valuemin={toScrollY(0)}
|
aria-valuemin={toScrollY(0)}
|
||||||
@ -365,7 +455,7 @@
|
|||||||
bind:this={scrollBar}
|
bind:this={scrollBar}
|
||||||
onmouseenter={() => (isHover = true)}
|
onmouseenter={() => (isHover = true)}
|
||||||
onmouseleave={() => (isHover = false)}
|
onmouseleave={() => (isHover = false)}
|
||||||
onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)}
|
onkeydown={keydown}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
>
|
>
|
||||||
{#if !usingMobileDevice && hoverLabel && (isHover || isDragging)}
|
{#if !usingMobileDevice && hoverLabel && (isHover || isDragging)}
|
||||||
@ -411,7 +501,7 @@
|
|||||||
{#if !usingMobileDevice && !isDragging}
|
{#if !usingMobileDevice && !isDragging}
|
||||||
<div
|
<div
|
||||||
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
||||||
style:top="{scrollY + PADDING_TOP}px"
|
style:top="{scrollY + PADDING_TOP - 2}px"
|
||||||
>
|
>
|
||||||
{#if assetStore.scrolling && scrollHoverLabel && !isHover}
|
{#if assetStore.scrolling && scrollHoverLabel && !isHover}
|
||||||
<p
|
<p
|
||||||
|
@ -595,7 +595,7 @@ export class AssetStore {
|
|||||||
#pendingChanges: PendingChange[] = [];
|
#pendingChanges: PendingChange[] = [];
|
||||||
#unsubscribers: Unsubscriber[] = [];
|
#unsubscribers: Unsubscriber[] = [];
|
||||||
|
|
||||||
#rowHeight = 235;
|
#rowHeight = $state(235);
|
||||||
#headerHeight = $state(49);
|
#headerHeight = $state(49);
|
||||||
#gap = $state(12);
|
#gap = $state(12);
|
||||||
|
|
||||||
@ -609,7 +609,11 @@ export class AssetStore {
|
|||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
set headerHeight(value) {
|
set headerHeight(value) {
|
||||||
|
if (this.#headerHeight == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.#headerHeight = value;
|
this.#headerHeight = value;
|
||||||
|
this.refreshLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
get headerHeight() {
|
get headerHeight() {
|
||||||
@ -617,13 +621,29 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set gap(value) {
|
set gap(value) {
|
||||||
|
if (this.#gap == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.#gap = value;
|
this.#gap = value;
|
||||||
|
this.refreshLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
get gap() {
|
get gap() {
|
||||||
return this.#gap;
|
return this.#gap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set rowHeight(value) {
|
||||||
|
if (this.#rowHeight == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#rowHeight = value;
|
||||||
|
this.refreshLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
get rowHeight() {
|
||||||
|
return this.#rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
set scrolling(value: boolean) {
|
set scrolling(value: boolean) {
|
||||||
this.#scrolling = value;
|
this.#scrolling = value;
|
||||||
if (value) {
|
if (value) {
|
||||||
@ -651,7 +671,6 @@ export class AssetStore {
|
|||||||
const changed = value !== this.#viewportWidth;
|
const changed = value !== this.#viewportWidth;
|
||||||
this.#viewportWidth = value;
|
this.#viewportWidth = value;
|
||||||
this.suspendTransitions = true;
|
this.suspendTransitions = true;
|
||||||
this.#rowHeight = value < 850 ? 100 : 235;
|
|
||||||
// side-effect - its ok!
|
// side-effect - its ok!
|
||||||
void this.#updateViewportGeometry(changed);
|
void this.#updateViewportGeometry(changed);
|
||||||
}
|
}
|
||||||
@ -751,7 +770,7 @@ export class AssetStore {
|
|||||||
let topIntersectingBucket = undefined;
|
let topIntersectingBucket = undefined;
|
||||||
for (const bucket of this.buckets) {
|
for (const bucket of this.buckets) {
|
||||||
this.#updateIntersection(bucket);
|
this.#updateIntersection(bucket);
|
||||||
if (!topIntersectingBucket && bucket.actuallyIntersecting) {
|
if (!topIntersectingBucket && bucket.actuallyIntersecting && bucket.isLoaded) {
|
||||||
topIntersectingBucket = bucket;
|
topIntersectingBucket = bucket;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4
web/src/lib/utils/focus-util.ts
Normal file
4
web/src/lib/utils/focus-util.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
const selectors =
|
||||||
|
'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)';
|
||||||
|
|
||||||
|
export const getFocusable = (container: ParentNode) => [...container.querySelectorAll<HTMLElement>(selectors)];
|
Loading…
x
Reference in New Issue
Block a user