Compare commits
3 Commits
fix/timeli
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0168353c2d | ||
|
|
c44b315117 | ||
|
|
98ab224791 |
@@ -161,7 +161,24 @@ export class NotificationService extends BaseService {
|
||||
|
||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
|
||||
if (asset) {
|
||||
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset));
|
||||
// need to specify authDto to this mapAsset request, because it tries to prevent
|
||||
// leaking information PR#7580 which expects a userId in the auth options object
|
||||
this.eventRepository.clientSend(
|
||||
'on_asset_update',
|
||||
userId,
|
||||
mapAsset(asset, {
|
||||
auth: {
|
||||
user: {
|
||||
id: userId,
|
||||
isAdmin: false,
|
||||
name: '',
|
||||
email: '',
|
||||
quotaUsageInBytes: 0,
|
||||
quotaSizeInBytes: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@
|
||||
|
||||
const onMouseLeave = () => {
|
||||
mouseOver = false;
|
||||
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
||||
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
|
||||
interface Props {
|
||||
segment: Snippet<
|
||||
[
|
||||
{
|
||||
segment: PhotostreamSegment;
|
||||
scrollToFunction: (top: number) => void;
|
||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||
},
|
||||
]
|
||||
>;
|
||||
skeleton: Snippet<
|
||||
[
|
||||
{
|
||||
segment: PhotostreamSegment;
|
||||
},
|
||||
]
|
||||
>;
|
||||
|
||||
showScrollbar?: boolean;
|
||||
/** `true` if this asset grid responds to navigation events; if `true`, then look at the
|
||||
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
|
||||
additionally, update the page location/url with the asset as the asset-grid is scrolled */
|
||||
enableRouting: boolean;
|
||||
timelineManager: PhotostreamManager;
|
||||
|
||||
showSkeleton?: boolean;
|
||||
isShowDeleteConfirmation?: boolean;
|
||||
styleMarginRightOverride?: string;
|
||||
|
||||
header?: Snippet<[scrollToFunction: (top: number) => void]>;
|
||||
children?: Snippet;
|
||||
empty?: Snippet;
|
||||
handleTimelineScroll?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
segment,
|
||||
|
||||
enableRouting,
|
||||
timelineManager = $bindable(),
|
||||
showSkeleton = $bindable(true),
|
||||
styleMarginRightOverride,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
showScrollbar,
|
||||
children,
|
||||
skeleton,
|
||||
empty,
|
||||
header,
|
||||
handleTimelineScroll = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let { gridScrollTarget } = assetViewingStore;
|
||||
|
||||
let element: HTMLElement | undefined = $state();
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
|
||||
const maxMd = $derived(mobileDevice.maxMd);
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
|
||||
$effect(() => {
|
||||
const layoutOptions = maxMd
|
||||
? {
|
||||
rowHeight: 100,
|
||||
headerHeight: 32,
|
||||
}
|
||||
: {
|
||||
rowHeight: 235,
|
||||
headerHeight: 48,
|
||||
};
|
||||
timelineManager.setLayoutOptions(layoutOptions);
|
||||
});
|
||||
|
||||
const scrollTo = (top: number) => {
|
||||
if (element) {
|
||||
element.scrollTo({ top });
|
||||
}
|
||||
updateSlidingWindow();
|
||||
};
|
||||
|
||||
const scrollBy = (y: number) => {
|
||||
if (element) {
|
||||
element.scrollBy(0, y);
|
||||
}
|
||||
updateSlidingWindow();
|
||||
};
|
||||
|
||||
const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => {
|
||||
const { heightDelta, scrollTop } = compensation;
|
||||
if (heightDelta !== undefined) {
|
||||
scrollBy(heightDelta);
|
||||
} else if (scrollTop !== undefined) {
|
||||
scrollTo(scrollTop);
|
||||
}
|
||||
timelineManager.clearScrollCompensation();
|
||||
};
|
||||
|
||||
const getAssetHeight = (assetId: string, monthGroup: PhotostreamSegment) => {
|
||||
// the following method may trigger any layouts, so need to
|
||||
// handle any scroll compensation that may have been set
|
||||
const height = monthGroup.findAssetAbsolutePosition(assetId);
|
||||
|
||||
// this is in a while loop, since scrollCompensations invoke scrolls
|
||||
// which may load months, triggering more scrollCompensations. Call
|
||||
// this in a loop, until no more layouts occur.
|
||||
while (timelineManager.scrollCompensation.monthGroup) {
|
||||
handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
|
||||
}
|
||||
return height;
|
||||
};
|
||||
|
||||
export const scrollToAssetId = async (assetId: string) => {
|
||||
const monthGroup = await timelineManager.findSegmentForAssetId(assetId);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const height = getAssetHeight(assetId, monthGroup);
|
||||
scrollTo(height);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const completeAfterNavigate = async ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) => {
|
||||
if (timelineManager.viewportHeight === 0 || timelineManager.viewportWidth === 0) {
|
||||
// this can happen if you do the following navigation order
|
||||
// /photos?at=<id>, /photos/<id>, http://example.com, browser back, browser back
|
||||
const rect = element?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
timelineManager.viewportHeight = rect.height;
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
if (scrollToAssetQueryParam) {
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollToAssetId(scrollTarget);
|
||||
}
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
scrollTo(0);
|
||||
}
|
||||
}
|
||||
showSkeleton = false;
|
||||
};
|
||||
|
||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||
// Yes, updateSlideWindow() is called by the onScroll event. However, if you also just scrolled
|
||||
// by explicitly invoking element.scrollX functions, there may be a delay with enough time to
|
||||
// set the intersecting property of the monthGroup to false, then true, which causes the DOM
|
||||
// nodes to be recreated, causing bad perf, and also, disrupting focus of those elements.
|
||||
// Also note: don't throttle, debounce, or otherwise do this function async - it causes flicker
|
||||
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
||||
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
showSkeleton = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<HotModuleReload
|
||||
onAfterUpdate={(payload: UpdatePayload) => {
|
||||
// when hmr happens, skeleton is initialized to true by default
|
||||
// normally, loading asset-grid is part of a navigation event, and the completion of
|
||||
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
|
||||
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
|
||||
// preventing skeleton from showing after hmr
|
||||
const finishHmr = () => {
|
||||
const asset = page.url.searchParams.get('at');
|
||||
if (asset) {
|
||||
$gridScrollTarget = { at: asset };
|
||||
}
|
||||
void completeAfterNavigate({ scrollToAssetQueryParam: true });
|
||||
};
|
||||
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('Photostream.svelte'));
|
||||
if (assetGridUpdate) {
|
||||
// wait 500ms for the update to be fully swapped in
|
||||
setTimeout(finishHmr, 500);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{@render header?.(scrollTo)}
|
||||
|
||||
<!-- Right margin MUST be equal to the width of scrubber -->
|
||||
<section
|
||||
id="asset-grid"
|
||||
class={[
|
||||
'h-full overflow-y-auto outline-none',
|
||||
{ 'scrollbar-hidden': !showScrollbar },
|
||||
{ 'm-0': isEmpty },
|
||||
{ 'ms-0': !isEmpty },
|
||||
]}
|
||||
style:margin-right={styleMarginRightOverride}
|
||||
style:scrollbar-width={showScrollbar ? 'auto' : 'none'}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
||||
bind:this={element}
|
||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||
>
|
||||
<section
|
||||
bind:this={timelineElement}
|
||||
id="virtual-timeline"
|
||||
class:invisible={showSkeleton}
|
||||
style:height={timelineManager.timelineHeight + 'px'}
|
||||
>
|
||||
<section
|
||||
use:resizeObserver={topSectionResizeObserver}
|
||||
class:invisible={showSkeleton}
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if isEmpty}
|
||||
<!-- (optional) empty placeholder -->
|
||||
{@render empty?.()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#each timelineManager.months as monthGroup (monthGroup.id)}
|
||||
{@const shouldDisplay = monthGroup.intersecting}
|
||||
{@const absoluteHeight = monthGroup.top}
|
||||
<div
|
||||
class="month-group"
|
||||
style:height={monthGroup.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
{#if !shouldDisplay}
|
||||
{@render skeleton({ segment: monthGroup })}
|
||||
{:else}
|
||||
{@render segment({
|
||||
segment: monthGroup,
|
||||
scrollToFunction: scrollTo,
|
||||
onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- spacer for lead-out -->
|
||||
<div
|
||||
class="h-[60px]"
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
|
||||
></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#asset-grid {
|
||||
contain: strict;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.month-group {
|
||||
contain: layout size paint;
|
||||
transform-style: flat;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: center center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,198 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Photostream from '$lib/components/timeline/Photostream.svelte';
|
||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
||||
import { findMonthAtScrollPosition } from '$lib/managers/timeline-manager/internal/search-support.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 { type ScrubberListenerWithScrollTo, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** `true` if this timeline responds to navigation events; if `true`, then look at the
|
||||
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
|
||||
additionally, update the page location/url with the asset as the asset-grid is scrolled */
|
||||
enableRouting: boolean;
|
||||
timelineManager: TimelineManager;
|
||||
|
||||
showSkeleton?: boolean;
|
||||
isShowDeleteConfirmation?: boolean;
|
||||
|
||||
segment: Snippet<
|
||||
[
|
||||
{
|
||||
segment: PhotostreamSegment;
|
||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||
},
|
||||
]
|
||||
>;
|
||||
skeleton: Snippet<
|
||||
[
|
||||
{
|
||||
segment: PhotostreamSegment;
|
||||
},
|
||||
]
|
||||
>;
|
||||
children?: Snippet;
|
||||
empty?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
enableRouting,
|
||||
timelineManager = $bindable(),
|
||||
|
||||
showSkeleton = true,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
segment,
|
||||
skeleton,
|
||||
children,
|
||||
empty,
|
||||
}: Props = $props();
|
||||
|
||||
const VIEWPORT_MULTIPLIER = 2; // Used to determine if timeline is "small"
|
||||
|
||||
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
|
||||
// Note: There may be multiple months visible within the viewport at any given time.
|
||||
let viewportTopMonthScrollPercent = $state(0);
|
||||
// The timeline month intersecting the top position of the viewport
|
||||
let viewportTopMonth: TimelineYearMonth | undefined = $state(undefined);
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
||||
let isInLeadOutSection = $state(false);
|
||||
// Width of the scrubber component in pixels, used to adjust timeline margins
|
||||
let scrubberWidth: number = $state(0);
|
||||
|
||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||
// this function updates the scrubber position based on the current scroll position in the timeline
|
||||
const handleTimelineScroll = () => {
|
||||
isInLeadOutSection = false;
|
||||
|
||||
// Handle edge cases: small timeline (limited scroll) or lead-in area scrolling
|
||||
const top = timelineManager.visibleWindow.top;
|
||||
if (isSmallTimeline() || top < timelineManager.topSectionHeight) {
|
||||
calculateTimelineScrollPercent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle normal month scrolling
|
||||
handleMonthScroll();
|
||||
};
|
||||
|
||||
const handleMonthScroll = () => {
|
||||
const scrollPosition = timelineManager.visibleWindow.top;
|
||||
const months = timelineManager.months;
|
||||
const maxScrollPercent = timelineManager.getMaxScrollPercent();
|
||||
|
||||
// Find the month at the current scroll position
|
||||
const searchResult = findMonthAtScrollPosition(months, scrollPosition, maxScrollPercent);
|
||||
|
||||
if (searchResult) {
|
||||
viewportTopMonth = searchResult.month;
|
||||
viewportTopMonthScrollPercent = searchResult.monthScrollPercent;
|
||||
isInLeadOutSection = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// We're in lead-out section
|
||||
isInLeadOutSection = true;
|
||||
timelineScrollPercent = 1;
|
||||
resetScrubberMonth();
|
||||
};
|
||||
|
||||
const resetScrubberMonth = () => {
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
};
|
||||
|
||||
const calculateTimelineScrollPercent = () => {
|
||||
const maxScroll = timelineManager.getMaxScroll();
|
||||
timelineScrollPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll);
|
||||
resetScrubberMonth();
|
||||
};
|
||||
|
||||
const handleOverallPercentScroll = (percent: number, scrollTo?: (offset: number) => void) => {
|
||||
const maxScroll = timelineManager.getMaxScroll();
|
||||
const offset = maxScroll * percent;
|
||||
scrollTo?.(offset);
|
||||
};
|
||||
|
||||
const findMonthGroup = (target: TimelineYearMonth) => {
|
||||
return timelineManager.months.find(
|
||||
({ yearMonth }) => yearMonth.year === target.year && yearMonth.month === target.month,
|
||||
);
|
||||
};
|
||||
|
||||
const isSmallTimeline = () => {
|
||||
return timelineManager.timelineHeight < timelineManager.viewportHeight * VIEWPORT_MULTIPLIER;
|
||||
};
|
||||
|
||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
|
||||
const onScrub: ScrubberListenerWithScrollTo = (scrubberData) => {
|
||||
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent, scrollToFunction } = scrubberData;
|
||||
|
||||
// Handle edge case or no month selected
|
||||
if (!scrubberMonth || isSmallTimeline()) {
|
||||
handleOverallPercentScroll(overallScrollPercent, scrollToFunction);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find and scroll to the selected month
|
||||
const monthGroup = findMonthGroup(scrubberMonth);
|
||||
if (monthGroup) {
|
||||
scrollToPositionWithinMonth(monthGroup, scrubberMonthScrollPercent, scrollToFunction);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToPositionWithinMonth = (
|
||||
monthGroup: MonthGroup,
|
||||
monthGroupScrollPercent: number,
|
||||
handleScrollTop?: (top: number) => void,
|
||||
) => {
|
||||
const topOffset = monthGroup.top;
|
||||
const maxScrollPercent = timelineManager.getMaxScrollPercent();
|
||||
const delta = monthGroup.height * monthGroupScrollPercent;
|
||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
handleScrollTop?.(scrollToTop);
|
||||
};
|
||||
let baseTimelineViewer: Photostream | undefined = $state();
|
||||
export const scrollToAsset = async (asset: TimelineAsset) =>
|
||||
(await baseTimelineViewer?.scrollToAssetId(asset.id)) ?? false;
|
||||
export const completeAfterNavigate = (args: { scrollToAssetQueryParam: boolean }) =>
|
||||
baseTimelineViewer?.completeAfterNavigate(args);
|
||||
</script>
|
||||
|
||||
<Photostream
|
||||
bind:this={baseTimelineViewer}
|
||||
{enableRouting}
|
||||
{timelineManager}
|
||||
{showSkeleton}
|
||||
{isShowDeleteConfirmation}
|
||||
styleMarginRightOverride={scrubberWidth + 'px'}
|
||||
{handleTimelineScroll}
|
||||
{segment}
|
||||
{skeleton}
|
||||
{children}
|
||||
{empty}
|
||||
>
|
||||
{#snippet header(scrollToFunction)}
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
{isInLeadOutSection}
|
||||
{timelineScrollPercent}
|
||||
{viewportTopMonthScrollPercent}
|
||||
{viewportTopMonth}
|
||||
onScrub={(scrubberData) => onScrub({ ...scrubberData, scrollToFunction })}
|
||||
bind:scrubberWidth
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Photostream>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
|
||||
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
content: Snippet<
|
||||
[
|
||||
{
|
||||
onDayGroupSelect: (dayGroup: DayGroup, asset: TimelineAsset[]) => void;
|
||||
onDayGroupAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
|
||||
},
|
||||
]
|
||||
>;
|
||||
onAssetSelect: (asset: TimelineAsset) => void;
|
||||
assetInteraction: AssetInteraction;
|
||||
}
|
||||
|
||||
let { content, assetInteraction, onAssetSelect }: Props = $props();
|
||||
|
||||
// called when clicking asset with shift key pressed or with mouse
|
||||
const onDayGroupAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => {
|
||||
onAssetSelect(asset);
|
||||
|
||||
const assetsInDayGroup = dayGroup.getAssets();
|
||||
const groupTitle = dayGroup.groupTitle;
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
const 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);
|
||||
}
|
||||
};
|
||||
|
||||
const onDayGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
|
||||
const group = dayGroup.groupTitle;
|
||||
if (assetInteraction.selectedGroup.has(group)) {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(group);
|
||||
for (const asset of assets) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
}
|
||||
} else {
|
||||
assetInteraction.addGroupToMultiselectGroup(group);
|
||||
for (const asset of assets) {
|
||||
onAssetSelect(asset);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{@render content({
|
||||
onDayGroupSelect,
|
||||
onDayGroupAssetSelect,
|
||||
})}
|
||||
@@ -1,208 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
|
||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
content: Snippet<
|
||||
[
|
||||
{
|
||||
onAssetOpen: (asset: TimelineAsset) => void;
|
||||
onAssetSelect: (asset: TimelineAsset) => void;
|
||||
onAssetHover: (asset: TimelineAsset | null) => void;
|
||||
},
|
||||
]
|
||||
>;
|
||||
segment: PhotostreamSegment;
|
||||
isSelectionMode: boolean;
|
||||
singleSelect: boolean;
|
||||
timelineManager: PhotostreamManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
|
||||
onAssetSelect?: (asset: TimelineAsset) => void;
|
||||
|
||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
segment,
|
||||
content,
|
||||
isSelectionMode,
|
||||
singleSelect,
|
||||
assetInteraction,
|
||||
timelineManager,
|
||||
onAssetOpen,
|
||||
onAssetSelect,
|
||||
onScrollCompensationMonthInDOM,
|
||||
}: Props = $props();
|
||||
|
||||
let shiftKeyIsDown = $state(false);
|
||||
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
let lastMouseHoverAsset: TimelineAsset | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (shiftKeyIsDown && lastMouseHoverAsset) {
|
||||
void selectAssetCandidates(lastMouseHoverAsset);
|
||||
}
|
||||
if (isEmpty) {
|
||||
assetInteraction.clearMultiselect();
|
||||
}
|
||||
});
|
||||
|
||||
const defaultAssetOpen = (asset: TimelineAsset) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
handleAssetSelect(asset);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const handleOnAssetOpen = (asset: TimelineAsset) => {
|
||||
if (onAssetOpen) {
|
||||
onAssetOpen(asset, () => defaultAssetOpen(asset));
|
||||
return;
|
||||
}
|
||||
defaultAssetOpen(asset);
|
||||
};
|
||||
|
||||
// called when clicking asset with shift key pressed or with mouse
|
||||
const handleAssetSelect = (asset: TimelineAsset) => {
|
||||
handleSelectAssets(asset);
|
||||
|
||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||
isSelectingAllAssets.set(true);
|
||||
} else {
|
||||
isSelectingAllAssets.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (searchStore.isSearchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = true;
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
if (lastMouseHoverAsset) {
|
||||
void selectAssetCandidates(lastMouseHoverAsset);
|
||||
return;
|
||||
}
|
||||
if (!assetInteraction.assetSelectionStart) {
|
||||
assetInteraction.setAssetSelectionStart(assetInteraction.selectedAssets.at(-1) ?? null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (searchStore.isSearchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = false;
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnHover = (asset: TimelineAsset | null) => {
|
||||
if (asset) {
|
||||
if (assetInteraction.selectionActive) {
|
||||
void selectAssetCandidates(asset);
|
||||
}
|
||||
lastMouseHoverAsset = asset;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAssets = (asset: TimelineAsset) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
onAssetSelect?.(asset);
|
||||
|
||||
if (singleSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
|
||||
const deselect = assetInteraction.hasSelectedAsset(asset.id);
|
||||
|
||||
// Select/deselect already loaded assets
|
||||
if (deselect) {
|
||||
for (const candidate of assetInteraction.assetSelectionCandidates) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
|
||||
}
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
} else {
|
||||
for (const candidate of assetInteraction.assetSelectionCandidates) {
|
||||
handleSelectAsset(candidate);
|
||||
}
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
|
||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||
const assets = timelineManager.retrieveLoadedRange(assetInteraction.assetSelectionStart, asset);
|
||||
for (const asset of assets) {
|
||||
if (deselect) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
} else {
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||
if ('albumAssets' in timelineManager) {
|
||||
const tm = timelineManager as TimelineManager;
|
||||
if (tm.albumAssets.has(asset.id)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
assetInteraction.selectAsset(asset);
|
||||
};
|
||||
|
||||
const selectAssetCandidates = (endAsset: TimelineAsset) => {
|
||||
if (!shiftKeyIsDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startAsset = assetInteraction.assetSelectionStart;
|
||||
if (!startAsset) {
|
||||
return;
|
||||
}
|
||||
const assets = assetsSnapshot(timelineManager.retrieveLoadedRange(startAsset, endAsset));
|
||||
assetInteraction.setAssetSelectionCandidates(assets);
|
||||
};
|
||||
|
||||
$effect.root(() => {
|
||||
if (timelineManager.scrollCompensation.monthGroup === segment) {
|
||||
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||
|
||||
{@render content({
|
||||
onAssetOpen: handleOnAssetOpen,
|
||||
onAssetSelect: (asset) => {
|
||||
void handleSelectAssets(asset);
|
||||
},
|
||||
onAssetHover: handleOnHover,
|
||||
})}
|
||||
@@ -1,26 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
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 PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte';
|
||||
import SelectableDay from '$lib/components/timeline/SelectableDay.svelte';
|
||||
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
|
||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
||||
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
||||
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import {
|
||||
getSegmentIdentifier,
|
||||
getTimes,
|
||||
type ScrubberListener,
|
||||
type TimelineYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
|
||||
interface Props {
|
||||
isSelectionMode?: boolean;
|
||||
@@ -44,12 +54,22 @@
|
||||
album?: AlbumResponseDto | null;
|
||||
person?: PersonResponseDto | null;
|
||||
isShowDeleteConfirmation?: boolean;
|
||||
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
|
||||
onAssetSelect?: (asset: TimelineAsset) => void;
|
||||
onSelect?: (asset: TimelineAsset) => void;
|
||||
onEscape?: () => void;
|
||||
children?: Snippet;
|
||||
empty?: Snippet;
|
||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||
onThumbnailClick?: (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -65,149 +85,694 @@
|
||||
album = null,
|
||||
person = null,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
|
||||
onAssetSelect,
|
||||
onAssetOpen,
|
||||
onSelect = () => {},
|
||||
onEscape = () => {},
|
||||
children,
|
||||
empty,
|
||||
customThumbnailLayout,
|
||||
onThumbnailClick,
|
||||
}: Props = $props();
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||
|
||||
let viewer: PhotostreamWithScrubber | undefined = $state();
|
||||
let showSkeleton: boolean = $state(true);
|
||||
let element: HTMLElement | undefined = $state();
|
||||
|
||||
// tri-state boolean
|
||||
let initialLoadWasAssetViewer: boolean | null = null;
|
||||
let hasNavigatedToOrFromAssetViewer: boolean = false;
|
||||
let timelineScrollPositionInitialized = false;
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
let showSkeleton = $state(true);
|
||||
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
|
||||
// Note: There may be multiple months visible within the viewport at any given time.
|
||||
let viewportTopMonthScrollPercent = $state(0);
|
||||
// The timeline month intersecting the top position of the viewport
|
||||
let viewportTopMonth: { year: number; month: number } | undefined = $state(undefined);
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
|
||||
beforeNavigate(({ from, to }) => {
|
||||
timelineManager.suspendTransitions = true;
|
||||
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
|
||||
// 60 is the bottom spacer element at 60px
|
||||
let bottomSectionHeight = 60;
|
||||
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
||||
let isInLeadOutSection = $state(false);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const maxMd = $derived(mobileDevice.maxMd);
|
||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||
|
||||
$effect(() => {
|
||||
const layoutOptions = maxMd
|
||||
? {
|
||||
rowHeight: 100,
|
||||
headerHeight: 32,
|
||||
}
|
||||
: {
|
||||
rowHeight: 235,
|
||||
headerHeight: 48,
|
||||
};
|
||||
timelineManager.setLayoutOptions(layoutOptions);
|
||||
});
|
||||
|
||||
const completeAfterNavigate = () => {
|
||||
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
|
||||
let isInitial = false;
|
||||
// Set initial load state only once
|
||||
if (initialLoadWasAssetViewer === null) {
|
||||
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||
isInitial = true;
|
||||
const scrollTo = (top: number) => {
|
||||
if (element) {
|
||||
element.scrollTo({ top });
|
||||
}
|
||||
};
|
||||
const scrollTop = (top: number) => {
|
||||
if (element) {
|
||||
element.scrollTop = top;
|
||||
}
|
||||
};
|
||||
const scrollBy = (y: number) => {
|
||||
if (element) {
|
||||
element.scrollBy(0, y);
|
||||
}
|
||||
};
|
||||
const scrollToTop = () => {
|
||||
scrollTo(0);
|
||||
};
|
||||
|
||||
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
|
||||
// the following method may trigger any layouts, so need to
|
||||
// handle any scroll compensation that may have been set
|
||||
const height = monthGroup!.findAssetAbsolutePosition(assetId);
|
||||
|
||||
while (timelineManager.scrollCompensation.monthGroup) {
|
||||
handleScrollCompensation(timelineManager.scrollCompensation);
|
||||
timelineManager.clearScrollCompensation();
|
||||
}
|
||||
return height;
|
||||
};
|
||||
|
||||
const assetIsVisible = (assetTop: number): boolean => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let scrollToAssetQueryParam = false;
|
||||
if (
|
||||
!timelineScrollPositionInitialized &&
|
||||
((isInitial && !assetViewerPage) || // Direct timeline load
|
||||
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
|
||||
) {
|
||||
scrollToAssetQueryParam = true;
|
||||
timelineScrollPositionInitialized = true;
|
||||
const { clientHeight, scrollTop } = element;
|
||||
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
|
||||
};
|
||||
|
||||
const scrollToAssetId = async (assetId: string) => {
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
|
||||
};
|
||||
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
|
||||
const height = getAssetHeight(assetId, monthGroup);
|
||||
|
||||
const onViewerClose = async (asset: { id: string }) => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
showSkeleton = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||
// If the asset is already visible, then don't scroll.
|
||||
if (assetIsVisible(height)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollToAsset = (asset: TimelineAsset) => {
|
||||
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
const height = getAssetHeight(asset.id, monthGroup);
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
return true;
|
||||
};
|
||||
|
||||
const completeNav = async () => {
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollToAssetId(scrollTarget);
|
||||
}
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
scrollToTop();
|
||||
}
|
||||
showSkeleton = false;
|
||||
};
|
||||
|
||||
beforeNavigate(() => (timelineManager.suspendTransitions = true));
|
||||
|
||||
afterNavigate((nav) => {
|
||||
const { complete } = nav;
|
||||
complete.then(completeNav, completeNav);
|
||||
});
|
||||
|
||||
const handleAfterUpdate = (payload: UpdatePayload) => {
|
||||
const timelineUpdate = payload.updates.some(
|
||||
(update) => update.path.endsWith('Timeline.svelte') || update.path.endsWith('assets-store.ts'),
|
||||
);
|
||||
|
||||
if (timelineUpdate) {
|
||||
setTimeout(() => {
|
||||
const asset = $page.url.searchParams.get('at');
|
||||
if (asset) {
|
||||
$gridScrollTarget = { at: asset };
|
||||
void navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||
{ replaceState: true, forceNavigate: true },
|
||||
);
|
||||
} else {
|
||||
scrollToTop();
|
||||
}
|
||||
showSkeleton = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeUpdate = (payload: UpdatePayload) => {
|
||||
const timelineUpdate = payload.updates.some((update) => update.path.endsWith('Timeline.svelte'));
|
||||
if (timelineUpdate) {
|
||||
timelineManager.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
||||
|
||||
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
|
||||
if (heightDelta !== undefined) {
|
||||
scrollBy(heightDelta);
|
||||
} else if (scrollTop !== undefined) {
|
||||
scrollTo(scrollTop);
|
||||
}
|
||||
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
|
||||
// the above calls. However, this delay is enough time to set the intersecting property
|
||||
// of the monthGroup to false, then true, which causes the DOM nodes to be recreated,
|
||||
// causing bad perf, and also, disrupting focus of those elements.
|
||||
updateSlidingWindow();
|
||||
};
|
||||
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
showSkeleton = false;
|
||||
}
|
||||
});
|
||||
|
||||
const getMaxScrollPercent = () => {
|
||||
const totalHeight = timelineManager.timelineHeight + bottomSectionHeight + timelineManager.topSectionHeight;
|
||||
return (totalHeight - timelineManager.viewportHeight) / totalHeight;
|
||||
};
|
||||
|
||||
const getMaxScroll = () => {
|
||||
if (!element || !timelineElement) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
timelineManager.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight)
|
||||
);
|
||||
};
|
||||
|
||||
const scrollToMonthGroupAndOffset = (monthGroup: MonthGroup, monthGroupScrollPercent: number) => {
|
||||
const topOffset = monthGroup.top;
|
||||
const maxScrollPercent = getMaxScrollPercent();
|
||||
const delta = monthGroup.height * monthGroupScrollPercent;
|
||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
scrollTop(scrollToTop);
|
||||
};
|
||||
|
||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
|
||||
const onScrub: ScrubberListener = (scrubberData) => {
|
||||
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
|
||||
|
||||
if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
const offset = maxScroll * overallScrollPercent;
|
||||
scrollTop(offset);
|
||||
} else {
|
||||
const monthGroup = timelineManager.months.find(
|
||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||
);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
scrollToMonthGroupAndOffset(monthGroup, scrubberMonthScrollPercent);
|
||||
}
|
||||
};
|
||||
|
||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||
const handleTimelineScroll = () => {
|
||||
isInLeadOutSection = false;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
} else {
|
||||
let top = element.scrollTop;
|
||||
if (top < timelineManager.topSectionHeight) {
|
||||
// in the lead-in area
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
const maxScroll = getMaxScroll();
|
||||
|
||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
return;
|
||||
}
|
||||
|
||||
let maxScrollPercent = getMaxScrollPercent();
|
||||
let found = false;
|
||||
|
||||
const monthsLength = timelineManager.months.length;
|
||||
for (let i = -1; i < monthsLength + 1; i++) {
|
||||
let monthGroup: TimelineYearMonth | undefined;
|
||||
let monthGroupHeight = 0;
|
||||
if (i === -1) {
|
||||
// lead-in
|
||||
monthGroupHeight = timelineManager.topSectionHeight;
|
||||
} else if (i === monthsLength) {
|
||||
// lead-out
|
||||
monthGroupHeight = bottomSectionHeight;
|
||||
} else {
|
||||
monthGroup = timelineManager.months[i].yearMonth;
|
||||
monthGroupHeight = timelineManager.months[i].height;
|
||||
}
|
||||
|
||||
let next = top - monthGroupHeight * maxScrollPercent;
|
||||
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
||||
if (next < -1 && monthGroup) {
|
||||
viewportTopMonth = monthGroup;
|
||||
|
||||
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||
viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
|
||||
|
||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
}
|
||||
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
top = next;
|
||||
}
|
||||
if (!found) {
|
||||
isInLeadOutSection = true;
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
timelineScrollPercent = 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||
if (!timelineManager.albumAssets.has(asset.id)) {
|
||||
assetInteraction.selectAsset(asset);
|
||||
}
|
||||
};
|
||||
|
||||
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
||||
|
||||
let shiftKeyIsDown = $state(false);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = false;
|
||||
}
|
||||
};
|
||||
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
|
||||
if (asset) {
|
||||
void selectAssetCandidates(asset);
|
||||
}
|
||||
lastAssetMouseEvent = asset;
|
||||
};
|
||||
|
||||
const handleGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
|
||||
const group = dayGroup.groupTitle;
|
||||
if (assetInteraction.selectedGroup.has(group)) {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(group);
|
||||
for (const asset of assets) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
}
|
||||
} else {
|
||||
assetInteraction.addGroupToMultiselectGroup(group);
|
||||
for (const asset of assets) {
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
}
|
||||
|
||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||
isSelectingAllAssets.set(true);
|
||||
} else {
|
||||
isSelectingAllAssets.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectAssets = async (asset: TimelineAsset) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
onSelect(asset);
|
||||
|
||||
if (singleSelect) {
|
||||
scrollTop(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
|
||||
const deselect = assetInteraction.hasSelectedAsset(asset.id);
|
||||
|
||||
// Select/deselect already loaded assets
|
||||
if (deselect) {
|
||||
for (const candidate of assetInteraction.assetSelectionCandidates) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
|
||||
}
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
} else {
|
||||
for (const candidate of assetInteraction.assetSelectionCandidates) {
|
||||
handleSelectAsset(candidate);
|
||||
}
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
|
||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
|
||||
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||
|
||||
if (startBucket === null || endBucket === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Select/deselect assets in range (start,end)
|
||||
let started = false;
|
||||
for (const monthGroup of timelineManager.months) {
|
||||
if (monthGroup === endBucket) {
|
||||
break;
|
||||
}
|
||||
if (started) {
|
||||
await timelineManager.loadSegment(monthGroup.identifier);
|
||||
for (const asset of monthGroup.assetsIterator()) {
|
||||
if (deselect) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
} else {
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (monthGroup === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update date group selection in range [start,end]
|
||||
started = false;
|
||||
for (const monthGroup of timelineManager.months) {
|
||||
if (monthGroup === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
if (started) {
|
||||
// Split month group into day groups and check each group
|
||||
for (const dayGroup of monthGroup.dayGroups) {
|
||||
const dayGroupTitle = dayGroup.groupTitle;
|
||||
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (monthGroup === endBucket) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
||||
};
|
||||
|
||||
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
|
||||
if (!shiftKeyIsDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startAsset = assetInteraction.assetSelectionStart;
|
||||
if (!startAsset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
|
||||
assetInteraction.setAssetSelectionCandidates(assets);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!lastAssetMouseEvent) {
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!shiftKeyIsDown) {
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (shiftKeyIsDown && lastAssetMouseEvent) {
|
||||
void selectAssetCandidates(lastAssetMouseEvent);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($showAssetViewer) {
|
||||
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
||||
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} />
|
||||
|
||||
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
|
||||
|
||||
<TimelineKeyboardActions
|
||||
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
|
||||
scrollToAsset={(asset) => scrollToAsset(asset) ?? false}
|
||||
{timelineManager}
|
||||
{assetInteraction}
|
||||
bind:isShowDeleteConfirmation
|
||||
{onEscape}
|
||||
/>
|
||||
|
||||
<PhotostreamWithScrubber
|
||||
bind:this={viewer}
|
||||
{enableRouting}
|
||||
{timelineManager}
|
||||
{isShowDeleteConfirmation}
|
||||
{showSkeleton}
|
||||
{children}
|
||||
{empty}
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={bottomSectionHeight}
|
||||
{isInLeadOutSection}
|
||||
{timelineScrollPercent}
|
||||
{viewportTopMonthScrollPercent}
|
||||
{viewportTopMonth}
|
||||
{onScrub}
|
||||
bind:scrubberWidth
|
||||
onScrubKeyDown={(evt) => {
|
||||
evt.preventDefault();
|
||||
let amount = 50;
|
||||
if (shiftKeyIsDown) {
|
||||
amount = 500;
|
||||
}
|
||||
if (evt.key === 'ArrowUp') {
|
||||
amount = -amount;
|
||||
if (shiftKeyIsDown) {
|
||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||
}
|
||||
} else if (evt.key === 'ArrowDown') {
|
||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Right margin MUST be equal to the width of scrubber -->
|
||||
<section
|
||||
id="asset-grid"
|
||||
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
||||
bind:this={element}
|
||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||
>
|
||||
{#snippet skeleton({ segment })}
|
||||
<Skeleton
|
||||
height={segment.height - segment.timelineManager.headerHeight}
|
||||
title={(segment as MonthGroup).monthGroupTitle}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
|
||||
<SelectableSegment
|
||||
{segment}
|
||||
{onScrollCompensationMonthInDOM}
|
||||
{timelineManager}
|
||||
{assetInteraction}
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
{onAssetOpen}
|
||||
{onAssetSelect}
|
||||
<section
|
||||
bind:this={timelineElement}
|
||||
id="virtual-timeline"
|
||||
class:invisible={showSkeleton}
|
||||
style:height={timelineManager.timelineHeight + 'px'}
|
||||
>
|
||||
<section
|
||||
use:resizeObserver={topSectionResizeObserver}
|
||||
class:invisible={showSkeleton}
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
>
|
||||
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
|
||||
<SelectableDay {assetInteraction} {onAssetSelect}>
|
||||
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
|
||||
<MonthSegment
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
monthGroup={segment as MonthGroup}
|
||||
{timelineManager}
|
||||
{onDayGroupSelect}
|
||||
>
|
||||
{#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={() => onAssetOpen(asset)}
|
||||
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
|
||||
onMouseEvent={(isMouseOver) => {
|
||||
if (isMouseOver) {
|
||||
onAssetHover(asset);
|
||||
} else {
|
||||
onAssetHover(null);
|
||||
}
|
||||
}}
|
||||
selected={isAssetSelected}
|
||||
selectionCandidate={isAssetSelectionCandidate}
|
||||
disabled={isAssetDisabled}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
{/snippet}
|
||||
</MonthSegment>
|
||||
{/snippet}
|
||||
</SelectableDay>
|
||||
{/snippet}
|
||||
</SelectableSegment>
|
||||
{/snippet}
|
||||
</PhotostreamWithScrubber>
|
||||
{@render children?.()}
|
||||
{#if isEmpty}
|
||||
<!-- (optional) empty placeholder -->
|
||||
{@render empty?.()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
|
||||
{@const display = monthGroup.intersecting}
|
||||
{@const absoluteHeight = monthGroup.top}
|
||||
|
||||
{#if !monthGroup.isLoaded}
|
||||
<div
|
||||
style:height={monthGroup.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<Skeleton
|
||||
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
|
||||
title={monthGroup.monthGroupTitle}
|
||||
/>
|
||||
</div>
|
||||
{:else if display}
|
||||
<div
|
||||
class="month-group"
|
||||
style:height={monthGroup.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<MonthSegment
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
{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}
|
||||
<!-- spacer for leadout -->
|
||||
<div
|
||||
class="h-[60px]"
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
|
||||
></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
|
||||
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
|
||||
{/if}
|
||||
</Portal>
|
||||
|
||||
<style>
|
||||
#asset-grid {
|
||||
contain: strict;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.month-group {
|
||||
contain: layout size paint;
|
||||
transform-style: flat;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: center center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,16 +9,15 @@
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
|
||||
let { asset: viewingAsset, mutex, preloadAssets } = assetViewingStore;
|
||||
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
|
||||
|
||||
interface Props {
|
||||
timelineManager: TimelineManager;
|
||||
|
||||
showSkeleton: boolean;
|
||||
withStacked?: boolean;
|
||||
isShared?: boolean;
|
||||
album?: AlbumResponseDto | null;
|
||||
person?: PersonResponseDto | null;
|
||||
onViewerClose?: (asset: { id: string }) => Promise<void>;
|
||||
|
||||
removeAction?:
|
||||
| AssetAction.UNARCHIVE
|
||||
@@ -31,12 +30,12 @@
|
||||
|
||||
let {
|
||||
timelineManager,
|
||||
showSkeleton = $bindable(false),
|
||||
removeAction,
|
||||
withStacked = false,
|
||||
isShared = false,
|
||||
album = null,
|
||||
person = null,
|
||||
onViewerClose = () => Promise.resolve(void 0),
|
||||
}: Props = $props();
|
||||
|
||||
const handlePrevious = async () => {
|
||||
@@ -80,6 +79,13 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
showSkeleton = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||
};
|
||||
|
||||
const handlePreAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case removeAction:
|
||||
@@ -91,7 +97,7 @@
|
||||
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
||||
// find the next asset to show or close the viewer
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
(await handleNext()) || (await handlePrevious()) || (await onViewerClose?.(action.asset));
|
||||
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
|
||||
|
||||
// delete after find the next one
|
||||
timelineManager.removeAssets([action.asset.id]);
|
||||
@@ -105,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;
|
||||
}
|
||||
|
||||
@@ -119,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(
|
||||
@@ -166,6 +172,6 @@
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onRandom={handleRandom}
|
||||
onClose={onViewerClose}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
{/await}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.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';
|
||||
@@ -12,9 +13,10 @@
|
||||
onArchive?: OnArchive;
|
||||
menuItem?: boolean;
|
||||
unarchive?: boolean;
|
||||
manager?: PhotostreamManager;
|
||||
}
|
||||
|
||||
let { onArchive, menuItem = false, unarchive = false }: Props = $props();
|
||||
let { onArchive, menuItem = false, unarchive = false, manager }: Props = $props();
|
||||
|
||||
let text = $derived(unarchive ? $t('unarchive') : $t('to_archive'));
|
||||
let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
|
||||
@@ -24,12 +26,13 @@
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleArchive = async () => {
|
||||
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
|
||||
const visibility = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== visibility);
|
||||
loading = true;
|
||||
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
|
||||
const ids = await archiveAssets(assets, visibility as AssetVisibility);
|
||||
if (ids) {
|
||||
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
|
||||
manager?.updateAssetOperation(ids, (asset) => ((asset.visibility = visibility), void 0));
|
||||
onArchive?.(ids, visibility ? AssetVisibility.Archive : AssetVisibility.Timeline);
|
||||
clearSelect();
|
||||
}
|
||||
loading = false;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.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';
|
||||
import { IconButton } from '@immich/ui';
|
||||
@@ -9,13 +11,14 @@
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
onAssetDelete: OnDelete;
|
||||
onUndoDelete?: OnUndoDelete | undefined;
|
||||
onAssetDelete?: OnDelete;
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
menuItem?: boolean;
|
||||
force?: boolean;
|
||||
manager?: PhotostreamManager;
|
||||
}
|
||||
|
||||
let { onAssetDelete, onUndoDelete = undefined, menuItem = false, force = !$featureFlags.trash }: Props = $props();
|
||||
let { onAssetDelete, onUndoDelete, menuItem = false, force = !$featureFlags.trash, manager }: Props = $props();
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
@@ -36,7 +39,12 @@
|
||||
const handleDelete = async () => {
|
||||
loading = true;
|
||||
const assets = [...getOwnedAssets()];
|
||||
await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
|
||||
const undo = (assets: TimelineAsset[]) => {
|
||||
manager?.upsertAssets(assets);
|
||||
onUndoDelete?.(assets);
|
||||
};
|
||||
await deleteAssets(force, onAssetDelete, assets, undo);
|
||||
manager?.removeAssets(assets.map((asset) => asset.id));
|
||||
clearSelect();
|
||||
isShowConfirmation = false;
|
||||
loading = false;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.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';
|
||||
@@ -16,9 +17,10 @@
|
||||
onFavorite?: OnFavorite;
|
||||
menuItem?: boolean;
|
||||
removeFavorite: boolean;
|
||||
manager?: PhotostreamManager;
|
||||
}
|
||||
|
||||
let { onFavorite, menuItem = false, removeFavorite }: Props = $props();
|
||||
let { onFavorite, menuItem = false, removeFavorite, manager }: Props = $props();
|
||||
|
||||
let text = $derived(removeFavorite ? $t('remove_from_favorites') : $t('to_favorite'));
|
||||
let icon = $derived(removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline);
|
||||
@@ -39,11 +41,7 @@
|
||||
if (ids.length > 0) {
|
||||
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
asset.isFavorite = isFavorite;
|
||||
}
|
||||
|
||||
manager?.updateAssetOperation(ids, (asset) => ((asset.isFavorite = isFavorite), void 0));
|
||||
onFavorite?.(ids, isFavorite);
|
||||
|
||||
notificationController.show({
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
type RelativeResult,
|
||||
} from '$lib/components/shared-components/change-date.svelte';
|
||||
import {
|
||||
setFocusToAsset as setFocusAssetUtil,
|
||||
setFocusTo as setFocusToUtil,
|
||||
type FocusDirection,
|
||||
type FocusInterval,
|
||||
setFocusToAsset as setFocusAssetInit,
|
||||
setFocusTo as setFocusToInit,
|
||||
} from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
@@ -34,7 +32,7 @@
|
||||
assetInteraction: AssetInteraction;
|
||||
isShowDeleteConfirmation: boolean;
|
||||
onEscape?: () => void;
|
||||
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>;
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -53,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();
|
||||
};
|
||||
@@ -149,10 +147,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
const setFocusTo = (direction: FocusDirection, interval: FocusInterval) =>
|
||||
setFocusToUtil(scrollToAsset, timelineManager, direction, interval);
|
||||
|
||||
const setFocusAsset = (asset: TimelineAsset) => setFocusAssetUtil(scrollToAsset, asset);
|
||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
||||
|
||||
let shortcutList = $derived(
|
||||
(() => {
|
||||
@@ -216,7 +212,7 @@
|
||||
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
|
||||
);
|
||||
if (asset) {
|
||||
void setFocusAsset(asset);
|
||||
setFocusAsset(asset);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -21,26 +21,19 @@ export const focusPreviousAsset = () =>
|
||||
|
||||
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
||||
|
||||
export const setFocusToAsset = async (
|
||||
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
const scrolled = await scrollToAsset(asset);
|
||||
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
|
||||
const scrolled = scrollToAsset(asset);
|
||||
if (scrolled) {
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export type FocusDirection = 'earlier' | 'later';
|
||||
|
||||
export type FocusInterval = 'day' | 'month' | 'year' | 'asset';
|
||||
|
||||
export const setFocusTo = async (
|
||||
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean,
|
||||
store: TimelineManager,
|
||||
direction: FocusDirection,
|
||||
interval: FocusInterval,
|
||||
direction: 'earlier' | 'later',
|
||||
interval: 'day' | 'month' | 'year' | 'asset',
|
||||
) => {
|
||||
if (tracker.isActive()) {
|
||||
// there are unfinished running invocations, so return early
|
||||
@@ -72,10 +65,7 @@ export const setFocusTo = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const scrolled = await scrollToAsset(asset);
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
}
|
||||
const scrolled = scrollToAsset(asset);
|
||||
if (scrolled) {
|
||||
await tick();
|
||||
if (!invocation.isStillValid()) {
|
||||
|
||||
@@ -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);
|
||||
@@ -269,14 +274,13 @@ export abstract class PhotostreamManager {
|
||||
return this.months.find((segment) => identifier.matches(segment));
|
||||
}
|
||||
|
||||
findSegmentForAssetId(assetId: string): Promise<PhotostreamSegment | undefined> {
|
||||
getSegmentForAssetId(assetId: string) {
|
||||
for (const month of this.months) {
|
||||
const asset = month.assets.find((asset) => asset.id === assetId);
|
||||
if (asset) {
|
||||
return Promise.resolve(month);
|
||||
return month;
|
||||
}
|
||||
}
|
||||
return Promise.resolve(void 0);
|
||||
}
|
||||
|
||||
refreshLayout() {
|
||||
@@ -295,15 +299,11 @@ export abstract class PhotostreamManager {
|
||||
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
|
||||
}
|
||||
|
||||
retrieveLoadedRange(start: AssetDescriptor, end: AssetDescriptor): TimelineAsset[] {
|
||||
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
|
||||
const range: TimelineAsset[] = [];
|
||||
let collecting = false;
|
||||
|
||||
for (const month of this.months) {
|
||||
if (collecting && !month.isLoaded) {
|
||||
// if there are any unloaded months in the range, return empty []
|
||||
return [];
|
||||
}
|
||||
for (const asset of month.assets) {
|
||||
if (asset.id === start.id) {
|
||||
collecting = true;
|
||||
@@ -312,14 +312,112 @@ export abstract class PhotostreamManager {
|
||||
range.push(asset);
|
||||
}
|
||||
if (asset.id === end.id) {
|
||||
return range;
|
||||
return Promise.resolve(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
return range;
|
||||
return Promise.resolve(range);
|
||||
}
|
||||
|
||||
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
|
||||
return Promise.resolve(this.retrieveLoadedRange(start, end));
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
@@ -143,79 +143,3 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MonthGroupForSearch {
|
||||
yearMonth: TimelineYearMonth;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BinarySearchResult {
|
||||
month: TimelineYearMonth;
|
||||
monthScrollPercent: number;
|
||||
}
|
||||
|
||||
export function findMonthAtScrollPosition(
|
||||
months: MonthGroupForSearch[],
|
||||
scrollPosition: number,
|
||||
maxScrollPercent: number,
|
||||
): BinarySearchResult | null {
|
||||
const SUBPIXEL_TOLERANCE = -1; // Tolerance for scroll position checks
|
||||
const NEAR_END_THRESHOLD = 0.9999; // Threshold for detecting near-end of month
|
||||
|
||||
if (months.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we're before the first month
|
||||
const firstMonthTop = months[0].top * maxScrollPercent;
|
||||
if (scrollPosition < firstMonthTop - SUBPIXEL_TOLERANCE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we're after the last month
|
||||
const lastMonth = months.at(-1)!;
|
||||
const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent;
|
||||
if (scrollPosition >= lastMonthBottom - SUBPIXEL_TOLERANCE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Binary search to find the month containing the scroll position
|
||||
let left = 0;
|
||||
let right = months.length - 1;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const month = months[mid];
|
||||
const monthTop = month.top * maxScrollPercent;
|
||||
const monthBottom = monthTop + month.height * maxScrollPercent;
|
||||
|
||||
if (scrollPosition >= monthTop - SUBPIXEL_TOLERANCE && scrollPosition < monthBottom - SUBPIXEL_TOLERANCE) {
|
||||
// Found the month containing the scroll position
|
||||
const distanceIntoMonth = scrollPosition - monthTop;
|
||||
const monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent));
|
||||
|
||||
// Handle month boundary edge case
|
||||
if (monthScrollPercent > NEAR_END_THRESHOLD && mid < months.length - 1) {
|
||||
return {
|
||||
month: months[mid + 1].yearMonth,
|
||||
monthScrollPercent: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
month: month.yearMonth,
|
||||
monthScrollPercent,
|
||||
};
|
||||
}
|
||||
|
||||
if (scrollPosition < monthTop) {
|
||||
right = mid - 1;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Shouldn't reach here, but return null if we do
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import {
|
||||
findMonthGroupForAsset,
|
||||
getMonthGroupByDate,
|
||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
@@ -176,7 +173,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
describe('upsertAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -197,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);
|
||||
@@ -213,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);
|
||||
@@ -239,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();
|
||||
@@ -265,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);
|
||||
@@ -279,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);
|
||||
});
|
||||
@@ -295,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]);
|
||||
});
|
||||
});
|
||||
@@ -310,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);
|
||||
});
|
||||
@@ -341,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);
|
||||
@@ -366,7 +356,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('ignores invalid IDs', () => {
|
||||
timelineManager.addAssets(
|
||||
timelineManager.upsertAssets(
|
||||
timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
@@ -386,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);
|
||||
@@ -400,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);
|
||||
@@ -432,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);
|
||||
});
|
||||
});
|
||||
@@ -557,12 +547,12 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.month).toEqual(2);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('ignores removed months', () => {
|
||||
@@ -576,11 +566,11 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,13 +171,7 @@ 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 findSegmentForAssetId(id: string) {
|
||||
async findMonthGroupForAsset(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
@@ -218,6 +202,11 @@ export class TimelineManager extends PhotostreamManager {
|
||||
return getMonthGroupByDate(this, yearMonth);
|
||||
}
|
||||
|
||||
getMonthGroupByAssetId(assetId: string) {
|
||||
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
|
||||
return monthGroupInfo?.monthGroup;
|
||||
}
|
||||
|
||||
async getRandomMonthGroup() {
|
||||
const random = Math.floor(Math.random() * this.months.length);
|
||||
const month = this.months[random];
|
||||
@@ -230,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 });
|
||||
@@ -319,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 };
|
||||
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean } | unknown;
|
||||
|
||||
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
|
||||
|
||||
|
||||
@@ -21,15 +21,15 @@ export type OnSetVisibility = (ids: string[]) => void;
|
||||
|
||||
export const deleteAssets = async (
|
||||
force: boolean,
|
||||
onAssetDelete: OnDelete,
|
||||
onAssetDelete: OnDelete | undefined,
|
||||
assets: TimelineAsset[],
|
||||
onUndoDelete: OnUndoDelete | undefined = undefined,
|
||||
onUndoDelete: OnUndoDelete | undefined,
|
||||
) => {
|
||||
const $t = get(t);
|
||||
try {
|
||||
const ids = assets.map((a) => a.id);
|
||||
await deleteBulk({ assetBulkDeleteDto: { ids, force } });
|
||||
onAssetDelete(ids);
|
||||
onAssetDelete?.(ids);
|
||||
|
||||
notificationController.show({
|
||||
message: force
|
||||
@@ -98,5 +98,5 @@ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager,
|
||||
},
|
||||
);
|
||||
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.upsertAssets(assets);
|
||||
}
|
||||
|
||||
@@ -24,19 +24,11 @@ export type TimelineDateTime = TimelineDate & {
|
||||
millisecond: number;
|
||||
};
|
||||
|
||||
export type ScrubberData = {
|
||||
export type ScrubberListener = (scrubberData: {
|
||||
scrubberMonth: { year: number; month: number };
|
||||
overallScrollPercent: number;
|
||||
scrubberMonthScrollPercent: number;
|
||||
};
|
||||
|
||||
export type ScrubberListener = (scrubberData: ScrubberData) => void | Promise<void>;
|
||||
|
||||
export type ScrubberDataWithScrollTo = ScrubberData & {
|
||||
scrollToFunction: (top: number) => void;
|
||||
};
|
||||
|
||||
export type ScrubberListenerWithScrollTo = (scrubberData: ScrubberDataWithScrollTo) => void | Promise<void>;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
|
||||
@@ -262,18 +261,15 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
const handleSetVisibility = () => {
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
const handleRemoveAssets = async (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
const handleRemoveAssets = async () => {
|
||||
await refreshAlbum();
|
||||
};
|
||||
|
||||
const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => {
|
||||
timelineManager.addAssets(assets);
|
||||
const handleUndoRemoveAssets = async () => {
|
||||
await refreshAlbum();
|
||||
};
|
||||
|
||||
@@ -452,7 +448,7 @@
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
{showArchiveIcon}
|
||||
onAssetSelect={onSelect}
|
||||
{onSelect}
|
||||
onEscape={handleEscape}
|
||||
>
|
||||
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
|
||||
@@ -572,14 +568,7 @@
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) =>
|
||||
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||
asset.isFavorite = isFavorite;
|
||||
return { remove: false };
|
||||
})}
|
||||
></FavoriteAction>
|
||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager}></FavoriteAction>
|
||||
{/if}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
|
||||
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
||||
@@ -594,7 +583,7 @@
|
||||
onClick={() => updateThumbnailUsingCurrentSelection()}
|
||||
/>
|
||||
{/if}
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
{/if}
|
||||
|
||||
@@ -606,7 +595,12 @@
|
||||
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
|
||||
{/if}
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} onUndoDelete={handleUndoRemoveAssets} />
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={handleRemoveAssets}
|
||||
onUndoDelete={handleUndoRemoveAssets}
|
||||
manager={timelineManager}
|
||||
/>
|
||||
{/if}
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
|
||||
@@ -65,32 +65,18 @@
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||
>
|
||||
<ArchiveAction
|
||||
unarchive
|
||||
onArchive={(ids, visibility) =>
|
||||
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||
asset.visibility = visibility;
|
||||
return { remove: false };
|
||||
})}
|
||||
/>
|
||||
<ArchiveAction unarchive manager={timelineManager} />
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) =>
|
||||
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||
asset.isFavorite = isFavorite;
|
||||
return { remove: false };
|
||||
})}
|
||||
/>
|
||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager} />
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||
<DeleteAssets menuItem manager={timelineManager} />
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||
>
|
||||
<FavoriteAction removeFavorite onFavorite={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||
<FavoriteAction removeFavorite manager={timelineManager} />
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
@@ -83,20 +83,12 @@
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetInteraction.isAllArchived}
|
||||
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
/>
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
|
||||
{#if $preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
|
||||
/>
|
||||
<DeleteAssets menuItem manager={timelineManager} />
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
@@ -349,15 +349,8 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAssets = async (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
await updateAssetCount();
|
||||
};
|
||||
|
||||
const handleUndoDeleteAssets = async (assets: TimelineAsset[]) => {
|
||||
timelineManager.addAssets(assets);
|
||||
await updateAssetCount();
|
||||
};
|
||||
const handleDeleteAssets = async () => await updateAssetCount();
|
||||
const handleUndoDeleteAssets = async () => await updateAssetCount();
|
||||
|
||||
let person = $derived(data.person);
|
||||
|
||||
@@ -392,7 +385,7 @@
|
||||
{assetInteraction}
|
||||
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||
onAssetSelect={handleSelectFeaturePhoto}
|
||||
onSelect={handleSelectFeaturePhoto}
|
||||
onEscape={handleEscape}
|
||||
>
|
||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
||||
@@ -511,14 +504,7 @@
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) =>
|
||||
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||
asset.isFavorite = isFavorite;
|
||||
return { remove: false };
|
||||
})}
|
||||
/>
|
||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager} />
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
||||
<MenuOption
|
||||
@@ -529,19 +515,16 @@
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetInteraction.isAllArchived}
|
||||
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
/>
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => handleDeleteAssets(assetIds)}
|
||||
onUndoDelete={(assets) => handleUndoDeleteAssets(assets)}
|
||||
manager={timelineManager}
|
||||
onAssetDelete={handleDeleteAssets}
|
||||
onUndoDelete={handleUndoDeleteAssets}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
|
||||
@@ -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[]) => {
|
||||
@@ -118,14 +118,7 @@
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) =>
|
||||
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||
asset.isFavorite = isFavorite;
|
||||
return { remove: false };
|
||||
})}
|
||||
></FavoriteAction>
|
||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager}></FavoriteAction>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
||||
@@ -146,15 +139,11 @@
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||
<ArchiveAction menuItem manager={timelineManager} />
|
||||
{#if $preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
|
||||
/>
|
||||
<DeleteAssets menuItem manager={timelineManager} />
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
||||
@@ -62,7 +63,7 @@
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.updateAssets(updatedAssets);
|
||||
timelineManager.upsertAssets(updatedAssets);
|
||||
|
||||
handleDeselectAll();
|
||||
};
|
||||
@@ -109,7 +110,17 @@
|
||||
return !!asset.latitude && !!asset.longitude;
|
||||
};
|
||||
|
||||
const handleAssetOpen = (asset: TimelineAsset, defaultAssetOpen: () => void) => {
|
||||
const handleThumbnailClick = (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => {
|
||||
if (hasGps(asset)) {
|
||||
locationUpdated = true;
|
||||
setTimeout(() => {
|
||||
@@ -117,9 +128,9 @@
|
||||
}, 1500);
|
||||
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
||||
void setQueryValue('at', asset.id);
|
||||
return;
|
||||
} else {
|
||||
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
defaultAssetOpen();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -182,7 +193,7 @@
|
||||
removeAction={AssetAction.ARCHIVE}
|
||||
onEscape={handleEscape}
|
||||
withStacked
|
||||
onAssetOpen={handleAssetOpen}
|
||||
onThumbnailClick={handleThumbnailClick}
|
||||
>
|
||||
{#snippet customThumbnailLayout(asset: TimelineAsset)}
|
||||
{#if hasGps(asset)}
|
||||
|
||||
Reference in New Issue
Block a user