mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(web, server): Implement justified layout for AssetGrid (#2666)
* Implement justified layout for timeline * Add withoutThumbs field to GetTimelineLayotDto * Back to rough estimation of initial buckets height * Remove getTimelineLayout endpoint * Estimate rough viewport height better * Fix shift/jump issues while scrolling up --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		| @@ -104,6 +104,7 @@ export class AssetRepository implements IAssetRepository { | ||||
|     // Get asset entity from a list of time buckets | ||||
|     let builder = this.assetRepository | ||||
|       .createQueryBuilder('asset') | ||||
|       .leftJoinAndSelect('asset.exifInfo', 'exifInfo') | ||||
|       .where('asset.ownerId = :userId', { userId: userId }) | ||||
|       .andWhere(`date_trunc('month', "fileCreatedAt") IN (:...buckets)`, { | ||||
|         buckets: [...dto.timeBucket], | ||||
|   | ||||
							
								
								
									
										11
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										11
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -12,6 +12,7 @@ | ||||
| 				"axios": "^0.27.2", | ||||
| 				"copy-image-clipboard": "^2.1.2", | ||||
| 				"handlebars": "^4.7.7", | ||||
| 				"justified-layout": "^4.1.0", | ||||
| 				"leaflet": "^1.9.3", | ||||
| 				"leaflet.markercluster": "^1.5.3", | ||||
| 				"lodash-es": "^4.17.21", | ||||
| @@ -9076,6 +9077,11 @@ | ||||
| 				"node": ">=6" | ||||
| 			} | ||||
| 		}, | ||||
| 		"node_modules/justified-layout": { | ||||
| 			"version": "4.1.0", | ||||
| 			"resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz", | ||||
| 			"integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==" | ||||
| 		}, | ||||
| 		"node_modules/kind-of": { | ||||
| 			"version": "6.0.3", | ||||
| 			"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", | ||||
| @@ -18186,6 +18192,11 @@ | ||||
| 			"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", | ||||
| 			"dev": true | ||||
| 		}, | ||||
| 		"justified-layout": { | ||||
| 			"version": "4.1.0", | ||||
| 			"resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz", | ||||
| 			"integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==" | ||||
| 		}, | ||||
| 		"kind-of": { | ||||
| 			"version": "6.0.3", | ||||
| 			"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", | ||||
|   | ||||
| @@ -62,6 +62,7 @@ | ||||
| 		"axios": "^0.27.2", | ||||
| 		"copy-image-clipboard": "^2.1.2", | ||||
| 		"handlebars": "^4.7.7", | ||||
| 		"justified-layout": "^4.1.0", | ||||
| 		"leaflet": "^1.9.3", | ||||
| 		"leaflet.markercluster": "^1.5.3", | ||||
| 		"lodash-es": "^4.17.21", | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| <script lang="ts"> | ||||
| 	import { BucketPosition } from '$lib/models/asset-grid-state'; | ||||
| 	import { onMount } from 'svelte'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
|  | ||||
| @@ -28,7 +29,17 @@ | ||||
| 					} | ||||
|  | ||||
| 					if (intersecting) { | ||||
| 						dispatch('intersected', container); | ||||
| 						let position: BucketPosition = BucketPosition.Visible; | ||||
| 						if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) { | ||||
| 							position = BucketPosition.Below; | ||||
| 						} else if (entries[0].boundingClientRect.bottom < 0) { | ||||
| 							position = BucketPosition.Above; | ||||
| 						} | ||||
|  | ||||
| 						dispatch('intersected', { | ||||
| 							container, | ||||
| 							position | ||||
| 						}); | ||||
| 					} | ||||
| 				}, | ||||
| 				{ | ||||
|   | ||||
| @@ -9,17 +9,20 @@ | ||||
| 	import { assetStore } from '$lib/stores/assets.store'; | ||||
| 	import { locale } from '$lib/stores/preferences.store'; | ||||
| 	import type { AssetResponseDto } from '@api'; | ||||
| 	import justifiedLayout from 'justified-layout'; | ||||
| 	import lodash from 'lodash-es'; | ||||
| 	import CheckCircle from 'svelte-material-icons/CheckCircle.svelte'; | ||||
| 	import CircleOutline from 'svelte-material-icons/CircleOutline.svelte'; | ||||
| 	import { flip } from 'svelte/animate'; | ||||
| 	import { fly } from 'svelte/transition'; | ||||
| 	import { getAssetRatio } from '$lib/utils/asset-utils'; | ||||
| 	import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
|  | ||||
| 	export let assets: AssetResponseDto[]; | ||||
| 	export let bucketDate: string; | ||||
| 	export let bucketHeight: number; | ||||
| 	export let isAlbumSelectionMode = false; | ||||
| 	export let viewportWidth: number; | ||||
|  | ||||
| 	const groupDateFormat: Intl.DateTimeFormatOptions = { | ||||
| 		weekday: 'short', | ||||
| @@ -28,20 +31,65 @@ | ||||
| 		year: 'numeric' | ||||
| 	}; | ||||
|  | ||||
| 	const dispatch = createEventDispatcher(); | ||||
|  | ||||
| 	let isMouseOverGroup = false; | ||||
| 	let actualBucketHeight: number; | ||||
| 	let hoveredDateGroup = ''; | ||||
|  | ||||
| 	interface LayoutBox { | ||||
| 		top: number; | ||||
| 		left: number; | ||||
| 		width: number; | ||||
| 	} | ||||
|  | ||||
| 	$: assetsGroupByDate = lodash | ||||
| 		.chain(assets) | ||||
| 		.groupBy((a) => new Date(a.fileCreatedAt).toLocaleDateString($locale, groupDateFormat)) | ||||
| 		.sortBy((group) => assets.indexOf(group[0])) | ||||
| 		.value(); | ||||
|  | ||||
| 	$: geometry = (() => { | ||||
| 		const geometry = []; | ||||
| 		for (let group of assetsGroupByDate) { | ||||
| 			geometry.push( | ||||
| 				justifiedLayout(group.map(getAssetRatio), { | ||||
| 					boxSpacing: 2, | ||||
| 					containerWidth: Math.floor(viewportWidth), | ||||
| 					containerPadding: 0, | ||||
| 					targetRowHeightTolerance: 0.15, | ||||
| 					targetRowHeight: 235 | ||||
| 				}) | ||||
| 			); | ||||
| 		} | ||||
| 		return geometry; | ||||
| 	})(); | ||||
|  | ||||
| 	$: { | ||||
| 		if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) { | ||||
| 			assetStore.updateBucketHeight(bucketDate, actualBucketHeight); | ||||
| 			const heightDelta = assetStore.updateBucketHeight(bucketDate, actualBucketHeight); | ||||
| 			if (heightDelta !== 0) { | ||||
| 				scrollTimeline(heightDelta); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	function scrollTimeline(heightDelta: number) { | ||||
| 		dispatch('shift', { | ||||
| 			heightDelta | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	const calculateWidth = (boxes: LayoutBox[]): number => { | ||||
| 		let width = 0; | ||||
| 		for (const box of boxes) { | ||||
| 			if (box.top < 100) { | ||||
| 				width = box.left + box.width; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return width; | ||||
| 	}; | ||||
|  | ||||
| 	const assetClickHandler = ( | ||||
| 		asset: AssetResponseDto, | ||||
| @@ -112,8 +160,9 @@ | ||||
|  | ||||
| <section | ||||
| 	id="asset-group-by-date" | ||||
| 	class="flex flex-wrap gap-12 mt-5" | ||||
| 	class="flex flex-wrap gap-x-12" | ||||
| 	bind:clientHeight={actualBucketHeight} | ||||
| 	bind:clientWidth={viewportWidth} | ||||
| > | ||||
| 	{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)} | ||||
| 		{@const dateGroupTitle = new Date(assetsInDateGroup[0].fileCreatedAt).toLocaleDateString( | ||||
| @@ -123,8 +172,7 @@ | ||||
| 		<!-- Asset Group By Date --> | ||||
|  | ||||
| 		<div | ||||
| 			animate:flip={{ duration: 300 }} | ||||
| 			class="flex flex-col" | ||||
| 			class="flex flex-col mt-5" | ||||
| 			on:mouseenter={() => { | ||||
| 				isMouseOverGroup = true; | ||||
| 				assetMouseEventHandler(dateGroupTitle); | ||||
| @@ -156,9 +204,18 @@ | ||||
| 			</p> | ||||
|  | ||||
| 			<!-- Image grid --> | ||||
| 			<div class="flex flex-wrap gap-[2px]"> | ||||
| 				{#each assetsInDateGroup as asset (asset.id)} | ||||
| 					<div animate:flip={{ duration: 300 }}> | ||||
| 			<div | ||||
| 				class="relative" | ||||
| 				style="height: {geometry[groupIndex].containerHeight}px;width: {calculateWidth( | ||||
| 					geometry[groupIndex].boxes | ||||
| 				)}px" | ||||
| 			> | ||||
| 				{#each assetsInDateGroup as asset, index (asset.id)} | ||||
| 					{@const box = geometry[groupIndex].boxes[index]} | ||||
| 					<div | ||||
| 						class="absolute" | ||||
| 						style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px" | ||||
| 					> | ||||
| 						<Thumbnail | ||||
| 							{asset} | ||||
| 							{groupIndex} | ||||
| @@ -168,6 +225,8 @@ | ||||
| 							selected={$selectedAssets.has(asset) || | ||||
| 								$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1} | ||||
| 							disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1} | ||||
| 							thumbnailWidth={box.width} | ||||
| 							thumbnailHeight={box.height} | ||||
| 						/> | ||||
| 					</div> | ||||
| 				{/each} | ||||
|   | ||||
| @@ -16,6 +16,7 @@ | ||||
| 		OnScrollbarDragDetail | ||||
| 	} from '../shared-components/scrollbar/scrollbar.svelte'; | ||||
| 	import AssetDateGroup from './asset-date-group.svelte'; | ||||
| 	import { BucketPosition } from '$lib/models/asset-grid-state'; | ||||
|  | ||||
| 	export let user: UserResponseDto | undefined = undefined; | ||||
| 	export let isAlbumSelectionMode = false; | ||||
| @@ -33,6 +34,7 @@ | ||||
| 				withoutThumbs: true | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		bucketInfo = assetCountByTimebucket; | ||||
|  | ||||
| 		assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket, user?.id); | ||||
| @@ -51,7 +53,7 @@ | ||||
| 		}); | ||||
|  | ||||
| 		bucketsToFetchInitially.forEach((bucketDate) => { | ||||
| 			assetStore.getAssetsByBucket(bucketDate); | ||||
| 			assetStore.getAssetsByBucket(bucketDate, BucketPosition.Visible); | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| @@ -60,15 +62,18 @@ | ||||
| 	}); | ||||
|  | ||||
| 	function intersectedHandler(event: CustomEvent) { | ||||
| 		const el = event.detail as HTMLElement; | ||||
| 		const el = event.detail.container as HTMLElement; | ||||
| 		const target = el.firstChild as HTMLElement; | ||||
|  | ||||
| 		if (target) { | ||||
| 			const bucketDate = target.id.split('_')[1]; | ||||
| 			assetStore.getAssetsByBucket(bucketDate); | ||||
| 			assetStore.getAssetsByBucket(bucketDate, event.detail.position); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	function handleScrollTimeline(event: CustomEvent) { | ||||
| 		assetGridElement.scrollBy(0, event.detail.heightDelta); | ||||
| 	} | ||||
|  | ||||
| 	const navigateToPreviousAsset = () => { | ||||
| 		assetInteractionStore.navigateAsset('previous'); | ||||
| 	}; | ||||
| @@ -115,9 +120,10 @@ | ||||
| 	/> | ||||
| {/if} | ||||
|  | ||||
| <!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar --> | ||||
| <section | ||||
| 	id="asset-grid" | ||||
| 	class="overflow-y-auto pl-4 scrollbar-hidden" | ||||
| 	class="overflow-y-auto ml-4 mb-4 mr-[60px] scrollbar-hidden" | ||||
| 	bind:clientHeight={viewportHeight} | ||||
| 	bind:clientWidth={viewportWidth} | ||||
| 	bind:this={assetGridElement} | ||||
| @@ -143,9 +149,11 @@ | ||||
| 						{#if intersecting} | ||||
| 							<AssetDateGroup | ||||
| 								{isAlbumSelectionMode} | ||||
| 								on:shift={handleScrollTimeline} | ||||
| 								assets={bucket.assets} | ||||
| 								bucketDate={bucket.bucketDate} | ||||
| 								bucketHeight={bucket.bucketHeight} | ||||
| 								{viewportWidth} | ||||
| 							/> | ||||
| 						{/if} | ||||
| 					</div> | ||||
|   | ||||
| @@ -1,5 +1,12 @@ | ||||
| import type { AssetResponseDto } from '@api'; | ||||
|  | ||||
| export enum BucketPosition { | ||||
| 	Above = 'above', | ||||
| 	Below = 'below', | ||||
| 	Visible = 'visible', | ||||
| 	Unknown = 'unknown' | ||||
| } | ||||
|  | ||||
| export class AssetBucket { | ||||
| 	/** | ||||
| 	 * The DOM height of the bucket in pixel | ||||
| @@ -9,6 +16,7 @@ export class AssetBucket { | ||||
| 	bucketDate!: string; | ||||
| 	assets!: AssetResponseDto[]; | ||||
| 	cancelToken!: AbortController; | ||||
| 	position!: BucketPosition; | ||||
| } | ||||
|  | ||||
| export class AssetGridState { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { AssetGridState } from '$lib/models/asset-grid-state'; | ||||
| import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state'; | ||||
| import { api, AssetResponseDto } from '@api'; | ||||
| import { derived, writable } from 'svelte/store'; | ||||
| import { assetGridState, assetStore } from './assets.store'; | ||||
| @@ -92,7 +92,7 @@ function createAssetInteractionStore() { | ||||
| 			} | ||||
|  | ||||
| 			if (nextBucket !== '') { | ||||
| 				await assetStore.getAssetsByBucket(nextBucket); | ||||
| 				await assetStore.getAssetsByBucket(nextBucket, BucketPosition.Below); | ||||
| 				navigateAsset(direction); | ||||
| 			} | ||||
| 			return; | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import { AssetGridState } from '$lib/models/asset-grid-state'; | ||||
| import { calculateViewportHeightByNumberOfAsset } from '$lib/utils/viewport-utils'; | ||||
| import { api, AssetCountByTimeBucketResponseDto } from '@api'; | ||||
| import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state'; | ||||
| import { AssetCountByTimeBucketResponseDto, api } from '@api'; | ||||
| import { sumBy, flatMap } from 'lodash-es'; | ||||
| import { writable } from 'svelte/store'; | ||||
|  | ||||
| @@ -20,6 +19,18 @@ function createAssetStore() { | ||||
| 	loadingBucketState.subscribe((state) => { | ||||
| 		_loadingBucketState = state; | ||||
| 	}); | ||||
|  | ||||
| 	const estimateViewportHeight = (assetCount: number, viewportWidth: number): number => { | ||||
| 		// Ideally we would use the average aspect ratio for the photoset, however assume | ||||
| 		// a normal landscape aspect ratio of 3:2, then discount for the likelihood we | ||||
| 		// will be scaling down and coalescing. | ||||
| 		const thumbnailHeight = 235; | ||||
| 		const unwrappedWidth = (3 / 2) * assetCount * thumbnailHeight * (7 / 10); | ||||
| 		const rows = Math.ceil(unwrappedWidth / viewportWidth); | ||||
| 		const height = rows * thumbnailHeight; | ||||
| 		return height; | ||||
| 	}; | ||||
|  | ||||
| 	/** | ||||
| 	 * Set initial state | ||||
| 	 * @param viewportHeight | ||||
| @@ -36,11 +47,12 @@ function createAssetStore() { | ||||
| 			viewportHeight, | ||||
| 			viewportWidth, | ||||
| 			timelineHeight: 0, | ||||
| 			buckets: data.buckets.map((d) => ({ | ||||
| 				bucketDate: d.timeBucket, | ||||
| 				bucketHeight: calculateViewportHeightByNumberOfAsset(d.count, viewportWidth), | ||||
| 			buckets: data.buckets.map((bucket) => ({ | ||||
| 				bucketDate: bucket.timeBucket, | ||||
| 				bucketHeight: estimateViewportHeight(bucket.count, viewportWidth), | ||||
| 				assets: [], | ||||
| 				cancelToken: new AbortController() | ||||
| 				cancelToken: new AbortController(), | ||||
| 				position: BucketPosition.Unknown | ||||
| 			})), | ||||
| 			assets: [], | ||||
| 			userId | ||||
| @@ -53,10 +65,15 @@ function createAssetStore() { | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const getAssetsByBucket = async (bucket: string) => { | ||||
| 	const getAssetsByBucket = async (bucket: string, position: BucketPosition) => { | ||||
| 		try { | ||||
| 			const currentBucketData = _assetGridState.buckets.find((b) => b.bucketDate === bucket); | ||||
| 			if (currentBucketData?.assets && currentBucketData.assets.length > 0) { | ||||
| 				assetGridState.update((state) => { | ||||
| 					const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket); | ||||
| 					state.buckets[bucketIndex].position = position; | ||||
| 					return state; | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| @@ -83,8 +100,8 @@ function createAssetStore() { | ||||
| 			assetGridState.update((state) => { | ||||
| 				const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket); | ||||
| 				state.buckets[bucketIndex].assets = assets; | ||||
| 				state.buckets[bucketIndex].position = position; | ||||
| 				state.assets = flatMap(state.buckets, (b) => b.assets); | ||||
|  | ||||
| 				return state; | ||||
| 			}); | ||||
| 			// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
| @@ -120,21 +137,31 @@ function createAssetStore() { | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const updateBucketHeight = (bucket: string, actualBucketHeight: number) => { | ||||
| 	const updateBucketHeight = (bucket: string, actualBucketHeight: number): number => { | ||||
| 		let scrollTimeline = false; | ||||
| 		let heightDelta = 0; | ||||
|  | ||||
| 		assetGridState.update((state) => { | ||||
| 			const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket); | ||||
| 			// 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; | ||||
| 			} | ||||
| 			heightDelta = actualBucketHeight - estimateBucketHeight; | ||||
| 			state.timelineHeight += heightDelta; | ||||
|  | ||||
| 			scrollTimeline = state.buckets[bucketIndex].position == BucketPosition.Above; | ||||
|  | ||||
| 			state.buckets[bucketIndex].bucketHeight = actualBucketHeight; | ||||
| 			state.buckets[bucketIndex].position = BucketPosition.Unknown; | ||||
|  | ||||
| 			return state; | ||||
| 		}); | ||||
|  | ||||
| 		if (scrollTimeline) { | ||||
| 			return heightDelta; | ||||
| 		} | ||||
|  | ||||
| 		return 0; | ||||
| 	}; | ||||
|  | ||||
| 	const cancelBucketRequest = async (token: AbortController, bucketDate: string) => { | ||||
|   | ||||
| @@ -150,3 +150,18 @@ export function getFileMimeType(file: File): string { | ||||
| 			return ''; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Returns aspect ratio for the asset | ||||
|  */ | ||||
| export function getAssetRatio(asset: AssetResponseDto) { | ||||
| 	let height = asset.exifInfo?.exifImageHeight || 235; | ||||
| 	let width = asset.exifInfo?.exifImageWidth || 235; | ||||
| 	const orientation = Number(asset.exifInfo?.orientation); | ||||
| 	if (orientation) { | ||||
| 		if (orientation == 6 || orientation == -90) { | ||||
| 			[width, height] = [height, width]; | ||||
| 		} | ||||
| 	} | ||||
| 	return { width, height }; | ||||
| } | ||||
|   | ||||
| @@ -1,14 +0,0 @@ | ||||
| /** | ||||
|  * Glossary | ||||
|  * 1. Section: Group of assets in a month | ||||
|  */ | ||||
|  | ||||
| export function calculateViewportHeightByNumberOfAsset(assetCount: number, viewportWidth: number) { | ||||
| 	const thumbnailHeight = 237; | ||||
|  | ||||
| 	// const unwrappedWidth = (3 / 2) * assetCount * thumbnailHeight * (7 / 10); | ||||
| 	const unwrappedWidth = assetCount * thumbnailHeight; | ||||
| 	const rows = Math.ceil(unwrappedWidth / viewportWidth); | ||||
| 	const height = rows * thumbnailHeight; | ||||
| 	return height; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user