Compare commits
6 Commits
refactor/t
...
feat/timel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c144b1ad17 | ||
|
|
c424b2f0a1 | ||
|
|
0a18d9c35e | ||
|
|
25dbb60574 | ||
|
|
a1e788e0bf | ||
|
|
3c39f44fa0 |
@@ -121,6 +121,7 @@
|
|||||||
|
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
mouseOver = false;
|
mouseOver = false;
|
||||||
|
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
|
||||||
};
|
};
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|||||||
68
web/src/lib/components/timeline/AssetLayout.svelte
Normal file
68
web/src/lib/components/timeline/AssetLayout.svelte
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
|
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { scale } from 'svelte/transition';
|
||||||
|
|
||||||
|
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||||
|
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { isUploading } = uploadAssetsStore;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
viewerAssets: ViewerAsset[];
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
photostreamManager: PhotostreamManager;
|
||||||
|
thumbnail: Snippet<
|
||||||
|
[
|
||||||
|
{
|
||||||
|
asset: TimelineAsset;
|
||||||
|
position: CommonPosition;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>;
|
||||||
|
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { viewerAssets, width, height, photostreamManager, thumbnail, customThumbnailLayout }: Props = $props();
|
||||||
|
|
||||||
|
const transitionDuration = $derived.by(() => (photostreamManager.suspendTransitions && !$isUploading ? 0 : 150));
|
||||||
|
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
|
|
||||||
|
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
|
||||||
|
return intersectables.filter((intersectable) => intersectable.intersecting);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Image grid -->
|
||||||
|
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
|
||||||
|
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||||
|
{@const position = viewerAsset.position!}
|
||||||
|
{@const asset = viewerAsset.asset!}
|
||||||
|
|
||||||
|
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||||
|
<div
|
||||||
|
data-asset-id={asset.id}
|
||||||
|
class="absolute"
|
||||||
|
style:top={position.top + 'px'}
|
||||||
|
style:left={position.left + 'px'}
|
||||||
|
style:width={position.width + 'px'}
|
||||||
|
style:height={position.height + 'px'}
|
||||||
|
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||||
|
animate:flip={{ duration: transitionDuration }}
|
||||||
|
>
|
||||||
|
{@render thumbnail({ asset, position })}
|
||||||
|
{@render customThumbnailLayout?.(asset)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[data-image-grid] {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,140 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
|
||||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
|
||||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { Icon } from '@immich/ui';
|
||||||
|
|
||||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||||
|
|
||||||
import { Icon } from '@immich/ui';
|
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||||
|
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||||
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { flip } from 'svelte/animate';
|
|
||||||
import { scale } from 'svelte/transition';
|
|
||||||
|
|
||||||
let { isUploading } = uploadAssetsStore;
|
let { isUploading } = uploadAssetsStore;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isSelectionMode: boolean;
|
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
||||||
|
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||||
singleSelect: boolean;
|
singleSelect: boolean;
|
||||||
withStacked: boolean;
|
assetInteraction: AssetInteraction;
|
||||||
showArchiveIcon: boolean;
|
|
||||||
monthGroup: MonthGroup;
|
monthGroup: MonthGroup;
|
||||||
timelineManager: TimelineManager;
|
timelineManager: TimelineManager;
|
||||||
assetInteraction: AssetInteraction;
|
onDayGroupSelect: (daygroup: DayGroup, assets: TimelineAsset[]) => void;
|
||||||
customLayout?: Snippet<[TimelineAsset]>;
|
|
||||||
|
|
||||||
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
|
||||||
onSelectAssets: (asset: TimelineAsset) => void;
|
|
||||||
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
|
||||||
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
|
||||||
onThumbnailClick?: (
|
|
||||||
asset: TimelineAsset,
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
dayGroup: DayGroup,
|
|
||||||
onClick: (
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
assets: TimelineAsset[],
|
|
||||||
groupTitle: string,
|
|
||||||
asset: TimelineAsset,
|
|
||||||
) => void,
|
|
||||||
) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
isSelectionMode,
|
thumbnail: thumbnailWithGroup,
|
||||||
|
customThumbnailLayout,
|
||||||
singleSelect,
|
singleSelect,
|
||||||
withStacked,
|
|
||||||
showArchiveIcon,
|
|
||||||
monthGroup = $bindable(),
|
|
||||||
assetInteraction,
|
assetInteraction,
|
||||||
|
monthGroup,
|
||||||
timelineManager,
|
timelineManager,
|
||||||
customLayout,
|
onDayGroupSelect,
|
||||||
onSelect,
|
|
||||||
onSelectAssets,
|
|
||||||
onSelectAssetCandidates,
|
|
||||||
onScrollCompensation,
|
|
||||||
onThumbnailClick,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isMouseOverGroup = $state(false);
|
let isMouseOverGroup = $state(false);
|
||||||
let hoveredDayGroup = $state();
|
let hoveredDayGroup = $state();
|
||||||
|
|
||||||
const transitionDuration = $derived.by(() =>
|
const transitionDuration = $derived.by(() =>
|
||||||
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||||
);
|
);
|
||||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
|
||||||
const _onClick = (
|
return intersectables.filter((intersectable) => intersectable.intersecting);
|
||||||
timelineManager: TimelineManager,
|
|
||||||
assets: TimelineAsset[],
|
|
||||||
groupTitle: string,
|
|
||||||
asset: TimelineAsset,
|
|
||||||
) => {
|
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
|
||||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
|
|
||||||
|
|
||||||
const assetSelectHandler = (
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
asset: TimelineAsset,
|
|
||||||
assetsInDayGroup: TimelineAsset[],
|
|
||||||
groupTitle: string,
|
|
||||||
) => {
|
|
||||||
onSelectAssets(asset);
|
|
||||||
|
|
||||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
|
||||||
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
|
|
||||||
assetInteraction.hasSelectedAsset(asset.id),
|
|
||||||
).length;
|
|
||||||
|
|
||||||
// if all assets are selected in a group, add the group to selected group
|
|
||||||
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
|
|
||||||
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
|
||||||
} else {
|
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
|
||||||
isSelectingAllAssets.set(true);
|
|
||||||
} else {
|
|
||||||
isSelectingAllAssets.set(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
|
|
||||||
// Show multi select icon on hover on date group
|
|
||||||
hoveredDayGroup = groupTitle;
|
|
||||||
|
|
||||||
if (assetInteraction.selectionActive) {
|
|
||||||
onSelectAssetCandidates(asset);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
|
|
||||||
return intersectable.filter((int) => int.intersecting);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect.root(() => {
|
|
||||||
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
|
|
||||||
onScrollCompensation(timelineManager.scrollCompensation);
|
|
||||||
timelineManager.clearScrollCompensation();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||||
{@const absoluteWidth = dayGroup.left}
|
{@const absoluteWidth = dayGroup.left}
|
||||||
|
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<section
|
<section
|
||||||
class={[
|
class={[
|
||||||
@@ -146,11 +55,11 @@
|
|||||||
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
|
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
|
||||||
onmouseenter={() => {
|
onmouseenter={() => {
|
||||||
isMouseOverGroup = true;
|
isMouseOverGroup = true;
|
||||||
assetMouseEventHandler(dayGroup.groupTitle, null);
|
hoveredDayGroup = dayGroup.groupTitle;
|
||||||
}}
|
}}
|
||||||
onmouseleave={() => {
|
onmouseleave={() => {
|
||||||
isMouseOverGroup = false;
|
isMouseOverGroup = false;
|
||||||
assetMouseEventHandler(dayGroup.groupTitle, null);
|
hoveredDayGroup = null;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- Date group title -->
|
<!-- Date group title -->
|
||||||
@@ -163,10 +72,10 @@
|
|||||||
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
|
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
|
||||||
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
|
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
|
||||||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||||
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
|
||||||
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
|
||||||
>
|
>
|
||||||
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
{#if isDayGroupSelected}
|
||||||
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
|
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
|
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
|
||||||
@@ -179,57 +88,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image grid -->
|
<AssetLayout
|
||||||
<div
|
photostreamManager={timelineManager}
|
||||||
data-image-grid
|
viewerAssets={dayGroup.viewerAssets}
|
||||||
class="relative overflow-clip"
|
height={dayGroup.height}
|
||||||
style:height={dayGroup.height + 'px'}
|
width={dayGroup.width}
|
||||||
style:width={dayGroup.width + 'px'}
|
{customThumbnailLayout}
|
||||||
>
|
>
|
||||||
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
|
{#snippet thumbnail({ asset, position })}
|
||||||
{@const position = viewerAsset.position!}
|
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
|
||||||
{@const asset = viewerAsset.asset!}
|
{/snippet}
|
||||||
|
</AssetLayout>
|
||||||
<!-- {#if viewerAsset.intersecting} -->
|
|
||||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
|
||||||
<div
|
|
||||||
data-asset-id={asset.id}
|
|
||||||
class="absolute"
|
|
||||||
style:top={position.top + 'px'}
|
|
||||||
style:left={position.left + 'px'}
|
|
||||||
style:width={position.width + 'px'}
|
|
||||||
style:height={position.height + 'px'}
|
|
||||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
|
||||||
animate:flip={{ duration: transitionDuration }}
|
|
||||||
>
|
|
||||||
<Thumbnail
|
|
||||||
showStackedIcon={withStacked}
|
|
||||||
{showArchiveIcon}
|
|
||||||
{asset}
|
|
||||||
{groupIndex}
|
|
||||||
onClick={(asset) => {
|
|
||||||
if (typeof onThumbnailClick === 'function') {
|
|
||||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
|
||||||
} else {
|
|
||||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
|
||||||
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
|
||||||
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
|
||||||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
|
||||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
|
||||||
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
|
||||||
thumbnailWidth={position.width}
|
|
||||||
thumbnailHeight={position.height}
|
|
||||||
/>
|
|
||||||
{#if customLayout}
|
|
||||||
{@render customLayout(asset)}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<!-- {/if} -->
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -237,7 +106,4 @@
|
|||||||
section {
|
section {
|
||||||
contain: layout paint style;
|
contain: layout paint style;
|
||||||
}
|
}
|
||||||
[data-image-grid] {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
304
web/src/lib/components/timeline/Photostream.svelte
Normal file
304
web/src/lib/components/timeline/Photostream.svelte
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
alwaysShowScrollbar?: boolean;
|
||||||
|
showSkeleton?: boolean;
|
||||||
|
isShowDeleteConfirmation?: boolean;
|
||||||
|
styleMarginRightOverride?: string;
|
||||||
|
|
||||||
|
header?: Snippet<[scrollToFunction: (top: number) => void]>;
|
||||||
|
children?: Snippet;
|
||||||
|
empty?: Snippet;
|
||||||
|
handleTimelineScroll?: () => void;
|
||||||
|
|
||||||
|
smallHeaderHeight?: {
|
||||||
|
rowHeight: number;
|
||||||
|
headerHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
largeHeaderHeight?: {
|
||||||
|
rowHeight: number;
|
||||||
|
headerHeight: number;
|
||||||
|
};
|
||||||
|
styleMarginContentHorizontal?: string;
|
||||||
|
styleMarginTop?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
segment,
|
||||||
|
|
||||||
|
enableRouting,
|
||||||
|
timelineManager = $bindable(),
|
||||||
|
showSkeleton = $bindable(true),
|
||||||
|
showScrollbar,
|
||||||
|
styleMarginRightOverride,
|
||||||
|
styleMarginContentHorizontal = '0px',
|
||||||
|
styleMarginTop = '0px',
|
||||||
|
alwaysShowScrollbar,
|
||||||
|
|
||||||
|
isShowDeleteConfirmation = $bindable(false),
|
||||||
|
|
||||||
|
children,
|
||||||
|
skeleton,
|
||||||
|
empty,
|
||||||
|
header,
|
||||||
|
handleTimelineScroll = () => {},
|
||||||
|
smallHeaderHeight = {
|
||||||
|
rowHeight: 100,
|
||||||
|
headerHeight: 32,
|
||||||
|
},
|
||||||
|
largeHeaderHeight = {
|
||||||
|
rowHeight: 235,
|
||||||
|
headerHeight: 48,
|
||||||
|
},
|
||||||
|
}: 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 ? smallHeaderHeight : largeHeaderHeight;
|
||||||
|
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={() => {
|
||||||
|
// 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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 -->
|
||||||
|
<photostream
|
||||||
|
id="asset-grid"
|
||||||
|
class={[
|
||||||
|
'overflow-y-auto outline-none',
|
||||||
|
{ 'scrollbar-hidden': !showScrollbar },
|
||||||
|
{ 'overflow-y-scroll': alwaysShowScrollbar },
|
||||||
|
{ 'm-0': isEmpty },
|
||||||
|
{ 'ms-0': !isEmpty },
|
||||||
|
]}
|
||||||
|
style:height={`calc(100% - ${styleMarginTop})`}
|
||||||
|
style:margin-top={styleMarginTop}
|
||||||
|
style:margin-right={styleMarginRightOverride}
|
||||||
|
style:scrollbar-width={showScrollbar ? 'thin' : 'none'}
|
||||||
|
tabindex="-1"
|
||||||
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
|
bind:this={element}
|
||||||
|
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
bind:this={timelineElement}
|
||||||
|
id="virtual-timeline"
|
||||||
|
style:margin-left={styleMarginContentHorizontal}
|
||||||
|
style:margin-right={styleMarginContentHorizontal}
|
||||||
|
class:invisible={showSkeleton}
|
||||||
|
style:height={timelineManager.timelineHeight + 'px'}
|
||||||
|
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
||||||
|
>
|
||||||
|
<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 && monthGroup.isLoaded}
|
||||||
|
{@const absoluteHeight = monthGroup.top}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="month-group"
|
||||||
|
style:margin-bottom={timelineManager.createLayoutOptions().spacing + 'px'}
|
||||||
|
style:position="absolute"
|
||||||
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
|
style:height={`${monthGroup.height}px`}
|
||||||
|
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>
|
||||||
|
</photostream>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
photostream {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#asset-grid {
|
||||||
|
contain: strict;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.month-group {
|
||||||
|
contain: layout size paint;
|
||||||
|
transform-style: flat;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
web/src/lib/components/timeline/PhotostreamWithScrubber.svelte
Normal file
198
web/src/lib/components/timeline/PhotostreamWithScrubber.svelte
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<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>
|
||||||
62
web/src/lib/components/timeline/SelectableDay.svelte
Normal file
62
web/src/lib/components/timeline/SelectableDay.svelte
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<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,
|
||||||
|
})}
|
||||||
208
web/src/lib/components/timeline/SelectableSegment.svelte
Normal file
208
web/src/lib/components/timeline/SelectableSegment.svelte
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<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,35 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
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 MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
|
||||||
import Scrubber from '$lib/components/timeline/Scrubber.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 TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
||||||
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
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 type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
|
||||||
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 { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { type Snippet } from 'svelte';
|
||||||
import type { UpdatePayload } from 'vite';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
@@ -53,22 +44,12 @@
|
|||||||
album?: AlbumResponseDto | null;
|
album?: AlbumResponseDto | null;
|
||||||
person?: PersonResponseDto | null;
|
person?: PersonResponseDto | null;
|
||||||
isShowDeleteConfirmation?: boolean;
|
isShowDeleteConfirmation?: boolean;
|
||||||
onSelect?: (asset: TimelineAsset) => void;
|
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
|
||||||
|
onAssetSelect?: (asset: TimelineAsset) => void;
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
empty?: Snippet;
|
empty?: Snippet;
|
||||||
customLayout?: Snippet<[TimelineAsset]>;
|
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||||
onThumbnailClick?: (
|
|
||||||
asset: TimelineAsset,
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
dayGroup: DayGroup,
|
|
||||||
onClick: (
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
assets: TimelineAsset[],
|
|
||||||
groupTitle: string,
|
|
||||||
asset: TimelineAsset,
|
|
||||||
) => void,
|
|
||||||
) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -84,476 +65,59 @@
|
|||||||
album = null,
|
album = null,
|
||||||
person = null,
|
person = null,
|
||||||
isShowDeleteConfirmation = $bindable(false),
|
isShowDeleteConfirmation = $bindable(false),
|
||||||
onSelect = () => {},
|
|
||||||
|
onAssetSelect,
|
||||||
|
onAssetOpen,
|
||||||
onEscape = () => {},
|
onEscape = () => {},
|
||||||
children,
|
children,
|
||||||
empty,
|
empty,
|
||||||
customLayout,
|
customThumbnailLayout,
|
||||||
onThumbnailClick,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||||
|
|
||||||
let element: HTMLElement | undefined = $state();
|
let viewer: PhotostreamWithScrubber | undefined = $state();
|
||||||
|
let showSkeleton: boolean = $state(true);
|
||||||
|
|
||||||
let timelineElement: HTMLElement | undefined = $state();
|
// tri-state boolean
|
||||||
let showSkeleton = $state(true);
|
let initialLoadWasAssetViewer: boolean | null = null;
|
||||||
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
|
let hasNavigatedToOrFromAssetViewer: boolean = false;
|
||||||
// Note: There may be multiple months visible within the viewport at any given time.
|
let timelineScrollPositionInitialized = false;
|
||||||
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);
|
|
||||||
|
|
||||||
// 60 is the bottom spacer element at 60px
|
beforeNavigate(({ from, to }) => {
|
||||||
let bottomSectionHeight = 60;
|
timelineManager.suspendTransitions = true;
|
||||||
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
|
||||||
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 scrollTo = (top: number) => {
|
const completeAfterNavigate = () => {
|
||||||
if (element) {
|
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
|
||||||
element.scrollTo({ top });
|
let isInitial = false;
|
||||||
|
// Set initial load state only once
|
||||||
|
if (initialLoadWasAssetViewer === null) {
|
||||||
|
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||||
|
isInitial = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scrollToAssetQueryParam = false;
|
||||||
|
if (
|
||||||
|
!timelineScrollPositionInitialized &&
|
||||||
|
((isInitial && !assetViewerPage) || // Direct timeline load
|
||||||
|
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
|
||||||
|
) {
|
||||||
|
scrollToAssetQueryParam = true;
|
||||||
|
timelineScrollPositionInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
|
||||||
};
|
};
|
||||||
const scrollTop = (top: number) => {
|
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
|
||||||
if (element) {
|
|
||||||
element.scrollTop = top;
|
const onViewerClose = async (asset: { id: string }) => {
|
||||||
}
|
assetViewingStore.showAssetViewer(false);
|
||||||
|
showSkeleton = true;
|
||||||
|
$gridScrollTarget = { at: asset.id };
|
||||||
|
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||||
};
|
};
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const height = getAssetHeight(assetId, monthGroup);
|
|
||||||
|
|
||||||
// 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 = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => {
|
|
||||||
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 handleSelectAssets = 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(() => {
|
$effect(() => {
|
||||||
if ($showAssetViewer) {
|
if ($showAssetViewer) {
|
||||||
@@ -563,148 +127,87 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
|
||||||
|
|
||||||
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
|
|
||||||
|
|
||||||
<TimelineKeyboardActions
|
<TimelineKeyboardActions
|
||||||
scrollToAsset={(asset) => scrollToAsset(asset) ?? false}
|
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
|
||||||
{timelineManager}
|
{timelineManager}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
bind:isShowDeleteConfirmation
|
bind:isShowDeleteConfirmation
|
||||||
{onEscape}
|
{onEscape}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if timelineManager.months.length > 0}
|
<PhotostreamWithScrubber
|
||||||
<Scrubber
|
bind:this={viewer}
|
||||||
{timelineManager}
|
{enableRouting}
|
||||||
height={timelineManager.viewportHeight}
|
{timelineManager}
|
||||||
timelineTopOffset={timelineManager.topSectionHeight}
|
{isShowDeleteConfirmation}
|
||||||
timelineBottomOffset={bottomSectionHeight}
|
{showSkeleton}
|
||||||
{isInLeadOutSection}
|
{children}
|
||||||
{timelineScrollPercent}
|
{empty}
|
||||||
{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())}
|
|
||||||
>
|
>
|
||||||
<section
|
{#snippet skeleton({ segment })}
|
||||||
bind:this={timelineElement}
|
<Skeleton
|
||||||
id="virtual-timeline"
|
height={segment.height - segment.timelineManager.headerHeight}
|
||||||
class:invisible={showSkeleton}
|
title={(segment as MonthGroup).monthGroupTitle}
|
||||||
style:height={timelineManager.timelineHeight + 'px'}
|
/>
|
||||||
>
|
{/snippet}
|
||||||
<section
|
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
|
||||||
use:resizeObserver={topSectionResizeObserver}
|
<SelectableSegment
|
||||||
class:invisible={showSkeleton}
|
{segment}
|
||||||
style:position="absolute"
|
{onScrollCompensationMonthInDOM}
|
||||||
style:left="0"
|
{timelineManager}
|
||||||
style:right="0"
|
{assetInteraction}
|
||||||
|
{isSelectionMode}
|
||||||
|
{singleSelect}
|
||||||
|
{onAssetOpen}
|
||||||
|
{onAssetSelect}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
|
||||||
{#if isEmpty}
|
<SelectableDay {assetInteraction} {onAssetSelect}>
|
||||||
<!-- (optional) empty placeholder -->
|
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
|
||||||
{@render empty?.()}
|
<MonthSegment
|
||||||
{/if}
|
{assetInteraction}
|
||||||
</section>
|
{customThumbnailLayout}
|
||||||
|
{singleSelect}
|
||||||
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
|
monthGroup={segment as MonthGroup}
|
||||||
{@const display = monthGroup.intersecting}
|
{timelineManager}
|
||||||
{@const absoluteHeight = monthGroup.top}
|
{onDayGroupSelect}
|
||||||
|
>
|
||||||
{#if !monthGroup.isLoaded}
|
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||||
<div
|
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
style:height={monthGroup.height + 'px'}
|
{@const isAssetSelected =
|
||||||
style:position="absolute"
|
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
||||||
style:width="100%"
|
<Thumbnail
|
||||||
>
|
showStackedIcon={withStacked}
|
||||||
<Skeleton
|
{showArchiveIcon}
|
||||||
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
|
{asset}
|
||||||
title={monthGroup.monthGroupTitle}
|
{groupIndex}
|
||||||
/>
|
onClick={() => onAssetOpen(asset)}
|
||||||
</div>
|
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
|
||||||
{:else if display}
|
onMouseEvent={(isMouseOver) => {
|
||||||
<div
|
if (isMouseOver) {
|
||||||
class="month-group"
|
onAssetHover(asset);
|
||||||
style:height={monthGroup.height + 'px'}
|
} else {
|
||||||
style:position="absolute"
|
onAssetHover(null);
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
}
|
||||||
style:width="100%"
|
}}
|
||||||
>
|
selected={isAssetSelected}
|
||||||
<MonthSegment
|
selectionCandidate={isAssetSelectionCandidate}
|
||||||
{withStacked}
|
disabled={isAssetDisabled}
|
||||||
{showArchiveIcon}
|
thumbnailWidth={position.width}
|
||||||
{assetInteraction}
|
thumbnailHeight={position.height}
|
||||||
{timelineManager}
|
/>
|
||||||
{isSelectionMode}
|
{/snippet}
|
||||||
{singleSelect}
|
</MonthSegment>
|
||||||
{monthGroup}
|
{/snippet}
|
||||||
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
|
</SelectableDay>
|
||||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
{/snippet}
|
||||||
onSelectAssets={handleSelectAssets}
|
</SelectableSegment>
|
||||||
onScrollCompensation={handleScrollCompensation}
|
{/snippet}
|
||||||
{customLayout}
|
</PhotostreamWithScrubber>
|
||||||
{onThumbnailClick}
|
|
||||||
/>
|
|
||||||
</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">
|
<Portal target="body">
|
||||||
{#if $showAssetViewer}
|
{#if $showAssetViewer}
|
||||||
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
|
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
|
||||||
{/if}
|
{/if}
|
||||||
</Portal>
|
</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,15 +9,16 @@
|
|||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
|
let { asset: viewingAsset, mutex, preloadAssets } = assetViewingStore;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timelineManager: TimelineManager;
|
timelineManager: TimelineManager;
|
||||||
showSkeleton: boolean;
|
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
album?: AlbumResponseDto | null;
|
album?: AlbumResponseDto | null;
|
||||||
person?: PersonResponseDto | null;
|
person?: PersonResponseDto | null;
|
||||||
|
onViewerClose?: (asset: { id: string }) => Promise<void>;
|
||||||
|
|
||||||
removeAction?:
|
removeAction?:
|
||||||
| AssetAction.UNARCHIVE
|
| AssetAction.UNARCHIVE
|
||||||
@@ -30,12 +31,12 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
timelineManager,
|
timelineManager,
|
||||||
showSkeleton = $bindable(false),
|
|
||||||
removeAction,
|
removeAction,
|
||||||
withStacked = false,
|
withStacked = false,
|
||||||
isShared = false,
|
isShared = false,
|
||||||
album = null,
|
album = null,
|
||||||
person = null,
|
person = null,
|
||||||
|
onViewerClose = () => Promise.resolve(void 0),
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const handlePrevious = async () => {
|
const handlePrevious = async () => {
|
||||||
@@ -79,13 +80,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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) => {
|
const handlePreAction = async (action: Action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case removeAction:
|
case removeAction:
|
||||||
@@ -97,7 +91,7 @@
|
|||||||
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
||||||
// find the next asset to show or close the viewer
|
// find the next asset to show or close the viewer
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
|
(await handleNext()) || (await handlePrevious()) || (await onViewerClose?.(action.asset));
|
||||||
|
|
||||||
// delete after find the next one
|
// delete after find the next one
|
||||||
timelineManager.removeAssets([action.asset.id]);
|
timelineManager.removeAssets([action.asset.id]);
|
||||||
@@ -172,6 +166,6 @@
|
|||||||
onPrevious={handlePrevious}
|
onPrevious={handlePrevious}
|
||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
onRandom={handleRandom}
|
onRandom={handleRandom}
|
||||||
onClose={handleClose}
|
onClose={onViewerClose}
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
type RelativeResult,
|
type RelativeResult,
|
||||||
} from '$lib/components/shared-components/change-date.svelte';
|
} from '$lib/components/shared-components/change-date.svelte';
|
||||||
import {
|
import {
|
||||||
setFocusToAsset as setFocusAssetInit,
|
setFocusToAsset as setFocusAssetUtil,
|
||||||
setFocusTo as setFocusToInit,
|
setFocusTo as setFocusToUtil,
|
||||||
|
type FocusDirection,
|
||||||
|
type FocusInterval,
|
||||||
} from '$lib/components/timeline/actions/focus-actions';
|
} from '$lib/components/timeline/actions/focus-actions';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
isShowDeleteConfirmation: boolean;
|
isShowDeleteConfirmation: boolean;
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
scrollToAsset: (asset: TimelineAsset) => boolean;
|
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -147,8 +149,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
const setFocusTo = (direction: FocusDirection, interval: FocusInterval) =>
|
||||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
setFocusToUtil(scrollToAsset, timelineManager, direction, interval);
|
||||||
|
|
||||||
|
const setFocusAsset = (asset: TimelineAsset) => setFocusAssetUtil(scrollToAsset, asset);
|
||||||
|
|
||||||
let shortcutList = $derived(
|
let shortcutList = $derived(
|
||||||
(() => {
|
(() => {
|
||||||
@@ -212,7 +216,7 @@
|
|||||||
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
|
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
|
||||||
);
|
);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
setFocusAsset(asset);
|
void setFocusAsset(asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,19 +21,26 @@ export const focusPreviousAsset = () =>
|
|||||||
|
|
||||||
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
||||||
|
|
||||||
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
|
export const setFocusToAsset = async (
|
||||||
const scrolled = scrollToAsset(asset);
|
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
|
||||||
|
asset: TimelineAsset,
|
||||||
|
) => {
|
||||||
|
const scrolled = await scrollToAsset(asset);
|
||||||
if (scrolled) {
|
if (scrolled) {
|
||||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||||
element?.focus();
|
element?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FocusDirection = 'earlier' | 'later';
|
||||||
|
|
||||||
|
export type FocusInterval = 'day' | 'month' | 'year' | 'asset';
|
||||||
|
|
||||||
export const setFocusTo = async (
|
export const setFocusTo = async (
|
||||||
scrollToAsset: (asset: TimelineAsset) => boolean,
|
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
|
||||||
store: TimelineManager,
|
store: TimelineManager,
|
||||||
direction: 'earlier' | 'later',
|
direction: FocusDirection,
|
||||||
interval: 'day' | 'month' | 'year' | 'asset',
|
interval: FocusInterval,
|
||||||
) => {
|
) => {
|
||||||
if (tracker.isActive()) {
|
if (tracker.isActive()) {
|
||||||
// there are unfinished running invocations, so return early
|
// there are unfinished running invocations, so return early
|
||||||
@@ -65,7 +72,10 @@ export const setFocusTo = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrolled = scrollToAsset(asset);
|
const scrolled = await scrollToAsset(asset);
|
||||||
|
if (!invocation.isStillValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (scrolled) {
|
if (scrolled) {
|
||||||
await tick();
|
await tick();
|
||||||
if (!invocation.isStillValid()) {
|
if (!invocation.isStillValid()) {
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
height: number;
|
height: number;
|
||||||
title: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { height = 0, title }: Props = $props();
|
let { height = 0, title }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-clip" style:height={height + 'px'}>
|
<div class="overflow-clip" style:height={height + 'px'}>
|
||||||
<div
|
{#if title}
|
||||||
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
<div
|
||||||
>
|
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
||||||
{title}
|
>
|
||||||
</div>
|
{title}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
|
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
|
||||||
style:width="calc(100% - 20px)"
|
style:width="calc(100% - 20px)"
|
||||||
|
|||||||
@@ -269,13 +269,14 @@ export abstract class PhotostreamManager {
|
|||||||
return this.months.find((segment) => identifier.matches(segment));
|
return this.months.find((segment) => identifier.matches(segment));
|
||||||
}
|
}
|
||||||
|
|
||||||
getSegmentForAssetId(assetId: string) {
|
findSegmentForAssetId(assetId: string): Promise<PhotostreamSegment | undefined> {
|
||||||
for (const month of this.months) {
|
for (const month of this.months) {
|
||||||
const asset = month.assets.find((asset) => asset.id === assetId);
|
const asset = month.assets.find((asset) => asset.id === assetId);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
return month;
|
return Promise.resolve(month);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Promise.resolve(void 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshLayout() {
|
refreshLayout() {
|
||||||
@@ -294,11 +295,15 @@ export abstract class PhotostreamManager {
|
|||||||
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
|
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
|
retrieveLoadedRange(start: AssetDescriptor, end: AssetDescriptor): TimelineAsset[] {
|
||||||
const range: TimelineAsset[] = [];
|
const range: TimelineAsset[] = [];
|
||||||
let collecting = false;
|
let collecting = false;
|
||||||
|
|
||||||
for (const month of this.months) {
|
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) {
|
for (const asset of month.assets) {
|
||||||
if (asset.id === start.id) {
|
if (asset.id === start.id) {
|
||||||
collecting = true;
|
collecting = true;
|
||||||
@@ -307,10 +312,14 @@ export abstract class PhotostreamManager {
|
|||||||
range.push(asset);
|
range.push(asset);
|
||||||
}
|
}
|
||||||
if (asset.id === end.id) {
|
if (asset.id === end.id) {
|
||||||
return Promise.resolve(range);
|
return range;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.resolve(range);
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
|
||||||
|
return Promise.resolve(this.retrieveLoadedRange(start, end));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,3 +143,79 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
import {
|
||||||
|
findMonthGroupForAsset,
|
||||||
|
getMonthGroupByDate,
|
||||||
|
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||||
import { AbortError } from '$lib/utils';
|
import { AbortError } from '$lib/utils';
|
||||||
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
@@ -556,10 +559,10 @@ describe('TimelineManager', () => {
|
|||||||
);
|
);
|
||||||
timelineManager.addAssets([assetOne, assetTwo]);
|
timelineManager.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.month).toEqual(2);
|
||||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores removed months', () => {
|
it('ignores removed months', () => {
|
||||||
@@ -576,8 +579,8 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager.addAssets([assetOne, assetTwo]);
|
timelineManager.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
timelineManager.removeAssets([assetTwo.id]);
|
timelineManager.removeAssets([assetTwo.id]);
|
||||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
|
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findMonthGroupForAsset(id: string) {
|
async findSegmentForAssetId(id: string) {
|
||||||
if (!this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
await this.initTask.waitUntilCompletion();
|
await this.initTask.waitUntilCompletion();
|
||||||
}
|
}
|
||||||
@@ -218,11 +218,6 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
return getMonthGroupByDate(this, yearMonth);
|
return getMonthGroupByDate(this, yearMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMonthGroupByAssetId(assetId: string) {
|
|
||||||
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
|
|
||||||
return monthGroupInfo?.monthGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRandomMonthGroup() {
|
async getRandomMonthGroup() {
|
||||||
const random = Math.floor(Math.random() * this.months.length);
|
const random = Math.floor(Math.random() * this.months.length);
|
||||||
const month = this.months[random];
|
const month = this.months[random];
|
||||||
|
|||||||
@@ -24,11 +24,19 @@ export type TimelineDateTime = TimelineDate & {
|
|||||||
millisecond: number;
|
millisecond: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScrubberListener = (scrubberData: {
|
export type ScrubberData = {
|
||||||
scrubberMonth: { year: number; month: number };
|
scrubberMonth: { year: number; month: number };
|
||||||
overallScrollPercent: number;
|
overallScrollPercent: number;
|
||||||
scrubberMonthScrollPercent: number;
|
scrubberMonthScrollPercent: number;
|
||||||
}) => void | Promise<void>;
|
};
|
||||||
|
|
||||||
|
export type ScrubberListener = (scrubberData: ScrubberData) => void | Promise<void>;
|
||||||
|
|
||||||
|
export type ScrubberDataWithScrollTo = ScrubberData & {
|
||||||
|
scrollToFunction: (top: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScrubberListenerWithScrollTo = (scrubberData: ScrubberDataWithScrollTo) => void | Promise<void>;
|
||||||
|
|
||||||
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||||
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
||||||
|
|||||||
@@ -452,7 +452,7 @@
|
|||||||
{isSelectionMode}
|
{isSelectionMode}
|
||||||
{singleSelect}
|
{singleSelect}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{onSelect}
|
onAssetSelect={onSelect}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
>
|
>
|
||||||
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
|
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@
|
|||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
|
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||||
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
|
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||||
onSelect={handleSelectFeaturePhoto}
|
onAssetSelect={handleSelectFeaturePhoto}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
>
|
>
|
||||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
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 { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
||||||
@@ -110,17 +109,7 @@
|
|||||||
return !!asset.latitude && !!asset.longitude;
|
return !!asset.latitude && !!asset.longitude;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (
|
const handleAssetOpen = (asset: TimelineAsset, defaultAssetOpen: () => void) => {
|
||||||
asset: TimelineAsset,
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
dayGroup: DayGroup,
|
|
||||||
onClick: (
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
assets: TimelineAsset[],
|
|
||||||
groupTitle: string,
|
|
||||||
asset: TimelineAsset,
|
|
||||||
) => void,
|
|
||||||
) => {
|
|
||||||
if (hasGps(asset)) {
|
if (hasGps(asset)) {
|
||||||
locationUpdated = true;
|
locationUpdated = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -128,9 +117,9 @@
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
||||||
void setQueryValue('at', asset.id);
|
void setQueryValue('at', asset.id);
|
||||||
} else {
|
return;
|
||||||
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
|
||||||
}
|
}
|
||||||
|
defaultAssetOpen();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -193,9 +182,9 @@
|
|||||||
removeAction={AssetAction.ARCHIVE}
|
removeAction={AssetAction.ARCHIVE}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
withStacked
|
withStacked
|
||||||
onThumbnailClick={handleThumbnailClick}
|
onAssetOpen={handleAssetOpen}
|
||||||
>
|
>
|
||||||
{#snippet customLayout(asset: TimelineAsset)}
|
{#snippet customThumbnailLayout(asset: TimelineAsset)}
|
||||||
{#if hasGps(asset)}
|
{#if hasGps(asset)}
|
||||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
|
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
|
||||||
{asset.city || $t('gps')}
|
{asset.city || $t('gps')}
|
||||||
|
|||||||
Reference in New Issue
Block a user