mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 02:13:51 -04: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 }) => {
|
test('download from a shared link', async ({ page }) => {
|
||||||
await page.goto(`/share/${sharedLink.key}`);
|
await page.goto(`/share/${sharedLink.key}`);
|
||||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
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.waitForSelector('#asset-group-by-date svg');
|
||||||
await page.getByRole('checkbox').click();
|
await page.getByRole('checkbox').click();
|
||||||
await page.getByRole('button', { name: 'Download' }).click();
|
await page.getByRole('button', { name: 'Download' }).click();
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
|
export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
textarea.style.height = height;
|
textarea.style.height = height;
|
||||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
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 { handlePromiseError } from '$lib/utils';
|
||||||
import AlbumSummary from './album-summary.svelte';
|
import AlbumSummary from './album-summary.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
export let sharedLink: SharedLinkResponseDto;
|
export let sharedLink: SharedLinkResponseDto;
|
||||||
export let user: UserResponseDto | undefined = undefined;
|
export let user: UserResponseDto | undefined = undefined;
|
||||||
@ -38,6 +39,9 @@
|
|||||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
onDestroy(() => {
|
||||||
|
assetStore.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@ -94,7 +98,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
|
<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">
|
<section class="pt-8 md:pt-24">
|
||||||
<!-- ALBUM TITLE -->
|
<!-- ALBUM TITLE -->
|
||||||
<h1
|
<h1
|
||||||
|
@ -15,7 +15,6 @@
|
|||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
|
import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
|
||||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
@ -70,7 +69,8 @@
|
|||||||
} = slideshowStore;
|
} = slideshowStore;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
close: void;
|
action: { type: AssetAction; asset: AssetResponseDto };
|
||||||
|
close: { asset: AssetResponseDto };
|
||||||
next: void;
|
next: void;
|
||||||
previous: void;
|
previous: void;
|
||||||
}>();
|
}>();
|
||||||
@ -201,7 +201,6 @@
|
|||||||
websocketEvents.on('on_asset_update', onAssetUpdate),
|
websocketEvents.on('on_asset_update', onAssetUpdate),
|
||||||
);
|
);
|
||||||
|
|
||||||
await navigate({ targetRoute: 'current', assetId: asset.id });
|
|
||||||
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||||
if (value === SlideshowState.PlaySlideshow) {
|
if (value === SlideshowState.PlaySlideshow) {
|
||||||
slideshowHistory.reset();
|
slideshowHistory.reset();
|
||||||
@ -268,9 +267,8 @@
|
|||||||
$isShowDetail = !$isShowDetail;
|
$isShowDetail = !$isShowDetail;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeViewer = async () => {
|
const closeViewer = () => {
|
||||||
dispatch('close');
|
dispatch('close', { asset });
|
||||||
await navigate({ targetRoute: 'current', assetId: null });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditor = () => {
|
const closeEditor = () => {
|
||||||
@ -378,9 +376,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => {
|
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||||
const { isMouseOver } = e.detail;
|
|
||||||
|
|
||||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -392,8 +388,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
case AssetAction.UNSTACK: {
|
case AssetAction.UNSTACK: {
|
||||||
await closeViewer();
|
closeViewer();
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -585,12 +580,11 @@
|
|||||||
? 'bg-transparent border-2 border-white'
|
? 'bg-transparent border-2 border-white'
|
||||||
: 'bg-gray-700/40'} inline-block hover:bg-transparent"
|
: 'bg-gray-700/40'} inline-block hover:bg-transparent"
|
||||||
asset={stackedAsset}
|
asset={stackedAsset}
|
||||||
onClick={(stackedAsset, event) => {
|
onClick={(stackedAsset) => {
|
||||||
event.preventDefault();
|
|
||||||
asset = stackedAsset;
|
asset = stackedAsset;
|
||||||
preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]];
|
preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]];
|
||||||
}}
|
}}
|
||||||
on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
|
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||||
readonly
|
readonly
|
||||||
thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
|
thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
|
||||||
showStackedIcon={false}
|
showStackedIcon={false}
|
||||||
|
@ -212,7 +212,6 @@
|
|||||||
title={person.name}
|
title={person.name}
|
||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
thumbhash={null}
|
|
||||||
hidden={person.isHidden}
|
hidden={person.isHidden}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { AssetTypeEnum, type AssetResponseDto, AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
|
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
|
||||||
import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard';
|
import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
@ -33,6 +33,7 @@
|
|||||||
let imageLoaded: boolean = false;
|
let imageLoaded: boolean = false;
|
||||||
let imageError: boolean = false;
|
let imageError: boolean = false;
|
||||||
let forceUseOriginal: boolean = false;
|
let forceUseOriginal: boolean = false;
|
||||||
|
let loader: HTMLImageElement;
|
||||||
|
|
||||||
$: isWebCompatible = isWebCompatibleImage(asset);
|
$: isWebCompatible = isWebCompatibleImage(asset);
|
||||||
$: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile;
|
$: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile;
|
||||||
@ -108,6 +109,25 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handlePromiseError(copyImage());
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@ -119,6 +139,8 @@
|
|||||||
{#if imageError}
|
{#if imageError}
|
||||||
<div class="h-full flex items-center justify-center">{$t('error_loading_image')}</div>
|
<div class="h-full flex items-center justify-center">{$t('error_loading_image')}</div>
|
||||||
{/if}
|
{/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">
|
<div bind:this={element} class="relative h-full select-none">
|
||||||
<img
|
<img
|
||||||
style="display:none"
|
style="display:none"
|
||||||
@ -128,7 +150,7 @@
|
|||||||
on:error={() => (imageError = imageLoaded = true)}
|
on:error={() => (imageError = imageLoaded = true)}
|
||||||
/>
|
/>
|
||||||
{#if !imageLoaded}
|
{#if !imageLoaded}
|
||||||
<div class="flex h-full items-center justify-center">
|
<div id="spinner" class="flex h-full items-center justify-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
{:else if !imageError}
|
{:else if !imageError}
|
||||||
@ -159,3 +181,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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', () => {
|
describe('ImageThumbnail component', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(HTMLImageElement.prototype, 'decode', {
|
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
|
||||||
value: vi.fn(),
|
value: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -12,13 +12,11 @@ describe('ImageThumbnail component', () => {
|
|||||||
const sut = render(ImageThumbnail, {
|
const sut = render(ImageThumbnail, {
|
||||||
url: 'http://localhost/img.png',
|
url: 'http://localhost/img.png',
|
||||||
altText: 'test',
|
altText: 'test',
|
||||||
thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI',
|
base64ThumbHash: '1QcSHQRnh493V4dIh4eXh1h4kJUI',
|
||||||
widthStyle: '250px',
|
widthStyle: '250px',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [_, thumbhash] = sut.getAllByRole('img');
|
const thumbhash = sut.getByTestId('thumbhash');
|
||||||
expect(thumbhash.getAttribute('src')).toContain(
|
expect(thumbhash).not.toBeFalsy();
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAgCAYAAAD5VeO1AAAMRklEQVR4AQBdAKL/', // truncated
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { decodeBase64 } from '$lib/utils';
|
|
||||||
import { fade } from 'svelte/transition';
|
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 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 url: string;
|
||||||
export let altText: string | undefined;
|
export let altText: string | undefined;
|
||||||
export let title: string | null = null;
|
export let title: string | null = null;
|
||||||
export let heightStyle: string | undefined = undefined;
|
export let heightStyle: string | undefined = undefined;
|
||||||
export let widthStyle: string;
|
export let widthStyle: string;
|
||||||
export let thumbhash: string | null = null;
|
export let base64ThumbHash: string | null = null;
|
||||||
export let curve = false;
|
export let curve = false;
|
||||||
export let shadow = false;
|
export let shadow = false;
|
||||||
export let circle = false;
|
export let circle = false;
|
||||||
@ -19,37 +21,58 @@
|
|||||||
export let border = false;
|
export let border = false;
|
||||||
export let preload = true;
|
export let preload = true;
|
||||||
export let hiddenIconClass = 'text-white';
|
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;
|
let img: HTMLImageElement;
|
||||||
|
|
||||||
onMount(async () => {
|
const setLoaded = () => {
|
||||||
await img.decode();
|
loaded = true;
|
||||||
await tick();
|
onComplete?.();
|
||||||
complete = true;
|
};
|
||||||
|
const setErrored = () => {
|
||||||
|
errored = true;
|
||||||
|
onComplete?.();
|
||||||
|
};
|
||||||
|
onMount(() => {
|
||||||
|
if (img.complete) {
|
||||||
|
setLoaded();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img
|
{#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}
|
bind:this={img}
|
||||||
|
on:load={setLoaded}
|
||||||
|
on:error={setErrored}
|
||||||
loading={preload ? 'eager' : 'lazy'}
|
loading={preload ? 'eager' : 'lazy'}
|
||||||
style:width={widthStyle}
|
style:width={widthStyle}
|
||||||
style:height={heightStyle}
|
style:height={heightStyle}
|
||||||
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
||||||
style:opacity={hidden ? '0.5' : '1'}
|
style:opacity={hidden ? '0.5' : '1'}
|
||||||
src={url}
|
src={url}
|
||||||
alt={altText}
|
alt={loaded || errored ? altText : ''}
|
||||||
{title}
|
{title}
|
||||||
class="object-cover transition duration-300 {border
|
class="object-cover {border ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary' : ''}"
|
||||||
? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary'
|
|
||||||
: ''}"
|
|
||||||
class:rounded-xl={curve}
|
class:rounded-xl={curve}
|
||||||
class:shadow-lg={shadow}
|
class:shadow-lg={shadow}
|
||||||
class:rounded-full={circle}
|
class:rounded-full={circle}
|
||||||
class:aspect-square={circle || !heightStyle}
|
class:aspect-square={circle || !heightStyle}
|
||||||
class:opacity-0={!thumbhash && !complete}
|
class:opacity-0={!thumbhash && !loaded}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if hidden}
|
{#if hidden}
|
||||||
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||||
@ -57,18 +80,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if thumbhash && !complete}
|
{#if base64ThumbHash && (!loaded || errored)}
|
||||||
<img
|
<canvas
|
||||||
|
use:thumbhash={{ base64ThumbHash }}
|
||||||
|
data-testid="thumbhash"
|
||||||
style:width={widthStyle}
|
style:width={widthStyle}
|
||||||
style:height={heightStyle}
|
style:height={heightStyle}
|
||||||
src={thumbHashToDataURL(decodeBase64(thumbhash))}
|
|
||||||
alt={altText}
|
|
||||||
{title}
|
{title}
|
||||||
class="absolute top-0 object-cover"
|
class="absolute top-0 object-cover"
|
||||||
class:rounded-xl={curve}
|
class:rounded-xl={curve}
|
||||||
class:shadow-lg={shadow}
|
class:shadow-lg={shadow}
|
||||||
class:rounded-full={circle}
|
class:rounded-full={circle}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
out:fade={{ duration: 300 }}
|
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { ProjectionType } from '$lib/constants';
|
import { ProjectionType } from '$lib/constants';
|
||||||
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
|
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
|
||||||
@ -18,18 +18,23 @@
|
|||||||
mdiMotionPlayOutline,
|
mdiMotionPlayOutline,
|
||||||
mdiRotate360,
|
mdiRotate360,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import ImageThumbnail from './image-thumbnail.svelte';
|
import ImageThumbnail from './image-thumbnail.svelte';
|
||||||
import VideoThumbnail from './video-thumbnail.svelte';
|
import VideoThumbnail from './video-thumbnail.svelte';
|
||||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||||
|
import { AssetStore } from '$lib/stores/assets.store';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
import type { DateGroup } from '$lib/utils/timeline-util';
|
||||||
select: { asset: AssetResponseDto };
|
|
||||||
'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number };
|
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 asset: AssetResponseDto;
|
||||||
|
export let dateGroup: DateGroup | undefined = undefined;
|
||||||
|
export let assetStore: AssetStore | undefined = undefined;
|
||||||
export let groupIndex = 0;
|
export let groupIndex = 0;
|
||||||
export let thumbnailSize: number | undefined = undefined;
|
export let thumbnailSize: number | undefined = undefined;
|
||||||
export let thumbnailWidth: number | undefined = undefined;
|
export let thumbnailWidth: number | undefined = undefined;
|
||||||
@ -40,72 +45,181 @@
|
|||||||
export let readonly = false;
|
export let readonly = false;
|
||||||
export let showArchiveIcon = false;
|
export let showArchiveIcon = false;
|
||||||
export let showStackedIcon = true;
|
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 = '';
|
let className = '';
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
||||||
|
let {
|
||||||
|
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
|
||||||
|
} = TUNABLES;
|
||||||
|
|
||||||
|
const componentId = generateId();
|
||||||
|
let element: HTMLElement | undefined;
|
||||||
let mouseOver = false;
|
let mouseOver = false;
|
||||||
|
let intersecting = false;
|
||||||
|
let lastRetrievedElement: HTMLElement | undefined;
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
$: if (!retrieveElement) {
|
||||||
|
lastRetrievedElement = undefined;
|
||||||
$: [width, height] = ((): [number, number] => {
|
}
|
||||||
if (thumbnailSize) {
|
$: if (retrieveElement && element && lastRetrievedElement !== element) {
|
||||||
return [thumbnailSize, thumbnailSize];
|
lastRetrievedElement = element;
|
||||||
|
onRetrieveElement?.(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thumbnailWidth && thumbnailHeight) {
|
$: width = thumbnailSize || thumbnailWidth || 235;
|
||||||
return [thumbnailWidth, thumbnailHeight];
|
$: height = thumbnailSize || thumbnailHeight || 235;
|
||||||
}
|
$: display = intersecting;
|
||||||
|
|
||||||
return [235, 235];
|
const onIconClickedHandler = (e?: MouseEvent) => {
|
||||||
})();
|
e?.stopPropagation();
|
||||||
|
e?.preventDefault();
|
||||||
const onIconClickedHandler = (e: MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
dispatch('select', { asset });
|
onSelect?.(asset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const callClickHandlers = () => {
|
||||||
|
if (selected) {
|
||||||
|
onIconClickedHandler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClick?.(asset);
|
||||||
|
};
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
callClickHandlers();
|
||||||
|
};
|
||||||
|
|
||||||
if (selected) {
|
const _onMouseEnter = () => {
|
||||||
onIconClickedHandler(e);
|
mouseOver = true;
|
||||||
return;
|
onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
|
||||||
}
|
|
||||||
|
|
||||||
onClick?.(asset, e);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseEnter = () => {
|
const onMouseEnter = () => {
|
||||||
mouseOver = true;
|
if (dateGroup && assetStore) {
|
||||||
|
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() });
|
||||||
|
} else {
|
||||||
|
_onMouseEnter();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
|
if (dateGroup && assetStore) {
|
||||||
|
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) });
|
||||||
|
} else {
|
||||||
mouseOver = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<IntersectionObserver once={false} on:intersected let:intersecting>
|
<div
|
||||||
<a
|
bind:this={element}
|
||||||
href={currentUrlReplaceAssetId(asset.id)}
|
use:intersectionObserver={{
|
||||||
|
...intersectionConfig,
|
||||||
|
onIntersect,
|
||||||
|
onSeparate,
|
||||||
|
}}
|
||||||
|
data-asset={asset.id}
|
||||||
|
data-int={intersecting}
|
||||||
style:width="{width}px"
|
style:width="{width}px"
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
class="group focus-visible:outline-none flex overflow-hidden {disabled
|
class="group focus-visible:outline-none flex overflow-hidden {disabled
|
||||||
? 'bg-gray-300'
|
? 'bg-gray-300'
|
||||||
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
|
: '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-not-allowed={disabled}
|
||||||
|
class:cursor-pointer={!disabled}
|
||||||
on:mouseenter={onMouseEnter}
|
on:mouseenter={onMouseEnter}
|
||||||
on:mouseleave={onMouseLeave}
|
on:mouseleave={onMouseLeave}
|
||||||
|
on:keypress={(evt) => {
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
callClickHandlers();
|
||||||
|
}
|
||||||
|
}}
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
on:click={handleClick}
|
on:click={handleClick}
|
||||||
|
role="link"
|
||||||
>
|
>
|
||||||
{#if intersecting}
|
{#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">
|
<div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px">
|
||||||
<!-- Select asset button -->
|
<!-- Select asset button -->
|
||||||
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
||||||
@ -189,11 +303,11 @@
|
|||||||
altText={$getAltText(asset)}
|
altText={$getAltText(asset)}
|
||||||
widthStyle="{width}px"
|
widthStyle="{width}px"
|
||||||
heightStyle="{height}px"
|
heightStyle="{height}px"
|
||||||
thumbhash={asset.thumbhash}
|
|
||||||
curve={selected}
|
curve={selected}
|
||||||
|
onComplete={() => (loaded = true)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{: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" />
|
<Icon path={mdiImageBrokenVariant} size="48" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@ -201,6 +315,7 @@
|
|||||||
{#if asset.type === AssetTypeEnum.Video}
|
{#if asset.type === AssetTypeEnum.Video}
|
||||||
<div class="absolute top-0 h-full w-full">
|
<div class="absolute top-0 h-full w-full">
|
||||||
<VideoThumbnail
|
<VideoThumbnail
|
||||||
|
{assetStore}
|
||||||
url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
|
url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
|
||||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||||
curve={selected}
|
curve={selected}
|
||||||
@ -213,6 +328,7 @@
|
|||||||
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
||||||
<div class="absolute top-0 h-full w-full">
|
<div class="absolute top-0 h-full w-full">
|
||||||
<VideoThumbnail
|
<VideoThumbnail
|
||||||
|
{assetStore}
|
||||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
|
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
|
||||||
pauseIcon={mdiMotionPauseOutline}
|
pauseIcon={mdiMotionPauseOutline}
|
||||||
playIcon={mdiMotionPlayOutline}
|
playIcon={mdiMotionPlayOutline}
|
||||||
@ -230,6 +346,6 @@
|
|||||||
out:fade={{ duration: 100 }}
|
out:fade={{ duration: 100 }}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</div>
|
||||||
</IntersectionObserver>
|
|
||||||
|
@ -3,7 +3,11 @@
|
|||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
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 url: string;
|
||||||
export let durationInSeconds = 0;
|
export let durationInSeconds = 0;
|
||||||
export let enablePlayback = false;
|
export let enablePlayback = false;
|
||||||
@ -13,6 +17,7 @@
|
|||||||
export let playIcon = mdiPlayCircleOutline;
|
export let playIcon = mdiPlayCircleOutline;
|
||||||
export let pauseIcon = mdiPauseCircleOutline;
|
export let pauseIcon = mdiPauseCircleOutline;
|
||||||
|
|
||||||
|
const componentId = generateId();
|
||||||
let remainingSeconds = durationInSeconds;
|
let remainingSeconds = durationInSeconds;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let error = false;
|
let error = false;
|
||||||
@ -27,6 +32,43 @@
|
|||||||
player.src = '';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
|
<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}
|
{/if}
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<span
|
<span class="pr-2 pt-2" on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
|
||||||
class="pr-2 pt-2"
|
|
||||||
on:mouseenter={() => {
|
|
||||||
if (playbackOnIconHover) {
|
|
||||||
enablePlayback = true;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
on:mouseleave={() => {
|
|
||||||
if (playbackOnIconHover) {
|
|
||||||
enablePlayback = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if enablePlayback}
|
{#if enablePlayback}
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
|
@ -113,7 +113,6 @@
|
|||||||
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
thumbhash={null}
|
|
||||||
hidden={person.isHidden}
|
hidden={person.isHidden}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -265,8 +265,6 @@
|
|||||||
title={$t('face_unassigned')}
|
title={$t('face_unassigned')}
|
||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
thumbhash={null}
|
|
||||||
hidden={false}
|
|
||||||
/>
|
/>
|
||||||
{:then data}
|
{:then data}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@ -277,8 +275,6 @@
|
|||||||
title={$t('face_unassigned')}
|
title={$t('face_unassigned')}
|
||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
thumbhash={null}
|
|
||||||
hidden={false}
|
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
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 CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
||||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
||||||
@ -38,6 +38,8 @@
|
|||||||
import { tweened } from 'svelte/motion';
|
import { tweened } from 'svelte/motion';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { t } from 'svelte-i18n';
|
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';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
|
||||||
const parseIndex = (s: string | null, max: number | null) =>
|
const parseIndex = (s: string | null, max: number | null) =>
|
||||||
@ -383,21 +385,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IntersectionObserver
|
|
||||||
once={false}
|
|
||||||
on:intersected={() => (galleryInView = true)}
|
|
||||||
on:hidden={() => (galleryInView = false)}
|
|
||||||
bottom={-200}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
id="gallery-memory"
|
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}
|
bind:this={memoryGallery}
|
||||||
bind:clientHeight={viewport.height}
|
|
||||||
bind:clientWidth={viewport.width}
|
|
||||||
>
|
>
|
||||||
<GalleryViewer assets={currentMemory.assets} {viewport} bind:selectedAssets />
|
<GalleryViewer assets={currentMemory.assets} {viewport} bind:selectedAssets />
|
||||||
</div>
|
</div>
|
||||||
</IntersectionObserver>
|
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
@ -1,84 +1,69 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
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 type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store';
|
||||||
import type { AssetStore, Viewport } from '$lib/stores/assets.store';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
|
||||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
|
||||||
import {
|
|
||||||
calculateWidth,
|
|
||||||
formatGroupTitle,
|
|
||||||
fromLocalDateTime,
|
|
||||||
splitBucketIntoDateGroups,
|
|
||||||
} from '$lib/utils/timeline-util';
|
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||||
import justifiedLayout from 'justified-layout';
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
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 element: HTMLElement | undefined = undefined;
|
||||||
export let bucketDate: string;
|
|
||||||
export let bucketHeight: number;
|
|
||||||
export let isSelectionMode = false;
|
export let isSelectionMode = false;
|
||||||
export let viewport: Viewport;
|
export let viewport: Viewport;
|
||||||
export let singleSelect = false;
|
export let singleSelect = false;
|
||||||
export let withStacked = false;
|
export let withStacked = false;
|
||||||
export let showArchiveIcon = 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 assetStore: AssetStore;
|
||||||
|
export let bucket: AssetBucket;
|
||||||
export let assetInteractionStore: AssetInteractionStore;
|
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 { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore;
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
select: { title: string; assets: AssetResponseDto[] };
|
select: { title: string; assets: AssetResponseDto[] };
|
||||||
selectAssets: AssetResponseDto;
|
selectAssets: AssetResponseDto;
|
||||||
selectAssetCandidates: AssetResponseDto | null;
|
selectAssetCandidates: AssetResponseDto | null;
|
||||||
shift: { heightDelta: number };
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let isMouseOverGroup = false;
|
let isMouseOverGroup = false;
|
||||||
let actualBucketHeight: number;
|
|
||||||
let hoveredDateGroup = '';
|
let hoveredDateGroup = '';
|
||||||
|
|
||||||
$: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
|
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
|
||||||
|
if (isSelectionMode || $isMultiSelectState) {
|
||||||
|
assetSelectHandler(asset, assets, groupTitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
|
};
|
||||||
|
|
||||||
$: geometry = (() => {
|
const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
|
||||||
const geometry = [];
|
if (assetGridElement && onScrollTarget) {
|
||||||
for (let group of assetsGroupByDate) {
|
const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
|
||||||
const justifiedLayoutResult = justifiedLayout(
|
onScrollTarget({ bucket, dateGroup, asset, offset });
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return geometry;
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if (actualBucketHeight && actualBucketHeight !== 0 && actualBucketHeight != bucketHeight) {
|
|
||||||
const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight);
|
|
||||||
if (heightDelta !== 0) {
|
|
||||||
scrollTimeline(heightDelta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollTimeline(heightDelta: number) {
|
|
||||||
dispatch('shift', {
|
|
||||||
heightDelta,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
|
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
|
||||||
|
|
||||||
@ -104,39 +89,79 @@
|
|||||||
dispatch('selectAssetCandidates', asset);
|
dispatch('selectAssetCandidates', asset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
$assetStore.taskManager.removeAllTasksForComponent(componentId);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}>
|
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
|
||||||
{#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)}
|
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
|
||||||
{@const asset = groupAssets[0]}
|
{@const display =
|
||||||
{@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))}
|
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)}
|
||||||
<!-- Asset Group By Date -->
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
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'}
|
||||||
|
>
|
||||||
|
{#if !display}
|
||||||
|
<Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
|
||||||
|
{/if}
|
||||||
|
{#if display}
|
||||||
|
<!-- Asset Group By Date -->
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-col"
|
on:mouseenter={() =>
|
||||||
on:mouseenter={() => {
|
$assetStore.taskManager.queueScrollSensitiveTask({
|
||||||
|
componentId,
|
||||||
|
task: () => {
|
||||||
isMouseOverGroup = true;
|
isMouseOverGroup = true;
|
||||||
assetMouseEventHandler(groupTitle, null);
|
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||||
}}
|
},
|
||||||
|
})}
|
||||||
on:mouseleave={() => {
|
on:mouseleave={() => {
|
||||||
|
$assetStore.taskManager.queueScrollSensitiveTask({
|
||||||
|
componentId,
|
||||||
|
task: () => {
|
||||||
isMouseOverGroup = false;
|
isMouseOverGroup = false;
|
||||||
assetMouseEventHandler(groupTitle, null);
|
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- Date group title -->
|
<!-- Date group title -->
|
||||||
<div
|
<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"
|
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: {geometry[groupIndex].containerWidth}px"
|
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||||
>
|
>
|
||||||
{#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))}
|
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||||
class="inline-block px-2 hover:cursor-pointer"
|
class="inline-block px-2 hover:cursor-pointer"
|
||||||
on:click={() => handleSelectGroup(groupTitle, groupAssets)}
|
on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
|
||||||
on:keydown={() => handleSelectGroup(groupTitle, groupAssets)}
|
on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
|
||||||
>
|
>
|
||||||
{#if $selectedGroup.has(groupTitle)}
|
{#if $selectedGroup.has(dateGroup.groupTitle)}
|
||||||
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon path={mdiCircleOutline} size="24" color="#757575" />
|
<Icon path={mdiCircleOutline} size="24" color="#757575" />
|
||||||
@ -144,38 +169,52 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="w-full truncate first-letter:capitalize" title={groupTitle}>
|
<span class="w-full truncate first-letter:capitalize" title={dateGroup.groupTitle}>
|
||||||
{groupTitle}
|
{dateGroup.groupTitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative overflow-clip"
|
||||||
style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px"
|
style:height={dateGroup.geometry.containerHeight + 'px'}
|
||||||
|
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||||
>
|
>
|
||||||
{#each groupAssets as asset, index (asset.id)}
|
{#each dateGroup.assets as asset, index (asset.id)}
|
||||||
{@const box = geometry[groupIndex].boxes[index]}
|
{@const box = dateGroup.geometry.boxes[index]}
|
||||||
|
<!-- update ASSET_GRID_PADDING-->
|
||||||
<div
|
<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"
|
class="absolute"
|
||||||
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
|
style:width={box.width + 'px'}
|
||||||
|
style:height={box.height + 'px'}
|
||||||
|
style:top={box.top + 'px'}
|
||||||
|
style:left={box.left + 'px'}
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
|
{dateGroup}
|
||||||
|
{assetStore}
|
||||||
|
intersectionConfig={{
|
||||||
|
root: assetGridElement,
|
||||||
|
bottom: renderThumbsAtBottomMargin,
|
||||||
|
top: renderThumbsAtTopMargin,
|
||||||
|
}}
|
||||||
|
retrieveElement={$assetStore.pendingScrollAssetId === asset.id}
|
||||||
|
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
|
||||||
showStackedIcon={withStacked}
|
showStackedIcon={withStacked}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{asset}
|
{asset}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
onClick={(asset, event) => {
|
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
|
||||||
if (isSelectionMode || $isMultiSelectState) {
|
onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
|
||||||
event.preventDefault();
|
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
|
||||||
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)}
|
selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
|
||||||
selectionCandidate={$assetSelectionCandidates.has(asset)}
|
selectionCandidate={$assetSelectionCandidates.has(asset)}
|
||||||
disabled={$assetStore.albumAssets.has(asset.id)}
|
disabled={$assetStore.albumAssets.has(asset.id)}
|
||||||
@ -186,11 +225,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#asset-group-by-date {
|
#asset-group-by-date {
|
||||||
contain: layout;
|
contain: layout paint style;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.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 { locale, showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
import { isSearchEnabled } from '$lib/stores/search.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
@ -13,19 +19,38 @@
|
|||||||
import { deleteAssets } from '$lib/utils/actions';
|
import { deleteAssets } from '$lib/utils/actions';
|
||||||
import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
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 type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
|
||||||
import { DateTime } from 'luxon';
|
import { throttle } from 'lodash-es';
|
||||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
|
||||||
import Portal from '../shared-components/portal/portal.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 ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||||
import AssetDateGroup from './asset-date-group.svelte';
|
import AssetDateGroup from './asset-date-group.svelte';
|
||||||
import DeleteAssetDialog from './delete-asset-dialog.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 isSelectionMode = false;
|
||||||
export let singleSelect = 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 assetStore: AssetStore;
|
||||||
export let assetInteractionStore: AssetInteractionStore;
|
export let assetInteractionStore: AssetInteractionStore;
|
||||||
export let removeAction:
|
export let removeAction:
|
||||||
@ -40,17 +65,32 @@
|
|||||||
export let album: AlbumResponseDto | null = null;
|
export let album: AlbumResponseDto | null = null;
|
||||||
export let isShowDeleteConfirmation = false;
|
export let isShowDeleteConfirmation = false;
|
||||||
|
|
||||||
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
|
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
|
||||||
|
|
||||||
const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
|
const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
|
||||||
assetInteractionStore;
|
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 element: HTMLElement;
|
||||||
|
let timelineElement: HTMLElement;
|
||||||
let showShortcuts = false;
|
let showShortcuts = false;
|
||||||
let showSkeleton = true;
|
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;
|
$: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0;
|
||||||
$: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id);
|
$: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id);
|
||||||
$: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived);
|
$: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived);
|
||||||
@ -59,30 +99,329 @@
|
|||||||
assetInteractionStore.clearMultiselect();
|
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 }>();
|
const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
|
||||||
|
|
||||||
onMount(async () => {
|
const isViewportOrigin = () => {
|
||||||
showSkeleton = false;
|
return viewport.height === 0 && viewport.width === 0;
|
||||||
assetStore.connect();
|
};
|
||||||
await assetStore.init(viewport);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
const isEqual = (a: ViewportXY, b: ViewportXY) => {
|
||||||
if ($showAssetViewer) {
|
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
|
||||||
$showAssetViewer = false;
|
};
|
||||||
|
|
||||||
|
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) => {
|
const trashOrDelete = async (force: boolean = false) => {
|
||||||
isShowDeleteConfirmation = false;
|
isShowDeleteConfirmation = false;
|
||||||
await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets);
|
await deleteAssets(
|
||||||
|
!(isTrashEnabled && !force),
|
||||||
|
(assetIds) => $assetStore.removeAssets(assetIds),
|
||||||
|
idsSelectedAssets,
|
||||||
|
);
|
||||||
assetInteractionStore.clearMultiselect();
|
assetInteractionStore.clearMultiselect();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -107,7 +446,7 @@
|
|||||||
const onStackAssets = async () => {
|
const onStackAssets = async () => {
|
||||||
const ids = await stackAssets(Array.from($selectedAssets));
|
const ids = await stackAssets(Array.from($selectedAssets));
|
||||||
if (ids) {
|
if (ids) {
|
||||||
assetStore.removeAssets(ids);
|
$assetStore.removeAssets(ids);
|
||||||
dispatch('escape');
|
dispatch('escape');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -115,7 +454,7 @@
|
|||||||
const toggleArchive = async () => {
|
const toggleArchive = async () => {
|
||||||
const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived);
|
const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
assetStore.removeAssets(ids);
|
$assetStore.removeAssets(ids);
|
||||||
deselectAllAssets();
|
deselectAllAssets();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -135,7 +474,7 @@
|
|||||||
{ shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') },
|
{ shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') },
|
||||||
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
||||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
{ 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: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
|
||||||
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
|
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
|
||||||
];
|
];
|
||||||
@ -154,29 +493,33 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const handleSelectAsset = (asset: AssetResponseDto) => {
|
const handleSelectAsset = (asset: AssetResponseDto) => {
|
||||||
if (!assetStore.albumAssets.has(asset.id)) {
|
if (!$assetStore.albumAssets.has(asset.id)) {
|
||||||
assetInteractionStore.selectAsset(asset);
|
assetInteractionStore.selectAsset(asset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function intersectedHandler(event: CustomEvent) {
|
function intersectedHandler(bucket: AssetBucket) {
|
||||||
const element_ = event.detail.container as HTMLElement;
|
updateLastIntersectedBucketDate();
|
||||||
const target = element_.firstChild as HTMLElement;
|
const intersectedTask = () => {
|
||||||
if (target) {
|
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
||||||
const bucketDate = target.id.split('_')[1];
|
void $assetStore.loadBucket(bucket.bucketDate);
|
||||||
await assetStore.loadBucket(bucketDate, event.detail.position);
|
};
|
||||||
}
|
$assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScrollTimeline(event: CustomEvent) {
|
function seperatedHandler(bucket: AssetBucket) {
|
||||||
element.scrollBy(0, event.detail.heightDelta);
|
const seperatedTask = () => {
|
||||||
|
$assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
|
||||||
|
bucket.cancel();
|
||||||
|
};
|
||||||
|
$assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePrevious = async () => {
|
const handlePrevious = async () => {
|
||||||
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
|
const previousAsset = await $assetStore.getPreviousAsset($viewingAsset);
|
||||||
|
|
||||||
if (previousAsset) {
|
if (previousAsset) {
|
||||||
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
|
const preloadAsset = await $assetStore.getPreviousAsset(previousAsset);
|
||||||
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
|
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
|
||||||
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
|
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
|
||||||
}
|
}
|
||||||
@ -185,10 +528,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
const nextAsset = await assetStore.getNextAsset($viewingAsset);
|
const nextAsset = await $assetStore.getNextAsset($viewingAsset);
|
||||||
|
|
||||||
if (nextAsset) {
|
if (nextAsset) {
|
||||||
const preloadAsset = await assetStore.getNextAsset(nextAsset);
|
const preloadAsset = await $assetStore.getNextAsset(nextAsset);
|
||||||
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
|
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
|
||||||
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
|
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
|
||||||
}
|
}
|
||||||
@ -196,7 +539,12 @@
|
|||||||
return !!nextAsset;
|
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) => {
|
const handleAction = async (action: Action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
@ -206,7 +554,7 @@
|
|||||||
case AssetAction.DELETE: {
|
case AssetAction.DELETE: {
|
||||||
// find the next asset to show or close the viewer
|
// find the next asset to show or close the viewer
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// 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
|
// delete after find the next one
|
||||||
assetStore.removeAssets([action.asset.id]);
|
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;
|
let lastAssetMouseEvent: AssetResponseDto | null = null;
|
||||||
|
|
||||||
$: if (!lastAssetMouseEvent) {
|
$: if (!lastAssetMouseEvent) {
|
||||||
@ -355,7 +689,7 @@
|
|||||||
// Select/deselect assets in all intermediate buckets
|
// Select/deselect assets in all intermediate buckets
|
||||||
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
|
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
|
||||||
const bucket = $assetStore.buckets[bucketIndex];
|
const bucket = $assetStore.buckets[bucketIndex];
|
||||||
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
await $assetStore.loadBucket(bucket.bucketDate);
|
||||||
for (const asset of bucket.assets) {
|
for (const asset of bucket.assets) {
|
||||||
if (deselect) {
|
if (deselect) {
|
||||||
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
|
||||||
@ -370,11 +704,10 @@
|
|||||||
const bucket = $assetStore.buckets[bucketIndex];
|
const bucket = $assetStore.buckets[bucketIndex];
|
||||||
|
|
||||||
// Split bucket into date groups and check each group
|
// 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) {
|
for (const dateGroup of assetsGroupByDate) {
|
||||||
const dateGroupTitle = formatGroupTitle(DateTime.fromISO(dateGroup[0].fileCreatedAt).startOf('day'));
|
const dateGroupTitle = formatGroupTitle(dateGroup.date);
|
||||||
if (dateGroup.every((a) => $selectedAssets.has(a))) {
|
if (dateGroup.assets.every((a) => $selectedAssets.has(a))) {
|
||||||
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
|
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
|
||||||
} else {
|
} else {
|
||||||
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
|
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
|
||||||
@ -411,6 +744,9 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
onDestroy(() => {
|
||||||
|
assetStore.taskManager.removeAllTasksForComponent(componentId);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} />
|
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} />
|
||||||
@ -427,78 +763,97 @@
|
|||||||
<ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
|
<ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Scrollbar
|
<Scrubber
|
||||||
|
invisible={showSkeleton}
|
||||||
{assetStore}
|
{assetStore}
|
||||||
height={viewport.height}
|
height={safeViewport.height}
|
||||||
{timelineY}
|
timelineTopOffset={topSectionHeight}
|
||||||
on:scrollTimeline={({ detail }) => (element.scrollTop = detail)}
|
timelineBottomOffset={bottomSectionHeight}
|
||||||
|
{leadout}
|
||||||
|
{scrubOverallPercent}
|
||||||
|
{scrubBucketPercent}
|
||||||
|
{scrubBucket}
|
||||||
|
{onScrub}
|
||||||
|
{stopScrub}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
||||||
<section
|
<section
|
||||||
id="asset-grid"
|
id="asset-grid"
|
||||||
class="scrollbar-hidden h-full overflow-y-auto outline-none pb-[60px] {isEmpty
|
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
|
||||||
? 'm-0'
|
|
||||||
: 'ml-4 tall:ml-0 md:mr-[60px]'}"
|
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={viewport.height}
|
use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
|
||||||
bind:clientWidth={viewport.width}
|
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
on:scroll={handleTimelineScroll}
|
on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
|
||||||
>
|
>
|
||||||
<!-- skeleton -->
|
<section
|
||||||
{#if showSkeleton}
|
use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
|
||||||
<div class="mt-8 animate-pulse">
|
class:invisible={showSkeleton}
|
||||||
<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}
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<!-- (optional) empty placeholder -->
|
|
||||||
{#if isEmpty}
|
{#if isEmpty}
|
||||||
|
<!-- (optional) empty placeholder -->
|
||||||
<slot name="empty" />
|
<slot name="empty" />
|
||||||
{/if}
|
{/if}
|
||||||
<section id="virtual-timeline" style:height={$assetStore.timelineHeight + 'px'}>
|
</section>
|
||||||
{#each $assetStore.buckets as bucket (bucket.bucketDate)}
|
|
||||||
<IntersectionObserver
|
<section
|
||||||
on:intersected={intersectedHandler}
|
bind:this={timelineElement}
|
||||||
on:hidden={() => assetStore.cancelBucket(bucket)}
|
id="virtual-timeline"
|
||||||
let:intersecting
|
class:invisible={showSkeleton}
|
||||||
top={750}
|
style:height={$assetStore.timelineHeight + 'px'}
|
||||||
bottom={750}
|
|
||||||
root={element}
|
|
||||||
>
|
>
|
||||||
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
|
{#each $assetStore.buckets as bucket (bucket.bucketDate)}
|
||||||
{#if intersecting}
|
{@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
|
<AssetDateGroup
|
||||||
|
assetGridElement={element}
|
||||||
|
renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
|
||||||
|
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
|
||||||
{withStacked}
|
{withStacked}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{assetStore}
|
{assetStore}
|
||||||
{assetInteractionStore}
|
{assetInteractionStore}
|
||||||
{isSelectionMode}
|
{isSelectionMode}
|
||||||
{singleSelect}
|
{singleSelect}
|
||||||
|
{onScrollTarget}
|
||||||
|
{onAssetInGrid}
|
||||||
|
{bucket}
|
||||||
|
viewport={safeViewport}
|
||||||
on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
|
on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
|
||||||
on:shift={handleScrollTimeline}
|
|
||||||
on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
|
on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
|
||||||
on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
|
on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
|
||||||
assets={bucket.assets}
|
|
||||||
bucketDate={bucket.bucketDate}
|
|
||||||
bucketHeight={bucket.bucketHeight}
|
|
||||||
{viewport}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</IntersectionObserver>
|
|
||||||
{/each}
|
{/each}
|
||||||
|
<div class="h-[60px]"></div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Portal target="body">
|
<Portal target="body">
|
||||||
@ -522,7 +877,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
#asset-grid {
|
#asset-grid {
|
||||||
contain: layout;
|
contain: strict;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<script lang="ts">
|
||||||
|
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { memoryStore } from '$lib/stores/memory.store';
|
import { memoryStore } from '$lib/stores/memory.store';
|
||||||
@ -38,7 +39,7 @@
|
|||||||
id="memory-lane"
|
id="memory-lane"
|
||||||
bind:this={memoryLaneElement}
|
bind:this={memoryLaneElement}
|
||||||
class="relative mt-5 overflow-x-hidden whitespace-nowrap transition-all"
|
class="relative mt-5 overflow-x-hidden whitespace-nowrap transition-all"
|
||||||
bind:offsetWidth
|
use:resizeObserver={({ width }) => (offsetWidth = width)}
|
||||||
on:scroll={onScroll}
|
on:scroll={onScroll}
|
||||||
>
|
>
|
||||||
{#if canScrollLeft || canScrollRight}
|
{#if canScrollLeft || canScrollRight}
|
||||||
@ -67,7 +68,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="inline-block" bind:offsetWidth={innerWidth}>
|
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
|
||||||
{#each $memoryStore as memory, index (memory.yearsAgo)}
|
{#each $memoryStore as memory, index (memory.yearsAgo)}
|
||||||
{#if memory.assets.length > 0}
|
{#if memory.assets.length > 0}
|
||||||
<a
|
<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 Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
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 { getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { calculateWidth } from '$lib/utils/timeline-util';
|
import { calculateWidth } from '$lib/utils/timeline-util';
|
||||||
import { type AssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto } from '@immich/sdk';
|
||||||
import justifiedLayout from 'justified-layout';
|
import justifiedLayout from 'justified-layout';
|
||||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||||
import Portal from '../portal/portal.svelte';
|
import Portal from '../portal/portal.svelte';
|
||||||
|
import { handlePromiseError } from '$lib/utils';
|
||||||
const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
|
|
||||||
|
|
||||||
export let assets: AssetResponseDto[];
|
export let assets: AssetResponseDto[];
|
||||||
export let selectedAssets: Set<AssetResponseDto> = new Set();
|
export let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||||
export let disableAssetSelect = false;
|
export let disableAssetSelect = false;
|
||||||
export let showArchiveIcon = false;
|
export let showArchiveIcon = false;
|
||||||
export let viewport: Viewport;
|
export let viewport: Viewport;
|
||||||
|
export let onIntersected: (() => void) | undefined = undefined;
|
||||||
export let showAssetName = false;
|
export let showAssetName = false;
|
||||||
|
|
||||||
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
|
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||||
@ -127,18 +127,15 @@
|
|||||||
<Thumbnail
|
<Thumbnail
|
||||||
{asset}
|
{asset}
|
||||||
readonly={disableAssetSelect}
|
readonly={disableAssetSelect}
|
||||||
onClick={async (asset, e) => {
|
onClick={(asset) => {
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (isMultiSelectionMode) {
|
if (isMultiSelectionMode) {
|
||||||
selectAssetHandler(asset);
|
selectAssetHandler(asset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await viewAssetHandler(asset);
|
void viewAssetHandler(asset);
|
||||||
}}
|
}}
|
||||||
on:select={(e) => selectAssetHandler(e.detail.asset)}
|
onSelect={(asset) => selectAssetHandler(asset)}
|
||||||
on:intersected={(event) =>
|
onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
|
||||||
i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined}
|
|
||||||
selected={selectedAssets.has(asset)}
|
selected={selectedAssets.has(asset)}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
thumbnailWidth={geometry.boxes[i].width}
|
thumbnailWidth={geometry.boxes[i].width}
|
||||||
@ -159,6 +156,15 @@
|
|||||||
<!-- Overlay Asset Viewer -->
|
<!-- Overlay Asset Viewer -->
|
||||||
{#if $isViewerOpen}
|
{#if $isViewerOpen}
|
||||||
<Portal target="body">
|
<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>
|
</Portal>
|
||||||
{/if}
|
{/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 Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||||
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
|
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
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 { shortcuts } from '$lib/actions/shortcut';
|
||||||
import { type AssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js';
|
import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js';
|
||||||
@ -158,7 +159,10 @@
|
|||||||
const index = getAssetIndex($viewingAsset.id) - 1 + assets.length;
|
const index = getAssetIndex($viewingAsset.id) - 1 + assets.length;
|
||||||
setAsset(assets[index % assets.length]);
|
setAsset(assets[index % assets.length]);
|
||||||
}}
|
}}
|
||||||
on:close={() => assetViewingStore.showAssetViewer(false)}
|
on:close={() => {
|
||||||
|
assetViewingStore.showAssetViewer(false);
|
||||||
|
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Portal>
|
</Portal>
|
||||||
{/await}
|
{/await}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { getKey } from '$lib/utils';
|
import { getKey } from '$lib/utils';
|
||||||
|
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { readonly, writable } from 'svelte/store';
|
import { readonly, writable } from 'svelte/store';
|
||||||
|
|
||||||
@ -6,6 +7,7 @@ function createAssetViewingStore() {
|
|||||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||||
const preloadAssets = writable<AssetResponseDto[]>([]);
|
const preloadAssets = writable<AssetResponseDto[]>([]);
|
||||||
const viewState = writable<boolean>(false);
|
const viewState = writable<boolean>(false);
|
||||||
|
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||||
|
|
||||||
const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => {
|
const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => {
|
||||||
preloadAssets.set(assetsToPreload);
|
preloadAssets.set(assetsToPreload);
|
||||||
@ -26,6 +28,7 @@ function createAssetViewingStore() {
|
|||||||
asset: readonly(viewingAssetStoreState),
|
asset: readonly(viewingAssetStoreState),
|
||||||
preloadAssets: readonly(preloadAssets),
|
preloadAssets: readonly(preloadAssets),
|
||||||
isViewing: viewState,
|
isViewing: viewState,
|
||||||
|
gridScrollTarget,
|
||||||
setAsset,
|
setAsset,
|
||||||
setAssetId,
|
setAssetId,
|
||||||
showAssetViewer,
|
showAssetViewer,
|
||||||
|
@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
|||||||
import { AbortError } from '$lib/utils';
|
import { AbortError } from '$lib/utils';
|
||||||
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
|
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||||
import { AssetStore, BucketPosition } from './assets.store';
|
import { AssetStore } from './assets.store';
|
||||||
|
|
||||||
describe('AssetStore', () => {
|
describe('AssetStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -26,7 +26,8 @@ describe('AssetStore', () => {
|
|||||||
]);
|
]);
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
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', () => {
|
it('should load buckets in viewport', () => {
|
||||||
@ -38,15 +39,15 @@ describe('AssetStore', () => {
|
|||||||
it('calculates bucket height', () => {
|
it('calculates bucket height', () => {
|
||||||
expect(assetStore.buckets).toEqual(
|
expect(assetStore.buckets).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ bucketDate: '2024-03-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: 3760 }),
|
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3811 }),
|
||||||
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }),
|
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates timeline height', () => {
|
it('calculates timeline height', () => {
|
||||||
expect(assetStore.timelineHeight).toBe(4230);
|
expect(assetStore.timelineHeight).toBe(4383);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -72,35 +73,28 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
return bucketAssets[timeBucket];
|
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 () => {
|
it('loads a bucket', async () => {
|
||||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
|
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(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3);
|
expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid buckets', async () => {
|
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);
|
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 () => {
|
it('cancels bucket loading', async () => {
|
||||||
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
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');
|
const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort');
|
||||||
assetStore.cancelBucket(bucket!);
|
bucket?.cancel();
|
||||||
expect(abortSpy).toBeCalledTimes(1);
|
expect(abortSpy).toBeCalledTimes(1);
|
||||||
|
|
||||||
await loadPromise;
|
await loadPromise;
|
||||||
@ -109,24 +103,24 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
it('prevents loading buckets multiple times', async () => {
|
it('prevents loading buckets multiple times', async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
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', BucketPosition.Unknown),
|
assetStore.loadBucket('2024-01-01T00:00:00.000Z'),
|
||||||
]);
|
]);
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
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);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows loading a canceled bucket', async () => {
|
it('allows loading a canceled bucket', async () => {
|
||||||
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
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;
|
await loadPromise;
|
||||||
expect(bucket?.assets.length).toEqual(0);
|
expect(bucket?.assets.length).toEqual(0);
|
||||||
|
|
||||||
await assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown);
|
await assetStore.loadBucket(bucket!.bucketDate);
|
||||||
expect(bucket!.assets.length).toEqual(3);
|
expect(bucket!.assets.length).toEqual(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -137,7 +131,8 @@ describe('AssetStore', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore({});
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
await assetStore.init({ width: 1588, height: 1000 });
|
await assetStore.init();
|
||||||
|
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is empty initially', () => {
|
it('is empty initially', () => {
|
||||||
@ -219,7 +214,8 @@ describe('AssetStore', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore({});
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
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', () => {
|
it('ignores non-existing assets', () => {
|
||||||
@ -263,7 +259,8 @@ describe('AssetStore', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore({});
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
await assetStore.init({ width: 1588, height: 1000 });
|
await assetStore.init();
|
||||||
|
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid IDs', () => {
|
it('ignores invalid IDs', () => {
|
||||||
@ -312,7 +309,8 @@ describe('AssetStore', () => {
|
|||||||
]);
|
]);
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
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 () => {
|
it('returns null for invalid assetId', async () => {
|
||||||
@ -321,15 +319,15 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId', async () => {
|
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');
|
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]);
|
expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId spanning multiple buckets', async () => {
|
it('returns previous assetId spanning multiple buckets', async () => {
|
||||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
|
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
||||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
|
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
||||||
|
|
||||||
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
|
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
|
||||||
const previousBucket = assetStore.getBucketByDate('2024-03-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 () => {
|
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 loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
|
||||||
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
|
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
|
||||||
@ -347,9 +345,9 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('skips removed assets', async () => {
|
it('skips removed assets', async () => {
|
||||||
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
|
await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
|
||||||
await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
|
await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
|
||||||
await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
|
await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
|
||||||
|
|
||||||
const [assetOne, assetTwo, assetThree] = assetStore.assets;
|
const [assetOne, assetTwo, assetThree] = assetStore.assets;
|
||||||
assetStore.removeAssets([assetTwo.id]);
|
assetStore.removeAssets([assetTwo.id]);
|
||||||
@ -357,7 +355,7 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when no more assets', async () => {
|
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();
|
expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -368,7 +366,8 @@ describe('AssetStore', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore({});
|
assetStore = new AssetStore({});
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
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', () => {
|
it('returns null for invalid buckets', () => {
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getKey } from '$lib/utils';
|
import { getKey } from '$lib/utils';
|
||||||
import { fromLocalDateTime } from '$lib/utils/timeline-util';
|
import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager';
|
||||||
import { TimeBucketSize, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
|
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 { throttle } from 'lodash-es';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -8,19 +13,24 @@ import { get, writable, type Unsubscriber } from 'svelte/store';
|
|||||||
import { handleError } from '../utils/handle-error';
|
import { handleError } from '../utils/handle-error';
|
||||||
import { websocketEvents } from './websocket';
|
import { websocketEvents } from './websocket';
|
||||||
|
|
||||||
export enum BucketPosition {
|
|
||||||
Above = 'above',
|
|
||||||
Below = 'below',
|
|
||||||
Visible = 'visible',
|
|
||||||
Unknown = 'unknown',
|
|
||||||
}
|
|
||||||
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
|
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
|
||||||
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>;
|
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>;
|
||||||
|
|
||||||
|
const LAYOUT_OPTIONS = {
|
||||||
|
boxSpacing: 2,
|
||||||
|
containerPadding: 0,
|
||||||
|
targetRowHeightTolerance: 0.15,
|
||||||
|
targetRowHeight: 235,
|
||||||
|
};
|
||||||
|
|
||||||
export interface Viewport {
|
export interface Viewport {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
export type ViewportXY = Viewport & {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface AssetLookup {
|
interface AssetLookup {
|
||||||
bucket: AssetBucket;
|
bucket: AssetBucket;
|
||||||
@ -29,16 +39,89 @@ interface AssetLookup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AssetBucket {
|
export class AssetBucket {
|
||||||
|
store!: AssetStore;
|
||||||
|
bucketDate!: string;
|
||||||
/**
|
/**
|
||||||
* The DOM height of the bucket in pixel
|
* 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
|
* This value is first estimated by the number of asset and later is corrected as the user scroll
|
||||||
*/
|
*/
|
||||||
bucketHeight!: number;
|
bucketHeight: number = 0;
|
||||||
bucketDate!: string;
|
isBucketHeightActual: boolean = false;
|
||||||
bucketCount!: number;
|
bucketDateFormattted!: string;
|
||||||
assets!: AssetResponseDto[];
|
bucketCount: number = 0;
|
||||||
cancelToken!: AbortController | null;
|
assets: AssetResponseDto[] = [];
|
||||||
position!: BucketPosition;
|
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 =>
|
const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
|
||||||
@ -65,34 +148,101 @@ interface TrashAssets {
|
|||||||
type: 'trash';
|
type: 'trash';
|
||||||
values: string[];
|
values: string[];
|
||||||
}
|
}
|
||||||
|
interface UpdateStackAssets {
|
||||||
|
type: 'update_stack_assets';
|
||||||
|
values: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const photoViewer = writable<HTMLImageElement | null>(null);
|
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 {
|
export class AssetStore {
|
||||||
private store$ = writable(this);
|
|
||||||
private assetToBucket: Record<string, AssetLookup> = {};
|
private assetToBucket: Record<string, AssetLookup> = {};
|
||||||
private pendingChanges: PendingChange[] = [];
|
private pendingChanges: PendingChange[] = [];
|
||||||
private unsubscribers: Unsubscriber[] = [];
|
private unsubscribers: Unsubscriber[] = [];
|
||||||
private options: AssetApiGetTimeBucketsRequest;
|
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;
|
initialized = false;
|
||||||
timelineHeight = 0;
|
timelineHeight = 0;
|
||||||
buckets: AssetBucket[] = [];
|
buckets: AssetBucket[] = [];
|
||||||
assets: AssetResponseDto[] = [];
|
assets: AssetResponseDto[] = [];
|
||||||
albumAssets: Set<string> = new Set();
|
albumAssets: Set<string> = new Set();
|
||||||
|
pendingScrollBucket: AssetBucket | undefined;
|
||||||
|
pendingScrollAssetId: string | undefined;
|
||||||
|
|
||||||
|
listeners: BucketListener[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
options: AssetStoreOptions,
|
options: AssetStoreOptions,
|
||||||
private albumId?: string,
|
private albumId?: string,
|
||||||
) {
|
) {
|
||||||
this.options = { ...options, size: TimeBucketSize.Month };
|
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);
|
this.store$.set(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe = this.store$.subscribe;
|
|
||||||
|
|
||||||
private addPendingChanges(...changes: PendingChange[]) {
|
private addPendingChanges(...changes: PendingChange[]) {
|
||||||
// prevent websocket events from happening before local client events
|
// prevent websocket events from happening before local client events
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -182,8 +332,35 @@ export class AssetStore {
|
|||||||
this.emit(true);
|
this.emit(true);
|
||||||
}, 2500);
|
}, 2500);
|
||||||
|
|
||||||
async init(viewport: Viewport) {
|
addListener(bucketListener: BucketListener) {
|
||||||
this.initialized = false;
|
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.timelineHeight = 0;
|
||||||
this.buckets = [];
|
this.buckets = [];
|
||||||
this.assets = [];
|
this.assets = [];
|
||||||
@ -194,65 +371,118 @@ export class AssetStore {
|
|||||||
...this.options,
|
...this.options,
|
||||||
key: getKey(),
|
key: getKey(),
|
||||||
});
|
});
|
||||||
|
this.buckets = timebuckets.map(
|
||||||
|
(bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }),
|
||||||
|
);
|
||||||
|
this.initializedSignal();
|
||||||
this.initialized = true;
|
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) {
|
for (const bucket of this.buckets) {
|
||||||
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
|
this.updateGeometry(bucket, changedWidth);
|
||||||
const rows = Math.ceil(unwrappedWidth / viewport.width);
|
|
||||||
const height = rows * THUMBNAIL_HEIGHT;
|
|
||||||
bucket.bucketHeight = height;
|
|
||||||
}
|
}
|
||||||
this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
|
this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
|
||||||
|
|
||||||
let height = 0;
|
|
||||||
const loaders = [];
|
const loaders = [];
|
||||||
|
let height = 0;
|
||||||
for (const bucket of this.buckets) {
|
for (const bucket of this.buckets) {
|
||||||
if (height < viewport.height) {
|
if (height >= viewport.height) {
|
||||||
height += bucket.bucketHeight;
|
|
||||||
loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
height += bucket.bucketHeight;
|
||||||
|
loaders.push(this.loadBucket(bucket.bucketDate));
|
||||||
|
}
|
||||||
await Promise.all(loaders);
|
await Promise.all(loaders);
|
||||||
|
this.notifyListeners({ type: 'viewport' });
|
||||||
this.emit(false);
|
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);
|
const bucket = this.getBucketByDate(bucketDate);
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (bucket.bucketCount === bucket.assets.length) {
|
||||||
bucket.position = position;
|
// already loaded
|
||||||
|
|
||||||
if (bucket.cancelToken || bucket.assets.length > 0) {
|
|
||||||
this.emit(false);
|
|
||||||
return;
|
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 {
|
try {
|
||||||
const assets = await getTimeBucket(
|
const assets = await getTimeBucket(
|
||||||
{
|
{
|
||||||
@ -260,9 +490,14 @@ export class AssetStore {
|
|||||||
timeBucket: bucketDate,
|
timeBucket: bucketDate,
|
||||||
key: getKey(),
|
key: getKey(),
|
||||||
},
|
},
|
||||||
{ signal: bucket.cancelToken.signal },
|
{ signal: cancelToken.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (cancelToken.signal.aborted) {
|
||||||
|
this.notifyListeners({ type: 'cancel', bucket });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.albumId) {
|
if (this.albumId) {
|
||||||
const albumAssets = await getTimeBucket(
|
const albumAssets = await getTimeBucket(
|
||||||
{
|
{
|
||||||
@ -271,50 +506,87 @@ export class AssetStore {
|
|||||||
size: this.options.size,
|
size: this.options.size,
|
||||||
key: getKey(),
|
key: getKey(),
|
||||||
},
|
},
|
||||||
{ signal: bucket.cancelToken.signal },
|
{ signal: cancelToken.signal },
|
||||||
);
|
);
|
||||||
|
if (cancelToken.signal.aborted) {
|
||||||
|
this.notifyListeners({ type: 'cancel', bucket });
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const asset of albumAssets) {
|
for (const asset of albumAssets) {
|
||||||
this.albumAssets.add(asset.id);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bucket.assets = assets;
|
|
||||||
|
|
||||||
this.emit(true);
|
|
||||||
} catch (error) {
|
|
||||||
const $t = get(t);
|
const $t = get(t);
|
||||||
handleError(error, $t('errors.failed_to_load_assets'));
|
handleError(error, $t('errors.failed_to_load_assets'));
|
||||||
|
bucket.errored();
|
||||||
} finally {
|
} finally {
|
||||||
bucket.cancelToken = null;
|
bucket.cancelToken = undefined;
|
||||||
|
this.emit(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelBucket(bucket: AssetBucket) {
|
updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) {
|
||||||
bucket.cancelToken?.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBucket(bucketDate: string, height: number) {
|
|
||||||
const bucket = this.getBucketByDate(bucketDate);
|
const bucket = this.getBucketByDate(bucketDate);
|
||||||
if (!bucket) {
|
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!;
|
||||||
|
}
|
||||||
|
this.emit(false);
|
||||||
|
return { delta };
|
||||||
}
|
}
|
||||||
|
|
||||||
const delta = height - bucket.bucketHeight;
|
updateBucketDateGroup(
|
||||||
const scrollTimeline = bucket.position == BucketPosition.Above;
|
bucket: AssetBucket,
|
||||||
|
dateGroup: DateGroup,
|
||||||
bucket.bucketHeight = height;
|
properties: { height?: number; intersecting?: boolean },
|
||||||
bucket.position = BucketPosition.Unknown;
|
) {
|
||||||
|
let delta = 0;
|
||||||
this.timelineHeight += delta;
|
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);
|
this.emit(false);
|
||||||
|
return { delta };
|
||||||
return scrollTimeline ? delta : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addAssets(assets: AssetResponseDto[]) {
|
addAssets(assets: AssetResponseDto[]) {
|
||||||
@ -354,15 +626,7 @@ export class AssetStore {
|
|||||||
let bucket = this.getBucketByDate(timeBucket);
|
let bucket = this.getBucketByDate(timeBucket);
|
||||||
|
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
bucket = {
|
bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT });
|
||||||
bucketDate: timeBucket,
|
|
||||||
bucketHeight: THUMBNAIL_HEIGHT,
|
|
||||||
bucketCount: 0,
|
|
||||||
assets: [],
|
|
||||||
cancelToken: null,
|
|
||||||
position: BucketPosition.Unknown,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.buckets.push(bucket);
|
this.buckets.push(bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,6 +647,8 @@ export class AssetStore {
|
|||||||
const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC();
|
const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC();
|
||||||
return bDate.diff(aDate).milliseconds;
|
return bDate.diff(aDate).milliseconds;
|
||||||
});
|
});
|
||||||
|
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
|
||||||
|
this.updateGeometry(bucket, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit(true);
|
this.emit(true);
|
||||||
@ -392,18 +658,73 @@ export class AssetStore {
|
|||||||
return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null;
|
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];
|
const bucketInfo = this.assetToBucket[id];
|
||||||
if (bucketInfo) {
|
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);
|
let date = fromLocalDateTime(localDateTime);
|
||||||
if (this.options.size == TimeBucketSize.Month) {
|
if (this.options.size == TimeBucketSize.Month) {
|
||||||
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
|
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||||
} else if (this.options.size == TimeBucketSize.Day) {
|
} else if (this.options.size == TimeBucketSize.Day) {
|
||||||
date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
|
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;
|
return this.assetToBucket[id] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,7 +738,7 @@ export class AssetStore {
|
|||||||
);
|
);
|
||||||
for (const bucket of this.buckets) {
|
for (const bucket of this.buckets) {
|
||||||
if (index < bucket.bucketCount) {
|
if (index < bucket.bucketCount) {
|
||||||
await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
await this.loadBucket(bucket.bucketDate);
|
||||||
return bucket.assets[index] || null;
|
return bucket.assets[index] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -458,6 +779,7 @@ export class AssetStore {
|
|||||||
// Iterate in reverse to allow array splicing.
|
// Iterate in reverse to allow array splicing.
|
||||||
for (let index = this.buckets.length - 1; index >= 0; index--) {
|
for (let index = this.buckets.length - 1; index >= 0; index--) {
|
||||||
const bucket = this.buckets[index];
|
const bucket = this.buckets[index];
|
||||||
|
let changed = false;
|
||||||
for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) {
|
for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) {
|
||||||
const asset = bucket.assets[index_];
|
const asset = bucket.assets[index_];
|
||||||
if (!idSet.has(asset.id)) {
|
if (!idSet.has(asset.id)) {
|
||||||
@ -465,17 +787,22 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bucket.assets.splice(index_, 1);
|
bucket.assets.splice(index_, 1);
|
||||||
|
changed = true;
|
||||||
if (bucket.assets.length === 0) {
|
if (bucket.assets.length === 0) {
|
||||||
this.buckets.splice(index, 1);
|
this.buckets.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changed) {
|
||||||
|
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
|
||||||
|
this.updateGeometry(bucket, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit(true);
|
this.emit(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
|
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
|
||||||
const info = await this.getBucketInfoForAssetId(asset);
|
const info = await this.getBucketInfoForAsset(asset);
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -491,12 +818,12 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousBucket = this.buckets[bucketIndex - 1];
|
const previousBucket = this.buckets[bucketIndex - 1];
|
||||||
await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown);
|
await this.loadBucket(previousBucket.bucketDate);
|
||||||
return previousBucket.assets.at(-1) || null;
|
return previousBucket.assets.at(-1) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
|
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
|
||||||
const info = await this.getBucketInfoForAssetId(asset);
|
const info = await this.getBucketInfoForAsset(asset);
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -512,7 +839,7 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextBucket = this.buckets[bucketIndex + 1];
|
const nextBucket = this.buckets[bucketIndex + 1];
|
||||||
await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown);
|
await this.loadBucket(nextBucket.bucketDate);
|
||||||
return nextBucket.assets[0] || null;
|
return nextBucket.assets[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -537,8 +864,7 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
this.assetToBucket = assetToBucket;
|
this.assetToBucket = assetToBucket;
|
||||||
}
|
}
|
||||||
|
this.store$.set(this);
|
||||||
this.store$.update(() => 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 { AppRoute } from '$lib/constants';
|
||||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.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 { downloadManager } from '$lib/stores/download';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { downloadRequest, getKey, withError } from '$lib/utils';
|
import { downloadRequest, getKey, withError } from '$lib/utils';
|
||||||
@ -403,7 +403,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const bucket of assetStore.buckets) {
|
for (const bucket of assetStore.buckets) {
|
||||||
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
await assetStore.loadBucket(bucket.bucketDate);
|
||||||
|
|
||||||
if (!get(isSelectingAllAssets)) {
|
if (!get(isSelectingAllAssets)) {
|
||||||
break; // Cancelled
|
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 type { NavigationTarget } from '@sveltejs/kit';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
export type AssetGridRouteSearchParams = {
|
||||||
|
at: string | null | undefined;
|
||||||
|
};
|
||||||
export const isExternalUrl = (url: string): boolean => {
|
export const isExternalUrl = (url: string): boolean => {
|
||||||
return new URL(url, window.location.href).origin !== window.location.origin;
|
return new URL(url, window.location.href).origin !== window.location.origin;
|
||||||
};
|
};
|
||||||
@ -33,17 +36,38 @@ function currentUrlWithoutAsset() {
|
|||||||
|
|
||||||
export function currentUrlReplaceAssetId(assetId: string) {
|
export function currentUrlReplaceAssetId(assetId: string) {
|
||||||
const $page = get(page);
|
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
|
// this contains special casing for the /photos/:assetId photos route, which hangs directly
|
||||||
// off / instead of a subpath, unlike every other asset-containing route.
|
// off / instead of a subpath, unlike every other asset-containing route.
|
||||||
return isPhotosRoute($page.route.id)
|
return isPhotosRoute($page.route.id)
|
||||||
? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}`
|
? `${AppRoute.PHOTOS}/${assetId}${searchparams}`
|
||||||
: `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`;
|
: `${$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() {
|
function currentUrl() {
|
||||||
const $page = get(page);
|
const $page = get(page);
|
||||||
const current = $page.url;
|
const current = $page.url;
|
||||||
return current.pathname + current.search;
|
return current.pathname + current.search + current.hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Route {
|
interface Route {
|
||||||
@ -55,24 +79,58 @@ interface Route {
|
|||||||
|
|
||||||
interface AssetRoute extends Route {
|
interface AssetRoute extends Route {
|
||||||
targetRoute: 'current';
|
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 {
|
function isAssetRoute(route: Route): route is AssetRoute {
|
||||||
return route.targetRoute === 'current' && 'assetId' in route;
|
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 { assetId } = route;
|
||||||
const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
|
const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
|
||||||
if (next !== currentUrl()) {
|
const current = currentUrl();
|
||||||
await goto(next, { replaceState: false });
|
if (next !== current || options?.forceNavigate) {
|
||||||
|
await goto(next, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigate<T extends Route>(change: T): Promise<void> {
|
async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) {
|
||||||
if (isAssetRoute(change)) {
|
const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route;
|
||||||
return navigateAssetRoute(change);
|
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
|
// future navigation requests here
|
||||||
throw `Invalid navigation: ${JSON.stringify(change)}`;
|
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 { locale } from '$lib/stores/preferences.store';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
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 { DateTime } from 'luxon';
|
||||||
import { get } from 'svelte/store';
|
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) =>
|
export const fromLocalDateTime = (localDateTime: string) =>
|
||||||
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
|
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
|
||||||
|
|
||||||
@ -48,20 +77,48 @@ export function formatGroupTitle(_date: DateTime): string {
|
|||||||
return date.toLocaleString(groupDateFormat);
|
return date.toLocaleString(groupDateFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitBucketIntoDateGroups(
|
type Geometry = ReturnType<typeof createJustifiedLayout> & {
|
||||||
assets: AssetResponseDto[],
|
containerWidth: number;
|
||||||
locale: string | undefined,
|
};
|
||||||
): AssetResponseDto[][] {
|
|
||||||
const grouped = groupBy(assets, (asset) =>
|
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 }),
|
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 = {
|
export type LayoutBox = {
|
||||||
|
aspectRatio: number;
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
|
||||||
width: number;
|
width: number;
|
||||||
|
height: number;
|
||||||
|
left: number;
|
||||||
|
forcedAspectRatio?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function calculateWidth(boxes: LayoutBox[]): number {
|
export function calculateWidth(boxes: LayoutBox[]): number {
|
||||||
@ -71,6 +128,14 @@ export function calculateWidth(boxes: LayoutBox[]): number {
|
|||||||
width = box.left + box.width;
|
width = box.left + box.width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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">
|
<script lang="ts">
|
||||||
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
|
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
|
||||||
import { page } from '$app/stores';
|
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
|
// $page.data.asset is loaded by route specific +page.ts loaders if that
|
||||||
// route contains the assetId path.
|
// route contains the assetId path.
|
||||||
$: {
|
$: {
|
||||||
@ -13,6 +13,8 @@
|
|||||||
} else {
|
} else {
|
||||||
$showAssetViewer = false;
|
$showAssetViewer = false;
|
||||||
}
|
}
|
||||||
|
const asset = $page.url.searchParams.get('at');
|
||||||
|
$gridScrollTarget = { at: asset };
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -43,7 +43,13 @@
|
|||||||
import { downloadAlbum } from '$lib/utils/asset-utils';
|
import { downloadAlbum } from '$lib/utils/asset-utils';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
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 {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
AssetOrder,
|
AssetOrder,
|
||||||
@ -78,12 +84,15 @@
|
|||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
let { isViewing: showAssetViewer, setAsset } = assetViewingStore;
|
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
|
||||||
let { slideshowState, slideshowNavigation } = slideshowStore;
|
let { slideshowState, slideshowNavigation } = slideshowStore;
|
||||||
|
|
||||||
|
let oldAt: AssetGridRouteSearchParams | null | undefined;
|
||||||
|
|
||||||
$: album = data.album;
|
$: album = data.album;
|
||||||
$: albumId = album.id;
|
$: albumId = album.id;
|
||||||
$: albumKey = `${albumId}_${albumOrder}`;
|
$: albumKey = `${albumId}_${albumOrder}`;
|
||||||
@ -244,7 +253,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === ViewMode.SELECT_ASSETS) {
|
if (viewMode === ViewMode.SELECT_ASSETS) {
|
||||||
handleCloseSelectAssets();
|
await handleCloseSelectAssets();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (viewMode === ViewMode.LINK_SHARING) {
|
if (viewMode === ViewMode.LINK_SHARING) {
|
||||||
@ -289,20 +298,37 @@
|
|||||||
|
|
||||||
timelineInteractionStore.clearMultiselect();
|
timelineInteractionStore.clearMultiselect();
|
||||||
viewMode = ViewMode.VIEW;
|
viewMode = ViewMode.VIEW;
|
||||||
|
await navigate(
|
||||||
|
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||||
|
{ replaceState: true, forceNavigate: true },
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.error_adding_assets_to_album'));
|
handleError(error, $t('errors.error_adding_assets_to_album'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseSelectAssets = () => {
|
const setModeToView = async () => {
|
||||||
viewMode = ViewMode.VIEW;
|
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();
|
timelineInteractionStore.clearMultiselect();
|
||||||
|
await setModeToView();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectFromComputer = async () => {
|
const handleSelectFromComputer = async () => {
|
||||||
await openFileUploadDialog({ albumId: album.id });
|
await openFileUploadDialog({ albumId: album.id });
|
||||||
timelineInteractionStore.clearMultiselect();
|
timelineInteractionStore.clearMultiselect();
|
||||||
viewMode = ViewMode.VIEW;
|
await setModeToView();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
|
const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
|
||||||
@ -400,6 +426,11 @@
|
|||||||
await deleteAlbum(album);
|
await deleteAlbum(album);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
assetStore.destroy();
|
||||||
|
timelineStore.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex overflow-hidden" bind:clientWidth={globalWidth}>
|
<div class="flex overflow-hidden" bind:clientWidth={globalWidth}>
|
||||||
@ -444,7 +475,14 @@
|
|||||||
{#if isEditor}
|
{#if isEditor}
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
title={$t('add_photos')}
|
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}
|
icon={mdiImagePlusOutline}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@ -530,12 +568,14 @@
|
|||||||
{#key albumKey}
|
{#key albumKey}
|
||||||
{#if viewMode === ViewMode.SELECT_ASSETS}
|
{#if viewMode === ViewMode.SELECT_ASSETS}
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
|
enableRouting={false}
|
||||||
assetStore={timelineStore}
|
assetStore={timelineStore}
|
||||||
assetInteractionStore={timelineInteractionStore}
|
assetInteractionStore={timelineInteractionStore}
|
||||||
isSelectionMode={true}
|
isSelectionMode={true}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
|
enableRouting={true}
|
||||||
{album}
|
{album}
|
||||||
{assetStore}
|
{assetStore}
|
||||||
{assetInteractionStore}
|
{assetInteractionStore}
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
|
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
@ -25,6 +26,10 @@
|
|||||||
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
||||||
|
|
||||||
$: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
|
$: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
assetStore.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isMultiSelectState}
|
{#if $isMultiSelectState}
|
||||||
@ -45,7 +50,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
|
<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" />
|
<EmptyPlaceholder text={$t('no_archived_assets_message')} slot="empty" />
|
||||||
</AssetGrid>
|
</AssetGrid>
|
||||||
</UserPageLayout>
|
</UserPageLayout>
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
@ -27,6 +28,10 @@
|
|||||||
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
||||||
|
|
||||||
$: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived);
|
$: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived);
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
assetStore.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Multiselection mode app bar -->
|
<!-- Multiselection mode app bar -->
|
||||||
@ -50,7 +55,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
|
<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" />
|
<EmptyPlaceholder text={$t('no_favorites_message')} slot="empty" />
|
||||||
</AssetGrid>
|
</AssetGrid>
|
||||||
</UserPageLayout>
|
</UserPageLayout>
|
||||||
|
@ -124,7 +124,10 @@
|
|||||||
showNavigation={viewingAssets.length > 1}
|
showNavigation={viewingAssets.length > 1}
|
||||||
on:next={navigateNext}
|
on:next={navigateNext}
|
||||||
on:previous={navigatePrevious}
|
on:previous={navigatePrevious}
|
||||||
on:close={() => assetViewingStore.showAssetViewer(false)}
|
on:close={() => {
|
||||||
|
assetViewingStore.showAssetViewer(false);
|
||||||
|
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||||
|
}}
|
||||||
isShared={false}
|
isShared={false}
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
assetInteractionStore.clearMultiselect();
|
assetInteractionStore.clearMultiselect();
|
||||||
|
assetStore.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -45,5 +46,5 @@
|
|||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</ControlAppBar>
|
</ControlAppBar>
|
||||||
{/if}
|
{/if}
|
||||||
<AssetGrid {assetStore} {assetInteractionStore} />
|
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} />
|
||||||
</main>
|
</main>
|
||||||
|
@ -52,7 +52,7 @@
|
|||||||
mdiEyeOutline,
|
mdiEyeOutline,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { listNavigation } from '$lib/actions/list-navigation';
|
import { listNavigation } from '$lib/actions/list-navigation';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -155,6 +155,7 @@
|
|||||||
}
|
}
|
||||||
if (previousPersonId !== data.person.id) {
|
if (previousPersonId !== data.person.id) {
|
||||||
handlePromiseError(updateAssetCount());
|
handlePromiseError(updateAssetCount());
|
||||||
|
assetStore.destroy();
|
||||||
assetStore = new AssetStore({
|
assetStore = new AssetStore({
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
personId: data.person.id,
|
personId: data.person.id,
|
||||||
@ -344,6 +345,10 @@
|
|||||||
await goto($page.url);
|
await goto($page.url);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
assetStore.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if viewMode === ViewMode.UNASSIGN_ASSETS}
|
{#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">
|
<main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
|
||||||
{#key refreshAssetGrid}
|
{#key refreshAssetGrid}
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
|
enableRouting={true}
|
||||||
{assetStore}
|
{assetStore}
|
||||||
{assetInteractionStore}
|
{assetInteractionStore}
|
||||||
isSelectionMode={viewMode === ViewMode.SELECT_PERSON}
|
isSelectionMode={viewMode === ViewMode.SELECT_PERSON}
|
||||||
|
@ -24,6 +24,7 @@
|
|||||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
|
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
|
||||||
@ -48,6 +49,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
assetStore.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isMultiSelectState}
|
{#if $isMultiSelectState}
|
||||||
@ -84,6 +89,7 @@
|
|||||||
|
|
||||||
<UserPageLayout hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
|
<UserPageLayout hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
|
enableRouting={true}
|
||||||
{assetStore}
|
{assetStore}
|
||||||
{assetInteractionStore}
|
{assetInteractionStore}
|
||||||
removeAction={AssetAction.ARCHIVE}
|
removeAction={AssetAction.ARCHIVE}
|
||||||
|
@ -291,7 +291,7 @@
|
|||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
assets={searchResultAssets}
|
assets={searchResultAssets}
|
||||||
bind:selectedAssets
|
bind:selectedAssets
|
||||||
on:intersected={loadNextPage}
|
onIntersected={loadNextPage}
|
||||||
showArchiveIcon={true}
|
showArchiveIcon={true}
|
||||||
{viewport}
|
{viewport}
|
||||||
/>
|
/>
|
||||||
|
@ -11,8 +11,13 @@
|
|||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { setSharedLink } from '$lib/utils';
|
import { setSharedLink } from '$lib/utils';
|
||||||
import { t } from 'svelte-i18n';
|
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;
|
export let data: PageData;
|
||||||
|
|
||||||
|
let { gridScrollTarget } = assetViewingStore;
|
||||||
let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data;
|
let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data;
|
||||||
let { title, description } = meta;
|
let { title, description } = meta;
|
||||||
let isOwned = $user ? $user.id === sharedLink?.userId : false;
|
let isOwned = $user ? $user.id === sharedLink?.userId : false;
|
||||||
@ -29,6 +34,11 @@
|
|||||||
description =
|
description =
|
||||||
sharedLink.description ||
|
sharedLink.description ||
|
||||||
$t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
|
$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) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_get_shared_link'));
|
handleError(error, $t('errors.unable_to_get_shared_link'));
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@
|
|||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
@ -84,6 +85,10 @@
|
|||||||
handleError(error, $t('errors.unable_to_restore_trash'));
|
handleError(error, $t('errors.unable_to_restore_trash'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
assetStore.destroy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isMultiSelectState}
|
{#if $isMultiSelectState}
|
||||||
@ -111,7 +116,7 @@
|
|||||||
</LinkButton>
|
</LinkButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssetGrid {assetStore} {assetInteractionStore}>
|
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore}>
|
||||||
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
|
<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 } })}
|
{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}
|
||||||
</p>
|
</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…
x
Reference in New Issue
Block a user