mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02:46:47 +01:00
fix(web): scrollbar (#3536)
This commit is contained in:
parent
b44f8d52ee
commit
6da51deb83
3 changed files with 127 additions and 176 deletions
|
@ -8,10 +8,7 @@
|
|||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
import Scrollbar, {
|
||||
OnScrollbarClickDetail,
|
||||
OnScrollbarDragDetail,
|
||||
} from '../shared-components/scrollbar/scrollbar.svelte';
|
||||
import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte';
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
import MemoryLane from './memory-lane.svelte';
|
||||
|
||||
|
@ -30,13 +27,13 @@
|
|||
export let assetInteractionStore: AssetInteractionStore;
|
||||
|
||||
const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore;
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
|
||||
|
||||
const viewport: Viewport = { width: 0, height: 0 };
|
||||
let assetGridElement: HTMLElement;
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
|
||||
let element: HTMLElement;
|
||||
let showShortcuts = false;
|
||||
|
||||
$: timelineY = element?.scrollTop || 0;
|
||||
|
||||
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
||||
|
||||
onMount(async () => {
|
||||
|
@ -84,7 +81,7 @@
|
|||
}
|
||||
|
||||
function handleScrollTimeline(event: CustomEvent) {
|
||||
assetGridElement.scrollBy(0, event.detail.heightDelta);
|
||||
element.scrollBy(0, event.detail.heightDelta);
|
||||
}
|
||||
|
||||
const navigateToPreviousAsset = async () => {
|
||||
|
@ -101,26 +98,18 @@
|
|||
}
|
||||
};
|
||||
|
||||
let lastScrollPosition = 0;
|
||||
let animationTick = false;
|
||||
|
||||
const handleTimelineScroll = () => {
|
||||
if (!animationTick) {
|
||||
window.requestAnimationFrame(() => {
|
||||
lastScrollPosition = assetGridElement?.scrollTop;
|
||||
animationTick = false;
|
||||
});
|
||||
|
||||
animationTick = true;
|
||||
if (animationTick) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollbarClick = (e: OnScrollbarClickDetail) => {
|
||||
assetGridElement.scrollTop = e.scrollTo;
|
||||
};
|
||||
|
||||
const handleScrollbarDrag = (e: OnScrollbarDragDetail) => {
|
||||
assetGridElement.scrollTop = e.scrollTo;
|
||||
animationTick = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
timelineY = element?.scrollTop || 0;
|
||||
animationTick = false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveSuccess = (e: CustomEvent) => {
|
||||
|
@ -278,26 +267,23 @@
|
|||
<ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
|
||||
{/if}
|
||||
|
||||
{#if $assetStore.timelineHeight > viewport.height}
|
||||
<Scrollbar
|
||||
{assetStore}
|
||||
scrollbarHeight={viewport.height}
|
||||
scrollTop={lastScrollPosition}
|
||||
on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
|
||||
on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)}
|
||||
/>
|
||||
{/if}
|
||||
<Scrollbar
|
||||
{assetStore}
|
||||
height={viewport.height}
|
||||
{timelineY}
|
||||
on:scrollTimeline={({ detail }) => (element.scrollTop = detail)}
|
||||
/>
|
||||
|
||||
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
||||
<section
|
||||
id="asset-grid"
|
||||
class="scrollbar-hidden mb-4 ml-4 mr-[60px] overflow-y-auto"
|
||||
class="scrollbar-hidden ml-4 mr-[60px] overflow-y-auto pb-4"
|
||||
bind:clientHeight={viewport.height}
|
||||
bind:clientWidth={viewport.width}
|
||||
bind:this={assetGridElement}
|
||||
bind:this={element}
|
||||
on:scroll={handleTimelineScroll}
|
||||
>
|
||||
{#if assetGridElement}
|
||||
{#if element}
|
||||
{#if showMemoryLane}
|
||||
<MemoryLane />
|
||||
{/if}
|
||||
|
@ -309,7 +295,7 @@
|
|||
let:intersecting
|
||||
top={750}
|
||||
bottom={750}
|
||||
root={assetGridElement}
|
||||
root={element}
|
||||
>
|
||||
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
|
||||
{#if intersecting}
|
||||
|
|
|
@ -1,158 +1,128 @@
|
|||
<script lang="ts" context="module">
|
||||
type OnScrollbarClick = {
|
||||
onscrollbarclick: OnScrollbarClickDetail;
|
||||
};
|
||||
|
||||
export type OnScrollbarClickDetail = {
|
||||
scrollTo: number;
|
||||
};
|
||||
|
||||
type OnScrollbarDrag = {
|
||||
onscrollbardrag: OnScrollbarDragDetail;
|
||||
};
|
||||
|
||||
export type OnScrollbarDragDetail = {
|
||||
scrollTo: number;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
|
||||
import type { AssetStore } from '$lib/stores/assets.store';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let scrollTop = 0;
|
||||
export let scrollbarHeight = 0;
|
||||
export let timelineY = 0;
|
||||
export let height = 0;
|
||||
export let assetStore: AssetStore;
|
||||
|
||||
$: timelineHeight = $assetStore.timelineHeight;
|
||||
$: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight;
|
||||
|
||||
let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
|
||||
let isHover = false;
|
||||
let isDragging = false;
|
||||
let hoveredDate: Date;
|
||||
let currentMouseYLocation = 0;
|
||||
let scrollbarPosition = 0;
|
||||
let animationTick = false;
|
||||
let isAnimating = false;
|
||||
let hoverLabel = '';
|
||||
let clientY = 0;
|
||||
let windowHeight = 0;
|
||||
|
||||
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
|
||||
$: offset = $isAlbumAssetSelectionOpen ? 100 : 76;
|
||||
const dispatchClick = createEventDispatcher<OnScrollbarClick>();
|
||||
const dispatchDrag = createEventDispatcher<OnScrollbarDrag>();
|
||||
$: {
|
||||
scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight;
|
||||
}
|
||||
const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height;
|
||||
const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height);
|
||||
|
||||
$: {
|
||||
let result: SegmentScrollbarLayout[] = [];
|
||||
for (const bucket of $assetStore.buckets) {
|
||||
let segmentLayout = new SegmentScrollbarLayout();
|
||||
segmentLayout.count = bucket.assets.length;
|
||||
segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight;
|
||||
segmentLayout.timeGroup = bucket.bucketDate;
|
||||
result.push(segmentLayout);
|
||||
const HOVER_DATE_HEIGHT = 30;
|
||||
|
||||
$: hoverY = height - windowHeight + clientY;
|
||||
$: scrollY = toScrollY(timelineY);
|
||||
$: segments = $assetStore.buckets.map((bucket) => ({
|
||||
count: bucket.assets.length,
|
||||
height: toScrollY(bucket.bucketHeight),
|
||||
timeGroup: bucket.bucketDate,
|
||||
}));
|
||||
|
||||
const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
|
||||
const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
|
||||
|
||||
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
|
||||
const wasDragging = isDragging;
|
||||
|
||||
isDragging = event.isDragging ?? isDragging;
|
||||
clientY = event.clientY;
|
||||
|
||||
if (wasDragging === false && isDragging) {
|
||||
scrollTimeline();
|
||||
}
|
||||
segmentScrollbarLayout = result;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent, currentDate: Date) => {
|
||||
currentMouseYLocation = e.clientY - offset - 30;
|
||||
|
||||
hoveredDate = new Date(currentDate.toISOString().slice(0, -1));
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
isDragging = true;
|
||||
scrollbarPosition = e.clientY - offset;
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
isDragging = false;
|
||||
scrollbarPosition = e.clientY - offset;
|
||||
dispatchClick('onscrollbarclick', { scrollTo: timelineScrolltop });
|
||||
};
|
||||
|
||||
const handleMouseDrag = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
if (!animationTick) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const dy = e.clientY - scrollbarPosition - offset;
|
||||
scrollbarPosition += dy;
|
||||
dispatchDrag('onscrollbardrag', { scrollTo: timelineScrolltop });
|
||||
animationTick = false;
|
||||
});
|
||||
|
||||
animationTick = true;
|
||||
}
|
||||
if (!isDragging || isAnimating) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAnimating = true;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollTimeline();
|
||||
isAnimating = false;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={windowHeight} />
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
id="immich-scrubbable-scrollbar"
|
||||
class="fixed right-0 z-[100] select-none bg-immich-bg hover:cursor-row-resize"
|
||||
style:width={isDragging ? '100vw' : '60px'}
|
||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||
on:mouseenter={() => (isHover = true)}
|
||||
on:mouseleave={() => {
|
||||
isHover = false;
|
||||
isDragging = false;
|
||||
}}
|
||||
on:mouseup={handleMouseUp}
|
||||
on:mousemove={handleMouseDrag}
|
||||
on:mousedown={handleMouseDown}
|
||||
style:height={scrollbarHeight + 'px'}
|
||||
>
|
||||
{#if isHover}
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 z-[100] w-[100px] rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 pl-1 pr-6 text-sm font-medium shadow-lg dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
style:top={currentMouseYLocation + 'px'}
|
||||
>
|
||||
{hoveredDate?.toLocaleString('default', { month: 'short' })}
|
||||
{hoveredDate?.getFullYear()}
|
||||
</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={scrollbarPosition + 'px'}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Time Segment -->
|
||||
{#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
|
||||
{@const groupDate = new Date(segment.timeGroup)}
|
||||
{#if $assetStore.timelineHeight > height}
|
||||
<div
|
||||
id="immich-scrubbable-scrollbar"
|
||||
class="fixed right-0 z-[100] 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"
|
||||
on:mouseenter={() => (isHover = true)}
|
||||
on:mouseleave={() => {
|
||||
isHover = false;
|
||||
isDragging = false;
|
||||
}}
|
||||
on:mouseenter={({ clientY, buttons }) => handleMouseEvent({ clientY, isDragging: !!buttons })}
|
||||
on:mousemove={({ clientY }) => handleMouseEvent({ clientY })}
|
||||
on:mousedown={({ clientY }) => handleMouseEvent({ clientY, isDragging: true })}
|
||||
on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
||||
>
|
||||
{#if isHover}
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 z-[100] w-[100px] rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 pl-1 pr-6 text-sm font-medium shadow-lg dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
style:top="{Math.max(hoverY - HOVER_DATE_HEIGHT, 0)}px"
|
||||
>
|
||||
{hoverLabel}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
id="time-segment"
|
||||
class="relative"
|
||||
style:height={segment.height + 'px'}
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
on:mousemove={(e) => handleMouseMove(e, groupDate)}
|
||||
>
|
||||
{#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()}
|
||||
{#if segment.height > 8}
|
||||
<!-- 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, index (segment.timeGroup)}
|
||||
{@const date = new Date(segment.timeGroup)}
|
||||
{@const year = date.getFullYear()}
|
||||
{@const label = `${date.toLocaleString('default', { month: 'short' })} ${year}`}
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
id="time-segment"
|
||||
class="relative"
|
||||
style:height={segment.height + 'px'}
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
on:mousemove={() => (hoverLabel = label)}
|
||||
>
|
||||
{#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year}
|
||||
{#if segment.height > 8}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg"
|
||||
>
|
||||
{year}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if segment.height > 5}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg"
|
||||
>
|
||||
{groupDate.getFullYear()}
|
||||
</div>
|
||||
class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300"
|
||||
/>
|
||||
{/if}
|
||||
{: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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
#immich-scrubbable-scrollbar,
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export class SegmentScrollbarLayout {
|
||||
height!: number;
|
||||
timeGroup!: string;
|
||||
count!: number;
|
||||
}
|
Loading…
Reference in a new issue