Browse Source

feat(web) add scrollbar with timeline information (#658)

- Implement a scrollbar with a timeline similar to Google Photos
- The scrollbar can also be dragged
Alex 2 years ago
parent
commit
d856b35afc

+ 7 - 0
web/src/app.css

@@ -88,4 +88,11 @@ input:focus-visible {
 		background: #4250afad;
 		border-radius: 16px;
 	}
+
+	/* Hidden scrollbar */
+	/* width */
+	.scrollbar-hidden::-webkit-scrollbar {
+		display: none;
+		scrollbar-width: none;
+	}
 }

+ 4 - 0
web/src/lib/components/album-page/album-viewer.svelte

@@ -26,12 +26,16 @@
 		NotificationType
 	} from '../shared-components/notification/notification';
 	import { browser } from '$app/env';
+	import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
 
 	export let album: AlbumResponseDto;
+	const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
 
 	let isShowAssetViewer = false;
 
 	let isShowAssetSelection = false;
+
+	$: $isAlbumAssetSelectionOpen = isShowAssetSelection;
 	$: {
 		if (browser) {
 			if (isShowAssetSelection) {

+ 43 - 3
web/src/lib/components/photos-page/asset-grid.svelte

@@ -3,7 +3,7 @@
 
 	import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
 	import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
-	import { api, TimeGroupEnum } from '@api';
+	import { api, AssetCountByTimeBucketResponseDto, TimeGroupEnum } from '@api';
 	import AssetDateGroup from './asset-date-group.svelte';
 	import Portal from '../shared-components/portal/portal.svelte';
 	import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@@ -12,16 +12,23 @@
 		isViewingAssetStoreState,
 		viewingAssetStoreState
 	} from '$lib/stores/asset-interaction.store';
+	import Scrollbar, {
+		OnScrollbarClickDetail,
+		OnScrollbarDragDetail
+	} from '../shared-components/scrollbar/scrollbar.svelte';
+
+	export let isAlbumSelectionMode = false;
 
 	let viewportHeight = 0;
 	let viewportWidth = 0;
 	let assetGridElement: HTMLElement;
-	export let isAlbumSelectionMode = false;
+	let bucketInfo: AssetCountByTimeBucketResponseDto;
 
 	onMount(async () => {
 		const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
 			timeGroup: TimeGroupEnum.Month
 		});
+		bucketInfo = assetCountByTimebucket;
 
 		assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
 
@@ -60,14 +67,46 @@
 	const navigateToNextAsset = () => {
 		assetInteractionStore.navigateAsset('next');
 	};
+
+	let lastScrollPosition = 0;
+	let animationTick = false;
+
+	const handleTimelineScroll = () => {
+		if (!animationTick) {
+			window.requestAnimationFrame(() => {
+				lastScrollPosition = assetGridElement?.scrollTop;
+				animationTick = false;
+			});
+
+			animationTick = true;
+		}
+	};
+
+	const handleScrollbarClick = (e: OnScrollbarClickDetail) => {
+		assetGridElement.scrollTop = e.scrollTo;
+	};
+
+	const handleScrollbarDrag = (e: OnScrollbarDragDetail) => {
+		assetGridElement.scrollTop = e.scrollTo;
+	};
 </script>
 
+{#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight}
+	<Scrollbar
+		scrollbarHeight={viewportHeight}
+		scrollTop={lastScrollPosition}
+		on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
+		on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)}
+	/>
+{/if}
+
 <section
 	id="asset-grid"
-	class="overflow-y-auto pl-4"
+	class="overflow-y-auto pl-4 scrollbar-hidden"
 	bind:clientHeight={viewportHeight}
 	bind:clientWidth={viewportWidth}
 	bind:this={assetGridElement}
+	on:scroll={handleTimelineScroll}
 >
 	{#if assetGridElement}
 		<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
@@ -117,5 +156,6 @@
 <style>
 	#asset-grid {
 		contain: layout;
+		scrollbar-width: none;
 	}
 </style>

+ 1 - 1
web/src/lib/components/shared-components/immich-thumbnail.svelte

@@ -136,7 +136,7 @@
 	<div
 		style:width={`${thumbnailSize}px`}
 		style:height={`${thumbnailSize}px`}
-		class={`bg-gray-100 relative  ${getSize()} ${
+		class={`bg-gray-100 relative select-none  ${getSize()} ${
 			disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
 		}`}
 		on:mouseenter={handleMouseOverThumbnail}

+ 100 - 58
web/src/lib/components/shared-components/scrollbar/scrollbar.svelte

@@ -1,74 +1,111 @@
+<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 { onMount } from 'svelte';
+	import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
+
+	import { assetGridState } from '$lib/stores/assets.store';
+
+	import { createEventDispatcher } from 'svelte';
 	import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
 
 	export let scrollTop = 0;
-	// export let viewportWidth = 0;
 	export let scrollbarHeight = 0;
 
-	let timelineHeight = 0;
+	$: timelineHeight = $assetGridState.timelineHeight;
+	$: viewportWidth = $assetGridState.viewportWidth;
+	$: 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;
 
+	const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
+	$: offset = $isAlbumAssetSelectionOpen ? 100 : 71;
+	const dispatchClick = createEventDispatcher<OnScrollbarClick>();
+	const dispatchDrag = createEventDispatcher<OnScrollbarDrag>();
 	$: {
 		scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight;
 	}
 
 	$: {
-		// let result: SegmentScrollbarLayout[] = [];
-		// for (const [i, segment] of assetStoreState.entries()) {
-		// 	let segmentLayout = new SegmentScrollbarLayout();
-		// 	segmentLayout.count = segmentData.groups[i].count;
-		// 	segmentLayout.height =
-		// 		segment.assets.length == 0
-		// 			? getSegmentHeight(segmentData.groups[i].count)
-		// 			: Math.round((segment.segmentHeight / timelineHeight) * scrollbarHeight);
-		// 	segmentLayout.timeGroup = segment.segmentDate;
-		// 	result.push(segmentLayout);
-		// }
-		// segmentScrollbarLayout = result;
+		let result: SegmentScrollbarLayout[] = [];
+		for (const bucket of $assetGridState.buckets) {
+			let segmentLayout = new SegmentScrollbarLayout();
+			segmentLayout.count = bucket.assets.length;
+			segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight;
+			segmentLayout.timeGroup = bucket.bucketDate;
+			result.push(segmentLayout);
+		}
+		segmentScrollbarLayout = result;
 	}
 
-	onMount(() => {
-		// segmentScrollbarLayout = getLayoutDistance();
-	});
-
-	// const getSegmentHeight = (groupCount: number) => {
-	// if (segmentData.groups.length > 0) {
-	// 	const percentage = (groupCount * 100) / segmentData.totalAssets;
-	// 	return Math.round((percentage * scrollbarHeight) / 100);
-	// } else {
-	// 	return 0;
-	// }
-	// };
-
-	// const getLayoutDistance = () => {
-	// let result: SegmentScrollbarLayout[] = [];
-	// for (const segment of segmentData.groups) {
-	// 	let segmentLayout = new SegmentScrollbarLayout();
-	// 	segmentLayout.count = segment.count;
-	// 	segmentLayout.height = getSegmentHeight(segment.count);
-	// 	segmentLayout.timeGroup = segment.timeGroup;
-	// 	result.push(segmentLayout);
-	// }
-	// return result;
-	// };
-
 	const handleMouseMove = (e: MouseEvent, currentDate: Date) => {
-		currentMouseYLocation = e.clientY - 71 - 30;
+		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;
+			}
+		}
+	};
 </script>
 
 <div
-	id="immich-scubbable-scrollbar"
-	class="fixed right-0 w-[60px] h-full bg-immich-bg z-[9999] hover:cursor-row-resize"
+	id="immich-scrubbable-scrollbar"
+	class="fixed right-0 bg-immich-bg z-10 hover:cursor-row-resize select-none"
+	style:width={isDragging ? '100vw' : '60px'}
+	style:background-color={isDragging ? 'transparent' : 'transparent'}
 	on:mouseenter={() => (isHover = true)}
-	on:mouseleave={() => (isHover = false)}
+	on:mouseleave={() => {
+		isHover = false;
+		isDragging = false;
+	}}
+	on:mouseup={handleMouseUp}
+	on:mousemove={handleMouseDrag}
+	on:mousedown={handleMouseDown}
+	style:height={scrollbarHeight + 'px'}
 >
 	{#if isHover}
 		<div
@@ -81,29 +118,33 @@
 	{/if}
 
 	<!-- Scroll Position Indicator Line -->
-	<div
-		class="absolute right-0 w-10 h-[2px] bg-immich-primary"
-		style:top={scrollbarPosition + 'px'}
-	/>
-
+	{#if !isDragging}
+		<div
+			class="absolute right-0 w-10 h-[2px] bg-immich-primary"
+			style:top={scrollbarPosition + 'px'}
+		/>
+	{/if}
 	<!-- Time Segment -->
 	{#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
 		{@const groupDate = new Date(segment.timeGroup)}
 
 		<div
-			class="relative "
+			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()}
-				<div
-					aria-label={segment.timeGroup + ' ' + segment.count}
-					class="absolute right-0 pr-3 z-10 text-xs font-medium"
-				>
-					{groupDate.getFullYear()}
-				</div>
-			{:else if segment.count > 5}
+				{#if segment.height > 8}
+					<div
+						aria-label={segment.timeGroup + ' ' + segment.count}
+						class="absolute right-0 pr-5 z-10 text-xs font-medium"
+					>
+						{groupDate.getFullYear()}
+					</div>
+				{/if}
+			{:else if segment.height > 5}
 				<div
 					aria-label={segment.timeGroup + ' ' + segment.count}
 					class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block"
@@ -114,7 +155,8 @@
 </div>
 
 <style>
-	#immich-scubbable-scrollbar {
+	#immich-scrubbable-scrollbar,
+	#time-segment {
 		contain: layout;
 	}
 </style>

+ 8 - 2
web/src/lib/components/shared-components/side-bar/side-bar.svelte

@@ -18,6 +18,7 @@
 	let showSharingCount = false;
 	let showAlbumsCount = false;
 
+	// let domCount = 0;
 	onMount(async () => {
 		if ($page.routeId == 'albums') {
 			selectedAction = AppSideBarSelection.ALBUMS;
@@ -26,6 +27,10 @@
 		} else if ($page.routeId == 'sharing') {
 			selectedAction = AppSideBarSelection.SHARING;
 		}
+
+		// setInterval(() => {
+		// 	domCount = document.getElementsByTagName('*').length;
+		// }, 500);
 	});
 
 	const getAssetCount = async () => {
@@ -48,6 +53,7 @@
 </script>
 
 <section id="sidebar" class="flex flex-col gap-1 pt-8 pr-6">
+	<!-- {domCount} -->
 	<a
 		sveltekit:prefetch
 		sveltekit:noscroll
@@ -110,7 +116,7 @@
 						<LoadingSpinner />
 					{:then data}
 						<div>
-							<p>{data.shared + data.sharing} albums</p>
+							<p>{data.shared + data.sharing} Albums</p>
 						</div>
 					{/await}
 				</div>
@@ -145,7 +151,7 @@
 						<LoadingSpinner />
 					{:then data}
 						<div>
-							<p>{data.owned} albums</p>
+							<p>{data.owned} Albums</p>
 						</div>
 					{/await}
 				</div>

+ 10 - 0
web/src/lib/stores/album-asset-selection.store.ts

@@ -0,0 +1,10 @@
+import { writable } from 'svelte/store';
+
+function createAlbumAssetSelectionStore() {
+	const isAlbumAssetSelectionOpen = writable<boolean>(false);
+	return {
+		isAlbumAssetSelectionOpen
+	};
+}
+
+export const albumAssetSelectionStore = createAlbumAssetSelectionStore();

+ 18 - 3
web/src/lib/stores/assets.store.ts

@@ -34,7 +34,7 @@ function createAssetStore() {
 		assetGridState.set({
 			viewportHeight,
 			viewportWidth,
-			timelineHeight: calculateViewportHeightByNumberOfAsset(data.totalCount, viewportWidth),
+			timelineHeight: 0,
 			buckets: data.buckets.map((d) => ({
 				bucketDate: d.timeBucket,
 				bucketHeight: calculateViewportHeightByNumberOfAsset(d.count, viewportWidth),
@@ -43,6 +43,12 @@ function createAssetStore() {
 			})),
 			assets: []
 		});
+
+		// Update timeline height based on calculated bucket height
+		assetGridState.update((state) => {
+			state.timelineHeight = lodash.sumBy(state.buckets, (d) => d.bucketHeight);
+			return state;
+		});
 	};
 
 	const getAssetsByBucket = async (bucket: string) => {
@@ -108,10 +114,19 @@ function createAssetStore() {
 		});
 	};
 
-	const updateBucketHeight = (bucket: string, height: number) => {
+	const updateBucketHeight = (bucket: string, actualBucketHeight: number) => {
 		assetGridState.update((state) => {
 			const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
-			state.buckets[bucketIndex].bucketHeight = height;
+			// Update timeline height based on the new bucket height
+			const estimateBucketHeight = state.buckets[bucketIndex].bucketHeight;
+
+			if (actualBucketHeight >= estimateBucketHeight) {
+				state.timelineHeight += actualBucketHeight - estimateBucketHeight;
+			} else {
+				state.timelineHeight -= estimateBucketHeight - actualBucketHeight;
+			}
+
+			state.buckets[bucketIndex].bucketHeight = actualBucketHeight;
 			return state;
 		});
 	};

+ 3 - 2
web/src/lib/utils/viewport-utils.ts

@@ -4,9 +4,10 @@
  */
 
 export function calculateViewportHeightByNumberOfAsset(assetCount: number, viewportWidth: number) {
-	const thumbnailHeight = 235;
+	const thumbnailHeight = 237;
 
-	const unwrappedWidth = (3 / 2) * assetCount * thumbnailHeight * (7 / 10);
+	// const unwrappedWidth = (3 / 2) * assetCount * thumbnailHeight * (7 / 10);
+	const unwrappedWidth = assetCount * thumbnailHeight;
 	const rows = Math.ceil(unwrappedWidth / viewportWidth);
 	const height = rows * thumbnailHeight;
 	return height;