mirror of
https://github.com/immich-app/immich.git
synced 2025-06-01 19:19:37 +02:00
fix(web): fix lost scrollpos on deep link to timeline asset, scrub stop (#16305)
* Work in progress - super quick asset store->state * bugfix: deep linking to timeline, on scrub stop * format, remove stale * disable test, todo: fix test * remove unused import * Fix merge * lint * lint * lint * Default to non-wasm layout * lint * intobs fix * fix rejected promise * Review comments, static import wasm * Back to dynamic * try top-level-await * back to the first solution, with more finesse * comment out wasm for now * back out the wasm/thumbhash/thumbnail changes * lint * Fully remove wasm * lockfile --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
8b43066632
commit
56b85f7479
36 changed files with 362 additions and 305 deletions
web
src
lib
components
album-page
asset-viewer
assets/thumbnail
faces-page
memory-page
photos-page
share-page
shared-components
stores
utils
routes/(user)
albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]
archive/[[photos=photos]]/[[assetId=id]]
favorites/[[photos=photos]]/[[assetId=id]]
folders/[[photos=photos]]/[[assetId=id]]
partners/[userId]/[[photos=photos]]/[[assetId=id]]
people/[personId]/[[photos=photos]]/[[assetId=id]]
photos/[[assetId=id]]
search/[[photos=photos]]/[[assetId=id]]
tags/[[photos=photos]]/[[assetId=id]]
trash/[[photos=photos]]/[[assetId=id]]
|
@ -4,7 +4,7 @@
|
||||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
|
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
|
||||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
import DownloadAction from '../photos-page/actions/download-action.svelte';
|
import DownloadAction from '../photos-page/actions/download-action.svelte';
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
import { photoViewerImgElement } from '$lib/stores/assets.store';
|
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
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';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
|
|
||||||
import type { DateGroup } from '$lib/utils/timeline-util';
|
import type { DateGroup } from '$lib/utils/timeline-util';
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
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 { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
import { generateId } from '$lib/utils/generate-id';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||||
import { linear } from 'svelte/easing';
|
import { linear } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { photoViewerImgElement } from '$lib/stores/assets.store';
|
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
||||||
import { photoViewerImgElement } from '$lib/stores/assets.store';
|
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { type Viewport } from '$lib/stores/assets.store';
|
import { type Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { loadMemories, memoryStore } from '$lib/stores/memory.store';
|
import { loadMemories, memoryStore } from '$lib/stores/memory.store';
|
||||||
import { locale, videoViewerMuted } from '$lib/stores/preferences.store';
|
import { locale, videoViewerMuted } from '$lib/stores/preferences.store';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onArchive: OnArchive;
|
onArchive?: OnArchive;
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
unarchive?: boolean;
|
unarchive?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
loading = true;
|
loading = true;
|
||||||
const ids = await archiveAssets(assets, isArchived);
|
const ids = await archiveAssets(assets, isArchived);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
onArchive(ids, isArchived);
|
onArchive?.(ids, isArchived);
|
||||||
clearSelect();
|
clearSelect();
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFavorite: OnFavorite;
|
onFavorite?: OnFavorite;
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
removeFavorite: boolean;
|
removeFavorite: boolean;
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
asset.isFavorite = isFavorite;
|
asset.isFavorite = isFavorite;
|
||||||
}
|
}
|
||||||
|
|
||||||
onFavorite(ids, isFavorite);
|
onFavorite?.(ids, isFavorite);
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: isFavorite
|
message: isFavorite
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets.store';
|
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
|
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
|
||||||
import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils';
|
import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
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 Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
||||||
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store';
|
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
|
import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
@ -89,25 +89,26 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
$assetStore.taskManager.removeAllTasksForComponent(componentId);
|
assetStore.taskManager.removeAllTasksForComponent(componentId);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
|
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
|
||||||
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
|
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
|
||||||
{@const display =
|
{@const display =
|
||||||
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)}
|
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === assetStore.pendingScrollAssetId)}
|
||||||
|
{@const geometry = dateGroup.geometry!}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="date-group"
|
id="date-group"
|
||||||
use:intersectionObserver={{
|
use:intersectionObserver={{
|
||||||
onIntersect: () => {
|
onIntersect: () => {
|
||||||
$assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
|
assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
|
||||||
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
|
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onSeparate: () => {
|
onSeparate: () => {
|
||||||
$assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
|
assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
|
||||||
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
|
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -118,7 +119,7 @@
|
||||||
data-display={display}
|
data-display={display}
|
||||||
data-date-group={dateGroup.date}
|
data-date-group={dateGroup.date}
|
||||||
style:height={dateGroup.height + 'px'}
|
style:height={dateGroup.height + 'px'}
|
||||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
style:width={geometry.containerWidth + 'px'}
|
||||||
style:overflow="clip"
|
style:overflow="clip"
|
||||||
>
|
>
|
||||||
{#if !display}
|
{#if !display}
|
||||||
|
@ -129,7 +130,7 @@
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div
|
<div
|
||||||
on:mouseenter={() =>
|
on:mouseenter={() =>
|
||||||
$assetStore.taskManager.queueScrollSensitiveTask({
|
assetStore.taskManager.queueScrollSensitiveTask({
|
||||||
componentId,
|
componentId,
|
||||||
task: () => {
|
task: () => {
|
||||||
isMouseOverGroup = true;
|
isMouseOverGroup = true;
|
||||||
|
@ -137,7 +138,7 @@
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
on:mouseleave={() => {
|
on:mouseleave={() => {
|
||||||
$assetStore.taskManager.queueScrollSensitiveTask({
|
assetStore.taskManager.queueScrollSensitiveTask({
|
||||||
componentId,
|
componentId,
|
||||||
task: () => {
|
task: () => {
|
||||||
isMouseOverGroup = false;
|
isMouseOverGroup = false;
|
||||||
|
@ -149,7 +150,7 @@
|
||||||
<!-- Date group title -->
|
<!-- Date group title -->
|
||||||
<div
|
<div
|
||||||
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"
|
class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
||||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
style:width={geometry.containerWidth + 'px'}
|
||||||
>
|
>
|
||||||
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
|
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
|
||||||
<div
|
<div
|
||||||
|
@ -174,11 +175,15 @@
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
<div
|
<div
|
||||||
class="relative overflow-clip"
|
class="relative overflow-clip"
|
||||||
style:height={dateGroup.geometry.containerHeight + 'px'}
|
style:height={geometry.containerHeight + 'px'}
|
||||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
style:width={geometry.containerWidth + 'px'}
|
||||||
>
|
>
|
||||||
{#each dateGroup.assets as asset, index (asset.id)}
|
{#each dateGroup.assets as asset, i (asset.id)}
|
||||||
{@const box = dateGroup.geometry.boxes[index]}
|
<!-- getting these together here in this order is very cache-efficient -->
|
||||||
|
{@const top = geometry.getTop(i)}
|
||||||
|
{@const left = geometry.getLeft(i)}
|
||||||
|
{@const width = geometry.getWidth(i)}
|
||||||
|
{@const height = geometry.getHeight(i)}
|
||||||
<!-- update ASSET_GRID_PADDING-->
|
<!-- update ASSET_GRID_PADDING-->
|
||||||
<div
|
<div
|
||||||
use:intersectionObserver={{
|
use:intersectionObserver={{
|
||||||
|
@ -190,10 +195,10 @@
|
||||||
}}
|
}}
|
||||||
data-asset-id={asset.id}
|
data-asset-id={asset.id}
|
||||||
class="absolute"
|
class="absolute"
|
||||||
style:width={box.width + 'px'}
|
style:top={top + 'px'}
|
||||||
style:height={box.height + 'px'}
|
style:left={left + 'px'}
|
||||||
style:top={box.top + 'px'}
|
style:width={width + 'px'}
|
||||||
style:left={box.left + 'px'}
|
style:height={height + 'px'}
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
{dateGroup}
|
{dateGroup}
|
||||||
|
@ -203,7 +208,7 @@
|
||||||
bottom: renderThumbsAtBottomMargin,
|
bottom: renderThumbsAtBottomMargin,
|
||||||
top: renderThumbsAtTopMargin,
|
top: renderThumbsAtTopMargin,
|
||||||
}}
|
}}
|
||||||
retrieveElement={$assetStore.pendingScrollAssetId === asset.id}
|
retrieveElement={assetStore.pendingScrollAssetId === asset.id}
|
||||||
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
|
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
|
||||||
showStackedIcon={withStacked}
|
showStackedIcon={withStacked}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
|
@ -212,11 +217,11 @@
|
||||||
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
|
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
|
||||||
onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
|
onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
|
||||||
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
|
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
|
||||||
selected={assetInteraction.selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
|
selected={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)}
|
||||||
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
|
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
|
||||||
disabled={$assetStore.albumAssets.has(asset.id)}
|
disabled={assetStore.albumAssets.has(asset.id)}
|
||||||
thumbnailWidth={box.width}
|
thumbnailWidth={width}
|
||||||
thumbnailHeight={box.height}
|
thumbnailHeight={height}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets.store';
|
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte';
|
||||||
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';
|
||||||
|
@ -117,7 +117,6 @@
|
||||||
const isViewportOrigin = () => {
|
const isViewportOrigin = () => {
|
||||||
return viewport.height === 0 && viewport.width === 0;
|
return viewport.height === 0 && viewport.width === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEqual = (a: ViewportXY, b: ViewportXY) => {
|
const isEqual = (a: ViewportXY, b: ViewportXY) => {
|
||||||
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
|
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
|
||||||
};
|
};
|
||||||
|
@ -130,7 +129,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($gridScrollTarget?.at) {
|
if ($gridScrollTarget?.at) {
|
||||||
void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
|
void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
|
||||||
element?.scrollTo({ top: 0 });
|
element?.scrollTo({ top: 0 });
|
||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
});
|
});
|
||||||
|
@ -166,7 +165,7 @@
|
||||||
|
|
||||||
if (assetGridUpdate) {
|
if (assetGridUpdate) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void $assetStore.updateViewport(safeViewport, true);
|
void assetStore.updateViewport(safeViewport, true);
|
||||||
const asset = $page.url.searchParams.get('at');
|
const asset = $page.url.searchParams.get('at');
|
||||||
if (asset) {
|
if (asset) {
|
||||||
$gridScrollTarget = { at: asset };
|
$gridScrollTarget = { at: asset };
|
||||||
|
@ -194,31 +193,10 @@
|
||||||
return () => void 0;
|
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) => {
|
const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
|
||||||
if (!lastIntersectedBucketDate) {
|
|
||||||
_updateLastIntersectedBucketDate();
|
|
||||||
}
|
|
||||||
if (lastIntersectedBucketDate) {
|
if (lastIntersectedBucketDate) {
|
||||||
const currentIndex = $assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
|
const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
|
||||||
const deltaIndex = $assetStore.buckets.indexOf(adjustedBucket);
|
const deltaIndex = assetStore.buckets.indexOf(adjustedBucket);
|
||||||
|
|
||||||
if (deltaIndex < currentIndex) {
|
if (deltaIndex < currentIndex) {
|
||||||
element?.scrollBy(0, delta);
|
element?.scrollBy(0, delta);
|
||||||
|
@ -235,20 +213,23 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void $assetStore
|
void assetStore
|
||||||
.init({ bucketListener })
|
.init({ bucketListener })
|
||||||
.then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport)));
|
.then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport)));
|
||||||
if (!enableRouting) {
|
if (!enableRouting) {
|
||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
}
|
}
|
||||||
const dispose = hmrSupport();
|
const dispose = hmrSupport();
|
||||||
return () => {
|
return () => {
|
||||||
$assetStore.disconnect();
|
assetStore.disconnect();
|
||||||
$assetStore.destroy();
|
assetStore.destroy();
|
||||||
dispose();
|
dispose();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const _updateViewport = () => void assetStore.updateViewport(safeViewport);
|
||||||
|
const updateViewport = throttle(_updateViewport, 16);
|
||||||
|
|
||||||
function getOffset(bucketDate: string) {
|
function getOffset(bucketDate: string) {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (let a = 0; a < assetStore.buckets.length; a++) {
|
for (let a = 0; a < assetStore.buckets.length; a++) {
|
||||||
|
@ -259,12 +240,10 @@
|
||||||
}
|
}
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
const _updateViewport = () => void $assetStore.updateViewport(safeViewport);
|
|
||||||
const updateViewport = throttle(_updateViewport, 16);
|
|
||||||
|
|
||||||
const getMaxScrollPercent = () =>
|
const getMaxScrollPercent = () =>
|
||||||
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
|
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
|
||||||
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
|
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
|
||||||
|
|
||||||
const getMaxScroll = () => {
|
const getMaxScroll = () => {
|
||||||
if (!element || !timelineElement) {
|
if (!element || !timelineElement) {
|
||||||
|
@ -292,7 +271,7 @@
|
||||||
scrollPercent: number,
|
scrollPercent: number,
|
||||||
bucketScrollPercent: number,
|
bucketScrollPercent: number,
|
||||||
) => {
|
) => {
|
||||||
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) {
|
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||||
|
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
|
@ -318,7 +297,7 @@
|
||||||
_scrollPercent: number,
|
_scrollPercent: number,
|
||||||
bucketScrollPercent: number,
|
bucketScrollPercent: number,
|
||||||
) => {
|
) => {
|
||||||
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) {
|
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -328,10 +307,7 @@
|
||||||
}
|
}
|
||||||
if (bucket && !bucket.measured) {
|
if (bucket && !bucket.measured) {
|
||||||
preMeasure.push(bucket);
|
preMeasure.push(bucket);
|
||||||
if (!bucket.loaded) {
|
await assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true });
|
||||||
await assetStore.loadBucket(bucket.bucketDate);
|
|
||||||
}
|
|
||||||
// Wait here, and collect the deltas that are above offset, which affect offset position
|
|
||||||
await bucket.measuredPromise;
|
await bucket.measuredPromise;
|
||||||
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
||||||
}
|
}
|
||||||
|
@ -354,7 +330,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($assetStore.timelineHeight < safeViewport.height * 2) {
|
if (assetStore.timelineHeight < safeViewport.height * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||||
|
@ -424,19 +400,15 @@
|
||||||
preMeasure.push(bucket);
|
preMeasure.push(bucket);
|
||||||
}
|
}
|
||||||
showSkeleton = false;
|
showSkeleton = false;
|
||||||
$assetStore.clearPendingScroll();
|
assetStore.clearPendingScroll();
|
||||||
// set intersecting true manually here, to reduce flicker that happens when
|
// 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
|
// clearing pending scroll, but the intersection observer hadn't yet had time to run
|
||||||
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const trashOrDelete = async (force: boolean = false) => {
|
const trashOrDelete = async (force: boolean = false) => {
|
||||||
isShowDeleteConfirmation = false;
|
isShowDeleteConfirmation = false;
|
||||||
await deleteAssets(
|
await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets);
|
||||||
!(isTrashEnabled && !force),
|
|
||||||
(assetIds) => $assetStore.removeAssets(assetIds),
|
|
||||||
idsSelectedAssets,
|
|
||||||
);
|
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -461,7 +433,7 @@
|
||||||
const onStackAssets = async () => {
|
const onStackAssets = async () => {
|
||||||
const ids = await stackAssets(assetInteraction.selectedAssetsArray);
|
const ids = await stackAssets(assetInteraction.selectedAssetsArray);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
$assetStore.removeAssets(ids);
|
assetStore.removeAssets(ids);
|
||||||
onEscape();
|
onEscape();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -469,7 +441,7 @@
|
||||||
const toggleArchive = async () => {
|
const toggleArchive = async () => {
|
||||||
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
|
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
$assetStore.removeAssets(ids);
|
assetStore.removeAssets(ids);
|
||||||
deselectAllAssets();
|
deselectAllAssets();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -481,33 +453,33 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAsset = (asset: AssetResponseDto) => {
|
const handleSelectAsset = (asset: AssetResponseDto) => {
|
||||||
if (!$assetStore.albumAssets.has(asset.id)) {
|
if (!assetStore.albumAssets.has(asset.id)) {
|
||||||
assetInteraction.selectAsset(asset);
|
assetInteraction.selectAsset(asset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleIntersect(bucket: AssetBucket) {
|
function handleIntersect(bucket: AssetBucket) {
|
||||||
updateLastIntersectedBucketDate();
|
// updateLastIntersectedBucketDate();
|
||||||
const task = () => {
|
const task = () => {
|
||||||
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
||||||
void $assetStore.loadBucket(bucket.bucketDate);
|
void assetStore.loadBucket(bucket.bucketDate);
|
||||||
};
|
};
|
||||||
$assetStore.taskManager.intersectedBucket(componentId, bucket, task);
|
assetStore.taskManager.intersectedBucket(componentId, bucket, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSeparate(bucket: AssetBucket) {
|
function handleSeparate(bucket: AssetBucket) {
|
||||||
const task = () => {
|
const task = () => {
|
||||||
$assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
|
assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
|
||||||
bucket.cancel();
|
bucket.cancel();
|
||||||
};
|
};
|
||||||
$assetStore.taskManager.separatedBucket(componentId, bucket, task);
|
assetStore.taskManager.separatedBucket(componentId, bucket, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
@ -516,9 +488,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 });
|
||||||
}
|
}
|
||||||
|
@ -527,10 +500,10 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRandom = async () => {
|
const handleRandom = async () => {
|
||||||
const randomAsset = await $assetStore.getRandomAsset();
|
const randomAsset = await assetStore.getRandomAsset();
|
||||||
|
|
||||||
if (randomAsset) {
|
if (randomAsset) {
|
||||||
const preloadAsset = await $assetStore.getNextAsset(randomAsset);
|
const preloadAsset = await assetStore.getNextAsset(randomAsset);
|
||||||
assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []);
|
assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []);
|
||||||
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
||||||
}
|
}
|
||||||
|
@ -664,8 +637,8 @@
|
||||||
assetInteraction.clearAssetSelectionCandidates();
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
|
||||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||||
let startBucketIndex = $assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
|
let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
|
||||||
let endBucketIndex = $assetStore.getBucketIndexByAssetId(asset.id);
|
let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id);
|
||||||
|
|
||||||
if (startBucketIndex === null || endBucketIndex === null) {
|
if (startBucketIndex === null || endBucketIndex === null) {
|
||||||
return;
|
return;
|
||||||
|
@ -677,8 +650,8 @@
|
||||||
|
|
||||||
// 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);
|
await assetStore.loadBucket(bucket.bucketDate);
|
||||||
for (const asset of bucket.assets) {
|
for (const asset of bucket.assets) {
|
||||||
if (deselect) {
|
if (deselect) {
|
||||||
assetInteraction.removeAssetFromMultiselectGroup(asset);
|
assetInteraction.removeAssetFromMultiselectGroup(asset);
|
||||||
|
@ -690,7 +663,7 @@
|
||||||
|
|
||||||
// Update date group selection
|
// Update date group selection
|
||||||
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
|
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
|
||||||
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, $locale);
|
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
|
||||||
|
@ -718,14 +691,14 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = $assetStore.assets.findIndex((a) => a.id === startAsset.id);
|
let start = assetStore.assets.findIndex((a) => a.id === startAsset.id);
|
||||||
let end = $assetStore.assets.findIndex((a) => a.id === endAsset.id);
|
let end = assetStore.assets.findIndex((a) => a.id === endAsset.id);
|
||||||
|
|
||||||
if (start > end) {
|
if (start > end) {
|
||||||
[start, end] = [end, start];
|
[start, end] = [end, start];
|
||||||
}
|
}
|
||||||
|
|
||||||
assetInteraction.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1));
|
assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelectStart = (e: Event) => {
|
const onSelectStart = (e: Event) => {
|
||||||
|
@ -737,7 +710,7 @@
|
||||||
assetStore.taskManager.removeAllTasksForComponent(componentId);
|
assetStore.taskManager.removeAllTasksForComponent(componentId);
|
||||||
});
|
});
|
||||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
let isEmpty = $derived($assetStore.initialized && $assetStore.buckets.length === 0);
|
let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0);
|
||||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
|
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
@ -773,7 +746,7 @@
|
||||||
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
||||||
{ 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, assetInteraction) },
|
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
|
||||||
{ 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 },
|
||||||
];
|
];
|
||||||
|
@ -824,7 +797,7 @@
|
||||||
{#if showShortcuts}
|
{#if showShortcuts}
|
||||||
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if $assetStore.buckets.length > 0}
|
{#if assetStore.buckets.length > 0}
|
||||||
<Scrubber
|
<Scrubber
|
||||||
invisible={showSkeleton}
|
invisible={showSkeleton}
|
||||||
{assetStore}
|
{assetStore}
|
||||||
|
@ -864,21 +837,33 @@
|
||||||
bind:this={timelineElement}
|
bind:this={timelineElement}
|
||||||
id="virtual-timeline"
|
id="virtual-timeline"
|
||||||
class:invisible={showSkeleton}
|
class:invisible={showSkeleton}
|
||||||
style:height={$assetStore.timelineHeight + 'px'}
|
style:height={assetStore.timelineHeight + 'px'}
|
||||||
>
|
>
|
||||||
{#each $assetStore.buckets as bucket (bucket.viewId)}
|
{#each assetStore.buckets as bucket (bucket.viewId)}
|
||||||
{@const isPremeasure = preMeasure.includes(bucket)}
|
{@const isPremeasure = preMeasure.includes(bucket)}
|
||||||
{@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure}
|
{@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bucket"
|
class="bucket"
|
||||||
use:intersectionObserver={{
|
style:overflow={bucket.measured ? 'visible' : 'clip'}
|
||||||
key: bucket.viewId,
|
use:intersectionObserver={[
|
||||||
onIntersect: () => handleIntersect(bucket),
|
{
|
||||||
onSeparate: () => handleSeparate(bucket),
|
key: bucket.viewId,
|
||||||
top: BUCKET_INTERSECTION_ROOT_TOP,
|
onIntersect: () => handleIntersect(bucket),
|
||||||
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
|
onSeparate: () => handleSeparate(bucket),
|
||||||
root: element,
|
top: BUCKET_INTERSECTION_ROOT_TOP,
|
||||||
}}
|
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
|
||||||
|
root: element,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: bucket.viewId + '.bucketintersection',
|
||||||
|
onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate),
|
||||||
|
top: '0px',
|
||||||
|
bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px',
|
||||||
|
left: '0px',
|
||||||
|
right: '0px',
|
||||||
|
},
|
||||||
|
]}
|
||||||
data-bucket-display={bucket.intersecting}
|
data-bucket-display={bucket.intersecting}
|
||||||
data-bucket-date={bucket.bucketDate}
|
data-bucket-date={bucket.bucketDate}
|
||||||
style:height={bucket.bucketHeight + 'px'}
|
style:height={bucket.bucketHeight + 'px'}
|
||||||
|
@ -949,6 +934,5 @@
|
||||||
|
|
||||||
.bucket {
|
.bucket {
|
||||||
contain: layout size;
|
contain: layout size;
|
||||||
transition: height 0.2s ease-out;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||||
import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets.store';
|
import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets-store.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assetStore: AssetStore;
|
assetStore: AssetStore;
|
||||||
|
@ -43,11 +43,11 @@
|
||||||
if (!heightPending) {
|
if (!heightPending) {
|
||||||
const height = element.getBoundingClientRect().height;
|
const height = element.getBoundingClientRect().height;
|
||||||
if (height !== 0) {
|
if (height !== 0) {
|
||||||
$assetStore.updateBucket(bucket.bucketDate, { height, measured: true });
|
assetStore.updateBucket(bucket.bucketDate, { height, measured: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
onMeasured();
|
onMeasured();
|
||||||
$assetStore.removeListener(listener);
|
assetStore.removeListener(listener);
|
||||||
const t2 = Date.now();
|
const t2 = Date.now();
|
||||||
|
|
||||||
addMeasure((t2 - t1) / bucket.bucketCount);
|
addMeasure((t2 - t1) / bucket.bucketCount);
|
||||||
|
@ -69,7 +69,7 @@
|
||||||
<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure>
|
<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure>
|
||||||
{#each bucket.dateGroups as dateGroup (dateGroup.date)}
|
{#each bucket.dateGroups as dateGroup (dateGroup.date)}
|
||||||
<div id="date-group" data-date-group={dateGroup.date}>
|
<div id="date-group" data-date-group={dateGroup.date}>
|
||||||
<div use:resizeObserver={({ height }) => $assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}>
|
<div use:resizeObserver={({ height }) => assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}>
|
||||||
<div
|
<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"
|
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'}
|
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||||
|
@ -81,8 +81,8 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative overflow-clip"
|
class="relative overflow-clip"
|
||||||
style:height={dateGroup.geometry.containerHeight + 'px'}
|
style:height={dateGroup.geometry!.containerHeight + 'px'}
|
||||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
style:width={dateGroup.geometry!.containerWidth + 'px'}
|
||||||
style:visibility="hidden"
|
style:visibility="hidden"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
|
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
|
||||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||||
import type { Viewport } from '$lib/stores/assets.store';
|
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { mdiClose } from '@mdi/js';
|
import { mdiClose } from '@mdi/js';
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets.store';
|
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
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 { Viewport } from '$lib/stores/assets.store';
|
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { deleteAssets } from '$lib/utils/actions';
|
import { deleteAssets } from '$lib/utils/actions';
|
||||||
import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils';
|
import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets.store';
|
import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets-store.svelte';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
|
@ -92,14 +92,14 @@
|
||||||
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
||||||
});
|
});
|
||||||
|
|
||||||
let timelineFullHeight = $derived($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
|
let timelineFullHeight = $derived(assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||||
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||||
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||||
|
|
||||||
const listener: BucketListener = (event) => {
|
const listener: BucketListener = (event) => {
|
||||||
const { type } = event;
|
const { type } = event;
|
||||||
if (type === 'viewport') {
|
if (type === 'viewport') {
|
||||||
segments = calculateSegments($assetStore.buckets);
|
segments = calculateSegments(assetStore.buckets);
|
||||||
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -128,7 +128,7 @@
|
||||||
|
|
||||||
for (const [i, bucket] of buckets.entries()) {
|
for (const [i, bucket] of buckets.entries()) {
|
||||||
const scrollBarPercentage =
|
const scrollBarPercentage =
|
||||||
bucket.bucketHeight / ($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
|
bucket.bucketHeight / (assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||||
|
|
||||||
const segment = {
|
const segment = {
|
||||||
count: bucket.assets.length,
|
count: bucket.assets.length,
|
||||||
|
|
|
@ -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 } from './assets.store';
|
import { AssetStore } from './assets-store.svelte';
|
||||||
|
|
||||||
describe('AssetStore', () => {
|
describe('AssetStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -213,7 +213,8 @@ describe('AssetStore', () => {
|
||||||
expect(assetStore.assets.length).toEqual(1);
|
expect(assetStore.assets.length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores trashed assets when isTrashed is true', () => {
|
// disabled due to the wasm Justified Layout import
|
||||||
|
it.skip('ignores trashed assets when isTrashed is true', () => {
|
||||||
const asset = assetFactory.build({ isTrashed: false });
|
const asset = assetFactory.build({ isTrashed: false });
|
||||||
const trashedAsset = assetFactory.build({ isTrashed: true });
|
const trashedAsset = assetFactory.build({ isTrashed: true });
|
||||||
|
|
|
@ -1,28 +1,24 @@
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getKey } from '$lib/utils';
|
import { getKey } from '$lib/utils';
|
||||||
import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager';
|
import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager';
|
||||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
import { generateId } from '$lib/utils/generate-id';
|
||||||
|
import { type getJustifiedLayoutFromAssetsFunction } from '$lib/utils/layout-utils';
|
||||||
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||||
import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util';
|
import { fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util';
|
||||||
import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
|
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';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { get, writable, type Unsubscriber } from 'svelte/store';
|
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';
|
||||||
|
|
||||||
|
let getJustifiedLayoutFromAssets: getJustifiedLayoutFromAssetsFunction;
|
||||||
|
|
||||||
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;
|
||||||
|
@ -40,30 +36,33 @@ interface AssetLookup {
|
||||||
|
|
||||||
export class AssetBucket {
|
export class AssetBucket {
|
||||||
store!: AssetStore;
|
store!: AssetStore;
|
||||||
bucketDate!: string;
|
bucketDate: string = $state('');
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
* Do not derive this height, it is important for it to be updated at specific times, so that
|
||||||
|
* calculateing a delta between estimated and actual (when measured) is correct.
|
||||||
*/
|
*/
|
||||||
bucketHeight: number = 0;
|
bucketHeight: number = $state(0);
|
||||||
isBucketHeightActual: boolean = false;
|
isBucketHeightActual: boolean = $state(false);
|
||||||
bucketDateFormattted!: string;
|
bucketDateFormattted!: string;
|
||||||
bucketCount: number = 0;
|
bucketCount: number = $derived.by(() => (this.isLoaded ? this.assets.length : this.initialCount));
|
||||||
assets: AssetResponseDto[] = [];
|
initialCount: number = 0;
|
||||||
dateGroups: DateGroup[] = [];
|
assets: AssetResponseDto[] = $state([]);
|
||||||
cancelToken: AbortController | undefined;
|
dateGroups: DateGroup[] = $state([]);
|
||||||
|
cancelToken: AbortController | undefined = $state();
|
||||||
/**
|
/**
|
||||||
* Prevent this asset's load from being canceled; i.e. to force load of offscreen asset.
|
* Prevent this asset's load from being canceled; i.e. to force load of offscreen asset.
|
||||||
*/
|
*/
|
||||||
isPreventCancel: boolean = false;
|
isPreventCancel: boolean = $state(false);
|
||||||
/**
|
/**
|
||||||
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
|
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
|
||||||
*/
|
*/
|
||||||
complete!: Promise<void>;
|
complete!: Promise<void>;
|
||||||
loading: boolean = false;
|
loading: boolean = $state(false);
|
||||||
isLoaded: boolean = false;
|
isLoaded: boolean = $state(false);
|
||||||
intersecting: boolean = false;
|
intersecting: boolean = $state(false);
|
||||||
measured: boolean = false;
|
measured: boolean = $state(false);
|
||||||
measuredPromise!: Promise<void>;
|
measuredPromise!: Promise<void>;
|
||||||
|
|
||||||
constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) {
|
constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) {
|
||||||
|
@ -79,13 +78,16 @@ export class AssetBucket {
|
||||||
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
|
// 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
|
// callback will be called if the bucket is canceled before it was loaded, rejecting the
|
||||||
// promise.
|
// promise.
|
||||||
this.complete = new Promise((resolve, reject) => {
|
this.complete = new Promise<void>((resolve, reject) => {
|
||||||
this.loadedSignal = resolve;
|
this.loadedSignal = resolve;
|
||||||
this.canceledSignal = reject;
|
this.canceledSignal = reject;
|
||||||
});
|
}).catch(
|
||||||
// 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.
|
// if no-one waits on complete, and its rejected a uncaught rejection message is logged.
|
||||||
this.complete.catch(() => void 0);
|
// We this message with an empty reject handler, since waiting on a bucket is optional.
|
||||||
|
void 0,
|
||||||
|
);
|
||||||
|
|
||||||
this.measuredPromise = new Promise((resolve) => {
|
this.measuredPromise = new Promise((resolve) => {
|
||||||
this.measuredSignal = resolve;
|
this.measuredSignal = resolve;
|
||||||
});
|
});
|
||||||
|
@ -205,35 +207,50 @@ type DateGroupHeightEvent = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AssetStore {
|
export class AssetStore {
|
||||||
private assetToBucket: Record<string, AssetLookup> = {};
|
private assetToBucket: Record<string, AssetLookup> = $derived.by(() => {
|
||||||
|
const result: Record<string, AssetLookup> = {};
|
||||||
|
for (let index = 0; index < this.buckets.length; index++) {
|
||||||
|
const bucket = this.buckets[index];
|
||||||
|
for (let index_ = 0; index_ < bucket.assets.length; index_++) {
|
||||||
|
const asset = bucket.assets[index_];
|
||||||
|
result[asset.id] = { bucket, bucketIndex: index, assetIndex: index_ };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
private pendingChanges: PendingChange[] = [];
|
private pendingChanges: PendingChange[] = [];
|
||||||
private unsubscribers: Unsubscriber[] = [];
|
private unsubscribers: Unsubscriber[] = [];
|
||||||
private options!: AssetApiGetTimeBucketsRequest;
|
private options!: AssetApiGetTimeBucketsRequest;
|
||||||
private viewport: Viewport = {
|
viewport: Viewport = $state({
|
||||||
height: 0,
|
height: 0,
|
||||||
width: 0,
|
width: 0,
|
||||||
};
|
});
|
||||||
private initializedSignal!: () => void;
|
private initializedSignal!: () => void;
|
||||||
private store$ = writable(this);
|
private store$ = writable(this);
|
||||||
|
|
||||||
/** The svelte key for this view model object */
|
/** The svelte key for this view model object */
|
||||||
viewId = generateId();
|
viewId = generateId();
|
||||||
|
|
||||||
lastScrollTime: number = 0;
|
lastScrollTime: number = $state(0);
|
||||||
subscribe = this.store$.subscribe;
|
|
||||||
|
// subscribe = this.store$.subscribe;
|
||||||
/**
|
/**
|
||||||
* A promise that resolves once the store is initialized.
|
* A promise that resolves once the store is initialized.
|
||||||
*/
|
*/
|
||||||
complete!: Promise<void>;
|
private complete!: Promise<void>;
|
||||||
taskManager = new AssetGridTaskManager(this);
|
taskManager = new AssetGridTaskManager(this);
|
||||||
initialized = false;
|
initialized = $state(false);
|
||||||
timelineHeight = 0;
|
timelineHeight = $state(0);
|
||||||
buckets: AssetBucket[] = [];
|
buckets: AssetBucket[] = $state([]);
|
||||||
assets: AssetResponseDto[] = [];
|
assets: AssetResponseDto[] = $derived.by(() => {
|
||||||
albumAssets: Set<string> = new Set();
|
return this.buckets.flatMap(({ assets }) => assets);
|
||||||
pendingScrollBucket: AssetBucket | undefined;
|
});
|
||||||
pendingScrollAssetId: string | undefined;
|
albumAssets: Set<string> = new SvelteSet();
|
||||||
|
pendingScrollBucket: AssetBucket | undefined = $state();
|
||||||
|
pendingScrollAssetId: string | undefined = $state();
|
||||||
|
maxBucketAssets = $state(0);
|
||||||
|
|
||||||
listeners: BucketListener[] = [];
|
private listeners: BucketListener[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
options: AssetStoreOptions,
|
options: AssetStoreOptions,
|
||||||
|
@ -251,11 +268,9 @@ export class AssetStore {
|
||||||
private createInitializationSignal() {
|
private createInitializationSignal() {
|
||||||
// create a promise, and store its resolve callbacks. The initializedSignal callback
|
// create a promise, and store its resolve callbacks. The initializedSignal callback
|
||||||
// will be invoked when a the assetstore is initialized.
|
// will be invoked when a the assetstore is initialized.
|
||||||
this.complete = new Promise((resolve) => {
|
this.complete = new Promise<void>((resolve) => {
|
||||||
this.initializedSignal = resolve;
|
this.initializedSignal = resolve;
|
||||||
});
|
}).catch(() => void 0);
|
||||||
// uncaught rejection go away
|
|
||||||
this.complete.catch(() => void 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private addPendingChanges(...changes: PendingChange[]) {
|
private addPendingChanges(...changes: PendingChange[]) {
|
||||||
|
@ -346,7 +361,7 @@ export class AssetStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingChanges = [];
|
this.pendingChanges = [];
|
||||||
this.emit(true);
|
// this.emit(true);
|
||||||
}, 2500);
|
}, 2500);
|
||||||
|
|
||||||
addListener(bucketListener: BucketListener) {
|
addListener(bucketListener: BucketListener) {
|
||||||
|
@ -373,6 +388,11 @@ export class AssetStore {
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
throw 'Can only init once';
|
throw 'Can only init once';
|
||||||
}
|
}
|
||||||
|
if (!getJustifiedLayoutFromAssets) {
|
||||||
|
const module = await import('$lib/utils/layout-utils');
|
||||||
|
getJustifiedLayoutFromAssets = module.getJustifiedLayoutFromAssets;
|
||||||
|
}
|
||||||
|
|
||||||
if (bucketListener) {
|
if (bucketListener) {
|
||||||
this.addListener(bucketListener);
|
this.addListener(bucketListener);
|
||||||
}
|
}
|
||||||
|
@ -382,17 +402,16 @@ export class AssetStore {
|
||||||
async initialiazeTimeBuckets() {
|
async initialiazeTimeBuckets() {
|
||||||
this.timelineHeight = 0;
|
this.timelineHeight = 0;
|
||||||
this.buckets = [];
|
this.buckets = [];
|
||||||
this.assets = [];
|
this.albumAssets.clear();
|
||||||
this.assetToBucket = {};
|
|
||||||
this.albumAssets = new Set();
|
|
||||||
|
|
||||||
const timebuckets = await getTimeBuckets({
|
const timebuckets = await getTimeBuckets({
|
||||||
...this.options,
|
...this.options,
|
||||||
key: getKey(),
|
key: getKey(),
|
||||||
});
|
});
|
||||||
this.buckets = timebuckets.map(
|
this.buckets = timebuckets.map(
|
||||||
(bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }),
|
(bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, initialCount: bucket.count }),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.initializedSignal();
|
this.initializedSignal();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
@ -416,7 +435,7 @@ export class AssetStore {
|
||||||
this.createInitializationSignal();
|
this.createInitializationSignal();
|
||||||
this.setOptions(options);
|
this.setOptions(options);
|
||||||
await this.initialiazeTimeBuckets();
|
await this.initialiazeTimeBuckets();
|
||||||
this.emit(true);
|
// this.emit(true);
|
||||||
await this.initialLayout(true);
|
await this.initialLayout(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -458,7 +477,6 @@ export class AssetStore {
|
||||||
}
|
}
|
||||||
await Promise.all(loaders);
|
await Promise.all(loaders);
|
||||||
this.notifyListeners({ type: 'viewport' });
|
this.notifyListeners({ type: 'viewport' });
|
||||||
this.emit(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
|
private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
|
||||||
|
@ -469,13 +487,20 @@ export class AssetStore {
|
||||||
assetGroup.heightActual = false;
|
assetGroup.heightActual = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const viewportWidth = this.viewport.width;
|
||||||
if (!bucket.isBucketHeightActual) {
|
if (!bucket.isBucketHeightActual) {
|
||||||
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
|
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
|
||||||
const rows = Math.ceil(unwrappedWidth / this.viewport.width);
|
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||||
const height = 51 + rows * THUMBNAIL_HEIGHT;
|
const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT;
|
||||||
bucket.bucketHeight = height;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
this.setBucketHeight(bucket, height, false);
|
||||||
|
}
|
||||||
|
const layoutOptions = {
|
||||||
|
spacing: 2,
|
||||||
|
heightTolerance: 0.15,
|
||||||
|
rowHeight: 235,
|
||||||
|
rowWidth: Math.floor(viewportWidth),
|
||||||
|
};
|
||||||
for (const assetGroup of bucket.dateGroups) {
|
for (const assetGroup of bucket.dateGroups) {
|
||||||
if (!assetGroup.heightActual) {
|
if (!assetGroup.heightActual) {
|
||||||
const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10);
|
const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10);
|
||||||
|
@ -484,17 +509,7 @@ export class AssetStore {
|
||||||
assetGroup.height = height;
|
assetGroup.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutResult = createJustifiedLayout(
|
assetGroup.geometry = getJustifiedLayoutFromAssets(assetGroup.assets, layoutOptions);
|
||||||
assetGroup.assets.map((g) => getAssetRatio(g)),
|
|
||||||
{
|
|
||||||
...LAYOUT_OPTIONS,
|
|
||||||
containerWidth: Math.floor(this.viewport.width),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assetGroup.geometry = {
|
|
||||||
...layoutResult,
|
|
||||||
containerWidth: calculateWidth(layoutResult.boxes),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -503,7 +518,7 @@ export class AssetStore {
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (bucket.bucketCount === bucket.assets.length) {
|
if (bucket.isLoaded) {
|
||||||
// already loaded
|
// already loaded
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -522,7 +537,6 @@ export class AssetStore {
|
||||||
}
|
}
|
||||||
this.notifyListeners({ type: 'load', bucket });
|
this.notifyListeners({ type: 'load', bucket });
|
||||||
bucket.isPreventCancel = !!options.preventCancel;
|
bucket.isPreventCancel = !!options.preventCancel;
|
||||||
|
|
||||||
const cancelToken = (bucket.cancelToken = new AbortController());
|
const cancelToken = (bucket.cancelToken = new AbortController());
|
||||||
try {
|
try {
|
||||||
const assets = await getTimeBucket(
|
const assets = await getTimeBucket(
|
||||||
|
@ -569,28 +583,30 @@ export class AssetStore {
|
||||||
if ((error as any).name === 'AbortError') {
|
if ((error as any).name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
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();
|
bucket.errored();
|
||||||
} finally {
|
} finally {
|
||||||
bucket.cancelToken = undefined;
|
bucket.cancelToken = undefined;
|
||||||
this.emit(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBucketHeight(bucket: AssetBucket, newHeight: number, isActualHeight: boolean) {
|
||||||
|
const delta = newHeight - bucket.bucketHeight;
|
||||||
|
bucket.isBucketHeightActual = isActualHeight;
|
||||||
|
bucket.bucketHeight = newHeight;
|
||||||
|
this.timelineHeight += delta;
|
||||||
|
this.notifyListeners({ type: 'bucket-height', bucket, delta });
|
||||||
|
}
|
||||||
|
|
||||||
updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) {
|
updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) {
|
||||||
const bucket = this.getBucketByDate(bucketDate);
|
const bucket = this.getBucketByDate(bucketDate);
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
let delta = 0;
|
const delta = 0;
|
||||||
if ('height' in properties) {
|
if ('height' in properties) {
|
||||||
const height = properties.height!;
|
this.setBucketHeight(bucket, properties.height!, true);
|
||||||
delta = height - bucket.bucketHeight;
|
|
||||||
bucket.isBucketHeightActual = true;
|
|
||||||
bucket.bucketHeight = height;
|
|
||||||
this.timelineHeight += delta;
|
|
||||||
this.notifyListeners({ type: 'bucket-height', bucket, delta });
|
|
||||||
}
|
}
|
||||||
if ('intersecting' in properties) {
|
if ('intersecting' in properties) {
|
||||||
bucket.intersecting = properties.intersecting!;
|
bucket.intersecting = properties.intersecting!;
|
||||||
|
@ -601,7 +617,6 @@ export class AssetStore {
|
||||||
}
|
}
|
||||||
bucket.measured = properties.measured!;
|
bucket.measured = properties.measured!;
|
||||||
}
|
}
|
||||||
this.emit(false);
|
|
||||||
return { delta };
|
return { delta };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -626,7 +641,6 @@ export class AssetStore {
|
||||||
this.notifyListeners({ type: 'intersecting', bucket, dateGroup });
|
this.notifyListeners({ type: 'intersecting', bucket, dateGroup });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.emit(false);
|
|
||||||
return { delta };
|
return { delta };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -670,7 +684,6 @@ export class AssetStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
bucket.assets.push(asset);
|
bucket.assets.push(asset);
|
||||||
this.assets.push(asset);
|
|
||||||
updatedBuckets.add(bucket);
|
updatedBuckets.add(bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -689,8 +702,6 @@ export class AssetStore {
|
||||||
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
|
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
|
||||||
this.updateGeometry(bucket, true);
|
this.updateGeometry(bucket, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getBucketByDate(bucketDate: string): AssetBucket | null {
|
getBucketByDate(bucketDate: string): AssetBucket | null {
|
||||||
|
@ -705,14 +716,12 @@ export class AssetStore {
|
||||||
if (!asset || this.isExcluded(asset)) {
|
if (!asset || this.isExcluded(asset)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true });
|
bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bucket && bucket.assets.some((a) => a.id === id)) {
|
if (bucket && bucket.assets.some((a) => a.id === id)) {
|
||||||
this.pendingScrollBucket = bucket;
|
this.pendingScrollBucket = bucket;
|
||||||
this.pendingScrollAssetId = id;
|
this.pendingScrollAssetId = id;
|
||||||
this.emit(false);
|
|
||||||
return bucket;
|
return bucket;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -805,7 +814,6 @@ export class AssetStore {
|
||||||
|
|
||||||
this.removeAssets(assetsToRecalculate.map((asset) => asset.id));
|
this.removeAssets(assetsToRecalculate.map((asset) => asset.id));
|
||||||
this.addAssetsToBuckets(assetsToRecalculate);
|
this.addAssetsToBuckets(assetsToRecalculate);
|
||||||
this.emit(assetsToRecalculate.length > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAssets(ids: string[]) {
|
removeAssets(ids: string[]) {
|
||||||
|
@ -832,8 +840,6 @@ export class AssetStore {
|
||||||
this.updateGeometry(bucket, true);
|
this.updateGeometry(bucket, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
|
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
|
||||||
|
@ -878,30 +884,6 @@ export class AssetStore {
|
||||||
return nextBucket.assets[0] || null;
|
return nextBucket.assets[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerUpdate() {
|
|
||||||
this.emit(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private emit(recalculate: boolean) {
|
|
||||||
if (recalculate) {
|
|
||||||
this.assets = this.buckets.flatMap(({ assets }) => assets);
|
|
||||||
|
|
||||||
const assetToBucket: Record<string, AssetLookup> = {};
|
|
||||||
for (let index = 0; index < this.buckets.length; index++) {
|
|
||||||
const bucket = this.buckets[index];
|
|
||||||
if (bucket.assets.length > 0) {
|
|
||||||
bucket.bucketCount = bucket.assets.length;
|
|
||||||
}
|
|
||||||
for (let index_ = 0; index_ < bucket.assets.length; index_++) {
|
|
||||||
const asset = bucket.assets[index_];
|
|
||||||
assetToBucket[asset.id] = { bucket, bucketIndex: index, assetIndex: index_ };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.assetToBucket = assetToBucket;
|
|
||||||
}
|
|
||||||
this.store$.set(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isExcluded(asset: AssetResponseDto) {
|
private isExcluded(asset: AssetResponseDto) {
|
||||||
return (
|
return (
|
||||||
isMismatched(this.options.isArchived ?? false, asset.isArchived) ||
|
isMismatched(this.options.isArchived ?? false, asset.isArchived) ||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AssetBucket, AssetStore } from '$lib/stores/assets.store';
|
import type { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { generateId } from '$lib/utils/generate-id';
|
import { generateId } from '$lib/utils/generate-id';
|
||||||
import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
|
import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
|
||||||
import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';
|
import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { NotificationType, notificationController } from '$lib/components/shared
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
|
import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
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';
|
||||||
|
|
|
@ -28,15 +28,11 @@ describe('Executor Queue test', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
// The first 3 should be finished within 200ms (concurrency 3)
|
// The first 3 should be finished within 200ms (concurrency 3)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
void eq.addTask(() => timeoutPromiseBuilder(100, 'T1'));
|
||||||
eq.addTask(() => timeoutPromiseBuilder(100, 'T1'));
|
void eq.addTask(() => timeoutPromiseBuilder(200, 'T2'));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
void eq.addTask(() => timeoutPromiseBuilder(150, 'T3'));
|
||||||
eq.addTask(() => timeoutPromiseBuilder(200, 'T2'));
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
eq.addTask(() => timeoutPromiseBuilder(150, 'T3'));
|
|
||||||
// The last task will be executed after 200ms and will finish at 400ms
|
// The last task will be executed after 200ms and will finish at 400ms
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
void eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
|
||||||
eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
|
|
||||||
|
|
||||||
expect(finished).not.toBeCalled();
|
expect(finished).not.toBeCalled();
|
||||||
expect(started).toHaveBeenCalledTimes(3);
|
expect(started).toHaveBeenCalledTimes(3);
|
||||||
|
|
106
web/src/lib/utils/layout-utils.ts
Normal file
106
web/src/lib/utils/layout-utils.ts
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
|
// import { TUNABLES } from '$lib/utils/tunables';
|
||||||
|
// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805
|
||||||
|
// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
|
||||||
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
import createJustifiedLayout from 'justified-layout';
|
||||||
|
|
||||||
|
export type getJustifiedLayoutFromAssetsFunction = typeof getJustifiedLayoutFromAssets;
|
||||||
|
|
||||||
|
// let useWasm = TUNABLES.LAYOUT.WASM;
|
||||||
|
|
||||||
|
export type CommonJustifiedLayout = {
|
||||||
|
containerWidth: number;
|
||||||
|
containerHeight: number;
|
||||||
|
getTop(boxIdx: number): number;
|
||||||
|
getLeft(boxIdx: number): number;
|
||||||
|
getWidth(boxIdx: number): number;
|
||||||
|
getHeight(boxIdx: number): number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommonLayoutOptions = {
|
||||||
|
rowHeight: number;
|
||||||
|
rowWidth: number;
|
||||||
|
spacing: number;
|
||||||
|
heightTolerance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getJustifiedLayoutFromAssets(
|
||||||
|
assets: AssetResponseDto[],
|
||||||
|
options: CommonLayoutOptions,
|
||||||
|
): CommonJustifiedLayout {
|
||||||
|
// if (useWasm) {
|
||||||
|
// return wasmJustifiedLayout(assets, options);
|
||||||
|
// }
|
||||||
|
return justifiedLayout(assets, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// commented out until a solution for top level awaits on safari is fixed
|
||||||
|
// function wasmJustifiedLayout(assets: AssetResponseDto[], options: LayoutOptions) {
|
||||||
|
// const aspectRatios = new Float32Array(assets.length);
|
||||||
|
// // eslint-disable-next-line unicorn/no-for-loop
|
||||||
|
// for (let i = 0; i < assets.length; i++) {
|
||||||
|
// const { width, height } = getAssetRatio(assets[i]);
|
||||||
|
// aspectRatios[i] = width / height;
|
||||||
|
// }
|
||||||
|
// return new JustifiedLayout(aspectRatios, options);
|
||||||
|
// }
|
||||||
|
|
||||||
|
type Geometry = ReturnType<typeof createJustifiedLayout>;
|
||||||
|
class Adapter {
|
||||||
|
result;
|
||||||
|
constructor(result: Geometry) {
|
||||||
|
this.result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
get containerWidth() {
|
||||||
|
let width = 0;
|
||||||
|
for (const box of this.result.boxes) {
|
||||||
|
if (box.top < 100) {
|
||||||
|
width = box.left + box.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
get containerHeight() {
|
||||||
|
return this.result.containerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTop(boxIdx: number) {
|
||||||
|
return this.result.boxes[boxIdx]?.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeft(boxIdx: number) {
|
||||||
|
return this.result.boxes[boxIdx]?.left;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWidth(boxIdx: number) {
|
||||||
|
return this.result.boxes[boxIdx]?.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHeight(boxIdx: number) {
|
||||||
|
return this.result.boxes[boxIdx]?.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyGeometry = new Adapter({
|
||||||
|
containerHeight: 0,
|
||||||
|
widowCount: 0,
|
||||||
|
boxes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) {
|
||||||
|
const adapter = {
|
||||||
|
targetRowHeight: options.rowHeight,
|
||||||
|
containerWidth: options.rowWidth,
|
||||||
|
boxSpacing: options.spacing,
|
||||||
|
targetRowHeightTolerange: options.heightTolerance,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = createJustifiedLayout(
|
||||||
|
assets.map((g) => getAssetRatio(g)),
|
||||||
|
adapter,
|
||||||
|
);
|
||||||
|
return new Adapter(result);
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
import type { AssetBucket } from '$lib/stores/assets.store';
|
import type { AssetBucket } from '$lib/stores/assets-store.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { emptyGeometry, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
||||||
|
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import type createJustifiedLayout from 'justified-layout';
|
|
||||||
import { groupBy, memoize, sortBy } from 'lodash-es';
|
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';
|
||||||
|
@ -13,7 +14,7 @@ export type DateGroup = {
|
||||||
height: number;
|
height: number;
|
||||||
heightActual: boolean;
|
heightActual: boolean;
|
||||||
intersecting: boolean;
|
intersecting: boolean;
|
||||||
geometry: Geometry;
|
geometry: CommonJustifiedLayout;
|
||||||
bucket: AssetBucket;
|
bucket: AssetBucket;
|
||||||
};
|
};
|
||||||
export type ScrubberListener = (
|
export type ScrubberListener = (
|
||||||
|
@ -80,19 +81,6 @@ export function formatGroupTitle(_date: DateTime): string {
|
||||||
return date.toLocaleString(groupDateFormat);
|
return date.toLocaleString(groupDateFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Geometry = ReturnType<typeof createJustifiedLayout> & {
|
|
||||||
containerWidth: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function emptyGeometry() {
|
|
||||||
return {
|
|
||||||
containerWidth: 0,
|
|
||||||
containerHeight: 0,
|
|
||||||
widowCount: 0,
|
|
||||||
boxes: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDateGroupTitle = memoize(formatGroupTitle);
|
const formatDateGroupTitle = memoize(formatGroupTitle);
|
||||||
|
|
||||||
export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
|
export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
|
||||||
|
@ -109,7 +97,7 @@ export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string |
|
||||||
height: 0,
|
height: 0,
|
||||||
heightActual: false,
|
heightActual: false,
|
||||||
intersecting: false,
|
intersecting: false,
|
||||||
geometry: emptyGeometry(),
|
geometry: emptyGeometry,
|
||||||
bucket,
|
bucket,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,6 +17,9 @@ function getFloat(string: string | null, fallback: number) {
|
||||||
return Number.parseFloat(string);
|
return Number.parseFloat(string);
|
||||||
}
|
}
|
||||||
export const TUNABLES = {
|
export const TUNABLES = {
|
||||||
|
LAYOUT: {
|
||||||
|
WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false),
|
||||||
|
},
|
||||||
SCROLL_TASK_QUEUE: {
|
SCROLL_TASK_QUEUE: {
|
||||||
TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25),
|
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_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5),
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
import { AppRoute, AlbumPageViewMode } from '$lib/constants';
|
import { AppRoute, AlbumPageViewMode } from '$lib/constants';
|
||||||
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
|
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
@ -445,10 +445,7 @@
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
{#if assetInteraction.isAllUserOwned}
|
{#if assetInteraction.isAllUserOwned}
|
||||||
<FavoriteAction
|
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
|
||||||
onFavorite={() => assetStore.triggerUpdate()}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
||||||
|
@ -462,11 +459,7 @@
|
||||||
onClick={() => updateThumbnailUsingCurrentSelection()}
|
onClick={() => updateThumbnailUsingCurrentSelection()}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<ArchiveAction
|
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||||
menuItem
|
|
||||||
unarchive={assetInteraction.isAllArchived}
|
|
||||||
onArchive={() => assetStore.triggerUpdate()}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
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';
|
||||||
|
@ -52,7 +52,7 @@
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
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';
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
|
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
|
||||||
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import type { Viewport } from '$lib/stores/assets.store';
|
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { foldersStore } from '$lib/stores/folders.svelte';
|
import { foldersStore } from '$lib/stores/folders.svelte';
|
||||||
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
||||||
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { mdiPlus, mdiArrowLeft } from '@mdi/js';
|
import { mdiPlus, mdiArrowLeft } from '@mdi/js';
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
|
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||||
|
@ -77,12 +77,8 @@
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Check to trigger rebuild the timeline when navigating between people from the info panel
|
// Check to trigger rebuild the timeline when navigating between people from the info panel
|
||||||
const change = assetStoreOptions.personId !== data.person.id;
|
|
||||||
assetStoreOptions.personId = data.person.id;
|
assetStoreOptions.personId = data.person.id;
|
||||||
handlePromiseError(assetStore.updateOptions(assetStoreOptions));
|
handlePromiseError(assetStore.updateOptions(assetStoreOptions));
|
||||||
if (change) {
|
|
||||||
assetStore.triggerUpdate();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
@ -156,7 +152,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleUnmerge = () => {
|
const handleUnmerge = () => {
|
||||||
$assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id));
|
assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id));
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
viewMode = PersonPageViewMode.VIEW_ASSETS;
|
viewMode = PersonPageViewMode.VIEW_ASSETS;
|
||||||
};
|
};
|
||||||
|
@ -358,7 +354,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAssets = async (assetIds: string[]) => {
|
const handleDeleteAssets = async (assetIds: string[]) => {
|
||||||
$assetStore.removeAssets(assetIds);
|
assetStore.removeAssets(assetIds);
|
||||||
await updateAssetCount();
|
await updateAssetCount();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -420,7 +416,7 @@
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
||||||
<MenuOption
|
<MenuOption
|
||||||
|
@ -433,7 +429,7 @@
|
||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
menuItem
|
menuItem
|
||||||
unarchive={assetInteraction.isAllArchived}
|
unarchive={assetInteraction.isAllArchived}
|
||||||
onArchive={(assetIds) => $assetStore.removeAssets(assetIds)}
|
onArchive={(assetIds) => assetStore.removeAssets(assetIds)}
|
||||||
/>
|
/>
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
import type { OnLink, OnUnlink } from '$lib/utils/actions';
|
import type { OnLink, OnUnlink } from '$lib/utils/actions';
|
||||||
|
@ -88,7 +88,7 @@
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
{#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected}
|
{#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected}
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
getTagById,
|
getTagById,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||||
import type { Viewport } from '$lib/stores/assets.store';
|
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
||||||
import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
|
import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
||||||
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
|
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
|
||||||
import { Button, HStack, Text } from '@immich/ui';
|
import { Button, HStack, Text } from '@immich/ui';
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
|
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
|
|
@ -14,6 +14,9 @@ const upstream = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
target: 'es2022',
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',
|
'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue