mirror of https://github.com/immich-app/immich.git
184 lines
5.5 KiB
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>
|