refactor(web): extract VirtualScrollManager base class
Extract a reusable VirtualScrollManager base class from TimelineManager
to enable code reuse for virtual scrolling behavior.
Changes:
- Add VirtualScrollManager base class with common scrolling logic
- Add ScrollSegment base class for month/segment abstraction
- Migrate TimelineMonth to extend ScrollSegment
- Migrate TimelineManager to extend VirtualScrollManager
- Move intersection/layout/load logic from internal/ to base classes
- Update components to work with new architecture
- Delete timeline-manager/internal/{intersection,layout,load}-support.ts
No functional changes - pure refactoring.
pull/23309/head
parent
928b69f415
commit
f1da027a8c
|
|
@ -2,14 +2,14 @@
|
||||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||||
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
|
||||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||||
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
||||||
|
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { isAssetViewerRoute } from '$lib/utils/navigation';
|
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 { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
let timelineScrollPercent: number = $state(0);
|
let timelineScrollPercent: number = $state(0);
|
||||||
let scrubberWidth = $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 maxMd = $derived(mobileDevice.maxMd);
|
||||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||||
|
|
||||||
|
|
@ -141,7 +141,7 @@
|
||||||
|
|
||||||
// Need to update window positions/intersections because <Portal> may have
|
// Need to update window positions/intersections because <Portal> may have
|
||||||
// gone from invisible to visible.
|
// gone from invisible to visible.
|
||||||
timelineManager.updateSlidingWindow();
|
timelineManager.updateVisibleWindow();
|
||||||
|
|
||||||
const assetTop = position.top;
|
const assetTop = position.top;
|
||||||
const assetBottom = position.top + position.height;
|
const assetBottom = position.top + position.height;
|
||||||
|
|
@ -296,7 +296,7 @@
|
||||||
scrubberMonthScrollPercent,
|
scrubberMonthScrollPercent,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const month = timelineManager.months.find(
|
const month = timelineManager.segments.find(
|
||||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||||
);
|
);
|
||||||
if (!month) {
|
if (!month) {
|
||||||
|
|
@ -325,7 +325,7 @@
|
||||||
let top = scrollableElement.scrollTop;
|
let top = scrollableElement.scrollTop;
|
||||||
let maxScrollPercent = timelineManager.maxScrollPercent;
|
let maxScrollPercent = timelineManager.maxScrollPercent;
|
||||||
|
|
||||||
const monthsLength = timelineManager.months.length;
|
const monthsLength = timelineManager.segments.length;
|
||||||
for (let i = -1; i < monthsLength + 1; i++) {
|
for (let i = -1; i < monthsLength + 1; i++) {
|
||||||
let month: ViewportTopMonth;
|
let month: ViewportTopMonth;
|
||||||
let monthHeight = 0;
|
let monthHeight = 0;
|
||||||
|
|
@ -338,8 +338,8 @@
|
||||||
month = 'lead-out';
|
month = 'lead-out';
|
||||||
monthHeight = timelineManager.bottomSectionHeight;
|
monthHeight = timelineManager.bottomSectionHeight;
|
||||||
} else {
|
} else {
|
||||||
month = timelineManager.months[i].yearMonth;
|
month = timelineManager.segments[i].yearMonth;
|
||||||
monthHeight = timelineManager.months[i].height;
|
monthHeight = timelineManager.segments[i].height;
|
||||||
}
|
}
|
||||||
|
|
||||||
let next = top - monthHeight * maxScrollPercent;
|
let next = top - monthHeight * maxScrollPercent;
|
||||||
|
|
@ -352,7 +352,7 @@
|
||||||
|
|
||||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||||
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||||
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
|
viewportTopMonth = timelineManager.segments[i + 1].yearMonth;
|
||||||
viewportTopMonthScrollPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -442,21 +442,23 @@
|
||||||
assetInteraction.clearAssetSelectionCandidates();
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
|
||||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||||
let startBucket = timelineManager.getMonthByAssetId(assetInteraction.assetSelectionStart.id);
|
let startBucket = timelineManager.getSegmentForAssetId(assetInteraction.assetSelectionStart.id) as
|
||||||
let endBucket = timelineManager.getMonthByAssetId(asset.id);
|
| TimelineMonth
|
||||||
|
| undefined;
|
||||||
|
let endBucket = timelineManager.getSegmentForAssetId(asset.id) as TimelineMonth | undefined;
|
||||||
|
|
||||||
if (startBucket === null || endBucket === null) {
|
if (!startBucket || !endBucket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select/deselect assets in range (start,end)
|
// Select/deselect assets in range (start,end)
|
||||||
let started = false;
|
let started = false;
|
||||||
for (const month of timelineManager.months) {
|
for (const month of timelineManager.segments) {
|
||||||
if (month === endBucket) {
|
if (month === endBucket) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (started) {
|
if (started) {
|
||||||
await timelineManager.loadMonth(month.yearMonth);
|
await timelineManager.loadSegment(getSegmentIdentifier(month.yearMonth));
|
||||||
for (const asset of month.assetsIterator()) {
|
for (const asset of month.assetsIterator()) {
|
||||||
if (deselect) {
|
if (deselect) {
|
||||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||||
|
|
@ -472,7 +474,7 @@
|
||||||
|
|
||||||
// Update date group selection in range [start,end]
|
// Update date group selection in range [start,end]
|
||||||
started = false;
|
started = false;
|
||||||
for (const month of timelineManager.months) {
|
for (const month of timelineManager.segments) {
|
||||||
if (month === startBucket) {
|
if (month === startBucket) {
|
||||||
started = true;
|
started = true;
|
||||||
}
|
}
|
||||||
|
|
@ -531,7 +533,7 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($showAssetViewer) {
|
if ($showAssetViewer) {
|
||||||
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
||||||
void timelineManager.loadMonth({ year: localDateTime.year, month: localDateTime.month });
|
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -562,7 +564,7 @@
|
||||||
{onEscape}
|
{onEscape}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if timelineManager.months.length > 0}
|
{#if timelineManager.segments.length > 0}
|
||||||
<Scrubber
|
<Scrubber
|
||||||
{timelineManager}
|
{timelineManager}
|
||||||
height={timelineManager.viewportHeight}
|
height={timelineManager.viewportHeight}
|
||||||
|
|
@ -600,7 +602,7 @@
|
||||||
bind:clientHeight={timelineManager.viewportHeight}
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
bind:clientWidth={timelineManager.viewportWidth}
|
bind:clientWidth={timelineManager.viewportWidth}
|
||||||
bind:this={scrollableElement}
|
bind:this={scrollableElement}
|
||||||
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
|
onscroll={() => (handleTimelineScroll(), timelineManager.updateVisibleWindow(), updateIsScrolling())}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
bind:this={timelineElement}
|
bind:this={timelineElement}
|
||||||
|
|
@ -622,11 +624,11 @@
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#each timelineManager.months as month (month.viewId)}
|
{#each timelineManager.segments as month (month.identifier.id)}
|
||||||
{@const display = month.intersecting}
|
{@const display = month.intersecting}
|
||||||
{@const absoluteHeight = month.top}
|
{@const absoluteHeight = month.top}
|
||||||
|
|
||||||
{#if !month.isLoaded}
|
{#if !month.loaded}
|
||||||
<div
|
<div
|
||||||
style:height={month.height + 'px'}
|
style:height={month.height + 'px'}
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
let isMouseOverGroup = $state(false);
|
let isMouseOverGroup = $state(false);
|
||||||
let hoveredDay = $state();
|
let hoveredDay = $state();
|
||||||
|
|
||||||
const transitionDuration = $derived.by(() => (month.timelineManager.suspendTransitions && !$isUploading ? 0 : 150));
|
const transitionDuration = $derived.by(() => (month.scrollManager.suspendTransitions && !$isUploading ? 0 : 150));
|
||||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
const _onClick = (
|
const _onClick = (
|
||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
|
|
@ -135,8 +135,8 @@
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<section
|
<section
|
||||||
class={[
|
class={[
|
||||||
{ 'transition-all': !month.timelineManager.suspendTransitions },
|
{ 'transition-all': !month.scrollManager.suspendTransitions },
|
||||||
!month.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
|
!month.scrollManager.suspendTransitions && `delay-${transitionDuration}`,
|
||||||
]}
|
]}
|
||||||
data-group
|
data-group
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
|
|
@ -178,49 +178,56 @@
|
||||||
|
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
<div data-image-grid class="relative overflow-clip" style:height={day.height + 'px'} style:width={day.width + 'px'}>
|
<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)}
|
<div
|
||||||
{@const position = viewerAsset.position!}
|
data-image-grid
|
||||||
{@const asset = viewerAsset.asset!}
|
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!}
|
||||||
|
|
||||||
<!-- {#if viewerAsset.intersecting} -->
|
<!-- {#if viewerAsset.intersecting} -->
|
||||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||||
<div
|
<div
|
||||||
data-asset-id={asset.id}
|
data-asset-id={asset.id}
|
||||||
class="absolute"
|
class="absolute"
|
||||||
style:top={position.top + 'px'}
|
style:top={position.top + 'px'}
|
||||||
style:left={position.left + 'px'}
|
style:left={position.left + 'px'}
|
||||||
style:width={position.width + 'px'}
|
style:width={position.width + 'px'}
|
||||||
style:height={position.height + 'px'}
|
style:height={position.height + 'px'}
|
||||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||||
animate:flip={{ duration: transitionDuration }}
|
animate:flip={{ duration: transitionDuration }}
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
showStackedIcon={withStacked}
|
showStackedIcon={withStacked}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{asset}
|
{asset}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
onClick={(asset) => {
|
onClick={(asset) => {
|
||||||
if (typeof onThumbnailClick === 'function') {
|
if (typeof onThumbnailClick === 'function') {
|
||||||
onThumbnailClick(asset, timelineManager, day, _onClick);
|
onThumbnailClick(asset, timelineManager, day, _onClick);
|
||||||
} else {
|
} else {
|
||||||
_onClick(timelineManager, day.getAssets(), day.dayTitle, asset);
|
_onClick(timelineManager, day.getAssets(), day.dayTitle, asset);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, day.getAssets(), day.dayTitle)}
|
onSelect={(asset) => assetSelectHandler(timelineManager, asset, day.getAssets(), day.dayTitle)}
|
||||||
onMouseEvent={() => assetMouseEventHandler(day.dayTitle, assetSnapshot(asset))}
|
onMouseEvent={() => assetMouseEventHandler(day.dayTitle, assetSnapshot(asset))}
|
||||||
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||||
day.month.timelineManager.albumAssets.has(asset.id)}
|
day.month.scrollManager.albumAssets.has(asset.id)}
|
||||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
disabled={day.month.timelineManager.albumAssets.has(asset.id)}
|
disabled={day.month.scrollManager.albumAssets.has(asset.id)}
|
||||||
thumbnailWidth={position.width}
|
thumbnailWidth={position.width}
|
||||||
thumbnailHeight={position.height}
|
thumbnailHeight={position.height}
|
||||||
/>
|
/>
|
||||||
{#if customLayout}
|
{#if customLayout}
|
||||||
{@render customLayout(asset)}
|
{@render customLayout(asset)}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- {/if} -->
|
<!-- {/if} -->
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
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));
|
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||||
let isShortcutModalOpen = false;
|
let isShortcutModalOpen = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1,212 @@
|
||||||
export class ScrollSegment {}
|
import type { TimelineAsset, UpdateGeometryOptions } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||||
|
import type {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,58 @@
|
||||||
import { debounce } from 'lodash-es';
|
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
|
import type { ScrollSegment } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||||
|
|
||||||
|
import { clamp, debounce } from 'lodash-es';
|
||||||
|
|
||||||
type LayoutOptions = {
|
type LayoutOptions = {
|
||||||
headerHeight: number;
|
headerHeight: number;
|
||||||
rowHeight: number;
|
rowHeight: number;
|
||||||
gap: number;
|
gap: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VisibleWindow = {
|
||||||
|
top: number;
|
||||||
|
bottom: 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 {
|
export abstract class VirtualScrollManager {
|
||||||
topSectionHeight = $state(0);
|
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);
|
bottomSectionHeight = $state(0);
|
||||||
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
|
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
|
||||||
|
isInitialized = $state(false);
|
||||||
visibleWindow = $derived.by(() => ({
|
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,
|
top: this.#scrollTop,
|
||||||
bottom: this.#scrollTop + this.viewportHeight,
|
bottom: this.#scrollTop + this.viewportHeight,
|
||||||
}));
|
}));
|
||||||
|
viewportTopSegmentIntersection: ViewportTopSegmentIntersection | undefined;
|
||||||
|
|
||||||
#viewportHeight = $state(0);
|
#viewportHeight = $state(0);
|
||||||
#viewportWidth = $state(0);
|
#viewportWidth = $state(0);
|
||||||
|
|
@ -24,14 +62,15 @@ export abstract class VirtualScrollManager {
|
||||||
#gap = $state(12);
|
#gap = $state(12);
|
||||||
#scrolling = $state(false);
|
#scrolling = $state(false);
|
||||||
#suspendTransitions = $state(false);
|
#suspendTransitions = $state(false);
|
||||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
#resumeTransitionsAfterDelay = debounce(() => (this.suspendTransitions = false), 1000);
|
||||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
#resumeScrollingStatusAfterDelay = debounce(() => (this.#scrolling = false), 1000);
|
||||||
#justifiedLayoutOptions = $derived({
|
#justifiedLayoutOptions = $derived({
|
||||||
spacing: 2,
|
spacing: 2,
|
||||||
heightTolerance: 0.5,
|
heightTolerance: 0.5,
|
||||||
rowHeight: this.#rowHeight,
|
rowHeight: this.#rowHeight,
|
||||||
rowWidth: Math.floor(this.viewportWidth),
|
rowWidth: Math.floor(this.viewportWidth),
|
||||||
});
|
});
|
||||||
|
#updatingIntersections = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.setLayoutOptions();
|
this.setLayoutOptions();
|
||||||
|
|
@ -45,6 +84,8 @@ export abstract class VirtualScrollManager {
|
||||||
return this.#justifiedLayoutOptions;
|
return this.#justifiedLayoutOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract get segments(): ScrollSegment[];
|
||||||
|
|
||||||
get maxScrollPercent() {
|
get maxScrollPercent() {
|
||||||
const totalHeight = this.totalViewerHeight;
|
const totalHeight = this.totalViewerHeight;
|
||||||
return (totalHeight - this.viewportHeight) / totalHeight;
|
return (totalHeight - this.viewportHeight) / totalHeight;
|
||||||
|
|
@ -94,7 +135,7 @@ export abstract class VirtualScrollManager {
|
||||||
this.#scrolling = value;
|
this.#scrolling = value;
|
||||||
if (value) {
|
if (value) {
|
||||||
this.suspendTransitions = true;
|
this.suspendTransitions = true;
|
||||||
this.#resetScrolling();
|
this.#resumeScrollingStatusAfterDelay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,7 +146,7 @@ export abstract class VirtualScrollManager {
|
||||||
set suspendTransitions(value: boolean) {
|
set suspendTransitions(value: boolean) {
|
||||||
this.#suspendTransitions = value;
|
this.#suspendTransitions = value;
|
||||||
if (value) {
|
if (value) {
|
||||||
this.#resetSuspendTransitions();
|
this.#resumeTransitionsAfterDelay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,10 +155,11 @@ export abstract class VirtualScrollManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
set viewportWidth(value: number) {
|
set viewportWidth(value: number) {
|
||||||
const changed = value !== this.#viewportWidth;
|
const oldViewport = this.viewportSnapshot;
|
||||||
this.#viewportWidth = value;
|
this.#viewportWidth = value;
|
||||||
this.suspendTransitions = true;
|
this.suspendTransitions = true;
|
||||||
void this.updateViewportGeometry(changed);
|
const newViewport = this.viewportSnapshot;
|
||||||
|
void this.onUpdateViewport(oldViewport, newViewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewportWidth() {
|
get viewportWidth() {
|
||||||
|
|
@ -125,22 +167,78 @@ export abstract class VirtualScrollManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
set viewportHeight(value: number) {
|
set viewportHeight(value: number) {
|
||||||
|
const oldViewport = this.viewportSnapshot;
|
||||||
this.#viewportHeight = value;
|
this.#viewportHeight = value;
|
||||||
this.#suspendTransitions = true;
|
this.#suspendTransitions = true;
|
||||||
void this.updateViewportGeometry(false);
|
const newViewport = this.viewportSnapshot;
|
||||||
|
void this.onUpdateViewport(oldViewport, newViewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewportHeight() {
|
get viewportHeight() {
|
||||||
return this.#viewportHeight;
|
return this.#viewportHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasEmptyViewport() {
|
get viewportSnapshot(): Viewport {
|
||||||
return this.viewportWidth === 0 || this.viewportHeight === 0;
|
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> = {}) {
|
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: Partial<LayoutOptions> = {}) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
@ -152,7 +250,7 @@ export abstract class VirtualScrollManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSlidingWindow() {
|
updateVisibleWindow() {
|
||||||
const scrollTop = this.scrollTop;
|
const scrollTop = this.scrollTop;
|
||||||
if (this.#scrollTop !== scrollTop) {
|
if (this.#scrollTop !== scrollTop) {
|
||||||
this.#scrollTop = scrollTop;
|
this.#scrollTop = scrollTop;
|
||||||
|
|
@ -160,9 +258,25 @@ export abstract class VirtualScrollManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshLayout() {
|
protected refreshLayout({ invalidateHeight = true }: { invalidateHeight?: boolean } = {}) {
|
||||||
|
for (const segment of this.segments) {
|
||||||
|
segment.updateGeometry({ invalidateHeight });
|
||||||
|
}
|
||||||
this.updateIntersections();
|
this.updateIntersections();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {}
|
destroy() {
|
||||||
|
this.isInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSegmentForAssetId(assetId: string) {
|
||||||
|
for (const segment of this.segments) {
|
||||||
|
const asset = segment.assets.find((asset) => asset.id === assetId);
|
||||||
|
if (asset) {
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isEmptyViewport = (viewport: Viewport) => viewport.width === 0 || viewport.height === 0;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export class TimelineDay {
|
||||||
readonly month: TimelineMonth;
|
readonly month: TimelineMonth;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
readonly dayTitle: string;
|
readonly dayTitle: string;
|
||||||
|
readonly dayTitleFull: string;
|
||||||
|
|
||||||
readonly day: number;
|
readonly day: number;
|
||||||
viewerAssets: ViewerAsset[] = $state([]);
|
viewerAssets: ViewerAsset[] = $state([]);
|
||||||
|
|
||||||
|
|
@ -24,11 +26,13 @@ export class TimelineDay {
|
||||||
#col = $state(0);
|
#col = $state(0);
|
||||||
#deferredLayout = false;
|
#deferredLayout = false;
|
||||||
|
|
||||||
constructor(month: TimelineMonth, index: number, day: number, dayTitle: string) {
|
constructor(month: TimelineMonth, index: number, day: number, groupTitle: string, groupTitleFull: string) {
|
||||||
this.index = index;
|
this.index = index;
|
||||||
this.month = month;
|
this.month = month;
|
||||||
this.day = day;
|
this.day = day;
|
||||||
this.dayTitle = dayTitle;
|
this.dayTitle = groupTitle;
|
||||||
|
this.dayTitleFull = groupTitleFull;
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
onCreateDay(this);
|
onCreateDay(this);
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +146,7 @@ export class TimelineDay {
|
||||||
}
|
}
|
||||||
unprocessedIds.delete(assetId);
|
unprocessedIds.delete(assetId);
|
||||||
processedIds.add(assetId);
|
processedIds.add(assetId);
|
||||||
if (remove || this.month.timelineManager.isExcluded(asset)) {
|
if (remove || this.month.scrollManager.isExcluded(asset)) {
|
||||||
this.viewerAssets.splice(index, 1);
|
this.viewerAssets.splice(index, 1);
|
||||||
changedGeometry = true;
|
changedGeometry = true;
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +169,7 @@ export class TimelineDay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get absoluteTop() {
|
get topAbsolute() {
|
||||||
return this.month.top + this.#top;
|
return this.month.top + this.#top;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.
|
||||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { AbortError } from '$lib/utils';
|
import { AbortError } from '$lib/utils';
|
||||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||||
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
@ -78,7 +78,7 @@ describe('TimelineManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates month height', () => {
|
it('calculates month height', () => {
|
||||||
const plainMonths = timelineManager.months.map((month) => ({
|
const plainMonths = timelineManager.segments.map((month) => ({
|
||||||
year: month.yearMonth.year,
|
year: month.yearMonth.year,
|
||||||
month: month.yearMonth.month,
|
month: month.yearMonth.month,
|
||||||
height: month.height,
|
height: month.height,
|
||||||
|
|
@ -98,7 +98,7 @@ describe('TimelineManager', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadMonth', () => {
|
describe('loadSegment', () => {
|
||||||
let timelineManager: TimelineManager;
|
let timelineManager: TimelineManager;
|
||||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||||
|
|
@ -134,48 +134,48 @@ describe('TimelineManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads a month', async () => {
|
it('loads a month', async () => {
|
||||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid months', async () => {
|
it('ignores invalid months', async () => {
|
||||||
await timelineManager.loadMonth({ year: 2023, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 }));
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels month loading', async () => {
|
it('cancels month loading', async () => {
|
||||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
|
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||||
void timelineManager.loadMonth({ year: 2024, month: 1 });
|
void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||||
month?.cancel();
|
month?.cancel();
|
||||||
expect(abortSpy).toBeCalledTimes(1);
|
expect(abortSpy).toBeCalledTimes(1);
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents loading months multiple times', async () => {
|
it('prevents loading months multiple times', async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
timelineManager.loadMonth({ year: 2024, month: 1 }),
|
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
|
||||||
timelineManager.loadMonth({ year: 2024, month: 1 }),
|
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
|
||||||
]);
|
]);
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
|
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows loading a canceled month', async () => {
|
it('allows loading a canceled month', async () => {
|
||||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
|
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||||
const loadPromise = timelineManager.loadMonth({ year: 2024, month: 1 });
|
const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
|
|
||||||
month.cancel();
|
month.cancel();
|
||||||
await loadPromise;
|
await loadPromise;
|
||||||
expect(month?.getAssets().length).toEqual(0);
|
expect(month?.assets.length).toEqual(0);
|
||||||
|
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
expect(month!.getAssets().length).toEqual(3);
|
expect(month!.assets.length).toEqual(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -190,7 +190,7 @@ describe('TimelineManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is empty initially', () => {
|
it('is empty initially', () => {
|
||||||
expect(timelineManager.months.length).toEqual(0);
|
expect(timelineManager.segments.length).toEqual(0);
|
||||||
expect(timelineManager.assetCount).toEqual(0);
|
expect(timelineManager.assetCount).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -202,12 +202,12 @@ describe('TimelineManager', () => {
|
||||||
);
|
);
|
||||||
timelineManager.upsertAssets([asset]);
|
timelineManager.upsertAssets([asset]);
|
||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
expect(timelineManager.segments[0].assets.length).toEqual(1);
|
||||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
expect(timelineManager.segments[0].yearMonth.month).toEqual(1);
|
||||||
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
|
expect(timelineManager.segments[0].getFirstAsset().id).toEqual(asset.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds assets to existing month', () => {
|
it('adds assets to existing month', () => {
|
||||||
|
|
@ -219,11 +219,11 @@ describe('TimelineManager', () => {
|
||||||
timelineManager.upsertAssets([assetOne]);
|
timelineManager.upsertAssets([assetOne]);
|
||||||
timelineManager.upsertAssets([assetTwo]);
|
timelineManager.upsertAssets([assetTwo]);
|
||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(timelineManager.assetCount).toEqual(2);
|
expect(timelineManager.assetCount).toEqual(2);
|
||||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
expect(timelineManager.segments[0].assets.length).toEqual(2);
|
||||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
expect(timelineManager.segments[0].yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('orders assets in months by descending date', () => {
|
it('orders assets in months by descending date', () => {
|
||||||
|
|
@ -246,10 +246,10 @@ describe('TimelineManager', () => {
|
||||||
|
|
||||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
|
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
|
||||||
expect(month).not.toBeNull();
|
expect(month).not.toBeNull();
|
||||||
expect(month?.getAssets().length).toEqual(3);
|
expect(month?.assets.length).toEqual(3);
|
||||||
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
|
expect(month?.assets[0].id).toEqual(assetOne.id);
|
||||||
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
|
expect(month?.assets[1].id).toEqual(assetThree.id);
|
||||||
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
|
expect(month?.assets[2].id).toEqual(assetTwo.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('orders months by descending date', () => {
|
it('orders months by descending date', () => {
|
||||||
|
|
@ -270,15 +270,15 @@ describe('TimelineManager', () => {
|
||||||
);
|
);
|
||||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(3);
|
expect(timelineManager.segments.length).toEqual(3);
|
||||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[0].yearMonth.month).toEqual(4);
|
expect(timelineManager.segments[0].yearMonth.month).toEqual(4);
|
||||||
|
|
||||||
expect(timelineManager.months[1].yearMonth.year).toEqual(2024);
|
expect(timelineManager.segments[1].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[1].yearMonth.month).toEqual(1);
|
expect(timelineManager.segments[1].yearMonth.month).toEqual(1);
|
||||||
|
|
||||||
expect(timelineManager.months[2].yearMonth.year).toEqual(2023);
|
expect(timelineManager.segments[2].yearMonth.year).toEqual(2023);
|
||||||
expect(timelineManager.months[2].yearMonth.month).toEqual(1);
|
expect(timelineManager.segments[2].yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates existing asset', () => {
|
it('updates existing asset', () => {
|
||||||
|
|
@ -435,11 +435,11 @@ describe('TimelineManager', () => {
|
||||||
|
|
||||||
timelineManager.upsertAssets([asset]);
|
timelineManager.upsertAssets([asset]);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
expect(timelineManager.segments[0].getFirstAsset().isFavorite).toEqual(false);
|
||||||
|
|
||||||
timelineManager.upsertAssets([updatedAsset]);
|
timelineManager.upsertAssets([updatedAsset]);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
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', () => {
|
it('asset moves months when asset date changes', () => {
|
||||||
|
|
@ -454,16 +454,16 @@ describe('TimelineManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
timelineManager.upsertAssets([asset]);
|
timelineManager.upsertAssets([asset]);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
|
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1);
|
||||||
|
|
||||||
timelineManager.upsertAssets([updatedAsset]);
|
timelineManager.upsertAssets([updatedAsset]);
|
||||||
expect(timelineManager.months.length).toEqual(2);
|
expect(timelineManager.segments.length).toEqual(2);
|
||||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
|
||||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
||||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
|
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('asset is removed during upsert when TimelineManager if visibility changes', async () => {
|
it('asset is removed during upsert when TimelineManager if visibility changes', async () => {
|
||||||
|
|
@ -551,8 +551,8 @@ describe('TimelineManager', () => {
|
||||||
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
|
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||||
|
|
||||||
expect(timelineManager.assetCount).toEqual(2);
|
expect(timelineManager.assetCount).toEqual(2);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
expect(timelineManager.segments[0].assets.length).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes asset from month', () => {
|
it('removes asset from month', () => {
|
||||||
|
|
@ -565,8 +565,8 @@ describe('TimelineManager', () => {
|
||||||
timelineManager.removeAssets([assetOne.id]);
|
timelineManager.removeAssets([assetOne.id]);
|
||||||
|
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
expect(timelineManager.segments[0].assets.length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not remove month when empty', () => {
|
it('does not remove month when empty', () => {
|
||||||
|
|
@ -579,7 +579,7 @@ describe('TimelineManager', () => {
|
||||||
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
||||||
|
|
||||||
expect(timelineManager.assetCount).toEqual(0);
|
expect(timelineManager.assetCount).toEqual(0);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -655,45 +655,45 @@ describe('TimelineManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId', async () => {
|
it('returns previous assetId', async () => {
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
|
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
|
||||||
|
|
||||||
const a = month!.getAssets()[0];
|
const a = month!.assets[0];
|
||||||
const b = month!.getAssets()[1];
|
const b = month!.assets[1];
|
||||||
const previous = await timelineManager.getLaterAsset(b);
|
const previous = await timelineManager.getLaterAsset(b);
|
||||||
expect(previous).toEqual(a);
|
expect(previous).toEqual(a);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId spanning multiple months', async () => {
|
it('returns previous assetId spanning multiple months', async () => {
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 2 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 3 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||||
|
|
||||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
|
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
|
||||||
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
|
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
|
||||||
const a = month!.getAssets()[0];
|
const a = month!.assets[0];
|
||||||
const b = previousMonth!.getAssets()[0];
|
const b = previousMonth!.assets[0];
|
||||||
const previous = await timelineManager.getLaterAsset(a);
|
const previous = await timelineManager.getLaterAsset(a);
|
||||||
expect(previous).toEqual(b);
|
expect(previous).toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads previous month', async () => {
|
it('loads previous month', async () => {
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 2 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
|
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
|
||||||
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
|
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
|
||||||
const a = month!.getFirstAsset();
|
const a = month!.getFirstAsset();
|
||||||
const b = previousMonth!.getFirstAsset();
|
const b = previousMonth!.getFirstAsset();
|
||||||
const loadMonthSpy = vi.spyOn(month!.loader!, 'execute');
|
const loadmonthSpy = vi.spyOn(month!.loader!, 'execute');
|
||||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||||
const previous = await timelineManager.getLaterAsset(a);
|
const previous = await timelineManager.getLaterAsset(a);
|
||||||
expect(previous).toEqual(b);
|
expect(previous).toEqual(b);
|
||||||
expect(loadMonthSpy).toBeCalledTimes(0);
|
expect(loadmonthSpy).toBeCalledTimes(0);
|
||||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips removed assets', async () => {
|
it('skips removed assets', async () => {
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 2 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 3 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||||
|
|
||||||
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
||||||
timelineManager.removeAssets([assetTwo.id]);
|
timelineManager.removeAssets([assetTwo.id]);
|
||||||
|
|
@ -701,8 +701,8 @@ describe('TimelineManager', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when no more assets', async () => {
|
it('returns null when no more assets', async () => {
|
||||||
await timelineManager.loadMonth({ year: 2024, month: 3 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||||
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
|
expect(await timelineManager.getLaterAsset(timelineManager.segments[0].getFirstAsset())).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -734,10 +734,10 @@ describe('TimelineManager', () => {
|
||||||
);
|
);
|
||||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
expect((timelineManager.getSegmentForAssetId(assetTwo.id) as TimelineMonth)?.yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
expect((timelineManager.getSegmentForAssetId(assetTwo.id) as TimelineMonth)?.yearMonth.month).toEqual(2);
|
||||||
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores removed months', () => {
|
it('ignores removed months', () => {
|
||||||
|
|
@ -754,8 +754,8 @@ describe('TimelineManager', () => {
|
||||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
timelineManager.removeAssets([assetTwo.id]);
|
timelineManager.removeAssets([assetTwo.id]);
|
||||||
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
|
import type { SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||||
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
|
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
|
||||||
import { updateIntersectionMonth } 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 {
|
import {
|
||||||
findClosestGroupForDate,
|
findClosestGroupForDate,
|
||||||
findMonthForAsset as findMonthForAssetUtil,
|
findMonthForAsset as findMonthForAssetUtil,
|
||||||
|
|
@ -27,47 +25,24 @@ import type {
|
||||||
} from '$lib/managers/timeline-manager/types';
|
} from '$lib/managers/timeline-manager/types';
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
import {
|
import {
|
||||||
|
getSegmentIdentifier,
|
||||||
setDifferenceInPlace,
|
setDifferenceInPlace,
|
||||||
toTimelineAsset,
|
toTimelineAsset,
|
||||||
type TimelineDateTime,
|
type TimelineDateTime,
|
||||||
type TimelineYearMonth,
|
type TimelineYearMonth,
|
||||||
} from '$lib/utils/timeline-util';
|
} from '$lib/utils/timeline-util';
|
||||||
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
||||||
import { clamp, isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
type ViewportTopMonthIntersection = {
|
|
||||||
month: TimelineMonth | 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 {
|
export class TimelineManager extends VirtualScrollManager {
|
||||||
override bottomSectionHeight = $state(60);
|
override bottomSectionHeight = $state(60);
|
||||||
|
|
||||||
override bodySectionHeight = $derived.by(() => {
|
segments: TimelineMonth[] = $state([]);
|
||||||
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: TimelineMonth[] = $state([]);
|
|
||||||
albumAssets: Set<string> = new SvelteSet();
|
albumAssets: Set<string> = new SvelteSet();
|
||||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||||
scrubberTimelineHeight: number = $state(0);
|
scrubberTimelineHeight: number = $state(0);
|
||||||
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
|
|
||||||
limitedScroll = $derived(this.maxScrollPercent < 0.5);
|
limitedScroll = $derived(this.maxScrollPercent < 0.5);
|
||||||
initTask = new CancellableTask(
|
initTask = new CancellableTask(
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -87,13 +62,17 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
static #INIT_OPTIONS = {};
|
static #INIT_OPTIONS = {};
|
||||||
#websocketSupport: WebsocketSupport | undefined;
|
#websocketSupport: WebsocketSupport | undefined;
|
||||||
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
||||||
#updatingIntersections = false;
|
|
||||||
#scrollableElement: HTMLElement | undefined = $state();
|
#scrollableElement: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get options() {
|
||||||
|
return this.#options;
|
||||||
|
}
|
||||||
|
|
||||||
override get scrollTop(): number {
|
override get scrollTop(): number {
|
||||||
return this.#scrollableElement?.scrollTop ?? 0;
|
return this.#scrollableElement?.scrollTop ?? 0;
|
||||||
}
|
}
|
||||||
|
|
@ -102,43 +81,61 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
this.#scrollableElement = element;
|
this.#scrollableElement = element;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollTo(top: number) {
|
override scrollTo(top: number) {
|
||||||
this.#scrollableElement?.scrollTo({ top });
|
this.#scrollableElement?.scrollTo({ top });
|
||||||
this.updateSlidingWindow();
|
this.updateVisibleWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollBy(y: number) {
|
override scrollBy(y: number) {
|
||||||
this.#scrollableElement?.scrollBy(0, y);
|
this.#scrollableElement?.scrollBy(0, y);
|
||||||
this.updateSlidingWindow();
|
this.updateVisibleWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async *assetsIterator(options?: {
|
protected override refreshLayout({ invalidateHeight = true }: { invalidateHeight?: boolean } = {}) {
|
||||||
startMonth?: TimelineMonth;
|
super.refreshLayout({ invalidateHeight });
|
||||||
startDay?: TimelineDay;
|
if (invalidateHeight) {
|
||||||
startAsset?: TimelineAsset;
|
this.#createScrubberMonths();
|
||||||
direction?: Direction;
|
|
||||||
}) {
|
|
||||||
const direction = options?.direction ?? 'earlier';
|
|
||||||
let { startDay, startAsset } = options ?? {};
|
|
||||||
for (const month of this.monthIterator({ direction, startMonth: options?.startMonth })) {
|
|
||||||
await this.loadMonth(month.yearMonth, { cancelable: false });
|
|
||||||
yield* month.assetsIterator({ startDay, startAsset, direction });
|
|
||||||
startDay = startAsset = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*monthIterator(options?: { direction?: Direction; startMonth?: TimelineMonth }) {
|
public override destroy() {
|
||||||
const isEarlier = options?.direction === 'earlier';
|
this.disconnect();
|
||||||
let startIndex = options?.startMonth
|
super.destroy();
|
||||||
? this.months.indexOf(options.startMonth)
|
}
|
||||||
: isEarlier
|
|
||||||
? 0
|
|
||||||
: this.months.length - 1;
|
|
||||||
|
|
||||||
while (startIndex >= 0 && startIndex < this.months.length) {
|
async updateOptions(options: TimelineManagerOptions) {
|
||||||
yield this.months[startIndex];
|
if (options.deferInit) {
|
||||||
startIndex += isEarlier ? 1 : -1;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.#options !== TimelineManager.#INIT_OPTIONS && 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
|
@ -157,171 +154,6 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
this.#websocketSupport = undefined;
|
this.#websocketSupport = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
#calculateMonthBottomViewportRatio(month: TimelineMonth | 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: TimelineMonth | 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) {
|
|
||||||
updateIntersectionMonth(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: TimelineMonth) {
|
|
||||||
const hasDeferred = month.days.some((group) => group.deferredLayout);
|
|
||||||
if (hasDeferred) {
|
|
||||||
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
|
|
||||||
for (const group of month.days) {
|
|
||||||
group.deferredLayout = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #initializeMonths() {
|
|
||||||
const timebuckets = await getTimeBuckets({
|
|
||||||
...authManager.params,
|
|
||||||
...this.#options,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.months = timebuckets.map((timeBucket) => {
|
|
||||||
const date = new SvelteDate(timeBucket.timeBucket);
|
|
||||||
return new TimelineMonth(
|
|
||||||
this,
|
|
||||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
|
||||||
timeBucket.count,
|
|
||||||
false,
|
|
||||||
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.#initializeMonths();
|
|
||||||
}, 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.monthTitle,
|
|
||||||
height: month.height,
|
|
||||||
}));
|
|
||||||
this.scrubberTimelineHeight = this.totalViewerHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadMonth(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
|
||||||
let cancelable = true;
|
|
||||||
if (options) {
|
|
||||||
cancelable = options.cancelable;
|
|
||||||
}
|
|
||||||
const month = getMonthByDate(this, yearMonth);
|
|
||||||
if (!month) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (month.loader?.executed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const executionStatus = await month.loader?.execute(async (signal: AbortSignal) => {
|
|
||||||
await loadFromTimeBuckets(this, month, this.#options, signal);
|
|
||||||
}, cancelable);
|
|
||||||
if (executionStatus === 'LOADED') {
|
|
||||||
updateGeometry(this, month, { invalidateHeight: false });
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertAssets(assets: TimelineAsset[]) {
|
upsertAssets(assets: TimelineAsset[]) {
|
||||||
const notUpdated = this.#updateAssets(assets);
|
const notUpdated = this.#updateAssets(assets);
|
||||||
const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset));
|
const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset));
|
||||||
|
|
@ -354,8 +186,18 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
||||||
await this.loadMonth(yearMonth, options);
|
await this.loadSegment(getSegmentIdentifier(yearMonth), options);
|
||||||
return getMonthByDate(this, yearMonth);
|
return getMonthByDate(this, yearMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -373,7 +215,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
let accumulatedCount = 0;
|
let accumulatedCount = 0;
|
||||||
|
|
||||||
let randomMonth: TimelineMonth | undefined = undefined;
|
let randomMonth: TimelineMonth | undefined = undefined;
|
||||||
for (const month of this.months) {
|
for (const month of this.segments) {
|
||||||
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
|
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
|
||||||
randomMonth = month;
|
randomMonth = month;
|
||||||
break;
|
break;
|
||||||
|
|
@ -384,7 +226,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
if (!randomMonth) {
|
if (!randomMonth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.loadMonth(randomMonth.yearMonth, { cancelable: false });
|
await this.loadSegment(getSegmentIdentifier(randomMonth.yearMonth), { cancelable: false });
|
||||||
|
|
||||||
let randomDay: TimelineDay | undefined = undefined;
|
let randomDay: TimelineDay | undefined = undefined;
|
||||||
for (const day of randomMonth.days) {
|
for (const day of randomMonth.days) {
|
||||||
|
|
@ -447,7 +289,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
|
|
||||||
if (!month) {
|
if (!month) {
|
||||||
month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order);
|
month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order);
|
||||||
this.months.push(month);
|
this.segments.push(month);
|
||||||
}
|
}
|
||||||
|
|
||||||
month.addTimelineAsset(asset, context);
|
month.addTimelineAsset(asset, context);
|
||||||
|
|
@ -458,11 +300,11 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const context = this.createUpsertContext();
|
const context = this.createUpsertContext();
|
||||||
const monthCount = this.months.length;
|
const monthCount = this.segments.length;
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
this.upsertAssetIntoSegment(asset, context);
|
this.upsertAssetIntoSegment(asset, context);
|
||||||
}
|
}
|
||||||
if (this.months.length !== monthCount) {
|
if (this.segments.length !== monthCount) {
|
||||||
this.postCreateSegments();
|
this.postCreateSegments();
|
||||||
}
|
}
|
||||||
this.postUpsert(context);
|
this.postUpsert(context);
|
||||||
|
|
@ -482,7 +324,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
const idsProcessed = new Set<string>();
|
const idsProcessed = new Set<string>();
|
||||||
const combinedMoveAssets: TimelineAsset[] = [];
|
const combinedMoveAssets: TimelineAsset[] = [];
|
||||||
for (const month of this.months) {
|
for (const month of this.segments) {
|
||||||
if (idsToProcess.size > 0) {
|
if (idsToProcess.size > 0) {
|
||||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||||
if (moveAssets.length > 0) {
|
if (moveAssets.length > 0) {
|
||||||
|
|
@ -502,7 +344,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
}
|
}
|
||||||
const changedGeometry = changedMonths.size > 0;
|
const changedGeometry = changedMonths.size > 0;
|
||||||
for (const month of changedMonths) {
|
for (const month of changedMonths) {
|
||||||
updateGeometry(this, month, { invalidateHeight: true });
|
month.updateGeometry({ invalidateHeight: true });
|
||||||
}
|
}
|
||||||
if (changedGeometry) {
|
if (changedGeometry) {
|
||||||
this.updateIntersections();
|
this.updateIntersections();
|
||||||
|
|
@ -510,15 +352,20 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||||
}
|
}
|
||||||
|
|
||||||
override refreshLayout() {
|
isExcluded(asset: TimelineAsset) {
|
||||||
for (const month of this.months) {
|
return (
|
||||||
updateGeometry(this, month, { invalidateHeight: true });
|
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||||
}
|
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||||
this.updateIntersections();
|
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAssetOrder() {
|
||||||
|
return this.#options.order ?? AssetOrder.Desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFirstAsset(): TimelineAsset | undefined {
|
getFirstAsset(): TimelineAsset | undefined {
|
||||||
return this.months[0]?.getFirstAsset();
|
return this.segments[0]?.getFirstAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLaterAsset(
|
async getLaterAsset(
|
||||||
|
|
@ -538,13 +385,12 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
||||||
let month = findMonthForDate(this, dateTime);
|
let month = findMonthForDate(this, dateTime);
|
||||||
if (!month) {
|
if (!month) {
|
||||||
// if exact match not found, find closest
|
month = findClosestGroupForDate(this.segments, dateTime);
|
||||||
month = findClosestGroupForDate(this.months, dateTime);
|
|
||||||
if (!month) {
|
if (!month) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.loadMonth(dateTime, { cancelable: false });
|
await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false });
|
||||||
const asset = month.findClosest(dateTime);
|
const asset = month.findClosest(dateTime);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
return asset;
|
return asset;
|
||||||
|
|
@ -558,20 +404,86 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
return retrieveRangeUtil(this, start, end);
|
return retrieveRangeUtil(this, start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
isExcluded(asset: TimelineAsset) {
|
clearDeferredLayout(month: TimelineMonth) {
|
||||||
return (
|
const hasDeferred = month.days.some((group) => group.deferredLayout);
|
||||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
if (hasDeferred) {
|
||||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
month.updateGeometry({ invalidateHeight: true, noDefer: true });
|
||||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
for (const group of month.days) {
|
||||||
);
|
group.deferredLayout = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssetOrder() {
|
async *assetsIterator(options?: {
|
||||||
return this.#options.order ?? AssetOrder.Desc;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected postCreateSegments(): void {
|
protected postCreateSegments(): void {
|
||||||
this.months.sort((a, b) => {
|
this.segments.sort((a, b) => {
|
||||||
return a.yearMonth.year === b.yearMonth.year
|
return a.yearMonth.year === b.yearMonth.year
|
||||||
? b.yearMonth.month - a.yearMonth.month
|
? b.yearMonth.month - a.yearMonth.month
|
||||||
: b.yearMonth.year - a.yearMonth.year;
|
: b.yearMonth.year - a.yearMonth.year;
|
||||||
|
|
@ -589,7 +501,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
|
|
||||||
for (const month of context.updatedMonths) {
|
for (const month of context.updatedMonths) {
|
||||||
month.sortDays();
|
month.sortDays();
|
||||||
updateGeometry(this, month, { invalidateHeight: true });
|
month.updateGeometry({ invalidateHeight: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,36 @@
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
|
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
|
||||||
import { onCreateMonth } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
|
import { onCreateMonth } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
|
||||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||||
import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||||
import { ScrollSegment } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
import { ScrollSegment, type SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import {
|
import {
|
||||||
formatDayTitle,
|
formatDayTitle,
|
||||||
|
formatDayTitleFull,
|
||||||
formatMonthTitle,
|
formatMonthTitle,
|
||||||
fromTimelinePlainDate,
|
fromTimelinePlainDate,
|
||||||
fromTimelinePlainDateTime,
|
fromTimelinePlainDateTime,
|
||||||
fromTimelinePlainYearMonth,
|
fromTimelinePlainYearMonth,
|
||||||
|
getSegmentIdentifier,
|
||||||
getTimes,
|
getTimes,
|
||||||
setDifferenceInPlace,
|
setDifferenceInPlace,
|
||||||
|
toISOYearMonthUTC,
|
||||||
type TimelineDateTime,
|
type TimelineDateTime,
|
||||||
type TimelineYearMonth,
|
type TimelineYearMonth,
|
||||||
} from '$lib/utils/timeline-util';
|
} from '$lib/utils/timeline-util';
|
||||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { AssetOrder, getTimeBucket, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
export class TimelineMonth extends ScrollSegment {
|
export class TimelineMonth extends ScrollSegment {
|
||||||
#intersecting: boolean = $state(false);
|
|
||||||
actuallyIntersecting: boolean = $state(false);
|
|
||||||
isLoaded: boolean = $state(false);
|
|
||||||
days: TimelineDay[] = $state([]);
|
days: TimelineDay[] = $state([]);
|
||||||
readonly timelineManager: TimelineManager;
|
|
||||||
|
|
||||||
#height: number = $state(0);
|
|
||||||
#top: number = $state(0);
|
|
||||||
|
|
||||||
#initialCount: number = 0;
|
|
||||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||||
percent: number = $state(0);
|
#yearMonth: TimelineYearMonth;
|
||||||
|
#identifier: SegmentIdentifier;
|
||||||
assetsCount: number = $derived(
|
#timelineManager: TimelineManager;
|
||||||
this.isLoaded ? this.days.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0) : this.#initialCount,
|
|
||||||
);
|
|
||||||
loader: CancellableTask | undefined;
|
|
||||||
isHeightActual: boolean = $state(false);
|
|
||||||
|
|
||||||
readonly monthTitle: string;
|
readonly monthTitle: string;
|
||||||
readonly yearMonth: TimelineYearMonth;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
|
|
@ -53,46 +40,162 @@ export class TimelineMonth extends ScrollSegment {
|
||||||
order: AssetOrder = AssetOrder.Desc,
|
order: AssetOrder = AssetOrder.Desc,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.timelineManager = timelineManager;
|
this.initialCount = initialCount;
|
||||||
this.#initialCount = initialCount;
|
this.#yearMonth = yearMonth;
|
||||||
|
this.#identifier = getSegmentIdentifier(yearMonth);
|
||||||
|
this.#timelineManager = timelineManager;
|
||||||
this.#sortOrder = order;
|
this.#sortOrder = order;
|
||||||
|
|
||||||
this.yearMonth = yearMonth;
|
|
||||||
this.monthTitle = formatMonthTitle(fromTimelinePlainYearMonth(yearMonth));
|
this.monthTitle = formatMonthTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||||
|
this.loaded = loaded;
|
||||||
this.loader = new CancellableTask(
|
|
||||||
() => {
|
|
||||||
this.isLoaded = true;
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.days = [];
|
|
||||||
this.isLoaded = false;
|
|
||||||
},
|
|
||||||
this.#handleLoadError,
|
|
||||||
);
|
|
||||||
if (loaded) {
|
|
||||||
this.isLoaded = true;
|
|
||||||
}
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
onCreateMonth(this);
|
onCreateMonth(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set intersecting(newValue: boolean) {
|
get identifier() {
|
||||||
const old = this.#intersecting;
|
return this.#identifier;
|
||||||
if (old === newValue) {
|
}
|
||||||
return;
|
|
||||||
|
get scrollManager(): TimelineManager {
|
||||||
|
return this.#timelineManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewerAssets() {
|
||||||
|
const assets: ViewerAsset[] = [];
|
||||||
|
for (const day of this.days) {
|
||||||
|
assets.push(...day.viewerAssets);
|
||||||
}
|
}
|
||||||
this.#intersecting = newValue;
|
return assets;
|
||||||
if (newValue) {
|
}
|
||||||
void this.timelineManager.loadMonth(this.yearMonth);
|
|
||||||
} else {
|
override findAssetAbsolutePosition(assetId: string) {
|
||||||
this.cancel();
|
this.#clearDeferredLayout();
|
||||||
|
for (const group of this.days) {
|
||||||
|
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.scrollManager.headerHeight,
|
||||||
|
height: viewerAsset.position.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get intersecting() {
|
protected async fetch(signal: AbortSignal): Promise<unknown> {
|
||||||
return this.#intersecting;
|
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 dayRow = 0;
|
||||||
|
let dayCol = 0;
|
||||||
|
|
||||||
|
const options = this.scrollManager.justifiedLayoutOptions;
|
||||||
|
for (const day of this.days) {
|
||||||
|
day.layout(options, noDefer);
|
||||||
|
|
||||||
|
// Calculate space needed for this item (including gap if not first in row)
|
||||||
|
const spaceNeeded = day.width + (dayCol > 0 ? this.scrollManager.gap : 0);
|
||||||
|
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= this.scrollManager.viewportWidth;
|
||||||
|
|
||||||
|
if (fitsInCurrentRow) {
|
||||||
|
day.row = dayRow;
|
||||||
|
day.col = dayCol++;
|
||||||
|
day.left = cumulativeWidth;
|
||||||
|
day.top = cumulativeHeight;
|
||||||
|
|
||||||
|
cumulativeWidth += day.width + this.scrollManager.gap;
|
||||||
|
} else {
|
||||||
|
// Move to next row
|
||||||
|
cumulativeHeight += currentRowHeight;
|
||||||
|
cumulativeWidth = 0;
|
||||||
|
dayRow++;
|
||||||
|
dayCol = 0;
|
||||||
|
|
||||||
|
// Position at start of new row
|
||||||
|
day.row = dayRow;
|
||||||
|
day.col = dayCol;
|
||||||
|
day.left = 0;
|
||||||
|
day.top = cumulativeHeight;
|
||||||
|
|
||||||
|
dayCol++;
|
||||||
|
cumulativeWidth += day.width + this.scrollManager.gap;
|
||||||
|
}
|
||||||
|
currentRowHeight = day.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() {
|
get lastDay() {
|
||||||
|
|
@ -103,19 +206,6 @@ export class TimelineMonth extends ScrollSegment {
|
||||||
return this.days[0]?.getFirstAsset();
|
return this.days[0]?.getFirstAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssets() {
|
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce
|
|
||||||
return this.days.reduce((accumulator: TimelineAsset[], g: TimelineDay) => accumulator.concat(g.getAssets()), []);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||||
if (ids.size === 0) {
|
if (ids.size === 0) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -215,6 +305,14 @@ export class TimelineMonth extends ScrollSegment {
|
||||||
return addContext.unprocessedAssets;
|
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) {
|
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
|
||||||
const { localDateTime } = timelineAsset;
|
const { localDateTime } = timelineAsset;
|
||||||
|
|
||||||
|
|
@ -229,7 +327,8 @@ export class TimelineMonth extends ScrollSegment {
|
||||||
addContext.setDay(day, localDateTime);
|
addContext.setDay(day, localDateTime);
|
||||||
} else {
|
} else {
|
||||||
const dayTitle = formatDayTitle(fromTimelinePlainDate(localDateTime));
|
const dayTitle = formatDayTitle(fromTimelinePlainDate(localDateTime));
|
||||||
day = new TimelineDay(this, this.days.length, localDateTime.day, dayTitle);
|
const dayTitleFull = formatDayTitleFull(fromTimelinePlainDate(localDateTime));
|
||||||
|
day = new TimelineDay(this, this.days.length, localDateTime.day, dayTitle, dayTitleFull);
|
||||||
this.days.push(day);
|
this.days.push(day);
|
||||||
addContext.setDay(day, localDateTime);
|
addContext.setDay(day, localDateTime);
|
||||||
addContext.newDays.add(day);
|
addContext.newDays.add(day);
|
||||||
|
|
@ -240,94 +339,6 @@ export class TimelineMonth extends ScrollSegment {
|
||||||
addContext.changedDays.add(day);
|
addContext.changedDays.add(day);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 prevMonth = timelineManager.months[index - 1];
|
|
||||||
if (prevMonth) {
|
|
||||||
const newTop = prevMonth.#top + prevMonth.#height;
|
|
||||||
if (this.#top !== newTop) {
|
|
||||||
this.#top = newTop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (heightDelta === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
|
|
||||||
const month = this.timelineManager.months[cursor];
|
|
||||||
const newTop = month.#top + heightDelta;
|
|
||||||
if (month.#top !== newTop) {
|
|
||||||
month.#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'));
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
findAssetAbsolutePosition(assetId: string) {
|
|
||||||
this.timelineManager.clearDeferredLayout(this);
|
|
||||||
for (const group of this.days) {
|
|
||||||
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?: { startDay?: TimelineDay; startAsset?: TimelineAsset; direction?: Direction }) {
|
*assetsIterator(options?: { startDay?: TimelineDay; startAsset?: TimelineAsset; direction?: Direction }) {
|
||||||
const direction = options?.direction ?? 'earlier';
|
const direction = options?.direction ?? 'earlier';
|
||||||
let { startAsset } = options ?? {};
|
let { startAsset } = options ?? {};
|
||||||
|
|
@ -342,6 +353,18 @@ export class TimelineMonth extends ScrollSegment {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
findAssetById(assetDescriptor: AssetDescriptor) {
|
||||||
for (const asset of this.assetsIterator()) {
|
for (const asset of this.assetsIterator()) {
|
||||||
if (asset.id === assetDescriptor.id) {
|
if (asset.id === assetDescriptor.id) {
|
||||||
|
|
@ -365,7 +388,13 @@ export class TimelineMonth extends ScrollSegment {
|
||||||
return closest;
|
return closest;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
#clearDeferredLayout() {
|
||||||
this.loader?.cancel();
|
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,73 +0,0 @@
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
|
||||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
|
||||||
|
|
||||||
const {
|
|
||||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
|
||||||
} = TUNABLES;
|
|
||||||
|
|
||||||
export function updateIntersectionMonth(timelineManager: TimelineManager, month: TimelineMonth) {
|
|
||||||
const actuallyIntersecting = calculateMonthIntersecting(timelineManager, month, 0, 0);
|
|
||||||
let preIntersecting = false;
|
|
||||||
if (!actuallyIntersecting) {
|
|
||||||
preIntersecting = calculateMonthIntersecting(
|
|
||||||
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 calculateMonthIntersecting(
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
month: TimelineMonth,
|
|
||||||
expandTop: number,
|
|
||||||
expandBottom: number,
|
|
||||||
) {
|
|
||||||
const monthTop = month.top;
|
|
||||||
const monthBottom = monthTop + month.height;
|
|
||||||
const topWindow = timelineManager.visibleWindow.top - expandTop;
|
|
||||||
const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom;
|
|
||||||
|
|
||||||
return isIntersecting(monthTop, monthBottom, 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 { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
|
||||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
|
||||||
import type { UpdateGeometryOptions } from '$lib/managers/timeline-manager/types';
|
|
||||||
|
|
||||||
export function updateGeometry(timelineManager: TimelineManager, month: TimelineMonth, 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;
|
|
||||||
}
|
|
||||||
layoutMonth(timelineManager, month, noDefer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function layoutMonth(timelineManager: TimelineManager, month: TimelineMonth, noDefer: boolean = false) {
|
|
||||||
let cumulativeHeight = 0;
|
|
||||||
let cumulativeWidth = 0;
|
|
||||||
let currentRowHeight = 0;
|
|
||||||
|
|
||||||
let dayRow = 0;
|
|
||||||
let dayCol = 0;
|
|
||||||
|
|
||||||
const options = timelineManager.justifiedLayoutOptions;
|
|
||||||
for (const day of month.days) {
|
|
||||||
day.layout(options, noDefer);
|
|
||||||
|
|
||||||
// Calculate space needed for this item (including gap if not first in row)
|
|
||||||
const spaceNeeded = day.width + (dayCol > 0 ? timelineManager.gap : 0);
|
|
||||||
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth;
|
|
||||||
|
|
||||||
if (fitsInCurrentRow) {
|
|
||||||
day.row = dayRow;
|
|
||||||
day.col = dayCol++;
|
|
||||||
day.left = cumulativeWidth;
|
|
||||||
day.top = cumulativeHeight;
|
|
||||||
|
|
||||||
cumulativeWidth += day.width + timelineManager.gap;
|
|
||||||
} else {
|
|
||||||
// Move to next row
|
|
||||||
cumulativeHeight += currentRowHeight;
|
|
||||||
cumulativeWidth = 0;
|
|
||||||
dayRow++;
|
|
||||||
dayCol = 0;
|
|
||||||
|
|
||||||
// Position at start of new row
|
|
||||||
day.row = dayRow;
|
|
||||||
day.col = dayCol;
|
|
||||||
day.left = 0;
|
|
||||||
day.top = cumulativeHeight;
|
|
||||||
|
|
||||||
dayCol++;
|
|
||||||
cumulativeWidth += day.width + timelineManager.gap;
|
|
||||||
}
|
|
||||||
currentRowHeight = day.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 { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
|
||||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
|
||||||
import type { TimelineManagerOptions } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
|
|
||||||
import { getTimeBucket } from '@immich/sdk';
|
|
||||||
|
|
||||||
export async function loadFromTimeBuckets(
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
month: TimelineMonth,
|
|
||||||
options: TimelineManagerOptions,
|
|
||||||
signal: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
if (month.getFirstAsset()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeBucket = toISOYearMonthUTC(month.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 = month.addAssets(bucketResponse, true);
|
|
||||||
if (unprocessedAssets.length > 0) {
|
|
||||||
console.error(
|
|
||||||
`Warning: getTimeBucket API returning assets not in requested month: ${month.yearMonth.month}, ${JSON.stringify(
|
|
||||||
unprocessedAssets.map((unprocessed) => ({
|
|
||||||
id: unprocessed.id,
|
|
||||||
localDateTime: unprocessed.localDateTime,
|
|
||||||
})),
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -33,7 +33,7 @@ export async function getAssetWithOffset(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findMonthForAsset(timelineManager: TimelineManager, id: string) {
|
export function findMonthForAsset(timelineManager: TimelineManager, id: string) {
|
||||||
for (const month of timelineManager.months) {
|
for (const month of timelineManager.segments) {
|
||||||
const asset = month.findAssetById({ id });
|
const asset = month.findAssetById({ id });
|
||||||
if (asset) {
|
if (asset) {
|
||||||
return { month, asset };
|
return { month, asset };
|
||||||
|
|
@ -45,7 +45,7 @@ export function getMonthByDate(
|
||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
targetYearMonth: TimelineYearMonth,
|
targetYearMonth: TimelineYearMonth,
|
||||||
): TimelineMonth | undefined {
|
): TimelineMonth | undefined {
|
||||||
return timelineManager.months.find(
|
return timelineManager.segments.find(
|
||||||
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +137,7 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findMonthForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
|
export function findMonthForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
|
||||||
for (const month of timelineManager.months) {
|
for (const month of timelineManager.segments) {
|
||||||
const { year, month: monthNum } = month.yearMonth;
|
const { year, month: monthNum } = month.yearMonth;
|
||||||
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
||||||
return month;
|
return month;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import { calculateViewerAssetIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
|
||||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
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 { CommonPosition } from '$lib/utils/layout-utils';
|
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 {
|
export class ViewerAsset {
|
||||||
readonly #day: TimelineDay;
|
readonly #day: TimelineDay;
|
||||||
|
|
@ -11,10 +18,10 @@ export class ViewerAsset {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = this.#day.month.timelineManager;
|
const scrollManager = this.#day.month.scrollManager;
|
||||||
const positionTop = this.#day.absoluteTop + this.position.top;
|
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();
|
position: CommonPosition | undefined = $state.raw();
|
||||||
|
|
@ -26,3 +33,19 @@ export class ViewerAsset {
|
||||||
this.asset = asset;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { asQueryString } from '$lib/utils/shared-links';
|
import { asQueryString } from '$lib/utils/shared-links';
|
||||||
|
import { getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
addAssetsToAlbum as addAssets,
|
addAssetsToAlbum as addAssets,
|
||||||
addAssetsToAlbums as addToAlbums,
|
addAssetsToAlbums as addToAlbums,
|
||||||
|
|
@ -490,8 +491,8 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
|
||||||
isSelectingAllAssets.set(true);
|
isSelectingAllAssets.set(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const month of timelineManager.months) {
|
for (const month of timelineManager.segments) {
|
||||||
await timelineManager.loadMonth(month.yearMonth);
|
await timelineManager.loadSegment(getSegmentIdentifier(month.yearMonth));
|
||||||
|
|
||||||
if (!get(isSelectingAllAssets)) {
|
if (!get(isSelectingAllAssets)) {
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
|
|
@ -499,8 +500,8 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
|
||||||
}
|
}
|
||||||
assetInteraction.selectAssets(assetsSnapshot([...month.assetsIterator()]));
|
assetInteraction.selectAssets(assetsSnapshot([...month.assetsIterator()]));
|
||||||
|
|
||||||
for (const dateGroup of month.days) {
|
for (const day of month.days) {
|
||||||
assetInteraction.addGroupToMultiselectGroup(dateGroup.dayTitle);
|
assetInteraction.addGroupToMultiselectGroup(day.dayTitle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
export enum TaskStatus {
|
||||||
|
DONE,
|
||||||
|
WAITED,
|
||||||
|
CANCELED,
|
||||||
|
LOADED,
|
||||||
|
ERRORED,
|
||||||
|
}
|
||||||
export class CancellableTask {
|
export class CancellableTask {
|
||||||
cancelToken: AbortController | null = null;
|
cancelToken: AbortController | null = null;
|
||||||
cancellable: boolean = true;
|
cancellable: boolean = true;
|
||||||
|
|
@ -32,18 +39,18 @@ export class CancellableTask {
|
||||||
|
|
||||||
async waitUntilCompletion() {
|
async waitUntilCompletion() {
|
||||||
if (this.executed) {
|
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
|
// 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.
|
// 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.
|
// in either case, we wait on the promise.
|
||||||
await this.complete;
|
await this.complete;
|
||||||
return 'WAITED';
|
return TaskStatus.WAITED;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
||||||
if (this.executed) {
|
if (this.executed) {
|
||||||
return 'DONE';
|
return TaskStatus.DONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if promise is pending, wait on previous request instead.
|
// if promise is pending, wait on previous request instead.
|
||||||
|
|
@ -54,7 +61,7 @@ export class CancellableTask {
|
||||||
this.cancellable = cancellable;
|
this.cancellable = cancellable;
|
||||||
}
|
}
|
||||||
await this.complete;
|
await this.complete;
|
||||||
return 'WAITED';
|
return TaskStatus.WAITED;
|
||||||
}
|
}
|
||||||
this.cancellable = cancellable;
|
this.cancellable = cancellable;
|
||||||
const cancelToken = (this.cancelToken = new AbortController());
|
const cancelToken = (this.cancelToken = new AbortController());
|
||||||
|
|
@ -62,18 +69,18 @@ export class CancellableTask {
|
||||||
try {
|
try {
|
||||||
await f(cancelToken.signal);
|
await f(cancelToken.signal);
|
||||||
if (cancelToken.signal.aborted) {
|
if (cancelToken.signal.aborted) {
|
||||||
return 'CANCELED';
|
return TaskStatus.CANCELED;
|
||||||
}
|
}
|
||||||
this.#transitionToExecuted();
|
this.#transitionToExecuted();
|
||||||
return 'LOADED';
|
return TaskStatus.LOADED;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if ((error as any).name === 'AbortError') {
|
if ((error as any).name === 'AbortError') {
|
||||||
// abort error is not treated as an error, but as a cancellation.
|
// abort error is not treated as an error, but as a cancellation.
|
||||||
return 'CANCELED';
|
return TaskStatus.CANCELED;
|
||||||
}
|
}
|
||||||
this.#transitionToErrored(error);
|
this.#transitionToErrored(error);
|
||||||
return 'ERRORED';
|
return TaskStatus.ERRORED;
|
||||||
} finally {
|
} finally {
|
||||||
this.cancelToken = null;
|
this.cancelToken = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||||
import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
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 { locale } from '$lib/stores/preferences.store';
|
||||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
|
@ -245,3 +247,29 @@ export function setDifferenceInPlace<T>(setA: Set<T>, setB: Set<T>): Set<T> {
|
||||||
}
|
}
|
||||||
return setA;
|
return setA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatDayTitleFull = (_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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@
|
||||||
const asset =
|
const asset =
|
||||||
$slideshowNavigation === SlideshowNavigation.Shuffle
|
$slideshowNavigation === SlideshowNavigation.Shuffle
|
||||||
? await timelineManager.getRandomAsset()
|
? await timelineManager.getRandomAsset()
|
||||||
: timelineManager.months[0]?.days[0]?.viewerAssets[0]?.asset;
|
: timelineManager.getFirstAsset();
|
||||||
if (asset) {
|
if (asset) {
|
||||||
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue