Compare commits
2 Commits
refactor/t
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0168353c2d | ||
|
|
c44b315117 |
68
web/src/lib/components/timeline/AssetLayout.svelte
Normal file
68
web/src/lib/components/timeline/AssetLayout.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
|
||||
import { flip } from 'svelte/animate';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
interface Props {
|
||||
viewerAssets: ViewerAsset[];
|
||||
width: number;
|
||||
height: number;
|
||||
photostreamManager: PhotostreamManager;
|
||||
thumbnail: Snippet<
|
||||
[
|
||||
{
|
||||
asset: TimelineAsset;
|
||||
position: CommonPosition;
|
||||
},
|
||||
]
|
||||
>;
|
||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||
}
|
||||
|
||||
let { viewerAssets, width, height, photostreamManager, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
|
||||
const transitionDuration = $derived.by(() => (photostreamManager.suspendTransitions && !$isUploading ? 0 : 150));
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
|
||||
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
|
||||
return intersectables.filter((intersectable) => intersectable.intersecting);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
|
||||
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
style:top={position.top + 'px'}
|
||||
style:left={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
style:height={position.height + 'px'}
|
||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||
animate:flip={{ duration: transitionDuration }}
|
||||
>
|
||||
{@render thumbnail({ asset, position })}
|
||||
{@render customThumbnailLayout?.(asset)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-image-grid] {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,140 +1,49 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
|
||||
import { Icon } from '@immich/ui';
|
||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
interface Props {
|
||||
isSelectionMode: boolean;
|
||||
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||
singleSelect: boolean;
|
||||
withStacked: boolean;
|
||||
showArchiveIcon: boolean;
|
||||
assetInteraction: AssetInteraction;
|
||||
monthGroup: MonthGroup;
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
customLayout?: Snippet<[TimelineAsset]>;
|
||||
|
||||
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||
onSelectAssets: (asset: TimelineAsset) => void;
|
||||
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
||||
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||
onThumbnailClick?: (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => void;
|
||||
onDayGroupSelect: (daygroup: DayGroup, assets: TimelineAsset[]) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isSelectionMode,
|
||||
thumbnail: thumbnailWithGroup,
|
||||
customThumbnailLayout,
|
||||
singleSelect,
|
||||
withStacked,
|
||||
showArchiveIcon,
|
||||
monthGroup = $bindable(),
|
||||
assetInteraction,
|
||||
monthGroup,
|
||||
timelineManager,
|
||||
customLayout,
|
||||
onSelect,
|
||||
onSelectAssets,
|
||||
onSelectAssetCandidates,
|
||||
onScrollCompensation,
|
||||
onThumbnailClick,
|
||||
onDayGroupSelect,
|
||||
}: Props = $props();
|
||||
|
||||
let isMouseOverGroup = $state(false);
|
||||
let hoveredDayGroup = $state();
|
||||
|
||||
const transitionDuration = $derived.by(() =>
|
||||
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||
);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
const _onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
|
||||
|
||||
const assetSelectHandler = (
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
assetsInDayGroup: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
) => {
|
||||
onSelectAssets(asset);
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
|
||||
assetInteraction.hasSelectedAsset(asset.id),
|
||||
).length;
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
|
||||
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||
}
|
||||
|
||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||
isSelectingAllAssets.set(true);
|
||||
} else {
|
||||
isSelectingAllAssets.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
|
||||
// Show multi select icon on hover on date group
|
||||
hoveredDayGroup = groupTitle;
|
||||
|
||||
if (assetInteraction.selectionActive) {
|
||||
onSelectAssetCandidates(asset);
|
||||
}
|
||||
};
|
||||
|
||||
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
|
||||
return intersectable.filter((int) => int.intersecting);
|
||||
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
|
||||
return intersectables.filter((intersectable) => intersectable.intersecting);
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
|
||||
onScrollCompensation(timelineManager.scrollCompensation);
|
||||
timelineManager.clearScrollCompensation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
{@const absoluteWidth = dayGroup.left}
|
||||
|
||||
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class={[
|
||||
@@ -146,11 +55,11 @@
|
||||
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
|
||||
onmouseenter={() => {
|
||||
isMouseOverGroup = true;
|
||||
assetMouseEventHandler(dayGroup.groupTitle, null);
|
||||
hoveredDayGroup = dayGroup.groupTitle;
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
isMouseOverGroup = false;
|
||||
assetMouseEventHandler(dayGroup.groupTitle, null);
|
||||
hoveredDayGroup = null;
|
||||
}}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
@@ -163,10 +72,10 @@
|
||||
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
|
||||
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
|
||||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
|
||||
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
|
||||
>
|
||||
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||
{#if isDayGroupSelected}
|
||||
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
|
||||
{:else}
|
||||
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
|
||||
@@ -179,57 +88,17 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div
|
||||
data-image-grid
|
||||
class="relative overflow-clip"
|
||||
style:height={dayGroup.height + 'px'}
|
||||
style:width={dayGroup.width + 'px'}
|
||||
<AssetLayout
|
||||
photostreamManager={timelineManager}
|
||||
viewerAssets={dayGroup.viewerAssets}
|
||||
height={dayGroup.height}
|
||||
width={dayGroup.width}
|
||||
{customThumbnailLayout}
|
||||
>
|
||||
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
|
||||
<!-- {#if viewerAsset.intersecting} -->
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
style:top={position.top + 'px'}
|
||||
style:left={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
style:height={position.height + 'px'}
|
||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||
animate:flip={{ duration: transitionDuration }}
|
||||
>
|
||||
<Thumbnail
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
{#if customLayout}
|
||||
{@render customLayout(asset)}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- {/if} -->
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet thumbnail({ asset, position })}
|
||||
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
|
||||
{/snippet}
|
||||
</AssetLayout>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -237,7 +106,4 @@
|
||||
section {
|
||||
contain: layout paint style;
|
||||
}
|
||||
[data-image-grid] {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
|
||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
||||
@@ -57,7 +58,7 @@
|
||||
onEscape?: () => void;
|
||||
children?: Snippet;
|
||||
empty?: Snippet;
|
||||
customLayout?: Snippet<[TimelineAsset]>;
|
||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||
onThumbnailClick?: (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
@@ -88,7 +89,7 @@
|
||||
onEscape = () => {},
|
||||
children,
|
||||
empty,
|
||||
customLayout,
|
||||
customThumbnailLayout,
|
||||
onThumbnailClick,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -419,7 +420,8 @@
|
||||
lastAssetMouseEvent = asset;
|
||||
};
|
||||
|
||||
const handleGroupSelect = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => {
|
||||
const handleGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
|
||||
const group = dayGroup.groupTitle;
|
||||
if (assetInteraction.selectedGroup.has(group)) {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(group);
|
||||
for (const asset of assets) {
|
||||
@@ -439,7 +441,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAssets = async (asset: TimelineAsset) => {
|
||||
const onSelectAssets = async (asset: TimelineAsset) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
@@ -561,6 +563,46 @@
|
||||
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
|
||||
}
|
||||
});
|
||||
|
||||
const assetSelectHandler = (
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
assetsInDayGroup: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
) => {
|
||||
void onSelectAssets(asset);
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
|
||||
assetInteraction.hasSelectedAsset(asset.id),
|
||||
).length;
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
|
||||
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||
}
|
||||
|
||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||
isSelectingAllAssets.set(true);
|
||||
} else {
|
||||
isSelectingAllAssets.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
const _onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||
@@ -661,20 +703,46 @@
|
||||
style:width="100%"
|
||||
>
|
||||
<MonthSegment
|
||||
{withStacked}
|
||||
{showArchiveIcon}
|
||||
{assetInteraction}
|
||||
{timelineManager}
|
||||
{isSelectionMode}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
|
||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||
onSelectAssets={handleSelectAssets}
|
||||
onScrollCompensation={handleScrollCompensation}
|
||||
{customLayout}
|
||||
{onThumbnailClick}
|
||||
/>
|
||||
{timelineManager}
|
||||
onDayGroupSelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
{@const isAssetSelected =
|
||||
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
||||
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
||||
<Thumbnail
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
|
||||
return;
|
||||
}
|
||||
void onSelectAssets(asset);
|
||||
}}
|
||||
onMouseEvent={() => handleSelectAssetCandidates(asset)}
|
||||
selected={isAssetSelected}
|
||||
selectionCandidate={isAssetSelectionCandidate}
|
||||
disabled={isAssetDisabled}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
{/snippet}
|
||||
</MonthSegment>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -111,12 +111,12 @@
|
||||
case AssetAction.UNARCHIVE:
|
||||
case AssetAction.FAVORITE:
|
||||
case AssetAction.UNFAVORITE: {
|
||||
timelineManager.updateAssets([action.asset]);
|
||||
timelineManager.upsertAssets([action.asset]);
|
||||
break;
|
||||
}
|
||||
|
||||
case AssetAction.ADD: {
|
||||
timelineManager.addAssets([action.asset]);
|
||||
timelineManager.upsertAssets([action.asset]);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
timelineManager.addAssets([toTimelineAsset(action.asset)]);
|
||||
timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
|
||||
if (action.stack) {
|
||||
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
|
||||
updateUnstackedAssetInTimeline(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||
import type { OnArchive } from '$lib/utils/actions';
|
||||
import { archiveAssets } from '$lib/utils/asset-utils';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
@@ -13,7 +13,7 @@
|
||||
onArchive?: OnArchive;
|
||||
menuItem?: boolean;
|
||||
unarchive?: boolean;
|
||||
manager?: TimelineManager;
|
||||
manager?: PhotostreamManager;
|
||||
}
|
||||
|
||||
let { onArchive, menuItem = false, unarchive = false, manager }: Props = $props();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
|
||||
@@ -15,7 +15,7 @@
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
menuItem?: boolean;
|
||||
force?: boolean;
|
||||
manager?: TimelineManager;
|
||||
manager?: PhotostreamManager;
|
||||
}
|
||||
|
||||
let { onAssetDelete, onUndoDelete, menuItem = false, force = !$featureFlags.trash, manager }: Props = $props();
|
||||
@@ -40,7 +40,7 @@
|
||||
loading = true;
|
||||
const assets = [...getOwnedAssets()];
|
||||
const undo = (assets: TimelineAsset[]) => {
|
||||
manager?.addAssets(assets);
|
||||
manager?.upsertAssets(assets);
|
||||
onUndoDelete?.(assets);
|
||||
};
|
||||
await deleteAssets(force, onAssetDelete, assets, undo);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||
import type { OnFavorite } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
@@ -17,7 +17,7 @@
|
||||
onFavorite?: OnFavorite;
|
||||
menuItem?: boolean;
|
||||
removeFavorite: boolean;
|
||||
manager?: TimelineManager;
|
||||
manager?: PhotostreamManager;
|
||||
}
|
||||
|
||||
let { onFavorite, menuItem = false, removeFavorite, manager }: Props = $props();
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
!(isTrashEnabled && !force),
|
||||
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||
assetInteraction.selectedAssets,
|
||||
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
|
||||
!isTrashEnabled || force ? undefined : (assets) => timelineManager.upsertAssets(assets),
|
||||
);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
@@ -3,16 +3,21 @@ import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-s
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { clamp, debounce } from 'lodash-es';
|
||||
|
||||
import type {
|
||||
import {
|
||||
PhotostreamSegment,
|
||||
SegmentIdentifier,
|
||||
type SegmentIdentifier,
|
||||
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
||||
|
||||
import { updateObject } from '$lib/managers/timeline-manager/internal/utils.svelte';
|
||||
import type {
|
||||
AssetDescriptor,
|
||||
AssetOperation,
|
||||
MoveAsset,
|
||||
TimelineAsset,
|
||||
TimelineManagerLayoutOptions,
|
||||
Viewport,
|
||||
} from '$lib/managers/timeline-manager/types';
|
||||
import { setDifference } from '$lib/utils/timeline-util';
|
||||
|
||||
export abstract class PhotostreamManager {
|
||||
isInitialized = $state(false);
|
||||
@@ -313,4 +318,106 @@ export abstract class PhotostreamManager {
|
||||
}
|
||||
return Promise.resolve(range);
|
||||
}
|
||||
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
return this.#runAssetOperation(new Set(ids), operation);
|
||||
}
|
||||
|
||||
upsertAssets(assets: TimelineAsset[]) {
|
||||
const notExcluded = assets.filter((asset) => !this.isExcluded(asset));
|
||||
const notUpdated = this.#updateAssets(notExcluded);
|
||||
this.addAssetsToSegments([...notUpdated]);
|
||||
}
|
||||
|
||||
#updateAssets(assets: TimelineAsset[]) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) =>
|
||||
updateObject(asset, lookup.get(asset.id)),
|
||||
);
|
||||
const result: TimelineAsset[] = [];
|
||||
for (const id of unprocessedIds.values()) {
|
||||
result.push(lookup.get(id)!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => ({ remove: true }));
|
||||
return [...unprocessedIds];
|
||||
}
|
||||
|
||||
isExcluded(_: TimelineAsset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected createUpsertContext(): unknown {
|
||||
return;
|
||||
}
|
||||
|
||||
protected abstract upsertAssetIntoSegment(asset: TimelineAsset, context: unknown): void;
|
||||
|
||||
protected postCreateSegments(): void {}
|
||||
|
||||
protected postUpsert(_: unknown): void {}
|
||||
|
||||
protected addAssetsToSegments(assets: TimelineAsset[]) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const context = this.createUpsertContext();
|
||||
const monthCount = this.months.length;
|
||||
for (const asset of assets) {
|
||||
this.upsertAssetIntoSegment(asset, context);
|
||||
}
|
||||
if (this.months.length !== monthCount) {
|
||||
this.postCreateSegments();
|
||||
}
|
||||
this.postUpsert(context);
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
#runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
if (ids.size === 0) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const changedMonthGroups = new Set<PhotostreamSegment>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
let idsToProcess = new Set(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: MoveAsset[][] = [];
|
||||
for (const month of this.months) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
}
|
||||
idsToProcess = setDifference(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedMonthGroups.add(month);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
this.addAssetsToSegments(combinedMoveAssets.flat().map((a) => a.asset));
|
||||
}
|
||||
const changedGeometry = changedMonthGroups.size > 0;
|
||||
for (const month of changedMonthGroups) {
|
||||
updateGeometry(this, month, { invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
this.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { AssetOperation, MoveAsset, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
|
||||
export type SegmentIdentifier = {
|
||||
@@ -147,4 +147,49 @@ export abstract class PhotostreamSegment {
|
||||
}
|
||||
|
||||
abstract findAssetAbsolutePosition(assetId: string): number;
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
if (ids.size === 0) {
|
||||
return {
|
||||
moveAssets: [] as MoveAsset[],
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
processedIds: new Set<string>(),
|
||||
unprocessedIds: ids,
|
||||
changedGeometry: false,
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const unprocessedIds = new Set<string>(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const processedIds = new Set<string>();
|
||||
const moveAssets: MoveAsset[] = [];
|
||||
let changedGeometry = false;
|
||||
for (const assetId of unprocessedIds) {
|
||||
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const asset = this.viewerAssets[index].asset!;
|
||||
const oldTime = { ...asset.localDateTime };
|
||||
const opResult = operation(asset);
|
||||
let remove = false;
|
||||
if (opResult) {
|
||||
remove = (opResult as { remove: boolean }).remove ?? false;
|
||||
}
|
||||
const newTime = asset.localDateTime;
|
||||
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
|
||||
const { year, month, day } = newTime;
|
||||
remove = true;
|
||||
moveAssets.push({ asset, date: { year, month, day } });
|
||||
}
|
||||
unprocessedIds.delete(assetId);
|
||||
processedIds.add(assetId);
|
||||
if (remove || this.timelineManager.isExcluded(asset)) {
|
||||
this.viewerAssets.splice(index, 1);
|
||||
changedGeometry = true;
|
||||
}
|
||||
}
|
||||
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,11 @@ export class DayGroup {
|
||||
|
||||
const asset = this.viewerAssets[index].asset!;
|
||||
const oldTime = { ...asset.localDateTime };
|
||||
let { remove } = operation(asset) ?? { remove: false };
|
||||
const opResult = operation(asset);
|
||||
let remove = false;
|
||||
if (opResult) {
|
||||
remove = (opResult as { remove: boolean }).remove ?? false;
|
||||
}
|
||||
const newTime = asset.localDateTime;
|
||||
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
|
||||
const { year, month, day } = newTime;
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
|
||||
import { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { AssetOperation, TimelineAsset } from '../types';
|
||||
import { updateGeometry } from './layout-support.svelte';
|
||||
import { getMonthGroupByDate } from './search-support.svelte';
|
||||
|
||||
export function addAssetsToMonthGroups(
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
options: { order: AssetOrder },
|
||||
) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addContext = new GroupInsertionCache();
|
||||
const updatedMonthGroups = new SvelteSet<MonthGroup>();
|
||||
const monthCount = timelineManager.months.length;
|
||||
for (const asset of assets) {
|
||||
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
|
||||
|
||||
if (!month) {
|
||||
month = new MonthGroup(timelineManager, asset.localDateTime, 1, true, options.order);
|
||||
timelineManager.months.push(month);
|
||||
}
|
||||
|
||||
month.addTimelineAsset(asset, addContext);
|
||||
updatedMonthGroups.add(month);
|
||||
}
|
||||
|
||||
if (timelineManager.months.length !== monthCount) {
|
||||
timelineManager.months.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDayGroups) {
|
||||
group.sortAssets(options.order);
|
||||
}
|
||||
|
||||
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
|
||||
monthGroup.sortDayGroups();
|
||||
}
|
||||
|
||||
for (const month of addContext.updatedBuckets) {
|
||||
month.sortDayGroups();
|
||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||
}
|
||||
timelineManager.updateIntersections();
|
||||
}
|
||||
|
||||
export function runAssetOperation(
|
||||
timelineManager: TimelineManager,
|
||||
ids: Set<string>,
|
||||
operation: AssetOperation,
|
||||
options: { order: AssetOrder },
|
||||
) {
|
||||
if (ids.size === 0) {
|
||||
return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false };
|
||||
}
|
||||
|
||||
const changedMonthGroups = new SvelteSet<MonthGroup>();
|
||||
let idsToProcess = new SvelteSet(ids);
|
||||
const idsProcessed = new SvelteSet<string>();
|
||||
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
|
||||
for (const month of timelineManager.months) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
}
|
||||
idsToProcess = setDifference(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedMonthGroups.add(month);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
addAssetsToMonthGroups(
|
||||
timelineManager,
|
||||
combinedMoveAssets.flat().map((a) => a.asset),
|
||||
options,
|
||||
);
|
||||
}
|
||||
const changedGeometry = changedMonthGroups.size > 0;
|
||||
for (const month of changedMonthGroups) {
|
||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
timelineManager.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
@@ -13,10 +13,10 @@ export class WebsocketSupport {
|
||||
#processPendingChanges = throttle(() => {
|
||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||
if (add.length > 0) {
|
||||
this.#timelineManager.addAssets(add);
|
||||
this.#timelineManager.upsertAssets(add);
|
||||
}
|
||||
if (update.length > 0) {
|
||||
this.#timelineManager.updateAssets(update);
|
||||
this.#timelineManager.upsertAssets(update);
|
||||
}
|
||||
if (remove.length > 0) {
|
||||
this.#timelineManager.removeAssets(remove);
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
describe('upsertAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -194,7 +194,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
@@ -210,8 +210,8 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne]);
|
||||
timelineManager.addAssets([assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne]);
|
||||
timelineManager.upsertAssets([assetTwo]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(2);
|
||||
@@ -236,7 +236,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||
expect(month).not.toBeNull();
|
||||
@@ -262,7 +262,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(3);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
@@ -276,11 +276,11 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('updates existing asset', () => {
|
||||
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
|
||||
const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
@@ -292,7 +292,7 @@ describe('TimelineManager', () => {
|
||||
|
||||
const timelineManager = new TimelineManager();
|
||||
await timelineManager.updateOptions({ isTrashed: true });
|
||||
timelineManager.addAssets([asset, trashedAsset]);
|
||||
timelineManager.upsertAssets([asset, trashedAsset]);
|
||||
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
|
||||
});
|
||||
});
|
||||
@@ -307,22 +307,15 @@ describe('TimelineManager', () => {
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('ignores non-existing assets', () => {
|
||||
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(0);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
});
|
||||
|
||||
it('updates an asset', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
||||
const updatedAsset = { ...asset, isFavorite: true };
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
timelineManager.upsertAssets([updatedAsset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
|
||||
});
|
||||
@@ -338,12 +331,12 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
timelineManager.upsertAssets([updatedAsset]);
|
||||
expect(timelineManager.months.length).toEqual(2);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
|
||||
@@ -363,7 +356,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('ignores invalid IDs', () => {
|
||||
timelineManager.addAssets(
|
||||
timelineManager.upsertAssets(
|
||||
timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
@@ -383,7 +376,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
timelineManager.removeAssets([assetOne.id]);
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
@@ -397,7 +390,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.upsertAssets(assets);
|
||||
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
@@ -429,7 +422,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
|
||||
});
|
||||
});
|
||||
@@ -554,7 +547,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||
@@ -573,7 +566,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
|
||||
@@ -11,14 +11,11 @@ import {
|
||||
} from '$lib/utils/timeline-util';
|
||||
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
|
||||
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
||||
import {
|
||||
addAssetsToMonthGroups,
|
||||
runAssetOperation,
|
||||
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
|
||||
import {
|
||||
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
||||
findMonthGroupForDate,
|
||||
@@ -28,16 +25,9 @@ import {
|
||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
||||
import { DayGroup } from './day-group.svelte';
|
||||
import { isMismatched, updateObject } from './internal/utils.svelte';
|
||||
import { isMismatched } from './internal/utils.svelte';
|
||||
import { MonthGroup } from './month-group.svelte';
|
||||
import type {
|
||||
AssetDescriptor,
|
||||
AssetOperation,
|
||||
Direction,
|
||||
ScrubberMonth,
|
||||
TimelineAsset,
|
||||
TimelineManagerOptions,
|
||||
} from './types';
|
||||
import type { AssetDescriptor, Direction, ScrubberMonth, TimelineAsset, TimelineManagerOptions } from './types';
|
||||
|
||||
export class TimelineManager extends PhotostreamManager {
|
||||
albumAssets: Set<string> = new SvelteSet();
|
||||
@@ -181,12 +171,6 @@ export class TimelineManager extends PhotostreamManager {
|
||||
this.scrubberTimelineHeight = this.timelineHeight;
|
||||
}
|
||||
|
||||
addAssets(assets: TimelineAsset[]) {
|
||||
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
|
||||
const notUpdated = this.updateAssets(assetsToUpdate);
|
||||
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
async findMonthGroupForAsset(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
@@ -235,40 +219,6 @@ export class TimelineManager extends PhotostreamManager {
|
||||
return month?.getRandomAsset();
|
||||
}
|
||||
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
updateAssets(assets: TimelineAsset[]) {
|
||||
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||
const { unprocessedIds } = runAssetOperation(
|
||||
this,
|
||||
new SvelteSet(lookup.keys()),
|
||||
(asset) => {
|
||||
updateObject(asset, lookup.get(asset.id));
|
||||
return { remove: false };
|
||||
},
|
||||
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||
);
|
||||
const result: TimelineAsset[] = [];
|
||||
for (const id of unprocessedIds.values()) {
|
||||
result.push(lookup.get(id)!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
const { unprocessedIds } = runAssetOperation(
|
||||
this,
|
||||
new SvelteSet(ids),
|
||||
() => {
|
||||
return { remove: true };
|
||||
},
|
||||
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||
);
|
||||
return [...unprocessedIds];
|
||||
}
|
||||
|
||||
refreshLayout() {
|
||||
for (const month of this.months) {
|
||||
updateGeometry(this, month, { invalidateHeight: true });
|
||||
@@ -324,4 +274,42 @@ export class TimelineManager extends PhotostreamManager {
|
||||
getAssetOrder() {
|
||||
return this.#options.order ?? AssetOrder.Desc;
|
||||
}
|
||||
|
||||
protected createUpsertContext(): unknown {
|
||||
return new GroupInsertionCache();
|
||||
}
|
||||
|
||||
protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
|
||||
let month = getMonthGroupByDate(this, asset.localDateTime);
|
||||
|
||||
if (!month) {
|
||||
month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order);
|
||||
this.months.push(month);
|
||||
}
|
||||
|
||||
month.addTimelineAsset(asset, context);
|
||||
}
|
||||
|
||||
protected postCreateSegments(): void {
|
||||
this.months.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
protected postUpsert(context: GroupInsertionCache): void {
|
||||
for (const group of context.existingDayGroups) {
|
||||
group.sortAssets(this.#options.order);
|
||||
}
|
||||
|
||||
for (const monthGroup of context.bucketsWithNewDayGroups) {
|
||||
monthGroup.sortDayGroups();
|
||||
}
|
||||
|
||||
for (const month of context.updatedBuckets) {
|
||||
month.sortDayGroups();
|
||||
updateGeometry(this, month, { invalidateHeight: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export type TimelineAsset = {
|
||||
longitude?: number | null;
|
||||
};
|
||||
|
||||
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean } | void;
|
||||
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean } | unknown;
|
||||
|
||||
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
|
||||
|
||||
|
||||
@@ -98,5 +98,5 @@ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager,
|
||||
},
|
||||
);
|
||||
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.upsertAssets(assets);
|
||||
}
|
||||
|
||||
@@ -70,12 +70,12 @@
|
||||
|
||||
const handleLink: OnLink = ({ still, motion }) => {
|
||||
timelineManager.removeAssets([motion.id]);
|
||||
timelineManager.updateAssets([still]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleUnlink: OnUnlink = ({ still, motion }) => {
|
||||
timelineManager.addAssets([motion]);
|
||||
timelineManager.updateAssets([still]);
|
||||
timelineManager.upsertAssets([motion]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.updateAssets(updatedAssets);
|
||||
timelineManager.upsertAssets(updatedAssets);
|
||||
|
||||
handleDeselectAll();
|
||||
};
|
||||
@@ -195,7 +195,7 @@
|
||||
withStacked
|
||||
onThumbnailClick={handleThumbnailClick}
|
||||
>
|
||||
{#snippet customLayout(asset: TimelineAsset)}
|
||||
{#snippet customThumbnailLayout(asset: TimelineAsset)}
|
||||
{#if hasGps(asset)}
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
|
||||
{asset.city || $t('gps')}
|
||||
|
||||
Reference in New Issue
Block a user