You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-06-23 04:38:12 +02:00
Feature - Implemented virtual scroll on web (#573)
This PR implemented a virtual scroll on the web, as seen in this article. [Building the Google Photos Web UI](https://medium.com/google-design/google-photos-45b714dfbed1)
This commit is contained in:
@ -15,32 +15,19 @@
|
||||
export let thumbnailSize: number | undefined = undefined;
|
||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||
export let selected: boolean = false;
|
||||
export let isExisted: boolean = false;
|
||||
|
||||
export let disabled: boolean = false;
|
||||
let imageData: string;
|
||||
// let videoData: string;
|
||||
|
||||
let mouseOver: boolean = false;
|
||||
$: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
|
||||
let mouseOverIcon: boolean = false;
|
||||
let videoPlayerNode: HTMLVideoElement;
|
||||
let isThumbnailVideoPlaying = false;
|
||||
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
||||
let videoProgress = '00:00';
|
||||
// let videoAbortController: AbortController;
|
||||
let videoUrl: string;
|
||||
|
||||
const loadImageData = async () => {
|
||||
const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
if (data instanceof Blob) {
|
||||
imageData = URL.createObjectURL(data);
|
||||
return imageData;
|
||||
}
|
||||
};
|
||||
|
||||
const loadVideoData = async () => {
|
||||
isThumbnailVideoPlaying = false;
|
||||
|
||||
@ -117,7 +104,7 @@
|
||||
$: getThumbnailBorderStyle = () => {
|
||||
if (selected) {
|
||||
return 'border-[20px] border-immich-primary/20';
|
||||
} else if (isExisted) {
|
||||
} else if (disabled) {
|
||||
return 'border-[20px] border-gray-300';
|
||||
} else {
|
||||
return '';
|
||||
@ -125,36 +112,38 @@
|
||||
};
|
||||
|
||||
$: getOverlaySelectorIconStyle = () => {
|
||||
if (selected || isExisted) {
|
||||
if (selected || disabled) {
|
||||
return '';
|
||||
} else {
|
||||
return 'bg-gradient-to-b from-gray-800/50';
|
||||
}
|
||||
};
|
||||
const thumbnailClickedHandler = () => {
|
||||
if (!isExisted) {
|
||||
if (!disabled) {
|
||||
dispatch('click', { asset });
|
||||
}
|
||||
};
|
||||
|
||||
const onIconClickedHandler = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch('select', { asset });
|
||||
if (!disabled) {
|
||||
dispatch('select', { asset });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<IntersectionObserver once={true} let:intersecting>
|
||||
<IntersectionObserver once={false} let:intersecting>
|
||||
<div
|
||||
style:width={`${thumbnailSize}px`}
|
||||
style:height={`${thumbnailSize}px`}
|
||||
class={`bg-gray-100 relative ${getSize()} ${
|
||||
isExisted ? 'cursor-not-allowed' : 'hover:cursor-pointer'
|
||||
disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
|
||||
}`}
|
||||
on:mouseenter={handleMouseOverThumbnail}
|
||||
on:mouseleave={handleMouseLeaveThumbnail}
|
||||
on:click={thumbnailClickedHandler}
|
||||
>
|
||||
{#if mouseOver || selected || isExisted}
|
||||
{#if mouseOver || selected || disabled}
|
||||
<div
|
||||
in:fade={{ duration: 200 }}
|
||||
class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
|
||||
@ -167,7 +156,7 @@
|
||||
>
|
||||
{#if selected}
|
||||
<CheckCircle size="24" color="#4250af" />
|
||||
{:else if isExisted}
|
||||
{:else if disabled}
|
||||
<CheckCircle size="24" color="#252525" />
|
||||
{:else}
|
||||
<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
|
||||
@ -212,12 +201,13 @@
|
||||
<!-- Thumbnail -->
|
||||
{#if intersecting}
|
||||
<img
|
||||
id={asset.id}
|
||||
style:width={`${thumbnailSize}px`}
|
||||
style:height={`${thumbnailSize}px`}
|
||||
in:fade={{ duration: 250 }}
|
||||
in:fade={{ duration: 150 }}
|
||||
src={`/api/asset/thumbnail/${asset.id}?format=${format}`}
|
||||
alt={asset.id}
|
||||
class={`object-cover ${getSize()} transition-all duration-100 z-0 ${getThumbnailBorderStyle()}`}
|
||||
class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
|
@ -0,0 +1,60 @@
|
||||
<script context="module" lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
|
||||
/**
|
||||
* Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}>
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
* @param {HTMLElement|string} target DOM Element or CSS Selector
|
||||
*/
|
||||
export function portal(el: any, target: any = 'body') {
|
||||
let targetEl;
|
||||
async function update(newTarget: any) {
|
||||
target = newTarget;
|
||||
if (typeof target === 'string') {
|
||||
targetEl = document.querySelector(target);
|
||||
if (targetEl === null) {
|
||||
await tick();
|
||||
targetEl = document.querySelector(target);
|
||||
}
|
||||
if (targetEl === null) {
|
||||
throw new Error(`No element found matching css selector: "${target}"`);
|
||||
}
|
||||
} else if (target instanceof HTMLElement) {
|
||||
targetEl = target;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`Unknown portal target type: ${
|
||||
target === null ? 'null' : typeof target
|
||||
}. Allowed types: string (CSS selector) or HTMLElement.`
|
||||
);
|
||||
}
|
||||
targetEl.appendChild(el);
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
update(target);
|
||||
return {
|
||||
update,
|
||||
destroy
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* DOM Element or CSS Selector
|
||||
* @type { HTMLElement|string}
|
||||
*/
|
||||
export let target = 'body';
|
||||
</script>
|
||||
|
||||
<div use:portal={target} hidden>
|
||||
<slot />
|
||||
</div>
|
@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
|
||||
|
||||
export let scrollTop = 0;
|
||||
export let viewportWidth = 0;
|
||||
export let scrollbarHeight = 0;
|
||||
|
||||
let timelineHeight = 0;
|
||||
let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
|
||||
let isHover = false;
|
||||
let hoveredDate: Date;
|
||||
let currentMouseYLocation: number = 0;
|
||||
let scrollbarPosition = 0;
|
||||
|
||||
$: {
|
||||
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;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// segmentScrollbarLayout = getLayoutDistance();
|
||||
|
||||
return () => {};
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
hoveredDate = new Date(currentDate.toISOString().slice(0, -1));
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="immich-scubbable-scrollbar"
|
||||
class="fixed right-0 w-[60px] h-full bg-immich-bg z-[9999] hover:cursor-row-resize"
|
||||
on:mouseenter={() => (isHover = true)}
|
||||
on:mouseleave={() => (isHover = false)}
|
||||
>
|
||||
{#if isHover}
|
||||
<div
|
||||
class="border-b-2 border-immich-primary w-[100px] right-0 pr-6 py-1 text-sm pl-1 font-medium absolute bg-white z-50 pointer-events-none rounded-tl-md shadow-lg"
|
||||
style:top={currentMouseYLocation + 'px'}
|
||||
>
|
||||
{hoveredDate?.toLocaleString('default', { month: 'short' })}
|
||||
{hoveredDate?.getFullYear()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scroll Position Indicator Line -->
|
||||
<div
|
||||
class="absolute right-0 w-10 h-[2px] bg-immich-primary"
|
||||
style:top={scrollbarPosition + 'px'}
|
||||
/>
|
||||
|
||||
<!-- Time Segment -->
|
||||
{#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
|
||||
{@const groupDate = new Date(segment.timeGroup)}
|
||||
|
||||
<div
|
||||
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}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#immich-scubbable-scrollbar {
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,5 @@
|
||||
export class SegmentScrollbarLayout {
|
||||
height!: number;
|
||||
timeGroup!: string;
|
||||
count!: number;
|
||||
}
|
@ -5,7 +5,7 @@
|
||||
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
|
||||
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
|
||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||
import { getAssetsInfo } from '$lib/stores/assets';
|
||||
// import { getAssetsInfo } fro$lib/stores/assets.storeets';
|
||||
let showDetail = true;
|
||||
|
||||
let uploadLength = 0;
|
||||
@ -83,7 +83,9 @@
|
||||
<div
|
||||
in:fade={{ duration: 250 }}
|
||||
out:fade={{ duration: 250, delay: 1000 }}
|
||||
on:outroend={() => getAssetsInfo()}
|
||||
on:outroend={() => {
|
||||
// getAssetsInfo()
|
||||
}}
|
||||
class="absolute right-6 bottom-6 z-[10000]"
|
||||
>
|
||||
{#if showDetail}
|
||||
|
Reference in New Issue
Block a user