mirror of
https://github.com/immich-app/immich.git
synced 2025-06-02 21:24:28 -04:00
fix: responsive: timeline glitch and keyboard-accessible scrubber (#17556)
* fix: responsive: timeline glitch * lint * fix margin-right on mobile --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
664c99278a
commit
5a51ad3622
@ -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),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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;
|
||||||
@ -222,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"
|
||||||
|
@ -78,13 +78,19 @@
|
|||||||
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 maxMd = $derived(mobileDevice.maxMd);
|
||||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
assetStore.rowHeight = maxMd ? 100 : 235;
|
||||||
|
});
|
||||||
|
|
||||||
const scrollTo = (top: number) => {
|
const scrollTo = (top: number) => {
|
||||||
element?.scrollTo({ top });
|
element?.scrollTo({ top });
|
||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
@ -162,7 +168,13 @@
|
|||||||
const updateIsScrolling = () => (assetStore.scrolling = true);
|
const updateIsScrolling = () => (assetStore.scrolling = true);
|
||||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||||
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
|
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
|
||||||
const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta);
|
const compensateScrollCallback = ({ delta, top }: { delta?: number; top?: number }) => {
|
||||||
|
if (delta) {
|
||||||
|
element?.scrollBy(0, delta);
|
||||||
|
} else if (top) {
|
||||||
|
element?.scrollTo({ top });
|
||||||
|
}
|
||||||
|
};
|
||||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
|
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@ -267,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) {
|
// 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;
|
||||||
}
|
}
|
||||||
@ -689,7 +712,6 @@
|
|||||||
|
|
||||||
{#if assetStore.buckets.length > 0}
|
{#if assetStore.buckets.length > 0}
|
||||||
<Scrubber
|
<Scrubber
|
||||||
invisible={showSkeleton}
|
|
||||||
{assetStore}
|
{assetStore}
|
||||||
height={assetStore.viewportHeight}
|
height={assetStore.viewportHeight}
|
||||||
timelineTopOffset={assetStore.topSectionHeight}
|
timelineTopOffset={assetStore.topSectionHeight}
|
||||||
@ -699,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;
|
||||||
@ -720,12 +743,8 @@
|
|||||||
<!-- 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={(usingMobileDevice ? 0 : 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), updateSlidingWindow())}
|
||||||
@ -763,7 +782,7 @@
|
|||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
<Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} />
|
<Skeleton height={bucket.bucketHeight - bucket.store.headerHeight} title={bucket.bucketDateFormatted} />
|
||||||
</div>
|
</div>
|
||||||
{:else if display}
|
{:else if display}
|
||||||
<div
|
<div
|
||||||
@ -788,7 +807,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> -->
|
<!-- spacer for leadout -->
|
||||||
|
<div
|
||||||
|
class="h-[60px]"
|
||||||
|
style:position="absolute"
|
||||||
|
style:left="0"
|
||||||
|
style:right="0"
|
||||||
|
style:transform={`translate3d(0,${assetStore.timelineHeight}px,0)`}
|
||||||
|
></div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -13,7 +13,11 @@
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div class="animate-pulse absolute h-full ml-[10px]" style:width="calc(100% - 10px)" data-skeleton="true"></div>
|
<div
|
||||||
|
class="animate-pulse absolute h-full ml-[10px] mr-[10px]"
|
||||||
|
style:width="calc(100% - 20px)"
|
||||||
|
data-skeleton="true"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@ -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,22 +39,47 @@
|
|||||||
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);
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
|
let isHoverOnPaddingTop = $state(false);
|
||||||
|
let isHoverOnPaddingBottom = $state(false);
|
||||||
let hoverY = $state(0);
|
let hoverY = $state(0);
|
||||||
let clientY = 0;
|
let clientY = 0;
|
||||||
let windowHeight = $state(0);
|
let windowHeight = $state(0);
|
||||||
let scrollBar: HTMLElement | undefined = $state();
|
let scrollBar: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
|
const toScrollY = (percent: number) => percent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||||
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
|
const toTimelineY = (scrollY: number) => scrollY / (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||||
|
|
||||||
|
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||||
|
|
||||||
|
const MOBILE_WIDTH = 20;
|
||||||
|
const DESKTOP_WIDTH = 60;
|
||||||
const HOVER_DATE_HEIGHT = 31.75;
|
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_YEAR_LABEL_DISTANCE = 16;
|
||||||
const MIN_DOT_DISTANCE = 8;
|
const MIN_DOT_DISTANCE = 8;
|
||||||
|
|
||||||
|
const width = $derived.by(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
return '100vw';
|
||||||
|
}
|
||||||
|
if (usingMobileDevice) {
|
||||||
|
if (assetStore.scrolling) {
|
||||||
|
return MOBILE_WIDTH + 'px';
|
||||||
|
}
|
||||||
|
return '0px';
|
||||||
|
}
|
||||||
|
return DESKTOP_WIDTH + 'px';
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
|
||||||
|
});
|
||||||
|
|
||||||
const toScrollFromBucketPercentage = (
|
const toScrollFromBucketPercentage = (
|
||||||
scrubBucket: { bucketDate: string | undefined } | undefined,
|
scrubBucket: { bucketDate: string | undefined } | undefined,
|
||||||
scrubBucketPercent: number,
|
scrubBucketPercent: number,
|
||||||
@ -72,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 - HOVER_DATE_HEIGHT * 2) - 2;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
|
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
|
||||||
@ -108,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,
|
||||||
@ -120,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;
|
||||||
@ -146,19 +173,99 @@
|
|||||||
};
|
};
|
||||||
let activeSegment: HTMLElement | undefined = $state();
|
let activeSegment: HTMLElement | undefined = $state();
|
||||||
const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
|
const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
|
||||||
const hoverLabel = $derived(activeSegment?.dataset.label);
|
const hoverLabel = $derived.by(() => {
|
||||||
|
if (isHoverOnPaddingTop) {
|
||||||
|
return segments.at(0)?.dateFormatted;
|
||||||
|
}
|
||||||
|
if (isHoverOnPaddingBottom) {
|
||||||
|
return segments.at(-1)?.dateFormatted;
|
||||||
|
}
|
||||||
|
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 findElementBestY = (elements: Element[], y: number, ...ids: string[]) => {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const filtered = elements.filter((element) => {
|
||||||
|
if (element instanceof HTMLElement && element.dataset.id) {
|
||||||
|
return ids.includes(element.dataset.id);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}) as HTMLElement[];
|
||||||
|
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 elements = document.elementsFromPoint(x, y);
|
||||||
|
const bestElement = findElementBestY(elements, y, 'time-segment', 'lead-in', 'lead-out');
|
||||||
|
|
||||||
|
if (bestElement) {
|
||||||
|
const segment = bestElement.element;
|
||||||
|
const boundingClientRect = bestElement.boundingClientRect;
|
||||||
|
const sy = boundingClientRect.y;
|
||||||
|
const relativeY = y - sy;
|
||||||
|
const bucketPercentY = relativeY / boundingClientRect.height;
|
||||||
|
return {
|
||||||
|
isOnPaddingTop: false,
|
||||||
|
isOnPaddingBottom: false,
|
||||||
|
segment,
|
||||||
|
bucketPercentY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if padding
|
||||||
|
const bar = findElementBestY(elements, 0, 'immich-scrubbable-scrollbar');
|
||||||
|
let isOnPaddingTop = false;
|
||||||
|
let isOnPaddingBottom = false;
|
||||||
|
|
||||||
|
if (bar) {
|
||||||
|
const sr = bar.boundingClientRect;
|
||||||
|
if (y < sr.top + PADDING_TOP) {
|
||||||
|
isOnPaddingTop = true;
|
||||||
|
}
|
||||||
|
if (y > sr.bottom - PADDING_BOTTOM - 1) {
|
||||||
|
isOnPaddingBottom = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOnPaddingTop,
|
||||||
|
isOnPaddingBottom,
|
||||||
|
segment: undefined,
|
||||||
|
bucketPercentY: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
|
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
|
||||||
const wasDragging = isDragging;
|
const wasDragging = isDragging;
|
||||||
@ -172,28 +279,13 @@
|
|||||||
|
|
||||||
const rect = scrollBar.getBoundingClientRect()!;
|
const rect = scrollBar.getBoundingClientRect()!;
|
||||||
const lower = 0;
|
const lower = 0;
|
||||||
const upper = rect?.height - HOVER_DATE_HEIGHT * 2;
|
const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
|
||||||
hoverY = clamp(clientY - rect?.top - HOVER_DATE_HEIGHT, lower, upper);
|
hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
|
||||||
const x = rect!.left + rect!.width / 2;
|
const x = rect!.left + rect!.width / 2;
|
||||||
const elems = document.elementsFromPoint(x, clientY);
|
const { segment, bucketPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
|
||||||
const segment = elems.find(({ id }) => id === 'time-segment');
|
activeSegment = segment;
|
||||||
let bucketPercentY = 0;
|
isHoverOnPaddingTop = isOnPaddingTop;
|
||||||
if (segment) {
|
isHoverOnPaddingBottom = isOnPaddingBottom;
|
||||||
activeSegment = segment as HTMLElement;
|
|
||||||
|
|
||||||
const sr = segment.getBoundingClientRect();
|
|
||||||
const sy = sr.y;
|
|
||||||
const relativeY = clientY - sy;
|
|
||||||
bucketPercentY = relativeY / sr.height;
|
|
||||||
} else {
|
|
||||||
const leadin = elems.find(({ id }) => id === 'lead-in');
|
|
||||||
if (leadin) {
|
|
||||||
activeSegment = leadin as HTMLElement;
|
|
||||||
} else {
|
|
||||||
activeSegment = undefined;
|
|
||||||
bucketPercentY = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollPercent = toTimelineY(hoverY);
|
const scrollPercent = toTimelineY(hoverY);
|
||||||
if (wasDragging === false && isDragging) {
|
if (wasDragging === false && isDragging) {
|
||||||
@ -225,9 +317,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
|
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
|
||||||
const isHoverScrollbar = elements.some(({ id }) => {
|
const isHoverScrollbar =
|
||||||
return id === 'immich-scrubbable-scrollbar' || id === 'time-label';
|
findElementBestY(elements, 0, 'immich-scrubbable-scrollbar', 'time-label', 'lead-in', 'lead-out') !== undefined;
|
||||||
});
|
|
||||||
|
|
||||||
isHover = isHoverScrollbar;
|
isHover = isHoverScrollbar;
|
||||||
|
|
||||||
@ -253,21 +344,89 @@
|
|||||||
handleMouseEvent({
|
handleMouseEvent({
|
||||||
clientY: touch.clientY,
|
clientY: touch.clientY,
|
||||||
});
|
});
|
||||||
event.preventDefault();
|
|
||||||
} else {
|
} else {
|
||||||
isHover = false;
|
isHover = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const opts = {
|
document.addEventListener('touchmove', onTouchMove, true);
|
||||||
passive: false,
|
|
||||||
};
|
|
||||||
globalThis.addEventListener('touchmove', onTouchMove, opts);
|
|
||||||
return () => {
|
return () => {
|
||||||
globalThis.removeEventListener('touchmove', onTouchMove);
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener('touchstart', onTouchStart, true);
|
||||||
|
document.addEventListener('touchend', onTouchEnd, true);
|
||||||
|
return () => {
|
||||||
|
document.addEventListener('touchstart', onTouchStart, 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
|
||||||
@ -275,30 +434,28 @@
|
|||||||
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
|
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
|
||||||
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
|
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
|
||||||
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
||||||
ontouchstart={onTouchStart}
|
|
||||||
ontouchend={onTouchEnd}
|
|
||||||
ontouchcancel={onTouchEnd}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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-valuenow={scrollY + HOVER_DATE_HEIGHT}
|
aria-valuetext={hoverLabel}
|
||||||
aria-valuemax={toScrollY(100)}
|
aria-valuenow={scrollY + PADDING_TOP}
|
||||||
|
aria-valuemax={toScrollY(1)}
|
||||||
aria-valuemin={toScrollY(0)}
|
aria-valuemin={toScrollY(0)}
|
||||||
id="immich-scrubbable-scrollbar"
|
data-id="immich-scrubbable-scrollbar"
|
||||||
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
|
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
|
||||||
style:padding-top={HOVER_DATE_HEIGHT + 'px'}
|
style:padding-top={PADDING_TOP + 'px'}
|
||||||
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
|
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||||
style:width={isDragging ? '100vw' : '60px'}
|
style:width
|
||||||
style:height={height + 'px'}
|
style:height={height + 'px'}
|
||||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||||
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)}
|
||||||
@ -318,7 +475,7 @@
|
|||||||
<div
|
<div
|
||||||
id="time-label"
|
id="time-label"
|
||||||
class="rounded-l-full w-[32px] pl-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none"
|
class="rounded-l-full w-[32px] pl-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none"
|
||||||
style:top="{scrollY + HOVER_DATE_HEIGHT - 25}px"
|
style:top="{PADDING_TOP + (scrollY - 50 / 2)}px"
|
||||||
style:height="50px"
|
style:height="50px"
|
||||||
style:right="0"
|
style:right="0"
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
@ -344,9 +501,9 @@
|
|||||||
{#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 + HOVER_DATE_HEIGHT}px"
|
style:top="{scrollY + PADDING_TOP - 2}px"
|
||||||
>
|
>
|
||||||
{#if assetStore.scrolling && scrollHoverLabel}
|
{#if assetStore.scrolling && scrollHoverLabel && !isHover}
|
||||||
<p
|
<p
|
||||||
transition:fade={{ duration: 200 }}
|
transition:fade={{ duration: 200 }}
|
||||||
class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
|
class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
|
||||||
@ -356,7 +513,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div id="lead-in" class="relative" style:height={relativeTopOffset + 'px'} data-label={segments.at(0)?.dateFormatted}>
|
<div
|
||||||
|
class="relative z-10"
|
||||||
|
style:height={relativeTopOffset + 'px'}
|
||||||
|
data-id="lead-in"
|
||||||
|
data-time-segment-bucket-date={segments.at(0)?.date}
|
||||||
|
data-label={segments.at(0)?.dateFormatted}
|
||||||
|
>
|
||||||
{#if relativeTopOffset > 6}
|
{#if relativeTopOffset > 6}
|
||||||
<div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300"></div>
|
<div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300"></div>
|
||||||
{/if}
|
{/if}
|
||||||
@ -364,28 +527,26 @@
|
|||||||
<!-- Time Segment -->
|
<!-- Time Segment -->
|
||||||
{#each segments as segment (segment.date)}
|
{#each segments as segment (segment.date)}
|
||||||
<div
|
<div
|
||||||
id="time-segment"
|
|
||||||
class="relative"
|
class="relative"
|
||||||
|
data-id="time-segment"
|
||||||
data-time-segment-bucket-date={segment.date}
|
data-time-segment-bucket-date={segment.date}
|
||||||
data-label={segment.dateFormatted}
|
data-label={segment.dateFormatted}
|
||||||
style:height={segment.height + 'px'}
|
style:height={segment.height + 'px'}
|
||||||
>
|
>
|
||||||
{#if !usingMobileDevice && segment.hasLabel}
|
{#if !usingMobileDevice}
|
||||||
<div class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono">
|
{#if segment.hasLabel}
|
||||||
{segment.date.year}
|
<div class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono">
|
||||||
</div>
|
{segment.date.year}
|
||||||
{/if}
|
</div>
|
||||||
{#if !usingMobileDevice && segment.hasDot}
|
{/if}
|
||||||
<div class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"></div>
|
{#if segment.hasDot}
|
||||||
|
<div class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"></div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
|
<div data-id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#immich-scrubbable-scrollbar,
|
|
||||||
#time-segment {
|
|
||||||
contain: layout size style;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -17,7 +17,7 @@ import {
|
|||||||
TimeBucketSize,
|
TimeBucketSize,
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { debounce, isEqual, throttle } from 'lodash-es';
|
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
@ -30,10 +30,6 @@ const {
|
|||||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||||
} = TUNABLES;
|
} = TUNABLES;
|
||||||
|
|
||||||
const THUMBNAIL_HEIGHT = 235;
|
|
||||||
const GAP = 12;
|
|
||||||
const HEADER = 49; //(1.5rem)
|
|
||||||
|
|
||||||
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
|
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
|
||||||
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
||||||
timelineAlbumId?: string;
|
timelineAlbumId?: string;
|
||||||
@ -83,8 +79,8 @@ class IntersectingAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = this.#group.bucket.store;
|
const store = this.#group.bucket.store;
|
||||||
const topWindow = store.visibleWindow.top + HEADER - INTERSECTION_EXPAND_TOP;
|
const topWindow = store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP;
|
||||||
const bottomWindow = store.visibleWindow.bottom + HEADER + INTERSECTION_EXPAND_BOTTOM;
|
const bottomWindow = store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM;
|
||||||
const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
|
const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
|
||||||
const positionBottom = positionTop + this.position.height;
|
const positionBottom = positionTop + this.position.height;
|
||||||
|
|
||||||
@ -97,7 +93,7 @@ class IntersectingAsset {
|
|||||||
|
|
||||||
position: CommonPosition | undefined = $state();
|
position: CommonPosition | undefined = $state();
|
||||||
asset: AssetResponseDto | undefined = $state();
|
asset: AssetResponseDto | undefined = $state();
|
||||||
id: string = $derived.by(() => this.asset!.id);
|
id: string | undefined = $derived(this.asset?.id);
|
||||||
|
|
||||||
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
|
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
|
||||||
this.#group = group;
|
this.#group = group;
|
||||||
@ -230,6 +226,7 @@ export type ViewportXY = Viewport & {
|
|||||||
export class AssetBucket {
|
export class AssetBucket {
|
||||||
// --- public ---
|
// --- public ---
|
||||||
#intersecting: boolean = $state(false);
|
#intersecting: boolean = $state(false);
|
||||||
|
actuallyIntersecting: boolean = $state(false);
|
||||||
isLoaded: boolean = $state(false);
|
isLoaded: boolean = $state(false);
|
||||||
dateGroups: AssetDateGroup[] = $state([]);
|
dateGroups: AssetDateGroup[] = $state([]);
|
||||||
readonly store: AssetStore;
|
readonly store: AssetStore;
|
||||||
@ -243,8 +240,10 @@ export class AssetBucket {
|
|||||||
*/
|
*/
|
||||||
#bucketHeight: number = $state(0);
|
#bucketHeight: number = $state(0);
|
||||||
#top: number = $state(0);
|
#top: number = $state(0);
|
||||||
|
|
||||||
#initialCount: number = 0;
|
#initialCount: number = 0;
|
||||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||||
|
percent: number = $state(0);
|
||||||
// --- should be private, but is used by AssetStore ---
|
// --- should be private, but is used by AssetStore ---
|
||||||
|
|
||||||
bucketCount: number = $derived(
|
bucketCount: number = $derived(
|
||||||
@ -282,6 +281,7 @@ export class AssetBucket {
|
|||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
this.dateGroups = [];
|
||||||
this.isLoaded = false;
|
this.isLoaded = false;
|
||||||
},
|
},
|
||||||
this.handleLoadError,
|
this.handleLoadError,
|
||||||
@ -401,8 +401,12 @@ export class AssetBucket {
|
|||||||
}
|
}
|
||||||
if (dateGroup) {
|
if (dateGroup) {
|
||||||
const intersectingAsset = new IntersectingAsset(dateGroup, asset);
|
const intersectingAsset = new IntersectingAsset(dateGroup, asset);
|
||||||
dateGroup.intersetingAssets.push(intersectingAsset);
|
if (dateGroup.intersetingAssets.some((a) => a.id === asset.id)) {
|
||||||
changedDateGroups.add(dateGroup);
|
console.error(`Ignoring attempt to add duplicate asset ${asset.id} to ${dateGroup.groupTitle}`);
|
||||||
|
} else {
|
||||||
|
dateGroup.intersetingAssets.push(intersectingAsset);
|
||||||
|
changedDateGroups.add(dateGroup);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
|
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
|
||||||
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset));
|
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset));
|
||||||
@ -440,29 +444,36 @@ export class AssetBucket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set bucketHeight(height: number) {
|
set bucketHeight(height: number) {
|
||||||
const { store } = this;
|
const { store, percent } = this;
|
||||||
const index = store.buckets.indexOf(this);
|
const index = store.buckets.indexOf(this);
|
||||||
const bucketHeightDelta = height - this.#bucketHeight;
|
const bucketHeightDelta = height - this.#bucketHeight;
|
||||||
|
this.#bucketHeight = height;
|
||||||
const prevBucket = store.buckets[index - 1];
|
const prevBucket = store.buckets[index - 1];
|
||||||
if (prevBucket) {
|
if (prevBucket) {
|
||||||
this.#top = prevBucket.#top + prevBucket.#bucketHeight;
|
const newTop = prevBucket.#top + prevBucket.#bucketHeight;
|
||||||
}
|
if (this.#top !== newTop) {
|
||||||
if (bucketHeightDelta) {
|
this.#top = newTop;
|
||||||
let cursor = index + 1;
|
}
|
||||||
while (cursor < store.buckets.length) {
|
}
|
||||||
const nextBucket = this.store.buckets[cursor];
|
for (let cursor = index + 1; cursor < store.buckets.length; cursor++) {
|
||||||
nextBucket.#top += bucketHeightDelta;
|
const bucket = this.store.buckets[cursor];
|
||||||
cursor++;
|
const newTop = bucket.#top + bucketHeightDelta;
|
||||||
|
if (bucket.#top !== newTop) {
|
||||||
|
bucket.#top = newTop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.#bucketHeight = height;
|
|
||||||
if (store.topIntersectingBucket) {
|
if (store.topIntersectingBucket) {
|
||||||
const currentIndex = store.buckets.indexOf(store.topIntersectingBucket);
|
const currentIndex = store.buckets.indexOf(store.topIntersectingBucket);
|
||||||
// if the bucket is 'before' the last intersecting bucket in the sliding window
|
// if the bucket is 'before' the last intersecting bucket in the sliding window
|
||||||
// then adjust the scroll position by the delta, to compensate for the bucket
|
// then adjust the scroll position by the delta, to compensate for the bucket
|
||||||
// size adjustment
|
// size adjustment
|
||||||
if (currentIndex > 0 && index <= currentIndex) {
|
if (currentIndex > 0) {
|
||||||
store.compensateScrollCallback?.(bucketHeightDelta);
|
if (index < currentIndex) {
|
||||||
|
store.compensateScrollCallback?.({ delta: bucketHeightDelta });
|
||||||
|
} else if (currentIndex == currentIndex && percent > 0) {
|
||||||
|
const top = this.top + height * percent;
|
||||||
|
store.compensateScrollCallback?.({ top });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -470,10 +481,7 @@ export class AssetBucket {
|
|||||||
return this.#bucketHeight;
|
return this.#bucketHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
set top(top: number) {
|
get top(): number {
|
||||||
this.#top = top;
|
|
||||||
}
|
|
||||||
get top() {
|
|
||||||
return this.#top + this.store.topSectionHeight;
|
return this.#top + this.store.topSectionHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,7 +498,7 @@ export class AssetBucket {
|
|||||||
for (const group of this.dateGroups) {
|
for (const group of this.dateGroups) {
|
||||||
const intersectingAsset = group.intersetingAssets.find((asset) => asset.id === assetId);
|
const intersectingAsset = group.intersetingAssets.find((asset) => asset.id === assetId);
|
||||||
if (intersectingAsset) {
|
if (intersectingAsset) {
|
||||||
return this.top + group.top + intersectingAsset.position!.top + HEADER;
|
return this.top + group.top + intersectingAsset.position!.top + this.store.headerHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
@ -556,7 +564,7 @@ export class AssetStore {
|
|||||||
scrubberTimelineHeight: number = $state(0);
|
scrubberTimelineHeight: number = $state(0);
|
||||||
|
|
||||||
// -- should be private, but used by AssetBucket
|
// -- should be private, but used by AssetBucket
|
||||||
compensateScrollCallback: ((delta: number) => void) | undefined;
|
compensateScrollCallback: (({ delta, top }: { delta?: number; top?: number }) => void) | undefined;
|
||||||
topIntersectingBucket: AssetBucket | undefined = $state();
|
topIntersectingBucket: AssetBucket | undefined = $state();
|
||||||
|
|
||||||
visibleWindow = $derived.by(() => ({
|
visibleWindow = $derived.by(() => ({
|
||||||
@ -581,13 +589,16 @@ export class AssetStore {
|
|||||||
|
|
||||||
// --- private
|
// --- private
|
||||||
static #INIT_OPTIONS = {};
|
static #INIT_OPTIONS = {};
|
||||||
#rowHeight = 235;
|
|
||||||
#viewportHeight = $state(0);
|
#viewportHeight = $state(0);
|
||||||
#viewportWidth = $state(0);
|
#viewportWidth = $state(0);
|
||||||
#scrollTop = $state(0);
|
#scrollTop = $state(0);
|
||||||
#pendingChanges: PendingChange[] = [];
|
#pendingChanges: PendingChange[] = [];
|
||||||
#unsubscribers: Unsubscriber[] = [];
|
#unsubscribers: Unsubscriber[] = [];
|
||||||
|
|
||||||
|
#rowHeight = $state(235);
|
||||||
|
#headerHeight = $state(49);
|
||||||
|
#gap = $state(12);
|
||||||
|
|
||||||
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
|
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
|
||||||
|
|
||||||
#scrolling = $state(false);
|
#scrolling = $state(false);
|
||||||
@ -597,6 +608,42 @@ export class AssetStore {
|
|||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
|
set headerHeight(value) {
|
||||||
|
if (this.#headerHeight == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#headerHeight = value;
|
||||||
|
this.refreshLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
get headerHeight() {
|
||||||
|
return this.#headerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
set gap(value) {
|
||||||
|
if (this.#gap == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#gap = value;
|
||||||
|
this.refreshLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
get 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) {
|
||||||
@ -624,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);
|
||||||
}
|
}
|
||||||
@ -724,29 +770,51 @@ 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.intersecting) {
|
if (!topIntersectingBucket && bucket.actuallyIntersecting && bucket.isLoaded) {
|
||||||
topIntersectingBucket = bucket;
|
topIntersectingBucket = bucket;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.topIntersectingBucket !== topIntersectingBucket) {
|
if (this.topIntersectingBucket !== topIntersectingBucket) {
|
||||||
this.topIntersectingBucket = topIntersectingBucket;
|
this.topIntersectingBucket = topIntersectingBucket;
|
||||||
}
|
}
|
||||||
|
for (const bucket of this.buckets) {
|
||||||
|
if (bucket === this.topIntersectingBucket) {
|
||||||
|
this.topIntersectingBucket.percent = clamp(
|
||||||
|
(this.visibleWindow.top - this.topIntersectingBucket.top) / this.topIntersectingBucket.bucketHeight,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bucket.percent = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateIntersection(bucket: AssetBucket) {
|
#calculateIntersecting(bucket: AssetBucket, expandTop: number, expandBottom: number) {
|
||||||
const bucketTop = bucket.top;
|
const bucketTop = bucket.top;
|
||||||
const bucketBottom = bucketTop + bucket.bucketHeight;
|
const bucketBottom = bucketTop + bucket.bucketHeight;
|
||||||
const topWindow = this.visibleWindow.top - INTERSECTION_EXPAND_TOP;
|
const topWindow = this.visibleWindow.top - expandTop;
|
||||||
const bottomWindow = this.visibleWindow.bottom + INTERSECTION_EXPAND_BOTTOM;
|
const bottomWindow = this.visibleWindow.bottom + expandBottom;
|
||||||
|
|
||||||
// a bucket intersections if
|
// a bucket intersections if
|
||||||
// 1) bucket's bottom is in the visible range -or-
|
// 1) bucket's bottom is in the visible range -or-
|
||||||
// 2) bucket's bottom is in the visible range -or-
|
// 2) bucket's bottom is in the visible range -or-
|
||||||
// 3) bucket's top is above visible range and bottom is below visible range
|
// 3) bucket's top is above visible range and bottom is below visible range
|
||||||
bucket.intersecting =
|
return (
|
||||||
(bucketTop >= topWindow && bucketTop < bottomWindow) ||
|
(bucketTop >= topWindow && bucketTop < bottomWindow) ||
|
||||||
(bucketBottom >= topWindow && bucketBottom < bottomWindow) ||
|
(bucketBottom >= topWindow && bucketBottom < bottomWindow) ||
|
||||||
(bucketTop < topWindow && bucketBottom >= bottomWindow);
|
(bucketTop < topWindow && bucketBottom >= bottomWindow)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateIntersection(bucket: AssetBucket) {
|
||||||
|
const actuallyIntersecting = this.#calculateIntersecting(bucket, 0, 0);
|
||||||
|
let preIntersecting = false;
|
||||||
|
if (!actuallyIntersecting) {
|
||||||
|
preIntersecting = this.#calculateIntersecting(bucket, INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM);
|
||||||
|
}
|
||||||
|
bucket.intersecting = actuallyIntersecting || preIntersecting;
|
||||||
|
bucket.actuallyIntersecting = actuallyIntersecting;
|
||||||
}
|
}
|
||||||
|
|
||||||
#processPendingChanges = throttle(() => {
|
#processPendingChanges = throttle(() => {
|
||||||
@ -763,7 +831,7 @@ export class AssetStore {
|
|||||||
this.#pendingChanges = [];
|
this.#pendingChanges = [];
|
||||||
}, 2500);
|
}, 2500);
|
||||||
|
|
||||||
setCompensateScrollCallback(compensateScrollCallback?: (delta: number) => void) {
|
setCompensateScrollCallback(compensateScrollCallback?: ({ delta, top }: { delta?: number; top?: number }) => void) {
|
||||||
this.compensateScrollCallback = compensateScrollCallback;
|
this.compensateScrollCallback = compensateScrollCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -800,11 +868,6 @@ export class AssetStore {
|
|||||||
this.#updateViewportGeometry(false);
|
this.#updateViewportGeometry(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLayoutOptions(options: AssetStoreLayoutOptions) {
|
|
||||||
this.#rowHeight = options.rowHeight;
|
|
||||||
this.refreshLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
async #init(options: AssetStoreOptions) {
|
async #init(options: AssetStoreOptions) {
|
||||||
// doing the following outside of the task reduces flickr
|
// doing the following outside of the task reduces flickr
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
@ -890,9 +953,9 @@ export class AssetStore {
|
|||||||
// optimize - if bucket already has data, no need to create estimates
|
// optimize - if bucket already has data, no need to create estimates
|
||||||
const viewportWidth = this.viewportWidth;
|
const viewportWidth = this.viewportWidth;
|
||||||
if (!bucket.isBucketHeightActual) {
|
if (!bucket.isBucketHeightActual) {
|
||||||
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
|
const unwrappedWidth = (3 / 2) * bucket.bucketCount * this.#rowHeight * (7 / 10);
|
||||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||||
const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT;
|
const height = 51 + Math.max(1, rows) * this.#rowHeight;
|
||||||
bucket.bucketHeight = height;
|
bucket.bucketHeight = height;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -918,7 +981,7 @@ export class AssetStore {
|
|||||||
assetGroup.layout(options);
|
assetGroup.layout(options);
|
||||||
rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1;
|
rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1;
|
||||||
if (dateGroupCol > 0) {
|
if (dateGroupCol > 0) {
|
||||||
rowSpaceRemaining[dateGroupRow] -= GAP;
|
rowSpaceRemaining[dateGroupRow] -= this.gap;
|
||||||
}
|
}
|
||||||
if (rowSpaceRemaining[dateGroupRow] >= 0) {
|
if (rowSpaceRemaining[dateGroupRow] >= 0) {
|
||||||
assetGroup.row = dateGroupRow;
|
assetGroup.row = dateGroupRow;
|
||||||
@ -928,7 +991,7 @@ export class AssetStore {
|
|||||||
|
|
||||||
dateGroupCol++;
|
dateGroupCol++;
|
||||||
|
|
||||||
cummulativeWidth += assetGroup.width + GAP;
|
cummulativeWidth += assetGroup.width + this.gap;
|
||||||
} else {
|
} else {
|
||||||
// starting a new row, we need to update the last col of the previous row
|
// starting a new row, we need to update the last col of the previous row
|
||||||
cummulativeWidth = 0;
|
cummulativeWidth = 0;
|
||||||
@ -942,10 +1005,10 @@ export class AssetStore {
|
|||||||
dateGroupCol++;
|
dateGroupCol++;
|
||||||
cummulativeHeight += lastRowHeight;
|
cummulativeHeight += lastRowHeight;
|
||||||
assetGroup.top = cummulativeHeight;
|
assetGroup.top = cummulativeHeight;
|
||||||
cummulativeWidth += assetGroup.width + GAP;
|
cummulativeWidth += assetGroup.width + this.gap;
|
||||||
lastRow = assetGroup.row - 1;
|
lastRow = assetGroup.row - 1;
|
||||||
}
|
}
|
||||||
lastRowHeight = assetGroup.height + HEADER;
|
lastRowHeight = assetGroup.height + this.headerHeight;
|
||||||
}
|
}
|
||||||
if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) {
|
if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) {
|
||||||
cummulativeHeight += lastRowHeight;
|
cummulativeHeight += lastRowHeight;
|
||||||
@ -974,6 +1037,11 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await bucket.loader?.execute(async (signal: AbortSignal) => {
|
const result = await bucket.loader?.execute(async (signal: AbortSignal) => {
|
||||||
|
if (bucket.getFirstAsset()) {
|
||||||
|
// this happens when a bucket was created by an event instead of via a loadBucket call
|
||||||
|
// so no need to load the bucket, it already has assets
|
||||||
|
return;
|
||||||
|
}
|
||||||
const assets = await getTimeBucket(
|
const assets = await getTimeBucket(
|
||||||
{
|
{
|
||||||
...this.#options,
|
...this.#options,
|
||||||
|
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)];
|
@ -32,6 +32,9 @@
|
|||||||
<UploadCover />
|
<UploadCover />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
.display-none {
|
.display-none {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user