mirror of
https://github.com/immich-app/immich.git
synced 2024-11-21 18:16:55 +02:00
feat(web): Scroll to asset in gridview; increase gridview perf; reduce memory; scrollbar ticks in fixed position (#10646)
* Squashed * Change strategy - now pre-measure buckets offscreen, so don't need to worry about sub-bucket scroll preservation * Reduce jank on scroll, delay DOM updates until after scroll * css opt, log measure time * Trickle out queue while scrolling, flush when stopped * yay * Cleanup cleanup... * everybody... * everywhere... * Clean up cleanup! * Everybody do their share * CLEANUP! * package-lock ? * dynamic measure, todo * Fix web test * type lint * fix e2e * e2e test * Better scrollbar * Tuning, and more tunables * Tunable tweaks, more tunables * Scrollbar dots and viewport events * lint * Tweaked tunnables, use requestIdleCallback for garbage tasks, bug fixes * New tunables, and don't update url by default * Bug fixes * Bug fix, with debug * Fix flickr, fix graybox bug, reduced debug * Refactor/cleanup * Fix * naming * Final cleanup * review comment * Forgot to update this after naming change * scrubber works, with debug * cleanup * Rename scrollbar to scrubber * rename to * left over rename and change to previous album bar * bugfix addassets, comments * missing destroy(), cleanup --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
07538299cf
commit
837b1e4929
@ -44,7 +44,7 @@ test.describe('Shared Links', () => {
|
||||
test('download from a shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await page.locator('.group > div').first().hover();
|
||||
await page.locator('.group').first().hover();
|
||||
await page.waitForSelector('#asset-group-by-date svg');
|
||||
await page.getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
|
@ -1,4 +1,7 @@
|
||||
export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
textarea.style.height = height;
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
};
|
||||
|
152
web/src/lib/actions/intersection-observer.ts
Normal file
152
web/src/lib/actions/intersection-observer.ts
Normal file
@ -0,0 +1,152 @@
|
||||
type Config = IntersectionObserverActionProperties & {
|
||||
observer?: IntersectionObserver;
|
||||
};
|
||||
type TrackedProperties = {
|
||||
root?: Element | Document | null;
|
||||
threshold?: number | number[];
|
||||
top?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
};
|
||||
type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
|
||||
type OnSeperateCallback = (element: HTMLElement) => unknown;
|
||||
type IntersectionObserverActionProperties = {
|
||||
key?: string;
|
||||
onSeparate?: OnSeperateCallback;
|
||||
onIntersect?: OnIntersectCallback;
|
||||
|
||||
root?: Element | Document | null;
|
||||
threshold?: number | number[];
|
||||
top?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
};
|
||||
type TaskKey = HTMLElement | string;
|
||||
|
||||
function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
|
||||
return (
|
||||
a?.bottom === b?.bottom &&
|
||||
a?.top === b?.top &&
|
||||
a?.left === b?.left &&
|
||||
a?.right == b?.right &&
|
||||
a?.threshold === b?.threshold &&
|
||||
a?.root === b?.root
|
||||
);
|
||||
}
|
||||
|
||||
const elementToConfig = new Map<TaskKey, Config>();
|
||||
|
||||
const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
|
||||
if (!target.isConnected) {
|
||||
elementToConfig.get(key)?.observer?.unobserve(target);
|
||||
return;
|
||||
}
|
||||
const {
|
||||
root,
|
||||
threshold,
|
||||
top = '0px',
|
||||
right = '0px',
|
||||
bottom = '0px',
|
||||
left = '0px',
|
||||
onSeparate,
|
||||
onIntersect,
|
||||
} = properties;
|
||||
const rootMargin = `${top} ${right} ${bottom} ${left}`;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
// This IntersectionObserver is limited to observing a single element, the one the
|
||||
// action is attached to. If there are multiple entries, it means that this
|
||||
// observer is being notified of multiple events that have occured quickly together,
|
||||
// and the latest element is the one we are interested in.
|
||||
|
||||
entries.sort((a, b) => a.time - b.time);
|
||||
|
||||
const latestEntry = entries.pop();
|
||||
if (latestEntry?.isIntersecting) {
|
||||
onIntersect?.(latestEntry);
|
||||
} else {
|
||||
onSeparate?.(target);
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
threshold,
|
||||
root,
|
||||
},
|
||||
);
|
||||
observer.observe(target);
|
||||
elementToConfig.set(key, { ...properties, observer });
|
||||
};
|
||||
|
||||
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
|
||||
elementToConfig.set(key, properties);
|
||||
observe(key, element, properties);
|
||||
}
|
||||
|
||||
function _intersectionObserver(
|
||||
key: HTMLElement | string,
|
||||
element: HTMLElement,
|
||||
properties: IntersectionObserverActionProperties,
|
||||
) {
|
||||
if (properties.disabled) {
|
||||
properties.onIntersect?.(element);
|
||||
} else {
|
||||
configure(key, element, properties);
|
||||
}
|
||||
return {
|
||||
update(properties: IntersectionObserverActionProperties) {
|
||||
const config = elementToConfig.get(key);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (isEquivalent(config, properties)) {
|
||||
return;
|
||||
}
|
||||
configure(key, element, properties);
|
||||
},
|
||||
destroy: () => {
|
||||
if (properties.disabled) {
|
||||
properties.onSeparate?.(element);
|
||||
} else {
|
||||
const config = elementToConfig.get(key);
|
||||
const { observer, onSeparate } = config || {};
|
||||
observer?.unobserve(element);
|
||||
elementToConfig.delete(key);
|
||||
if (onSeparate) {
|
||||
onSeparate?.(element);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function intersectionObserver(
|
||||
element: HTMLElement,
|
||||
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
|
||||
) {
|
||||
// svelte doesn't allow multiple use:action directives of the same kind on the same element,
|
||||
// so accept an array when multiple configurations are needed.
|
||||
if (Array.isArray(properties)) {
|
||||
if (!properties.every((p) => p.key)) {
|
||||
throw new Error('Multiple configurations must specify key');
|
||||
}
|
||||
const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
|
||||
return {
|
||||
update: (properties: IntersectionObserverActionProperties[]) => {
|
||||
for (const [i, props] of properties.entries()) {
|
||||
observers[i].update(props);
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
for (const observer of observers) {
|
||||
observer.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return _intersectionObserver(element, element, properties);
|
||||
}
|
43
web/src/lib/actions/resize-observer.ts
Normal file
43
web/src/lib/actions/resize-observer.ts
Normal file
@ -0,0 +1,43 @@
|
||||
type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
|
||||
|
||||
let observer: ResizeObserver;
|
||||
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
|
||||
|
||||
/**
|
||||
* Installs a resizeObserver on the given element - when the element changes
|
||||
* size, invokes a callback function with the width/height. Intended as a
|
||||
* replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
|
||||
* an iframe to measure the size of the element, which can be bad for
|
||||
* performance and memory usage. In svelte5, they adapted bind:clientHeight and
|
||||
* bind:clientWidth to use an internal resize observer.
|
||||
*
|
||||
* TODO: When svelte5 is ready, go back to bind:clientWidth and
|
||||
* bind:clientHeight.
|
||||
*/
|
||||
export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
|
||||
if (!observer) {
|
||||
callbacks = new WeakMap();
|
||||
observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const onResize = callbacks.get(entry.target as HTMLElement);
|
||||
if (onResize) {
|
||||
onResize({
|
||||
target: entry.target as HTMLElement,
|
||||
width: entry.borderBoxSize[0].inlineSize,
|
||||
height: entry.borderBoxSize[0].blockSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
callbacks.set(element, onResize);
|
||||
observer.observe(element);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
callbacks.delete(element);
|
||||
observer.unobserve(element);
|
||||
},
|
||||
};
|
||||
}
|
14
web/src/lib/actions/thumbhash.ts
Normal file
14
web/src/lib/actions/thumbhash.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { decodeBase64 } from '$lib/utils';
|
||||
import { thumbHashToRGBA } from 'thumbhash';
|
||||
|
||||
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
|
||||
const pixels = ctx.createImageData(w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
pixels.data.set(rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import AlbumSummary from './album-summary.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export let sharedLink: SharedLinkResponseDto;
|
||||
export let user: UserResponseDto | undefined = undefined;
|
||||
@ -38,6 +39,9 @@
|
||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
assetStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@ -94,7 +98,7 @@
|
||||
</header>
|
||||
|
||||
<main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
|
||||
<AssetGrid {album} {assetStore} {assetInteractionStore}>
|
||||
<AssetGrid enableRouting={true} {album} {assetStore} {assetInteractionStore}>
|
||||
<section class="pt-8 md:pt-24">
|
||||
<!-- ALBUM TITLE -->
|
||||
<h1
|
||||
|
@ -15,7 +15,6 @@
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||
import {
|
||||
AssetJobName,
|
||||
@ -70,7 +69,8 @@
|
||||
} = slideshowStore;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
action: { type: AssetAction; asset: AssetResponseDto };
|
||||
close: { asset: AssetResponseDto };
|
||||
next: void;
|
||||
previous: void;
|
||||
}>();
|
||||
@ -201,7 +201,6 @@
|
||||
websocketEvents.on('on_asset_update', onAssetUpdate),
|
||||
);
|
||||
|
||||
await navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
@ -268,9 +267,8 @@
|
||||
$isShowDetail = !$isShowDetail;
|
||||
};
|
||||
|
||||
const closeViewer = async () => {
|
||||
dispatch('close');
|
||||
await navigate({ targetRoute: 'current', assetId: null });
|
||||
const closeViewer = () => {
|
||||
dispatch('close', { asset });
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
@ -378,9 +376,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => {
|
||||
const { isMouseOver } = e.detail;
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||
};
|
||||
|
||||
@ -392,8 +388,7 @@
|
||||
}
|
||||
|
||||
case AssetAction.UNSTACK: {
|
||||
await closeViewer();
|
||||
break;
|
||||
closeViewer();
|
||||
}
|
||||
}
|
||||
|
||||
@ -585,12 +580,11 @@
|
||||
? 'bg-transparent border-2 border-white'
|
||||
: 'bg-gray-700/40'} inline-block hover:bg-transparent"
|
||||
asset={stackedAsset}
|
||||
onClick={(stackedAsset, event) => {
|
||||
event.preventDefault();
|
||||
onClick={(stackedAsset) => {
|
||||
asset = stackedAsset;
|
||||
preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]];
|
||||
}}
|
||||
on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
|
||||
showStackedIcon={false}
|
||||
|
@ -212,7 +212,6 @@
|
||||
title={person.name}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={person.isHidden}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,82 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { BucketPosition } from '$lib/stores/assets.store';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
|
||||
export let once = false;
|
||||
export let top = 0;
|
||||
export let bottom = 0;
|
||||
export let left = 0;
|
||||
export let right = 0;
|
||||
export let root: HTMLElement | null = null;
|
||||
|
||||
export let intersecting = false;
|
||||
let container: HTMLDivElement;
|
||||
const dispatch = createEventDispatcher<{
|
||||
hidden: HTMLDivElement;
|
||||
intersected: {
|
||||
container: HTMLDivElement;
|
||||
position: BucketPosition;
|
||||
};
|
||||
}>();
|
||||
|
||||
onMount(() => {
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
intersecting = entries.some((entry) => entry.isIntersecting);
|
||||
if (!intersecting) {
|
||||
dispatch('hidden', container);
|
||||
}
|
||||
|
||||
if (intersecting && once) {
|
||||
observer.unobserve(container);
|
||||
}
|
||||
|
||||
if (intersecting) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
root,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.unobserve(container);
|
||||
}
|
||||
|
||||
// The following is a fallback for older browsers
|
||||
function handler() {
|
||||
const bcr = container.getBoundingClientRect();
|
||||
|
||||
intersecting =
|
||||
bcr.bottom + bottom > 0 &&
|
||||
bcr.right + right > 0 &&
|
||||
bcr.top - top < window.innerHeight &&
|
||||
bcr.left - left < window.innerWidth;
|
||||
|
||||
if (intersecting && once) {
|
||||
window.removeEventListener('scroll', handler);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handler);
|
||||
return () => window.removeEventListener('scroll', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container}>
|
||||
<slot {intersecting} />
|
||||
</div>
|
@ -12,7 +12,7 @@
|
||||
import { AssetTypeEnum, type AssetResponseDto, AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
|
||||
import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
@ -33,6 +33,7 @@
|
||||
let imageLoaded: boolean = false;
|
||||
let imageError: boolean = false;
|
||||
let forceUseOriginal: boolean = false;
|
||||
let loader: HTMLImageElement;
|
||||
|
||||
$: isWebCompatible = isWebCompatibleImage(asset);
|
||||
$: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile;
|
||||
@ -108,6 +109,25 @@
|
||||
event.preventDefault();
|
||||
handlePromiseError(copyImage());
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
assetFileUrl = imageLoaderUrl;
|
||||
};
|
||||
const onerror = () => {
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
if (loader.complete) {
|
||||
onload();
|
||||
}
|
||||
loader.addEventListener('load', onload);
|
||||
loader.addEventListener('error', onerror);
|
||||
return () => {
|
||||
loader?.removeEventListener('load', onload);
|
||||
loader?.removeEventListener('error', onerror);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@ -119,6 +139,8 @@
|
||||
{#if imageError}
|
||||
<div class="h-full flex items-center justify-center">{$t('error_loading_image')}</div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||
<div bind:this={element} class="relative h-full select-none">
|
||||
<img
|
||||
style="display:none"
|
||||
@ -128,7 +150,7 @@
|
||||
on:error={() => (imageError = imageLoaded = true)}
|
||||
/>
|
||||
{#if !imageLoaded}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
@ -159,3 +181,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
|
@ -3,8 +3,8 @@ import { render } from '@testing-library/svelte';
|
||||
|
||||
describe('ImageThumbnail component', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'decode', {
|
||||
value: vi.fn(),
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,13 +12,11 @@ describe('ImageThumbnail component', () => {
|
||||
const sut = render(ImageThumbnail, {
|
||||
url: 'http://localhost/img.png',
|
||||
altText: 'test',
|
||||
thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI',
|
||||
base64ThumbHash: '1QcSHQRnh493V4dIh4eXh1h4kJUI',
|
||||
widthStyle: '250px',
|
||||
});
|
||||
|
||||
const [_, thumbhash] = sut.getAllByRole('img');
|
||||
expect(thumbhash.getAttribute('src')).toContain(
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAgCAYAAAD5VeO1AAAMRklEQVR4AQBdAKL/', // truncated
|
||||
);
|
||||
const thumbhash = sut.getByTestId('thumbhash');
|
||||
expect(thumbhash).not.toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
@ -1,17 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { decodeBase64 } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { fade } from 'svelte/transition';
|
||||
import { thumbHashToDataURL } from 'thumbhash';
|
||||
import { mdiEyeOffOutline } from '@mdi/js';
|
||||
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { mdiEyeOffOutline, mdiImageBrokenVariant } from '@mdi/js';
|
||||
|
||||
export let url: string;
|
||||
export let altText: string | undefined;
|
||||
export let title: string | null = null;
|
||||
export let heightStyle: string | undefined = undefined;
|
||||
export let widthStyle: string;
|
||||
export let thumbhash: string | null = null;
|
||||
export let base64ThumbHash: string | null = null;
|
||||
export let curve = false;
|
||||
export let shadow = false;
|
||||
export let circle = false;
|
||||
@ -19,37 +21,58 @@
|
||||
export let border = false;
|
||||
export let preload = true;
|
||||
export let hiddenIconClass = 'text-white';
|
||||
export let onComplete: (() => void) | undefined = undefined;
|
||||
|
||||
let {
|
||||
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
|
||||
} = TUNABLES;
|
||||
|
||||
let loaded = false;
|
||||
let errored = false;
|
||||
|
||||
let complete = false;
|
||||
let img: HTMLImageElement;
|
||||
|
||||
onMount(async () => {
|
||||
await img.decode();
|
||||
await tick();
|
||||
complete = true;
|
||||
const setLoaded = () => {
|
||||
loaded = true;
|
||||
onComplete?.();
|
||||
};
|
||||
const setErrored = () => {
|
||||
errored = true;
|
||||
onComplete?.();
|
||||
};
|
||||
onMount(() => {
|
||||
if (img.complete) {
|
||||
setLoaded();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<img
|
||||
bind:this={img}
|
||||
loading={preload ? 'eager' : 'lazy'}
|
||||
style:width={widthStyle}
|
||||
style:height={heightStyle}
|
||||
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
||||
style:opacity={hidden ? '0.5' : '1'}
|
||||
src={url}
|
||||
alt={altText}
|
||||
{title}
|
||||
class="object-cover transition duration-300 {border
|
||||
? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary'
|
||||
: ''}"
|
||||
class:rounded-xl={curve}
|
||||
class:shadow-lg={shadow}
|
||||
class:rounded-full={circle}
|
||||
class:aspect-square={circle || !heightStyle}
|
||||
class:opacity-0={!thumbhash && !complete}
|
||||
draggable="false"
|
||||
/>
|
||||
{#if errored}
|
||||
<div class="absolute flex h-full w-full items-center justify-center p-4 z-10">
|
||||
<Icon path={mdiImageBrokenVariant} size="48" />
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
bind:this={img}
|
||||
on:load={setLoaded}
|
||||
on:error={setErrored}
|
||||
loading={preload ? 'eager' : 'lazy'}
|
||||
style:width={widthStyle}
|
||||
style:height={heightStyle}
|
||||
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
||||
style:opacity={hidden ? '0.5' : '1'}
|
||||
src={url}
|
||||
alt={loaded || errored ? altText : ''}
|
||||
{title}
|
||||
class="object-cover {border ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary' : ''}"
|
||||
class:rounded-xl={curve}
|
||||
class:shadow-lg={shadow}
|
||||
class:rounded-full={circle}
|
||||
class:aspect-square={circle || !heightStyle}
|
||||
class:opacity-0={!thumbhash && !loaded}
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||
@ -57,18 +80,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if thumbhash && !complete}
|
||||
<img
|
||||
{#if base64ThumbHash && (!loaded || errored)}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash }}
|
||||
data-testid="thumbhash"
|
||||
style:width={widthStyle}
|
||||
style:height={heightStyle}
|
||||
src={thumbHashToDataURL(decodeBase64(thumbhash))}
|
||||
alt={altText}
|
||||
{title}
|
||||
class="absolute top-0 object-cover"
|
||||
class:rounded-xl={curve}
|
||||
class:shadow-lg={shadow}
|
||||
class:rounded-full={circle}
|
||||
draggable="false"
|
||||
out:fade={{ duration: 300 }}
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
/>
|
||||
{/if}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
|
||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
|
||||
@ -18,18 +18,23 @@
|
||||
mdiMotionPlayOutline,
|
||||
mdiRotate360,
|
||||
} from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImageThumbnail from './image-thumbnail.svelte';
|
||||
import VideoThumbnail from './video-thumbnail.svelte';
|
||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||
import { AssetStore } from '$lib/stores/assets.store';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: { asset: AssetResponseDto };
|
||||
'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number };
|
||||
}>();
|
||||
import type { DateGroup } from '$lib/utils/timeline-util';
|
||||
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let dateGroup: DateGroup | undefined = undefined;
|
||||
export let assetStore: AssetStore | undefined = undefined;
|
||||
export let groupIndex = 0;
|
||||
export let thumbnailSize: number | undefined = undefined;
|
||||
export let thumbnailWidth: number | undefined = undefined;
|
||||
@ -40,72 +45,181 @@
|
||||
export let readonly = false;
|
||||
export let showArchiveIcon = false;
|
||||
export let showStackedIcon = true;
|
||||
export let onClick: ((asset: AssetResponseDto, event: Event) => void) | undefined = undefined;
|
||||
export let intersectionConfig: {
|
||||
root?: HTMLElement;
|
||||
bottom?: string;
|
||||
top?: string;
|
||||
left?: string;
|
||||
priority?: number;
|
||||
disabled?: boolean;
|
||||
} = {};
|
||||
|
||||
export let retrieveElement: boolean = false;
|
||||
export let onIntersected: (() => void) | undefined = undefined;
|
||||
export let onClick: ((asset: AssetResponseDto) => void) | undefined = undefined;
|
||||
export let onRetrieveElement: ((elment: HTMLElement) => void) | undefined = undefined;
|
||||
export let onSelect: ((asset: AssetResponseDto) => void) | undefined = undefined;
|
||||
export let onMouseEvent: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined =
|
||||
undefined;
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
let {
|
||||
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
|
||||
} = TUNABLES;
|
||||
|
||||
const componentId = generateId();
|
||||
let element: HTMLElement | undefined;
|
||||
let mouseOver = false;
|
||||
let intersecting = false;
|
||||
let lastRetrievedElement: HTMLElement | undefined;
|
||||
let loaded = false;
|
||||
|
||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
$: if (!retrieveElement) {
|
||||
lastRetrievedElement = undefined;
|
||||
}
|
||||
$: if (retrieveElement && element && lastRetrievedElement !== element) {
|
||||
lastRetrievedElement = element;
|
||||
onRetrieveElement?.(element);
|
||||
}
|
||||
|
||||
$: [width, height] = ((): [number, number] => {
|
||||
if (thumbnailSize) {
|
||||
return [thumbnailSize, thumbnailSize];
|
||||
}
|
||||
$: width = thumbnailSize || thumbnailWidth || 235;
|
||||
$: height = thumbnailSize || thumbnailHeight || 235;
|
||||
$: display = intersecting;
|
||||
|
||||
if (thumbnailWidth && thumbnailHeight) {
|
||||
return [thumbnailWidth, thumbnailHeight];
|
||||
}
|
||||
|
||||
return [235, 235];
|
||||
})();
|
||||
|
||||
const onIconClickedHandler = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const onIconClickedHandler = (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
if (!disabled) {
|
||||
dispatch('select', { asset });
|
||||
onSelect?.(asset);
|
||||
}
|
||||
};
|
||||
|
||||
const callClickHandlers = () => {
|
||||
if (selected) {
|
||||
onIconClickedHandler();
|
||||
return;
|
||||
}
|
||||
onClick?.(asset);
|
||||
};
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
callClickHandlers();
|
||||
};
|
||||
|
||||
if (selected) {
|
||||
onIconClickedHandler(e);
|
||||
return;
|
||||
}
|
||||
|
||||
onClick?.(asset, e);
|
||||
const _onMouseEnter = () => {
|
||||
mouseOver = true;
|
||||
onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
|
||||
};
|
||||
|
||||
const onMouseEnter = () => {
|
||||
mouseOver = true;
|
||||
if (dateGroup && assetStore) {
|
||||
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() });
|
||||
} else {
|
||||
_onMouseEnter();
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
mouseOver = false;
|
||||
if (dateGroup && assetStore) {
|
||||
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) });
|
||||
} else {
|
||||
mouseOver = false;
|
||||
}
|
||||
};
|
||||
|
||||
const _onIntersect = () => {
|
||||
intersecting = true;
|
||||
onIntersected?.();
|
||||
};
|
||||
|
||||
const onIntersect = () => {
|
||||
if (intersecting === true) {
|
||||
return;
|
||||
}
|
||||
if (dateGroup && assetStore) {
|
||||
assetStore.taskManager.intersectedThumbnail(componentId, dateGroup, asset, () => void _onIntersect());
|
||||
} else {
|
||||
void _onIntersect();
|
||||
}
|
||||
};
|
||||
|
||||
const onSeparate = () => {
|
||||
if (intersecting === false) {
|
||||
return;
|
||||
}
|
||||
if (dateGroup && assetStore) {
|
||||
assetStore.taskManager.seperatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
|
||||
} else {
|
||||
intersecting = false;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore?.taskManager.removeAllTasksForComponent(componentId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<IntersectionObserver once={false} on:intersected let:intersecting>
|
||||
<a
|
||||
href={currentUrlReplaceAssetId(asset.id)}
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class="group focus-visible:outline-none flex overflow-hidden {disabled
|
||||
? 'bg-gray-300'
|
||||
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
|
||||
class:cursor-not-allowed={disabled}
|
||||
on:mouseenter={onMouseEnter}
|
||||
on:mouseleave={onMouseLeave}
|
||||
tabindex={0}
|
||||
on:click={handleClick}
|
||||
>
|
||||
{#if intersecting}
|
||||
<div
|
||||
bind:this={element}
|
||||
use:intersectionObserver={{
|
||||
...intersectionConfig,
|
||||
onIntersect,
|
||||
onSeparate,
|
||||
}}
|
||||
data-asset={asset.id}
|
||||
data-int={intersecting}
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class="group focus-visible:outline-none flex overflow-hidden {disabled
|
||||
? 'bg-gray-300'
|
||||
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
|
||||
>
|
||||
{#if !loaded && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
class="absolute object-cover z-10"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
{#if display}
|
||||
<!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
|
||||
the navigation url on scroll. Replace this with button for now. -->
|
||||
<div
|
||||
class:cursor-not-allowed={disabled}
|
||||
class:cursor-pointer={!disabled}
|
||||
on:mouseenter={onMouseEnter}
|
||||
on:mouseleave={onMouseLeave}
|
||||
on:keypress={(evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
callClickHandlers();
|
||||
}
|
||||
}}
|
||||
tabindex={0}
|
||||
on:click={handleClick}
|
||||
role="link"
|
||||
>
|
||||
{#if mouseOver}
|
||||
<!-- lazy show the url on mouse over-->
|
||||
<a
|
||||
class="absolute z-30 {className} top-[41px]"
|
||||
style:cursor="unset"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
href={currentUrlReplaceAssetId(asset.id)}
|
||||
on:click={(evt) => evt.preventDefault()}
|
||||
tabindex={0}
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
<div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px">
|
||||
<!-- Select asset button -->
|
||||
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
||||
@ -189,11 +303,11 @@
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
thumbhash={asset.thumbhash}
|
||||
curve={selected}
|
||||
onComplete={() => (loaded = true)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center p-4">
|
||||
<div class="absolute flex h-full w-full items-center justify-center p-4 z-10">
|
||||
<Icon path={mdiImageBrokenVariant} size="48" />
|
||||
</div>
|
||||
{/if}
|
||||
@ -201,6 +315,7 @@
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<div class="absolute top-0 h-full w-full">
|
||||
<VideoThumbnail
|
||||
{assetStore}
|
||||
url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
@ -213,6 +328,7 @@
|
||||
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
||||
<div class="absolute top-0 h-full w-full">
|
||||
<VideoThumbnail
|
||||
{assetStore}
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
@ -230,6 +346,6 @@
|
||||
out:fade={{ duration: 100 }}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</a>
|
||||
</IntersectionObserver>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -3,7 +3,11 @@
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { AssetStore } from '$lib/stores/assets.store';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export let assetStore: AssetStore | undefined = undefined;
|
||||
export let url: string;
|
||||
export let durationInSeconds = 0;
|
||||
export let enablePlayback = false;
|
||||
@ -13,6 +17,7 @@
|
||||
export let playIcon = mdiPlayCircleOutline;
|
||||
export let pauseIcon = mdiPauseCircleOutline;
|
||||
|
||||
const componentId = generateId();
|
||||
let remainingSeconds = durationInSeconds;
|
||||
let loading = true;
|
||||
let error = false;
|
||||
@ -27,6 +32,43 @@
|
||||
player.src = '';
|
||||
}
|
||||
}
|
||||
const onMouseEnter = () => {
|
||||
if (assetStore) {
|
||||
assetStore.taskManager.queueScrollSensitiveTask({
|
||||
componentId,
|
||||
task: () => {
|
||||
if (playbackOnIconHover) {
|
||||
enablePlayback = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (playbackOnIconHover) {
|
||||
enablePlayback = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
if (assetStore) {
|
||||
assetStore.taskManager.queueScrollSensitiveTask({
|
||||
componentId,
|
||||
task: () => {
|
||||
if (playbackOnIconHover) {
|
||||
enablePlayback = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (playbackOnIconHover) {
|
||||
enablePlayback = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore?.taskManager.removeAllTasksForComponent(componentId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
@ -37,19 +79,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<span
|
||||
class="pr-2 pt-2"
|
||||
on:mouseenter={() => {
|
||||
if (playbackOnIconHover) {
|
||||
enablePlayback = true;
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
if (playbackOnIconHover) {
|
||||
enablePlayback = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="pr-2 pt-2" on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
|
||||
{#if enablePlayback}
|
||||
{#if loading}
|
||||
<LoadingSpinner />
|
||||
|
@ -113,7 +113,6 @@
|
||||
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={person.isHidden}
|
||||
/>
|
||||
</div>
|
||||
|
@ -265,8 +265,6 @@
|
||||
title={$t('face_unassigned')}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={false}
|
||||
/>
|
||||
{:then data}
|
||||
<ImageThumbnail
|
||||
@ -277,8 +275,6 @@
|
||||
title={$t('face_unassigned')}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={false}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
|
||||
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
||||
@ -38,6 +38,8 @@
|
||||
import { tweened } from 'svelte/motion';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
|
||||
const parseIndex = (s: string | null, max: number | null) =>
|
||||
@ -383,21 +385,18 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<IntersectionObserver
|
||||
once={false}
|
||||
on:intersected={() => (galleryInView = true)}
|
||||
on:hidden={() => (galleryInView = false)}
|
||||
bottom={-200}
|
||||
<div
|
||||
id="gallery-memory"
|
||||
use:intersectionObserver={{
|
||||
onIntersect: () => (galleryInView = true),
|
||||
onSeparate: () => (galleryInView = false),
|
||||
bottom: '-200px',
|
||||
}}
|
||||
use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))}
|
||||
bind:this={memoryGallery}
|
||||
>
|
||||
<div
|
||||
id="gallery-memory"
|
||||
bind:this={memoryGallery}
|
||||
bind:clientHeight={viewport.height}
|
||||
bind:clientWidth={viewport.width}
|
||||
>
|
||||
<GalleryViewer assets={currentMemory.assets} {viewport} bind:selectedAssets />
|
||||
</div>
|
||||
</IntersectionObserver>
|
||||
<GalleryViewer assets={currentMemory.assets} {viewport} bind:selectedAssets />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
@ -1,84 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import type { AssetStore, Viewport } from '$lib/stores/assets.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
import {
|
||||
calculateWidth,
|
||||
formatGroupTitle,
|
||||
fromLocalDateTime,
|
||||
splitBucketIntoDateGroups,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import justifiedLayout from 'justified-layout';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
|
||||
export let assets: AssetResponseDto[];
|
||||
export let bucketDate: string;
|
||||
export let bucketHeight: number;
|
||||
export let element: HTMLElement | undefined = undefined;
|
||||
export let isSelectionMode = false;
|
||||
export let viewport: Viewport;
|
||||
export let singleSelect = false;
|
||||
export let withStacked = false;
|
||||
export let showArchiveIcon = false;
|
||||
|
||||
export let assetGridElement: HTMLElement | undefined = undefined;
|
||||
export let renderThumbsAtBottomMargin: string | undefined = undefined;
|
||||
export let renderThumbsAtTopMargin: string | undefined = undefined;
|
||||
export let assetStore: AssetStore;
|
||||
export let bucket: AssetBucket;
|
||||
export let assetInteractionStore: AssetInteractionStore;
|
||||
|
||||
export let onScrollTarget: ScrollTargetListener | undefined = undefined;
|
||||
export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
|
||||
|
||||
const componentId = generateId();
|
||||
$: bucketDate = bucket.bucketDate;
|
||||
$: dateGroups = bucket.dateGroups;
|
||||
|
||||
const {
|
||||
DATEGROUP: { INTERSECTION_DISABLED, INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
|
||||
} = TUNABLES;
|
||||
/* TODO figure out a way to calculate this*/
|
||||
const TITLE_HEIGHT = 51;
|
||||
|
||||
const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore;
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: { title: string; assets: AssetResponseDto[] };
|
||||
selectAssets: AssetResponseDto;
|
||||
selectAssetCandidates: AssetResponseDto | null;
|
||||
shift: { heightDelta: number };
|
||||
}>();
|
||||
|
||||
let isMouseOverGroup = false;
|
||||
let actualBucketHeight: number;
|
||||
let hoveredDateGroup = '';
|
||||
|
||||
$: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
|
||||
|
||||
$: geometry = (() => {
|
||||
const geometry = [];
|
||||
for (let group of assetsGroupByDate) {
|
||||
const justifiedLayoutResult = justifiedLayout(
|
||||
group.map((assetGroup) => getAssetRatio(assetGroup)),
|
||||
{
|
||||
boxSpacing: 2,
|
||||
containerWidth: Math.floor(viewport.width),
|
||||
containerPadding: 0,
|
||||
targetRowHeightTolerance: 0.15,
|
||||
targetRowHeight: 235,
|
||||
},
|
||||
);
|
||||
geometry.push({
|
||||
...justifiedLayoutResult,
|
||||
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
|
||||
});
|
||||
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
|
||||
if (isSelectionMode || $isMultiSelectState) {
|
||||
assetSelectHandler(asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
return geometry;
|
||||
})();
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
$: {
|
||||
if (actualBucketHeight && actualBucketHeight !== 0 && actualBucketHeight != bucketHeight) {
|
||||
const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight);
|
||||
if (heightDelta !== 0) {
|
||||
scrollTimeline(heightDelta);
|
||||
}
|
||||
const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
|
||||
if (assetGridElement && onScrollTarget) {
|
||||
const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
|
||||
onScrollTarget({ bucket, dateGroup, asset, offset });
|
||||
}
|
||||
}
|
||||
|
||||
function scrollTimeline(heightDelta: number) {
|
||||
dispatch('shift', {
|
||||
heightDelta,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
|
||||
|
||||
@ -104,93 +89,149 @@
|
||||
dispatch('selectAssetCandidates', asset);
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
$assetStore.taskManager.removeAllTasksForComponent(componentId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}>
|
||||
{#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)}
|
||||
{@const asset = groupAssets[0]}
|
||||
{@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))}
|
||||
<!-- Asset Group By Date -->
|
||||
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
|
||||
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
|
||||
{@const display =
|
||||
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)}
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex flex-col"
|
||||
on:mouseenter={() => {
|
||||
isMouseOverGroup = true;
|
||||
assetMouseEventHandler(groupTitle, null);
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
isMouseOverGroup = false;
|
||||
assetMouseEventHandler(groupTitle, null);
|
||||
id="date-group"
|
||||
use:intersectionObserver={{
|
||||
onIntersect: () => {
|
||||
$assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
|
||||
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
|
||||
);
|
||||
},
|
||||
onSeparate: () => {
|
||||
$assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () =>
|
||||
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
|
||||
);
|
||||
},
|
||||
top: INTERSECTION_ROOT_TOP,
|
||||
bottom: INTERSECTION_ROOT_BOTTOM,
|
||||
root: assetGridElement,
|
||||
disabled: INTERSECTION_DISABLED,
|
||||
}}
|
||||
data-display={display}
|
||||
data-date-group={dateGroup.date}
|
||||
style:height={dateGroup.height + 'px'}
|
||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||
style:overflow={'clip'}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<div
|
||||
class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
||||
style="width: {geometry[groupIndex].containerWidth}px"
|
||||
>
|
||||
{#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))}
|
||||
{#if !display}
|
||||
<Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
|
||||
{/if}
|
||||
{#if display}
|
||||
<!-- Asset Group By Date -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
on:mouseenter={() =>
|
||||
$assetStore.taskManager.queueScrollSensitiveTask({
|
||||
componentId,
|
||||
task: () => {
|
||||
isMouseOverGroup = true;
|
||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||
},
|
||||
})}
|
||||
on:mouseleave={() => {
|
||||
$assetStore.taskManager.queueScrollSensitiveTask({
|
||||
componentId,
|
||||
task: () => {
|
||||
isMouseOverGroup = false;
|
||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<div
|
||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
on:click={() => handleSelectGroup(groupTitle, groupAssets)}
|
||||
on:keydown={() => handleSelectGroup(groupTitle, groupAssets)}
|
||||
class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||
>
|
||||
{#if $selectedGroup.has(groupTitle)}
|
||||
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
||||
{:else}
|
||||
<Icon path={mdiCircleOutline} size="24" color="#757575" />
|
||||
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))}
|
||||
<div
|
||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
|
||||
on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
|
||||
>
|
||||
{#if $selectedGroup.has(dateGroup.groupTitle)}
|
||||
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
||||
{:else}
|
||||
<Icon path={mdiCircleOutline} size="24" color="#757575" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="w-full truncate first-letter:capitalize" title={dateGroup.groupTitle}>
|
||||
{dateGroup.groupTitle}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="w-full truncate first-letter:capitalize" title={groupTitle}>
|
||||
{groupTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div
|
||||
class="relative"
|
||||
style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px"
|
||||
>
|
||||
{#each groupAssets as asset, index (asset.id)}
|
||||
{@const box = geometry[groupIndex].boxes[index]}
|
||||
<!-- Image grid -->
|
||||
<div
|
||||
class="absolute"
|
||||
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
|
||||
class="relative overflow-clip"
|
||||
style:height={dateGroup.geometry.containerHeight + 'px'}
|
||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||
>
|
||||
<Thumbnail
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset, event) => {
|
||||
if (isSelectionMode || $isMultiSelectState) {
|
||||
event.preventDefault();
|
||||
assetSelectHandler(asset, groupAssets, groupTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
assetViewingStore.setAsset(asset);
|
||||
}}
|
||||
on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)}
|
||||
on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)}
|
||||
selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
|
||||
selectionCandidate={$assetSelectionCandidates.has(asset)}
|
||||
disabled={$assetStore.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={box.width}
|
||||
thumbnailHeight={box.height}
|
||||
/>
|
||||
{#each dateGroup.assets as asset, index (asset.id)}
|
||||
{@const box = dateGroup.geometry.boxes[index]}
|
||||
<!-- update ASSET_GRID_PADDING-->
|
||||
<div
|
||||
use:intersectionObserver={{
|
||||
onIntersect: () => onAssetInGrid?.(asset),
|
||||
top: `-${TITLE_HEIGHT}px`,
|
||||
bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`,
|
||||
right: `-${viewport.width - 1}px`,
|
||||
root: assetGridElement,
|
||||
}}
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
style:width={box.width + 'px'}
|
||||
style:height={box.height + 'px'}
|
||||
style:top={box.top + 'px'}
|
||||
style:left={box.left + 'px'}
|
||||
>
|
||||
<Thumbnail
|
||||
{dateGroup}
|
||||
{assetStore}
|
||||
intersectionConfig={{
|
||||
root: assetGridElement,
|
||||
bottom: renderThumbsAtBottomMargin,
|
||||
top: renderThumbsAtTopMargin,
|
||||
}}
|
||||
retrieveElement={$assetStore.pendingScrollAssetId === asset.id}
|
||||
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
|
||||
onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
|
||||
selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
|
||||
selectionCandidate={$assetSelectionCandidates.has(asset)}
|
||||
disabled={$assetStore.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={box.width}
|
||||
thumbnailHeight={box.height}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#asset-group-by-date {
|
||||
contain: layout;
|
||||
contain: layout paint style;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,11 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { BucketPosition, isSelectingAllAssets, type AssetStore, type Viewport } from '$lib/stores/assets.store';
|
||||
import {
|
||||
AssetBucket,
|
||||
AssetStore,
|
||||
isSelectingAllAssets,
|
||||
type BucketListener,
|
||||
type ViewportXY,
|
||||
} from '$lib/stores/assets.store';
|
||||
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
@ -13,19 +19,38 @@
|
||||
import { deleteAssets } from '$lib/utils/actions';
|
||||
import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
formatGroupTitle,
|
||||
splitBucketIntoDateGroups,
|
||||
type ScrubberListener,
|
||||
type ScrollTargetListener,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte';
|
||||
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
||||
|
||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import MeasureDateGroup from '$lib/components/photos-page/measure-date-group.svelte';
|
||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
|
||||
export let isSelectionMode = false;
|
||||
export let singleSelect = false;
|
||||
|
||||
/** `true` if this asset grid is responds to navigation events; if `true`, then look at the
|
||||
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
|
||||
additionally, update the page location/url with the asset as the asset-grid is scrolled */
|
||||
export let enableRouting: boolean;
|
||||
|
||||
export let assetStore: AssetStore;
|
||||
export let assetInteractionStore: AssetInteractionStore;
|
||||
export let removeAction:
|
||||
@ -40,17 +65,32 @@
|
||||
export let album: AlbumResponseDto | null = null;
|
||||
export let isShowDeleteConfirmation = false;
|
||||
|
||||
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
|
||||
const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
|
||||
assetInteractionStore;
|
||||
const viewport: Viewport = { width: 0, height: 0 };
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets } = assetViewingStore;
|
||||
|
||||
const viewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
|
||||
const safeViewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
|
||||
|
||||
const componentId = generateId();
|
||||
let element: HTMLElement;
|
||||
let timelineElement: HTMLElement;
|
||||
let showShortcuts = false;
|
||||
let showSkeleton = true;
|
||||
let internalScroll = false;
|
||||
let navigating = false;
|
||||
let preMeasure: AssetBucket[] = [];
|
||||
let lastIntersectedBucketDate: string | undefined;
|
||||
let scrubBucketPercent = 0;
|
||||
let scrubBucket: { bucketDate: string | undefined } | undefined;
|
||||
let scrubOverallPercent: number = 0;
|
||||
let topSectionHeight = 0;
|
||||
let topSectionOffset = 0;
|
||||
// 60 is the bottom spacer element at 60px
|
||||
let bottomSectionHeight = 60;
|
||||
let leadout = false;
|
||||
|
||||
$: timelineY = element?.scrollTop || 0;
|
||||
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
|
||||
$: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0;
|
||||
$: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id);
|
||||
$: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived);
|
||||
@ -59,30 +99,329 @@
|
||||
assetInteractionStore.clearMultiselect();
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
void assetStore.updateViewport(viewport);
|
||||
if (element && isViewportOrigin()) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
viewport.height = rect.height;
|
||||
viewport.width = rect.width;
|
||||
viewport.x = rect.x;
|
||||
viewport.y = rect.y;
|
||||
}
|
||||
if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
|
||||
safeViewport.height = viewport.height;
|
||||
safeViewport.width = viewport.width;
|
||||
safeViewport.x = viewport.x;
|
||||
safeViewport.y = viewport.y;
|
||||
updateViewport();
|
||||
}
|
||||
}
|
||||
const {
|
||||
ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW },
|
||||
BUCKET: {
|
||||
INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP,
|
||||
INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM,
|
||||
},
|
||||
THUMBNAIL: {
|
||||
INTERSECTION_ROOT_TOP: THUMBNAIL_INTERSECTION_ROOT_TOP,
|
||||
INTERSECTION_ROOT_BOTTOM: THUMBNAIL_INTERSECTION_ROOT_BOTTOM,
|
||||
},
|
||||
} = TUNABLES;
|
||||
|
||||
const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
|
||||
|
||||
onMount(async () => {
|
||||
showSkeleton = false;
|
||||
assetStore.connect();
|
||||
await assetStore.init(viewport);
|
||||
});
|
||||
const isViewportOrigin = () => {
|
||||
return viewport.height === 0 && viewport.width === 0;
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
if ($showAssetViewer) {
|
||||
$showAssetViewer = false;
|
||||
const isEqual = (a: ViewportXY, b: ViewportXY) => {
|
||||
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
|
||||
};
|
||||
|
||||
const completeNav = () => {
|
||||
navigating = false;
|
||||
if (internalScroll) {
|
||||
internalScroll = false;
|
||||
return;
|
||||
}
|
||||
|
||||
assetStore.disconnect();
|
||||
if ($gridScrollTarget?.at) {
|
||||
void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
|
||||
element.scrollTo({ top: 0 });
|
||||
showSkeleton = false;
|
||||
});
|
||||
} else {
|
||||
element.scrollTo({ top: 0 });
|
||||
showSkeleton = false;
|
||||
}
|
||||
};
|
||||
|
||||
afterNavigate((nav) => {
|
||||
const { complete, type } = nav;
|
||||
if (type === 'enter') {
|
||||
return;
|
||||
}
|
||||
complete.then(completeNav, completeNav);
|
||||
});
|
||||
|
||||
beforeNavigate(() => {
|
||||
navigating = true;
|
||||
});
|
||||
|
||||
const hmrSupport = () => {
|
||||
// when hmr happens, skeleton is initialized to true by default
|
||||
// normally, loading asset-grid is part of a navigation event, and the completion of
|
||||
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
|
||||
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
|
||||
// preventing skeleton from showing after hmr
|
||||
if (import.meta && import.meta.hot) {
|
||||
const afterApdate = (payload: UpdatePayload) => {
|
||||
const assetGridUpdate = payload.updates.some(
|
||||
(update) => update.path.endsWith('asset-grid.svelte') || update.path.endsWith('assets-store.ts'),
|
||||
);
|
||||
|
||||
if (assetGridUpdate) {
|
||||
setTimeout(() => {
|
||||
void $assetStore.updateViewport(safeViewport, true);
|
||||
const asset = $page.url.searchParams.get('at');
|
||||
if (asset) {
|
||||
$gridScrollTarget = { at: asset };
|
||||
void navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||
{ replaceState: true, forceNavigate: true },
|
||||
);
|
||||
} else {
|
||||
element.scrollTo({ top: 0 });
|
||||
showSkeleton = false;
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
import.meta.hot?.on('vite:afterUpdate', afterApdate);
|
||||
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
|
||||
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte'));
|
||||
if (assetGridUpdate) {
|
||||
assetStore.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return () => import.meta.hot?.off('vite:afterUpdate', afterApdate);
|
||||
}
|
||||
return () => void 0;
|
||||
};
|
||||
|
||||
const _updateLastIntersectedBucketDate = () => {
|
||||
let elem = document.elementFromPoint(safeViewport.x + 1, safeViewport.y + 1);
|
||||
|
||||
while (elem != null) {
|
||||
if (elem.id === 'bucket') {
|
||||
break;
|
||||
}
|
||||
elem = elem.parentElement;
|
||||
}
|
||||
if (elem) {
|
||||
lastIntersectedBucketDate = (elem as HTMLElement).dataset.bucketDate;
|
||||
}
|
||||
};
|
||||
const updateLastIntersectedBucketDate = throttle(_updateLastIntersectedBucketDate, 16, {
|
||||
leading: false,
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
|
||||
if (!lastIntersectedBucketDate) {
|
||||
_updateLastIntersectedBucketDate();
|
||||
}
|
||||
if (lastIntersectedBucketDate) {
|
||||
const currentIndex = $assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
|
||||
const deltaIndex = $assetStore.buckets.indexOf(adjustedBucket);
|
||||
|
||||
if (deltaIndex < currentIndex) {
|
||||
element?.scrollBy(0, delta);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const bucketListener: BucketListener = (event) => {
|
||||
const { type } = event;
|
||||
if (type === 'bucket-height') {
|
||||
const { bucket, delta } = event;
|
||||
scrollTolastIntersectedBucket(bucket, delta);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void $assetStore
|
||||
.init({ bucketListener })
|
||||
.then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport)));
|
||||
if (!enableRouting) {
|
||||
showSkeleton = false;
|
||||
}
|
||||
const dispose = hmrSupport();
|
||||
return () => {
|
||||
$assetStore.disconnect();
|
||||
$assetStore.destroy();
|
||||
dispose();
|
||||
};
|
||||
});
|
||||
|
||||
function getOffset(bucketDate: string) {
|
||||
let offset = 0;
|
||||
for (let a = 0; a < assetStore.buckets.length; a++) {
|
||||
if (assetStore.buckets[a].bucketDate === bucketDate) {
|
||||
break;
|
||||
}
|
||||
offset += assetStore.buckets[a].bucketHeight;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
const _updateViewport = () => void $assetStore.updateViewport(safeViewport);
|
||||
const updateViewport = throttle(_updateViewport, 16);
|
||||
|
||||
const getMaxScrollPercent = () =>
|
||||
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
|
||||
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
|
||||
|
||||
const getMaxScroll = () =>
|
||||
topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
|
||||
|
||||
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
|
||||
const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
|
||||
const maxScrollPercent = getMaxScrollPercent();
|
||||
const delta = bucket.bucketHeight * bucketScrollPercent;
|
||||
const scrollTop = (topOffset + delta) * maxScrollPercent;
|
||||
element.scrollTop = scrollTop;
|
||||
};
|
||||
|
||||
const _onScrub: ScrubberListener = (
|
||||
bucketDate: string | undefined,
|
||||
scrollPercent: number,
|
||||
bucketScrollPercent: number,
|
||||
) => {
|
||||
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
|
||||
const maxScroll = getMaxScroll();
|
||||
const offset = maxScroll * scrollPercent;
|
||||
element.scrollTop = offset;
|
||||
} else {
|
||||
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
||||
}
|
||||
};
|
||||
const onScrub = throttle(_onScrub, 16, { leading: false, trailing: true });
|
||||
|
||||
const stopScrub: ScrubberListener = async (
|
||||
bucketDate: string | undefined,
|
||||
_scrollPercent: number,
|
||||
bucketScrollPercent: number,
|
||||
) => {
|
||||
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
return;
|
||||
}
|
||||
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
if (bucket && !bucket.measured) {
|
||||
preMeasure.push(bucket);
|
||||
if (!bucket.loaded) {
|
||||
await assetStore.loadBucket(bucket.bucketDate);
|
||||
}
|
||||
// Wait here, and collect the deltas that are above offset, which affect offset position
|
||||
await bucket.measuredPromise;
|
||||
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleTimelineScroll = () => {
|
||||
leadout = false;
|
||||
if ($assetStore.timelineHeight < safeViewport.height * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
|
||||
scrubBucket = undefined;
|
||||
scrubBucketPercent = 0;
|
||||
} else {
|
||||
let top = element?.scrollTop;
|
||||
if (top < topSectionHeight) {
|
||||
// in the lead-in area
|
||||
scrubBucket = undefined;
|
||||
scrubBucketPercent = 0;
|
||||
const maxScroll = getMaxScroll();
|
||||
|
||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
return;
|
||||
}
|
||||
|
||||
let maxScrollPercent = getMaxScrollPercent();
|
||||
let found = false;
|
||||
|
||||
// create virtual buckets....
|
||||
const vbuckets = [
|
||||
{ bucketHeight: topSectionHeight, bucketDate: undefined },
|
||||
...assetStore.buckets,
|
||||
{ bucketHeight: bottomSectionHeight, bucketDate: undefined },
|
||||
];
|
||||
|
||||
for (const bucket of vbuckets) {
|
||||
let next = top - bucket.bucketHeight * maxScrollPercent;
|
||||
if (next < 0) {
|
||||
scrubBucket = bucket;
|
||||
scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
top = next;
|
||||
}
|
||||
if (!found) {
|
||||
leadout = true;
|
||||
scrubBucket = undefined;
|
||||
scrubBucketPercent = 0;
|
||||
scrubOverallPercent = 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleTimelineScroll = throttle(_handleTimelineScroll, 16, { leading: false, trailing: true });
|
||||
|
||||
const _onAssetInGrid = async (asset: AssetResponseDto) => {
|
||||
if (!enableRouting || navigating || internalScroll) {
|
||||
return;
|
||||
}
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
internalScroll = true;
|
||||
await navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||
{ replaceState: true, forceNavigate: true },
|
||||
);
|
||||
};
|
||||
const onAssetInGrid = NAVIGATE_ON_ASSET_IN_VIEW
|
||||
? throttle(_onAssetInGrid, 16, { leading: false, trailing: true })
|
||||
: () => void 0;
|
||||
|
||||
const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => {
|
||||
element.scrollTo({ top: offset });
|
||||
if (!bucket.measured) {
|
||||
preMeasure.push(bucket);
|
||||
}
|
||||
showSkeleton = false;
|
||||
$assetStore.clearPendingScroll();
|
||||
// set intersecting true manually here, to reduce flicker that happens when
|
||||
// clearing pending scroll, but the intersection observer hadn't yet had time to run
|
||||
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
||||
};
|
||||
|
||||
const trashOrDelete = async (force: boolean = false) => {
|
||||
isShowDeleteConfirmation = false;
|
||||
await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets);
|
||||
await deleteAssets(
|
||||
!(isTrashEnabled && !force),
|
||||
(assetIds) => $assetStore.removeAssets(assetIds),
|
||||
idsSelectedAssets,
|
||||
);
|
||||
assetInteractionStore.clearMultiselect();
|
||||
};
|
||||
|
||||
@ -107,7 +446,7 @@
|
||||
const onStackAssets = async () => {
|
||||
const ids = await stackAssets(Array.from($selectedAssets));
|
||||
if (ids) {
|
||||
assetStore.removeAssets(ids);
|
||||
$assetStore.removeAssets(ids);
|
||||
dispatch('escape');
|
||||
}
|
||||
};
|
||||
@ -115,7 +454,7 @@
|
||||
const toggleArchive = async () => {
|
||||
const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived);
|
||||
if (ids) {
|
||||
assetStore.removeAssets(ids);
|
||||
$assetStore.removeAssets(ids);
|
||||
deselectAllAssets();
|
||||
}
|
||||
};
|
||||
@ -135,7 +474,7 @@
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') },
|
||||
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) },
|
||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) },
|
||||
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
|
||||
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
|
||||
];
|
||||
@ -154,29 +493,33 @@
|
||||
})();
|
||||
|
||||
const handleSelectAsset = (asset: AssetResponseDto) => {
|
||||
if (!assetStore.albumAssets.has(asset.id)) {
|
||||
if (!$assetStore.albumAssets.has(asset.id)) {
|
||||
assetInteractionStore.selectAsset(asset);
|
||||
}
|
||||
};
|
||||
|
||||
async function intersectedHandler(event: CustomEvent) {
|
||||
const element_ = event.detail.container as HTMLElement;
|
||||
const target = element_.firstChild as HTMLElement;
|
||||
if (target) {
|
||||
const bucketDate = target.id.split('_')[1];
|
||||
await assetStore.loadBucket(bucketDate, event.detail.position);
|
||||
}
|
||||
function intersectedHandler(bucket: AssetBucket) {
|
||||
updateLastIntersectedBucketDate();
|
||||
const intersectedTask = () => {
|
||||
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
||||
void $assetStore.loadBucket(bucket.bucketDate);
|
||||
};
|
||||
$assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask);
|
||||
}
|
||||
|
||||
function handleScrollTimeline(event: CustomEvent) {
|
||||
element.scrollBy(0, event.detail.heightDelta);
|
||||
function seperatedHandler(bucket: AssetBucket) {
|
||||
const seperatedTask = () => {
|
||||
$assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
|
||||
bucket.cancel();
|
||||
};
|
||||
$assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask);
|
||||
}
|
||||
|
||||
const handlePrevious = async () => {
|
||||
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
|
||||
const previousAsset = await $assetStore.getPreviousAsset($viewingAsset);
|
||||
|
||||
if (previousAsset) {
|
||||
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
|
||||
const preloadAsset = await $assetStore.getPreviousAsset(previousAsset);
|
||||
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
|
||||
}
|
||||
@ -185,10 +528,10 @@
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
const nextAsset = await assetStore.getNextAsset($viewingAsset);
|
||||
const nextAsset = await $assetStore.getNextAsset($viewingAsset);
|
||||
|
||||
if (nextAsset) {
|
||||
const preloadAsset = await assetStore.getNextAsset(nextAsset);
|
||||
const preloadAsset = await $assetStore.getNextAsset(nextAsset);
|
||||
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
|
||||
}
|
||||
@ -196,7 +539,12 @@
|
||||
return !!nextAsset;
|
||||
};
|
||||
|
||||
const handleClose = () => assetViewingStore.showAssetViewer(false);
|
||||
const handleClose = async ({ detail: { asset } }: { detail: { asset: AssetResponseDto } }) => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
showSkeleton = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||
};
|
||||
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
@ -206,7 +554,7 @@
|
||||
case AssetAction.DELETE: {
|
||||
// find the next asset to show or close the viewer
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
(await handleNext()) || (await handlePrevious()) || handleClose();
|
||||
(await handleNext()) || (await handlePrevious()) || (await handleClose({ detail: { asset: action.asset } }));
|
||||
|
||||
// delete after find the next one
|
||||
assetStore.removeAssets([action.asset.id]);
|
||||
@ -232,20 +580,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
let animationTick = false;
|
||||
|
||||
const handleTimelineScroll = () => {
|
||||
if (animationTick) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationTick = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
timelineY = element?.scrollTop || 0;
|
||||
animationTick = false;
|
||||
});
|
||||
};
|
||||
|
||||
let lastAssetMouseEvent: AssetResponseDto | null = null;
|
||||
|
||||
$: if (!lastAssetMouseEvent) {
|
||||
@ -355,7 +689,7 @@
|
||||
// Select/deselect assets in all intermediate buckets
|
||||
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
|
||||
const bucket = $assetStore.buckets[bucketIndex];
|
||||
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
||||
await $assetStore.loadBucket(bucket.bucketDate);
|
||||
for (const asset of bucket.assets) {
|
||||
if (deselect) {
|
||||
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||
@ -370,11 +704,10 @@
|
||||
const bucket = $assetStore.buckets[bucketIndex];
|
||||
|
||||
// Split bucket into date groups and check each group
|
||||
const assetsGroupByDate = splitBucketIntoDateGroups(bucket.assets, $locale);
|
||||
|
||||
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
|
||||
for (const dateGroup of assetsGroupByDate) {
|
||||
const dateGroupTitle = formatGroupTitle(DateTime.fromISO(dateGroup[0].fileCreatedAt).startOf('day'));
|
||||
if (dateGroup.every((a) => $selectedAssets.has(a))) {
|
||||
const dateGroupTitle = formatGroupTitle(dateGroup.date);
|
||||
if (dateGroup.assets.every((a) => $selectedAssets.has(a))) {
|
||||
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
|
||||
} else {
|
||||
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
|
||||
@ -411,6 +744,9 @@
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
onDestroy(() => {
|
||||
assetStore.taskManager.removeAllTasksForComponent(componentId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} />
|
||||
@ -427,78 +763,97 @@
|
||||
<ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
|
||||
{/if}
|
||||
|
||||
<Scrollbar
|
||||
<Scrubber
|
||||
invisible={showSkeleton}
|
||||
{assetStore}
|
||||
height={viewport.height}
|
||||
{timelineY}
|
||||
on:scrollTimeline={({ detail }) => (element.scrollTop = detail)}
|
||||
height={safeViewport.height}
|
||||
timelineTopOffset={topSectionHeight}
|
||||
timelineBottomOffset={bottomSectionHeight}
|
||||
{leadout}
|
||||
{scrubOverallPercent}
|
||||
{scrubBucketPercent}
|
||||
{scrubBucket}
|
||||
{onScrub}
|
||||
{stopScrub}
|
||||
/>
|
||||
|
||||
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
||||
<section
|
||||
id="asset-grid"
|
||||
class="scrollbar-hidden h-full overflow-y-auto outline-none pb-[60px] {isEmpty
|
||||
? 'm-0'
|
||||
: 'ml-4 tall:ml-0 md:mr-[60px]'}"
|
||||
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
|
||||
tabindex="-1"
|
||||
bind:clientHeight={viewport.height}
|
||||
bind:clientWidth={viewport.width}
|
||||
use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
|
||||
bind:this={element}
|
||||
on:scroll={handleTimelineScroll}
|
||||
on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
|
||||
>
|
||||
<!-- skeleton -->
|
||||
{#if showSkeleton}
|
||||
<div class="mt-8 animate-pulse">
|
||||
<div class="mb-2 h-4 w-24 rounded-full bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
|
||||
<div class="flex w-[120%] flex-wrap">
|
||||
{#each Array.from({ length: 100 }) as _}
|
||||
<div class="m-[1px] h-[10em] w-[16em] bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if element}
|
||||
<section
|
||||
use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
|
||||
class:invisible={showSkeleton}
|
||||
>
|
||||
<slot />
|
||||
|
||||
<!-- (optional) empty placeholder -->
|
||||
{#if isEmpty}
|
||||
<!-- (optional) empty placeholder -->
|
||||
<slot name="empty" />
|
||||
{/if}
|
||||
<section id="virtual-timeline" style:height={$assetStore.timelineHeight + 'px'}>
|
||||
{#each $assetStore.buckets as bucket (bucket.bucketDate)}
|
||||
<IntersectionObserver
|
||||
on:intersected={intersectedHandler}
|
||||
on:hidden={() => assetStore.cancelBucket(bucket)}
|
||||
let:intersecting
|
||||
top={750}
|
||||
bottom={750}
|
||||
root={element}
|
||||
>
|
||||
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
|
||||
{#if intersecting}
|
||||
<AssetDateGroup
|
||||
{withStacked}
|
||||
{showArchiveIcon}
|
||||
{assetStore}
|
||||
{assetInteractionStore}
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
|
||||
on:shift={handleScrollTimeline}
|
||||
on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
|
||||
on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
|
||||
assets={bucket.assets}
|
||||
bucketDate={bucket.bucketDate}
|
||||
bucketHeight={bucket.bucketHeight}
|
||||
{viewport}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</IntersectionObserver>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section
|
||||
bind:this={timelineElement}
|
||||
id="virtual-timeline"
|
||||
class:invisible={showSkeleton}
|
||||
style:height={$assetStore.timelineHeight + 'px'}
|
||||
>
|
||||
{#each $assetStore.buckets as bucket (bucket.bucketDate)}
|
||||
{@const isPremeasure = preMeasure.includes(bucket)}
|
||||
{@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure}
|
||||
<div
|
||||
id="bucket"
|
||||
use:intersectionObserver={{
|
||||
onIntersect: () => intersectedHandler(bucket),
|
||||
onSeparate: () => seperatedHandler(bucket),
|
||||
top: BUCKET_INTERSECTION_ROOT_TOP,
|
||||
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
|
||||
root: element,
|
||||
}}
|
||||
data-bucket-display={bucket.intersecting}
|
||||
data-bucket-date={bucket.bucketDate}
|
||||
style:height={bucket.bucketHeight + 'px'}
|
||||
>
|
||||
{#if display && !bucket.measured}
|
||||
<MeasureDateGroup
|
||||
{bucket}
|
||||
{assetStore}
|
||||
onMeasured={() => (preMeasure = preMeasure.filter((b) => b !== bucket))}
|
||||
></MeasureDateGroup>
|
||||
{/if}
|
||||
|
||||
{#if !display || !bucket.measured}
|
||||
<Skeleton height={bucket.bucketHeight + 'px'} title={`${bucket.bucketDateFormattted}`} />
|
||||
{/if}
|
||||
{#if display && bucket.measured}
|
||||
<AssetDateGroup
|
||||
assetGridElement={element}
|
||||
renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
|
||||
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
|
||||
{withStacked}
|
||||
{showArchiveIcon}
|
||||
{assetStore}
|
||||
{assetInteractionStore}
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
{onScrollTarget}
|
||||
{onAssetInGrid}
|
||||
{bucket}
|
||||
viewport={safeViewport}
|
||||
on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
|
||||
on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
|
||||
on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="h-[60px]"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<Portal target="body">
|
||||
@ -522,7 +877,7 @@
|
||||
|
||||
<style>
|
||||
#asset-grid {
|
||||
contain: layout;
|
||||
contain: strict;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
89
web/src/lib/components/photos-page/measure-date-group.svelte
Normal file
89
web/src/lib/components/photos-page/measure-date-group.svelte
Normal file
@ -0,0 +1,89 @@
|
||||
<script lang="ts" context="module">
|
||||
const recentTimes: number[] = [];
|
||||
// TODO: track average time to measure, and use this to populate TUNABLES.ASSETS_STORE.CHECK_INTERVAL_MS
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function adjustTunables(avg: number) {}
|
||||
function addMeasure(time: number) {
|
||||
recentTimes.push(time);
|
||||
if (recentTimes.length > 10) {
|
||||
recentTimes.shift();
|
||||
}
|
||||
const sum = recentTimes.reduce((acc: number, val: number) => {
|
||||
return acc + val;
|
||||
}, 0);
|
||||
const avg = sum / recentTimes.length;
|
||||
adjustTunables(avg);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets.store';
|
||||
|
||||
export let assetStore: AssetStore;
|
||||
export let bucket: AssetBucket;
|
||||
export let onMeasured: () => void;
|
||||
|
||||
async function _measure(element: Element) {
|
||||
try {
|
||||
await bucket.complete;
|
||||
const t1 = Date.now();
|
||||
let heightPending = bucket.dateGroups.some((group) => !group.heightActual);
|
||||
if (heightPending) {
|
||||
const listener: BucketListener = (event) => {
|
||||
const { type } = event;
|
||||
if (type === 'height') {
|
||||
const { bucket: changedBucket } = event;
|
||||
if (changedBucket === bucket && type === 'height') {
|
||||
heightPending = bucket.dateGroups.some((group) => !group.heightActual);
|
||||
if (!heightPending) {
|
||||
const height = element.getBoundingClientRect().height;
|
||||
if (height !== 0) {
|
||||
$assetStore.updateBucket(bucket.bucketDate, { height: height, measured: true });
|
||||
}
|
||||
|
||||
onMeasured();
|
||||
$assetStore.removeListener(listener);
|
||||
const t2 = Date.now();
|
||||
|
||||
addMeasure((t2 - t1) / bucket.bucketCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
assetStore.addListener(listener);
|
||||
}
|
||||
} catch {
|
||||
// ignore if complete rejects (canceled load)
|
||||
}
|
||||
}
|
||||
function measure(element: Element) {
|
||||
void _measure(element);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure>
|
||||
{#each bucket.dateGroups as dateGroup}
|
||||
<div id="date-group" data-date-group={dateGroup.date}>
|
||||
<div
|
||||
use:resizeObserver={({ height }) => $assetStore.updateBucketDateGroup(bucket, dateGroup, { height: height })}
|
||||
>
|
||||
<div
|
||||
class="flex z-[100] sticky top-[-1px] pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||
>
|
||||
<span class="w-full truncate first-letter:capitalize">
|
||||
{dateGroup.groupTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative overflow-clip"
|
||||
style:height={dateGroup.geometry.containerHeight + 'px'}
|
||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||
style:visibility={'hidden'}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { memoryStore } from '$lib/stores/memory.store';
|
||||
@ -38,7 +39,7 @@
|
||||
id="memory-lane"
|
||||
bind:this={memoryLaneElement}
|
||||
class="relative mt-5 overflow-x-hidden whitespace-nowrap transition-all"
|
||||
bind:offsetWidth
|
||||
use:resizeObserver={({ width }) => (offsetWidth = width)}
|
||||
on:scroll={onScroll}
|
||||
>
|
||||
{#if canScrollLeft || canScrollRight}
|
||||
@ -67,7 +68,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="inline-block" bind:offsetWidth={innerWidth}>
|
||||
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
|
||||
{#each $memoryStore as memory, index (memory.yearsAgo)}
|
||||
{#if memory.assets.length > 0}
|
||||
<a
|
||||
|
35
web/src/lib/components/photos-page/skeleton.svelte
Normal file
35
web/src/lib/components/photos-page/skeleton.svelte
Normal file
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
export let title: string | null = null;
|
||||
export let height: string | null = null;
|
||||
</script>
|
||||
|
||||
<div class="overflow-clip" style={`height: ${height}`}>
|
||||
{#if title}
|
||||
<div
|
||||
class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
||||
>
|
||||
<span class="w-full truncate first-letter:capitalize">{title}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div id="skeleton" style={`height: ${height}`}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#skeleton {
|
||||
background-image: url('/light_skeleton.png');
|
||||
background-repeat: repeat;
|
||||
background-size: 235px, 235px;
|
||||
}
|
||||
:global(.dark) #skeleton {
|
||||
background-image: url('/dark_skeleton.png');
|
||||
}
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#skeleton {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.1s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
@ -4,25 +4,25 @@
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import type { BucketPosition, Viewport } from '$lib/stores/assets.store';
|
||||
import type { Viewport } from '$lib/stores/assets.store';
|
||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { calculateWidth } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import justifiedLayout from 'justified-layout';
|
||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||
import Portal from '../portal/portal.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
export let assets: AssetResponseDto[];
|
||||
export let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||
export let disableAssetSelect = false;
|
||||
export let showArchiveIcon = false;
|
||||
export let viewport: Viewport;
|
||||
export let onIntersected: (() => void) | undefined = undefined;
|
||||
export let showAssetName = false;
|
||||
|
||||
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||
@ -127,18 +127,15 @@
|
||||
<Thumbnail
|
||||
{asset}
|
||||
readonly={disableAssetSelect}
|
||||
onClick={async (asset, e) => {
|
||||
e.preventDefault();
|
||||
|
||||
onClick={(asset) => {
|
||||
if (isMultiSelectionMode) {
|
||||
selectAssetHandler(asset);
|
||||
return;
|
||||
}
|
||||
await viewAssetHandler(asset);
|
||||
void viewAssetHandler(asset);
|
||||
}}
|
||||
on:select={(e) => selectAssetHandler(e.detail.asset)}
|
||||
on:intersected={(event) =>
|
||||
i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined}
|
||||
onSelect={(asset) => selectAssetHandler(asset)}
|
||||
onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
|
||||
selected={selectedAssets.has(asset)}
|
||||
{showArchiveIcon}
|
||||
thumbnailWidth={geometry.boxes[i].width}
|
||||
@ -159,6 +156,15 @@
|
||||
<!-- Overlay Asset Viewer -->
|
||||
{#if $isViewerOpen}
|
||||
<Portal target="body">
|
||||
<AssetViewer asset={$viewingAsset} onAction={handleAction} on:previous={handlePrevious} on:next={handleNext} />
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
onAction={handleAction}
|
||||
on:previous={handlePrevious}
|
||||
on:next={handleNext}
|
||||
on:close={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
{/if}
|
||||
|
@ -1,183 +0,0 @@
|
||||
<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>
|
@ -0,0 +1,281 @@
|
||||
<script lang="ts">
|
||||
import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets.store';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let timelineTopOffset = 0;
|
||||
export let timelineBottomOffset = 0;
|
||||
export let height = 0;
|
||||
export let assetStore: AssetStore;
|
||||
export let invisible = false;
|
||||
export let scrubOverallPercent: number = 0;
|
||||
export let scrubBucketPercent: number = 0;
|
||||
export let scrubBucket: { bucketDate: string | undefined } | undefined = undefined;
|
||||
export let leadout: boolean = false;
|
||||
export let onScrub: ScrubberListener | undefined = undefined;
|
||||
export let startScrub: ScrubberListener | undefined = undefined;
|
||||
export let stopScrub: ScrubberListener | undefined = undefined;
|
||||
|
||||
let isHover = false;
|
||||
let isDragging = false;
|
||||
let hoverLabel: string | undefined;
|
||||
let bucketDate: string | undefined;
|
||||
let hoverY = 0;
|
||||
let clientY = 0;
|
||||
let windowHeight = 0;
|
||||
let scrollBar: HTMLElement | undefined;
|
||||
let segments: Segment[] = [];
|
||||
|
||||
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
|
||||
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
|
||||
|
||||
const HOVER_DATE_HEIGHT = 31.75;
|
||||
const MIN_YEAR_LABEL_DISTANCE = 16;
|
||||
const MIN_DOT_DISTANCE = 8;
|
||||
|
||||
const toScrollFromBucketPercentage = (
|
||||
scrubBucket: { bucketDate: string | undefined } | undefined,
|
||||
scrubBucketPercent: number,
|
||||
scrubOverallPercent: number,
|
||||
) => {
|
||||
if (scrubBucket) {
|
||||
let offset = relativeTopOffset;
|
||||
let match = false;
|
||||
for (const segment of segments) {
|
||||
if (segment.bucketDate === scrubBucket.bucketDate) {
|
||||
offset += scrubBucketPercent * segment.height;
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
offset += segment.height;
|
||||
}
|
||||
if (!match) {
|
||||
offset += scrubBucketPercent * relativeBottomOffset;
|
||||
}
|
||||
// 2px is the height of the indicator
|
||||
return offset - 2;
|
||||
} else if (leadout) {
|
||||
let offset = relativeTopOffset;
|
||||
for (const segment of segments) {
|
||||
offset += segment.height;
|
||||
}
|
||||
offset += scrubOverallPercent * relativeBottomOffset;
|
||||
return offset - 2;
|
||||
} else {
|
||||
// 2px is the height of the indicator
|
||||
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
|
||||
}
|
||||
};
|
||||
$: scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
||||
$: timelineFullHeight = $assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset;
|
||||
$: relativeTopOffset = toScrollY(timelineTopOffset / timelineFullHeight);
|
||||
$: relativeBottomOffset = toScrollY(timelineBottomOffset / timelineFullHeight);
|
||||
|
||||
const listener: BucketListener = (event) => {
|
||||
const { type } = event;
|
||||
if (type === 'viewport') {
|
||||
segments = calculateSegments($assetStore.buckets);
|
||||
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
assetStore.addListener(listener);
|
||||
return () => assetStore.removeListener(listener);
|
||||
});
|
||||
|
||||
type Segment = {
|
||||
count: number;
|
||||
height: number;
|
||||
dateFormatted: string;
|
||||
bucketDate: string;
|
||||
date: DateTime;
|
||||
hasLabel: boolean;
|
||||
hasDot: boolean;
|
||||
};
|
||||
|
||||
const calculateSegments = (buckets: AssetBucket[]) => {
|
||||
let height = 0;
|
||||
let dotHeight = 0;
|
||||
|
||||
let segments: Segment[] = [];
|
||||
let previousLabeledSegment: Segment | undefined;
|
||||
|
||||
for (const [i, bucket] of buckets.entries()) {
|
||||
const scrollBarPercentage =
|
||||
bucket.bucketHeight / ($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||
|
||||
const segment = {
|
||||
count: bucket.assets.length,
|
||||
height: toScrollY(scrollBarPercentage),
|
||||
bucketDate: bucket.bucketDate,
|
||||
date: fromLocalDateTime(bucket.bucketDate),
|
||||
dateFormatted: bucket.bucketDateFormattted,
|
||||
hasLabel: false,
|
||||
hasDot: false,
|
||||
};
|
||||
|
||||
if (i === 0) {
|
||||
segment.hasDot = true;
|
||||
segment.hasLabel = true;
|
||||
previousLabeledSegment = segment;
|
||||
} else {
|
||||
if (previousLabeledSegment?.date?.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
|
||||
height = 0;
|
||||
segment.hasLabel = true;
|
||||
previousLabeledSegment = segment;
|
||||
}
|
||||
if (i !== 1 && segment.height > 5 && dotHeight > MIN_DOT_DISTANCE) {
|
||||
segment.hasDot = true;
|
||||
dotHeight = 0;
|
||||
}
|
||||
|
||||
height += segment.height;
|
||||
dotHeight += segment.height;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
hoverLabel = segments[0]?.dateFormatted;
|
||||
return segments;
|
||||
};
|
||||
|
||||
const updateLabel = (segment: HTMLElement) => {
|
||||
hoverLabel = segment.dataset.label;
|
||||
bucketDate = segment.dataset.timeSegmentBucketDate;
|
||||
};
|
||||
|
||||
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
|
||||
const wasDragging = isDragging;
|
||||
|
||||
isDragging = event.isDragging ?? isDragging;
|
||||
clientY = event.clientY;
|
||||
|
||||
if (!scrollBar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = scrollBar.getBoundingClientRect()!;
|
||||
const lower = 0;
|
||||
const upper = rect?.height - HOVER_DATE_HEIGHT * 2;
|
||||
hoverY = clamp(clientY - rect?.top - HOVER_DATE_HEIGHT, lower, upper);
|
||||
const x = rect!.left + rect!.width / 2;
|
||||
const elems = document.elementsFromPoint(x, clientY);
|
||||
const segment = elems.find(({ id }) => id === 'time-segment');
|
||||
let bucketPercentY = 0;
|
||||
if (segment) {
|
||||
updateLabel(segment as HTMLElement);
|
||||
const sr = segment.getBoundingClientRect();
|
||||
const sy = sr.y;
|
||||
const relativeY = clientY - sy;
|
||||
bucketPercentY = relativeY / sr.height;
|
||||
} else {
|
||||
const leadin = elems.find(({ id }) => id === 'lead-in');
|
||||
if (leadin) {
|
||||
updateLabel(leadin as HTMLElement);
|
||||
} else {
|
||||
bucketDate = undefined;
|
||||
bucketPercentY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const scrollPercent = toTimelineY(hoverY);
|
||||
if (wasDragging === false && isDragging) {
|
||||
void startScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
}
|
||||
|
||||
if (wasDragging && !isDragging) {
|
||||
void stopScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
};
|
||||
</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 -->
|
||||
|
||||
<div
|
||||
id="immich-scrubbable-scrollbar"
|
||||
class={`absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize`}
|
||||
style:padding-top={HOVER_DATE_HEIGHT + 'px'}
|
||||
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
|
||||
class:invisible
|
||||
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 hoverLabel && (isHover || isDragging)}
|
||||
<div
|
||||
id="time-label"
|
||||
class="truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit 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="{hoverY + 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 + HOVER_DATE_HEIGHT}px"
|
||||
/>
|
||||
{/if}
|
||||
<div id="lead-in" class="relative" style:height={relativeTopOffset + 'px'} data-label={segments.at(0)?.dateFormatted}>
|
||||
{#if relativeTopOffset > 6}
|
||||
<div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300" />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Time Segment -->
|
||||
{#each segments as segment}
|
||||
<div
|
||||
id="time-segment"
|
||||
class="relative"
|
||||
data-time-segment-bucket-date={segment.date}
|
||||
data-label={segment.dateFormatted}
|
||||
style:height={segment.height + 'px'}
|
||||
aria-label={segment.dateFormatted + ' ' + segment.count}
|
||||
>
|
||||
{#if segment.hasLabel}
|
||||
<div
|
||||
aria-label={segment.dateFormatted + ' ' + segment.count}
|
||||
class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono"
|
||||
>
|
||||
{segment.date.year}
|
||||
</div>
|
||||
{/if}
|
||||
{#if segment.hasDot}
|
||||
<div
|
||||
aria-label={segment.dateFormatted + ' ' + segment.count}
|
||||
class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#immich-scrubbable-scrollbar,
|
||||
#time-segment {
|
||||
contain: layout size style;
|
||||
}
|
||||
</style>
|
@ -4,7 +4,8 @@
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { suggestDuplicateByFileSize } from '$lib/utils';
|
||||
import { handlePromiseError, suggestDuplicateByFileSize } from '$lib/utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js';
|
||||
@ -158,7 +159,10 @@
|
||||
const index = getAssetIndex($viewingAsset.id) - 1 + assets.length;
|
||||
setAsset(assets[index % assets.length]);
|
||||
}}
|
||||
on:close={() => assetViewingStore.showAssetViewer(false)}
|
||||
on:close={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
{/await}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { getKey } from '$lib/utils';
|
||||
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { readonly, writable } from 'svelte/store';
|
||||
|
||||
@ -6,6 +7,7 @@ function createAssetViewingStore() {
|
||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
const preloadAssets = writable<AssetResponseDto[]>([]);
|
||||
const viewState = writable<boolean>(false);
|
||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => {
|
||||
preloadAssets.set(assetsToPreload);
|
||||
@ -26,6 +28,7 @@ function createAssetViewingStore() {
|
||||
asset: readonly(viewingAssetStoreState),
|
||||
preloadAssets: readonly(preloadAssets),
|
||||
isViewing: viewState,
|
||||
gridScrollTarget,
|
||||
setAsset,
|
||||
setAssetId,
|
||||
showAssetViewer,
|
||||
|
@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { AssetStore, BucketPosition } from './assets.store';
|
||||
import { AssetStore } from './assets.store';
|
||||
|
||||
describe('AssetStore', () => {
|
||||
beforeEach(() => {
|
||||
@ -26,7 +26,8 @@ describe('AssetStore', () => {
|
||||
]);
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
||||
|
||||
await assetStore.init({ width: 1588, height: 1000 });
|
||||
await assetStore.init();
|
||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('should load buckets in viewport', () => {
|
||||
@ -38,15 +39,15 @@ describe('AssetStore', () => {
|
||||
it('calculates bucket height', () => {
|
||||
expect(assetStore.buckets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 235 }),
|
||||
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3760 }),
|
||||
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }),
|
||||
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 286 }),
|
||||
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3811 }),
|
||||
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates timeline height', () => {
|
||||
expect(assetStore.timelineHeight).toBe(4230);
|
||||
expect(assetStore.timelineHeight).toBe(4383);
|
||||
});
|
||||
});
|
||||
|
||||
@ -72,35 +73,28 @@ describe('AssetStore', () => {
|
||||
|
||||
return bucketAssets[timeBucket];
|
||||
});
|
||||
await assetStore.init({ width: 0, height: 0 });
|
||||
await assetStore.init();
|
||||
await assetStore.updateViewport({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('loads a bucket', async () => {
|
||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('ignores invalid buckets', async () => {
|
||||
await assetStore.loadBucket('2023-01-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2023-01-01T00:00:00.000Z');
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('only updates the position of loaded buckets', async () => {
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown);
|
||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Unknown);
|
||||
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Visible);
|
||||
});
|
||||
|
||||
it('cancels bucket loading', async () => {
|
||||
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
||||
const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown);
|
||||
const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
|
||||
|
||||
const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort');
|
||||
assetStore.cancelBucket(bucket!);
|
||||
bucket?.cancel();
|
||||
expect(abortSpy).toBeCalledTimes(1);
|
||||
|
||||
await loadPromise;
|
||||
@ -109,24 +103,24 @@ describe('AssetStore', () => {
|
||||
|
||||
it('prevents loading buckets multiple times', async () => {
|
||||
await Promise.all([
|
||||
assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown),
|
||||
assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown),
|
||||
assetStore.loadBucket('2024-01-01T00:00:00.000Z'),
|
||||
assetStore.loadBucket('2024-01-01T00:00:00.000Z'),
|
||||
]);
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown);
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows loading a canceled bucket', async () => {
|
||||
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
||||
const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown);
|
||||
const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
|
||||
|
||||
assetStore.cancelBucket(bucket!);
|
||||
bucket?.cancel();
|
||||
await loadPromise;
|
||||
expect(bucket?.assets.length).toEqual(0);
|
||||
|
||||
await assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown);
|
||||
await assetStore.loadBucket(bucket!.bucketDate);
|
||||
expect(bucket!.assets.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
@ -137,7 +131,8 @@ describe('AssetStore', () => {
|
||||
beforeEach(async () => {
|
||||
assetStore = new AssetStore({});
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
await assetStore.init({ width: 1588, height: 1000 });
|
||||
await assetStore.init();
|
||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('is empty initially', () => {
|
||||
@ -219,7 +214,8 @@ describe('AssetStore', () => {
|
||||
beforeEach(async () => {
|
||||
assetStore = new AssetStore({});
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
await assetStore.init({ width: 1588, height: 1000 });
|
||||
await assetStore.init();
|
||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('ignores non-existing assets', () => {
|
||||
@ -263,7 +259,8 @@ describe('AssetStore', () => {
|
||||
beforeEach(async () => {
|
||||
assetStore = new AssetStore({});
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
await assetStore.init({ width: 1588, height: 1000 });
|
||||
await assetStore.init();
|
||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('ignores invalid IDs', () => {
|
||||
@ -312,7 +309,8 @@ describe('AssetStore', () => {
|
||||
]);
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
||||
|
||||
await assetStore.init({ width: 0, height: 0 });
|
||||
await assetStore.init();
|
||||
await assetStore.updateViewport({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('returns null for invalid assetId', async () => {
|
||||
@ -321,15 +319,15 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('returns previous assetId', async () => {
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
|
||||
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
||||
|
||||
expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]);
|
||||
});
|
||||
|
||||
it('returns previous assetId spanning multiple buckets', async () => {
|
||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
||||
|
||||
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
|
||||
const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
|
||||
@ -337,7 +335,7 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('loads previous bucket', async () => {
|
||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
||||
|
||||
const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
|
||||
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
|
||||
@ -347,9 +345,9 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('skips removed assets', async () => {
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
|
||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
||||
|
||||
const [assetOne, assetTwo, assetThree] = assetStore.assets;
|
||||
assetStore.removeAssets([assetTwo.id]);
|
||||
@ -357,7 +355,7 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('returns null when no more assets', async () => {
|
||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
|
||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
||||
expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -368,7 +366,8 @@ describe('AssetStore', () => {
|
||||
beforeEach(async () => {
|
||||
assetStore = new AssetStore({});
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
await assetStore.init({ width: 0, height: 0 });
|
||||
await assetStore.init();
|
||||
await assetStore.updateViewport({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('returns null for invalid buckets', () => {
|
||||
|
@ -1,6 +1,11 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getKey } from '$lib/utils';
|
||||
import { fromLocalDateTime } from '$lib/utils/timeline-util';
|
||||
import { TimeBucketSize, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager';
|
||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||
import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util';
|
||||
import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
|
||||
import createJustifiedLayout from 'justified-layout';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
@ -8,19 +13,24 @@ import { get, writable, type Unsubscriber } from 'svelte/store';
|
||||
import { handleError } from '../utils/handle-error';
|
||||
import { websocketEvents } from './websocket';
|
||||
|
||||
export enum BucketPosition {
|
||||
Above = 'above',
|
||||
Below = 'below',
|
||||
Visible = 'visible',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
|
||||
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>;
|
||||
|
||||
const LAYOUT_OPTIONS = {
|
||||
boxSpacing: 2,
|
||||
containerPadding: 0,
|
||||
targetRowHeightTolerance: 0.15,
|
||||
targetRowHeight: 235,
|
||||
};
|
||||
|
||||
export interface Viewport {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
export type ViewportXY = Viewport & {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
interface AssetLookup {
|
||||
bucket: AssetBucket;
|
||||
@ -29,16 +39,89 @@ interface AssetLookup {
|
||||
}
|
||||
|
||||
export class AssetBucket {
|
||||
store!: AssetStore;
|
||||
bucketDate!: string;
|
||||
/**
|
||||
* The DOM height of the bucket in pixel
|
||||
* This value is first estimated by the number of asset and later is corrected as the user scroll
|
||||
*/
|
||||
bucketHeight!: number;
|
||||
bucketDate!: string;
|
||||
bucketCount!: number;
|
||||
assets!: AssetResponseDto[];
|
||||
cancelToken!: AbortController | null;
|
||||
position!: BucketPosition;
|
||||
bucketHeight: number = 0;
|
||||
isBucketHeightActual: boolean = false;
|
||||
bucketDateFormattted!: string;
|
||||
bucketCount: number = 0;
|
||||
assets: AssetResponseDto[] = [];
|
||||
dateGroups: DateGroup[] = [];
|
||||
cancelToken: AbortController | undefined;
|
||||
/**
|
||||
* Prevent this asset's load from being canceled; i.e. to force load of offscreen asset.
|
||||
*/
|
||||
isPreventCancel: boolean = false;
|
||||
/**
|
||||
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
|
||||
*/
|
||||
complete!: Promise<void>;
|
||||
loading: boolean = false;
|
||||
isLoaded: boolean = false;
|
||||
intersecting: boolean = false;
|
||||
measured: boolean = false;
|
||||
measuredPromise!: Promise<void>;
|
||||
|
||||
constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) {
|
||||
Object.assign(this, props);
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init() {
|
||||
// create a promise, and store its resolve/reject callbacks. The loadedSignal callback
|
||||
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
|
||||
// callback will be called if the bucket is canceled before it was loaded, rejecting the
|
||||
// promise.
|
||||
this.complete = new Promise((resolve, reject) => {
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
});
|
||||
// if no-one waits on complete, and its rejected a uncaught rejection message is logged.
|
||||
// We this message with an empty reject handler, since waiting on a bucket is optional.
|
||||
this.complete.catch(() => void 0);
|
||||
this.measuredPromise = new Promise((resolve) => {
|
||||
this.measuredSignal = resolve;
|
||||
});
|
||||
|
||||
this.bucketDateFormattted = fromLocalDateTime(this.bucketDate)
|
||||
.startOf('month')
|
||||
.toJSDate()
|
||||
.toLocaleString(get(locale), {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
private loadedSignal: (() => void) | undefined;
|
||||
private canceledSignal: (() => void) | undefined;
|
||||
measuredSignal: (() => void) | undefined;
|
||||
|
||||
cancel() {
|
||||
if (this.isLoaded) {
|
||||
return;
|
||||
}
|
||||
if (this.isPreventCancel) {
|
||||
return;
|
||||
}
|
||||
this.cancelToken?.abort();
|
||||
this.canceledSignal?.();
|
||||
this.init();
|
||||
}
|
||||
|
||||
loaded() {
|
||||
this.loadedSignal?.();
|
||||
this.isLoaded = true;
|
||||
}
|
||||
|
||||
errored() {
|
||||
this.canceledSignal?.();
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
|
||||
@ -65,34 +148,101 @@ interface TrashAssets {
|
||||
type: 'trash';
|
||||
values: string[];
|
||||
}
|
||||
interface UpdateStackAssets {
|
||||
type: 'update_stack_assets';
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export const photoViewer = writable<HTMLImageElement | null>(null);
|
||||
|
||||
type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets;
|
||||
type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
||||
|
||||
export type BucketListener = (
|
||||
event:
|
||||
| ViewPortEvent
|
||||
| BucketLoadEvent
|
||||
| BucketLoadedEvent
|
||||
| BucketCancelEvent
|
||||
| BucketHeightEvent
|
||||
| DateGroupIntersecting
|
||||
| DateGroupHeightEvent,
|
||||
) => void;
|
||||
|
||||
type ViewPortEvent = {
|
||||
type: 'viewport';
|
||||
};
|
||||
type BucketLoadEvent = {
|
||||
type: 'load';
|
||||
bucket: AssetBucket;
|
||||
};
|
||||
type BucketLoadedEvent = {
|
||||
type: 'loaded';
|
||||
bucket: AssetBucket;
|
||||
};
|
||||
type BucketCancelEvent = {
|
||||
type: 'cancel';
|
||||
bucket: AssetBucket;
|
||||
};
|
||||
type BucketHeightEvent = {
|
||||
type: 'bucket-height';
|
||||
bucket: AssetBucket;
|
||||
delta: number;
|
||||
};
|
||||
type DateGroupIntersecting = {
|
||||
type: 'intersecting';
|
||||
bucket: AssetBucket;
|
||||
dateGroup: DateGroup;
|
||||
};
|
||||
type DateGroupHeightEvent = {
|
||||
type: 'height';
|
||||
bucket: AssetBucket;
|
||||
dateGroup: DateGroup;
|
||||
delta: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export class AssetStore {
|
||||
private store$ = writable(this);
|
||||
private assetToBucket: Record<string, AssetLookup> = {};
|
||||
private pendingChanges: PendingChange[] = [];
|
||||
private unsubscribers: Unsubscriber[] = [];
|
||||
private options: AssetApiGetTimeBucketsRequest;
|
||||
private viewport: Viewport = {
|
||||
height: 0,
|
||||
width: 0,
|
||||
};
|
||||
private initializedSignal!: () => void;
|
||||
private store$ = writable(this);
|
||||
|
||||
lastScrollTime: number = 0;
|
||||
subscribe = this.store$.subscribe;
|
||||
/**
|
||||
* A promise that resolves once the store is initialized.
|
||||
*/
|
||||
taskManager = new AssetGridTaskManager(this);
|
||||
complete!: Promise<void>;
|
||||
initialized = false;
|
||||
timelineHeight = 0;
|
||||
buckets: AssetBucket[] = [];
|
||||
assets: AssetResponseDto[] = [];
|
||||
albumAssets: Set<string> = new Set();
|
||||
pendingScrollBucket: AssetBucket | undefined;
|
||||
pendingScrollAssetId: string | undefined;
|
||||
|
||||
listeners: BucketListener[] = [];
|
||||
|
||||
constructor(
|
||||
options: AssetStoreOptions,
|
||||
private albumId?: string,
|
||||
) {
|
||||
this.options = { ...options, size: TimeBucketSize.Month };
|
||||
// create a promise, and store its resolve callbacks. The initializedSignal callback
|
||||
// will be invoked when a the assetstore is initialized.
|
||||
this.complete = new Promise((resolve) => {
|
||||
this.initializedSignal = resolve;
|
||||
});
|
||||
this.store$.set(this);
|
||||
}
|
||||
|
||||
subscribe = this.store$.subscribe;
|
||||
|
||||
private addPendingChanges(...changes: PendingChange[]) {
|
||||
// prevent websocket events from happening before local client events
|
||||
setTimeout(() => {
|
||||
@ -182,8 +332,35 @@ export class AssetStore {
|
||||
this.emit(true);
|
||||
}, 2500);
|
||||
|
||||
async init(viewport: Viewport) {
|
||||
this.initialized = false;
|
||||
addListener(bucketListener: BucketListener) {
|
||||
this.listeners.push(bucketListener);
|
||||
}
|
||||
removeListener(bucketListener: BucketListener) {
|
||||
this.listeners = this.listeners.filter((l) => l != bucketListener);
|
||||
}
|
||||
private notifyListeners(
|
||||
event:
|
||||
| ViewPortEvent
|
||||
| BucketLoadEvent
|
||||
| BucketLoadedEvent
|
||||
| BucketCancelEvent
|
||||
| BucketHeightEvent
|
||||
| DateGroupIntersecting
|
||||
| DateGroupHeightEvent,
|
||||
) {
|
||||
for (const fn of this.listeners) {
|
||||
fn(event);
|
||||
}
|
||||
}
|
||||
async init({ bucketListener }: { bucketListener?: BucketListener } = {}) {
|
||||
if (this.initialized) {
|
||||
throw 'Can only init once';
|
||||
}
|
||||
if (bucketListener) {
|
||||
this.addListener(bucketListener);
|
||||
}
|
||||
// uncaught rejection go away
|
||||
this.complete.catch(() => void 0);
|
||||
this.timelineHeight = 0;
|
||||
this.buckets = [];
|
||||
this.assets = [];
|
||||
@ -194,65 +371,118 @@ export class AssetStore {
|
||||
...this.options,
|
||||
key: getKey(),
|
||||
});
|
||||
|
||||
this.buckets = timebuckets.map(
|
||||
(bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }),
|
||||
);
|
||||
this.initializedSignal();
|
||||
this.initialized = true;
|
||||
|
||||
this.buckets = timebuckets.map((bucket) => ({
|
||||
bucketDate: bucket.timeBucket,
|
||||
bucketHeight: 0,
|
||||
bucketCount: bucket.count,
|
||||
assets: [],
|
||||
cancelToken: null,
|
||||
position: BucketPosition.Unknown,
|
||||
}));
|
||||
|
||||
// if loading an asset, the grid-view may be hidden, which means
|
||||
// it has 0 width and height. No need to update bucket or timeline
|
||||
// heights in this case. Later, updateViewport will be called to
|
||||
// update the heights.
|
||||
if (viewport.height !== 0 && viewport.width !== 0) {
|
||||
await this.updateViewport(viewport);
|
||||
}
|
||||
}
|
||||
|
||||
async updateViewport(viewport: Viewport) {
|
||||
public destroy() {
|
||||
this.taskManager.destroy();
|
||||
this.listeners = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async updateViewport(viewport: Viewport, force?: boolean) {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (viewport.height === 0 && viewport.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) {
|
||||
return;
|
||||
}
|
||||
|
||||
// changing width invalidates the actual height, and needs to be remeasured, since width changes causes
|
||||
// layout reflows.
|
||||
const changedWidth = this.viewport.width != viewport.width;
|
||||
this.viewport = { ...viewport };
|
||||
|
||||
for (const bucket of this.buckets) {
|
||||
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / viewport.width);
|
||||
const height = rows * THUMBNAIL_HEIGHT;
|
||||
bucket.bucketHeight = height;
|
||||
this.updateGeometry(bucket, changedWidth);
|
||||
}
|
||||
this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
|
||||
|
||||
let height = 0;
|
||||
const loaders = [];
|
||||
let height = 0;
|
||||
for (const bucket of this.buckets) {
|
||||
if (height < viewport.height) {
|
||||
height += bucket.bucketHeight;
|
||||
loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible));
|
||||
continue;
|
||||
if (height >= viewport.height) {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
height += bucket.bucketHeight;
|
||||
loaders.push(this.loadBucket(bucket.bucketDate));
|
||||
}
|
||||
await Promise.all(loaders);
|
||||
this.notifyListeners({ type: 'viewport' });
|
||||
this.emit(false);
|
||||
}
|
||||
|
||||
async loadBucket(bucketDate: string, position: BucketPosition): Promise<void> {
|
||||
private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
|
||||
if (invalidateHeight) {
|
||||
bucket.isBucketHeightActual = false;
|
||||
bucket.measured = false;
|
||||
for (const assetGroup of bucket.dateGroups) {
|
||||
assetGroup.heightActual = false;
|
||||
}
|
||||
}
|
||||
if (!bucket.isBucketHeightActual) {
|
||||
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / this.viewport.width);
|
||||
const height = 51 + rows * THUMBNAIL_HEIGHT;
|
||||
bucket.bucketHeight = height;
|
||||
}
|
||||
|
||||
for (const assetGroup of bucket.dateGroups) {
|
||||
if (!assetGroup.heightActual) {
|
||||
const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / this.viewport.width);
|
||||
const height = rows * THUMBNAIL_HEIGHT;
|
||||
assetGroup.height = height;
|
||||
}
|
||||
|
||||
const layoutResult = createJustifiedLayout(
|
||||
assetGroup.assets.map((g) => getAssetRatio(g)),
|
||||
{
|
||||
...LAYOUT_OPTIONS,
|
||||
containerWidth: Math.floor(this.viewport.width),
|
||||
},
|
||||
);
|
||||
assetGroup.geometry = {
|
||||
...layoutResult,
|
||||
containerWidth: calculateWidth(layoutResult.boxes),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise<void> {
|
||||
const bucket = this.getBucketByDate(bucketDate);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.position = position;
|
||||
|
||||
if (bucket.cancelToken || bucket.assets.length > 0) {
|
||||
this.emit(false);
|
||||
if (bucket.bucketCount === bucket.assets.length) {
|
||||
// already loaded
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.cancelToken = new AbortController();
|
||||
if (bucket.cancelToken != null && bucket.bucketCount !== bucket.assets.length) {
|
||||
// if promise is pending, and preventCancel is requested, then don't overwrite it
|
||||
if (!bucket.isPreventCancel && options.preventCancel) {
|
||||
bucket.isPreventCancel = options.preventCancel;
|
||||
}
|
||||
await bucket.complete;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.pending) {
|
||||
this.pendingScrollBucket = bucket;
|
||||
}
|
||||
this.notifyListeners({ type: 'load', bucket });
|
||||
bucket.isPreventCancel = !!options.preventCancel;
|
||||
|
||||
const cancelToken = (bucket.cancelToken = new AbortController());
|
||||
try {
|
||||
const assets = await getTimeBucket(
|
||||
{
|
||||
@ -260,9 +490,14 @@ export class AssetStore {
|
||||
timeBucket: bucketDate,
|
||||
key: getKey(),
|
||||
},
|
||||
{ signal: bucket.cancelToken.signal },
|
||||
{ signal: cancelToken.signal },
|
||||
);
|
||||
|
||||
if (cancelToken.signal.aborted) {
|
||||
this.notifyListeners({ type: 'cancel', bucket });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.albumId) {
|
||||
const albumAssets = await getTimeBucket(
|
||||
{
|
||||
@ -271,50 +506,87 @@ export class AssetStore {
|
||||
size: this.options.size,
|
||||
key: getKey(),
|
||||
},
|
||||
{ signal: bucket.cancelToken.signal },
|
||||
{ signal: cancelToken.signal },
|
||||
);
|
||||
|
||||
if (cancelToken.signal.aborted) {
|
||||
this.notifyListeners({ type: 'cancel', bucket });
|
||||
return;
|
||||
}
|
||||
for (const asset of albumAssets) {
|
||||
this.albumAssets.add(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (bucket.cancelToken.signal.aborted) {
|
||||
bucket.assets = assets;
|
||||
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
|
||||
this.updateGeometry(bucket, true);
|
||||
this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
|
||||
bucket.loaded();
|
||||
this.notifyListeners({ type: 'loaded', bucket });
|
||||
} catch (error) {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
if ((error as any).name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.assets = assets;
|
||||
|
||||
this.emit(true);
|
||||
} catch (error) {
|
||||
const $t = get(t);
|
||||
handleError(error, $t('errors.failed_to_load_assets'));
|
||||
bucket.errored();
|
||||
} finally {
|
||||
bucket.cancelToken = null;
|
||||
bucket.cancelToken = undefined;
|
||||
this.emit(true);
|
||||
}
|
||||
}
|
||||
|
||||
cancelBucket(bucket: AssetBucket) {
|
||||
bucket.cancelToken?.abort();
|
||||
}
|
||||
|
||||
updateBucket(bucketDate: string, height: number) {
|
||||
updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) {
|
||||
const bucket = this.getBucketByDate(bucketDate);
|
||||
if (!bucket) {
|
||||
return 0;
|
||||
return {};
|
||||
}
|
||||
let delta = 0;
|
||||
if ('height' in properties) {
|
||||
const height = properties.height!;
|
||||
delta = height - bucket.bucketHeight;
|
||||
bucket.isBucketHeightActual = true;
|
||||
bucket.bucketHeight = height;
|
||||
this.timelineHeight += delta;
|
||||
this.notifyListeners({ type: 'bucket-height', bucket, delta });
|
||||
}
|
||||
if ('intersecting' in properties) {
|
||||
bucket.intersecting = properties.intersecting!;
|
||||
}
|
||||
if ('measured' in properties) {
|
||||
if (properties.measured) {
|
||||
bucket.measuredSignal?.();
|
||||
}
|
||||
bucket.measured = properties.measured!;
|
||||
}
|
||||
|
||||
const delta = height - bucket.bucketHeight;
|
||||
const scrollTimeline = bucket.position == BucketPosition.Above;
|
||||
|
||||
bucket.bucketHeight = height;
|
||||
bucket.position = BucketPosition.Unknown;
|
||||
|
||||
this.timelineHeight += delta;
|
||||
|
||||
this.emit(false);
|
||||
return { delta };
|
||||
}
|
||||
|
||||
return scrollTimeline ? delta : 0;
|
||||
updateBucketDateGroup(
|
||||
bucket: AssetBucket,
|
||||
dateGroup: DateGroup,
|
||||
properties: { height?: number; intersecting?: boolean },
|
||||
) {
|
||||
let delta = 0;
|
||||
if ('height' in properties) {
|
||||
const height = properties.height!;
|
||||
if (height > 0) {
|
||||
delta = height - dateGroup.height;
|
||||
dateGroup.heightActual = true;
|
||||
dateGroup.height = height;
|
||||
this.notifyListeners({ type: 'height', bucket, dateGroup, delta, height });
|
||||
}
|
||||
}
|
||||
if ('intersecting' in properties) {
|
||||
dateGroup.intersecting = properties.intersecting!;
|
||||
if (dateGroup.intersecting) {
|
||||
this.notifyListeners({ type: 'intersecting', bucket, dateGroup });
|
||||
}
|
||||
}
|
||||
this.emit(false);
|
||||
return { delta };
|
||||
}
|
||||
|
||||
addAssets(assets: AssetResponseDto[]) {
|
||||
@ -354,15 +626,7 @@ export class AssetStore {
|
||||
let bucket = this.getBucketByDate(timeBucket);
|
||||
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
bucketDate: timeBucket,
|
||||
bucketHeight: THUMBNAIL_HEIGHT,
|
||||
bucketCount: 0,
|
||||
assets: [],
|
||||
cancelToken: null,
|
||||
position: BucketPosition.Unknown,
|
||||
};
|
||||
|
||||
bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT });
|
||||
this.buckets.push(bucket);
|
||||
}
|
||||
|
||||
@ -383,6 +647,8 @@ export class AssetStore {
|
||||
const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC();
|
||||
return bDate.diff(aDate).milliseconds;
|
||||
});
|
||||
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
|
||||
this.updateGeometry(bucket, true);
|
||||
}
|
||||
|
||||
this.emit(true);
|
||||
@ -392,18 +658,73 @@ export class AssetStore {
|
||||
return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null;
|
||||
}
|
||||
|
||||
async getBucketInfoForAssetId({ id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>) {
|
||||
async findAndLoadBucketAsPending(id: string) {
|
||||
const bucketInfo = this.assetToBucket[id];
|
||||
if (bucketInfo) {
|
||||
return bucketInfo;
|
||||
const bucket = bucketInfo.bucket;
|
||||
this.pendingScrollBucket = bucket;
|
||||
this.pendingScrollAssetId = id;
|
||||
this.emit(false);
|
||||
return bucket;
|
||||
}
|
||||
const asset = await getAssetInfo({ id });
|
||||
if (asset) {
|
||||
if (this.options.isArchived !== asset.isArchived) {
|
||||
return;
|
||||
}
|
||||
const bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true });
|
||||
if (bucket) {
|
||||
this.pendingScrollBucket = bucket;
|
||||
this.pendingScrollAssetId = asset.id;
|
||||
this.emit(false);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
|
||||
/* Must be paired with matching clearPendingScroll() call */
|
||||
async scheduleScrollToAssetId(scrollTarget: AssetGridRouteSearchParams, onFailure: () => void) {
|
||||
try {
|
||||
const { at: assetId } = scrollTarget;
|
||||
if (assetId) {
|
||||
await this.complete;
|
||||
const bucket = await this.findAndLoadBucketAsPending(assetId);
|
||||
if (bucket) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// failure
|
||||
}
|
||||
onFailure();
|
||||
}
|
||||
|
||||
clearPendingScroll() {
|
||||
this.pendingScrollBucket = undefined;
|
||||
this.pendingScrollAssetId = undefined;
|
||||
}
|
||||
|
||||
private async loadBucketAtTime(localDateTime: string, options: { preventCancel?: boolean; pending?: boolean }) {
|
||||
let date = fromLocalDateTime(localDateTime);
|
||||
if (this.options.size == TimeBucketSize.Month) {
|
||||
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||
} else if (this.options.size == TimeBucketSize.Day) {
|
||||
date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||
}
|
||||
await this.loadBucket(date.toISO()!, BucketPosition.Unknown);
|
||||
const iso = date.toISO()!;
|
||||
await this.loadBucket(iso, options);
|
||||
return this.getBucketByDate(iso);
|
||||
}
|
||||
|
||||
private async getBucketInfoForAsset(
|
||||
{ id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>,
|
||||
options: { preventCancel?: boolean; pending?: boolean } = {},
|
||||
) {
|
||||
const bucketInfo = this.assetToBucket[id];
|
||||
if (bucketInfo) {
|
||||
return bucketInfo;
|
||||
}
|
||||
await this.loadBucketAtTime(localDateTime, options);
|
||||
return this.assetToBucket[id] || null;
|
||||
}
|
||||
|
||||
@ -417,7 +738,7 @@ export class AssetStore {
|
||||
);
|
||||
for (const bucket of this.buckets) {
|
||||
if (index < bucket.bucketCount) {
|
||||
await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
||||
await this.loadBucket(bucket.bucketDate);
|
||||
return bucket.assets[index] || null;
|
||||
}
|
||||
|
||||
@ -458,6 +779,7 @@ export class AssetStore {
|
||||
// Iterate in reverse to allow array splicing.
|
||||
for (let index = this.buckets.length - 1; index >= 0; index--) {
|
||||
const bucket = this.buckets[index];
|
||||
let changed = false;
|
||||
for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) {
|
||||
const asset = bucket.assets[index_];
|
||||
if (!idSet.has(asset.id)) {
|
||||
@ -465,17 +787,22 @@ export class AssetStore {
|
||||
}
|
||||
|
||||
bucket.assets.splice(index_, 1);
|
||||
changed = true;
|
||||
if (bucket.assets.length === 0) {
|
||||
this.buckets.splice(index, 1);
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
|
||||
this.updateGeometry(bucket, true);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(true);
|
||||
}
|
||||
|
||||
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
|
||||
const info = await this.getBucketInfoForAssetId(asset);
|
||||
const info = await this.getBucketInfoForAsset(asset);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
@ -491,12 +818,12 @@ export class AssetStore {
|
||||
}
|
||||
|
||||
const previousBucket = this.buckets[bucketIndex - 1];
|
||||
await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown);
|
||||
await this.loadBucket(previousBucket.bucketDate);
|
||||
return previousBucket.assets.at(-1) || null;
|
||||
}
|
||||
|
||||
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
|
||||
const info = await this.getBucketInfoForAssetId(asset);
|
||||
const info = await this.getBucketInfoForAsset(asset);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
@ -512,7 +839,7 @@ export class AssetStore {
|
||||
}
|
||||
|
||||
const nextBucket = this.buckets[bucketIndex + 1];
|
||||
await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown);
|
||||
await this.loadBucket(nextBucket.bucketDate);
|
||||
return nextBucket.assets[0] || null;
|
||||
}
|
||||
|
||||
@ -537,8 +864,7 @@ export class AssetStore {
|
||||
}
|
||||
this.assetToBucket = assetToBucket;
|
||||
}
|
||||
|
||||
this.store$.update(() => this);
|
||||
this.store$.set(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
465
web/src/lib/utils/asset-store-task-manager.ts
Normal file
465
web/src/lib/utils/asset-store-task-manager.ts
Normal file
@ -0,0 +1,465 @@
|
||||
import type { AssetBucket, AssetStore } from '$lib/stores/assets.store';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
|
||||
import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';
|
||||
import { type DateGroup } from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { clamp } from 'lodash-es';
|
||||
|
||||
type Task = () => void;
|
||||
|
||||
class InternalTaskManager {
|
||||
assetStore: AssetStore;
|
||||
componentTasks = new Map<string, Set<string>>();
|
||||
priorityQueue = new KeyedPriorityQueue<string, Task>();
|
||||
idleQueue = new Map<string, Task>();
|
||||
taskCleaners = new Map<string, Task>();
|
||||
|
||||
queueTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
lastIdle: number | undefined;
|
||||
|
||||
constructor(assetStore: AssetStore) {
|
||||
this.assetStore = assetStore;
|
||||
}
|
||||
destroy() {
|
||||
this.componentTasks.clear();
|
||||
this.priorityQueue.clear();
|
||||
this.idleQueue.clear();
|
||||
this.taskCleaners.clear();
|
||||
clearTimeout(this.queueTimer);
|
||||
if (this.lastIdle) {
|
||||
cancelIdleCB(this.lastIdle);
|
||||
}
|
||||
}
|
||||
getOrCreateComponentTasks(componentId: string) {
|
||||
let componentTaskSet = this.componentTasks.get(componentId);
|
||||
if (!componentTaskSet) {
|
||||
componentTaskSet = new Set<string>();
|
||||
this.componentTasks.set(componentId, componentTaskSet);
|
||||
}
|
||||
|
||||
return componentTaskSet;
|
||||
}
|
||||
deleteFromComponentTasks(componentId: string, taskId: string) {
|
||||
if (this.componentTasks.has(componentId)) {
|
||||
const componentTaskSet = this.componentTasks.get(componentId);
|
||||
componentTaskSet?.delete(taskId);
|
||||
if (componentTaskSet?.size === 0) {
|
||||
this.componentTasks.delete(componentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drainIntersectedQueue() {
|
||||
let count = 0;
|
||||
for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) {
|
||||
t.value();
|
||||
if (this.taskCleaners.has(t.key)) {
|
||||
this.taskCleaners.get(t.key)!();
|
||||
this.taskCleaners.delete(t.key);
|
||||
}
|
||||
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
|
||||
this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) {
|
||||
clearTimeout(this.queueTimer);
|
||||
this.queueTimer = setTimeout(() => {
|
||||
const delta = Date.now() - this.assetStore.lastScrollTime;
|
||||
if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
|
||||
let amount = clamp(
|
||||
1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR),
|
||||
1,
|
||||
TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2,
|
||||
);
|
||||
|
||||
const nextDelay = clamp(
|
||||
amount > 1
|
||||
? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR)
|
||||
: TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS,
|
||||
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY,
|
||||
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY,
|
||||
);
|
||||
|
||||
while (amount > 0) {
|
||||
this.priorityQueue.shift()?.value();
|
||||
amount--;
|
||||
}
|
||||
if (this.priorityQueue.length > 0) {
|
||||
this.scheduleDrainIntersectedQueue(nextDelay);
|
||||
}
|
||||
} else {
|
||||
this.drainIntersectedQueue();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
removeAllTasksForComponent(componentId: string) {
|
||||
if (this.componentTasks.has(componentId)) {
|
||||
const tasksIds = this.componentTasks.get(componentId) || [];
|
||||
for (const taskId of tasksIds) {
|
||||
this.priorityQueue.remove(taskId);
|
||||
this.idleQueue.delete(taskId);
|
||||
if (this.taskCleaners.has(taskId)) {
|
||||
const cleanup = this.taskCleaners.get(taskId);
|
||||
this.taskCleaners.delete(taskId);
|
||||
cleanup!();
|
||||
}
|
||||
}
|
||||
}
|
||||
this.componentTasks.delete(componentId);
|
||||
}
|
||||
|
||||
queueScrollSensitiveTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId,
|
||||
priority = 10,
|
||||
taskId = generateId(),
|
||||
}: {
|
||||
task: Task;
|
||||
cleanup?: Task;
|
||||
componentId: string;
|
||||
priority?: number;
|
||||
taskId?: string;
|
||||
}) {
|
||||
this.priorityQueue.push(taskId, task, priority);
|
||||
if (cleanup) {
|
||||
this.taskCleaners.set(taskId, cleanup);
|
||||
}
|
||||
this.getOrCreateComponentTasks(componentId).add(taskId);
|
||||
const lastTime = this.assetStore.lastScrollTime;
|
||||
const delta = Date.now() - lastTime;
|
||||
if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
|
||||
this.scheduleDrainIntersectedQueue();
|
||||
} else {
|
||||
// flush the queue early
|
||||
clearTimeout(this.queueTimer);
|
||||
this.drainIntersectedQueue();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleDrainSeparatedQueue() {
|
||||
if (this.lastIdle) {
|
||||
cancelIdleCB(this.lastIdle);
|
||||
}
|
||||
this.lastIdle = idleCB(
|
||||
() => {
|
||||
let count = 0;
|
||||
let entry = this.idleQueue.entries().next().value;
|
||||
while (entry) {
|
||||
const [taskId, task] = entry;
|
||||
this.idleQueue.delete(taskId);
|
||||
task();
|
||||
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
|
||||
break;
|
||||
}
|
||||
entry = this.idleQueue.entries().next().value;
|
||||
}
|
||||
if (this.idleQueue.size > 0) {
|
||||
this.scheduleDrainSeparatedQueue();
|
||||
}
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
}
|
||||
queueSeparateTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId,
|
||||
taskId,
|
||||
}: {
|
||||
task: Task;
|
||||
cleanup: Task;
|
||||
componentId: string;
|
||||
taskId: string;
|
||||
}) {
|
||||
this.idleQueue.set(taskId, task);
|
||||
this.taskCleaners.set(taskId, cleanup);
|
||||
this.getOrCreateComponentTasks(componentId).add(taskId);
|
||||
this.scheduleDrainSeparatedQueue();
|
||||
}
|
||||
|
||||
removeIntersectedTask(taskId: string) {
|
||||
const removed = this.priorityQueue.remove(taskId);
|
||||
if (this.taskCleaners.has(taskId)) {
|
||||
const cleanup = this.taskCleaners.get(taskId);
|
||||
this.taskCleaners.delete(taskId);
|
||||
cleanup!();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
removeSeparateTask(taskId: string) {
|
||||
const removed = this.idleQueue.delete(taskId);
|
||||
if (this.taskCleaners.has(taskId)) {
|
||||
const cleanup = this.taskCleaners.get(taskId);
|
||||
this.taskCleaners.delete(taskId);
|
||||
cleanup!();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetGridTaskManager {
|
||||
private internalManager: InternalTaskManager;
|
||||
constructor(assetStore: AssetStore) {
|
||||
this.internalManager = new InternalTaskManager(assetStore);
|
||||
}
|
||||
|
||||
tasks: Map<AssetBucket, BucketTask> = new Map();
|
||||
|
||||
queueScrollSensitiveTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId,
|
||||
priority = 10,
|
||||
taskId = generateId(),
|
||||
}: {
|
||||
task: Task;
|
||||
cleanup?: Task;
|
||||
componentId: string;
|
||||
priority?: number;
|
||||
taskId?: string;
|
||||
}) {
|
||||
return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId });
|
||||
}
|
||||
|
||||
removeAllTasksForComponent(componentId: string) {
|
||||
return this.internalManager.removeAllTasksForComponent(componentId);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
return this.internalManager.destroy();
|
||||
}
|
||||
|
||||
private getOrCreateBucketTask(bucket: AssetBucket) {
|
||||
let bucketTask = this.tasks.get(bucket);
|
||||
if (!bucketTask) {
|
||||
bucketTask = this.createBucketTask(bucket);
|
||||
}
|
||||
return bucketTask;
|
||||
}
|
||||
|
||||
private createBucketTask(bucket: AssetBucket) {
|
||||
const bucketTask = new BucketTask(this.internalManager, this, bucket);
|
||||
this.tasks.set(bucket, bucketTask);
|
||||
return bucketTask;
|
||||
}
|
||||
|
||||
intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(bucket);
|
||||
bucketTask.scheduleIntersected(componentId, task);
|
||||
}
|
||||
|
||||
seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(bucket);
|
||||
bucketTask.scheduleSeparated(componentId, seperated);
|
||||
}
|
||||
|
||||
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
|
||||
}
|
||||
|
||||
seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
bucketTask.separatedDateGroup(componentId, dateGroup, seperated);
|
||||
}
|
||||
|
||||
intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
|
||||
}
|
||||
|
||||
seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.separatedThumbnail(componentId, asset, seperated);
|
||||
}
|
||||
}
|
||||
|
||||
class IntersectionTask {
|
||||
internalTaskManager: InternalTaskManager;
|
||||
seperatedKey;
|
||||
intersectedKey;
|
||||
priority;
|
||||
|
||||
intersected: Task | undefined;
|
||||
separated: Task | undefined;
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
|
||||
this.internalTaskManager = internalTaskManager;
|
||||
this.seperatedKey = keyPrefix + ':s:' + key;
|
||||
this.intersectedKey = keyPrefix + ':i:' + key;
|
||||
this.priority = priority;
|
||||
}
|
||||
|
||||
trackIntersectedTask(componentId: string, task: Task) {
|
||||
const execTask = () => {
|
||||
if (this.separated) {
|
||||
return;
|
||||
}
|
||||
task?.();
|
||||
};
|
||||
this.intersected = execTask;
|
||||
const cleanup = () => {
|
||||
this.intersected = undefined;
|
||||
this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey);
|
||||
};
|
||||
return { task: execTask, cleanup };
|
||||
}
|
||||
|
||||
trackSeperatedTask(componentId: string, task: Task) {
|
||||
const execTask = () => {
|
||||
if (this.intersected) {
|
||||
return;
|
||||
}
|
||||
task?.();
|
||||
};
|
||||
this.separated = execTask;
|
||||
const cleanup = () => {
|
||||
this.separated = undefined;
|
||||
this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey);
|
||||
};
|
||||
return { task: execTask, cleanup };
|
||||
}
|
||||
|
||||
removePendingSeparated() {
|
||||
if (this.separated) {
|
||||
this.internalTaskManager.removeSeparateTask(this.seperatedKey);
|
||||
}
|
||||
}
|
||||
removePendingIntersected() {
|
||||
if (this.intersected) {
|
||||
this.internalTaskManager.removeIntersectedTask(this.intersectedKey);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleIntersected(componentId: string, intersected: Task) {
|
||||
this.removePendingSeparated();
|
||||
if (this.intersected) {
|
||||
return;
|
||||
}
|
||||
const { task, cleanup } = this.trackIntersectedTask(componentId, intersected);
|
||||
this.internalTaskManager.queueScrollSensitiveTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId: componentId,
|
||||
priority: this.priority,
|
||||
taskId: this.intersectedKey,
|
||||
});
|
||||
}
|
||||
|
||||
scheduleSeparated(componentId: string, separated: Task) {
|
||||
this.removePendingIntersected();
|
||||
|
||||
if (this.separated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { task, cleanup } = this.trackSeperatedTask(componentId, separated);
|
||||
this.internalTaskManager.queueSeparateTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId: componentId,
|
||||
taskId: this.seperatedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
class BucketTask extends IntersectionTask {
|
||||
assetBucket: AssetBucket;
|
||||
assetGridTaskManager: AssetGridTaskManager;
|
||||
// indexed by dateGroup's date
|
||||
dateTasks: Map<DateGroup, DateGroupTask> = new Map();
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) {
|
||||
super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY);
|
||||
this.assetBucket = assetBucket;
|
||||
this.assetGridTaskManager = parent;
|
||||
}
|
||||
|
||||
getOrCreateDateGroupTask(dateGroup: DateGroup) {
|
||||
let dateGroupTask = this.dateTasks.get(dateGroup);
|
||||
if (!dateGroupTask) {
|
||||
dateGroupTask = this.createDateGroupTask(dateGroup);
|
||||
}
|
||||
return dateGroupTask;
|
||||
}
|
||||
|
||||
createDateGroupTask(dateGroup: DateGroup) {
|
||||
const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup);
|
||||
this.dateTasks.set(dateGroup, dateGroupTask);
|
||||
return dateGroupTask;
|
||||
}
|
||||
|
||||
removePendingSeparated() {
|
||||
super.removePendingSeparated();
|
||||
for (const dateGroupTask of this.dateTasks.values()) {
|
||||
dateGroupTask.removePendingSeparated();
|
||||
}
|
||||
}
|
||||
|
||||
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
|
||||
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.scheduleIntersected(componentId, intersected);
|
||||
}
|
||||
|
||||
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
|
||||
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.scheduleSeparated(componentId, separated);
|
||||
}
|
||||
}
|
||||
class DateGroupTask extends IntersectionTask {
|
||||
dateGroup: DateGroup;
|
||||
bucketTask: BucketTask;
|
||||
// indexed by thumbnail's asset
|
||||
thumbnailTasks: Map<AssetResponseDto, ThumbnailTask> = new Map();
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) {
|
||||
super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY);
|
||||
this.dateGroup = dateGroup;
|
||||
this.bucketTask = parent;
|
||||
}
|
||||
|
||||
removePendingSeparated() {
|
||||
super.removePendingSeparated();
|
||||
for (const thumbnailTask of this.thumbnailTasks.values()) {
|
||||
thumbnailTask.removePendingSeparated();
|
||||
}
|
||||
}
|
||||
|
||||
getOrCreateThumbnailTask(asset: AssetResponseDto) {
|
||||
let thumbnailTask = this.thumbnailTasks.get(asset);
|
||||
if (!thumbnailTask) {
|
||||
thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset);
|
||||
this.thumbnailTasks.set(asset, thumbnailTask);
|
||||
}
|
||||
return thumbnailTask;
|
||||
}
|
||||
|
||||
intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) {
|
||||
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
|
||||
thumbnailTask.scheduleIntersected(componentId, intersected);
|
||||
}
|
||||
|
||||
separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) {
|
||||
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
|
||||
thumbnailTask.scheduleSeparated(componentId, seperated);
|
||||
}
|
||||
}
|
||||
class ThumbnailTask extends IntersectionTask {
|
||||
asset: AssetResponseDto;
|
||||
dateGroupTask: DateGroupTask;
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) {
|
||||
super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY);
|
||||
this.asset = asset;
|
||||
this.dateGroupTask = parent;
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@ import { NotificationType, notificationController } from '$lib/components/shared
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
|
||||
import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
|
||||
import { downloadManager } from '$lib/stores/download';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { downloadRequest, getKey, withError } from '$lib/utils';
|
||||
@ -403,7 +403,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt
|
||||
|
||||
try {
|
||||
for (const bucket of assetStore.buckets) {
|
||||
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
||||
await assetStore.loadBucket(bucket.bucketDate);
|
||||
|
||||
if (!get(isSelectingAllAssets)) {
|
||||
break; // Cancelled
|
||||
|
20
web/src/lib/utils/idle-callback-support.ts
Normal file
20
web/src/lib/utils/idle-callback-support.ts
Normal file
@ -0,0 +1,20 @@
|
||||
interface RequestIdleCallback {
|
||||
didTimeout?: boolean;
|
||||
timeRemaining?(): DOMHighResTimeStamp;
|
||||
}
|
||||
interface RequestIdleCallbackOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) {
|
||||
const start = Date.now();
|
||||
return setTimeout(cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) }), 100);
|
||||
}
|
||||
|
||||
function fake_cancelIdleCallback(id: number) {
|
||||
return clearTimeout(id);
|
||||
}
|
||||
|
||||
export const idleCB = window.requestIdleCallback || fake_requestIdleCallback;
|
||||
export const cancelIdleCB = window.cancelIdleCallback || fake_cancelIdleCallback;
|
50
web/src/lib/utils/keyed-priority-queue.ts
Normal file
50
web/src/lib/utils/keyed-priority-queue.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export class KeyedPriorityQueue<K, T> {
|
||||
private items: { key: K; value: T; priority: number }[] = [];
|
||||
private set: Set<K> = new Set();
|
||||
|
||||
clear() {
|
||||
this.items = [];
|
||||
this.set.clear();
|
||||
}
|
||||
|
||||
remove(key: K) {
|
||||
const removed = this.set.delete(key);
|
||||
if (removed) {
|
||||
const idx = this.items.findIndex((i) => i.key === key);
|
||||
if (idx >= 0) {
|
||||
this.items.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
push(key: K, value: T, priority: number) {
|
||||
if (this.set.has(key)) {
|
||||
return this.length;
|
||||
}
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (this.items[i].priority > priority) {
|
||||
this.set.add(key);
|
||||
this.items.splice(i, 0, { key, value, priority });
|
||||
return this.length;
|
||||
}
|
||||
}
|
||||
this.set.add(key);
|
||||
return this.items.push({ key, value, priority });
|
||||
}
|
||||
|
||||
shift() {
|
||||
let item = this.items.shift();
|
||||
while (item) {
|
||||
if (this.set.has(item.key)) {
|
||||
this.set.delete(item.key);
|
||||
return item;
|
||||
}
|
||||
item = this.items.shift();
|
||||
}
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.set.size;
|
||||
}
|
||||
}
|
@ -5,6 +5,9 @@ import { getAssetInfo } from '@immich/sdk';
|
||||
import type { NavigationTarget } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export type AssetGridRouteSearchParams = {
|
||||
at: string | null | undefined;
|
||||
};
|
||||
export const isExternalUrl = (url: string): boolean => {
|
||||
return new URL(url, window.location.href).origin !== window.location.origin;
|
||||
};
|
||||
@ -33,17 +36,38 @@ function currentUrlWithoutAsset() {
|
||||
|
||||
export function currentUrlReplaceAssetId(assetId: string) {
|
||||
const $page = get(page);
|
||||
const params = new URLSearchParams($page.url.search);
|
||||
// always remove the assetGridScrollTargetParams
|
||||
params.delete('at');
|
||||
const searchparams = params.size > 0 ? '?' + params.toString() : '';
|
||||
// this contains special casing for the /photos/:assetId photos route, which hangs directly
|
||||
// off / instead of a subpath, unlike every other asset-containing route.
|
||||
return isPhotosRoute($page.route.id)
|
||||
? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}`
|
||||
: `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`;
|
||||
? `${AppRoute.PHOTOS}/${assetId}${searchparams}`
|
||||
: `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${searchparams}`;
|
||||
}
|
||||
|
||||
function replaceScrollTarget(url: string, searchParams?: AssetGridRouteSearchParams | null) {
|
||||
const $page = get(page);
|
||||
const parsed = new URL(url, $page.url);
|
||||
|
||||
const { at: assetId } = searchParams || { at: null };
|
||||
|
||||
if (!assetId) {
|
||||
return parsed.pathname;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams($page.url.search);
|
||||
if (assetId) {
|
||||
params.set('at', assetId);
|
||||
}
|
||||
return parsed.pathname + '?' + params.toString();
|
||||
}
|
||||
|
||||
function currentUrl() {
|
||||
const $page = get(page);
|
||||
const current = $page.url;
|
||||
return current.pathname + current.search;
|
||||
return current.pathname + current.search + current.hash;
|
||||
}
|
||||
|
||||
interface Route {
|
||||
@ -55,24 +79,58 @@ interface Route {
|
||||
|
||||
interface AssetRoute extends Route {
|
||||
targetRoute: 'current';
|
||||
assetId: string | null;
|
||||
assetId: string | null | undefined;
|
||||
}
|
||||
interface AssetGridRoute extends Route {
|
||||
targetRoute: 'current';
|
||||
assetId: string | null | undefined;
|
||||
assetGridRouteSearchParams: AssetGridRouteSearchParams | null | undefined;
|
||||
}
|
||||
|
||||
type ImmichRoute = AssetRoute | AssetGridRoute;
|
||||
|
||||
type NavOptions = {
|
||||
/* navigate even if url is the same */
|
||||
forceNavigate?: boolean | undefined;
|
||||
replaceState?: boolean | undefined;
|
||||
noScroll?: boolean | undefined;
|
||||
keepFocus?: boolean | undefined;
|
||||
invalidateAll?: boolean | undefined;
|
||||
state?: App.PageState | undefined;
|
||||
};
|
||||
|
||||
function isAssetRoute(route: Route): route is AssetRoute {
|
||||
return route.targetRoute === 'current' && 'assetId' in route;
|
||||
}
|
||||
|
||||
async function navigateAssetRoute(route: AssetRoute) {
|
||||
function isAssetGridRoute(route: Route): route is AssetGridRoute {
|
||||
return route.targetRoute === 'current' && 'assetId' in route && 'assetGridRouteSearchParams' in route;
|
||||
}
|
||||
|
||||
async function navigateAssetRoute(route: AssetRoute, options?: NavOptions) {
|
||||
const { assetId } = route;
|
||||
const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
|
||||
if (next !== currentUrl()) {
|
||||
await goto(next, { replaceState: false });
|
||||
const current = currentUrl();
|
||||
if (next !== current || options?.forceNavigate) {
|
||||
await goto(next, options);
|
||||
}
|
||||
}
|
||||
|
||||
export function navigate<T extends Route>(change: T): Promise<void> {
|
||||
if (isAssetRoute(change)) {
|
||||
return navigateAssetRoute(change);
|
||||
async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) {
|
||||
const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route;
|
||||
const assetUrl = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
|
||||
const next = replaceScrollTarget(assetUrl, assetGridScrollTarget);
|
||||
const current = currentUrl();
|
||||
if (next !== current || options?.forceNavigate) {
|
||||
await goto(next, options);
|
||||
}
|
||||
}
|
||||
|
||||
export function navigate(change: ImmichRoute, options?: NavOptions): Promise<void> {
|
||||
if (isAssetGridRoute(change)) {
|
||||
return navigateAssetGridRoute(change, options);
|
||||
} else if (isAssetRoute(change)) {
|
||||
return navigateAssetRoute(change, options);
|
||||
}
|
||||
// future navigation requests here
|
||||
throw `Invalid navigation: ${JSON.stringify(change)}`;
|
||||
|
21
web/src/lib/utils/priority-queue.ts
Normal file
21
web/src/lib/utils/priority-queue.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export class PriorityQueue<T> {
|
||||
private items: { value: T; priority: number }[] = [];
|
||||
|
||||
push(value: T, priority: number) {
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (this.items[i].priority > priority) {
|
||||
this.items.splice(i, 0, { value, priority });
|
||||
return this.length;
|
||||
}
|
||||
}
|
||||
return this.items.push({ value, priority });
|
||||
}
|
||||
|
||||
shift() {
|
||||
return this.items.shift();
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.items.length;
|
||||
}
|
||||
}
|
@ -1,9 +1,38 @@
|
||||
import type { AssetBucket } from '$lib/stores/assets.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { groupBy, sortBy } from 'lodash-es';
|
||||
import type createJustifiedLayout from 'justified-layout';
|
||||
import { groupBy, memoize, sortBy } from 'lodash-es';
|
||||
import { DateTime } from 'luxon';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export type DateGroup = {
|
||||
date: DateTime;
|
||||
groupTitle: string;
|
||||
assets: AssetResponseDto[];
|
||||
height: number;
|
||||
heightActual: boolean;
|
||||
intersecting: boolean;
|
||||
geometry: Geometry;
|
||||
bucket: AssetBucket;
|
||||
};
|
||||
export type ScrubberListener = (
|
||||
bucketDate: string | undefined,
|
||||
overallScrollPercent: number,
|
||||
bucketScrollPercent: number,
|
||||
) => void | Promise<void>;
|
||||
export type ScrollTargetListener = ({
|
||||
bucket,
|
||||
dateGroup,
|
||||
asset,
|
||||
offset,
|
||||
}: {
|
||||
bucket: AssetBucket;
|
||||
dateGroup: DateGroup;
|
||||
asset: AssetResponseDto;
|
||||
offset: number;
|
||||
}) => void;
|
||||
|
||||
export const fromLocalDateTime = (localDateTime: string) =>
|
||||
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
|
||||
|
||||
@ -48,20 +77,48 @@ export function formatGroupTitle(_date: DateTime): string {
|
||||
return date.toLocaleString(groupDateFormat);
|
||||
}
|
||||
|
||||
export function splitBucketIntoDateGroups(
|
||||
assets: AssetResponseDto[],
|
||||
locale: string | undefined,
|
||||
): AssetResponseDto[][] {
|
||||
const grouped = groupBy(assets, (asset) =>
|
||||
type Geometry = ReturnType<typeof createJustifiedLayout> & {
|
||||
containerWidth: number;
|
||||
};
|
||||
|
||||
function emptyGeometry() {
|
||||
return {
|
||||
containerWidth: 0,
|
||||
containerHeight: 0,
|
||||
widowCount: 0,
|
||||
boxes: [],
|
||||
};
|
||||
}
|
||||
|
||||
const formatDateGroupTitle = memoize(formatGroupTitle);
|
||||
|
||||
export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
|
||||
const grouped = groupBy(bucket.assets, (asset) =>
|
||||
fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }),
|
||||
);
|
||||
return sortBy(grouped, (group) => assets.indexOf(group[0]));
|
||||
const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0]));
|
||||
return sorted.map((group) => {
|
||||
const date = fromLocalDateTime(group[0].localDateTime).startOf('day');
|
||||
return {
|
||||
date,
|
||||
groupTitle: formatDateGroupTitle(date),
|
||||
assets: group,
|
||||
height: 0,
|
||||
heightActual: false,
|
||||
intersecting: false,
|
||||
geometry: emptyGeometry(),
|
||||
bucket: bucket,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type LayoutBox = {
|
||||
aspectRatio: number;
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
forcedAspectRatio?: boolean;
|
||||
};
|
||||
|
||||
export function calculateWidth(boxes: LayoutBox[]): number {
|
||||
@ -71,6 +128,14 @@ export function calculateWidth(boxes: LayoutBox[]): number {
|
||||
width = box.left + box.width;
|
||||
}
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
|
||||
let offset = 0;
|
||||
while (element.offsetParent && element !== stop) {
|
||||
offset += element.offsetTop;
|
||||
element = element.offsetParent as HTMLElement;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
63
web/src/lib/utils/tunables.ts
Normal file
63
web/src/lib/utils/tunables.ts
Normal file
@ -0,0 +1,63 @@
|
||||
function getBoolean(string: string | null, fallback: boolean) {
|
||||
if (string === null) {
|
||||
return fallback;
|
||||
}
|
||||
return 'true' === string;
|
||||
}
|
||||
function getNumber(string: string | null, fallback: number) {
|
||||
if (string === null) {
|
||||
return fallback;
|
||||
}
|
||||
return Number.parseInt(string);
|
||||
}
|
||||
function getFloat(string: string | null, fallback: number) {
|
||||
if (string === null) {
|
||||
return fallback;
|
||||
}
|
||||
return Number.parseFloat(string);
|
||||
}
|
||||
export const TUNABLES = {
|
||||
SCROLL_TASK_QUEUE: {
|
||||
TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25),
|
||||
TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5),
|
||||
TRICKLE_ACCELERATED_MIN_DELAY: getNumber(
|
||||
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'),
|
||||
8,
|
||||
),
|
||||
TRICKLE_ACCELERATED_MAX_DELAY: getNumber(
|
||||
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'),
|
||||
2000,
|
||||
),
|
||||
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15),
|
||||
DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16),
|
||||
MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200),
|
||||
CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16),
|
||||
},
|
||||
INTERSECTION_OBSERVER_QUEUE: {
|
||||
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15),
|
||||
THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16),
|
||||
THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true),
|
||||
},
|
||||
ASSET_GRID: {
|
||||
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
|
||||
},
|
||||
BUCKET: {
|
||||
PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2),
|
||||
INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%',
|
||||
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%',
|
||||
},
|
||||
DATEGROUP: {
|
||||
PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4),
|
||||
INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false),
|
||||
INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%',
|
||||
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%',
|
||||
},
|
||||
THUMBNAIL: {
|
||||
PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8),
|
||||
INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%',
|
||||
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%',
|
||||
},
|
||||
IMAGE_THUMBNAIL: {
|
||||
THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150),
|
||||
},
|
||||
};
|
@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
let { isViewing: showAssetViewer, setAsset } = assetViewingStore;
|
||||
|
||||
// This block takes care of opening the viewer.
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
|
||||
|
||||
// $page.data.asset is loaded by route specific +page.ts loaders if that
|
||||
// route contains the assetId path.
|
||||
$: {
|
||||
@ -13,6 +13,8 @@
|
||||
} else {
|
||||
$showAssetViewer = false;
|
||||
}
|
||||
const asset = $page.url.searchParams.get('at');
|
||||
$gridScrollTarget = { at: asset };
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -43,7 +43,13 @@
|
||||
import { downloadAlbum } from '$lib/utils/asset-utils';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
|
||||
import {
|
||||
isAlbumsRoute,
|
||||
isPeopleRoute,
|
||||
isSearchRoute,
|
||||
navigate,
|
||||
type AssetGridRouteSearchParams,
|
||||
} from '$lib/utils/navigation';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetOrder,
|
||||
@ -78,12 +84,15 @@
|
||||
import type { PageData } from './$types';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let { isViewing: showAssetViewer, setAsset } = assetViewingStore;
|
||||
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
|
||||
let { slideshowState, slideshowNavigation } = slideshowStore;
|
||||
|
||||
let oldAt: AssetGridRouteSearchParams | null | undefined;
|
||||
|
||||
$: album = data.album;
|
||||
$: albumId = album.id;
|
||||
$: albumKey = `${albumId}_${albumOrder}`;
|
||||
@ -244,7 +253,7 @@
|
||||
}
|
||||
|
||||
if (viewMode === ViewMode.SELECT_ASSETS) {
|
||||
handleCloseSelectAssets();
|
||||
await handleCloseSelectAssets();
|
||||
return;
|
||||
}
|
||||
if (viewMode === ViewMode.LINK_SHARING) {
|
||||
@ -289,20 +298,37 @@
|
||||
|
||||
timelineInteractionStore.clearMultiselect();
|
||||
viewMode = ViewMode.VIEW;
|
||||
await navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||
{ replaceState: true, forceNavigate: true },
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_adding_assets_to_album'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSelectAssets = () => {
|
||||
const setModeToView = async () => {
|
||||
viewMode = ViewMode.VIEW;
|
||||
assetStore.destroy();
|
||||
assetStore = new AssetStore({ albumId, order: albumOrder });
|
||||
timelineStore.destroy();
|
||||
timelineStore = new AssetStore({ isArchived: false }, albumId);
|
||||
await navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } },
|
||||
{ replaceState: true, forceNavigate: true },
|
||||
);
|
||||
oldAt = null;
|
||||
};
|
||||
|
||||
const handleCloseSelectAssets = async () => {
|
||||
timelineInteractionStore.clearMultiselect();
|
||||
await setModeToView();
|
||||
};
|
||||
|
||||
const handleSelectFromComputer = async () => {
|
||||
await openFileUploadDialog({ albumId: album.id });
|
||||
timelineInteractionStore.clearMultiselect();
|
||||
viewMode = ViewMode.VIEW;
|
||||
await setModeToView();
|
||||
};
|
||||
|
||||
const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
|
||||
@ -400,6 +426,11 @@
|
||||
await deleteAlbum(album);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore.destroy();
|
||||
timelineStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex overflow-hidden" bind:clientWidth={globalWidth}>
|
||||
@ -444,7 +475,14 @@
|
||||
{#if isEditor}
|
||||
<CircleIconButton
|
||||
title={$t('add_photos')}
|
||||
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
|
||||
on:click={async () => {
|
||||
viewMode = ViewMode.SELECT_ASSETS;
|
||||
oldAt = { at: $gridScrollTarget?.at };
|
||||
await navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } },
|
||||
{ replaceState: true },
|
||||
);
|
||||
}}
|
||||
icon={mdiImagePlusOutline}
|
||||
/>
|
||||
{/if}
|
||||
@ -530,12 +568,14 @@
|
||||
{#key albumKey}
|
||||
{#if viewMode === ViewMode.SELECT_ASSETS}
|
||||
<AssetGrid
|
||||
enableRouting={false}
|
||||
assetStore={timelineStore}
|
||||
assetInteractionStore={timelineInteractionStore}
|
||||
isSelectionMode={true}
|
||||
/>
|
||||
{:else}
|
||||
<AssetGrid
|
||||
enableRouting={true}
|
||||
{album}
|
||||
{assetStore}
|
||||
{assetInteractionStore}
|
||||
|
@ -17,6 +17,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@ -25,6 +26,10 @@
|
||||
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
||||
|
||||
$: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $isMultiSelectState}
|
||||
@ -45,7 +50,7 @@
|
||||
{/if}
|
||||
|
||||
<UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
|
||||
<AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
|
||||
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
|
||||
<EmptyPlaceholder text={$t('no_archived_assets_message')} slot="empty" />
|
||||
</AssetGrid>
|
||||
</UserPageLayout>
|
||||
|
@ -19,6 +19,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@ -27,6 +28,10 @@
|
||||
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
||||
|
||||
$: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived);
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Multiselection mode app bar -->
|
||||
@ -50,7 +55,7 @@
|
||||
{/if}
|
||||
|
||||
<UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
|
||||
<AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}>
|
||||
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}>
|
||||
<EmptyPlaceholder text={$t('no_favorites_message')} slot="empty" />
|
||||
</AssetGrid>
|
||||
</UserPageLayout>
|
||||
|
@ -124,7 +124,10 @@
|
||||
showNavigation={viewingAssets.length > 1}
|
||||
on:next={navigateNext}
|
||||
on:previous={navigatePrevious}
|
||||
on:close={() => assetViewingStore.showAssetViewer(false)}
|
||||
on:close={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
isShared={false}
|
||||
/>
|
||||
{/await}
|
||||
|
@ -23,6 +23,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
assetInteractionStore.clearMultiselect();
|
||||
assetStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -45,5 +46,5 @@
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
<AssetGrid {assetStore} {assetInteractionStore} />
|
||||
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} />
|
||||
</main>
|
||||
|
@ -52,7 +52,7 @@
|
||||
mdiEyeOutline,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { listNavigation } from '$lib/actions/list-navigation';
|
||||
import { t } from 'svelte-i18n';
|
||||
@ -155,6 +155,7 @@
|
||||
}
|
||||
if (previousPersonId !== data.person.id) {
|
||||
handlePromiseError(updateAssetCount());
|
||||
assetStore.destroy();
|
||||
assetStore = new AssetStore({
|
||||
isArchived: false,
|
||||
personId: data.person.id,
|
||||
@ -344,6 +345,10 @@
|
||||
await goto($page.url);
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if viewMode === ViewMode.UNASSIGN_ASSETS}
|
||||
@ -442,6 +447,7 @@
|
||||
<main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
|
||||
{#key refreshAssetGrid}
|
||||
<AssetGrid
|
||||
enableRouting={true}
|
||||
{assetStore}
|
||||
{assetInteractionStore}
|
||||
isSelectionMode={viewMode === ViewMode.SELECT_PERSON}
|
||||
|
@ -24,6 +24,7 @@
|
||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
|
||||
@ -48,6 +49,10 @@
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $isMultiSelectState}
|
||||
@ -84,6 +89,7 @@
|
||||
|
||||
<UserPageLayout hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
|
||||
<AssetGrid
|
||||
enableRouting={true}
|
||||
{assetStore}
|
||||
{assetInteractionStore}
|
||||
removeAction={AssetAction.ARCHIVE}
|
||||
|
@ -291,7 +291,7 @@
|
||||
<GalleryViewer
|
||||
assets={searchResultAssets}
|
||||
bind:selectedAssets
|
||||
on:intersected={loadNextPage}
|
||||
onIntersected={loadNextPage}
|
||||
showArchiveIcon={true}
|
||||
{viewport}
|
||||
/>
|
||||
|
@ -11,8 +11,13 @@
|
||||
import type { PageData } from './$types';
|
||||
import { setSharedLink } from '$lib/utils';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let { gridScrollTarget } = assetViewingStore;
|
||||
let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data;
|
||||
let { title, description } = meta;
|
||||
let isOwned = $user ? $user.id === sharedLink?.userId : false;
|
||||
@ -29,6 +34,11 @@
|
||||
description =
|
||||
sharedLink.description ||
|
||||
$t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
|
||||
await tick();
|
||||
await navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||
{ forceNavigate: true, replaceState: true },
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_get_shared_link'));
|
||||
}
|
||||
|
@ -25,6 +25,7 @@
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@ -84,6 +85,10 @@
|
||||
handleError(error, $t('errors.unable_to_restore_trash'));
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $isMultiSelectState}
|
||||
@ -111,7 +116,7 @@
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<AssetGrid {assetStore} {assetInteractionStore}>
|
||||
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore}>
|
||||
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
|
||||
{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}
|
||||
</p>
|
||||
|
BIN
web/static/dark_skeleton.png
Normal file
BIN
web/static/dark_skeleton.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.9 KiB |
BIN
web/static/light_skeleton.png
Normal file
BIN
web/static/light_skeleton.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.9 KiB |
Loading…
Reference in New Issue
Block a user