immich/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte

184 lines
5.5 KiB
Svelte

<script lang="ts">
import type { AssetStore, AssetBucket } from '$lib/stores/assets.store';
import type { DateTime } from 'luxon';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { createEventDispatcher } from 'svelte';
import { clamp } from 'lodash-es';
import { locale } from '$lib/stores/preferences.store';
export let timelineY = 0;
export let height = 0;
export let assetStore: AssetStore;
let isHover = false;
let isDragging = false;
let isAnimating = false;
let hoverLabel = '';
let hoverY = 0;
let clientY = 0;
let windowHeight = 0;
let scrollBar: HTMLElement | undefined;
const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height;
const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height);
const HOVER_DATE_HEIGHT = 30;
const MIN_YEAR_LABEL_DISTANCE = 16;
$: {
hoverY = clamp(height - windowHeight + clientY, 0, height);
if (scrollBar) {
const rect = scrollBar.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + Math.min(hoverY, height - 1);
updateLabel(x, y);
}
}
$: scrollY = toScrollY(timelineY);
class Segment {
public count = 0;
public height = 0;
public timeGroup = '';
public date!: DateTime;
public hasLabel = false;
}
const calculateSegments = (buckets: AssetBucket[]) => {
let height = 0;
let previous: Segment;
return buckets.map((bucket) => {
const segment = new Segment();
segment.count = bucket.assets.length;
segment.height = toScrollY(bucket.bucketHeight);
segment.timeGroup = bucket.bucketDate;
segment.date = fromLocalDateTime(segment.timeGroup);
if (previous?.date.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
previous.hasLabel = true;
height = 0;
}
height += segment.height;
previous = segment;
return segment;
});
};
$: segments = calculateSegments($assetStore.buckets);
const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
const updateLabel = (cursorX: number, cursorY: number) => {
const segment = document.elementsFromPoint(cursorX, cursorY).find(({ id }) => id === 'time-segment');
if (!segment) {
return;
}
const attr = (segment as HTMLElement).dataset.date;
if (!attr) {
return;
}
hoverLabel = new Date(attr).toLocaleString($locale, {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
};
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
const wasDragging = isDragging;
isDragging = event.isDragging ?? isDragging;
clientY = event.clientY;
if (wasDragging === false && isDragging) {
scrollTimeline();
}
if (!isDragging || isAnimating) {
return;
}
isAnimating = true;
window.requestAnimationFrame(() => {
scrollTimeline();
isAnimating = false;
});
};
</script>
<svelte:window
bind:innerHeight={windowHeight}
on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
/>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if $assetStore.timelineHeight > height}
<div
id="immich-scrubbable-scrollbar"
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
style:width={isDragging ? '100vw' : '60px'}
style:height={height + 'px'}
style:background-color={isDragging ? 'transparent' : 'transparent'}
draggable="false"
bind:this={scrollBar}
on:mouseenter={() => (isHover = true)}
on:mouseleave={() => (isHover = false)}
>
{#if isHover || isDragging}
<div
id="time-label"
class="pointer-events-none absolute right-0 z-[100] min-w-24 w-fit whitespace-nowrap rounded-tl-md border-b-2 border-immich-primary bg-immich-bg 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 dark:text-immich-dark-fg"
style:top="{clamp(hoverY - HOVER_DATE_HEIGHT, 0, height - HOVER_DATE_HEIGHT - 2)}px"
>
{hoverLabel}
</div>
{/if}
<!-- Scroll Position Indicator Line -->
{#if !isDragging}
<div
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
style:top="{scrollY}px"
/>
{/if}
<!-- Time Segment -->
{#each segments as segment}
<div
id="time-segment"
class="relative"
data-date={segment.date}
style:height={segment.height + 'px'}
aria-label={segment.timeGroup + ' ' + segment.count}
>
{#if segment.hasLabel}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 bottom-0 z-10 pr-5 text-[12px] dark:text-immich-dark-fg font-immich-mono"
>
{segment.date.year}
</div>
{:else if segment.height > 5}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300"
/>
{/if}
</div>
{/each}
</div>
{/if}
<style>
#immich-scrubbable-scrollbar,
#time-segment {
contain: layout;
}
</style>