mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 04:41:40 -08:00
Compare commits
7 Commits
feat/mobil
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a59f8c68 | ||
|
|
de84e46f62 | ||
|
|
6d3dda7e2e | ||
|
|
4ca76b24e9 | ||
|
|
9a5e8c07ab | ||
|
|
9bcbf003e6 | ||
|
|
8e97c584cf |
@@ -6,7 +6,7 @@
|
||||
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { ScrubberMonth, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
@@ -20,7 +20,7 @@
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { isAssetViewerRoute } from '$lib/utils/navigation';
|
||||
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { getSegmentIdentifier, getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
@@ -58,7 +58,7 @@
|
||||
onThumbnailClick?: (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
day: TimelineDay,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
@@ -109,7 +109,7 @@
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.segments.length === 0);
|
||||
const maxMd = $derived(mobileDevice.maxMd);
|
||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||
|
||||
@@ -130,10 +130,8 @@
|
||||
timelineManager.scrollableElement = scrollableElement;
|
||||
});
|
||||
|
||||
const getAssetPosition = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
|
||||
|
||||
const scrollToAssetPosition = (assetId: string, monthGroup: MonthGroup) => {
|
||||
const position = getAssetPosition(assetId, monthGroup);
|
||||
const scrollToAssetPosition = (assetId: string, month: TimelineMonth) => {
|
||||
const position = month.findAssetAbsolutePosition(assetId);
|
||||
|
||||
if (!position) {
|
||||
return;
|
||||
@@ -141,7 +139,7 @@
|
||||
|
||||
// Need to update window positions/intersections because <Portal> may have
|
||||
// gone from invisible to visible.
|
||||
timelineManager.updateSlidingWindow();
|
||||
timelineManager.updateVisibleWindow();
|
||||
|
||||
const assetTop = position.top;
|
||||
const assetBottom = position.top + position.height;
|
||||
@@ -176,20 +174,20 @@
|
||||
};
|
||||
|
||||
const scrollAndLoadAsset = async (assetId: string) => {
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||
if (!monthGroup) {
|
||||
const month = await timelineManager.search.getMonthForAsset(assetId);
|
||||
if (!month) {
|
||||
return false;
|
||||
}
|
||||
scrollToAssetPosition(assetId, monthGroup);
|
||||
scrollToAssetPosition(assetId, month);
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollToAsset = (asset: TimelineAsset) => {
|
||||
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||
if (!monthGroup) {
|
||||
const month = timelineManager.search.findMonthForAsset(asset.id)?.month;
|
||||
if (!month) {
|
||||
return false;
|
||||
}
|
||||
scrollToAssetPosition(asset.id, monthGroup);
|
||||
scrollToAssetPosition(asset.id, month);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -264,10 +262,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
const scrollToSegmentPercentage = (segmentTop: number, segmentHeight: number, monthGroupScrollPercent: number) => {
|
||||
const scrollToSegmentPercentage = (segmentTop: number, segmentHeight: number, monthScrollPercent: number) => {
|
||||
const topOffset = segmentTop;
|
||||
const maxScrollPercent = timelineManager.maxScrollPercent;
|
||||
const delta = segmentHeight * monthGroupScrollPercent;
|
||||
const delta = segmentHeight * monthScrollPercent;
|
||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
timelineManager.scrollTo(scrollToTop);
|
||||
@@ -296,13 +294,13 @@
|
||||
scrubberMonthScrollPercent,
|
||||
);
|
||||
} else {
|
||||
const monthGroup = timelineManager.months.find(
|
||||
const month = timelineManager.segments.find(
|
||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||
);
|
||||
if (!monthGroup) {
|
||||
if (!month) {
|
||||
return;
|
||||
}
|
||||
scrollToSegmentPercentage(monthGroup.top, monthGroup.height, scrubberMonthScrollPercent);
|
||||
scrollToSegmentPercentage(month.top, month.height, scrubberMonthScrollPercent);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -325,34 +323,34 @@
|
||||
let top = scrollableElement.scrollTop;
|
||||
let maxScrollPercent = timelineManager.maxScrollPercent;
|
||||
|
||||
const monthsLength = timelineManager.months.length;
|
||||
const monthsLength = timelineManager.segments.length;
|
||||
for (let i = -1; i < monthsLength + 1; i++) {
|
||||
let monthGroup: ViewportTopMonth;
|
||||
let monthGroupHeight = 0;
|
||||
let month: ViewportTopMonth;
|
||||
let monthHeight = 0;
|
||||
if (i === -1) {
|
||||
// lead-in
|
||||
monthGroup = 'lead-in';
|
||||
monthGroupHeight = timelineManager.topSectionHeight;
|
||||
month = 'lead-in';
|
||||
monthHeight = timelineManager.topSectionHeight;
|
||||
} else if (i === monthsLength) {
|
||||
// lead-out
|
||||
monthGroup = 'lead-out';
|
||||
monthGroupHeight = timelineManager.bottomSectionHeight;
|
||||
month = 'lead-out';
|
||||
monthHeight = timelineManager.bottomSectionHeight;
|
||||
} else {
|
||||
monthGroup = timelineManager.months[i].yearMonth;
|
||||
monthGroupHeight = timelineManager.months[i].height;
|
||||
month = timelineManager.segments[i].yearMonth;
|
||||
monthHeight = timelineManager.segments[i].height;
|
||||
}
|
||||
|
||||
let next = top - monthGroupHeight * maxScrollPercent;
|
||||
let next = top - monthHeight * maxScrollPercent;
|
||||
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
||||
if (next < -1 && monthGroup) {
|
||||
viewportTopMonth = monthGroup;
|
||||
if (next < -1 && month) {
|
||||
viewportTopMonth = month;
|
||||
|
||||
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||
viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
|
||||
viewportTopMonthScrollPercent = Math.max(0, top / (monthHeight * maxScrollPercent));
|
||||
|
||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
|
||||
viewportTopMonth = timelineManager.segments[i + 1].yearMonth;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
}
|
||||
break;
|
||||
@@ -442,22 +440,22 @@
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
|
||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
|
||||
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||
let startBucket = await timelineManager.search.getMonthForAsset(assetInteraction.assetSelectionStart.id);
|
||||
let endBucket = await timelineManager.search.getMonthForAsset(asset.id);
|
||||
|
||||
if (startBucket === null || endBucket === null) {
|
||||
if (!startBucket || !endBucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Select/deselect assets in range (start,end)
|
||||
let started = false;
|
||||
for (const monthGroup of timelineManager.months) {
|
||||
if (monthGroup === endBucket) {
|
||||
for (const month of timelineManager.segments) {
|
||||
if (month === endBucket) {
|
||||
break;
|
||||
}
|
||||
if (started) {
|
||||
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
||||
for (const asset of monthGroup.assetsIterator()) {
|
||||
await timelineManager.loadSegment(getSegmentIdentifier(month.yearMonth));
|
||||
for (const asset of month.assetsIterator()) {
|
||||
if (deselect) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
} else {
|
||||
@@ -465,29 +463,29 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
if (monthGroup === startBucket) {
|
||||
if (month === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update date group selection in range [start,end]
|
||||
started = false;
|
||||
for (const monthGroup of timelineManager.months) {
|
||||
if (monthGroup === startBucket) {
|
||||
for (const month of timelineManager.segments) {
|
||||
if (month === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
if (started) {
|
||||
// Split month group into day groups and check each group
|
||||
for (const dayGroup of monthGroup.dayGroups) {
|
||||
const dayGroupTitle = dayGroup.groupTitle;
|
||||
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
|
||||
for (const day of month.days) {
|
||||
const dayTitle = day.dayTitle;
|
||||
if (day.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dayTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
|
||||
assetInteraction.removeGroupFromMultiselectGroup(dayTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (monthGroup === endBucket) {
|
||||
if (month === endBucket) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -506,7 +504,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
|
||||
const assets = assetsSnapshot(await timelineManager.search.retrieveRange(startAsset, endAsset));
|
||||
assetInteraction.setAssetSelectionCandidates(assets);
|
||||
};
|
||||
|
||||
@@ -531,7 +529,7 @@
|
||||
$effect(() => {
|
||||
if ($showAssetViewer) {
|
||||
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
||||
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
|
||||
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -562,7 +560,7 @@
|
||||
{onEscape}
|
||||
/>
|
||||
|
||||
{#if timelineManager.months.length > 0}
|
||||
{#if timelineManager.segments.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
height={timelineManager.viewportHeight}
|
||||
@@ -600,7 +598,7 @@
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={timelineManager.viewportWidth}
|
||||
bind:this={scrollableElement}
|
||||
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
|
||||
onscroll={() => (handleTimelineScroll(), timelineManager.updateVisibleWindow(), updateIsScrolling())}
|
||||
>
|
||||
<section
|
||||
bind:this={timelineElement}
|
||||
@@ -622,23 +620,23 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
|
||||
{@const display = monthGroup.intersecting}
|
||||
{@const absoluteHeight = monthGroup.top}
|
||||
{#each timelineManager.segments as month (month.identifier.id)}
|
||||
{@const display = month.intersecting}
|
||||
{@const absoluteHeight = month.top}
|
||||
|
||||
{#if !monthGroup.isLoaded}
|
||||
{#if !month.loaded}
|
||||
<div
|
||||
style:height={monthGroup.height + 'px'}
|
||||
style:height={month.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} />
|
||||
<Skeleton {invisible} height={month.height} title={month.monthTitle} />
|
||||
</div>
|
||||
{:else if display}
|
||||
<div
|
||||
class="month-group"
|
||||
style:height={monthGroup.height + 'px'}
|
||||
style:height={month.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
@@ -650,7 +648,7 @@
|
||||
{timelineManager}
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
{month}
|
||||
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
|
||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||
onSelectAssets={handleSelectAssets}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
@@ -40,10 +40,10 @@
|
||||
|
||||
const handlePrevious = async () => {
|
||||
const release = await mutex.acquire();
|
||||
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
|
||||
const laterAsset = await timelineManager.search.getLaterAsset($viewingAsset);
|
||||
|
||||
if (laterAsset) {
|
||||
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
|
||||
const preloadAsset = await timelineManager.search.getLaterAsset(laterAsset);
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
|
||||
@@ -55,10 +55,10 @@
|
||||
|
||||
const handleNext = async () => {
|
||||
const release = await mutex.acquire();
|
||||
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
|
||||
const earlierAsset = await timelineManager.search.getEarlierAsset($viewingAsset);
|
||||
|
||||
if (earlierAsset) {
|
||||
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
|
||||
const preloadAsset = await timelineManager.search.getEarlierAsset(earlierAsset);
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
|
||||
@@ -69,7 +69,7 @@
|
||||
};
|
||||
|
||||
const handleRandom = async () => {
|
||||
const randomAsset = await timelineManager.getRandomAsset();
|
||||
const randomAsset = await timelineManager.search.getRandomAsset();
|
||||
|
||||
if (randomAsset) {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
|
||||
@@ -110,13 +110,9 @@
|
||||
case AssetAction.ARCHIVE:
|
||||
case AssetAction.UNARCHIVE:
|
||||
case AssetAction.FAVORITE:
|
||||
case AssetAction.UNFAVORITE: {
|
||||
timelineManager.updateAssets([action.asset]);
|
||||
break;
|
||||
}
|
||||
|
||||
case AssetAction.UNFAVORITE:
|
||||
case AssetAction.ADD: {
|
||||
timelineManager.addAssets([action.asset]);
|
||||
timelineManager.upsertAssets([action.asset]);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -135,7 +131,7 @@
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
timelineManager.addAssets([toTimelineAsset(action.asset)]);
|
||||
timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
|
||||
if (action.stack) {
|
||||
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
|
||||
updateUnstackedAssetInTimeline(
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { scale } from 'svelte/transition';
|
||||
@@ -25,7 +23,7 @@
|
||||
singleSelect: boolean;
|
||||
withStacked: boolean;
|
||||
showArchiveIcon: boolean;
|
||||
monthGroup: MonthGroup;
|
||||
month: TimelineMonth;
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
customLayout?: Snippet<[TimelineAsset]>;
|
||||
@@ -36,7 +34,7 @@
|
||||
onThumbnailClick?: (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
day: TimelineDay,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
@@ -51,7 +49,7 @@
|
||||
singleSelect,
|
||||
withStacked,
|
||||
showArchiveIcon,
|
||||
monthGroup = $bindable(),
|
||||
month: monthGroup = $bindable(),
|
||||
assetInteraction,
|
||||
timelineManager,
|
||||
customLayout,
|
||||
@@ -66,7 +64,7 @@
|
||||
let hoveredDayGroup = $state();
|
||||
|
||||
const transitionDuration = $derived.by(() =>
|
||||
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||
monthGroup.scrollManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||
);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
const _onClick = (
|
||||
@@ -124,52 +122,52 @@
|
||||
return intersectable.filter((int) => int.intersecting);
|
||||
}
|
||||
|
||||
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
|
||||
const { month, year } = dayGroup.monthGroup.yearMonth;
|
||||
const getDayGroupFullDate = (day: TimelineDay): string => {
|
||||
const { month, year } = day.month.yearMonth;
|
||||
const date = fromTimelinePlainDate({
|
||||
year,
|
||||
month,
|
||||
day: dayGroup.day,
|
||||
day: day.day,
|
||||
});
|
||||
return getDateLocaleString(date);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
{@const absoluteWidth = dayGroup.left}
|
||||
{#each filterIntersecting(monthGroup.days) as day, groupIndex (day.day)}
|
||||
{@const absoluteWidth = day.left}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class={[
|
||||
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
|
||||
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
|
||||
{ 'transition-all': !monthGroup.scrollManager.suspendTransitions },
|
||||
!monthGroup.scrollManager.suspendTransitions && `delay-${transitionDuration}`,
|
||||
]}
|
||||
data-group
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
|
||||
style:transform={`translate3d(${absoluteWidth}px,${day.top}px,0)`}
|
||||
onmouseenter={() => {
|
||||
isMouseOverGroup = true;
|
||||
assetMouseEventHandler(dayGroup.groupTitle, null);
|
||||
assetMouseEventHandler(day.dayTitle, null);
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
isMouseOverGroup = false;
|
||||
assetMouseEventHandler(dayGroup.groupTitle, null);
|
||||
assetMouseEventHandler(day.dayTitle, null);
|
||||
}}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<div
|
||||
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
||||
style:width={dayGroup.width + 'px'}
|
||||
style:width={day.width + 'px'}
|
||||
>
|
||||
{#if !singleSelect}
|
||||
<div
|
||||
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
|
||||
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
|
||||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||
class:w-8={(hoveredDayGroup === day.dayTitle && isMouseOverGroup) ||
|
||||
assetInteraction.selectedGroup.has(day.dayTitle)}
|
||||
onclick={() => handleSelectGroup(day.dayTitle, assetsSnapshot(day.getAssets()))}
|
||||
onkeydown={() => handleSelectGroup(day.dayTitle, assetsSnapshot(day.getAssets()))}
|
||||
>
|
||||
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||
{#if assetInteraction.selectedGroup.has(day.dayTitle)}
|
||||
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
|
||||
{:else}
|
||||
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
|
||||
@@ -177,19 +175,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
|
||||
{dayGroup.groupTitle}
|
||||
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(day)}>
|
||||
{day.dayTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div
|
||||
data-image-grid
|
||||
class="relative overflow-clip"
|
||||
style:height={dayGroup.height + 'px'}
|
||||
style:width={dayGroup.width + 'px'}
|
||||
>
|
||||
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
<div data-image-grid class="relative overflow-clip" style:height={day.height + 'px'} style:width={day.width + 'px'}>
|
||||
{#each filterIntersecting(day.viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
|
||||
@@ -212,17 +205,16 @@
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
onThumbnailClick(asset, timelineManager, day, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
_onClick(timelineManager, day.getAssets(), day.dayTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, day.getAssets(), day.dayTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(day.dayTitle, assetSnapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) || day.month.scrollManager.albumAssets.has(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||
disabled={day.month.scrollManager.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
setFocusTo as setFocusToInit,
|
||||
} from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
@@ -46,7 +46,7 @@
|
||||
!(isTrashEnabled && !force),
|
||||
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||
assetInteraction.selectedAssets,
|
||||
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
|
||||
!isTrashEnabled || force ? undefined : (assets) => timelineManager.upsertAssets(assets),
|
||||
);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
@@ -122,7 +122,7 @@
|
||||
};
|
||||
|
||||
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.segments.length === 0);
|
||||
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||
let isShortcutModalOpen = false;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
@@ -31,7 +31,7 @@ export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean
|
||||
|
||||
export const setFocusTo = async (
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean,
|
||||
store: TimelineManager,
|
||||
timelineManager: TimelineManager,
|
||||
direction: 'earlier' | 'later',
|
||||
interval: 'day' | 'month' | 'year' | 'asset',
|
||||
) => {
|
||||
@@ -53,8 +53,8 @@ export const setFocusTo = async (
|
||||
|
||||
const asset =
|
||||
direction === 'earlier'
|
||||
? await store.getEarlierAsset({ id }, interval)
|
||||
: await store.getLaterAsset({ id }, interval);
|
||||
? await timelineManager.search.getEarlierAsset({ id }, interval)
|
||||
: await timelineManager.search.getLaterAsset({ id }, interval);
|
||||
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { TimelineAsset, UpdateGeometryOptions } from '$lib/managers/timeline-manager/types';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type {
|
||||
AssetOperation,
|
||||
VirtualScrollManager,
|
||||
VisibleWindow,
|
||||
} from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { CancellableTask, TaskStatus } from '$lib/utils/cancellable-task';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
export type SegmentIdentifier = {
|
||||
get id(): string;
|
||||
matches(segment: ScrollSegment): boolean;
|
||||
};
|
||||
export abstract class ScrollSegment {
|
||||
#intersecting = $state(false);
|
||||
#actuallyIntersecting = $state(false);
|
||||
#isLoaded = $state(false);
|
||||
#height = $state(0);
|
||||
#top = $state(0);
|
||||
#assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset));
|
||||
|
||||
initialCount = $state(0);
|
||||
percent = $state(0);
|
||||
isHeightActual = $state(false);
|
||||
assetsCount = $derived.by(() => (this.loaded ? this.viewerAssets.length : this.initialCount));
|
||||
loader = new CancellableTask(
|
||||
() => (this.loaded = true),
|
||||
() => (this.loaded = false),
|
||||
() => this.handleLoadError,
|
||||
);
|
||||
|
||||
abstract get scrollManager(): VirtualScrollManager;
|
||||
|
||||
abstract get identifier(): SegmentIdentifier;
|
||||
|
||||
abstract get viewerAssets(): ViewerAsset[];
|
||||
|
||||
abstract findAssetAbsolutePosition(assetId: string): { top: number; height: number } | undefined;
|
||||
|
||||
protected abstract fetch(signal: AbortSignal): Promise<unknown>;
|
||||
|
||||
get loaded() {
|
||||
return this.#isLoaded;
|
||||
}
|
||||
|
||||
protected set loaded(newValue: boolean) {
|
||||
this.#isLoaded = newValue;
|
||||
}
|
||||
|
||||
get intersecting() {
|
||||
return this.#intersecting;
|
||||
}
|
||||
|
||||
set intersecting(newValue: boolean) {
|
||||
const old = this.#intersecting;
|
||||
if (old === newValue) {
|
||||
return;
|
||||
}
|
||||
this.#intersecting = newValue;
|
||||
if (newValue) {
|
||||
void this.load(true);
|
||||
} else {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
get actuallyIntersecting() {
|
||||
return this.#actuallyIntersecting;
|
||||
}
|
||||
|
||||
get assets(): TimelineAsset[] {
|
||||
return this.#assets;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.#height;
|
||||
}
|
||||
|
||||
set height(height: number) {
|
||||
if (this.#height === height) {
|
||||
return;
|
||||
}
|
||||
const scrollManager = this.scrollManager;
|
||||
const index = scrollManager.segments.indexOf(this);
|
||||
const heightDelta = height - this.#height;
|
||||
this.#height = height;
|
||||
const prevSegment = scrollManager.segments[index - 1];
|
||||
if (prevSegment) {
|
||||
const newTop = prevSegment.#top + prevSegment.#height;
|
||||
if (this.#top !== newTop) {
|
||||
this.#top = newTop;
|
||||
}
|
||||
}
|
||||
if (heightDelta === 0) {
|
||||
return;
|
||||
}
|
||||
for (let cursor = index + 1; cursor < scrollManager.segments.length; cursor++) {
|
||||
const segment = this.scrollManager.segments[cursor];
|
||||
const newTop = segment.#top + heightDelta;
|
||||
if (segment.#top !== newTop) {
|
||||
segment.#top = newTop;
|
||||
}
|
||||
}
|
||||
if (!scrollManager.viewportTopSegmentIntersection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { segment, viewportTopSegmentRatio, segmentBottomViewportRatio } =
|
||||
scrollManager.viewportTopSegmentIntersection;
|
||||
|
||||
const currentIndex = segment ? scrollManager.segments.indexOf(segment) : -1;
|
||||
if (!segment || currentIndex <= 0 || index > currentIndex) {
|
||||
return;
|
||||
}
|
||||
if (index < currentIndex || segmentBottomViewportRatio < 1) {
|
||||
scrollManager.scrollBy(heightDelta);
|
||||
} else if (index === currentIndex) {
|
||||
const scrollTo = this.top + heightDelta * viewportTopSegmentRatio;
|
||||
scrollManager.scrollTo(scrollTo);
|
||||
}
|
||||
}
|
||||
|
||||
get top(): number {
|
||||
return this.#top + this.scrollManager.topSectionHeight;
|
||||
}
|
||||
|
||||
async load(cancelable: boolean): Promise<TaskStatus> {
|
||||
const result = await this.loader?.execute(async (signal: AbortSignal) => {
|
||||
await this.fetch(signal);
|
||||
}, cancelable);
|
||||
if (result === TaskStatus.LOADED) {
|
||||
this.updateGeometry({ invalidateHeight: false });
|
||||
this.calculateAndUpdateIntersection(this.scrollManager.visibleWindow);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
protected handleLoadError(error: unknown) {
|
||||
const _$t = get(t);
|
||||
handleError(error, _$t('errors.failed_to_load_assets'));
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.loader?.cancel();
|
||||
}
|
||||
|
||||
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
||||
this.intersecting = intersecting;
|
||||
this.#actuallyIntersecting = actuallyIntersecting;
|
||||
}
|
||||
|
||||
updateGeometry(options: UpdateGeometryOptions) {
|
||||
const { invalidateHeight = true, noDefer = false } = options;
|
||||
if (invalidateHeight) {
|
||||
this.isHeightActual = false;
|
||||
}
|
||||
if (!this.loaded) {
|
||||
const viewportWidth = this.scrollManager.viewportWidth;
|
||||
if (!this.isHeightActual) {
|
||||
const unwrappedWidth = (3 / 2) * this.assetsCount * this.scrollManager.rowHeight * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||
const height = 51 + Math.max(1, rows) * this.scrollManager.rowHeight;
|
||||
this.height = height;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.layout(noDefer);
|
||||
}
|
||||
|
||||
layout(_: boolean) {}
|
||||
|
||||
protected calculateSegmentIntersecting(visibleWindow: VisibleWindow, expandTop: number, expandBottom: number) {
|
||||
const segmentTop = this.top;
|
||||
const segmentBottom = segmentTop + this.height;
|
||||
const topWindow = visibleWindow.top - expandTop;
|
||||
const bottomWindow = visibleWindow.bottom + expandBottom;
|
||||
|
||||
return isIntersecting(segmentTop, segmentBottom, topWindow, bottomWindow);
|
||||
}
|
||||
|
||||
calculateAndUpdateIntersection(visibleWindow: VisibleWindow) {
|
||||
const actuallyIntersecting = this.calculateSegmentIntersecting(visibleWindow, 0, 0);
|
||||
let preIntersecting = false;
|
||||
if (!actuallyIntersecting) {
|
||||
preIntersecting = this.calculateSegmentIntersecting(
|
||||
visibleWindow,
|
||||
INTERSECTION_EXPAND_TOP,
|
||||
INTERSECTION_EXPAND_BOTTOM,
|
||||
);
|
||||
}
|
||||
this.updateIntersection({ intersecting: actuallyIntersecting || preIntersecting, actuallyIntersecting });
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const unprocessedIds = new Set<string>(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const processedIds = new Set<string>();
|
||||
const moveAssets: TimelineAsset[] = [];
|
||||
let changedGeometry = false;
|
||||
for (const assetId of unprocessedIds) {
|
||||
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const asset = this.viewerAssets[index].asset!;
|
||||
const opResult = operation(asset);
|
||||
let remove = false;
|
||||
if (opResult) {
|
||||
remove = (opResult as { remove: boolean }).remove ?? false;
|
||||
}
|
||||
|
||||
unprocessedIds.delete(assetId);
|
||||
processedIds.add(assetId);
|
||||
if (remove || this.scrollManager.isExcluded(asset)) {
|
||||
this.viewerAssets.splice(index, 1);
|
||||
changedGeometry = true;
|
||||
}
|
||||
}
|
||||
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* General function to check if a segment region intersects with a window region.
|
||||
* @param regionTop - Top position of the region to check
|
||||
* @param regionBottom - Bottom position of the region to check
|
||||
* @param windowTop - Top position of the window
|
||||
* @param windowBottom - Bottom position of the window
|
||||
* @returns true if the region intersects with the window
|
||||
*/
|
||||
export const isIntersecting = (regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) =>
|
||||
(regionTop >= windowTop && regionTop < windowBottom) ||
|
||||
(regionBottom >= windowTop && regionBottom < windowBottom) ||
|
||||
(regionTop < windowTop && regionBottom >= windowBottom);
|
||||
@@ -1,20 +1,60 @@
|
||||
import { debounce } from 'lodash-es';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { setDifferenceInPlace } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { ScrollSegment, SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import { updateObject } from '$lib/managers/VirtualScrollManager/utils.svelte';
|
||||
|
||||
import { clamp, debounce } from 'lodash-es';
|
||||
|
||||
export type VisibleWindow = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
};
|
||||
export type AssetOperation = (asset: TimelineAsset) => unknown;
|
||||
|
||||
type LayoutOptions = {
|
||||
headerHeight: number;
|
||||
rowHeight: number;
|
||||
gap: number;
|
||||
};
|
||||
type ViewportTopSegmentIntersection = {
|
||||
segment: ScrollSegment | null;
|
||||
// Where viewport top intersects segment (0 = segment top, 1 = segment bottom)
|
||||
viewportTopSegmentRatio: number;
|
||||
// Where first segment bottom is in viewport (0 = viewport top, 1 = viewport bottom)
|
||||
segmentBottomViewportRatio: number;
|
||||
};
|
||||
|
||||
export abstract class VirtualScrollManager {
|
||||
topSectionHeight = $state(0);
|
||||
bodySectionHeight = $state(0);
|
||||
bodySectionHeight = $derived.by(() => {
|
||||
let height = 0;
|
||||
for (const segment of this.segments) {
|
||||
height += segment.height;
|
||||
}
|
||||
return height;
|
||||
});
|
||||
bottomSectionHeight = $state(0);
|
||||
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
|
||||
|
||||
visibleWindow = $derived.by(() => ({
|
||||
isInitialized = $state(false);
|
||||
streamViewerHeight = $derived.by(() => {
|
||||
let height = this.topSectionHeight;
|
||||
for (const segment of this.segments) {
|
||||
height += segment.height;
|
||||
}
|
||||
return height;
|
||||
});
|
||||
assetCount = $derived.by(() => {
|
||||
let count = 0;
|
||||
for (const segment of this.segments) {
|
||||
count += segment.assetsCount;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
visibleWindow: VisibleWindow = $derived.by(() => ({
|
||||
top: this.#scrollTop,
|
||||
bottom: this.#scrollTop + this.viewportHeight,
|
||||
}));
|
||||
viewportTopSegmentIntersection: ViewportTopSegmentIntersection | undefined;
|
||||
|
||||
#viewportHeight = $state(0);
|
||||
#viewportWidth = $state(0);
|
||||
@@ -24,14 +64,15 @@ export abstract class VirtualScrollManager {
|
||||
#gap = $state(12);
|
||||
#scrolling = $state(false);
|
||||
#suspendTransitions = $state(false);
|
||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||
#resumeTransitionsAfterDelay = debounce(() => (this.suspendTransitions = false), 1000);
|
||||
#resumeScrollingStatusAfterDelay = debounce(() => (this.#scrolling = false), 1000);
|
||||
#justifiedLayoutOptions = $derived({
|
||||
spacing: 2,
|
||||
heightTolerance: 0.5,
|
||||
rowHeight: this.#rowHeight,
|
||||
rowWidth: Math.floor(this.viewportWidth),
|
||||
});
|
||||
#updatingIntersections = false;
|
||||
|
||||
constructor() {
|
||||
this.setLayoutOptions();
|
||||
@@ -45,6 +86,8 @@ export abstract class VirtualScrollManager {
|
||||
return this.#justifiedLayoutOptions;
|
||||
}
|
||||
|
||||
abstract get segments(): ScrollSegment[];
|
||||
|
||||
get maxScrollPercent() {
|
||||
const totalHeight = this.totalViewerHeight;
|
||||
return (totalHeight - this.viewportHeight) / totalHeight;
|
||||
@@ -94,7 +137,7 @@ export abstract class VirtualScrollManager {
|
||||
this.#scrolling = value;
|
||||
if (value) {
|
||||
this.suspendTransitions = true;
|
||||
this.#resetScrolling();
|
||||
this.#resumeScrollingStatusAfterDelay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +148,7 @@ export abstract class VirtualScrollManager {
|
||||
set suspendTransitions(value: boolean) {
|
||||
this.#suspendTransitions = value;
|
||||
if (value) {
|
||||
this.#resetSuspendTransitions();
|
||||
this.#resumeTransitionsAfterDelay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +157,11 @@ export abstract class VirtualScrollManager {
|
||||
}
|
||||
|
||||
set viewportWidth(value: number) {
|
||||
const changed = value !== this.#viewportWidth;
|
||||
const oldViewport = this.viewportSnapshot;
|
||||
this.#viewportWidth = value;
|
||||
this.suspendTransitions = true;
|
||||
void this.updateViewportGeometry(changed);
|
||||
const newViewport = this.viewportSnapshot;
|
||||
void this.onUpdateViewport(oldViewport, newViewport);
|
||||
}
|
||||
|
||||
get viewportWidth() {
|
||||
@@ -125,22 +169,78 @@ export abstract class VirtualScrollManager {
|
||||
}
|
||||
|
||||
set viewportHeight(value: number) {
|
||||
const oldViewport = this.viewportSnapshot;
|
||||
this.#viewportHeight = value;
|
||||
this.#suspendTransitions = true;
|
||||
void this.updateViewportGeometry(false);
|
||||
const newViewport = this.viewportSnapshot;
|
||||
void this.onUpdateViewport(oldViewport, newViewport);
|
||||
}
|
||||
|
||||
get viewportHeight() {
|
||||
return this.#viewportHeight;
|
||||
}
|
||||
|
||||
get hasEmptyViewport() {
|
||||
return this.viewportWidth === 0 || this.viewportHeight === 0;
|
||||
get viewportSnapshot(): Viewport {
|
||||
return {
|
||||
width: $state.snapshot(this.#viewportWidth),
|
||||
height: $state.snapshot(this.#viewportHeight),
|
||||
};
|
||||
}
|
||||
|
||||
protected updateIntersections(): void {}
|
||||
scrollTo(_: number) {}
|
||||
|
||||
protected updateViewportGeometry(_: boolean) {}
|
||||
scrollBy(_: number) {}
|
||||
|
||||
#calculateSegmentBottomViewportRatio(segment: ScrollSegment | null) {
|
||||
if (!segment) {
|
||||
return 0;
|
||||
}
|
||||
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
|
||||
const bottomOfSegment = segment.top + segment.height;
|
||||
const bottomOfSegmentInViewport = bottomOfSegment - this.visibleWindow.top;
|
||||
return clamp(bottomOfSegmentInViewport / windowHeight, 0, 1);
|
||||
}
|
||||
|
||||
#calculateViewportTopRatioInMonth(month: ScrollSegment | null) {
|
||||
if (!month) {
|
||||
return 0;
|
||||
}
|
||||
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
|
||||
}
|
||||
|
||||
protected updateIntersections() {
|
||||
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#updatingIntersections = true;
|
||||
let topSegment: ScrollSegment | null = null;
|
||||
for (const segment of this.segments) {
|
||||
segment.calculateAndUpdateIntersection(this.visibleWindow);
|
||||
if (segment.actuallyIntersecting && topSegment === null) {
|
||||
topSegment = segment;
|
||||
}
|
||||
}
|
||||
|
||||
const viewportTopSegmentRatio = this.#calculateViewportTopRatioInMonth(topSegment);
|
||||
const segmentBottomViewportRatio = this.#calculateSegmentBottomViewportRatio(topSegment);
|
||||
|
||||
this.viewportTopSegmentIntersection = {
|
||||
segment: topSegment,
|
||||
viewportTopSegmentRatio,
|
||||
segmentBottomViewportRatio,
|
||||
};
|
||||
|
||||
this.#updatingIntersections = false;
|
||||
}
|
||||
|
||||
protected onUpdateViewport(oldViewport: Viewport, newViewport: Viewport) {
|
||||
if (!this.isInitialized || isEmptyViewport(newViewport)) {
|
||||
return;
|
||||
}
|
||||
const changedWidth = oldViewport.width !== newViewport.width || isEmptyViewport(oldViewport);
|
||||
this.refreshLayout({ invalidateHeight: changedWidth });
|
||||
}
|
||||
|
||||
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: Partial<LayoutOptions> = {}) {
|
||||
let changed = false;
|
||||
@@ -152,7 +252,7 @@ export abstract class VirtualScrollManager {
|
||||
}
|
||||
}
|
||||
|
||||
updateSlidingWindow() {
|
||||
updateVisibleWindow() {
|
||||
const scrollTop = this.scrollTop;
|
||||
if (this.#scrollTop !== scrollTop) {
|
||||
this.#scrollTop = scrollTop;
|
||||
@@ -160,9 +260,132 @@ export abstract class VirtualScrollManager {
|
||||
}
|
||||
}
|
||||
|
||||
refreshLayout() {
|
||||
protected refreshLayout({ invalidateHeight = true }: { invalidateHeight?: boolean } = {}) {
|
||||
for (const segment of this.segments) {
|
||||
segment.updateGeometry({ invalidateHeight });
|
||||
}
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
destroy() {
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
|
||||
const { cancelable = true } = options ?? {};
|
||||
const segment = this.segments.find((segment) => identifier.matches(segment));
|
||||
if (!segment || segment.loader?.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await segment.load(cancelable);
|
||||
}
|
||||
|
||||
upsertAssets(assets: TimelineAsset[]) {
|
||||
const notExcluded = assets.filter((asset) => !this.isExcluded(asset));
|
||||
const notUpdated = this.#updateAssets(notExcluded);
|
||||
this.addAssetsToSegments(notUpdated);
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
this.#runAssetOperation(ids, () => ({ remove: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given operation against every passed in asset id.
|
||||
*
|
||||
* @returns An object with the changed ids, unprocessed ids, and if this resulted
|
||||
* in changes of the timeline geometry.
|
||||
*/
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
return this.#runAssetOperation(ids, operation);
|
||||
}
|
||||
|
||||
isExcluded(_: TimelineAsset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected addAssetsToSegments(assets: TimelineAsset[]) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const context = this.createUpsertContext();
|
||||
const monthCount = this.segments.length;
|
||||
for (const asset of assets) {
|
||||
this.upsertAssetIntoSegment(asset, context);
|
||||
}
|
||||
if (this.segments.length !== monthCount) {
|
||||
this.postCreateSegments();
|
||||
}
|
||||
this.postUpsert(context);
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected upsertAssetIntoSegment(asset: TimelineAsset, context: unknown): void {}
|
||||
protected createUpsertContext(): unknown {
|
||||
return undefined;
|
||||
}
|
||||
protected postUpsert(_: unknown): void {}
|
||||
protected postCreateSegments(): void {}
|
||||
|
||||
/**
|
||||
* Looks up the specified asset from the TimelineAsset using its id, and then updates the
|
||||
* existing object to match the rest of the TimelineAsset parameter.
|
||||
|
||||
* @returns list of assets that were updated (not found)
|
||||
*/
|
||||
#updateAssets(updatedAssets: TimelineAsset[]) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const lookup = new Map<string, TimelineAsset>();
|
||||
const ids = [];
|
||||
for (const asset of updatedAssets) {
|
||||
ids.push(asset.id);
|
||||
lookup.set(asset.id, asset);
|
||||
}
|
||||
const { unprocessedIds } = this.#runAssetOperation(ids, (asset) => updateObject(asset, lookup.get(asset.id)));
|
||||
const result: TimelineAsset[] = [];
|
||||
for (const id of unprocessedIds) {
|
||||
result.push(lookup.get(id)!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
#runAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const changedMonths = new Set<ScrollSegment>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const idsToProcess = new Set(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: TimelineAsset[] = [];
|
||||
for (const month of this.segments) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(...moveAssets);
|
||||
}
|
||||
setDifferenceInPlace(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedMonths.add(month);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
this.addAssetsToSegments(combinedMoveAssets);
|
||||
}
|
||||
const changedGeometry = changedMonths.size > 0;
|
||||
for (const month of changedMonths) {
|
||||
month.updateGeometry({ invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
this.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
}
|
||||
|
||||
export const isEmptyViewport = (viewport: Viewport) => viewport.width === 0 || viewport.height === 0;
|
||||
|
||||
52
web/src/lib/managers/VirtualScrollManager/utils.svelte.ts
Normal file
52
web/src/lib/managers/VirtualScrollManager/utils.svelte.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function setDifference<T>(setA: Set<T>, setB: Set<T>): Set<T> {
|
||||
// Check if native Set.prototype.difference is available (ES2025)
|
||||
|
||||
const setWithDifference = setA as unknown as Set<T> & { difference?: (other: Set<T>) => Set<T> };
|
||||
if (setWithDifference.difference && typeof setWithDifference.difference === 'function') {
|
||||
return setWithDifference.difference(setB);
|
||||
}
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const result = new Set<T>();
|
||||
for (const value of setA) {
|
||||
if (!setB.has(value)) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all elements of setB from setA in-place (mutates setA).
|
||||
*/
|
||||
export function setDifferenceInPlace<T>(setA: Set<T>, setB: Set<T>): Set<T> {
|
||||
for (const value of setB) {
|
||||
setA.delete(value);
|
||||
}
|
||||
return setA;
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { onCreateTimelineDay } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte';
|
||||
import type { AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { MonthGroup } from './month-group.svelte';
|
||||
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
|
||||
export class DayGroup {
|
||||
readonly monthGroup: MonthGroup;
|
||||
export class TimelineDay {
|
||||
readonly month: TimelineMonth;
|
||||
readonly index: number;
|
||||
readonly groupTitle: string;
|
||||
readonly dayTitle: string;
|
||||
readonly dayTitleFull: string;
|
||||
readonly day: number;
|
||||
viewerAssets: ViewerAsset[] = $state([]);
|
||||
|
||||
@@ -26,11 +25,15 @@ export class DayGroup {
|
||||
#col = $state(0);
|
||||
#deferredLayout = false;
|
||||
|
||||
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) {
|
||||
constructor(month: TimelineMonth, index: number, day: number, groupTitle: string, groupTitleFull: string) {
|
||||
this.index = index;
|
||||
this.monthGroup = monthGroup;
|
||||
this.month = month;
|
||||
this.day = day;
|
||||
this.groupTitle = groupTitle;
|
||||
this.dayTitle = groupTitle;
|
||||
this.dayTitleFull = groupTitleFull;
|
||||
if (import.meta.env.DEV) {
|
||||
onCreateTimelineDay(this);
|
||||
}
|
||||
}
|
||||
|
||||
get top() {
|
||||
@@ -102,17 +105,11 @@ export class DayGroup {
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
if (ids.size === 0) {
|
||||
return {
|
||||
moveAssets: [] as MoveAsset[],
|
||||
processedIds: new SvelteSet<string>(),
|
||||
unprocessedIds: ids,
|
||||
changedGeometry: false,
|
||||
};
|
||||
}
|
||||
const unprocessedIds = new SvelteSet<string>(ids);
|
||||
const processedIds = new SvelteSet<string>();
|
||||
const moveAssets: MoveAsset[] = [];
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const unprocessedIds = new Set<string>(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const processedIds = new Set<string>();
|
||||
const moveAssets: TimelineAsset[] = [];
|
||||
let changedGeometry = false;
|
||||
for (const assetId of unprocessedIds) {
|
||||
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
|
||||
@@ -121,17 +118,24 @@ export class DayGroup {
|
||||
}
|
||||
|
||||
const asset = this.viewerAssets[index].asset!;
|
||||
// save old time, pre-mutating operation
|
||||
const oldTime = { ...asset.localDateTime };
|
||||
let { remove } = operation(asset);
|
||||
const opResult = operation(asset);
|
||||
let remove = false;
|
||||
if (opResult) {
|
||||
remove = (opResult as { remove: boolean }).remove ?? false;
|
||||
}
|
||||
const newTime = asset.localDateTime;
|
||||
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
|
||||
const { year, month, day } = newTime;
|
||||
if (
|
||||
!remove &&
|
||||
(oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day)
|
||||
) {
|
||||
remove = true;
|
||||
moveAssets.push({ asset, date: { year, month, day } });
|
||||
moveAssets.push(asset);
|
||||
}
|
||||
unprocessedIds.delete(assetId);
|
||||
processedIds.add(assetId);
|
||||
if (remove || this.monthGroup.timelineManager.isExcluded(asset)) {
|
||||
if (remove || this.month.scrollManager.isExcluded(asset)) {
|
||||
this.viewerAssets.splice(index, 1);
|
||||
changedGeometry = true;
|
||||
}
|
||||
@@ -140,7 +144,7 @@ export class DayGroup {
|
||||
}
|
||||
|
||||
layout(options: CommonLayoutOptions, noDefer: boolean) {
|
||||
if (!noDefer && !this.monthGroup.intersecting) {
|
||||
if (!noDefer && !this.month.intersecting) {
|
||||
this.#deferredLayout = true;
|
||||
return;
|
||||
}
|
||||
@@ -154,7 +158,7 @@ export class DayGroup {
|
||||
}
|
||||
}
|
||||
|
||||
get absoluteDayGroupTop() {
|
||||
return this.monthGroup.top + this.#top;
|
||||
get topAbsolute() {
|
||||
return this.month.top + this.#top;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { setDifference } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { type TimelineDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
export class GroupInsertionCache {
|
||||
#lookupCache: {
|
||||
[year: number]: { [month: number]: { [day: number]: TimelineDay } };
|
||||
} = {};
|
||||
unprocessedAssets: TimelineAsset[] = [];
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
changedDays = new Set<TimelineDay>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
newDays = new Set<TimelineDay>();
|
||||
|
||||
getDay({ year, month, day }: TimelineDate): TimelineDay | undefined {
|
||||
return this.#lookupCache[year]?.[month]?.[day];
|
||||
}
|
||||
|
||||
setDay(day: TimelineDay, { year, month, day: dayNum }: TimelineDate) {
|
||||
if (!this.#lookupCache[year]) {
|
||||
this.#lookupCache[year] = {};
|
||||
}
|
||||
if (!this.#lookupCache[year][month]) {
|
||||
this.#lookupCache[year][month] = {};
|
||||
}
|
||||
this.#lookupCache[year][month][dayNum] = day;
|
||||
}
|
||||
|
||||
get existingDays() {
|
||||
return setDifference(this.changedDays, this.newDays);
|
||||
}
|
||||
|
||||
get updatedMonths() {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const months = new Set<TimelineMonth>();
|
||||
for (const day of this.changedDays) {
|
||||
months.add(day.month);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
get monthsWithNewDays() {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const months = new Set<TimelineMonth>();
|
||||
for (const day of this.newDays) {
|
||||
months.add(day.month);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
sort(month: TimelineMonth, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
for (const day of this.changedDays) {
|
||||
day.sortAssets(sortOrder);
|
||||
}
|
||||
for (const day of this.newDays) {
|
||||
day.sortAssets(sortOrder);
|
||||
}
|
||||
if (this.newDays.size > 0) {
|
||||
month.sortDays();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { setTestHooks } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||
import { tick } from 'svelte';
|
||||
import { TimelineManager } from './timeline-manager.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
async function getAssets(timelineManager: TimelineManager) {
|
||||
const assets = [];
|
||||
@@ -16,6 +19,10 @@ async function getAssets(timelineManager: TimelineManager) {
|
||||
return assets;
|
||||
}
|
||||
|
||||
function getMonthForAssetId(timelineManager: TimelineManager, id: string) {
|
||||
return timelineManager.search.findMonthForAsset(id)?.month;
|
||||
}
|
||||
|
||||
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
|
||||
return {
|
||||
...arg,
|
||||
@@ -74,7 +81,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('calculates month height', () => {
|
||||
const plainMonths = timelineManager.months.map((month) => ({
|
||||
const plainMonths = timelineManager.segments.map((month) => ({
|
||||
year: month.yearMonth.year,
|
||||
month: month.yearMonth.month,
|
||||
height: month.height,
|
||||
@@ -94,7 +101,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMonthGroup', () => {
|
||||
describe('loadSegment', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
@@ -130,52 +137,52 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('loads a month', async () => {
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(0);
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('ignores invalid months', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2023, month: 1 });
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 }));
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('cancels month loading', async () => {
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||
void timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 })!;
|
||||
void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||
month?.cancel();
|
||||
expect(abortSpy).toBeCalledTimes(1);
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('prevents loading months multiple times', async () => {
|
||||
await Promise.all([
|
||||
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
|
||||
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
|
||||
]);
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows loading a canceled month', async () => {
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||
const loadPromise = timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 })!;
|
||||
const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
|
||||
month.cancel();
|
||||
await loadPromise;
|
||||
expect(month?.getAssets().length).toEqual(0);
|
||||
expect(month?.assets.length).toEqual(0);
|
||||
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(month!.getAssets().length).toEqual(3);
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
expect(month!.assets.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
describe('upsertAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -186,7 +193,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('is empty initially', () => {
|
||||
expect(timelineManager.months.length).toEqual(0);
|
||||
expect(timelineManager.segments.length).toEqual(0);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
});
|
||||
|
||||
@@ -196,14 +203,14 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.segments.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
|
||||
expect(timelineManager.segments[0].assets.length).toEqual(1);
|
||||
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.segments[0].yearMonth.month).toEqual(1);
|
||||
expect(timelineManager.segments[0].getFirstAsset().id).toEqual(asset.id);
|
||||
});
|
||||
|
||||
it('adds assets to existing month', () => {
|
||||
@@ -212,14 +219,14 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne]);
|
||||
timelineManager.addAssets([assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne]);
|
||||
timelineManager.upsertAssets([assetTwo]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.segments.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(2);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||
expect(timelineManager.segments[0].assets.length).toEqual(2);
|
||||
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.segments[0].yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('orders assets in months by descending date', () => {
|
||||
@@ -238,14 +245,14 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 });
|
||||
expect(month).not.toBeNull();
|
||||
expect(month?.getAssets().length).toEqual(3);
|
||||
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
|
||||
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
|
||||
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
|
||||
expect(month?.assets.length).toEqual(3);
|
||||
expect(month?.assets[0].id).toEqual(assetOne.id);
|
||||
expect(month?.assets[1].id).toEqual(assetThree.id);
|
||||
expect(month?.assets[2].id).toEqual(assetTwo.id);
|
||||
});
|
||||
|
||||
it('orders months by descending date', () => {
|
||||
@@ -264,25 +271,25 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(3);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[0].yearMonth.month).toEqual(4);
|
||||
expect(timelineManager.segments.length).toEqual(3);
|
||||
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.segments[0].yearMonth.month).toEqual(4);
|
||||
|
||||
expect(timelineManager.months[1].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[1].yearMonth.month).toEqual(1);
|
||||
expect(timelineManager.segments[1].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.segments[1].yearMonth.month).toEqual(1);
|
||||
|
||||
expect(timelineManager.months[2].yearMonth.year).toEqual(2023);
|
||||
expect(timelineManager.months[2].yearMonth.month).toEqual(1);
|
||||
expect(timelineManager.segments[2].yearMonth.year).toEqual(2023);
|
||||
expect(timelineManager.segments[2].yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('updates existing asset', () => {
|
||||
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
|
||||
const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
@@ -294,12 +301,128 @@ describe('TimelineManager', () => {
|
||||
|
||||
const timelineManager = new TimelineManager();
|
||||
await timelineManager.updateOptions({ isTrashed: true });
|
||||
timelineManager.addAssets([asset, trashedAsset]);
|
||||
timelineManager.upsertAssets([asset, trashedAsset]);
|
||||
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAssets', () => {
|
||||
describe('ensure efficient timeline operations', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
let month1day1asset1: TimelineAsset,
|
||||
month1day2asset1: TimelineAsset,
|
||||
month1day2asset2: TimelineAsset,
|
||||
month1day3asset1: TimelineAsset,
|
||||
month2day1asset1: TimelineAsset,
|
||||
month2day2asset1: TimelineAsset,
|
||||
month2day2asset2: TimelineAsset;
|
||||
|
||||
type DayMocks = {
|
||||
layoutFn: MockInstance;
|
||||
sortAssetsFn: MockInstance;
|
||||
};
|
||||
type MonthMocks = {
|
||||
sortDaysFn: MockInstance;
|
||||
};
|
||||
|
||||
const dayGroups = new Map<TimelineDay, DayMocks>();
|
||||
const months = new Map<TimelineMonth, MonthMocks>();
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
setTestHooks({
|
||||
onCreateTimelineDay: (day: TimelineDay) => {
|
||||
dayGroups.set(day, {
|
||||
layoutFn: vi.spyOn(day, 'layout'),
|
||||
sortAssetsFn: vi.spyOn(day, 'sortAssets'),
|
||||
});
|
||||
},
|
||||
onCreateTimelineMonth: (month: TimelineMonth) => {
|
||||
months.set(month, {
|
||||
sortDaysFn: vi.spyOn(month, 'sortDays'),
|
||||
});
|
||||
},
|
||||
});
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
month1day1asset1 = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
month1day2asset1 = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
month1day2asset2 = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T13:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
month1day3asset1 = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
month2day1asset1 = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-16T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
month2day2asset1 = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-18T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
month2day2asset2 = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-18T13:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
timelineManager.upsertAssets([
|
||||
month1day1asset1,
|
||||
month1day2asset1,
|
||||
month1day2asset2,
|
||||
month1day3asset1,
|
||||
month2day1asset1,
|
||||
month2day2asset1,
|
||||
month2day2asset2,
|
||||
]);
|
||||
vitest.resetAllMocks();
|
||||
});
|
||||
it.skip('Not Ready Yet - optimizations not complete: moving asset between months only sorts/layout the affected months once', () => {
|
||||
// move from 2024-01-15 to 2024-01-16
|
||||
timelineManager.updateAssetOperation([month1day2asset1.id], (asset) => {
|
||||
asset.localDateTime.day = asset.localDateTime.day + 1;
|
||||
});
|
||||
for (const [day, mocks] of dayGroups) {
|
||||
if (day.day === 15 && day.month.yearMonth.month === 1) {
|
||||
// source - should be layout once
|
||||
expect.soft(mocks.layoutFn).toBeCalledTimes(1);
|
||||
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1);
|
||||
}
|
||||
if (day.day === 16 && day.month.yearMonth.month === 1) {
|
||||
// target - should be layout once
|
||||
expect.soft(mocks.layoutFn).toBeCalledTimes(1);
|
||||
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1);
|
||||
}
|
||||
// everything else - should not be layed-out
|
||||
expect.soft(mocks.layoutFn).toBeCalledTimes(0);
|
||||
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(0);
|
||||
}
|
||||
for (const [_, mocks] of months) {
|
||||
// if the day itself did not change, probably no need to sort it
|
||||
// in the timeline manager, the day-group identity is immutable - you will never
|
||||
// "move" a whole day to another day - only the assets inside will be moved from
|
||||
// one to the other.
|
||||
expect.soft(mocks.sortDaysFn).toBeCalledTimes(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -309,24 +432,17 @@ describe('TimelineManager', () => {
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('ignores non-existing assets', () => {
|
||||
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(0);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
});
|
||||
|
||||
it('updates an asset', () => {
|
||||
it('upserts an asset', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
||||
const updatedAsset = { ...asset, isFavorite: true };
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
||||
expect(timelineManager.segments[0].getFirstAsset().isFavorite).toEqual(false);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
timelineManager.upsertAssets([updatedAsset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
|
||||
expect(timelineManager.segments[0].getFirstAsset().isFavorite).toEqual(true);
|
||||
});
|
||||
|
||||
it('asset moves months when asset date changes', () => {
|
||||
@@ -340,17 +456,17 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(timelineManager.segments.length).toEqual(1);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(1);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
expect(timelineManager.months.length).toEqual(2);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
|
||||
timelineManager.upsertAssets([updatedAsset]);
|
||||
expect(timelineManager.segments.length).toEqual(2);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(0);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })).not.toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })?.assets.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -365,7 +481,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('ignores invalid IDs', () => {
|
||||
timelineManager.addAssets(
|
||||
timelineManager.upsertAssets(
|
||||
timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
@@ -375,8 +491,8 @@ describe('TimelineManager', () => {
|
||||
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(2);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||
expect(timelineManager.segments.length).toEqual(1);
|
||||
expect(timelineManager.segments[0].assets.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('removes asset from month', () => {
|
||||
@@ -385,12 +501,12 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
timelineManager.removeAssets([assetOne.id]);
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||
expect(timelineManager.segments.length).toEqual(1);
|
||||
expect(timelineManager.segments[0].assets.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('does not remove month when empty', () => {
|
||||
@@ -399,11 +515,11 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.upsertAssets(assets);
|
||||
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.segments.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -431,7 +547,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
|
||||
});
|
||||
});
|
||||
@@ -474,63 +590,63 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('returns null for invalid assetId', async () => {
|
||||
expect(() => timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
||||
expect(await timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
|
||||
expect(() => timelineManager.search.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
||||
expect(await timelineManager.search.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns previous assetId', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 });
|
||||
|
||||
const a = month!.getAssets()[0];
|
||||
const b = month!.getAssets()[1];
|
||||
const previous = await timelineManager.getLaterAsset(b);
|
||||
const a = month!.assets[0];
|
||||
const b = month!.assets[1];
|
||||
const previous = await timelineManager.search.getLaterAsset(b);
|
||||
expect(previous).toEqual(a);
|
||||
});
|
||||
|
||||
it('returns previous assetId spanning multiple months', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||
const a = month!.getAssets()[0];
|
||||
const b = previousMonth!.getAssets()[0];
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 2 });
|
||||
const previousMonth = timelineManager.search.findMonthByDate({ year: 2024, month: 3 });
|
||||
const a = month!.assets[0];
|
||||
const b = previousMonth!.assets[0];
|
||||
const previous = await timelineManager.search.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
});
|
||||
|
||||
it('loads previous month', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 2 });
|
||||
const previousMonth = timelineManager.search.findMonthByDate({ year: 2024, month: 3 });
|
||||
const a = month!.getFirstAsset();
|
||||
const b = previousMonth!.getFirstAsset();
|
||||
const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute');
|
||||
const loadmonthSpy = vi.spyOn(month!.loader!, 'execute');
|
||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
const previous = await timelineManager.search.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
expect(loadMonthGroupSpy).toBeCalledTimes(0);
|
||||
expect(loadmonthSpy).toBeCalledTimes(0);
|
||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('skips removed assets', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||
|
||||
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(await timelineManager.getLaterAsset(assetThree)).toEqual(assetOne);
|
||||
expect(await timelineManager.search.getLaterAsset(assetThree)).toEqual(assetOne);
|
||||
});
|
||||
|
||||
it('returns null when no more assets', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||
expect(await timelineManager.search.getLaterAsset(timelineManager.segments[0].getFirstAsset())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthGroupIndexByAssetId', () => {
|
||||
describe('getmonthIndexByAssetId', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -541,8 +657,8 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('returns null for invalid months', () => {
|
||||
expect(getMonthGroupByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: -1, month: -1 })).toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the month index', () => {
|
||||
@@ -556,12 +672,12 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
expect(getMonthForAssetId(timelineManager, assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(getMonthForAssetId(timelineManager, assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('ignores removed months', () => {
|
||||
@@ -575,11 +691,11 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -628,7 +744,7 @@ describe('TimelineManager', () => {
|
||||
expect(assetCount).toBe(14);
|
||||
const discoveredAssets: Set<string> = new Set();
|
||||
for (let idx = 0; idx < assetCount; idx++) {
|
||||
const asset = await timelineManager.getRandomAsset(idx);
|
||||
const asset = await timelineManager.search.getRandomAsset(idx);
|
||||
expect(asset).toBeDefined();
|
||||
const id = asset!.id;
|
||||
expect(discoveredAssets.has(id)).toBeFalsy();
|
||||
244
web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts
Normal file
244
web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/TimelineInsertionCache.svelte';
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte';
|
||||
import { TimelineWebsocketExtension } from '$lib/managers/timeline-manager/TimelineWebsocketExtension';
|
||||
import type {
|
||||
Direction,
|
||||
ScrubberMonth,
|
||||
TimelineAsset,
|
||||
TimelineManagerOptions,
|
||||
Viewport,
|
||||
} from '$lib/managers/timeline-manager/types';
|
||||
import { isMismatched } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, getTimeBuckets } from '@immich/sdk';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export class TimelineManager extends VirtualScrollManager {
|
||||
override bottomSectionHeight = $state(60);
|
||||
readonly search = new TimelineSearchExtension(this);
|
||||
readonly websocket = new TimelineWebsocketExtension(this);
|
||||
readonly albumAssets: Set<string> = new SvelteSet();
|
||||
readonly limitedScroll = $derived(this.maxScrollPercent < 0.5);
|
||||
readonly initTask = new CancellableTask(
|
||||
() => {
|
||||
this.isInitialized = true;
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
return;
|
||||
}
|
||||
this.websocket.connect();
|
||||
},
|
||||
() => {
|
||||
this.websocket.disconnect();
|
||||
this.isInitialized = false;
|
||||
},
|
||||
() => void 0,
|
||||
);
|
||||
|
||||
segments: TimelineMonth[] = $state([]);
|
||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
|
||||
#options: TimelineManagerOptions = {};
|
||||
#scrollableElement: HTMLElement | undefined = $state();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.#options;
|
||||
}
|
||||
|
||||
override get scrollTop(): number {
|
||||
return this.#scrollableElement?.scrollTop ?? 0;
|
||||
}
|
||||
|
||||
set scrollableElement(element: HTMLElement | undefined) {
|
||||
this.#scrollableElement = element;
|
||||
}
|
||||
|
||||
override scrollTo(top: number) {
|
||||
this.#scrollableElement?.scrollTo({ top });
|
||||
this.updateVisibleWindow();
|
||||
}
|
||||
|
||||
override scrollBy(y: number) {
|
||||
this.#scrollableElement?.scrollBy(0, y);
|
||||
this.updateVisibleWindow();
|
||||
}
|
||||
|
||||
protected override refreshLayout({ invalidateHeight = true }: { invalidateHeight?: boolean } = {}) {
|
||||
super.refreshLayout({ invalidateHeight });
|
||||
if (invalidateHeight) {
|
||||
this.#createScrubberMonths();
|
||||
}
|
||||
}
|
||||
|
||||
public override destroy() {
|
||||
this.websocket.disconnect();
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
async updateOptions(options: TimelineManagerOptions) {
|
||||
if (options.deferInit) {
|
||||
return;
|
||||
}
|
||||
if (isEqual(this.#options, options)) {
|
||||
return;
|
||||
}
|
||||
await this.initTask.reset();
|
||||
await this.#init(options);
|
||||
this.refreshLayout();
|
||||
}
|
||||
|
||||
async updateViewport(viewport: Viewport) {
|
||||
if (viewport.height === 0 && viewport.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.initTask.executed) {
|
||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||
}
|
||||
|
||||
const oldViewport: Viewport = {
|
||||
width: this.viewportWidth,
|
||||
height: this.viewportHeight,
|
||||
};
|
||||
|
||||
this.viewportHeight = viewport.height;
|
||||
this.viewportWidth = viewport.width;
|
||||
this.onUpdateViewport(oldViewport, viewport);
|
||||
}
|
||||
|
||||
protected override createUpsertContext(): GroupInsertionCache {
|
||||
return new GroupInsertionCache();
|
||||
}
|
||||
|
||||
protected override upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
|
||||
let month = this.search.findMonthByDate(asset.localDateTime);
|
||||
|
||||
if (!month) {
|
||||
month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order);
|
||||
this.segments.push(month);
|
||||
}
|
||||
|
||||
month.addTimelineAsset(asset, context);
|
||||
}
|
||||
|
||||
protected override postCreateSegments(): void {
|
||||
this.segments.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
protected override postUpsert(context: GroupInsertionCache): void {
|
||||
for (const group of context.existingDays) {
|
||||
group.sortAssets(this.#options.order);
|
||||
}
|
||||
|
||||
for (const month of context.monthsWithNewDays) {
|
||||
month.sortDays();
|
||||
}
|
||||
|
||||
for (const month of context.updatedMonths) {
|
||||
month.sortDays();
|
||||
month.updateGeometry({ invalidateHeight: true });
|
||||
}
|
||||
}
|
||||
|
||||
override isExcluded(asset: TimelineAsset) {
|
||||
return (
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||
);
|
||||
}
|
||||
|
||||
getAssetOrder() {
|
||||
return this.#options.order ?? AssetOrder.Desc;
|
||||
}
|
||||
|
||||
getFirstAsset(): TimelineAsset | undefined {
|
||||
return this.segments[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
async *assetsIterator(options?: {
|
||||
startMonth?: TimelineMonth;
|
||||
startDay?: TimelineDay;
|
||||
startAsset?: TimelineAsset;
|
||||
direction?: Direction;
|
||||
}) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startDay, startAsset } = options ?? {};
|
||||
for (const month of this.monthIterator({ direction, startMonth: options?.startMonth })) {
|
||||
await this.loadSegment(getSegmentIdentifier(month.yearMonth), { cancelable: false });
|
||||
yield* month.assetsIterator({ startDay, startAsset, direction });
|
||||
startDay = startAsset = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
*monthIterator(options?: { direction?: Direction; startMonth?: TimelineMonth }) {
|
||||
const isEarlier = options?.direction === 'earlier';
|
||||
let startIndex = options?.startMonth
|
||||
? this.segments.indexOf(options.startMonth)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.segments.length - 1;
|
||||
|
||||
while (startIndex >= 0 && startIndex < this.segments.length) {
|
||||
yield this.segments[startIndex];
|
||||
startIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
async #init(options: TimelineManagerOptions) {
|
||||
this.isInitialized = false;
|
||||
this.segments = [];
|
||||
this.albumAssets.clear();
|
||||
await this.initTask.execute(async () => {
|
||||
this.#options = options;
|
||||
const timebuckets = await getTimeBuckets({
|
||||
...authManager.params,
|
||||
...this.#options,
|
||||
});
|
||||
|
||||
for (const timeBucket of timebuckets) {
|
||||
const date = new SvelteDate(timeBucket.timeBucket);
|
||||
this.segments.push(
|
||||
new TimelineMonth(
|
||||
this,
|
||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
||||
timeBucket.count,
|
||||
false,
|
||||
this.#options.order,
|
||||
),
|
||||
);
|
||||
}
|
||||
this.albumAssets.clear();
|
||||
}, true);
|
||||
this.refreshLayout();
|
||||
}
|
||||
|
||||
#createScrubberMonths() {
|
||||
this.scrubberMonths = this.segments.map((month) => ({
|
||||
assetCount: month.assetsCount,
|
||||
year: month.yearMonth.year,
|
||||
month: month.yearMonth.month,
|
||||
title: month.monthTitle,
|
||||
height: month.height,
|
||||
}));
|
||||
this.scrubberTimelineHeight = this.totalViewerHeight;
|
||||
}
|
||||
}
|
||||
393
web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts
Normal file
393
web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/TimelineInsertionCache.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { onCreateTimelineMonth } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte';
|
||||
import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import { ScrollSegment, type SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import { setDifferenceInPlace } from '$lib/managers/VirtualScrollManager/utils.svelte';
|
||||
import type { AssetOperation } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import {
|
||||
formatGroupTitle,
|
||||
formatGroupTitleFull,
|
||||
formatMonthGroupTitle,
|
||||
fromTimelinePlainDate,
|
||||
fromTimelinePlainDateTime,
|
||||
fromTimelinePlainYearMonth,
|
||||
getSegmentIdentifier,
|
||||
getTimes,
|
||||
toISOYearMonthUTC,
|
||||
type TimelineDateTime,
|
||||
type TimelineYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, getTimeBucket, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
|
||||
export class TimelineMonth extends ScrollSegment {
|
||||
days: TimelineDay[] = $state([]);
|
||||
|
||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||
#yearMonth: TimelineYearMonth;
|
||||
#identifier: SegmentIdentifier;
|
||||
#timelineManager: TimelineManager;
|
||||
|
||||
readonly monthTitle: string;
|
||||
|
||||
constructor(
|
||||
timelineManager: TimelineManager,
|
||||
yearMonth: TimelineYearMonth,
|
||||
initialCount: number,
|
||||
loaded: boolean,
|
||||
order: AssetOrder = AssetOrder.Desc,
|
||||
) {
|
||||
super();
|
||||
this.initialCount = initialCount;
|
||||
this.#yearMonth = yearMonth;
|
||||
this.#identifier = getSegmentIdentifier(yearMonth);
|
||||
this.#timelineManager = timelineManager;
|
||||
this.#sortOrder = order;
|
||||
this.monthTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
this.loaded = loaded;
|
||||
if (import.meta.env.DEV) {
|
||||
onCreateTimelineMonth(this);
|
||||
}
|
||||
}
|
||||
|
||||
get identifier() {
|
||||
return this.#identifier;
|
||||
}
|
||||
|
||||
get scrollManager(): TimelineManager {
|
||||
return this.#timelineManager;
|
||||
}
|
||||
|
||||
get viewerAssets() {
|
||||
const assets: ViewerAsset[] = [];
|
||||
for (const day of this.days) {
|
||||
assets.push(...day.viewerAssets);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
override findAssetAbsolutePosition(assetId: string) {
|
||||
this.#clearDeferredLayout();
|
||||
for (const day of this.days) {
|
||||
const viewerAsset = day.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
||||
if (viewerAsset) {
|
||||
if (!viewerAsset.position) {
|
||||
console.warn('No position for asset');
|
||||
return;
|
||||
}
|
||||
return {
|
||||
top: this.top + day.top + viewerAsset.position.top + this.#timelineManager.headerHeight,
|
||||
height: viewerAsset.position.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async fetch(signal: AbortSignal): Promise<unknown> {
|
||||
if (this.getFirstAsset()) {
|
||||
return;
|
||||
}
|
||||
const timelineManager = this.#timelineManager;
|
||||
const options = timelineManager.options;
|
||||
const timeBucket = toISOYearMonthUTC(this.yearMonth);
|
||||
const bucketResponse = await getTimeBucket(
|
||||
{
|
||||
...authManager.params,
|
||||
...options,
|
||||
timeBucket,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
if (!bucketResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.timelineAlbumId) {
|
||||
const albumAssets = await getTimeBucket(
|
||||
{
|
||||
...authManager.params,
|
||||
albumId: options.timelineAlbumId,
|
||||
timeBucket,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
for (const id of albumAssets.id) {
|
||||
timelineManager.albumAssets.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const unprocessedAssets = this.addAssets(bucketResponse, true);
|
||||
if (unprocessedAssets.length > 0) {
|
||||
console.error(
|
||||
`Warning: getTimeBucket API returning assets not in requested month: ${this.yearMonth.month}, ${JSON.stringify(
|
||||
unprocessedAssets.map((unprocessed) => ({
|
||||
id: unprocessed.id,
|
||||
localDateTime: unprocessed.localDateTime,
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override layout(noDefer: boolean) {
|
||||
let cumulativeHeight = 0;
|
||||
let cumulativeWidth = 0;
|
||||
let currentRowHeight = 0;
|
||||
|
||||
let dayGroupRow = 0;
|
||||
let dayGroupCol = 0;
|
||||
|
||||
const options = this.scrollManager.justifiedLayoutOptions;
|
||||
for (const dayGroup of this.days) {
|
||||
dayGroup.layout(options, noDefer);
|
||||
|
||||
// Calculate space needed for this item (including gap if not first in row)
|
||||
const spaceNeeded = dayGroup.width + (dayGroupCol > 0 ? this.scrollManager.gap : 0);
|
||||
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= this.scrollManager.viewportWidth;
|
||||
|
||||
if (fitsInCurrentRow) {
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol++;
|
||||
dayGroup.left = cumulativeWidth;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
cumulativeWidth += dayGroup.width + this.scrollManager.gap;
|
||||
} else {
|
||||
// Move to next row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
cumulativeWidth = 0;
|
||||
dayGroupRow++;
|
||||
dayGroupCol = 0;
|
||||
|
||||
// Position at start of new row
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol;
|
||||
dayGroup.left = 0;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
dayGroupCol++;
|
||||
cumulativeWidth += dayGroup.width + this.scrollManager.gap;
|
||||
}
|
||||
currentRowHeight = dayGroup.height + this.scrollManager.headerHeight;
|
||||
}
|
||||
|
||||
// Add the height of the final row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
|
||||
this.height = cumulativeHeight;
|
||||
this.isHeightActual = true;
|
||||
}
|
||||
|
||||
override updateIntersection({
|
||||
intersecting,
|
||||
actuallyIntersecting,
|
||||
}: {
|
||||
intersecting: boolean;
|
||||
actuallyIntersecting: boolean;
|
||||
}) {
|
||||
super.updateIntersection({ intersecting, actuallyIntersecting });
|
||||
if (intersecting) {
|
||||
this.#clearDeferredLayout();
|
||||
}
|
||||
}
|
||||
|
||||
get yearMonth() {
|
||||
return this.#yearMonth;
|
||||
}
|
||||
|
||||
get lastDay() {
|
||||
return this.days.at(-1);
|
||||
}
|
||||
|
||||
getFirstAsset() {
|
||||
return this.days[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
override runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
const { days } = this;
|
||||
let combinedChangedGeometry = false;
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const idsToProcess = new Set(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: TimelineAsset[] = [];
|
||||
let index = days.length;
|
||||
while (index--) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const group = days[index];
|
||||
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
|
||||
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(...moveAssets);
|
||||
}
|
||||
setDifferenceInPlace(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
|
||||
if (group.viewerAssets.length === 0) {
|
||||
days.splice(index, 1);
|
||||
combinedChangedGeometry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
moveAssets: combinedMoveAssets,
|
||||
unprocessedIds: idsToProcess,
|
||||
processedIds: idsProcessed,
|
||||
changedGeometry: combinedChangedGeometry,
|
||||
};
|
||||
}
|
||||
|
||||
addAssets(bucketAssets: TimeBucketAssetResponseDto, preSorted: boolean) {
|
||||
const addContext = new GroupInsertionCache();
|
||||
for (let i = 0; i < bucketAssets.id.length; i++) {
|
||||
const { localDateTime, fileCreatedAt } = getTimes(
|
||||
bucketAssets.fileCreatedAt[i],
|
||||
bucketAssets.localOffsetHours[i],
|
||||
);
|
||||
|
||||
const timelineAsset: TimelineAsset = {
|
||||
city: bucketAssets.city[i],
|
||||
country: bucketAssets.country[i],
|
||||
duration: bucketAssets.duration[i],
|
||||
id: bucketAssets.id[i],
|
||||
visibility: bucketAssets.visibility[i],
|
||||
isFavorite: bucketAssets.isFavorite[i],
|
||||
isImage: bucketAssets.isImage[i],
|
||||
isTrashed: bucketAssets.isTrashed[i],
|
||||
isVideo: !bucketAssets.isImage[i],
|
||||
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||
localDateTime,
|
||||
fileCreatedAt,
|
||||
ownerId: bucketAssets.ownerId[i],
|
||||
projectionType: bucketAssets.projectionType[i],
|
||||
ratio: bucketAssets.ratio[i],
|
||||
stack: bucketAssets.stack?.[i]
|
||||
? {
|
||||
id: bucketAssets.stack[i]![0],
|
||||
primaryAssetId: bucketAssets.id[i],
|
||||
assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
|
||||
}
|
||||
: null,
|
||||
thumbhash: bucketAssets.thumbhash[i],
|
||||
people: null, // People are not included in the bucket assets
|
||||
};
|
||||
|
||||
if (bucketAssets.latitude?.[i] && bucketAssets.longitude?.[i]) {
|
||||
timelineAsset.latitude = bucketAssets.latitude?.[i];
|
||||
timelineAsset.longitude = bucketAssets.longitude?.[i];
|
||||
}
|
||||
this.addTimelineAsset(timelineAsset, addContext);
|
||||
}
|
||||
if (!preSorted) {
|
||||
for (const group of addContext.existingDays) {
|
||||
group.sortAssets(this.#sortOrder);
|
||||
}
|
||||
|
||||
if (addContext.newDays.size > 0) {
|
||||
this.sortDays();
|
||||
}
|
||||
|
||||
addContext.sort(this, this.#sortOrder);
|
||||
}
|
||||
return addContext.unprocessedAssets;
|
||||
}
|
||||
|
||||
sortDays() {
|
||||
if (this.#sortOrder === AssetOrder.Asc) {
|
||||
return this.days.sort((a, b) => a.day - b.day);
|
||||
}
|
||||
|
||||
return this.days.sort((a, b) => b.day - a.day);
|
||||
}
|
||||
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
|
||||
const { localDateTime } = timelineAsset;
|
||||
|
||||
const { year, month } = this.yearMonth;
|
||||
if (month !== localDateTime.month || year !== localDateTime.year) {
|
||||
addContext.unprocessedAssets.push(timelineAsset);
|
||||
return;
|
||||
}
|
||||
|
||||
let day = addContext.getDay(localDateTime) || this.findDayByDay(localDateTime.day);
|
||||
if (day) {
|
||||
addContext.setDay(day, localDateTime);
|
||||
} else {
|
||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||
const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime));
|
||||
day = new TimelineDay(this, this.days.length, localDateTime.day, groupTitle, groupTitleFull);
|
||||
this.days.push(day);
|
||||
addContext.setDay(day, localDateTime);
|
||||
addContext.newDays.add(day);
|
||||
}
|
||||
|
||||
const viewerAsset = new ViewerAsset(day, timelineAsset);
|
||||
day.viewerAssets.push(viewerAsset);
|
||||
addContext.changedDays.add(day);
|
||||
}
|
||||
|
||||
*assetsIterator(options?: { startDay?: TimelineDay; startAsset?: TimelineAsset; direction?: Direction }) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startAsset } = options ?? {};
|
||||
const isEarlier = direction === 'earlier';
|
||||
let groupIndex = options?.startDay ? this.days.indexOf(options.startDay) : isEarlier ? 0 : this.days.length - 1;
|
||||
|
||||
while (groupIndex >= 0 && groupIndex < this.days.length) {
|
||||
const group = this.days[groupIndex];
|
||||
yield* group.assetsIterator({ startAsset, direction });
|
||||
startAsset = undefined;
|
||||
groupIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
findDayForAsset(asset: TimelineAsset) {
|
||||
for (const group of this.days) {
|
||||
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findDayByDay(day: number) {
|
||||
return this.days.find((group) => group.day === day);
|
||||
}
|
||||
|
||||
findAssetById(assetDescriptor: AssetDescriptor) {
|
||||
for (const asset of this.assetsIterator()) {
|
||||
if (asset.id === assetDescriptor.id) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findClosest(target: TimelineDateTime) {
|
||||
const targetDate = fromTimelinePlainDateTime(target);
|
||||
let closest = undefined;
|
||||
let smallestDiff = Infinity;
|
||||
for (const current of this.assetsIterator()) {
|
||||
const currentAssetDate = fromTimelinePlainDateTime(current.localDateTime);
|
||||
const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds'));
|
||||
if (diff < smallestDiff) {
|
||||
smallestDiff = diff;
|
||||
closest = current;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
#clearDeferredLayout() {
|
||||
const hasDeferred = this.days.some((group) => group.deferredLayout);
|
||||
if (hasDeferred) {
|
||||
this.updateGeometry({ invalidateHeight: true, noDefer: true });
|
||||
for (const group of this.days) {
|
||||
group.deferredLayout = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,40 @@
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { findClosestMonthToDate } from '$lib/utils/timeline-util';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import { findClosestGroupForDate } from './search-support.svelte';
|
||||
|
||||
function createMockMonthGroup(year: number, month: number): MonthGroup {
|
||||
function createMockMonthGroup(year: number, month: number): TimelineMonth {
|
||||
return {
|
||||
yearMonth: { year, month },
|
||||
} as MonthGroup;
|
||||
} as TimelineMonth;
|
||||
}
|
||||
|
||||
describe('findClosestGroupForDate', () => {
|
||||
describe('findClosestMonthToDate', () => {
|
||||
it('should return undefined for empty months array', () => {
|
||||
const result = findClosestGroupForDate([], { year: 2024, month: 1 });
|
||||
const result = findClosestMonthToDate([], { year: 2024, month: 1 });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the only month when there is only one month', () => {
|
||||
const months = [createMockMonthGroup(2024, 6)];
|
||||
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2025, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should return exact match when available', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 6 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 6 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should find closest month when target is between two months', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 4 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 4 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle year boundaries correctly (2023-12 vs 2024-01)', () => {
|
||||
const months = [createMockMonthGroup(2023, 12), createMockMonthGroup(2024, 2)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 1 });
|
||||
// 2024-01 is 1 month from 2023-12 and 1 month from 2024-02
|
||||
// Should return first encountered with min distance (2023-12)
|
||||
expect(result?.yearMonth).toEqual({ year: 2023, month: 12 });
|
||||
@@ -42,33 +42,33 @@ describe('findClosestGroupForDate', () => {
|
||||
|
||||
it('should correctly calculate distance across years', () => {
|
||||
const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 6)];
|
||||
const result = findClosestGroupForDate(months, { year: 2023, month: 6 });
|
||||
const result = findClosestMonthToDate(months, { year: 2023, month: 6 });
|
||||
// Both are exactly 12 months away, should return first encountered
|
||||
expect(result?.yearMonth).toEqual({ year: 2022, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle target before all months', () => {
|
||||
const months = [createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle target after all months', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6)];
|
||||
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2025, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle multiple years correctly', () => {
|
||||
const months = [createMockMonthGroup(2020, 1), createMockMonthGroup(2022, 1), createMockMonthGroup(2024, 1)];
|
||||
const result = findClosestGroupForDate(months, { year: 2023, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2023, month: 1 });
|
||||
// 2023-01 is 12 months from 2022-01 and 12 months from 2024-01
|
||||
expect(result?.yearMonth).toEqual({ year: 2022, month: 1 });
|
||||
});
|
||||
|
||||
it('should prefer closer month when one is clearly closer', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 10)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 11 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 11 });
|
||||
// 2024-11 is 1 month from 2024-10 and 10 months from 2024-01
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 10 });
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import {
|
||||
findClosestMonthToDate,
|
||||
getSegmentIdentifier,
|
||||
plainDateTimeCompare,
|
||||
toTimelineAsset,
|
||||
type TimelineDateTime,
|
||||
type TimelineYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, getAssetInfo } from '@immich/sdk';
|
||||
|
||||
export class TimelineSearchExtension {
|
||||
#timelineManager: TimelineManager;
|
||||
constructor(timelineManager: TimelineManager) {
|
||||
this.#timelineManager = timelineManager;
|
||||
}
|
||||
|
||||
async getLaterAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier');
|
||||
}
|
||||
|
||||
async getMonthForAsset(id: string) {
|
||||
if (!this.#timelineManager.isInitialized) {
|
||||
await this.#timelineManager.initTask.waitUntilCompletion();
|
||||
}
|
||||
|
||||
let { month } = this.findMonthForAsset(id) ?? {};
|
||||
if (month) {
|
||||
return month;
|
||||
}
|
||||
|
||||
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = toTimelineAsset(response);
|
||||
if (!asset || this.#timelineManager.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
month = await this.#loadMonthAtTime(asset.localDateTime, { cancelable: false });
|
||||
if (month?.findAssetById({ id })) {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
||||
await this.#timelineManager.loadSegment(getSegmentIdentifier(yearMonth), options);
|
||||
return this.findMonthByDate(yearMonth);
|
||||
}
|
||||
|
||||
async #getAssetWithOffset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
direction: Direction,
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
const { asset, month } = this.findMonthForAsset(assetDescriptor.id) ?? {};
|
||||
if (!month || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case 'asset': {
|
||||
return this.#getAssetByAssetOffset(asset, month, direction);
|
||||
}
|
||||
case 'day': {
|
||||
return this.#getAssetByDayOffset(asset, month, direction);
|
||||
}
|
||||
case 'month': {
|
||||
return this.#getAssetByMonthOffset(month, direction);
|
||||
}
|
||||
case 'year': {
|
||||
return this.#getAssetByYearOffset(month, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findMonthForAsset(id: string) {
|
||||
for (const month of this.#timelineManager.segments) {
|
||||
const asset = month.findAssetById({ id });
|
||||
if (asset) {
|
||||
return { month, asset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findMonthByDate(targetYearMonth: TimelineYearMonth): TimelineMonth | undefined {
|
||||
return this.#timelineManager.segments.find(
|
||||
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
||||
);
|
||||
}
|
||||
|
||||
// note: the `index` input is expected to be in the range [0, assetCount). This
|
||||
// value can be passed to make the method deterministic, which is mainly useful
|
||||
// for testing.
|
||||
async getRandomAsset(index?: number): Promise<TimelineAsset | undefined> {
|
||||
const randomAssetIndex = index ?? Math.floor(Math.random() * this.#timelineManager.assetCount);
|
||||
|
||||
let accumulatedCount = 0;
|
||||
|
||||
let randomMonth: TimelineMonth | undefined = undefined;
|
||||
for (const month of this.#timelineManager.segments) {
|
||||
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
|
||||
randomMonth = month;
|
||||
break;
|
||||
}
|
||||
|
||||
accumulatedCount += month.assetsCount;
|
||||
}
|
||||
if (!randomMonth) {
|
||||
return;
|
||||
}
|
||||
await this.#timelineManager.loadSegment(getSegmentIdentifier(randomMonth.yearMonth), { cancelable: false });
|
||||
|
||||
let randomDay: TimelineDay | undefined = undefined;
|
||||
for (const day of randomMonth.days) {
|
||||
if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) {
|
||||
randomDay = day;
|
||||
break;
|
||||
}
|
||||
|
||||
accumulatedCount += day.viewerAssets.length;
|
||||
}
|
||||
if (!randomDay) {
|
||||
return;
|
||||
}
|
||||
|
||||
return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
|
||||
}
|
||||
|
||||
async #getAssetByAssetOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) {
|
||||
const day = month.findDayForAsset(asset);
|
||||
for await (const targetAsset of this.#timelineManager.assetsIterator({
|
||||
startMonth: month,
|
||||
startDay: day,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (asset.id !== targetAsset.id) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByDayOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) {
|
||||
const day = month.findDayForAsset(asset);
|
||||
for await (const targetAsset of this.#timelineManager.assetsIterator({
|
||||
startMonth: month,
|
||||
startDay: day,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByMonthOffset(month: TimelineMonth, direction: Direction) {
|
||||
for (const targetMonth of this.#timelineManager.monthIterator({ startMonth: month, direction })) {
|
||||
if (targetMonth.yearMonth.month !== month.yearMonth.month) {
|
||||
const { value, done } = await this.#timelineManager
|
||||
.assetsIterator({ startMonth: targetMonth, direction })
|
||||
.next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByYearOffset(month: TimelineMonth, direction: Direction) {
|
||||
for (const targetMonth of this.#timelineManager.monthIterator({ startMonth: month, direction })) {
|
||||
if (targetMonth.yearMonth.year !== month.yearMonth.year) {
|
||||
const { value, done } = await this.#timelineManager
|
||||
.assetsIterator({ startMonth: targetMonth, direction })
|
||||
.next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
||||
let { asset: startAsset, month: startMonth } = this.findMonthForAsset(start.id) ?? {};
|
||||
if (!startMonth || !startAsset) {
|
||||
return [];
|
||||
}
|
||||
let { asset: endAsset, month: endMonth } = this.findMonthForAsset(end.id) ?? {};
|
||||
if (!endMonth || !endAsset) {
|
||||
return [];
|
||||
}
|
||||
const assetOrder: AssetOrder = this.#timelineManager.getAssetOrder();
|
||||
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
[startMonth, endMonth] = [endMonth, startMonth];
|
||||
}
|
||||
|
||||
const range: TimelineAsset[] = [];
|
||||
const startDay = startMonth.findDayForAsset(startAsset);
|
||||
for await (const targetAsset of this.#timelineManager.assetsIterator({
|
||||
startMonth,
|
||||
startDay,
|
||||
startAsset,
|
||||
})) {
|
||||
range.push(targetAsset);
|
||||
if (targetAsset.id === endAsset.id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
findMonthForDate(targetYearMonth: TimelineYearMonth) {
|
||||
for (const month of this.#timelineManager.segments) {
|
||||
const { year, month: monthNum } = month.yearMonth;
|
||||
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
||||
let month = this.findMonthForDate(dateTime);
|
||||
if (!month) {
|
||||
month = findClosestMonthToDate(this.#timelineManager.segments, dateTime);
|
||||
if (!month) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.#timelineManager.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false });
|
||||
const asset = month.findClosest(dateTime);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
for await (const asset of this.#timelineManager.assetsIterator({ startMonth: month })) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
|
||||
export type TestHooks = {
|
||||
onCreateTimelineMonth(month: TimelineMonth): unknown;
|
||||
onCreateTimelineDay(day: TimelineDay): unknown;
|
||||
};
|
||||
|
||||
let testHooks: TestHooks | undefined = undefined;
|
||||
|
||||
export const setTestHooks = (hooks: TestHooks) => {
|
||||
testHooks = hooks;
|
||||
};
|
||||
|
||||
export const onCreateTimelineMonth = (month: TimelineMonth) => testHooks?.onCreateTimelineMonth(month);
|
||||
export const onCreateTimelineDay = (day: TimelineDay) => testHooks?.onCreateTimelineDay(day);
|
||||
@@ -1,22 +1,19 @@
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { throttle } from 'lodash-es';
|
||||
import type { Unsubscriber } from 'svelte/store';
|
||||
|
||||
export class WebsocketSupport {
|
||||
#pendingChanges: PendingChange[] = [];
|
||||
#unsubscribers: Unsubscriber[] = [];
|
||||
#timelineManager: TimelineManager;
|
||||
|
||||
#processPendingChanges = throttle(() => {
|
||||
export class TimelineWebsocketExtension {
|
||||
readonly #timelineManager: TimelineManager;
|
||||
readonly #processPendingChanges = throttle(() => {
|
||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||
if (add.length > 0) {
|
||||
this.#timelineManager.addAssets(add);
|
||||
this.#timelineManager.upsertAssets(add);
|
||||
}
|
||||
if (update.length > 0) {
|
||||
this.#timelineManager.updateAssets(update);
|
||||
this.#timelineManager.upsertAssets(update);
|
||||
}
|
||||
if (remove.length > 0) {
|
||||
this.#timelineManager.removeAssets(remove);
|
||||
@@ -24,11 +21,26 @@ export class WebsocketSupport {
|
||||
this.#pendingChanges = [];
|
||||
}, 2500);
|
||||
|
||||
#pendingChanges: PendingChange[] = [];
|
||||
#unsubscribers: Unsubscriber[] = [];
|
||||
#connected = false;
|
||||
|
||||
constructor(timeineManager: TimelineManager) {
|
||||
this.#timelineManager = timeineManager;
|
||||
}
|
||||
|
||||
connectWebsocketEvents() {
|
||||
connect() {
|
||||
if (this.#connected) {
|
||||
throw new Error('TimelineManager already connected');
|
||||
}
|
||||
this.#connectWebsocketEvents();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#disconnectWebsocketEvents();
|
||||
}
|
||||
|
||||
#connectWebsocketEvents() {
|
||||
this.#unsubscribers.push(
|
||||
websocketEvents.on('on_upload_success', (asset) =>
|
||||
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
|
||||
@@ -41,7 +53,7 @@ export class WebsocketSupport {
|
||||
);
|
||||
}
|
||||
|
||||
disconnectWebsocketEvents() {
|
||||
#disconnectWebsocketEvents() {
|
||||
for (const unsubscribe of this.#unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { DayGroup } from './day-group.svelte';
|
||||
import type { MonthGroup } from './month-group.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export class GroupInsertionCache {
|
||||
#lookupCache: {
|
||||
[year: number]: { [month: number]: { [day: number]: DayGroup } };
|
||||
} = {};
|
||||
unprocessedAssets: TimelineAsset[] = [];
|
||||
changedDayGroups = new SvelteSet<DayGroup>();
|
||||
newDayGroups = new SvelteSet<DayGroup>();
|
||||
|
||||
getDayGroup({ year, month, day }: TimelineDate): DayGroup | undefined {
|
||||
return this.#lookupCache[year]?.[month]?.[day];
|
||||
}
|
||||
|
||||
setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelineDate) {
|
||||
if (!this.#lookupCache[year]) {
|
||||
this.#lookupCache[year] = {};
|
||||
}
|
||||
if (!this.#lookupCache[year][month]) {
|
||||
this.#lookupCache[year][month] = {};
|
||||
}
|
||||
this.#lookupCache[year][month][day] = dayGroup;
|
||||
}
|
||||
|
||||
get existingDayGroups() {
|
||||
return setDifference(this.changedDayGroups, this.newDayGroups);
|
||||
}
|
||||
|
||||
get updatedBuckets() {
|
||||
const updated = new SvelteSet<MonthGroup>();
|
||||
for (const group of this.changedDayGroups) {
|
||||
updated.add(group.monthGroup);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
get bucketsWithNewDayGroups() {
|
||||
const updated = new SvelteSet<MonthGroup>();
|
||||
for (const group of this.newDayGroups) {
|
||||
updated.add(group.monthGroup);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
sort(monthGroup: MonthGroup, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
for (const group of this.changedDayGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
for (const group of this.newDayGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
if (this.newDayGroups.size > 0) {
|
||||
monthGroup.sortDayGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
|
||||
const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0);
|
||||
let preIntersecting = false;
|
||||
if (!actuallyIntersecting) {
|
||||
preIntersecting = calculateMonthGroupIntersecting(
|
||||
timelineManager,
|
||||
month,
|
||||
INTERSECTION_EXPAND_TOP,
|
||||
INTERSECTION_EXPAND_BOTTOM,
|
||||
);
|
||||
}
|
||||
month.intersecting = actuallyIntersecting || preIntersecting;
|
||||
month.actuallyIntersecting = actuallyIntersecting;
|
||||
if (preIntersecting || actuallyIntersecting) {
|
||||
timelineManager.clearDeferredLayout(month);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* General function to check if a rectangular region intersects with a window.
|
||||
* @param regionTop - Top position of the region to check
|
||||
* @param regionBottom - Bottom position of the region to check
|
||||
* @param windowTop - Top position of the window
|
||||
* @param windowBottom - Bottom position of the window
|
||||
* @returns true if the region intersects with the window
|
||||
*/
|
||||
export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) {
|
||||
return (
|
||||
(regionTop >= windowTop && regionTop < windowBottom) ||
|
||||
(regionBottom >= windowTop && regionBottom < windowBottom) ||
|
||||
(regionTop < windowTop && regionBottom >= windowBottom)
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateMonthGroupIntersecting(
|
||||
timelineManager: TimelineManager,
|
||||
monthGroup: MonthGroup,
|
||||
expandTop: number,
|
||||
expandBottom: number,
|
||||
) {
|
||||
const monthGroupTop = monthGroup.top;
|
||||
const monthGroupBottom = monthGroupTop + monthGroup.height;
|
||||
const topWindow = timelineManager.visibleWindow.top - expandTop;
|
||||
const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom;
|
||||
|
||||
return isIntersecting(monthGroupTop, monthGroupBottom, topWindow, bottomWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate intersection for viewer assets with additional parameters like header height
|
||||
*/
|
||||
export function calculateViewerAssetIntersecting(
|
||||
timelineManager: TimelineManager,
|
||||
positionTop: number,
|
||||
positionHeight: number,
|
||||
expandTop: number = INTERSECTION_EXPAND_TOP,
|
||||
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
|
||||
) {
|
||||
const topWindow = timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop;
|
||||
const bottomWindow = timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom;
|
||||
|
||||
const positionBottom = positionTop + positionHeight;
|
||||
|
||||
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { UpdateGeometryOptions } from '../types';
|
||||
|
||||
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {
|
||||
const { invalidateHeight, noDefer = false } = options;
|
||||
if (invalidateHeight) {
|
||||
month.isHeightActual = false;
|
||||
}
|
||||
if (!month.isLoaded) {
|
||||
const viewportWidth = timelineManager.viewportWidth;
|
||||
if (!month.isHeightActual) {
|
||||
const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||
const height = 51 + Math.max(1, rows) * timelineManager.rowHeight;
|
||||
month.height = height;
|
||||
}
|
||||
return;
|
||||
}
|
||||
layoutMonthGroup(timelineManager, month, noDefer);
|
||||
}
|
||||
|
||||
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {
|
||||
let cumulativeHeight = 0;
|
||||
let cumulativeWidth = 0;
|
||||
let currentRowHeight = 0;
|
||||
|
||||
let dayGroupRow = 0;
|
||||
let dayGroupCol = 0;
|
||||
|
||||
const options = timelineManager.justifiedLayoutOptions;
|
||||
for (const dayGroup of month.dayGroups) {
|
||||
dayGroup.layout(options, noDefer);
|
||||
|
||||
// Calculate space needed for this item (including gap if not first in row)
|
||||
const spaceNeeded = dayGroup.width + (dayGroupCol > 0 ? timelineManager.gap : 0);
|
||||
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth;
|
||||
|
||||
if (fitsInCurrentRow) {
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol++;
|
||||
dayGroup.left = cumulativeWidth;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
cumulativeWidth += dayGroup.width + timelineManager.gap;
|
||||
} else {
|
||||
// Move to next row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
cumulativeWidth = 0;
|
||||
dayGroupRow++;
|
||||
dayGroupCol = 0;
|
||||
|
||||
// Position at start of new row
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol;
|
||||
dayGroup.left = 0;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
dayGroupCol++;
|
||||
cumulativeWidth += dayGroup.width + timelineManager.gap;
|
||||
}
|
||||
currentRowHeight = dayGroup.height + timelineManager.headerHeight;
|
||||
}
|
||||
|
||||
// Add the height of the final row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
|
||||
month.height = cumulativeHeight;
|
||||
month.isHeightActual = true;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
|
||||
import { getTimeBucket } from '@immich/sdk';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { TimelineManagerOptions } from '../types';
|
||||
|
||||
export async function loadFromTimeBuckets(
|
||||
timelineManager: TimelineManager,
|
||||
monthGroup: MonthGroup,
|
||||
options: TimelineManagerOptions,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (monthGroup.getFirstAsset()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeBucket = toISOYearMonthUTC(monthGroup.yearMonth);
|
||||
const bucketResponse = await getTimeBucket(
|
||||
{
|
||||
...authManager.params,
|
||||
...options,
|
||||
timeBucket,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
if (!bucketResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.timelineAlbumId) {
|
||||
const albumAssets = await getTimeBucket(
|
||||
{
|
||||
...authManager.params,
|
||||
albumId: options.timelineAlbumId,
|
||||
timeBucket,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
for (const id of albumAssets.id) {
|
||||
timelineManager.albumAssets.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const unprocessedAssets = monthGroup.addAssets(bucketResponse);
|
||||
if (unprocessedAssets.length > 0) {
|
||||
console.error(
|
||||
`Warning: getTimeBucket API returning assets not in requested month: ${monthGroup.yearMonth.month}, ${JSON.stringify(
|
||||
unprocessedAssets.map((unprocessed) => ({
|
||||
id: unprocessed.id,
|
||||
localDateTime: unprocessed.localDateTime,
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
|
||||
import { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { AssetOperation, TimelineAsset } from '../types';
|
||||
import { updateGeometry } from './layout-support.svelte';
|
||||
import { getMonthGroupByDate } from './search-support.svelte';
|
||||
|
||||
export function addAssetsToMonthGroups(
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
options: { order: AssetOrder },
|
||||
) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addContext = new GroupInsertionCache();
|
||||
const updatedMonthGroups = new SvelteSet<MonthGroup>();
|
||||
const monthCount = timelineManager.months.length;
|
||||
for (const asset of assets) {
|
||||
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
|
||||
|
||||
if (!month) {
|
||||
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
|
||||
month.isLoaded = true;
|
||||
timelineManager.months.push(month);
|
||||
}
|
||||
|
||||
month.addTimelineAsset(asset, addContext);
|
||||
updatedMonthGroups.add(month);
|
||||
}
|
||||
|
||||
if (timelineManager.months.length !== monthCount) {
|
||||
timelineManager.months.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDayGroups) {
|
||||
group.sortAssets(options.order);
|
||||
}
|
||||
|
||||
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
|
||||
monthGroup.sortDayGroups();
|
||||
}
|
||||
|
||||
for (const month of addContext.updatedBuckets) {
|
||||
month.sortDayGroups();
|
||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||
}
|
||||
timelineManager.updateIntersections();
|
||||
}
|
||||
|
||||
export function runAssetOperation(
|
||||
timelineManager: TimelineManager,
|
||||
ids: Set<string>,
|
||||
operation: AssetOperation,
|
||||
options: { order: AssetOrder },
|
||||
) {
|
||||
if (ids.size === 0) {
|
||||
return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false };
|
||||
}
|
||||
|
||||
const changedMonthGroups = new SvelteSet<MonthGroup>();
|
||||
let idsToProcess = new SvelteSet(ids);
|
||||
const idsProcessed = new SvelteSet<string>();
|
||||
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
|
||||
for (const month of timelineManager.months) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
}
|
||||
idsToProcess = setDifference(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedMonthGroups.add(month);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
addAssetsToMonthGroups(
|
||||
timelineManager,
|
||||
combinedMoveAssets.flat().map((a) => a.asset),
|
||||
options,
|
||||
);
|
||||
}
|
||||
const changedGeometry = changedMonthGroups.size > 0;
|
||||
for (const month of changedMonthGroups) {
|
||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
timelineManager.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
||||
|
||||
export async function getAssetWithOffset(
|
||||
timelineManager: TimelineManager,
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
direction: Direction,
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
const { asset, monthGroup } = findMonthGroupForAsset(timelineManager, assetDescriptor.id) ?? {};
|
||||
if (!monthGroup || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case 'asset': {
|
||||
return getAssetByAssetOffset(timelineManager, asset, monthGroup, direction);
|
||||
}
|
||||
case 'day': {
|
||||
return getAssetByDayOffset(timelineManager, asset, monthGroup, direction);
|
||||
}
|
||||
case 'month': {
|
||||
return getAssetByMonthOffset(timelineManager, monthGroup, direction);
|
||||
}
|
||||
case 'year': {
|
||||
return getAssetByYearOffset(timelineManager, monthGroup, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findMonthGroupForAsset(timelineManager: TimelineManager, id: string) {
|
||||
for (const month of timelineManager.months) {
|
||||
const asset = month.findAssetById({ id });
|
||||
if (asset) {
|
||||
return { monthGroup: month, asset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getMonthGroupByDate(
|
||||
timelineManager: TimelineManager,
|
||||
targetYearMonth: TimelineYearMonth,
|
||||
): MonthGroup | undefined {
|
||||
return timelineManager.months.find(
|
||||
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAssetByAssetOffset(
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
monthGroup: MonthGroup,
|
||||
direction: Direction,
|
||||
) {
|
||||
const dayGroup = monthGroup.findDayGroupForAsset(asset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonthGroup: monthGroup,
|
||||
startDayGroup: dayGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (asset.id !== targetAsset.id) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByDayOffset(
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
monthGroup: MonthGroup,
|
||||
direction: Direction,
|
||||
) {
|
||||
const dayGroup = monthGroup.findDayGroupForAsset(asset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonthGroup: monthGroup,
|
||||
startDayGroup: dayGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByMonthOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) {
|
||||
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) {
|
||||
if (targetMonth.yearMonth.month !== month.yearMonth.month) {
|
||||
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByYearOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) {
|
||||
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) {
|
||||
if (targetMonth.yearMonth.year !== month.yearMonth.year) {
|
||||
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function retrieveRange(timelineManager: TimelineManager, start: AssetDescriptor, end: AssetDescriptor) {
|
||||
let { asset: startAsset, monthGroup: startMonthGroup } = findMonthGroupForAsset(timelineManager, start.id) ?? {};
|
||||
if (!startMonthGroup || !startAsset) {
|
||||
return [];
|
||||
}
|
||||
let { asset: endAsset, monthGroup: endMonthGroup } = findMonthGroupForAsset(timelineManager, end.id) ?? {};
|
||||
if (!endMonthGroup || !endAsset) {
|
||||
return [];
|
||||
}
|
||||
const assetOrder: AssetOrder = timelineManager.getAssetOrder();
|
||||
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
[startMonthGroup, endMonthGroup] = [endMonthGroup, startMonthGroup];
|
||||
}
|
||||
|
||||
const range: TimelineAsset[] = [];
|
||||
const startDayGroup = startMonthGroup.findDayGroupForAsset(startAsset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonthGroup,
|
||||
startDayGroup,
|
||||
startAsset,
|
||||
})) {
|
||||
range.push(targetAsset);
|
||||
if (targetAsset.id === endAsset.id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
|
||||
for (const month of timelineManager.months) {
|
||||
const { year, month: monthNum } = month.yearMonth;
|
||||
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findClosestGroupForDate(months: MonthGroup[], targetYearMonth: TimelineYearMonth) {
|
||||
const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month });
|
||||
|
||||
let closestMonth: MonthGroup | undefined;
|
||||
let minDifference = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (const month of months) {
|
||||
const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month });
|
||||
const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months);
|
||||
|
||||
if (totalDiff < minDifference) {
|
||||
minDifference = totalDiff;
|
||||
closestMonth = month;
|
||||
}
|
||||
}
|
||||
|
||||
return closestMonth;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
||||
return option === undefined ? false : option !== value;
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
formatGroupTitle,
|
||||
formatMonthGroupTitle,
|
||||
fromTimelinePlainDate,
|
||||
fromTimelinePlainDateTime,
|
||||
fromTimelinePlainYearMonth,
|
||||
getTimes,
|
||||
setDifference,
|
||||
type TimelineDateTime,
|
||||
type TimelineYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { DayGroup } from './day-group.svelte';
|
||||
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
||||
import type { TimelineManager } from './timeline-manager.svelte';
|
||||
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
|
||||
export class MonthGroup {
|
||||
#intersecting: boolean = $state(false);
|
||||
actuallyIntersecting: boolean = $state(false);
|
||||
isLoaded: boolean = $state(false);
|
||||
dayGroups: DayGroup[] = $state([]);
|
||||
readonly timelineManager: TimelineManager;
|
||||
|
||||
#height: number = $state(0);
|
||||
#top: number = $state(0);
|
||||
|
||||
#initialCount: number = 0;
|
||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||
percent: number = $state(0);
|
||||
|
||||
assetsCount: number = $derived(
|
||||
this.isLoaded
|
||||
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
|
||||
: this.#initialCount,
|
||||
);
|
||||
loader: CancellableTask | undefined;
|
||||
isHeightActual: boolean = $state(false);
|
||||
|
||||
readonly monthGroupTitle: string;
|
||||
readonly yearMonth: TimelineYearMonth;
|
||||
|
||||
constructor(
|
||||
store: TimelineManager,
|
||||
yearMonth: TimelineYearMonth,
|
||||
initialCount: number,
|
||||
order: AssetOrder = AssetOrder.Desc,
|
||||
) {
|
||||
this.timelineManager = store;
|
||||
this.#initialCount = initialCount;
|
||||
this.#sortOrder = order;
|
||||
|
||||
this.yearMonth = yearMonth;
|
||||
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
|
||||
this.loader = new CancellableTask(
|
||||
() => {
|
||||
this.isLoaded = true;
|
||||
},
|
||||
() => {
|
||||
this.dayGroups = [];
|
||||
this.isLoaded = false;
|
||||
},
|
||||
this.#handleLoadError,
|
||||
);
|
||||
}
|
||||
|
||||
set intersecting(newValue: boolean) {
|
||||
const old = this.#intersecting;
|
||||
if (old === newValue) {
|
||||
return;
|
||||
}
|
||||
this.#intersecting = newValue;
|
||||
if (newValue) {
|
||||
void this.timelineManager.loadMonthGroup(this.yearMonth);
|
||||
} else {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
get intersecting() {
|
||||
return this.#intersecting;
|
||||
}
|
||||
|
||||
get lastDayGroup() {
|
||||
return this.dayGroups.at(-1);
|
||||
}
|
||||
|
||||
getFirstAsset() {
|
||||
return this.dayGroups[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
getAssets() {
|
||||
// eslint-disable-next-line unicorn/no-array-reduce
|
||||
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []);
|
||||
}
|
||||
|
||||
sortDayGroups() {
|
||||
if (this.#sortOrder === AssetOrder.Asc) {
|
||||
return this.dayGroups.sort((a, b) => a.day - b.day);
|
||||
}
|
||||
|
||||
return this.dayGroups.sort((a, b) => b.day - a.day);
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
if (ids.size === 0) {
|
||||
return {
|
||||
moveAssets: [] as MoveAsset[],
|
||||
processedIds: new SvelteSet<string>(),
|
||||
unprocessedIds: ids,
|
||||
changedGeometry: false,
|
||||
};
|
||||
}
|
||||
const { dayGroups } = this;
|
||||
let combinedChangedGeometry = false;
|
||||
let idsToProcess = new SvelteSet(ids);
|
||||
const idsProcessed = new SvelteSet<string>();
|
||||
const combinedMoveAssets: MoveAsset[][] = [];
|
||||
let index = dayGroups.length;
|
||||
while (index--) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const group = dayGroups[index];
|
||||
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
}
|
||||
idsToProcess = setDifference(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
|
||||
if (group.viewerAssets.length === 0) {
|
||||
dayGroups.splice(index, 1);
|
||||
combinedChangedGeometry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
moveAssets: combinedMoveAssets.flat(),
|
||||
unprocessedIds: idsToProcess,
|
||||
processedIds: idsProcessed,
|
||||
changedGeometry: combinedChangedGeometry,
|
||||
};
|
||||
}
|
||||
|
||||
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
||||
const addContext = new GroupInsertionCache();
|
||||
for (let i = 0; i < bucketAssets.id.length; i++) {
|
||||
const { localDateTime, fileCreatedAt } = getTimes(
|
||||
bucketAssets.fileCreatedAt[i],
|
||||
bucketAssets.localOffsetHours[i],
|
||||
);
|
||||
|
||||
const timelineAsset: TimelineAsset = {
|
||||
city: bucketAssets.city[i],
|
||||
country: bucketAssets.country[i],
|
||||
duration: bucketAssets.duration[i],
|
||||
id: bucketAssets.id[i],
|
||||
visibility: bucketAssets.visibility[i],
|
||||
isFavorite: bucketAssets.isFavorite[i],
|
||||
isImage: bucketAssets.isImage[i],
|
||||
isTrashed: bucketAssets.isTrashed[i],
|
||||
isVideo: !bucketAssets.isImage[i],
|
||||
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||
localDateTime,
|
||||
fileCreatedAt,
|
||||
ownerId: bucketAssets.ownerId[i],
|
||||
projectionType: bucketAssets.projectionType[i],
|
||||
ratio: bucketAssets.ratio[i],
|
||||
stack: bucketAssets.stack?.[i]
|
||||
? {
|
||||
id: bucketAssets.stack[i]![0],
|
||||
primaryAssetId: bucketAssets.id[i],
|
||||
assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
|
||||
}
|
||||
: null,
|
||||
thumbhash: bucketAssets.thumbhash[i],
|
||||
people: null, // People are not included in the bucket assets
|
||||
};
|
||||
|
||||
if (bucketAssets.latitude?.[i] && bucketAssets.longitude?.[i]) {
|
||||
timelineAsset.latitude = bucketAssets.latitude?.[i];
|
||||
timelineAsset.longitude = bucketAssets.longitude?.[i];
|
||||
}
|
||||
this.addTimelineAsset(timelineAsset, addContext);
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDayGroups) {
|
||||
group.sortAssets(this.#sortOrder);
|
||||
}
|
||||
|
||||
if (addContext.newDayGroups.size > 0) {
|
||||
this.sortDayGroups();
|
||||
}
|
||||
|
||||
addContext.sort(this, this.#sortOrder);
|
||||
|
||||
return addContext.unprocessedAssets;
|
||||
}
|
||||
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
|
||||
const { localDateTime } = timelineAsset;
|
||||
|
||||
const { year, month } = this.yearMonth;
|
||||
if (month !== localDateTime.month || year !== localDateTime.year) {
|
||||
addContext.unprocessedAssets.push(timelineAsset);
|
||||
return;
|
||||
}
|
||||
|
||||
let dayGroup = addContext.getDayGroup(localDateTime) || this.findDayGroupByDay(localDateTime.day);
|
||||
if (dayGroup) {
|
||||
addContext.setDayGroup(dayGroup, localDateTime);
|
||||
} else {
|
||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle);
|
||||
this.dayGroups.push(dayGroup);
|
||||
addContext.setDayGroup(dayGroup, localDateTime);
|
||||
addContext.newDayGroups.add(dayGroup);
|
||||
}
|
||||
|
||||
const viewerAsset = new ViewerAsset(dayGroup, timelineAsset);
|
||||
dayGroup.viewerAssets.push(viewerAsset);
|
||||
addContext.changedDayGroups.add(dayGroup);
|
||||
}
|
||||
|
||||
get viewId() {
|
||||
const { year, month } = this.yearMonth;
|
||||
return year + '-' + month;
|
||||
}
|
||||
|
||||
set height(height: number) {
|
||||
if (this.#height === height) {
|
||||
return;
|
||||
}
|
||||
const timelineManager = this.timelineManager;
|
||||
const index = timelineManager.months.indexOf(this);
|
||||
const heightDelta = height - this.#height;
|
||||
this.#height = height;
|
||||
const prevMonthGroup = timelineManager.months[index - 1];
|
||||
if (prevMonthGroup) {
|
||||
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
|
||||
if (this.#top !== newTop) {
|
||||
this.#top = newTop;
|
||||
}
|
||||
}
|
||||
if (heightDelta === 0) {
|
||||
return;
|
||||
}
|
||||
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
|
||||
const monthGroup = this.timelineManager.months[cursor];
|
||||
const newTop = monthGroup.#top + heightDelta;
|
||||
if (monthGroup.#top !== newTop) {
|
||||
monthGroup.#top = newTop;
|
||||
}
|
||||
}
|
||||
if (!timelineManager.viewportTopMonthIntersection) {
|
||||
return;
|
||||
}
|
||||
const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection;
|
||||
const currentIndex = month ? timelineManager.months.indexOf(month) : -1;
|
||||
if (!month || currentIndex <= 0 || index > currentIndex) {
|
||||
return;
|
||||
}
|
||||
if (index < currentIndex || monthBottomViewportRatio < 1) {
|
||||
timelineManager.scrollBy(heightDelta);
|
||||
} else if (index === currentIndex) {
|
||||
const scrollTo = this.top + height * viewportTopRatioInMonth;
|
||||
timelineManager.scrollTo(scrollTo);
|
||||
}
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.#height;
|
||||
}
|
||||
|
||||
get top(): number {
|
||||
return this.#top + this.timelineManager.topSectionHeight;
|
||||
}
|
||||
|
||||
#handleLoadError(error: unknown) {
|
||||
const _$t = get(t);
|
||||
handleError(error, _$t('errors.failed_to_load_assets'));
|
||||
}
|
||||
|
||||
findDayGroupForAsset(asset: TimelineAsset) {
|
||||
for (const group of this.dayGroups) {
|
||||
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findDayGroupByDay(day: number) {
|
||||
return this.dayGroups.find((group) => group.day === day);
|
||||
}
|
||||
|
||||
findAssetAbsolutePosition(assetId: string) {
|
||||
this.timelineManager.clearDeferredLayout(this);
|
||||
for (const group of this.dayGroups) {
|
||||
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
||||
if (viewerAsset) {
|
||||
if (!viewerAsset.position) {
|
||||
console.warn('No position for asset');
|
||||
return;
|
||||
}
|
||||
return {
|
||||
top: this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
|
||||
height: viewerAsset.position.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startAsset } = options ?? {};
|
||||
const isEarlier = direction === 'earlier';
|
||||
let groupIndex = options?.startDayGroup
|
||||
? this.dayGroups.indexOf(options.startDayGroup)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.dayGroups.length - 1;
|
||||
|
||||
while (groupIndex >= 0 && groupIndex < this.dayGroups.length) {
|
||||
const group = this.dayGroups[groupIndex];
|
||||
yield* group.assetsIterator({ startAsset, direction });
|
||||
startAsset = undefined;
|
||||
groupIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
findAssetById(assetDescriptor: AssetDescriptor) {
|
||||
for (const asset of this.assetsIterator()) {
|
||||
if (asset.id === assetDescriptor.id) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findClosest(target: TimelineDateTime) {
|
||||
const targetDate = fromTimelinePlainDateTime(target);
|
||||
let closest = undefined;
|
||||
let smallestDiff = Infinity;
|
||||
for (const current of this.assetsIterator()) {
|
||||
const currentAssetDate = fromTimelinePlainDateTime(current.localDateTime);
|
||||
const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds'));
|
||||
if (diff < smallestDiff) {
|
||||
smallestDiff = diff;
|
||||
closest = current;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.loader?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
||||
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
|
||||
import {
|
||||
addAssetsToMonthGroups,
|
||||
runAssetOperation,
|
||||
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
|
||||
import {
|
||||
findClosestGroupForDate,
|
||||
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
||||
findMonthGroupForDate,
|
||||
getAssetWithOffset,
|
||||
getMonthGroupByDate,
|
||||
retrieveRange as retrieveRangeUtil,
|
||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
||||
import { clamp, isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { DayGroup } from './day-group.svelte';
|
||||
import { isMismatched, updateObject } from './internal/utils.svelte';
|
||||
import { MonthGroup } from './month-group.svelte';
|
||||
import type {
|
||||
AssetDescriptor,
|
||||
AssetOperation,
|
||||
Direction,
|
||||
ScrubberMonth,
|
||||
TimelineAsset,
|
||||
TimelineManagerOptions,
|
||||
Viewport,
|
||||
} from './types';
|
||||
|
||||
type ViewportTopMonthIntersection = {
|
||||
month: MonthGroup | undefined;
|
||||
// Where viewport top intersects month (0 = month top, 1 = month bottom)
|
||||
viewportTopRatioInMonth: number;
|
||||
// Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom)
|
||||
monthBottomViewportRatio: number;
|
||||
};
|
||||
export class TimelineManager extends VirtualScrollManager {
|
||||
override bottomSectionHeight = $state(60);
|
||||
|
||||
override bodySectionHeight = $derived.by(() => {
|
||||
let height = 0;
|
||||
for (const month of this.months) {
|
||||
height += month.height;
|
||||
}
|
||||
return height;
|
||||
});
|
||||
|
||||
assetCount = $derived.by(() => {
|
||||
let count = 0;
|
||||
for (const month of this.months) {
|
||||
count += month.assetsCount;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
isInitialized = $state(false);
|
||||
months: MonthGroup[] = $state([]);
|
||||
albumAssets: Set<string> = new SvelteSet();
|
||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
|
||||
limitedScroll = $derived(this.maxScrollPercent < 0.5);
|
||||
initTask = new CancellableTask(
|
||||
() => {
|
||||
this.isInitialized = true;
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
return;
|
||||
}
|
||||
this.connect();
|
||||
},
|
||||
() => {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
},
|
||||
() => void 0,
|
||||
);
|
||||
|
||||
static #INIT_OPTIONS = {};
|
||||
#websocketSupport: WebsocketSupport | undefined;
|
||||
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
||||
#updatingIntersections = false;
|
||||
#scrollableElement: HTMLElement | undefined = $state();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override get scrollTop(): number {
|
||||
return this.#scrollableElement?.scrollTop ?? 0;
|
||||
}
|
||||
|
||||
set scrollableElement(element: HTMLElement | undefined) {
|
||||
this.#scrollableElement = element;
|
||||
}
|
||||
|
||||
scrollTo(top: number) {
|
||||
this.#scrollableElement?.scrollTo({ top });
|
||||
this.updateSlidingWindow();
|
||||
}
|
||||
|
||||
scrollBy(y: number) {
|
||||
this.#scrollableElement?.scrollBy(0, y);
|
||||
this.updateSlidingWindow();
|
||||
}
|
||||
|
||||
async *assetsIterator(options?: {
|
||||
startMonthGroup?: MonthGroup;
|
||||
startDayGroup?: DayGroup;
|
||||
startAsset?: TimelineAsset;
|
||||
direction?: Direction;
|
||||
}) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startDayGroup, startAsset } = options ?? {};
|
||||
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
|
||||
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false });
|
||||
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
|
||||
startDayGroup = startAsset = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
*monthGroupIterator(options?: { direction?: Direction; startMonthGroup?: MonthGroup }) {
|
||||
const isEarlier = options?.direction === 'earlier';
|
||||
let startIndex = options?.startMonthGroup
|
||||
? this.months.indexOf(options.startMonthGroup)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.months.length - 1;
|
||||
|
||||
while (startIndex >= 0 && startIndex < this.months.length) {
|
||||
yield this.months[startIndex];
|
||||
startIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.#websocketSupport) {
|
||||
throw new Error('TimelineManager already connected');
|
||||
}
|
||||
this.#websocketSupport = new WebsocketSupport(this);
|
||||
this.#websocketSupport.connectWebsocketEvents();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (!this.#websocketSupport) {
|
||||
return;
|
||||
}
|
||||
this.#websocketSupport.disconnectWebsocketEvents();
|
||||
this.#websocketSupport = undefined;
|
||||
}
|
||||
|
||||
#calculateMonthBottomViewportRatio(month: MonthGroup | undefined) {
|
||||
if (!month) {
|
||||
return 0;
|
||||
}
|
||||
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
|
||||
const bottomOfMonth = month.top + month.height;
|
||||
const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top;
|
||||
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
|
||||
}
|
||||
|
||||
#calculateVewportTopRatioInMonth(month: MonthGroup | undefined) {
|
||||
if (!month) {
|
||||
return 0;
|
||||
}
|
||||
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
|
||||
}
|
||||
|
||||
override updateIntersections() {
|
||||
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||
return;
|
||||
}
|
||||
this.#updatingIntersections = true;
|
||||
|
||||
for (const month of this.months) {
|
||||
updateIntersectionMonthGroup(this, month);
|
||||
}
|
||||
|
||||
const month = this.months.find((month) => month.actuallyIntersecting);
|
||||
const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month);
|
||||
const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month);
|
||||
|
||||
this.viewportTopMonthIntersection = {
|
||||
month,
|
||||
monthBottomViewportRatio,
|
||||
viewportTopRatioInMonth,
|
||||
};
|
||||
|
||||
this.#updatingIntersections = false;
|
||||
}
|
||||
|
||||
clearDeferredLayout(month: MonthGroup) {
|
||||
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
|
||||
if (hasDeferred) {
|
||||
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
|
||||
for (const group of month.dayGroups) {
|
||||
group.deferredLayout = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #initializeMonthGroups() {
|
||||
const timebuckets = await getTimeBuckets({
|
||||
...authManager.params,
|
||||
...this.#options,
|
||||
});
|
||||
|
||||
this.months = timebuckets.map((timeBucket) => {
|
||||
const date = new SvelteDate(timeBucket.timeBucket);
|
||||
return new MonthGroup(
|
||||
this,
|
||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
||||
timeBucket.count,
|
||||
this.#options.order,
|
||||
);
|
||||
});
|
||||
this.albumAssets.clear();
|
||||
this.updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
async updateOptions(options: TimelineManagerOptions) {
|
||||
if (options.deferInit) {
|
||||
return;
|
||||
}
|
||||
if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
||||
return;
|
||||
}
|
||||
await this.initTask.reset();
|
||||
await this.#init(options);
|
||||
this.updateViewportGeometry(false);
|
||||
this.#createScrubberMonths();
|
||||
}
|
||||
|
||||
async #init(options: TimelineManagerOptions) {
|
||||
this.isInitialized = false;
|
||||
this.months = [];
|
||||
this.albumAssets.clear();
|
||||
await this.initTask.execute(async () => {
|
||||
this.#options = options;
|
||||
await this.#initializeMonthGroups();
|
||||
}, true);
|
||||
}
|
||||
|
||||
public override destroy() {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
async updateViewport(viewport: Viewport) {
|
||||
if (viewport.height === 0 && viewport.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.initTask.executed) {
|
||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||
}
|
||||
|
||||
const changedWidth = viewport.width !== this.viewportWidth;
|
||||
this.viewportHeight = viewport.height;
|
||||
this.viewportWidth = viewport.width;
|
||||
this.updateViewportGeometry(changedWidth);
|
||||
}
|
||||
|
||||
protected override updateViewportGeometry(changedWidth: boolean) {
|
||||
if (!this.isInitialized || this.hasEmptyViewport) {
|
||||
return;
|
||||
}
|
||||
for (const month of this.months) {
|
||||
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
||||
}
|
||||
this.updateIntersections();
|
||||
if (changedWidth) {
|
||||
this.#createScrubberMonths();
|
||||
}
|
||||
}
|
||||
|
||||
#createScrubberMonths() {
|
||||
this.scrubberMonths = this.months.map((month) => ({
|
||||
assetCount: month.assetsCount,
|
||||
year: month.yearMonth.year,
|
||||
month: month.yearMonth.month,
|
||||
title: month.monthGroupTitle,
|
||||
height: month.height,
|
||||
}));
|
||||
this.scrubberTimelineHeight = this.totalViewerHeight;
|
||||
}
|
||||
|
||||
async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||
let cancelable = true;
|
||||
if (options) {
|
||||
cancelable = options.cancelable;
|
||||
}
|
||||
const monthGroup = getMonthGroupByDate(this, yearMonth);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (monthGroup.loader?.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const executionStatus = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
|
||||
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
||||
}, cancelable);
|
||||
if (executionStatus === 'LOADED') {
|
||||
updateGeometry(this, monthGroup, { invalidateHeight: false });
|
||||
this.updateIntersections();
|
||||
}
|
||||
}
|
||||
|
||||
addAssets(assets: TimelineAsset[]) {
|
||||
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
|
||||
const notUpdated = this.updateAssets(assetsToUpdate);
|
||||
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
async findMonthGroupForAsset(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
|
||||
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
||||
if (monthGroup) {
|
||||
return monthGroup;
|
||||
}
|
||||
|
||||
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = toTimelineAsset(response);
|
||||
if (!asset || this.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
||||
if (monthGroup?.findAssetById({ id })) {
|
||||
return monthGroup;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
||||
await this.loadMonthGroup(yearMonth, options);
|
||||
return getMonthGroupByDate(this, yearMonth);
|
||||
}
|
||||
|
||||
getMonthGroupByAssetId(assetId: string) {
|
||||
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
|
||||
return monthGroupInfo?.monthGroup;
|
||||
}
|
||||
|
||||
// note: the `index` input is expected to be in the range [0, assetCount). This
|
||||
// value can be passed to make the method deterministic, which is mainly useful
|
||||
// for testing.
|
||||
async getRandomAsset(index?: number): Promise<TimelineAsset | undefined> {
|
||||
const randomAssetIndex = index ?? Math.floor(Math.random() * this.assetCount);
|
||||
|
||||
let accumulatedCount = 0;
|
||||
|
||||
let randomMonth: MonthGroup | undefined = undefined;
|
||||
for (const month of this.months) {
|
||||
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
|
||||
randomMonth = month;
|
||||
break;
|
||||
}
|
||||
|
||||
accumulatedCount += month.assetsCount;
|
||||
}
|
||||
if (!randomMonth) {
|
||||
return;
|
||||
}
|
||||
await this.loadMonthGroup(randomMonth.yearMonth, { cancelable: false });
|
||||
|
||||
let randomDay: DayGroup | undefined = undefined;
|
||||
for (const day of randomMonth.dayGroups) {
|
||||
if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) {
|
||||
randomDay = day;
|
||||
break;
|
||||
}
|
||||
|
||||
accumulatedCount += day.viewerAssets.length;
|
||||
}
|
||||
if (!randomDay) {
|
||||
return;
|
||||
}
|
||||
|
||||
return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
|
||||
}
|
||||
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
updateAssets(assets: TimelineAsset[]) {
|
||||
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||
const { unprocessedIds } = runAssetOperation(
|
||||
this,
|
||||
new SvelteSet(lookup.keys()),
|
||||
(asset) => {
|
||||
updateObject(asset, lookup.get(asset.id));
|
||||
return { remove: false };
|
||||
},
|
||||
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||
);
|
||||
const result: TimelineAsset[] = [];
|
||||
for (const id of unprocessedIds.values()) {
|
||||
result.push(lookup.get(id)!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
const { unprocessedIds } = runAssetOperation(
|
||||
this,
|
||||
new SvelteSet(ids),
|
||||
() => {
|
||||
return { remove: true };
|
||||
},
|
||||
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||
);
|
||||
return [...unprocessedIds];
|
||||
}
|
||||
|
||||
override refreshLayout() {
|
||||
for (const month of this.months) {
|
||||
updateGeometry(this, month, { invalidateHeight: true });
|
||||
}
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
getFirstAsset(): TimelineAsset | undefined {
|
||||
return this.months[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
async getLaterAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
|
||||
}
|
||||
|
||||
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
||||
let monthGroup = findMonthGroupForDate(this, dateTime);
|
||||
if (!monthGroup) {
|
||||
// if exact match not found, find closest
|
||||
monthGroup = findClosestGroupForDate(this.months, dateTime);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.loadMonthGroup(dateTime, { cancelable: false });
|
||||
const asset = monthGroup.findClosest(dateTime);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
for await (const asset of this.assetsIterator({ startMonthGroup: monthGroup })) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
||||
return retrieveRangeUtil(this, start, end);
|
||||
}
|
||||
|
||||
isExcluded(asset: TimelineAsset) {
|
||||
return (
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||
);
|
||||
}
|
||||
|
||||
getAssetOrder() {
|
||||
return this.#options.order ?? AssetOrder.Desc;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TimelineDate, TimelineDateTime, TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import type { TimelineDateTime, TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk';
|
||||
|
||||
export type ViewportTopMonth = TimelineYearMonth | undefined | 'lead-in' | 'lead-out';
|
||||
@@ -37,9 +37,7 @@ export type TimelineAsset = {
|
||||
longitude?: number | null;
|
||||
};
|
||||
|
||||
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
||||
|
||||
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
|
||||
export type AssetOperation = (asset: TimelineAsset) => unknown;
|
||||
|
||||
export interface Viewport {
|
||||
width: number;
|
||||
|
||||
@@ -2,3 +2,58 @@ import type { TimelineAsset } from './types';
|
||||
|
||||
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
|
||||
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
||||
return option === undefined ? false : option !== value;
|
||||
}
|
||||
|
||||
export function setDifference<T>(setA: Set<T>, setB: Set<T>): Set<T> {
|
||||
// Check if native Set.prototype.difference is available (ES2025)
|
||||
const setWithDifference = setA as unknown as Set<T> & { difference?: (other: Set<T>) => Set<T> };
|
||||
if (setWithDifference.difference && typeof setWithDifference.difference === 'function') {
|
||||
return setWithDifference.difference(setB);
|
||||
}
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const result = new Set<T>();
|
||||
for (const value of setA) {
|
||||
if (!setB.has(value)) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all elements of setB from setA in-place (mutates setA).
|
||||
*/
|
||||
export function setDifferenceInPlace<T>(setA: Set<T>, setB: Set<T>): Set<T> {
|
||||
for (const value of setB) {
|
||||
setA.delete(value);
|
||||
}
|
||||
return setA;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
|
||||
import type { DayGroup } from './day-group.svelte';
|
||||
import { calculateViewerAssetIntersecting } from './internal/intersection-support.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
export class ViewerAsset {
|
||||
readonly #group: DayGroup;
|
||||
readonly #day: TimelineDay;
|
||||
|
||||
intersecting = $derived.by(() => {
|
||||
if (!this.position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = this.#group.monthGroup.timelineManager;
|
||||
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
|
||||
const scrollManager = this.#day.month.scrollManager;
|
||||
const positionTop = this.#day.topAbsolute + this.position.top;
|
||||
|
||||
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
||||
return calculateViewerAssetIntersecting(scrollManager, positionTop, this.position.height);
|
||||
});
|
||||
|
||||
position: CommonPosition | undefined = $state.raw();
|
||||
asset: TimelineAsset = <TimelineAsset>$state();
|
||||
id: string = $derived(this.asset.id);
|
||||
|
||||
constructor(group: DayGroup, asset: TimelineAsset) {
|
||||
this.#group = group;
|
||||
constructor(day: TimelineDay, asset: TimelineAsset) {
|
||||
this.#day = day;
|
||||
this.asset = asset;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate intersection for viewer assets with additional parameters like header height
|
||||
*/
|
||||
function calculateViewerAssetIntersecting(
|
||||
scrollManager: VirtualScrollManager,
|
||||
positionTop: number,
|
||||
positionHeight: number,
|
||||
expandTop: number = INTERSECTION_EXPAND_TOP,
|
||||
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
|
||||
) {
|
||||
const topWindow = scrollManager.visibleWindow.top - scrollManager.headerHeight - expandTop;
|
||||
const bottomWindow = scrollManager.visibleWindow.bottom + scrollManager.headerHeight + expandBottom;
|
||||
const positionBottom = positionTop + positionHeight;
|
||||
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
// Get the local date/time components from the selected string using neutral timezone
|
||||
const dateTime = toDatetime(selectedDate, selectedOption) as DateTime<true>;
|
||||
const asset = await timelineManager.getClosestAssetToDate(dateTime.toObject());
|
||||
const asset = await timelineManager.search.getClosestAssetToDate(dateTime.toObject());
|
||||
onClose(asset);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ToastAction from '$lib/components/ToastAction.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { StackResponse } from '$lib/utils/asset-utils';
|
||||
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk';
|
||||
@@ -109,5 +109,5 @@ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager,
|
||||
},
|
||||
);
|
||||
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.upsertAssets(assets);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import ToastAction from '$lib/components/ToastAction.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
@@ -14,6 +14,7 @@ import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { asQueryString } from '$lib/utils/shared-links';
|
||||
import { getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
addAssetsToAlbum as addAssets,
|
||||
addAssetsToAlbums as addToAlbums,
|
||||
@@ -490,8 +491,8 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
|
||||
isSelectingAllAssets.set(true);
|
||||
|
||||
try {
|
||||
for (const monthGroup of timelineManager.months) {
|
||||
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
||||
for (const monthGroup of timelineManager.segments) {
|
||||
await timelineManager.loadSegment(getSegmentIdentifier(monthGroup.yearMonth));
|
||||
|
||||
if (!get(isSelectingAllAssets)) {
|
||||
assetInteraction.clearMultiselect();
|
||||
@@ -499,8 +500,8 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
|
||||
}
|
||||
assetInteraction.selectAssets(assetsSnapshot([...monthGroup.assetsIterator()]));
|
||||
|
||||
for (const dateGroup of monthGroup.dayGroups) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle);
|
||||
for (const dateGroup of monthGroup.days) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dateGroup.dayTitle);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
export enum TaskStatus {
|
||||
DONE,
|
||||
WAITED,
|
||||
CANCELED,
|
||||
LOADED,
|
||||
ERRORED,
|
||||
}
|
||||
export class CancellableTask {
|
||||
cancelToken: AbortController | null = null;
|
||||
cancellable: boolean = true;
|
||||
@@ -32,18 +39,18 @@ export class CancellableTask {
|
||||
|
||||
async waitUntilCompletion() {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
return TaskStatus.DONE;
|
||||
}
|
||||
// if there is a cancel token, task is currently executing, so wait on the promise. If it
|
||||
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
|
||||
// in either case, we wait on the promise.
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
return TaskStatus.WAITED;
|
||||
}
|
||||
|
||||
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
return TaskStatus.DONE;
|
||||
}
|
||||
|
||||
// if promise is pending, wait on previous request instead.
|
||||
@@ -54,7 +61,7 @@ export class CancellableTask {
|
||||
this.cancellable = cancellable;
|
||||
}
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
return TaskStatus.WAITED;
|
||||
}
|
||||
this.cancellable = cancellable;
|
||||
const cancelToken = (this.cancelToken = new AbortController());
|
||||
@@ -62,18 +69,18 @@ export class CancellableTask {
|
||||
try {
|
||||
await f(cancelToken.signal);
|
||||
if (cancelToken.signal.aborted) {
|
||||
return 'CANCELED';
|
||||
return TaskStatus.CANCELED;
|
||||
}
|
||||
this.#transitionToExecuted();
|
||||
return 'LOADED';
|
||||
return TaskStatus.LOADED;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((error as any).name === 'AbortError') {
|
||||
// abort error is not treated as an error, but as a cancellation.
|
||||
return 'CANCELED';
|
||||
return TaskStatus.CANCELED;
|
||||
}
|
||||
this.#transitionToErrored(error);
|
||||
return 'ERRORED';
|
||||
return TaskStatus.ERRORED;
|
||||
} finally {
|
||||
this.cancelToken = null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import type { ScrollSegment, SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { DateTime, type LocaleOptions } from 'luxon';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
// Move type definitions to the top
|
||||
@@ -222,12 +223,47 @@ export const plainDateTimeCompare = (ascending: boolean, a: TimelineDateTime, b:
|
||||
return aDateTime.millisecond - bDateTime.millisecond;
|
||||
};
|
||||
|
||||
export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
|
||||
const result = new SvelteSet<T>();
|
||||
for (const value of setA) {
|
||||
if (!setB.has(value)) {
|
||||
result.add(value);
|
||||
export const formatGroupTitleFull = (_date: DateTime): string => {
|
||||
if (!_date.isValid) {
|
||||
return _date.toString();
|
||||
}
|
||||
const date = _date as DateTime<true>;
|
||||
return getDateLocaleString(date);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a segment identifier for a given year/month or date/time.
|
||||
* This is used to uniquely identify and match timeline segments (MonthGroups).
|
||||
*/
|
||||
export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime): SegmentIdentifier => ({
|
||||
get id() {
|
||||
return yearMonth.year + '-' + yearMonth.month;
|
||||
},
|
||||
matches: (segment: ScrollSegment) => {
|
||||
const monthSegment = segment as TimelineMonth;
|
||||
return (
|
||||
monthSegment.yearMonth &&
|
||||
monthSegment.yearMonth.year === yearMonth.year &&
|
||||
monthSegment.yearMonth.month === yearMonth.month
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const findClosestMonthToDate = (months: TimelineMonth[], targetYearMonth: TimelineYearMonth) => {
|
||||
const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month });
|
||||
|
||||
let closestMonth: TimelineMonth | undefined;
|
||||
let minDifference = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (const month of months) {
|
||||
const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month });
|
||||
const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months);
|
||||
|
||||
if (totalDiff < minDifference) {
|
||||
minDifference = totalDiff;
|
||||
closestMonth = month;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return closestMonth;
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
@@ -140,8 +140,8 @@
|
||||
const handleStartSlideshow = async () => {
|
||||
const asset =
|
||||
$slideshowNavigation === SlideshowNavigation.Shuffle
|
||||
? await timelineManager.getRandomAsset()
|
||||
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
|
||||
? await timelineManager.search.getRandomAsset()
|
||||
: timelineManager.getFirstAsset();
|
||||
if (asset) {
|
||||
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
||||
}
|
||||
@@ -266,7 +266,7 @@
|
||||
};
|
||||
|
||||
const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => {
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.upsertAssets(assets);
|
||||
await refreshAlbum();
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { AssetAction } from '$lib/constants';
|
||||
|
||||
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
@@ -94,7 +94,7 @@
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { AssetVisibility, lockAuthSession } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
|
||||
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
||||
@@ -339,7 +339,7 @@
|
||||
};
|
||||
|
||||
const handleUndoDeleteAssets = async (assets: TimelineAsset[]) => {
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.upsertAssets(assets);
|
||||
await updateAssetCount();
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
@@ -69,12 +69,12 @@
|
||||
|
||||
const handleLink: OnLink = ({ still, motion }) => {
|
||||
timelineManager.removeAssets([motion.id]);
|
||||
timelineManager.updateAssets([still]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleUnlink: OnUnlink = ({ still, motion }) => {
|
||||
timelineManager.addAssets([motion]);
|
||||
timelineManager.updateAssets([still]);
|
||||
timelineManager.upsertAssets([motion]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
@@ -153,7 +153,7 @@
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<hr />
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
|
||||
import SkipLink from '$lib/elements/SkipLink.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import TagCreateModal from '$lib/modals/TagCreateModal.svelte';
|
||||
import TagEditModal from '$lib/modals/TagEditModal.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
@@ -63,7 +63,7 @@
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.updateAssets(updatedAssets);
|
||||
timelineManager.upsertAssets(updatedAssets);
|
||||
|
||||
handleDeselectAll();
|
||||
};
|
||||
@@ -113,7 +113,7 @@
|
||||
const handleThumbnailClick = (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
day: TimelineDay,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
@@ -129,7 +129,7 @@
|
||||
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
||||
void setQueryValue('at', asset.id);
|
||||
} else {
|
||||
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
onClick(timelineManager, day.getAssets(), day.dayTitle, asset);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user